Merge branch 'stable-3.6' into stable-3.7

* stable-3.6:
  Set version to 3.5.7-SNAPSHOT
  Set version to 3.5.6
  Move creation of PerThreadCache to SshCommand
  Update bouncycastle to 1.72
  Align commons-compress and tukaani-xz versions with jgit
  Bump JGit to 74fa245b3
  Bump JGit to 45de4fa
  Log external ID differential cache loader failure
  ProjectState: simplify the 'getPluginConfig' method

Release-Notes: skip
Change-Id: I86100dc9c13f94e97c1d1dcc27bb95058a7ea256
diff --git a/.bazelignore b/.bazelignore
index 69c04b1..aac80af 100644
--- a/.bazelignore
+++ b/.bazelignore
@@ -1,2 +1,5 @@
 eclipse-out
 node_modules
+polygerrit-ui/node_modules
+plugins/node_modules
+tools/node_tools/node_modules
diff --git a/.bazelrc b/.bazelrc
index e63d1b5..407b005 100644
--- a/.bazelrc
+++ b/.bazelrc
@@ -27,9 +27,6 @@
 build:remote17 --tool_java_language_version=17
 build:remote17 --tool_java_runtime_version=remotejdk_17
 
-# workaround for https://github.com/bazelbuild/bazel/issues/17956 on MacOS 13.3 and XCode 14.3
-build --host_conlyopt=-std=c90
-
 # Enable strict_action_env flag to. For more information on this feature see
 # https://groups.google.com/forum/#!topic/bazel-discuss/_VmRfMyyHBk.
 # This will be the new default behavior at some point (and the flag was flipped
diff --git a/.bazelversion b/.bazelversion
index c7cb131..5e32542 100644
--- a/.bazelversion
+++ b/.bazelversion
@@ -1 +1 @@
-5.3.1
+6.1.2
diff --git a/.gitignore b/.gitignore
index 8edc10e..53bc9f6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -33,6 +33,9 @@
 /node_modules/
 /package-lock.json
 /plugins/*
+/polygerrit-ui/coverage/
+/polygerrit-ui/app/plugins/*
+/polygerrit-ui/screenshots/Chrome/failed/
 !/plugins/.eslintignore
 !/plugins/.eslintrc.js
 !/plugins/.prettierrc.js
diff --git a/Documentation/cmd-check-project-access.txt b/Documentation/cmd-check-project-access.txt
new file mode 100644
index 0000000..885993a
--- /dev/null
+++ b/Documentation/cmd-check-project-access.txt
@@ -0,0 +1,74 @@
+= gerrit check-project-access
+
+== NAME
+gerrit check-project-access - Check project readability of all users in a
+matching the given link:rest-api-accounts.html#account-id[account identifier]
+
+== SYNOPSIS
+[verse]
+--
+_ssh_ -p <port> <host> _gerrit check-project-access_
+  [--project <PROJECT> | -p <PROJECT>]
+  [--user <USER> | -u <USER>]
+--
+
+== DESCRIPTION
+Allow users to check if user has access to a project’s changes, comments, code
+differences, and Git access over SSH or HTTP.
+
+It returns all users in given input String, where it includes: username, email
+address and full name.
+
+== ACCESS
+Users who have view access and administrate server capability.
+
+== EXAMPLES
+Check if users can read all references in the repository called "test_project",
+in given input String TestUser.
+
+Given that there are
+a user with username "test_user1", email "one@email.com", and Full name as
+TestUser,
+a user with username "test_user2", email "two@email.com", and Full name as
+TestUser,
+a user with username "test_user3", email "TestUser@email.com", and Full name
+as John Doe
+
+----
+$ ssh -p @SSH_PORT@ @SSH_HOST@ gerrit check-project-access
+  -p test_project
+  -u TestUser
+
+Username: 'test_user1', Email: 'one@example.com',  Full Name: 'TestUser'
+, Result: TRUE
+Username: 'test_user3', Email: 'TestUser@example.com',  Full Name: 'John Doe'
+, Result: FALSE
+Username: 'test_user2', Email: 'two@example.com',  Full Name: 'TestUser'
+, Result: FALSE
+----
+
+----
+$ ssh -p @SSH_PORT@ @SSH_HOST@ gerrit check-project-access
+  -p test_project_doesnt_exist
+  -u TestUser
+
+fatal: project 'test_project_doesnt_exist' is unavailable
+----
+
+----
+$ ssh -p @SSH_PORT@ @SSH_HOST@ gerrit check-project-access
+  -p test_project
+  -u test_user_doesnt_exist
+
+fatal: No accounts found for your query: "test_user_doesnt_exist"
+Tip: Try double-escaping spaces, for example: "--user Last,\\ First"
+----
+
+
+GERRIT
+------
+Part of link:index.html[Gerrit Code Review]
+
+SEARCHBOX
+---------
+
diff --git a/Documentation/cmd-create-project.txt b/Documentation/cmd-create-project.txt
index 0575eb9..358324d 100644
--- a/Documentation/cmd-create-project.txt
+++ b/Documentation/cmd-create-project.txt
@@ -136,13 +136,13 @@
 	project.  Disabled by default.
 
 --use-signed-off-by::
---so:
+--so::
 	If enabled, each change must contain a Signed-off-by line
 	from either the author or the uploader in the commit message.
 	Disabled by default.
 
 --create-new-change-for-all-not-in-target::
---ncfa:
+--ncfa::
 	If enabled, a new change is created for every commit that is not in
 	the target branch. If the pushed commit is a merge commit, this flag is
 	ignored for that push. To avoid accidental creation of a large number
diff --git a/Documentation/cmd-index.txt b/Documentation/cmd-index.txt
index 7f1a6e8..c959a07 100644
--- a/Documentation/cmd-index.txt
+++ b/Documentation/cmd-index.txt
@@ -61,6 +61,9 @@
 link:cmd-copy-approvals.html[gerrit copy-approvals]::
 	Copy all inferred approvals labels to the latest patch-set.
 
+link:cmd-check-project-access.html[gerrit check-project-access]::
+	Check if user(s) can read non-config refs of a project
+
 link:cmd-create-branch.html[gerrit create-branch]::
 	Create a new project branch.
 
diff --git a/Documentation/cmd-stream-events.txt b/Documentation/cmd-stream-events.txt
index f0ad460..5fd0bfc 100644
--- a/Documentation/cmd-stream-events.txt
+++ b/Documentation/cmd-stream-events.txt
@@ -324,6 +324,19 @@
 
 comment:: Review comment cover message.
 
+=== Project Head Updated
+
+Sent when project's head is updated.
+
+type:: "project-head-updated"
+
+oldHead:: The old project head name
+
+newHead:: The new project head name
+
+eventCreatedOn:: Time in seconds since the UNIX epoch when this event was
+created.
+
 == SEE ALSO
 
 * link:json.html[JSON Data Formats]
diff --git a/Documentation/concept-changes.txt b/Documentation/concept-changes.txt
index 0444fab..6e76a8a 100644
--- a/Documentation/concept-changes.txt
+++ b/Documentation/concept-changes.txt
@@ -34,6 +34,11 @@
 |Owner
 |The contributor who created the change.
 
+|Uploader
+|The user that uploaded the current patch set (e.g. the user that executed the
+`git push` command, or the user that triggered the patch set creation through
+an action in the UI).
+
 |Assignee
 |The contributor responsible for the change. Often used when a change has
 mulitple reviewers to identify the individual responsible for final approval.
diff --git a/Documentation/config-accounts.txt b/Documentation/config-accounts.txt
index aca9591..716fa2f 100644
--- a/Documentation/config-accounts.txt
+++ b/Documentation/config-accounts.txt
@@ -383,8 +383,8 @@
 [[starred-changes]]
 == Starred Changes
 
-link:dev-stars.html[Starred changes] allow users to mark changes as
-favorites and receive email notifications for them.
+Starred changes allow users to mark changes as favorites and receive email
+notifications for them.
 
 Each starred change is a tuple of an account ID, a change ID and a
 label.
@@ -402,8 +402,7 @@
 when the prefix ends with '/', this ref format is optimized to find
 starred changes by change ID. Finding starred changes by change ID is
 e.g. needed when a change is updated so that all users that have
-the link:dev-stars.html#default-star[default star] on the change can be
-notified by email.
+the star on the change can be notified by email.
 
 Gerrit also needs an efficient way to find all changes that were
 starred by an account, e.g. to provide results for the
diff --git a/Documentation/config-gerrit.txt b/Documentation/config-gerrit.txt
index 3fbc283..6ca790a 100644
--- a/Documentation/config-gerrit.txt
+++ b/Documentation/config-gerrit.txt
@@ -1460,7 +1460,7 @@
 Maximum number of patch sets allowed per change. If this is insufficient,
 recreate the change with a new Change-Id, then abandon the old change.
 +
-By default 1,500.
+By default 1000.
 
 [[change.maxUpdates]]change.maxUpdates::
 +
@@ -1754,8 +1754,11 @@
   link = "#/q/$1"
 
 [commentlink "bugzilla"]
-  match = "(bug\\s+#?)(\\d+)"
-  link = http://bugs.example.com/show_bug.cgi?id=$2
+  match = "(^|\\s)(bug\\s+#?)(\\d+)($|\\s)"
+  link = http://bugs.example.com/show_bug.cgi?id=$3
+  prefix = $1
+  suffix = $4
+  text = $2$3
 
 [commentlink "tracker"]
   match = ([Bb]ug:\\s+)(\\d+)
@@ -1790,6 +1793,10 @@
 be updated to match text formats.
 +
 A common pattern to match is `bug\\s+(\\d+)`.
++
+In order to better control the visual presentation of the link `prefix`,
+`suffix` and `text` is used. With the generated link html looking like:
+`prefix<a ...>text</a>suffix`.
 
 [[commentlink.name.link]]commentlink.<name>.link::
 +
@@ -1798,6 +1805,27 @@
 +
 The link property is used only when the html property is not present.
 
+[[commentlink.name.prefix]]commentlink.<name>.prefix::
++
+The text inserted before the link. Groups in the match expression may be
+accessed as `$'n'`.
++
+The link property is used only when the html property is not present.
+
+[[commentlink.name.suffix]]commentlink.<name>.suffix::
++
+The text inserted after the link. Groups in the match expression may be
+accessed as `$'n'`.
++
+The link property is used only when the html property is not present.
+
+[[commentlink.name.text]]commentlink.<name>.text::
++
+The text content of the link. Groups in the match expression may be
+accessed as `$'n'`.
++
+The link property is used only when the html property is not present.
+
 [[commentlink.name.html]]commentlink.<name>.html::
 +
 HTML to replace the entire matched string with.  If present,
@@ -2544,6 +2572,26 @@
 by the target version of the upgrade. Refer to the release notes and check whether
 the rolling upgrade is possible or not and the associated constraints.
 
+[[gerrit.importedServerId]]gerrit.importedServerId::
++
+ServerId of the repositories imported from other Gerrit servers. Changes coming
+associated with the imported serverIds are indexed and displayed in the UI.
++
+Specify multiple `gerrit.importedServerId` for allowing the import from multiple
+Gerrit servers with different serverIds.
++
+[NOTE]
+The account-ids referenced in the imported changes are used for looking up the
+associated account-id locally, using the `imported:` external-id.
+Example: the account-id 1000 from the imported server-id 59a4964e-6376-4ed9-beef
+will be looked up in the local accounts using the `imported:1000@59a4964e-6376-4ed9-beef`
+external-id.
++
+If this value is not set, all changes imported from other Gerrit servers will be
+ignored.
++
+By default empty.
+
 [[gerrit.serverId]]gerrit.serverId::
 +
 Used by NoteDb to, amongst other things, identify author identities from
@@ -3233,6 +3281,15 @@
 If not set or set to a zero, defaults to the number of logical CPUs as returned
 by the JVM. If set to a negative value, defaults to a direct executor.
 
+[[index.cacheQueryResultsByChangeNum]]index.cacheQueryResultsByChangeNum::
++
+Allow to cache and reuse the change JSON elements by their Change number.
+This improves the performance of queries that are returning Changes duplicates.
+It needs to be turned off when having Changes imported from other servers
+because of the potential conflicts of change numbers.
++
+Defaults to true.
+
 [[index.onlineUpgrade]]index.onlineUpgrade::
 +
 Whether to upgrade to new index schema versions while the server is
@@ -5631,7 +5688,7 @@
 
 [[trackingid.name.system]]trackingid.<name>.system::
 +
-The name of the external tracking system (maximum 10 characters).
+The name of the external tracking system (maximum 20 characters).
 It is possible to have several trackingid entries for the same
 tracking system.
 
diff --git a/Documentation/config-groups.txt b/Documentation/config-groups.txt
index 0917515..4abb223 100644
--- a/Documentation/config-groups.txt
+++ b/Documentation/config-groups.txt
@@ -34,7 +34,7 @@
 group, there is a ref, stored as a sharded UUID, e.g.
 
 ----
-  refs/groups/ef/deafbeefdeafbeefdeafbeefdeafbeefdeafbeef
+  refs/groups/de/deafbeefdeafbeefdeafbeefdeafbeefdeafbeef
 ----
 
 The ref points to commits holding files. The files are
diff --git a/Documentation/config-labels.txt b/Documentation/config-labels.txt
index 3fa84b1..e4eee10 100644
--- a/Documentation/config-labels.txt
+++ b/Documentation/config-labels.txt
@@ -11,6 +11,43 @@
 link:config-submit-requirements.html[submit requirements] documentation to
 configure such rules.
 
+[[sticky_votes]]
+== Sticky Votes
+
+Whether votes are sticky when a new patch set is created depends on the
+link:#label_copyCondition[copyCondition] of the label. If an approval
+matches the configured condition it is copied from the old current
+patch set to the new current patch set. Votes that are not copied to
+the new patch set, are called `outdated`.
+
+If votes get outdated due to pushing a new patch set the uploader is
+informed about this by a message in the git output. In addition,
+outdated votes are also listed in the email notification that is sent
+for the new patch set (unless this is disabled by a custom email
+template). Note, that the uploader only get this email notification if
+they have configured `Every Comment` for `Email notifications` in their
+user preferences. With any other email preference the email sender, the
+uploader in this case, is not included in the email recipients.
+
+If votes get outdated due to creating a new patch set the user of the
+removed vote is added to the attention set of the change, as they need
+to re-review the change and renew their vote.
+
+If a vote is applied on an outdated patch set (i.e. a patch set that is
+not the current patch set) the vote is copied forward to follow-up
+patch sets if possible. A newly added or updated vote on an outdated
+patch set is copied to follow-up patch sets if:
+
+* the vote is copyable (i.e. it matches the
+link:#label_copyCondition[copyCondition] of the label)
+* neither the follow-up patch set nor an intermediate patch set has a
+  non-copied vote or a deletion vote (vote with value `0`) that
+  overrides the copy vote
+
+If an approval on an outdated patch set is removed or updated to a
+value that is not copyable, existing copies of that approval on
+follow-up patch sets are removed.
+
 [[label_Code-Review]]
 == Label: Code-Review
 
@@ -211,9 +248,18 @@
 === `label.Label-Name.function (deprecated)`
 
 Label functions dictate the rules for requiring certain label votes before a
-change is allowed for submission. Label functions are **deprecated**, favour
-using link:config-submit-requirements.html[submit requirements] instead,
-except if it's needed to set the value to `PatchSetLock`.
+change is allowed for submission. Label functions are **deprecated** and updates
+that set `function` to a blocking value {`MaxWithBlock`, `MaxNoBlock`,
+`AnyWithBlock`} will be rejected. Existing label function definitions can only
+be updated to {`NoBlock`, `NoOp`, `PatchSetLock`}. New label defintions should
+also explicitly set the `function` attribute to a non-blocking value since the
+default is `MaxWithBlock`.
+
+If your project has a
+blocking label function, we highly encourage you to change it to `NoBlock` and
+add a submit-requirement for the same label. See the
+link:config-submit-requirements.html#code-review-example[submit-requirements
+documentation] for more details.
 
 The name of a function for evaluating multiple votes for a label.  This
 function is only applied if the default submit rule is used for a label.
@@ -275,15 +321,6 @@
 
 Defaults to true.
 
-[[label_copyAnyScore]]
-=== `label.Label-Name.copyAnyScore`
-
-*DEPRECATED: use `is:ANY` predicate in
-link:config-labels.html#label_copyCondition[copyCondition] instead*
-
-If true, any score for the label is copied forward when a new patch
-set is uploaded. Defaults to false.
-
 [[label_copyCondition]]
 === `label.Label-Name.copyCondition`
 
@@ -296,24 +333,104 @@
 
 Gerrit currently supports the following predicates:
 
-==== changekind:{REWORK,TRIVIAL_REBASE,MERGE_FIRST_PARENT_UPDATE,NO_CODE_CHANGE,NO_CHANGE}
+[[changekind]]
+==== changekind:{NO_CHANGE,NO_CODE_CHANGE,MERGE_FIRST_PARENT_UPDATE,REWORK,TRIVIAL_REBASE}
 
-Matches if the diff between two patch sets was of a certain change kind.
+Matches if the diff between two patch sets was of a certain change kind:
 
-`REWORK` matches all kind of change kinds because any other change kind
+* [[no_change]]`NO_CHANGE`:
++
+Matches when a new patch set is uploaded that has the same parent tree,
+code delta, and commit message as the previous patch set. This means
+that only the patch set SHA-1 is different. This can be used to enable
+sticky approvals, reducing turn-around for this special case.
++
+It is recommended to leave this enabled for both, the Code-Review and
+the Verified labels.
++
+`NO_CHANGE` is more trivial than a trivial rebase, no code change and
+a first parent update, hence this change kind is also matched by
+`changekind:TRIVIAL_REBASE`, `changekind:NO_CODE_CHANGE` and if it's
+a merge commit by `changekind:MERGE_FIRST_PARENT_UPDATE`.
+
+
+* [[no_code_change]]`NO_CODE_CHANGE`:
++
+Matches when a new patch set is uploaded that has the same parent tree
+as the previous patch set and the same code diff (including context
+lines) as the previous patch set. This means only the commit message
+may be different; the change hasn't even been rebased. Also matches if
+the commit message is not different, which means this includes matching
+patch sets that have `NO_CHANGE` as the change kind.
++
+This predicate can be used to enable sticky approvals on labels that
+only depend on the code, reducing turn-around if only the commit
+message is changed prior to submitting a change.
++
+For the Verified label that is optionally installed by the
+link:pgm-init.html[init] site program this predicate is used by
+default.
+
+* [[merge_first_parent_update]]`MERGE_FIRST_PARENT_UPDATE`:
++
+Matches when a new patch set is uploaded that is a new merge commit
+which only differs from the merge commit in the previous patch set in
+its first parent, or has identical parents (aka the change kind of the
+merge commit is `NO_CHANGE`).
++
+The first parent of the merge commit is part of the change's target
+branch, whereas the other parent(s) refer to the feature branch(es) to
+be merged.
++
+Matching this change kind is useful if you don't want to trigger CI or
+human verification again if your target branch moved on but the feature
+branch(es) being merged into the target branch did not change.
++
+This predicate does not match if the patch set is not a merge commit.
+
+* [[trivial_rebase]]`TRIVIAL_REBASE`:
++
+Matches when a new patch set is uploaded that is a trivial rebase. A
+new patch set is considered to be trivial rebase if the commit message
+is the same as in the previous patch set and if it has the same diff
+(including context lines) as the previous patch set. This is the case
+if the change was rebased onto a different parent and that rebase did
+not require git to perform any conflict resolution, or if the parent
+did not change at all (aka the change kind of the commit is
+`NO_CHANGE`).
++
+This predicate can be used to enable sticky approvals, reducing
+turn-around for trivial rebases prior to submitting a change.
++
+For the pre-installed Code-Review label this predicate is used by
+default.
+
+* [[rework]]`REWORK`:
++
+Matches all kind of change kinds because any other change kind
 is just a more trivial version of a rework. This means setting
 `changekind:REWORK` is equivalent to setting `is:ANY`.
 
-`NO_CHANGE` is more trivial than a trivial rebase, no code change and
-a first parent update, hence this change kind is also matched by
-`changekind:TRIVIAL_REBASE`, `changekind:NO_CODE_CHANGE` and
-`changekind:MERGE_FIRST_PARENT_UPDATE` (only if the change is for a
-merge commit).
-
+[[is_magic]]
 ==== is:{MIN,MAX,ANY}
 
-Matches votes that are equal to the minimal or maximal voting range. Or any votes.
+Matches approvals that have a minimal, maximal or any score:
 
+* [[is_min]]`MIN`:
++
+Matches approvals that have a minimal score, i.e. the lowest possible
+(negative) value for this label.
+
+* [[is_max]]`MAX`:
++
+Matches approvals that a maximal score, i.e. the highest possible
+(positive) value for this label.
+
+* [[is_any]]`ANY`:
++
+Matches any approval when a new patch set is uploaded.
+
+[[is_value]]
 ==== is:'VALUE'
 
 Matches approvals that have a voting value that is equal to 'VALUE'.
@@ -332,11 +449,25 @@
 Matches all votes if the new patch set was uploaded by a member of
 link:#group-id[\{group-id\}].
 
+[[has_unchanged_files]]
 ==== has:unchanged-files
 
-Matches when the new patch-set includes the same files as the old patch-set.
+Matches when the new patch-set has the same list of files as the
+previous patch-set.
 
-Only 'unchanged-files' is supported for 'has'.
+Votes are not copied in the following cases:
+
+  * If one more files are renamed in the new patch set. These files are counted
+  as a deletion of the file at the old path and an addition of the file at the
+  new path. This means the list of files did change.
+  * If one or more files are reverted to their original content, that is files
+  that become same as in the base revision.
+
+This predicate is useful if you don't want to trigger CI or human
+verification again if the list of files didn't change.
+
+Note, "unchanged-files" is the only value that is supported for the
+"has" operator.
 
 [[group-id]]
 ==== Group ID
@@ -367,129 +498,6 @@
 copyCondition = is:MIN OR -change-kind:REWORK OR uploaderin:dead...beef
 ----
 
-[[label_copyMinScore]]
-=== `label.Label-Name.copyMinScore`
-
-*DEPRECATED: use `is:MIN` predicate in
-link:config-labels.html#label_copyCondition[copyCondition] instead*
-
-If true, the lowest possible negative value for the label is copied
-forward when a new patch set is uploaded. Defaults to false, except
-for All-Projects which has it true by default.
-
-[[label_copyMaxScore]]
-=== `label.Label-Name.copyMaxScore`
-
-*DEPRECATED: use `is:MAX` predicate in
-link:config-labels.html#label_copyCondition[copyCondition] instead*
-
-If true, the highest possible positive value for the label is copied
-forward when a new patch set is uploaded. This can be used to enable
-sticky approvals, reducing turn-around for trivial cleanups prior to
-submitting a change. Defaults to false.
-
-[[label_copyAllScoresIfListOfFilesDidNotChange]]
-=== `label.Label-Name.copyAllScoresIfListOfFilesDidNotChange`
-
-*DEPRECATED: use `is:ANY AND has:unchanged-files` predicates in
-link:config-labels.html#label_copyCondition[copyCondition] instead*
-
-This policy is useful if you don't want to trigger CI or human
-verification again if the list of files didn't change.
-
-If true, all scores for the label are copied forward when a new
-patch-set is uploaded that has the same list of files as the previous
-patch-set.
-
-Renames are considered different files when computing whether new files
-were added or old files were deleted. Hence, if there are renames, scores will
-*NOT* be copied over.
-
-Defaults to false.
-
-[[label_copyAllScoresOnMergeFirstParentUpdate]]
-=== `label.Label-Name.copyAllScoresOnMergeFirstParentUpdate`
-
-*DEPRECATED: use `is:ANY AND changekind:MERGE_FIRST_PARENT_UPDATE` predicates
-in link:config-labels.html#label_copyCondition[copyCondition] instead*
-
-This policy is useful if you don't want to trigger CI or human
-verification again if your target branch moved on but the feature
-branch being merged into the target branch did not change. It only
-applies if the patch set is a merge commit.
-
-If true, all scores for the label are copied forward when a new
-patch set is uploaded that is a new merge commit which only
-differs from the previous patch set in its first parent, or has
-identical parents. The first parent would be the parent of the merge
-commit that is part of the change's target branch, whereas the other
-parent(s) refer to the feature branch(es) to be merged.
-
-Defaults to false.
-
-[[label_copyAllScoresOnTrivialRebase]]
-=== `label.Label-Name.copyAllScoresOnTrivialRebase`
-
-*DEPRECATED: use `is:ANY AND changekind:TRIVIAL_REBASE` predicates
-in link:config-labels.html#label_copyCondition[copyCondition] instead*
-
-If true, all scores for the label are copied forward when a new patch set is
-uploaded that is a trivial rebase. A new patch set is considered to be trivial
-rebase if the commit message is the same as in the previous patch set and if it
-has the same diff (including context lines) as the previous patch set. This is
-the case if the change was rebased onto a different parent and that rebase did
-not require git to perform any conflict resolution, or if the parent did not
-change at all.
-
-This can be used to enable sticky approvals, reducing turn-around for
-trivial rebases prior to submitting a change.
-For the pre-installed Code-Review label this is enabled by default.
-
-Defaults to false.
-
-[[label_copyAllScoresIfNoCodeChange]]
-=== `label.Label-Name.copyAllScoresIfNoCodeChange`
-
-*DEPRECATED: use `is:ANY AND changekind:NO_CODE_CHANGE` predicates in
-link:config-labels.html#label_copyCondition[copyCondition] instead*
-
-If true, all scores for the label are copied forward when a new patch set is
-uploaded that has the same parent tree as the previous patch set and the same
-code diff (including context lines) as the previous patch set. This means only
-the commit message is different; the change hasn't even been rebased. This can
-be used to enable sticky approvals on labels that only depend on the code,
-reducing turn-around if only the commit message is changed prior to submitting a
-change. For the Verified label that is optionally installed by the
-link:pgm-init.html[init] site program this is enabled by default.
-
-Defaults to false.
-
-[[label_copyAllScoresIfNoChange]]
-=== `label.Label-Name.copyAllScoresIfNoChange`
-
-*DEPRECATED: use `is:ANY AND changekind:NO_CHANGE` predicates in
-link:config-labels.html#label_copyCondition[copyCondition] instead*
-
-If true, all scores for the label are copied forward when a new patch
-set is uploaded that has the same parent tree, code delta, and commit
-message as the previous patch set. This means that only the patch
-set SHA-1 is different. This can be used to enable sticky
-approvals, reducing turn-around for this special case.
-It is recommended to leave this enabled for both Verified and
-Code-Review labels.
-
-Defaults to true.
-
-[[label_copyValue]]
-=== `label.Label-Name.copyValue`
-
-*DEPRECATED: use `is:<value>` predicate in
-link:config-labels.html#label_copyCondition[copyCondition] instead*
-
-Value that should be copied forward when a new patch set is uploaded.
-This can be used to enable sticky votes. Can be specified multiple
-times. By default not set.
-
 [[label_canOverride]]
 === `label.Label-Name.canOverride`
 
diff --git a/Documentation/config-project-config.txt b/Documentation/config-project-config.txt
index 9fd5b1b..7a0e305 100644
--- a/Documentation/config-project-config.txt
+++ b/Documentation/config-project-config.txt
@@ -9,6 +9,7 @@
 relevant in an automation scenario of the access controls.
 
 
+[[refs-meta-config]]
 == The +refs/meta/config+ namespace
 
 The namespace contains three different files that play different
@@ -318,17 +319,306 @@
 
 [[content_merge]]submit.mergeContent::
 +
-Defines whether Gerrit will try to
-do a content merge when a path conflict occurs. Valid values are
-'true', 'false', or 'INHERIT'.  Default is 'INHERIT'. This option can
-be modified by any project owner through the project console, `Browse`
-> `Repositories` > my/project > `Allow content merges`.
+Defines whether Gerrit will try to do a content merge when a path conflict
+occurs while submitting a change.
++
+A path conflict occurs when the same file has been changed on both sides of a
+merge, e.g. when the same file has been touched in a change and concurrently in
+the target branch.
++
+Doing a content merge means that Gerrit attempts to merge the conflicting file
+contents from both sides of the merge. This is successful if the touched lines
+(plus some surrounding context lines) do not overlap (i.e. both sides touch
+distinct lines).
++
+NOTE: The content merge setting is not relevant when
+link:#fast_forward_only[fast forward only] is configured as the
+link:#submit.action[submit action] because in this case Gerrit will never
+perform a merge, rebase or cherry-pick on submit.
++
+If content merges are disabled, the submit button in the Gerrit web UI is
+disabled, if any path conflict would occur on submitting the change. Users then
+need to rebase the change manually to resolve the path conflict and then get
+the change re-approved so that they can submit it.
++
+NOTE: If only distinct lines have been touched on both sides, rebasing the
+change from the Gerrit UI is sufficient to resolve the path conflict, since the
+rebase action always does the rebase with content merge enabled.
++
+The advantage of enabling content merges on submit is that it makes it less
+likely that change submissions are rejected due to conflicts. Each change
+submission that goes through with content merge, but would be rejected
+otherwise, saves the user from needing to do extra work to get the change
+submitted (rebase the change, get it re-approved and then submit it again).
++
+On the other hand, disabling content merges decreases the chance of breaking
+branches by submitting content merges of incompatible modifications in the same
+file, e.g. a function is removed on one side and a new usage of that function
+is added on the other side. Note, that the chance of breaking a branch by
+incompatible modifications is only reduced, but not eliminated, e.g. even with
+content merges disabled it's possible that a function is removed in one file
+and a new usage of that function is added in another file.
++
+The huge drawback of disabling content merge is that users need to do extra
+work when a change isn't submittable due to a path conflict which could be
+avoided if content merge was enabled (see above). In addition to this, it also
+confuses and frustrates users if a change submission is rejected by Gerrit due
+to a path conflict, but then when they rebase the change manually they do not
+see any conflict (because manual rebases are always done with content merge
+enabled).
++
+In general, the stability gain of disabling content merges is not worth the
+overhead and confusion that this adds for users, which is why disabling content
+merges is highly discouraged.
++
+Valid values are `true`, `false`, or `INHERIT`.
++
+The default is `INHERIT`.
++
+NOTE: Project owners can also set this option in the Gerrit UI:
+`Browse` > `Repositories` > my/repository > `Allow content merges`.
 
 [[submit.action]]submit.action::
 +
-Defines the link:#submit-type[submit type].  Valid
-values are 'fast forward only', 'merge if necessary', 'rebase if necessary',
-'rebase always', 'merge always' and 'cherry pick'.  The default is 'merge if necessary'.
+Defines the submit action aka submit type aka submit strategy that Gerrit
+should use to integrate changes into the target branch when they are submitted.
++
+In general, submitting a change only merges the change if all its dependencies
+are also submitted. The only exception is the `cherry pick` submit action which
+ignores dependencies and hence is not recommended to be used (see
+link:#cherry_pick[below]).
++
+[[submit-type]]
+--
+The following submit actions are supported:
+
+[[merge_if_necessary]]
+* 'merge if necessary':
++
+With this action, when a change is being submitted, Gerrit fast-forwards the
+target branch if possible, and otherwise creates a merge commit automatically.
++
+A fast-forward is possible if the commit that represents the current patch set
+of the change has the current head of the target branch in its parent lineage.
++
+If a fast-forward is not possible, Gerrit automatically creates a merge commit
+that merges the current patch set of the change into the target branch and then
+the target branch is fast-forwarded to the merge commit.
++
+The behavior of this submit action is identical with the classical `git merge`
+behavior, or
+link:https://git-scm.com/docs/git-merge#Documentation/git-merge.txt---ff[git
+merge --ff].
++
+With this submit action the commits that have been reviewed and approved are
+retained in the git history of the target branch. This means, by looking at the
+history of the target branch, you can see for all commits when they were
+originally committed and on which parent commit they were originally based.
+
+[[always_merge]]
+[[merge_always]]
+* 'merge always':
++
+With this action, when a change is being submitted, Gerrit always creates a
+merge commit, even if a fast-forward is possible.
++
+This is identical to the behavior of
+link:https://git-scm.com/docs/git-merge#Documentation/git-merge.txt---no-ff[git merge --no-ff].
++
+With this submit action the commits that have been reviewed and approved are
+retained in the git history of the target branch. This means, by looking at the
+history of the target branch, you can see for all commits when they were
+originally committed and on which parent commit they were originally based. In
+addition, from the merge commits you can see when the changes were submitted
+and it's possible to follow submissions with `git log --first-parent`.
+
+[[rebase_if_necessary]]
+* 'rebase if necessary':
++
+With this action, when a change is being submitted, Gerrit fast-forwards the
+target branch if possible, and otherwise does a rebase automatically.
++
+A fast-forward is possible if the commit that represents the current patch set
+of the change has the current head of the target branch in its parent lineage.
++
+If a fast-forward is not possible, Gerrit automatically rebases the current
+patch set of the change on top of the current head of the target branch and
+then the target branch is fast-forwarded to the rebased commit.
++
+With this submit action, when a rebase is performed, the original commits that
+have been reviewed and approved do not become part of the target branch's
+history. This means the information on when the original commits were committed
+and on which parent they were based is not retained in the branch history.
++
+Using this submit action results in a linear history of the target branch,
+unless merge commits are being submitted. For some people this is an advantage
+since they find the linear history easier to read.
++
+NOTE: Rebasing merge commits is not supported. If a change with a merge commit
+is submitted the link:#merge_if_necessary[merge if necessary] submit action is
+applied.
++
+When rebasing the patchset, Gerrit automatically appends onto the end of the
+commit message a short summary of the change's approvals, and a URL link back
+to the change in the web UI (see link:#submit-footers[below]). If a fast-forward
+is done no footers are added.
+
+[[rebase_always]]
+* 'rebase always':
++
+With this action, when a change is being submitted, Gerrit always does a
+rebase, even if a fast-forward is possible.
++
+With this submit action, the original commits that have been reviewed and
+approved do not become part of the target branch's history. This means the
+information on when the original commits were committed and on which parent
+they were based is not retained in the branch history.
++
+Using this submit action results in a linear history of the target branch,
+unless merge commits are being submitted. For some people this is an advantage
+since they find the linear history easier to read.
++
+NOTE: Rebasing merge commits is not supported. If a change with a merge commit
+is submitted the link:#merge_if_necessary[merge if necessary] submit action is
+applied.
++
+When rebasing the patchset, Gerrit automatically appends onto the end of the
+commit message a short summary of the change's approvals, and a URL link back
+to the change in the web UI (see link:#submit-footers[below]).
++
+The footers that are added are exactly the same footers that are also added by
+the link:cherry_pick[cherry pick] action. Thus, the `rebase always` action can
+be considered similar to the `cherry pick` action, but with the important
+distinction that `rebase always` does not ignore dependencies, which is why
+using the `rebase always` action should be preferred over the `cherry pick`
+submit action.
+
+[[fast_forward_only]]
+* 'fast forward only' (usage generally not recommended):
++
+With this action a change can only be submitted if at submit time the target
+branch can be fast-forwarded to the commit that represents the current patch
+set of the change. This means in order for a change to be submittable its
+current patch set must have the current head of the target branch in its parent
+lineage.
++
+The advantage of using this action is that the target branch is always updated
+to the exact commit that has been reviewed and approved. In particular, if CI
+verification is configured, this means that the CI verified the exact commit to
+which the target branch is being fast-forwarded on submit (assuming no approval
+copying is configured for CI votes).
++
+The huge drawback of using this action is that whenever one change is submitted
+all other open changes for the same branch, that are not successors of the
+submitted change, become non-submittable, since the target branch can no longer
+be fast-forwarded to their current patch sets. Making these changes submittable
+again requires rebasing and re-approving/re-verifying them. For most projects
+this causes an unreasonable amount of overhead that doesn't justify the
+stability gain by verifying exact commits so that using this submit action is
+generally discouraged. Using this action should only be considered for projects
+that have a low frequency of changes and that have special requirements to
+never break any target branch.
++
+NOTE: With this submit action Gerrit does not create merge commits on
+submitting a change, but merge commits that are created on the client, prior to
+uploading to Gerrit for review, may still be submitted.
+
+[[cherry_pick]]
+* 'cherry pick' (usage not recommended, use link:#rebase_always[rebase always]
+instead):
++
+With this submit action Gerrit always performs a cherry pick of the current
+patch set when a change is submitted. This ignores the parent lineage and
+instead creates a brand new commit on top of the current head of the target
+branch. The submitter becomes the committer of the new commit and the original
+commit author is retained.
++
+Ignoring change dependencies on submit is often confusing for users. For users
+that stack changes on top of each other, it's unexpected that these
+dependencies are ignored on submit. Ignoring dependencies also means that
+submitters need to submit the changes individually in the correct order.
+Otherwise it's likely that submissions fail due to conflicts or that the
+target branch gets broken (because it contains the submitted change, but not
+its predecessors which may be required for the submitted change to work
+correctly).
++
+If link:config-gerrit.html#change.submitWholeTopic[`change.submitWholeTopic`]
+is enabled changes that have the same topic are submitted together, the same as
+with all other submit actions. This means by setting the same topic on all
+dependent changes it's possible to submit a stack of changes together and
+overcome the limitation that change dependencies are ignored.
++
+When cherry picking the patchset, Gerrit automatically appends onto the end of
+the commit message a short summary of the change's approvals, and a URL link
+back to the change in the web UI (see link:#submit-footers[below]).
++
+Using this submit action is not recommended because it ignores change
+dependencies, instead link:#rebase_always[rebase always] should be used which
+behaves the same way except that it respects change dependencies (in particular
+`rebase always` adds the same kind of footers to the merged commit as
+`cherry pick`).
+
+--
++
+[[submit_type_inherit]]
+In addition the submit action can be set to `Inherit`, which means that the
+value that is configured in the parent project applies. For new projects
+`Inherit` is the default, unless the default is overridden by the global
+link:config-gerrit.html#repository.name.defaultSubmitType[defaultSubmitType]
+configuration. Configuring `Inherit` for the `All-Projects` root project is
+equivalent to configuring link:#merge_if_necessary[merge if necessary].
++
+If `submit.action` is not set, the default is 'merge if necessary'.
++
+NOTE: The different submit actions are also described in the
+link:https://docs.google.com/presentation/d/1C73UgQdzZDw0gzpaEqIC6SPujZJhqamyqO1XOHjH-uk/edit#slide=id.g4d6c16487b_1_800[Gerrit - Concepts and Workflows]
+presentation, where their behavior is visualized by git commit graphs.
++
+NOTE: If Gerrit performs a merge, rebase or cherry-pick as part of the
+change submission (true for all submit actions, except for
+link:#fast_forward_only[fast forward only]), it is controlled by the
+link:#submit.mergeContent[mergeContent] setting whether a content merge is
+performed when there is a path conflict.
++
+NOTE: If Gerrit performs a merge, rebase or cherry-pick as part of the
+change submission (true for all submit actions, except for
+link:#fast_forward_only[fast forward only]), it can be that trying to submit
+a change would fail due to Git conflicts (if the same lines were modified
+concurrently, or if link:#submit.mergeContent[mergeContent] is disabled also if
+the same files were modified concurrently). In this case the submit button in
+the Gerrit web UI is disabled and a tooltip on the disabled submit button
+informs about the change being non-mergeable.
++
+[[submit-footers]]
+--
+NOTE: If Gerrit performs a rebase or cherry-pick as part of the change
+submission (true for link:#rebase_if_necessary[rebase if necessary],
+link:#rebase_always[rebase always] and link:#cherry_pick[cherry pick]) Gerrit
+inserts additional footers into the commit message of the newly created
+commit: +
+ +
+* `Change-Id: <change-id>` (only if this footer is not already present, see
+  link:user-changeid.html[Change-Id]) +
+* `Reviewed-on: <change-url>` (links to the change in Gerrit where this commit
+  was reviewed) +
+* `Reviewed-by: <reviewer>` (once for every reviewer with a positive
+  `Code-Review` vote) +
+* `Tested-by: <reviewer>` (once for every reviewer with a positive `Verified`
+  vote) +
+* `<label-name>: <reviewer>` (once for every reviewer with a positive vote on
+  any other label) +
+ +
+In addition, plugins that implement a
+link:dev-plugins.html#change-message-modifier[Change Message Modifier] may add
+additional custom footers.
+--
++
+NOTE: For the value of `submit.action` in `project.config` use the exact
+spelling as given above, e.g. 'merge if necessary' (without the single quotes,
+but with the spaces).
++
+NOTE: Project owners can also set the submit action in the Gerrit UI:
+`Browse` > `Repositories` > my/repository > `Submit type`
 
 [[submit.matchAuthorToCommitterDate]]submit.matchAuthorToCommitterDate::
 +
@@ -346,98 +636,6 @@
 is set to 'true' the merge would fail in such a case. An empty commit is still allowed as the
 initial commit on a branch.
 
-[[submit-type]]
-==== Submit Type
-
-'submit.action': The method Gerrit uses to submit a change to a project.
-
-The submit type can also be modified by any project owner through the
-project console, `Browse` > `Repositories` > my/project > 'Submit type'.
-In general, a submitting a change only merges the change if all its
-dependencies are also submitted, with exceptions documented below.
-
-The following submit types are supported:
-
-[[submit_type_inherit]]
-* Inherit
-+
-This is the default for new projects, unless overridden by a global
-link:config-gerrit.html#repository.name.defaultSubmitType[`defaultSubmitType` option].
-+
-Inherit the submit type from the parent project. In `All-Projects`, this
-is equivalent to link:#merge_if_necessary[Merge If Necessary].
-
-[[fast_forward_only]]
-* Fast Forward Only
-+
-With this method Gerrit does not create merge commits on submitting a
-change. Merge commits may still be submitted, but they must be created
-on the client prior to uploading to Gerrit for review.
-+
-To submit a change, the change must be a strict superset of the
-destination branch.  That is, the change must already contain the
-tip of the destination branch at submit time.
-
-[[merge_if_necessary]]
-* Merge If Necessary
-+
-If the change being submitted is a strict superset of the destination
-branch, then the branch is fast-forwarded to the change.  If not,
-then a merge commit is automatically created.  This is identical
-to the classical `git merge` behavior, or `git merge --ff`.
-
-[[always_merge]]
-* Always Merge
-+
-Always produce a merge commit, even if the change is a strict
-superset of the destination branch.  This is identical to the
-behavior of `git merge --no-ff`, and may be useful if the
-project needs to follow submits with `git log --first-parent`.
-
-[[cherry_pick]]
-* Cherry Pick
-+
-Always cherry pick the patch set, ignoring the parent lineage
-and instead creating a brand new commit on top of the current
-branch head.
-+
-When cherry picking a change, Gerrit automatically appends onto the
-end of the commit message a short summary of the change's approvals,
-and a URL link back to the change on the web.  The committer header
-is also set to the submitter, while the author header retains the
-original patch set author.
-+
-Note that Gerrit ignores dependencies between changes when using this
-submit type unless
-link:config-gerrit.html#change.submitWholeTopic[`change.submitWholeTopic`]
-is enabled and depending changes share the same topic. So generally
-submitters must remember to submit changes in the right order when using this
-submit type. If all you want is extra information in the commit message,
-consider using the Rebase Always submit strategy.
-
-[[rebase_if_necessary]]
-* Rebase If Necessary
-+
-If the change being submitted is a strict superset of the destination
-branch, then the branch is fast-forwarded to the change.  If not,
-then the change is automatically rebased and then the branch is
-fast-forwarded to the change.
-+
-When Gerrit tries to do a merge, by default the merge will only
-succeed if there is no path conflict.  A path conflict occurs when
-the same file has also been changed on the other side of the merge.
-
-[[rebase_always]]
-* Rebase Always
-+
-Basically, the same as Rebase If Necessary, but it creates a new patchset even
-if fast forward is possible AND like Cherry Pick it ensures footers such as
-Change-Id, Reviewed-On, and others are present in resulting commit that is
-merged.
-+
-Thus, Rebase Always can be considered similar to Cherry Pick, but with
-the important distinction that Rebase Always does not ignore dependencies.
-
 
 [[access-section]]
 === Access section
diff --git a/Documentation/config-submit-requirements.txt b/Documentation/config-submit-requirements.txt
index 2686f39..8298be3 100644
--- a/Documentation/config-submit-requirements.txt
+++ b/Documentation/config-submit-requirements.txt
@@ -38,6 +38,15 @@
 submit requirements for certain branch patterns. See the
 link:#exempt-branch-example[exempt branch] example.
 
+Often submit requirements should only apply to branches that contain source
+code. In this case this parameter can be used to exclude the
+link:config-project-config.html#refs-meta-config[refs/meta/config] branch from
+a submit requirement:
+
+----
+  applicableIf = -branch:refs/meta/config
+----
+
 This field is optional, and if not specified, the submit requirement is
 considered applicable for all changes in the project.
 
diff --git a/Documentation/dev-community.txt b/Documentation/dev-community.txt
index 7488f74..47c0be2 100644
--- a/Documentation/dev-community.txt
+++ b/Documentation/dev-community.txt
@@ -54,7 +54,6 @@
 * link:dev-build-plugins.html[Building Gerrit plugins]
 * link:pg-plugin-dev.html[JavaScript Plugin Development and API]
 * link:config-validation.html[Validation Interfaces]
-* link:dev-stars.html[Starring Changes]
 * link:quota.html[Quota Enforcement]
 
 [[maintainer]]
diff --git a/Documentation/dev-contributing.txt b/Documentation/dev-contributing.txt
index fcc8b7e..107473a 100644
--- a/Documentation/dev-contributing.txt
+++ b/Documentation/dev-contributing.txt
@@ -24,33 +24,31 @@
 link:dev-roles.html#maintainer[maintainers,role=external,window=_blank]
 review them adhoc.
 
-For large/complex features, it is required to follow the
-link:#design-driven-contribution-process[design-driven contribution
-process] and specify the feature in a link:dev-design-docs.html[design
-doc,role=external,window=_blank] before starting with the implementation.
+For large/complex features, it is required to specify the feature in a
+link:dev-design-docs.html[design document,role=external,window=_blank] before
+starting implementation, as per the
+link:#design-driven-contribution-process[design-driven contribution process].
 
 If link:dev-roles.html#contributor[contributors,role=external,window=_blank]
-choose the lightweight contribution process and during the review it turns out
-that the feature is too large or complex,
-link:dev-roles.html#maintainer[maintainers,role=external,window=_blank] can
-require to follow the design-driven contribution process instead.
+choose the lightweight contribution process but the feature is found to be 
+large or complex, link:dev-roles.html#maintainer[maintainers,role=external,window=_blank]
+can require that the design-driven contribution process be followed instead.
 
 If you are in doubt which process is right for you, consult the
 link:https://groups.google.com/d/forum/repo-discuss[repo-discuss,role=external,window=_blank]
 mailing list.
 
 These contribution processes apply to everyone who contributes code to
-the Gerrit project, including link:dev-roles.html#maintainer[
-maintainers,role=external,window=_blank]. When reading this document, keep in
-mind that maintainers are also contributors when they contribute code.
+the Gerrit project. link:dev-roles.html#maintainer[
+Maintainers,role=external,window=_blank] are also considered contributors
+when they contribute code.
 
 If a new feature is large or complex, it is often difficult to find a
-maintainer who can take the time that is needed for a thorough review,
-and who can help with getting the changes submitted. To avoid that this
-results in unpredictable long waiting times during code review,
-contributors can ask for link:#mentorship[mentor support]. A mentor
-helps with timely code reviews and technical guidance. Doing the
-implementation is still the responsibility of the contributor.
+maintainer who can take the time that is needed for a thorough review. This
+can result in unpredictably long waiting times before the changes are
+submitted. To avoid that, contributors can ask for link:#mentorship[mentor support].
+A mentor helps with timely code reviews and technical guidance, though the 
+implementation itself is still the responsibility of the contributor.
 
 [[comparison]]
 === Quick Comparison
@@ -66,8 +64,8 @@
 |Review  |adhoc (when reviewer is available)|by a dedicated mentor (if
 a link:#mentorship[mentor] was assigned)
 |Caveats |features may get vetoed after the implementation was already
-done, maintainers may make the design-driven contribution process
-required if a change gets too complex/large|design doc must stay open
+done, maintainers may require the design-driven contribution process
+be followed if a change gets too complex/large|design doc must stay open
 for a minimum of 10 calendar days, a mentor may not be available
 immediately
 |Applicable to|documentation updates, bug fixes, small features|
@@ -83,40 +81,32 @@
 link:#design-driven-contribution-process[design-driven contribution
 process] is required.
 
-As Gerrit is a code review tool, naturally contributions will
-be reviewed before they will get submitted to the code base.  To
-start your contribution, please make a git commit and upload it
-for review to the link:https://gerrit-review.googlesource.com/[
-gerrit-review.googlesource.com,role=external,window=_blank] Gerrit server.  To
-help speed up the review of your change, review these link:dev-crafting-changes.html[
+To start contributing to Gerrit, upload your git commit for review to the
+link:https://gerrit-review.googlesource.com/[gerrit-review.googlesource.com,
+role=external,window=_blank] Gerrit server. Review these link:dev-crafting-changes.html[
 guidelines,role=external,window=_blank] before submitting your change.  You can
-view the pending Gerrit contributions and their statuses
-link:https://gerrit-review.googlesource.com/#/q/status:open+project:gerrit[here,role=external,window=_blank].
+view pending contributions link:https://gerrit-review.googlesource.com/#/q/status:open+project:gerrit[here,role=external,window=_blank].
 
 Depending on the size of that list it might take a while for
-your change to get reviewed.  Naturally there are fewer
-link:dev-roles.html#maintainer[maintainers,role=external,window=_blank], that
-can approve changes, than link:dev-roles.html#contributor[contributors,role=external,window=_blank];
-so anything that you can do to ensure that your contribution will undergo fewer
-revisions will speed up the contribution process.  This includes
-helping out reviewing other people's changes to relieve the load from
-the maintainers.  Even if you are not familiar with Gerrit's internals,
+your change to get reviewed. Anything that you can do to ensure that your
+contribution will undergo fewer revisions will speed up the contribution process.
+This includes helping out reviewing other people's changes to relieve the
+load from the maintainers. Even if you are not familiar with Gerrit's internals,
 it would be of great help if you can download, try out, and comment on
-new features.  If it works as advertised, say so, and if you have the
+new features. If it works as advertised, say so, and if you have the
 privileges to do so, go ahead and give it a `+1 Verified`.  If you
 would find the feature useful, say so and give it a `+1 Code Review`.
 
-And finally, the quicker you respond to the comments of your reviewers,
-the quicker your change might get merged!  Try to reply to every
-comment after submitting your new patch, particularly if you decided
-against making the suggested change. Reviewers don't want to seem like
-nags and pester you if you haven't replied or made a fix, so it helps
-them know if you missed it or decided against it.
+Finally, the quicker you respond to the comments of your reviewers, the
+quicker your change can be merged! Try to reply to every comment after
+submitting your new patch, particularly if you decided against making the
+suggested change. Reviewers don't want to seem like nags and pester you
+if you haven't replied or made a fix, so it helps them know if you missed
+it or decided against it.
 
-Features or API extensions, even if they are small, will incur
-long-time maintenance and support burden, so they should be left
-pending for at least 24 hours to give maintainers in all timezones a
-chance to evaluate.
+A new feature or API extension, even if small, can incur a long-time
+maintenance and support burden and should be left pending for 24 hours
+to give maintainers in all time zones a chance to evaluate the change.
 
 [[design-driven-contribution-process]]
 === Design-driven Contribution Process
@@ -126,19 +116,19 @@
 
 For large/complex features it is important to:
 
-* agree on the functionality and scope before spending too much time
-  on the implementation
+* agree on functionality and scope before spending too much time on
+  implementation
 * ensure that they are in line with Gerrit's project scope and vision
 * ensure that they are well aligned with other features
-* think about possibilities how the feature could be evolved over time
+* consider how the feature could be evolved over time
 
 This is why for large/complex features it is required to describe the
 feature in a link:dev-design-docs.html[design doc,role=external,window=_blank]
 and get it approved by the
-link:dev-processes.html#steering-committee[steering committee,role=external,window=_blank],
+link:dev-processes.html#steering-committee[steering committee,role=external,window=_blank]
 before starting the implementation.
 
-The design-driven contribution process has the following steps:
+The design-driven contribution process consists of the following steps:
 
 * A link:dev-roles.html#contributor[contributor,role=external,window=_blank]
   link:dev-design-docs.html#propose[proposes,role=external,window=_blank] a new
@@ -155,40 +145,31 @@
   be accepted.
 * To be submitted, the design doc needs to be approved by the
   link:dev-processes.html#steering-committee[steering committee,role=external,window=_blank].
-* After the design was approved, the implementation is done by pushing
-  changes for review, see link:#lightweight-contribution-process[
+* After the design is approved, it is implemented by pushing
+  changes for review, see the link:#lightweight-contribution-process[
   lightweight contribution process]. Changes that are associated with
   a design should all share a common hashtag. The contributor is the
-  main driver of the implementation and responsible that it is done.
-  Others from the Gerrit community are usually much welcome to help
-  with the implementation.
+  main driver of the implementation and responsible for its completion.
+  Others from the Gerrit community are usually welcome to help.
 
-In order to be accepted/submitted, it is not necessary that the design
-doc fully specifies all the details, but the idea of the feature and
-how it fits into Gerrit should be sufficiently clear (judged by the
-steering committee). Contributors are expected to keep the design doc
-updated and fill in gaps while they go forward with the implementation.
-We expect that implementing the feature and updating the design doc
-will be an iterative process.
+The design doc does not need to fully specify each detail of the feature,
+but its concept and how it fits into Gerrit should be sufficiently clear,
+as judged by the steering committee. Contributors are expected to keep
+the design doc updated and fill in gaps while they go forward with the
+implementation. We expect that implementing the feature and updating the
+design doc will be an iterative process.
 
-While the design doc is still in review, contributors may already start
-with the implementation (e.g. do some prototyping to demonstrate parts
-of the proposed design), but those changes should not be submitted
-while the design wasn't approved yet. Another way to demonstrate the
-design can be to add screenshots or the like, early enough in the doc.
+While the design doc is still in review, contributors may start with the
+implementation (e.g. do some prototyping to demonstrate parts of the
+proposed design), but those changes should not be submitted while the
+design is not yet approved. Another way to demonstrate the design can be
+mocking screenshots in the doc.
 
 By approving a design, the steering committee commits to:
 
 * Accepting the feature when it is implemented.
 * Supporting the feature by assigning a link:dev-roles.html#mentor[
-  mentor,role=external,window=_blank] (if requested, see link:#mentorship[mentorship]).
-
-If the implementation of a feature gets stuck and it's unclear whether
-the feature gets fully done, it should be discussed with the steering
-committee how to proceed. If the contributor cannot commit to finish
-the implementation and no other contributor can take over, changes that
-have already been submitted for the feature might get reverted so that
-there is no unused or half-finished code in the code base.
+  mentor,role=external,window=_blank] if requested (see link:#mentorship[mentorship]).
 
 For contributors, the design-driven contribution process has the
 following advantages:
@@ -196,12 +177,11 @@
 * By writing a design doc, the feature gets more attention. During the
   design review, feedback from various sides can be collected, which
   likely leads to improvements of the feature.
-* Once a design was approved by the
+* Once a design is approved by the
   link:dev-processes.html#steering-committee[steering committee,role=external,window=_blank],
   the contributor can be almost certain that the feature will be accepted.
-  Hence, there is only a low risk to invest into implementing a feature
-  and see it being rejected later during the code review, as it can
-  happen with the lightweight contribution process.
+  Hence, there little risk of the feature being rejected later in code review,
+  as can occur with the lightweight contribution process.
 * The contributor can link:#mentorship[get a dedicated mentor assigned]
   who provides timely reviews and serves as a contact person for
   technical questions and discussing details of the design.
@@ -249,12 +229,11 @@
 * done criteria that define when the feature is done and the mentorship
   ends
 
-If a feature is not finished in time, it should be discussed with the
-steering committee how to proceed. If the contributor cannot commit to
-finish the implementation in time and no other contributor can take
-over, changes that have already been submitted for the feature might
-get reverted so that there is no unused or half-finished code in the
-code base.
+If a feature implementation is not completed in time and no contributors
+can commit to finishing the implementation, changes that have already been
+submitted for the feature may be reverted to avoid unused or half-finished
+code in the code base. In these circumstances, the steering committee
+determines how to proceed.
 
 [[esc-dd-evaluation]]
 == How the ESC evaluates design documents
@@ -314,7 +293,7 @@
 === Core vs. Plugin decision
 Q: `Would this fit better in a plugin?`
 
-* Yes:The proposed feature or rework is an implementation (e.g. Lucene
+* Yes: The proposed feature or rework is an implementation (e.g. Lucene
   is an index implementation) of a generic concept that others
   might want to implement differently.
 * Yes: The proposed feature or rework is very specific to a custom setup.
diff --git a/Documentation/dev-e2e-tests.txt b/Documentation/dev-e2e-tests.txt
index 8e5463d..1151f1c 100644
--- a/Documentation/dev-e2e-tests.txt
+++ b/Documentation/dev-e2e-tests.txt
@@ -204,6 +204,13 @@
 
 * `-Dcom.google.gerrit.scenarios.context_path=/context`
 
+==== Authentication
+
+The `authenticated` property allows test scenarios to use authenticated HTTP clones. Its default is
+no authentication:
+
+* `-Dcom.google.gerrit.scenarios.authenticated=false`
+
 ==== Automatic properties
 
 The link:#_input_file[example keywords,role=external,window=_blank] also include `_PROJECT`,
diff --git a/Documentation/dev-eclipse.txt b/Documentation/dev-eclipse.txt
index dce5eb0..79febe4 100644
--- a/Documentation/dev-eclipse.txt
+++ b/Documentation/dev-eclipse.txt
@@ -115,11 +115,11 @@
 
 == Testing
 
-=== The Gerrit web app UI is served by `server.go` process. To launch it,
+=== The Gerrit web app UI is served by `Web Dev Server`. To launch it,
 run this command:
 
 ----
-  $ bazel run polygerrit-ui:devserver
+  $ npm run start
 ----
 
 === Running the Daemon
diff --git a/Documentation/dev-readme.txt b/Documentation/dev-readme.txt
index f045ab8..6ff064c 100644
--- a/Documentation/dev-readme.txt
+++ b/Documentation/dev-readme.txt
@@ -178,6 +178,9 @@
 NOTE: To learn why using `java -jar` isn't sufficient, see
 <<special_bazel_java_version,this explanation>>.
 
+NOTE: When launching the daemong this way, the settings from the `[container]` section from the
+`$GERRIT_SITE/etc/gerrit.config` are not honored.
+
 To debug the Gerrit server of this test site:
 
 .  Open a debug port (such as port 5005). To do so, insert the following code
@@ -188,6 +191,49 @@
 -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=5005
 ----
 
+=== Running the Daemon honoring the [container] section settings
+
+To run the Daemon and honor the `[container]` section settings use the `gerrit.sh` script:
+
+----
+  $ cd $GERRIT_SITE
+  $ bin/gerrit.sh run
+----
+
+To run the Daemon in debug mode use the `--debug` option:
+
+----
+  $ bin/gerrit.sh run --debug
+----
+
+The default debug port is `8000`. To specify a different debug port use the `--debug-port` option:
+
+----
+  $ bin/gerrit.sh run --debug --debug-port=5005
+----
+
+The `--debug-address` option also exists and is a synonym for the ``--debug-port` option:
+
+----
+  $ bin/gerrit.sh run --debug --debug-address=5005
+----
+
+Note that, by default, the debugger will only accept connections from the localhost. To enable
+debug connections from other host(s) provide also a host name or wildcard in the `--debug-address`
+value:
+
+----
+  $ bin/gerrit.sh run --debug --debug-address=*:5005
+----
+
+Debugging the Daemon startup requires starting the JVM in suspended debug mode. The JVM will await
+for a debugger to attach before proceeding with the start. Use the `--suspend` option for that
+scenario:
+
+----
+  $ bin/gerrit.sh run --debug --suspend
+----
+
 === Running the Daemon with Gerrit Inspector
 
 link:dev-inspector.html[Gerrit Inspector] is an interactive scriptable
diff --git a/Documentation/dev-release.txt b/Documentation/dev-release.txt
index 0849c56..40470a6 100644
--- a/Documentation/dev-release.txt
+++ b/Documentation/dev-release.txt
@@ -327,6 +327,20 @@
   git submodule foreach '[ "$sm_path" == "modules/jgit" ] || git push gerrit-review tag "v$version"'
 ----
 
+[[publish-typescript-plugin-api]]
+==== Publish TypeScript Plugin API
+
+Only applies to major and minor releases! Not required for patch releases.
+
+* Publish a new version of the npm package `@gerritcodereview/typescript-api`.
+See link:https://gerrit.googlesource.com/gerrit/+/master/polygerrit-ui/app/api/README.md[api/README.md,role=external,window=_blank]
+for details.
+
+* The plugins in the stable branch of the minor release and the master branch
+be changed to use the new API version, see
+link:https://gerrit-review.googlesource.com/c/gerrit/+/340069[
+example change,role=external,window=_blank]
+
 [[upload-documentation]]
 ==== Upload the Documentation
 
diff --git a/Documentation/dev-roles.txt b/Documentation/dev-roles.txt
index cecaedc..d8f7e11 100644
--- a/Documentation/dev-roles.txt
+++ b/Documentation/dev-roles.txt
@@ -66,11 +66,12 @@
 link:https://groups.google.com/d/forum/repo-discuss[repo-discuss,role=external,window=_blank]
 mailing list):
 
-* become member of the `gerrit-verifiers` group, which allows to:
+* become member of the `gerrit-trusted-contributors` group, which allows to:
 ** vote on the `Verified` and `Code-Style` labels
 ** edit hashtags on all changes
 ** edit topics on all open changes
 ** abandon changes
+** revert changes
 * approve posts to the
   link:https://groups.google.com/d/forum/repo-discuss[repo-discuss,role=external,window=_blank]
   mailing list
@@ -106,14 +107,14 @@
 have. In addition they have signed a link:dev-cla.html[contributor
 license agreement] which enables them to push changes.
 
-Regular contributors can ask to be added to the `gerrit-verifiers`
+Regular contributors can ask to be added to the `gerrit-trusted-contributors`
 group, which allows to:
 
 * add patch sets to changes of other users
 * propose project config changes (push changes for the
   `refs/meta/config` branch
 
-Being member of the `gerrit-verifiers` group includes further
+Being member of the `gerrit-trusted-contributors` group includes further
 permissions (see link:#supporter[supporter] section above).
 
 It's highly appreciated if contributors engage in code reviews,
diff --git a/Documentation/dev-stars.txt b/Documentation/dev-stars.txt
deleted file mode 100644
index 764e326..0000000
--- a/Documentation/dev-stars.txt
+++ /dev/null
@@ -1,82 +0,0 @@
-= Gerrit Code Review - Stars
-
-== Description
-
-Changes can be starred with labels that behave like private hashtags.
-Any label can be applied to a change, but these labels are only visible
-to the user for which the labels have been set.
-
-Stars allow users to categorize changes by self-defined criteria and
-then build link:user-dashboards.html[dashboards] for them by making use
-of the link:#query-stars[star query operators].
-
-[[star-api]]
-== Star API
-
-The link:rest-api-accounts.html#star-endpoints[star REST API] supports:
-
-* link:rest-api-accounts.html#get-stars[
-  get star labels from a change]
-* link:rest-api-accounts.html#set-stars[
-  update star labels on a change]
-* link:rest-api-accounts.html#get-starred-changes[
-  list changes that are starred by any label]
-
-Star labels are also included in
-link:rest-api-changes.html#change-info[ChangeInfo] entities that are
-returned by the link:rest-api-changes.html[changes REST API].
-
-There are link:rest-api-accounts.html#default-star-endpoints[
-additional REST endpoints] for the link:#default-star[default star].
-
-[[default-star]]
-== Default Star
-
-If the default star is set by a user, this user is automatically
-notified by email whenever updates are made to that change.
-
-The default star is the star that is shown in the WebUI and which can
-be updated from there.
-
-The default star is represented by the special star label 'star'.
-
-[[ignore-star]]
-== Ignore Star
-
-If the ignore star is set by a user, this user gets no email
-notifications for updates of that change, even if this user is a
-reviewer of the change or the change is matched by a project watch of
-the user.
-
-Since changes can only be ignored once they are created, users that
-watch a project will always get the email notifications for the change
-creation. Only then the change can be ignored.
-
-Users that are added as reviewer or assignee to a change that they have
-ignored will be notified about this, so that they know about the review
-request. They can then decide to remove the ignore star.
-
-The ignore star is represented by the special star label 'ignore'.
-
-[[query-stars]]
-== Query Stars
-
-There are several query operators to find changes with stars:
-
-* link:user-search.html#is-starred[is:starred] /
-  link:user-search.html#has-star[has:star]:
-  Matches any change that was starred by the current user with the
-  link:#default-star[default star].
-
-[[syntax]]
-== Syntax
-
-Star labels cannot contain whitespace characters. All other characters
-are allowed.
-
-GERRIT
-------
-Part of link:index.html[Gerrit Code Review]
-
-SEARCHBOX
----------
diff --git a/Documentation/intro-gerrit-walkthrough.txt b/Documentation/intro-gerrit-walkthrough.txt
index ff37ffc..345eb1c 100644
--- a/Documentation/intro-gerrit-walkthrough.txt
+++ b/Documentation/intro-gerrit-walkthrough.txt
@@ -130,7 +130,7 @@
 In general, the *Code-Review* check requires an individual to look at the code,
 while the *Verified* check is done by an automated build server, through a
 mechanism such as the
-link:https://wiki.jenkins-ci.org/display/JENKINS/Gerrit+Trigger[Gerrit Trigger
+link:https://plugins.jenkins.io/gerrit-trigger/[Gerrit Trigger
 Jenkins Plugin,role=external,window=_blank].
 
 IMPORTANT: The Code-Review and Verified checks require different permissions
@@ -253,7 +253,7 @@
 can add custom checks or even remove the Verified check entirely.
 
 Verification is typically an automated process using the
-link:https://wiki.jenkins-ci.org/display/JENKINS/Gerrit+Trigger[Gerrit Trigger Jenkins Plugin,role=external,window=_blank]
+link:https://plugins.jenkins.io/gerrit-trigger/[Gerrit Trigger Jenkins Plugin,role=external,window=_blank]
 or a similar mechanism. However, there are still times when a change requires
 manual verification, or a reviewer needs to check how or if a change works.
 To accommodate these and other similar circumstances, Gerrit exposes each change
diff --git a/Documentation/intro-project-owner.txt b/Documentation/intro-project-owner.txt
index 8a3b10e..f13bc22 100644
--- a/Documentation/intro-project-owner.txt
+++ b/Documentation/intro-project-owner.txt
@@ -316,9 +316,9 @@
 
 A useful feature on labels is the possibility to automatically copy
 scores forward to new patch sets if it was a
-link:config-labels.html#label_copyAllScoresOnTrivialRebase[trivial
-rebase] or if link:config-labels.html#label_copyAllScoresIfNoCodeChange[
-there was no code change] (e.g. only the commit message was edited).
+link:config-labels.html#trivial_rebase[trivial rebase] or if
+link:config-labels.html#no_code_change[there was no code change] (e.g.
+only the commit message was edited).
 
 [[submit-rules]]
 == Submit Rules
@@ -374,7 +374,7 @@
 There are several solutions for integrating continuous integration
 systems. The most commonly used are:
 
-- link:https://wiki.jenkins-ci.org/display/JENKINS/Gerrit+Trigger[
+- link:https://plugins.jenkins.io/gerrit-trigger/[
   Gerrit Trigger,role=external,window=_blank] plugin for link:http://jenkins-ci.org/[Jenkins,role=external,window=_blank]
 
 - link:http://www.mediawiki.org/wiki/Continuous_integration/Zuul[
diff --git a/Documentation/intro-user.txt b/Documentation/intro-user.txt
index c3abedb..0f78e1f 100644
--- a/Documentation/intro-user.txt
+++ b/Documentation/intro-user.txt
@@ -368,6 +368,10 @@
 project-level]. The project dashboards can be seen in the web UI under
 `Projects` > `List` > <project-name> > `Dashboards`.
 
+All dashboards and search pages allow an action to be applied to multiple
+changes at once. Select the changes with the checkboxes on the left side and
+choose the action from the action bar at the top of the change section.
+
 [[submit]]
 == Submit a Change
 
@@ -667,19 +671,6 @@
   or build artifacts containing build numbers) can fetch the code
   using the commit ID.
 
-[[ignore]]
-== Ignoring Or Marking Changes As 'Reviewed'
-
-Changes can be ignored, which means they will not appear in the 'Incoming
-Reviews' dashboard and any related email notifications will be suppressed.
-This can be useful when you are added as a reviewer to a change on which
-you do not actively participate in the review, but do not want to completely
-remove yourself.
-
-Alternatively, rather than completely ignoring the change, it can be marked
-as 'Reviewed'. Marking a change as 'Reviewed' means it will not be highlighted
-in the dashboard, until a new patch set is uploaded.
-
 [[inline-edit]]
 == Inline Edit
 
diff --git a/Documentation/js_licenses.txt b/Documentation/js_licenses.txt
index 5b7e073..e2afbf5 100644
--- a/Documentation/js_licenses.txt
+++ b/Documentation/js_licenses.txt
@@ -2,6 +2,7 @@
 [[Apache2_0]]
 Apache2.0
 
+* fonts:material-icons
 * fonts:robotofonts
 
 [[Apache2_0_license]]
@@ -403,6 +404,7 @@
 * @polymer/iron-resizable-behavior
 * @polymer/iron-selector
 * @polymer/iron-validatable-behavior
+* @polymer/marked-element
 * @polymer/neon-animation
 * @polymer/paper-behaviors
 * @polymer/paper-button
@@ -1304,6 +1306,60 @@
 ----
 
 
+[[marked]]
+marked
+
+* marked
+
+[[marked_license]]
+----
+# License information
+
+## Contribution License Agreement
+
+If you contribute code to this project, you are implicitly allowing your code
+to be distributed under the MIT license. You are also implicitly verifying that
+all code is your original work. `</legalese>`
+
+## Marked
+
+Copyright (c) 2011-2018, Christopher Jeffrey (https://github.com/chjj/)
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+
+## Markdown
+
+Copyright © 2004, John Gruber 
+http://daringfireball.net/ 
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
+
+* Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
+* Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
+* Neither the name “Markdown” nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
+
+This software is provided by the copyright holders and contributors “as is” and any express or implied warranties, including, but not limited to, the implied warranties of merchantability and fitness for a particular purpose are disclaimed. In no event shall the copyright owner or contributors be liable for any direct, indirect, incidental, special, exemplary, or consequential damages (including, but not limited to, procurement of substitute goods or services; loss of use, data, or profits; or business interruption) however caused and on any theory of liability, whether in contract, strict liability, or tort (including negligence or otherwise) arising in any way out of the use of this software, even if advised of the possibility of such damage.
+
+----
+
+
 [[page]]
 page
 
@@ -1611,6 +1667,219 @@
 ----
 
 
+[[safevalues]]
+safevalues
+
+* safevalues
+
+[[safevalues_license]]
+----
+
+                                 Apache License
+                           Version 2.0, January 2004
+                        http://www.apache.org/licenses/
+
+   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+   1. Definitions.
+
+      "License" shall mean the terms and conditions for use, reproduction,
+      and distribution as defined by Sections 1 through 9 of this document.
+
+      "Licensor" shall mean the copyright owner or entity authorized by
+      the copyright owner that is granting the License.
+
+      "Legal Entity" shall mean the union of the acting entity and all
+      other entities that control, are controlled by, or are under common
+      control with that entity. For the purposes of this definition,
+      "control" means (i) the power, direct or indirect, to cause the
+      direction or management of such entity, whether by contract or
+      otherwise, or (ii) ownership of fifty percent (50%) or more of the
+      outstanding shares, or (iii) beneficial ownership of such entity.
+
+      "You" (or "Your") shall mean an individual or Legal Entity
+      exercising permissions granted by this License.
+
+      "Source" form shall mean the preferred form for making modifications,
+      including but not limited to software source code, documentation
+      source, and configuration files.
+
+      "Object" form shall mean any form resulting from mechanical
+      transformation or translation of a Source form, including but
+      not limited to compiled object code, generated documentation,
+      and conversions to other media types.
+
+      "Work" shall mean the work of authorship, whether in Source or
+      Object form, made available under the License, as indicated by a
+      copyright notice that is included in or attached to the work
+      (an example is provided in the Appendix below).
+
+      "Derivative Works" shall mean any work, whether in Source or Object
+      form, that is based on (or derived from) the Work and for which the
+      editorial revisions, annotations, elaborations, or other modifications
+      represent, as a whole, an original work of authorship. For the purposes
+      of this License, Derivative Works shall not include works that remain
+      separable from, or merely link (or bind by name) to the interfaces of,
+      the Work and Derivative Works thereof.
+
+      "Contribution" shall mean any work of authorship, including
+      the original version of the Work and any modifications or additions
+      to that Work or Derivative Works thereof, that is intentionally
+      submitted to Licensor for inclusion in the Work by the copyright owner
+      or by an individual or Legal Entity authorized to submit on behalf of
+      the copyright owner. For the purposes of this definition, "submitted"
+      means any form of electronic, verbal, or written communication sent
+      to the Licensor or its representatives, including but not limited to
+      communication on electronic mailing lists, source code control systems,
+      and issue tracking systems that are managed by, or on behalf of, the
+      Licensor for the purpose of discussing and improving the Work, but
+      excluding communication that is conspicuously marked or otherwise
+      designated in writing by the copyright owner as "Not a Contribution."
+
+      "Contributor" shall mean Licensor and any individual or Legal Entity
+      on behalf of whom a Contribution has been received by Licensor and
+      subsequently incorporated within the Work.
+
+   2. Grant of Copyright License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      copyright license to reproduce, prepare Derivative Works of,
+      publicly display, publicly perform, sublicense, and distribute the
+      Work and such Derivative Works in Source or Object form.
+
+   3. Grant of Patent License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      (except as stated in this section) patent license to make, have made,
+      use, offer to sell, sell, import, and otherwise transfer the Work,
+      where such license applies only to those patent claims licensable
+      by such Contributor that are necessarily infringed by their
+      Contribution(s) alone or by combination of their Contribution(s)
+      with the Work to which such Contribution(s) was submitted. If You
+      institute patent litigation against any entity (including a
+      cross-claim or counterclaim in a lawsuit) alleging that the Work
+      or a Contribution incorporated within the Work constitutes direct
+      or contributory patent infringement, then any patent licenses
+      granted to You under this License for that Work shall terminate
+      as of the date such litigation is filed.
+
+   4. Redistribution. You may reproduce and distribute copies of the
+      Work or Derivative Works thereof in any medium, with or without
+      modifications, and in Source or Object form, provided that You
+      meet the following conditions:
+
+      (a) You must give any other recipients of the Work or
+          Derivative Works a copy of this License; and
+
+      (b) You must cause any modified files to carry prominent notices
+          stating that You changed the files; and
+
+      (c) You must retain, in the Source form of any Derivative Works
+          that You distribute, all copyright, patent, trademark, and
+          attribution notices from the Source form of the Work,
+          excluding those notices that do not pertain to any part of
+          the Derivative Works; and
+
+      (d) If the Work includes a "NOTICE" text file as part of its
+          distribution, then any Derivative Works that You distribute must
+          include a readable copy of the attribution notices contained
+          within such NOTICE file, excluding those notices that do not
+          pertain to any part of the Derivative Works, in at least one
+          of the following places: within a NOTICE text file distributed
+          as part of the Derivative Works; within the Source form or
+          documentation, if provided along with the Derivative Works; or,
+          within a display generated by the Derivative Works, if and
+          wherever such third-party notices normally appear. The contents
+          of the NOTICE file are for informational purposes only and
+          do not modify the License. You may add Your own attribution
+          notices within Derivative Works that You distribute, alongside
+          or as an addendum to the NOTICE text from the Work, provided
+          that such additional attribution notices cannot be construed
+          as modifying the License.
+
+      You may add Your own copyright statement to Your modifications and
+      may provide additional or different license terms and conditions
+      for use, reproduction, or distribution of Your modifications, or
+      for any such Derivative Works as a whole, provided Your use,
+      reproduction, and distribution of the Work otherwise complies with
+      the conditions stated in this License.
+
+   5. Submission of Contributions. Unless You explicitly state otherwise,
+      any Contribution intentionally submitted for inclusion in the Work
+      by You to the Licensor shall be under the terms and conditions of
+      this License, without any additional terms or conditions.
+      Notwithstanding the above, nothing herein shall supersede or modify
+      the terms of any separate license agreement you may have executed
+      with Licensor regarding such Contributions.
+
+   6. Trademarks. This License does not grant permission to use the trade
+      names, trademarks, service marks, or product names of the Licensor,
+      except as required for reasonable and customary use in describing the
+      origin of the Work and reproducing the content of the NOTICE file.
+
+   7. Disclaimer of Warranty. Unless required by applicable law or
+      agreed to in writing, Licensor provides the Work (and each
+      Contributor provides its Contributions) on an "AS IS" BASIS,
+      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+      implied, including, without limitation, any warranties or conditions
+      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+      PARTICULAR PURPOSE. You are solely responsible for determining the
+      appropriateness of using or redistributing the Work and assume any
+      risks associated with Your exercise of permissions under this License.
+
+   8. Limitation of Liability. In no event and under no legal theory,
+      whether in tort (including negligence), contract, or otherwise,
+      unless required by applicable law (such as deliberate and grossly
+      negligent acts) or agreed to in writing, shall any Contributor be
+      liable to You for damages, including any direct, indirect, special,
+      incidental, or consequential damages of any character arising as a
+      result of this License or out of the use or inability to use the
+      Work (including but not limited to damages for loss of goodwill,
+      work stoppage, computer failure or malfunction, or any and all
+      other commercial damages or losses), even if such Contributor
+      has been advised of the possibility of such damages.
+
+   9. Accepting Warranty or Additional Liability. While redistributing
+      the Work or Derivative Works thereof, You may choose to offer,
+      and charge a fee for, acceptance of support, warranty, indemnity,
+      or other liability obligations and/or rights consistent with this
+      License. However, in accepting such obligations, You may act only
+      on Your own behalf and on Your sole responsibility, not on behalf
+      of any other Contributor, and only if You agree to indemnify,
+      defend, and hold each Contributor harmless for any liability
+      incurred by, or claims asserted against, such Contributor by reason
+      of your accepting any such warranty or additional liability.
+
+   END OF TERMS AND CONDITIONS
+
+   APPENDIX: How to apply the Apache License to your work.
+
+      To apply the Apache License to your work, attach the following
+      boilerplate notice, with the fields enclosed by brackets "[]"
+      replaced with your own identifying information. (Don't include
+      the brackets!)  The text should be enclosed in the appropriate
+      comment syntax for the file format. We also recommend that a
+      file or class name and description of purpose be included on the
+      same "printed page" as the copyright notice for easier
+      identification within third-party archives.
+
+   Copyright [yyyy] [name of copyright owner]
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
+
+----
+
+
 [[tslib]]
 tslib
 
@@ -1633,3 +1902,216 @@
 
 ----
 
+
+[[web-vitals]]
+web-vitals
+
+* web-vitals
+
+[[web-vitals_license]]
+----
+
+                                 Apache License
+                           Version 2.0, January 2004
+                        http://www.apache.org/licenses/
+
+   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+   1. Definitions.
+
+      "License" shall mean the terms and conditions for use, reproduction,
+      and distribution as defined by Sections 1 through 9 of this document.
+
+      "Licensor" shall mean the copyright owner or entity authorized by
+      the copyright owner that is granting the License.
+
+      "Legal Entity" shall mean the union of the acting entity and all
+      other entities that control, are controlled by, or are under common
+      control with that entity. For the purposes of this definition,
+      "control" means (i) the power, direct or indirect, to cause the
+      direction or management of such entity, whether by contract or
+      otherwise, or (ii) ownership of fifty percent (50%) or more of the
+      outstanding shares, or (iii) beneficial ownership of such entity.
+
+      "You" (or "Your") shall mean an individual or Legal Entity
+      exercising permissions granted by this License.
+
+      "Source" form shall mean the preferred form for making modifications,
+      including but not limited to software source code, documentation
+      source, and configuration files.
+
+      "Object" form shall mean any form resulting from mechanical
+      transformation or translation of a Source form, including but
+      not limited to compiled object code, generated documentation,
+      and conversions to other media types.
+
+      "Work" shall mean the work of authorship, whether in Source or
+      Object form, made available under the License, as indicated by a
+      copyright notice that is included in or attached to the work
+      (an example is provided in the Appendix below).
+
+      "Derivative Works" shall mean any work, whether in Source or Object
+      form, that is based on (or derived from) the Work and for which the
+      editorial revisions, annotations, elaborations, or other modifications
+      represent, as a whole, an original work of authorship. For the purposes
+      of this License, Derivative Works shall not include works that remain
+      separable from, or merely link (or bind by name) to the interfaces of,
+      the Work and Derivative Works thereof.
+
+      "Contribution" shall mean any work of authorship, including
+      the original version of the Work and any modifications or additions
+      to that Work or Derivative Works thereof, that is intentionally
+      submitted to Licensor for inclusion in the Work by the copyright owner
+      or by an individual or Legal Entity authorized to submit on behalf of
+      the copyright owner. For the purposes of this definition, "submitted"
+      means any form of electronic, verbal, or written communication sent
+      to the Licensor or its representatives, including but not limited to
+      communication on electronic mailing lists, source code control systems,
+      and issue tracking systems that are managed by, or on behalf of, the
+      Licensor for the purpose of discussing and improving the Work, but
+      excluding communication that is conspicuously marked or otherwise
+      designated in writing by the copyright owner as "Not a Contribution."
+
+      "Contributor" shall mean Licensor and any individual or Legal Entity
+      on behalf of whom a Contribution has been received by Licensor and
+      subsequently incorporated within the Work.
+
+   2. Grant of Copyright License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      copyright license to reproduce, prepare Derivative Works of,
+      publicly display, publicly perform, sublicense, and distribute the
+      Work and such Derivative Works in Source or Object form.
+
+   3. Grant of Patent License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      (except as stated in this section) patent license to make, have made,
+      use, offer to sell, sell, import, and otherwise transfer the Work,
+      where such license applies only to those patent claims licensable
+      by such Contributor that are necessarily infringed by their
+      Contribution(s) alone or by combination of their Contribution(s)
+      with the Work to which such Contribution(s) was submitted. If You
+      institute patent litigation against any entity (including a
+      cross-claim or counterclaim in a lawsuit) alleging that the Work
+      or a Contribution incorporated within the Work constitutes direct
+      or contributory patent infringement, then any patent licenses
+      granted to You under this License for that Work shall terminate
+      as of the date such litigation is filed.
+
+   4. Redistribution. You may reproduce and distribute copies of the
+      Work or Derivative Works thereof in any medium, with or without
+      modifications, and in Source or Object form, provided that You
+      meet the following conditions:
+
+      (a) You must give any other recipients of the Work or
+          Derivative Works a copy of this License; and
+
+      (b) You must cause any modified files to carry prominent notices
+          stating that You changed the files; and
+
+      (c) You must retain, in the Source form of any Derivative Works
+          that You distribute, all copyright, patent, trademark, and
+          attribution notices from the Source form of the Work,
+          excluding those notices that do not pertain to any part of
+          the Derivative Works; and
+
+      (d) If the Work includes a "NOTICE" text file as part of its
+          distribution, then any Derivative Works that You distribute must
+          include a readable copy of the attribution notices contained
+          within such NOTICE file, excluding those notices that do not
+          pertain to any part of the Derivative Works, in at least one
+          of the following places: within a NOTICE text file distributed
+          as part of the Derivative Works; within the Source form or
+          documentation, if provided along with the Derivative Works; or,
+          within a display generated by the Derivative Works, if and
+          wherever such third-party notices normally appear. The contents
+          of the NOTICE file are for informational purposes only and
+          do not modify the License. You may add Your own attribution
+          notices within Derivative Works that You distribute, alongside
+          or as an addendum to the NOTICE text from the Work, provided
+          that such additional attribution notices cannot be construed
+          as modifying the License.
+
+      You may add Your own copyright statement to Your modifications and
+      may provide additional or different license terms and conditions
+      for use, reproduction, or distribution of Your modifications, or
+      for any such Derivative Works as a whole, provided Your use,
+      reproduction, and distribution of the Work otherwise complies with
+      the conditions stated in this License.
+
+   5. Submission of Contributions. Unless You explicitly state otherwise,
+      any Contribution intentionally submitted for inclusion in the Work
+      by You to the Licensor shall be under the terms and conditions of
+      this License, without any additional terms or conditions.
+      Notwithstanding the above, nothing herein shall supersede or modify
+      the terms of any separate license agreement you may have executed
+      with Licensor regarding such Contributions.
+
+   6. Trademarks. This License does not grant permission to use the trade
+      names, trademarks, service marks, or product names of the Licensor,
+      except as required for reasonable and customary use in describing the
+      origin of the Work and reproducing the content of the NOTICE file.
+
+   7. Disclaimer of Warranty. Unless required by applicable law or
+      agreed to in writing, Licensor provides the Work (and each
+      Contributor provides its Contributions) on an "AS IS" BASIS,
+      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+      implied, including, without limitation, any warranties or conditions
+      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+      PARTICULAR PURPOSE. You are solely responsible for determining the
+      appropriateness of using or redistributing the Work and assume any
+      risks associated with Your exercise of permissions under this License.
+
+   8. Limitation of Liability. In no event and under no legal theory,
+      whether in tort (including negligence), contract, or otherwise,
+      unless required by applicable law (such as deliberate and grossly
+      negligent acts) or agreed to in writing, shall any Contributor be
+      liable to You for damages, including any direct, indirect, special,
+      incidental, or consequential damages of any character arising as a
+      result of this License or out of the use or inability to use the
+      Work (including but not limited to damages for loss of goodwill,
+      work stoppage, computer failure or malfunction, or any and all
+      other commercial damages or losses), even if such Contributor
+      has been advised of the possibility of such damages.
+
+   9. Accepting Warranty or Additional Liability. While redistributing
+      the Work or Derivative Works thereof, You may choose to offer,
+      and charge a fee for, acceptance of support, warranty, indemnity,
+      or other liability obligations and/or rights consistent with this
+      License. However, in accepting such obligations, You may act only
+      on Your own behalf and on Your sole responsibility, not on behalf
+      of any other Contributor, and only if You agree to indemnify,
+      defend, and hold each Contributor harmless for any liability
+      incurred by, or claims asserted against, such Contributor by reason
+      of your accepting any such warranty or additional liability.
+
+   END OF TERMS AND CONDITIONS
+
+   APPENDIX: How to apply the Apache License to your work.
+
+      To apply the Apache License to your work, attach the following
+      boilerplate notice, with the fields enclosed by brackets "[]"
+      replaced with your own identifying information. (Don't include
+      the brackets!)  The text should be enclosed in the appropriate
+      comment syntax for the file format. We also recommend that a
+      file or class name and description of purpose be included on the
+      same "printed page" as the copyright notice for easier
+      identification within third-party archives.
+
+   Copyright 2020 Google LLC
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       https://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
+
+----
+
diff --git a/Documentation/licenses.txt b/Documentation/licenses.txt
index e5966de..8ccbcab 100644
--- a/Documentation/licenses.txt
+++ b/Documentation/licenses.txt
@@ -42,6 +42,7 @@
 [[Apache2_0]]
 Apache2.0
 
+* auto:auto-factory
 * auto:auto-value
 * auto:auto-value-annotations
 * auto:auto-value-gson
@@ -56,6 +57,7 @@
 * dropwizard:dropwizard-core
 * errorprone:annotations
 * flogger:api
+* fonts:material-icons
 * fonts:robotofonts
 * guice:guice
 * guice:guice-assistedinject
@@ -3306,6 +3308,7 @@
 * @polymer/iron-resizable-behavior
 * @polymer/iron-selector
 * @polymer/iron-validatable-behavior
+* @polymer/marked-element
 * @polymer/neon-animation
 * @polymer/paper-behaviors
 * @polymer/paper-button
@@ -4207,6 +4210,60 @@
 ----
 
 
+[[marked]]
+marked
+
+* marked
+
+[[marked_license]]
+----
+# License information
+
+## Contribution License Agreement
+
+If you contribute code to this project, you are implicitly allowing your code
+to be distributed under the MIT license. You are also implicitly verifying that
+all code is your original work. `</legalese>`
+
+## Marked
+
+Copyright (c) 2011-2018, Christopher Jeffrey (https://github.com/chjj/)
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+
+## Markdown
+
+Copyright © 2004, John Gruber 
+http://daringfireball.net/ 
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
+
+* Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
+* Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
+* Neither the name “Markdown” nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
+
+This software is provided by the copyright holders and contributors “as is” and any express or implied warranties, including, but not limited to, the implied warranties of merchantability and fitness for a particular purpose are disclaimed. In no event shall the copyright owner or contributors be liable for any direct, indirect, incidental, special, exemplary, or consequential damages (including, but not limited to, procurement of substitute goods or services; loss of use, data, or profits; or business interruption) however caused and on any theory of liability, whether in contract, strict liability, or tort (including negligence or otherwise) arising in any way out of the use of this software, even if advised of the possibility of such damage.
+
+----
+
+
 [[page]]
 page
 
@@ -4514,6 +4571,219 @@
 ----
 
 
+[[safevalues]]
+safevalues
+
+* safevalues
+
+[[safevalues_license]]
+----
+
+                                 Apache License
+                           Version 2.0, January 2004
+                        http://www.apache.org/licenses/
+
+   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+   1. Definitions.
+
+      "License" shall mean the terms and conditions for use, reproduction,
+      and distribution as defined by Sections 1 through 9 of this document.
+
+      "Licensor" shall mean the copyright owner or entity authorized by
+      the copyright owner that is granting the License.
+
+      "Legal Entity" shall mean the union of the acting entity and all
+      other entities that control, are controlled by, or are under common
+      control with that entity. For the purposes of this definition,
+      "control" means (i) the power, direct or indirect, to cause the
+      direction or management of such entity, whether by contract or
+      otherwise, or (ii) ownership of fifty percent (50%) or more of the
+      outstanding shares, or (iii) beneficial ownership of such entity.
+
+      "You" (or "Your") shall mean an individual or Legal Entity
+      exercising permissions granted by this License.
+
+      "Source" form shall mean the preferred form for making modifications,
+      including but not limited to software source code, documentation
+      source, and configuration files.
+
+      "Object" form shall mean any form resulting from mechanical
+      transformation or translation of a Source form, including but
+      not limited to compiled object code, generated documentation,
+      and conversions to other media types.
+
+      "Work" shall mean the work of authorship, whether in Source or
+      Object form, made available under the License, as indicated by a
+      copyright notice that is included in or attached to the work
+      (an example is provided in the Appendix below).
+
+      "Derivative Works" shall mean any work, whether in Source or Object
+      form, that is based on (or derived from) the Work and for which the
+      editorial revisions, annotations, elaborations, or other modifications
+      represent, as a whole, an original work of authorship. For the purposes
+      of this License, Derivative Works shall not include works that remain
+      separable from, or merely link (or bind by name) to the interfaces of,
+      the Work and Derivative Works thereof.
+
+      "Contribution" shall mean any work of authorship, including
+      the original version of the Work and any modifications or additions
+      to that Work or Derivative Works thereof, that is intentionally
+      submitted to Licensor for inclusion in the Work by the copyright owner
+      or by an individual or Legal Entity authorized to submit on behalf of
+      the copyright owner. For the purposes of this definition, "submitted"
+      means any form of electronic, verbal, or written communication sent
+      to the Licensor or its representatives, including but not limited to
+      communication on electronic mailing lists, source code control systems,
+      and issue tracking systems that are managed by, or on behalf of, the
+      Licensor for the purpose of discussing and improving the Work, but
+      excluding communication that is conspicuously marked or otherwise
+      designated in writing by the copyright owner as "Not a Contribution."
+
+      "Contributor" shall mean Licensor and any individual or Legal Entity
+      on behalf of whom a Contribution has been received by Licensor and
+      subsequently incorporated within the Work.
+
+   2. Grant of Copyright License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      copyright license to reproduce, prepare Derivative Works of,
+      publicly display, publicly perform, sublicense, and distribute the
+      Work and such Derivative Works in Source or Object form.
+
+   3. Grant of Patent License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      (except as stated in this section) patent license to make, have made,
+      use, offer to sell, sell, import, and otherwise transfer the Work,
+      where such license applies only to those patent claims licensable
+      by such Contributor that are necessarily infringed by their
+      Contribution(s) alone or by combination of their Contribution(s)
+      with the Work to which such Contribution(s) was submitted. If You
+      institute patent litigation against any entity (including a
+      cross-claim or counterclaim in a lawsuit) alleging that the Work
+      or a Contribution incorporated within the Work constitutes direct
+      or contributory patent infringement, then any patent licenses
+      granted to You under this License for that Work shall terminate
+      as of the date such litigation is filed.
+
+   4. Redistribution. You may reproduce and distribute copies of the
+      Work or Derivative Works thereof in any medium, with or without
+      modifications, and in Source or Object form, provided that You
+      meet the following conditions:
+
+      (a) You must give any other recipients of the Work or
+          Derivative Works a copy of this License; and
+
+      (b) You must cause any modified files to carry prominent notices
+          stating that You changed the files; and
+
+      (c) You must retain, in the Source form of any Derivative Works
+          that You distribute, all copyright, patent, trademark, and
+          attribution notices from the Source form of the Work,
+          excluding those notices that do not pertain to any part of
+          the Derivative Works; and
+
+      (d) If the Work includes a "NOTICE" text file as part of its
+          distribution, then any Derivative Works that You distribute must
+          include a readable copy of the attribution notices contained
+          within such NOTICE file, excluding those notices that do not
+          pertain to any part of the Derivative Works, in at least one
+          of the following places: within a NOTICE text file distributed
+          as part of the Derivative Works; within the Source form or
+          documentation, if provided along with the Derivative Works; or,
+          within a display generated by the Derivative Works, if and
+          wherever such third-party notices normally appear. The contents
+          of the NOTICE file are for informational purposes only and
+          do not modify the License. You may add Your own attribution
+          notices within Derivative Works that You distribute, alongside
+          or as an addendum to the NOTICE text from the Work, provided
+          that such additional attribution notices cannot be construed
+          as modifying the License.
+
+      You may add Your own copyright statement to Your modifications and
+      may provide additional or different license terms and conditions
+      for use, reproduction, or distribution of Your modifications, or
+      for any such Derivative Works as a whole, provided Your use,
+      reproduction, and distribution of the Work otherwise complies with
+      the conditions stated in this License.
+
+   5. Submission of Contributions. Unless You explicitly state otherwise,
+      any Contribution intentionally submitted for inclusion in the Work
+      by You to the Licensor shall be under the terms and conditions of
+      this License, without any additional terms or conditions.
+      Notwithstanding the above, nothing herein shall supersede or modify
+      the terms of any separate license agreement you may have executed
+      with Licensor regarding such Contributions.
+
+   6. Trademarks. This License does not grant permission to use the trade
+      names, trademarks, service marks, or product names of the Licensor,
+      except as required for reasonable and customary use in describing the
+      origin of the Work and reproducing the content of the NOTICE file.
+
+   7. Disclaimer of Warranty. Unless required by applicable law or
+      agreed to in writing, Licensor provides the Work (and each
+      Contributor provides its Contributions) on an "AS IS" BASIS,
+      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+      implied, including, without limitation, any warranties or conditions
+      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+      PARTICULAR PURPOSE. You are solely responsible for determining the
+      appropriateness of using or redistributing the Work and assume any
+      risks associated with Your exercise of permissions under this License.
+
+   8. Limitation of Liability. In no event and under no legal theory,
+      whether in tort (including negligence), contract, or otherwise,
+      unless required by applicable law (such as deliberate and grossly
+      negligent acts) or agreed to in writing, shall any Contributor be
+      liable to You for damages, including any direct, indirect, special,
+      incidental, or consequential damages of any character arising as a
+      result of this License or out of the use or inability to use the
+      Work (including but not limited to damages for loss of goodwill,
+      work stoppage, computer failure or malfunction, or any and all
+      other commercial damages or losses), even if such Contributor
+      has been advised of the possibility of such damages.
+
+   9. Accepting Warranty or Additional Liability. While redistributing
+      the Work or Derivative Works thereof, You may choose to offer,
+      and charge a fee for, acceptance of support, warranty, indemnity,
+      or other liability obligations and/or rights consistent with this
+      License. However, in accepting such obligations, You may act only
+      on Your own behalf and on Your sole responsibility, not on behalf
+      of any other Contributor, and only if You agree to indemnify,
+      defend, and hold each Contributor harmless for any liability
+      incurred by, or claims asserted against, such Contributor by reason
+      of your accepting any such warranty or additional liability.
+
+   END OF TERMS AND CONDITIONS
+
+   APPENDIX: How to apply the Apache License to your work.
+
+      To apply the Apache License to your work, attach the following
+      boilerplate notice, with the fields enclosed by brackets "[]"
+      replaced with your own identifying information. (Don't include
+      the brackets!)  The text should be enclosed in the appropriate
+      comment syntax for the file format. We also recommend that a
+      file or class name and description of purpose be included on the
+      same "printed page" as the copyright notice for easier
+      identification within third-party archives.
+
+   Copyright [yyyy] [name of copyright owner]
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
+
+----
+
+
 [[tslib]]
 tslib
 
@@ -4537,6 +4807,219 @@
 ----
 
 
+[[web-vitals]]
+web-vitals
+
+* web-vitals
+
+[[web-vitals_license]]
+----
+
+                                 Apache License
+                           Version 2.0, January 2004
+                        http://www.apache.org/licenses/
+
+   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+   1. Definitions.
+
+      "License" shall mean the terms and conditions for use, reproduction,
+      and distribution as defined by Sections 1 through 9 of this document.
+
+      "Licensor" shall mean the copyright owner or entity authorized by
+      the copyright owner that is granting the License.
+
+      "Legal Entity" shall mean the union of the acting entity and all
+      other entities that control, are controlled by, or are under common
+      control with that entity. For the purposes of this definition,
+      "control" means (i) the power, direct or indirect, to cause the
+      direction or management of such entity, whether by contract or
+      otherwise, or (ii) ownership of fifty percent (50%) or more of the
+      outstanding shares, or (iii) beneficial ownership of such entity.
+
+      "You" (or "Your") shall mean an individual or Legal Entity
+      exercising permissions granted by this License.
+
+      "Source" form shall mean the preferred form for making modifications,
+      including but not limited to software source code, documentation
+      source, and configuration files.
+
+      "Object" form shall mean any form resulting from mechanical
+      transformation or translation of a Source form, including but
+      not limited to compiled object code, generated documentation,
+      and conversions to other media types.
+
+      "Work" shall mean the work of authorship, whether in Source or
+      Object form, made available under the License, as indicated by a
+      copyright notice that is included in or attached to the work
+      (an example is provided in the Appendix below).
+
+      "Derivative Works" shall mean any work, whether in Source or Object
+      form, that is based on (or derived from) the Work and for which the
+      editorial revisions, annotations, elaborations, or other modifications
+      represent, as a whole, an original work of authorship. For the purposes
+      of this License, Derivative Works shall not include works that remain
+      separable from, or merely link (or bind by name) to the interfaces of,
+      the Work and Derivative Works thereof.
+
+      "Contribution" shall mean any work of authorship, including
+      the original version of the Work and any modifications or additions
+      to that Work or Derivative Works thereof, that is intentionally
+      submitted to Licensor for inclusion in the Work by the copyright owner
+      or by an individual or Legal Entity authorized to submit on behalf of
+      the copyright owner. For the purposes of this definition, "submitted"
+      means any form of electronic, verbal, or written communication sent
+      to the Licensor or its representatives, including but not limited to
+      communication on electronic mailing lists, source code control systems,
+      and issue tracking systems that are managed by, or on behalf of, the
+      Licensor for the purpose of discussing and improving the Work, but
+      excluding communication that is conspicuously marked or otherwise
+      designated in writing by the copyright owner as "Not a Contribution."
+
+      "Contributor" shall mean Licensor and any individual or Legal Entity
+      on behalf of whom a Contribution has been received by Licensor and
+      subsequently incorporated within the Work.
+
+   2. Grant of Copyright License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      copyright license to reproduce, prepare Derivative Works of,
+      publicly display, publicly perform, sublicense, and distribute the
+      Work and such Derivative Works in Source or Object form.
+
+   3. Grant of Patent License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      (except as stated in this section) patent license to make, have made,
+      use, offer to sell, sell, import, and otherwise transfer the Work,
+      where such license applies only to those patent claims licensable
+      by such Contributor that are necessarily infringed by their
+      Contribution(s) alone or by combination of their Contribution(s)
+      with the Work to which such Contribution(s) was submitted. If You
+      institute patent litigation against any entity (including a
+      cross-claim or counterclaim in a lawsuit) alleging that the Work
+      or a Contribution incorporated within the Work constitutes direct
+      or contributory patent infringement, then any patent licenses
+      granted to You under this License for that Work shall terminate
+      as of the date such litigation is filed.
+
+   4. Redistribution. You may reproduce and distribute copies of the
+      Work or Derivative Works thereof in any medium, with or without
+      modifications, and in Source or Object form, provided that You
+      meet the following conditions:
+
+      (a) You must give any other recipients of the Work or
+          Derivative Works a copy of this License; and
+
+      (b) You must cause any modified files to carry prominent notices
+          stating that You changed the files; and
+
+      (c) You must retain, in the Source form of any Derivative Works
+          that You distribute, all copyright, patent, trademark, and
+          attribution notices from the Source form of the Work,
+          excluding those notices that do not pertain to any part of
+          the Derivative Works; and
+
+      (d) If the Work includes a "NOTICE" text file as part of its
+          distribution, then any Derivative Works that You distribute must
+          include a readable copy of the attribution notices contained
+          within such NOTICE file, excluding those notices that do not
+          pertain to any part of the Derivative Works, in at least one
+          of the following places: within a NOTICE text file distributed
+          as part of the Derivative Works; within the Source form or
+          documentation, if provided along with the Derivative Works; or,
+          within a display generated by the Derivative Works, if and
+          wherever such third-party notices normally appear. The contents
+          of the NOTICE file are for informational purposes only and
+          do not modify the License. You may add Your own attribution
+          notices within Derivative Works that You distribute, alongside
+          or as an addendum to the NOTICE text from the Work, provided
+          that such additional attribution notices cannot be construed
+          as modifying the License.
+
+      You may add Your own copyright statement to Your modifications and
+      may provide additional or different license terms and conditions
+      for use, reproduction, or distribution of Your modifications, or
+      for any such Derivative Works as a whole, provided Your use,
+      reproduction, and distribution of the Work otherwise complies with
+      the conditions stated in this License.
+
+   5. Submission of Contributions. Unless You explicitly state otherwise,
+      any Contribution intentionally submitted for inclusion in the Work
+      by You to the Licensor shall be under the terms and conditions of
+      this License, without any additional terms or conditions.
+      Notwithstanding the above, nothing herein shall supersede or modify
+      the terms of any separate license agreement you may have executed
+      with Licensor regarding such Contributions.
+
+   6. Trademarks. This License does not grant permission to use the trade
+      names, trademarks, service marks, or product names of the Licensor,
+      except as required for reasonable and customary use in describing the
+      origin of the Work and reproducing the content of the NOTICE file.
+
+   7. Disclaimer of Warranty. Unless required by applicable law or
+      agreed to in writing, Licensor provides the Work (and each
+      Contributor provides its Contributions) on an "AS IS" BASIS,
+      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+      implied, including, without limitation, any warranties or conditions
+      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+      PARTICULAR PURPOSE. You are solely responsible for determining the
+      appropriateness of using or redistributing the Work and assume any
+      risks associated with Your exercise of permissions under this License.
+
+   8. Limitation of Liability. In no event and under no legal theory,
+      whether in tort (including negligence), contract, or otherwise,
+      unless required by applicable law (such as deliberate and grossly
+      negligent acts) or agreed to in writing, shall any Contributor be
+      liable to You for damages, including any direct, indirect, special,
+      incidental, or consequential damages of any character arising as a
+      result of this License or out of the use or inability to use the
+      Work (including but not limited to damages for loss of goodwill,
+      work stoppage, computer failure or malfunction, or any and all
+      other commercial damages or losses), even if such Contributor
+      has been advised of the possibility of such damages.
+
+   9. Accepting Warranty or Additional Liability. While redistributing
+      the Work or Derivative Works thereof, You may choose to offer,
+      and charge a fee for, acceptance of support, warranty, indemnity,
+      or other liability obligations and/or rights consistent with this
+      License. However, in accepting such obligations, You may act only
+      on Your own behalf and on Your sole responsibility, not on behalf
+      of any other Contributor, and only if You agree to indemnify,
+      defend, and hold each Contributor harmless for any liability
+      incurred by, or claims asserted against, such Contributor by reason
+      of your accepting any such warranty or additional liability.
+
+   END OF TERMS AND CONDITIONS
+
+   APPENDIX: How to apply the Apache License to your work.
+
+      To apply the Apache License to your work, attach the following
+      boilerplate notice, with the fields enclosed by brackets "[]"
+      replaced with your own identifying information. (Don't include
+      the brackets!)  The text should be enclosed in the appropriate
+      comment syntax for the file format. We also recommend that a
+      file or class name and description of purpose be included on the
+      same "printed page" as the copyright notice for easier
+      identification within third-party archives.
+
+   Copyright 2020 Google LLC
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       https://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
+
+----
+
+
 GERRIT
 ------
 Part of link:index.html[Gerrit Code Review]
diff --git a/Documentation/linux-quickstart.txt b/Documentation/linux-quickstart.txt
index c45de05..13873ed 100644
--- a/Documentation/linux-quickstart.txt
+++ b/Documentation/linux-quickstart.txt
@@ -29,10 +29,10 @@
 . Download the desired Gerrit archive.
 
 To view previous archives, see
-link:https://gerrit-releases.storage.googleapis.com/index.html[Gerrit Code Review: Releases,role=external,window=_blank]. The steps below install Gerrit 3.1.3:
+link:https://gerrit-releases.storage.googleapis.com/index.html[Gerrit Code Review: Releases,role=external,window=_blank]. The steps below install Gerrit 3.5.1:
 
 ....
-wget https://gerrit-releases.storage.googleapis.com/gerrit-3.1.3.war
+wget https://gerrit-releases.storage.googleapis.com/gerrit-3.5.1.war
 ....
 
 NOTE: To build and install Gerrit from the source files, see
diff --git a/Documentation/metrics.txt b/Documentation/metrics.txt
index 2da0412..175488c 100644
--- a/Documentation/metrics.txt
+++ b/Documentation/metrics.txt
@@ -457,6 +457,15 @@
 === Group
 
 * `group/guess_relevant_groups_latency`: Latency for guessing relevant groups.
+* `group/handles_count`: Number of calls to GroupBackend.handles.
+* `group/get_count`: Number of calls to GroupBackend.get.
+* `group/suggest_count`: Number of calls to GroupBackend.suggest.
+* `group/contains_count`: Number of calls to GroupMemberships.contains.
+* `group/contains_any_of_count`: Number of calls to
+  GroupMemberships.containsAnyOf.
+* `group/intersection_count`: Number of calls to GroupMemberships.intersection.
+* `group/known_groups_count`: Number of calls to GroupMemberships.getKnownGroups.
+
 
 === Replication Plugin
 
diff --git a/Documentation/pg-plugin-dev.txt b/Documentation/pg-plugin-dev.txt
index 381c3e1..61944b6 100644
--- a/Documentation/pg-plugin-dev.txt
+++ b/Documentation/pg-plugin-dev.txt
@@ -128,16 +128,18 @@
 Just add code like this to your JavaScript plugin:
 
 ``` js
-    const styleEl = document.createElement('style');
-    styleEl.innerHTML = `
-        html {
-          --header-background-color: #c3d9ff;
-        }
-        html.darkTheme {
-          --header-background-color: #c3d9ff90;
-        }
-    `;
-    document.head.appendChild(styleEl);
+Gerrit.install(plugin => {
+  const styleEl = document.createElement('style');
+  styleEl.innerHTML = `
+      html {
+        --header-background-color: #c3d9ff;
+      }
+      html.darkTheme {
+        --header-background-color: #c3d9ff90;
+      }
+  `;
+  document.head.appendChild(styleEl);
+});
 ```
 
 [[high-level-api-concepts]]
diff --git a/Documentation/pg-plugin-endpoints.txt b/Documentation/pg-plugin-endpoints.txt
index f2a72f1..41f544d 100644
--- a/Documentation/pg-plugin-endpoints.txt
+++ b/Documentation/pg-plugin-endpoints.txt
@@ -141,6 +141,11 @@
 === header-title
 This endpoint wraps the title-text in the application header.
 
+=== cherrypick-main
+This endpoint is located in the cherrypick dialog. It has two slots `top`
+and `bottom` and `changes` as a parameter with the list of changes (or
+just the one change) to be cherrypicked.
+
 === confirm-revert-change
 This endpoint is inside the confirm revert dialog. By default it displays a
 generic confirmation message regarding reverting the change. Plugins may add
diff --git a/Documentation/pgm-daemon.txt b/Documentation/pgm-daemon.txt
index 586f685..560fb92 100644
--- a/Documentation/pgm-daemon.txt
+++ b/Documentation/pgm-daemon.txt
@@ -10,6 +10,10 @@
   -d <SITE_PATH>
   [--enable-httpd | --disable-httpd]
   [--enable-sshd | --disable-sshd]
+  [--debug]
+  [--debug-port]
+  [--debug_address]
+  [--suspend]
   [--console-log]
   [--replica]
   [--headless]
@@ -39,6 +43,17 @@
 	Enable (or disable) the internal SSH daemon, answering SSH
 	clients and remotely executed commands.  Enabled by default.
 
+--debug::
+	Start JVM in debug mode.
+
+--debug-port::
+--debug_address:
+	Specify which JVM debug port/address to use. The default debug address is 8000.
+
+--suspend::
+	Start JVM debug in suspended mode. The JVM will await for a debugger
+	to attach before proceeding with the start.
+
 --replica::
 	Run in replica mode, permitting only read operations
     by clients.  Commands which modify state such as
diff --git a/Documentation/rest-api-accounts.txt b/Documentation/rest-api-accounts.txt
index ae0c0a6..7e06e4a 100644
--- a/Documentation/rest-api-accounts.txt
+++ b/Documentation/rest-api-accounts.txt
@@ -1284,7 +1284,7 @@
   )]}'
   {
     "changes_per_page": 25,
-    "theme": "LIGHT",
+    "theme": "AUTO",
     "date_format": "STD",
     "time_format": "HHMM_12",
     "diff_view": "SIDE_BY_SIDE",
@@ -1292,6 +1292,7 @@
     "mute_common_path_prefixes": true,
     "publish_comments_on_push": true,
     "work_in_progress_by_default": true,
+    "allow_browser_notifications": true,
     "default_base_for_merges": "FIRST_PARENT",
     "my": [
       {
@@ -1344,6 +1345,7 @@
     "size_bar_in_change_table": true,
     "disable_keyboard_shortcuts": true,
     "disable_token_highlighting": true,
+    "allow_browser_notifications": false,
     "diff_view": "SIDE_BY_SIDE",
     "mute_common_path_prefixes": true,
     "my": [
@@ -2658,7 +2660,7 @@
 Allowed values are `10`, `25`, `50`, `100`.
 |`theme`                        ||
 Which theme to use.
-Allowed values are `DARK` or `LIGHT`.
+Allowed values are `AUTO` or `DARK` or `LIGHT`.
 |`expand_inline_diffs`          |not set if `false`|
 Whether to expand diffs inline instead of opening as separate page
 (Gerrit web app UI only).
@@ -2686,6 +2688,8 @@
 |`signed_off_by`                |not set if `false`|
 Whether to insert Signed-off-by footer in changes created with the
 inline edit feature.
+|`allow_browser_notifications`  |not set if `false`|
+Whether to prompt user to enable browser notification in browser.
 |`my`                           ||
 The menu items of the `MY` top menu as a list of
 link:rest-api-config.html#top-menu-item-info[TopMenuItemInfo] entities.
@@ -2729,7 +2733,7 @@
 Allowed values are `10`, `25`, `50`, `100`.
 |`theme`                        |optional|
 Which theme to use.
-Allowed values are `DARK` or `LIGHT`.
+Allowed values are `AUTO` or `DARK` or `LIGHT`.
 |`expand_inline_diffs`          |not set if `false`|
 Whether to expand diffs inline instead of opening as separate page
 (Gerrit web app UI only).
@@ -2755,6 +2759,8 @@
 |`signed_off_by`                |optional|
 Whether to insert Signed-off-by footer in changes created with the
 inline edit feature.
+|`allow_browser_notifications`  |not set if `false`|
+Whether to prompt user to enable browser notification in browser.
 |`my`                           |optional|
 The menu items of the `MY` top menu as a list of
 link:rest-api-config.html#top-menu-item-info[TopMenuItemInfo] entities.
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index f270ac5..ba9c884 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -2579,35 +2579,6 @@
   }
 ----
 
-[[ignore]]
-=== Ignore
---
-'PUT /changes/link:#change-id[\{change-id\}]/ignore'
---
-
-Marks a change as ignored. The change will not be shown in the incoming
-reviews dashboard, and email notifications will be suppressed. Ignoring
-a change does not cause the change's "updated" timestamp to be modified,
-and the owner is not notified.
-
-.Request
-----
-  PUT /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/ignore HTTP/1.0
-----
-
-[[unignore]]
-=== Unignore
---
-'PUT /changes/link:#change-id[\{change-id\}]/unignore'
---
-
-Un-marks a change as ignored.
-
-.Request
-----
-  PUT /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/unignore HTTP/1.0
-----
-
 [[get-hashtags]]
 === Get Hashtags
 --
@@ -2825,8 +2796,18 @@
 --
 
 Tests a submit requirement and returns the result as a
-link:#submit-requirement-result-info[SubmitRequirementResultInfo]. The request
-body must contain a link:#submit-requirement-input[SubmitRequirementInput].
+link:#submit-requirement-result-info[SubmitRequirementResultInfo].
+
+The submit requirement can be specified in one of the following ways:
+
+  * In the request body as a link:#submit-requirement-input[SubmitRequirementInput].
+  * By passing the two request parameters `sr-name` and
+ `refs-config-change-id`. The submit requirement will then be loaded from
+ the project config pointed to by the latest patchset of this change ID.
+ The `sr-name` should point to an existing submit-requirement and the
+ `refs-config-change-id` must be a valid change identifier for a change in the
+ refs/meta/config branch, otherwise the request would fail with
+ `400 Bad Request`.
 
 Note that this endpoint does not modify the change resource.
 
@@ -4047,6 +4028,16 @@
 Retrieves related changes of a revision.  Related changes are changes that either
 depend on, or are dependencies of the revision.
 
+Additional fields can be obtained by adding `o` parameters. Since these may slow
+down processing they are disabled by default. Currently a single parameter is
+supported:
+
+[[get-related-changes-submittable]]
+--
+* `SUBMITTABLE`: Compute the `submittable` field in the returned
+  link:#related-change-and-commit-info[RelatedChangeAndCommitInfo] entities.
+--
+
 .Request
 ----
   GET /changes/gerrit~master~I5e4fc08ce34d33c090c9e0bf320de1b17309f774/revisions/b1cb4caa6be46d12b94c25aa68aebabcbb3f53fe/related HTTP/1.0
@@ -5306,8 +5297,8 @@
   }
 ----
 
-[[apply-fix]]
-=== Apply Fix
+[[apply-stored-fix]]
+=== Apply Stored Fix
 --
 'POST /changes/<<change-id,\{change-id\}>>/revisions/<<revision-id,\{revision-id\}>>/fixes/<<fix-id,\{fix-id\}>>/apply'
 --
@@ -5374,6 +5365,101 @@
   The existing change edit could not be merged with another tree.
 ----
 
+[[apply-provided-fix]]
+==== Apply Provided Fix
+--
+'POST /changes/<<change-id,\{change-id\}>>/revisions/<<revision-id,\{revision-id\}>>/fix:apply'
+--
+Applies a list of <<fix-replacement-info,FixReplacementInfo>> loaded from the
+<<apply-provided-fix-input,ApplyProvidedFixInput>> entity. The fixes are passed as part of the request body. The
+application of the fixes creates a new change edit. `Apply Provided Fix` can only be applied on the current
+patchset.
+
+.Request
+----
+  POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/revisions/674ac754f91e64a0efb8087e59a176484bd534d1/fix:apply HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "fix_replacement_infos": [
+      {
+        "path": "abcd.txt",
+        "range": {
+          "start_line": 2,
+          "start_character": 2,
+          "end_line": 2,
+          "end_character": 5
+        },
+        "replacement": "abcdefg"
+      }
+    ]
+  }
+----
+
+If the `ApplyProvidedFixInput` was successfully applied, an link:#edit-info[EditInfo] describing the
+resulting change edit is returned.
+
+.Response
+----
+  HTTP/1.1 200 OK
+    Content-Disposition: attachment
+    Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "commit": {
+      "commit": "bd43e48c33d2b1a03485040eba38cefc505f7997",
+      "parents": [
+        {
+          "commit": "9825962f8ab6da89afebad3f5034db05fb4b7560"
+        }
+      ],
+      "author": {
+          "name": "John Doe",
+          "email": "john.doe@example.com",
+          "date": "2022-05-07 15:21:27.000000000",
+          "tz": 120
+      },
+      "committer": {
+           "name": "Jane Doe",
+           "email": "jane.doe@example.com",
+           "date": "2022-05-07 15:35:43.000000000",
+           "tz": 120
+      },
+      "subject": "Implement feature X",
+      "message": "Implement feature X\n\nWith this feature ..."
+    },
+    "base_patch_set_number": 1,
+    "base_revision": "86d87686ce0ef7f7c536bfc7e9a66f5a6fa5d658",
+    "ref": "refs/users/01/1000001/edit-1/1"
+  }
+----
+
+If the application failed due to presence of an existing change edit,
+the response "`400 Bad Request`" including an error message in the response body
+is returned.
+
+.Response
+----
+  HTTP/1.1 400 Bad Request
+  Content-Disposition: attachment
+  Content-Type: text/plain; charset=UTF-8
+
+  Change edit already exists. A new change edit can't be created
+----
+
+If the application failed due to application on a previous patch set, the response
+"`409 Conflict`" including an error message in the response body is returned.
+
+.Response
+----
+  HTTP/1.1 409 Conflict
+  Content-Disposition: attachment
+  Content-Type: text/plain; charset=UTF-8
+
+  A change edit may only be created for the current patch set 1,2 (and not for 1,1)
+----
+
 [[list-files]]
 === List Files
 --
@@ -5730,8 +5816,8 @@
 differences are reported in the result.  Valid values are `IGNORE_NONE`,
 `IGNORE_TRAILING`, `IGNORE_LEADING_AND_TRAILING` or `IGNORE_ALL`.
 
-[[preview-fix]]
-=== Preview fix
+[[preview-stored-fix]]
+=== Preview Stored Fix
 --
 'GET /changes/<<change-id,\{change-id\}>>/revisions/<<revision-id,\{revision-id\}>>/fixes/<<fix-id,\{fix-id\}>>/preview'
 --
@@ -5741,6 +5827,102 @@
 
 Each link:#diff-info[DiffInfo] is the differences between the patch set indicated by revision-id and a virtual patch set with the applied fix.
 
+[[preview-provided-fix]]
+=== Preview Provided fix
+--
+'POST /changes/<<change-id,\{change-id\}>>/revisions/<<revision-id,\{revision-id\}>>/fix:preview'
+--
+
+Gets the diffs of all files for a list of <<fix-replacement-info,FixReplacementInfo>> loaded from
+the <<apply-provided-fix-input,ApplyProvidedFixInput>> entity. The fixes are passed as part of the
+request body.
+As response, a map of link:#diff-info[DiffInfo] is returned that describes the diffs.
+
+Each link:#diff-info[DiffInfo] is the differences between the patch set indicated by revision-id
+and a virtual patch set with the applied fix. No content on the server will be modified as part of this request.
+
+.Request
+----
+  POST /changes/myProject~master~Id6f0b9d946791f8aba90ace53074eda565983452/revisions/1/fix:preview HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "fix_replacement_infos": [
+      {
+        "path": "abcd.txt",
+        "range": {
+          "start_line": 2,
+          "start_character": 2,
+          "end_line": 2,
+          "end_character": 5
+        },
+        "replacement": "abcdefg"
+      }
+    ]
+  }
+----
+
+If the `Preview Provided Fix` operation was successful, a link:#diff-info[DiffInfo] previewing the
+change is returned.
+
+.Response
+----
+  HTTP/1.1 200 OK
+    Content-Disposition: attachment
+    Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "abcd.txt": {
+      "meta_a": {
+        "name": "abcd.txt",
+        "content_type": "text/plain",
+        "lines": 3
+      },
+      "meta_b": {
+        "name": "abcd.txt",
+        "content_type": "text/plain",
+        "lines": 3
+      },
+      "intraline_status": "OK",
+      "change_type": "MODIFIED",
+      "content": [
+        {
+          "ab": [
+            "ABCDEFGHI"
+          ]
+        },
+        {
+          "a": [
+            "JKLMNOPQR"
+          ],
+          "b": [
+            "JKabcdefgOPQR"
+          ],
+          "edit_a": [
+            [
+              2,
+              3
+            ]
+          ],
+          "edit_b": [
+            [
+              2,
+              7
+            ]
+          ]
+        },
+        {
+          "ab": [
+            ""
+          ]
+        }
+      ]
+    }
+  }
+----
+
+
 [[get-blame]]
 === Get Blame
 --
@@ -6650,7 +6832,8 @@
 by one of the following REST endpoints: link:#create-change[Create
 Change], link:#create-merge-patch-set-for-change[Create Merge Patch Set
 For Change], link:#cherry-pick[Cherry Pick Revision],
-link:rest-api-project.html#cherry-pick-commit[Cherry Pick Commit]
+link:rest-api-project.html#cherry-pick-commit[Cherry Pick Commit],
+link:#rebase-change[Rebase Change]
 |==================================
 
 [[change-input]]
@@ -7080,6 +7263,9 @@
 |`ignore_automatic_attention_set_rules`|optional|
 If set to true, ignore all automatic attention set rules described in the
 link:#attention-set[attention set]. When not set, the default is false.
+|`reason`         |optional|
+The reason why this vote is deleted. Will +
+go into the change message.
 |=============================
 
 [[description-input]]
@@ -7207,6 +7393,16 @@
 Whether the web link should be shown on the unified diff screen.
 |=======================
 
+[[apply-provided-fix-input]]
+=== ApplyProvidedFixInput
+The `ApplyProvidedFixInput` entity contains the fixes to be applied on a review.
+
+[options="header",cols="1,6"]
+|=======================
+|Field Name              |Description
+|`fix_replacement_infos` |A list of <<fix-replacement-info,FixReplacementInfo>>.
+|=======================
+
 [[edit-file-info]]
 === EditFileInfo
 The `EditFileInfo` entity contains additional information
@@ -7276,12 +7472,12 @@
 Number of inserted lines. +
 Not set for binary files or if no lines were inserted. +
 An empty last line is not included in the count and hence this number can
-differ by one from details provided in <<#diff-info,DiffInfo>>.
+differ by one from details provided in <<diff-info,DiffInfo>>.
 |`lines_deleted` |optional|
 Number of deleted lines. +
 Not set for binary files or if no lines were deleted. +
 An empty last line is not included in the count and hence this number can
-differ by one from details provided in <<#diff-info,DiffInfo>>.
+differ by one from details provided in <<diff-info,DiffInfo>>.
 |`size_delta`    ||
 Number of bytes by which the file size increased/decreased.
 |`size`          ||
@@ -7704,6 +7900,9 @@
 |`_current_revision_number`|optional|The current revision number.
 |`status`                  |optional|The status of the change. The status of
 the change is one of (`NEW`, `MERGED`, `ABANDONED`).
+|`submittable`             |optional|Boolean indicating whether the change is
+submittable. +
+Only populated if link:#get-related-changes-submittable[requested].
 |===========================
 
 [[related-changes-info]]
@@ -7754,30 +7953,38 @@
 The `RevertInput` entity contains information for reverting a change.
 
 [options="header",cols="1,^1,5"]
-|=============================
-|Field Name      ||Description
-|`message`       |optional|
+|=================================
+|Field Name          ||Description
+|`message`           |optional|
 Message to be added as review comment to the change when reverting the
 change.
-|`notify`        |optional|
+|`notify`            |optional|
 Notify handling that defines to whom email notifications should be sent
 for reverting the change. +
 Allowed values are `NONE`, `OWNER`, `OWNER_REVIEWERS` and `ALL`. +
 If not set, the default is `ALL`.
-|`notify_details`|optional|
+|`notify_details`    |optional|
 Additional information about whom to notify about the revert as a map
 of link:user-notify.html#recipient-types[recipient type] to
 link:#notify-info[NotifyInfo] entity.
-|`topic`         |optional|
+|`topic`             |optional|
 Name of the topic for the revert change. If not set, the default for Revert
 endpoint is the topic of the change being reverted, and the default for the
 RevertSubmission endpoint is `revert-{submission_id}-{timestamp.now}`.
 Topic can't contain quotation marks.
-|`work_in_progress` |optional|
+|`work_in_progress`  |optional|
 When present, change is marked as Work In Progress. The `notify` input is
 used if it's present, otherwise it will be overridden to `OWNER`. +
 If not set, the default is false.
-|=============================
+|`validation_options`|optional|
+Map with key-value pairs that are forwarded as options to the commit validation
+listeners (e.g. can be used to skip certain validations). Which validation
+options are supported depends on the installed commit validation listeners.
+Gerrit core doesn't support any validation options, but commit validation
+listeners that are implemented in plugins may. Please refer to the
+documentation of the installed plugins to learn whether they support validation
+options. Unknown validation options are silently ignored.
+|=================================
 
 [[revert-submission-info]]
 === RevertSubmissionInfo
@@ -7923,6 +8130,8 @@
 |`ready`                  |optional|
 If true, the change was moved from WIP to ready for review as a result of this
 action. Not set if false.
+|`error`                  |optional|
+Error message for non-200 responses.
 |============================
 
 [[reviewer-info]]
@@ -8251,6 +8460,13 @@
 `branch:refs/heads/foo and label:verified=+1`.
 |`fulfilled`||
 True if the submit requirement is fulfilled for the change.
+|`status`||
+A string containing the status of evaluating the expression which can be one
+of the following: +
+  * `PASS` - expression was evaluated and result is true. +
+  * `FAIL` - expression was evaluated and result is false. +
+  * `ERROR` - an error occurred while evaluating the expression. +
+  * `NOT_EVALUATED` - expression was not evaluated.
 |`passing_atoms`|optional|
 A list of passing atoms as strings. For the above expression,
 `passing_atoms` can contain ["branch:refs/heads/foo"] if the branch predicate is
@@ -8315,16 +8531,17 @@
 submit requirement did not define an applicability expression.
 Note that fields `expression`, `passing_atoms` and `failing_atoms` are always
 omitted for the `applicability_expression_result`.
-|`submittability_expression_result`|optional|
+|`submittability_expression_result`||
 A link:#submit-requirement-expression-info[SubmitRequirementExpressionInfo]
 containing the result of evaluating the submittability expression. +
-If the submit requirement does not apply, the expression is not evaluated and
-the field is not set.
+If the submit requirement does not apply, the `status` field of the result
+will be set to `NOT_EVALUATED`.
 |`override_expression_result`|optional|
 A link:#submit-requirement-expression-info[SubmitRequirementExpressionInfo]
 containing the result of evaluating the override expression. +
-Not set if the submit requirement did not define an override expression or
-if it does not apply.
+Not set if the submit requirement did not define an override expression. If the
+submit requirement does not apply, the `status` field of the result will be set
+to `NOT_EVALUATED`.
 |===========================
 
 [[submitted-together-info]]
diff --git a/Documentation/rest-api-config.txt b/Documentation/rest-api-config.txt
index 90e1884..45de1b1 100644
--- a/Documentation/rest-api-config.txt
+++ b/Documentation/rest-api-config.txt
@@ -1380,7 +1380,14 @@
   POST /config/server/index.changes HTTP/1.0
   Content-Type: application/json; charset=UTF-8
 
-  {changes: ["foo~101", "bar~202"]}
+  {
+    "changes": [
+      "foo~101",
+      "bar~202",
+      "303"
+    ],
+    "delete_missing": "true"
+  }
 ----
 
 .Response
@@ -1389,6 +1396,9 @@
   Content-Disposition: attachment
 ----
 
+When `delete_missing` is set to `true` changes to be reindexed which are missing in NoteDb
+will be deleted in the index.
+
 
 [[ids]]
 == IDs
@@ -1878,6 +1888,10 @@
 |Field Name         ||Description
 |`changes`   ||
 List of link:rest-api-changes.html#change-id[change-ids]
+|`delete_missing`  |optional|
+Delete changes which are missing in NoteDb from the index. This can be used
+to get rid of stale index entries. Possible values are `true` and `false`.
+By default set to `false`.
 |================================
 
 [[jvm-summary-info]]
diff --git a/Documentation/rest-api-projects.txt b/Documentation/rest-api-projects.txt
index 413923f..efc746a 100644
--- a/Documentation/rest-api-projects.txt
+++ b/Documentation/rest-api-projects.txt
@@ -402,12 +402,12 @@
   Content-Type: application/json; charset=UTF-8
 
   )]}'
-  {
-    "test": {
+  [
+    {
       "id": "test",
       "description": "\u003chtml\u003e is escaped"
     }
-  }
+  ]
 ----
 
 [[project-query-limit]]
@@ -3079,9 +3079,7 @@
       },
       "default_value": 0,
       "can_override": true,
-      "copy_min_score": true,
-      "copy_all_scores_if_no_change": true,
-      "copy_all_scores_on_trivial_rebase": true,
+      "copy_condition": "changekind:NO_CHANGE OR changekind:TRIVIAL_REBASE or is:MIN",
       "allow_post_submit": true
     }
   ]
@@ -3126,9 +3124,7 @@
       },
       "default_value": 0,
       "can_override": true,
-      "copy_min_score": true,
-      "copy_all_scores_if_no_change": true,
-      "copy_all_scores_on_trivial_rebase": true,
+      "copy_condition": "changekind:NO_CHANGE OR changekind:TRIVIAL_REBASE or is:MIN",
       "allow_post_submit": true
     },
     {
@@ -3144,7 +3140,7 @@
       },
       "default_value": 0,
       "can_override": true,
-      "copy_any_score": true,
+      "copy_condition": "is:ANY",
       "allow_post_submit": true
     }
   ]
@@ -3189,9 +3185,7 @@
     },
     "default_value": 0,
     "can_override": true,
-    "copy_min_score": true,
-    "copy_all_scores_if_no_change": true,
-    "copy_all_scores_on_trivial_rebase": true,
+    "copy_condition": "changekind:NO_CHANGE OR changekind:TRIVIAL_REBASE OR is:MIN",
     "allow_post_submit": true
   }
 ----
@@ -3250,7 +3244,7 @@
     },
     "default_value": 0,
     "can_override": true,
-    "copy_all_scores_if_no_change": true,
+    "copy_condition": "changekind:NO_CHANGE",
     "allow_post_submit": true
   }
 ----
@@ -3302,9 +3296,7 @@
     },
     "default_value": 0,
     "can_override": true,
-    "copy_min_score": true,
-    "copy_all_scores_if_no_change": true,
-    "copy_all_scores_on_trivial_rebase": true,
+    "copy_condition": "changekind:NO_CHANGE OR changekind:TRIVIAL_REBASE OR is:MIN",
     "allow_post_submit": true,
     "ignore_self_approval": true
   }
@@ -3388,7 +3380,7 @@
         "function": "MaxWithBlock"
       },
       "Baz-Review": {
-        "copy_min_score": true
+        "copy_condition": "is:MIN"
       }
     }
   }
@@ -3401,6 +3393,198 @@
   HTTP/1.1 200 OK
 ----
 
+[[submit-requirement-endpoints]]
+== Submit Requirement Endpoints
+
+[[create-submit-requirement]]
+=== Create Submit Requirement
+
+--
+'PUT /projects/link:#project-name[\{project-name\}]/submit_requirements/link:#submit-requirement-name[\{submit-requirement-name\}]'
+--
+
+Creates a new submit requirement definition in this project.
+
+The calling user must have write access to the `refs/meta/config` branch of the
+project.
+
+If a submit requirement with this name is already defined in this project, this
+submit requirement definition is updated
+(see link:#update-submit-requirement[Update Submit Requirement]).
+
+The submit requirement data must be provided in the request body as
+link:#submit-requirement-input[SubmitRequirementInput].
+
+.Request
+----
+  PUT /projects/My-Project/submit_requirements/Code-Review HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "name": "Code-Review",
+    "description": "At least one maximum vote for the Code-Review label is required",
+    "submittability_expression": "label:Code-Review=+2"
+  }
+----
+
+As response a link:#submit-requirement-info[SubmitRequirementInfo] entity is
+returned that describes the created submit requirement.
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+      "name": "Code-Review",
+      "description": "At least one maximum vote for the Code-Review label is required",
+      "submittability_expression": "label:Code-Review=+2",
+      "allow_override_in_child_projects": false
+   }
+----
+
+[[update-submit-requirement]]
+=== Update Submit Requirement
+
+--
+'PUT /projects/link:#project-name[\{project-name\}]/submit_requirements/link:#submit-requirement-name[\{submit-requirement-name\}]'
+--
+
+Updates the definition of a submit requirement that is defined in this project.
+
+The calling user must have write access to the `refs/meta/config` branch of the
+project.
+
+The new submit requirement will overwrite the existing submit requirement.
+That is, unspecified attributes will be set to their defaults.
+
+.Request
+----
+  PUT /projects/My-Project/submit_requirements/Code-Review HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "name": "Code-Review",
+    "description": "At least one maximum vote for the Code-Review label is required",
+    "submittability_expression": "label:Code-Review=+2"
+  }
+----
+
+As response a link:#submit-requirement-info[SubmitRequirementInfo] entity is
+returned that describes the created submit requirement.
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+      "name": "Code-Review",
+      "description": "At least one maximum vote for the Code-Review label is required",
+      "submittability_expression": "label:Code-Review=+2",
+      "allow_override_in_child_projects": false
+   }
+----
+
+[[get-submit-requirement]]
+=== Get Submit Requirement
+--
+'GET /projects/link:#project-name[\{project-name\}]/submit_requirements/link:#submit-requirement-name[\{submit-requirement-name\}]'
+--
+
+Retrieves the definition of a submit requirement that is defined in this project.
+
+The calling user must have read access to the `refs/meta/config` branch of the
+project.
+
+.Request
+----
+  GET /projects/All-Projects/submit-requirement/Code-Review HTTP/1.0
+----
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+      "name": "Code-Review",
+      "description": "At least one maximum vote for the Code-Review label is required",
+      "submittability_expression": "label:Code-Review=+2",
+      "allow_override_in_child_projects": false
+   }
+----
+
+[[list-submit-requirements]]
+=== List Submit Requirements
+--
+'GET /projects/link:#project-name[\{project-name\}]/submit_requirements'
+--
+
+Retrieves a list of all submit requirements for this project. The `inherited`
+parameter can be supplied to also list submit requirements from parent projects.
+
+The calling user must have read access to the `refs/meta/config` branch of the
+project. If the `inherited` option is used, the caller must have read access to
+the `refs/meta/config` branch of all parent projects as well.
+
+As response a list of link:#submit-requirement-info[SubmitRequirementInfo]
+entities is returned.
+
+.Request
+----
+  GET /projects/All-Projects/submit-requirements HTTP/1.0
+----
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  [
+    {
+      "name": "Code-Review",
+      "description": "At least one maximum vote for the Code-Review label is required",
+      "submittability_expression": "label:Code-Review=+2",
+      "allow_override_in_child_projects": false
+    }
+  ]
+----
+
+[[delete-submit-requirement]]
+=== Delete Submit Requirement
+--
+'DELETE /projects/link:#project-name[\{project-name\}]/submit_requirements/link:#submit-requirement-name[\{submit-requirement-name\}]'
+--
+
+Deletes the definition of a submit requirement that is defined in this project.
+
+The calling user must have write access to the `refs/meta/config` branch of the
+project. The request would fail with `404 Not Found` if there is no submit
+requirement with this name for this project.
+
+No request body is needed.
+
+.Request
+----
+  DELETE /projects/My-Project/submit_requirements/Foo-Review HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+----
+
+If a submit requirement was deleted the response is "`204 No Content`".
+
+.Response
+----
+  HTTP/1.1 204 No Content
+----
 
 [[ids]]
 == IDs
@@ -3429,6 +3613,11 @@
 === \{label-name\}
 The name of a review label.
 
+[[submit-requirement-name]]
+=== \{submit-requirement-name\}
+The name of a submit requirement.
+
+
 [[project-name]]
 === \{project-name\}
 The name of the project.
@@ -3609,6 +3798,9 @@
 |`link`     |        |The URL to direct the user to whenever the
 regular expression is matched, as documented in
 link:config-gerrit.html#commentlink.name.link[commentlink.name.link].
+|`prefix`   |optional|Text inserted before the link.
+|`suffix`   |optional|Text inserted after the link.
+|`text`     |optional|Text of the link.
 |`enabled`  |optional|Whether the commentlink is enabled, as documented
 in link:config-gerrit.html#commentlink.name.enabled[
 commentlink.name.enabled]. If not set the commentlink is enabled.
@@ -3628,6 +3820,9 @@
 |`link`     |        |The URL to direct the user to whenever the
 regular expression is matched, as documented in
 link:config-gerrit.html#commentlink.name.link[commentlink.name.link].
+|`prefix`   |optional|Text inserted before the link.
+|`suffix`   |optional|Text inserted after the link.
+|`text`     |optional|Text of the link.
 |`enabled`  |optional|Whether the commentlink is enabled, as documented
 in link:config-gerrit.html#commentlink.name.enabled[
 commentlink.name.enabled]. If not set the commentlink is enabled.
@@ -4015,36 +4210,8 @@
 |`can_override`  |`false` if not set|
 Whether this label can be link:config-labels.html#label_canOverride[overridden]
 by child projects.
-|`copy_any_score`|`false` if not set|
-*DEPRECATED* Whether link:config-labels.html#label_copyAnyScore[copyAnyScore]
-is set on the label.
 |`copy_condition`|optional|
 See link:config-labels.html#label_copyCondition[copyCondition].
-|`copy_min_score`|`false` if not set|
-*DEPRECATED* Whether link:config-labels.html#label_copyMinScore[copyMinScore]
-is set on the label.
-|`copy_max_score`|`false` if not set|
-*DEPRECATED* Whether link:config-labels.html#label_copyMaxScore[copyMaxScore]
-is set on the label.
-|`copy_all_scores_if_no_change`|`false` if not set|
-*DEPRECATED* Whether link:config-labels.html#label_copyAllScoresIfNoChange[
-copyAllScoresIfNoChange] is set on the label.
-|`copy_all_scores_if_no_code_change`|`false` if not set|
-*DEPRECATED* Whether link:config-labels.html#label_copyAllScoresIfNoCodeChange[
-copyAllScoresIfNoCodeChange] is set on the label.
-|`copy_all_scores_on_trivial_rebase`|`false` if not set|
-*DEPRECATED* Whether link:config-labels.html#label_copyAllScoresOnTrivialRebase[
-copyAllScoresOnTrivialRebase] is set on the label.
-|`copy_all_scores_if_list_of_files_did_not_change`|`false` if not set|
-*DEPRECATED* Whether
-link:config-labels.html#label_copyAllScoresIfListOfFilesDidNotChange[
-copyAllScoresIfListOfFilesDidNotChange] is set on the label.
-|`copy_all_scores_on_merge_first_parent_update`|`false` if not set|
-*DEPRECATED* Whether
-link:config-labels.html#label_copyAllScoresOnMergeFirstParentUpdate[
-copyAllScoresOnMergeFirstParentUpdate] is set on the label.
-|`copy_values`   |optional|
-List of values that should be copied forward when a new patch set is uploaded.
 |`allow_post_submit`|`false` if not set|
 Whether link:config-labels.html#label_allowPostSubmit[allowPostSubmit] is set
 on the label.
@@ -4091,36 +4258,10 @@
 |`can_override`  |optional|
 Whether this label can be link:config-labels.html#label_canOverride[overridden]
 by child projects.
-|`copy_any_score`|optional|
-Whether link:config-labels.html#label_copyAnyScore[copyAnyScore] is set on the
-label.
 |`copy_condition`|optional|
 See link:config-labels.html#label_copyCondition[copyCondition].
 |`unset_copy_condition`|optional|
 If true, clears the value stored in `copy_condition`.
-|`copy_min_score`|optional|
-Whether link:config-labels.html#label_copyMinScore[copyMinScore] is set on the
-label.
-|`copy_max_score`|optional|
-Whether link:config-labels.html#label_copyMaxScore[copyMaxScore] is set on the
-label.
-|`copy_all_scores_if_no_change`|optional|
-Whether link:config-labels.html#label_copyAllScoresIfNoChange[
-copyAllScoresIfNoChange] is set on the label.
-|`copy_all_scores_if_no_code_change`|optional|
-Whether link:config-labels.html#label_copyAllScoresIfNoCodeChange[
-copyAllScoresIfNoCodeChange] is set on the label.
-|`copy_all_scores_on_trivial_rebase`|optional|
-Whether link:config-labels.html#label_copyAllScoresOnTrivialRebase[
-copyAllScoresOnTrivialRebase] is set on the label.
-|`copy_all_scores_if_list_of_files_did_not_change`|optional|
-Whether link:config-labels.html#label_copyAllScoresIfListOfFilesDidNotChange[
-copyAllScoresIfListOfFilesDidNotChange] is set on the label.
-|`copy_all_scores_on_merge_first_parent_update`|optional|
-Whether link:config-labels.html#label_copyAllScoresOnMergeFirstParentUpdate[
-copyAllScoresOnMergeFirstParentUpdate] is set on the label.
-|`copy_values`   |optional|
-List of values that should be copied forward when a new patch set is uploaded.
 |`allow_post_submit`|optional|
 Whether link:config-labels.html#label_allowPostSubmit[allowPostSubmit] is set
 on the label.
@@ -4374,6 +4515,57 @@
 |`size_of_packed_objects`  |Size of packed objects in bytes.
 |======================================
 
+[[submit-requirement-info]]
+=== SubmitRequirementInfo
+The `SubmitRequirementInfo` entity describes a submit requirement.
+
+[options="header",cols="1,^1,5"]
+|===========================
+|Field Name      ||Description
+|`name`||
+The submit requirement name.
+|`description`|optional|
+Description of the submit requirement.
+|`applicability_expression`|optional|
+Query expression that can be evaluated on any change. If evaluated to true on a
+change, the submit requirement is then applicable for this change.
+If not specified, the submit requirement is applicable for all changes.
+|`submittability_expression`||
+Query expression that can be evaluated on any change. If evaluated to true on a
+change, the submit requirement is fulfilled and not blocking change submission.
+|`override_expression`|optional|
+Query expression that can be evaluated on any change. If evaluated to true on a
+change, the submit requirement is overridden and not blocking change submission.
+|`allow_override_in_child_projects`||
+Whether this submit requirement can be overridden in child projects.
+|===========================
+
+[[submit-requirement-input]]
+=== SubmitRequirementInput
+The `SubmitRequirementInput` entity describes a submit requirement.
+
+[options="header",cols="1,^1,5"]
+|===========================
+|Field Name      ||Description
+|`name`||
+The submit requirement name.
+|`description`|optional|
+Description of the submit requirement.
+|`applicability_expression`|optional|
+Query expression that can be evaluated on any change. If evaluated to true on a
+change, the submit requirement is then applicable for this change.
+If not specified, the submit requirement is applicable for all changes.
+|`submittability_expression`||
+Query expression that can be evaluated on any change. If evaluated to true on a
+change, the submit requirement is fulfilled and not blocking change submission.
+|`override_expression`|optional|
+Query expression that can be evaluated on any change. If evaluated to true on a
+change, the submit requirement is overridden and not blocking change submission.
+|`allow_override_in_child_projects`|optional|
+Whether this submit requirement can be overridden in child projects. Default is
+`false`.
+|===========================
+
 [[submit-type-info]]
 === SubmitTypeInfo
 Information about the link:config-project-config.html#submit-type[default submit
diff --git a/Documentation/user-attention-set.txt b/Documentation/user-attention-set.txt
index 512f784..0a7a77c 100644
--- a/Documentation/user-attention-set.txt
+++ b/Documentation/user-attention-set.txt
@@ -47,6 +47,9 @@
   an unresolved comment.
 * If another user removed a user's vote, the user with the deleted vote will be
   added to the attention set.
+* If a vote becomes outdated by uploading a new patch set (vote is not sticky),
+  the user whose vote has been removed is added to the attention set, as they
+  need to re-review the change and vote newly.
 * Only owner, uploader, reviewers and ccs can be in the attention set.
 * The rules for service accounts are different, see link:#bots[Bots].
 * Users are not added by automatic rules when the change is work in progress.
diff --git a/Documentation/user-notify.txt b/Documentation/user-notify.txt
index 932acab..de5ea57 100644
--- a/Documentation/user-notify.txt
+++ b/Documentation/user-notify.txt
@@ -18,8 +18,8 @@
 [[user]]
 == User Level Settings
 
-Individual users can configure email subscriptions by editing
-watched projects through Settings > Watched Projects with the web UI.
+Individual users can configure email subscriptions by editing their
+notifications in the Web UI under `Settings` > `Notifications`.
 
 Specific projects may be watched, or the special project
 `All-Projects` can be watched to watch all projects that
diff --git a/Documentation/user-search.txt b/Documentation/user-search.txt
index cc7d5d1..d09717de 100644
--- a/Documentation/user-search.txt
+++ b/Documentation/user-search.txt
@@ -8,16 +8,18 @@
 query, execute it, and present the results.
 
 [options="header"]
-|=================================================
-|Description          | Default Query
-|All > Open           | status:open '(or is:open)'
-|All > Merged         | status:merged
-|All > Abandoned      | status:abandoned
-|My > Watched Changes | is:watched is:open
-|My > Starred Changes | is:starred
-|My > Draft Comments  | has:draft
-|Open changes in Foo  | status:open project:Foo
-|=================================================
+|=======================================================
+|Description                | Default Query
+|Changes > Open             | status:open '(or is:open)'
+|Changes > Merged           | status:merged
+|Changes > Abandoned        | status:abandoned
+|Your > Watched Changes     | is:watched is:open
+|Your > Starred Changes     | is:starred
+|Your > Draft Comments      | has:draft
+|Your > Edits               | has:edit
+|Your > All Visible Changes | is:visible
+|Open changes in Foo        | status:open project:Foo
+|=======================================================
 
 
 == Basic Change Search
@@ -363,7 +365,7 @@
 files, use `file:^.*\.java`.
 +
 The entire regular expression pattern, including the `^` character,
-should be double quoted. For example, to match all XML
+can be double quoted. For example, to match all XML
 files named like 'name1.xml', 'name2.xml', and 'name3.xml' use
 `file:"^name[1-3].xml"`.
 +
@@ -371,8 +373,8 @@
 +
 *More examples:*
 
-* `-file:^path/.*` - changes that do not modify files from `path/`.
-* `file:{^~(path/.*)}` - changes that modify files not from `path/` (but may
+* `-path:^path/.*` - changes that do not modify files from `path/`.
+* `path:{^~(path/.*)}` - changes that modify files not from `path/` (but may
 contain files from `path/`).
 
 [[file]]
@@ -433,19 +435,6 @@
 Matches any change that has a commit message with a footer where the footer
 name is equal to 'FOOTERNAME'.The matching is done case-sensitive.
 
-[[star]]
-star:'LABEL'::
-+
-Matches any change that was starred by the current user with the label
-'LABEL'.
-+
-E.g. if changes that are not interesting are marked with an `ignore`
-star, they could be filtered out by '-star:ignore'.
-+
-'star:star' is the same as 'has:star' and 'is:starred'.
-
-Only "ignore" and "star" are supported labels.
-
 [[has]]
 has:draft::
 +
@@ -454,8 +443,8 @@
 [[has-star]]
 has:star::
 +
-Same as 'is:starred' and 'star:star', true if the change has been
-starred by the current user with the default label.
+Same as 'is:starred', true if the change has been starred by the current user
+with the default label.
 
 has:edit::
 +
@@ -558,11 +547,6 @@
 link:config-gerrit.html#change.mergeabilityComputationBehavior[change.mergeabilityComputationBehavior]
 for details.
 
-[[ignored]]
-is:ignored::
-+
-True if the change is ignored. Same as `star:ignore`.
-
 [[private]]
 is:private::
 +
@@ -712,17 +696,20 @@
 `-is:starred` is the exact opposite of `is:starred` and will
 therefore return changes that are *not* starred by the current user.
 
-The operator `NOT` (in all caps) is a synonym.
+The operator `NOT` (in all caps) or `not` (all lower case) is a
+synonym.
 
 === AND
-The boolean operator `AND` (in all caps) can be used to join two
-other operators together.  This results in a restriction of the
-results, returning only changes that match both operators.
+The boolean operator `AND` (in all caps) or `and` (all lower case)
+can be used to join two other operators together.  This results in
+a restriction of the results, returning only changes that match both
+operators.
 
 === OR
-The boolean operator `OR` (in all caps) can be used to find changes
-that match either operator.  This increases the number of results
-that are returned, as more changes are considered.
+The boolean operator `OR` (in all caps) or `or` (all lower case)
+can be used to find changes that match either operator. This
+increases the number of results that are returned, as more changes
+are considered.
 
 
 [[labels]]
diff --git a/Documentation/user-upload.txt b/Documentation/user-upload.txt
index 2bfc62d..8c51207 100644
--- a/Documentation/user-upload.txt
+++ b/Documentation/user-upload.txt
@@ -340,7 +340,7 @@
 To avoid confusion in parsing the git ref, at least the following characters
 must be percent-encoded: " %^@.~-+_:/!". Note that some of the reserved
 characters (like tilde) are not escaped in the standard URL encoding rules,
-so a language-provided function (e.g. encodeURIComponent(), in javascript)
+so a language-provided function (e.g. encodeURIComponent(), in JavaScript)
 might not suffice. To be safest, you might consider percent-encoding all
 non-alphanumeric characters (and all multibyte UTF-8 code points).
 
diff --git a/README.md b/README.md
index 8a4379b..4df9271 100644
--- a/README.md
+++ b/README.md
@@ -60,7 +60,7 @@
 
 On Debian/Ubuntu run:
 
-        apt-get update & apt-get install gerrit=<version>-<release>
+        apt-get update && apt-get install gerrit=<version>-<release>
 
 _NOTE: release is a counter that starts with 1 and indicates the number of packages that have
 been released with the same version of the software._
diff --git a/WORKSPACE b/WORKSPACE
index fe6b94e..ca5942b 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -1,29 +1,27 @@
-# npm packages are split into different node_modules directories based on their usage.
-# 1. /node_modules (referenced as @npm) - contains packages to run tests, check code, etc...
-#    It is expected that @npm is used ONLY to run tools. No packages from @npm are used by
-#    other code in gerrit.
-# 2. @tools_npm (tools/node_tools/node_modules) - the tools/node_tools folder contains self-written tools
-#    which are run for building and/or testing. The @tools_npm directory contains all the packages needed to
-#    run this tools.
-# 3. @ui_npm (polygerrit-ui/app/node_modules) - packages with source code which are necessary to run polygerrit
-#    and to bundle it. Only code from these packages can be included in the final bundle for polygerrit.
-#    @ui_npm folder must not have devDependencies. All dev dependencies must be placed in @ui_dev_npm
-# 4. @ui_dev_npm (polygerrit-ui/node_modules) - devDependencies for polygerrit. The packages from these
-#    folder can be used for testing, but must not be included in the final bundle.
-# 5. @plugins_npm (plugins/node_modules) - plugin dependencies for polygerrit plugins.
-#    The packages here are expected to be used in plugins.
-# Note: separation between @ui_npm and @ui_dev_npm is necessary because with bazel we can't generate
-#    two managed directories from the same package.json. At the same time we want to avoid accidental
-#    usages of code from devDependencies in polygerrit bundle.
+# npm packages are split into different node_modules directories based on their
+# usage.
+# 1. @npm (node_modules) - contains packages to run tests, check code, etc...
+#    It is expected that @npm is used ONLY to run tools. No packages from @npm
+#    are used by other code in gerrit.
+# 2. @tools_npm (tools/node_tools/node_modules) - the tools/node_tools folder
+#    contains self-written tools which are run for building and/or testing. The
+#    @tools_npm directory contains all the packages needed to run this tools.
+# 3. @ui_npm (polygerrit-ui/app/node_modules) - packages with source code which
+#    are necessary to run polygerrit and to bundle it. Only code from these
+#    packages can be included in the final bundle for polygerrit. @ui_npm folder
+#    must not have devDependencies. All devDependencies must be placed in
+#    @ui_dev_npm.
+# 4. @ui_dev_npm (polygerrit-ui/node_modules) - devDependencies for polygerrit.
+#    The packages from these folder can be used for testing, but must not be
+#    included in the final bundle.
+# 5. @plugins_npm (plugins/node_modules) - plugin dependencies for polygerrit
+#    plugins. The packages here are expected to be used in plugins.
+# Note: separation between @ui_npm and @ui_dev_npm is necessary because with
+#    rules_nodejs we can't generate two external repositories from the same
+#    package.json. At the same time we want to avoid accidental usages of code
+#    from devDependencies in polygerrit bundle.
 workspace(
     name = "gerrit",
-    managed_directories = {
-        "@npm": ["node_modules"],
-        "@ui_npm": ["polygerrit-ui/app/node_modules"],
-        "@ui_dev_npm": ["polygerrit-ui/node_modules"],
-        "@tools_npm": ["tools/node_tools/node_modules"],
-        "@plugins_npm": ["plugins/node_modules"],
-    },
 )
 
 load("@bazel_tools//tools/build_defs/repo:git.bzl", "git_repository")
@@ -42,13 +40,60 @@
     ],
 )
 
+# TODO: Remove this when java_tools v12.1 included in regular Bazel release
+# See https://github.com/bazelbuild/bazel/issues/17695
+http_archive(
+    name = "remote_java_tools",
+    sha256 = "0db35ec44745fd15b77d9df954e70a4fcf74554dd5bfe3f6e6cb6bbdc1f1c649",
+    urls = [
+        "https://mirror.bazel.build/bazel_java_tools/releases/java/v12.1/java_tools-v12.1.zip",
+        "https://github.com/bazelbuild/java_tools/releases/download/java_v12.1/java_tools-v12.1.zip",
+    ],
+)
+
+http_archive(
+    name = "remote_java_tools_linux",
+    sha256 = "093ecac3b42fcbc3621d08edc3ae3c8b0bc2bf56a0d9a85ddcdb1e0bcf10cbc7",
+    urls = [
+        "https://mirror.bazel.build/bazel_java_tools/releases/java/v12.1/java_tools_linux-v12.1.zip",
+        "https://github.com/bazelbuild/java_tools/releases/download/java_v12.1/java_tools_linux-v12.1.zip",
+    ],
+)
+
+http_archive(
+    name = "remote_java_tools_windows",
+    sha256 = "1df7cc7fac54f437f43c24c019462e13058f394fdba5a64f566b92e8af18d0cf",
+    urls = [
+        "https://mirror.bazel.build/bazel_java_tools/releases/java/v12.1/java_tools_windows-v12.1.zip",
+        "https://github.com/bazelbuild/java_tools/releases/download/java_v12.1/java_tools_windows-v12.1.zip",
+    ],
+)
+
+http_archive(
+    name = "remote_java_tools_darwin_x86_64",
+    sha256 = "16ca145203a62a1fcd6ae50513c0935d938591cb309b9b1172e257c57873f60d",
+    urls = [
+        "https://mirror.bazel.build/bazel_java_tools/releases/java/v12.1/java_tools_darwin_x86_64-v12.1.zip",
+        "https://github.com/bazelbuild/java_tools/releases/download/java_v12.1/java_tools_darwin_x86_64-v12.1.zip",
+    ],
+)
+
+http_archive(
+    name = "remote_java_tools_darwin_arm64",
+    sha256 = "1d8e575e558782c2ceec0940e424f0e2df56b0df3d7fae68333eaceef2c4e41c",
+    urls = [
+        "https://mirror.bazel.build/bazel_java_tools/releases/java/v12.1/java_tools_darwin_arm64-v12.1.zip",
+        "https://github.com/bazelbuild/java_tools/releases/download/java_v12.1/java_tools_darwin_arm64-v12.1.zip",
+    ],
+)
+
 http_archive(
     name = "rbe_jdk11",
-    sha256 = "5939e2a4e56d1fc53b6c44c6db97ee068c9f4bd18e86c762f6ab8b4fff5e294b",
-    strip_prefix = "rbe_autoconfig-3.0.0",
+    sha256 = "dbcfd6f26589ef506b91fe03a12dc559ca9c84699e4cf6381150522287f0e6f6",
+    strip_prefix = "rbe_autoconfig-3.1.0",
     urls = [
-        "https://gerrit-bazel.storage.googleapis.com/rbe_autoconfig/v3.0.0.tar.gz",
-        "https://github.com/davido/rbe_autoconfig/archive/v3.0.0.tar.gz",
+        "https://gerrit-bazel.storage.googleapis.com/rbe_autoconfig/v3.1.0.tar.gz",
+        "https://github.com/davido/rbe_autoconfig/archive/v3.1.0.tar.gz",
     ],
 )
 
@@ -67,8 +112,8 @@
 
 http_archive(
     name = "build_bazel_rules_nodejs",
-    sha256 = "c077680a307eb88f3e62b0b662c2e9c6315319385bc8c637a861ffdbed8ca247",
-    urls = ["https://github.com/bazelbuild/rules_nodejs/releases/download/5.1.0/rules_nodejs-5.1.0.tar.gz"],
+    sha256 = "0fad45a9bda7dc1990c47b002fd64f55041ea751fafc00cd34efb96107675778",
+    urls = ["https://github.com/bazelbuild/rules_nodejs/releases/download/5.5.0/rules_nodejs-5.5.0.tar.gz"],
 )
 
 load("@build_bazel_rules_nodejs//:repositories.bzl", "build_bazel_rules_nodejs_dependencies")
@@ -101,55 +146,6 @@
     firefox = True,
 )
 
-http_archive(
-    name = "rules_pkg",
-    sha256 = "038f1caa773a7e35b3663865ffb003169c6a71dc995e39bf4815792f385d837d",
-    urls = [
-        "https://mirror.bazel.build/github.com/bazelbuild/rules_pkg/releases/download/0.4.0/rules_pkg-0.4.0.tar.gz",
-        "https://github.com/bazelbuild/rules_pkg/releases/download/0.4.0/rules_pkg-0.4.0.tar.gz",
-    ],
-)
-
-load("@rules_pkg//:deps.bzl", "rules_pkg_dependencies")
-
-rules_pkg_dependencies()
-
-# Golang support for PolyGerrit local dev server.
-http_archive(
-    name = "io_bazel_rules_go",
-    sha256 = "d6b2513456fe2229811da7eb67a444be7785f5323c6708b38d851d2b51e54d83",
-    urls = [
-        "https://mirror.bazel.build/github.com/bazelbuild/rules_go/releases/download/v0.30.0/rules_go-v0.30.0.zip",
-        "https://github.com/bazelbuild/rules_go/releases/download/v0.30.0/rules_go-v0.30.0.zip",
-    ],
-)
-
-load("@io_bazel_rules_go//go:deps.bzl", "go_register_toolchains", "go_rules_dependencies")
-
-go_rules_dependencies()
-
-go_register_toolchains(version = "1.17.6")
-
-http_archive(
-    name = "bazel_gazelle",
-    sha256 = "de69a09dc70417580aabf20a28619bb3ef60d038470c7cf8442fafcf627c21cb",
-    urls = [
-        "https://mirror.bazel.build/github.com/bazelbuild/bazel-gazelle/releases/download/v0.24.0/bazel-gazelle-v0.24.0.tar.gz",
-        "https://github.com/bazelbuild/bazel-gazelle/releases/download/v0.24.0/bazel-gazelle-v0.24.0.tar.gz",
-    ],
-)
-
-load("@bazel_gazelle//:deps.bzl", "gazelle_dependencies", "go_repository")
-
-gazelle_dependencies()
-
-# Dependencies for PolyGerrit local dev server.
-go_repository(
-    name = "com_github_howeyc_fsnotify",
-    commit = "441bbc86b167f3c1f4786afae9931403b99fdacf",
-    importpath = "github.com/howeyc/fsnotify",
-)
-
 register_toolchains("//tools:error_prone_warnings_toolchain_java11_definition")
 
 register_toolchains("//tools:error_prone_warnings_toolchain_java17_definition")
@@ -187,8 +183,8 @@
 load("@build_bazel_rules_nodejs//:index.bzl", "node_repositories", "yarn_install")
 
 node_repositories(
-    node_version = "16.13.2",
-    yarn_version = "1.22.17",
+    node_version = "16.15.0",
+    yarn_version = "1.22.18",
 )
 
 yarn_install(
diff --git a/antlr3/com/google/gerrit/index/query/Query.g b/antlr3/com/google/gerrit/index/query/Query.g
index 0994d9b..ea521f9 100644
--- a/antlr3/com/google/gerrit/index/query/Query.g
+++ b/antlr3/com/google/gerrit/index/query/Query.g
@@ -142,9 +142,9 @@
   | EXACT_PHRASE
   ;
 
-AND: 'AND' ;
-OR:  'OR'  ;
-NOT: 'NOT' ;
+AND: 'AND' | 'and';
+OR:  'OR' | 'or'  ;
+NOT: 'NOT' | 'not' ;
 
 COLON: ':' ;
 
diff --git a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/GerritSimulation.scala b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/GerritSimulation.scala
index e26fc00..580ae81 100644
--- a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/GerritSimulation.scala
+++ b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/GerritSimulation.scala
@@ -108,7 +108,7 @@
     val property = packageName + "." + term
     var value = default
     default match {
-      case _: String | _: Double =>
+      case _: String | _: Double | _: Boolean =>
         val propertyValue = Option(System.getProperty(property))
         if (propertyValue.nonEmpty) {
           value = propertyValue.get
diff --git a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/GitSimulation.scala b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/GitSimulation.scala
index 5d5f5d5..5885fb0 100644
--- a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/GitSimulation.scala
+++ b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/GitSimulation.scala
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.scenarios
 
+import java.nio.charset.StandardCharsets.UTF_8
 import java.io.{File, IOException}
 import java.net.URLEncoder
 
@@ -31,7 +32,10 @@
   protected val gitProtocol: GitProtocol = GitProtocol()
 
   override def replaceOverride(in: String): String = {
-    var next = replaceKeyWith("_project", URLEncoder.encode(getFullProjectName(projectName), "UTF-8"), in)
+    var next = replaceKeyWith("_project", URLEncoder.encode(getFullProjectName(projectName), UTF_8), in)
+    val authenticated = getProperty("authenticated", false).toBoolean
+    val value = "CONTEXT_PATH" + (if (authenticated) "/a" else "")
+    next = replaceKeyWith("context_path", value, next)
     super.replaceOverride(next)
   }
 
diff --git a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/ProjectSimulation.scala b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/ProjectSimulation.scala
index 7d7bed7..3802cea 100644
--- a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/ProjectSimulation.scala
+++ b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/ProjectSimulation.scala
@@ -14,12 +14,13 @@
 
 package com.google.gerrit.scenarios
 
+import java.nio.charset.StandardCharsets.UTF_8
 import java.net.URLEncoder
 
 class ProjectSimulation extends GerritSimulation {
   projectName = "defaultTestProject"
 
   override def replaceOverride(in: String): String = {
-    replaceProperty("project", URLEncoder.encode(getFullProjectName(projectName), "UTF-8"), in)
+    replaceProperty("project", URLEncoder.encode(getFullProjectName(projectName), UTF_8), in)
   }
 }
diff --git a/java/Main.java b/java/Main.java
index 09c8c76..c04db2c 100644
--- a/java/Main.java
+++ b/java/Main.java
@@ -16,6 +16,7 @@
 public final class Main {
   private static final String FLOGGER_BACKEND_PROPERTY = "flogger.backend_factory";
   private static final String FLOGGER_LOGGING_CONTEXT = "flogger.logging_context";
+  private static final Runtime.Version MIN_JAVA_VERSION = Runtime.Version.parse("11.0.10");
 
   // We don't do any real work here because we need to import
   // the archive lookup code and we cannot import a class in
@@ -34,11 +35,11 @@
   }
 
   private static boolean onSupportedJavaVersion() {
-    final String version = System.getProperty("java.specification.version");
-    if (1.8 <= parse(version)) {
+    Runtime.Version version = Runtime.version();
+    if (version.compareTo(MIN_JAVA_VERSION) >= 0) {
       return true;
     }
-    System.err.println("fatal: Gerrit Code Review requires Java 8 or later");
+    System.err.println("fatal: Gerrit Code Review requires Java " + MIN_JAVA_VERSION + " or later");
     System.err.println("       (trying to run on Java " + version + ")");
     return false;
   }
@@ -58,22 +59,5 @@
         "com.google.common.flogger.backend.log4j.Log4jBackendFactory#getInstance");
   }
 
-  private static double parse(String version) {
-    if (version == null || version.length() == 0) {
-      return 0.0;
-    }
-
-    try {
-      final int fd = version.indexOf('.');
-      final int sd = version.indexOf('.', fd + 1);
-      if (0 < sd) {
-        version = version.substring(0, sd);
-      }
-      return Double.parseDouble(version);
-    } catch (NumberFormatException e) {
-      return 0.0;
-    }
-  }
-
   private Main() {}
 }
diff --git a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
index 903b709..1177734 100644
--- a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
@@ -45,7 +45,6 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
-import com.google.common.jimfs.Jimfs;
 import com.google.common.primitives.Chars;
 import com.google.common.testing.FakeTicker;
 import com.google.gerrit.acceptance.AcceptanceTestRequestScope.Context;
@@ -154,14 +153,7 @@
 import java.io.ByteArrayOutputStream;
 import java.io.File;
 import java.io.IOException;
-import java.io.InputStream;
-import java.io.OutputStream;
 import java.lang.reflect.Modifier;
-import java.nio.file.DirectoryStream;
-import java.nio.file.FileSystem;
-import java.nio.file.FileSystems;
-import java.nio.file.Files;
-import java.nio.file.Path;
 import java.sql.Timestamp;
 import java.time.Instant;
 import java.util.ArrayList;
@@ -175,6 +167,7 @@
 import java.util.Map;
 import java.util.Objects;
 import java.util.Optional;
+import java.util.UUID;
 import java.util.regex.Pattern;
 import java.util.stream.Collectors;
 import java.util.stream.IntStream;
@@ -183,7 +176,6 @@
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.Constants;
-import org.eclipse.jgit.lib.NullProgressMonitor;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.Ref;
@@ -193,11 +185,7 @@
 import org.eclipse.jgit.revwalk.RevTree;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.storage.file.FileBasedConfig;
-import org.eclipse.jgit.transport.FetchResult;
-import org.eclipse.jgit.transport.RefSpec;
 import org.eclipse.jgit.transport.Transport;
-import org.eclipse.jgit.transport.TransportBundleStream;
-import org.eclipse.jgit.transport.URIish;
 import org.eclipse.jgit.util.FS;
 import org.eclipse.jgit.util.SystemReader;
 import org.junit.After;
@@ -966,7 +954,7 @@
             repo,
             "new subject",
             "new file",
-            "new content");
+            "new content " + UUID.randomUUID());
     return result;
   }
 
@@ -1203,58 +1191,6 @@
     assertThat(replyTo.getString()).doesNotContain(email);
   }
 
-  /**
-   * Fetches each bundle into a newly cloned repository, then it applies the bundle, and returns the
-   * resulting tree id.
-   *
-   * <p>Omits NoteDb meta refs.
-   */
-  protected Map<BranchNameKey, ObjectId> fetchFromBundles(BinaryResult bundles) throws Exception {
-    assertThat(bundles.getContentType()).isEqualTo("application/x-zip");
-
-    FileSystem fs = Jimfs.newFileSystem();
-    Path previewPath = fs.getPath("preview.zip");
-    try (OutputStream out = Files.newOutputStream(previewPath)) {
-      bundles.writeTo(out);
-    }
-    Map<BranchNameKey, ObjectId> ret = new HashMap<>();
-    try (FileSystem zipFs = FileSystems.newFileSystem(previewPath, (ClassLoader) null);
-        DirectoryStream<Path> dirStream =
-            Files.newDirectoryStream(Iterables.getOnlyElement(zipFs.getRootDirectories()))) {
-      for (Path p : dirStream) {
-        if (!Files.isRegularFile(p)) {
-          continue;
-        }
-        String bundleName = p.getFileName().toString();
-        int len = bundleName.length();
-        assertThat(bundleName).endsWith(".git");
-        String repoName = bundleName.substring(0, len - 4);
-        Project.NameKey proj = Project.nameKey(repoName);
-        TestRepository<?> localRepo = cloneProject(proj);
-
-        try (InputStream bundleStream = Files.newInputStream(p);
-            TransportBundleStream tbs =
-                new TransportBundleStream(
-                    localRepo.getRepository(), new URIish(bundleName), bundleStream)) {
-          FetchResult fr =
-              tbs.fetch(
-                  NullProgressMonitor.INSTANCE,
-                  Arrays.asList(new RefSpec("refs/*:refs/preview/*")));
-          for (Ref r : fr.getAdvertisedRefs()) {
-            String refName = r.getName();
-            if (RefNames.isNoteDbMetaRef(refName)) {
-              continue;
-            }
-            RevCommit c = localRepo.getRevWalk().parseCommit(r.getObjectId());
-            ret.put(BranchNameKey.create(proj, refName), c.getTree().copy());
-          }
-        }
-      }
-    }
-    assertThat(ret).isNotEmpty();
-    return ret;
-  }
-
   /** Assert that the given branches have the given tree ids. */
   protected void assertTrees(Project.NameKey proj, Map<BranchNameKey, ObjectId> trees)
       throws Exception {
diff --git a/java/com/google/gerrit/acceptance/AbstractDynamicOptionsTest.java b/java/com/google/gerrit/acceptance/AbstractDynamicOptionsTest.java
index a4ed80a..4e8d20d 100644
--- a/java/com/google/gerrit/acceptance/AbstractDynamicOptionsTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractDynamicOptionsTest.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance;
 
 import static com.google.gerrit.sshd.CommandMetaData.Mode.MASTER_OR_SLAVE;
+import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.common.collect.Lists;
 import com.google.gerrit.extensions.annotations.Exports;
@@ -50,7 +51,7 @@
 
     public void display(OutputStream displayOutputStream) throws Exception {
       PrintWriter stdout =
-          new PrintWriter(new BufferedWriter(new OutputStreamWriter(displayOutputStream, "UTF-8")));
+          new PrintWriter(new BufferedWriter(new OutputStreamWriter(displayOutputStream, UTF_8)));
       try {
         OutputFormat.JSON
             .newGson()
diff --git a/java/com/google/gerrit/acceptance/ChangeIndexedCounter.java b/java/com/google/gerrit/acceptance/ChangeIndexedCounter.java
index 1ff7d0e..5991646 100644
--- a/java/com/google/gerrit/acceptance/ChangeIndexedCounter.java
+++ b/java/com/google/gerrit/acceptance/ChangeIndexedCounter.java
@@ -46,6 +46,10 @@
   }
 
   public void assertReindexOf(ChangeInfo info, long expectedCount) {
+    if (expectedCount == 0) {
+      assertThat(countsByChange.asMap()).isEmpty();
+      return;
+    }
     assertThat(countsByChange.asMap()).containsExactly(info._number, expectedCount);
     clear();
   }
diff --git a/java/com/google/gerrit/acceptance/ExtensionRegistry.java b/java/com/google/gerrit/acceptance/ExtensionRegistry.java
index 0acf3bc..3d90bf0 100644
--- a/java/com/google/gerrit/acceptance/ExtensionRegistry.java
+++ b/java/com/google/gerrit/acceptance/ExtensionRegistry.java
@@ -21,6 +21,7 @@
 import com.google.gerrit.extensions.config.PluginProjectPermissionDefinition;
 import com.google.gerrit.extensions.events.AccountActivationListener;
 import com.google.gerrit.extensions.events.AccountIndexedListener;
+import com.google.gerrit.extensions.events.AttentionSetListener;
 import com.google.gerrit.extensions.events.ChangeIndexedListener;
 import com.google.gerrit.extensions.events.CommentAddedListener;
 import com.google.gerrit.extensions.events.GitBatchRefUpdateListener;
@@ -103,6 +104,7 @@
   private final DynamicSet<OnPostReview> onPostReviews;
   private final DynamicSet<ReviewerAddedListener> reviewerAddedListeners;
   private final DynamicSet<ReviewerDeletedListener> reviewerDeletedListeners;
+  private final DynamicSet<AttentionSetListener> attentionSetListeners;
 
   private final DynamicMap<ChangeHasOperandFactory> hasOperands;
   private final DynamicMap<ChangeIsOperandFactory> isOperands;
@@ -147,7 +149,8 @@
       DynamicSet<ReviewerAddedListener> reviewerAddedListeners,
       DynamicSet<ReviewerDeletedListener> reviewerDeletedListeners,
       DynamicMap<ChangeHasOperandFactory> hasOperands,
-      DynamicMap<ChangeIsOperandFactory> isOperands) {
+      DynamicMap<ChangeIsOperandFactory> isOperands,
+      DynamicSet<AttentionSetListener> attentionSetListeners) {
     this.accountIndexedListeners = accountIndexedListeners;
     this.changeIndexedListeners = changeIndexedListeners;
     this.groupIndexedListeners = groupIndexedListeners;
@@ -187,6 +190,7 @@
     this.reviewerDeletedListeners = reviewerDeletedListeners;
     this.hasOperands = hasOperands;
     this.isOperands = isOperands;
+    this.attentionSetListeners = attentionSetListeners;
   }
 
   public Registration newRegistration() {
@@ -330,6 +334,10 @@
       return add(workInProgressStateChangedListeners, workInProgressStateChangedListener);
     }
 
+    public Registration add(AttentionSetListener attentionSetListener) {
+      return add(attentionSetListeners, attentionSetListener);
+    }
+
     public Registration add(CapabilityDefinition capabilityDefinition, String exportName) {
       return add(capabilityDefinitions, capabilityDefinition, exportName);
     }
diff --git a/java/com/google/gerrit/acceptance/PushOneCommit.java b/java/com/google/gerrit/acceptance/PushOneCommit.java
index d46fb78..99db40a 100644
--- a/java/com/google/gerrit/acceptance/PushOneCommit.java
+++ b/java/com/google/gerrit/acceptance/PushOneCommit.java
@@ -263,7 +263,11 @@
     if (changeId != null) {
       commitBuilder = testRepo.amendRef("HEAD").insertChangeId(changeId.substring(1));
     } else {
-      commitBuilder = testRepo.branch("HEAD").commit().insertChangeId(nextChangeId());
+      if (subject.contains("\nChange-Id: ")) {
+        commitBuilder = testRepo.amendRef("HEAD");
+      } else {
+        commitBuilder = testRepo.branch("HEAD").commit().insertChangeId(nextChangeId());
+      }
     }
     commitBuilder.message(subject).author(i).committer(new PersonIdent(i, testRepo.getDate()));
   }
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImpl.java b/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImpl.java
index 580f10f..c1029be 100644
--- a/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImpl.java
+++ b/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImpl.java
@@ -47,7 +47,6 @@
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
 import java.io.IOException;
-import java.sql.Timestamp;
 import java.time.Instant;
 import java.util.Arrays;
 import java.util.Objects;
@@ -140,13 +139,14 @@
         RevWalk revWalk = new RevWalk(objectInserter.newReader())) {
       Instant now = TimeUtil.now();
       IdentifiedUser changeOwner = getChangeOwner(changeCreation);
-      PersonIdent authorAndCommitter =
-          changeOwner.newCommitterIdent(now, serverIdent.getTimeZone());
+      PersonIdent authorAndCommitter = changeOwner.newCommitterIdent(now, serverIdent.getZoneId());
       ObjectId commitId =
           createCommit(repository, revWalk, objectInserter, changeCreation, authorAndCommitter);
 
       String refName = RefNames.fullName(changeCreation.branch());
       ChangeInserter inserter = getChangeInserter(changeId, refName, commitId);
+      changeCreation.topic().ifPresent(t -> inserter.setTopic(t));
+      inserter.setApprovals(changeCreation.approvals());
 
       try (BatchUpdate batchUpdate = batchUpdateFactory.create(project, changeOwner, now)) {
         batchUpdate.setRepository(repository, revWalk, objectInserter);
@@ -494,13 +494,10 @@
       return Optional.ofNullable(oldPatchsetCommit.getAuthorIdent()).orElse(serverIdent);
     }
 
-    // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-    // Instants
-    @SuppressWarnings("JdkObsolete")
     private PersonIdent getCommitter(RevCommit oldPatchsetCommit, Instant now) {
       PersonIdent oldPatchsetCommitter =
           Optional.ofNullable(oldPatchsetCommit.getCommitterIdent()).orElse(serverIdent);
-      if (asSeconds(now) == asSeconds(oldPatchsetCommitter.getWhen().toInstant())) {
+      if (asSeconds(now) == asSeconds(oldPatchsetCommitter.getWhenAsInstant())) {
         /* We need to ensure that the resulting commit SHA-1 is different from the old patchset.
          * In real situations, this automatically happens as two patchsets won't have exactly the
          * same commit timestamp even when the tree and commit message are the same. In tests,
@@ -510,7 +507,7 @@
          * here and simply add a second. */
         now = now.plusSeconds(1);
       }
-      return new PersonIdent(oldPatchsetCommitter, Timestamp.from(now));
+      return new PersonIdent(oldPatchsetCommitter, now);
     }
 
     private long asSeconds(Instant date) {
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/TestChangeCreation.java b/java/com/google/gerrit/acceptance/testsuite/change/TestChangeCreation.java
index 5871e17..a064d02 100644
--- a/java/com/google/gerrit/acceptance/testsuite/change/TestChangeCreation.java
+++ b/java/com/google/gerrit/acceptance/testsuite/change/TestChangeCreation.java
@@ -16,6 +16,7 @@
 
 import com.google.auto.value.AutoValue;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.acceptance.testsuite.ThrowingFunction;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
@@ -34,6 +35,10 @@
 
   public abstract Optional<Account.Id> owner();
 
+  public abstract Optional<String> topic();
+
+  public abstract ImmutableMap<String, Short> approvals();
+
   public abstract String commitMessage();
 
   public abstract ImmutableList<TreeModification> treeModifications();
@@ -50,7 +55,8 @@
         .branch(Constants.R_HEADS + Constants.MASTER)
         .commitMessage("A test change")
         // Which value we choose here doesn't matter. All relevant code paths set the desired value.
-        .mergeStrategy(MergeStrategy.OURS);
+        .mergeStrategy(MergeStrategy.OURS)
+        .approvals(ImmutableMap.of());
   }
 
   @AutoValue.Builder
@@ -66,6 +72,15 @@
     /** The change owner. Must be an existing user account. */
     public abstract Builder owner(Account.Id owner);
 
+    /** The topic to add this change to. */
+    public abstract Builder topic(String topic);
+
+    /**
+     * The approvals to apply to this change. Map of label name to value. All approvals will be
+     * granted by the uploader.
+     */
+    public abstract Builder approvals(ImmutableMap<String, Short> approvals);
+
     /**
      * The commit message. The message may contain a {@code Change-Id} footer but does not need to.
      * If the footer is absent, it will be generated.
diff --git a/java/com/google/gerrit/asciidoctor/DocIndexer.java b/java/com/google/gerrit/asciidoctor/DocIndexer.java
index 6cbe2a9..acd6aad 100644
--- a/java/com/google/gerrit/asciidoctor/DocIndexer.java
+++ b/java/com/google/gerrit/asciidoctor/DocIndexer.java
@@ -42,8 +42,8 @@
 import org.apache.lucene.index.IndexWriter;
 import org.apache.lucene.index.IndexWriterConfig;
 import org.apache.lucene.index.IndexWriterConfig.OpenMode;
+import org.apache.lucene.store.ByteBuffersDirectory;
 import org.apache.lucene.store.IndexInput;
-import org.apache.lucene.store.RAMDirectory;
 import org.kohsuke.args4j.Argument;
 import org.kohsuke.args4j.CmdLineException;
 import org.kohsuke.args4j.CmdLineParser;
@@ -92,9 +92,9 @@
     }
   }
 
-  private RAMDirectory index()
+  private ByteBuffersDirectory index()
       throws IOException, UnsupportedEncodingException, FileNotFoundException {
-    RAMDirectory directory = new RAMDirectory();
+    ByteBuffersDirectory directory = new ByteBuffersDirectory();
     IndexWriterConfig config = new IndexWriterConfig(new StandardAnalyzer(CharArraySet.EMPTY_SET));
     config.setOpenMode(OpenMode.CREATE);
     config.setCommitOnClose(true);
@@ -140,7 +140,7 @@
     return directory;
   }
 
-  private byte[] zip(RAMDirectory dir) throws IOException {
+  private byte[] zip(ByteBuffersDirectory dir) throws IOException {
     ByteArrayOutputStream buf = new ByteArrayOutputStream();
     try (ZipOutputStream zip = new ZipOutputStream(buf)) {
       for (String name : dir.listAll()) {
diff --git a/java/com/google/gerrit/common/UsedAt.java b/java/com/google/gerrit/common/UsedAt.java
index 3e103c8..1b87f32 100644
--- a/java/com/google/gerrit/common/UsedAt.java
+++ b/java/com/google/gerrit/common/UsedAt.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.common;
 
+import static java.lang.annotation.ElementType.CONSTRUCTOR;
 import static java.lang.annotation.ElementType.FIELD;
 import static java.lang.annotation.ElementType.METHOD;
 import static java.lang.annotation.ElementType.TYPE;
@@ -25,25 +26,26 @@
 import java.lang.annotation.Target;
 
 /**
- * A marker to say a method/type/field is added or is increased to public solely because it is
- * called from inside a project or an organisation using Gerrit.
+ * A marker to say a method/type/field/constructor is added or is increased to public solely because
+ * it is called from inside a project or an organisation using Gerrit.
  */
-@Target({METHOD, TYPE, FIELD})
+@Target({METHOD, TYPE, FIELD, CONSTRUCTOR})
 @Retention(RUNTIME)
 @Repeatable(UsedAt.Uses.class)
 public @interface UsedAt {
   /** Enumeration of projects that call a method/type/field. */
   enum Project {
-    GOOGLE,
     COLLABNET,
+    GOOGLE,
+    PLUGINS_ALL, // Use this project if a method/type is generally made available to all plugins.
     PLUGIN_CHECKS,
     PLUGIN_CODE_OWNERS,
     PLUGIN_DELETE_PROJECT,
-    PLUGIN_SERVICEUSER,
     PLUGIN_HIGH_AVAILABILITY,
     PLUGIN_MULTI_SITE,
+    PLUGIN_SERVICEUSER,
     PLUGIN_WEBSESSION_FLATFILE,
-    PLUGINS_ALL, // Use this project if a method/type is generally made available to all plugins.
+    MODULE_GIT_REFS_FILTER
   }
 
   /** Reference to the project that uses the method annotated with this annotation. */
diff --git a/java/com/google/gerrit/entities/Account.java b/java/com/google/gerrit/entities/Account.java
index 303e79f..85dbdeb 100644
--- a/java/com/google/gerrit/entities/Account.java
+++ b/java/com/google/gerrit/entities/Account.java
@@ -46,6 +46,10 @@
  */
 @AutoValue
 public abstract class Account {
+
+  /** Placeholder for indicating an account-id that does not correspond to any local account */
+  public static final Id UNKNOWN_ACCOUNT_ID = id(0);
+
   public static Id id(int id) {
     return new AutoValue_Account_Id(id);
   }
diff --git a/java/com/google/gerrit/entities/KeyUtil.java b/java/com/google/gerrit/entities/KeyUtil.java
index be28689..0f14cd9 100644
--- a/java/com/google/gerrit/entities/KeyUtil.java
+++ b/java/com/google/gerrit/entities/KeyUtil.java
@@ -14,7 +14,8 @@
 
 package com.google.gerrit.entities;
 
-import java.io.UnsupportedEncodingException;
+import static java.nio.charset.StandardCharsets.UTF_8;
+
 import java.util.Arrays;
 
 public class KeyUtil {
@@ -49,13 +50,7 @@
   }
 
   public static String encode(final String key) {
-    final byte[] b;
-    try {
-      b = key.getBytes("UTF-8");
-    } catch (UnsupportedEncodingException e) {
-      throw new IllegalStateException("No UTF-8 support", e);
-    }
-
+    final byte[] b = key.getBytes(UTF_8);
     final StringBuilder r = new StringBuilder(b.length);
     for (int i = 0; i < b.length; i++) {
       final int c = b[i] & 0xff;
@@ -99,10 +94,6 @@
     } catch (ArrayIndexOutOfBoundsException err) {
       throw new IllegalArgumentException("Bad encoding" + key, err);
     }
-    try {
-      return new String(b, 0, bPtr, "UTF-8");
-    } catch (UnsupportedEncodingException e) {
-      throw new IllegalStateException("No UTF-8 support", e);
-    }
+    return new String(b, 0, bPtr, UTF_8);
   }
 }
diff --git a/java/com/google/gerrit/entities/LabelType.java b/java/com/google/gerrit/entities/LabelType.java
index e36224e..0349a73 100644
--- a/java/com/google/gerrit/entities/LabelType.java
+++ b/java/com/google/gerrit/entities/LabelType.java
@@ -22,7 +22,6 @@
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.common.Nullable;
 import java.util.ArrayList;
-import java.util.Collection;
 import java.util.List;
 import java.util.Optional;
 
@@ -30,15 +29,6 @@
 public abstract class LabelType {
   public static final boolean DEF_ALLOW_POST_SUBMIT = true;
   public static final boolean DEF_CAN_OVERRIDE = true;
-  public static final boolean DEF_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE = false;
-  public static final boolean DEF_COPY_ALL_SCORES_IF_NO_CHANGE = true;
-  public static final boolean DEF_COPY_ALL_SCORES_IF_NO_CODE_CHANGE = false;
-  public static final boolean DEF_COPY_ALL_SCORES_ON_TRIVIAL_REBASE = false;
-  public static final boolean DEF_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE = false;
-  public static final boolean DEF_COPY_ANY_SCORE = false;
-  public static final boolean DEF_COPY_MAX_SCORE = false;
-  public static final boolean DEF_COPY_MIN_SCORE = false;
-  public static final ImmutableList<Short> DEF_COPY_VALUES = ImmutableList.of();
   public static final boolean DEF_IGNORE_SELF_APPROVAL = false;
 
   public static LabelType withDefaultValues(String name) {
@@ -99,24 +89,6 @@
 
   public abstract LabelFunction getFunction();
 
-  public abstract boolean isCopyAnyScore();
-
-  public abstract boolean isCopyMinScore();
-
-  public abstract boolean isCopyMaxScore();
-
-  public abstract boolean isCopyAllScoresIfListOfFilesDidNotChange();
-
-  public abstract boolean isCopyAllScoresOnMergeFirstParentUpdate();
-
-  public abstract boolean isCopyAllScoresOnTrivialRebase();
-
-  public abstract boolean isCopyAllScoresIfNoCodeChange();
-
-  public abstract boolean isCopyAllScoresIfNoChange();
-
-  public abstract ImmutableList<Short> getCopyValues();
-
   public abstract boolean isAllowPostSubmit();
 
   public abstract boolean isIgnoreSelfApproval();
@@ -152,16 +124,6 @@
         .setMaxNegative(Short.MIN_VALUE)
         .setMaxPositive(Short.MAX_VALUE)
         .setCanOverride(DEF_CAN_OVERRIDE)
-        .setCopyAllScoresIfListOfFilesDidNotChange(
-            DEF_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE)
-        .setCopyAllScoresIfNoChange(DEF_COPY_ALL_SCORES_IF_NO_CHANGE)
-        .setCopyAllScoresIfNoCodeChange(DEF_COPY_ALL_SCORES_IF_NO_CODE_CHANGE)
-        .setCopyAllScoresOnTrivialRebase(DEF_COPY_ALL_SCORES_ON_TRIVIAL_REBASE)
-        .setCopyAllScoresOnMergeFirstParentUpdate(DEF_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE)
-        .setCopyAnyScore(DEF_COPY_ANY_SCORE)
-        .setCopyMaxScore(DEF_COPY_MAX_SCORE)
-        .setCopyMinScore(DEF_COPY_MIN_SCORE)
-        .setCopyValues(DEF_COPY_VALUES)
         .setAllowPostSubmit(DEF_ALLOW_POST_SUBMIT)
         .setIgnoreSelfApproval(DEF_IGNORE_SELF_APPROVAL);
   }
@@ -185,11 +147,19 @@
   }
 
   public boolean isMaxNegative(PatchSetApproval ca) {
-    return getMaxNegative() == ca.value();
+    return isMaxNegative(ca.value());
+  }
+
+  public boolean isMaxNegative(short value) {
+    return getMaxNegative() == value;
   }
 
   public boolean isMaxPositive(PatchSetApproval ca) {
-    return getMaxPositive() == ca.value();
+    return isMaxPositive(ca.value());
+  }
+
+  public boolean isMaxPositive(short value) {
+    return getMaxPositive() == value;
   }
 
   public LabelValue getValue(short value) {
@@ -250,28 +220,8 @@
 
     public abstract Builder setDefaultValue(short defaultValue);
 
-    public abstract Builder setCopyAnyScore(boolean copyAnyScore);
-
     public abstract Builder setCopyCondition(@Nullable String copyCondition);
 
-    public abstract Builder setCopyMinScore(boolean copyMinScore);
-
-    public abstract Builder setCopyMaxScore(boolean copyMaxScore);
-
-    public abstract Builder setCopyAllScoresIfListOfFilesDidNotChange(
-        boolean copyAllScoresIfListOfFilesDidNotChange);
-
-    public abstract Builder setCopyAllScoresOnMergeFirstParentUpdate(
-        boolean copyAllScoresOnMergeFirstParentUpdate);
-
-    public abstract Builder setCopyAllScoresOnTrivialRebase(boolean copyAllScoresOnTrivialRebase);
-
-    public abstract Builder setCopyAllScoresIfNoCodeChange(boolean copyAllScoresIfNoCodeChange);
-
-    public abstract Builder setCopyAllScoresIfNoChange(boolean copyAllScoresIfNoChange);
-
-    public abstract Builder setCopyValues(Collection<Short> copyValues);
-
     public abstract Builder setMaxNegative(short maxNegative);
 
     public abstract Builder setMaxPositive(short maxPositive);
@@ -280,8 +230,6 @@
 
     protected abstract String getName();
 
-    protected abstract ImmutableList<Short> getCopyValues();
-
     protected abstract Builder setByValue(ImmutableMap<Short, LabelValue> byValue);
 
     @Nullable
@@ -313,8 +261,6 @@
       }
       setByValue(byValue.build());
 
-      setCopyValues(ImmutableList.sortedCopyOf(getCopyValues()));
-
       return autoBuild();
     }
   }
diff --git a/java/com/google/gerrit/entities/PatchSetApprovals.java b/java/com/google/gerrit/entities/PatchSetApprovals.java
new file mode 100644
index 0000000..8fed9c0
--- /dev/null
+++ b/java/com/google/gerrit/entities/PatchSetApprovals.java
@@ -0,0 +1,59 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.entities;
+
+import com.google.auto.value.AutoValue;
+import com.google.auto.value.extension.memoized.Memoized;
+import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.collect.Multimaps;
+
+/** All approvals of a change by patch set. */
+@AutoValue
+public abstract class PatchSetApprovals {
+  /**
+   * Returns all approvals by patch set, including copied approvals
+   *
+   * <p>Approvals that have been copied from a previous patch set are returned as part of the
+   * result. These approvals can be identified by looking at {@link PatchSetApproval#copied()}.
+   */
+  public abstract ImmutableListMultimap<PatchSet.Id, PatchSetApproval> all();
+
+  /**
+   * Returns non-copied approvals by patch set.
+   *
+   * <p>Approvals that have been copied from a previous patch set are filtered out.
+   */
+  @Memoized
+  public ImmutableListMultimap<PatchSet.Id, PatchSetApproval> onlyNonCopied() {
+    return ImmutableListMultimap.copyOf(
+        Multimaps.filterEntries(all(), entry -> !entry.getValue().copied()));
+  }
+
+  /**
+   * Returns copied approvals by patch set.
+   *
+   * <p>Approvals that have not been copied from a previous patch set are filtered out.
+   */
+  @Memoized
+  public ImmutableListMultimap<PatchSet.Id, PatchSetApproval> onlyCopied() {
+    return ImmutableListMultimap.copyOf(
+        Multimaps.filterEntries(all(), entry -> entry.getValue().copied()));
+  }
+
+  public static PatchSetApprovals create(
+      ImmutableListMultimap<PatchSet.Id, PatchSetApproval> approvalsByPatchSet) {
+    return new AutoValue_PatchSetApprovals(approvalsByPatchSet);
+  }
+}
diff --git a/java/com/google/gerrit/entities/ProjectChangeKey.java b/java/com/google/gerrit/entities/ProjectChangeKey.java
new file mode 100644
index 0000000..15dc5e9
--- /dev/null
+++ b/java/com/google/gerrit/entities/ProjectChangeKey.java
@@ -0,0 +1,29 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.entities;
+
+import com.google.auto.value.AutoValue;
+
+/** Stores together numeric {@link Change.Id} and a project name for the change */
+@AutoValue
+public abstract class ProjectChangeKey {
+  public static ProjectChangeKey create(Project.NameKey projectName, Change.Id changeId) {
+    return new AutoValue_ProjectChangeKey(projectName, changeId);
+  }
+
+  public abstract Project.NameKey projectName();
+
+  public abstract Change.Id changeId();
+}
diff --git a/java/com/google/gerrit/entities/StoredCommentLinkInfo.java b/java/com/google/gerrit/entities/StoredCommentLinkInfo.java
index dc5abbc..75bc034 100644
--- a/java/com/google/gerrit/entities/StoredCommentLinkInfo.java
+++ b/java/com/google/gerrit/entities/StoredCommentLinkInfo.java
@@ -30,10 +30,38 @@
   @Nullable
   public abstract String getMatch();
 
-  /** The link to replace the match with. This can only be set if html is {@code null}. */
+  /**
+   * The link to replace the match with. This can only be set if html is {@code null}.
+   *
+   * <p>The constructed link is using {@link #getLink()} {@link #getPrefix()} {@link #getSuffix()}
+   * and {@link #getText()}, and has the shape of
+   *
+   * <p>{@code PREFIX<a href="LINK">TEXT</a>SUFFIX}
+   */
   @Nullable
   public abstract String getLink();
 
+  /**
+   * The text before the link tag that the match is replaced with. This can only be set if link is
+   * not {@code null}.
+   */
+  @Nullable
+  public abstract String getPrefix();
+
+  /**
+   * The text after the link tag that the match is replaced with. This can only be set if link is
+   * not {@code null}.
+   */
+  @Nullable
+  public abstract String getSuffix();
+
+  /**
+   * The content of the link tag that the match is replaced with. This can only be set if link is
+   * not {@code null}.
+   */
+  @Nullable
+  public abstract String getText();
+
   /** The html to replace the match with. This can only be set if link is {@code null}. */
   @Nullable
   public abstract String getHtml();
@@ -72,6 +100,9 @@
     return builder(src.name)
         .setMatch(src.match)
         .setLink(src.link)
+        .setPrefix(src.prefix)
+        .setSuffix(src.suffix)
+        .setText(src.text)
         .setHtml(src.html)
         .setEnabled(enabled)
         .setOverrideOnly(false)
@@ -84,6 +115,9 @@
     info.name = getName();
     info.match = getMatch();
     info.link = getLink();
+    info.prefix = getPrefix();
+    info.suffix = getSuffix();
+    info.text = getText();
     info.html = getHtml();
     info.enabled = getEnabled();
     return info;
@@ -97,6 +131,12 @@
 
     public abstract Builder setLink(@Nullable String value);
 
+    public abstract Builder setPrefix(@Nullable String value);
+
+    public abstract Builder setSuffix(@Nullable String value);
+
+    public abstract Builder setText(@Nullable String value);
+
     public abstract Builder setHtml(@Nullable String value);
 
     public abstract Builder setEnabled(@Nullable Boolean value);
@@ -106,6 +146,9 @@
     public StoredCommentLinkInfo build() {
       checkArgument(getName() != null, "invalid commentlink.name");
       setLink(Strings.emptyToNull(getLink()));
+      setPrefix(Strings.emptyToNull(getPrefix()));
+      setSuffix(Strings.emptyToNull(getSuffix()));
+      setText(Strings.emptyToNull(getText()));
       setHtml(Strings.emptyToNull(getHtml()));
       if (!getOverrideOnly()) {
         checkArgument(
@@ -126,6 +169,12 @@
 
     protected abstract String getLink();
 
+    protected abstract String getPrefix();
+
+    protected abstract String getSuffix();
+
+    protected abstract String getText();
+
     protected abstract String getHtml();
 
     protected abstract boolean getOverrideOnly();
diff --git a/java/com/google/gerrit/entities/SubmitRequirementExpressionResult.java b/java/com/google/gerrit/entities/SubmitRequirementExpressionResult.java
index aff0994..c24227d 100644
--- a/java/com/google/gerrit/entities/SubmitRequirementExpressionResult.java
+++ b/java/com/google/gerrit/entities/SubmitRequirementExpressionResult.java
@@ -95,10 +95,32 @@
         ImmutableList.of());
   }
 
+  public static SubmitRequirementExpressionResult notEvaluated(SubmitRequirementExpression expr) {
+    return SubmitRequirementExpressionResult.create(
+        expr, Status.NOT_EVALUATED, ImmutableList.of(), ImmutableList.of());
+  }
+
   public static TypeAdapter<SubmitRequirementExpressionResult> typeAdapter(Gson gson) {
     return new AutoValue_SubmitRequirementExpressionResult.GsonTypeAdapter(gson);
   }
 
+  public abstract Builder toBuilder();
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+    public abstract Builder expression(SubmitRequirementExpression expression);
+
+    public abstract Builder status(Status status);
+
+    public abstract Builder errorMessage(Optional<String> errorMessage);
+
+    public abstract Builder passingAtoms(ImmutableList<String> passingAtoms);
+
+    public abstract Builder failingAtoms(ImmutableList<String> failingAtoms);
+
+    public abstract SubmitRequirementExpressionResult build();
+  }
+
   public enum Status {
     /** Submit requirement expression is fulfilled for a given change. */
     PASS,
@@ -107,7 +129,10 @@
     FAIL,
 
     /** Submit requirement expression contains invalid syntax and is not parsable. */
-    ERROR
+    ERROR,
+
+    /** Submit requirement expression was not evaluated. */
+    NOT_EVALUATED
   }
 
   /**
diff --git a/java/com/google/gerrit/extensions/api/access/PluginProjectPermission.java b/java/com/google/gerrit/extensions/api/access/PluginProjectPermission.java
index a62fc63..b3680ea 100644
--- a/java/com/google/gerrit/extensions/api/access/PluginProjectPermission.java
+++ b/java/com/google/gerrit/extensions/api/access/PluginProjectPermission.java
@@ -34,7 +34,7 @@
     requireNonNull(pluginName, "pluginName");
     requireNonNull(permission, "permission");
     checkArgument(
-        isValidPluginPermissionName(permission), "invalid plugin permission name: ", permission);
+        isValidPluginPermissionName(permission), "invalid plugin permission name: %s", permission);
 
     this.pluginName = pluginName;
     this.permission = permission;
diff --git a/java/com/google/gerrit/extensions/api/changes/ChangeApi.java b/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
index 2224649..018a6cf 100644
--- a/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
+++ b/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
@@ -128,20 +128,6 @@
   }
 
   /**
-   * Ignore or un-ignore this change.
-   *
-   * @param ignore ignore the change if true
-   */
-  void ignore(boolean ignore) throws RestApiException;
-
-  /**
-   * Check if this change is ignored.
-   *
-   * @return true if the change is ignored
-   */
-  boolean ignored() throws RestApiException;
-
-  /**
    * Create a new change that reverts this change.
    *
    * @see Changes#id(int)
@@ -427,6 +413,39 @@
 
   ChangeInfo check(FixInput fix) throws RestApiException;
 
+  abstract class CheckSubmitRequirementRequest {
+    /** Submit requirement name. */
+    private String name;
+
+    /**
+     * A change ID for a change in {@link com.google.gerrit.entities.RefNames#REFS_CONFIG} branch
+     * from which the submit-requirement will be loaded.
+     */
+    private String refsConfigChangeId;
+
+    public abstract SubmitRequirementResultInfo get() throws RestApiException;
+
+    public CheckSubmitRequirementRequest srName(String srName) {
+      this.name = srName;
+      return this;
+    }
+
+    public CheckSubmitRequirementRequest refsConfigChangeId(String changeId) {
+      this.refsConfigChangeId = changeId;
+      return this;
+    }
+
+    protected String srName() {
+      return name;
+    }
+
+    protected String getRefsConfigChangeId() {
+      return refsConfigChangeId;
+    }
+  }
+
+  CheckSubmitRequirementRequest checkSubmitRequirementRequest() throws RestApiException;
+
   /** Returns the result of evaluating the {@link SubmitRequirementInput} input on the change. */
   SubmitRequirementResultInfo checkSubmitRequirement(SubmitRequirementInput input)
       throws RestApiException;
@@ -767,6 +786,11 @@
     }
 
     @Override
+    public CheckSubmitRequirementRequest checkSubmitRequirementRequest() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public SubmitRequirementResultInfo checkSubmitRequirement(SubmitRequirementInput input)
         throws RestApiException {
       throw new NotImplementedException();
@@ -800,16 +824,6 @@
     }
 
     @Override
-    public void ignore(boolean ignore) throws RestApiException {
-      throw new NotImplementedException();
-    }
-
-    @Override
-    public boolean ignored() throws RestApiException {
-      throw new NotImplementedException();
-    }
-
-    @Override
     public PureRevertInfo pureRevert() throws RestApiException {
       throw new NotImplementedException();
     }
diff --git a/java/com/google/gerrit/extensions/api/changes/DeleteVoteInput.java b/java/com/google/gerrit/extensions/api/changes/DeleteVoteInput.java
index 8432c8f..f75443e 100644
--- a/java/com/google/gerrit/extensions/api/changes/DeleteVoteInput.java
+++ b/java/com/google/gerrit/extensions/api/changes/DeleteVoteInput.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.extensions.api.changes;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.restapi.DefaultInput;
 import java.util.Map;
 
@@ -31,4 +32,7 @@
    * are added to the attention set upon deletion of their vote by other users.
    */
   public boolean ignoreAutomaticAttentionSetRules;
+
+  /** Reason for this vote deletion. Will appear in the change message. */
+  @Nullable public String reason;
 }
diff --git a/tools/polygerrit-updater/src/utils/unexpectedValue.ts b/java/com/google/gerrit/extensions/api/changes/GetRelatedOption.java
similarity index 66%
rename from tools/polygerrit-updater/src/utils/unexpectedValue.ts
rename to java/com/google/gerrit/extensions/api/changes/GetRelatedOption.java
index 690c283..ab81c60 100644
--- a/tools/polygerrit-updater/src/utils/unexpectedValue.ts
+++ b/java/com/google/gerrit/extensions/api/changes/GetRelatedOption.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2019 The Android Open Source Project
+// Copyright (C) 2022 The Android Open Source Project
 //
 // Licensed under the Apache License, Version 2.0 (the "License");
 // you may not use this file except in compliance with the License.
@@ -12,6 +12,10 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-export function unexpectedValue<T>(x: T): never {
-  throw new Error(`Unexpected value '${x}'`);
+package com.google.gerrit.extensions.api.changes;
+
+/** Options for "Get Related Changes" requests. */
+public enum GetRelatedOption {
+  /** Compute submittability boolean for all returned changes. */
+  SUBMITTABLE
 }
diff --git a/java/com/google/gerrit/extensions/api/changes/RelatedChangeAndCommitInfo.java b/java/com/google/gerrit/extensions/api/changes/RelatedChangeAndCommitInfo.java
index 5bf22aa..740caa8 100644
--- a/java/com/google/gerrit/extensions/api/changes/RelatedChangeAndCommitInfo.java
+++ b/java/com/google/gerrit/extensions/api/changes/RelatedChangeAndCommitInfo.java
@@ -25,6 +25,7 @@
   public Integer _revisionNumber;
   public Integer _currentRevisionNumber;
   public String status;
+  public Boolean submittable;
 
   public RelatedChangeAndCommitInfo() {}
 
@@ -38,6 +39,7 @@
         .add("_revisionNumber", _revisionNumber)
         .add("_currentRevisionNumber", _currentRevisionNumber)
         .add("status", status)
+        .add("submittable", submittable)
         .toString();
   }
 }
diff --git a/java/com/google/gerrit/extensions/api/changes/RevertInput.java b/java/com/google/gerrit/extensions/api/changes/RevertInput.java
index 148d24a..613e48e 100644
--- a/java/com/google/gerrit/extensions/api/changes/RevertInput.java
+++ b/java/com/google/gerrit/extensions/api/changes/RevertInput.java
@@ -36,4 +36,6 @@
    * {@link NotifyHandling#OWNER}
    */
   public boolean workInProgress;
+
+  public Map<String, String> validationOptions;
 }
diff --git a/java/com/google/gerrit/extensions/api/changes/RevisionApi.java b/java/com/google/gerrit/extensions/api/changes/RevisionApi.java
index b659cca..73fc170 100644
--- a/java/com/google/gerrit/extensions/api/changes/RevisionApi.java
+++ b/java/com/google/gerrit/extensions/api/changes/RevisionApi.java
@@ -19,6 +19,7 @@
 import com.google.gerrit.extensions.client.ArchiveFormat;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.ActionInfo;
+import com.google.gerrit.extensions.common.ApplyProvidedFixInput;
 import com.google.gerrit.extensions.common.ApprovalInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.CommentInfo;
@@ -33,6 +34,7 @@
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.NotImplementedException;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import java.util.EnumSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
@@ -117,8 +119,20 @@
    */
   EditInfo applyFix(String fixId) throws RestApiException;
 
+  /**
+   * Applies fix similar to {@code applyFix} method. Instead of using a fix stored in the server,
+   * this applies the fix provided in {@code ApplyProvidedFixInput}
+   *
+   * @param applyProvidedFixInput The fix(es) to apply to a new change edit.
+   * @throws RestApiException if the fix couldn't be applied.
+   */
+  EditInfo applyFix(ApplyProvidedFixInput applyProvidedFixInput) throws RestApiException;
+
   Map<String, DiffInfo> getFixPreview(String fixId) throws RestApiException;
 
+  Map<String, DiffInfo> getFixPreview(ApplyProvidedFixInput applyProvidedFixInput)
+      throws RestApiException;
+
   DraftApi createDraft(DraftInput in) throws RestApiException;
 
   DraftApi draft(String id) throws RestApiException;
@@ -146,6 +160,8 @@
 
   RelatedChangesInfo related() throws RestApiException;
 
+  RelatedChangesInfo related(EnumSet<GetRelatedOption> listOptions) throws RestApiException;
+
   /** Returns votes on the revision. */
   ListMultimap<String, ApprovalInfo> votes() throws RestApiException;
 
@@ -313,11 +329,22 @@
     }
 
     @Override
+    public EditInfo applyFix(ApplyProvidedFixInput applyProvidedFixInput) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public Map<String, DiffInfo> getFixPreview(String fixId) throws RestApiException {
       throw new NotImplementedException();
     }
 
     @Override
+    public Map<String, DiffInfo> getFixPreview(ApplyProvidedFixInput applyProvidedFixInput)
+        throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public Map<String, List<CommentInfo>> drafts() throws RestApiException {
       throw new NotImplementedException();
     }
@@ -383,6 +410,11 @@
     }
 
     @Override
+    public RelatedChangesInfo related(EnumSet<GetRelatedOption> options) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public ListMultimap<String, ApprovalInfo> votes() throws RestApiException {
       throw new NotImplementedException();
     }
diff --git a/java/com/google/gerrit/extensions/api/projects/CommentLinkInfo.java b/java/com/google/gerrit/extensions/api/projects/CommentLinkInfo.java
index 23849e4..b45fcee 100644
--- a/java/com/google/gerrit/extensions/api/projects/CommentLinkInfo.java
+++ b/java/com/google/gerrit/extensions/api/projects/CommentLinkInfo.java
@@ -17,9 +17,13 @@
 import com.google.common.base.MoreObjects;
 import java.util.Objects;
 
+/** See {@link com.google.gerrit.entities.StoredCommentLinkInfo} for field documentation. */
 public class CommentLinkInfo {
   public String match;
   public String link;
+  public String prefix;
+  public String suffix;
+  public String text;
   public String html;
   public Boolean enabled; // null means true
 
@@ -34,6 +38,9 @@
       CommentLinkInfo that = (CommentLinkInfo) o;
       return Objects.equals(this.match, that.match)
           && Objects.equals(this.link, that.link)
+          && Objects.equals(this.prefix, that.prefix)
+          && Objects.equals(this.suffix, that.suffix)
+          && Objects.equals(this.text, that.text)
           && Objects.equals(this.html, that.html)
           && Objects.equals(this.enabled, that.enabled);
     }
@@ -51,6 +58,9 @@
         .add("name", name)
         .add("match", match)
         .add("link", link)
+        .add("prefix", prefix)
+        .add("suffix", suffix)
+        .add("text", text)
         .add("html", html)
         .add("enabled", enabled)
         .toString();
diff --git a/java/com/google/gerrit/extensions/api/projects/CommentLinkInput.java b/java/com/google/gerrit/extensions/api/projects/CommentLinkInput.java
index 3aad7e1..1c964a4 100644
--- a/java/com/google/gerrit/extensions/api/projects/CommentLinkInput.java
+++ b/java/com/google/gerrit/extensions/api/projects/CommentLinkInput.java
@@ -14,14 +14,22 @@
 
 package com.google.gerrit.extensions.api.projects;
 
-/*
+/**
  * Input for a commentlink configuration on a project.
+ *
+ * <p>See {@link com.google.gerrit.entities.StoredCommentLinkInfo} for additional details.
  */
 public class CommentLinkInput {
   /** A JavaScript regular expression to match positions to be replaced with a hyperlink. */
   public String match;
   /** The URL to direct the user to whenever the regular expression is matched. */
   public String link;
+  /** Text inserted before the link if the regular expression is matched. */
+  public String prefix;
+  /** Text inserted after the link if the regular expression is matched. */
+  public String suffix;
+  /** Text of the link. */
+  public String text;
   /** Whether the commentlink is enabled. */
   public Boolean enabled;
 }
diff --git a/java/com/google/gerrit/extensions/api/projects/ProjectApi.java b/java/com/google/gerrit/extensions/api/projects/ProjectApi.java
index 59475a4..f6408b6 100644
--- a/java/com/google/gerrit/extensions/api/projects/ProjectApi.java
+++ b/java/com/google/gerrit/extensions/api/projects/ProjectApi.java
@@ -22,6 +22,7 @@
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.LabelDefinitionInfo;
 import com.google.gerrit.extensions.common.ProjectInfo;
+import com.google.gerrit.extensions.common.SubmitRequirementInfo;
 import com.google.gerrit.extensions.restapi.NotImplementedException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import java.util.Collection;
@@ -227,6 +228,21 @@
 
   LabelApi label(String labelName) throws RestApiException;
 
+  ListSubmitRequirementsRequest submitRequirements() throws RestApiException;
+
+  abstract class ListSubmitRequirementsRequest {
+    protected boolean inherited;
+
+    public abstract List<SubmitRequirementInfo> get() throws RestApiException;
+
+    public ListSubmitRequirementsRequest withInherited(boolean inherited) {
+      this.inherited = inherited;
+      return this;
+    }
+  }
+
+  SubmitRequirementApi submitRequirement(String name) throws RestApiException;
+
   /**
    * Adds, updates and deletes label definitions in a batch.
    *
@@ -421,11 +437,21 @@
     }
 
     @Override
+    public ListSubmitRequirementsRequest submitRequirements() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public LabelApi label(String labelName) throws RestApiException {
       throw new NotImplementedException();
     }
 
     @Override
+    public SubmitRequirementApi submitRequirement(String name) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public void labels(BatchLabelInput input) throws RestApiException {
       throw new NotImplementedException();
     }
diff --git a/java/com/google/gerrit/extensions/api/projects/SubmitRequirementApi.java b/java/com/google/gerrit/extensions/api/projects/SubmitRequirementApi.java
new file mode 100644
index 0000000..a6e79db
--- /dev/null
+++ b/java/com/google/gerrit/extensions/api/projects/SubmitRequirementApi.java
@@ -0,0 +1,60 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.api.projects;
+
+import com.google.gerrit.extensions.common.SubmitRequirementInfo;
+import com.google.gerrit.extensions.common.SubmitRequirementInput;
+import com.google.gerrit.extensions.restapi.NotImplementedException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+
+public interface SubmitRequirementApi {
+  /** Create a new submit requirement. */
+  SubmitRequirementApi create(SubmitRequirementInput input) throws RestApiException;
+
+  /** Get existing submit requirement. */
+  SubmitRequirementInfo get() throws RestApiException;
+
+  /** Update existing submit requirement. */
+  SubmitRequirementInfo update(SubmitRequirementInput input) throws RestApiException;
+
+  /** Delete existing submit requirement. */
+  void delete() throws RestApiException;
+
+  /**
+   * A default implementation which allows source compatibility when adding new methods to the
+   * interface.
+   */
+  class NotImplemented implements SubmitRequirementApi {
+    @Override
+    public SubmitRequirementApi create(SubmitRequirementInput input) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public SubmitRequirementInfo get() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public SubmitRequirementInfo update(SubmitRequirementInput input) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public void delete() throws RestApiException {
+      throw new NotImplementedException();
+    }
+  }
+}
diff --git a/java/com/google/gerrit/extensions/client/ChangeKind.java b/java/com/google/gerrit/extensions/client/ChangeKind.java
index e58e005..6240bba 100644
--- a/java/com/google/gerrit/extensions/client/ChangeKind.java
+++ b/java/com/google/gerrit/extensions/client/ChangeKind.java
@@ -29,5 +29,42 @@
   NO_CODE_CHANGE,
 
   /** Same tree, parent tree, same commit message. */
-  NO_CHANGE
+  NO_CHANGE;
+
+  public boolean matches(ChangeKind changeKind, boolean isMerge) {
+    switch (changeKind) {
+      case REWORK:
+        // REWORK inlcudes all other change kinds, since those are just more trivial cases of a
+        // rework
+        return true;
+      case TRIVIAL_REBASE:
+        return isTrivialRebase();
+      case MERGE_FIRST_PARENT_UPDATE:
+        return isMergeFirstParentUpdate(isMerge);
+      case NO_CHANGE:
+        return this == NO_CHANGE;
+      case NO_CODE_CHANGE:
+        return isNoCodeChange();
+    }
+    throw new IllegalStateException("unexpected change kind: " + changeKind);
+  }
+
+  public boolean isNoCodeChange() {
+    // NO_CHANGE is a more trivial case of NO_CODE_CHANGE and hence matched as well
+    return this == NO_CHANGE || this == NO_CODE_CHANGE;
+  }
+
+  public boolean isTrivialRebase() {
+    // NO_CHANGE is a more trivial case of TRIVIAL_REBASE and hence matched as well
+    return this == NO_CHANGE || this == TRIVIAL_REBASE;
+  }
+
+  public boolean isMergeFirstParentUpdate(boolean isMerge) {
+    if (!isMerge) {
+      return false;
+    }
+
+    // NO_CHANGE is a more trivial case of MERGE_FIRST_PARENT_UPDATE and hence matched as well
+    return this == NO_CHANGE || this == MERGE_FIRST_PARENT_UPDATE;
+  }
 }
diff --git a/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java b/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
index 6acf3f4..1ee2cd8 100644
--- a/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
+++ b/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
@@ -102,6 +102,7 @@
   }
 
   public enum Theme {
+    AUTO,
     DARK,
     LIGHT
   }
@@ -149,6 +150,7 @@
   public Boolean workInProgressByDefault;
   public List<MenuItem> my;
   public List<String> changeTable;
+  public Boolean allowBrowserNotifications;
 
   public DateFormat getDateFormat() {
     if (dateFormat == null) {
@@ -189,7 +191,7 @@
     GeneralPreferencesInfo p = new GeneralPreferencesInfo();
     p.changesPerPage = DEFAULT_PAGESIZE;
     p.downloadScheme = null;
-    p.theme = Theme.LIGHT;
+    p.theme = Theme.AUTO;
     p.dateFormat = DateFormat.STD;
     p.timeFormat = TimeFormat.HHMM_12;
     p.expandInlineDiffs = false;
@@ -207,6 +209,7 @@
     p.disableKeyboardShortcuts = false;
     p.disableTokenHighlighting = false;
     p.workInProgressByDefault = false;
+    p.allowBrowserNotifications = true;
     return p;
   }
 }
diff --git a/java/com/google/gerrit/extensions/common/ApplyProvidedFixInput.java b/java/com/google/gerrit/extensions/common/ApplyProvidedFixInput.java
new file mode 100644
index 0000000..cd28d83
--- /dev/null
+++ b/java/com/google/gerrit/extensions/common/ApplyProvidedFixInput.java
@@ -0,0 +1,27 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,//
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.common;
+
+import java.util.List;
+
+/**
+ * Input containing fix definitions to apply the provided fix to the change, on the patchset
+ * specified by revision id.
+ */
+public class ApplyProvidedFixInput {
+  public ApplyProvidedFixInput() {}
+
+  public List<FixReplacementInfo> fixReplacementInfos;
+}
diff --git a/java/com/google/gerrit/extensions/common/LabelDefinitionInfo.java b/java/com/google/gerrit/extensions/common/LabelDefinitionInfo.java
index d3baecd..975061b 100644
--- a/java/com/google/gerrit/extensions/common/LabelDefinitionInfo.java
+++ b/java/com/google/gerrit/extensions/common/LabelDefinitionInfo.java
@@ -26,16 +26,7 @@
   public short defaultValue;
   public List<String> branches;
   public Boolean canOverride;
-  public Boolean copyAnyScore;
   public String copyCondition;
-  public Boolean copyMinScore;
-  public Boolean copyMaxScore;
-  public Boolean copyAllScoresIfListOfFilesDidNotChange;
-  public Boolean copyAllScoresIfNoChange;
-  public Boolean copyAllScoresIfNoCodeChange;
-  public Boolean copyAllScoresOnTrivialRebase;
-  public Boolean copyAllScoresOnMergeFirstParentUpdate;
-  public List<Short> copyValues;
   public Boolean allowPostSubmit;
   public Boolean ignoreSelfApproval;
 }
diff --git a/java/com/google/gerrit/extensions/common/LabelDefinitionInput.java b/java/com/google/gerrit/extensions/common/LabelDefinitionInput.java
index 1d580bb..277fccd 100644
--- a/java/com/google/gerrit/extensions/common/LabelDefinitionInput.java
+++ b/java/com/google/gerrit/extensions/common/LabelDefinitionInput.java
@@ -25,17 +25,8 @@
   public Short defaultValue;
   public List<String> branches;
   public Boolean canOverride;
-  public Boolean copyAnyScore;
   public String copyCondition;
   public Boolean unsetCopyCondition;
-  public Boolean copyMinScore;
-  public Boolean copyMaxScore;
-  public Boolean copyAllScoresIfListOfFilesDidNotChange;
-  public Boolean copyAllScoresIfNoChange;
-  public Boolean copyAllScoresIfNoCodeChange;
-  public Boolean copyAllScoresOnTrivialRebase;
-  public Boolean copyAllScoresOnMergeFirstParentUpdate;
-  public List<Short> copyValues;
   public Boolean allowPostSubmit;
   public Boolean ignoreSelfApproval;
 }
diff --git a/java/com/google/gerrit/extensions/common/SubmitRequirementExpressionInfo.java b/java/com/google/gerrit/extensions/common/SubmitRequirementExpressionInfo.java
index b4731f2..742d0c8 100644
--- a/java/com/google/gerrit/extensions/common/SubmitRequirementExpressionInfo.java
+++ b/java/com/google/gerrit/extensions/common/SubmitRequirementExpressionInfo.java
@@ -17,7 +17,10 @@
 import java.util.List;
 import java.util.Objects;
 
-/** Result of evaluating a single submit requirement expression. */
+/**
+ * Result of evaluating a single submit requirement expression. This API entity is populated from
+ * {@link com.google.gerrit.entities.SubmitRequirementExpressionResult}.
+ */
 public class SubmitRequirementExpressionInfo {
 
   /** Submit requirement expression as a String. */
@@ -26,6 +29,9 @@
   /** A boolean indicating if the expression is fulfilled on a change. */
   public boolean fulfilled;
 
+  /** A status indicating if the expression is fulfilled, non-fulfilled or not evaluated. */
+  public Status status;
+
   /**
    * A list of all atoms that are passing, for example query "branch:refs/heads/foo and project:bar"
    * has two atoms: ["branch:refs/heads/foo", "project:bar"].
@@ -44,6 +50,24 @@
    */
   public String errorMessage;
 
+  /**
+   * Values in this enum should match with values in {@link
+   * com.google.gerrit.entities.SubmitRequirementExpressionResult.Status}.
+   */
+  public enum Status {
+    /** Expression was evaluated and the result was true. */
+    PASS,
+
+    /** Expression was evaluated and the result was false. */
+    FAIL,
+
+    /** An error occurred while evaluating the expression. */
+    ERROR,
+
+    /** Expression was not evaluated. */
+    NOT_EVALUATED
+  }
+
   @Override
   public boolean equals(Object o) {
     if (this == o) {
diff --git a/java/com/google/gerrit/extensions/common/SubmitRequirementInfo.java b/java/com/google/gerrit/extensions/common/SubmitRequirementInfo.java
new file mode 100644
index 0000000..9347e7e
--- /dev/null
+++ b/java/com/google/gerrit/extensions/common/SubmitRequirementInfo.java
@@ -0,0 +1,45 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.common;
+
+public class SubmitRequirementInfo {
+  /** Name of the submit requirement. */
+  public String name;
+
+  /** Description of the submit requirement. */
+  public String description;
+
+  /**
+   * Expression string to be evaluated on a change. Decides if this submit requirement is applicable
+   * on the given change.
+   */
+  public String applicabilityExpression;
+
+  /**
+   * Expression string to be evaluated on a change. When evaluated to true, this submit requirement
+   * becomes fulfilled for this change.
+   */
+  public String submittabilityExpression;
+
+  /**
+   * Expression string to be evaluated on a change. When evaluated to true, this submit requirement
+   * becomes fulfilled for this change regardless of the evaluation of the {@link
+   * #submittabilityExpression}.
+   */
+  public String overrideExpression;
+
+  /** Boolean indicating if this submit requirement can be overridden in child projects. */
+  public boolean allowOverrideInChildProjects;
+}
diff --git a/java/com/google/gerrit/extensions/common/testing/GitPersonSubject.java b/java/com/google/gerrit/extensions/common/testing/GitPersonSubject.java
index cb9d855..f75ec66 100644
--- a/java/com/google/gerrit/extensions/common/testing/GitPersonSubject.java
+++ b/java/com/google/gerrit/extensions/common/testing/GitPersonSubject.java
@@ -72,14 +72,14 @@
 
   // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
   // Instant
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
   @SuppressWarnings("JdkObsolete")
   public void matches(PersonIdent ident) {
     isNotNull();
     name().isEqualTo(ident.getName());
     email().isEqualTo(ident.getEmailAddress());
-    check("roundedDate()").that(gitPerson.date.getTime()).isEqualTo(ident.getWhen().getTime());
+    check("roundedDate()")
+        .that(gitPerson.date.getTime())
+        .isEqualTo(ident.getWhenAsInstant().toEpochMilli());
     tz().isEqualTo(ident.getTimeZoneOffset());
   }
 }
diff --git a/java/com/google/gerrit/extensions/events/AttentionSetListener.java b/java/com/google/gerrit/extensions/events/AttentionSetListener.java
new file mode 100644
index 0000000..ada30ce
--- /dev/null
+++ b/java/com/google/gerrit/extensions/events/AttentionSetListener.java
@@ -0,0 +1,46 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.events;
+
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+import java.util.Set;
+
+/** Notified whenever the attention set is changed. */
+@ExtensionPoint
+public interface AttentionSetListener {
+  interface Event extends ChangeEvent {
+
+    /**
+     * Returns the users added to the attention set because of this change
+     *
+     * @return Account IDs
+     */
+    Set<Integer> usersAdded();
+
+    /**
+     * Returns the users removed from the attention set because of this change
+     *
+     * @return Account IDs
+     */
+    Set<Integer> usersRemoved();
+  }
+
+  /**
+   * This function will be called when the attention set changes
+   *
+   * @param event The event that changed the attention set
+   */
+  void onAttentionSetChanged(Event event);
+}
diff --git a/java/com/google/gerrit/gpg/BUILD b/java/com/google/gerrit/gpg/BUILD
index 9f804c4..0ee5212 100644
--- a/java/com/google/gerrit/gpg/BUILD
+++ b/java/com/google/gerrit/gpg/BUILD
@@ -13,6 +13,7 @@
         "//java/com/google/gerrit/server/api",
         "//lib:guava",
         "//lib:jgit",
+        "//lib/auto:auto-factory",
         "//lib/bouncycastle:bcpg-neverlink",
         "//lib/bouncycastle:bcprov-neverlink",
         "//lib/flogger:api",
diff --git a/java/com/google/gerrit/gpg/GerritPushCertificateChecker.java b/java/com/google/gerrit/gpg/GerritPushCertificateChecker.java
index c3dec61..874f1dc 100644
--- a/java/com/google/gerrit/gpg/GerritPushCertificateChecker.java
+++ b/java/com/google/gerrit/gpg/GerritPushCertificateChecker.java
@@ -14,28 +14,24 @@
 
 package com.google.gerrit.gpg;
 
+import com.google.auto.factory.AutoFactory;
+import com.google.auto.factory.Provided;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
 import org.eclipse.jgit.lib.Repository;
 
+@AutoFactory
 public class GerritPushCertificateChecker extends PushCertificateChecker {
-  public interface Factory {
-    GerritPushCertificateChecker create(IdentifiedUser expectedUser);
-  }
-
   private final GitRepositoryManager repoManager;
   private final AllUsersName allUsers;
 
-  @Inject
   GerritPushCertificateChecker(
-      GerritPublicKeyChecker.Factory keyCheckerFactory,
-      GitRepositoryManager repoManager,
-      AllUsersName allUsers,
-      @Assisted IdentifiedUser expectedUser) {
+      @Provided GerritPublicKeyChecker.Factory keyCheckerFactory,
+      @Provided GitRepositoryManager repoManager,
+      @Provided AllUsersName allUsers,
+      IdentifiedUser expectedUser) {
     super(keyCheckerFactory.create().setExpectedUser(expectedUser));
     this.repoManager = repoManager;
     this.allUsers = allUsers;
diff --git a/java/com/google/gerrit/gpg/GpgModule.java b/java/com/google/gerrit/gpg/GpgModule.java
index 45c1ab5..623b5f0 100644
--- a/java/com/google/gerrit/gpg/GpgModule.java
+++ b/java/com/google/gerrit/gpg/GpgModule.java
@@ -42,7 +42,6 @@
     }
     if (enableSignedPush) {
       install(new SignedPushModule());
-      factory(GerritPushCertificateChecker.Factory.class);
     }
     install(new GpgApiModule(enableSignedPush && configEditGpgKeys));
   }
diff --git a/java/com/google/gerrit/gpg/SignedPushPreReceiveHook.java b/java/com/google/gerrit/gpg/SignedPushPreReceiveHook.java
index 21a5b6e..abc51c2 100644
--- a/java/com/google/gerrit/gpg/SignedPushPreReceiveHook.java
+++ b/java/com/google/gerrit/gpg/SignedPushPreReceiveHook.java
@@ -48,11 +48,11 @@
   }
 
   private final Provider<IdentifiedUser> user;
-  private final GerritPushCertificateChecker.Factory checkerFactory;
+  private final GerritPushCertificateCheckerFactory checkerFactory;
 
   @Inject
   public SignedPushPreReceiveHook(
-      Provider<IdentifiedUser> user, GerritPushCertificateChecker.Factory checkerFactory) {
+      Provider<IdentifiedUser> user, GerritPushCertificateCheckerFactory checkerFactory) {
     this.user = user;
     this.checkerFactory = checkerFactory;
   }
diff --git a/java/com/google/gerrit/gpg/api/GpgApiAdapterImpl.java b/java/com/google/gerrit/gpg/api/GpgApiAdapterImpl.java
index 652afea..6ae0334 100644
--- a/java/com/google/gerrit/gpg/api/GpgApiAdapterImpl.java
+++ b/java/com/google/gerrit/gpg/api/GpgApiAdapterImpl.java
@@ -22,7 +22,7 @@
 import com.google.gerrit.extensions.common.PushCertificateInfo;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.gpg.GerritPushCertificateChecker;
+import com.google.gerrit.gpg.GerritPushCertificateCheckerFactory;
 import com.google.gerrit.gpg.PushCertificateChecker;
 import com.google.gerrit.gpg.server.GpgKeys;
 import com.google.gerrit.gpg.server.PostGpgKeys;
@@ -44,14 +44,14 @@
   private final Provider<PostGpgKeys> postGpgKeys;
   private final Provider<GpgKeys> gpgKeys;
   private final GpgKeyApiImpl.Factory gpgKeyApiFactory;
-  private final GerritPushCertificateChecker.Factory pushCertCheckerFactory;
+  private final GerritPushCertificateCheckerFactory pushCertCheckerFactory;
 
   @Inject
   GpgApiAdapterImpl(
       Provider<PostGpgKeys> postGpgKeys,
       Provider<GpgKeys> gpgKeys,
       GpgKeyApiImpl.Factory gpgKeyApiFactory,
-      GerritPushCertificateChecker.Factory pushCertCheckerFactory) {
+      GerritPushCertificateCheckerFactory pushCertCheckerFactory) {
     this.postGpgKeys = postGpgKeys;
     this.gpgKeys = gpgKeys;
     this.gpgKeyApiFactory = gpgKeyApiFactory;
diff --git a/java/com/google/gerrit/httpd/GitOverHttpServlet.java b/java/com/google/gerrit/httpd/GitOverHttpServlet.java
index 189d081..e1ead59 100644
--- a/java/com/google/gerrit/httpd/GitOverHttpServlet.java
+++ b/java/com/google/gerrit/httpd/GitOverHttpServlet.java
@@ -432,14 +432,14 @@
         requestListeners.runEach(l -> l.onRequest(requestInfo));
 
         try {
-          perm.check(ProjectPermission.RUN_UPLOAD_PACK);
-        } catch (AuthException e) {
-          GitSmartHttpTools.sendError(
-              (HttpServletRequest) request,
-              responseWrapper,
-              HttpServletResponse.SC_FORBIDDEN,
-              "upload-pack not permitted on this server");
-          return;
+          if (!perm.test(ProjectPermission.RUN_UPLOAD_PACK)) {
+            GitSmartHttpTools.sendError(
+                (HttpServletRequest) request,
+                responseWrapper,
+                HttpServletResponse.SC_FORBIDDEN,
+                "upload-pack not permitted on this server");
+            return;
+          }
         } catch (PermissionBackendException e) {
           responseWrapper.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
           throw new ServletException(e);
@@ -591,18 +591,18 @@
       Capable canUpload;
       try {
         try {
-          permissionBackend
+          if (!permissionBackend
               .currentUser()
               .project(state.getNameKey())
-              .check(ProjectPermission.RUN_RECEIVE_PACK);
+              .test(ProjectPermission.RUN_RECEIVE_PACK)) {
+            GitSmartHttpTools.sendError(
+                httpRequest,
+                responseWrapper,
+                HttpServletResponse.SC_FORBIDDEN,
+                "receive-pack not permitted on this server");
+            return;
+          }
           canUpload = arc.canUpload();
-        } catch (AuthException e) {
-          GitSmartHttpTools.sendError(
-              httpRequest,
-              responseWrapper,
-              HttpServletResponse.SC_FORBIDDEN,
-              "receive-pack not permitted on this server");
-          return;
         } catch (PermissionBackendException e) {
           throw new RuntimeException(e);
         }
diff --git a/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java b/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java
index ce22ae8..72bfe40 100644
--- a/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java
+++ b/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java
@@ -39,6 +39,7 @@
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.HashMap;
+import java.util.HashSet;
 import java.util.Map;
 import java.util.Set;
 import java.util.function.Function;
@@ -68,8 +69,10 @@
             staticTemplateData(
                 canonicalURL, cdnPath, faviconPath, urlParameterMap, urlInScriptTagOrdainer))
         .putAll(dynamicTemplateData(gerritApi, requestedURL));
-    Set<String> enabledExperiments = experimentFeatures.getEnabledExperimentFeatures();
-
+    Set<String> enabledExperiments = new HashSet<>();
+    enabledExperiments.addAll(experimentFeatures.getEnabledExperimentFeatures());
+    // Add all experiments enabled through url
+    enabledExperiments.addAll(IndexHtmlUtil.experimentData(urlParameterMap));
     if (!enabledExperiments.isEmpty()) {
       data.put("enabledExperiments", serializeObject(GSON, enabledExperiments).toString());
     }
@@ -90,18 +93,13 @@
     IndexPreloadingUtil.RequestedPage page = IndexPreloadingUtil.parseRequestedPage(requestedPath);
     switch (page) {
       case CHANGE:
-        data.put(
-            "defaultChangeDetailHex", ListOption.toHex(IndexPreloadingUtil.CHANGE_DETAIL_OPTIONS));
-        data.put(
-            "changeRequestsPath",
-            IndexPreloadingUtil.computeChangeRequestsPath(requestedPath, page).get());
-        break;
       case DIFF:
         data.put(
             "defaultChangeDetailHex", ListOption.toHex(IndexPreloadingUtil.CHANGE_DETAIL_OPTIONS));
         data.put(
             "changeRequestsPath",
             IndexPreloadingUtil.computeChangeRequestsPath(requestedPath, page).get());
+        data.put("changeNum", IndexPreloadingUtil.computeChangeNum(requestedPath, page).get());
         break;
       case DASHBOARD:
         // Dashboard is preloaded queries are added later when we check user is authenticated.
diff --git a/java/com/google/gerrit/httpd/raw/IndexPreloadingUtil.java b/java/com/google/gerrit/httpd/raw/IndexPreloadingUtil.java
index fa9a820..1c6e058 100644
--- a/java/com/google/gerrit/httpd/raw/IndexPreloadingUtil.java
+++ b/java/com/google/gerrit/httpd/raw/IndexPreloadingUtil.java
@@ -59,16 +59,15 @@
   public static final String DASHBOARD_HAS_UNPUBLISHED_DRAFTS_QUERY = "has:draft limit:10";
   public static final String YOUR_TURN = "attention:${user} limit:25";
   public static final String DASHBOARD_ASSIGNED_QUERY =
-      "assignee:${user} (-is:wip OR " + "owner:self OR assignee:self) is:open -is:ignored limit:25";
+      "assignee:${user} (-is:wip OR " + "owner:self OR assignee:self) is:open limit:25";
   public static final String DASHBOARD_WORK_IN_PROGRESS_QUERY =
       "is:open owner:${user} is:wip limit:25";
-  public static final String DASHBOARD_OUTGOING_QUERY =
-      "is:open owner:${user} -is:wip -is:ignored limit:25";
+  public static final String DASHBOARD_OUTGOING_QUERY = "is:open owner:${user} -is:wip limit:25";
   public static final String DASHBOARD_INCOMING_QUERY =
-      "is:open -owner:${user} -is:wip -is:ignored (reviewer:${user} OR assignee:${user}) limit:25";
-  public static final String CC_QUERY = "is:open -is:ignored -is:wip cc:${user} limit:10";
+      "is:open -owner:${user} -is:wip (reviewer:${user} OR assignee:${user}) limit:25";
+  public static final String CC_QUERY = "is:open -is:wip cc:${user} limit:10";
   public static final String DASHBOARD_RECENTLY_CLOSED_QUERY =
-      "is:closed -is:ignored (-is:wip OR owner:self) "
+      "is:closed (-is:wip OR owner:self) "
           + "(owner:${user} OR reviewer:${user} OR assignee:${user} "
           + "OR cc:${user}) -age:4w limit:10";
   public static final String NEW_USER = "owner:${user} limit:1";
@@ -168,6 +167,30 @@
     return Optional.empty();
   }
 
+  public static Optional<Integer> computeChangeNum(String requestedURL, RequestedPage page) {
+    Matcher matcher;
+    switch (page) {
+      case CHANGE:
+        matcher = CHANGE_URL_PATTERN.matcher(requestedURL);
+        break;
+      case DIFF:
+        matcher = DIFF_URL_PATTERN.matcher(requestedURL);
+        break;
+      case DASHBOARD:
+      case PAGE_WITHOUT_PRELOADING:
+      default:
+        return Optional.empty();
+    }
+
+    if (matcher.matches()) {
+      Integer changeNum = Ints.tryParse(matcher.group("changeNum"));
+      if (changeNum != null) {
+        return Optional.of(changeNum);
+      }
+    }
+    return Optional.empty();
+  }
+
   public static List<String> computeDashboardQueryList(Server serverApi) throws RestApiException {
     List<String> queryList = new ArrayList<>();
     queryList.add(SELF_DASHBOARD_HAS_UNPUBLISHED_DRAFTS_QUERY);
diff --git a/java/com/google/gerrit/httpd/raw/StaticModule.java b/java/com/google/gerrit/httpd/raw/StaticModule.java
index 8e8a9d2..15dcf42 100644
--- a/java/com/google/gerrit/httpd/raw/StaticModule.java
+++ b/java/com/google/gerrit/httpd/raw/StaticModule.java
@@ -99,6 +99,7 @@
 
   private static final String DOC_SERVLET = "DocServlet";
   private static final String FAVICON_SERVLET = "FaviconServlet";
+  private static final String SERVICE_WORKER_SERVLET = "ServiceWorkerServlet";
   private static final String POLYGERRIT_INDEX_SERVLET = "PolyGerritUiIndexServlet";
   private static final String ROBOTS_TXT_SERVLET = "RobotsTxtServlet";
 
@@ -167,6 +168,7 @@
     public void configureServlets() {
       serve("/robots.txt").with(named(ROBOTS_TXT_SERVLET));
       serve("/favicon.ico").with(named(FAVICON_SERVLET));
+      serve("/service-worker.js").with(named(SERVICE_WORKER_SERVLET));
     }
 
     @Provides
@@ -201,6 +203,19 @@
       return new SingleFileServlet(cache, webappSourcePath("favicon.ico"), true);
     }
 
+    @Provides
+    @Singleton
+    @Named(SERVICE_WORKER_SERVLET)
+    HttpServlet getServiceWorkerServlet(@Named(CACHE) Cache<Path, Resource> cache) {
+      Paths p = getPaths();
+      if (p.warFs != null) {
+        return new SingleFileServlet(
+            cache, p.warFs.getPath("/polygerrit_ui/workers/service-worker.js"), false);
+      }
+      return new SingleFileServlet(
+          cache, webappSourcePath("polygerrit_ui/workers/service-worker.js"), true);
+    }
+
     private Path webappSourcePath(String name) {
       Paths p = getPaths();
       if (p.unpackedWar != null) {
diff --git a/java/com/google/gerrit/index/BUILD b/java/com/google/gerrit/index/BUILD
index 95b3581..ba1c8bd 100644
--- a/java/com/google/gerrit/index/BUILD
+++ b/java/com/google/gerrit/index/BUILD
@@ -28,9 +28,11 @@
         "//java/com/google/gerrit/git",
         "//java/com/google/gerrit/json",
         "//java/com/google/gerrit/metrics",
+        "//java/com/google/gerrit/proto",
         "//java/com/google/gerrit/server/logging",
         "//lib:guava",
         "//lib:jgit",
+        "//lib:protobuf",
         "//lib/antlr:java-runtime",
         "//lib/auto:auto-value",
         "//lib/auto:auto-value-annotations",
diff --git a/java/com/google/gerrit/index/FieldDef.java b/java/com/google/gerrit/index/FieldDef.java
index 76aa7cc..1c2074b 100644
--- a/java/com/google/gerrit/index/FieldDef.java
+++ b/java/com/google/gerrit/index/FieldDef.java
@@ -21,6 +21,9 @@
 import com.google.common.base.CharMatcher;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.index.SchemaFieldDefs.Getter;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
+import com.google.gerrit.index.SchemaFieldDefs.Setter;
 import java.io.IOException;
 import java.sql.Timestamp;
 import java.util.Optional;
@@ -28,11 +31,18 @@
 /**
  * Definition of a field stored in the secondary index.
  *
+ * <p>{@link FieldDef}-s must not be changed once introduced to the codebase. Instead, a new
+ * FieldDef must be added and the old one removed from the schema (in two upgrade steps, see {@code
+ * com.google.gerrit.index.IndexUpgradeValidator}).
+ *
+ * <p>Note that {@link FieldDef} does not override {@link Object#equals(Object)}. It relies on
+ * instances being singletons so that the default (i.e. reference) comparison works.
+ *
  * @param <I> input type from which documents are created and search results are returned.
  * @param <T> type that should be extracted from the input object when converting to an index
  *     document.
  */
-public final class FieldDef<I, T> {
+public final class FieldDef<I, T> implements SchemaField<I, T> {
   public static FieldDef.Builder<String> exact(String name) {
     return new FieldDef.Builder<>(FieldType.EXACT, name);
   }
@@ -61,17 +71,6 @@
     return new FieldDef.Builder<>(FieldType.TIMESTAMP, name);
   }
 
-  @FunctionalInterface
-  public interface Getter<I, T> {
-    @Nullable
-    T get(I input) throws IOException;
-  }
-
-  @FunctionalInterface
-  public interface Setter<I, T> {
-    void set(I object, T value);
-  }
-
   public static class Builder<T> {
     private final FieldType<T> type;
     private final String name;
@@ -139,16 +138,19 @@
   }
 
   /** Returns name of the field. */
+  @Override
   public String getName() {
     return name;
   }
 
   /** Returns type of the field; for repeatable fields, the inner type, not the iterable type. */
+  @Override
   public FieldType<?> getType() {
     return type;
   }
 
   /** Returns whether the field should be stored in the index. */
+  @Override
   public boolean isStored() {
     return stored;
   }
@@ -159,6 +161,7 @@
    * @param input input object.
    * @return the field value(s) to index.
    */
+  @Override
   @Nullable
   public T get(I input) {
     try {
@@ -177,6 +180,7 @@
    * @return {@code true} if the field was set, {@code false} otherwise
    */
   @SuppressWarnings("unchecked")
+  @Override
   public boolean setIfPossible(I object, StoredValue doc) {
     if (!setter.isPresent()) {
       return false;
@@ -204,6 +208,7 @@
   }
 
   /** Returns whether the field is repeatable. */
+  @Override
   public boolean isRepeatable() {
     return repeatable;
   }
diff --git a/java/com/google/gerrit/index/IndexedField.java b/java/com/google/gerrit/index/IndexedField.java
new file mode 100644
index 0000000..e44f562
--- /dev/null
+++ b/java/com/google/gerrit/index/IndexedField.java
@@ -0,0 +1,515 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.index;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkState;
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.base.CharMatcher;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.reflect.TypeToken;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.converter.ProtoConverter;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.index.SchemaFieldDefs.Getter;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
+import com.google.gerrit.index.SchemaFieldDefs.Setter;
+import com.google.gerrit.proto.Protos;
+import com.google.protobuf.MessageLite;
+import java.io.IOException;
+import java.lang.reflect.ParameterizedType;
+import java.lang.reflect.Type;
+import java.sql.Timestamp;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
+import java.util.stream.StreamSupport;
+
+/**
+ * Definition of a field stored in the secondary index.
+ *
+ * <p>Each IndexedField, stored in index, may have multiple {@link SearchSpec} which defines how it
+ * can be searched and how the index tokens are generated.
+ *
+ * <p>Index implementations may choose to store IndexedField and {@link SearchSpec} (search tokens)
+ * separately, however {@link com.google.gerrit.index.query.IndexedQuery} always issues the queries
+ * to {@link SearchSpec}.
+ *
+ * <p>This allows index implementations to store IndexedField once, while enabling multiple
+ * tokenization strategies on the same IndexedField with {@link SearchSpec}
+ *
+ * @param <I> input type from which documents are created and search results are returned.
+ * @param <T> type that should be extracted from the input object when converting to an index
+ *     document.
+ */
+// TODO(mariasavtchouk): revisit the class name after migration is done.
+@SuppressWarnings("serial")
+@AutoValue
+public abstract class IndexedField<I, T> {
+
+  public static final TypeToken<Integer> INTEGER_TYPE = new TypeToken<>() {};
+  public static final TypeToken<Iterable<Integer>> ITERABLE_INTEGER_TYPE = new TypeToken<>() {};
+  public static final TypeToken<Long> LONG_TYPE = new TypeToken<>() {};
+  public static final TypeToken<Iterable<Long>> ITERABLE_LONG_TYPE = new TypeToken<>() {};
+  public static final TypeToken<String> STRING_TYPE = new TypeToken<>() {};
+  public static final TypeToken<Iterable<String>> ITERABLE_STRING_TYPE = new TypeToken<>() {};
+  public static final TypeToken<byte[]> BYTE_ARRAY_TYPE = new TypeToken<>() {};
+  public static final TypeToken<Iterable<byte[]>> ITERABLE_BYTE_ARRAY_TYPE = new TypeToken<>() {};
+  public static final TypeToken<Timestamp> TIMESTAMP_TYPE = new TypeToken<>() {};
+
+  // Should not be used directly, only used to check if the proto is stored
+  private static final TypeToken<MessageLite> MESSAGE_TYPE = new TypeToken<>() {};
+
+  public static <I, T> Builder<I, T> builder(String name, TypeToken<T> fieldType) {
+    return new AutoValue_IndexedField.Builder<I, T>()
+        .name(name)
+        .fieldType(fieldType)
+        .stored(false)
+        .required(false);
+  }
+
+  public static <I> Builder<I, Iterable<String>> iterableStringBuilder(String name) {
+    return builder(name, IndexedField.ITERABLE_STRING_TYPE);
+  }
+
+  public static <I> Builder<I, String> stringBuilder(String name) {
+    return builder(name, IndexedField.STRING_TYPE);
+  }
+
+  public static <I> Builder<I, Integer> integerBuilder(String name) {
+    return builder(name, IndexedField.INTEGER_TYPE);
+  }
+
+  public static <I> Builder<I, Long> longBuilder(String name) {
+    return builder(name, IndexedField.LONG_TYPE);
+  }
+
+  public static <I> Builder<I, Iterable<Integer>> iterableIntegerBuilder(String name) {
+    return builder(name, IndexedField.ITERABLE_INTEGER_TYPE);
+  }
+
+  public static <I> Builder<I, Timestamp> timestampBuilder(String name) {
+    return builder(name, IndexedField.TIMESTAMP_TYPE);
+  }
+
+  public static <I> Builder<I, byte[]> byteArrayBuilder(String name) {
+    return builder(name, IndexedField.BYTE_ARRAY_TYPE);
+  }
+
+  public static <I> Builder<I, Iterable<byte[]>> iterableByteArrayBuilder(String name) {
+    return builder(name, IndexedField.ITERABLE_BYTE_ARRAY_TYPE);
+  }
+
+  /**
+   * Defines how {@link IndexedField} can be searched and how the index tokens are generated.
+   *
+   * <p>Multiple {@link SearchSpec} can be defined on single {@link IndexedField}.
+   *
+   * <p>Depending on the implementation, indexes can choose to store {@link IndexedField} and {@link
+   * SearchSpec} separately. The searches are issues to {@link SearchSpec}.
+   */
+  public class SearchSpec implements SchemaField<I, T> {
+    private final String name;
+    private final SearchOption searchOption;
+
+    public SearchSpec(String name, SearchOption searchOption) {
+      checkName(name);
+      this.name = name;
+      this.searchOption = searchOption;
+    }
+
+    @Override
+    public boolean isStored() {
+      return getField().stored();
+    }
+
+    @Override
+    public boolean isRepeatable() {
+      return getField().repeatable();
+    }
+
+    @Override
+    @Nullable
+    public T get(I obj) {
+      return getField().get(obj);
+    }
+
+    @Override
+    public String getName() {
+      return name;
+    }
+
+    @Override
+    public FieldType<?> getType() {
+      SearchOption searchOption = getSearchOption();
+      TypeToken<?> fieldType = getField().fieldType();
+      if (searchOption.equals(SearchOption.STORE_ONLY)) {
+        return FieldType.STORED_ONLY;
+      } else if ((fieldType.equals(IndexedField.INTEGER_TYPE)
+              || fieldType.equals(IndexedField.ITERABLE_INTEGER_TYPE))
+          && searchOption.equals(SearchOption.EXACT)) {
+        return FieldType.INTEGER;
+      } else if (fieldType.equals(IndexedField.INTEGER_TYPE)
+          && searchOption.equals(SearchOption.RANGE)) {
+        return FieldType.INTEGER_RANGE;
+      } else if (fieldType.equals(IndexedField.LONG_TYPE)) {
+        return FieldType.LONG;
+      } else if (fieldType.equals(IndexedField.TIMESTAMP_TYPE)) {
+        return FieldType.TIMESTAMP;
+      } else if (fieldType.equals(IndexedField.STRING_TYPE)
+          || fieldType.equals(IndexedField.ITERABLE_STRING_TYPE)) {
+        if (searchOption.equals(SearchOption.EXACT)) {
+          return FieldType.EXACT;
+        } else if (searchOption.equals(SearchOption.FULL_TEXT)) {
+          return FieldType.FULL_TEXT;
+        } else if (searchOption.equals(SearchOption.PREFIX)) {
+          return FieldType.PREFIX;
+        }
+      }
+      throw new IllegalArgumentException(
+          String.format(
+              "search spec [%s, %s] is not supported on field [%s, %s]",
+              getName(), getSearchOption(), getField().name(), getField().fieldType()));
+    }
+
+    @Override
+    public boolean setIfPossible(I object, StoredValue doc) {
+      return getField().setIfPossible(object, doc);
+    }
+
+    /**
+     * Returns {@link SearchOption} enabled on this field.
+     *
+     * @return {@link SearchOption}
+     */
+    public SearchOption getSearchOption() {
+      return searchOption;
+    }
+
+    /**
+     * Returns {@link IndexedField} on which this spec was created.
+     *
+     * @return original {@link IndexedField} of this spec.
+     */
+    public IndexedField<I, T> getField() {
+      return IndexedField.this;
+    }
+
+    private String checkName(String name) {
+      CharMatcher m = CharMatcher.anyOf("abcdefghijklmnopqrstuvwxyz0123456789_");
+      checkArgument(name != null && m.matchesAllOf(name), "illegal field name: %s", name);
+      return name;
+    }
+  }
+
+  /**
+   * Adds {@link SearchSpec} to this {@link IndexedField}
+   *
+   * @param name the name to use for in the search.
+   * @param searchOption the tokenization option, enabled by the new {@link SearchSpec}
+   * @return the added {@link SearchSpec}.
+   */
+  public SearchSpec addSearchSpec(String name, SearchOption searchOption) {
+    SearchSpec searchSpec = new SearchSpec(name, searchOption);
+    checkArgument(
+        !searchSpecs.containsKey(searchSpec.getName()),
+        "Can not add search spec %s, because it is already defined on field %s",
+        searchSpec.getName(),
+        name());
+    searchSpecs.put(searchSpec.getName(), searchSpec);
+    return searchSpec;
+  }
+
+  public SearchSpec exact(String name) {
+    return addSearchSpec(name, SearchOption.EXACT);
+  }
+
+  public SearchSpec fullText(String name) {
+    return addSearchSpec(name, SearchOption.FULL_TEXT);
+  }
+
+  public SearchSpec range(String name) {
+    return addSearchSpec(name, SearchOption.RANGE);
+  }
+
+  public SearchSpec integerRange(String name) {
+    checkState(fieldType().equals(INTEGER_TYPE));
+    return addSearchSpec(name, SearchOption.RANGE);
+  }
+
+  public SearchSpec integer(String name) {
+    checkState(fieldType().equals(INTEGER_TYPE) || fieldType().equals(ITERABLE_INTEGER_TYPE));
+    return addSearchSpec(name, SearchOption.EXACT);
+  }
+
+  public SearchSpec longSearch(String name) {
+    checkState(fieldType().equals(LONG_TYPE) || fieldType().equals(ITERABLE_LONG_TYPE));
+    return addSearchSpec(name, SearchOption.EXACT);
+  }
+
+  public SearchSpec prefix(String name) {
+    return addSearchSpec(name, SearchOption.PREFIX);
+  }
+
+  public SearchSpec storedOnly(String name) {
+    checkState(stored());
+    return addSearchSpec(name, SearchOption.STORE_ONLY);
+  }
+
+  public SearchSpec timestamp(String name) {
+    checkState(fieldType().equals(TIMESTAMP_TYPE));
+    return addSearchSpec(name, SearchOption.RANGE);
+  }
+
+  /** A builder for {@link IndexedField}. */
+  @AutoValue.Builder
+  public abstract static class Builder<I, T> {
+
+    public abstract IndexedField.Builder<I, T> name(String name);
+
+    public abstract IndexedField.Builder<I, T> description(Optional<String> description);
+
+    public abstract IndexedField.Builder<I, T> description(String description);
+
+    public abstract Builder<I, T> required(boolean required);
+
+    public Builder<I, T> required() {
+      required(true);
+      return this;
+    }
+
+    /** Allow reading the actual data from the index. */
+    public abstract Builder<I, T> stored(boolean stored);
+
+    public Builder<I, T> stored() {
+      stored(true);
+      return this;
+    }
+
+    abstract Builder<I, T> repeatable(boolean repeatable);
+
+    public abstract Builder<I, T> size(Optional<Integer> value);
+
+    public abstract Builder<I, T> size(Integer value);
+
+    public abstract Builder<I, T> getter(Getter<I, T> getter);
+
+    public abstract Builder<I, T> fieldSetter(Optional<Setter<I, T>> setter);
+
+    abstract TypeToken<T> fieldType();
+
+    public abstract Builder<I, T> fieldType(TypeToken<T> type);
+
+    public abstract Builder<I, T> protoConverter(
+        Optional<ProtoConverter<? extends MessageLite, ?>> value);
+
+    abstract IndexedField<I, T> autoBuild(); // not public
+
+    public final IndexedField<I, T> build() {
+      boolean isRepeatable = fieldType().isSubtypeOf(Iterable.class);
+      repeatable(isRepeatable);
+      IndexedField<I, T> field = autoBuild();
+      checkName(field.name());
+      checkArgument(!field.size().isPresent() || field.size().get() > 0);
+      return field;
+    }
+
+    public final IndexedField<I, T> build(Getter<I, T> getter, Setter<I, T> setter) {
+      return this.getter(getter).fieldSetter(Optional.of(setter)).build();
+    }
+
+    public final IndexedField<I, T> build(
+        Getter<I, T> getter,
+        Setter<I, T> setter,
+        ProtoConverter<? extends MessageLite, ?> protoConverter) {
+      return this.getter(getter)
+          .fieldSetter(Optional.of(setter))
+          .protoConverter(Optional.of(protoConverter))
+          .build();
+    }
+
+    public final IndexedField<I, T> build(Getter<I, T> getter) {
+      return this.getter(getter).fieldSetter(Optional.empty()).build();
+    }
+
+    private static String checkName(String name) {
+      String allowedCharacters = "abcdefghijklmnopqrstuvwxyz0123456789_";
+      CharMatcher m = CharMatcher.anyOf(allowedCharacters + allowedCharacters.toUpperCase());
+      checkArgument(name != null && m.matchesAllOf(name), "illegal field name: %s", name);
+      return name;
+    }
+  }
+
+  private Map<String, SearchSpec> searchSpecs = new HashMap<>();
+
+  /** The name to store this field under. */
+  public abstract String name();
+
+  /** Optional description of the field data. */
+  public abstract Optional<String> description();
+
+  /** True if this field is mandatory. Default is false. */
+  public abstract boolean required();
+
+  /** Allow reading the actual data from the index. Default is false. */
+  public abstract boolean stored();
+
+  /** True if this field is repeatable. */
+  public abstract boolean repeatable();
+
+  /**
+   * Optional size constrain on the field. The size is not constrained if this property is {@link
+   * Optional#empty()}
+   */
+  public abstract Optional<Integer> size();
+
+  /** See {@link Getter} */
+  public abstract Getter<I, T> getter();
+
+  /** See {@link Setter} */
+  public abstract Optional<Setter<I, T>> fieldSetter();
+
+  /**
+   * The {@link TypeToken} describing the contents of the field. See static constants for the common
+   * supported types.
+   *
+   * @return {@link TypeToken} of this field.
+   */
+  public abstract TypeToken<T> fieldType();
+
+  /** If the {@link #fieldType()} is proto, the converter to use on byte/proto conversions. */
+  public abstract Optional<ProtoConverter<? extends MessageLite, ?>> protoConverter();
+
+  /**
+   * Returns all {@link SearchSpec}, enabled on this field.
+   *
+   * <p>Note: weather or not a search is supported by the index depends on {@link Schema} version.
+   */
+  public ImmutableMap<String, SearchSpec> getSearchSpecs() {
+    return ImmutableMap.copyOf(searchSpecs);
+  }
+
+  /**
+   * Get the field contents from the input object.
+   *
+   * @param input input object.
+   * @return the field value(s) to index.
+   */
+  @Nullable
+  public T get(I input) {
+    try {
+      return getter().get(input);
+    } catch (IOException e) {
+      throw new StorageException(e);
+    }
+  }
+
+  @SuppressWarnings("unchecked")
+  public boolean setIfPossible(I object, StoredValue doc) {
+    if (!fieldSetter().isPresent()) {
+      return false;
+    }
+
+    if (this.fieldType().equals(STRING_TYPE)) {
+      fieldSetter().get().set(object, (T) doc.asString());
+      return true;
+    } else if (this.fieldType().equals(ITERABLE_STRING_TYPE)) {
+      fieldSetter().get().set(object, (T) doc.asStrings());
+      return true;
+    } else if (this.fieldType().equals(INTEGER_TYPE)) {
+      fieldSetter().get().set(object, (T) doc.asInteger());
+      return true;
+    } else if (this.fieldType().equals(ITERABLE_INTEGER_TYPE)) {
+      fieldSetter().get().set(object, (T) doc.asIntegers());
+      return true;
+    } else if (this.fieldType().equals(LONG_TYPE)) {
+      fieldSetter().get().set(object, (T) doc.asLong());
+      return true;
+    } else if (this.fieldType().equals(ITERABLE_LONG_TYPE)) {
+      fieldSetter().get().set(object, (T) doc.asLongs());
+      return true;
+    } else if (this.fieldType().equals(BYTE_ARRAY_TYPE)) {
+      fieldSetter().get().set(object, (T) doc.asByteArray());
+      return true;
+    } else if (this.fieldType().equals(ITERABLE_BYTE_ARRAY_TYPE)) {
+      fieldSetter().get().set(object, (T) doc.asByteArrays());
+      return true;
+    } else if (this.fieldType().equals(TIMESTAMP_TYPE)) {
+      checkState(!repeatable(), "can't repeat timestamp values");
+      fieldSetter().get().set(object, (T) doc.asTimestamp());
+      return true;
+    } else if (isProtoType()) {
+      MessageLite proto = doc.asProto();
+      if (proto != null) {
+        fieldSetter().get().set(object, (T) proto);
+        return true;
+      }
+      byte[] bytes = doc.asByteArray();
+      if (bytes != null && protoConverter().isPresent()) {
+        fieldSetter().get().set(object, (T) parseProtoFrom(bytes));
+        return true;
+      }
+    } else if (isProtoIterableType()) {
+      Iterable<MessageLite> protos = doc.asProtos();
+      if (protos != null) {
+        fieldSetter().get().set(object, (T) protos);
+        return true;
+      }
+      Iterable<byte[]> bytes = doc.asByteArrays();
+      if (bytes != null && protoConverter().isPresent()) {
+        fieldSetter().get().set(object, (T) decodeProtos(bytes));
+        return true;
+      }
+    }
+    return false;
+  }
+
+  /** Returns true if the {@link #fieldType} is a proto message. */
+  public boolean isProtoType() {
+    if (repeatable()) {
+      return false;
+    }
+    return MESSAGE_TYPE.isSupertypeOf(fieldType());
+  }
+
+  /** Returns true if the {@link #fieldType} is a list of proto messages. */
+  public boolean isProtoIterableType() {
+    if (!repeatable()) {
+      return false;
+    }
+    if (!(fieldType().getType() instanceof ParameterizedType)) {
+      return false;
+    }
+    ParameterizedType parameterizedType = (ParameterizedType) fieldType().getType();
+    if (parameterizedType.getActualTypeArguments().length != 1) {
+      return false;
+    }
+    Type type = parameterizedType.getActualTypeArguments()[0];
+    return MESSAGE_TYPE.isSupertypeOf(type);
+  }
+
+  private ImmutableList<MessageLite> decodeProtos(Iterable<byte[]> raw) {
+    return StreamSupport.stream(raw.spliterator(), false)
+        .map(bytes -> parseProtoFrom(bytes))
+        .collect(toImmutableList());
+  }
+
+  private MessageLite parseProtoFrom(byte[] bytes) {
+    return Protos.parseUnchecked(protoConverter().get().getParser(), bytes);
+  }
+}
diff --git a/java/com/google/gerrit/index/Schema.java b/java/com/google/gerrit/index/Schema.java
index 91c3f70..403be35 100644
--- a/java/com/google/gerrit/index/Schema.java
+++ b/java/com/google/gerrit/index/Schema.java
@@ -14,67 +14,134 @@
 
 package com.google.gerrit.index;
 
+import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.collect.ImmutableMap.toImmutableMap;
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
 
 import com.google.common.base.MoreObjects;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.List;
 import java.util.Objects;
 import java.util.Optional;
+import java.util.Set;
+import java.util.function.Function;
 
 /** Specific version of a secondary index schema. */
 public class Schema<T> {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   public static class Builder<T> {
-    private final List<FieldDef<T, ?>> fields = new ArrayList<>();
-    private boolean useLegacyNumericFields;
+    private final List<SchemaField<T, ?>> searchFields = new ArrayList<>();
+    private final List<IndexedField<T, ?>> indexedFields = new ArrayList<>();
+
+    private Optional<Integer> version = Optional.empty();
+
+    public Builder<T> version(int version) {
+      this.version = Optional.of(version);
+      return this;
+    }
 
     public Builder<T> add(Schema<T> schema) {
-      this.fields.addAll(schema.getFields().values());
+      this.indexedFields.addAll(schema.getIndexFields().values());
+      this.searchFields.addAll(schema.getSchemaFields().values());
+      if (!version.isPresent()) {
+        version(schema.getVersion() + 1);
+      }
       return this;
     }
 
     @SafeVarargs
     public final Builder<T> add(FieldDef<T, ?>... fields) {
-      this.fields.addAll(Arrays.asList(fields));
+      return add(ImmutableList.copyOf(fields));
+    }
+
+    public final Builder<T> add(ImmutableList<FieldDef<T, ?>> fields) {
+      this.searchFields.addAll(fields);
       return this;
     }
 
     @SafeVarargs
     public final Builder<T> remove(FieldDef<T, ?>... fields) {
-      this.fields.removeAll(Arrays.asList(fields));
+      this.searchFields.removeAll(Arrays.asList(fields));
       return this;
     }
 
-    public Builder<T> legacyNumericFields(boolean useLegacyNumericFields) {
-      this.useLegacyNumericFields = useLegacyNumericFields;
+    @SafeVarargs
+    public final Builder<T> addSearchSpecs(IndexedField<T, ?>.SearchSpec... searchSpecs) {
+      return addSearchSpecs(ImmutableList.copyOf(searchSpecs));
+    }
+
+    public Builder<T> addSearchSpecs(ImmutableList<IndexedField<T, ?>.SearchSpec> searchSpecs) {
+      for (IndexedField<T, ?>.SearchSpec searchSpec : searchSpecs) {
+        checkArgument(
+            this.indexedFields.contains(searchSpec.getField()),
+            "%s spec can only be added to the schema that contains %s field",
+            searchSpec.getName(),
+            searchSpec.getField().name());
+      }
+      this.searchFields.addAll(searchSpecs);
+      return this;
+    }
+
+    @SafeVarargs
+    public final Builder<T> addIndexedFields(IndexedField<T, ?>... fields) {
+      return addIndexedFields(ImmutableList.copyOf(fields));
+    }
+
+    public Builder<T> addIndexedFields(ImmutableList<IndexedField<T, ?>> indexedFields) {
+      this.indexedFields.addAll(indexedFields);
+      return this;
+    }
+
+    @SafeVarargs
+    public final Builder<T> remove(IndexedField<T, ?>.SearchSpec... searchSpecs) {
+      this.searchFields.removeAll(Arrays.asList(searchSpecs));
+      return this;
+    }
+
+    @SafeVarargs
+    public final Builder<T> remove(IndexedField<T, ?>... indexedFields) {
+      for (IndexedField<T, ?> field : indexedFields) {
+        ImmutableMap<String, ? extends IndexedField<T, ?>.SearchSpec> searchSpecs =
+            field.getSearchSpecs();
+        checkArgument(
+            !searchSpecs.values().stream().anyMatch(this.searchFields::contains),
+            "Field %s can be only removed from schema after all of its searches are removed.",
+            field.name());
+      }
+      this.indexedFields.removeAll(Arrays.asList(indexedFields));
       return this;
     }
 
     public Schema<T> build() {
-      return new Schema<>(useLegacyNumericFields, ImmutableList.copyOf(fields));
+      checkState(version.isPresent());
+      return new Schema<>(
+          version.get(), ImmutableList.copyOf(indexedFields), ImmutableList.copyOf(searchFields));
     }
   }
 
   public static class Values<T> {
-    private final FieldDef<T, ?> field;
+    private final SchemaField<T, ?> field;
     private final Iterable<?> values;
 
-    private Values(FieldDef<T, ?> field, Iterable<?> values) {
+    private Values(SchemaField<T, ?> field, Iterable<?> values) {
       this.field = field;
       this.values = values;
     }
 
-    public FieldDef<T, ?> getField() {
+    public SchemaField<T, ?> getField() {
       return field;
     }
 
@@ -83,59 +150,67 @@
     }
   }
 
-  private static <T> FieldDef<T, ?> checkSame(FieldDef<T, ?> f1, FieldDef<T, ?> f2) {
+  private static <T> SchemaField<T, ?> checkSame(SchemaField<T, ?> f1, SchemaField<T, ?> f2) {
     checkState(f1 == f2, "Mismatched %s fields: %s != %s", f1.getName(), f1, f2);
     return f1;
   }
 
-  private final ImmutableMap<String, FieldDef<T, ?>> fields;
-  private final ImmutableMap<String, FieldDef<T, ?>> storedFields;
-  private final boolean useLegacyNumericFields;
+  private final ImmutableSet<String> storedFields;
+
+  private final ImmutableMap<String, SchemaField<T, ?>> schemaFields;
+  private final ImmutableMap<String, IndexedField<T, ?>> indexedFields;
 
   private int version;
 
-  public Schema(boolean useLegacyNumericFields, Iterable<FieldDef<T, ?>> fields) {
-    this(0, useLegacyNumericFields, fields);
-  }
-
-  public Schema(int version, boolean useLegacyNumericFields, Iterable<FieldDef<T, ?>> fields) {
+  private Schema(
+      int version,
+      ImmutableList<IndexedField<T, ?>> indexedFields,
+      ImmutableList<SchemaField<T, ?>> schemaFields) {
     this.version = version;
-    ImmutableMap.Builder<String, FieldDef<T, ?>> b = ImmutableMap.builder();
-    ImmutableMap.Builder<String, FieldDef<T, ?>> sb = ImmutableMap.builder();
-    for (FieldDef<T, ?> f : fields) {
-      b.put(f.getName(), f);
-      if (f.isStored()) {
-        sb.put(f.getName(), f);
-      }
-    }
-    this.fields = b.build();
-    this.storedFields = sb.build();
-    this.useLegacyNumericFields = useLegacyNumericFields;
+
+    this.indexedFields =
+        indexedFields.stream().collect(toImmutableMap(IndexedField::name, Function.identity()));
+    this.schemaFields =
+        schemaFields.stream().collect(toImmutableMap(SchemaField::getName, Function.identity()));
+
+    Set<String> duplicateKeys =
+        Sets.intersection(this.schemaFields.keySet(), this.indexedFields.keySet());
+    checkArgument(
+        duplicateKeys.isEmpty(),
+        "DuplicateKeys found %s, indexFields:%s, schemaFields: %s",
+        duplicateKeys,
+        this.indexedFields.keySet(),
+        this.schemaFields.keySet());
+    this.storedFields =
+        schemaFields.stream()
+            .filter(SchemaField::isStored)
+            .map(SchemaField::getName)
+            .collect(toImmutableSet());
   }
 
   public final int getVersion() {
     return version;
   }
 
-  public final boolean useLegacyNumericFields() {
-    return useLegacyNumericFields;
-  }
-
   /**
    * Get all fields in this schema.
    *
    * <p>This is primarily useful for iteration. Most callers should prefer one of the helper methods
-   * {@link #getField(FieldDef, FieldDef...)} or {@link #hasField(FieldDef)} to looking up fields by
-   * name
+   * {@link #getField(SchemaField, SchemaField...)} or {@link #hasField(SchemaField)} to looking up
+   * fields by name
    *
    * @return all fields in this schema indexed by name.
    */
-  public final ImmutableMap<String, FieldDef<T, ?>> getFields() {
-    return fields;
+  public final ImmutableMap<String, SchemaField<T, ?>> getSchemaFields() {
+    return ImmutableMap.copyOf(schemaFields);
+  }
+
+  public final ImmutableMap<String, IndexedField<T, ?>> getIndexFields() {
+    return indexedFields;
   }
 
   /** Returns all fields in this schema where {@link FieldDef#isStored()} is true. */
-  public final ImmutableMap<String, FieldDef<T, ?>> getStoredFields() {
+  public final ImmutableSet<String> getStoredFields() {
     return storedFields;
   }
 
@@ -148,13 +223,14 @@
    *     absent if no field matches.
    */
   @SafeVarargs
-  public final Optional<FieldDef<T, ?>> getField(FieldDef<T, ?> first, FieldDef<T, ?>... rest) {
-    FieldDef<T, ?> field = fields.get(first.getName());
+  public final Optional<SchemaField<T, ?>> getField(
+      SchemaField<T, ?> first, SchemaField<T, ?>... rest) {
+    SchemaField<T, ?> field = getSchemaField(first);
     if (field != null) {
       return Optional.of(checkSame(field, first));
     }
-    for (FieldDef<T, ?> f : rest) {
-      field = fields.get(f.getName());
+    for (SchemaField<T, ?> f : rest) {
+      field = getSchemaField(first);
       if (field != null) {
         return Optional.of(checkSame(field, f));
       }
@@ -168,8 +244,8 @@
    * @param field field to look up.
    * @return whether the field is present.
    */
-  public final boolean hasField(FieldDef<T, ?> field) {
-    FieldDef<T, ?> f = fields.get(field.getName());
+  public final boolean hasField(SchemaField<T, ?> field) {
+    SchemaField<T, ?> f = getSchemaField(field);
     if (f == null) {
       return false;
     }
@@ -177,7 +253,20 @@
     return true;
   }
 
-  private Values<T> fieldValues(T obj, FieldDef<T, ?> f, ImmutableSet<String> skipFields) {
+  public final boolean hasField(String fieldName) {
+    return this.getSchemaField(fieldName) != null;
+  }
+
+  private SchemaField<T, ?> getSchemaField(SchemaField<T, ?> field) {
+    return getSchemaField(field.getName());
+  }
+
+  public SchemaField<T, ?> getSchemaField(String fieldName) {
+    return schemaFields.get(fieldName);
+  }
+
+  private @Nullable Values<T> fieldValues(
+      T obj, SchemaField<T, ?> f, ImmutableSet<String> skipFields) {
     if (skipFields.contains(f.getName())) {
       return null;
     }
@@ -215,10 +304,11 @@
    */
   public final Iterable<Values<T>> buildFields(T obj, ImmutableSet<String> skipFields) {
     try {
-      return fields.values().stream()
+      return schemaFields.values().stream()
           .map(f -> fieldValues(obj, f, skipFields))
           .filter(Objects::nonNull)
           .collect(toImmutableList());
+
     } catch (StorageException e) {
       return ImmutableList.of();
     }
@@ -226,10 +316,9 @@
 
   @Override
   public String toString() {
-    return MoreObjects.toStringHelper(this).addValue(fields.keySet()).toString();
-  }
-
-  public void setVersion(int version) {
-    this.version = version;
+    return MoreObjects.toStringHelper(this)
+        .addValue(indexedFields.keySet())
+        .addValue(schemaFields.keySet())
+        .toString();
   }
 }
diff --git a/java/com/google/gerrit/index/SchemaDefinitions.java b/java/com/google/gerrit/index/SchemaDefinitions.java
index e8efd22..4e58d91 100644
--- a/java/com/google/gerrit/index/SchemaDefinitions.java
+++ b/java/com/google/gerrit/index/SchemaDefinitions.java
@@ -42,6 +42,7 @@
     return name;
   }
 
+  /** Returns all schemas sorted by version (ascending). */
   public final ImmutableSortedMap<Integer, Schema<V>> getSchemas() {
     return schemas;
   }
diff --git a/java/com/google/gerrit/index/SchemaFieldDefs.java b/java/com/google/gerrit/index/SchemaFieldDefs.java
new file mode 100644
index 0000000..e0b5dd2
--- /dev/null
+++ b/java/com/google/gerrit/index/SchemaFieldDefs.java
@@ -0,0 +1,103 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.index;
+
+import com.google.gerrit.common.Nullable;
+import java.io.IOException;
+
+/** Interfaces that define properties of fields in {@link Schema}. */
+public class SchemaFieldDefs {
+
+  /**
+   * Definition of a field stored in the secondary index.
+   *
+   * <p>{@link SchemaField}-s must not be changed once introduced to the codebase. Instead, a new
+   * FieldDef must be added and the old one removed from the schema (in two upgrade steps, see
+   * {@code com.google.gerrit.index.IndexUpgradeValidator}).
+   *
+   * @param <I> input type from which documents are created and search results are returned.
+   * @param <T> type that should be extracted from the input object when converting to an index
+   *     document.
+   */
+  public interface SchemaField<T, I> {
+
+    /** Returns whether the field should be stored in the index. */
+    boolean isStored();
+
+    /** Returns whether the field is repeatable. */
+    boolean isRepeatable();
+
+    /**
+     * Get the field contents from the input object.
+     *
+     * @param input input object.
+     * @return the field value(s) to index.
+     */
+    @Nullable
+    I get(T input);
+
+    /** Returns the name of the field. */
+    String getName();
+
+    /**
+     * Returns type of the field; for repeatable fields, the inner type, not the iterable type.
+     * TODO(mariasavtchuk): remove after migrating to the new field formats
+     */
+    FieldType<?> getType();
+
+    /**
+     * Set the field contents back to an object. Used to reconstruct fields from indexed values.
+     * No-op if the field can't be reconstructed.
+     *
+     * @param object input object.
+     * @param doc indexed document
+     * @return {@code true} if the field was set, {@code false} otherwise
+     */
+    boolean setIfPossible(T object, StoredValue doc);
+  }
+
+  /**
+   * Getter to extract value that should be stored in index from the input object.
+   *
+   * <p>This interface allows to specify a method or lambda for populating an index field. Note that
+   * for existing fields, changing the code of either the {@link Getter} implementation or the
+   * method(s) that it calls would invalidate existing index data. Therefore, instead of changing
+   * the semantics of an existing field, a new field must be added using the new semantics from the
+   * start. The old field can be removed in another upgrade step (cf. {@code
+   * com.google.gerrit.index.IndexUpgradeValidator}).
+   *
+   * @param <I> type from which documents are created and search results are returned.
+   * @param <T> type that should be extracted from the input object to an index field.
+   */
+  @FunctionalInterface
+  public interface Getter<I, T> {
+    @Nullable
+    T get(I input) throws IOException;
+  }
+
+  /**
+   * Setter to reconstruct fields from indexed values back to an object.
+   *
+   * <p>See {@link Getter} for restrictions on changing the implementation.
+   *
+   * @param <I> type from which documents are created and search results are returned.
+   * @param <T> type that should be extracted from the input object when converting toto an index
+   *     field.
+   */
+  @FunctionalInterface
+  public interface Setter<I, T> {
+    void set(I object, T value);
+  }
+}
diff --git a/java/com/google/gerrit/index/SchemaUtil.java b/java/com/google/gerrit/index/SchemaUtil.java
index 9599d6a..8f47cf5 100644
--- a/java/com/google/gerrit/index/SchemaUtil.java
+++ b/java/com/google/gerrit/index/SchemaUtil.java
@@ -25,7 +25,6 @@
 import java.lang.reflect.Field;
 import java.lang.reflect.Modifier;
 import java.lang.reflect.ParameterizedType;
-import java.util.Collection;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.HashSet;
@@ -49,7 +48,12 @@
             @SuppressWarnings("unchecked")
             Schema<V> schema = (Schema<V>) f.get(null);
             checkArgument(f.getName().startsWith("V"));
-            schema.setVersion(Integer.parseInt(f.getName().substring(1)));
+            int versionName = Integer.parseInt(f.getName().substring(1));
+            checkArgument(
+                versionName == schema.getVersion(),
+                "Schema version %s does not match its name %s",
+                schema.getVersion(),
+                f.getName());
             schemas.put(schema.getVersion(), schema);
           } catch (IllegalAccessException e) {
             throw new IllegalArgumentException(e);
@@ -66,29 +70,56 @@
     return ImmutableSortedMap.copyOf(schemas);
   }
 
-  public static <V> Schema<V> schema(Collection<FieldDef<V, ?>> fields) {
-    return new Schema<>(true, ImmutableList.copyOf(fields));
+  @SafeVarargs
+  public static <V> Schema<V> schema(FieldDef<V, ?>... fields) {
+    return new Schema.Builder<V>().version(0).add(fields).build();
   }
 
-  public static <V> Schema<V> schema(Schema<V> schema, boolean useLegacyNumericFields) {
-    return new Schema<>(
-        useLegacyNumericFields,
-        new ImmutableList.Builder<FieldDef<V, ?>>().addAll(schema.getFields().values()).build());
+  @SafeVarargs
+  public static <V> Schema<V> schema(int version, FieldDef<V, ?>... fields) {
+    return new Schema.Builder<V>().version(version).add(fields).build();
+  }
+
+  public static <V> Schema<V> schema(int version, ImmutableList<FieldDef<V, ?>> fields) {
+    return new Schema.Builder<V>().version(version).add(fields).build();
   }
 
   @SafeVarargs
   public static <V> Schema<V> schema(Schema<V> schema, FieldDef<V, ?>... moreFields) {
-    return new Schema<>(
-        true,
-        new ImmutableList.Builder<FieldDef<V, ?>>()
-            .addAll(schema.getFields().values())
-            .addAll(ImmutableList.copyOf(moreFields))
-            .build());
+    return new Schema.Builder<V>().add(schema).add(moreFields).build();
   }
 
-  @SafeVarargs
-  public static <V> Schema<V> schema(FieldDef<V, ?>... fields) {
-    return new Schema<>(true, ImmutableList.copyOf(fields));
+  public static <V> Schema<V> schema(
+      int version,
+      ImmutableList<FieldDef<V, ?>> fieldDefs,
+      ImmutableList<IndexedField<V, ?>> indexedFields,
+      ImmutableList<IndexedField<V, ?>.SearchSpec> searchSpecs) {
+    return new Schema.Builder<V>()
+        .version(version)
+        .add(fieldDefs)
+        .addIndexedFields(indexedFields)
+        .addSearchSpecs(searchSpecs)
+        .build();
+  }
+
+  public static <V> Schema<V> schema(
+      Schema<V> schema,
+      ImmutableList<FieldDef<V, ?>> fieldDefs,
+      ImmutableList<IndexedField<V, ?>> indexedFields,
+      ImmutableList<IndexedField<V, ?>.SearchSpec> searchSpecs) {
+    return new Schema.Builder<V>()
+        .add(schema)
+        .add(fieldDefs)
+        .addIndexedFields(indexedFields)
+        .addSearchSpecs(searchSpecs)
+        .build();
+  }
+
+  public static <V> Schema<V> schema(
+      ImmutableList<FieldDef<V, ?>> fieldDefs,
+      ImmutableList<IndexedField<V, ?>> indexFields,
+      ImmutableList<IndexedField<V, ?>.SearchSpec> searchSpecs) {
+    return schema(/* version= */ 0, fieldDefs, indexFields, searchSpecs);
   }
 
   public static Set<String> getPersonParts(PersonIdent person) {
diff --git a/java/com/google/gerrit/index/SearchOption.java b/java/com/google/gerrit/index/SearchOption.java
new file mode 100644
index 0000000..3fbb68a
--- /dev/null
+++ b/java/com/google/gerrit/index/SearchOption.java
@@ -0,0 +1,29 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.index;
+
+/** Tokenization options enabled on {@link IndexedField}. */
+public enum SearchOption {
+  /** Enables range queries on the field. */
+  RANGE,
+  /** Enables prefix-match search on the field. */
+  PREFIX,
+  /** Enables exact-match search on the field. */
+  EXACT,
+  /** Enables fuzzy-match search on the field. */
+  FULL_TEXT,
+  /** The field can not be searched and is only returned as a payload from the index. */
+  STORE_ONLY,
+}
diff --git a/java/com/google/gerrit/index/StoredValue.java b/java/com/google/gerrit/index/StoredValue.java
index fe790c5..a7e7c26 100644
--- a/java/com/google/gerrit/index/StoredValue.java
+++ b/java/com/google/gerrit/index/StoredValue.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.index;
 
+import com.google.gerrit.common.Nullable;
+import com.google.protobuf.MessageLite;
 import java.sql.Timestamp;
 
 /**
@@ -47,4 +49,22 @@
 
   /** Returns the {@code byte[]} values of the field. */
   Iterable<byte[]> asByteArrays();
+
+  /**
+   * Returns the {@code MessageLite} value of the field.
+   *
+   * <p>Returns {@code null} if value is not stored as protos (e.g. stored as bytes). {@link
+   * #asByteArray} can be called instead to obtain the value.
+   */
+  @Nullable
+  MessageLite asProto();
+
+  /**
+   * Returns the {@code MessageLite} values of the field.
+   *
+   * <p>Returns {@code null} if value is not stored as protos (e.g. stored as bytes). {@link
+   * #asByteArrays} can be called instead to obtain the value.
+   */
+  @Nullable
+  Iterable<MessageLite> asProtos();
 }
diff --git a/java/com/google/gerrit/index/project/ProjectField.java b/java/com/google/gerrit/index/project/ProjectField.java
index c2c8986..3114b4c 100644
--- a/java/com/google/gerrit/index/project/ProjectField.java
+++ b/java/com/google/gerrit/index/project/ProjectField.java
@@ -26,7 +26,12 @@
 import com.google.gerrit.index.RefState;
 import com.google.gerrit.index.SchemaUtil;
 
-/** Index schema for projects. */
+/**
+ * Index schema for projects.
+ *
+ * <p>Note that this class does not override {@link Object#equals(Object)}. It relies on instances
+ * being singletons so that the default (i.e. reference) comparison works.
+ */
 public class ProjectField {
   private static byte[] toRefState(Project project) {
     return RefState.create(RefNames.REFS_CONFIG, project.getConfigRefState())
diff --git a/java/com/google/gerrit/index/project/ProjectSchemaDefinitions.java b/java/com/google/gerrit/index/project/ProjectSchemaDefinitions.java
index 3cc5f9b..0619566 100644
--- a/java/com/google/gerrit/index/project/ProjectSchemaDefinitions.java
+++ b/java/com/google/gerrit/index/project/ProjectSchemaDefinitions.java
@@ -19,12 +19,18 @@
 import com.google.gerrit.index.Schema;
 import com.google.gerrit.index.SchemaDefinitions;
 
-/** Definition of project index versions (schemata). See {@link SchemaDefinitions}. */
+/**
+ * Definition of project index versions (schemata). See {@link SchemaDefinitions}.
+ *
+ * <p>Upgrades are subject to constraints, see {@code
+ * com.google.gerrit.index.IndexUpgradeValidator}.
+ */
 public class ProjectSchemaDefinitions extends SchemaDefinitions<ProjectData> {
 
   @Deprecated
   static final Schema<ProjectData> V1 =
       schema(
+          /* version= */ 1,
           ProjectField.NAME,
           ProjectField.DESCRIPTION,
           ProjectField.PARENT_NAME,
@@ -38,7 +44,10 @@
   @Deprecated static final Schema<ProjectData> V3 = schema(V2);
 
   // Lucene index was changed to add an additional field for sorting.
-  static final Schema<ProjectData> V4 = schema(V3);
+  @Deprecated static final Schema<ProjectData> V4 = schema(V3);
+
+  // Upgrade Lucene to 7.x requires reindexing.
+  static final Schema<ProjectData> V5 = schema(V4);
 
   /**
    * Name of the project index to be used when contacting index backends or loading configurations.
diff --git a/java/com/google/gerrit/index/query/FieldBundle.java b/java/com/google/gerrit/index/query/FieldBundle.java
index 6ecb6e6..60881df 100644
--- a/java/com/google/gerrit/index/query/FieldBundle.java
+++ b/java/com/google/gerrit/index/query/FieldBundle.java
@@ -19,7 +19,7 @@
 import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.ListMultimap;
-import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
 
 /** FieldBundle is an abstraction that allows retrieval of raw values from different sources. */
 public class FieldBundle {
@@ -34,8 +34,8 @@
   /**
    * Get a field's value based on the field definition.
    *
-   * @param fieldDef the definition of the field of which the value should be retrieved. The field
-   *     must be stored and contained in the result set as specified by {@link
+   * @param schemaField the definition of the field of which the value should be retrieved. The
+   *     field must be stored and contained in the result set as specified by {@link
    *     com.google.gerrit.index.QueryOptions}.
    * @param <T> Data type of the returned object based on the field definition
    * @return Either a single element or an Iterable based on the field definition. An empty list is
@@ -44,16 +44,16 @@
    *     check is only enforced on non-repeatable fields.
    */
   @SuppressWarnings("unchecked")
-  public <T> T getValue(FieldDef<?, T> fieldDef) {
-    checkArgument(fieldDef.isStored(), "Field must be stored");
+  public <T> T getValue(SchemaField<?, T> schemaField) {
+    checkArgument(schemaField.isStored(), "Field must be stored");
     checkArgument(
-        fields.containsKey(fieldDef.getName()) || fieldDef.isRepeatable(),
+        fields.containsKey(schemaField.getName()) || schemaField.isRepeatable(),
         "Field %s is not in result set %s",
-        fieldDef.getName(),
+        schemaField.getName(),
         fields.keySet());
 
-    Iterable<Object> result = fields.get(fieldDef.getName());
-    if (fieldDef.isRepeatable()) {
+    Iterable<Object> result = fields.get(schemaField.getName());
+    if (schemaField.isRepeatable()) {
       return (T) result;
     }
     return (T) Iterables.getOnlyElement(result);
diff --git a/java/com/google/gerrit/index/query/IndexPredicate.java b/java/com/google/gerrit/index/query/IndexPredicate.java
index b65fb96..de81c47 100644
--- a/java/com/google/gerrit/index/query/IndexPredicate.java
+++ b/java/com/google/gerrit/index/query/IndexPredicate.java
@@ -21,8 +21,8 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.primitives.Ints;
 import com.google.common.primitives.Longs;
-import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.FieldType;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
 import java.util.Objects;
 import java.util.Set;
 import java.util.stream.StreamSupport;
@@ -37,19 +37,19 @@
    */
   private static final Splitter FULL_TEXT_SPLITTER = Splitter.on(CharMatcher.anyOf(" ,.-:\\/_=\n"));
 
-  private final FieldDef<I, ?> def;
+  private final SchemaField<I, ?> def;
 
-  protected IndexPredicate(FieldDef<I, ?> def, String value) {
+  protected IndexPredicate(SchemaField<I, ?> def, String value) {
     super(def.getName(), value);
     this.def = def;
   }
 
-  protected IndexPredicate(FieldDef<I, ?> def, String name, String value) {
+  protected IndexPredicate(SchemaField<I, ?> def, String name, String value) {
     super(name, value);
     this.def = def;
   }
 
-  public FieldDef<I, ?> getField() {
+  public SchemaField<I, ?> getField() {
     return def;
   }
 
diff --git a/java/com/google/gerrit/index/query/QueryProcessor.java b/java/com/google/gerrit/index/query/QueryProcessor.java
index 21d4c2e..959694b 100644
--- a/java/com/google/gerrit/index/query/QueryProcessor.java
+++ b/java/com/google/gerrit/index/query/QueryProcessor.java
@@ -367,7 +367,7 @@
       return requestedFields;
     }
     Index<?, T> index = indexes.getSearchIndex();
-    return index != null ? index.getSchema().getStoredFields().keySet() : ImmutableSet.of();
+    return index != null ? index.getSchema().getStoredFields() : ImmutableSet.of();
   }
 
   /**
diff --git a/java/com/google/gerrit/index/query/TooManyTermsInQueryException.java b/java/com/google/gerrit/index/query/TooManyTermsInQueryException.java
index b0a394e..9158a39 100644
--- a/java/com/google/gerrit/index/query/TooManyTermsInQueryException.java
+++ b/java/com/google/gerrit/index/query/TooManyTermsInQueryException.java
@@ -14,16 +14,24 @@
 
 package com.google.gerrit.index.query;
 
+import com.google.gerrit.common.UsedAt;
+
 public class TooManyTermsInQueryException extends QueryParseException {
   private static final long serialVersionUID = 1L;
 
   private static final String MESSAGE = "too many terms in query";
 
+  @UsedAt(UsedAt.Project.GOOGLE)
   public TooManyTermsInQueryException() {
     super(MESSAGE);
   }
 
+  @UsedAt(UsedAt.Project.GOOGLE)
   public TooManyTermsInQueryException(Throwable why) {
     super(MESSAGE, why);
   }
+
+  public TooManyTermsInQueryException(int numTerms, int maxConfiguredTerms) {
+    super(MESSAGE + String.format(": %d terms (max = %d)", numTerms, maxConfiguredTerms));
+  }
 }
diff --git a/java/com/google/gerrit/index/testing/AbstractFakeIndex.java b/java/com/google/gerrit/index/testing/AbstractFakeIndex.java
index 4241828..50efef0 100644
--- a/java/com/google/gerrit/index/testing/AbstractFakeIndex.java
+++ b/java/com/google/gerrit/index/testing/AbstractFakeIndex.java
@@ -25,10 +25,10 @@
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.entities.Project;
-import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.Index;
 import com.google.gerrit.index.QueryOptions;
 import com.google.gerrit.index.Schema;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
 import com.google.gerrit.index.project.ProjectData;
 import com.google.gerrit.index.project.ProjectIndex;
 import com.google.gerrit.index.query.DataSource;
@@ -165,7 +165,7 @@
         K searchAfter = null;
         for (V result : results) {
           ImmutableListMultimap.Builder<String, Object> fields = ImmutableListMultimap.builder();
-          for (FieldDef<V, ?> field : getSchema().getFields().values()) {
+          for (SchemaField<V, ?> field : getSchema().getSchemaFields().values()) {
             if (field.get(result) == null) {
               continue;
             }
@@ -249,7 +249,7 @@
     @Override
     protected Map<String, Object> docFor(ChangeData value) {
       ImmutableMap.Builder<String, Object> doc = ImmutableMap.builder();
-      for (FieldDef<ChangeData, ?> field : getSchema().getFields().values()) {
+      for (SchemaField<ChangeData, ?> field : getSchema().getSchemaFields().values()) {
         if (ChangeField.MERGEABLE.getName().equals(field.getName()) && skipMergable) {
           continue;
         }
@@ -267,7 +267,7 @@
           changeDataFactory.create(
               Project.nameKey((String) doc.get(ChangeField.PROJECT.getName())),
               Change.id(Integer.valueOf((String) doc.get(ChangeField.LEGACY_ID_STR.getName()))));
-      for (FieldDef<ChangeData, ?> field : getSchema().getFields().values()) {
+      for (SchemaField<ChangeData, ?> field : getSchema().getSchemaFields().values()) {
         field.setIfPossible(cd, new FakeStoredValue(doc.get(field.getName())));
       }
       return cd;
diff --git a/java/com/google/gerrit/index/testing/BUILD b/java/com/google/gerrit/index/testing/BUILD
index a30eaca..9af2598 100644
--- a/java/com/google/gerrit/index/testing/BUILD
+++ b/java/com/google/gerrit/index/testing/BUILD
@@ -23,5 +23,6 @@
         "//lib/flogger:api",
         "//lib/guice",
         "//lib/guice:guice-assistedinject",
+        "//proto:entities_java_proto",
     ],
 )
diff --git a/java/com/google/gerrit/index/testing/FakeStoredValue.java b/java/com/google/gerrit/index/testing/FakeStoredValue.java
index 5091068..133e0ab 100644
--- a/java/com/google/gerrit/index/testing/FakeStoredValue.java
+++ b/java/com/google/gerrit/index/testing/FakeStoredValue.java
@@ -14,15 +14,29 @@
 
 package com.google.gerrit.index.testing;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.index.StoredValue;
+import com.google.protobuf.MessageLite;
 import java.sql.Timestamp;
 
 /** Bridge to recover fields from the fake index. */
 public class FakeStoredValue implements StoredValue {
   private final Object field;
+  /**
+   * Some index implementations store protos, some convert them to bytes first.
+   *
+   * <p>This property is true if field is stored in proto format.
+   */
+  private final boolean isProto;
 
   public FakeStoredValue(Object field) {
     this.field = field;
+    this.isProto = false;
+  }
+
+  public FakeStoredValue(Object field, boolean isProto) {
+    this.field = field;
+    this.isProto = isProto;
   }
 
   @Override
@@ -69,6 +83,25 @@
   }
 
   @Override
+  @Nullable
+  public MessageLite asProto() {
+    if (isProto) {
+      return (MessageLite) field;
+    }
+    return null;
+  }
+
+  @Override
+  @Nullable
+  @SuppressWarnings("unchecked")
+  public Iterable<MessageLite> asProtos() {
+    if (isProto) {
+      return (Iterable<MessageLite>) field;
+    }
+    return null;
+  }
+
+  @Override
   @SuppressWarnings("unchecked")
   public Iterable<byte[]> asByteArrays() {
     return (Iterable<byte[]>) field;
diff --git a/java/com/google/gerrit/index/testing/TestIndexedFields.java b/java/com/google/gerrit/index/testing/TestIndexedFields.java
new file mode 100644
index 0000000..f80b8a1
--- /dev/null
+++ b/java/com/google/gerrit/index/testing/TestIndexedFields.java
@@ -0,0 +1,209 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.index.testing;
+
+import com.google.common.reflect.TypeToken;
+import com.google.gerrit.entities.converter.ChangeProtoConverter;
+import com.google.gerrit.index.IndexedField;
+import com.google.gerrit.index.SchemaFieldDefs.Getter;
+import com.google.gerrit.index.SchemaFieldDefs.Setter;
+import com.google.gerrit.proto.Entities;
+import com.google.gerrit.proto.Entities.Change;
+import com.google.gerrit.proto.Entities.Change_Id;
+import java.io.IOException;
+import java.sql.Timestamp;
+
+/**
+ * Collection of {@link IndexedField}, used in unit tests.
+ *
+ * <p>The list of {@link IndexedField} below are field types, that are currently supported and used
+ * in different index implementations
+ *
+ * <p>They are used in unit tests to make sure these types can be extracted to index and assigned
+ * back to object.
+ */
+public final class TestIndexedFields {
+
+  /** Test input object for {@link IndexedField} */
+  public static class TestIndexedData {
+
+    /** Key that is used to index to identify indexed object */
+    private Object key;
+
+    /** Field value that is extracted from this indexed object to the index document. */
+    private Object testFieldValue;
+
+    public Object getTestField() {
+      return testFieldValue;
+    }
+
+    public void setTestFieldValue(Object testFieldValue) {
+      this.testFieldValue = testFieldValue;
+    }
+
+    public Object getKey() {
+      return key;
+    }
+
+    public void setKey(Object key) {
+      this.key = key;
+    }
+  }
+
+  /** Setter for {@link TestIndexedData} */
+  private static class TestIndexedDataSetter<T> implements Setter<TestIndexedData, T> {
+    @Override
+    public void set(TestIndexedData testIndexedData, T value) {
+      testIndexedData.setTestFieldValue(value);
+    }
+  }
+
+  /** Getter for {@link TestIndexedData} */
+  @SuppressWarnings("unchecked")
+  private static class TestIndexedDataGetter<T> implements Getter<TestIndexedData, T> {
+    @Override
+    public T get(TestIndexedData input) throws IOException {
+      return (T) input.getTestField();
+    }
+  }
+
+  public static <T> TestIndexedDataSetter<T> setter() {
+    return new TestIndexedDataSetter<>();
+  }
+
+  public static <T> TestIndexedDataGetter<T> getter() {
+    return new TestIndexedDataGetter<>();
+  }
+
+  public static final IndexedField<TestIndexedData, Integer> INTEGER_FIELD =
+      IndexedField.<TestIndexedData>integerBuilder("IntegerTestField").build(getter(), setter());
+
+  public static final IndexedField<TestIndexedData, Integer>.SearchSpec INTEGER_FIELD_SPEC =
+      INTEGER_FIELD.integer("integer_test");
+
+  public static final IndexedField<TestIndexedData, Iterable<Integer>> ITERABLE_INTEGER_FIELD =
+      IndexedField.<TestIndexedData>iterableIntegerBuilder("IterableIntegerTestField")
+          .build(getter(), setter());
+
+  public static final IndexedField<TestIndexedData, Iterable<Integer>>.SearchSpec
+      ITERABLE_INTEGER_FIELD_SPEC = ITERABLE_INTEGER_FIELD.integer("iterable_integer_test");
+
+  public static final IndexedField<TestIndexedData, Integer> INTEGER_RANGE_FIELD =
+      IndexedField.<TestIndexedData>integerBuilder("IntegerRangeTestField")
+          .build(TestIndexedFields.getter(), TestIndexedFields.setter());
+  public static final IndexedField<TestIndexedData, Integer>.SearchSpec INTEGER_RANGE_FIELD_SPEC =
+      INTEGER_RANGE_FIELD.range("integer_range_test");
+
+  public static final IndexedField<TestIndexedData, Iterable<Integer>>
+      ITERABLE_INTEGER_RANGE_FIELD =
+          IndexedField.<TestIndexedData>iterableIntegerBuilder("IterableIntegerRangeTestField")
+              .build(TestIndexedFields.getter(), TestIndexedFields.setter());
+
+  public static final IndexedField<TestIndexedData, Iterable<Integer>>.SearchSpec
+      ITERABLE_INTEGER_RANGE_FIELD_SPEC =
+          ITERABLE_INTEGER_RANGE_FIELD.range("iterable_integer_range_test");
+
+  public static final IndexedField<TestIndexedData, Long> LONG_FIELD =
+      IndexedField.<TestIndexedData>longBuilder("LongTestField").build(getter(), setter());
+
+  public static final IndexedField<TestIndexedData, Long>.SearchSpec LONG_FIELD_SPEC =
+      LONG_FIELD.longSearch("long_test");
+
+  public static final IndexedField<TestIndexedData, Iterable<Long>> ITERABLE_LONG_FIELD =
+      IndexedField.<TestIndexedData, Iterable<Long>>builder(
+              "IterableLongTestField", IndexedField.ITERABLE_LONG_TYPE)
+          .build(TestIndexedFields.getter(), TestIndexedFields.setter());
+
+  public static final IndexedField<TestIndexedData, Iterable<Long>>.SearchSpec
+      ITERABLE_LONG_FIELD_SPEC = ITERABLE_LONG_FIELD.longSearch("iterable_long_test");
+
+  public static final IndexedField<TestIndexedData, Long> LONG_RANGE_FIELD =
+      IndexedField.<TestIndexedData>longBuilder("LongRangeTestField")
+          .build(TestIndexedFields.getter(), TestIndexedFields.setter());
+
+  public static final IndexedField<TestIndexedData, Long>.SearchSpec LONG_RANGE_FIELD_SPEC =
+      LONG_RANGE_FIELD.range("long_range_test");
+
+  public static final IndexedField<TestIndexedData, Iterable<Long>> ITERABLE_LONG_RANGE_FIELD =
+      IndexedField.<TestIndexedData, Iterable<Long>>builder(
+              "IterableLongRangeTestField", IndexedField.ITERABLE_LONG_TYPE)
+          .build(TestIndexedFields.getter(), TestIndexedFields.setter());
+
+  public static final IndexedField<TestIndexedData, Iterable<Long>>.SearchSpec
+      ITERABLE_LONG_RANGE_FIELD_SPEC = ITERABLE_LONG_RANGE_FIELD.range("iterable_long_range_test");
+
+  public static final IndexedField<TestIndexedData, Timestamp> TIMESTAMP_FIELD =
+      IndexedField.<TestIndexedData>timestampBuilder("TimestampTestField")
+          .build(getter(), setter());
+
+  public static final IndexedField<TestIndexedData, Timestamp>.SearchSpec TIMESTAMP_FIELD_SPEC =
+      TIMESTAMP_FIELD.timestamp("timestamp_test");
+
+  public static final IndexedField<TestIndexedData, Iterable<String>> ITERABLE_STRING_FIELD =
+      IndexedField.<TestIndexedData>iterableStringBuilder("IterableStringTestField")
+          .build(getter(), setter());
+
+  public static final IndexedField<TestIndexedData, Iterable<String>>.SearchSpec
+      ITERABLE_STRING_FIELD_SPEC = ITERABLE_STRING_FIELD.fullText("iterable_test_string");
+
+  public static final IndexedField<TestIndexedData, String> STRING_FIELD =
+      IndexedField.<TestIndexedData>stringBuilder("StringTestField").build(getter(), setter());
+
+  public static final IndexedField<TestIndexedData, String>.SearchSpec STRING_FIELD_SPEC =
+      STRING_FIELD.fullText("string_test");
+
+  public static final IndexedField<TestIndexedData, Iterable<byte[]>> ITERABLE_STORED_BYTE_FIELD =
+      IndexedField.<TestIndexedData>iterableByteArrayBuilder("IterableByteTestField")
+          .stored()
+          .build(getter(), setter());
+
+  public static final IndexedField<TestIndexedData, Iterable<byte[]>>.SearchSpec
+      ITERABLE_STORED_BYTE_SPEC = ITERABLE_STORED_BYTE_FIELD.storedOnly("iterable_byte_test");
+
+  public static final IndexedField<TestIndexedData, byte[]> STORED_BYTE_FIELD =
+      IndexedField.<TestIndexedData>byteArrayBuilder("ByteTestField")
+          .stored()
+          .build(getter(), setter());
+
+  public static final IndexedField<TestIndexedData, byte[]>.SearchSpec STORED_BYTE_SPEC =
+      STORED_BYTE_FIELD.storedOnly("byte_test");
+
+  public static final IndexedField<TestIndexedData, Entities.Change> STORED_PROTO_FIELD =
+      IndexedField.<TestIndexedData, Entities.Change>builder(
+              "TestChange", new TypeToken<Entities.Change>() {})
+          .stored()
+          .build(getter(), setter(), ChangeProtoConverter.INSTANCE);
+
+  public static final IndexedField<TestIndexedData, Entities.Change>.SearchSpec
+      STORED_PROTO_FIELD_SPEC = STORED_PROTO_FIELD.storedOnly("test_change");
+
+  public static final IndexedField<TestIndexedData, Iterable<Entities.Change>>
+      ITERABLE_STORED_PROTO_FIELD =
+          IndexedField.<TestIndexedData, Iterable<Entities.Change>>builder(
+                  "IterableTestChange", new TypeToken<Iterable<Entities.Change>>() {})
+              .stored()
+              .build(getter(), setter(), ChangeProtoConverter.INSTANCE);
+
+  public static final IndexedField<TestIndexedData, Iterable<Entities.Change>>.SearchSpec
+      ITERABLE_PROTO_FIELD_SPEC = ITERABLE_STORED_PROTO_FIELD.storedOnly("iterable_test_change");
+
+  public static Change createChangeProto(int id) {
+    return Entities.Change.newBuilder()
+        .setChangeId(Change_Id.newBuilder().setId(id).build())
+        .build();
+  }
+
+  private TestIndexedFields() {}
+}
diff --git a/java/com/google/gerrit/lucene/AbstractLuceneIndex.java b/java/com/google/gerrit/lucene/AbstractLuceneIndex.java
index 586887b..7b47248 100644
--- a/java/com/google/gerrit/lucene/AbstractLuceneIndex.java
+++ b/java/com/google/gerrit/lucene/AbstractLuceneIndex.java
@@ -33,12 +33,12 @@
 import com.google.common.util.concurrent.ThreadFactoryBuilder;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.FieldType;
 import com.google.gerrit.index.Index;
 import com.google.gerrit.index.QueryOptions;
 import com.google.gerrit.index.Schema;
 import com.google.gerrit.index.Schema.Values;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
 import com.google.gerrit.index.query.DataSource;
 import com.google.gerrit.index.query.FieldBundle;
 import com.google.gerrit.index.query.ListResultSet;
@@ -50,7 +50,6 @@
 import com.google.gerrit.server.logging.LoggingContextAwareScheduledExecutorService;
 import java.io.IOException;
 import java.sql.Timestamp;
-import java.util.Map;
 import java.util.Set;
 import java.util.concurrent.Callable;
 import java.util.concurrent.ExecutionException;
@@ -66,8 +65,6 @@
 import org.apache.lucene.document.Field;
 import org.apache.lucene.document.Field.Store;
 import org.apache.lucene.document.IntPoint;
-import org.apache.lucene.document.LegacyIntField;
-import org.apache.lucene.document.LegacyLongField;
 import org.apache.lucene.document.LongPoint;
 import org.apache.lucene.document.StoredField;
 import org.apache.lucene.document.StringField;
@@ -88,11 +85,10 @@
 import org.apache.lucene.store.Directory;
 
 /** Basic Lucene index implementation. */
-@SuppressWarnings("deprecation")
 public abstract class AbstractLuceneIndex<K, V> implements Index<K, V> {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  static String sortFieldName(FieldDef<?, ?> f) {
+  static String sortFieldName(SchemaField<?, ?> f) {
     return f.getName() + "_SORT";
   }
 
@@ -348,13 +344,9 @@
     if (type == FieldType.INTEGER || type == FieldType.INTEGER_RANGE) {
       for (Object value : values.getValues()) {
         Integer intValue = (Integer) value;
-        if (schema.useLegacyNumericFields()) {
-          doc.add(new LegacyIntField(name, intValue, store));
-        } else {
-          doc.add(new IntPoint(name, intValue));
-          if (store == Store.YES) {
-            doc.add(new StoredField(name, intValue));
-          }
+        doc.add(new IntPoint(name, intValue));
+        if (store == Store.YES) {
+          doc.add(new StoredField(name, intValue));
         }
       }
     } else if (type == FieldType.LONG) {
@@ -383,22 +375,17 @@
   }
 
   private void addLongField(Document doc, String name, Store store, Long longValue) {
-    if (schema.useLegacyNumericFields()) {
-      doc.add(new LegacyLongField(name, longValue, store));
-    } else {
-      doc.add(new LongPoint(name, longValue));
-      if (store == Store.YES) {
-        doc.add(new StoredField(name, longValue));
-      }
+    doc.add(new LongPoint(name, longValue));
+    if (store == Store.YES) {
+      doc.add(new StoredField(name, longValue));
     }
   }
 
   protected FieldBundle toFieldBundle(Document doc) {
-    Map<String, FieldDef<V, ?>> allFields = getSchema().getFields();
     ListMultimap<String, Object> rawFields = ArrayListMultimap.create();
     for (IndexableField field : doc.getFields()) {
-      checkArgument(allFields.containsKey(field.name()), "Unrecognized field " + field.name());
-      FieldType<?> type = allFields.get(field.name()).getType();
+      checkArgument(getSchema().hasField(field.name()), "Unrecognized field " + field.name());
+      FieldType<?> type = getSchema().getSchemaField(field.name()).getType();
       if (type == FieldType.EXACT || type == FieldType.FULL_TEXT || type == FieldType.PREFIX) {
         rawFields.put(field.name(), field.stringValue());
       } else if (type == FieldType.INTEGER || type == FieldType.INTEGER_RANGE) {
@@ -416,7 +403,7 @@
     return new FieldBundle(rawFields);
   }
 
-  private static Field.Store store(FieldDef<?, ?> f) {
+  private static Field.Store store(SchemaField<?, ?> f) {
     return f.isStored() ? Field.Store.YES : Field.Store.NO;
   }
 
diff --git a/java/com/google/gerrit/lucene/ChangeSubIndex.java b/java/com/google/gerrit/lucene/ChangeSubIndex.java
index 475dac4..ce50473 100644
--- a/java/com/google/gerrit/lucene/ChangeSubIndex.java
+++ b/java/com/google/gerrit/lucene/ChangeSubIndex.java
@@ -15,18 +15,17 @@
 package com.google.gerrit.lucene;
 
 import static com.google.common.collect.Iterables.getOnlyElement;
-import static com.google.gerrit.lucene.LuceneChangeIndex.ID2_SORT_FIELD;
-import static com.google.gerrit.lucene.LuceneChangeIndex.ID_SORT_FIELD;
+import static com.google.gerrit.lucene.LuceneChangeIndex.ID_STR_SORT_FIELD;
 import static com.google.gerrit.lucene.LuceneChangeIndex.MERGED_ON_SORT_FIELD;
 import static com.google.gerrit.lucene.LuceneChangeIndex.UPDATED_SORT_FIELD;
 import static com.google.gerrit.server.index.change.ChangeSchemaDefinitions.NAME;
 
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.QueryOptions;
 import com.google.gerrit.index.Schema;
 import com.google.gerrit.index.Schema.Values;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
 import com.google.gerrit.index.query.DataSource;
 import com.google.gerrit.index.query.FieldBundle;
 import com.google.gerrit.index.query.Predicate;
@@ -119,13 +118,10 @@
   @Override
   void add(Document doc, Values<ChangeData> values) {
     // Add separate DocValues fields for those fields needed for sorting.
-    FieldDef<ChangeData, ?> f = values.getField();
-    if (f == ChangeField.LEGACY_ID) {
-      int v = (Integer) getOnlyElement(values.getValues());
-      doc.add(new NumericDocValuesField(ID_SORT_FIELD, v));
-    } else if (f == ChangeField.LEGACY_ID_STR) {
+    SchemaField<ChangeData, ?> f = values.getField();
+    if (f == ChangeField.LEGACY_ID_STR) {
       String v = (String) getOnlyElement(values.getValues());
-      doc.add(new NumericDocValuesField(ID2_SORT_FIELD, Integer.valueOf(v)));
+      doc.add(new NumericDocValuesField(ID_STR_SORT_FIELD, Integer.valueOf(v)));
     } else if (f == ChangeField.UPDATED) {
       long t = ((Timestamp) getOnlyElement(values.getValues())).getTime();
       doc.add(new NumericDocValuesField(UPDATED_SORT_FIELD, t));
diff --git a/java/com/google/gerrit/lucene/LuceneAccountIndex.java b/java/com/google/gerrit/lucene/LuceneAccountIndex.java
index c4a5240..2e1771f 100644
--- a/java/com/google/gerrit/lucene/LuceneAccountIndex.java
+++ b/java/com/google/gerrit/lucene/LuceneAccountIndex.java
@@ -15,18 +15,18 @@
 package com.google.gerrit.lucene;
 
 import static com.google.common.collect.Iterables.getOnlyElement;
-import static com.google.gerrit.server.index.account.AccountField.FULL_NAME;
-import static com.google.gerrit.server.index.account.AccountField.ID;
-import static com.google.gerrit.server.index.account.AccountField.ID_STR;
-import static com.google.gerrit.server.index.account.AccountField.PREFERRED_EMAIL_EXACT;
+import static com.google.gerrit.server.index.account.AccountField.FULL_NAME_SPEC;
+import static com.google.gerrit.server.index.account.AccountField.ID_FIELD_SPEC;
+import static com.google.gerrit.server.index.account.AccountField.ID_STR_FIELD_SPEC;
+import static com.google.gerrit.server.index.account.AccountField.PREFERRED_EMAIL_EXACT_SPEC;
 
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.QueryOptions;
 import com.google.gerrit.index.Schema;
 import com.google.gerrit.index.Schema.Values;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
 import com.google.gerrit.index.query.DataSource;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
@@ -50,9 +50,9 @@
 import org.apache.lucene.search.SearcherFactory;
 import org.apache.lucene.search.Sort;
 import org.apache.lucene.search.SortField;
+import org.apache.lucene.store.ByteBuffersDirectory;
 import org.apache.lucene.store.Directory;
 import org.apache.lucene.store.FSDirectory;
-import org.apache.lucene.store.RAMDirectory;
 import org.apache.lucene.util.BytesRef;
 import org.eclipse.jgit.lib.Config;
 
@@ -60,19 +60,20 @@
     implements AccountIndex {
   private static final String ACCOUNTS = "accounts";
 
-  private static final String FULL_NAME_SORT_FIELD = sortFieldName(FULL_NAME);
-  private static final String EMAIL_SORT_FIELD = sortFieldName(PREFERRED_EMAIL_EXACT);
-  private static final String ID_SORT_FIELD = sortFieldName(ID);
-  private static final String ID2_SORT_FIELD = sortFieldName(ID_STR);
+  private static final String FULL_NAME_SORT_FIELD = sortFieldName(FULL_NAME_SPEC);
+  private static final String EMAIL_SORT_FIELD = sortFieldName(PREFERRED_EMAIL_EXACT_SPEC);
+  private static final String ID_SORT_FIELD = sortFieldName(ID_FIELD_SPEC);
+  private static final String ID2_SORT_FIELD = sortFieldName(ID_STR_FIELD_SPEC);
 
   private static Term idTerm(boolean useLegacyNumericFields, AccountState as) {
     return idTerm(useLegacyNumericFields, as.account().id());
   }
 
   private static Term idTerm(boolean useLegacyNumericFields, Account.Id id) {
-    FieldDef<AccountState, ?> idField = useLegacyNumericFields ? ID : ID_STR;
+    SchemaField<AccountState, ?> idField =
+        useLegacyNumericFields ? ID_FIELD_SPEC : ID_STR_FIELD_SPEC;
     if (useLegacyNumericFields) {
-      return QueryBuilder.intTerm(idField.getName(), id.get());
+      return QueryBuilder.intTerm(idField.getName());
     }
     return QueryBuilder.stringTerm(idField.getName(), Integer.toString(id.get()));
   }
@@ -84,7 +85,7 @@
   private static Directory dir(Schema<AccountState> schema, Config cfg, SitePaths sitePaths)
       throws IOException {
     if (LuceneIndexModule.isInMemoryTest(cfg)) {
-      return new RAMDirectory();
+      return new ByteBuffersDirectory();
     }
     Path indexDir = LuceneVersionManager.getDir(sitePaths, ACCOUNTS, schema);
     return FSDirectory.open(indexDir);
@@ -117,17 +118,17 @@
   @Override
   void add(Document doc, Values<AccountState> values) {
     // Add separate DocValues fields for those fields needed for sorting.
-    FieldDef<AccountState, ?> f = values.getField();
-    if (f == ID) {
+    SchemaField<AccountState, ?> f = values.getField();
+    if (f == ID_FIELD_SPEC) {
       int v = (Integer) getOnlyElement(values.getValues());
       doc.add(new NumericDocValuesField(ID_SORT_FIELD, v));
-    } else if (f == ID_STR) {
+    } else if (f == ID_STR_FIELD_SPEC) {
       String v = (String) getOnlyElement(values.getValues());
       doc.add(new NumericDocValuesField(ID2_SORT_FIELD, Integer.valueOf(v)));
-    } else if (f == FULL_NAME) {
+    } else if (f == FULL_NAME_SPEC) {
       String value = (String) getOnlyElement(values.getValues());
       doc.add(new SortedDocValuesField(FULL_NAME_SORT_FIELD, new BytesRef(value)));
-    } else if (f == PREFERRED_EMAIL_EXACT) {
+    } else if (f == PREFERRED_EMAIL_EXACT_SPEC) {
       String value = (String) getOnlyElement(values.getValues());
       doc.add(new SortedDocValuesField(EMAIL_SORT_FIELD, new BytesRef(value)));
     }
@@ -137,7 +138,7 @@
   @Override
   public void replace(AccountState as) {
     try {
-      replace(idTerm(getSchema().useLegacyNumericFields(), as), toDocument(as)).get();
+      replace(idTerm(getSchema().hasField(ID_FIELD_SPEC), as), toDocument(as)).get();
     } catch (ExecutionException | InterruptedException e) {
       throw new StorageException(e);
     }
@@ -155,7 +156,7 @@
   @Override
   public void delete(Account.Id key) {
     try {
-      delete(idTerm(getSchema().useLegacyNumericFields(), key)).get();
+      delete(idTerm(getSchema().hasField(ID_FIELD_SPEC), key)).get();
     } catch (ExecutionException | InterruptedException e) {
       throw new StorageException(e);
     }
@@ -164,15 +165,14 @@
   @Override
   public DataSource<AccountState> getSource(Predicate<AccountState> p, QueryOptions opts)
       throws QueryParseException {
-    queryBuilder.getSchema().useLegacyNumericFields();
     return new LuceneQuerySource(
-        opts.filterFields(o -> IndexUtils.accountFields(o, getSchema().useLegacyNumericFields())),
+        opts.filterFields(o -> IndexUtils.accountFields(o, getSchema().hasField(ID_FIELD_SPEC))),
         queryBuilder.toQuery(p),
         getSort());
   }
 
   private Sort getSort() {
-    String idSortField = getSchema().useLegacyNumericFields() ? ID_SORT_FIELD : ID2_SORT_FIELD;
+    String idSortField = getSchema().hasField(ID_FIELD_SPEC) ? ID_SORT_FIELD : ID2_SORT_FIELD;
     return new Sort(
         new SortField(FULL_NAME_SORT_FIELD, SortField.Type.STRING, false),
         new SortField(EMAIL_SORT_FIELD, SortField.Type.STRING, false),
@@ -181,10 +181,11 @@
 
   @Override
   protected AccountState fromDocument(Document doc) {
-    FieldDef<AccountState, ?> idField = getSchema().useLegacyNumericFields() ? ID : ID_STR;
+    SchemaField<AccountState, ?> idField =
+        getSchema().hasField(ID_FIELD_SPEC) ? ID_STR_FIELD_SPEC : ID_STR_FIELD_SPEC;
     Account.Id id =
         Account.id(
-            getSchema().useLegacyNumericFields()
+            getSchema().hasField(ID_FIELD_SPEC)
                 ? doc.getField(idField.getName()).numericValue().intValue()
                 : Integer.valueOf(doc.getField(idField.getName()).stringValue()));
     // Use the AccountCache rather than depending on any stored fields in the document (of which
diff --git a/java/com/google/gerrit/lucene/LuceneChangeIndex.java b/java/com/google/gerrit/lucene/LuceneChangeIndex.java
index daa921c..c28e948b 100644
--- a/java/com/google/gerrit/lucene/LuceneChangeIndex.java
+++ b/java/com/google/gerrit/lucene/LuceneChangeIndex.java
@@ -17,7 +17,6 @@
 import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.gerrit.lucene.AbstractLuceneIndex.sortFieldName;
 import static com.google.gerrit.server.git.QueueProvider.QueueType.INTERACTIVE;
-import static com.google.gerrit.server.index.change.ChangeField.LEGACY_ID;
 import static com.google.gerrit.server.index.change.ChangeField.LEGACY_ID_STR;
 import static com.google.gerrit.server.index.change.ChangeField.PROJECT;
 import static com.google.gerrit.server.index.change.ChangeIndexRewriter.CLOSED_STATUSES;
@@ -39,10 +38,10 @@
 import com.google.gerrit.entities.converter.ChangeProtoConverter;
 import com.google.gerrit.entities.converter.ProtoConverter;
 import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.PaginationType;
 import com.google.gerrit.index.QueryOptions;
 import com.google.gerrit.index.Schema;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
 import com.google.gerrit.index.query.FieldBundle;
 import com.google.gerrit.index.query.HasCardinality;
 import com.google.gerrit.index.query.Predicate;
@@ -89,7 +88,7 @@
 import org.apache.lucene.search.SortField;
 import org.apache.lucene.search.TopDocs;
 import org.apache.lucene.search.TopFieldDocs;
-import org.apache.lucene.store.RAMDirectory;
+import org.apache.lucene.store.ByteBuffersDirectory;
 import org.apache.lucene.util.BytesRef;
 import org.eclipse.jgit.lib.Config;
 
@@ -105,30 +104,19 @@
 
   static final String UPDATED_SORT_FIELD = sortFieldName(ChangeField.UPDATED);
   static final String MERGED_ON_SORT_FIELD = sortFieldName(ChangeField.MERGED_ON);
-  static final String ID_SORT_FIELD = sortFieldName(ChangeField.LEGACY_ID);
-  static final String ID2_SORT_FIELD = sortFieldName(ChangeField.LEGACY_ID_STR);
+  static final String ID_STR_SORT_FIELD = sortFieldName(ChangeField.LEGACY_ID_STR);
 
   private static final String CHANGES = "changes";
   private static final String CHANGES_OPEN = "open";
   private static final String CHANGES_CLOSED = "closed";
   private static final String CHANGE_FIELD = ChangeField.CHANGE.getName();
 
-  @FunctionalInterface
-  interface IdTerm {
-    Term get(String name, int id);
+  static Term idTerm(ChangeData cd) {
+    return idTerm(cd.getVirtualId());
   }
 
-  static Term idTerm(IdTerm idTerm, FieldDef<ChangeData, ?> idField, ChangeData cd) {
-    return idTerm(idTerm, idField, cd.getId());
-  }
-
-  static Term idTerm(IdTerm idTerm, FieldDef<ChangeData, ?> idField, Change.Id id) {
-    return idTerm.get(idField.getName(), id.get());
-  }
-
-  @FunctionalInterface
-  interface ChangeIdExtractor {
-    Change.Id extract(IndexableField f);
+  static Term idTerm(Change.Id id) {
+    return QueryBuilder.stringTerm(LEGACY_ID_STR.getName(), Integer.toString(id.get()));
   }
 
   private final ListeningExecutorService executor;
@@ -137,12 +125,6 @@
   private final QueryBuilder<ChangeData> queryBuilder;
   private final ChangeSubIndex openIndex;
   private final ChangeSubIndex closedIndex;
-
-  // TODO(davido): Remove the below fields when support for legacy numeric fields is removed.
-  private final FieldDef<ChangeData, ?> idField;
-  private final String idSortFieldName;
-  private final IdTerm idTerm;
-  private final ChangeIdExtractor extractor;
   private final ImmutableSet<String> skipFields;
 
   @Inject
@@ -173,7 +155,7 @@
           new ChangeSubIndex(
               schema,
               sitePaths,
-              new RAMDirectory(),
+              new ByteBuffersDirectory(),
               "ramOpen",
               skipFields,
               openConfig,
@@ -183,7 +165,7 @@
           new ChangeSubIndex(
               schema,
               sitePaths,
-              new RAMDirectory(),
+              new ByteBuffersDirectory(),
               "ramClosed",
               skipFields,
               closedConfig,
@@ -210,20 +192,6 @@
               searcherFactory,
               autoFlush);
     }
-
-    idField = this.schema.useLegacyNumericFields() ? LEGACY_ID : LEGACY_ID_STR;
-    idSortFieldName = schema.useLegacyNumericFields() ? ID_SORT_FIELD : ID2_SORT_FIELD;
-    idTerm =
-        (name, id) ->
-            this.schema.useLegacyNumericFields()
-                ? QueryBuilder.intTerm(name, id)
-                : QueryBuilder.stringTerm(name, Integer.toString(id));
-    extractor =
-        (f) ->
-            Change.id(
-                this.schema.useLegacyNumericFields()
-                    ? f.numericValue().intValue()
-                    : Integer.valueOf(f.stringValue()));
   }
 
   @Override
@@ -242,7 +210,7 @@
 
   @Override
   public void replace(ChangeData cd) {
-    Term id = LuceneChangeIndex.idTerm(idTerm, idField, cd);
+    Term id = LuceneChangeIndex.idTerm(cd);
     // toDocument is essentially static and doesn't depend on the specific
     // sub-index, so just pick one.
     Document doc = openIndex.toDocument(cd);
@@ -275,9 +243,9 @@
 
   @Override
   public void delete(Change.Id changeId) {
-    Term id = LuceneChangeIndex.idTerm(idTerm, idField, changeId);
+    Term idTerm = LuceneChangeIndex.idTerm(changeId);
     try {
-      Futures.allAsList(openIndex.delete(id), closedIndex.delete(id)).get();
+      Futures.allAsList(openIndex.delete(idTerm), closedIndex.delete(idTerm)).get();
     } catch (ExecutionException | InterruptedException e) {
       throw new StorageException(e);
     }
@@ -314,7 +282,7 @@
     return new Sort(
         new SortField(UPDATED_SORT_FIELD, SortField.Type.LONG, true),
         new SortField(MERGED_ON_SORT_FIELD, SortField.Type.LONG, true),
-        new SortField(idSortFieldName, SortField.Type.LONG, true));
+        new SortField(ID_STR_SORT_FIELD, SortField.Type.LONG, true));
   }
 
   private class QuerySource implements ChangeDataSource {
@@ -368,7 +336,7 @@
         throw new StorageException("interrupted");
       }
 
-      final Set<String> fields = IndexUtils.changeFields(opts, schema.useLegacyNumericFields());
+      final Set<String> fields = IndexUtils.changeFields(opts);
       return new ChangeDataResults(
           executor.submit(
               new Callable<Results>() {
@@ -391,7 +359,7 @@
       Map<ChangeSubIndex, ScoreDoc> searchAfterBySubIndex;
 
       try {
-        Results r = doRead(IndexUtils.changeFields(opts, schema.useLegacyNumericFields()));
+        Results r = doRead(IndexUtils.changeFields(opts));
         documents = r.docs;
         searchAfterBySubIndex = r.searchAfterBySubIndex;
       } catch (IOException e) {
@@ -530,7 +498,7 @@
         ImmutableList.Builder<ChangeData> result =
             ImmutableList.builderWithExpectedSize(docs.size());
         for (Document doc : docs) {
-          result.add(toChangeData(fields(doc, fields), fields, idField.getName()));
+          result.add(toChangeData(fields(doc, fields), fields, LEGACY_ID_STR.getName()));
         }
         return result.build();
       } catch (InterruptedException e) {
@@ -577,12 +545,13 @@
     } else {
       IndexableField f = Iterables.getFirst(doc.get(idFieldName), null);
 
+      Change.Id id = Change.id(Integer.valueOf(f.stringValue()));
       // IndexUtils#changeFields ensures either CHANGE or PROJECT is always present.
       IndexableField project = doc.get(PROJECT.getName()).iterator().next();
-      cd = changeDataFactory.create(Project.nameKey(project.stringValue()), extractor.extract(f));
+      cd = changeDataFactory.create(Project.nameKey(project.stringValue()), id);
     }
 
-    for (FieldDef<ChangeData, ?> field : getSchema().getFields().values()) {
+    for (SchemaField<ChangeData, ?> field : getSchema().getSchemaFields().values()) {
       if (fields.contains(field.getName()) && doc.get(field.getName()) != null) {
         field.setIfPossible(cd, new LuceneStoredValue(doc.get(field.getName())));
       }
diff --git a/java/com/google/gerrit/lucene/LuceneGroupIndex.java b/java/com/google/gerrit/lucene/LuceneGroupIndex.java
index f7a2248..d475ab7 100644
--- a/java/com/google/gerrit/lucene/LuceneGroupIndex.java
+++ b/java/com/google/gerrit/lucene/LuceneGroupIndex.java
@@ -15,16 +15,16 @@
 package com.google.gerrit.lucene;
 
 import static com.google.common.collect.Iterables.getOnlyElement;
-import static com.google.gerrit.server.index.group.GroupField.UUID;
+import static com.google.gerrit.server.index.group.GroupField.UUID_FIELD_SPEC;
 
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.QueryOptions;
 import com.google.gerrit.index.Schema;
 import com.google.gerrit.index.Schema.Values;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
 import com.google.gerrit.index.query.DataSource;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
@@ -46,9 +46,9 @@
 import org.apache.lucene.search.SearcherFactory;
 import org.apache.lucene.search.Sort;
 import org.apache.lucene.search.SortField;
+import org.apache.lucene.store.ByteBuffersDirectory;
 import org.apache.lucene.store.Directory;
 import org.apache.lucene.store.FSDirectory;
-import org.apache.lucene.store.RAMDirectory;
 import org.apache.lucene.util.BytesRef;
 import org.eclipse.jgit.lib.Config;
 
@@ -57,14 +57,14 @@
 
   private static final String GROUPS = "groups";
 
-  private static final String UUID_SORT_FIELD = sortFieldName(UUID);
+  private static final String UUID_SORT_FIELD = sortFieldName(UUID_FIELD_SPEC);
 
   private static Term idTerm(InternalGroup group) {
     return idTerm(group.getGroupUUID());
   }
 
   private static Term idTerm(AccountGroup.UUID uuid) {
-    return QueryBuilder.stringTerm(UUID.getName(), uuid.get());
+    return QueryBuilder.stringTerm(UUID_FIELD_SPEC.getName(), uuid.get());
   }
 
   private final GerritIndexWriterConfig indexWriterConfig;
@@ -74,7 +74,7 @@
   private static Directory dir(Schema<?> schema, Config cfg, SitePaths sitePaths)
       throws IOException {
     if (LuceneIndexModule.isInMemoryTest(cfg)) {
-      return new RAMDirectory();
+      return new ByteBuffersDirectory();
     }
     Path indexDir = LuceneVersionManager.getDir(sitePaths, GROUPS, schema);
     return FSDirectory.open(indexDir);
@@ -107,8 +107,8 @@
   @Override
   void add(Document doc, Values<InternalGroup> values) {
     // Add separate DocValues field for the field that is needed for sorting.
-    FieldDef<InternalGroup, ?> f = values.getField();
-    if (f == UUID) {
+    SchemaField<InternalGroup, ?> f = values.getField();
+    if (f == UUID_FIELD_SPEC) {
       String value = (String) getOnlyElement(values.getValues());
       doc.add(new SortedDocValuesField(UUID_SORT_FIELD, new BytesRef(value)));
     }
@@ -153,7 +153,8 @@
 
   @Override
   protected InternalGroup fromDocument(Document doc) {
-    AccountGroup.UUID uuid = AccountGroup.uuid(doc.getField(UUID.getName()).stringValue());
+    AccountGroup.UUID uuid =
+        AccountGroup.uuid(doc.getField(UUID_FIELD_SPEC.getName()).stringValue());
     // Use the GroupCache rather than depending on any stored fields in the
     // document (of which there shouldn't be any).
     return groupCache.get().get(uuid).orElse(null);
diff --git a/java/com/google/gerrit/lucene/LuceneProjectIndex.java b/java/com/google/gerrit/lucene/LuceneProjectIndex.java
index 11707be..96b22db 100644
--- a/java/com/google/gerrit/lucene/LuceneProjectIndex.java
+++ b/java/com/google/gerrit/lucene/LuceneProjectIndex.java
@@ -20,10 +20,10 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.QueryOptions;
 import com.google.gerrit.index.Schema;
 import com.google.gerrit.index.Schema.Values;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
 import com.google.gerrit.index.project.ProjectData;
 import com.google.gerrit.index.project.ProjectIndex;
 import com.google.gerrit.index.query.DataSource;
@@ -47,9 +47,9 @@
 import org.apache.lucene.search.SearcherFactory;
 import org.apache.lucene.search.Sort;
 import org.apache.lucene.search.SortField;
+import org.apache.lucene.store.ByteBuffersDirectory;
 import org.apache.lucene.store.Directory;
 import org.apache.lucene.store.FSDirectory;
-import org.apache.lucene.store.RAMDirectory;
 import org.apache.lucene.util.BytesRef;
 import org.eclipse.jgit.lib.Config;
 
@@ -74,7 +74,7 @@
   private static Directory dir(Schema<ProjectData> schema, Config cfg, SitePaths sitePaths)
       throws IOException {
     if (LuceneIndexModule.isInMemoryTest(cfg)) {
-      return new RAMDirectory();
+      return new ByteBuffersDirectory();
     }
     Path indexDir = LuceneVersionManager.getDir(sitePaths, PROJECTS, schema);
     return FSDirectory.open(indexDir);
@@ -107,7 +107,7 @@
   @Override
   void add(Document doc, Values<ProjectData> values) {
     // Add separate DocValues field for the field that is needed for sorting.
-    FieldDef<ProjectData, ?> f = values.getField();
+    SchemaField<ProjectData, ?> f = values.getField();
     if (f == NAME) {
       String value = (String) getOnlyElement(values.getValues());
       doc.add(new SortedDocValuesField(NAME_SORT_FIELD, new BytesRef(value)));
diff --git a/java/com/google/gerrit/lucene/LuceneStoredValue.java b/java/com/google/gerrit/lucene/LuceneStoredValue.java
index efe489b..1f8c039 100644
--- a/java/com/google/gerrit/lucene/LuceneStoredValue.java
+++ b/java/com/google/gerrit/lucene/LuceneStoredValue.java
@@ -18,7 +18,9 @@
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.collect.Iterables;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.index.StoredValue;
+import com.google.protobuf.MessageLite;
 import java.sql.Timestamp;
 import java.util.List;
 import org.apache.lucene.index.IndexableField;
@@ -81,6 +83,20 @@
     return copyAsBytes(field);
   }
 
+  @Override
+  @Nullable
+  public MessageLite asProto() {
+    // Lucene does not store protos
+    return null;
+  }
+
+  @Override
+  @Nullable
+  public Iterable<MessageLite> asProtos() {
+    // Lucene does not store protos
+    return null;
+  }
+
   private static List<byte[]> copyAsBytes(List<IndexableField> fields) {
     return fields.stream()
         .map(
diff --git a/java/com/google/gerrit/lucene/QueryBuilder.java b/java/com/google/gerrit/lucene/QueryBuilder.java
index e1b56c6..14ad528 100644
--- a/java/com/google/gerrit/lucene/QueryBuilder.java
+++ b/java/com/google/gerrit/lucene/QueryBuilder.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.lucene;
 
 import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkState;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static org.apache.lucene.search.BooleanClause.Occur.MUST;
 import static org.apache.lucene.search.BooleanClause.Occur.MUST_NOT;
@@ -39,36 +40,18 @@
 import org.apache.lucene.document.LongPoint;
 import org.apache.lucene.index.Term;
 import org.apache.lucene.search.BooleanQuery;
-import org.apache.lucene.search.LegacyNumericRangeQuery;
 import org.apache.lucene.search.MatchAllDocsQuery;
 import org.apache.lucene.search.PrefixQuery;
 import org.apache.lucene.search.Query;
 import org.apache.lucene.search.RegexpQuery;
 import org.apache.lucene.search.TermQuery;
 import org.apache.lucene.util.BytesRefBuilder;
-import org.apache.lucene.util.LegacyNumericUtils;
 
-@SuppressWarnings("deprecation")
 public class QueryBuilder<V> {
-  @FunctionalInterface
-  static interface IntTermQuery {
-    Query get(String name, int value);
-  }
-
-  @FunctionalInterface
-  static interface IntRangeQuery {
-    Query get(String name, int min, int max);
-  }
-
-  @FunctionalInterface
-  static interface LongRangeQuery {
-    Query get(String name, long min, long max);
-  }
-
-  static Term intTerm(String name, int value) {
-    BytesRefBuilder builder = new BytesRefBuilder();
-    LegacyNumericUtils.intToPrefixCoded(value, 0, builder);
-    return new Term(name, builder.get());
+  /** @param name field name qparam i key value */
+  static Term intTerm(String name) {
+    checkState(false, "Lucene index implementation removed legacy numeric type");
+    return null;
   }
 
   static Term stringTerm(String name, String value) {
@@ -84,29 +67,9 @@
   private final Schema<V> schema;
   private final org.apache.lucene.util.QueryBuilder queryBuilder;
 
-  // TODO(davido): Remove the below fields when support for legacy numeric fields is removed.
-  private final IntTermQuery intTermQuery;
-  private final IntRangeQuery intRangeTermQuery;
-  private final LongRangeQuery longRangeQuery;
-
   public QueryBuilder(Schema<V> schema, Analyzer analyzer) {
     this.schema = schema;
     queryBuilder = new org.apache.lucene.util.QueryBuilder(analyzer);
-    intTermQuery =
-        (name, value) ->
-            this.schema.useLegacyNumericFields()
-                ? new TermQuery(intTerm(name, value))
-                : intPoint(name, value);
-    intRangeTermQuery =
-        (name, min, max) ->
-            this.schema.useLegacyNumericFields()
-                ? LegacyNumericRangeQuery.newIntRange(name, min, max, true, true)
-                : IntPoint.newRangeQuery(name, min, max);
-    longRangeQuery =
-        (name, min, max) ->
-            this.schema.useLegacyNumericFields()
-                ? LegacyNumericRangeQuery.newLongRange(name, min, max, true, true)
-                : LongPoint.newRangeQuery(name, min, max);
   }
 
   public Query toQuery(Predicate<V> p) throws QueryParseException {
@@ -209,7 +172,7 @@
     } catch (NumberFormatException e) {
       throw new QueryParseException("not an integer: " + p.getValue(), e);
     }
-    return intTermQuery.get(p.getField().getName(), value);
+    return intPoint(p.getField().getName(), value);
   }
 
   private Query intRangeQuery(IndexPredicate<V> p) throws QueryParseException {
@@ -220,9 +183,9 @@
       int maximum = r.getMaximumValue();
       if (minimum == maximum) {
         // Just fall back to a standard integer query.
-        return intTermQuery.get(name, minimum);
+        return intPoint(name, minimum);
       }
-      return intRangeTermQuery.get(name, minimum, maximum);
+      return IntPoint.newRangeQuery(name, minimum, maximum);
     }
     throw new QueryParseException("not an integer range: " + p);
   }
@@ -230,7 +193,7 @@
   private Query timestampQuery(IndexPredicate<V> p) throws QueryParseException {
     if (p instanceof TimestampRangePredicate) {
       TimestampRangePredicate<V> r = (TimestampRangePredicate<V>) p;
-      return longRangeQuery.get(
+      return LongPoint.newRangeQuery(
           r.getField().getName(),
           r.getMinTimestamp().toEpochMilli(),
           r.getMaxTimestamp().toEpochMilli());
@@ -240,7 +203,7 @@
 
   private Query notTimestamp(TimestampRangePredicate<V> r) throws QueryParseException {
     if (r.getMinTimestamp().toEpochMilli() == 0) {
-      return longRangeQuery.get(
+      return LongPoint.newRangeQuery(
           r.getField().getName(), r.getMaxTimestamp().toEpochMilli(), Long.MAX_VALUE);
     }
     throw new QueryParseException("cannot negate: " + r);
diff --git a/java/com/google/gerrit/metrics/BUILD b/java/com/google/gerrit/metrics/BUILD
index 0cb0275..5f4e0c0 100644
--- a/java/com/google/gerrit/metrics/BUILD
+++ b/java/com/google/gerrit/metrics/BUILD
@@ -14,6 +14,7 @@
         "//lib:jgit",
         "//lib/auto:auto-value",
         "//lib/auto:auto-value-annotations",
+        "//lib/errorprone:annotations",
         "//lib/flogger:api",
         "//lib/guice",
     ],
diff --git a/java/com/google/gerrit/metrics/MetricMaker.java b/java/com/google/gerrit/metrics/MetricMaker.java
index 618d421..3f9bab1 100644
--- a/java/com/google/gerrit/metrics/MetricMaker.java
+++ b/java/com/google/gerrit/metrics/MetricMaker.java
@@ -16,6 +16,7 @@
 
 import com.google.common.base.Supplier;
 import com.google.common.collect.ImmutableSet;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.extensions.registration.RegistrationHandle;
 import java.util.Set;
 
@@ -80,6 +81,7 @@
    * @param desc description of the metric.
    * @return registration handle
    */
+  @CanIgnoreReturnValue
   public <V> RegistrationHandle newConstantMetric(String name, V value, Description desc) {
     desc.setConstant();
 
@@ -110,6 +112,7 @@
    * @param trigger function to compute the value of the metric.
    * @return registration handle
    */
+  @CanIgnoreReturnValue
   public <V> RegistrationHandle newCallbackMetric(
       String name, Class<V> valueClass, Description desc, Supplier<V> trigger) {
     CallbackMetric0<V> metric = newCallbackMetric(name, valueClass, desc);
diff --git a/java/com/google/gerrit/metrics/proc/ThreadMXBeanSun.java b/java/com/google/gerrit/metrics/proc/ThreadMXBeanSun.java
index fe52618..ca750cd 100644
--- a/java/com/google/gerrit/metrics/proc/ThreadMXBeanSun.java
+++ b/java/com/google/gerrit/metrics/proc/ThreadMXBeanSun.java
@@ -35,9 +35,7 @@
 
   @Override
   public long getCurrentThreadAllocatedBytes() {
-    // TODO(ms): call getCurrentThreadAllocatedBytes as soon as this is available in the patched
-    // Java version used by bazel
-    return getThreadAllocatedBytes(Thread.currentThread().getId());
+    return sys.getCurrentThreadAllocatedBytes();
   }
 
   @Override
diff --git a/java/com/google/gerrit/pgm/init/AccountsOnInit.java b/java/com/google/gerrit/pgm/init/AccountsOnInit.java
index c56a8d9..be5fe1a 100644
--- a/java/com/google/gerrit/pgm/init/AccountsOnInit.java
+++ b/java/com/google/gerrit/pgm/init/AccountsOnInit.java
@@ -30,7 +30,6 @@
 import java.io.File;
 import java.io.IOException;
 import java.nio.file.Path;
-import java.util.Date;
 import org.eclipse.jgit.dircache.DirCache;
 import org.eclipse.jgit.dircache.DirCacheEditor;
 import org.eclipse.jgit.dircache.DirCacheEditor.PathEdit;
@@ -61,16 +60,12 @@
     this.allUsers = allUsers.get();
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   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(), Date.from(account.registeredOn()));
+          new PersonIdent(new GerritPersonIdentProvider(flags.cfg).get(), account.registeredOn());
 
       Config accountConfig = new Config();
       AccountProperties.writeToAccountConfig(
diff --git a/java/com/google/gerrit/pgm/init/GroupsOnInit.java b/java/com/google/gerrit/pgm/init/GroupsOnInit.java
index 020705e..f8fcadd 100644
--- a/java/com/google/gerrit/pgm/init/GroupsOnInit.java
+++ b/java/com/google/gerrit/pgm/init/GroupsOnInit.java
@@ -40,7 +40,6 @@
 import java.io.File;
 import java.io.IOException;
 import java.nio.file.Path;
-import java.sql.Timestamp;
 import java.time.Instant;
 import java.util.stream.Stream;
 import org.eclipse.jgit.errors.ConfigInvalidException;
@@ -166,14 +165,10 @@
     return AuditLogFormatter.createBackedBy(ImmutableSet.of(account), ImmutableSet.of(), serverId);
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   private void commit(Repository repository, GroupConfig groupConfig, Instant groupCreatedOn)
       throws IOException {
     PersonIdent personIdent =
-        new PersonIdent(
-            new GerritPersonIdentProvider(flags.cfg).get(), Timestamp.from(groupCreatedOn));
+        new PersonIdent(new GerritPersonIdentProvider(flags.cfg).get(), groupCreatedOn);
     try (MetaDataUpdate metaDataUpdate = createMetaDataUpdate(repository, personIdent)) {
       groupConfig.commit(metaDataUpdate);
     }
diff --git a/java/com/google/gerrit/pgm/init/InitLabels.java b/java/com/google/gerrit/pgm/init/InitLabels.java
index 3edc732..f862e12 100644
--- a/java/com/google/gerrit/pgm/init/InitLabels.java
+++ b/java/com/google/gerrit/pgm/init/InitLabels.java
@@ -26,7 +26,7 @@
 
 @Singleton
 public class InitLabels implements InitStep {
-  private static final String KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE = "copyAllScoresIfNoCodeChange";
+  private static final String KEY_COPY_CONDITION = "copyCondition";
   private static final String KEY_LABEL = "label";
   private static final String KEY_FUNCTION = "function";
   private static final String KEY_VALUE = "value";
@@ -62,7 +62,11 @@
           LABEL_VERIFIED,
           KEY_VALUE,
           Arrays.asList("-1 Fails", "0 No score", "+1 Verified"));
-      cfg.setBoolean(KEY_LABEL, LABEL_VERIFIED, KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE, true);
+      cfg.setString(
+          KEY_LABEL,
+          LABEL_VERIFIED,
+          KEY_COPY_CONDITION,
+          "changekind:NO_CHANGE OR changekind:NO_CODE_CHANGE");
       allProjectsConfig.save("Configure 'Verified' label");
     }
   }
diff --git a/java/com/google/gerrit/pgm/util/BatchProgramModule.java b/java/com/google/gerrit/pgm/util/BatchProgramModule.java
index 208ed1f..05b50da 100644
--- a/java/com/google/gerrit/pgm/util/BatchProgramModule.java
+++ b/java/com/google/gerrit/pgm/util/BatchProgramModule.java
@@ -47,6 +47,7 @@
 import com.google.gerrit.server.cache.mem.DefaultMemoryCacheModule;
 import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.change.ChangeKindCacheImpl;
+import com.google.gerrit.server.change.EmailNewPatchSet;
 import com.google.gerrit.server.change.MergeabilityCacheImpl;
 import com.google.gerrit.server.change.PatchSetInserter;
 import com.google.gerrit.server.change.RebaseChangeOp;
@@ -61,15 +62,14 @@
 import com.google.gerrit.server.config.GitReceivePackGroups;
 import com.google.gerrit.server.config.GitUploadPackGroups;
 import com.google.gerrit.server.config.SysExecutorModule;
+import com.google.gerrit.server.extensions.events.AttentionSetObserver;
 import com.google.gerrit.server.extensions.events.EventUtil;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.extensions.events.RevisionCreated;
 import com.google.gerrit.server.extensions.events.WorkInProgressStateChanged;
-import com.google.gerrit.server.git.MergeUtil;
 import com.google.gerrit.server.git.PureRevertCache;
 import com.google.gerrit.server.git.SearchingChangeCacheImpl;
 import com.google.gerrit.server.git.TagCache;
-import com.google.gerrit.server.mail.send.ReplacePatchSetSender;
 import com.google.gerrit.server.notedb.NoteDbModule;
 import com.google.gerrit.server.patch.DiffExecutorModule;
 import com.google.gerrit.server.patch.DiffOperationsImpl;
@@ -154,9 +154,8 @@
         .in(SINGLETON);
     bind(Realm.class).to(FakeRealm.class);
     bind(IdentifiedUser.class).toProvider(Providers.of(null));
-    bind(ReplacePatchSetSender.Factory.class).toProvider(Providers.of(null));
+    bind(EmailNewPatchSet.Factory.class).toProvider(Providers.of(null));
     bind(CurrentUser.class).to(InternalUser.class);
-    factory(MergeUtil.Factory.class);
     factory(PatchSetInserter.Factory.class);
     factory(RebaseChangeOp.Factory.class);
 
@@ -223,6 +222,7 @@
     bind(RevisionCreated.class).toInstance(RevisionCreated.DISABLED);
     bind(WorkInProgressStateChanged.class).toInstance(WorkInProgressStateChanged.DISABLED);
     bind(AccountVisibility.class).toProvider(AccountVisibilityProvider.class).in(SINGLETON);
+    bind(AttentionSetObserver.class).toInstance(AttentionSetObserver.DISABLED);
 
     ModuleOverloader.override(
             modules,
diff --git a/java/com/google/gerrit/pgm/util/RuntimeShutdown.java b/java/com/google/gerrit/pgm/util/RuntimeShutdown.java
index c5e8567..37cf20e 100644
--- a/java/com/google/gerrit/pgm/util/RuntimeShutdown.java
+++ b/java/com/google/gerrit/pgm/util/RuntimeShutdown.java
@@ -95,6 +95,7 @@
       }
     }
 
+    @SuppressWarnings("DoNotCall")
     void manualShutdown() {
       Runtime.getRuntime().removeShutdownHook(this);
       run();
diff --git a/java/com/google/gerrit/server/BUILD b/java/com/google/gerrit/server/BUILD
index 7a6187d..2be3383 100644
--- a/java/com/google/gerrit/server/BUILD
+++ b/java/com/google/gerrit/server/BUILD
@@ -104,6 +104,7 @@
         "//lib:servlet-api",
         "//lib:soy",
         "//lib:tukaani-xz",
+        "//lib/auto:auto-factory",
         "//lib/auto:auto-value",
         "//lib/auto:auto-value-annotations",
         "//lib/bouncycastle:bcpkix-neverlink",
@@ -120,6 +121,7 @@
         "//lib/guice",
         "//lib/guice:guice-assistedinject",
         "//lib/guice:guice-servlet",
+        "//lib/httpcomponents:httpclient",
         "//lib/jsoup",
         "//lib/log:log4j",
         "//lib/lucene:lucene-analyzers-common",
diff --git a/java/com/google/gerrit/server/CommonConverters.java b/java/com/google/gerrit/server/CommonConverters.java
index e7fd1c5..4ad143b 100644
--- a/java/com/google/gerrit/server/CommonConverters.java
+++ b/java/com/google/gerrit/server/CommonConverters.java
@@ -25,14 +25,11 @@
  * static utility methods.
  */
 public class CommonConverters {
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   public static GitPerson toGitPerson(PersonIdent ident) {
     GitPerson result = new GitPerson();
     result.name = ident.getName();
     result.email = ident.getEmailAddress();
-    result.setDate(ident.getWhen().toInstant());
+    result.setDate(ident.getWhenAsInstant());
     result.tz = ident.getTimeZoneOffset();
     return result;
   }
diff --git a/java/com/google/gerrit/server/IdentifiedUser.java b/java/com/google/gerrit/server/IdentifiedUser.java
index 122e18d..65a81f7 100644
--- a/java/com/google/gerrit/server/IdentifiedUser.java
+++ b/java/com/google/gerrit/server/IdentifiedUser.java
@@ -50,9 +50,9 @@
 import java.net.SocketAddress;
 import java.net.URL;
 import java.time.Instant;
+import java.time.ZoneId;
 import java.util.Optional;
 import java.util.Set;
-import java.util.TimeZone;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.util.SystemReader;
 
@@ -427,10 +427,10 @@
   }
 
   public PersonIdent newRefLogIdent() {
-    return newRefLogIdent(Instant.now(), TimeZone.getDefault());
+    return newRefLogIdent(Instant.now(), ZoneId.systemDefault());
   }
 
-  public PersonIdent newRefLogIdent(Instant when, TimeZone tz) {
+  public PersonIdent newRefLogIdent(Instant when, ZoneId zoneId) {
     final Account ua = getAccount();
 
     String name = ua.fullName();
@@ -451,21 +451,18 @@
               : ua.preferredEmail();
     }
 
-    return newPersonIdent(name, user, when, tz);
+    return new PersonIdent(name, user, when, zoneId);
   }
 
   private String constructMailAddress(Account ua, String host) {
     return getUserName().orElse("") + "|account-" + ua.id().toString() + "@" + host;
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   public PersonIdent newCommitterIdent(PersonIdent ident) {
-    return newCommitterIdent(ident.getWhen().toInstant(), ident.getTimeZone());
+    return newCommitterIdent(ident.getWhenAsInstant(), ident.getZoneId());
   }
 
-  public PersonIdent newCommitterIdent(Instant when, TimeZone tz) {
+  public PersonIdent newCommitterIdent(Instant when, ZoneId zoneId) {
     final Account ua = getAccount();
     String name = ua.fullName();
     String email = ua.preferredEmail();
@@ -500,7 +497,7 @@
       }
     }
 
-    return newPersonIdent(name, email, when, tz);
+    return new PersonIdent(name, email, when, zoneId);
   }
 
   @Override
@@ -568,19 +565,4 @@
     }
     return host;
   }
-
-  /**
-   * Create a {@link PersonIdent} from an {@code Instant} and a {@link TimeZone}.
-   *
-   * <p>We use the {@link PersonIdent#PersonIdent(String, String, long, int)} constructor to avoid
-   * doing a conversion to {@code java.util.Date} here. For the {@code int aTZ} argument, which is
-   * the time zone, we do the same computation as in {@link PersonIdent#PersonIdent(String, String,
-   * java.util.Date, TimeZone)} (just instead of getting the epoch millis from {@code
-   * java.util.Date} we get them from {@link Instant}).
-   */
-  // TODO(issue-15517): Drop this method once JGit's PersonIdent class supports Instants
-  private static PersonIdent newPersonIdent(String name, String email, Instant when, TimeZone tz) {
-    return new PersonIdent(
-        name, email, when.toEpochMilli(), tz.getOffset(when.toEpochMilli()) / (60 * 1000));
-  }
 }
diff --git a/java/com/google/gerrit/server/PublishCommentsOp.java b/java/com/google/gerrit/server/PublishCommentsOp.java
index 84afe8c..827c078 100644
--- a/java/com/google/gerrit/server/PublishCommentsOp.java
+++ b/java/com/google/gerrit/server/PublishCommentsOp.java
@@ -15,11 +15,11 @@
 package com.google.gerrit.server;
 
 import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
-import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.server.change.EmailReviewComments;
@@ -32,13 +32,12 @@
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.CommentsRejectedException;
 import com.google.gerrit.server.update.PostUpdateContext;
-import com.google.gerrit.server.update.RepoView;
-import com.google.gerrit.server.util.LabelVote;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.List;
+import org.eclipse.jgit.lib.ObjectId;
 
 /**
  * A {@link BatchUpdateOp} that can be used to publish draft comments
@@ -52,15 +51,14 @@
   private final CommentAdded commentAdded;
   private final CommentsUtil commentsUtil;
   private final EmailReviewComments.Factory email;
-  private final List<LabelVote> labelDelta = new ArrayList<>();
   private final Project.NameKey projectNameKey;
   private final PatchSet.Id psId;
   private final PublishCommentUtil publishCommentUtil;
   private final ChangeMessagesUtil changeMessagesUtil;
 
+  private ObjectId preUpdateMetaId;
   private List<HumanComment> comments = new ArrayList<>();
   private String mailMessage;
-  private IdentifiedUser user;
 
   public interface Factory {
     PublishCommentsOp create(PatchSet.Id psId, Project.NameKey projectNameKey);
@@ -92,7 +90,7 @@
   public boolean updateChange(ChangeContext ctx)
       throws ResourceConflictException, UnprocessableEntityException, IOException,
           PatchListNotAvailableException, CommentsRejectedException {
-    user = ctx.getIdentifiedUser();
+    preUpdateMetaId = ctx.getNotes().getMetaId();
     comments = commentsUtil.draftByChangeAuthor(ctx.getNotes(), ctx.getUser().getAccountId());
 
     // PublishCommentsOp should update a separate ChangeUpdate Object than the one used by other ops
@@ -103,7 +101,7 @@
     //   2. Each ChangeUpdate results in 1 commit in NoteDb
     // We do it this way so that the execution results in 2 different commits in NoteDb
     ChangeUpdate changeUpdate = ctx.getDistinctUpdate(psId);
-    publishCommentUtil.publish(ctx, changeUpdate, comments, null);
+    publishCommentUtil.publish(ctx, changeUpdate, comments, /* tag= */ null);
     return insertMessage(changeUpdate);
   }
 
@@ -116,25 +114,15 @@
     PatchSet ps = psUtil.get(changeNotes, psId);
     NotifyResolver.Result notify = ctx.getNotify(changeNotes.getChangeId());
     if (notify.shouldNotify()) {
-      RepoView repoView;
-      try {
-        repoView = ctx.getRepoView();
-      } catch (IOException ex) {
-        throw new StorageException(
-            String.format("Repository %s not found", ctx.getProject().get()), ex);
-      }
       email
           .create(
-              notify,
-              changeNotes,
+              ctx,
               ps,
-              user,
+              preUpdateMetaId,
               mailMessage,
-              ctx.getWhen(),
               comments,
-              null,
-              labelDelta,
-              repoView)
+              /* patchSetComment= */ null,
+              /* labels= */ ImmutableList.of())
           .sendAsync();
     }
     commentAdded.fire(
@@ -159,7 +147,7 @@
     }
     mailMessage =
         changeMessagesUtil.setChangeMessage(
-            changeUpdate, "Patch Set " + psId.get() + ":" + buf, null);
+            changeUpdate, "Patch Set " + psId.get() + ":" + buf, /* tag= */ null);
     return true;
   }
 }
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/StarredChangesUtil.java
index 95d891e..0f5629e 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/StarredChangesUtil.java
@@ -37,12 +37,10 @@
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.git.GitUpdateFailureException;
 import com.google.gerrit.git.LockFailureException;
-import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.index.change.ChangeField;
-import com.google.gerrit.server.index.change.ChangeIndexer;
 import com.google.gerrit.server.logging.Metadata;
 import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.logging.TraceContext.TraceTimer;
@@ -110,6 +108,11 @@
     }
   }
 
+  public enum Operation {
+    ADD,
+    REMOVE
+  }
+
   @AutoValue
   public abstract static class StarRef {
     private static final StarRef MISSING =
@@ -158,15 +161,11 @@
   }
 
   public static final String DEFAULT_LABEL = "star";
-  public static final String IGNORE_LABEL = "ignore";
-  public static final ImmutableSortedSet<String> DEFAULT_LABELS =
-      ImmutableSortedSet.of(DEFAULT_LABEL);
 
   private final GitRepositoryManager repoManager;
   private final GitReferenceUpdated gitRefUpdated;
   private final AllUsersName allUsers;
   private final Provider<PersonIdent> serverIdent;
-  private final ChangeIndexer indexer;
   private final Provider<InternalChangeQuery> queryProvider;
 
   @Inject
@@ -175,13 +174,11 @@
       GitReferenceUpdated gitRefUpdated,
       AllUsersName allUsers,
       @GerritPersonIdent Provider<PersonIdent> serverIdent,
-      ChangeIndexer indexer,
       Provider<InternalChangeQuery> queryProvider) {
     this.repoManager = repoManager;
     this.gitRefUpdated = gitRefUpdated;
     this.allUsers = allUsers;
     this.serverIdent = serverIdent;
-    this.indexer = indexer;
     this.queryProvider = queryProvider;
   }
 
@@ -197,34 +194,27 @@
     }
   }
 
-  public NavigableSet<String> star(
-      Account.Id accountId,
-      Project.NameKey project,
-      Change.Id changeId,
-      Set<String> labelsToAdd,
-      Set<String> labelsToRemove)
+  public void star(Account.Id accountId, Project.NameKey project, Change.Id changeId, Operation op)
       throws IllegalLabelException {
     try (Repository repo = repoManager.openRepository(allUsers)) {
       String refName = RefNames.refsStarredChanges(changeId, accountId);
       StarRef old = readLabels(repo, refName);
 
       NavigableSet<String> labels = new TreeSet<>(old.labels());
-      if (labelsToAdd != null) {
-        labels.addAll(labelsToAdd);
-      }
-      if (labelsToRemove != null) {
-        labels.removeAll(labelsToRemove);
+      switch (op) {
+        case ADD:
+          labels.add(DEFAULT_LABEL);
+          break;
+        case REMOVE:
+          labels.remove(DEFAULT_LABEL);
+          break;
       }
 
       if (labels.isEmpty()) {
         deleteRef(repo, refName, old.objectId());
       } else {
-        checkMutuallyExclusiveLabels(labels);
         updateLabels(repo, refName, old.objectId(), labels);
       }
-
-      indexer.index(project, changeId);
-      return Collections.unmodifiableNavigableSet(labels);
     } catch (IOException e) {
       throw new StorageException(
           String.format("Star change %d for account %d failed", changeId.get(), accountId.get()),
@@ -349,32 +339,6 @@
     }
   }
 
-  public void ignore(ChangeResource rsrc) throws IllegalLabelException {
-    star(
-        rsrc.getUser().asIdentifiedUser().getAccountId(),
-        rsrc.getProject(),
-        rsrc.getChange().getId(),
-        ImmutableSet.of(IGNORE_LABEL),
-        ImmutableSet.of());
-  }
-
-  public void unignore(ChangeResource rsrc) throws IllegalLabelException {
-    star(
-        rsrc.getUser().asIdentifiedUser().getAccountId(),
-        rsrc.getProject(),
-        rsrc.getChange().getId(),
-        ImmutableSet.of(),
-        ImmutableSet.of(IGNORE_LABEL));
-  }
-
-  public boolean isIgnoredBy(Change.Id changeId, Account.Id accountId) {
-    return getLabels(accountId, changeId).contains(IGNORE_LABEL);
-  }
-
-  public boolean isIgnored(ChangeResource rsrc) {
-    return isIgnoredBy(rsrc.getChange().getId(), rsrc.getUser().asIdentifiedUser().getAccountId());
-  }
-
   public static StarRef readLabels(Repository repo, String refName) throws IOException {
     try (TraceTimer traceTimer =
         TraceContext.newTimer(
@@ -414,13 +378,6 @@
     }
   }
 
-  private static void checkMutuallyExclusiveLabels(Set<String> labels)
-      throws MutuallyExclusiveLabelsException {
-    if (labels.containsAll(ImmutableSet.of(DEFAULT_LABEL, IGNORE_LABEL))) {
-      throw new MutuallyExclusiveLabelsException(DEFAULT_LABEL, IGNORE_LABEL);
-    }
-  }
-
   private static void validateLabels(Collection<String> labels) throws InvalidLabelsException {
     if (labels == null) {
       return;
diff --git a/java/com/google/gerrit/server/account/AccountCache.java b/java/com/google/gerrit/server/account/AccountCache.java
index 54bfa56..5ae7345 100644
--- a/java/com/google/gerrit/server/account/AccountCache.java
+++ b/java/com/google/gerrit/server/account/AccountCache.java
@@ -14,10 +14,13 @@
 
 package com.google.gerrit.server.account;
 
+import com.google.gerrit.common.UsedAt;
+import com.google.gerrit.common.UsedAt.Project;
 import com.google.gerrit.entities.Account;
 import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
+import org.eclipse.jgit.lib.ObjectId;
 
 /** Caches important (but small) account state to avoid database hits. */
 public interface AccountCache {
@@ -72,4 +75,18 @@
    *     exists or if loading the external ID fails {@link Optional#empty()} is returned
    */
   Optional<AccountState> getByUsername(String username);
+
+  /**
+   * Returns an {@code AccountState} instance for the given account ID at the given {@code metaId}
+   * of {@link com.google.gerrit.entities.RefNames#refsUsers} ref.
+   *
+   * <p>The caller is responsible to ensure the presence of {@code metaId} and the corresponding
+   * meta ref. The method does not populate {@link AccountState#defaultPreferences}.
+   *
+   * @param accountId ID of the account that should be retrieved.
+   * @param metaId the sha1 of commit in {@link com.google.gerrit.entities.RefNames#refsUsers} ref.
+   * @return {@code AccountState} instance for the given account ID at specific sha1 {@code metaId}.
+   */
+  @UsedAt(Project.GOOGLE)
+  AccountState getFromMetaId(Account.Id accountId, ObjectId metaId);
 }
diff --git a/java/com/google/gerrit/server/account/AccountCacheImpl.java b/java/com/google/gerrit/server/account/AccountCacheImpl.java
index 1d9150d..66a36f6 100644
--- a/java/com/google/gerrit/server/account/AccountCacheImpl.java
+++ b/java/com/google/gerrit/server/account/AccountCacheImpl.java
@@ -45,6 +45,7 @@
 import java.util.Optional;
 import java.util.Set;
 import java.util.concurrent.ExecutionException;
+import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
 
@@ -106,6 +107,18 @@
   }
 
   @Override
+  public AccountState getFromMetaId(Account.Id id, ObjectId metaId) {
+    try {
+      CachedAccountDetails.Key key = CachedAccountDetails.Key.create(id, metaId);
+
+      CachedAccountDetails accountDetails = accountDetailsCache.get(key);
+      return AccountState.forCachedAccount(accountDetails, CachedPreferences.EMPTY, externalIds);
+    } catch (IOException | ExecutionException e) {
+      throw new StorageException(e);
+    }
+  }
+
+  @Override
   public Map<Account.Id, AccountState> get(Set<Account.Id> accountIds) {
     try {
       try (Repository allUsers = repoManager.openRepository(allUsersName)) {
diff --git a/java/com/google/gerrit/server/account/AccountConfig.java b/java/com/google/gerrit/server/account/AccountConfig.java
index 28e881e1..4143f77 100644
--- a/java/com/google/gerrit/server/account/AccountConfig.java
+++ b/java/com/google/gerrit/server/account/AccountConfig.java
@@ -36,7 +36,6 @@
 import java.io.IOException;
 import java.time.Instant;
 import java.util.ArrayList;
-import java.util.Date;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -258,9 +257,6 @@
     return c;
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   @Override
   protected boolean onSave(CommitBuilder commit) throws IOException, ConfigInvalidException {
     checkLoaded();
@@ -279,8 +275,8 @@
       }
 
       Instant registeredOn = loadedAccountProperties.get().getRegisteredOn();
-      commit.setAuthor(new PersonIdent(commit.getAuthor(), Date.from(registeredOn)));
-      commit.setCommitter(new PersonIdent(commit.getCommitter(), Date.from(registeredOn)));
+      commit.setAuthor(new PersonIdent(commit.getAuthor(), registeredOn));
+      commit.setCommitter(new PersonIdent(commit.getCommitter(), registeredOn));
     }
 
     saveAccount();
diff --git a/java/com/google/gerrit/server/account/AccountControl.java b/java/com/google/gerrit/server/account/AccountControl.java
index f5f9b3d..c80059b 100644
--- a/java/com/google/gerrit/server/account/AccountControl.java
+++ b/java/com/google/gerrit/server/account/AccountControl.java
@@ -176,7 +176,7 @@
       logger.atFine().log(
           "user %s can see own account %d", user.getLoggableName(), otherUser.getId().get());
       return true;
-    } else if (viewAll()) {
+    } else if (canViewAll()) {
       logger.atFine().log(
           "user %s can see account %d (view all accounts = true)",
           user.getLoggableName(), otherUser.getId().get());
@@ -255,7 +255,7 @@
     }
   }
 
-  private boolean viewAll() {
+  public boolean canViewAll() {
     if (viewAll == null) {
       try {
         viewAll = perm.test(GlobalPermission.VIEW_ALL_ACCOUNTS);
diff --git a/java/com/google/gerrit/server/account/AccountsUpdate.java b/java/com/google/gerrit/server/account/AccountsUpdate.java
index 3ee6365..8d5fea4 100644
--- a/java/com/google/gerrit/server/account/AccountsUpdate.java
+++ b/java/com/google/gerrit/server/account/AccountsUpdate.java
@@ -60,6 +60,7 @@
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
 
 /**
  * Creates and updates accounts.
@@ -319,17 +320,13 @@
    * @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
    */
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   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.getWhen().toInstant());
+                  Account account = accountConfig.getNewAccount(committerIdent.getWhenAsInstant());
                   AccountState accountState = AccountState.forAccount(account);
                   AccountDelta.Builder deltaBuilder = AccountDelta.builder();
                   init.configure(accountState, deltaBuilder);
@@ -513,23 +510,31 @@
         updatedAccounts.size() == 1
             ? Iterables.getOnlyElement(updatedAccounts).message
             : "Batch update for " + updatedAccounts.size() + " accounts";
+    ObjectId oldExternalIdsRevision = externalIdNotes.getRevision();
+    // These update the same ref, so they need to be stacked on top of one another using the same
+    // ExternalIdNotes instance.
+    RevCommit revCommit =
+        commitExternalIdUpdates(externalIdUpdateMessage, allUsersRepo, batchRefUpdate);
+    boolean externalIdsUpdated = !Objects.equals(revCommit.getId(), oldExternalIdsRevision);
     for (UpdatedAccount updatedAccount : updatedAccounts) {
+
       // These updates are all for different refs (because batches never update the same account
       // more than once), so there can be multiple commits in the same batch, all with the same base
       // revision in their AccountConfig.
+      // We allow empty commits:
+      // 1) When creating a new account, so that the user branch gets created with an empty commit
+      // when no account properties are set and hence no
+      // 'account.config' file will be created.
+      // 2) When updating "refs/meta/external-ids", so that refs/users/* meta ref is updated too.
+      // This allows to schedule reindexing of account transactionally  on refs/users/* meta
+      // updates.
+      boolean allowEmptyCommit = externalIdsUpdated || updatedAccount.created;
       commitAccountConfig(
           updatedAccount.message,
           allUsersRepo,
           batchRefUpdate,
           updatedAccount.accountConfig,
-          updatedAccount.created /* allowEmptyCommit */);
-      // When creating a new account we must allow empty commits so that the user branch gets
-      // created with an empty commit when no account properties are set and hence no
-      // 'account.config' file will be created.
-
-      // These update the same ref, so they need to be stacked on top of one another using the same
-      // ExternalIdNotes instance.
-      commitExternalIdUpdates(externalIdUpdateMessage, allUsersRepo, batchRefUpdate);
+          allowEmptyCommit);
     }
 
     RefUpdateUtil.executeChecked(batchRefUpdate, allUsersRepo);
@@ -562,10 +567,10 @@
     }
   }
 
-  private void commitExternalIdUpdates(
+  private RevCommit commitExternalIdUpdates(
       String message, Repository allUsersRepo, BatchRefUpdate batchRefUpdate) throws IOException {
     try (MetaDataUpdate md = createMetaDataUpdate(message, allUsersRepo, batchRefUpdate)) {
-      externalIdNotes.commit(md);
+      return externalIdNotes.commit(md);
     }
   }
 
diff --git a/java/com/google/gerrit/server/account/Emails.java b/java/com/google/gerrit/server/account/Emails.java
index 45f0844..8c3f033 100644
--- a/java/com/google/gerrit/server/account/Emails.java
+++ b/java/com/google/gerrit/server/account/Emails.java
@@ -113,14 +113,11 @@
     return externalIds.byEmail(email).stream().map(ExternalId::accountId).collect(toImmutableSet());
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   public UserIdentity toUserIdentity(PersonIdent who) throws IOException {
     UserIdentity u = new UserIdentity();
     u.setName(who.getName());
     u.setEmail(who.getEmailAddress());
-    u.setDate(who.getWhen().toInstant());
+    u.setDate(who.getWhenAsInstant());
     u.setTimeZone(who.getTimeZoneOffset());
 
     // If only one account has access to this email address, select it
diff --git a/java/com/google/gerrit/server/account/InternalAccountDirectory.java b/java/com/google/gerrit/server/account/InternalAccountDirectory.java
index f7b0b60..b895834 100644
--- a/java/com/google/gerrit/server/account/InternalAccountDirectory.java
+++ b/java/com/google/gerrit/server/account/InternalAccountDirectory.java
@@ -27,7 +27,6 @@
 import com.google.gerrit.extensions.common.AvatarInfo;
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.externalids.ExternalId;
@@ -101,12 +100,8 @@
     Account.Id currentUserId = null;
     if (self.get().isIdentifiedUser()) {
       currentUserId = self.get().getAccountId();
-
-      try {
-        permissionBackend.currentUser().check(GlobalPermission.MODIFY_ACCOUNT);
+      if (permissionBackend.currentUser().test(GlobalPermission.MODIFY_ACCOUNT)) {
         canModifyAccount = true;
-      } catch (AuthException e) {
-        canModifyAccount = false;
       }
     }
 
diff --git a/java/com/google/gerrit/server/account/UniversalGroupBackend.java b/java/com/google/gerrit/server/account/UniversalGroupBackend.java
index 5bd9bea..1587bc5 100644
--- a/java/com/google/gerrit/server/account/UniversalGroupBackend.java
+++ b/java/com/google/gerrit/server/account/UniversalGroupBackend.java
@@ -27,10 +27,16 @@
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.metrics.Counter1;
+import com.google.gerrit.metrics.Counter2;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.Field;
+import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.StartupCheck;
 import com.google.gerrit.server.StartupException;
 import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.logging.Metadata;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.gerrit.server.plugincontext.PluginSetEntryContext;
 import com.google.gerrit.server.project.ProjectState;
@@ -49,11 +55,57 @@
 public class UniversalGroupBackend implements GroupBackend {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
+  private static final Field<String> SYSTEM_FIELD =
+      Field.ofString("system", Metadata.Builder::groupSystem).build();
+
   private final PluginSetContext<GroupBackend> backends;
+  private final Counter1<String> handlesCount;
+  private final Counter1<String> getCount;
+  private final Counter2<String, Integer> suggestCount;
+  private final Counter2<String, Boolean> containsCount;
+  private final Counter2<String, Boolean> containsAnyCount;
+  private final Counter2<String, Integer> intersectionCount;
+  private final Counter2<String, Integer> knownGroupsCount;
 
   @Inject
-  UniversalGroupBackend(PluginSetContext<GroupBackend> backends) {
+  UniversalGroupBackend(PluginSetContext<GroupBackend> backends, MetricMaker metricMaker) {
     this.backends = backends;
+    this.handlesCount =
+        metricMaker.newCounter(
+            "group/handles_count", new Description("Calls to GroupBackend.handles"), SYSTEM_FIELD);
+    this.getCount =
+        metricMaker.newCounter(
+            "group/get_count", new Description("Calls to GroupBackend.get"), SYSTEM_FIELD);
+    this.suggestCount =
+        metricMaker.newCounter(
+            "group/suggest_count",
+            new Description("Calls to GroupBackend.suggest"),
+            SYSTEM_FIELD,
+            Field.ofInteger("num_suggested", (meta, value) -> {}).build());
+    this.containsCount =
+        metricMaker.newCounter(
+            "group/contains_count",
+            new Description("Calls to GroupMemberships.contains"),
+            SYSTEM_FIELD,
+            Field.ofBoolean("contains", (meta, value) -> {}).build());
+    this.containsAnyCount =
+        metricMaker.newCounter(
+            "group/contains_any_of_count",
+            new Description("Calls to GroupMemberships.containsAnyOf"),
+            SYSTEM_FIELD,
+            Field.ofBoolean("contains_any_of", (meta, value) -> {}).build());
+    this.intersectionCount =
+        metricMaker.newCounter(
+            "group/intersection_count",
+            new Description("Calls to GroupMemberships.intersection"),
+            SYSTEM_FIELD,
+            Field.ofInteger("num_intersection", (meta, value) -> {}).build());
+    this.knownGroupsCount =
+        metricMaker.newCounter(
+            "group/known_groups_count",
+            new Description("Calls to GroupMemberships.getKnownGroups"),
+            SYSTEM_FIELD,
+            Field.ofInteger("num_known_groups", (meta, value) -> {}).build());
   }
 
   @Nullable
@@ -70,7 +122,12 @@
 
   @Override
   public boolean handles(AccountGroup.UUID uuid) {
-    return backend(uuid) != null;
+    GroupBackend b = backend(uuid);
+    if (b == null) {
+      return false;
+    }
+    handlesCount.increment(name(b));
+    return true;
   }
 
   @Override
@@ -83,13 +140,19 @@
       logger.atFine().log("Unknown GroupBackend for UUID: %s", uuid);
       return null;
     }
+    getCount.increment(name(b));
     return b.get(uuid);
   }
 
   @Override
   public Collection<GroupReference> suggest(String name, ProjectState project) {
     Set<GroupReference> groups = Sets.newTreeSet(GROUP_REF_NAME_COMPARATOR);
-    backends.runEach(g -> groups.addAll(g.suggest(name, project)));
+    backends.runEach(
+        g -> {
+          Collection<GroupReference> suggestions = g.suggest(name, project);
+          suggestCount.increment(name(g), suggestions.size());
+          groups.addAll(suggestions);
+        });
     return groups;
   }
 
@@ -108,11 +171,11 @@
     }
 
     @Nullable
-    private GroupMembership membership(AccountGroup.UUID uuid) {
+    private Map.Entry<GroupBackend, GroupMembership> membership(AccountGroup.UUID uuid) {
       if (uuid != null) {
         for (Map.Entry<GroupBackend, GroupMembership> m : memberships.entrySet()) {
           if (m.getKey().handles(uuid)) {
-            return m.getValue();
+            return m;
           }
         }
       }
@@ -125,51 +188,57 @@
       if (uuid == null) {
         return false;
       }
-      GroupMembership m = membership(uuid);
+      Map.Entry<GroupBackend, GroupMembership> m = membership(uuid);
       if (m == null) {
         return false;
       }
-      return m.contains(uuid);
+      boolean contains = m.getValue().contains(uuid);
+      containsCount.increment(name(m.getKey()), contains);
+      return contains;
     }
 
     @Override
     public boolean containsAnyOf(Iterable<AccountGroup.UUID> uuids) {
-      ListMultimap<GroupMembership, AccountGroup.UUID> lookups =
+      ListMultimap<Map.Entry<GroupBackend, GroupMembership>, AccountGroup.UUID> lookups =
           MultimapBuilder.hashKeys().arrayListValues().build();
       for (AccountGroup.UUID uuid : uuids) {
         if (uuid == null) {
           continue;
         }
-        GroupMembership m = membership(uuid);
+        Map.Entry<GroupBackend, GroupMembership> m = membership(uuid);
         if (m == null) {
           continue;
         }
         lookups.put(m, uuid);
       }
-      for (Map.Entry<GroupMembership, Collection<AccountGroup.UUID>> entry :
-          lookups.asMap().entrySet()) {
-        GroupMembership m = entry.getKey();
-        Collection<AccountGroup.UUID> ids = entry.getValue();
+      for (Map.Entry<GroupBackend, GroupMembership> groupBackends : lookups.asMap().keySet()) {
+
+        GroupMembership m = groupBackends.getValue();
+        Collection<AccountGroup.UUID> ids = lookups.asMap().get(groupBackends);
         if (ids.size() == 1) {
           if (m.contains(Iterables.getOnlyElement(ids))) {
+            containsAnyCount.increment(name(groupBackends.getKey()), true);
             return true;
           }
         } else if (m.containsAnyOf(ids)) {
+          containsAnyCount.increment(name(groupBackends.getKey()), true);
           return true;
         }
+        // We would have returned if contains was true.
+        containsAnyCount.increment(name(groupBackends.getKey()), false);
       }
       return false;
     }
 
     @Override
     public Set<AccountGroup.UUID> intersection(Iterable<AccountGroup.UUID> uuids) {
-      ListMultimap<GroupMembership, AccountGroup.UUID> lookups =
+      ListMultimap<Map.Entry<GroupBackend, GroupMembership>, AccountGroup.UUID> lookups =
           MultimapBuilder.hashKeys().arrayListValues().build();
       for (AccountGroup.UUID uuid : uuids) {
         if (uuid == null) {
           continue;
         }
-        GroupMembership m = membership(uuid);
+        Map.Entry<GroupBackend, GroupMembership> m = membership(uuid);
         if (m == null) {
           logger.atFine().log("Unknown GroupMembership for UUID: %s", uuid);
           continue;
@@ -177,9 +246,11 @@
         lookups.put(m, uuid);
       }
       Set<AccountGroup.UUID> groups = new HashSet<>();
-      for (Map.Entry<GroupMembership, Collection<AccountGroup.UUID>> entry :
-          lookups.asMap().entrySet()) {
-        groups.addAll(entry.getKey().intersection(entry.getValue()));
+      for (Map.Entry<GroupBackend, GroupMembership> groupBackend : lookups.asMap().keySet()) {
+        Set<AccountGroup.UUID> intersection =
+            groupBackend.getValue().intersection(lookups.asMap().get(groupBackend));
+        intersectionCount.increment(name(groupBackend.getKey()), intersection.size());
+        groups.addAll(intersection);
       }
       return groups;
     }
@@ -187,8 +258,10 @@
     @Override
     public Set<AccountGroup.UUID> getKnownGroups() {
       Set<AccountGroup.UUID> groups = new HashSet<>();
-      for (GroupMembership m : memberships.values()) {
-        groups.addAll(m.getKnownGroups());
+      for (Map.Entry<GroupBackend, GroupMembership> entry : memberships.entrySet()) {
+        Set<AccountGroup.UUID> knownGroups = entry.getValue().getKnownGroups();
+        knownGroupsCount.increment(name(entry.getKey()), knownGroups.size());
+        groups.addAll(knownGroups);
       }
       return groups;
     }
@@ -204,6 +277,13 @@
     return false;
   }
 
+  private static String name(GroupBackend backend) {
+    if (backend == null) {
+      return "none";
+    }
+    return backend.getClass().getSimpleName();
+  }
+
   public static class ConfigCheck implements StartupCheck {
     private final Config cfg;
     private final UniversalGroupBackend universalGroupBackend;
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalId.java b/java/com/google/gerrit/server/account/externalids/ExternalId.java
index 0a51171..1616198 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalId.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalId.java
@@ -135,6 +135,9 @@
   /** Scheme used for GPG public keys. */
   public static final String SCHEME_GPGKEY = "gpgkey";
 
+  /** Scheme for imported accounts from other servers with different GerritServerId */
+  public static final String SCHEME_IMPORTED = "imported";
+
   /** Scheme for external auth used during authentication, e.g. OAuth Token */
   public static final String SCHEME_EXTERNAL = "external";
 
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdCache.java b/java/com/google/gerrit/server/account/externalids/ExternalIdCache.java
index 9f766d0..fe8feac 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdCache.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdCache.java
@@ -30,7 +30,7 @@
  *
  * <p>All returned collections are unmodifiable.
  */
-interface ExternalIdCache {
+public interface ExternalIdCache {
   Optional<ExternalId> byKey(ExternalId.Key key) throws IOException;
 
   ImmutableSet<ExternalId> byAccount(Account.Id accountId) throws IOException;
diff --git a/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java b/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
index a49061d..1713171 100644
--- a/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
+++ b/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
@@ -19,7 +19,6 @@
 import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.ListMultimap;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.AbandonInput;
 import com.google.gerrit.extensions.api.changes.AssigneeInput;
 import com.google.gerrit.extensions.api.changes.AttentionSetApi;
@@ -65,8 +64,6 @@
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.server.DynamicOptions;
-import com.google.gerrit.server.StarredChangesUtil;
-import com.google.gerrit.server.StarredChangesUtil.IllegalLabelException;
 import com.google.gerrit.server.change.ChangeMessageResource;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.WorkInProgressOp;
@@ -88,7 +85,6 @@
 import com.google.gerrit.server.restapi.change.GetPastAssignees;
 import com.google.gerrit.server.restapi.change.GetPureRevert;
 import com.google.gerrit.server.restapi.change.GetTopic;
-import com.google.gerrit.server.restapi.change.Ignore;
 import com.google.gerrit.server.restapi.change.Index;
 import com.google.gerrit.server.restapi.change.ListChangeComments;
 import com.google.gerrit.server.restapi.change.ListChangeDrafts;
@@ -111,7 +107,6 @@
 import com.google.gerrit.server.restapi.change.SetWorkInProgress;
 import com.google.gerrit.server.restapi.change.SubmittedTogether;
 import com.google.gerrit.server.restapi.change.SuggestChangeReviewers;
-import com.google.gerrit.server.restapi.change.Unignore;
 import com.google.gerrit.util.cli.CmdLineParser;
 import com.google.inject.Inject;
 import com.google.inject.Injector;
@@ -167,18 +162,15 @@
   private final Provider<ListChangeDrafts> listDraftsProvider;
   private final ChangeEditApiImpl.Factory changeEditApi;
   private final Check check;
-  private final CheckSubmitRequirement checkSubmitRequirement;
+  private final Provider<CheckSubmitRequirement> checkSubmitRequirementProvider;
   private final Index index;
   private final Move move;
   private final PostPrivate postPrivate;
   private final DeletePrivate deletePrivate;
-  private final Ignore ignore;
-  private final Unignore unignore;
   private final SetWorkInProgress setWip;
   private final SetReadyForReview setReady;
   private final PutMessage putMessage;
   private final Provider<GetPureRevert> getPureRevertProvider;
-  private final StarredChangesUtil stars;
   private final DynamicOptionParser dynamicOptionParser;
   private final Injector injector;
   private final DynamicMap<DynamicOptions.DynamicBean> dynamicBeans;
@@ -222,18 +214,15 @@
       Provider<ListChangeDrafts> listDraftsProvider,
       ChangeEditApiImpl.Factory changeEditApi,
       Check check,
-      CheckSubmitRequirement checkSubmitRequirement,
+      Provider<CheckSubmitRequirement> checkSubmitRequirement,
       Index index,
       Move move,
       PostPrivate postPrivate,
       DeletePrivate deletePrivate,
-      Ignore ignore,
-      Unignore unignore,
       SetWorkInProgress setWip,
       SetReadyForReview setReady,
       PutMessage putMessage,
       Provider<GetPureRevert> getPureRevertProvider,
-      StarredChangesUtil stars,
       DynamicOptionParser dynamicOptionParser,
       @Assisted ChangeResource change,
       Injector injector,
@@ -275,18 +264,15 @@
     this.listDraftsProvider = listDraftsProvider;
     this.changeEditApi = changeEditApi;
     this.check = check;
-    this.checkSubmitRequirement = checkSubmitRequirement;
+    this.checkSubmitRequirementProvider = checkSubmitRequirement;
     this.index = index;
     this.move = move;
     this.postPrivate = postPrivate;
     this.deletePrivate = deletePrivate;
-    this.ignore = ignore;
-    this.unignore = unignore;
     this.setWip = setWip;
     this.setReady = setReady;
     this.putMessage = putMessage;
     this.getPureRevertProvider = getPureRevertProvider;
-    this.stars = stars;
     this.dynamicOptionParser = dynamicOptionParser;
     this.change = change;
     this.injector = injector;
@@ -714,10 +700,27 @@
   }
 
   @Override
+  public CheckSubmitRequirementRequest checkSubmitRequirementRequest() {
+    return new CheckSubmitRequirementRequest() {
+      @Override
+      public SubmitRequirementResultInfo get() throws RestApiException {
+        try {
+          CheckSubmitRequirement check = checkSubmitRequirementProvider.get();
+          check.setSrName(this.srName());
+          check.setRefsConfigChangeId(this.getRefsConfigChangeId());
+          return check.apply(change, null).value();
+        } catch (Exception e) {
+          throw asRestApiException("Cannot check submit requirement", e);
+        }
+      }
+    };
+  }
+
+  @Override
   public SubmitRequirementResultInfo checkSubmitRequirement(SubmitRequirementInput input)
       throws RestApiException {
     try {
-      return checkSubmitRequirement.apply(change, input).value();
+      return checkSubmitRequirementProvider.get().apply(change, input).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot check submit requirement", e);
     }
@@ -733,30 +736,6 @@
   }
 
   @Override
-  public void ignore(boolean ignore) throws RestApiException {
-    // TODO(dborowitz): Convert to RetryingRestModifyView. Needs to plumb BatchUpdate.Factory into
-    // StarredChangesUtil.
-    try {
-      if (ignore) {
-        this.ignore.apply(change, new Input());
-      } else {
-        unignore.apply(change, new Input());
-      }
-    } catch (StorageException | IllegalLabelException e) {
-      throw asRestApiException("Cannot ignore change", e);
-    }
-  }
-
-  @Override
-  public boolean ignored() throws RestApiException {
-    try {
-      return stars.isIgnored(change);
-    } catch (StorageException e) {
-      throw asRestApiException("Cannot check if ignored", e);
-    }
-  }
-
-  @Override
   public PureRevertInfo pureRevert() throws RestApiException {
     return pureRevert(null);
   }
diff --git a/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java b/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
index 9aa9306..a7931f1 100644
--- a/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
+++ b/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
@@ -29,6 +29,7 @@
 import com.google.gerrit.extensions.api.changes.DraftApi;
 import com.google.gerrit.extensions.api.changes.DraftInput;
 import com.google.gerrit.extensions.api.changes.FileApi;
+import com.google.gerrit.extensions.api.changes.GetRelatedOption;
 import com.google.gerrit.extensions.api.changes.RebaseInput;
 import com.google.gerrit.extensions.api.changes.RelatedChangesInfo;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
@@ -40,6 +41,7 @@
 import com.google.gerrit.extensions.client.ArchiveFormat;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.ActionInfo;
+import com.google.gerrit.extensions.common.ApplyProvidedFixInput;
 import com.google.gerrit.extensions.common.ApprovalInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.CommentInfo;
@@ -64,7 +66,8 @@
 import com.google.gerrit.server.change.RebaseUtil;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.restapi.change.ApplyFix;
+import com.google.gerrit.server.restapi.change.ApplyProvidedFix;
+import com.google.gerrit.server.restapi.change.ApplyStoredFix;
 import com.google.gerrit.server.restapi.change.CherryPick;
 import com.google.gerrit.server.restapi.change.Comments;
 import com.google.gerrit.server.restapi.change.CreateDraftComment;
@@ -74,7 +77,6 @@
 import com.google.gerrit.server.restapi.change.GetArchive;
 import com.google.gerrit.server.restapi.change.GetCommit;
 import com.google.gerrit.server.restapi.change.GetDescription;
-import com.google.gerrit.server.restapi.change.GetFixPreview;
 import com.google.gerrit.server.restapi.change.GetMergeList;
 import com.google.gerrit.server.restapi.change.GetPatch;
 import com.google.gerrit.server.restapi.change.GetRelated;
@@ -86,6 +88,7 @@
 import com.google.gerrit.server.restapi.change.ListRobotComments;
 import com.google.gerrit.server.restapi.change.Mergeable;
 import com.google.gerrit.server.restapi.change.PostReview;
+import com.google.gerrit.server.restapi.change.PreviewFix;
 import com.google.gerrit.server.restapi.change.PutDescription;
 import com.google.gerrit.server.restapi.change.Rebase;
 import com.google.gerrit.server.restapi.change.Reviewed;
@@ -132,8 +135,10 @@
   private final ListRobotComments listRobotComments;
   private final ListPortedComments listPortedComments;
   private final ListPortedDrafts listPortedDrafts;
-  private final ApplyFix applyFix;
-  private final GetFixPreview getFixPreview;
+  private final ApplyStoredFix applyStoredFix;
+  private final PreviewFix.Stored previewStoredFix;
+  private final ApplyProvidedFix applyProvidedFix;
+  private final PreviewFix.Provided previewProvidedFix;
   private final Fixes fixes;
   private final ListRevisionDrafts listDrafts;
   private final CreateDraftComment createDraft;
@@ -178,8 +183,10 @@
       ListRobotComments listRobotComments,
       ListPortedComments listPortedComments,
       ListPortedDrafts listPortedDrafts,
-      ApplyFix applyFix,
-      GetFixPreview getFixPreview,
+      ApplyStoredFix applyStoredFix,
+      PreviewFix.Stored previewStoredFix,
+      ApplyProvidedFix applyProvidedFix,
+      PreviewFix.Provided previewProvidedFix,
       Fixes fixes,
       ListRevisionDrafts listDrafts,
       CreateDraftComment createDraft,
@@ -223,8 +230,10 @@
     this.listRobotComments = listRobotComments;
     this.listPortedComments = listPortedComments;
     this.listPortedDrafts = listPortedDrafts;
-    this.applyFix = applyFix;
-    this.getFixPreview = getFixPreview;
+    this.applyStoredFix = applyStoredFix;
+    this.previewStoredFix = previewStoredFix;
+    this.applyProvidedFix = applyProvidedFix;
+    this.previewProvidedFix = previewProvidedFix;
     this.fixes = fixes;
     this.listDrafts = listDrafts;
     this.createDraft = createDraft;
@@ -477,7 +486,16 @@
   @Override
   public EditInfo applyFix(String fixId) throws RestApiException {
     try {
-      return applyFix.apply(fixes.parse(revision, IdString.fromDecoded(fixId)), null).value();
+      return applyStoredFix.apply(fixes.parse(revision, IdString.fromDecoded(fixId)), null).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot apply stored fix", e);
+    }
+  }
+
+  @Override
+  public EditInfo applyFix(ApplyProvidedFixInput applyProvidedFixInput) throws RestApiException {
+    try {
+      return applyProvidedFix.apply(revision, applyProvidedFixInput).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot apply fix", e);
     }
@@ -486,9 +504,19 @@
   @Override
   public Map<String, DiffInfo> getFixPreview(String fixId) throws RestApiException {
     try {
-      return getFixPreview.apply(fixes.parse(revision, IdString.fromDecoded(fixId))).value();
+      return previewStoredFix.apply(fixes.parse(revision, IdString.fromDecoded(fixId))).value();
     } catch (Exception e) {
-      throw asRestApiException("Cannot get fix preview", e);
+      throw asRestApiException("Cannot preview stored fix", e);
+    }
+  }
+
+  @Override
+  public Map<String, DiffInfo> getFixPreview(ApplyProvidedFixInput applyProvidedFixInput)
+      throws RestApiException {
+    try {
+      return previewProvidedFix.apply(revision, applyProvidedFixInput).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot preview provided fix", e);
     }
   }
 
@@ -615,7 +643,13 @@
 
   @Override
   public RelatedChangesInfo related() throws RestApiException {
+    return related(EnumSet.noneOf(GetRelatedOption.class));
+  }
+
+  @Override
+  public RelatedChangesInfo related(EnumSet<GetRelatedOption> options) throws RestApiException {
     try {
+      options.forEach(getRelated::addOption);
       return getRelated.apply(revision).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot get related changes", e);
diff --git a/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java b/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java
index 5baed86..3a892bc 100644
--- a/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java
+++ b/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java
@@ -41,6 +41,7 @@
 import com.google.gerrit.extensions.api.projects.ParentInput;
 import com.google.gerrit.extensions.api.projects.ProjectApi;
 import com.google.gerrit.extensions.api.projects.ProjectInput;
+import com.google.gerrit.extensions.api.projects.SubmitRequirementApi;
 import com.google.gerrit.extensions.api.projects.TagApi;
 import com.google.gerrit.extensions.api.projects.TagInfo;
 import com.google.gerrit.extensions.common.BatchLabelInput;
@@ -48,6 +49,7 @@
 import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.common.LabelDefinitionInfo;
 import com.google.gerrit.extensions.common.ProjectInfo;
+import com.google.gerrit.extensions.common.SubmitRequirementInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.NotImplementedException;
@@ -78,6 +80,7 @@
 import com.google.gerrit.server.restapi.project.ListBranches;
 import com.google.gerrit.server.restapi.project.ListDashboards;
 import com.google.gerrit.server.restapi.project.ListLabels;
+import com.google.gerrit.server.restapi.project.ListSubmitRequirements;
 import com.google.gerrit.server.restapi.project.ListTags;
 import com.google.gerrit.server.restapi.project.PostLabels;
 import com.google.gerrit.server.restapi.project.ProjectsCollection;
@@ -138,8 +141,10 @@
   private final Index index;
   private final IndexChanges indexChanges;
   private final Provider<ListLabels> listLabels;
+  private final Provider<ListSubmitRequirements> listSubmitRequirements;
   private final PostLabels postLabels;
   private final LabelApiImpl.Factory labelApi;
+  private final SubmitRequirementApiImpl.Factory submitRequirementApi;
 
   @AssistedInject
   ProjectApiImpl(
@@ -177,8 +182,10 @@
       Index index,
       IndexChanges indexChanges,
       Provider<ListLabels> listLabels,
+      Provider<ListSubmitRequirements> listSubmitRequirements,
       PostLabels postLabels,
       LabelApiImpl.Factory labelApi,
+      SubmitRequirementApiImpl.Factory submitRequirementApi,
       @Assisted ProjectResource project) {
     this(
         permissionBackend,
@@ -216,8 +223,10 @@
         index,
         indexChanges,
         listLabels,
+        listSubmitRequirements,
         postLabels,
         labelApi,
+        submitRequirementApi,
         null);
   }
 
@@ -257,8 +266,10 @@
       Index index,
       IndexChanges indexChanges,
       Provider<ListLabels> listLabels,
+      Provider<ListSubmitRequirements> listSubmitRequirements,
       PostLabels postLabels,
       LabelApiImpl.Factory labelApi,
+      SubmitRequirementApiImpl.Factory submitRequirementApi,
       @Assisted String name) {
     this(
         permissionBackend,
@@ -296,8 +307,10 @@
         index,
         indexChanges,
         listLabels,
+        listSubmitRequirements,
         postLabels,
         labelApi,
+        submitRequirementApi,
         name);
   }
 
@@ -337,8 +350,10 @@
       Index index,
       IndexChanges indexChanges,
       Provider<ListLabels> listLabels,
+      Provider<ListSubmitRequirements> listSubmitRequirements,
       PostLabels postLabels,
       LabelApiImpl.Factory labelApi,
+      SubmitRequirementApiImpl.Factory submitRequirementApi,
       String name) {
     this.permissionBackend = permissionBackend;
     this.createProject = createProject;
@@ -376,8 +391,10 @@
     this.index = index;
     this.indexChanges = indexChanges;
     this.listLabels = listLabels;
+    this.listSubmitRequirements = listSubmitRequirements;
     this.postLabels = postLabels;
     this.labelApi = labelApi;
+    this.submitRequirementApi = submitRequirementApi;
   }
 
   @Override
@@ -737,6 +754,20 @@
   }
 
   @Override
+  public ListSubmitRequirementsRequest submitRequirements() {
+    return new ListSubmitRequirementsRequest() {
+      @Override
+      public List<SubmitRequirementInfo> get() throws RestApiException {
+        try {
+          return listSubmitRequirements.get().withInherited(inherited).apply(checkExists()).value();
+        } catch (Exception e) {
+          throw asRestApiException("Cannot list submit requirements", e);
+        }
+      }
+    };
+  }
+
+  @Override
   public LabelApi label(String labelName) throws RestApiException {
     try {
       return labelApi.create(checkExists(), labelName);
@@ -746,6 +777,15 @@
   }
 
   @Override
+  public SubmitRequirementApi submitRequirement(String name) throws RestApiException {
+    try {
+      return submitRequirementApi.create(checkExists(), name);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot parse submit requirement", e);
+    }
+  }
+
+  @Override
   public void labels(BatchLabelInput input) throws RestApiException {
     try {
       postLabels.apply(checkExists(), input);
diff --git a/java/com/google/gerrit/server/api/projects/ProjectsModule.java b/java/com/google/gerrit/server/api/projects/ProjectsModule.java
index 987c71f..9f7e1b4 100644
--- a/java/com/google/gerrit/server/api/projects/ProjectsModule.java
+++ b/java/com/google/gerrit/server/api/projects/ProjectsModule.java
@@ -29,5 +29,6 @@
     factory(CommitApiImpl.Factory.class);
     factory(DashboardApiImpl.Factory.class);
     factory(LabelApiImpl.Factory.class);
+    factory(SubmitRequirementApiImpl.Factory.class);
   }
 }
diff --git a/java/com/google/gerrit/server/api/projects/SubmitRequirementApiImpl.java b/java/com/google/gerrit/server/api/projects/SubmitRequirementApiImpl.java
new file mode 100644
index 0000000..aa6ef71
--- /dev/null
+++ b/java/com/google/gerrit/server/api/projects/SubmitRequirementApiImpl.java
@@ -0,0 +1,122 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.api.projects;
+
+import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
+import static com.google.gerrit.server.project.ProjectCache.illegalState;
+
+import com.google.gerrit.extensions.api.projects.SubmitRequirementApi;
+import com.google.gerrit.extensions.common.Input;
+import com.google.gerrit.extensions.common.SubmitRequirementInfo;
+import com.google.gerrit.extensions.common.SubmitRequirementInput;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.gerrit.server.project.SubmitRequirementResource;
+import com.google.gerrit.server.restapi.project.CreateSubmitRequirement;
+import com.google.gerrit.server.restapi.project.DeleteSubmitRequirement;
+import com.google.gerrit.server.restapi.project.GetSubmitRequirement;
+import com.google.gerrit.server.restapi.project.SubmitRequirementsCollection;
+import com.google.gerrit.server.restapi.project.UpdateSubmitRequirement;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+
+public class SubmitRequirementApiImpl implements SubmitRequirementApi {
+  interface Factory {
+    SubmitRequirementApiImpl create(ProjectResource project, String name);
+  }
+
+  private final SubmitRequirementsCollection submitRequirements;
+  private final CreateSubmitRequirement createSubmitRequirement;
+  private final UpdateSubmitRequirement updateSubmitRequirement;
+  private final DeleteSubmitRequirement deleteSubmitRequirement;
+  private final GetSubmitRequirement getSubmitRequirement;
+  private final String name;
+  private final ProjectCache projectCache;
+
+  private ProjectResource project;
+
+  @Inject
+  SubmitRequirementApiImpl(
+      SubmitRequirementsCollection submitRequirements,
+      CreateSubmitRequirement createSubmitRequirement,
+      UpdateSubmitRequirement updateSubmitRequirement,
+      DeleteSubmitRequirement deleteSubmitRequirement,
+      GetSubmitRequirement getSubmitRequirement,
+      ProjectCache projectCache,
+      @Assisted ProjectResource project,
+      @Assisted String name) {
+    this.submitRequirements = submitRequirements;
+    this.createSubmitRequirement = createSubmitRequirement;
+    this.updateSubmitRequirement = updateSubmitRequirement;
+    this.deleteSubmitRequirement = deleteSubmitRequirement;
+    this.getSubmitRequirement = getSubmitRequirement;
+    this.projectCache = projectCache;
+    this.project = project;
+    this.name = name;
+  }
+
+  @Override
+  public SubmitRequirementApi create(SubmitRequirementInput input) throws RestApiException {
+    try {
+      createSubmitRequirement.apply(project, IdString.fromDecoded(name), input);
+
+      // recreate project resource because project state was updated
+      project =
+          new ProjectResource(
+              projectCache
+                  .get(project.getNameKey())
+                  .orElseThrow(illegalState(project.getNameKey())),
+              project.getUser());
+
+      return this;
+    } catch (Exception e) {
+      throw asRestApiException("Cannot create submit requirement", e);
+    }
+  }
+
+  @Override
+  public SubmitRequirementInfo get() throws RestApiException {
+    try {
+      return getSubmitRequirement.apply(resource()).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get submit requirement", e);
+    }
+  }
+
+  @Override
+  public SubmitRequirementInfo update(SubmitRequirementInput input) throws RestApiException {
+    try {
+      return updateSubmitRequirement.apply(resource(), input).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot update submit requirement", e);
+    }
+  }
+
+  @Override
+  public void delete() throws RestApiException {
+    try {
+      deleteSubmitRequirement.apply(resource(), new Input());
+    } catch (Exception e) {
+      throw asRestApiException("Cannot delete submit requirement", e);
+    }
+  }
+
+  private SubmitRequirementResource resource() throws RestApiException, PermissionBackendException {
+    return submitRequirements.parse(project, IdString.fromDecoded(name));
+  }
+}
diff --git a/java/com/google/gerrit/server/approval/ApprovalCopier.java b/java/com/google/gerrit/server/approval/ApprovalCopier.java
index 31380f4..059445e 100644
--- a/java/com/google/gerrit/server/approval/ApprovalCopier.java
+++ b/java/com/google/gerrit/server/approval/ApprovalCopier.java
@@ -14,15 +14,16 @@
 
 package com.google.gerrit.server.approval;
 
-import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.common.base.Preconditions.checkState;
+import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
 
+import com.google.auto.value.AutoValue;
+import com.google.common.annotations.VisibleForTesting;
 import com.google.common.collect.HashBasedTable;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Table;
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.LabelTypes;
@@ -32,76 +33,122 @@
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.client.ChangeKind;
 import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.change.ChangeKindCache;
 import com.google.gerrit.server.change.LabelNormalizer;
+import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.logging.Metadata;
 import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.logging.TraceContext.TraceTimer;
 import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.patch.DiffNotAvailableException;
-import com.google.gerrit.server.patch.DiffOperations;
-import com.google.gerrit.server.patch.DiffOptions;
-import com.google.gerrit.server.patch.gitdiff.ModifiedFile;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.query.approval.ApprovalContext;
 import com.google.gerrit.server.query.approval.ApprovalQueryBuilder;
-import com.google.gerrit.server.query.approval.ListOfFilesUnchangedPredicate;
 import com.google.gerrit.server.util.ManualRequestContext;
 import com.google.gerrit.server.util.OneOffRequestContext;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.util.Collection;
 import java.util.Map;
 import java.util.Optional;
 import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevWalk;
 
 /**
- * Computes approvals for a given patch set by looking at approvals applied to the given patch set
- * and by additionally copying approvals from the previous patch set. The latter is done by
- * asserting a change's kind and checking the project config for copy conditions.
+ * Computes copied approvals for a given patch set.
  *
- * <p>The result of a copy is stored in NoteDb when a new patch set is created.
+ * <p>Approvals are copied if:
+ *
+ * <ul>
+ *   <li>the approval on the previous patch set matches the copy condition of its label
+ *   <li>the approval is not overridden by a current approval on the patch set
+ * </ul>
+ *
+ * <p>Callers should store the copied approvals in NoteDb when a new patch set is created.
  */
 @Singleton
-class ApprovalCopier {
+@VisibleForTesting
+public class ApprovalCopier {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  private final DiffOperations diffOperations;
+  @AutoValue
+  public abstract static class Result {
+    /**
+     * Approvals that have been copied from the previous patch set.
+     *
+     * <p>An approval is copied if:
+     *
+     * <ul>
+     *   <li>the approval on the previous patch set matches the copy condition of its label
+     *   <li>the approval is not overridden by a current approval on the patch set
+     * </ul>
+     */
+    public abstract ImmutableSet<PatchSetApproval> copiedApprovals();
+
+    /**
+     * Approvals on the previous patch set that have not been copied to the patch set.
+     *
+     * <p>These approvals didn't match the copy condition of their labels and hence haven't been
+     * copied.
+     *
+     * <p>Only returns non-copied approvals of the previous patch set. Approvals from earlier patch
+     * sets that were outdated before are not included.
+     */
+    public abstract ImmutableSet<PatchSetApproval> outdatedApprovals();
+
+    static Result empty() {
+      return create(
+          /* copiedApprovals= */ ImmutableSet.of(), /* outdatedApprovals= */ ImmutableSet.of());
+    }
+
+    @VisibleForTesting
+    public static Result create(
+        ImmutableSet<PatchSetApproval> copiedApprovals,
+        ImmutableSet<PatchSetApproval> outdatedApprovals) {
+      return new AutoValue_ApprovalCopier_Result(copiedApprovals, outdatedApprovals);
+    }
+  }
+
+  private final GitRepositoryManager repoManager;
   private final ProjectCache projectCache;
   private final ChangeKindCache changeKindCache;
+  private final PatchSetUtil psUtil;
   private final LabelNormalizer labelNormalizer;
   private final ApprovalQueryBuilder approvalQueryBuilder;
   private final OneOffRequestContext requestContext;
-  private final ListOfFilesUnchangedPredicate listOfFilesUnchangedPredicate;
 
   @Inject
   ApprovalCopier(
-      DiffOperations diffOperations,
+      GitRepositoryManager repoManager,
       ProjectCache projectCache,
       ChangeKindCache changeKindCache,
+      PatchSetUtil psUtil,
       LabelNormalizer labelNormalizer,
       ApprovalQueryBuilder approvalQueryBuilder,
-      OneOffRequestContext requestContext,
-      ListOfFilesUnchangedPredicate listOfFilesUnchangedPredicate) {
-    this.diffOperations = diffOperations;
+      OneOffRequestContext requestContext) {
+    this.repoManager = repoManager;
     this.projectCache = projectCache;
     this.changeKindCache = changeKindCache;
+    this.psUtil = psUtil;
     this.labelNormalizer = labelNormalizer;
     this.approvalQueryBuilder = approvalQueryBuilder;
     this.requestContext = requestContext;
-    this.listOfFilesUnchangedPredicate = listOfFilesUnchangedPredicate;
   }
 
   /**
-   * Returns all approvals that apply to the given patch set. Honors copied approvals from previous
-   * patch-set.
+   * Returns all copied approvals that apply to the given patch set.
+   *
+   * <p>Approvals are copied if:
+   *
+   * <ul>
+   *   <li>the approval on the previous patch set matches the copy condition of its label
+   *   <li>the approval is not overridden by a current approval on the patch set
+   * </ul>
    */
-  Iterable<PatchSetApproval> forPatchSet(
-      ChangeNotes notes, PatchSet ps, RevWalk rw, Config repoConfig) {
+  @VisibleForTesting
+  public Result forPatchSet(ChangeNotes notes, PatchSet ps, RevWalk rw, Config repoConfig) {
     ProjectState project;
     try (TraceTimer traceTimer =
         TraceContext.newTimer(
@@ -114,226 +161,132 @@
           projectCache
               .get(notes.getProjectName())
               .orElseThrow(illegalState(notes.getProjectName()));
-      Collection<PatchSetApproval> approvals =
-          getForPatchSetWithoutNormalization(notes, project, ps, rw, repoConfig);
-      return labelNormalizer.normalize(notes, approvals).getNormalized();
+      return computeForPatchSet(project.getLabelTypes(), notes, ps, rw, repoConfig);
     }
   }
 
-  private boolean canCopyBasedOnBooleanLabelConfigs(
-      ProjectState project,
-      PatchSetApproval psa,
-      PatchSet.Id psId,
-      ChangeKind kind,
-      boolean isMerge,
-      LabelType type,
-      @Nullable Map<String, ModifiedFile> baseVsCurrentDiff,
-      @Nullable Map<String, ModifiedFile> baseVsPriorDiff,
-      @Nullable Map<String, ModifiedFile> priorVsCurrentDiff) {
-    int n = psa.key().patchSetId().get();
-    checkArgument(n != psId.get());
-
-    if (type.isCopyMinScore() && type.isMaxNegative(psa)) {
-      logger.atFine().log(
-          "veto approval %s on label %s of patch set %d of change %d can be copied"
-              + " to patch set %d because the label has set copyMinScore = true on project %s",
-          psa.value(),
-          psa.label(),
-          n,
-          psa.key().patchSetId().changeId().get(),
-          psId.get(),
-          project.getName());
-      return true;
-    } else if (type.isCopyMaxScore() && type.isMaxPositive(psa)) {
-      logger.atFine().log(
-          "max approval %s on label %s of patch set %d of change %d can be copied"
-              + " to patch set %d because the label has set copyMaxScore = true on project %s",
-          psa.value(),
-          psa.label(),
-          n,
-          psa.key().patchSetId().changeId().get(),
-          psId.get(),
-          project.getName());
-      return true;
-    } else if (type.isCopyAnyScore()) {
-      logger.atFine().log(
-          "approval %d on label %s of patch set %d of change %d can be copied"
-              + " to patch set %d because the label has set copyAnyScore = true on project %s",
-          psa.value(),
-          psa.label(),
-          n,
-          psa.key().patchSetId().changeId().get(),
-          psId.get(),
-          project.getName());
-      return true;
-    } else if (type.getCopyValues().contains(psa.value())) {
-      logger.atFine().log(
-          "approval %d on label %s of patch set %d of change %d can be copied"
-              + " to patch set %d because the label has set copyValue = %d on project %s",
-          psa.value(),
-          psa.label(),
-          n,
-          psa.key().patchSetId().changeId().get(),
-          psId.get(),
-          psa.value(),
-          project.getName());
-      return true;
-    } else if (type.isCopyAllScoresIfListOfFilesDidNotChange()
-        && listOfFilesUnchangedPredicate.match(
-            baseVsCurrentDiff, baseVsPriorDiff, priorVsCurrentDiff)) {
-      logger.atFine().log(
-          "approval %d on label %s of patch set %d of change %d can be copied"
-              + " to patch set %d because the label has set "
-              + "copyAllScoresIfListOfFilesDidNotChange = true on "
-              + "project %s and list of files did not change (maybe except a rename, which is "
-              + "still the same file).",
-          psa.value(),
-          psa.label(),
-          n,
-          psa.key().patchSetId().changeId().get(),
-          psId.get(),
-          project.getName());
-      return true;
-    }
-    switch (kind) {
-      case MERGE_FIRST_PARENT_UPDATE:
-        if (type.isCopyAllScoresOnMergeFirstParentUpdate()) {
-          logger.atFine().log(
-              "approval %d on label %s of patch set %d of change %d can be copied"
-                  + " to patch set %d because change kind is %s and the label has set"
-                  + " copyAllScoresOnMergeFirstParentUpdate = true on project %s",
-              psa.value(),
-              psa.label(),
-              n,
-              psa.key().patchSetId().changeId().get(),
-              psId.get(),
-              kind,
-              project.getName());
-          return true;
-        }
-        return false;
-      case NO_CODE_CHANGE:
-        if (type.isCopyAllScoresIfNoCodeChange()) {
-          logger.atFine().log(
-              "approval %d on label %s of patch set %d of change %d can be copied"
-                  + " to patch set %d because change kind is %s and the label has set"
-                  + " copyAllScoresIfNoCodeChange = true on project %s",
-              psa.value(),
-              psa.label(),
-              n,
-              psa.key().patchSetId().changeId().get(),
-              psId.get(),
-              kind,
-              project.getName());
-          return true;
-        }
-        return false;
-      case TRIVIAL_REBASE:
-        if (type.isCopyAllScoresOnTrivialRebase()) {
-          logger.atFine().log(
-              "approval %d on label %s of patch set %d of change %d can be copied"
-                  + " to patch set %d because change kind is %s and the label has set"
-                  + " copyAllScoresOnTrivialRebase = true on project %s",
-              psa.value(),
-              psa.label(),
-              n,
-              psa.key().patchSetId().changeId().get(),
-              psId.get(),
-              kind,
-              project.getName());
-          return true;
-        }
-        return false;
-      case NO_CHANGE:
-        if (type.isCopyAllScoresIfNoChange()) {
-          logger.atFine().log(
-              "approval %d on label %s of patch set %d of change %d can be copied"
-                  + " to patch set %d because change kind is %s and the label has set"
-                  + " copyAllScoresIfNoCodeChange = true on project %s",
-              psa.value(),
-              psa.label(),
-              n,
-              psa.key().patchSetId().changeId().get(),
-              psId.get(),
-              kind,
-              project.getName());
-          return true;
-        }
-        if (type.isCopyAllScoresOnTrivialRebase()) {
-          logger.atFine().log(
-              "approval %d on label %s of patch set %d of change %d can be copied"
-                  + " to patch set %d because change kind is %s and the label has set"
-                  + " copyAllScoresOnTrivialRebase = true on project %s",
-              psa.value(),
-              psa.label(),
-              n,
-              psa.key().patchSetId().changeId().get(),
-              psId.get(),
-              kind,
-              project.getName());
-          return true;
-        }
-        if (isMerge && type.isCopyAllScoresOnMergeFirstParentUpdate()) {
-          logger.atFine().log(
-              "approval %d on label %s of patch set %d of change %d can be copied"
-                  + " to patch set %d because change kind is %s and the label has set"
-                  + " copyAllScoresOnMergeFirstParentUpdate = true on project %s",
-              psa.value(),
-              psa.label(),
-              n,
-              psa.key().patchSetId().changeId().get(),
-              psId.get(),
-              kind,
-              project.getName());
-          return true;
-        }
-        if (type.isCopyAllScoresIfNoCodeChange()) {
-          logger.atFine().log(
-              "approval %d on label %s of patch set %d of change %d can be copied"
-                  + " to patch set %d because change kind is %s and the label has set"
-                  + " copyAllScoresIfNoCodeChange = true on project %s",
-              psa.value(),
-              psa.label(),
-              n,
-              psa.key().patchSetId().changeId().get(),
-              psId.get(),
-              kind,
-              project.getName());
-          return true;
-        }
-        return false;
-      case REWORK:
-      default:
-        logger.atFine().log(
-            "approval %d on label %s of patch set %d of change %d cannot be copied"
-                + " to patch set %d because change kind is %s",
-            psa.value(), psa.label(), n, psa.key().patchSetId().changeId().get(), psId.get(), kind);
-        return false;
-    }
-  }
-
-  private boolean canCopyBasedOnCopyCondition(
+  /**
+   * Returns all follow-up patch sets of the given patch set to which the given approval is
+   * copyable.
+   *
+   * <p>An approval is considered as copyable to a follow-up patch set if it matches the copy rules
+   * of the label and it is copyable to all intermediate follow-up patch sets as well.
+   *
+   * <p>The returned follow-up patch sets are returned in the order of their patch set IDs.
+   *
+   * <p>Note: This method only checks the copy rules to detect if the approval is copyable. There
+   * are other factors, not checked here, that can prevent the copying of the approval to the
+   * returned follow-up patch sets (e.g. if they already have a matching non-copy approval that
+   * prevents the copying).
+   *
+   * @param changeNotes the change notes
+   * @param sourcePatchSet the patch set on which the approval was applied
+   * @param approverId the account ID of the user that applied the approval
+   * @param label the label of the approval that was applied
+   * @param approvalValue the value of the approval that was applied
+   * @return the follow-up patch sets to which the approval is copyable, ordered by patch set ID
+   */
+  public ImmutableList<PatchSet.Id> forApproval(
       ChangeNotes changeNotes,
-      PatchSetApproval psa,
-      PatchSet patchSet,
-      LabelType type,
+      PatchSet sourcePatchSet,
+      Account.Id approverId,
+      String label,
+      short approvalValue)
+      throws IOException {
+    ImmutableList.Builder<PatchSet.Id> targetPatchSetsBuilder = ImmutableList.builder();
+
+    Optional<LabelType> labelType =
+        projectCache
+            .get(changeNotes.getProjectName())
+            .orElseThrow(illegalState(changeNotes.getProjectName()))
+            .getLabelTypes()
+            .byLabel(label);
+    if (!labelType.isPresent()) {
+      // no label type exists for this label, hence this approval cannot be copied
+      return ImmutableList.of();
+    }
+
+    try (Repository repo = repoManager.openRepository(changeNotes.getProjectName());
+        RevWalk revWalk = new RevWalk(repo)) {
+      ImmutableList<PatchSet.Id> followUpPatchSets =
+          changeNotes.getPatchSets().keySet().stream()
+              .filter(psId -> psId.get() > sourcePatchSet.id().get())
+              .collect(toImmutableList());
+      PatchSet priorPatchSet = sourcePatchSet;
+
+      // Iterate over the follow-up patch sets in order to copy the approval from their prior patch
+      // set if possible (copy from PS N-1 to PS N).
+      for (PatchSet.Id followUpPatchSetId : followUpPatchSets) {
+        PatchSet followUpPatchSet = psUtil.get(changeNotes, followUpPatchSetId);
+        ChangeKind changeKind =
+            changeKindCache.getChangeKind(
+                changeNotes.getProjectName(),
+                revWalk,
+                repo.getConfig(),
+                priorPatchSet.commitId(),
+                followUpPatchSet.commitId());
+        boolean isMerge = isMerge(changeNotes.getProjectName(), revWalk, followUpPatchSet);
+
+        if (canCopy(
+            changeNotes,
+            priorPatchSet.id(),
+            followUpPatchSet,
+            approverId,
+            labelType.get(),
+            approvalValue,
+            changeKind,
+            isMerge,
+            revWalk,
+            repo.getConfig())) {
+          targetPatchSetsBuilder.add(followUpPatchSetId);
+        } else {
+          // The approval is not copyable to this follow-up patch set.
+          // This means it's also not copyable to any further follow-up patch set and we should stop
+          // the loop here.
+          break;
+        }
+        priorPatchSet = followUpPatchSet;
+      }
+    }
+    return targetPatchSetsBuilder.build();
+  }
+
+  private boolean canCopy(
+      ChangeNotes changeNotes,
+      PatchSet.Id sourcePatchSetId,
+      PatchSet targetPatchSet,
+      Account.Id approverId,
+      LabelType labelType,
+      short approvalValue,
       ChangeKind changeKind,
       boolean isMerge,
       RevWalk revWalk,
       Config repoConfig) {
-    if (!type.getCopyCondition().isPresent()) {
+    if (!labelType.getCopyCondition().isPresent()) {
       return false;
     }
     ApprovalContext ctx =
         ApprovalContext.create(
-            changeNotes, psa, patchSet, changeKind, isMerge, revWalk, repoConfig);
+            changeNotes,
+            sourcePatchSetId,
+            approverId,
+            labelType,
+            approvalValue,
+            targetPatchSet,
+            changeKind,
+            isMerge,
+            revWalk,
+            repoConfig);
     try {
       // Use a request context to run checks as an internal user with expanded visibility. This is
       // so that the output of the copy condition does not depend on who is running the current
       // request (e.g. a group used in this query might not be visible to the person sending this
       // request).
       try (ManualRequestContext ignored = requestContext.open()) {
-        return approvalQueryBuilder.parse(type.getCopyCondition().get()).asMatchable().match(ctx);
+        return approvalQueryBuilder
+            .parse(labelType.getCopyCondition().get())
+            .asMatchable()
+            .match(ctx);
       }
     } catch (QueryParseException e) {
       logger.atWarning().withCause(e).log(
@@ -342,100 +295,102 @@
     }
   }
 
-  private Collection<PatchSetApproval> getForPatchSetWithoutNormalization(
-      ChangeNotes notes, ProjectState project, PatchSet patchSet, RevWalk rw, Config repoConfig) {
-    checkState(
-        project.getNameKey().equals(notes.getProjectName()),
-        "project must match %s, %s",
-        project.getNameKey(),
-        notes.getProjectName());
-
-    PatchSet.Id psId = patchSet.id();
-    // Add approvals on the given patch set to the result
-    Table<String, Account.Id, PatchSetApproval> resultByUser = HashBasedTable.create();
-    ImmutableList<PatchSetApproval> nonCopiedApprovalsForGivenPatchSet =
-        notes.load().getApprovals().get(patchSet.id());
-    nonCopiedApprovalsForGivenPatchSet.forEach(
-        psa -> resultByUser.put(psa.label(), psa.accountId(), psa));
+  private Result computeForPatchSet(
+      LabelTypes labelTypes,
+      ChangeNotes notes,
+      PatchSet targetPatchSet,
+      RevWalk rw,
+      Config repoConfig) {
+    Project.NameKey projectName = notes.getProjectName();
+    PatchSet.Id targetPsId = targetPatchSet.id();
 
     // Bail out immediately if this is the first patch set. Return only approvals granted on the
     // given patch set.
-    if (psId.get() == 1) {
-      return resultByUser.values();
+    if (targetPsId.get() == 1) {
+      return Result.empty();
     }
-    Map.Entry<PatchSet.Id, PatchSet> priorPatchSet = notes.load().getPatchSets().lowerEntry(psId);
+    Map.Entry<PatchSet.Id, PatchSet> priorPatchSet =
+        notes.load().getPatchSets().lowerEntry(targetPsId);
     if (priorPatchSet == null) {
-      return resultByUser.values();
+      return Result.empty();
     }
 
-    ImmutableList<PatchSetApproval> priorApprovalsIncludingCopied =
-        notes.load().getApprovalsWithCopied().get(priorPatchSet.getKey());
+    Table<String, Account.Id, PatchSetApproval> currentApprovalsByUser = HashBasedTable.create();
+    ImmutableList<PatchSetApproval> nonCopiedApprovalsForGivenPatchSet =
+        notes.load().getApprovals().onlyNonCopied().get(targetPatchSet.id());
+    nonCopiedApprovalsForGivenPatchSet.forEach(
+        psa -> currentApprovalsByUser.put(psa.label(), psa.accountId(), psa));
+
+    Table<String, Account.Id, PatchSetApproval> copiedApprovalsByUser = HashBasedTable.create();
+    ImmutableSet.Builder<PatchSetApproval> outdatedApprovalsBuilder = ImmutableSet.builder();
+
+    ImmutableList<PatchSetApproval> priorApprovals =
+        notes.load().getApprovals().all().get(priorPatchSet.getKey());
 
     // Add labels from the previous patch set to the result in case the label isn't already there
     // and settings as well as change kind allow copying.
     ChangeKind changeKind =
         changeKindCache.getChangeKind(
-            project.getNameKey(),
+            projectName,
             rw,
             repoConfig,
             priorPatchSet.getValue().commitId(),
-            patchSet.commitId());
-    boolean isMerge = isMerge(project.getNameKey(), rw, patchSet);
+            targetPatchSet.commitId());
+    boolean isMerge = isMerge(projectName, rw, targetPatchSet);
     logger.atFine().log(
         "change kind for patch set %d of change %d against prior patch set %s is %s",
-        patchSet.id().get(),
-        patchSet.id().changeId().get(),
+        targetPatchSet.id().get(),
+        targetPatchSet.id().changeId().get(),
         priorPatchSet.getValue().id().changeId(),
         changeKind);
 
-    Map<String, ModifiedFile> baseVsCurrent = null;
-    Map<String, ModifiedFile> baseVsPrior = null;
-    Map<String, ModifiedFile> priorVsCurrent = null;
-    LabelTypes labelTypes = project.getLabelTypes();
-    for (PatchSetApproval psa : priorApprovalsIncludingCopied) {
-      if (resultByUser.contains(psa.label(), psa.accountId())) {
+    for (PatchSetApproval priorPsa : priorApprovals) {
+      if (priorPsa.value() == 0) {
+        // approvals with a zero vote record the deletion of a vote,
+        // they should neither be copied nor be reported as outdated, hence just skip them
         continue;
       }
-      Optional<LabelType> type = labelTypes.byLabel(psa.labelId());
-      // Only compute modified files if there is a relevant label, since this is expensive.
-      if (baseVsCurrent == null
-          && type.isPresent()
-          && type.get().isCopyAllScoresIfListOfFilesDidNotChange()) {
-        baseVsCurrent = listModifiedFiles(project, patchSet, rw, repoConfig);
-        baseVsPrior = listModifiedFiles(project, priorPatchSet.getValue(), rw, repoConfig);
-        priorVsCurrent =
-            listModifiedFiles(
-                project, priorPatchSet.getValue().commitId(), patchSet.commitId(), rw, repoConfig);
-      }
-      if (!type.isPresent()) {
+
+      Optional<LabelType> labelType = labelTypes.byLabel(priorPsa.labelId());
+      if (!labelType.isPresent()) {
         logger.atFine().log(
             "approval %d on label %s of patch set %d of change %d cannot be copied"
                 + " to patch set %d because the label no longer exists on project %s",
-            psa.value(),
-            psa.label(),
-            psa.key().patchSetId().get(),
-            psa.key().patchSetId().changeId().get(),
-            psId.get(),
-            project.getName());
+            priorPsa.value(),
+            priorPsa.label(),
+            priorPsa.key().patchSetId().get(),
+            priorPsa.key().patchSetId().changeId().get(),
+            targetPsId.get(),
+            projectName);
+        outdatedApprovalsBuilder.add(priorPsa);
         continue;
       }
-      if (!canCopyBasedOnBooleanLabelConfigs(
-              project,
-              psa,
-              patchSet.id(),
-              changeKind,
-              isMerge,
-              type.get(),
-              baseVsCurrent,
-              baseVsPrior,
-              priorVsCurrent)
-          && !canCopyBasedOnCopyCondition(
-              notes, psa, patchSet, type.get(), changeKind, isMerge, rw, repoConfig)) {
+      if (canCopy(
+          notes,
+          priorPsa.patchSetId(),
+          targetPatchSet,
+          priorPsa.accountId(),
+          labelType.get(),
+          priorPsa.value(),
+          changeKind,
+          isMerge,
+          rw,
+          repoConfig)) {
+        if (!currentApprovalsByUser.contains(priorPsa.label(), priorPsa.accountId())) {
+          copiedApprovalsByUser.put(
+              priorPsa.label(),
+              priorPsa.accountId(),
+              priorPsa.copyWithPatchSet(targetPatchSet.id()));
+        }
+      } else {
+        outdatedApprovalsBuilder.add(priorPsa);
         continue;
       }
-      resultByUser.put(psa.label(), psa.accountId(), psa.copyWithPatchSet(patchSet.id()));
     }
-    return resultByUser.values();
+
+    ImmutableSet<PatchSetApproval> copiedApprovals =
+        labelNormalizer.normalize(notes, copiedApprovalsByUser.values()).getNormalized();
+    return Result.create(copiedApprovals, outdatedApprovalsBuilder.build());
   }
 
   private boolean isMerge(Project.NameKey project, RevWalk rw, PatchSet patchSet) {
@@ -449,58 +404,4 @@
           e);
     }
   }
-
-  /**
-   * Gets the modified files between the two latest patch-sets. Can be used to compute difference in
-   * files between those two patch-sets .
-   */
-  private Map<String, ModifiedFile> listModifiedFiles(
-      ProjectState project, PatchSet ps, RevWalk revWalk, Config repoConfig) {
-    try {
-      Integer parentNum =
-          listOfFilesUnchangedPredicate.isInitialCommit(project.getNameKey(), ps.commitId())
-              ? 0
-              : 1;
-      return diffOperations.loadModifiedFilesAgainstParent(
-          project.getNameKey(),
-          ps.commitId(),
-          parentNum,
-          DiffOptions.DEFAULTS,
-          revWalk,
-          repoConfig);
-    } catch (DiffNotAvailableException ex) {
-      throw new StorageException(
-          "failed to compute difference in files, so won't copy"
-              + " votes on labels even if list of files is the same and "
-              + "copyAllIfListOfFilesDidNotChange",
-          ex);
-    }
-  }
-
-  /**
-   * Gets the modified files between two commits corresponding to different patchsets of the same
-   * change.
-   */
-  private Map<String, ModifiedFile> listModifiedFiles(
-      ProjectState project,
-      ObjectId sourceCommit,
-      ObjectId targetCommit,
-      RevWalk revWalk,
-      Config repoConfig) {
-    try {
-      return diffOperations.loadModifiedFiles(
-          project.getNameKey(),
-          sourceCommit,
-          targetCommit,
-          DiffOptions.DEFAULTS,
-          revWalk,
-          repoConfig);
-    } catch (DiffNotAvailableException ex) {
-      throw new StorageException(
-          "failed to compute difference in files, so won't copy"
-              + " votes on labels even if list of files is the same and "
-              + "copyAllIfListOfFilesDidNotChange",
-          ex);
-    }
-  }
 }
diff --git a/java/com/google/gerrit/server/approval/ApprovalsUtil.java b/java/com/google/gerrit/server/approval/ApprovalsUtil.java
index fdcaf69..8014e17 100644
--- a/java/com/google/gerrit/server/approval/ApprovalsUtil.java
+++ b/java/com/google/gerrit/server/approval/ApprovalsUtil.java
@@ -15,18 +15,29 @@
 package com.google.gerrit.server.approval;
 
 import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.collect.ImmutableListMultimap.toImmutableListMultimap;
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.CC;
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
+import static java.util.Comparator.comparing;
+import static java.util.Objects.requireNonNull;
+import static java.util.stream.Collectors.joining;
 
 import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.ArrayListMultimap;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Lists;
+import com.google.common.collect.Multimap;
+import com.google.common.collect.Multimaps;
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.LabelType;
@@ -38,10 +49,13 @@
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.ReviewerStatusUpdate;
+import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.change.LabelNormalizer;
+import com.google.gerrit.server.config.AnonymousCowardName;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
@@ -50,13 +64,20 @@
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.query.approval.ApprovalQueryBuilder;
+import com.google.gerrit.server.query.approval.UserInPredicate;
+import com.google.gerrit.server.util.AccountTemplateUtil;
 import com.google.gerrit.server.util.LabelVote;
+import com.google.gerrit.server.util.ManualRequestContext;
+import com.google.gerrit.server.util.OneOffRequestContext;
 import com.google.inject.Inject;
+import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.time.Instant;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
+import java.util.HashSet;
 import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
@@ -95,22 +116,34 @@
     return Iterables.filter(psas, a -> Objects.equals(a.accountId(), accountId));
   }
 
-  private final ApprovalCopier approvalInference;
+  private final AccountCache accountCache;
+  private final String anonymousCowardName;
+  private final ApprovalCopier approvalCopier;
+  private final Provider<ApprovalQueryBuilder> approvalQueryBuilderProvider;
   private final PermissionBackend permissionBackend;
   private final ProjectCache projectCache;
   private final LabelNormalizer labelNormalizer;
+  private final OneOffRequestContext requestContext;
 
   @VisibleForTesting
   @Inject
   public ApprovalsUtil(
-      ApprovalCopier approvalInference,
+      AccountCache accountCache,
+      @AnonymousCowardName String anonymousCowardName,
+      ApprovalCopier approvalCopier,
+      Provider<ApprovalQueryBuilder> approvalQueryBuilderProvider,
       PermissionBackend permissionBackend,
       ProjectCache projectCache,
-      LabelNormalizer labelNormalizer) {
-    this.approvalInference = approvalInference;
+      LabelNormalizer labelNormalizer,
+      OneOffRequestContext requestContext) {
+    this.accountCache = accountCache;
+    this.anonymousCowardName = anonymousCowardName;
+    this.approvalCopier = approvalCopier;
+    this.approvalQueryBuilderProvider = approvalQueryBuilderProvider;
     this.permissionBackend = permissionBackend;
     this.projectCache = projectCache;
     this.labelNormalizer = labelNormalizer;
+    this.requestContext = requestContext;
   }
 
   /**
@@ -336,26 +369,340 @@
 
   public ListMultimap<PatchSet.Id, PatchSetApproval> byChangeExcludingCopiedApprovals(
       ChangeNotes notes) {
-    return notes.load().getApprovals();
+    return notes.load().getApprovals().onlyNonCopied();
   }
 
   /**
-   * This method should only be used when we want to dynamically compute the approvals. Generally,
-   * the copied approvals are available in {@link ChangeNotes}. However, if the patch-set is just
-   * being created, we need to dynamically compute the approvals so that we can persist them in
-   * storage. The {@link RevWalk} and {@link Config} objects that are being used to create the new
-   * patch-set are required for this method. Here we also add those votes to the provided {@link
-   * ChangeUpdate} object.
+   * Copies approvals to a new patch set.
+   *
+   * <p>Computes the approvals of the prior patch set that should be copied to the new patch set and
+   * stores them in NoteDb.
+   *
+   * <p>For outdated approvals (approvals on the prior patch set which are outdated by the new patch
+   * set and hence not copied) the approvers are added to the attention set since they need to
+   * re-review the change and renew their approvals.
+   *
+   * @param notes the change notes
+   * @param patchSet the newly created patch set
+   * @param revWalk {@link RevWalk} that can see the new patch set revision
+   * @param repoConfig the repo config
+   * @param changeUpdate changeUpdate that is used to persist the copied approvals and update the
+   *     attention set
+   * @return the result of the approval copying
    */
-  public void persistCopiedApprovals(
+  public ApprovalCopier.Result copyApprovalsToNewPatchSet(
       ChangeNotes notes,
       PatchSet patchSet,
       RevWalk revWalk,
       Config repoConfig,
       ChangeUpdate changeUpdate) {
-    approvalInference
-        .forPatchSet(notes, patchSet, revWalk, repoConfig)
-        .forEach(a -> changeUpdate.putCopiedApproval(a));
+    ApprovalCopier.Result approvalCopierResult =
+        approvalCopier.forPatchSet(notes, patchSet, revWalk, repoConfig);
+    approvalCopierResult.copiedApprovals().forEach(a -> changeUpdate.putCopiedApproval(a));
+
+    if (!notes.getChange().isWorkInProgress()) {
+      // The attention set should not be updated when the change is work-in-progress.
+      addAttentionSetUpdatesForOutdatedApprovals(
+          changeUpdate, approvalCopierResult.outdatedApprovals());
+    }
+
+    return approvalCopierResult;
+  }
+
+  private void addAttentionSetUpdatesForOutdatedApprovals(
+      ChangeUpdate changeUpdate, ImmutableSet<PatchSetApproval> outdatedApprovals) {
+    Set<AttentionSetUpdate> updates = new HashSet<>();
+
+    Multimap<Account.Id, PatchSetApproval> outdatedApprovalsByUser = ArrayListMultimap.create();
+    outdatedApprovals.forEach(psa -> outdatedApprovalsByUser.put(psa.accountId(), psa));
+    for (Map.Entry<Account.Id, Collection<PatchSetApproval>> e :
+        outdatedApprovalsByUser.asMap().entrySet()) {
+      Account.Id approverId = e.getKey();
+      Collection<PatchSetApproval> outdatedUserApprovals = e.getValue();
+
+      String message;
+      if (outdatedUserApprovals.size() == 1) {
+        PatchSetApproval outdatedUserApproval = Iterables.getOnlyElement(outdatedUserApprovals);
+        message =
+            String.format(
+                "Vote got outdated and was removed: %s",
+                LabelVote.create(outdatedUserApproval.label(), outdatedUserApproval.value())
+                    .format());
+      } else {
+        message =
+            String.format(
+                "Votes got outdated and were removed: %s",
+                outdatedUserApprovals.stream()
+                    .map(
+                        outdatedUserApproval ->
+                            LabelVote.create(
+                                    outdatedUserApproval.label(), outdatedUserApproval.value())
+                                .format())
+                    .sorted()
+                    .collect(joining(", ")));
+      }
+
+      updates.add(
+          AttentionSetUpdate.createForWrite(approverId, AttentionSetUpdate.Operation.ADD, message));
+    }
+    changeUpdate.addToPlannedAttentionSetUpdates(updates);
+  }
+
+  public Optional<String> formatApprovalCopierResult(
+      ApprovalCopier.Result approvalCopierResult, LabelTypes labelTypes) {
+    requireNonNull(approvalCopierResult, "approvalCopierResult");
+    requireNonNull(labelTypes, "labelTypes");
+
+    if (approvalCopierResult.copiedApprovals().isEmpty()
+        && approvalCopierResult.outdatedApprovals().isEmpty()) {
+      return Optional.empty();
+    }
+
+    StringBuilder message = new StringBuilder();
+
+    if (!approvalCopierResult.copiedApprovals().isEmpty()) {
+      message.append("Copied Votes:\n");
+      message.append(
+          formatApprovalListWithCopyCondition(approvalCopierResult.copiedApprovals(), labelTypes));
+    }
+    if (!approvalCopierResult.outdatedApprovals().isEmpty()) {
+      if (!approvalCopierResult.copiedApprovals().isEmpty()) {
+        message.append("\n");
+      }
+      message.append("Outdated Votes:\n");
+      message.append(
+          formatApprovalListWithCopyCondition(
+              approvalCopierResult.outdatedApprovals(), labelTypes));
+    }
+
+    return Optional.of(message.toString());
+  }
+
+  /**
+   * Formats the given approvals as a bullet list, each approval with the corresponding copy
+   * condition if available.
+   *
+   * <p>E.g.:
+   *
+   * <pre>
+   * * Code-Review+1, Code-Review+2 (copy condition: "is:MIN")
+   * * Verified+1 (copy condition: "is:MIN")
+   * </pre>
+   *
+   * <p>Entries in the list can have the following formats:
+   *
+   * <ul>
+   *   <li>{@code <comma-separated-list-of-approvals-for-the-same-label> (copy condition:
+   *       "<copy-condition-without-UserInPredicate>")} (if a copy condition without UserInPredicate
+   *       is present), e.g.: {@code Code-Review+1, Code-Review+2 (copy condition: "is:MIN")}
+   *   <li>{@code <approval> by <comma-separated-list-of-approvers> (copy condition:
+   *       "<copy-condition-with-UserInPredicate>")} (if a copy condition with UserInPredicate is
+   *       present), e.g. {@code Code-Review+1 by <GERRIT_ACCOUNT_1000000>, <GERRIT_ACCOUNT_1000001>
+   *       (copy condition: "approverin:7d9e2d5b561e75230e4463ae757ac5d6ff715d85")}
+   *   <li>{@code <comma-separated-list-of-approval-for-the-same-label>} (if no copy condition is
+   *       present), e.g.: {@code Code-Review+1, Code-Review+2}
+   *   <li>{@code <comma-separated-list-of-approval-for-the-same-label> (label type is missing)} (if
+   *       the label type is missing), e.g.: {@code Code-Review+1, Code-Review+2 (label type is
+   *       missing)}
+   *   <li>{@code <comma-separated-list-of-approval-for-the-same-label> (non-parseable copy
+   *       condition: "<non-parseable copy-condition>")} (if a non-parseable copy condition is
+   *       present), e.g.: {@code Code-Review+1, Code-Review+2 (non-parseable copy condition:
+   *       "is:FOO")}
+   * </ul>
+   *
+   * @param approvals the approvals that should be formatted
+   * @param labelTypes the label types
+   * @return bullet list with the formatted approvals
+   */
+  private String formatApprovalListWithCopyCondition(
+      ImmutableSet<PatchSetApproval> approvals, LabelTypes labelTypes) {
+    StringBuilder message = new StringBuilder();
+
+    // sort approvals by label vote so that we list them in a deterministic order
+    ImmutableList<PatchSetApproval> approvalsSortedByLabelVote =
+        approvals.stream()
+            .sorted(comparing(psa -> LabelVote.create(psa.label(), psa.value()).format()))
+            .collect(toImmutableList());
+
+    ImmutableListMultimap<String, PatchSetApproval> approvalsByLabel =
+        Multimaps.index(approvalsSortedByLabelVote, PatchSetApproval::label);
+
+    for (Map.Entry<String, Collection<PatchSetApproval>> approvalsByLabelEntry :
+        approvalsByLabel.asMap().entrySet()) {
+      String label = approvalsByLabelEntry.getKey();
+      Collection<PatchSetApproval> approvalsForSameLabel = approvalsByLabelEntry.getValue();
+
+      message.append("* ");
+      if (!labelTypes.byLabel(label).isPresent()) {
+        message
+            .append(formatApprovalsAsLabelVotesList(approvalsForSameLabel))
+            .append(" (label type is missing)\n");
+        continue;
+      }
+
+      LabelType labelType = labelTypes.byLabel(label).get();
+      if (!labelType.getCopyCondition().isPresent()) {
+        message.append(formatApprovalsAsLabelVotesList(approvalsForSameLabel)).append("\n");
+        continue;
+      }
+
+      message
+          .append(
+              formatApprovalsWithCopyCondition(
+                  approvalsForSameLabel, labelType.getCopyCondition().get()))
+          .append("\n");
+    }
+
+    return message.toString();
+  }
+
+  /**
+   * Formats the given approvals of the same label with the given copy condition.
+   *
+   * <p>E.g.: {Code-Review+1, Code-Review+2 (copy condition: "is:MIN")}
+   *
+   * <p>The following format may be returned:
+   *
+   * <ul>
+   *   <li>{@code <comma-separated-list-of-approvals-for-the-same-label> (copy condition:
+   *       "<copy-condition-without-UserInPredicate>")} (if a copy condition without UserInPredicate
+   *       is present), e.g.: {@code Code-Review+1, Code-Review+2 (copy condition: "is:MIN")}
+   *   <li>{@code <approval> by <comma-separated-list-of-approvers> (copy condition:
+   *       "<copy-condition-with-UserInPredicate>")} (if a copy condition with UserInPredicate is
+   *       present), e.g. {@code Code-Review+1 by <GERRIT_ACCOUNT_1000000>, <GERRIT_ACCOUNT_1000001>
+   *       (copy condition: "approverin:7d9e2d5b561e75230e4463ae757ac5d6ff715d85")}
+   *   <li>{@code <comma-separated-list-of-approval-for-the-same-label> (non-parseable copy
+   *       condition: "<non-parseable copy-condition>")} (if a non-parseable copy condition is
+   *       present), e.g.: {@code Code-Review+1, Code-Review+2 (non-parseable copy condition:
+   *       "is:FOO")}
+   * </ul>
+   *
+   * @param approvalsForSameLabel the approvals that should be formatted, must be for the same label
+   * @param copyCondition the copy condition of the label
+   * @return the formatted approvals
+   */
+  private String formatApprovalsWithCopyCondition(
+      Collection<PatchSetApproval> approvalsForSameLabel, String copyCondition) {
+    StringBuilder message = new StringBuilder();
+
+    boolean containsUserInPredicate;
+    try {
+      containsUserInPredicate = containsUserInPredicate(copyCondition);
+    } catch (QueryParseException e) {
+      logger.atWarning().withCause(e).log("Non-parsable query condition");
+      message.append(formatApprovalsAsLabelVotesList(approvalsForSameLabel));
+      message.append(String.format(" (non-parseable copy condition: \"%s\")", copyCondition));
+      return message.toString();
+    }
+
+    if (containsUserInPredicate) {
+      // If a UserInPredicate is used (e.g. 'approverin:<group>' or 'uploaderin:<group>') we need to
+      // include the approvers into the change message since they are relevant for the matching. For
+      // example it can happen that the same approval of different users is copied for the one user
+      // but not for the other user (since the one user is a member of the approverin group and the
+      // other user isn't).
+      //
+      // Example:
+      // * label Foo has the copy condition 'is:ANY approverin:123'
+      // * group 123 contains UserA as member, but not UserB
+      // * a change has the following approvals: Foo+1 by UserA and Foo+1 by UserB
+      //
+      // In this case Foo+1 by UserA is copied because UserA is a member of group 123 and the copy
+      // condition matches, while Foo+1 by UserB is not copied because UserB is not a member of
+      // group 123 and the copy condition doesn't match.
+      //
+      // So it can happen that the same approval Foo+1, but by different users, is copied and
+      // outdated at the same time. To allow users to understand that the copying depends on who did
+      // the approval, the approvers must be included into the change message.
+
+      // sort the approvals by their approvers name-email so that the approvers always appear in a
+      // deterministic order
+      ImmutableList<PatchSetApproval> approvalsSortedByLabelVoteAndApprover =
+          approvalsForSameLabel.stream()
+              .sorted(
+                  comparing(
+                          (PatchSetApproval psa) ->
+                              LabelVote.create(psa.label(), psa.value()).format())
+                      .thenComparing(
+                          psa ->
+                              accountCache
+                                  .getEvenIfMissing(psa.accountId())
+                                  .account()
+                                  .getNameEmail(anonymousCowardName)))
+              .collect(toImmutableList());
+
+      ImmutableListMultimap<LabelVote, Account.Id> approversByLabelVote =
+          Multimaps.index(
+                  approvalsSortedByLabelVoteAndApprover,
+                  psa -> LabelVote.create(psa.label(), psa.value()))
+              .entries().stream()
+              .collect(toImmutableListMultimap(e -> e.getKey(), e -> e.getValue().accountId()));
+      message.append(
+          approversByLabelVote.asMap().entrySet().stream()
+              .map(
+                  approversByLabelVoteEntry ->
+                      formatLabelVoteWithApprovers(
+                          approversByLabelVoteEntry.getKey(), approversByLabelVoteEntry.getValue()))
+              .collect(joining(", ")));
+    } else {
+      // copy condition doesn't contain a UserInPredicate
+      message.append(formatApprovalsAsLabelVotesList(approvalsForSameLabel));
+    }
+    message.append(String.format(" (copy condition: \"%s\")", copyCondition));
+    return message.toString();
+  }
+
+  private boolean containsUserInPredicate(String copyCondition) throws QueryParseException {
+    // Use a request context to run checks as an internal user with expanded visibility. This is
+    // so that the output of the copy condition does not depend on who is running the current
+    // request (e.g. a group used in this query might not be visible to the person sending this
+    // request).
+    try (ManualRequestContext ignored = requestContext.open()) {
+      return approvalQueryBuilderProvider.get().parse(copyCondition).getFlattenedPredicateList()
+          .stream()
+          .anyMatch(UserInPredicate.class::isInstance);
+    }
+  }
+
+  /**
+   * Formats the given approvals as a comma-separated list of label votes.
+   *
+   * <p>E.g.: {@code Code-Review+1, CodeReview+2}
+   *
+   * @param sortedApprovalsForSameLabel the approvals that should be formatted as a comma-separated
+   *     list of label votes, must be sorted
+   * @return the given approvals as a comma-separated list of label votes
+   */
+  private String formatApprovalsAsLabelVotesList(
+      Collection<PatchSetApproval> sortedApprovalsForSameLabel) {
+    return sortedApprovalsForSameLabel.stream()
+        .map(psa -> LabelVote.create(psa.label(), psa.value()))
+        .distinct()
+        .map(LabelVote::format)
+        .collect(joining(", "));
+  }
+
+  /**
+   * Formats the given label vote with a comma-separated list of the given approvers.
+   *
+   * <p>E.g.: {@code Code-Review+1 by <user1-placeholder>, <user2-placeholder>}
+   *
+   * @param labelVote the label vote that should be formatted with a comma-separated list of the
+   *     given approver
+   * @param sortedApprovers the approvers that should be formatted as a comma-separated list for the
+   *     given label vote
+   * @return the given label vote with a comma-separated list of the given approvers
+   */
+  private String formatLabelVoteWithApprovers(
+      LabelVote labelVote, Collection<Account.Id> sortedApprovers) {
+    return new StringBuilder()
+        .append(labelVote.format())
+        .append(" by ")
+        .append(
+            sortedApprovers.stream()
+                .map(AccountTemplateUtil::getAccountTemplate)
+                .collect(joining(", ")))
+        .toString();
   }
 
   /**
@@ -368,7 +715,7 @@
    *     deleted labels.
    */
   public Iterable<PatchSetApproval> byPatchSet(ChangeNotes notes, PatchSet.Id psId) {
-    List<PatchSetApproval> approvalsNotNormalized = notes.load().getApprovalsWithCopied().get(psId);
+    List<PatchSetApproval> approvalsNotNormalized = notes.load().getApprovals().all().get(psId);
     return labelNormalizer.normalize(notes, approvalsNotNormalized).getNormalized();
   }
 
diff --git a/java/com/google/gerrit/server/cache/serialize/entities/LabelTypeSerializer.java b/java/com/google/gerrit/server/cache/serialize/entities/LabelTypeSerializer.java
index 5a71ced..eb3949e 100644
--- a/java/com/google/gerrit/server/cache/serialize/entities/LabelTypeSerializer.java
+++ b/java/com/google/gerrit/server/cache/serialize/entities/LabelTypeSerializer.java
@@ -43,19 +43,6 @@
         .setIgnoreSelfApproval(proto.getIgnoreSelfApproval())
         .setDefaultValue(Shorts.saturatedCast(proto.getDefaultValue()))
         .setCopyCondition(Strings.emptyToNull(proto.getCopyCondition()))
-        .setCopyAnyScore(proto.getCopyAnyScore())
-        .setCopyMinScore(proto.getCopyMinScore())
-        .setCopyMaxScore(proto.getCopyMaxScore())
-        .setCopyAllScoresIfListOfFilesDidNotChange(
-            proto.getCopyAllScoresIfListOfFilesDidNotChange())
-        .setCopyAllScoresOnMergeFirstParentUpdate(proto.getCopyAllScoresOnMergeFirstParentUpdate())
-        .setCopyAllScoresOnTrivialRebase(proto.getCopyAllScoresOnTrivialRebase())
-        .setCopyAllScoresIfNoCodeChange(proto.getCopyAllScoresIfNoCodeChange())
-        .setCopyAllScoresIfNoChange(proto.getCopyAllScoresIfNoChange())
-        .setCopyValues(
-            proto.getCopyValuesList().stream()
-                .map(Shorts::saturatedCast)
-                .collect(toImmutableList()))
         .setMaxNegative(Shorts.saturatedCast(proto.getMaxNegative()))
         .setMaxPositive(Shorts.saturatedCast(proto.getMaxPositive()))
         .setRefPatterns(proto.getRefPatternsList())
@@ -73,18 +60,6 @@
         .setDescription(autoValue.getDescription().orElse(""))
         .setFunction(FUNCTION_CONVERTER.reverse().convert(autoValue.getFunction()))
         .setCopyCondition(autoValue.getCopyCondition().orElse(""))
-        .setCopyAnyScore(autoValue.isCopyAnyScore())
-        .setCopyMinScore(autoValue.isCopyMinScore())
-        .setCopyMaxScore(autoValue.isCopyMaxScore())
-        .setCopyAllScoresIfListOfFilesDidNotChange(
-            autoValue.isCopyAllScoresIfListOfFilesDidNotChange())
-        .setCopyAllScoresOnMergeFirstParentUpdate(
-            autoValue.isCopyAllScoresOnMergeFirstParentUpdate())
-        .setCopyAllScoresOnTrivialRebase(autoValue.isCopyAllScoresOnTrivialRebase())
-        .setCopyAllScoresIfNoCodeChange(autoValue.isCopyAllScoresIfNoCodeChange())
-        .setCopyAllScoresIfNoChange(autoValue.isCopyAllScoresIfNoChange())
-        .addAllCopyValues(
-            autoValue.getCopyValues().stream().map(c -> (int) c).collect(toImmutableList()))
         .setAllowPostSubmit(autoValue.isAllowPostSubmit())
         .setIgnoreSelfApproval(autoValue.isIgnoreSelfApproval())
         .setDefaultValue(Shorts.saturatedCast(autoValue.getDefaultValue()))
diff --git a/java/com/google/gerrit/server/cache/serialize/entities/StoredCommentLinkInfoSerializer.java b/java/com/google/gerrit/server/cache/serialize/entities/StoredCommentLinkInfoSerializer.java
index a7a84f7..5aa7a2a 100644
--- a/java/com/google/gerrit/server/cache/serialize/entities/StoredCommentLinkInfoSerializer.java
+++ b/java/com/google/gerrit/server/cache/serialize/entities/StoredCommentLinkInfoSerializer.java
@@ -27,6 +27,9 @@
     return StoredCommentLinkInfo.builder(proto.getName())
         .setMatch(emptyToNull(proto.getMatch()))
         .setLink(emptyToNull(proto.getLink()))
+        .setPrefix(emptyToNull(proto.getPrefix()))
+        .setSuffix(emptyToNull(proto.getSuffix()))
+        .setText(emptyToNull(proto.getText()))
         .setHtml(emptyToNull(proto.getHtml()))
         .setEnabled(proto.getEnabled())
         .setOverrideOnly(proto.getOverrideOnly())
@@ -38,6 +41,9 @@
         .setName(autoValue.getName())
         .setMatch(nullToEmpty(autoValue.getMatch()))
         .setLink(nullToEmpty(autoValue.getLink()))
+        .setPrefix(nullToEmpty(autoValue.getPrefix()))
+        .setSuffix(nullToEmpty(autoValue.getSuffix()))
+        .setText(nullToEmpty(autoValue.getText()))
         .setHtml(nullToEmpty(autoValue.getHtml()))
         .setEnabled(Optional.ofNullable(autoValue.getEnabled()).orElse(true))
         .setOverrideOnly(autoValue.getOverrideOnly())
diff --git a/java/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementExpressionResultSerializer.java b/java/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementExpressionResultSerializer.java
index 436fe76..f61e261 100644
--- a/java/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementExpressionResultSerializer.java
+++ b/java/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementExpressionResultSerializer.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.cache.serialize.entities;
 
+import com.google.common.base.Converter;
+import com.google.common.base.Enums;
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.entities.SubmitRequirementExpression;
@@ -26,11 +28,21 @@
  * SubmitRequirementExpressionResultProto}.
  */
 public class SubmitRequirementExpressionResultSerializer {
+
+  private static final Converter<String, SubmitRequirementExpressionResult.Status>
+      STATUS_CONVERTER = Enums.stringConverter(SubmitRequirementExpressionResult.Status.class);
+
   public static SubmitRequirementExpressionResult deserialize(
       SubmitRequirementExpressionResultProto proto) {
+    SubmitRequirementExpressionResult.Status status;
+    try {
+      status = STATUS_CONVERTER.convert(proto.getStatus());
+    } catch (IllegalArgumentException e) {
+      status = SubmitRequirementExpressionResult.Status.ERROR;
+    }
     return SubmitRequirementExpressionResult.create(
         SubmitRequirementExpression.create(proto.getExpression()),
-        SubmitRequirementExpressionResult.Status.valueOf(proto.getStatus()),
+        status,
         proto.getPassingAtomsList().stream().collect(ImmutableList.toImmutableList()),
         proto.getFailingAtomsList().stream().collect(ImmutableList.toImmutableList()),
         Optional.ofNullable(Strings.emptyToNull(proto.getErrorMessage())));
@@ -40,7 +52,7 @@
       SubmitRequirementExpressionResult r) {
     return SubmitRequirementExpressionResultProto.newBuilder()
         .setExpression(r.expression().expressionString())
-        .setStatus(r.status().name())
+        .setStatus(STATUS_CONVERTER.reverse().convert(r.status()))
         .addAllPassingAtoms(r.passingAtoms())
         .addAllFailingAtoms(r.failingAtoms())
         .setErrorMessage(r.errorMessage().orElse(""))
diff --git a/java/com/google/gerrit/server/change/AbandonUtil.java b/java/com/google/gerrit/server/change/AbandonUtil.java
index 29bd045..59b32c2 100644
--- a/java/com/google/gerrit/server/change/AbandonUtil.java
+++ b/java/com/google/gerrit/server/change/AbandonUtil.java
@@ -40,10 +40,7 @@
 
   private final ChangeCleanupConfig cfg;
   private final Provider<ChangeQueryProcessor> queryProvider;
-  // Provider is needed, because AbandonUtil is singleton, but ChangeQueryBuilder accesses
-  // index collection, that is only provided when multiversion index module is started.
-  // TODO(davido); Remove provider again, when support for legacy numeric fields is removed.
-  private final Provider<ChangeQueryBuilder> queryBuilderProvider;
+  private final ChangeQueryBuilder queryBuilder;
   private final BatchAbandon batchAbandon;
   private final InternalUser internalUser;
 
@@ -52,11 +49,11 @@
       ChangeCleanupConfig cfg,
       InternalUser.Factory internalUserFactory,
       Provider<ChangeQueryProcessor> queryProvider,
-      Provider<ChangeQueryBuilder> queryBuilderProvider,
+      ChangeQueryBuilder queryBuilder,
       BatchAbandon batchAbandon) {
     this.cfg = cfg;
     this.queryProvider = queryProvider;
-    this.queryBuilderProvider = queryBuilderProvider;
+    this.queryBuilder = queryBuilder;
     this.batchAbandon = batchAbandon;
     internalUser = internalUserFactory.create();
   }
@@ -74,11 +71,7 @@
       }
 
       List<ChangeData> changesToAbandon =
-          queryProvider
-              .get()
-              .enforceVisibility(false)
-              .query(queryBuilderProvider.get().parse(query))
-              .entities();
+          queryProvider.get().enforceVisibility(false).query(queryBuilder.parse(query)).entities();
       ImmutableListMultimap.Builder<Project.NameKey, ChangeData> builder =
           ImmutableListMultimap.builder();
       for (ChangeData cd : changesToAbandon) {
@@ -118,7 +111,7 @@
           queryProvider
               .get()
               .enforceVisibility(false)
-              .query(queryBuilderProvider.get().parse(newQuery))
+              .query(queryBuilder.parse(newQuery))
               .entities();
       if (!changesToAbandon.isEmpty()) {
         validChanges.add(cd);
diff --git a/java/com/google/gerrit/server/change/AddToAttentionSetOp.java b/java/com/google/gerrit/server/change/AddToAttentionSetOp.java
index a980c32..ec90bec 100644
--- a/java/com/google/gerrit/server/change/AddToAttentionSetOp.java
+++ b/java/com/google/gerrit/server/change/AddToAttentionSetOp.java
@@ -16,13 +16,11 @@
 
 import static java.util.Objects.requireNonNull;
 
-import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.server.mail.send.AddToAttentionSetSender;
-import com.google.gerrit.server.mail.send.MessageIdGenerator;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.update.BatchUpdateOp;
@@ -31,18 +29,14 @@
 import com.google.gerrit.server.util.AttentionSetEmail;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-import java.io.IOException;
 
 /** Add a specified user to the attention set. */
 public class AddToAttentionSetOp implements BatchUpdateOp {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
   public interface Factory {
     AddToAttentionSetOp create(Account.Id attentionUserId, String reason, boolean notify);
   }
 
   private final ChangeData.Factory changeDataFactory;
-  private final MessageIdGenerator messageIdGenerator;
   private final AddToAttentionSetSender.Factory addToAttentionSetSender;
   private final AttentionSetEmail.Factory attentionSetEmailFactory;
 
@@ -63,14 +57,12 @@
   AddToAttentionSetOp(
       ChangeData.Factory changeDataFactory,
       AddToAttentionSetSender.Factory addToAttentionSetSender,
-      MessageIdGenerator messageIdGenerator,
       AttentionSetEmail.Factory attentionSetEmailFactory,
       @Assisted Account.Id attentionUserId,
       @Assisted String reason,
       @Assisted boolean notify) {
     this.changeDataFactory = changeDataFactory;
     this.addToAttentionSetSender = addToAttentionSetSender;
-    this.messageIdGenerator = messageIdGenerator;
     this.attentionSetEmailFactory = attentionSetEmailFactory;
 
     this.attentionUserId = requireNonNull(attentionUserId, "user");
@@ -93,8 +85,8 @@
 
     change = ctx.getChange();
 
-    ChangeUpdate update = ctx.getUpdate(ctx.getChange().currentPatchSetId());
-    update.addToPlannedAttentionSetUpdates(
+    ChangeUpdate changeUpdate = ctx.getUpdate(ctx.getChange().currentPatchSetId());
+    changeUpdate.addToPlannedAttentionSetUpdates(
         AttentionSetUpdate.createForWrite(
             attentionUserId, AttentionSetUpdate.Operation.ADD, reason));
     return true;
@@ -105,18 +97,13 @@
     if (!notify) {
       return;
     }
-    try {
-      attentionSetEmailFactory
-          .create(
-              addToAttentionSetSender.create(ctx.getProject(), change.getId()),
-              ctx,
-              change,
-              reason,
-              messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), change.currentPatchSetId()),
-              attentionUserId)
-          .sendAsync();
-    } catch (IOException e) {
-      logger.atSevere().withCause(e).log(e.getMessage(), change.getId());
-    }
+    attentionSetEmailFactory
+        .create(
+            addToAttentionSetSender.create(ctx.getProject(), change.getId()),
+            ctx,
+            change,
+            reason,
+            attentionUserId)
+        .sendAsync();
   }
 }
diff --git a/java/com/google/gerrit/server/change/ChangeInserter.java b/java/com/google/gerrit/server/change/ChangeInserter.java
index 6ef7f1e..edaca70 100644
--- a/java/com/google/gerrit/server/change/ChangeInserter.java
+++ b/java/com/google/gerrit/server/change/ChangeInserter.java
@@ -467,10 +467,10 @@
     approvalsUtil.addApprovalsForNewPatchSet(
         update, labelTypes, patchSet, ctx.getUser(), approvals);
 
-    // Check if approvals are changing in with this update. If so, add current user to reviewers.
-    // Note that this is done separately as addReviewers is filtering out the change owner as
+    // Check if approvals are changing with this update. If so, add the current user (aka the
+    // approver) as a reviewers because all approvers must also be reviewers.
+    // Note that this is done separately as addReviewers is filtering out the change owner as a
     // reviewer which is needed in several other code paths.
-    // TODO(dborowitz): Still necessary?
     if (!approvals.isEmpty()) {
       update.putReviewer(ctx.getAccountId(), REVIEWER);
     }
diff --git a/java/com/google/gerrit/server/change/ChangeJson.java b/java/com/google/gerrit/server/change/ChangeJson.java
index 32e40eb..02b0a60 100644
--- a/java/com/google/gerrit/server/change/ChangeJson.java
+++ b/java/com/google/gerrit/server/change/ChangeJson.java
@@ -235,6 +235,7 @@
   private final Optional<PluginDefinedInfosFactory> pluginDefinedInfosFactory;
   private final boolean includeMergeable;
   private final boolean lazyLoad;
+  private final boolean cacheQueryResultsByChangeNum;
 
   private AccountLoader accountLoader;
   private FixInput fix;
@@ -274,6 +275,8 @@
     this.includeMergeable = MergeabilityComputationBehavior.fromConfig(cfg).includeInApi();
     this.lazyLoad = containsAnyOf(this.options, REQUIRE_LAZY_LOAD);
     this.pluginDefinedInfosFactory = pluginDefinedInfosFactory;
+    this.cacheQueryResultsByChangeNum =
+        cfg.getBoolean("index", "cacheQueryResultsByChangeNum", true);
 
     logger.atFine().log("options = %s", options);
   }
@@ -496,7 +499,7 @@
         // This problem has two sides where 'last in the list' has to be respected:
         // (1) Caching
         // (2) Reusing
-        boolean isCacheable = i != changes.size() - 1;
+        boolean isCacheable = cacheQueryResultsByChangeNum && (i != changes.size() - 1);
         ChangeData cd = changes.get(i);
         ChangeInfo info = cache.get(cd.getId());
         if (info != null && isCacheable) {
diff --git a/java/com/google/gerrit/server/change/DeleteReviewerOp.java b/java/com/google/gerrit/server/change/DeleteReviewerOp.java
index 0116b01..1199be5 100644
--- a/java/com/google/gerrit/server/change/DeleteReviewerOp.java
+++ b/java/com/google/gerrit/server/change/DeleteReviewerOp.java
@@ -228,7 +228,7 @@
 
   private Iterable<PatchSetApproval> approvals(ChangeContext ctx, Account.Id accountId) {
     Iterable<PatchSetApproval> approvals;
-    approvals = ctx.getNotes().getApprovalsWithCopied().values();
+    approvals = ctx.getNotes().getApprovals().all().values();
     return Iterables.filter(approvals, psa -> accountId.equals(psa.accountId()));
   }
 
diff --git a/java/com/google/gerrit/server/change/EmailNewPatchSet.java b/java/com/google/gerrit/server/change/EmailNewPatchSet.java
new file mode 100644
index 0000000..f6ae6a3
--- /dev/null
+++ b/java/com/google/gerrit/server/change/EmailNewPatchSet.java
@@ -0,0 +1,241 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementResult;
+import com.google.gerrit.extensions.client.ChangeKind;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.config.SendEmailExecutor;
+import com.google.gerrit.server.mail.send.MessageIdGenerator;
+import com.google.gerrit.server.mail.send.MessageIdGenerator.MessageId;
+import com.google.gerrit.server.mail.send.ReplacePatchSetSender;
+import com.google.gerrit.server.patch.PatchSetInfoFactory;
+import com.google.gerrit.server.update.PostUpdateContext;
+import com.google.gerrit.server.util.RequestContext;
+import com.google.gerrit.server.util.RequestScopePropagator;
+import com.google.gerrit.server.util.ThreadLocalRequestContext;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.time.Instant;
+import java.util.Map;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Future;
+import org.eclipse.jgit.lib.ObjectId;
+
+public class EmailNewPatchSet {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  public interface Factory {
+    EmailNewPatchSet create(
+        PostUpdateContext postUpdateContext,
+        PatchSet patchSet,
+        String message,
+        ImmutableSet<PatchSetApproval> outdatedApprovals,
+        @Assisted("reviewers") ImmutableSet<Account.Id> reviewers,
+        @Assisted("extraCcs") ImmutableSet<Account.Id> extraCcs,
+        ChangeKind changeKind,
+        ObjectId preUpdateMetaId);
+  }
+
+  private final ExecutorService sendEmailExecutor;
+  private final ThreadLocalRequestContext threadLocalRequestContext;
+  private final AsyncSender asyncSender;
+
+  private RequestScopePropagator requestScopePropagator;
+
+  @Inject
+  EmailNewPatchSet(
+      @SendEmailExecutor ExecutorService sendEmailExecutor,
+      ThreadLocalRequestContext threadLocalRequestContext,
+      ReplacePatchSetSender.Factory replacePatchSetFactory,
+      PatchSetInfoFactory patchSetInfoFactory,
+      MessageIdGenerator messageIdGenerator,
+      @Assisted PostUpdateContext postUpdateContext,
+      @Assisted PatchSet patchSet,
+      @Assisted String message,
+      @Assisted ImmutableSet<PatchSetApproval> outdatedApprovals,
+      @Assisted("reviewers") ImmutableSet<Account.Id> reviewers,
+      @Assisted("extraCcs") ImmutableSet<Account.Id> extraCcs,
+      @Assisted ChangeKind changeKind,
+      @Assisted ObjectId preUpdateMetaId) {
+    this.sendEmailExecutor = sendEmailExecutor;
+    this.threadLocalRequestContext = threadLocalRequestContext;
+
+    MessageId messageId;
+    try {
+      messageId =
+          messageIdGenerator.fromChangeUpdateAndReason(
+              postUpdateContext.getRepoView(), patchSet.id(), "EmailReplacePatchSet");
+    } catch (IOException e) {
+      throw new UncheckedIOException(e);
+    }
+
+    Change.Id changeId = patchSet.id().changeId();
+
+    // Getting the change data from PostUpdateContext retrieves a cached ChangeData
+    // instance. This ChangeData instance has been created when the change was (re)indexed
+    // due to the update, and hence has submit requirement results already cached (since
+    // (re)indexing triggers the evaluation of the submit requirements).
+    Map<SubmitRequirement, SubmitRequirementResult> postUpdateSubmitRequirementResults =
+        postUpdateContext
+            .getChangeData(postUpdateContext.getProject(), changeId)
+            .submitRequirementsIncludingLegacy();
+    this.asyncSender =
+        new AsyncSender(
+            postUpdateContext.getIdentifiedUser(),
+            replacePatchSetFactory,
+            patchSetInfoFactory,
+            messageId,
+            postUpdateContext.getNotify(changeId),
+            postUpdateContext.getProject(),
+            changeId,
+            patchSet,
+            message,
+            postUpdateContext.getWhen(),
+            outdatedApprovals,
+            reviewers,
+            extraCcs,
+            changeKind,
+            preUpdateMetaId,
+            postUpdateSubmitRequirementResults);
+  }
+
+  public EmailNewPatchSet setRequestScopePropagator(RequestScopePropagator requestScopePropagator) {
+    this.requestScopePropagator = requestScopePropagator;
+    return this;
+  }
+
+  public void sendAsync() {
+    @SuppressWarnings("unused")
+    Future<?> possiblyIgnoredError =
+        sendEmailExecutor.submit(
+            requestScopePropagator != null
+                ? requestScopePropagator.wrap(asyncSender)
+                : () -> {
+                  RequestContext old = threadLocalRequestContext.setContext(asyncSender);
+                  try {
+                    asyncSender.run();
+                  } finally {
+                    threadLocalRequestContext.setContext(old);
+                  }
+                });
+  }
+
+  /**
+   * {@link Runnable} that sends the email asynchonously.
+   *
+   * <p>Only pass objects into this class that are thread-safe (e.g. immutable) so that they can be
+   * safely accessed from the background thread.
+   */
+  private static class AsyncSender implements Runnable, RequestContext {
+    private final IdentifiedUser user;
+    private final ReplacePatchSetSender.Factory replacePatchSetFactory;
+    private final PatchSetInfoFactory patchSetInfoFactory;
+    private final MessageId messageId;
+    private final NotifyResolver.Result notify;
+    private final Project.NameKey projectName;
+    private final Change.Id changeId;
+    private final PatchSet patchSet;
+    private final String message;
+    private final Instant timestamp;
+    private final ImmutableSet<PatchSetApproval> outdatedApprovals;
+    private final ImmutableSet<Account.Id> reviewers;
+    private final ImmutableSet<Account.Id> extraCcs;
+    private final ChangeKind changeKind;
+    private final ObjectId preUpdateMetaId;
+    private final Map<SubmitRequirement, SubmitRequirementResult>
+        postUpdateSubmitRequirementResults;
+
+    AsyncSender(
+        IdentifiedUser user,
+        ReplacePatchSetSender.Factory replacePatchSetFactory,
+        PatchSetInfoFactory patchSetInfoFactory,
+        MessageId messageId,
+        NotifyResolver.Result notify,
+        Project.NameKey projectName,
+        Change.Id changeId,
+        PatchSet patchSet,
+        String message,
+        Instant timestamp,
+        ImmutableSet<PatchSetApproval> outdatedApprovals,
+        ImmutableSet<Account.Id> reviewers,
+        ImmutableSet<Account.Id> extraCcs,
+        ChangeKind changeKind,
+        ObjectId preUpdateMetaId,
+        Map<SubmitRequirement, SubmitRequirementResult> postUpdateSubmitRequirementResults) {
+      this.user = user;
+      this.replacePatchSetFactory = replacePatchSetFactory;
+      this.patchSetInfoFactory = patchSetInfoFactory;
+      this.messageId = messageId;
+      this.notify = notify;
+      this.projectName = projectName;
+      this.changeId = changeId;
+      this.patchSet = patchSet;
+      this.message = message;
+      this.timestamp = timestamp;
+      this.outdatedApprovals = outdatedApprovals;
+      this.reviewers = reviewers;
+      this.extraCcs = extraCcs;
+      this.changeKind = changeKind;
+      this.preUpdateMetaId = preUpdateMetaId;
+      this.postUpdateSubmitRequirementResults = postUpdateSubmitRequirementResults;
+    }
+
+    @Override
+    public void run() {
+      try {
+        ReplacePatchSetSender emailSender =
+            replacePatchSetFactory.create(
+                projectName,
+                changeId,
+                changeKind,
+                preUpdateMetaId,
+                postUpdateSubmitRequirementResults);
+        emailSender.setFrom(user.getAccountId());
+        emailSender.setPatchSet(patchSet, patchSetInfoFactory.get(projectName, patchSet));
+        emailSender.setChangeMessage(message, timestamp);
+        emailSender.setNotify(notify);
+        emailSender.addReviewers(reviewers);
+        emailSender.addExtraCC(extraCcs);
+        emailSender.addOutdatedApproval(outdatedApprovals);
+        emailSender.setMessageId(messageId);
+        emailSender.send();
+      } catch (Exception e) {
+        logger.atSevere().withCause(e).log("Cannot send email for new patch set %s", patchSet.id());
+      }
+    }
+
+    @Override
+    public String toString() {
+      return "send-email newpatchset";
+    }
+
+    @Override
+    public CurrentUser getUser() {
+      return user.getRealUser();
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/change/EmailReviewComments.java b/java/com/google/gerrit/server/change/EmailReviewComments.java
index f94e592..a9886c7 100644
--- a/java/com/google/gerrit/server/change/EmailReviewComments.java
+++ b/java/com/google/gerrit/server/change/EmailReviewComments.java
@@ -16,29 +16,38 @@
 
 import static com.google.gerrit.server.CommentsUtil.COMMENT_ORDER;
 
+import com.google.common.collect.ImmutableList;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Comment;
 import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementResult;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.config.SendEmailExecutor;
 import com.google.gerrit.server.mail.send.CommentSender;
 import com.google.gerrit.server.mail.send.MessageIdGenerator;
-import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.mail.send.MessageIdGenerator.MessageId;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
-import com.google.gerrit.server.update.RepoView;
+import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.gerrit.server.util.LabelVote;
 import com.google.gerrit.server.util.RequestContext;
 import com.google.gerrit.server.util.ThreadLocalRequestContext;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
+import java.io.IOException;
+import java.io.UncheckedIOException;
 import java.time.Instant;
 import java.util.List;
+import java.util.Map;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Future;
+import org.eclipse.jgit.lib.ObjectId;
 
-public class EmailReviewComments implements Runnable, RequestContext {
+public class EmailReviewComments {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   public interface Factory {
@@ -47,13 +56,11 @@
     /**
      * Creates handle for sending email
      *
-     * @param notify setting for handling notification.
-     * @param notes change notes.
+     * @param postUpdateContext the post update context from the calling BatchUpdateOp
      * @param patchSet patch set corresponding to the top-level op
-     * @param user user the email should come from.
+     * @param preUpdateMetaId the SHA1 to which the notes branch pointed before the update
      * @param message used by text template only. The contents of this message typically include the
      *     "Patch set N" header and "(M comments)".
-     * @param timestamp timestamp when the comments were added.
      * @param comments inline comments.
      * @param patchSetComment used by HTML template only: some quasi-human-generated text. The
      *     contents should *not* include a "Patch set N" header or "(M comments)" footer, as these
@@ -61,34 +68,17 @@
      * @param labels labels applied as part of this review operation.
      */
     EmailReviewComments create(
-        NotifyResolver.Result notify,
-        ChangeNotes notes,
+        PostUpdateContext postUpdateContext,
         PatchSet patchSet,
-        IdentifiedUser user,
+        ObjectId preUpdateMetaId,
         @Assisted("message") String message,
-        Instant timestamp,
         List<? extends Comment> comments,
-        @Assisted("patchSetComment") String patchSetComment,
-        List<LabelVote> labels,
-        RepoView repoView);
+        @Nullable @Assisted("patchSetComment") String patchSetComment,
+        List<LabelVote> labels);
   }
 
   private final ExecutorService sendEmailsExecutor;
-  private final PatchSetInfoFactory patchSetInfoFactory;
-  private final CommentSender.Factory commentSenderFactory;
-  private final ThreadLocalRequestContext requestContext;
-  private final MessageIdGenerator messageIdGenerator;
-
-  private final NotifyResolver.Result notify;
-  private final ChangeNotes notes;
-  private final PatchSet patchSet;
-  private final IdentifiedUser user;
-  private final String message;
-  private final Instant timestamp;
-  private final List<? extends Comment> comments;
-  private final String patchSetComment;
-  private final List<LabelVote> labels;
-  private final RepoView repoView;
+  private final AsyncSender asyncSender;
 
   @Inject
   EmailReviewComments(
@@ -97,69 +87,151 @@
       CommentSender.Factory commentSenderFactory,
       ThreadLocalRequestContext requestContext,
       MessageIdGenerator messageIdGenerator,
-      @Assisted NotifyResolver.Result notify,
-      @Assisted ChangeNotes notes,
+      @Assisted PostUpdateContext postUpdateContext,
       @Assisted PatchSet patchSet,
-      @Assisted IdentifiedUser user,
+      @Assisted ObjectId preUpdateMetaId,
       @Assisted("message") String message,
-      @Assisted Instant timestamp,
       @Assisted List<? extends Comment> comments,
       @Nullable @Assisted("patchSetComment") String patchSetComment,
-      @Assisted List<LabelVote> labels,
-      @Assisted RepoView repoView) {
+      @Assisted List<LabelVote> labels) {
     this.sendEmailsExecutor = executor;
-    this.patchSetInfoFactory = patchSetInfoFactory;
-    this.commentSenderFactory = commentSenderFactory;
-    this.requestContext = requestContext;
-    this.messageIdGenerator = messageIdGenerator;
-    this.notify = notify;
-    this.notes = notes;
-    this.patchSet = patchSet;
-    this.user = user;
-    this.message = message;
-    this.timestamp = timestamp;
-    this.comments = COMMENT_ORDER.sortedCopy(comments);
-    this.patchSetComment = patchSetComment;
-    this.labels = labels;
-    this.repoView = repoView;
+
+    MessageId messageId;
+    try {
+      messageId =
+          messageIdGenerator.fromChangeUpdateAndReason(
+              postUpdateContext.getRepoView(), patchSet.id(), "EmailReviewComments");
+    } catch (IOException e) {
+      throw new UncheckedIOException(e);
+    }
+
+    Change.Id changeId = patchSet.id().changeId();
+
+    // Getting the change data from PostUpdateContext retrieves a cached ChangeData
+    // instance. This ChangeData instance has been created when the change was (re)indexed
+    // due to the update, and hence has submit requirement results already cached (since
+    // (re)indexing triggers the evaluation of the submit requirements).
+    Map<SubmitRequirement, SubmitRequirementResult> postUpdateSubmitRequirementResults =
+        postUpdateContext
+            .getChangeData(postUpdateContext.getProject(), changeId)
+            .submitRequirementsIncludingLegacy();
+    this.asyncSender =
+        new AsyncSender(
+            requestContext,
+            commentSenderFactory,
+            patchSetInfoFactory,
+            postUpdateContext.getUser().asIdentifiedUser(),
+            messageId,
+            postUpdateContext.getNotify(changeId),
+            postUpdateContext.getProject(),
+            changeId,
+            patchSet,
+            preUpdateMetaId,
+            message,
+            postUpdateContext.getWhen(),
+            ImmutableList.copyOf(COMMENT_ORDER.sortedCopy(comments)),
+            patchSetComment,
+            ImmutableList.copyOf(labels),
+            postUpdateSubmitRequirementResults);
   }
 
   public void sendAsync() {
     @SuppressWarnings("unused")
-    Future<?> possiblyIgnoredError = sendEmailsExecutor.submit(this);
+    Future<?> possiblyIgnoredError = sendEmailsExecutor.submit(asyncSender);
   }
 
-  @Override
-  public void run() {
-    RequestContext old = requestContext.setContext(this);
-    try {
-      CommentSender emailSender =
-          commentSenderFactory.create(notes.getProjectName(), notes.getChangeId());
-      emailSender.setFrom(user.getAccountId());
-      emailSender.setPatchSet(patchSet, patchSetInfoFactory.get(notes.getProjectName(), patchSet));
-      emailSender.setChangeMessage(message, timestamp);
-      emailSender.setComments(comments);
-      emailSender.setPatchSetComment(patchSetComment);
-      emailSender.setLabels(labels);
-      emailSender.setNotify(notify);
-      emailSender.setMessageId(
-          messageIdGenerator.fromChangeUpdateAndReason(
-              repoView, patchSet.id(), "EmailReviewComments"));
-      emailSender.send();
-    } catch (Exception e) {
-      logger.atSevere().withCause(e).log("Cannot email comments for %s", patchSet.id());
-    } finally {
-      requestContext.setContext(old);
+  /**
+   * {@link Runnable} that sends the email asynchonously.
+   *
+   * <p>Only pass objects into this class that are thread-safe (e.g. immutable) so that they can be
+   * safely accessed from the background thread.
+   */
+  // TODO: The passed in Comment class is not thread-safe, replace it with an AutoValue type.
+  private static class AsyncSender implements Runnable, RequestContext {
+    private final ThreadLocalRequestContext requestContext;
+    private final CommentSender.Factory commentSenderFactory;
+    private final PatchSetInfoFactory patchSetInfoFactory;
+    private final IdentifiedUser user;
+    private final MessageId messageId;
+    private final NotifyResolver.Result notify;
+    private final Project.NameKey projectName;
+    private final Change.Id changeId;
+    private final PatchSet patchSet;
+    private final ObjectId preUpdateMetaId;
+    private final String message;
+    private final Instant timestamp;
+    private final ImmutableList<? extends Comment> comments;
+    @Nullable private final String patchSetComment;
+    private final ImmutableList<LabelVote> labels;
+    private final Map<SubmitRequirement, SubmitRequirementResult>
+        postUpdateSubmitRequirementResults;
+
+    AsyncSender(
+        ThreadLocalRequestContext requestContext,
+        CommentSender.Factory commentSenderFactory,
+        PatchSetInfoFactory patchSetInfoFactory,
+        IdentifiedUser user,
+        MessageId messageId,
+        NotifyResolver.Result notify,
+        Project.NameKey projectName,
+        Change.Id changeId,
+        PatchSet patchSet,
+        ObjectId preUpdateMetaId,
+        String message,
+        Instant timestamp,
+        ImmutableList<? extends Comment> comments,
+        @Nullable String patchSetComment,
+        ImmutableList<LabelVote> labels,
+        Map<SubmitRequirement, SubmitRequirementResult> postUpdateSubmitRequirementResults) {
+      this.requestContext = requestContext;
+      this.commentSenderFactory = commentSenderFactory;
+      this.patchSetInfoFactory = patchSetInfoFactory;
+      this.user = user;
+      this.messageId = messageId;
+      this.notify = notify;
+      this.projectName = projectName;
+      this.changeId = changeId;
+      this.patchSet = patchSet;
+      this.preUpdateMetaId = preUpdateMetaId;
+      this.message = message;
+      this.timestamp = timestamp;
+      this.comments = comments;
+      this.patchSetComment = patchSetComment;
+      this.labels = labels;
+      this.postUpdateSubmitRequirementResults = postUpdateSubmitRequirementResults;
     }
-  }
 
-  @Override
-  public String toString() {
-    return "send-email comments";
-  }
+    @Override
+    public void run() {
+      RequestContext old = requestContext.setContext(this);
+      try {
+        CommentSender emailSender =
+            commentSenderFactory.create(
+                projectName, changeId, preUpdateMetaId, postUpdateSubmitRequirementResults);
+        emailSender.setFrom(user.getAccountId());
+        emailSender.setPatchSet(patchSet, patchSetInfoFactory.get(projectName, patchSet));
+        emailSender.setChangeMessage(message, timestamp);
+        emailSender.setComments(comments);
+        emailSender.setPatchSetComment(patchSetComment);
+        emailSender.setLabels(labels);
+        emailSender.setNotify(notify);
+        emailSender.setMessageId(messageId);
+        emailSender.send();
+      } catch (Exception e) {
+        logger.atSevere().withCause(e).log("Cannot email comments for %s", patchSet.id());
+      } finally {
+        requestContext.setContext(old);
+      }
+    }
 
-  @Override
-  public CurrentUser getUser() {
-    return user.getRealUser();
+    @Override
+    public String toString() {
+      return "send-email comments";
+    }
+
+    @Override
+    public CurrentUser getUser() {
+      return user.getRealUser();
+    }
   }
 }
diff --git a/java/com/google/gerrit/server/change/LabelNormalizer.java b/java/com/google/gerrit/server/change/LabelNormalizer.java
index aeb9db0..79e2054 100644
--- a/java/com/google/gerrit/server/change/LabelNormalizer.java
+++ b/java/com/google/gerrit/server/change/LabelNormalizer.java
@@ -15,13 +15,13 @@
 package com.google.gerrit.server.change;
 
 import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
 
 import com.google.auto.value.AutoValue;
 import com.google.common.annotations.VisibleForTesting;
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.Iterables;
-import com.google.common.collect.Lists;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Streams;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.LabelTypes;
@@ -32,8 +32,9 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.util.Collection;
-import java.util.List;
+import java.util.HashSet;
 import java.util.Optional;
+import java.util.Set;
 
 /**
  * Normalizes votes on labels according to project config.
@@ -49,23 +50,25 @@
   public abstract static class Result {
     @VisibleForTesting
     static Result create(
-        List<PatchSetApproval> unchanged,
-        List<PatchSetApproval> updated,
-        List<PatchSetApproval> deleted) {
+        Set<PatchSetApproval> unchanged,
+        Set<PatchSetApproval> updated,
+        Set<PatchSetApproval> deleted) {
       return new AutoValue_LabelNormalizer_Result(
-          ImmutableList.copyOf(unchanged),
-          ImmutableList.copyOf(updated),
-          ImmutableList.copyOf(deleted));
+          ImmutableSet.copyOf(unchanged),
+          ImmutableSet.copyOf(updated),
+          ImmutableSet.copyOf(deleted));
     }
 
-    public abstract ImmutableList<PatchSetApproval> unchanged();
+    public abstract ImmutableSet<PatchSetApproval> unchanged();
 
-    public abstract ImmutableList<PatchSetApproval> updated();
+    public abstract ImmutableSet<PatchSetApproval> updated();
 
-    public abstract ImmutableList<PatchSetApproval> deleted();
+    public abstract ImmutableSet<PatchSetApproval> deleted();
 
-    public Iterable<PatchSetApproval> getNormalized() {
-      return Iterables.concat(unchanged(), updated());
+    public ImmutableSet<PatchSetApproval> getNormalized() {
+      return Streams.concat(unchanged().stream(), updated().stream())
+          .distinct()
+          .collect(toImmutableSet());
     }
   }
 
@@ -84,9 +87,9 @@
    * @param approvals list of approvals.
    */
   public Result normalize(ChangeNotes notes, Collection<PatchSetApproval> approvals) {
-    List<PatchSetApproval> unchanged = Lists.newArrayListWithCapacity(approvals.size());
-    List<PatchSetApproval> updated = Lists.newArrayListWithCapacity(approvals.size());
-    List<PatchSetApproval> deleted = Lists.newArrayListWithCapacity(approvals.size());
+    Set<PatchSetApproval> unchanged = new HashSet<>(approvals.size());
+    Set<PatchSetApproval> updated = new HashSet<>(approvals.size());
+    Set<PatchSetApproval> deleted = new HashSet<>(approvals.size());
     LabelTypes labelTypes =
         projectCache
             .get(notes.getProjectName())
diff --git a/java/com/google/gerrit/server/change/PatchSetInserter.java b/java/com/google/gerrit/server/change/PatchSetInserter.java
index fc56e80..f7bec1c0 100644
--- a/java/com/google/gerrit/server/change/PatchSetInserter.java
+++ b/java/com/google/gerrit/server/change/PatchSetInserter.java
@@ -21,11 +21,12 @@
 import static java.util.Objects.requireNonNull;
 
 import com.google.common.collect.ImmutableListMultimap;
-import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetInfo;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.client.ChangeKind;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
@@ -33,14 +34,13 @@
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.ReviewerSet;
+import com.google.gerrit.server.approval.ApprovalCopier;
 import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.events.CommitReceivedEvent;
 import com.google.gerrit.server.extensions.events.RevisionCreated;
 import com.google.gerrit.server.extensions.events.WorkInProgressStateChanged;
 import com.google.gerrit.server.git.validators.CommitValidationException;
 import com.google.gerrit.server.git.validators.CommitValidators;
-import com.google.gerrit.server.mail.send.MessageIdGenerator;
-import com.google.gerrit.server.mail.send.ReplacePatchSetSender;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.patch.AutoMerger;
@@ -65,8 +65,6 @@
 import org.eclipse.jgit.transport.ReceiveCommand;
 
 public class PatchSetInserter implements BatchUpdateOp {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
   public interface Factory {
     PatchSetInserter create(ChangeNotes notes, PatchSet.Id psId, ObjectId commitId);
   }
@@ -74,15 +72,15 @@
   // Injected fields.
   private final PermissionBackend permissionBackend;
   private final PatchSetInfoFactory patchSetInfoFactory;
+  private final ChangeKindCache changeKindCache;
   private final CommitValidators.Factory commitValidatorsFactory;
-  private final ReplacePatchSetSender.Factory replacePatchSetFactory;
+  private final EmailNewPatchSet.Factory emailNewPatchSetFactory;
   private final ProjectCache projectCache;
   private final RevisionCreated revisionCreated;
   private final ApprovalsUtil approvalsUtil;
   private final ChangeMessagesUtil cmUtil;
   private final PatchSetUtil psUtil;
   private final WorkInProgressStateChanged wipStateChanged;
-  private final MessageIdGenerator messageIdGenerator;
   private final AutoMerger autoMerger;
 
   // Assisted-injected fields.
@@ -111,9 +109,12 @@
   private Change change;
   private PatchSet patchSet;
   private PatchSetInfo patchSetInfo;
+  private ChangeKind changeKind;
   private String mailMessage;
   private ReviewerSet oldReviewers;
   private boolean oldWorkInProgressState;
+  private ApprovalCopier.Result approvalCopierResult;
+  private ObjectId preUpdateMetaId;
 
   @Inject
   public PatchSetInserter(
@@ -121,13 +122,13 @@
       ApprovalsUtil approvalsUtil,
       ChangeMessagesUtil cmUtil,
       PatchSetInfoFactory patchSetInfoFactory,
+      ChangeKindCache changeKindCache,
       CommitValidators.Factory commitValidatorsFactory,
-      ReplacePatchSetSender.Factory replacePatchSetFactory,
+      EmailNewPatchSet.Factory emailNewPatchSetFactory,
       PatchSetUtil psUtil,
       RevisionCreated revisionCreated,
       ProjectCache projectCache,
       WorkInProgressStateChanged wipStateChanged,
-      MessageIdGenerator messageIdGenerator,
       AutoMerger autoMerger,
       @Assisted ChangeNotes notes,
       @Assisted PatchSet.Id psId,
@@ -136,13 +137,13 @@
     this.approvalsUtil = approvalsUtil;
     this.cmUtil = cmUtil;
     this.patchSetInfoFactory = patchSetInfoFactory;
+    this.changeKindCache = changeKindCache;
     this.commitValidatorsFactory = commitValidatorsFactory;
-    this.replacePatchSetFactory = replacePatchSetFactory;
+    this.emailNewPatchSetFactory = emailNewPatchSetFactory;
     this.psUtil = psUtil;
     this.revisionCreated = revisionCreated;
     this.projectCache = projectCache;
     this.wipStateChanged = wipStateChanged;
-    this.messageIdGenerator = messageIdGenerator;
     this.autoMerger = autoMerger;
 
     this.origNotes = notes;
@@ -238,6 +239,15 @@
       throws AuthException, ResourceConflictException, IOException, PermissionBackendException {
     validate(ctx);
     ctx.addRefUpdate(ObjectId.zeroId(), commitId, getPatchSetId().toRefName());
+
+    changeKind =
+        changeKindCache.getChangeKind(
+            ctx.getProject(),
+            ctx.getRevWalk(),
+            ctx.getRepoView().getConfig(),
+            psUtil.current(origNotes).commitId(),
+            commitId);
+
     Optional<ReceiveCommand> autoMerge =
         autoMerger.createAutoMergeCommitIfNecessary(
             ctx.getRepoView(),
@@ -252,6 +262,7 @@
   @Override
   public boolean updateChange(ChangeContext ctx)
       throws ResourceConflictException, IOException, BadRequestException {
+    preUpdateMetaId = ctx.getNotes().getMetaId();
     change = ctx.getChange();
     ChangeUpdate update = ctx.getUpdate(psId);
     update.setSubjectForCommit("Create patch set " + psId.get());
@@ -278,12 +289,6 @@
       oldReviewers = approvalsUtil.getReviewers(ctx.getNotes());
     }
 
-    if (message != null) {
-      mailMessage =
-          cmUtil.setChangeMessage(
-              update, message, ChangeMessagesUtil.uploadedPatchSetTag(change.isWorkInProgress()));
-    }
-
     oldWorkInProgressState = change.isWorkInProgress();
     if (workInProgress != null) {
       change.setWorkInProgress(workInProgress);
@@ -307,34 +312,68 @@
     }
 
     if (storeCopiedVotes) {
-      approvalsUtil.persistCopiedApprovals(
-          ctx.getNotes(), patchSet, ctx.getRevWalk(), ctx.getRepoView().getConfig(), update);
+      approvalCopierResult =
+          approvalsUtil.copyApprovalsToNewPatchSet(
+              ctx.getNotes(), patchSet, ctx.getRevWalk(), ctx.getRepoView().getConfig(), update);
     }
 
+    mailMessage = insertChangeMessage(update, ctx);
+
     return true;
   }
 
+  @Nullable
+  private String insertChangeMessage(ChangeUpdate update, ChangeContext ctx) {
+    StringBuilder messageBuilder = new StringBuilder();
+    if (message != null) {
+      messageBuilder.append(message);
+    }
+
+    if (approvalCopierResult != null) {
+      approvalsUtil
+          .formatApprovalCopierResult(
+              approvalCopierResult,
+              projectCache
+                  .get(ctx.getProject())
+                  .orElseThrow(illegalState(ctx.getProject()))
+                  .getLabelTypes())
+          .ifPresent(
+              msg -> {
+                if (message != null && !message.endsWith("\n")) {
+                  messageBuilder.append("\n");
+                }
+                messageBuilder.append("\n").append(msg);
+              });
+    }
+
+    String changeMessage = messageBuilder.toString();
+    if (changeMessage.isEmpty()) {
+      return null;
+    }
+
+    return cmUtil.setChangeMessage(
+        update,
+        messageBuilder.toString(),
+        ChangeMessagesUtil.uploadedPatchSetTag(change.isWorkInProgress()));
+  }
+
   @Override
   public void postUpdate(PostUpdateContext ctx) {
     NotifyResolver.Result notify = ctx.getNotify(change.getId());
     if (notify.shouldNotify() && sendEmail) {
       requireNonNull(mailMessage);
-      try {
-        ReplacePatchSetSender emailSender =
-            replacePatchSetFactory.create(ctx.getProject(), change.getId());
-        emailSender.setFrom(ctx.getAccountId());
-        emailSender.setPatchSet(patchSet, patchSetInfo);
-        emailSender.setChangeMessage(mailMessage, ctx.getWhen());
-        emailSender.addReviewers(oldReviewers.byState(REVIEWER));
-        emailSender.addExtraCC(oldReviewers.byState(CC));
-        emailSender.setNotify(notify);
-        emailSender.setMessageId(
-            messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), patchSet.id()));
-        emailSender.send();
-      } catch (Exception err) {
-        logger.atSevere().withCause(err).log(
-            "Cannot send email for new patch set on change %s", change.getId());
-      }
+
+      emailNewPatchSetFactory
+          .create(
+              ctx,
+              patchSet,
+              mailMessage,
+              approvalCopierResult.outdatedApprovals(),
+              oldReviewers.byState(REVIEWER),
+              oldReviewers.byState(CC),
+              changeKind,
+              preUpdateMetaId)
+          .sendAsync();
     }
 
     if (fireRevisionCreated) {
diff --git a/java/com/google/gerrit/server/change/RebaseChangeOp.java b/java/com/google/gerrit/server/change/RebaseChangeOp.java
index a0fa8e9..4de21d6 100644
--- a/java/com/google/gerrit/server/change/RebaseChangeOp.java
+++ b/java/com/google/gerrit/server/change/RebaseChangeOp.java
@@ -35,6 +35,7 @@
 import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
 import com.google.gerrit.server.git.GroupCollector;
 import com.google.gerrit.server.git.MergeUtil;
+import com.google.gerrit.server.git.MergeUtilFactory;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.InvalidChangeOperationException;
@@ -75,7 +76,7 @@
   }
 
   private final PatchSetInserter.Factory patchSetInserterFactory;
-  private final MergeUtil.Factory mergeUtilFactory;
+  private final MergeUtilFactory mergeUtilFactory;
   private final RebaseUtil rebaseUtil;
   private final ChangeResource.Factory changeResourceFactory;
 
@@ -106,7 +107,7 @@
   @Inject
   RebaseChangeOp(
       PatchSetInserter.Factory patchSetInserterFactory,
-      MergeUtil.Factory mergeUtilFactory,
+      MergeUtilFactory mergeUtilFactory,
       RebaseUtil rebaseUtil,
       ChangeResource.Factory changeResourceFactory,
       IdentifiedUser.GenericFactory identifiedUserFactory,
diff --git a/java/com/google/gerrit/server/change/RelatedChangesSorter.java b/java/com/google/gerrit/server/change/RelatedChangesSorter.java
index 719578f..b6e3121 100644
--- a/java/com/google/gerrit/server/change/RelatedChangesSorter.java
+++ b/java/com/google/gerrit/server/change/RelatedChangesSorter.java
@@ -28,7 +28,6 @@
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
-import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.permissions.ChangePermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
@@ -237,12 +236,8 @@
 
   private boolean isVisible(PatchSetData psd) throws PermissionBackendException {
     PermissionBackend.WithUser perm = permissionBackend.currentUser();
-    try {
-      perm.change(psd.data()).check(ChangePermission.READ);
-    } catch (AuthException e) {
-      return false;
-    }
-    return projectCache.get(psd.data().project()).map(ProjectState::statePermitsRead).orElse(false);
+    return perm.change(psd.data()).test(ChangePermission.READ)
+        && projectCache.get(psd.data().project()).map(ProjectState::statePermitsRead).orElse(false);
   }
 
   @AutoValue
diff --git a/java/com/google/gerrit/server/change/RemoveFromAttentionSetOp.java b/java/com/google/gerrit/server/change/RemoveFromAttentionSetOp.java
index 50ee9d4..1d92521 100644
--- a/java/com/google/gerrit/server/change/RemoveFromAttentionSetOp.java
+++ b/java/com/google/gerrit/server/change/RemoveFromAttentionSetOp.java
@@ -16,13 +16,11 @@
 
 import static java.util.Objects.requireNonNull;
 
-import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.entities.AttentionSetUpdate.Operation;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.server.mail.send.MessageIdGenerator;
 import com.google.gerrit.server.mail.send.RemoveFromAttentionSetSender;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.query.change.ChangeData;
@@ -32,19 +30,15 @@
 import com.google.gerrit.server.util.AttentionSetEmail;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-import java.io.IOException;
 import java.util.Optional;
 
 /** Remove a specified user from the attention set. */
 public class RemoveFromAttentionSetOp implements BatchUpdateOp {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
   public interface Factory {
     RemoveFromAttentionSetOp create(Account.Id attentionUserId, String reason, boolean notify);
   }
 
   private final ChangeData.Factory changeDataFactory;
-  private final MessageIdGenerator messageIdGenerator;
   private final RemoveFromAttentionSetSender.Factory removeFromAttentionSetSender;
   private final AttentionSetEmail.Factory attentionSetEmailFactory;
 
@@ -64,14 +58,12 @@
   @Inject
   RemoveFromAttentionSetOp(
       ChangeData.Factory changeDataFactory,
-      MessageIdGenerator messageIdGenerator,
       RemoveFromAttentionSetSender.Factory removeFromAttentionSetSenderFactory,
       AttentionSetEmail.Factory attentionSetEmailFactory,
       @Assisted Account.Id attentionUserId,
       @Assisted String reason,
       @Assisted boolean notify) {
     this.changeDataFactory = changeDataFactory;
-    this.messageIdGenerator = messageIdGenerator;
     this.removeFromAttentionSetSender = removeFromAttentionSetSenderFactory;
     this.attentionSetEmailFactory = attentionSetEmailFactory;
     this.attentionUserId = requireNonNull(attentionUserId, "user");
@@ -94,8 +86,8 @@
 
     change = ctx.getChange();
 
-    ChangeUpdate update = ctx.getUpdate(ctx.getChange().currentPatchSetId());
-    update.addToPlannedAttentionSetUpdates(
+    ChangeUpdate changeUpdate = ctx.getUpdate(ctx.getChange().currentPatchSetId());
+    changeUpdate.addToPlannedAttentionSetUpdates(
         AttentionSetUpdate.createForWrite(attentionUserId, Operation.REMOVE, reason));
     return true;
   }
@@ -105,18 +97,13 @@
     if (!notify) {
       return;
     }
-    try {
-      attentionSetEmailFactory
-          .create(
-              removeFromAttentionSetSender.create(ctx.getProject(), change.getId()),
-              ctx,
-              change,
-              reason,
-              messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), change.currentPatchSetId()),
-              attentionUserId)
-          .sendAsync();
-    } catch (IOException e) {
-      logger.atSevere().withCause(e).log(e.getMessage(), change.getId());
-    }
+    attentionSetEmailFactory
+        .create(
+            removeFromAttentionSetSender.create(ctx.getProject(), change.getId()),
+            ctx,
+            change,
+            reason,
+            attentionUserId)
+        .sendAsync();
   }
 }
diff --git a/java/com/google/gerrit/server/change/ReviewerModifier.java b/java/com/google/gerrit/server/change/ReviewerModifier.java
index c98fcaa..9580565 100644
--- a/java/com/google/gerrit/server/change/ReviewerModifier.java
+++ b/java/com/google/gerrit/server/change/ReviewerModifier.java
@@ -83,6 +83,7 @@
 import java.util.Optional;
 import java.util.Set;
 import java.util.function.Function;
+import java.util.stream.Stream;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
@@ -453,9 +454,13 @@
       this.input = input;
       this.failureType = null;
       result = new ReviewerResult(input.reviewer);
-      // Always silently ignore adding the owner as any type of reviewer on their own change. They
-      // may still be implicitly added as a reviewer if they vote, but not via the reviewer API.
-      this.reviewers = omitOwner(notes, reviewers);
+      if (!state().equals(REMOVED)) {
+        // Always silently ignore adding the owner as any type of reviewer on their own change. They
+        // may still be implicitly added as a reviewer if they vote, but not via the reviewer API.
+        this.reviewers = reviewersAsList(notes, reviewers, /* omitChangeOwner= */ true);
+      } else {
+        this.reviewers = reviewersAsList(notes, reviewers, /* omitChangeOwner= */ false);
+      }
       this.reviewersByEmail =
           reviewersByEmail == null ? ImmutableSet.of() : ImmutableSet.copyOf(reviewersByEmail);
       this.caller = caller.asIdentifiedUser();
@@ -489,12 +494,18 @@
       this.exactMatchFound = exactMatchFound;
     }
 
-    private ImmutableSet<Account> omitOwner(ChangeNotes notes, Iterable<Account> reviewers) {
-      return reviewers != null
-          ? Streams.stream(reviewers)
-              .filter(account -> !account.id().equals(notes.getChange().getOwner()))
-              .collect(toImmutableSet())
-          : ImmutableSet.of();
+    private ImmutableSet<Account> reviewersAsList(
+        ChangeNotes notes, @Nullable Iterable<Account> reviewers, boolean omitChangeOwner) {
+      if (reviewers == null) {
+        return ImmutableSet.of();
+      }
+
+      Stream<Account> reviewerStream = Streams.stream(reviewers);
+      if (omitChangeOwner) {
+        reviewerStream =
+            reviewerStream.filter(account -> !account.id().equals(notes.getChange().getOwner()));
+      }
+      return reviewerStream.collect(toImmutableSet());
     }
 
     public void gatherResults(ChangeData cd) throws PermissionBackendException {
diff --git a/java/com/google/gerrit/server/change/RevisionJson.java b/java/com/google/gerrit/server/change/RevisionJson.java
index 0170f35..bd9c52b 100644
--- a/java/com/google/gerrit/server/change/RevisionJson.java
+++ b/java/com/google/gerrit/server/change/RevisionJson.java
@@ -57,7 +57,7 @@
 import com.google.gerrit.server.account.AccountLoader;
 import com.google.gerrit.server.account.GpgApiAdapter;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.MergeUtil;
+import com.google.gerrit.server.git.MergeUtilFactory;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gerrit.server.permissions.ChangePermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
@@ -87,7 +87,7 @@
     RevisionJson create(Iterable<ListChangesOption> options);
   }
 
-  private final MergeUtil.Factory mergeUtilFactory;
+  private final MergeUtilFactory mergeUtilFactory;
   private final IdentifiedUser.GenericFactory userFactory;
   private final FileInfoJson fileInfoJson;
   private final GpgApiAdapter gpgApi;
@@ -111,7 +111,7 @@
       AnonymousUser anonymous,
       ProjectCache projectCache,
       IdentifiedUser.GenericFactory userFactory,
-      MergeUtil.Factory mergeUtilFactory,
+      MergeUtilFactory mergeUtilFactory,
       FileInfoJson fileInfoJson,
       AccountLoader.Factory accountLoaderFactory,
       DynamicMap<DownloadScheme> downloadSchemes,
diff --git a/java/com/google/gerrit/server/change/SubmitRequirementsJson.java b/java/com/google/gerrit/server/change/SubmitRequirementsJson.java
index 96c863e..fcd9e90 100644
--- a/java/com/google/gerrit/server/change/SubmitRequirementsJson.java
+++ b/java/com/google/gerrit/server/change/SubmitRequirementsJson.java
@@ -65,7 +65,10 @@
       boolean hide) {
     SubmitRequirementExpressionInfo info = new SubmitRequirementExpressionInfo();
     info.expression = hide ? null : expression.expressionString();
-    info.fulfilled = result.status().equals(SubmitRequirementExpressionResult.Status.PASS);
+    info.fulfilled =
+        result.status().equals(SubmitRequirementExpressionResult.Status.PASS)
+            || result.status().equals(SubmitRequirementExpressionResult.Status.NOT_EVALUATED);
+    info.status = SubmitRequirementExpressionInfo.Status.valueOf(result.status().name());
     info.passingAtoms = hide ? null : result.passingAtoms();
     info.failingAtoms = hide ? null : result.failingAtoms();
     info.errorMessage = result.errorMessage().isPresent() ? result.errorMessage().get() : null;
diff --git a/java/com/google/gerrit/server/change/WorkInProgressOp.java b/java/com/google/gerrit/server/change/WorkInProgressOp.java
index 1409170..04fd1c0 100644
--- a/java/com/google/gerrit/server/change/WorkInProgressOp.java
+++ b/java/com/google/gerrit/server/change/WorkInProgressOp.java
@@ -19,21 +19,18 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
-import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.common.InputWithMessage;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.extensions.events.WorkInProgressStateChanged;
-import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.PostUpdateContext;
-import com.google.gerrit.server.update.RepoView;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-import java.io.IOException;
+import org.eclipse.jgit.lib.ObjectId;
 
 /* Set work in progress or ready for review state on a change */
 public class WorkInProgressOp implements BatchUpdateOp {
@@ -61,8 +58,8 @@
   private final WorkInProgressStateChanged stateChanged;
 
   private boolean sendEmail = true;
+  private ObjectId preUpdateMetaId;
   private Change change;
-  private ChangeNotes notes;
   private PatchSet ps;
   private String mailMessage;
 
@@ -88,8 +85,8 @@
 
   @Override
   public boolean updateChange(ChangeContext ctx) {
+    preUpdateMetaId = ctx.getNotes().getMetaId();
     change = ctx.getChange();
-    notes = ctx.getNotes();
     ps = psUtil.get(ctx.getNotes(), change.currentPatchSetId());
     ChangeUpdate update = ctx.getUpdate(change.currentPatchSetId());
     change.setWorkInProgress(workInProgress);
@@ -131,25 +128,15 @@
         || !sendEmail) {
       return;
     }
-    RepoView repoView;
-    try {
-      repoView = ctx.getRepoView();
-    } catch (IOException ex) {
-      throw new StorageException(
-          String.format("Repository %s not found", ctx.getProject().get()), ex);
-    }
     email
         .create(
-            notify,
-            notes,
+            ctx,
             ps,
-            ctx.getIdentifiedUser(),
-            mailMessage,
-            ctx.getWhen(),
-            ImmutableList.of(),
+            preUpdateMetaId,
             mailMessage,
             ImmutableList.of(),
-            repoView)
+            mailMessage,
+            ImmutableList.of())
         .sendAsync();
   }
 }
diff --git a/java/com/google/gerrit/server/config/GerritGlobalModule.java b/java/com/google/gerrit/server/config/GerritGlobalModule.java
index 6449155..e5b063b 100644
--- a/java/com/google/gerrit/server/config/GerritGlobalModule.java
+++ b/java/com/google/gerrit/server/config/GerritGlobalModule.java
@@ -37,6 +37,7 @@
 import com.google.gerrit.extensions.events.AccountIndexedListener;
 import com.google.gerrit.extensions.events.AgreementSignupListener;
 import com.google.gerrit.extensions.events.AssigneeChangedListener;
+import com.google.gerrit.extensions.events.AttentionSetListener;
 import com.google.gerrit.extensions.events.ChangeAbandonedListener;
 import com.google.gerrit.extensions.events.ChangeDeletedListener;
 import com.google.gerrit.extensions.events.ChangeIndexedListener;
@@ -121,6 +122,7 @@
 import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.change.ChangeKindCacheImpl;
 import com.google.gerrit.server.change.ChangePluginDefinedInfoFactory;
+import com.google.gerrit.server.change.EmailNewPatchSet;
 import com.google.gerrit.server.change.FileInfoJsonModule;
 import com.google.gerrit.server.change.MergeabilityCacheImpl;
 import com.google.gerrit.server.change.ReviewerSuggestion;
@@ -130,11 +132,11 @@
 import com.google.gerrit.server.events.EventListener;
 import com.google.gerrit.server.events.EventsMetrics;
 import com.google.gerrit.server.events.UserScopedEventListener;
+import com.google.gerrit.server.extensions.events.AttentionSetObserver;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.extensions.webui.UiActions;
 import com.google.gerrit.server.git.ChangeMessageModifier;
 import com.google.gerrit.server.git.GitModule;
-import com.google.gerrit.server.git.MergeUtil;
 import com.google.gerrit.server.git.MergedByPushOp;
 import com.google.gerrit.server.git.MultiProgressMonitor;
 import com.google.gerrit.server.git.NotesBranchUtil;
@@ -173,6 +175,7 @@
 import com.google.gerrit.server.mail.send.MailSoyTemplateProvider;
 import com.google.gerrit.server.mime.FileTypeRegistry;
 import com.google.gerrit.server.mime.MimeUtilFileTypeRegistry;
+import com.google.gerrit.server.notedb.DeleteZombieCommentsRefs;
 import com.google.gerrit.server.notedb.NoteDbModule;
 import com.google.gerrit.server.notedb.StoreSubmitRequirementsOp;
 import com.google.gerrit.server.patch.DiffOperationsImpl;
@@ -188,7 +191,7 @@
 import com.google.gerrit.server.project.ProjectCacheImpl;
 import com.google.gerrit.server.project.ProjectNameLockManager;
 import com.google.gerrit.server.project.ProjectState;
-import com.google.gerrit.server.project.SubmitRequirementExpressionsValidator;
+import com.google.gerrit.server.project.SubmitRequirementConfigValidator;
 import com.google.gerrit.server.project.SubmitRequirementsEvaluatorImpl;
 import com.google.gerrit.server.project.SubmitRuleEvaluator;
 import com.google.gerrit.server.query.FileEditsPredicate;
@@ -297,7 +300,7 @@
     factory(ChangeIsVisibleToPredicate.Factory.class);
     factory(DistinctVotersPredicate.Factory.class);
     factory(DeadlineChecker.Factory.class);
-    factory(MergeUtil.Factory.class);
+    factory(EmailNewPatchSet.Factory.class);
     factory(MultiProgressMonitor.Factory.class);
     factory(PatchScriptFactory.Factory.class);
     factory(PatchScriptFactoryForAutoFix.Factory.class);
@@ -312,6 +315,7 @@
     bind(AccountDefaultDisplayName.class).toInstance(accountDefaultDisplayName);
     factory(ProjectOwnerGroupsProvider.Factory.class);
     factory(SubmitRuleEvaluator.Factory.class);
+    factory(DeleteZombieCommentsRefs.Factory.class);
 
     bind(AuthBackend.class).to(UniversalAuthBackend.class).in(SINGLETON);
     DynamicSet.setOf(binder(), AuthBackend.class);
@@ -397,7 +401,7 @@
     DynamicSet.setOf(binder(), UserScopedEventListener.class);
     DynamicSet.setOf(binder(), CommitValidationListener.class);
     DynamicSet.bind(binder(), CommitValidationListener.class)
-        .to(SubmitRequirementExpressionsValidator.class);
+        .to(SubmitRequirementConfigValidator.class);
     DynamicSet.setOf(binder(), CommentValidator.class);
     DynamicSet.setOf(binder(), ChangeMessageModifier.class);
     DynamicSet.setOf(binder(), RefOperationValidationListener.class);
@@ -454,6 +458,7 @@
     DynamicSet.setOf(binder(), MailSoyTemplateProvider.class);
     DynamicSet.setOf(binder(), OnPostReview.class);
     DynamicMap.mapOf(binder(), AccountTagProvider.class);
+    DynamicSet.setOf(binder(), AttentionSetListener.class);
 
     DynamicMap.mapOf(binder(), MailFilter.class);
     bind(MailFilter.class).annotatedWith(Exports.named("ListMailFilter")).to(ListMailFilter.class);
@@ -508,5 +513,7 @@
         .annotatedWith(UniqueAnnotations.create())
         .to(PluginConfigFactory.class);
     DynamicMap.mapOf(binder(), ExternalIdUpsertPreprocessor.class);
+
+    bind(AttentionSetObserver.class);
   }
 }
diff --git a/java/com/google/gerrit/server/config/GerritImportedServerIds.java b/java/com/google/gerrit/server/config/GerritImportedServerIds.java
new file mode 100644
index 0000000..c47d3be
--- /dev/null
+++ b/java/com/google/gerrit/server/config/GerritImportedServerIds.java
@@ -0,0 +1,29 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.config;
+
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import com.google.inject.BindingAnnotation;
+import java.lang.annotation.Retention;
+
+/**
+ * List of ServerIds of the Gerrit data imported from other servers.
+ *
+ * <p>This values correspond to the {@code GerritServerId} of other servers.
+ */
+@Retention(RUNTIME)
+@BindingAnnotation
+public @interface GerritImportedServerIds {}
diff --git a/java/com/google/gerrit/server/config/GerritImportedServerIdsProvider.java b/java/com/google/gerrit/server/config/GerritImportedServerIdsProvider.java
new file mode 100644
index 0000000..2a74833
--- /dev/null
+++ b/java/com/google/gerrit/server/config/GerritImportedServerIdsProvider.java
@@ -0,0 +1,37 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.config;
+
+import com.google.common.collect.ImmutableList;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import org.eclipse.jgit.lib.Config;
+
+public class GerritImportedServerIdsProvider implements Provider<ImmutableList<String>> {
+  public static final String SECTION = "gerrit";
+  public static final String KEY = "importedServerId";
+
+  private final ImmutableList<String> importedIds;
+
+  @Inject
+  public GerritImportedServerIdsProvider(@GerritServerConfig Config cfg) {
+    importedIds = ImmutableList.copyOf(cfg.getStringList(SECTION, null, KEY));
+  }
+
+  @Override
+  public ImmutableList<String> get() {
+    return importedIds;
+  }
+}
diff --git a/java/com/google/gerrit/server/config/PreferencesParserUtil.java b/java/com/google/gerrit/server/config/PreferencesParserUtil.java
index 69d75be..fbdb324 100644
--- a/java/com/google/gerrit/server/config/PreferencesParserUtil.java
+++ b/java/com/google/gerrit/server/config/PreferencesParserUtil.java
@@ -182,6 +182,7 @@
       my.add(new MenuItem("Edits", "#/q/has:edit", null));
       my.add(new MenuItem("Watched Changes", "#/q/is:watched+is:open", null));
       my.add(new MenuItem("Starred Changes", "#/q/is:starred", null));
+      my.add(new MenuItem("All Visible Changes", "#/q/is:visible", null));
       my.add(new MenuItem("Groups", "#/settings/#Groups", null));
     }
     return my;
diff --git a/java/com/google/gerrit/server/config/TrackingFootersProvider.java b/java/com/google/gerrit/server/config/TrackingFootersProvider.java
index 1611da9..778ab4c 100644
--- a/java/com/google/gerrit/server/config/TrackingFootersProvider.java
+++ b/java/com/google/gerrit/server/config/TrackingFootersProvider.java
@@ -32,7 +32,7 @@
 public class TrackingFootersProvider implements Provider<TrackingFooters> {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  private static final int MAX_LENGTH = 10;
+  private static final int MAX_LENGTH = 20;
 
   private static final String TRACKING_ID_TAG = "trackingid";
   private static final String FOOTER_TAG = "footer";
diff --git a/java/com/google/gerrit/server/config/UrlFormatter.java b/java/com/google/gerrit/server/config/UrlFormatter.java
index 5054da6..04ea438 100644
--- a/java/com/google/gerrit/server/config/UrlFormatter.java
+++ b/java/com/google/gerrit/server/config/UrlFormatter.java
@@ -83,6 +83,12 @@
     return getWebUrl().map(url -> url + "Documentation/" + page + "#" + anchor);
   }
 
+  /** Returns a URL pointing to a plugin documentation page, at a given named anchor. */
+  default Optional<String> getPluginDocUrl(String pluginName, String page, String anchor) {
+    return getWebUrl()
+        .map(url -> url + "plugins/" + pluginName + "/Documentation/" + page + "#" + anchor);
+  }
+
   /** Returns a REST API URL for a given suffix (eg. "accounts/self/details") */
   default Optional<String> getRestUrl(String suffix) {
     return getWebUrl().map(url -> url + suffix);
diff --git a/java/com/google/gerrit/server/documentation/QueryDocumentationExecutor.java b/java/com/google/gerrit/server/documentation/QueryDocumentationExecutor.java
index 59ae6f8..7d3ddf1 100644
--- a/java/com/google/gerrit/server/documentation/QueryDocumentationExecutor.java
+++ b/java/com/google/gerrit/server/documentation/QueryDocumentationExecutor.java
@@ -33,9 +33,9 @@
 import org.apache.lucene.search.Query;
 import org.apache.lucene.search.ScoreDoc;
 import org.apache.lucene.search.TopDocs;
+import org.apache.lucene.store.ByteBuffersDirectory;
 import org.apache.lucene.store.Directory;
 import org.apache.lucene.store.IndexOutput;
-import org.apache.lucene.store.RAMDirectory;
 
 @Singleton
 public class QueryDocumentationExecutor {
@@ -100,7 +100,7 @@
   }
 
   protected Directory readIndexDirectory() throws IOException {
-    Directory dir = new RAMDirectory();
+    Directory dir = new ByteBuffersDirectory();
     byte[] buffer = new byte[4096];
     InputStream index = getClass().getResourceAsStream(Constants.INDEX_ZIP);
     if (index == null) {
diff --git a/java/com/google/gerrit/server/edit/ChangeEditModifier.java b/java/com/google/gerrit/server/edit/ChangeEditModifier.java
index 232aa6a..2957d6b 100644
--- a/java/com/google/gerrit/server/edit/ChangeEditModifier.java
+++ b/java/com/google/gerrit/server/edit/ChangeEditModifier.java
@@ -53,10 +53,10 @@
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.time.Instant;
+import java.time.ZoneId;
 import java.util.List;
 import java.util.Objects;
 import java.util.Optional;
-import java.util.TimeZone;
 import org.eclipse.jgit.diff.DiffAlgorithm;
 import org.eclipse.jgit.diff.DiffAlgorithm.SupportedAlgorithm;
 import org.eclipse.jgit.diff.RawText;
@@ -91,7 +91,7 @@
 @Singleton
 public class ChangeEditModifier {
 
-  private final TimeZone tz;
+  private final ZoneId zoneId;
   private final Provider<CurrentUser> currentUser;
   private final PermissionBackend permissionBackend;
   private final ChangeEditUtil changeEditUtil;
@@ -110,12 +110,12 @@
       ProjectCache projectCache) {
     this.currentUser = currentUser;
     this.permissionBackend = permissionBackend;
-    this.tz = gerritIdent.getTimeZone();
+    this.zoneId = gerritIdent.getZoneId();
     this.changeEditUtil = changeEditUtil;
     this.patchSetUtil = patchSetUtil;
     this.projectCache = projectCache;
 
-    noteDbEdits = new NoteDbEdits(tz, indexer, currentUser);
+    noteDbEdits = new NoteDbEdits(zoneId, indexer, currentUser);
   }
 
   /**
@@ -519,7 +519,7 @@
 
   private PersonIdent getCommitterIdent(Instant commitTimestamp) {
     IdentifiedUser user = currentUser.get().asIdentifiedUser();
-    return user.newCommitterIdent(commitTimestamp, tz);
+    return user.newCommitterIdent(commitTimestamp, zoneId);
   }
 
   /**
@@ -709,12 +709,12 @@
   }
 
   private static class NoteDbEdits {
-    private final TimeZone tz;
+    private final ZoneId zoneId;
     private final ChangeIndexer indexer;
     private final Provider<CurrentUser> currentUser;
 
-    NoteDbEdits(TimeZone tz, ChangeIndexer indexer, Provider<CurrentUser> currentUser) {
-      this.tz = tz;
+    NoteDbEdits(ZoneId zoneId, ChangeIndexer indexer, Provider<CurrentUser> currentUser) {
+      this.zoneId = zoneId;
       this.indexer = indexer;
       this.currentUser = currentUser;
     }
@@ -841,7 +841,7 @@
 
     private PersonIdent getRefLogIdent(Instant timestamp) {
       IdentifiedUser user = currentUser.get().asIdentifiedUser();
-      return user.newRefLogIdent(timestamp, tz);
+      return user.newRefLogIdent(timestamp, zoneId);
     }
 
     private void reindex(Change change) {
diff --git a/java/com/google/gerrit/server/events/ProjectHeadUpdatedEvent.java b/java/com/google/gerrit/server/events/ProjectHeadUpdatedEvent.java
new file mode 100644
index 0000000..90ed285
--- /dev/null
+++ b/java/com/google/gerrit/server/events/ProjectHeadUpdatedEvent.java
@@ -0,0 +1,36 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.events;
+
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.Project.NameKey;
+
+public class ProjectHeadUpdatedEvent extends ProjectEvent {
+
+  static final String TYPE = "project-head-updated";
+
+  public String projectName;
+  public String oldHead;
+  public String newHead;
+
+  public ProjectHeadUpdatedEvent() {
+    super(TYPE);
+  }
+
+  @Override
+  public NameKey getProjectNameKey() {
+    return Project.nameKey(projectName);
+  }
+}
diff --git a/java/com/google/gerrit/server/events/StreamEventsApiListener.java b/java/com/google/gerrit/server/events/StreamEventsApiListener.java
index abacb85..afe2a7c 100644
--- a/java/com/google/gerrit/server/events/StreamEventsApiListener.java
+++ b/java/com/google/gerrit/server/events/StreamEventsApiListener.java
@@ -39,6 +39,7 @@
 import com.google.gerrit.extensions.events.CommentAddedListener;
 import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
 import com.google.gerrit.extensions.events.HashtagsEditedListener;
+import com.google.gerrit.extensions.events.HeadUpdatedListener;
 import com.google.gerrit.extensions.events.NewProjectCreatedListener;
 import com.google.gerrit.extensions.events.PrivateStateChangedListener;
 import com.google.gerrit.extensions.events.ReviewerAddedListener;
@@ -86,7 +87,8 @@
         ReviewerDeletedListener,
         RevisionCreatedListener,
         TopicEditedListener,
-        VoteDeletedListener {
+        VoteDeletedListener,
+        HeadUpdatedListener {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   public static class StreamEventsApiListenerModule extends AbstractModule {
@@ -111,6 +113,7 @@
       DynamicSet.bind(binder(), VoteDeletedListener.class).to(StreamEventsApiListener.class);
       DynamicSet.bind(binder(), WorkInProgressStateChangedListener.class)
           .to(StreamEventsApiListener.class);
+      DynamicSet.bind(binder(), HeadUpdatedListener.class).to(StreamEventsApiListener.class);
     }
   }
 
@@ -339,6 +342,16 @@
   }
 
   @Override
+  public void onHeadUpdated(HeadUpdatedListener.Event ev) {
+    ProjectHeadUpdatedEvent event = new ProjectHeadUpdatedEvent();
+    event.projectName = ev.getProjectName();
+    event.oldHead = ev.getOldHeadName();
+    event.newHead = ev.getNewHeadName();
+
+    dispatcher.run(d -> d.postEvent(event.getProjectNameKey(), event));
+  }
+
+  @Override
   public void onHashtagsEdited(HashtagsEditedListener.Event ev) {
     try {
       ChangeNotes notes = getNotes(ev.getChange());
diff --git a/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java b/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java
index ffeb44b..b876341 100644
--- a/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java
+++ b/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java
@@ -27,13 +27,6 @@
   public static String GERRIT_BACKEND_REQUEST_FEATURE_REMOVE_REVISION_ETAG =
       "GerritBackendRequestFeature__remove_revision_etag";
 
-  /**
-   * When set, we compute information from All-Users repository if able, instead of computing it
-   * from the change index.
-   */
-  public static final String GERRIT_BACKEND_REQUEST_FEATURE_COMPUTE_FROM_ALL_USERS_REPOSITORY =
-      "GerritBackendRequestFeature__compute_from_all_users_repository";
-
   /** Features, enabled by default in the current release. */
   public static final ImmutableSet<String> DEFAULT_ENABLED_FEATURES =
       ImmutableSet.of(UI_FEATURE_PATCHSET_COMMENTS, UI_FEATURE_SUBMIT_REQUIREMENTS_UI);
diff --git a/java/com/google/gerrit/server/extensions/events/AbstractChangeEvent.java b/java/com/google/gerrit/server/extensions/events/AbstractChangeEvent.java
index fde4088..6a73348 100644
--- a/java/com/google/gerrit/server/extensions/events/AbstractChangeEvent.java
+++ b/java/com/google/gerrit/server/extensions/events/AbstractChangeEvent.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.extensions.events;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
@@ -28,7 +29,7 @@
   private final NotifyHandling notify;
 
   protected AbstractChangeEvent(
-      ChangeInfo change, AccountInfo who, Instant when, NotifyHandling notify) {
+      ChangeInfo change, @Nullable AccountInfo who, Instant when, NotifyHandling notify) {
     this.changeInfo = change;
     this.who = who;
     this.when = when;
diff --git a/java/com/google/gerrit/server/extensions/events/AttentionSetObserver.java b/java/com/google/gerrit/server/extensions/events/AttentionSetObserver.java
new file mode 100644
index 0000000..04ffcc1
--- /dev/null
+++ b/java/com/google/gerrit/server/extensions/events/AttentionSetObserver.java
@@ -0,0 +1,133 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.extensions.events;
+
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.AttentionSetUpdate;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.events.AttentionSetListener;
+import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.plugincontext.PluginSetContext;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.time.Instant;
+import java.util.HashSet;
+import java.util.Set;
+
+/** Helper class to fire an event when an attention set changes. */
+@Singleton
+public class AttentionSetObserver {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private final PluginSetContext<AttentionSetListener> listeners;
+  private final EventUtil util;
+  private final AccountCache accountCache;
+
+  public static final AttentionSetObserver DISABLED =
+      new AttentionSetObserver() {
+        @Override
+        public void fire(
+            ChangeData changeData,
+            AccountState accountState,
+            AttentionSetUpdate update,
+            Instant when) {}
+      };
+
+  @Inject
+  AttentionSetObserver(
+      PluginSetContext<AttentionSetListener> listeners, EventUtil util, AccountCache accountCache) {
+    this.listeners = listeners;
+    this.util = util;
+    this.accountCache = accountCache;
+  }
+
+  /** Constructor only for DISABLED version of the AttentionSetObserver. */
+  private AttentionSetObserver() {
+    this.listeners = null;
+    this.util = null;
+    this.accountCache = null;
+  }
+
+  /**
+   * Notify all listening plugins
+   *
+   * @param changeData is current data of the change
+   * @param accountState is the initiator of the change
+   * @param update is the update that caused the event
+   * @param when is the time of the event
+   */
+  public void fire(
+      ChangeData changeData,
+      @Nullable AccountState accountState,
+      AttentionSetUpdate update,
+      Instant when) {
+    if (listeners.isEmpty()) {
+      return;
+    }
+    AccountState target = accountCache.get(update.account()).get();
+
+    HashSet<Integer> added = new HashSet<>();
+    HashSet<Integer> removed = new HashSet<>();
+    switch (update.operation()) {
+      case ADD:
+        added.add(target.account().id().get());
+        break;
+      case REMOVE:
+        removed.add(target.account().id().get());
+        break;
+    }
+
+    try {
+      Event event =
+          new Event(
+              util.changeInfo(changeData), util.accountInfo(accountState), added, removed, when);
+      listeners.runEach(l -> l.onAttentionSetChanged(event));
+    } catch (Exception e) {
+      logger.atSevere().withCause(e).log("Exception while firing AttentionSet changed event");
+    }
+  }
+
+  /** Event to be fired when an attention set changes */
+  public static class Event extends AbstractChangeEvent implements AttentionSetListener.Event {
+    private final Set<Integer> added;
+    private final Set<Integer> removed;
+
+    public Event(
+        ChangeInfo change,
+        @Nullable AccountInfo editor,
+        Set<Integer> added,
+        Set<Integer> removed,
+        Instant when) {
+      super(change, editor, when, NotifyHandling.ALL);
+      this.added = added;
+      this.removed = removed;
+    }
+
+    @Override
+    public Set<Integer> usersAdded() {
+      return added;
+    }
+
+    @Override
+    public Set<Integer> usersRemoved() {
+      return removed;
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/extensions/events/EventUtil.java b/java/com/google/gerrit/server/extensions/events/EventUtil.java
index 45f7ecb..b669571 100644
--- a/java/com/google/gerrit/server/extensions/events/EventUtil.java
+++ b/java/com/google/gerrit/server/extensions/events/EventUtil.java
@@ -17,6 +17,7 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
@@ -98,7 +99,7 @@
     return revisionJsonFactory.create(changeOptions).getRevisionInfo(cd, ps);
   }
 
-  public AccountInfo accountInfo(AccountState accountState) {
+  public AccountInfo accountInfo(@Nullable AccountState accountState) {
     if (accountState == null || accountState.account().id() == null) {
       return null;
     }
diff --git a/java/com/google/gerrit/server/extensions/events/VoteDeleted.java b/java/com/google/gerrit/server/extensions/events/VoteDeleted.java
index deaaff8..d127260 100644
--- a/java/com/google/gerrit/server/extensions/events/VoteDeleted.java
+++ b/java/com/google/gerrit/server/extensions/events/VoteDeleted.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.extensions.events;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
@@ -58,7 +59,7 @@
       Map<String, Short> oldApprovals,
       NotifyHandling notify,
       String message,
-      AccountState remover,
+      @Nullable AccountState remover,
       Instant when) {
     if (listeners.isEmpty()) {
       return;
@@ -69,8 +70,8 @@
               util.changeInfo(changeData),
               util.revisionInfo(changeData.project(), ps),
               util.accountInfo(reviewer),
-              util.approvals(remover, approvals, when),
-              util.approvals(remover, oldApprovals, when),
+              util.approvals(reviewer, approvals, when),
+              util.approvals(reviewer, oldApprovals, when),
               notify,
               message,
               util.accountInfo(remover),
diff --git a/java/com/google/gerrit/server/fixes/FixCalculator.java b/java/com/google/gerrit/server/fixes/FixCalculator.java
index 9ea628e..df20fbf 100644
--- a/java/com/google/gerrit/server/fixes/FixCalculator.java
+++ b/java/com/google/gerrit/server/fixes/FixCalculator.java
@@ -355,6 +355,11 @@
     }
 
     void processLineToColumn(int to, boolean append) throws IndexOutOfBoundsException {
+      int from = srcPosition.column;
+      if (from > to) {
+        throw new IndexOutOfBoundsException(
+            String.format("The parameter from is greater than to. from: %d, to: %d", from, to));
+      }
       if (to == 0) {
         return;
       }
@@ -366,7 +371,6 @@
           throw new IndexOutOfBoundsException("The processLineToColumn shouldn't add end of line");
         }
       }
-      int from = srcPosition.column;
       int charCount = to - from;
       srcPosition.appendStringWithoutEOLMark(charCount);
       if (append) {
diff --git a/java/com/google/gerrit/server/git/BanCommit.java b/java/com/google/gerrit/server/git/BanCommit.java
index 9cc754c..e27197c 100644
--- a/java/com/google/gerrit/server/git/BanCommit.java
+++ b/java/com/google/gerrit/server/git/BanCommit.java
@@ -31,8 +31,8 @@
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.time.Instant;
+import java.time.ZoneId;
 import java.util.List;
-import java.util.TimeZone;
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
 import org.eclipse.jgit.errors.MissingObjectException;
 import org.eclipse.jgit.lib.Constants;
@@ -78,7 +78,7 @@
 
   private final Provider<IdentifiedUser> currentUser;
   private final GitRepositoryManager repoManager;
-  private final TimeZone tz;
+  private final ZoneId zoneId;
   private final PermissionBackend permissionBackend;
   private final NotesBranchUtil.Factory notesBranchUtilFactory;
 
@@ -93,7 +93,7 @@
     this.repoManager = repoManager;
     this.notesBranchUtilFactory = notesBranchUtilFactory;
     this.permissionBackend = permissionBackend;
-    this.tz = gerritIdent.getTimeZone();
+    this.zoneId = gerritIdent.getZoneId();
   }
 
   /**
@@ -155,7 +155,7 @@
   }
 
   private PersonIdent createPersonIdent() {
-    return currentUser.get().newCommitterIdent(Instant.now(), tz);
+    return currentUser.get().newCommitterIdent(Instant.now(), zoneId);
   }
 
   private static String buildCommitMessage(List<ObjectId> bannedCommits, String reason) {
diff --git a/java/com/google/gerrit/server/git/CommitUtil.java b/java/com/google/gerrit/server/git/CommitUtil.java
index e52c45f..fa46bf4 100644
--- a/java/com/google/gerrit/server/git/CommitUtil.java
+++ b/java/com/google/gerrit/server/git/CommitUtil.java
@@ -17,6 +17,7 @@
 import static com.google.common.base.MoreObjects.firstNonNull;
 
 import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
@@ -57,6 +58,7 @@
 import java.time.Instant;
 import java.util.ArrayList;
 import java.util.HashSet;
+import java.util.Map;
 import java.util.Set;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
@@ -220,7 +222,7 @@
 
     PersonIdent committerIdent = serverIdent.get();
     PersonIdent authorIdent =
-        user.asIdentifiedUser().newCommitterIdent(ts, committerIdent.getTimeZone());
+        user.asIdentifiedUser().newCommitterIdent(ts, committerIdent.getZoneId());
 
     RevCommit parentToCommitToRevert = commitToRevert.getParent(0);
     revWalk.parseHeaders(parentToCommitToRevert);
@@ -274,6 +276,7 @@
             .create(changeId, revertCommit, notes.getChange().getDest().branch())
             .setTopic(input.topic == null ? changeToRevert.getTopic() : input.topic.trim());
     ins.setMessage("Uploaded patch set 1.");
+    ins.setValidationOptions(getValidateOptionsAsMultimap(input.validationOptions));
 
     ReviewerSet reviewerSet = approvalsUtil.getReviewers(notes);
 
@@ -298,6 +301,20 @@
     return changeId;
   }
 
+  private static ImmutableListMultimap<String, String> getValidateOptionsAsMultimap(
+      @Nullable Map<String, String> validationOptions) {
+    if (validationOptions == null) {
+      return ImmutableListMultimap.of();
+    }
+
+    ImmutableListMultimap.Builder<String, String> validationOptionsBuilder =
+        ImmutableListMultimap.builder();
+    validationOptions
+        .entrySet()
+        .forEach(e -> validationOptionsBuilder.put(e.getKey(), e.getValue()));
+    return validationOptionsBuilder.build();
+  }
+
   private class NotifyOp implements BatchUpdateOp {
     private final Change change;
     private final ChangeInserter ins;
diff --git a/java/com/google/gerrit/server/git/MergeUtil.java b/java/com/google/gerrit/server/git/MergeUtil.java
index d84ce7b..ae247ad 100644
--- a/java/com/google/gerrit/server/git/MergeUtil.java
+++ b/java/com/google/gerrit/server/git/MergeUtil.java
@@ -23,6 +23,8 @@
 import static java.util.Comparator.naturalOrder;
 import static java.util.stream.Collectors.joining;
 
+import com.google.auto.factory.AutoFactory;
+import com.google.auto.factory.Provided;
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
@@ -60,8 +62,6 @@
 import com.google.gerrit.server.submit.CommitMergeStatus;
 import com.google.gerrit.server.submit.MergeIdenticalTreeException;
 import com.google.gerrit.server.submit.MergeSorter;
-import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
 import java.io.InputStream;
 import java.util.ArrayList;
@@ -115,6 +115,7 @@
  * flushing should use {@link ObjectInserter#newReader()}. This is already the default behavior of
  * {@code BatchUpdate}.
  */
+@AutoFactory
 public class MergeUtil {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
@@ -135,12 +136,6 @@
     return useRecursiveMerge(cfg) ? MergeStrategy.RECURSIVE : MergeStrategy.RESOLVE;
   }
 
-  public interface Factory {
-    MergeUtil create(ProjectState project);
-
-    MergeUtil create(ProjectState project, boolean useContentMerge);
-  }
-
   private final IdentifiedUser.GenericFactory identifiedUserFactory;
   private final DynamicItem<UrlFormatter> urlFormatter;
   private final ApprovalsUtil approvalsUtil;
@@ -149,40 +144,38 @@
   private final boolean useRecursiveMerge;
   private final PluggableCommitMessageGenerator commitMessageGenerator;
 
-  @AssistedInject
   MergeUtil(
-      @GerritServerConfig Config serverConfig,
-      IdentifiedUser.GenericFactory identifiedUserFactory,
-      DynamicItem<UrlFormatter> urlFormatter,
-      ApprovalsUtil approvalsUtil,
-      PluggableCommitMessageGenerator commitMessageGenerator,
-      @Assisted ProjectState project) {
+      @Provided @GerritServerConfig Config serverConfig,
+      @Provided IdentifiedUser.GenericFactory identifiedUserFactory,
+      @Provided DynamicItem<UrlFormatter> urlFormatter,
+      @Provided ApprovalsUtil approvalsUtil,
+      @Provided PluggableCommitMessageGenerator commitMessageGenerator,
+      ProjectState project) {
     this(
         serverConfig,
         identifiedUserFactory,
         urlFormatter,
         approvalsUtil,
-        project,
         commitMessageGenerator,
+        project,
         project.is(BooleanProjectConfig.USE_CONTENT_MERGE));
   }
 
-  @AssistedInject
   MergeUtil(
-      @GerritServerConfig Config serverConfig,
-      IdentifiedUser.GenericFactory identifiedUserFactory,
-      DynamicItem<UrlFormatter> urlFormatter,
-      ApprovalsUtil approvalsUtil,
-      @Assisted ProjectState project,
-      PluggableCommitMessageGenerator commitMessageGenerator,
-      @Assisted boolean useContentMerge) {
+      @Provided @GerritServerConfig Config serverConfig,
+      @Provided IdentifiedUser.GenericFactory identifiedUserFactory,
+      @Provided DynamicItem<UrlFormatter> urlFormatter,
+      @Provided ApprovalsUtil approvalsUtil,
+      @Provided PluggableCommitMessageGenerator commitMessageGenerator,
+      ProjectState project,
+      boolean useContentMerge) {
     this.identifiedUserFactory = identifiedUserFactory;
     this.urlFormatter = urlFormatter;
     this.approvalsUtil = approvalsUtil;
+    this.commitMessageGenerator = commitMessageGenerator;
     this.project = project;
     this.useContentMerge = useContentMerge;
     this.useRecursiveMerge = useRecursiveMerge(serverConfig);
-    this.commitMessageGenerator = commitMessageGenerator;
   }
 
   public CodeReviewCommit getFirstFastForward(
diff --git a/java/com/google/gerrit/server/git/PureRevertCache.java b/java/com/google/gerrit/server/git/PureRevertCache.java
index 3910393..90eadf3 100644
--- a/java/com/google/gerrit/server/git/PureRevertCache.java
+++ b/java/com/google/gerrit/server/git/PureRevertCache.java
@@ -138,13 +138,13 @@
 
   static class Loader extends CacheLoader<PureRevertKeyProto, Boolean> {
     private final GitRepositoryManager repoManager;
-    private final MergeUtil.Factory mergeUtilFactory;
+    private final MergeUtilFactory mergeUtilFactory;
     private final ProjectCache projectCache;
 
     @Inject
     Loader(
         GitRepositoryManager repoManager,
-        MergeUtil.Factory mergeUtilFactory,
+        MergeUtilFactory mergeUtilFactory,
         ProjectCache projectCache) {
       this.repoManager = repoManager;
       this.mergeUtilFactory = mergeUtilFactory;
diff --git a/java/com/google/gerrit/server/git/receive/AsyncReceiveCommits.java b/java/com/google/gerrit/server/git/receive/AsyncReceiveCommits.java
index dd5af2c..08849348 100644
--- a/java/com/google/gerrit/server/git/receive/AsyncReceiveCommits.java
+++ b/java/com/google/gerrit/server/git/receive/AsyncReceiveCommits.java
@@ -26,7 +26,6 @@
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.metrics.Counter0;
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.Description.Units;
@@ -291,10 +290,7 @@
     receivePack.setPreReceiveHook(asHook());
     receivePack.setPostReceiveHook(lazyPostReceive.create(user, projectName));
 
-    try {
-      projectState.checkStatePermitsRead();
-      this.perm.check(ProjectPermission.READ);
-    } catch (AuthException | ResourceConflictException e) {
+    if (!projectState.statePermitsRead() || !this.perm.test(ProjectPermission.READ)) {
       receivePack.setCheckReferencedObjectsAreReachable(
           receiveConfig.checkReferencedObjectsAreReachable);
     }
diff --git a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
index 71606dc..0f5e3bc 100644
--- a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
+++ b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
@@ -1007,6 +1007,7 @@
             .setIsWorkInProgress(wip)
             .build();
     addMessage(changeFormatter.changeUpdated(input));
+    u.getOutdatedApprovalsMessage().map(msg -> "\n" + msg + "\n").ifPresent(this::addMessage);
   }
 
   private void insertChangesAndPatchSets(
@@ -1276,9 +1277,7 @@
                   + NoteDbPushOption.ALLOW.value());
           return;
         }
-        try {
-          permissionBackend.user(user).check(GlobalPermission.ACCESS_DATABASE);
-        } catch (AuthException e) {
+        if (!permissionBackend.user(user).test(GlobalPermission.ACCESS_DATABASE)) {
           reject(cmd, "NoteDb update requires access database permission");
           return;
         }
@@ -1320,9 +1319,7 @@
   private void validateConfigPush(ReceiveCommand cmd) throws PermissionBackendException {
     try (TraceTimer traceTimer = newTimer("validateConfigPush")) {
       logger.atFine().log("Processing %s command", cmd.getRefName());
-      try {
-        permissions.check(ProjectPermission.WRITE_CONFIG);
-      } catch (AuthException e) {
+      if (!permissions.test(ProjectPermission.WRITE_CONFIG)) {
         reject(
             cmd,
             String.format(
@@ -1360,20 +1357,16 @@
             } else {
               if (!oldParent.equals(newParent)) {
                 if (allowProjectOwnersToChangeParent) {
-                  try {
-                    permissionBackend
-                        .user(user)
-                        .project(project.getNameKey())
-                        .check(ProjectPermission.WRITE_CONFIG);
-                  } catch (AuthException e) {
+                  if (!permissionBackend
+                      .user(user)
+                      .project(project.getNameKey())
+                      .test(ProjectPermission.WRITE_CONFIG)) {
                     reject(
                         cmd, "invalid project configuration: only project owners can set parent");
                     return;
                   }
                 } else {
-                  try {
-                    permissionBackend.user(user).check(GlobalPermission.ADMINISTRATE_SERVER);
-                  } catch (AuthException e) {
+                  if (!permissionBackend.user(user).test(GlobalPermission.ADMINISTRATE_SERVER)) {
                     reject(cmd, "invalid project configuration: only Gerrit admin can set parent");
                     return;
                   }
@@ -2696,7 +2689,11 @@
 
   private ChangeLookup lookupByChangeKey(RevCommit c, Change.Key key) {
     try (TraceTimer traceTimer = newTimer("lookupByChangeKey")) {
-      return new ChangeLookup(c, key, queryProvider.get().byBranchKey(magicBranch.dest, key));
+      List<ChangeData> byBranchKeyExactMatch =
+          queryProvider.get().byBranchKey(magicBranch.dest, key).stream()
+              .filter(cd -> cd.change().getKey().equals(key))
+              .collect(toList());
+      return new ChangeLookup(c, key, byBranchKeyExactMatch);
     }
   }
 
@@ -3021,9 +3018,7 @@
           return false;
         }
 
-        try {
-          permissions.change(notes).check(ChangePermission.ADD_PATCH_SET);
-        } catch (AuthException no) {
+        if (!permissions.change(notes).test(ChangePermission.ADD_PATCH_SET)) {
           reject(inputCommand, "cannot add patch set to " + ontoChange + ".");
           return false;
         }
@@ -3070,18 +3065,8 @@
       if ((magicBranch.workInProgress || magicBranch.ready)
           && magicBranch.workInProgress != change.isWorkInProgress()
           && !user.getAccountId().equals(change.getOwner())) {
-        boolean hasWriteConfigPermission = false;
-        try {
-          permissions.check(ProjectPermission.WRITE_CONFIG);
-          hasWriteConfigPermission = true;
-        } catch (AuthException e) {
-          // Do nothing.
-        }
-
-        if (!hasWriteConfigPermission) {
-          try {
-            permissions.change(notes).check(ChangePermission.TOGGLE_WORK_IN_PROGRESS_STATE);
-          } catch (AuthException e1) {
+        if (!permissions.test(ProjectPermission.WRITE_CONFIG)) {
+          if (!permissions.change(notes).test(ChangePermission.TOGGLE_WORK_IN_PROGRESS_STATE)) {
             reject(inputCommand, ONLY_USERS_WITH_TOGGLE_WIP_STATE_PERM_CAN_MODIFY_WIP);
           }
         }
@@ -3210,22 +3195,20 @@
 
         RevCommit priorCommit = revisions.inverse().get(priorPatchSet);
         replaceOp =
-            replaceOpFactory
-                .create(
-                    projectState,
-                    notes.getChange().getDest(),
-                    checkMergedInto,
-                    checkMergedInto ? inputCommand.getNewId().name() : null,
-                    priorPatchSet,
-                    priorCommit,
-                    psId,
-                    newCommit,
-                    info,
-                    groups,
-                    magicBranch,
-                    receivePack.getPushCertificate(),
-                    notes.getChange())
-                .setRequestScopePropagator(requestScopePropagator);
+            replaceOpFactory.create(
+                projectState,
+                notes.getChange(),
+                checkMergedInto,
+                checkMergedInto ? inputCommand.getNewId().name() : null,
+                priorPatchSet,
+                priorCommit,
+                psId,
+                newCommit,
+                info,
+                groups,
+                magicBranch,
+                receivePack.getPushCertificate(),
+                requestScopePropagator);
         bu.addOp(notes.getChangeId(), replaceOp);
         if (progress != null) {
           bu.addOp(notes.getChangeId(), new ChangeProgressOp(progress));
@@ -3252,9 +3235,14 @@
       }
     }
 
+    @Nullable
     String getRejectMessage() {
       return replaceOp != null ? replaceOp.getRejectMessage() : null;
     }
+
+    Optional<String> getOutdatedApprovalsMessage() {
+      return replaceOp != null ? replaceOp.getOutdatedApprovalsMessage() : Optional.empty();
+    }
   }
 
   private class UpdateGroupsRequest {
diff --git a/java/com/google/gerrit/server/git/receive/ReplaceOp.java b/java/com/google/gerrit/server/git/receive/ReplaceOp.java
index e7e0e8f..644f82e 100644
--- a/java/com/google/gerrit/server/git/receive/ReplaceOp.java
+++ b/java/com/google/gerrit/server/git/receive/ReplaceOp.java
@@ -21,6 +21,7 @@
 import static com.google.gerrit.server.mail.MailUtil.getRecipientsFromReviewers;
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
+import static java.util.stream.Collectors.joining;
 import static org.eclipse.jgit.lib.Constants.R_HEADS;
 
 import com.google.common.base.Strings;
@@ -28,7 +29,7 @@
 import com.google.common.collect.Streams;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.PatchSet;
@@ -46,24 +47,26 @@
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountResolver;
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.approval.ApprovalCopier;
 import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.change.ChangeKindCache;
+import com.google.gerrit.server.change.EmailNewPatchSet;
 import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.change.ReviewerModifier;
 import com.google.gerrit.server.change.ReviewerModifier.InternalReviewerInput;
 import com.google.gerrit.server.change.ReviewerModifier.ReviewerModification;
 import com.google.gerrit.server.change.ReviewerModifier.ReviewerModificationList;
 import com.google.gerrit.server.change.ReviewerOp;
-import com.google.gerrit.server.config.SendEmailExecutor;
+import com.google.gerrit.server.config.AnonymousCowardName;
 import com.google.gerrit.server.config.UrlFormatter;
 import com.google.gerrit.server.extensions.events.CommentAdded;
 import com.google.gerrit.server.extensions.events.RevisionCreated;
 import com.google.gerrit.server.git.MergedByPushOp;
 import com.google.gerrit.server.git.receive.ReceiveCommits.MagicBranchInput;
 import com.google.gerrit.server.mail.MailUtil.MailRecipients;
-import com.google.gerrit.server.mail.send.MessageIdGenerator;
-import com.google.gerrit.server.mail.send.ReplacePatchSetSender;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -75,6 +78,7 @@
 import com.google.gerrit.server.update.Context;
 import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.gerrit.server.update.RepoContext;
+import com.google.gerrit.server.util.LabelVote;
 import com.google.gerrit.server.util.RequestScopePropagator;
 import com.google.gerrit.server.validators.ValidationException;
 import com.google.inject.Inject;
@@ -86,8 +90,6 @@
 import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
-import java.util.concurrent.ExecutorService;
-import java.util.concurrent.Future;
 import java.util.stream.Stream;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.ObjectId;
@@ -102,7 +104,7 @@
   public interface Factory {
     ReplaceOp create(
         ProjectState projectState,
-        BranchNameKey dest,
+        Change change,
         boolean checkMergedInto,
         @Nullable String mergeResultRevId,
         @Assisted("priorPatchSetId") PatchSet.Id priorPatchSetId,
@@ -113,30 +115,29 @@
         List<String> groups,
         @Nullable MagicBranchInput magicBranch,
         @Nullable PushCertificate pushCertificate,
-        Change change);
+        RequestScopePropagator requestScopePropagator);
   }
 
   private static final String CHANGE_IS_CLOSED = "change is closed";
 
+  private final AccountCache accountCache;
   private final AccountResolver accountResolver;
+  private final String anonymousCowardName;
   private final ApprovalsUtil approvalsUtil;
   private final ChangeData.Factory changeDataFactory;
   private final ChangeKindCache changeKindCache;
   private final ChangeMessagesUtil cmUtil;
-  private final ExecutorService sendEmailExecutor;
+  private final EmailNewPatchSet.Factory emailNewPatchSetFactory;
   private final RevisionCreated revisionCreated;
   private final CommentAdded commentAdded;
   private final MergedByPushOp.Factory mergedByPushOpFactory;
   private final PatchSetUtil psUtil;
-  private final ReplacePatchSetSender.Factory replacePatchSetFactory;
   private final ProjectCache projectCache;
   private final ReviewerModifier reviewerModifier;
-  private final Change change;
-  private final MessageIdGenerator messageIdGenerator;
   private final DynamicItem<UrlFormatter> urlFormatter;
 
   private final ProjectState projectState;
-  private final BranchNameKey dest;
+  private final Change change;
   private final boolean checkMergedInto;
   private final String mergeResultRevId;
   private final PatchSet.Id priorPatchSetId;
@@ -146,6 +147,7 @@
   private final PatchSetInfo info;
   private final MagicBranchInput magicBranch;
   private final PushCertificate pushCertificate;
+  private final RequestScopePropagator requestScopePropagator;
   private List<String> groups;
 
   private final Map<String, Short> approvals = new HashMap<>();
@@ -155,15 +157,17 @@
   private PatchSet newPatchSet;
   private ChangeKind changeKind;
   private String mailMessage;
+  private ApprovalCopier.Result approvalCopierResult;
   private String rejectMessage;
   private MergedByPushOp mergedByPushOp;
-  private RequestScopePropagator requestScopePropagator;
   private ReviewerModificationList reviewerAdditions;
   private MailRecipients oldRecipients;
 
   @Inject
   ReplaceOp(
+      AccountCache accountCache,
       AccountResolver accountResolver,
+      @AnonymousCowardName String anonymousCowardName,
       ApprovalsUtil approvalsUtil,
       ChangeData.Factory changeDataFactory,
       ChangeKindCache changeKindCache,
@@ -172,15 +176,12 @@
       CommentAdded commentAdded,
       MergedByPushOp.Factory mergedByPushOpFactory,
       PatchSetUtil psUtil,
-      ReplacePatchSetSender.Factory replacePatchSetFactory,
       ProjectCache projectCache,
-      @SendEmailExecutor ExecutorService sendEmailExecutor,
+      EmailNewPatchSet.Factory emailNewPatchSetFactory,
       ReviewerModifier reviewerModifier,
-      Change change,
-      MessageIdGenerator messageIdGenerator,
       DynamicItem<UrlFormatter> urlFormatter,
       @Assisted ProjectState projectState,
-      @Assisted BranchNameKey dest,
+      @Assisted Change change,
       @Assisted boolean checkMergedInto,
       @Assisted @Nullable String mergeResultRevId,
       @Assisted("priorPatchSetId") PatchSet.Id priorPatchSetId,
@@ -190,8 +191,11 @@
       @Assisted PatchSetInfo info,
       @Assisted List<String> groups,
       @Assisted @Nullable MagicBranchInput magicBranch,
-      @Assisted @Nullable PushCertificate pushCertificate) {
+      @Assisted @Nullable PushCertificate pushCertificate,
+      @Assisted RequestScopePropagator requestScopePropagator) {
+    this.accountCache = accountCache;
     this.accountResolver = accountResolver;
+    this.anonymousCowardName = anonymousCowardName;
     this.approvalsUtil = approvalsUtil;
     this.changeDataFactory = changeDataFactory;
     this.changeKindCache = changeKindCache;
@@ -200,16 +204,13 @@
     this.commentAdded = commentAdded;
     this.mergedByPushOpFactory = mergedByPushOpFactory;
     this.psUtil = psUtil;
-    this.replacePatchSetFactory = replacePatchSetFactory;
     this.projectCache = projectCache;
-    this.sendEmailExecutor = sendEmailExecutor;
+    this.emailNewPatchSetFactory = emailNewPatchSetFactory;
     this.reviewerModifier = reviewerModifier;
-    this.change = change;
-    this.messageIdGenerator = messageIdGenerator;
     this.urlFormatter = urlFormatter;
 
     this.projectState = projectState;
-    this.dest = dest;
+    this.change = change;
     this.checkMergedInto = checkMergedInto;
     this.mergeResultRevId = mergeResultRevId;
     this.priorPatchSetId = priorPatchSetId;
@@ -220,6 +221,7 @@
     this.groups = groups;
     this.magicBranch = magicBranch;
     this.pushCertificate = pushCertificate;
+    this.requestScopePropagator = requestScopePropagator;
   }
 
   @Override
@@ -235,7 +237,7 @@
             commitId);
 
     if (checkMergedInto) {
-      String mergedInto = findMergedInto(ctx, dest.branch(), commit);
+      String mergedInto = findMergedInto(ctx, change.getDest().branch(), commit);
       if (mergedInto != null) {
         mergedByPushOp =
             mergedByPushOpFactory.create(
@@ -338,15 +340,17 @@
     }
     reviewerAdditions.updateChange(ctx, newPatchSet);
 
-    // Check if approvals are changing in with this update. If so, add current user to reviewers.
-    // Note that this is done separately as addReviewers is filtering out the change owner as
+    // Check if approvals are changing with this update. If so, add the current user (aka the
+    // approver) as a reviewers because all approvers must also be reviewers.
+    // Note that this is done separately as addReviewers is filtering out the change owner as a
     // reviewer which is needed in several other code paths.
     if (magicBranch != null && !magicBranch.labels.isEmpty()) {
       update.putReviewer(ctx.getAccountId(), REVIEWER);
     }
 
-    approvalsUtil.persistCopiedApprovals(
-        ctx.getNotes(), newPatchSet, ctx.getRevWalk(), ctx.getRepoView().getConfig(), update);
+    approvalCopierResult =
+        approvalsUtil.copyApprovalsToNewPatchSet(
+            ctx.getNotes(), newPatchSet, ctx.getRevWalk(), ctx.getRepoView().getConfig(), update);
 
     mailMessage = insertChangeMessage(update, ctx, reviewMessage);
     if (mergedByPushOp == null) {
@@ -420,6 +424,15 @@
     if (!Strings.isNullOrEmpty(reviewMessage)) {
       message.append("\n\n").append(reviewMessage);
     }
+    approvalsUtil
+        .formatApprovalCopierResult(approvalCopierResult, projectState.getLabelTypes())
+        .ifPresent(
+            msg -> {
+              if (Strings.isNullOrEmpty(reviewMessage) || !reviewMessage.endsWith("\n")) {
+                message.append("\n");
+              }
+              message.append("\n").append(msg);
+            });
     boolean workInProgress = ctx.getChange().isWorkInProgress();
     if (magicBranch != null && magicBranch.workInProgress) {
       workInProgress = true;
@@ -489,16 +502,28 @@
   @Override
   public void postUpdate(PostUpdateContext ctx) throws Exception {
     reviewerAdditions.postUpdate(ctx);
-    if (changeKind != ChangeKind.TRIVIAL_REBASE) {
-      // TODO(dborowitz): Merge email templates so we only have to send one.
-      Runnable e = new ReplaceEmailTask(ctx);
-      if (requestScopePropagator != null) {
-        @SuppressWarnings("unused")
-        Future<?> possiblyIgnoredError = sendEmailExecutor.submit(requestScopePropagator.wrap(e));
-      } else {
-        e.run();
-      }
-    }
+
+    // TODO(dborowitz): Merge email templates so we only have to send one.
+    emailNewPatchSetFactory
+        .create(
+            ctx,
+            newPatchSet,
+            mailMessage,
+            approvalCopierResult.outdatedApprovals(),
+            Streams.concat(
+                    oldRecipients.getReviewers().stream(),
+                    reviewerAdditions.flattenResults(ReviewerOp.Result::addedReviewers).stream()
+                        .map(PatchSetApproval::accountId))
+                .collect(toImmutableSet()),
+            Streams.concat(
+                    oldRecipients.getCcOnly().stream(),
+                    reviewerAdditions.flattenResults(ReviewerOp.Result::addedCCs).stream())
+                .collect(toImmutableSet()),
+            changeKind,
+            notes.getMetaId())
+        .setRequestScopePropagator(requestScopePropagator)
+        .sendAsync();
+
     NotifyResolver.Result notify = ctx.getNotify(notes.getChangeId());
     revisionCreated.fire(
         ctx.getChangeData(notes), newPatchSet, ctx.getAccount(), ctx.getWhen(), notify);
@@ -512,49 +537,6 @@
     }
   }
 
-  private class ReplaceEmailTask implements Runnable {
-    private final Context ctx;
-
-    private ReplaceEmailTask(Context ctx) {
-      this.ctx = ctx;
-    }
-
-    @Override
-    public void run() {
-      try {
-        ReplacePatchSetSender emailSender =
-            replacePatchSetFactory.create(projectState.getNameKey(), notes.getChangeId());
-        emailSender.setFrom(ctx.getAccount().account().id());
-        emailSender.setPatchSet(newPatchSet, info);
-        emailSender.setChangeMessage(mailMessage, ctx.getWhen());
-        emailSender.setNotify(ctx.getNotify(notes.getChangeId()));
-        emailSender.addReviewers(
-            Streams.concat(
-                    oldRecipients.getReviewers().stream(),
-                    reviewerAdditions.flattenResults(ReviewerOp.Result::addedReviewers).stream()
-                        .map(PatchSetApproval::accountId))
-                .collect(toImmutableSet()));
-        emailSender.addExtraCC(
-            Streams.concat(
-                    oldRecipients.getCcOnly().stream(),
-                    reviewerAdditions.flattenResults(ReviewerOp.Result::addedCCs).stream())
-                .collect(toImmutableSet()));
-        emailSender.setMessageId(
-            messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), patchSetId));
-        // TODO(dborowitz): Support byEmail
-        emailSender.send();
-      } catch (Exception e) {
-        logger.atSevere().withCause(e).log(
-            "Cannot send email for new patch set %s", newPatchSet.id());
-      }
-    }
-
-    @Override
-    public String toString() {
-      return "send-email newpatchset";
-    }
-  }
-
   private void fireApprovalsEvent(PostUpdateContext ctx) {
     if (approvals.isEmpty()) {
       return;
@@ -604,13 +586,42 @@
     return rejectMessage;
   }
 
-  public ReceiveCommand getCommand() {
-    return cmd;
+  public Optional<String> getOutdatedApprovalsMessage() {
+    if (approvalCopierResult == null || approvalCopierResult.outdatedApprovals().isEmpty()) {
+      return Optional.empty();
+    }
+
+    return Optional.of(
+        "The following approvals got outdated and were removed:\n"
+            + approvalCopierResult.outdatedApprovals().stream()
+                .map(
+                    outdatedApproval ->
+                        String.format(
+                            "* %s by %s",
+                            LabelVote.create(outdatedApproval.label(), outdatedApproval.value())
+                                .format(),
+                            getNameFor(outdatedApproval.accountId())))
+                .sorted()
+                .collect(joining("\n")));
   }
 
-  public ReplaceOp setRequestScopePropagator(RequestScopePropagator requestScopePropagator) {
-    this.requestScopePropagator = requestScopePropagator;
-    return this;
+  private String getNameFor(Account.Id accountId) {
+    Optional<Account> account = accountCache.get(accountId).map(AccountState::account);
+    String name = null;
+    if (account.isPresent()) {
+      name = account.get().fullName();
+      if (name == null) {
+        name = account.get().preferredEmail();
+      }
+    }
+    if (name == null) {
+      name = anonymousCowardName + " #" + accountId;
+    }
+    return name;
+  }
+
+  public ReceiveCommand getCommand() {
+    return cmd;
   }
 
   private static String findMergedInto(Context ctx, String first, RevCommit commit) {
diff --git a/java/com/google/gerrit/server/git/validators/CommitValidators.java b/java/com/google/gerrit/server/git/validators/CommitValidators.java
index 6118157..999f810 100644
--- a/java/com/google/gerrit/server/git/validators/CommitValidators.java
+++ b/java/com/google/gerrit/server/git/validators/CommitValidators.java
@@ -49,10 +49,12 @@
 import com.google.gerrit.server.logging.Metadata;
 import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.logging.TraceContext.TraceTimer;
+import com.google.gerrit.server.patch.DiffOperations;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.permissions.RefPermission;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
+import com.google.gerrit.server.project.LabelConfigValidator;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.ProjectState;
@@ -105,6 +107,7 @@
     private final AccountValidator accountValidator;
     private final ProjectCache projectCache;
     private final ProjectConfig.Factory projectConfigFactory;
+    private final DiffOperations diffOperations;
     private final Config config;
 
     @Inject
@@ -119,7 +122,8 @@
         ExternalIdsConsistencyChecker externalIdsConsistencyChecker,
         AccountValidator accountValidator,
         ProjectCache projectCache,
-        ProjectConfig.Factory projectConfigFactory) {
+        ProjectConfig.Factory projectConfigFactory,
+        DiffOperations diffOperations) {
       this.gerritIdent = gerritIdent;
       this.urlFormatter = urlFormatter;
       this.config = config;
@@ -131,6 +135,7 @@
       this.accountValidator = accountValidator;
       this.projectCache = projectCache;
       this.projectConfigFactory = projectConfigFactory;
+      this.diffOperations = diffOperations;
     }
 
     public CommitValidators forReceiveCommits(
@@ -162,7 +167,8 @@
           .add(new PluginCommitValidationListener(pluginValidators, skipValidation))
           .add(new ExternalIdUpdateListener(allUsers, externalIdsConsistencyChecker))
           .add(new AccountCommitValidator(repoManager, allUsers, accountValidator))
-          .add(new GroupCommitValidator(allUsers));
+          .add(new GroupCommitValidator(allUsers))
+          .add(new LabelConfigValidator(diffOperations));
       return new CommitValidators(validators.build());
     }
 
@@ -191,7 +197,8 @@
           .add(new PluginCommitValidationListener(pluginValidators))
           .add(new ExternalIdUpdateListener(allUsers, externalIdsConsistencyChecker))
           .add(new AccountCommitValidator(repoManager, allUsers, accountValidator))
-          .add(new GroupCommitValidator(allUsers));
+          .add(new GroupCommitValidator(allUsers))
+          .add(new LabelConfigValidator(diffOperations));
       return new CommitValidators(validators.build());
     }
 
@@ -401,11 +408,17 @@
         sshPort = 22;
       }
 
+      // TODO(15944): Remove once both SFTP/SCP protocol are supported.
+      //
+      // In newer versions of OpenSSH, the default hook installation command will fail with a
+      // cryptic error because the scp binary defaults to a different protocol.
+      String scpFlagHint = "(for OpenSSH >= 9.0 you need to add the flag '-O' to the scp command)";
+
       String sshHook =
           String.format(
               "gitdir=$(git rev-parse --git-dir); scp -p -P %d %s@%s:hooks/commit-msg ${gitdir}/hooks/",
               sshPort, user.getUserName().orElse("<USERNAME>"), sshHost);
-      return String.format("  %s\nor, for http(s):\n  %s", sshHook, httpHook);
+      return String.format("  %s\n%s\nor, for http(s):\n  %s", sshHook, scpFlagHint, httpHook);
     }
   }
 
@@ -551,10 +564,10 @@
         return Collections.emptyList();
       }
       try {
-        perm.check(RefPermission.MERGE);
-        return Collections.emptyList();
-      } catch (AuthException e) {
-        throw new CommitValidationException("you are not allowed to upload merges", e);
+        if (perm.test(RefPermission.MERGE)) {
+          return Collections.emptyList();
+        }
+        throw new CommitValidationException("you are not allowed to upload merges");
       } catch (PermissionBackendException e) {
         logger.atSevere().withCause(e).log("cannot check MERGE");
         throw new CommitValidationException("internal auth error");
diff --git a/java/com/google/gerrit/server/git/validators/RefOperationValidators.java b/java/com/google/gerrit/server/git/validators/RefOperationValidators.java
index 85d232e..9ac3c89 100644
--- a/java/com/google/gerrit/server/git/validators/RefOperationValidators.java
+++ b/java/com/google/gerrit/server/git/validators/RefOperationValidators.java
@@ -157,8 +157,10 @@
             && !refEvent.command.getRefName().equals(RefNames.REFS_USERS_DEFAULT)) {
           if (refEvent.command.getType().equals(ReceiveCommand.Type.CREATE)) {
             try {
-              perm.check(GlobalPermission.ACCESS_DATABASE);
-            } catch (AuthException | PermissionBackendException e) {
+              if (!perm.test(GlobalPermission.ACCESS_DATABASE)) {
+                throw new ValidationException("Not allowed to create user branch.");
+              }
+            } catch (PermissionBackendException e) {
               throw new ValidationException("Not allowed to create user branch.", e);
             }
             if (Account.Id.fromRef(refEvent.command.getRefName()) == null) {
diff --git a/java/com/google/gerrit/server/group/db/AuditLogReader.java b/java/com/google/gerrit/server/group/db/AuditLogReader.java
index 3f7ef2c..4c1f69b 100644
--- a/java/com/google/gerrit/server/group/db/AuditLogReader.java
+++ b/java/com/google/gerrit/server/group/db/AuditLogReader.java
@@ -139,9 +139,6 @@
     return result.stream().map(AccountGroupByIdAudit.Builder::build).collect(toImmutableList());
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   private Optional<ParsedCommit> parse(AccountGroup.UUID uuid, RevCommit c) {
     Optional<Account.Id> authorId = NoteDbUtil.parseIdent(c.getAuthorIdent());
     if (!authorId.isPresent()) {
@@ -169,7 +166,7 @@
     return Optional.of(
         new AutoValue_AuditLogReader_ParsedCommit(
             authorId.get(),
-            c.getAuthorIdent().getWhen().toInstant(),
+            c.getAuthorIdent().getWhenAsInstant(),
             ImmutableList.copyOf(addedMembers),
             ImmutableList.copyOf(removedMembers),
             ImmutableList.copyOf(addedSubgroups),
diff --git a/java/com/google/gerrit/server/group/db/GroupConfig.java b/java/com/google/gerrit/server/group/db/GroupConfig.java
index 71cc08c..4f2c049 100644
--- a/java/com/google/gerrit/server/group/db/GroupConfig.java
+++ b/java/com/google/gerrit/server/group/db/GroupConfig.java
@@ -37,7 +37,6 @@
 import java.io.IOException;
 import java.time.Instant;
 import java.util.Arrays;
-import java.util.Date;
 import java.util.Optional;
 import java.util.function.Function;
 import java.util.regex.Pattern;
@@ -300,9 +299,6 @@
     return c;
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   @Override
   protected boolean onSave(CommitBuilder commit) throws IOException, ConfigInvalidException {
     checkLoaded();
@@ -321,8 +317,8 @@
     Instant commitTimestamp =
         TimeUtil.truncateToSecond(
             groupDelta.flatMap(GroupDelta::getUpdatedOn).orElseGet(TimeUtil::now));
-    commit.setAuthor(new PersonIdent(commit.getAuthor(), Date.from(commitTimestamp)));
-    commit.setCommitter(new PersonIdent(commit.getCommitter(), Date.from(commitTimestamp)));
+    commit.setAuthor(new PersonIdent(commit.getAuthor(), commitTimestamp));
+    commit.setCommitter(new PersonIdent(commit.getCommitter(), commitTimestamp));
 
     InternalGroup updatedGroup = updateGroup(commitTimestamp);
 
diff --git a/java/com/google/gerrit/server/index/IndexUtils.java b/java/com/google/gerrit/server/index/IndexUtils.java
index ee8dfc8..80cc463 100644
--- a/java/com/google/gerrit/server/index/IndexUtils.java
+++ b/java/com/google/gerrit/server/index/IndexUtils.java
@@ -15,21 +15,18 @@
 package com.google.gerrit.server.index;
 
 import static com.google.gerrit.server.index.change.ChangeField.CHANGE;
-import static com.google.gerrit.server.index.change.ChangeField.LEGACY_ID;
 import static com.google.gerrit.server.index.change.ChangeField.LEGACY_ID_STR;
 import static com.google.gerrit.server.index.change.ChangeField.PROJECT;
 
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Sets;
 import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.QueryOptions;
 import com.google.gerrit.index.project.ProjectField;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.index.account.AccountField;
 import com.google.gerrit.server.index.group.GroupField;
-import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.GroupBackedUser;
 import java.io.IOException;
 import java.util.Set;
@@ -65,17 +62,17 @@
    */
   public static Set<String> accountFields(Set<String> fields, boolean useLegacyNumericFields) {
     String idFieldName =
-        useLegacyNumericFields ? AccountField.ID.getName() : AccountField.ID_STR.getName();
+        useLegacyNumericFields
+            ? AccountField.ID_FIELD_SPEC.getName()
+            : AccountField.ID_STR_FIELD_SPEC.getName();
     return fields.contains(idFieldName) ? fields : Sets.union(fields, ImmutableSet.of(idFieldName));
   }
 
   /**
    * Returns a sanitized set of fields for change index queries by removing fields that the current
-   * index version doesn't support and accounting for numeric vs. string primary keys. The primary
-   * key situation is temporary and should be removed after the migration is done.
+   * index version doesn't support.
    */
-  public static Set<String> changeFields(QueryOptions opts, boolean useLegacyNumericFields) {
-    FieldDef<ChangeData, ?> idField = useLegacyNumericFields ? LEGACY_ID : LEGACY_ID_STR;
+  public static Set<String> changeFields(QueryOptions opts) {
     // Ensure we request enough fields to construct a ChangeData. We need both
     // change ID and project, which can either come via the Change field or
     // separate fields.
@@ -84,10 +81,10 @@
       // A Change is always sufficient.
       return fs;
     }
-    if (fs.contains(PROJECT.getName()) && fs.contains(idField.getName())) {
+    if (fs.contains(PROJECT.getName()) && fs.contains(LEGACY_ID_STR.getName())) {
       return fs;
     }
-    return Sets.union(fs, ImmutableSet.of(idField.getName(), PROJECT.getName()));
+    return Sets.union(fs, ImmutableSet.of(LEGACY_ID_STR.getName(), PROJECT.getName()));
   }
 
   /**
@@ -97,9 +94,9 @@
    */
   public static Set<String> groupFields(QueryOptions opts) {
     Set<String> fs = opts.fields();
-    return fs.contains(GroupField.UUID.getName())
+    return fs.contains(GroupField.UUID_FIELD_SPEC.getName())
         ? fs
-        : Sets.union(fs, ImmutableSet.of(GroupField.UUID.getName()));
+        : Sets.union(fs, ImmutableSet.of(GroupField.UUID_FIELD_SPEC.getName()));
   }
 
   /** Returns a index-friendly representation of a {@link CurrentUser} to be used in queries. */
diff --git a/java/com/google/gerrit/server/index/account/AccountField.java b/java/com/google/gerrit/server/index/account/AccountField.java
index 416b175..c802205 100644
--- a/java/com/google/gerrit/server/index/account/AccountField.java
+++ b/java/com/google/gerrit/server/index/account/AccountField.java
@@ -14,11 +14,6 @@
 
 package com.google.gerrit.server.index.account;
 
-import static com.google.gerrit.index.FieldDef.exact;
-import static com.google.gerrit.index.FieldDef.integer;
-import static com.google.gerrit.index.FieldDef.prefix;
-import static com.google.gerrit.index.FieldDef.storedOnly;
-import static com.google.gerrit.index.FieldDef.timestamp;
 import static java.util.stream.Collectors.toSet;
 
 import com.google.common.collect.FluentIterable;
@@ -26,7 +21,7 @@
 import com.google.common.collect.Iterables;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.entities.RefNames;
-import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.IndexedField;
 import com.google.gerrit.index.RefState;
 import com.google.gerrit.index.SchemaUtil;
 import com.google.gerrit.server.account.AccountState;
@@ -41,13 +36,31 @@
 import java.util.Set;
 import org.eclipse.jgit.lib.ObjectId;
 
-/** Secondary index schemas for accounts. */
+/**
+ * Secondary index schemas for accounts.
+ *
+ * <p>Note that this class does not override {@link Object#equals(Object)}. It relies on instances
+ * being singletons so that the default (i.e. reference) comparison works.
+ */
 public class AccountField {
-  public static final FieldDef<AccountState, Integer> ID =
-      integer("id").stored().build(a -> a.account().id().get());
 
-  public static final FieldDef<AccountState, String> ID_STR =
-      exact("id_str").stored().build(a -> String.valueOf(a.account().id().get()));
+  public static final IndexedField<AccountState, Integer> ID_FIELD =
+      IndexedField.<AccountState>integerBuilder("Id")
+          .stored()
+          .required()
+          .build(a -> a.account().id().get());
+
+  public static final IndexedField<AccountState, Integer>.SearchSpec ID_FIELD_SPEC =
+      ID_FIELD.integer("id");
+
+  public static final IndexedField<AccountState, String> ID_STR_FIELD =
+      IndexedField.<AccountState>stringBuilder("IdStr")
+          .stored()
+          .required()
+          .build(a -> String.valueOf(a.account().id().get()));
+
+  public static final IndexedField<AccountState, String>.SearchSpec ID_STR_FIELD_SPEC =
+      ID_STR_FIELD.exact("id_str");
 
   /**
    * External IDs.
@@ -55,9 +68,13 @@
    * <p>This field includes secondary emails. Use this field only if the current user is allowed to
    * see secondary emails (requires the {@link GlobalCapability#MODIFY_ACCOUNT} capability).
    */
-  public static final FieldDef<AccountState, Iterable<String>> EXTERNAL_ID =
-      exact("external_id")
-          .buildRepeatable(a -> Iterables.transform(a.externalIds(), id -> id.key().get()));
+  public static final IndexedField<AccountState, Iterable<String>> EXTERNAL_ID_FIELD =
+      IndexedField.<AccountState>iterableStringBuilder("ExternalId")
+          .required()
+          .build(a -> Iterables.transform(a.externalIds(), id -> id.key().get()));
+
+  public static final IndexedField<AccountState, Iterable<String>>.SearchSpec
+      EXTERNAL_ID_FIELD_SPEC = EXTERNAL_ID_FIELD.exact("external_id");
 
   /**
    * Fuzzy prefix match on name and email parts.
@@ -66,37 +83,59 @@
    * is allowed to see secondary emails (requires the {@link GlobalCapability#MODIFY_ACCOUNT}
    * capability).
    *
-   * <p>Use the {@link AccountField#NAME_PART_NO_SECONDARY_EMAIL} if the current user can't see
+   * <p>Use the {@link AccountField#NAME_PART_NO_SECONDARY_EMAIL_SPEC} if the current user can't see
    * secondary emails.
    */
-  public static final FieldDef<AccountState, Iterable<String>> NAME_PART =
-      prefix("name")
-          .buildRepeatable(
-              a -> getNameParts(a, Iterables.transform(a.externalIds(), ExternalId::email)));
+  public static final IndexedField<AccountState, Iterable<String>> NAME_PART_FIELD =
+      IndexedField.<AccountState>iterableStringBuilder("FullNameAndAllEmailsParts")
+          .description("Full name, all linked emails and their parts (split at special characters)")
+          .required()
+          .build(a -> getNameParts(a, Iterables.transform(a.externalIds(), ExternalId::email)));
+
+  public static final IndexedField<AccountState, Iterable<String>>.SearchSpec NAME_PART_SPEC =
+      NAME_PART_FIELD.prefix("name");
 
   /**
    * Fuzzy prefix match on name and preferred email parts. Parts of secondary emails are not
    * included.
    */
-  public static final FieldDef<AccountState, Iterable<String>> NAME_PART_NO_SECONDARY_EMAIL =
-      prefix("name2")
-          .buildRepeatable(a -> getNameParts(a, Arrays.asList(a.account().preferredEmail())));
+  public static final IndexedField<AccountState, Iterable<String>>
+      NAME_PART_NO_SECONDARY_EMAIL_FIELD =
+          IndexedField.<AccountState>iterableStringBuilder("FullNameAndPreferredEmailParts")
+              .description(
+                  "Full name, preferred emails and its parts (split at special characters)")
+              .required()
+              .build(a -> getNameParts(a, Arrays.asList(a.account().preferredEmail())));
 
-  public static final FieldDef<AccountState, String> FULL_NAME =
-      exact("full_name").build(a -> a.account().fullName());
+  public static final IndexedField<AccountState, Iterable<String>>.SearchSpec
+      NAME_PART_NO_SECONDARY_EMAIL_SPEC = NAME_PART_NO_SECONDARY_EMAIL_FIELD.prefix("name2");
 
-  public static final FieldDef<AccountState, String> ACTIVE =
-      exact("inactive").build(a -> a.account().isActive() ? "1" : "0");
+  public static final IndexedField<AccountState, String> FULL_NAME_FIELD =
+      IndexedField.<AccountState>stringBuilder("FullName")
+          .required()
+          .build(a -> a.account().fullName());
 
+  public static final IndexedField<AccountState, String>.SearchSpec FULL_NAME_SPEC =
+      FULL_NAME_FIELD.exact("full_name");
+
+  public static final IndexedField<AccountState, String> ACTIVE_FIELD =
+      IndexedField.<AccountState>stringBuilder("Active")
+          .required()
+          .build(a -> a.account().isActive() ? "1" : "0");
+
+  public static final IndexedField<AccountState, String>.SearchSpec ACTIVE_FIELD_SPEC =
+      ACTIVE_FIELD.exact("inactive");
   /**
    * All emails (preferred email + secondary emails). Use this field only if the current user is
    * allowed to see secondary emails (requires the 'Modify Account' capability).
    *
-   * <p>Use the {@link AccountField#PREFERRED_EMAIL} if the current user can't see secondary emails.
+   * <p>Use the {@link AccountField#PREFERRED_EMAIL_LOWER_CASE_SPEC} if the current user can't see
+   * secondary emails.
    */
-  public static final FieldDef<AccountState, Iterable<String>> EMAIL =
-      prefix("email")
-          .buildRepeatable(
+  public static final IndexedField<AccountState, Iterable<String>> EMAIL_FIELD =
+      IndexedField.<AccountState>iterableStringBuilder("Email")
+          .required()
+          .build(
               a ->
                   FluentIterable.from(a.externalIds())
                       .transform(ExternalId::email)
@@ -105,42 +144,66 @@
                       .transform(String::toLowerCase)
                       .toSet());
 
-  public static final FieldDef<AccountState, String> PREFERRED_EMAIL =
-      prefix("preferredemail")
+  public static final IndexedField<AccountState, Iterable<String>>.SearchSpec EMAIL_SPEC =
+      EMAIL_FIELD.prefix("email");
+
+  public static final IndexedField<AccountState, String> PREFERRED_EMAIL_LOWER_CASE_FIELD =
+      IndexedField.<AccountState>stringBuilder("PreferredEmailLowerCase")
           .build(
               a -> {
                 String preferredEmail = a.account().preferredEmail();
                 return preferredEmail != null ? preferredEmail.toLowerCase() : null;
               });
 
-  public static final FieldDef<AccountState, String> PREFERRED_EMAIL_EXACT =
-      exact("preferredemail_exact").build(a -> a.account().preferredEmail());
+  public static final IndexedField<AccountState, String>.SearchSpec
+      PREFERRED_EMAIL_LOWER_CASE_SPEC = PREFERRED_EMAIL_LOWER_CASE_FIELD.prefix("preferredemail");
+
+  public static final IndexedField<AccountState, String> PREFERRED_EMAIL_EXACT_FIELD =
+      IndexedField.<AccountState>stringBuilder("PreferredEmail")
+          .build(a -> a.account().preferredEmail());
+
+  public static final IndexedField<AccountState, String>.SearchSpec PREFERRED_EMAIL_EXACT_SPEC =
+      PREFERRED_EMAIL_EXACT_FIELD.exact("preferredemail_exact");
 
   // TODO(issue-15518): Migrate type for timestamp index fields from Timestamp to Instant
-  public static final FieldDef<AccountState, Timestamp> REGISTERED =
-      timestamp("registered").build(a -> Timestamp.from(a.account().registeredOn()));
+  public static final IndexedField<AccountState, Timestamp> REGISTERED_FIELD =
+      IndexedField.<AccountState>timestampBuilder("Registered")
+          .required()
+          .build(a -> Timestamp.from(a.account().registeredOn()));
 
-  public static final FieldDef<AccountState, String> USERNAME =
-      exact("username").build(a -> a.userName().map(String::toLowerCase).orElse(""));
+  public static final IndexedField<AccountState, Timestamp>.SearchSpec REGISTERED_SPEC =
+      REGISTERED_FIELD.timestamp("registered");
 
-  public static final FieldDef<AccountState, Iterable<String>> WATCHED_PROJECT =
-      exact("watchedproject")
-          .buildRepeatable(
+  public static final IndexedField<AccountState, String> USERNAME_FIELD =
+      IndexedField.<AccountState>stringBuilder("Username")
+          .build(a -> a.userName().map(String::toLowerCase).orElse(""));
+
+  public static final IndexedField<AccountState, String>.SearchSpec USERNAME_SPEC =
+      USERNAME_FIELD.exact("username");
+
+  public static final IndexedField<AccountState, Iterable<String>> WATCHED_PROJECT_FIELD =
+      IndexedField.<AccountState>iterableStringBuilder("WatchedProject")
+          .build(
               a ->
                   FluentIterable.from(a.projectWatches().keySet())
                       .transform(k -> k.project().get())
                       .toSet());
 
+  public static final IndexedField<AccountState, Iterable<String>>.SearchSpec WATCHED_PROJECT_SPEC =
+      WATCHED_PROJECT_FIELD.exact("watchedproject");
+
   /**
    * All values of all refs that were used in the course of indexing this document, except the
    * refs/meta/external-ids notes branch which is handled specially (see {@link
-   * #EXTERNAL_ID_STATE}).
+   * #EXTERNAL_ID_STATE_SPEC}).
    *
    * <p>Emitted as UTF-8 encoded strings of the form {@code project:ref/name:[hex sha]}.
    */
-  public static final FieldDef<AccountState, Iterable<byte[]>> REF_STATE =
-      storedOnly("ref_state")
-          .buildRepeatable(
+  public static final IndexedField<AccountState, Iterable<byte[]>> REF_STATE_FIELD =
+      IndexedField.<AccountState>iterableByteArrayBuilder("RefState")
+          .stored()
+          .required()
+          .build(
               a -> {
                 if (a.account().metaId() == null) {
                   return ImmutableList.of();
@@ -157,21 +220,29 @@
                         .toByteArray(new AllUsersName(AllUsersNameProvider.DEFAULT)));
               });
 
+  public static final IndexedField<AccountState, Iterable<byte[]>>.SearchSpec REF_STATE_SPEC =
+      REF_STATE_FIELD.storedOnly("ref_state");
+
   /**
    * All note values of all external IDs that were used in the course of indexing this document.
    *
    * <p>Emitted as UTF-8 encoded strings of the form {@code [hex sha of external ID]:[hex sha of
    * note blob]}, or with other words {@code [note ID]:[note data ID]}.
    */
-  public static final FieldDef<AccountState, Iterable<byte[]>> EXTERNAL_ID_STATE =
-      storedOnly("external_id_state")
-          .buildRepeatable(
+  public static final IndexedField<AccountState, Iterable<byte[]>> EXTERNAL_ID_STATE_FIELD =
+      IndexedField.<AccountState>iterableByteArrayBuilder("ExternalIdState")
+          .stored()
+          .required()
+          .build(
               a ->
                   a.externalIds().stream()
                       .filter(e -> e.blobId() != null)
                       .map(ExternalId::toByteArray)
                       .collect(toSet()));
 
+  public static final IndexedField<AccountState, Iterable<byte[]>>.SearchSpec
+      EXTERNAL_ID_STATE_SPEC = EXTERNAL_ID_STATE_FIELD.storedOnly("external_id_state");
+
   private static final Set<String> getNameParts(AccountState a, Iterable<String> emails) {
     String fullName = a.account().fullName();
     Set<String> parts = SchemaUtil.getNameParts(fullName, emails);
diff --git a/java/com/google/gerrit/server/index/account/AccountIndexRewriter.java b/java/com/google/gerrit/server/index/account/AccountIndexRewriter.java
index 8b95f7b..94dfbf1 100644
--- a/java/com/google/gerrit/server/index/account/AccountIndexRewriter.java
+++ b/java/com/google/gerrit/server/index/account/AccountIndexRewriter.java
@@ -58,19 +58,19 @@
   public void validateMaxTermsInQuery(Predicate<AccountState> predicate)
       throws QueryParseException {
     MutableInteger leafTerms = new MutableInteger();
-    validateMaxTermsInQuery(predicate, leafTerms);
+    countLeafTerms(predicate, leafTerms);
+    if (leafTerms.value > config.maxTerms()) {
+      throw new TooManyTermsInQueryException(leafTerms.value, config.maxTerms());
+    }
   }
 
-  private void validateMaxTermsInQuery(Predicate<AccountState> predicate, MutableInteger leafTerms)
-      throws TooManyTermsInQueryException {
-    if (!(predicate instanceof IndexPredicate)) {
-      if (++leafTerms.value > config.maxTerms()) {
-        throw new TooManyTermsInQueryException();
-      }
+  private void countLeafTerms(Predicate<AccountState> predicate, MutableInteger leafTerms) {
+    if (predicate instanceof IndexPredicate) {
+      ++leafTerms.value;
     }
 
     for (Predicate<AccountState> childPredicate : predicate.getChildren()) {
-      validateMaxTermsInQuery(childPredicate, leafTerms);
+      countLeafTerms(childPredicate, leafTerms);
     }
   }
 }
diff --git a/java/com/google/gerrit/server/index/account/AccountSchemaDefinitions.java b/java/com/google/gerrit/server/index/account/AccountSchemaDefinitions.java
index 5de3ba4..31fbf36 100644
--- a/java/com/google/gerrit/server/index/account/AccountSchemaDefinitions.java
+++ b/java/com/google/gerrit/server/index/account/AccountSchemaDefinitions.java
@@ -16,35 +16,55 @@
 
 import static com.google.gerrit.index.SchemaUtil.schema;
 
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.index.IndexedField;
 import com.google.gerrit.index.Schema;
 import com.google.gerrit.index.SchemaDefinitions;
 import com.google.gerrit.server.account.AccountState;
 
-/** Definition of account index versions (schemata). See {@link SchemaDefinitions}. */
+/**
+ * Definition of account index versions (schemata). See {@link SchemaDefinitions}.
+ *
+ * <p>Upgrades are subject to constraints, see {@code
+ * com.google.gerrit.index.IndexUpgradeValidator}.
+ */
 public class AccountSchemaDefinitions extends SchemaDefinitions<AccountState> {
+
   @Deprecated
-  static final Schema<AccountState> V4 =
+  static final Schema<AccountState> V8 =
       schema(
-          AccountField.ACTIVE,
-          AccountField.EMAIL,
-          AccountField.EXTERNAL_ID,
-          AccountField.FULL_NAME,
-          AccountField.ID,
-          AccountField.NAME_PART,
-          AccountField.REGISTERED,
-          AccountField.USERNAME,
-          AccountField.WATCHED_PROJECT);
-
-  @Deprecated static final Schema<AccountState> V5 = schema(V4, AccountField.PREFERRED_EMAIL);
-
-  @Deprecated
-  static final Schema<AccountState> V6 =
-      schema(V5, AccountField.REF_STATE, AccountField.EXTERNAL_ID_STATE);
-
-  @Deprecated static final Schema<AccountState> V7 = schema(V6, AccountField.PREFERRED_EMAIL_EXACT);
-
-  @Deprecated
-  static final Schema<AccountState> V8 = schema(V7, AccountField.NAME_PART_NO_SECONDARY_EMAIL);
+          /* version= */ 8,
+          ImmutableList.of(),
+          ImmutableList.of(
+              AccountField.ID_FIELD,
+              AccountField.ACTIVE_FIELD,
+              AccountField.EMAIL_FIELD,
+              AccountField.EXTERNAL_ID_FIELD,
+              AccountField.EXTERNAL_ID_STATE_FIELD,
+              AccountField.FULL_NAME_FIELD,
+              AccountField.NAME_PART_FIELD,
+              AccountField.NAME_PART_NO_SECONDARY_EMAIL_FIELD,
+              AccountField.PREFERRED_EMAIL_EXACT_FIELD,
+              AccountField.PREFERRED_EMAIL_LOWER_CASE_FIELD,
+              AccountField.REF_STATE_FIELD,
+              AccountField.REGISTERED_FIELD,
+              AccountField.USERNAME_FIELD,
+              AccountField.WATCHED_PROJECT_FIELD),
+          ImmutableList.<IndexedField<AccountState, ?>.SearchSpec>of(
+              AccountField.ID_FIELD_SPEC,
+              AccountField.ACTIVE_FIELD_SPEC,
+              AccountField.EMAIL_SPEC,
+              AccountField.EXTERNAL_ID_FIELD_SPEC,
+              AccountField.EXTERNAL_ID_STATE_SPEC,
+              AccountField.FULL_NAME_SPEC,
+              AccountField.NAME_PART_NO_SECONDARY_EMAIL_SPEC,
+              AccountField.NAME_PART_SPEC,
+              AccountField.PREFERRED_EMAIL_LOWER_CASE_SPEC,
+              AccountField.PREFERRED_EMAIL_EXACT_SPEC,
+              AccountField.REF_STATE_SPEC,
+              AccountField.REGISTERED_SPEC,
+              AccountField.USERNAME_SPEC,
+              AccountField.WATCHED_PROJECT_SPEC));
 
   // Bump Lucene version requires reindexing
   @Deprecated static final Schema<AccountState> V9 = schema(V8);
@@ -55,14 +75,19 @@
   // New numeric types: use dimensional points using the k-d tree geo-spatial data structure
   // to offer fast single- and multi-dimensional numeric range. As the consequense, integer
   // document id type is replaced with string document id type.
+  @Deprecated
   static final Schema<AccountState> V11 =
       new Schema.Builder<AccountState>()
           .add(V10)
-          .remove(AccountField.ID)
-          .add(AccountField.ID_STR)
-          .legacyNumericFields(false)
+          .remove(AccountField.ID_FIELD_SPEC)
+          .remove(AccountField.ID_FIELD)
+          .addIndexedFields(AccountField.ID_STR_FIELD)
+          .addSearchSpecs(AccountField.ID_STR_FIELD_SPEC)
           .build();
 
+  // Upgrade Lucene to 7.x requires reindexing.
+  static final Schema<AccountState> V12 = schema(V11);
+
   /**
    * Name of the account index to be used when contacting index backends or loading configurations.
    */
diff --git a/java/com/google/gerrit/server/index/account/StalenessChecker.java b/java/com/google/gerrit/server/index/account/StalenessChecker.java
index 50fdcde..699dfbe 100644
--- a/java/com/google/gerrit/server/index/account/StalenessChecker.java
+++ b/java/com/google/gerrit/server/index/account/StalenessChecker.java
@@ -58,15 +58,15 @@
 public class StalenessChecker {
   public static final ImmutableSet<String> FIELDS =
       ImmutableSet.of(
-          AccountField.ID.getName(),
-          AccountField.REF_STATE.getName(),
-          AccountField.EXTERNAL_ID_STATE.getName());
+          AccountField.ID_FIELD_SPEC.getName(),
+          AccountField.REF_STATE_SPEC.getName(),
+          AccountField.EXTERNAL_ID_STATE_SPEC.getName());
 
   public static final ImmutableSet<String> FIELDS2 =
       ImmutableSet.of(
-          AccountField.ID_STR.getName(),
-          AccountField.REF_STATE.getName(),
-          AccountField.EXTERNAL_ID_STATE.getName());
+          AccountField.ID_STR_FIELD_SPEC.getName(),
+          AccountField.REF_STATE_SPEC.getName(),
+          AccountField.EXTERNAL_ID_STATE_SPEC.getName());
 
   private final AccountIndexCollection indexes;
   private final GitRepositoryManager repoManager;
@@ -94,13 +94,13 @@
       // No index; caller couldn't do anything if it is stale.
       return StalenessCheckResult.notStale();
     }
-    if (!i.getSchema().hasField(AccountField.REF_STATE)
-        || !i.getSchema().hasField(AccountField.EXTERNAL_ID_STATE)) {
+    if (!i.getSchema().hasField(AccountField.REF_STATE_SPEC)
+        || !i.getSchema().hasField(AccountField.EXTERNAL_ID_STATE_SPEC)) {
       // Index version not new enough for this check.
       return StalenessCheckResult.notStale();
     }
 
-    boolean useLegacyNumericFields = i.getSchema().useLegacyNumericFields();
+    boolean useLegacyNumericFields = i.getSchema().hasField(AccountField.ID_FIELD_SPEC);
     ImmutableSet<String> fields = useLegacyNumericFields ? FIELDS : FIELDS2;
     Optional<FieldBundle> result =
         i.getRaw(
@@ -121,8 +121,9 @@
       }
     }
 
-    for (Map.Entry<Project.NameKey, RefState> e :
-        RefState.parseStates(result.get().getValue(AccountField.REF_STATE)).entries()) {
+    Iterable<byte[]> refStates =
+        result.get().<Iterable<byte[]>>getValue(AccountField.REF_STATE_SPEC);
+    for (Map.Entry<Project.NameKey, RefState> e : RefState.parseStates(refStates).entries()) {
       // Custom All-Users repository names are not indexed. Instead, the default name is used.
       // Therefore, defer to the currently configured All-Users name.
       Project.NameKey repoName =
@@ -137,8 +138,10 @@
     }
 
     Set<ExternalId> extIds = externalIds.byAccount(id);
+
     ListMultimap<ObjectId, ObjectId> extIdStates =
-        parseExternalIdStates(result.get().getValue(AccountField.EXTERNAL_ID_STATE));
+        parseExternalIdStates(
+            result.get().<Iterable<byte[]>>getValue(AccountField.EXTERNAL_ID_STATE_SPEC));
     if (extIdStates.size() != extIds.size()) {
       return StalenessCheckResult.stale(
           "External IDs of the account were modified since the account was indexed. (%s != %s)",
diff --git a/java/com/google/gerrit/server/index/change/ChangeField.java b/java/com/google/gerrit/server/index/change/ChangeField.java
index c302a36..d3f6268 100644
--- a/java/com/google/gerrit/server/index/change/ChangeField.java
+++ b/java/com/google/gerrit/server/index/change/ChangeField.java
@@ -63,6 +63,7 @@
 import com.google.gerrit.entities.converter.ProtoConverter;
 import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.RefState;
+import com.google.gerrit.index.SchemaFieldDefs;
 import com.google.gerrit.index.SchemaUtil;
 import com.google.gerrit.json.OutputFormat;
 import com.google.gerrit.proto.Protos;
@@ -107,6 +108,9 @@
  *
  * <p>Field names are all lowercase alphanumeric plus underscore; index implementations may create
  * unambiguous derived field names containing other characters.
+ *
+ * <p>Note that this class does not override {@link Object#equals(Object)}. It relies on instances
+ * being singletons so that the default (i.e. reference) comparison works.
  */
 public class ChangeField {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
@@ -123,11 +127,8 @@
 
   // TODO: Rename LEGACY_ID to NUMERIC_ID
   /** Legacy change ID. */
-  public static final FieldDef<ChangeData, Integer> LEGACY_ID =
-      integer("legacy_id").stored().build(cd -> cd.getId().get());
-
   public static final FieldDef<ChangeData, String> LEGACY_ID_STR =
-      exact("legacy_id_str").stored().build(cd -> String.valueOf(cd.getId().get()));
+      exact("legacy_id_str").stored().build(cd -> String.valueOf(cd.getVirtualId().get()));
 
   /** Newer style Change-Id key. */
   public static final FieldDef<ChangeData, String> ID =
@@ -1399,7 +1400,7 @@
     return converter.fromProto(message);
   }
 
-  private static <T> FieldDef.Getter<ChangeData, T> changeGetter(Function<Change, T> func) {
+  private static <T> SchemaFieldDefs.Getter<ChangeData, T> changeGetter(Function<Change, T> func) {
     return in -> in.change() != null ? func.apply(in.change()) : null;
   }
 
diff --git a/java/com/google/gerrit/server/index/change/ChangeIndex.java b/java/com/google/gerrit/server/index/change/ChangeIndex.java
index 05c5c77..6fc2665 100644
--- a/java/com/google/gerrit/server/index/change/ChangeIndex.java
+++ b/java/com/google/gerrit/server/index/change/ChangeIndex.java
@@ -30,8 +30,6 @@
 
   @Override
   default Predicate<ChangeData> keyPredicate(Change.Id id) {
-    return getSchema().useLegacyNumericFields()
-        ? ChangePredicates.id(id)
-        : ChangePredicates.idStr(id);
+    return ChangePredicates.idStr(id);
   }
 }
diff --git a/java/com/google/gerrit/server/index/change/ChangeIndexRewriter.java b/java/com/google/gerrit/server/index/change/ChangeIndexRewriter.java
index 8b75872..05fb780 100644
--- a/java/com/google/gerrit/server/index/change/ChangeIndexRewriter.java
+++ b/java/com/google/gerrit/server/index/change/ChangeIndexRewriter.java
@@ -19,13 +19,14 @@
 
 import com.google.common.collect.Lists;
 import com.google.common.collect.Sets;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Change.Status;
-import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.index.IndexRewriter;
 import com.google.gerrit.index.QueryOptions;
 import com.google.gerrit.index.Schema;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
 import com.google.gerrit.index.query.AndCardinalPredicate;
 import com.google.gerrit.index.query.AndPredicate;
 import com.google.gerrit.index.query.HasCardinality;
@@ -88,7 +89,7 @@
     return s != null ? s : EnumSet.allOf(Change.Status.class);
   }
 
-  private static EnumSet<Change.Status> extractStatus(Predicate<ChangeData> in) {
+  private static @Nullable EnumSet<Change.Status> extractStatus(Predicate<ChangeData> in) {
     if (in instanceof ChangeStatusPredicate) {
       Status status = ((ChangeStatusPredicate) in).getStatus();
       return status != null ? EnumSet.of(status) : null;
@@ -161,6 +162,9 @@
 
     MutableInteger leafTerms = new MutableInteger();
     Predicate<ChangeData> out = rewriteImpl(in, index, opts, leafTerms);
+    if (leafTerms.value > config.maxTerms()) {
+      throw new TooManyTermsInQueryException(leafTerms.value, config.maxTerms());
+    }
     if (isSameInstance(in, out) || out instanceof IndexPredicate) {
       return new IndexedChangeQuery(index, out, opts);
     } else if (out == null /* cannot rewrite */) {
@@ -189,9 +193,7 @@
       throws QueryParseException {
     in = IsSubmittablePredicate.rewrite(in);
     if (isIndexPredicate(in, index)) {
-      if (++leafTerms.value > config.maxTerms()) {
-        throw new TooManyTermsInQueryException();
-      }
+      ++leafTerms.value;
       return in;
     } else if (in instanceof LimitPredicate) {
       // Replace any limits with the limit provided by the caller. The caller
@@ -249,9 +251,9 @@
     }
     IndexPredicate<ChangeData> p = (IndexPredicate<ChangeData>) in;
 
-    FieldDef<ChangeData, ?> def = p.getField();
+    SchemaField<ChangeData, ?> field = p.getField();
     Schema<ChangeData> schema = index.getSchema();
-    return schema.hasField(def);
+    return schema.hasField(field);
   }
 
   private Predicate<ChangeData> partitionChildren(
diff --git a/java/com/google/gerrit/server/index/change/ChangeIndexer.java b/java/com/google/gerrit/server/index/change/ChangeIndexer.java
index 8f68904..8015189 100644
--- a/java/com/google/gerrit/server/index/change/ChangeIndexer.java
+++ b/java/com/google/gerrit/server/index/change/ChangeIndexer.java
@@ -43,10 +43,8 @@
 import com.google.inject.OutOfScopeException;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
-import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
-import java.util.List;
 import java.util.Map;
 import java.util.Set;
 import java.util.concurrent.Callable;
@@ -184,21 +182,6 @@
   }
 
   /**
-   * Start indexing multiple changes in parallel.
-   *
-   * @param ids changes to index.
-   * @return future for completing indexing of all changes.
-   */
-  public ListenableFuture<List<ChangeData>> indexAsync(
-      Project.NameKey project, Collection<Change.Id> ids) {
-    List<ListenableFuture<ChangeData>> futures = new ArrayList<>(ids.size());
-    for (Change.Id id : ids) {
-      futures.add(indexAsync(project, id));
-    }
-    return Futures.allAsList(futures);
-  }
-
-  /**
    * Synchronously index a change, then check if the index is stale due to a race condition.
    *
    * @param cd change to index.
@@ -373,6 +356,7 @@
 
     protected abstract T callImpl() throws Exception;
 
+    @SuppressWarnings("unused")
     protected abstract void remove();
 
     @Override
diff --git a/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java b/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
index 0a06735..6116f5a 100644
--- a/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
+++ b/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
@@ -20,21 +20,34 @@
 import com.google.gerrit.index.SchemaDefinitions;
 import com.google.gerrit.server.query.change.ChangeData;
 
-/** Definition of change index versions (schemata). See {@link SchemaDefinitions}. */
+/**
+ * Definition of change index versions (schemata). See {@link SchemaDefinitions}.
+ *
+ * <p>Upgrades are subject to constraints, see {@code
+ * com.google.gerrit.index.IndexUpgradeValidator}.
+ */
 public class ChangeSchemaDefinitions extends SchemaDefinitions<ChangeData> {
+  /** Added new field {@link ChangeField#IS_SUBMITTABLE} based on submit requirements. */
   @Deprecated
-  static final Schema<ChangeData> V55 =
+  static final Schema<ChangeData> V74 =
       schema(
+          /* version= */ 74,
           ChangeField.ADDED,
           ChangeField.APPROVAL,
           ChangeField.ASSIGNEE,
+          ChangeField.ATTENTION_SET_FULL,
+          ChangeField.ATTENTION_SET_USERS,
+          ChangeField.ATTENTION_SET_USERS_COUNT,
           ChangeField.AUTHOR,
           ChangeField.CHANGE,
+          ChangeField.CHERRY_PICK,
+          ChangeField.CHERRY_PICK_OF_CHANGE,
+          ChangeField.CHERRY_PICK_OF_PATCHSET,
           ChangeField.COMMENT,
           ChangeField.COMMENTBY,
           ChangeField.COMMIT,
-          ChangeField.COMMITTER,
           ChangeField.COMMIT_MESSAGE,
+          ChangeField.COMMITTER,
           ChangeField.DELETED,
           ChangeField.DELTA,
           ChangeField.DIRECTORY,
@@ -47,14 +60,19 @@
           ChangeField.EXTENSION,
           ChangeField.FILE_PART,
           ChangeField.FOOTER,
+          ChangeField.FUZZY_HASHTAG,
           ChangeField.FUZZY_TOPIC,
           ChangeField.GROUP,
           ChangeField.HASHTAG,
           ChangeField.HASHTAG_CASE_AWARE,
           ChangeField.ID,
+          ChangeField.IS_PURE_REVERT,
+          ChangeField.IS_SUBMITTABLE,
           ChangeField.LABEL,
-          ChangeField.LEGACY_ID,
+          ChangeField.LEGACY_ID_STR,
+          ChangeField.MERGE,
           ChangeField.MERGEABLE,
+          ChangeField.MERGED_ON,
           ChangeField.ONLY_EXTENSIONS,
           ChangeField.OWNER,
           ChangeField.PATCH_SET,
@@ -77,131 +95,18 @@
           ChangeField.STATUS,
           ChangeField.STORED_SUBMIT_RECORD_LENIENT,
           ChangeField.STORED_SUBMIT_RECORD_STRICT,
+          ChangeField.STORED_SUBMIT_REQUIREMENTS,
           ChangeField.SUBMISSIONID,
           ChangeField.SUBMIT_RECORD,
+          ChangeField.SUBMIT_RULE_RESULT,
           ChangeField.TOTAL_COMMENT_COUNT,
           ChangeField.TR,
           ChangeField.UNRESOLVED_COMMENT_COUNT,
           ChangeField.UPDATED,
+          ChangeField.UPLOADER,
           ChangeField.WIP);
 
   /**
-   * The computation of the {@link ChangeField#EXTENSION} field is changed, hence reindexing is
-   * required.
-   */
-  @Deprecated static final Schema<ChangeData> V56 = schema(V55);
-
-  /**
-   * New numeric types: use dimensional points using the k-d tree geo-spatial data structure to
-   * offer fast single- and multi-dimensional numeric range. As the consequense, {@link
-   * ChangeField#LEGACY_ID} is replaced with {@link ChangeField#LEGACY_ID_STR}.
-   */
-  @Deprecated
-  static final Schema<ChangeData> V57 =
-      new Schema.Builder<ChangeData>()
-          .add(V56)
-          .remove(ChangeField.LEGACY_ID)
-          .add(ChangeField.LEGACY_ID_STR)
-          .legacyNumericFields(false)
-          .build();
-
-  /**
-   * Added new fields {@link ChangeField#CHERRY_PICK_OF_CHANGE} and {@link
-   * ChangeField#CHERRY_PICK_OF_PATCHSET}.
-   */
-  @Deprecated
-  static final Schema<ChangeData> V58 =
-      new Schema.Builder<ChangeData>()
-          .add(V57)
-          .add(ChangeField.CHERRY_PICK_OF_CHANGE)
-          .add(ChangeField.CHERRY_PICK_OF_PATCHSET)
-          .build();
-
-  /**
-   * Added new fields {@link ChangeField#ATTENTION_SET_USERS} and {@link
-   * ChangeField#ATTENTION_SET_FULL}.
-   */
-  @Deprecated
-  static final Schema<ChangeData> V59 =
-      new Schema.Builder<ChangeData>()
-          .add(V58)
-          .add(ChangeField.ATTENTION_SET_USERS)
-          .add(ChangeField.ATTENTION_SET_FULL)
-          .build();
-
-  /** Added new fields {@link ChangeField#MERGE} */
-  @Deprecated
-  static final Schema<ChangeData> V60 =
-      new Schema.Builder<ChangeData>().add(V59).add(ChangeField.MERGE).build();
-
-  /** Added new field {@link ChangeField#MERGED_ON} */
-  @Deprecated
-  static final Schema<ChangeData> V61 =
-      new Schema.Builder<ChangeData>().add(V60).add(ChangeField.MERGED_ON).build();
-
-  /** Added new field {@link ChangeField#FUZZY_HASHTAG} */
-  @Deprecated
-  static final Schema<ChangeData> V62 =
-      new Schema.Builder<ChangeData>().add(V61).add(ChangeField.FUZZY_HASHTAG).build();
-
-  /**
-   * The computation of the {@link ChangeField#DIRECTORY} field is changed, hence reindexing is
-   * required.
-   */
-  @Deprecated static final Schema<ChangeData> V63 = schema(V62, false);
-
-  /** Added support for MIN/MAX/ANY for {@link ChangeField#LABEL} */
-  @Deprecated static final Schema<ChangeData> V64 = schema(V63, false);
-
-  /** Added new field for submit requirements. */
-  @Deprecated
-  static final Schema<ChangeData> V65 =
-      new Schema.Builder<ChangeData>().add(V64).add(ChangeField.STORED_SUBMIT_REQUIREMENTS).build();
-
-  /**
-   * The computation of {@link ChangeField#LABEL} has changed: We added the non_uploader arg to the
-   * label field.
-   */
-  @Deprecated static final Schema<ChangeData> V66 = schema(V65, false);
-
-  /** Updated submit records: store the rule name that created the submit record. */
-  @Deprecated static final Schema<ChangeData> V67 = schema(V66, false);
-
-  /** Added new field {@link ChangeField#SUBMIT_RULE_RESULT}. */
-  @Deprecated
-  static final Schema<ChangeData> V68 =
-      new Schema.Builder<ChangeData>().add(V67).add(ChangeField.SUBMIT_RULE_RESULT).build();
-
-  /** Added new field {@link ChangeField#CHERRY_PICK}. */
-  @Deprecated
-  static final Schema<ChangeData> V69 =
-      new Schema.Builder<ChangeData>().add(V68).add(ChangeField.CHERRY_PICK).build();
-
-  /** Added new field {@link ChangeField#ATTENTION_SET_USERS_COUNT}. */
-  @Deprecated
-  static final Schema<ChangeData> V70 =
-      new Schema.Builder<ChangeData>().add(V69).add(ChangeField.ATTENTION_SET_USERS_COUNT).build();
-
-  /** Added new field {@link ChangeField#UPLOADER}. */
-  @Deprecated
-  static final Schema<ChangeData> V71 =
-      new Schema.Builder<ChangeData>().add(V70).add(ChangeField.UPLOADER).build();
-
-  /** Added new field {@link ChangeField#IS_PURE_REVERT}. */
-  @Deprecated
-  static final Schema<ChangeData> V72 =
-      new Schema.Builder<ChangeData>().add(V71).add(ChangeField.IS_PURE_REVERT).build();
-
-  @Deprecated
-  /** Added new "count=$count" argument to the {@link ChangeField#LABEL} operator. */
-  static final Schema<ChangeData> V73 = schema(V72, false);
-
-  @Deprecated
-  /** Added new field {@link ChangeField#IS_SUBMITTABLE} based on submit requirements. */
-  static final Schema<ChangeData> V74 =
-      new Schema.Builder<ChangeData>().add(V73).add(ChangeField.IS_SUBMITTABLE).build();
-
-  /**
    * Added new field {@link ChangeField#PREFIX_HASHTAG} and {@link ChangeField#PREFIX_TOPIC} to
    * allow easier search for topics.
    */
@@ -219,9 +124,19 @@
       new Schema.Builder<ChangeData>().add(V75).add(ChangeField.FOOTER_NAME).build();
 
   /** Added new field {@link ChangeField#COMMIT_MESSAGE_EXACT}. */
+  @Deprecated
   static final Schema<ChangeData> V77 =
       new Schema.Builder<ChangeData>().add(V76).add(ChangeField.COMMIT_MESSAGE_EXACT).build();
 
+  // Upgrade Lucene to 7.x requires reindexing.
+  @Deprecated static final Schema<ChangeData> V78 = schema(V77);
+
+  /** Remove draft and star fields. */
+  static final Schema<ChangeData> V79 =
+      new Schema.Builder<ChangeData>()
+          .add(V78)
+          .remove(ChangeField.DRAFTBY, ChangeField.STAR, ChangeField.STARBY)
+          .build();
   /**
    * Name of the change index to be used when contacting index backends or loading configurations.
    */
diff --git a/java/com/google/gerrit/server/index/change/ReindexAfterRefUpdate.java b/java/com/google/gerrit/server/index/change/ReindexAfterRefUpdate.java
index 458f4a4..b52b2d1 100644
--- a/java/com/google/gerrit/server/index/change/ReindexAfterRefUpdate.java
+++ b/java/com/google/gerrit/server/index/change/ReindexAfterRefUpdate.java
@@ -90,11 +90,13 @@
     if (allUsersName.get().equals(event.getProjectName())) {
       for (UpdatedRef ref : event.getUpdatedRefs()) {
         if (!RefNames.REFS_CONFIG.equals(ref.getRefName())) {
-          Account.Id accountId = Account.Id.fromRef(ref.getRefName());
-          if (accountId != null && !ref.getRefName().startsWith(RefNames.REFS_STARRED_CHANGES)) {
-            indexer.get().index(accountId);
+          if (ref.getRefName().startsWith(RefNames.REFS_STARRED_CHANGES)) {
             break;
           }
+          Account.Id accountId = Account.Id.fromRef(ref.getRefName());
+          if (accountId != null) {
+            indexer.get().index(accountId);
+          }
         }
       }
       // The update is in All-Users and not on refs/meta/config. So it's not a change. Return early.
@@ -148,6 +150,7 @@
 
     protected abstract V impl(RequestContext ctx) throws Exception;
 
+    @SuppressWarnings("unused")
     protected abstract void remove();
   }
 
diff --git a/java/com/google/gerrit/server/index/group/GroupField.java b/java/com/google/gerrit/server/index/group/GroupField.java
index af74514..7a26f31 100644
--- a/java/com/google/gerrit/server/index/group/GroupField.java
+++ b/java/com/google/gerrit/server/index/group/GroupField.java
@@ -15,76 +15,125 @@
 package com.google.gerrit.server.index.group;
 
 import static com.google.common.collect.ImmutableList.toImmutableList;
-import static com.google.gerrit.index.FieldDef.exact;
-import static com.google.gerrit.index.FieldDef.fullText;
-import static com.google.gerrit.index.FieldDef.integer;
-import static com.google.gerrit.index.FieldDef.prefix;
-import static com.google.gerrit.index.FieldDef.storedOnly;
-import static com.google.gerrit.index.FieldDef.timestamp;
 
 import com.google.common.base.MoreObjects;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.git.ObjectIds;
-import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.IndexedField;
 import com.google.gerrit.index.SchemaUtil;
 import java.sql.Timestamp;
 import org.eclipse.jgit.lib.ObjectId;
 
-/** Secondary index schemas for groups. */
+/**
+ * Secondary index schemas for groups.
+ *
+ * <p>Note that this class does not override {@link Object#equals(Object)}. It relies on instances
+ * being singletons so that the default (i.e. reference) comparison works.
+ */
 public class GroupField {
   /** Legacy group ID. */
-  public static final FieldDef<InternalGroup, Integer> ID =
-      integer("id").build(g -> g.getId().get());
+  public static final IndexedField<InternalGroup, Integer> ID_FIELD =
+      IndexedField.<InternalGroup>integerBuilder("Id").required().build(g -> g.getId().get());
+
+  public static final IndexedField<InternalGroup, Integer>.SearchSpec ID_FIELD_SPEC =
+      ID_FIELD.integer("id");
 
   /** Group UUID. */
-  public static final FieldDef<InternalGroup, String> UUID =
-      exact("uuid").stored().build(g -> g.getGroupUUID().get());
+  public static final IndexedField<InternalGroup, String> UUID_FIELD =
+      IndexedField.<InternalGroup>stringBuilder("UUID")
+          .required()
+          .stored()
+          .build(g -> g.getGroupUUID().get());
+
+  public static final IndexedField<InternalGroup, String>.SearchSpec UUID_FIELD_SPEC =
+      UUID_FIELD.exact("uuid");
 
   /** Group owner UUID. */
-  public static final FieldDef<InternalGroup, String> OWNER_UUID =
-      exact("owner_uuid").build(g -> g.getOwnerGroupUUID().get());
+  public static final IndexedField<InternalGroup, String> OWNER_UUID_FIELD =
+      IndexedField.<InternalGroup>stringBuilder("OwnerUUID")
+          .required()
+          .build(g -> g.getOwnerGroupUUID().get());
+
+  public static final IndexedField<InternalGroup, String>.SearchSpec OWNER_UUID_SPEC =
+      OWNER_UUID_FIELD.exact("owner_uuid");
 
   /** Timestamp indicating when this group was created. */
   // TODO(issue-15518): Migrate type for timestamp index fields from Timestamp to Instant
-  public static final FieldDef<InternalGroup, Timestamp> CREATED_ON =
-      timestamp("created_on").build(internalGroup -> Timestamp.from(internalGroup.getCreatedOn()));
+  public static final IndexedField<InternalGroup, Timestamp> CREATED_ON_FIELD =
+      IndexedField.<InternalGroup>timestampBuilder("CreatedOn")
+          .required()
+          .build(internalGroup -> Timestamp.from(internalGroup.getCreatedOn()));
+
+  public static final IndexedField<InternalGroup, Timestamp>.SearchSpec CREATED_ON_SPEC =
+      CREATED_ON_FIELD.timestamp("created_on");
 
   /** Group name. */
-  public static final FieldDef<InternalGroup, String> NAME =
-      exact("name").build(InternalGroup::getName);
+  public static final IndexedField<InternalGroup, String> NAME_FIELD =
+      IndexedField.<InternalGroup>stringBuilder("Name")
+          .required()
+          .size(200)
+          .build(InternalGroup::getName);
+
+  public static final IndexedField<InternalGroup, String>.SearchSpec NAME_SPEC =
+      NAME_FIELD.exact("name");
 
   /** Prefix match on group name parts. */
-  public static final FieldDef<InternalGroup, Iterable<String>> NAME_PART =
-      prefix("name_part").buildRepeatable(g -> SchemaUtil.getNameParts(g.getName()));
+  public static final IndexedField<InternalGroup, Iterable<String>> NAME_PART_FIELD =
+      IndexedField.<InternalGroup>iterableStringBuilder("NamePart")
+          .required()
+          .size(200)
+          .build(g -> SchemaUtil.getNameParts(g.getName()));
+
+  public static final IndexedField<InternalGroup, Iterable<String>>.SearchSpec NAME_PART_SPEC =
+      NAME_PART_FIELD.prefix("name_part");
 
   /** Group description. */
-  public static final FieldDef<InternalGroup, String> DESCRIPTION =
-      fullText("description").build(InternalGroup::getDescription);
+  public static final IndexedField<InternalGroup, String> DESCRIPTION_FIELD =
+      IndexedField.<InternalGroup>stringBuilder("Description").build(InternalGroup::getDescription);
+
+  public static final IndexedField<InternalGroup, String>.SearchSpec DESCRIPTION_SPEC =
+      DESCRIPTION_FIELD.fullText("description");
 
   /** Whether the group is visible to all users. */
-  public static final FieldDef<InternalGroup, String> IS_VISIBLE_TO_ALL =
-      exact("is_visible_to_all").build(g -> g.isVisibleToAll() ? "1" : "0");
+  public static final IndexedField<InternalGroup, String> IS_VISIBLE_TO_ALL_FIELD =
+      IndexedField.<InternalGroup>stringBuilder("IsVisibleToAll")
+          .required()
+          .size(1)
+          .build(g -> g.isVisibleToAll() ? "1" : "0");
 
-  public static final FieldDef<InternalGroup, Iterable<Integer>> MEMBER =
-      integer("member")
-          .buildRepeatable(
-              g -> g.getMembers().stream().map(Account.Id::get).collect(toImmutableList()));
+  public static final IndexedField<InternalGroup, String>.SearchSpec IS_VISIBLE_TO_ALL_SPEC =
+      IS_VISIBLE_TO_ALL_FIELD.exact("is_visible_to_all");
 
-  public static final FieldDef<InternalGroup, Iterable<String>> SUBGROUP =
-      exact("subgroup")
-          .buildRepeatable(
+  public static final IndexedField<InternalGroup, Iterable<Integer>> MEMBER_FIELD =
+      IndexedField.<InternalGroup>iterableIntegerBuilder("Member")
+          .build(g -> g.getMembers().stream().map(Account.Id::get).collect(toImmutableList()));
+
+  public static final IndexedField<InternalGroup, Iterable<Integer>>.SearchSpec MEMBER_SPEC =
+      MEMBER_FIELD.integer("member");
+
+  public static final IndexedField<InternalGroup, Iterable<String>> SUBGROUP_FIELD =
+      IndexedField.<InternalGroup>iterableStringBuilder("Subgroup")
+          .build(
               g ->
                   g.getSubgroups().stream().map(AccountGroup.UUID::get).collect(toImmutableList()));
 
+  public static final IndexedField<InternalGroup, Iterable<String>>.SearchSpec SUBGROUP_SPEC =
+      SUBGROUP_FIELD.exact("subgroup");
+
   /** ObjectId of HEAD:refs/groups/<UUID>. */
-  public static final FieldDef<InternalGroup, byte[]> REF_STATE =
-      storedOnly("ref_state")
+  public static final IndexedField<InternalGroup, byte[]> REF_STATE_FIELD =
+      IndexedField.<InternalGroup>byteArrayBuilder("RefState")
+          .stored()
+          .required()
           .build(
               g -> {
                 byte[] a = new byte[ObjectIds.STR_LEN];
                 MoreObjects.firstNonNull(g.getRefState(), ObjectId.zeroId()).copyTo(a, 0);
                 return a;
               });
+
+  public static final IndexedField<InternalGroup, byte[]>.SearchSpec REF_STATE_SPEC =
+      REF_STATE_FIELD.storedOnly("ref_state");
 }
diff --git a/java/com/google/gerrit/server/index/group/GroupSchemaDefinitions.java b/java/com/google/gerrit/server/index/group/GroupSchemaDefinitions.java
index c4d8952..26f9e96 100644
--- a/java/com/google/gerrit/server/index/group/GroupSchemaDefinitions.java
+++ b/java/com/google/gerrit/server/index/group/GroupSchemaDefinitions.java
@@ -16,29 +16,48 @@
 
 import static com.google.gerrit.index.SchemaUtil.schema;
 
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.entities.InternalGroup;
+import com.google.gerrit.index.IndexedField;
 import com.google.gerrit.index.Schema;
 import com.google.gerrit.index.SchemaDefinitions;
 
-/** Definition of group index versions (schemata). See {@link SchemaDefinitions}. */
+/**
+ * Definition of group index versions (schemata). See {@link SchemaDefinitions}.
+ *
+ * <p>Upgrades are subject to constraints, see {@code
+ * com.google.gerrit.index.IndexUpgradeValidator}.
+ */
 public class GroupSchemaDefinitions extends SchemaDefinitions<InternalGroup> {
   @Deprecated
-  static final Schema<InternalGroup> V2 =
+  static final Schema<InternalGroup> V5 =
       schema(
-          GroupField.DESCRIPTION,
-          GroupField.ID,
-          GroupField.IS_VISIBLE_TO_ALL,
-          GroupField.NAME,
-          GroupField.NAME_PART,
-          GroupField.OWNER_UUID,
-          GroupField.UUID);
-
-  @Deprecated static final Schema<InternalGroup> V3 = schema(V2, GroupField.CREATED_ON);
-
-  @Deprecated
-  static final Schema<InternalGroup> V4 = schema(V3, GroupField.MEMBER, GroupField.SUBGROUP);
-
-  @Deprecated static final Schema<InternalGroup> V5 = schema(V4, GroupField.REF_STATE);
+          /* version= */ 5,
+          ImmutableList.of(),
+          ImmutableList.of(
+              GroupField.CREATED_ON_FIELD,
+              GroupField.DESCRIPTION_FIELD,
+              GroupField.ID_FIELD,
+              GroupField.IS_VISIBLE_TO_ALL_FIELD,
+              GroupField.MEMBER_FIELD,
+              GroupField.NAME_FIELD,
+              GroupField.NAME_PART_FIELD,
+              GroupField.OWNER_UUID_FIELD,
+              GroupField.REF_STATE_FIELD,
+              GroupField.SUBGROUP_FIELD,
+              GroupField.UUID_FIELD),
+          ImmutableList.<IndexedField<InternalGroup, ?>.SearchSpec>of(
+              GroupField.CREATED_ON_SPEC,
+              GroupField.DESCRIPTION_SPEC,
+              GroupField.ID_FIELD_SPEC,
+              GroupField.IS_VISIBLE_TO_ALL_SPEC,
+              GroupField.MEMBER_SPEC,
+              GroupField.NAME_SPEC,
+              GroupField.NAME_PART_SPEC,
+              GroupField.OWNER_UUID_SPEC,
+              GroupField.REF_STATE_SPEC,
+              GroupField.SUBGROUP_SPEC,
+              GroupField.UUID_FIELD_SPEC));
 
   // Bump Lucene version requires reindexing
   @Deprecated static final Schema<InternalGroup> V6 = schema(V5);
@@ -48,7 +67,10 @@
 
   // New numeric types: use dimensional points using the k-d tree geo-spatial data structure
   // to offer fast single- and multi-dimensional numeric range.
-  static final Schema<InternalGroup> V8 = schema(V7, false);
+  @Deprecated static final Schema<InternalGroup> V8 = schema(V7);
+
+  // Upgrade Lucene to 7.x requires reindexing.
+  static final Schema<InternalGroup> V9 = schema(V8);
 
   /** Singleton instance of the schema definitions. This is one per JVM. */
   public static final GroupSchemaDefinitions INSTANCE = new GroupSchemaDefinitions();
diff --git a/java/com/google/gerrit/server/index/group/IndexedGroupQuery.java b/java/com/google/gerrit/server/index/group/IndexedGroupQuery.java
index 1602e4d2..1d8cc1e 100644
--- a/java/com/google/gerrit/server/index/group/IndexedGroupQuery.java
+++ b/java/com/google/gerrit/server/index/group/IndexedGroupQuery.java
@@ -39,9 +39,9 @@
   public static QueryOptions createOptions(
       IndexConfig config, int start, int pageSize, int limit, Set<String> fields) {
     // Always include GroupField.UUID since it is needed to load the group from NoteDb.
-    if (!fields.contains(GroupField.UUID.getName())) {
+    if (!fields.contains(GroupField.UUID_FIELD_SPEC.getName())) {
       fields = new HashSet<>(fields);
-      fields.add(GroupField.UUID.getName());
+      fields.add(GroupField.UUID_FIELD_SPEC.getName());
     }
     return QueryOptions.create(config, start, pageSize, limit, fields);
   }
diff --git a/java/com/google/gerrit/server/index/group/StalenessChecker.java b/java/com/google/gerrit/server/index/group/StalenessChecker.java
index 4e992cb..72370bb 100644
--- a/java/com/google/gerrit/server/index/group/StalenessChecker.java
+++ b/java/com/google/gerrit/server/index/group/StalenessChecker.java
@@ -39,7 +39,7 @@
 @Singleton
 public class StalenessChecker {
   public static final ImmutableSet<String> FIELDS =
-      ImmutableSet.of(GroupField.UUID.getName(), GroupField.REF_STATE.getName());
+      ImmutableSet.of(GroupField.UUID_FIELD_SPEC.getName(), GroupField.REF_STATE_SPEC.getName());
 
   private final GroupIndexCollection indexes;
   private final GitRepositoryManager repoManager;
@@ -84,7 +84,8 @@
     try (Repository repo = repoManager.openRepository(allUsers)) {
       Ref ref = repo.exactRef(RefNames.refsGroups(uuid));
       ObjectId head = ref == null ? ObjectId.zeroId() : ref.getObjectId();
-      ObjectId idFromIndex = ObjectId.fromString(result.get().getValue(GroupField.REF_STATE), 0);
+      ObjectId idFromIndex =
+          ObjectId.fromString(result.get().getValue(GroupField.REF_STATE_SPEC), 0);
       if (head.equals(idFromIndex)) {
         return StalenessCheckResult.notStale();
       }
diff --git a/java/com/google/gerrit/server/logging/Metadata.java b/java/com/google/gerrit/server/logging/Metadata.java
index 5cd0e98..b433e9f 100644
--- a/java/com/google/gerrit/server/logging/Metadata.java
+++ b/java/com/google/gerrit/server/logging/Metadata.java
@@ -102,6 +102,9 @@
   /** The name of a group. */
   public abstract Optional<String> groupName();
 
+  /** The group system being queried. */
+  public abstract Optional<String> groupSystem();
+
   /** The UUID of a group. */
   public abstract Optional<String> groupUuid();
 
@@ -328,6 +331,8 @@
 
     public abstract Builder groupName(@Nullable String groupName);
 
+    public abstract Builder groupSystem(@Nullable String groupSystem);
+
     public abstract Builder groupUuid(@Nullable String groupUuid);
 
     public abstract Builder httpStatus(int httpStatus);
diff --git a/java/com/google/gerrit/server/mail/receive/MailProcessor.java b/java/com/google/gerrit/server/mail/receive/MailProcessor.java
index 0fc89ba..e362c4b 100644
--- a/java/com/google/gerrit/server/mail/receive/MailProcessor.java
+++ b/java/com/google/gerrit/server/mail/receive/MailProcessor.java
@@ -366,16 +366,13 @@
       // Send email notifications
       outgoingMailFactory
           .create(
-              ctx.getNotify(notes.getChangeId()),
-              notes,
+              ctx,
               patchSet,
-              ctx.getUser().asIdentifiedUser(),
+              notes.getMetaId(),
               mailMessage,
-              ctx.getWhen(),
               comments,
               patchSetComment,
-              ImmutableList.of(),
-              ctx.getRepoView())
+              ImmutableList.of())
           .sendAsync();
       // Get previous approvals from this user
       Map<String, Short> approvals = new HashMap<>();
diff --git a/java/com/google/gerrit/server/mail/send/AbandonedSender.java b/java/com/google/gerrit/server/mail/send/AbandonedSender.java
index 3ac610d..d8b20ba 100644
--- a/java/com/google/gerrit/server/mail/send/AbandonedSender.java
+++ b/java/com/google/gerrit/server/mail/send/AbandonedSender.java
@@ -41,7 +41,6 @@
     ccAllApprovals();
     bccStarredBy();
     includeWatchers(NotifyType.ABANDONED_CHANGES);
-    removeUsersThatIgnoredTheChange();
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/mail/send/AddToAttentionSetSender.java b/java/com/google/gerrit/server/mail/send/AddToAttentionSetSender.java
index b13bcf6..f9ef199 100644
--- a/java/com/google/gerrit/server/mail/send/AddToAttentionSetSender.java
+++ b/java/com/google/gerrit/server/mail/send/AddToAttentionSetSender.java
@@ -30,7 +30,7 @@
   @Inject
   public AddToAttentionSetSender(
       EmailArguments args, @Assisted Project.NameKey project, @Assisted Change.Id changeId) {
-    super(args, project, changeId);
+    super(args, "addToAttentionSet", project, changeId);
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/mail/send/AttentionSetSender.java b/java/com/google/gerrit/server/mail/send/AttentionSetSender.java
index 8f898a8..d1ee4ee 100644
--- a/java/com/google/gerrit/server/mail/send/AttentionSetSender.java
+++ b/java/com/google/gerrit/server/mail/send/AttentionSetSender.java
@@ -11,6 +11,7 @@
 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 // See the License for the specific language governing permissions and
 // limitations under the License.
+
 package com.google.gerrit.server.mail.send;
 
 import com.google.gerrit.entities.Account;
@@ -23,8 +24,9 @@
   private Account.Id attentionSetUser;
   private String reason;
 
-  public AttentionSetSender(EmailArguments args, Project.NameKey project, Change.Id changeId) {
-    super(args, "addToAttentionSet", ChangeEmail.newChangeData(args, project, changeId));
+  public AttentionSetSender(
+      EmailArguments args, String messageClass, Project.NameKey project, Change.Id changeId) {
+    super(args, messageClass, ChangeEmail.newChangeData(args, project, changeId));
   }
 
   @Override
@@ -34,7 +36,6 @@
     ccAllApprovals();
     bccStarredBy();
     ccExistingReviewers();
-    removeUsersThatIgnoredTheChange();
   }
 
   public void setAttentionSetUser(Account.Id attentionSetUser) {
diff --git a/java/com/google/gerrit/server/mail/send/ChangeEmail.java b/java/com/google/gerrit/server/mail/send/ChangeEmail.java
index 288ccf8..8be5548 100644
--- a/java/com/google/gerrit/server/mail/send/ChangeEmail.java
+++ b/java/com/google/gerrit/server/mail/send/ChangeEmail.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.mail.send;
 
-import static com.google.common.collect.ImmutableSet.toImmutableSet;
+import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.gerrit.server.util.AttentionSetUtil.additionsOnly;
 
 import com.google.common.base.Splitter;
@@ -54,6 +54,8 @@
 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;
@@ -65,6 +67,7 @@
 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;
@@ -83,6 +86,11 @@
     return ea.changeDataFactory.create(project, id);
   }
 
+  protected static ChangeData newChangeData(
+      EmailArguments ea, Project.NameKey project, Change.Id id, ObjectId metaId) {
+    return ea.changeDataFactory.create(ea.changeNotesFactory.createChecked(project, id, metaId));
+  }
+
   private final Set<Account.Id> currentAttentionSet;
   protected final Change change;
   protected final ChangeData changeData;
@@ -245,13 +253,22 @@
         .reduce(0, Integer::sum);
   }
 
-  /** Get a link to the change; null if the server doesn't know its own address. */
+  /**
+   * Get a link to the change; null if the server doesn't know its own address or if the address is
+   * malformed. The link will contain a usp parameter set to "email" to inform the frontend on
+   * clickthroughs where the link came from.
+   */
   @Nullable
   public String getChangeUrl() {
-    return args.urlFormatter
-        .get()
-        .getChangeViewUrl(change.getProject(), change.getId())
-        .orElse(null);
+    Optional<String> changeUrl =
+        args.urlFormatter.get().getChangeViewUrl(change.getProject(), change.getId());
+    if (!changeUrl.isPresent()) return null;
+    try {
+      URI uri = new URIBuilder(changeUrl.get()).addParameter("usp", "email").build();
+      return uri.toString();
+    } catch (URISyntaxException e) {
+      return null;
+    }
   }
 
   public String getChangeMessageThreadId() {
@@ -375,14 +392,6 @@
     }
   }
 
-  protected void removeUsersThatIgnoredTheChange() {
-    for (Map.Entry<Account.Id, Collection<String>> e : stars.asMap().entrySet()) {
-      if (e.getValue().contains(StarredChangesUtil.IGNORE_LABEL)) {
-        args.accountCache.get(e.getKey()).ifPresent(a -> removeUser(a.account()));
-      }
-    }
-  }
-
   @Override
   protected final Watchers getWatchers(NotifyType type, boolean includeWatchersFromNotifyConfig) {
     if (!NotifyHandling.ALL.equals(notify.handling())) {
@@ -458,12 +467,7 @@
     if (!projectState.statePermitsRead()) {
       return false;
     }
-    try {
-      args.permissionBackend.absentUser(to).change(changeData).check(ChangePermission.READ);
-      return true;
-    } catch (AuthException e) {
-      return false;
-    }
+    return args.permissionBackend.absentUser(to).change(changeData).test(ChangePermission.READ);
   }
 
   /** Find all users who are authors of any part of this change. */
@@ -559,7 +563,7 @@
       // We need names rather than account ids / emails to make it user readable.
       soyContext.put(
           "attentionSet",
-          currentAttentionSet.stream().map(this::getNameFor).collect(toImmutableSet()));
+          currentAttentionSet.stream().map(this::getNameFor).sorted().collect(toImmutableList()));
     }
   }
 
diff --git a/java/com/google/gerrit/server/mail/send/CommentSender.java b/java/com/google/gerrit/server/mail/send/CommentSender.java
index ce5438b..3c821cc 100644
--- a/java/com/google/gerrit/server/mail/send/CommentSender.java
+++ b/java/com/google/gerrit/server/mail/send/CommentSender.java
@@ -14,11 +14,16 @@
 
 package com.google.gerrit.server.mail.send;
 
+import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.gerrit.entities.Patch.PATCHSET_LEVEL;
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.base.Strings;
+import com.google.common.base.Supplier;
+import com.google.common.base.Suppliers;
+import com.google.common.collect.ImmutableList;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.FilenameComparator;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
@@ -28,6 +33,8 @@
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RobotComment;
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementResult;
 import com.google.gerrit.exceptions.EmailException;
 import com.google.gerrit.exceptions.NoSuchEntityException;
 import com.google.gerrit.exceptions.StorageException;
@@ -55,6 +62,7 @@
 import java.util.Optional;
 import org.apache.james.mime4j.dom.field.FieldName;
 import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
 
 /** Send comments, after the author of them hit used Publish Comments in the UI. */
@@ -64,7 +72,11 @@
 
   public interface Factory {
 
-    CommentSender create(Project.NameKey project, Change.Id changeId);
+    CommentSender create(
+        Project.NameKey project,
+        Change.Id changeId,
+        ObjectId preUpdateMetaId,
+        Map<SubmitRequirement, SubmitRequirementResult> postUpdateSubmitRequirementResults);
   }
 
   private class FileCommentGroup {
@@ -106,11 +118,14 @@
   }
 
   private List<? extends Comment> inlineComments = Collections.emptyList();
-  private String patchSetComment;
-  private List<LabelVote> labels = Collections.emptyList();
+  @Nullable private String patchSetComment;
+  private ImmutableList<LabelVote> labels = ImmutableList.of();
   private final CommentsUtil commentsUtil;
   private final boolean incomingEmailEnabled;
   private final String replyToAddress;
+  private final Supplier<Map<SubmitRequirement, SubmitRequirementResult>>
+      preUpdateSubmitRequirementResultsSupplier;
+  private final Map<SubmitRequirement, SubmitRequirementResult> postUpdateSubmitRequirementResults;
 
   @Inject
   public CommentSender(
@@ -118,24 +133,35 @@
       CommentsUtil commentsUtil,
       @GerritServerConfig Config cfg,
       @Assisted Project.NameKey project,
-      @Assisted Change.Id changeId) {
+      @Assisted Change.Id changeId,
+      @Assisted ObjectId preUpdateMetaId,
+      @Assisted
+          Map<SubmitRequirement, SubmitRequirementResult> postUpdateSubmitRequirementResults) {
     super(args, "comment", newChangeData(args, project, changeId));
     this.commentsUtil = commentsUtil;
     this.incomingEmailEnabled =
         cfg.getEnum("receiveemail", null, "protocol", Protocol.NONE).ordinal()
             > Protocol.NONE.ordinal();
     this.replyToAddress = cfg.getString("sendemail", null, "replyToAddress");
+    this.preUpdateSubmitRequirementResultsSupplier =
+        Suppliers.memoize(
+            () ->
+                // Triggers an (expensive) evaluation of the submit requirements. This is OK since
+                // all callers sent this email asynchronously, see EmailReviewComments.
+                newChangeData(args, project, changeId, preUpdateMetaId)
+                    .submitRequirementsIncludingLegacy());
+    this.postUpdateSubmitRequirementResults = postUpdateSubmitRequirementResults;
   }
 
   public void setComments(List<? extends Comment> comments) {
     inlineComments = comments;
   }
 
-  public void setPatchSetComment(String comment) {
+  public void setPatchSetComment(@Nullable String comment) {
     this.patchSetComment = comment;
   }
 
-  public void setLabels(List<LabelVote> labels) {
+  public void setLabels(ImmutableList<LabelVote> labels) {
     this.labels = labels;
   }
 
@@ -150,7 +176,6 @@
       bccStarredBy();
       includeWatchers(NotifyType.ALL_COMMENTS, !change.isWorkInProgress() && !change.isPrivate());
     }
-    removeUsersThatIgnoredTheChange();
 
     // Add header that enables identifying comments on parsed email.
     // Grouping is currently done by timestamp.
@@ -506,6 +531,15 @@
     soyContext.put(
         "coverLetterBlocks", commentBlocksToSoyData(CommentFormatter.parse(getCoverLetter())));
 
+    if (isChangeNoLongerSubmittable()) {
+      soyContext.put("unsatisfiedSubmitRequirements", formatUnsatisfiedSubmitRequirements());
+      soyContext.put(
+          "oldSubmitRequirements",
+          formatSubmitRequirments(preUpdateSubmitRequirementResultsSupplier.get()));
+      soyContext.put(
+          "newSubmitRequirements", formatSubmitRequirments(postUpdateSubmitRequirementResults));
+    }
+
     footers.add(MailHeader.COMMENT_DATE.withDelimiter() + getCommentTimestamp());
     footers.add(MailHeader.HAS_COMMENTS.withDelimiter() + (hasComments ? "Yes" : "No"));
     footers.add(MailHeader.HAS_LABELS.withDelimiter() + (labels.isEmpty() ? "No" : "Yes"));
@@ -515,6 +549,59 @@
     }
   }
 
+  /**
+   * Checks whether the change is no longer submittable.
+   *
+   * @return {@code true} if the change has been submittable before the update and is no longer
+   *     submittable after the update has been applied, otherwise {@code false}
+   */
+  private boolean isChangeNoLongerSubmittable() {
+    boolean isSubmittablePreUpdate =
+        preUpdateSubmitRequirementResultsSupplier.get().values().stream()
+            .allMatch(SubmitRequirementResult::fulfilled);
+    logger.atFine().log(
+        "the submitability of change %s before the update is %s",
+        change.getId(), isSubmittablePreUpdate);
+    if (!isSubmittablePreUpdate) {
+      return false;
+    }
+
+    boolean isSubmittablePostUpdate =
+        postUpdateSubmitRequirementResults.values().stream()
+            .allMatch(SubmitRequirementResult::fulfilled);
+    logger.atFine().log(
+        "the submitability of change %s after the update is %s",
+        change.getId(), isSubmittablePostUpdate);
+    return !isSubmittablePostUpdate;
+  }
+
+  private ImmutableList<String> formatUnsatisfiedSubmitRequirements() {
+    return postUpdateSubmitRequirementResults.entrySet().stream()
+        .filter(e -> SubmitRequirementResult.Status.UNSATISFIED.equals(e.getValue().status()))
+        .map(Map.Entry::getKey)
+        .map(SubmitRequirement::name)
+        .sorted()
+        .collect(toImmutableList());
+  }
+
+  private static ImmutableList<String> formatSubmitRequirments(
+      Map<SubmitRequirement, SubmitRequirementResult> submitRequirementResults) {
+    return submitRequirementResults.entrySet().stream()
+        .map(
+            e -> {
+              if (e.getValue().errorMessage().isPresent()) {
+                return String.format(
+                    "%s: %s (%s)",
+                    e.getKey().name(),
+                    e.getValue().status().name(),
+                    e.getValue().errorMessage().get());
+              }
+              return String.format("%s: %s", e.getKey().name(), e.getValue().status().name());
+            })
+        .sorted()
+        .collect(toImmutableList());
+  }
+
   private String getLine(PatchFile fileInfo, short side, int lineNbr) {
     try {
       return fileInfo.getLine(side, lineNbr);
@@ -535,8 +622,8 @@
     }
   }
 
-  private List<Map<String, Object>> getLabelVoteSoyData(List<LabelVote> votes) {
-    List<Map<String, Object>> result = new ArrayList<>();
+  private ImmutableList<Map<String, Object>> getLabelVoteSoyData(ImmutableList<LabelVote> votes) {
+    ImmutableList.Builder<Map<String, Object>> result = ImmutableList.builder();
     for (LabelVote vote : votes) {
       Map<String, Object> data = new HashMap<>();
       data.put("label", vote.label());
@@ -546,7 +633,7 @@
       data.put("value", (int) vote.value());
       result.add(data);
     }
-    return result;
+    return result.build();
   }
 
   private String getCommentTimestamp() {
diff --git a/java/com/google/gerrit/server/mail/send/DeleteReviewerSender.java b/java/com/google/gerrit/server/mail/send/DeleteReviewerSender.java
index 0de0dbe..70676e3 100644
--- a/java/com/google/gerrit/server/mail/send/DeleteReviewerSender.java
+++ b/java/com/google/gerrit/server/mail/send/DeleteReviewerSender.java
@@ -63,7 +63,6 @@
     includeWatchers(NotifyType.ALL_COMMENTS);
     reviewers.stream().forEach(r -> add(RecipientType.TO, r));
     addByEmail(RecipientType.TO, reviewersByEmail);
-    removeUsersThatIgnoredTheChange();
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/mail/send/DeleteVoteSender.java b/java/com/google/gerrit/server/mail/send/DeleteVoteSender.java
index 77efbf8..f71cc00 100644
--- a/java/com/google/gerrit/server/mail/send/DeleteVoteSender.java
+++ b/java/com/google/gerrit/server/mail/send/DeleteVoteSender.java
@@ -41,7 +41,6 @@
     ccAllApprovals();
     bccStarredBy();
     includeWatchers(NotifyType.ALL_COMMENTS);
-    removeUsersThatIgnoredTheChange();
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/mail/send/MergedSender.java b/java/com/google/gerrit/server/mail/send/MergedSender.java
index cec857d..693c669 100644
--- a/java/com/google/gerrit/server/mail/send/MergedSender.java
+++ b/java/com/google/gerrit/server/mail/send/MergedSender.java
@@ -62,7 +62,6 @@
     bccStarredBy();
     includeWatchers(NotifyType.ALL_COMMENTS);
     includeWatchers(NotifyType.SUBMITTED_CHANGES);
-    removeUsersThatIgnoredTheChange();
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/mail/send/ModifyReviewerSender.java b/java/com/google/gerrit/server/mail/send/ModifyReviewerSender.java
index b187f9c..dcf3b6c 100644
--- a/java/com/google/gerrit/server/mail/send/ModifyReviewerSender.java
+++ b/java/com/google/gerrit/server/mail/send/ModifyReviewerSender.java
@@ -37,6 +37,5 @@
     super.init();
 
     ccExistingReviewers();
-    removeUsersThatIgnoredTheChange();
   }
 }
diff --git a/java/com/google/gerrit/server/mail/send/NewChangeSender.java b/java/com/google/gerrit/server/mail/send/NewChangeSender.java
index 001de52..e899fc5 100644
--- a/java/com/google/gerrit/server/mail/send/NewChangeSender.java
+++ b/java/com/google/gerrit/server/mail/send/NewChangeSender.java
@@ -116,7 +116,7 @@
       names.add(getNameFor(id));
     }
     for (Address address : removedByEmailReviewers) {
-      names.add(address.name());
+      names.add(address.toString());
     }
     return names;
   }
diff --git a/java/com/google/gerrit/server/mail/send/RemoveFromAttentionSetSender.java b/java/com/google/gerrit/server/mail/send/RemoveFromAttentionSetSender.java
index 6762b7d..5242bfb 100644
--- a/java/com/google/gerrit/server/mail/send/RemoveFromAttentionSetSender.java
+++ b/java/com/google/gerrit/server/mail/send/RemoveFromAttentionSetSender.java
@@ -30,7 +30,7 @@
   @Inject
   public RemoveFromAttentionSetSender(
       EmailArguments args, @Assisted Project.NameKey project, @Assisted Change.Id changeId) {
-    super(args, project, changeId);
+    super(args, "removeFromAttentionSet", project, changeId);
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/mail/send/ReplacePatchSetSender.java b/java/com/google/gerrit/server/mail/send/ReplacePatchSetSender.java
index 9516b9f..0d32dd5 100644
--- a/java/com/google/gerrit/server/mail/send/ReplacePatchSetSender.java
+++ b/java/com/google/gerrit/server/mail/send/ReplacePatchSetSender.java
@@ -14,34 +14,89 @@
 
 package com.google.gerrit.server.mail.send;
 
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
+import com.google.common.base.Supplier;
+import com.google.common.base.Suppliers;
+import com.google.common.collect.ImmutableList;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.NotifyConfig.NotifyType;
+import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementResult;
 import com.google.gerrit.exceptions.EmailException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.extensions.client.ChangeKind;
+import com.google.gerrit.server.util.LabelVote;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.HashSet;
 import java.util.List;
+import java.util.Map;
 import java.util.Set;
+import org.eclipse.jgit.lib.ObjectId;
 
 /** Send notice of new patch sets for reviewers. */
 public class ReplacePatchSetSender extends ReplyToChangeSender {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   public interface Factory {
-    ReplacePatchSetSender create(Project.NameKey project, Change.Id changeId);
+    ReplacePatchSetSender create(
+        Project.NameKey project,
+        Change.Id changeId,
+        ChangeKind changeKind,
+        ObjectId preUpdateMetaId,
+        Map<SubmitRequirement, SubmitRequirementResult> postUpdateSubmitRequirementResults);
   }
 
   private final Set<Account.Id> reviewers = new HashSet<>();
   private final Set<Account.Id> extraCC = new HashSet<>();
+  private final ChangeKind changeKind;
+  private final Set<PatchSetApproval> outdatedApprovals = new HashSet<>();
+  private final Supplier<Map<SubmitRequirement, SubmitRequirementResult>>
+      preUpdateSubmitRequirementResultsSupplier;
+  private final Map<SubmitRequirement, SubmitRequirementResult> postUpdateSubmitRequirementResults;
 
   @Inject
   public ReplacePatchSetSender(
-      EmailArguments args, @Assisted Project.NameKey project, @Assisted Change.Id changeId) {
+      EmailArguments args,
+      @Assisted Project.NameKey project,
+      @Assisted Change.Id changeId,
+      @Assisted ChangeKind changeKind,
+      @Assisted ObjectId preUpdateMetaId,
+      @Assisted
+          Map<SubmitRequirement, SubmitRequirementResult> postUpdateSubmitRequirementResults) {
     super(args, "newpatchset", newChangeData(args, project, changeId));
+    this.changeKind = changeKind;
+
+    this.preUpdateSubmitRequirementResultsSupplier =
+        Suppliers.memoize(
+            () ->
+                // Triggers an (expensive) evaluation of the submit requirements. This is OK since
+                // all callers sent this email asynchronously, see EmailNewPatchSet.
+                newChangeData(args, project, changeId, preUpdateMetaId)
+                    .submitRequirementsIncludingLegacy());
+
+    this.postUpdateSubmitRequirementResults = postUpdateSubmitRequirementResults;
+  }
+
+  @Override
+  protected boolean shouldSendMessage() {
+    if (!isChangeNoLongerSubmittable() && changeKind.isTrivialRebase()) {
+      logger.atFine().log(
+          "skip email because new patch set is a trivial rebase that didn't make the change"
+              + " non-submittable");
+      return false;
+    }
+
+    return super.shouldSendMessage();
   }
 
   public void addReviewers(Collection<Account.Id> cc) {
@@ -52,6 +107,12 @@
     extraCC.addAll(cc);
   }
 
+  public void addOutdatedApproval(@Nullable Collection<PatchSetApproval> outdatedApprovals) {
+    if (outdatedApprovals != null) {
+      this.outdatedApprovals.addAll(outdatedApprovals);
+    }
+  }
+
   @Override
   protected void init() throws EmailException {
     super.init();
@@ -71,7 +132,6 @@
     }
     bccStarredBy();
     includeWatchers(NotifyType.NEW_PATCHSETS, !change.isWorkInProgress() && !change.isPrivate());
-    removeUsersThatIgnoredTheChange();
   }
 
   @Override
@@ -82,7 +142,7 @@
     }
   }
 
-  public List<String> getReviewerNames() {
+  public ImmutableList<String> getReviewerNames() {
     List<String> names = new ArrayList<>();
     for (Account.Id id : reviewers) {
       if (id.equals(fromId)) {
@@ -93,12 +153,87 @@
     if (names.isEmpty()) {
       return null;
     }
-    return names;
+    return names.stream().sorted().collect(toImmutableList());
+  }
+
+  private ImmutableList<String> formatOutdatedApprovals() {
+    return outdatedApprovals.stream()
+        .map(
+            outdatedApproval ->
+                String.format(
+                    "%s by %s",
+                    LabelVote.create(outdatedApproval.label(), outdatedApproval.value()).format(),
+                    getNameFor(outdatedApproval.accountId())))
+        .sorted()
+        .collect(toImmutableList());
   }
 
   @Override
   protected void setupSoyContext() {
     super.setupSoyContext();
     soyContextEmailData.put("reviewerNames", getReviewerNames());
+    soyContextEmailData.put("outdatedApprovals", formatOutdatedApprovals());
+
+    if (isChangeNoLongerSubmittable()) {
+      soyContext.put("unsatisfiedSubmitRequirements", formatUnsatisfiedSubmitRequirements());
+      soyContext.put(
+          "oldSubmitRequirements",
+          formatSubmitRequirments(preUpdateSubmitRequirementResultsSupplier.get()));
+      soyContext.put(
+          "newSubmitRequirements", formatSubmitRequirments(postUpdateSubmitRequirementResults));
+    }
+  }
+
+  /**
+   * Checks whether the change is no longer submittable.
+   *
+   * @return {@code true} if the change has been submittable before the update and is no longer
+   *     submittable after the update has been applied, otherwise {@code false}
+   */
+  private boolean isChangeNoLongerSubmittable() {
+    boolean isSubmittablePreUpdate =
+        preUpdateSubmitRequirementResultsSupplier.get().values().stream()
+            .allMatch(SubmitRequirementResult::fulfilled);
+    logger.atFine().log(
+        "the submitability of change %s before the update is %s",
+        change.getId(), isSubmittablePreUpdate);
+    if (!isSubmittablePreUpdate) {
+      return false;
+    }
+
+    boolean isSubmittablePostUpdate =
+        postUpdateSubmitRequirementResults.values().stream()
+            .allMatch(SubmitRequirementResult::fulfilled);
+    logger.atFine().log(
+        "the submitability of change %s after the update is %s",
+        change.getId(), isSubmittablePostUpdate);
+    return !isSubmittablePostUpdate;
+  }
+
+  private ImmutableList<String> formatUnsatisfiedSubmitRequirements() {
+    return postUpdateSubmitRequirementResults.entrySet().stream()
+        .filter(e -> SubmitRequirementResult.Status.UNSATISFIED.equals(e.getValue().status()))
+        .map(Map.Entry::getKey)
+        .map(SubmitRequirement::name)
+        .sorted()
+        .collect(toImmutableList());
+  }
+
+  private static ImmutableList<String> formatSubmitRequirments(
+      Map<SubmitRequirement, SubmitRequirementResult> submitRequirementResults) {
+    return submitRequirementResults.entrySet().stream()
+        .map(
+            e -> {
+              if (e.getValue().errorMessage().isPresent()) {
+                return String.format(
+                    "%s: %s (%s)",
+                    e.getKey().name(),
+                    e.getValue().status().name(),
+                    e.getValue().errorMessage().get());
+              }
+              return String.format("%s: %s", e.getKey().name(), e.getValue().status().name());
+            })
+        .sorted()
+        .collect(toImmutableList());
   }
 }
diff --git a/java/com/google/gerrit/server/mail/send/RestoredSender.java b/java/com/google/gerrit/server/mail/send/RestoredSender.java
index ffe70cf..e37d8f9 100644
--- a/java/com/google/gerrit/server/mail/send/RestoredSender.java
+++ b/java/com/google/gerrit/server/mail/send/RestoredSender.java
@@ -41,7 +41,6 @@
     ccAllApprovals();
     bccStarredBy();
     includeWatchers(NotifyType.ALL_COMMENTS);
-    removeUsersThatIgnoredTheChange();
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/mail/send/RevertedSender.java b/java/com/google/gerrit/server/mail/send/RevertedSender.java
index c11529b..1d7223d 100644
--- a/java/com/google/gerrit/server/mail/send/RevertedSender.java
+++ b/java/com/google/gerrit/server/mail/send/RevertedSender.java
@@ -40,7 +40,6 @@
     ccAllApprovals();
     bccStarredBy();
     includeWatchers(NotifyType.ALL_COMMENTS);
-    removeUsersThatIgnoredTheChange();
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java b/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java
index 158972f..93f29f6 100644
--- a/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java
+++ b/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java
@@ -17,6 +17,7 @@
 import static java.util.Objects.requireNonNull;
 
 import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.entities.Change;
@@ -24,6 +25,7 @@
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.metrics.Timer0;
 import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.GerritImportedServerIds;
 import com.google.gerrit.server.config.GerritServerId;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.notedb.ChangeNotesCommit.ChangeNotesRevWalk;
@@ -51,6 +53,7 @@
     public final AllUsersName allUsers;
     public final NoteDbMetrics metrics;
     public final String serverId;
+    public final ImmutableList<String> importedServerIds;
 
     // Providers required to avoid dependency cycles.
 
@@ -64,7 +67,8 @@
         ChangeNoteJson changeNoteJson,
         NoteDbMetrics metrics,
         Provider<ChangeNotesCache> cache,
-        @GerritServerId String serverId) {
+        @GerritServerId String serverId,
+        @GerritImportedServerIds ImmutableList<String> importedServerIds) {
       this.failOnLoadForTest = new AtomicBoolean();
       this.repoManager = repoManager;
       this.allUsers = allUsers;
@@ -72,6 +76,7 @@
       this.metrics = metrics;
       this.cache = cache;
       this.serverId = serverId;
+      this.importedServerIds = importedServerIds;
     }
   }
 
diff --git a/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java b/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java
index 7efda47..e6f1622 100644
--- a/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java
+++ b/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java
@@ -29,7 +29,6 @@
 import com.google.gerrit.server.InternalUser;
 import java.io.IOException;
 import java.time.Instant;
-import java.util.Date;
 import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.ObjectId;
@@ -56,9 +55,6 @@
   private ObjectId result;
   boolean rootOnly;
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   AbstractChangeUpdate(
       ChangeNotes notes,
       CurrentUser user,
@@ -66,7 +62,7 @@
       ChangeNoteUtil noteUtil,
       Instant when) {
     this.noteUtil = noteUtil;
-    this.serverIdent = new PersonIdent(serverIdent, Date.from(when));
+    this.serverIdent = new PersonIdent(serverIdent, when);
     this.notes = notes;
     this.change = notes.getChange();
     this.accountId = accountId(user);
@@ -76,9 +72,6 @@
     this.when = when;
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   AbstractChangeUpdate(
       ChangeNoteUtil noteUtil,
       PersonIdent serverIdent,
@@ -92,7 +85,7 @@
         (notes != null && change == null) || (notes == null && change != null),
         "exactly one of notes or change required");
     this.noteUtil = noteUtil;
-    this.serverIdent = new PersonIdent(serverIdent, Date.from(when));
+    this.serverIdent = new PersonIdent(serverIdent, when);
     this.notes = notes;
     this.change = change != null ? change : notes.getChange();
     this.accountId = accountId;
@@ -213,9 +206,6 @@
    *     deleted.
    * @throws IOException if a lower-level error occurred.
    */
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   final ObjectId apply(RevWalk rw, ObjectInserter ins, ObjectId curr) throws IOException {
     if (isEmpty()) {
       return null;
@@ -236,7 +226,7 @@
       return null; // Impl is a no-op.
     }
     cb.setAuthor(authorIdent);
-    cb.setCommitter(new PersonIdent(serverIdent, Date.from(when)));
+    cb.setCommitter(new PersonIdent(serverIdent, when));
     setParentCommit(cb, curr);
     if (cb.getTreeId() == null) {
       if (curr.equals(z)) {
diff --git a/java/com/google/gerrit/server/notedb/ChangeNoteJson.java b/java/com/google/gerrit/server/notedb/ChangeNoteJson.java
index 0f1d362c..de401ac 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNoteJson.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNoteJson.java
@@ -16,6 +16,8 @@
 
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.entities.EntitiesAdapterFactory;
+import com.google.gerrit.entities.SubmitRequirementExpressionResult;
+import com.google.gerrit.entities.SubmitRequirementExpressionResult.Status;
 import com.google.gerrit.json.EnumTypeAdapterFactory;
 import com.google.gerrit.json.OptionalSubmitRequirementExpressionResultAdapterFactory;
 import com.google.gerrit.json.OptionalTypeAdapter;
@@ -65,6 +67,9 @@
             new OptionalBooleanAdapter().nullSafe())
         .registerTypeAdapterFactory(new OptionalSubmitRequirementExpressionResultAdapterFactory())
         .registerTypeAdapter(ObjectId.class, new ObjectIdAdapter())
+        .registerTypeAdapter(
+            SubmitRequirementExpressionResult.Status.class,
+            new SubmitRequirementExpressionResultStatusAdapter())
         .setPrettyPrinting()
         .create();
   }
@@ -158,4 +163,32 @@
       return builder.build();
     }
   }
+
+  /**
+   * A Json type adapter for the Status enum in {@link SubmitRequirementExpressionResult}. This
+   * adapter is able to parse unrecognized values. Unrecognized values are converted to the value
+   * "ERROR" The adapter is needed to ensure forward compatibility since we want to add more values
+   * to this enum. We do that to ensure safer rollout in distributed setups where some tasks are
+   * updated before others. We make sure that tasks running the old binaries are still able to parse
+   * values written by tasks running the new binaries.
+   *
+   * <p>TODO(ghareeb): Remove this adapter.
+   */
+  static class SubmitRequirementExpressionResultStatusAdapter
+      extends TypeAdapter<SubmitRequirementExpressionResult.Status> {
+    @Override
+    public void write(JsonWriter jsonWriter, Status status) throws IOException {
+      jsonWriter.value(status.name());
+    }
+
+    @Override
+    public Status read(JsonReader jsonReader) throws IOException {
+      String val = jsonReader.nextString();
+      try {
+        return SubmitRequirementExpressionResult.Status.valueOf(val);
+      } catch (IllegalArgumentException e) {
+        return SubmitRequirementExpressionResult.Status.ERROR;
+      }
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java b/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java
index e9d2f4c..a30cfe0 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java
@@ -24,7 +24,6 @@
 import com.google.gson.Gson;
 import com.google.inject.Inject;
 import java.time.Instant;
-import java.util.Date;
 import java.util.List;
 import java.util.Optional;
 import org.eclipse.jgit.errors.ConfigInvalidException;
@@ -100,16 +99,13 @@
    * Returns a {@link PersonIdent} that contains the account ID, but not the user's name or email
    * address.
    */
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   public PersonIdent newAccountIdIdent(
       Account.Id accountId, Instant when, PersonIdent serverIdent) {
     return new PersonIdent(
         getAccountIdAsUsername(accountId),
         getAccountIdAsEmailAddress(accountId),
-        Date.from(when),
-        serverIdent.getTimeZone());
+        when,
+        serverIdent.getZoneId());
   }
 
   /** Returns the string {@code "Gerrit User " + accountId}, to pseudonymize user names. */
@@ -384,18 +380,26 @@
    *   <li>The label, vote, and the Gerrit account are mandatory (unlike FOOTER_LABEL where Gerrit
    *       Account is also optional since by default it's the committer).
    * </ul>
+   *
+   * <p>Footer example for removal: Copied-Label: -<LABEL> <Gerrit Account>,<Gerrit Real Account>
+   *
+   * <ul>
+   *   <li><Gerrit Real Account> is also optional, if it was not set.
+   * </ul>
    */
   public static ParsedPatchSetApproval parseCopiedApproval(String labelLine)
       throws ConfigInvalidException {
     try {
-      // Copied approvals can't be explicitly removed. They are removed the same way as non-copied
-      // approvals.
-      checkFooter(!labelLine.startsWith("-"), FOOTER_COPIED_LABEL, labelLine);
       ParsedPatchSetApproval.Builder rawPatchSetApproval =
-          ParsedPatchSetApproval.builder().footerLine(labelLine).isRemoval(false);
+          ParsedPatchSetApproval.builder().footerLine(labelLine);
 
-      int tagStart = labelLine.indexOf(":\"");
-      int uuidStart = labelLine.indexOf(", ");
+      boolean isRemoval = labelLine.startsWith("-");
+      rawPatchSetApproval.isRemoval(isRemoval);
+      int labelStart = isRemoval ? 1 : 0;
+      int uuidStart = isRemoval ? -1 : labelLine.indexOf(", ");
+      int tagStart = isRemoval ? -1 : labelLine.indexOf(":\"");
+
+      checkFooter(!isRemoval || uuidStart == -1, FOOTER_LABEL, labelLine);
 
       // Weird tag that contains uuid delimiter. The uuid is actually not present.
       if (tagStart != -1 && uuidStart > tagStart) {
@@ -408,7 +412,8 @@
           FOOTER_COPIED_LABEL,
           labelLine);
 
-      String labelVoteStr = labelLine.substring(0, uuidStart != -1 ? uuidStart : identitiesStart);
+      String labelVoteStr =
+          labelLine.substring(labelStart, uuidStart != -1 ? uuidStart : identitiesStart);
       rawPatchSetApproval.labelVote(labelVoteStr);
       if (uuidStart != -1) {
         String uuid = labelLine.substring(uuidStart + 2, identitiesStart);
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotes.java b/java/com/google/gerrit/server/notedb/ChangeNotes.java
index 3095cd2..dc76994 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotes.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotes.java
@@ -16,7 +16,6 @@
 
 import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkState;
-import static com.google.common.collect.ImmutableListMultimap.toImmutableListMultimap;
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.gerrit.entities.RefNames.changeMetaRef;
 import static java.util.Comparator.comparing;
@@ -38,6 +37,7 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.errorprone.annotations.FormatMethod;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.entities.BranchNameKey;
@@ -47,6 +47,7 @@
 import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.entities.PatchSetApprovals;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.entities.RobotComment;
@@ -90,9 +91,6 @@
   static final Ordering<PatchSetApproval> PSA_BY_TIME =
       Ordering.from(comparing(PatchSetApproval::granted));
 
-  public static final Ordering<ChangeMessage> MESSAGE_BY_TIME =
-      Ordering.from(comparing(ChangeMessage::getWrittenOn));
-
   @FormatMethod
   public static ConfigInvalidException parseException(
       Change.Id changeId, String fmt, Object... args) {
@@ -141,6 +139,16 @@
       return createChecked(c.getProject(), c.getId());
     }
 
+    /**
+     * Load the change-notes associated to a project/change-id using an existing open repository
+     *
+     * @param repo existing open repository
+     * @param project project associated with the repository
+     * @param changeId change-id associated with the change-notes to load
+     * @param metaRevId version of the change-id to load, null for loading the latest
+     * @return change-notes object for the change
+     */
+    @UsedAt(UsedAt.Project.MODULE_GIT_REFS_FILTER)
     public ChangeNotes createChecked(
         Repository repo,
         Project.NameKey project,
@@ -390,8 +398,7 @@
   // Lazy defensive copies of mutable ReviewDb types, to avoid polluting the
   // ChangeNotesCache from handlers.
   private ImmutableSortedMap<PatchSet.Id, PatchSet> patchSets;
-  private ImmutableListMultimap<PatchSet.Id, PatchSetApproval> approvals;
-  private ImmutableListMultimap<PatchSet.Id, PatchSetApproval> approvalsWithCopied;
+  private PatchSetApprovals approvals;
   private ImmutableSet<Comment.Key> commentKeys;
 
   public ChangeNotes(
@@ -419,6 +426,10 @@
     return state.metaId();
   }
 
+  public String getServerId() {
+    return state.serverId();
+  }
+
   public ImmutableSortedMap<PatchSet.Id, PatchSet> getPatchSets() {
     if (patchSets == null) {
       ImmutableSortedMap.Builder<PatchSet.Id, PatchSet> b =
@@ -429,28 +440,14 @@
     return patchSets;
   }
 
-  /**
-   * Gets the approvals, not including the copied approvals. To get copied approvals as well, use
-   * {@link #getApprovalsWithCopied}, or use {@code ApprovalInference}.
-   */
-  public ImmutableListMultimap<PatchSet.Id, PatchSetApproval> getApprovals() {
+  /** Gets the approvals of all patch sets. */
+  public PatchSetApprovals getApprovals() {
     if (approvals == null) {
-      approvals =
-          state.approvals().stream()
-              .filter(e -> !e.getValue().copied())
-              .collect(toImmutableListMultimap(e -> e.getKey(), e -> e.getValue()));
+      approvals = PatchSetApprovals.create(ImmutableListMultimap.copyOf(state.approvals()));
     }
     return approvals;
   }
 
-  /** Gets all approvals, including copied approvals. */
-  public ImmutableListMultimap<PatchSet.Id, PatchSetApproval> getApprovalsWithCopied() {
-    if (approvalsWithCopied == null) {
-      approvalsWithCopied = ImmutableListMultimap.copyOf(state.approvals());
-    }
-    return approvalsWithCopied;
-  }
-
   public ReviewerSet getReviewers() {
     return state.reviewers();
   }
@@ -682,7 +679,9 @@
      * be to bump the cache version, but that would invalidate all persistent cache entries, what we
      * rather try to avoid.
      */
-    if (!Strings.isNullOrEmpty(stateServerId) && !args.serverId.equals(stateServerId)) {
+    if (!Strings.isNullOrEmpty(stateServerId)
+        && !args.serverId.equals(stateServerId)
+        && !args.importedServerIds.contains(stateServerId)) {
       throw new InvalidServerIdException(args.serverId, stateServerId);
     }
 
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesCache.java b/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
index 40bf6e5..0f2c877 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
@@ -26,6 +26,7 @@
 import com.google.gerrit.proto.Protos;
 import com.google.gerrit.server.ReviewerByEmailSet;
 import com.google.gerrit.server.ReviewerSet;
+import com.google.gerrit.server.account.externalids.ExternalIdCache;
 import com.google.gerrit.server.cache.CacheModule;
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesKeyProto;
 import com.google.gerrit.server.cache.serialize.CacheSerializer;
@@ -61,7 +62,7 @@
             .weigher(Weigher.class)
             .maximumWeight(10 << 20)
             .diskLimit(-1)
-            .version(4)
+            .version(5)
             .keySerializer(Key.Serializer.INSTANCE)
             .valueSerializer(ChangeNotesState.Serializer.INSTANCE);
       }
@@ -366,7 +367,13 @@
           "Load change notes for change %s of project %s", key.changeId(), key.project());
       ChangeNotesParser parser =
           new ChangeNotesParser(
-              key.changeId(), key.id(), walkSupplier.get(), args.changeNoteJson, args.metrics);
+              key.changeId(),
+              key.id(),
+              walkSupplier.get(),
+              args.changeNoteJson,
+              args.metrics,
+              args.serverId,
+              externalIdCache);
       ChangeNotesState result = parser.parseAll();
       // This assignment only happens if call() was actually called, which only
       // happens when Cache#get(K, Callable<V>) incurs a cache miss.
@@ -377,11 +384,16 @@
 
   private final Cache<Key, ChangeNotesState> cache;
   private final Args args;
+  private final ExternalIdCache externalIdCache;
 
   @Inject
-  ChangeNotesCache(@Named(CACHE_NAME) Cache<Key, ChangeNotesState> cache, Args args) {
+  ChangeNotesCache(
+      @Named(CACHE_NAME) Cache<Key, ChangeNotesState> cache,
+      Args args,
+      ExternalIdCache externalIdCache) {
     this.cache = cache;
     this.args = args;
+    this.externalIdCache = externalIdCache;
   }
 
   Value get(
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesParser.java b/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
index 1d8ec82..f2a659d 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
@@ -79,6 +79,7 @@
 import com.google.gerrit.server.ReviewerByEmailSet;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.ReviewerStatusUpdate;
+import com.google.gerrit.server.account.externalids.ExternalIdCache;
 import com.google.gerrit.server.notedb.ChangeNoteUtil.ParsedPatchSetApproval;
 import com.google.gerrit.server.notedb.ChangeNotesCommit.ChangeNotesRevWalk;
 import com.google.gerrit.server.util.LabelVote;
@@ -199,18 +200,24 @@
   // the latest record unsets the field).
   private Optional<PatchSet.Id> cherryPickOf;
   private Instant mergedOn;
+  private final ExternalIdCache externalIdCache;
+  private final String gerritServerId;
 
   ChangeNotesParser(
       Change.Id changeId,
       ObjectId tip,
       ChangeNotesRevWalk walk,
       ChangeNoteJson changeNoteJson,
-      NoteDbMetrics metrics) {
+      NoteDbMetrics metrics,
+      String gerritServerId,
+      ExternalIdCache externalIdCache) {
     this.id = changeId;
     this.tip = tip;
     this.walk = walk;
     this.changeNoteJson = changeNoteJson;
     this.metrics = metrics;
+    this.externalIdCache = externalIdCache;
+    this.gerritServerId = gerritServerId;
     approvals = new LinkedHashMap<>();
     bufferedApprovals = new ArrayList<>();
     reviewers = HashBasedTable.create();
@@ -948,9 +955,15 @@
       realAccountId = parseIdent(realIdent);
     }
 
-    LabelVote l;
+    LabelVote labelVote;
     try {
-      l = LabelVote.parseWithEquals(parsedPatchSetApproval.labelVote());
+      if (!parsedPatchSetApproval.isRemoval()) {
+        labelVote = LabelVote.parseWithEquals(parsedPatchSetApproval.labelVote());
+      } else {
+        String labelName = parsedPatchSetApproval.labelVote();
+        LabelType.checkNameInternal(labelName);
+        labelVote = LabelVote.create(labelName, (short) 0);
+      }
     } catch (IllegalArgumentException e) {
       ConfigInvalidException pe =
           parseException(
@@ -961,9 +974,9 @@
 
     PatchSetApproval.Builder psa =
         PatchSetApproval.builder()
-            .key(PatchSetApproval.key(psId, accountId, LabelId.create(l.label())))
+            .key(PatchSetApproval.key(psId, accountId, LabelId.create(labelVote.label())))
             .uuid(parsedPatchSetApproval.uuid().map(PatchSetApproval::uuid))
-            .value(l.value())
+            .value(labelVote.value())
             .granted(ts)
             .tag(parsedPatchSetApproval.tag())
             .copied(true);
@@ -1270,11 +1283,8 @@
    * @param commit the commit to return commit time.
    * @return the timestamp when the commit was applied.
    */
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   private Instant getCommitTimestamp(ChangeNotesCommit commit) {
-    return commit.getCommitterIdent().getWhen().toInstant();
+    return commit.getCommitterIdent().getWhenAsInstant();
   }
 
   private void pruneReviewers() {
@@ -1403,7 +1413,7 @@
   }
 
   private Account.Id parseIdent(PersonIdent ident) throws ConfigInvalidException {
-    return NoteDbUtil.parseIdent(ident)
+    return NoteDbUtil.parseIdent(ident, gerritServerId, externalIdCache)
         .orElseThrow(
             () -> parseException("cannot retrieve account id: %s", ident.getEmailAddress()));
   }
diff --git a/java/com/google/gerrit/server/notedb/ChangeUpdate.java b/java/com/google/gerrit/server/notedb/ChangeUpdate.java
index 590d30f..62c734b 100644
--- a/java/com/google/gerrit/server/notedb/ChangeUpdate.java
+++ b/java/com/google/gerrit/server/notedb/ChangeUpdate.java
@@ -52,10 +52,12 @@
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ImmutableTable;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Table;
 import com.google.common.collect.Table.Cell;
 import com.google.common.collect.TreeBasedTable;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Address;
 import com.google.gerrit.entities.AttentionSetUpdate;
@@ -141,7 +143,7 @@
   private final ServiceUserClassifier serviceUserClassifier;
   private final PatchSetApprovalUuidGenerator patchSetApprovalUuidGenerator;
 
-  private final Table<String, Account.Id, Optional<Short>> approvals;
+  private final Table<String, Account.Id, Optional<PatchSetApproval>> approvals;
   private final List<PatchSetApproval> copiedApprovals = new ArrayList<>();
   private final Map<Account.Id, ReviewerStateInternal> reviewers = new LinkedHashMap<>();
   private final Map<Address, ReviewerStateInternal> reviewersByEmail = new LinkedHashMap<>();
@@ -181,6 +183,9 @@
   private DeleteChangeMessageRewriter deleteChangeMessageRewriter;
   private List<SubmitRequirementResult> submitRequirementResults;
 
+  private ImmutableList.Builder<AttentionSetUpdate> attentionSetUpdatesBuilder =
+      ImmutableList.builder();
+
   @SuppressWarnings("UnusedMethod")
   @AssistedInject
   private ChangeUpdate(
@@ -215,7 +220,7 @@
         noteUtil);
   }
 
-  private static Table<String, Account.Id, Optional<Short>> approvals(
+  private static Table<String, Account.Id, Optional<PatchSetApproval>> approvals(
       Comparator<String> nameComparator) {
     return TreeBasedTable.create(nameComparator, naturalOrder());
   }
@@ -282,7 +287,18 @@
   }
 
   public void putApprovalFor(Account.Id reviewer, String label, short value) {
-    approvals.put(label, reviewer, Optional.of(value));
+    PatchSetApproval psa =
+        PatchSetApproval.builder()
+            .key(PatchSetApproval.key(getPatchSetId(), reviewer, LabelId.create(label)))
+            .value(value)
+            .granted(when)
+            .uuid(patchSetApprovalUuidGenerator.get(getPatchSetId(), reviewer, label, value, when))
+            .build();
+    approvals.put(label, reviewer, Optional.of(psa));
+  }
+
+  public ImmutableTable<String, Account.Id, Optional<PatchSetApproval>> getApprovals() {
+    return ImmutableTable.copyOf(approvals);
   }
 
   void removeApproval(String label) {
@@ -302,6 +318,23 @@
     copiedApprovals.add(copiedPatchSetApproval);
   }
 
+  public void removeCopiedApprovalFor(
+      @Nullable Account.Id realUserId, Account.Id reviewerId, String label) {
+    PatchSetApproval.Builder psaBuilder =
+        PatchSetApproval.builder()
+            .copied(true)
+            .key(PatchSetApproval.key(getPatchSetId(), reviewerId, LabelId.create(label)))
+            .value(0)
+            .uuid(Optional.empty())
+            .granted(when);
+
+    if (realUserId != null) {
+      psaBuilder.realAccountId(realUserId);
+    }
+
+    copiedApprovals.add(psaBuilder.build());
+  }
+
   public void merge(SubmissionId submissionId, Iterable<SubmitRecord> submitRecords) {
     this.status = Change.Status.MERGED;
     this.submissionId = submissionId.toString();
@@ -427,12 +460,21 @@
   }
 
   /**
-   * All updates must have a timestamp of null since we use the commit's timestamp. There also must
-   * not be multiple updates for a single user. Only the first update takes place because of the
-   * different priorities: e.g, if we want to add someone to the attention set but also want to
-   * remove someone from the attention set, we should ensure to add/remove that user based on the
-   * priority of the addition and removal. If most importantly we want to remove the user, then we
-   * must first create the removal, and the addition will not take effect.
+   * Adds attention set updates that should be stored in NoteDb.
+   *
+   * <p>If invoked multiple times with attention set updates for the same user, only the attention
+   * set update of the first invocation is stored for this user and further attention set updates
+   * for this user are silently ignored. This means if callers invoke this method multiple times
+   * with attention set updates for the same user, they must ensure that the first call is being
+   * done with the attention set update that should take precedence.
+   *
+   * @param updates Attention set updates that should be performed. The updates must not have any
+   *     timestamp set ({@link AttentionSetUpdate#timestamp()} must return {@code null}). This is
+   *     because the timestamp of all performed updates is always the timestamp of when the NoteDb
+   *     commit is created. Each of the provided updates must be for a different user, if there are
+   *     multiple updates for the same user the update is rejected.
+   * @throws IllegalArgumentException thrown if any of the provided updates has a timestamp set, or
+   *     if the provided set of updates contains multiple updates for the same user
    */
   public void addToPlannedAttentionSetUpdates(Set<AttentionSetUpdate> updates) {
     if (updates == null || updates.isEmpty() || ignoreFurtherAttentionSetUpdates) {
@@ -464,6 +506,10 @@
     addToPlannedAttentionSetUpdates(ImmutableSet.of(update));
   }
 
+  public ImmutableList<AttentionSetUpdate> getAttentionSetUpdates() {
+    return attentionSetUpdatesBuilder.build();
+  }
+
   public void setAssignee(Account.Id assignee) {
     checkArgument(assignee != null, "use removeAssignee");
     this.assignee = Optional.of(assignee);
@@ -751,8 +797,8 @@
       addFooter(msg, e.getValue().getByEmailFooterKey(), e.getKey().toString());
     }
 
-    for (Table.Cell<String, Account.Id, Optional<Short>> c : approvals.cellSet()) {
-      addLabelFooter(msg, c, patchSetId);
+    for (Table.Cell<String, Account.Id, Optional<PatchSetApproval>> c : approvals.cellSet()) {
+      addLabelFooter(msg, c);
     }
     for (PatchSetApproval patchSetApproval : copiedApprovals) {
       addCopiedLabelFooter(msg, patchSetApproval);
@@ -840,7 +886,7 @@
   }
 
   private void addLabelFooter(
-      StringBuilder msg, Cell<String, Account.Id, Optional<Short>> c, PatchSet.Id patchSetId) {
+      StringBuilder msg, Cell<String, Account.Id, Optional<PatchSetApproval>> c) {
     addFooter(msg, FOOTER_LABEL);
     String label = c.getRowKey();
     Account.Id reviewerId = c.getColumnKey();
@@ -851,10 +897,10 @@
       // Since vote removals do not need to be referenced, e.g. by the copy approvals, they do not
       // require a UUID.
     } else {
-      short value = c.getValue().get();
-      msg.append(LabelVote.create(label, c.getValue().get()).formatWithEquals());
+      short value = c.getValue().get().value();
+      msg.append(LabelVote.create(label, value).formatWithEquals());
       msg.append(", ");
-      msg.append(patchSetApprovalUuidGenerator.get(patchSetId, reviewerId, label, value, when));
+      msg.append(c.getValue().get().uuid().get());
     }
     if (!reviewerId.equals(getAccountId())) {
       noteUtil.appendAccountIdIdentString(msg.append(' '), reviewerId);
@@ -864,7 +910,20 @@
 
   private void addCopiedLabelFooter(StringBuilder msg, PatchSetApproval patchSetApproval) {
     if (patchSetApproval.value() == 0) {
-      // Can only happen if we removed a vote. There is no need to persist removed votes.
+      addFooter(msg, FOOTER_COPIED_LABEL);
+
+      // Mark the copied approval as deleted.
+      msg.append('-').append(patchSetApproval.label());
+
+      noteUtil.appendAccountIdIdentString(msg.append(' '), patchSetApproval.accountId());
+
+      // In the non-copied labels, we don't need to pass the real account id since it's already
+      // in FOOTER_REAL_USER. Here, we want to retain the original real account id.
+      if (!patchSetApproval.realAccountId().equals(patchSetApproval.accountId())) {
+        noteUtil.appendAccountIdIdentString(msg.append(","), patchSetApproval.realAccountId());
+      }
+
+      msg.append('\n');
       return;
     }
     addFooter(msg, FOOTER_COPIED_LABEL);
@@ -881,7 +940,7 @@
 
     // In the non-copied labels, we don't need to pass the real account id since it's already
     // in FOOTER_REAL_USER. Here, we want to retain the original real account id.
-    if (patchSetApproval.realAccountId() != null) {
+    if (!patchSetApproval.realAccountId().equals(patchSetApproval.accountId())) {
       noteUtil.appendAccountIdIdentString(msg.append(","), patchSetApproval.realAccountId());
     }
 
@@ -914,11 +973,13 @@
       // be submitted or when the caller is a robot.
       return;
     }
+
+    Set<AttentionSetUpdate> updates = new HashSet<>();
     Set<Account.Id> currentReviewers =
         getNotes().getReviewers().byState(ReviewerStateInternal.REVIEWER);
-    Set<AttentionSetUpdate> updates = new HashSet<>();
     for (Map.Entry<Account.Id, ReviewerStateInternal> reviewer : reviewers.entrySet()) {
       Account.Id reviewerId = reviewer.getKey();
+
       ReviewerStateInternal reviewerState = reviewer.getValue();
       // Only add new reviewers to the attention set. Also, don't add the owner because the owner
       // can only be a "dummy" reviewer for legacy reasons.
@@ -1024,6 +1085,7 @@
       }
 
       addFooter(msg, FOOTER_ATTENTION, noteUtil.attentionSetUpdateToJson(attentionSetUpdate));
+      attentionSetUpdatesBuilder.add(attentionSetUpdate);
       hasUpdates = true;
     }
     return hasUpdates;
diff --git a/java/com/google/gerrit/server/notedb/CommitRewriter.java b/java/com/google/gerrit/server/notedb/CommitRewriter.java
index a0ac243..da20475 100644
--- a/java/com/google/gerrit/server/notedb/CommitRewriter.java
+++ b/java/com/google/gerrit/server/notedb/CommitRewriter.java
@@ -356,7 +356,7 @@
   private ImmutableSet<AccountState> collectAccounts(ChangeNotes changeNotes) {
     Set<Account.Id> accounts = new HashSet<>();
     accounts.add(changeNotes.getChange().getOwner());
-    for (PatchSetApproval patchSetApproval : changeNotes.getApprovals().values()) {
+    for (PatchSetApproval patchSetApproval : changeNotes.getApprovals().all().values()) {
       if (patchSetApproval.accountId() != null) {
         accounts.add(patchSetApproval.accountId());
       }
@@ -578,12 +578,9 @@
     }
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   private boolean verifyPersonIdent(PersonIdent newIdent, PersonIdent originalIdent) {
     return newIdent.getTimeZoneOffset() == originalIdent.getTimeZoneOffset()
-        && newIdent.getWhen().getTime() == originalIdent.getWhen().getTime()
+        && newIdent.getWhenAsInstant().equals(originalIdent.getWhenAsInstant())
         && newIdent.getEmailAddress().equals(originalIdent.getEmailAddress());
   }
 
diff --git a/java/com/google/gerrit/server/notedb/DeleteZombieCommentsRefs.java b/java/com/google/gerrit/server/notedb/DeleteZombieCommentsRefs.java
index 28436db..c8d93f8 100644
--- a/java/com/google/gerrit/server/notedb/DeleteZombieCommentsRefs.java
+++ b/java/com/google/gerrit/server/notedb/DeleteZombieCommentsRefs.java
@@ -15,19 +15,40 @@
 package com.google.gerrit.server.notedb;
 
 import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.gerrit.entities.RefNames.REFS_DRAFT_COMMENTS;
+import static org.eclipse.jgit.lib.Constants.EMPTY_TREE_ID;
 
+import com.google.auto.value.AutoValue;
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
+import com.google.common.collect.Sets;
+import com.google.common.collect.Sets.SetView;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.HumanComment;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.git.RefUpdateUtil;
+import com.google.gerrit.server.CommentsUtil;
+import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.inject.Inject;
+import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.assistedinject.Assisted;
+import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
+import java.sql.Timestamp;
 import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.HashSet;
 import java.util.List;
+import java.util.Map;
+import java.util.Set;
 import java.util.function.Consumer;
+import java.util.stream.Collectors;
 import org.eclipse.jgit.lib.BatchRefUpdate;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
@@ -39,16 +60,21 @@
  * href="https://gerrit-review.googlesource.com/c/gerrit/+/246233">
  * https://gerrit-review.googlesource.com/c/gerrit/+/246233 </a>
  *
- * <p>An earlier bug in the deletion of draft comments {@code
- * refs/draft-comments/$change_id_short/$change_id/$user_id} caused some draft refs to remain in Git
- * and not get deleted. These refs point to an empty tree.
+ * <p>The implementation has two cases for detecting zombie drafts:
+ *
+ * <ul>
+ *   <li>An earlier bug in the deletion of draft comments {@code
+ *       refs/draft-comments/$change_id_short/$change_id/$user_id} caused some draft refs to remain
+ *       in Git and not get deleted. These refs point to an empty tree. We delete such refs.
+ *   <li>Inspecting all draft-comment refs. Check for each draft if there exists a published comment
+ *       with the same UUID. These comments are called zombie drafts. If the program is run in
+ *       {@link #dryRun} mode, the zombie draft IDs will only be logged for tracking, otherwise they
+ *       will also be deleted.
+ * </uL>
  */
 public class DeleteZombieCommentsRefs {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  private static final String EMPTY_TREE_ID = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
-  private static final String DRAFT_REFS_PREFIX = "refs/draft-comments";
-
   // Number of refs deleted at once in a batch ref-update.
   // Log progress after deleting every CHUNK_SIZE refs
   private static final int CHUNK_SIZE = 3000;
@@ -56,19 +82,73 @@
   private final GitRepositoryManager repoManager;
   private final AllUsersName allUsers;
   private final int cleanupPercentage;
-  private Repository allUsersRepo;
+
+  /**
+   * Run the logic in dry run mode only. That is, detected zombie drafts will be logged only but not
+   * deleted. Creators of this class can use {@link Factory#create(int, boolean)} to specify the dry
+   * run mode. If {@link Factory#create(int)} is used, the dry run mode will be set to its default:
+   * true.
+   */
+  private final boolean dryRun;
+
   private final Consumer<String> uiConsumer;
+  @Nullable private final DraftCommentNotes.Factory draftNotesFactory;
+  @Nullable private final ChangeNotes.Factory changeNotesFactory;
+  @Nullable private final CommentsUtil commentsUtil;
+  @Nullable private final ChangeUpdate.Factory changeUpdateFactory;
+  @Nullable private final IdentifiedUser.GenericFactory userFactory;
 
   public interface Factory {
     DeleteZombieCommentsRefs create(int cleanupPercentage);
+
+    DeleteZombieCommentsRefs create(int cleanupPercentage, boolean dryRun);
   }
 
-  @Inject
+  @AssistedInject
   public DeleteZombieCommentsRefs(
       AllUsersName allUsers,
       GitRepositoryManager repoManager,
+      ChangeNotes.Factory changeNotesFactory,
+      DraftCommentNotes.Factory draftNotesFactory,
+      CommentsUtil commentsUtil,
+      ChangeUpdate.Factory changeUpdateFactory,
+      IdentifiedUser.GenericFactory userFactory,
       @Assisted Integer cleanupPercentage) {
-    this(allUsers, repoManager, cleanupPercentage, (msg) -> {});
+    this(
+        allUsers,
+        repoManager,
+        cleanupPercentage,
+        /* dryRun= */ true,
+        (msg) -> {},
+        changeNotesFactory,
+        draftNotesFactory,
+        commentsUtil,
+        changeUpdateFactory,
+        userFactory);
+  }
+
+  @AssistedInject
+  public DeleteZombieCommentsRefs(
+      AllUsersName allUsers,
+      GitRepositoryManager repoManager,
+      ChangeNotes.Factory changeNotesFactory,
+      DraftCommentNotes.Factory draftNotesFactory,
+      CommentsUtil commentsUtil,
+      ChangeUpdate.Factory changeUpdateFactory,
+      IdentifiedUser.GenericFactory userFactory,
+      @Assisted Integer cleanupPercentage,
+      @Assisted boolean dryRun) {
+    this(
+        allUsers,
+        repoManager,
+        cleanupPercentage,
+        dryRun,
+        (msg) -> {},
+        changeNotesFactory,
+        draftNotesFactory,
+        commentsUtil,
+        changeUpdateFactory,
+        userFactory);
   }
 
   public DeleteZombieCommentsRefs(
@@ -76,43 +156,252 @@
       GitRepositoryManager repoManager,
       Integer cleanupPercentage,
       Consumer<String> uiConsumer) {
+    this(
+        allUsers,
+        repoManager,
+        cleanupPercentage,
+        /* dryRun= */ false,
+        uiConsumer,
+        null,
+        null,
+        null,
+        null,
+        null);
+  }
+
+  private DeleteZombieCommentsRefs(
+      AllUsersName allUsers,
+      GitRepositoryManager repoManager,
+      Integer cleanupPercentage,
+      boolean dryRun,
+      Consumer<String> uiConsumer,
+      @Nullable ChangeNotes.Factory changeNotesFactory,
+      @Nullable DraftCommentNotes.Factory draftNotesFactory,
+      @Nullable CommentsUtil commentsUtil,
+      @Nullable ChangeUpdate.Factory changeUpdateFactory,
+      @Nullable IdentifiedUser.GenericFactory userFactory) {
     this.allUsers = allUsers;
     this.repoManager = repoManager;
     this.cleanupPercentage = (cleanupPercentage == null) ? 100 : cleanupPercentage;
+    this.dryRun = dryRun;
     this.uiConsumer = uiConsumer;
+    this.draftNotesFactory = draftNotesFactory;
+    this.changeNotesFactory = changeNotesFactory;
+    this.commentsUtil = commentsUtil;
+    this.changeUpdateFactory = changeUpdateFactory;
+    this.userFactory = userFactory;
   }
 
   public void execute() throws IOException {
-    allUsersRepo = repoManager.openRepository(allUsers);
-
-    List<Ref> draftRefs = allUsersRepo.getRefDatabase().getRefsByPrefix(DRAFT_REFS_PREFIX);
-    List<Ref> zombieRefs = filterZombieRefs(draftRefs);
-
-    logInfo(
-        String.format(
-            "Found a total of %d zombie draft refs in %s repo.",
-            zombieRefs.size(), allUsers.get()));
-
-    logInfo(String.format("Cleanup percentage = %d", cleanupPercentage));
-    zombieRefs =
-        zombieRefs.stream()
-            .filter(ref -> Change.Id.fromAllUsersRef(ref.getName()).get() % 100 < cleanupPercentage)
-            .collect(toImmutableList());
-    logInfo(String.format("Number of zombie refs to be cleaned = %d", zombieRefs.size()));
-
-    long zombieRefsCnt = zombieRefs.size();
-    long deletedRefsCnt = 0;
-    long startTime = System.currentTimeMillis();
-
-    for (List<Ref> refsBatch : Iterables.partition(zombieRefs, CHUNK_SIZE)) {
-      deleteBatchZombieRefs(refsBatch);
-      long elapsed = (System.currentTimeMillis() - startTime) / 1000;
-      deletedRefsCnt += refsBatch.size();
-      logProgress(deletedRefsCnt, zombieRefsCnt, elapsed);
+    deleteDraftRefsThatPointToEmptyTree();
+    if (draftNotesFactory != null) {
+      deleteDraftCommentsThatAreAlsoPublished();
     }
   }
 
-  private void deleteBatchZombieRefs(List<Ref> refsBatch) throws IOException {
+  private void deleteDraftRefsThatPointToEmptyTree() throws IOException {
+    try (Repository allUsersRepo = repoManager.openRepository(allUsers)) {
+      List<Ref> draftRefs = allUsersRepo.getRefDatabase().getRefsByPrefix(REFS_DRAFT_COMMENTS);
+      List<Ref> zombieRefs = filterZombieRefs(allUsersRepo, draftRefs);
+
+      logInfo(
+          String.format(
+              "Found a total of %d zombie draft refs in %s repo.",
+              zombieRefs.size(), allUsers.get()));
+
+      logInfo(String.format("Cleanup percentage = %d", cleanupPercentage));
+      zombieRefs =
+          zombieRefs.stream()
+              .filter(
+                  ref -> Change.Id.fromAllUsersRef(ref.getName()).get() % 100 < cleanupPercentage)
+              .collect(toImmutableList());
+      logInfo(String.format("Number of zombie refs to be cleaned = %d", zombieRefs.size()));
+
+      if (dryRun) {
+        logInfo(
+            "Running in dry run mode. Skipping deletion of draft refs pointing to an empty tree.");
+        return;
+      }
+
+      long zombieRefsCnt = zombieRefs.size();
+      long deletedRefsCnt = 0;
+      long startTime = System.currentTimeMillis();
+
+      for (List<Ref> refsBatch : Iterables.partition(zombieRefs, CHUNK_SIZE)) {
+        deleteBatchZombieRefs(allUsersRepo, refsBatch);
+        long elapsed = (System.currentTimeMillis() - startTime) / 1000;
+        deletedRefsCnt += refsBatch.size();
+        logProgress(deletedRefsCnt, zombieRefsCnt, elapsed);
+      }
+    }
+  }
+
+  /**
+   * Iterates over all draft refs in All-Users repository. For each draft ref, checks if there
+   * exists a published comment with the same UUID and deletes the draft ref if that's the case
+   * because it is a zombie draft.
+   *
+   * @return the number of detected and deleted zombie draft comments.
+   */
+  @VisibleForTesting
+  public int deleteDraftCommentsThatAreAlsoPublished() throws IOException {
+    try (Repository allUsersRepo = repoManager.openRepository(allUsers)) {
+      Timestamp earliestZombieTs = null;
+      Timestamp latestZombieTs = null;
+      int numZombies = 0;
+      List<Ref> draftRefs = allUsersRepo.getRefDatabase().getRefsByPrefix(REFS_DRAFT_COMMENTS);
+      // Filter the number of draft refs to be processed according to the cleanup percentage.
+      draftRefs =
+          draftRefs.stream()
+              .filter(
+                  ref -> Change.Id.fromAllUsersRef(ref.getName()).get() % 100 < cleanupPercentage)
+              .collect(toImmutableList());
+      Set<ChangeUserIDsPair> visitedSet = new HashSet<>();
+      ImmutableSet<Change.Id> changeIds =
+          draftRefs.stream()
+              .map(d -> Change.Id.fromAllUsersRef(d.getName()))
+              .collect(ImmutableSet.toImmutableSet());
+      Map<Change.Id, Project.NameKey> changeProjectMap = mapChangeIdsToProjects(changeIds);
+      for (Ref draftRef : draftRefs) {
+        try {
+          Change.Id changeId = Change.Id.fromAllUsersRef(draftRef.getName());
+          Account.Id accountId = Account.Id.fromRef(draftRef.getName());
+          ChangeUserIDsPair changeUserIDsPair = ChangeUserIDsPair.create(changeId, accountId);
+          if (!visitedSet.add(changeUserIDsPair)) {
+            continue;
+          }
+          if (!changeProjectMap.containsKey(changeId)) {
+            logger.atWarning().log(
+                "Could not find a project associated with change ID %s. Skipping draft ref %s.",
+                changeId, draftRef.getName());
+            continue;
+          }
+          DraftCommentNotes draftNotes = draftNotesFactory.create(changeId, accountId).load();
+          ChangeNotes notes =
+              changeNotesFactory.createChecked(changeProjectMap.get(changeId), changeId);
+          List<HumanComment> drafts = draftNotes.getComments().values().asList();
+          List<HumanComment> published = commentsUtil.publishedHumanCommentsByChange(notes);
+          Set<String> publishedIds = toUuid(published);
+          List<HumanComment> zombieDrafts =
+              drafts.stream()
+                  .filter(draft -> publishedIds.contains(draft.key.uuid))
+                  .collect(Collectors.toList());
+          for (HumanComment zombieDraft : zombieDrafts) {
+            earliestZombieTs = getEarlierTs(earliestZombieTs, zombieDraft.writtenOn);
+            latestZombieTs = getLaterTs(latestZombieTs, zombieDraft.writtenOn);
+          }
+          zombieDrafts.forEach(
+              zombieDraft ->
+                  logger.atWarning().log(
+                      "Draft comment with uuid '%s' of change %s, account %s, written on %s,"
+                          + " is a zombie draft that is already published.",
+                      zombieDraft.key.uuid, changeId, accountId, zombieDraft.writtenOn));
+          if (!zombieDrafts.isEmpty() && !dryRun) {
+            deleteZombieComments(accountId, notes, zombieDrafts);
+          }
+          numZombies += zombieDrafts.size();
+        } catch (Exception e) {
+          logger.atWarning().withCause(e).log("Failed to process ref %s", draftRef.getName());
+        }
+      }
+      if (numZombies > 0) {
+        logger.atWarning().log(
+            "Detected %d additional zombie drafts (earliest at %s, latest at %s).",
+            numZombies, earliestZombieTs, latestZombieTs);
+      }
+      return numZombies;
+    }
+  }
+
+  @AutoValue
+  abstract static class ChangeUserIDsPair {
+    abstract Change.Id changeId();
+
+    abstract Account.Id accountId();
+
+    static ChangeUserIDsPair create(Change.Id changeId, Account.Id accountId) {
+      return new AutoValue_DeleteZombieCommentsRefs_ChangeUserIDsPair(changeId, accountId);
+    }
+  }
+
+  /**
+   * Accepts a list of draft (zombie) comments for the same change and delete them by executing a
+   * {@link ChangeUpdate} on NoteDb. The update is executed using the user account who created this
+   * draft.
+   */
+  private void deleteZombieComments(
+      Account.Id accountId, ChangeNotes changeNotes, List<HumanComment> draftsToDelete)
+      throws IOException {
+    if (changeUpdateFactory == null || userFactory == null) {
+      return;
+    }
+    ChangeUpdate changeUpdate =
+        changeUpdateFactory.create(changeNotes, userFactory.create(accountId), TimeUtil.now());
+    draftsToDelete.forEach(c -> changeUpdate.deleteComment(c));
+    changeUpdate.commit();
+    logger.atInfo().log(
+        "Deleted zombie draft comments with UUIDs %s",
+        draftsToDelete.stream().map(d -> d.key.uuid).collect(Collectors.toList()));
+  }
+
+  /**
+   * Map each change ID to its associated project.
+   *
+   * <p>When doing a ref scan of draft refs
+   * "refs/draft-comments/$change_id_short/$change_id/$user_id" we don't know which project this
+   * draft comment is associated with. The project name is needed to load published comments for the
+   * change, hence we map each change ID to its project here by scanning through the change meta ref
+   * of the change ID in all projects.
+   */
+  private Map<Change.Id, Project.NameKey> mapChangeIdsToProjects(
+      ImmutableSet<Change.Id> changeIds) {
+    Map<Change.Id, Project.NameKey> result = new HashMap<>();
+    for (Project.NameKey project : repoManager.list()) {
+      try (Repository repo = repoManager.openRepository(project)) {
+        SetView<Change.Id> unmappedChangeIds = Sets.difference(changeIds, result.keySet());
+        for (Change.Id changeId : unmappedChangeIds) {
+          Ref ref = repo.getRefDatabase().exactRef(RefNames.changeMetaRef(changeId));
+          if (ref != null) {
+            result.put(changeId, project);
+          }
+        }
+      } catch (Exception e) {
+        logger.atWarning().withCause(e).log("Failed to open repository for project '%s'.", project);
+      }
+      if (changeIds.size() == result.size()) {
+        // We do not need to scan the remaining repositories
+        break;
+      }
+    }
+    if (result.size() != changeIds.size()) {
+      logger.atWarning().log(
+          "Failed to associate the following change Ids to a project: %s",
+          Sets.difference(changeIds, result.keySet()));
+    }
+    return result;
+  }
+
+  /** Map the list of input comments to their UUIDs. */
+  private Set<String> toUuid(List<HumanComment> in) {
+    return in.stream().map(c -> c.key.uuid).collect(Collectors.toSet());
+  }
+
+  private Timestamp getEarlierTs(@Nullable Timestamp t1, Timestamp t2) {
+    if (t1 == null) {
+      return t2;
+    }
+    return t1.before(t2) ? t1 : t2;
+  }
+
+  private Timestamp getLaterTs(@Nullable Timestamp t1, Timestamp t2) {
+    if (t1 == null) {
+      return t2;
+    }
+    return t1.after(t2) ? t1 : t2;
+  }
+
+  private void deleteBatchZombieRefs(Repository allUsersRepo, List<Ref> refsBatch)
+      throws IOException {
     List<ReceiveCommand> deleteCommands =
         refsBatch.stream()
             .map(
@@ -126,18 +415,19 @@
     RefUpdateUtil.executeChecked(bru, allUsersRepo);
   }
 
-  private List<Ref> filterZombieRefs(List<Ref> allDraftRefs) throws IOException {
+  private List<Ref> filterZombieRefs(Repository allUsersRepo, List<Ref> allDraftRefs)
+      throws IOException {
     List<Ref> zombieRefs = new ArrayList<>((int) (allDraftRefs.size() * 0.5));
     for (Ref ref : allDraftRefs) {
-      if (isZombieRef(ref)) {
+      if (isZombieRef(allUsersRepo, ref)) {
         zombieRefs.add(ref);
       }
     }
     return zombieRefs;
   }
 
-  private boolean isZombieRef(Ref ref) throws IOException {
-    return allUsersRepo.parseCommit(ref.getObjectId()).getTree().getName().equals(EMPTY_TREE_ID);
+  private boolean isZombieRef(Repository allUsersRepo, Ref ref) throws IOException {
+    return allUsersRepo.parseCommit(ref.getObjectId()).getTree().getId().equals(EMPTY_TREE_ID);
   }
 
   private void logInfo(String message) {
diff --git a/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java b/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java
index 94e11c8..ad1f4c5 100644
--- a/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java
+++ b/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java
@@ -18,14 +18,18 @@
 import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.common.base.Preconditions.checkState;
+import static com.google.common.collect.ImmutableListMultimap.flatteningToImmutableListMultimap;
 import static com.google.gerrit.server.logging.TraceContext.newTimer;
 
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.MultimapBuilder;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.ProjectChangeKey;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.git.RefUpdateUtil;
@@ -73,7 +77,7 @@
 public class NoteDbUpdateManager implements AutoCloseable {
   private static final int MAX_UPDATES_DEFAULT = 1000;
   /** Limits the number of patch sets that can be created. Can be overridden in the config. */
-  private static final int MAX_PATCH_SETS_DEFAULT = 1500;
+  private static final int MAX_PATCH_SETS_DEFAULT = 1000;
 
   public interface Factory {
     NoteDbUpdateManager create(Project.NameKey projectName);
@@ -353,6 +357,14 @@
     }
   }
 
+  public ImmutableListMultimap<ProjectChangeKey, AttentionSetUpdate> attentionSetUpdates() {
+    return this.changeUpdates.values().stream()
+        .collect(
+            flatteningToImmutableListMultimap(
+                cu -> ProjectChangeKey.create(cu.getProjectName(), cu.getId()),
+                cu -> cu.getAttentionSetUpdates().stream()));
+  }
+
   private BatchRefUpdate execute(OpenRepo or, boolean dryrun, @Nullable PushCertificate pushCert)
       throws IOException {
     if (or == null || or.cmds.isEmpty()) {
diff --git a/java/com/google/gerrit/server/notedb/NoteDbUtil.java b/java/com/google/gerrit/server/notedb/NoteDbUtil.java
index 396e29b..64bf430 100644
--- a/java/com/google/gerrit/server/notedb/NoteDbUtil.java
+++ b/java/com/google/gerrit/server/notedb/NoteDbUtil.java
@@ -20,8 +20,12 @@
 import com.google.common.primitives.Ints;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIdCache;
+import java.io.IOException;
 import java.sql.Timestamp;
 import java.util.Optional;
+import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.util.GitDateFormatter;
 import org.eclipse.jgit.util.GitDateFormatter.Format;
@@ -48,6 +52,45 @@
     return Optional.empty();
   }
 
+  /**
+   * Returns an AccountId for the given email address and the current serverId. Reverse lookup the
+   * AccountId using the ExternalIdCache if the account has a foreign serverId.
+   *
+   * @param ident the accountId@serverId identity
+   * @param serverId the Gerrit's serverId
+   * @param externalIdCache reference to the cache for looking up the external ids
+   * @return a defined accountId if the account was found, {@link Account#UNKNOWN_ACCOUNT_ID} if the
+   *     lookup via external-id did not return any account, or an empty value if the identity was
+   *     malformed.
+   * @throws ConfigInvalidException when the lookup of the external-id failed
+   */
+  public static Optional<Account.Id> parseIdent(
+      PersonIdent ident, String serverId, ExternalIdCache externalIdCache)
+      throws ConfigInvalidException {
+    String email = ident.getEmailAddress();
+    int at = email.indexOf('@');
+    if (at >= 0) {
+      Integer id = Ints.tryParse(email.substring(0, at));
+      String accountServerId = email.substring(at + 1);
+      if (id != null) {
+        if (accountServerId.equals(serverId)) {
+          return Optional.of(Account.id(id));
+        }
+
+        ExternalId.Key extIdKey = ExternalId.Key.create(ExternalId.SCHEME_IMPORTED, email, false);
+        try {
+          return externalIdCache
+              .byKey(extIdKey)
+              .map(ExternalId::accountId)
+              .or(() -> Optional.of(Account.UNKNOWN_ACCOUNT_ID));
+        } catch (IOException e) {
+          throw new ConfigInvalidException("Unable to lookup external id from cache", e);
+        }
+      }
+    }
+    return Optional.empty();
+  }
+
   public static String extractHostPartFromPersonIdent(PersonIdent ident) {
     String email = ident.getEmailAddress();
     int at = email.indexOf('@');
diff --git a/java/com/google/gerrit/server/notedb/SubmitRequirementProtoConverter.java b/java/com/google/gerrit/server/notedb/SubmitRequirementProtoConverter.java
index a815f57..96d3080 100644
--- a/java/com/google/gerrit/server/notedb/SubmitRequirementProtoConverter.java
+++ b/java/com/google/gerrit/server/notedb/SubmitRequirementProtoConverter.java
@@ -40,6 +40,8 @@
       SubmitRequirementResultProto.getDescriptor().findFieldByNumber(6);
   private static final FieldDescriptor SR_FORCED_FIELD =
       SubmitRequirementResultProto.getDescriptor().findFieldByNumber(7);
+  private static final FieldDescriptor SR_HIDDEN_FIELD =
+      SubmitRequirementResultProto.getDescriptor().findFieldByNumber(8);
 
   @Override
   public SubmitRequirementResultProto toProto(SubmitRequirementResult r) {
@@ -53,6 +55,9 @@
     if (r.forced().isPresent()) {
       builder.setForced(r.forced().get());
     }
+    if (r.hidden().isPresent()) {
+      builder.setHidden(r.hidden().get());
+    }
     if (r.applicabilityExpressionResult().isPresent()) {
       builder.setApplicabilityExpressionResult(
           SubmitRequirementExpressionResultSerializer.serialize(
@@ -84,6 +89,9 @@
     if (proto.hasField(SR_FORCED_FIELD)) {
       builder.forced(Optional.of(proto.getForced()));
     }
+    if (proto.hasField(SR_HIDDEN_FIELD)) {
+      builder.hidden(Optional.of(proto.getHidden()));
+    }
     if (proto.hasField(SR_APPLICABILITY_EXPR_RESULT_FIELD)) {
       builder.applicabilityExpressionResult(
           Optional.of(
diff --git a/java/com/google/gerrit/server/patch/AutoMerger.java b/java/com/google/gerrit/server/patch/AutoMerger.java
index f29d1c1..1f4720d 100644
--- a/java/com/google/gerrit/server/patch/AutoMerger.java
+++ b/java/com/google/gerrit/server/patch/AutoMerger.java
@@ -169,7 +169,9 @@
       return Optional.empty();
     }
 
-    if (repoView.getRef(RefNames.refsCacheAutomerge(maybeMergeCommit.name())).isPresent()) {
+    String automergeRef = RefNames.refsCacheAutomerge(maybeMergeCommit.name());
+    logger.atFine().log("AutoMerge ref=%s, mergeCommit=%s", automergeRef, maybeMergeCommit.name());
+    if (repoView.getRef(automergeRef).isPresent()) {
       logger.atFine().log("AutoMerge alredy exists");
       return Optional.empty();
     }
@@ -178,7 +180,7 @@
         new ReceiveCommand(
             ObjectId.zeroId(),
             createAutoMergeCommit(repoView, rw, ins, maybeMergeCommit),
-            RefNames.refsCacheAutomerge(maybeMergeCommit.name())));
+            automergeRef));
   }
 
   /**
@@ -250,6 +252,7 @@
               merge.getParent(1),
               m.getMergeResults());
     }
+    logger.atFine().log("AutoMerge treeId=%s", treeId.name());
 
     rw.parseHeaders(merge);
     // For maximum stability, choose a single ident using the committer time of
@@ -268,16 +271,20 @@
       cb.addParentId(p);
     }
 
+    ObjectId commitId = ins.insert(cb);
+    logger.atFine().log("AutoMerge commitId=%s", commitId.name());
+    ins.flush();
+
     if (ins instanceof InMemoryInserter) {
       // When using an InMemoryInserter we need to read back the values from that inserter because
       // they are not available.
       try (ObjectReader tmpReader = ins.newReader();
           RevWalk tmpRw = new RevWalk(tmpReader)) {
-        return tmpRw.parseCommit(ins.insert(cb));
+        return tmpRw.parseCommit(commitId);
       }
     }
 
-    return rw.parseCommit(ins.insert(cb));
+    return rw.parseCommit(commitId);
   }
 
   private static class NonFlushingWrapper extends ObjectInserter.Filter {
diff --git a/java/com/google/gerrit/server/patch/BaseCommitUtil.java b/java/com/google/gerrit/server/patch/BaseCommitUtil.java
index dcd3e85..56a01b9 100644
--- a/java/com/google/gerrit/server/patch/BaseCommitUtil.java
+++ b/java/com/google/gerrit/server/patch/BaseCommitUtil.java
@@ -17,7 +17,6 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
-import com.google.gerrit.git.LockFailureException;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.InMemoryInserter;
@@ -31,7 +30,6 @@
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.ObjectReader;
-import org.eclipse.jgit.lib.RefUpdate;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevObject;
@@ -141,37 +139,7 @@
     ObjectId autoMergeId =
         autoMerger.createAutoMergeCommit(new RepoView(repo, rw, ins), rw, ins, mergeCommit);
     ins.flush();
-    return updateRef(repo, rw, refName, autoMergeId, mergeCommit);
-  }
-
-  private static RevCommit updateRef(
-      Repository repo, RevWalk rw, String refName, ObjectId autoMergeId, RevCommit merge)
-      throws IOException {
-    RefUpdate ru = repo.updateRef(refName);
-    ru.setNewObjectId(autoMergeId);
-    ru.disableRefLog();
-    switch (ru.forceUpdate()) {
-      case FAST_FORWARD:
-      case FORCED:
-      case NEW:
-      case NO_CHANGE:
-        return rw.parseCommit(autoMergeId);
-      case LOCK_FAILURE:
-        throw new LockFailureException(
-            String.format("Failed to create auto-merge of %s", merge.name()), ru);
-      case IO_FAILURE:
-      case NOT_ATTEMPTED:
-      case REJECTED:
-      case REJECTED_CURRENT_BRANCH:
-      case REJECTED_MISSING_OBJECT:
-      case REJECTED_OTHER_REASON:
-      case RENAMED:
-      default:
-        throw new IOException(
-            String.format(
-                "Failed to create auto-merge of %s: Cannot write %s (%s)",
-                merge.name(), refName, ru.getResult()));
-    }
+    return rw.parseCommit(autoMergeId);
   }
 
   private ObjectInserter newInserter(Repository repo) {
diff --git a/java/com/google/gerrit/server/patch/PatchScriptFactory.java b/java/com/google/gerrit/server/patch/PatchScriptFactory.java
index 02f125a..3baa3b1 100644
--- a/java/com/google/gerrit/server/patch/PatchScriptFactory.java
+++ b/java/com/google/gerrit/server/patch/PatchScriptFactory.java
@@ -175,10 +175,8 @@
       throws LargeObjectException, AuthException, InvalidChangeOperationException, IOException,
           PermissionBackendException {
 
-    try {
-      permissionBackend.user(currentUser).change(notes).check(ChangePermission.READ);
-    } catch (AuthException e) {
-      throw new NoSuchChangeException(changeId, e);
+    if (!permissionBackend.user(currentUser).change(notes).test(ChangePermission.READ)) {
+      throw new NoSuchChangeException(changeId);
     }
 
     if (!projectCache
diff --git a/java/com/google/gerrit/server/patch/PatchScriptFactoryForAutoFix.java b/java/com/google/gerrit/server/patch/PatchScriptFactoryForAutoFix.java
index accd2bd..348e244 100644
--- a/java/com/google/gerrit/server/patch/PatchScriptFactoryForAutoFix.java
+++ b/java/com/google/gerrit/server/patch/PatchScriptFactoryForAutoFix.java
@@ -97,10 +97,8 @@
       throws LargeObjectException, AuthException, InvalidChangeOperationException, IOException,
           PermissionBackendException, ResourceNotFoundException {
 
-    try {
-      permissionBackend.currentUser().change(notes).check(ChangePermission.READ);
-    } catch (AuthException e) {
-      throw new NoSuchChangeException(changeId, e);
+    if (!permissionBackend.currentUser().change(notes).test(ChangePermission.READ)) {
+      throw new NoSuchChangeException(changeId);
     }
 
     if (!projectCache
diff --git a/java/com/google/gerrit/server/patch/SubmitWithStickyApprovalDiff.java b/java/com/google/gerrit/server/patch/SubmitWithStickyApprovalDiff.java
index 2385a70..7562b49 100644
--- a/java/com/google/gerrit/server/patch/SubmitWithStickyApprovalDiff.java
+++ b/java/com/google/gerrit/server/patch/SubmitWithStickyApprovalDiff.java
@@ -294,7 +294,7 @@
     ProjectState projectState =
         projectCache.get(notes.getProjectName()).orElseThrow(illegalState(notes.getProjectName()));
     PatchSet.Id maxPatchSetId = PatchSet.id(notes.getChangeId(), 1);
-    for (PatchSetApproval patchSetApproval : notes.getApprovals().values()) {
+    for (PatchSetApproval patchSetApproval : notes.getApprovals().onlyNonCopied().values()) {
       if (!patchSetApproval.label().equals(LabelId.CODE_REVIEW)) {
         continue;
       }
diff --git a/java/com/google/gerrit/server/patch/gitdiff/ModifiedFile.java b/java/com/google/gerrit/server/patch/gitdiff/ModifiedFile.java
index a464235..62d66c0 100644
--- a/java/com/google/gerrit/server/patch/gitdiff/ModifiedFile.java
+++ b/java/com/google/gerrit/server/patch/gitdiff/ModifiedFile.java
@@ -15,6 +15,8 @@
 package com.google.gerrit.server.patch.gitdiff;
 
 import com.google.auto.value.AutoValue;
+import com.google.common.base.Converter;
+import com.google.common.base.Enums;
 import com.google.gerrit.entities.Patch.ChangeType;
 import com.google.gerrit.proto.Protos;
 import com.google.gerrit.server.cache.proto.Cache.ModifiedFileProto;
@@ -84,6 +86,9 @@
   enum Serializer implements CacheSerializer<ModifiedFile> {
     INSTANCE;
 
+    private static final Converter<String, ChangeType> CHANGE_TYPE_CONVERTER =
+        Enums.stringConverter(ChangeType.class);
+
     private static final FieldDescriptor oldPathDescriptor =
         ModifiedFileProto.getDescriptor().findFieldByNumber(2);
 
@@ -97,7 +102,7 @@
 
     public ModifiedFileProto toProto(ModifiedFile modifiedFile) {
       ModifiedFileProto.Builder builder = ModifiedFileProto.newBuilder();
-      builder.setChangeType(modifiedFile.changeType().toString());
+      builder.setChangeType(CHANGE_TYPE_CONVERTER.reverse().convert(modifiedFile.changeType()));
       if (modifiedFile.oldPath().isPresent()) {
         builder.setOldPath(modifiedFile.oldPath().get());
       }
@@ -115,7 +120,7 @@
 
     public ModifiedFile fromProto(ModifiedFileProto modifiedFileProto) {
       ModifiedFile.Builder builder = ModifiedFile.builder();
-      builder.changeType(ChangeType.valueOf(modifiedFileProto.getChangeType()));
+      builder.changeType(CHANGE_TYPE_CONVERTER.convert(modifiedFileProto.getChangeType()));
 
       if (modifiedFileProto.hasField(oldPathDescriptor)) {
         builder.oldPath(Optional.of(modifiedFileProto.getOldPath()));
diff --git a/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiff.java b/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiff.java
index 22ec328..d0d024c 100644
--- a/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiff.java
+++ b/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiff.java
@@ -18,6 +18,8 @@
 import static com.google.gerrit.server.patch.DiffUtil.stringSize;
 
 import com.google.auto.value.AutoValue;
+import com.google.common.base.Converter;
+import com.google.common.base.Enums;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.entities.Patch;
@@ -232,6 +234,15 @@
   public enum Serializer implements CacheSerializer<GitFileDiff> {
     INSTANCE;
 
+    private static final Converter<String, Patch.FileMode> FILE_MODE_CONVERTER =
+        Enums.stringConverter(Patch.FileMode.class);
+
+    private static final Converter<String, Patch.ChangeType> CHANGE_TYPE_CONVERTER =
+        Enums.stringConverter(Patch.ChangeType.class);
+
+    private static final Converter<String, Patch.PatchType> PATCH_TYPE_CONVERTER =
+        Enums.stringConverter(Patch.PatchType.class);
+
     private static final FieldDescriptor OLD_PATH_DESCRIPTOR =
         GitFileDiffProto.getDescriptor().findFieldByNumber(3);
 
@@ -258,7 +269,7 @@
               .setFileHeader(gitFileDiff.fileHeader())
               .setOldId(idConverter.toByteString(gitFileDiff.oldId().toObjectId()))
               .setNewId(idConverter.toByteString(gitFileDiff.newId().toObjectId()))
-              .setChangeType(gitFileDiff.changeType().name());
+              .setChangeType(CHANGE_TYPE_CONVERTER.reverse().convert(gitFileDiff.changeType()));
       gitFileDiff
           .edits()
           .forEach(
@@ -276,13 +287,13 @@
         builder.setNewPath(gitFileDiff.newPath().get());
       }
       if (gitFileDiff.oldMode().isPresent()) {
-        builder.setOldMode(gitFileDiff.oldMode().get().name());
+        builder.setOldMode(FILE_MODE_CONVERTER.reverse().convert(gitFileDiff.oldMode().get()));
       }
       if (gitFileDiff.newMode().isPresent()) {
-        builder.setNewMode(gitFileDiff.newMode().get().name());
+        builder.setNewMode(FILE_MODE_CONVERTER.reverse().convert(gitFileDiff.newMode().get()));
       }
       if (gitFileDiff.patchType().isPresent()) {
-        builder.setPatchType(gitFileDiff.patchType().get().name());
+        builder.setPatchType(PATCH_TYPE_CONVERTER.reverse().convert(gitFileDiff.patchType().get()));
       }
       if (gitFileDiff.negative().isPresent()) {
         builder.setNegative(gitFileDiff.negative().get());
@@ -303,7 +314,7 @@
           .fileHeader(proto.getFileHeader())
           .oldId(AbbreviatedObjectId.fromObjectId(idConverter.fromByteString(proto.getOldId())))
           .newId(AbbreviatedObjectId.fromObjectId(idConverter.fromByteString(proto.getNewId())))
-          .changeType(ChangeType.valueOf(proto.getChangeType()));
+          .changeType(CHANGE_TYPE_CONVERTER.convert(proto.getChangeType()));
 
       if (proto.hasField(OLD_PATH_DESCRIPTOR)) {
         builder.oldPath(Optional.of(proto.getOldPath()));
@@ -312,13 +323,13 @@
         builder.newPath(Optional.of(proto.getNewPath()));
       }
       if (proto.hasField(OLD_MODE_DESCRIPTOR)) {
-        builder.oldMode(Optional.of(Patch.FileMode.valueOf(proto.getOldMode())));
+        builder.oldMode(Optional.of(FILE_MODE_CONVERTER.convert(proto.getOldMode())));
       }
       if (proto.hasField(NEW_MODE_DESCRIPTOR)) {
-        builder.newMode(Optional.of(Patch.FileMode.valueOf(proto.getNewMode())));
+        builder.newMode(Optional.of(FILE_MODE_CONVERTER.convert(proto.getNewMode())));
       }
       if (proto.hasField(PATCH_TYPE_DESCRIPTOR)) {
-        builder.patchType(Optional.of(Patch.PatchType.valueOf(proto.getPatchType())));
+        builder.patchType(Optional.of(PATCH_TYPE_CONVERTER.convert(proto.getPatchType())));
       }
       if (proto.hasField(NEGATIVE_DESCRIPTOR)) {
         builder.negative(Optional.of(proto.getNegative()));
diff --git a/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCacheKey.java b/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCacheKey.java
index 2d80614..2e18e93 100644
--- a/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCacheKey.java
+++ b/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCacheKey.java
@@ -17,6 +17,8 @@
 import static com.google.gerrit.server.patch.DiffUtil.stringSize;
 
 import com.google.auto.value.AutoValue;
+import com.google.common.base.Converter;
+import com.google.common.base.Enums;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.Project.NameKey;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo;
@@ -104,6 +106,12 @@
   public enum Serializer implements CacheSerializer<GitFileDiffCacheKey> {
     INSTANCE;
 
+    private static final Converter<String, DiffAlgorithm> DIFF_ALGORITHM_CONVERTER =
+        Enums.stringConverter(DiffAlgorithm.class);
+
+    private static final Converter<String, Whitespace> WHITESPACE_CONVERTER =
+        Enums.stringConverter(Whitespace.class);
+
     @Override
     public byte[] serialize(GitFileDiffCacheKey key) {
       ObjectIdConverter idConverter = ObjectIdConverter.create();
@@ -114,8 +122,8 @@
               .setBTree(idConverter.toByteString(key.newTree()))
               .setFilePath(key.newFilePath())
               .setRenameScore(key.renameScore())
-              .setDiffAlgorithm(key.diffAlgorithm().name())
-              .setWhitepsace(key.whitespace().name())
+              .setDiffAlgorithm(DIFF_ALGORITHM_CONVERTER.reverse().convert(key.diffAlgorithm()))
+              .setWhitepsace(WHITESPACE_CONVERTER.reverse().convert(key.whitespace()))
               .setUseTimeout(key.useTimeout())
               .build());
     }
@@ -130,8 +138,8 @@
           .newTree(idConverter.fromByteString(proto.getBTree()))
           .newFilePath(proto.getFilePath())
           .renameScore(proto.getRenameScore())
-          .diffAlgorithm(DiffAlgorithm.valueOf(proto.getDiffAlgorithm()))
-          .whitespace(Whitespace.valueOf(proto.getWhitepsace()))
+          .diffAlgorithm(DIFF_ALGORITHM_CONVERTER.convert(proto.getDiffAlgorithm()))
+          .whitespace(WHITESPACE_CONVERTER.convert(proto.getWhitepsace()))
           .useTimeout(proto.getUseTimeout())
           .build();
     }
diff --git a/java/com/google/gerrit/server/permissions/GitVisibleChangeFilter.java b/java/com/google/gerrit/server/permissions/GitVisibleChangeFilter.java
index 506d292..12a7841 100644
--- a/java/com/google/gerrit/server/permissions/GitVisibleChangeFilter.java
+++ b/java/com/google/gerrit/server/permissions/GitVisibleChangeFilter.java
@@ -130,11 +130,10 @@
                   notesResult -> {
                     if (!notesResult.error().isPresent()) {
                       return changeDataFactory.create(notesResult.notes());
-                    } else {
-                      logger.atWarning().withCause(notesResult.error().get()).log(
-                          "Unable to load ChangeNotes for %s", notesResult.id());
-                      return null;
                     }
+                    logger.atWarning().withCause(notesResult.error().get()).log(
+                        "Unable to load ChangeNotes for %s", notesResult.id());
+                    return null;
                   })
               .filter(Objects::nonNull);
     } catch (IOException e) {
diff --git a/java/com/google/gerrit/server/project/LabelConfigValidator.java b/java/com/google/gerrit/server/project/LabelConfigValidator.java
new file mode 100644
index 0000000..3e5ff6b
--- /dev/null
+++ b/java/com/google/gerrit/server/project/LabelConfigValidator.java
@@ -0,0 +1,357 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.project;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Sets;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.LabelFunction;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.client.ChangeKind;
+import com.google.gerrit.server.events.CommitReceivedEvent;
+import com.google.gerrit.server.git.validators.CommitValidationException;
+import com.google.gerrit.server.git.validators.CommitValidationListener;
+import com.google.gerrit.server.git.validators.CommitValidationMessage;
+import com.google.gerrit.server.git.validators.ValidationMessage;
+import com.google.gerrit.server.patch.DiffNotAvailableException;
+import com.google.gerrit.server.patch.DiffOperations;
+import com.google.gerrit.server.patch.DiffOptions;
+import com.google.gerrit.server.patch.filediff.FileDiffOutput;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
+
+/**
+ * Validates modifications to label configurations in the {@code project.config} file that is stored
+ * in {@code refs/meta/config}.
+ *
+ * <p>Rejects setting/changing deprecated fields that are no longer supported (fields {@code
+ * copyAnyScore}, {@code copyMinScore}, {@code copyMaxScore}, {@code copyAllScoresIfNoChange},
+ * {@code copyAllScoresIfNoCodeChange}, {@code copyAllScoresOnMergeFirstParentUpdate}, {@code
+ * copyAllScoresOnTrivialRebase}, {@code copyAllScoresIfListOfFilesDidNotChange}, {@code
+ * copyValue}).
+ *
+ * <p>Updates that unset the deprecated fields or that don't touch them are allowed.
+ */
+@Singleton
+public class LabelConfigValidator implements CommitValidationListener {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  @VisibleForTesting public static final String KEY_COPY_ANY_SCORE = "copyAnyScore";
+
+  @VisibleForTesting public static final String KEY_COPY_MIN_SCORE = "copyMinScore";
+
+  @VisibleForTesting public static final String KEY_COPY_MAX_SCORE = "copyMaxScore";
+
+  @VisibleForTesting public static final String KEY_COPY_VALUE = "copyValue";
+
+  @VisibleForTesting
+  public static final String KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE =
+      "copyAllScoresOnMergeFirstParentUpdate";
+
+  @VisibleForTesting
+  public static final String KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE = "copyAllScoresOnTrivialRebase";
+
+  @VisibleForTesting
+  public static final String KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE = "copyAllScoresIfNoCodeChange";
+
+  @VisibleForTesting
+  public static final String KEY_COPY_ALL_SCORES_IF_NO_CHANGE = "copyAllScoresIfNoChange";
+
+  @VisibleForTesting
+  public static final String KEY_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE =
+      "copyAllScoresIfListOfFilesDidNotChange";
+
+  // Map of deprecated boolean flags to the predicates that should be used in the copy condition
+  // instead.
+  private static final ImmutableMap<String, String> DEPRECATED_FLAGS =
+      ImmutableMap.<String, String>builder()
+          .put(KEY_COPY_ANY_SCORE, "is:ANY")
+          .put(KEY_COPY_MIN_SCORE, "is:MIN")
+          .put(KEY_COPY_MAX_SCORE, "is:MAX")
+          .put(KEY_COPY_ALL_SCORES_IF_NO_CHANGE, "changekind:" + ChangeKind.NO_CHANGE.name())
+          .put(
+              KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE,
+              "changekind:" + ChangeKind.NO_CODE_CHANGE.name())
+          .put(
+              KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE,
+              "changekind:" + ChangeKind.MERGE_FIRST_PARENT_UPDATE.name())
+          .put(
+              KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE,
+              "changekind:" + ChangeKind.TRIVIAL_REBASE.name())
+          .put(KEY_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE, "has:unchanged-files")
+          .build();
+
+  private final DiffOperations diffOperations;
+
+  @Inject
+  public LabelConfigValidator(DiffOperations diffOperations) {
+    this.diffOperations = diffOperations;
+  }
+
+  @Override
+  public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
+      throws CommitValidationException {
+    try {
+      if (!receiveEvent.refName.equals(RefNames.REFS_CONFIG)
+          || !isFileChanged(receiveEvent, ProjectConfig.PROJECT_CONFIG)) {
+        // The project.config file in refs/meta/config was not modified, hence we do not need to do
+        // any validation and can return early.
+        return ImmutableList.of();
+      }
+
+      ImmutableList.Builder<CommitValidationMessage> validationMessageBuilder =
+          ImmutableList.builder();
+
+      // Load the new config
+      Config newConfig;
+      try {
+        newConfig = loadNewConfig(receiveEvent);
+      } catch (ConfigInvalidException e) {
+        // The current config is invalid, hence we cannot inspect the delta.
+        // Rejecting invalid configs is not the responsibility of this validator, hence ignore this
+        // exception here.
+        logger.atWarning().log(
+            "cannot inspect the project config, because parsing %s from revision %s"
+                + " in project %s failed: %s",
+            ProjectConfig.PROJECT_CONFIG,
+            receiveEvent.commit.name(),
+            receiveEvent.getProjectNameKey(),
+            e.getMessage());
+        return ImmutableList.of();
+      }
+
+      // Load the old config
+      Optional<Config> oldConfig = loadOldConfig(receiveEvent);
+
+      for (String labelName : newConfig.getSubsections(ProjectConfig.LABEL)) {
+        for (String deprecatedFlag : DEPRECATED_FLAGS.keySet()) {
+          if (flagChangedOrNewlySet(newConfig, oldConfig.orElse(null), labelName, deprecatedFlag)) {
+            validationMessageBuilder.add(
+                new CommitValidationMessage(
+                    String.format(
+                        "Parameter '%s.%s.%s' is deprecated and cannot be set,"
+                            + " use '%s' in '%s.%s.%s' instead.",
+                        ProjectConfig.LABEL,
+                        labelName,
+                        deprecatedFlag,
+                        DEPRECATED_FLAGS.get(deprecatedFlag),
+                        ProjectConfig.LABEL,
+                        labelName,
+                        ProjectConfig.KEY_COPY_CONDITION),
+                    ValidationMessage.Type.ERROR));
+          }
+        }
+
+        if (copyValuesChangedOrNewlySet(newConfig, oldConfig.orElse(null), labelName)) {
+          validationMessageBuilder.add(
+              new CommitValidationMessage(
+                  String.format(
+                      "Parameter '%s.%s.%s' is deprecated and cannot be set,"
+                          + " use 'is:<copy-value>' in '%s.%s.%s' instead.",
+                      ProjectConfig.LABEL,
+                      labelName,
+                      KEY_COPY_VALUE,
+                      ProjectConfig.LABEL,
+                      labelName,
+                      ProjectConfig.KEY_COPY_CONDITION),
+                  ValidationMessage.Type.ERROR));
+        }
+
+        // Ban modifying label functions to any blocking function value
+        if (flagChangedOrNewlySet(
+            newConfig, oldConfig.orElse(null), labelName, ProjectConfig.KEY_FUNCTION)) {
+          String fnName =
+              newConfig.getString(ProjectConfig.LABEL, labelName, ProjectConfig.KEY_FUNCTION);
+          Optional<LabelFunction> labelFn = LabelFunction.parse(fnName);
+          if (labelFn.isPresent() && !isLabelFunctionAllowed(labelFn.get())) {
+            validationMessageBuilder.add(
+                new CommitValidationMessage(
+                    String.format(
+                        "Value '%s' of '%s.%s.%s' is not allowed and cannot be set."
+                            + " Label functions can only be set to {%s, %s, %s}."
+                            + " Use submit requirements instead of label functions.",
+                        fnName,
+                        ProjectConfig.LABEL,
+                        labelName,
+                        ProjectConfig.KEY_FUNCTION,
+                        LabelFunction.NO_BLOCK,
+                        LabelFunction.NO_OP,
+                        LabelFunction.PATCH_SET_LOCK),
+                    ValidationMessage.Type.ERROR));
+          }
+        }
+
+        // Ban deletions of label functions as well since the default is MaxWithBlock
+        if (flagDeleted(newConfig, oldConfig.orElse(null), labelName, ProjectConfig.KEY_FUNCTION)) {
+          validationMessageBuilder.add(
+              new CommitValidationMessage(
+                  String.format(
+                      "Cannot delete '%s.%s.%s'."
+                          + " Label functions can only be set to {%s, %s, %s}."
+                          + " Use submit requirements instead of label functions.",
+                      ProjectConfig.LABEL,
+                      labelName,
+                      ProjectConfig.KEY_FUNCTION,
+                      LabelFunction.NO_BLOCK,
+                      LabelFunction.NO_OP,
+                      LabelFunction.PATCH_SET_LOCK),
+                  ValidationMessage.Type.ERROR));
+        }
+      }
+
+      ImmutableList<CommitValidationMessage> validationMessages = validationMessageBuilder.build();
+      if (!validationMessages.isEmpty()) {
+        throw new CommitValidationException(
+            String.format(
+                "invalid %s file in revision %s",
+                ProjectConfig.PROJECT_CONFIG, receiveEvent.commit.getName()),
+            validationMessages);
+      }
+      return ImmutableList.of();
+    } catch (IOException | DiffNotAvailableException e) {
+      String errorMessage =
+          String.format(
+              "failed to validate file %s for revision %s in ref %s of project %s",
+              ProjectConfig.PROJECT_CONFIG,
+              receiveEvent.commit.getName(),
+              RefNames.REFS_CONFIG,
+              receiveEvent.getProjectNameKey());
+      logger.atSevere().withCause(e).log("%s", errorMessage);
+      throw new CommitValidationException(errorMessage, e);
+    }
+  }
+
+  /**
+   * Whether the given file was changed in the given revision.
+   *
+   * @param receiveEvent the receive event
+   * @param fileName the name of the file
+   */
+  private boolean isFileChanged(CommitReceivedEvent receiveEvent, String fileName)
+      throws DiffNotAvailableException {
+    Map<String, FileDiffOutput> fileDiffOutputs;
+    if (receiveEvent.commit.getParentCount() > 0) {
+      // normal commit with one parent: use listModifiedFilesAgainstParent with parentNum = 1 to
+      // compare against the only parent (using parentNum = 0 to compare against the default parent
+      // would also work)
+      // merge commit with 2 or more parents: must use listModifiedFilesAgainstParent with parentNum
+      // = 1 to compare against the first parent (using parentNum = 0 would compare against the
+      // auto-merge)
+      fileDiffOutputs =
+          diffOperations.listModifiedFilesAgainstParent(
+              receiveEvent.getProjectNameKey(), receiveEvent.commit, 1, DiffOptions.DEFAULTS);
+    } else {
+      // initial commit: must use listModifiedFilesAgainstParent with parentNum = 0
+      fileDiffOutputs =
+          diffOperations.listModifiedFilesAgainstParent(
+              receiveEvent.getProjectNameKey(),
+              receiveEvent.commit,
+              /* parentNum=*/ 0,
+              DiffOptions.DEFAULTS);
+    }
+    return fileDiffOutputs.keySet().contains(fileName);
+  }
+
+  private Config loadNewConfig(CommitReceivedEvent receiveEvent)
+      throws IOException, ConfigInvalidException {
+    ProjectLevelConfig.Bare bareConfig = new ProjectLevelConfig.Bare(ProjectConfig.PROJECT_CONFIG);
+    bareConfig.load(receiveEvent.project.getNameKey(), receiveEvent.revWalk, receiveEvent.commit);
+    return bareConfig.getConfig();
+  }
+
+  private Optional<Config> loadOldConfig(CommitReceivedEvent receiveEvent) throws IOException {
+    if (receiveEvent.commit.getParentCount() == 0) {
+      // initial commit, an old config doesn't exist
+      return Optional.empty();
+    }
+
+    try {
+      ProjectLevelConfig.Bare bareConfig =
+          new ProjectLevelConfig.Bare(ProjectConfig.PROJECT_CONFIG);
+      bareConfig.load(
+          receiveEvent.project.getNameKey(),
+          receiveEvent.revWalk,
+          receiveEvent.commit.getParent(0));
+      return Optional.of(bareConfig.getConfig());
+    } catch (ConfigInvalidException e) {
+      // the old config is not parseable, treat this the same way as if an old config didn't exist
+      // so that all parameters in the new config are validated
+      logger.atWarning().log(
+          "cannot inspect the old project config, because parsing %s from parent revision %s"
+              + " in project %s failed: %s",
+          ProjectConfig.PROJECT_CONFIG,
+          receiveEvent.commit.name(),
+          receiveEvent.getProjectNameKey(),
+          e.getMessage());
+      return Optional.empty();
+    }
+  }
+
+  private static boolean flagChangedOrNewlySet(
+      Config newConfig, @Nullable Config oldConfig, String labelName, String key) {
+    if (oldConfig == null) {
+      return newConfig.getNames(ProjectConfig.LABEL, labelName).contains(key);
+    }
+
+    // Use getString rather than getBoolean so that we do not have to deal with values that cannot
+    // be parsed as a boolean.
+    String oldValue = oldConfig.getString(ProjectConfig.LABEL, labelName, key);
+    String newValue = newConfig.getString(ProjectConfig.LABEL, labelName, key);
+    return newValue != null && !newValue.equals(oldValue);
+  }
+
+  private static boolean flagDeleted(
+      Config newConfig, @Nullable Config oldConfig, String labelName, String key) {
+    if (oldConfig == null) {
+      return false;
+    }
+    String oldValue = oldConfig.getString(ProjectConfig.LABEL, labelName, key);
+    String newValue = newConfig.getString(ProjectConfig.LABEL, labelName, key);
+    return oldValue != null && newValue == null;
+  }
+
+  private static boolean copyValuesChangedOrNewlySet(
+      Config newConfig, @Nullable Config oldConfig, String labelName) {
+    if (oldConfig == null) {
+      return newConfig.getNames(ProjectConfig.LABEL, labelName).contains(KEY_COPY_VALUE);
+    }
+
+    // Ignore the order in which the copy values are defined in the new and old config, since the
+    // order doesn't matter for this parameter.
+    ImmutableSet<String> oldValues =
+        ImmutableSet.copyOf(
+            oldConfig.getStringList(ProjectConfig.LABEL, labelName, KEY_COPY_VALUE));
+    ImmutableSet<String> newValues =
+        ImmutableSet.copyOf(
+            newConfig.getStringList(ProjectConfig.LABEL, labelName, KEY_COPY_VALUE));
+    return !newValues.isEmpty() && !Sets.difference(newValues, oldValues).isEmpty();
+  }
+
+  private static boolean isLabelFunctionAllowed(LabelFunction labelFunction) {
+    return labelFunction.equals(LabelFunction.NO_BLOCK)
+        || labelFunction.equals(LabelFunction.NO_OP)
+        || labelFunction.equals(LabelFunction.PATCH_SET_LOCK);
+  }
+}
diff --git a/java/com/google/gerrit/server/project/LabelDefinitionJson.java b/java/com/google/gerrit/server/project/LabelDefinitionJson.java
index 71ea12b..235eb34 100644
--- a/java/com/google/gerrit/server/project/LabelDefinitionJson.java
+++ b/java/com/google/gerrit/server/project/LabelDefinitionJson.java
@@ -34,17 +34,6 @@
     label.branches = labelType.getRefPatterns() != null ? labelType.getRefPatterns() : null;
     label.canOverride = toBoolean(labelType.isCanOverride());
     label.copyCondition = labelType.getCopyCondition().orElse(null);
-    label.copyAnyScore = toBoolean(labelType.isCopyAnyScore());
-    label.copyMinScore = toBoolean(labelType.isCopyMinScore());
-    label.copyMaxScore = toBoolean(labelType.isCopyMaxScore());
-    label.copyAllScoresIfListOfFilesDidNotChange =
-        toBoolean(labelType.isCopyAllScoresIfListOfFilesDidNotChange());
-    label.copyAllScoresIfNoChange = toBoolean(labelType.isCopyAllScoresIfNoChange());
-    label.copyAllScoresIfNoCodeChange = toBoolean(labelType.isCopyAllScoresIfNoCodeChange());
-    label.copyAllScoresOnTrivialRebase = toBoolean(labelType.isCopyAllScoresOnTrivialRebase());
-    label.copyAllScoresOnMergeFirstParentUpdate =
-        toBoolean(labelType.isCopyAllScoresOnMergeFirstParentUpdate());
-    label.copyValues = labelType.getCopyValues().isEmpty() ? null : labelType.getCopyValues();
     label.allowPostSubmit = toBoolean(labelType.isAllowPostSubmit());
     label.ignoreSelfApproval = toBoolean(labelType.isIgnoreSelfApproval());
     return label;
diff --git a/java/com/google/gerrit/server/project/ProjectConfig.java b/java/com/google/gerrit/server/project/ProjectConfig.java
index d816d84..47b0a53 100644
--- a/java/com/google/gerrit/server/project/ProjectConfig.java
+++ b/java/com/google/gerrit/server/project/ProjectConfig.java
@@ -109,20 +109,9 @@
   public static final String KEY_LABEL_DESCRIPTION = "description";
   public static final String KEY_FUNCTION = "function";
   public static final String KEY_DEFAULT_VALUE = "defaultValue";
-  public static final String KEY_COPY_MIN_SCORE = "copyMinScore";
   public static final String KEY_ALLOW_POST_SUBMIT = "allowPostSubmit";
   public static final String KEY_IGNORE_SELF_APPROVAL = "ignoreSelfApproval";
-  public static final String KEY_COPY_ANY_SCORE = "copyAnyScore";
   public static final String KEY_COPY_CONDITION = "copyCondition";
-  public static final String KEY_COPY_MAX_SCORE = "copyMaxScore";
-  public static final String KEY_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE =
-      "copyAllScoresIfListOfFilesDidNotChange";
-  public static final String KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE =
-      "copyAllScoresOnMergeFirstParentUpdate";
-  public static final String KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE = "copyAllScoresOnTrivialRebase";
-  public static final String KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE = "copyAllScoresIfNoCodeChange";
-  public static final String KEY_COPY_ALL_SCORES_IF_NO_CHANGE = "copyAllScoresIfNoChange";
-  public static final String KEY_COPY_VALUE = "copyValue";
   public static final String KEY_VALUE = "value";
   public static final String KEY_CAN_OVERRIDE = "canOverride";
   public static final String KEY_BRANCH = "branch";
@@ -144,6 +133,9 @@
   public static final String KEY_MATCH = "match";
   private static final String KEY_HTML = "html";
   public static final String KEY_LINK = "link";
+  public static final String KEY_PREFIX = "prefix";
+  public static final String KEY_SUFFIX = "suffix";
+  public static final String KEY_TEXT = "text";
   public static final String KEY_ENABLED = "enabled";
 
   public static final String PROJECT_CONFIG = "project.config";
@@ -339,6 +331,10 @@
     }
 
     String link = cfg.getString(COMMENTLINK, name, KEY_LINK);
+    String linkPrefix = cfg.getString(COMMENTLINK, name, KEY_PREFIX);
+    String linkSuffix = cfg.getString(COMMENTLINK, name, KEY_SUFFIX);
+    String linkText = cfg.getString(COMMENTLINK, name, KEY_TEXT);
+
     String html = cfg.getString(COMMENTLINK, name, KEY_HTML);
     boolean hasHtml = !Strings.isNullOrEmpty(html);
 
@@ -363,6 +359,9 @@
     return StoredCommentLinkInfo.builder(name)
         .setMatch(match)
         .setLink(link)
+        .setPrefix(linkPrefix)
+        .setSuffix(linkSuffix)
+        .setText(linkText)
         .setHtml(html)
         .setEnabled(enabled)
         .setOverrideOnly(false)
@@ -550,6 +549,7 @@
     return submitRequirementSections;
   }
 
+  /** Adds or replaces the given {@link SubmitRequirement} in this config. */
   public void upsertSubmitRequirement(SubmitRequirement requirement) {
     submitRequirementSections.put(requirement.name(), requirement);
   }
@@ -1018,7 +1018,7 @@
         continue;
       }
 
-      // The expressions are validated in SubmitRequirementExpressionsValidator.
+      // The expressions are validated in SubmitRequirementConfigValidator.
 
       SubmitRequirement submitRequirement =
           SubmitRequirement.builder()
@@ -1135,62 +1135,6 @@
           rc.getBoolean(LABEL, name, KEY_ALLOW_POST_SUBMIT, LabelType.DEF_ALLOW_POST_SUBMIT));
       label.setIgnoreSelfApproval(
           rc.getBoolean(LABEL, name, KEY_IGNORE_SELF_APPROVAL, LabelType.DEF_IGNORE_SELF_APPROVAL));
-      label.setCopyAnyScore(
-          rc.getBoolean(LABEL, name, KEY_COPY_ANY_SCORE, LabelType.DEF_COPY_ANY_SCORE));
-      label.setCopyMinScore(
-          rc.getBoolean(LABEL, name, KEY_COPY_MIN_SCORE, LabelType.DEF_COPY_MIN_SCORE));
-      label.setCopyMaxScore(
-          rc.getBoolean(LABEL, name, KEY_COPY_MAX_SCORE, LabelType.DEF_COPY_MAX_SCORE));
-      label.setCopyAllScoresIfListOfFilesDidNotChange(
-          rc.getBoolean(
-              LABEL,
-              name,
-              KEY_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE,
-              LabelType.DEF_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE));
-      label.setCopyAllScoresOnMergeFirstParentUpdate(
-          rc.getBoolean(
-              LABEL,
-              name,
-              KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE,
-              LabelType.DEF_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE));
-      label.setCopyAllScoresOnTrivialRebase(
-          rc.getBoolean(
-              LABEL,
-              name,
-              KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE,
-              LabelType.DEF_COPY_ALL_SCORES_ON_TRIVIAL_REBASE));
-      label.setCopyAllScoresIfNoCodeChange(
-          rc.getBoolean(
-              LABEL,
-              name,
-              KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE,
-              LabelType.DEF_COPY_ALL_SCORES_IF_NO_CODE_CHANGE));
-      label.setCopyAllScoresIfNoChange(
-          rc.getBoolean(
-              LABEL,
-              name,
-              KEY_COPY_ALL_SCORES_IF_NO_CHANGE,
-              LabelType.DEF_COPY_ALL_SCORES_IF_NO_CHANGE));
-      Set<Short> copyValues = new HashSet<>();
-      for (String value : rc.getStringList(LABEL, name, KEY_COPY_VALUE)) {
-        if (value == null) {
-          // value is null if copyValue in project.config is set to an empty string
-          continue;
-        }
-        try {
-          short copyValue = Shorts.checkedCast(PermissionRule.parseInt(value));
-          if (!copyValues.add(copyValue)) {
-            error(
-                String.format("Duplicate %s \"%s\" for label \"%s\"", KEY_COPY_VALUE, value, name));
-          }
-        } catch (IllegalArgumentException notValue) {
-          error(
-              String.format(
-                  "Invalid %s \"%s\" for label \"%s\": %s",
-                  KEY_COPY_VALUE, value, name, notValue.getMessage()));
-        }
-      }
-      label.setCopyValues(copyValues);
       label.setCanOverride(
           rc.getBoolean(LABEL, name, KEY_CAN_OVERRIDE, LabelType.DEF_CAN_OVERRIDE));
       List<String> refPatterns = getStringListOrNull(rc, LABEL, name, KEY_BRANCH);
@@ -1443,6 +1387,15 @@
         if (!Strings.isNullOrEmpty(cm.getLink())) {
           rc.setString(COMMENTLINK, cm.getName(), KEY_LINK, cm.getLink());
         }
+        if (!Strings.isNullOrEmpty(cm.getPrefix())) {
+          rc.setString(COMMENTLINK, cm.getName(), KEY_PREFIX, cm.getPrefix());
+        }
+        if (!Strings.isNullOrEmpty(cm.getSuffix())) {
+          rc.setString(COMMENTLINK, cm.getName(), KEY_SUFFIX, cm.getSuffix());
+        }
+        if (!Strings.isNullOrEmpty(cm.getText())) {
+          rc.setString(COMMENTLINK, cm.getName(), KEY_TEXT, cm.getText());
+        }
         if (cm.getEnabled() != null && !cm.getEnabled()) {
           rc.setBoolean(COMMENTLINK, cm.getName(), KEY_ENABLED, cm.getEnabled());
         }
@@ -1654,67 +1607,6 @@
           label.isIgnoreSelfApproval(),
           LabelType.DEF_IGNORE_SELF_APPROVAL);
       setBooleanConfigKey(
-          rc,
-          LABEL,
-          name,
-          KEY_COPY_ANY_SCORE,
-          label.isCopyAnyScore(),
-          LabelType.DEF_COPY_ANY_SCORE);
-      setBooleanConfigKey(
-          rc,
-          LABEL,
-          name,
-          KEY_COPY_MIN_SCORE,
-          label.isCopyMinScore(),
-          LabelType.DEF_COPY_MIN_SCORE);
-      setBooleanConfigKey(
-          rc,
-          LABEL,
-          name,
-          KEY_COPY_MAX_SCORE,
-          label.isCopyMaxScore(),
-          LabelType.DEF_COPY_MAX_SCORE);
-      setBooleanConfigKey(
-          rc,
-          LABEL,
-          name,
-          KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE,
-          label.isCopyAllScoresOnTrivialRebase(),
-          LabelType.DEF_COPY_ALL_SCORES_ON_TRIVIAL_REBASE);
-      setBooleanConfigKey(
-          rc,
-          LABEL,
-          name,
-          KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE,
-          label.isCopyAllScoresIfNoCodeChange(),
-          LabelType.DEF_COPY_ALL_SCORES_IF_NO_CODE_CHANGE);
-      setBooleanConfigKey(
-          rc,
-          LABEL,
-          name,
-          KEY_COPY_ALL_SCORES_IF_NO_CHANGE,
-          label.isCopyAllScoresIfNoChange(),
-          LabelType.DEF_COPY_ALL_SCORES_IF_NO_CHANGE);
-      setBooleanConfigKey(
-          rc,
-          LABEL,
-          name,
-          KEY_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE,
-          label.isCopyAllScoresIfListOfFilesDidNotChange(),
-          LabelType.DEF_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE);
-      setBooleanConfigKey(
-          rc,
-          LABEL,
-          name,
-          KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE,
-          label.isCopyAllScoresOnMergeFirstParentUpdate(),
-          LabelType.DEF_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE);
-      rc.setStringList(
-          LABEL,
-          name,
-          KEY_COPY_VALUE,
-          label.getCopyValues().stream().map(LabelValue::formatValue).collect(toList()));
-      setBooleanConfigKey(
           rc, LABEL, name, KEY_CAN_OVERRIDE, label.isCanOverride(), LabelType.DEF_CAN_OVERRIDE);
       List<String> values = new ArrayList<>(label.getValues().size());
       for (LabelValue value : label.getValues()) {
diff --git a/java/com/google/gerrit/server/project/RefUtil.java b/java/com/google/gerrit/server/project/RefUtil.java
index 797756b..e86ad41 100644
--- a/java/com/google/gerrit/server/project/RefUtil.java
+++ b/java/com/google/gerrit/server/project/RefUtil.java
@@ -18,11 +18,9 @@
 import static org.eclipse.jgit.lib.Constants.R_TAGS;
 
 import com.google.common.collect.Iterables;
-import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import java.io.IOException;
 import java.util.Collections;
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
@@ -37,37 +35,28 @@
 import org.eclipse.jgit.revwalk.RevWalk;
 
 public class RefUtil {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
   private RefUtil() {}
 
-  public static ObjectId parseBaseRevision(
-      Repository repo, Project.NameKey projectName, String baseRevision)
-      throws InvalidRevisionException {
+  public static ObjectId parseBaseRevision(Repository repo, String baseRevision)
+      throws UnprocessableEntityException, IOException {
     try {
       ObjectId revid = repo.resolve(baseRevision);
       if (revid == null) {
-        throw new InvalidRevisionException(baseRevision);
+        throw new UnprocessableEntityException(
+            String.format("base revision \"%s\" not found", baseRevision));
       }
       return revid;
-    } catch (IOException err) {
-      logger.atSevere().withCause(err).log(
-          "Cannot resolve \"%s\" in project \"%s\"", baseRevision, projectName.get());
-      throw new InvalidRevisionException(baseRevision);
-    } catch (RevisionSyntaxException err) {
-      throw new InvalidRevisionException(baseRevision, err);
+    } catch (RevisionSyntaxException e) {
+      throw new UnprocessableEntityException(
+          String.format("base revision \"%s\" is invalid", baseRevision), e);
     }
   }
 
-  public static RevWalk verifyConnected(Repository repo, ObjectId revid)
-      throws InvalidRevisionException {
+  public static RevWalk verifyConnected(Repository repo, ObjectId baseRevision)
+      throws BadRequestException, UnprocessableEntityException, IOException {
     try {
       ObjectWalk rw = new ObjectWalk(repo);
-      try {
-        rw.markStart(rw.parseCommit(revid));
-      } catch (IncorrectObjectTypeException err) {
-        throw new InvalidRevisionException(revid.name(), err);
-      }
+      rw.markStart(rw.parseCommit(baseRevision));
       RefDatabase refDb = repo.getRefDatabase();
       Iterable<Ref> refs =
           Iterables.concat(
@@ -85,12 +74,12 @@
       }
       rw.checkConnectivity();
       return rw;
-    } catch (IncorrectObjectTypeException | MissingObjectException err) {
-      throw new InvalidRevisionException(revid.name(), err);
-    } catch (IOException err) {
-      logger.atSevere().withCause(err).log(
-          "Repository \"%s\" may be corrupt; suggest running git fsck", repo.getDirectory());
-      throw new InvalidRevisionException(revid.name());
+    } catch (IncorrectObjectTypeException e) {
+      throw new BadRequestException(
+          String.format("base revision \"%s\" is not a commit", baseRevision.name()), e);
+    } catch (MissingObjectException e) {
+      throw new UnprocessableEntityException(
+          String.format("base revision \"%s\" not found", baseRevision.name()), e);
     }
   }
 
@@ -119,19 +108,4 @@
     }
     return result;
   }
-
-  /** Error indicating the revision is invalid as supplied. */
-  public static class InvalidRevisionException extends Exception {
-    private static final long serialVersionUID = 1L;
-
-    public static final String MESSAGE = "Invalid Revision";
-
-    InvalidRevisionException(@Nullable String invalidRevision) {
-      super(MESSAGE + ": " + invalidRevision);
-    }
-
-    InvalidRevisionException(@Nullable String invalidRevision, Throwable why) {
-      super(MESSAGE + ": " + invalidRevision, why);
-    }
-  }
 }
diff --git a/java/com/google/gerrit/server/project/SubmitRequirementConfigValidator.java b/java/com/google/gerrit/server/project/SubmitRequirementConfigValidator.java
new file mode 100644
index 0000000..6366a14
--- /dev/null
+++ b/java/com/google/gerrit/server/project/SubmitRequirementConfigValidator.java
@@ -0,0 +1,135 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.project;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.server.events.CommitReceivedEvent;
+import com.google.gerrit.server.git.validators.CommitValidationException;
+import com.google.gerrit.server.git.validators.CommitValidationListener;
+import com.google.gerrit.server.git.validators.CommitValidationMessage;
+import com.google.gerrit.server.git.validators.ValidationMessage;
+import com.google.gerrit.server.patch.DiffNotAvailableException;
+import com.google.gerrit.server.patch.DiffOperations;
+import com.google.gerrit.server.patch.DiffOptions;
+import com.google.inject.Inject;
+import java.io.IOException;
+import java.util.List;
+import java.util.stream.Collectors;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+/**
+ * Validates the expressions of submit requirements in {@code project.config}.
+ *
+ * <p>Other validation of submit requirements is done in {@link ProjectConfig}, see {@code
+ * ProjectConfig#loadSubmitRequirementSections(Config)}.
+ *
+ * <p>The validation of the expressions cannot be in {@link ProjectConfig} as it requires injecting
+ * {@link SubmitRequirementsEvaluator} and we cannot do injections into {@link ProjectConfig} (since
+ * {@link ProjectConfig} is cached in the project cache).
+ */
+public class SubmitRequirementConfigValidator implements CommitValidationListener {
+  private final DiffOperations diffOperations;
+  private final ProjectConfig.Factory projectConfigFactory;
+  private final SubmitRequirementExpressionsValidator submitRequirementExpressionsValidator;
+
+  @Inject
+  SubmitRequirementConfigValidator(
+      DiffOperations diffOperations,
+      ProjectConfig.Factory projectConfigFactory,
+      SubmitRequirementExpressionsValidator submitRequirementExpressionsValidator) {
+    this.diffOperations = diffOperations;
+    this.projectConfigFactory = projectConfigFactory;
+    this.submitRequirementExpressionsValidator = submitRequirementExpressionsValidator;
+  }
+
+  @Override
+  public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent event)
+      throws CommitValidationException {
+    try {
+      if (!event.refName.equals(RefNames.REFS_CONFIG)
+          || !isFileChanged(event, ProjectConfig.PROJECT_CONFIG)) {
+        // the project.config file in refs/meta/config was not modified, hence we do not need to
+        // validate the submit requirements in it
+        return ImmutableList.of();
+      }
+
+      ProjectConfig projectConfig = getProjectConfig(event);
+      ImmutableList.Builder<String> validationMsgsBuilder = ImmutableList.builder();
+      for (SubmitRequirement submitRequirement :
+          projectConfig.getSubmitRequirementSections().values()) {
+        validationMsgsBuilder.addAll(
+            submitRequirementExpressionsValidator.validateExpressions(submitRequirement));
+      }
+      ImmutableList<String> validationMsgs = validationMsgsBuilder.build();
+      if (!validationMsgs.isEmpty()) {
+        throw new CommitValidationException(
+            String.format(
+                "invalid submit requirement expressions in %s (revision = %s)",
+                ProjectConfig.PROJECT_CONFIG, projectConfig.getRevision().name()),
+            new ImmutableList.Builder<CommitValidationMessage>()
+                .add(
+                    new CommitValidationMessage(
+                        "Invalid project configuration", ValidationMessage.Type.ERROR))
+                .addAll(
+                    validationMsgs.stream()
+                        .map(m -> toCommitValidationMessage(m))
+                        .collect(Collectors.toList()))
+                .build());
+      }
+      return ImmutableList.of();
+    } catch (IOException | DiffNotAvailableException | ConfigInvalidException e) {
+      throw new CommitValidationException(
+          String.format(
+              "failed to validate submit requirement expressions in %s for revision %s in ref %s"
+                  + " of project %s",
+              ProjectConfig.PROJECT_CONFIG,
+              event.commit.getName(),
+              RefNames.REFS_CONFIG,
+              event.project.getNameKey()),
+          e);
+    }
+  }
+
+  private static CommitValidationMessage toCommitValidationMessage(String message) {
+    return new CommitValidationMessage(message, ValidationMessage.Type.ERROR);
+  }
+
+  /**
+   * Whether the given file was changed in the given revision.
+   *
+   * @param receiveEvent the receive event
+   * @param fileName the name of the file
+   */
+  private boolean isFileChanged(CommitReceivedEvent receiveEvent, String fileName)
+      throws DiffNotAvailableException {
+    return diffOperations
+        .listModifiedFilesAgainstParent(
+            receiveEvent.project.getNameKey(),
+            receiveEvent.commit,
+            /* parentNum=*/ 0,
+            DiffOptions.DEFAULTS)
+        .keySet().stream()
+        .anyMatch(fileName::equals);
+  }
+
+  private ProjectConfig getProjectConfig(CommitReceivedEvent receiveEvent)
+      throws IOException, ConfigInvalidException {
+    ProjectConfig projectConfig = projectConfigFactory.create(receiveEvent.project.getNameKey());
+    projectConfig.load(receiveEvent.revWalk, receiveEvent.commit);
+    return projectConfig;
+  }
+}
diff --git a/java/com/google/gerrit/server/project/SubmitRequirementExpressionsValidator.java b/java/com/google/gerrit/server/project/SubmitRequirementExpressionsValidator.java
index 8717581..f2e4ff8 100644
--- a/java/com/google/gerrit/server/project/SubmitRequirementExpressionsValidator.java
+++ b/java/com/google/gerrit/server/project/SubmitRequirementExpressionsValidator.java
@@ -15,144 +15,59 @@
 package com.google.gerrit.server.project;
 
 import com.google.common.collect.ImmutableList;
-import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.entities.SubmitRequirement;
 import com.google.gerrit.entities.SubmitRequirementExpression;
 import com.google.gerrit.index.query.QueryParseException;
-import com.google.gerrit.server.events.CommitReceivedEvent;
-import com.google.gerrit.server.git.validators.CommitValidationException;
-import com.google.gerrit.server.git.validators.CommitValidationListener;
-import com.google.gerrit.server.git.validators.CommitValidationMessage;
-import com.google.gerrit.server.git.validators.ValidationMessage;
-import com.google.gerrit.server.patch.DiffNotAvailableException;
-import com.google.gerrit.server.patch.DiffOperations;
-import com.google.gerrit.server.patch.DiffOptions;
 import com.google.inject.Inject;
-import java.io.IOException;
+import com.google.inject.Singleton;
 import java.util.ArrayList;
-import java.util.Collection;
 import java.util.List;
-import org.eclipse.jgit.errors.ConfigInvalidException;
 
-/**
- * Validates the expressions of submit requirements in {@code project.config}.
- *
- * <p>Other validation of submit requirements is done in {@link ProjectConfig}, see {@code
- * ProjectConfig#loadSubmitRequirementSections(Config)}.
- *
- * <p>The validation of the expressions cannot be in {@link ProjectConfig} as it requires injecting
- * {@link SubmitRequirementsEvaluator} and we cannot do injections into {@link ProjectConfig} (since
- * {@link ProjectConfig} is cached in the project cache).
- */
-public class SubmitRequirementExpressionsValidator implements CommitValidationListener {
-  private final DiffOperations diffOperations;
-  private final ProjectConfig.Factory projectConfigFactory;
+@Singleton
+public class SubmitRequirementExpressionsValidator {
   private final SubmitRequirementsEvaluator submitRequirementsEvaluator;
 
   @Inject
-  SubmitRequirementExpressionsValidator(
-      DiffOperations diffOperations,
-      ProjectConfig.Factory projectConfigFactory,
-      SubmitRequirementsEvaluator submitRequirementsEvaluator) {
-    this.diffOperations = diffOperations;
-    this.projectConfigFactory = projectConfigFactory;
+  SubmitRequirementExpressionsValidator(SubmitRequirementsEvaluator submitRequirementsEvaluator) {
     this.submitRequirementsEvaluator = submitRequirementsEvaluator;
   }
 
-  @Override
-  public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent event)
-      throws CommitValidationException {
-    try {
-      if (!event.refName.equals(RefNames.REFS_CONFIG)
-          || !isFileChanged(event, ProjectConfig.PROJECT_CONFIG)) {
-        // the project.config file in refs/meta/config was not modified, hence we do not need to
-        // validate the submit requirements in it
-        return ImmutableList.of();
-      }
-
-      ProjectConfig projectConfig = getProjectConfig(event);
-      ImmutableList<CommitValidationMessage> validationMessages =
-          validateSubmitRequirementExpressions(
-              projectConfig.getSubmitRequirementSections().values());
-      if (!validationMessages.isEmpty()) {
-        throw new CommitValidationException(
-            String.format(
-                "invalid submit requirement expressions in %s (revision = %s)",
-                ProjectConfig.PROJECT_CONFIG, projectConfig.getRevision()),
-            validationMessages);
-      }
-      return ImmutableList.of();
-    } catch (IOException | DiffNotAvailableException | ConfigInvalidException e) {
-      throw new CommitValidationException(
-          String.format(
-              "failed to validate submit requirement expressions in %s for revision %s in ref %s"
-                  + " of project %s",
-              ProjectConfig.PROJECT_CONFIG,
-              event.commit.getName(),
-              RefNames.REFS_CONFIG,
-              event.project.getNameKey()),
-          e);
-    }
-  }
-
   /**
-   * Whether the given file was changed in the given revision.
+   * Validates the query expressions on the input {@code submitRequirement}.
    *
-   * @param receiveEvent the receive event
-   * @param fileName the name of the file
+   * @return list of string containing the error messages resulting from the validation. The list is
+   *     empty if the "submit requirement" is valid.
    */
-  private boolean isFileChanged(CommitReceivedEvent receiveEvent, String fileName)
-      throws DiffNotAvailableException {
-    return diffOperations
-        .listModifiedFilesAgainstParent(
-            receiveEvent.project.getNameKey(),
-            receiveEvent.commit,
-            /* parentNum=*/ 0,
-            DiffOptions.DEFAULTS)
-        .keySet().stream()
-        .anyMatch(fileName::equals);
-  }
-
-  private ProjectConfig getProjectConfig(CommitReceivedEvent receiveEvent)
-      throws IOException, ConfigInvalidException {
-    ProjectConfig projectConfig = projectConfigFactory.create(receiveEvent.project.getNameKey());
-    projectConfig.load(receiveEvent.revWalk, receiveEvent.commit);
-    return projectConfig;
-  }
-
-  private ImmutableList<CommitValidationMessage> validateSubmitRequirementExpressions(
-      Collection<SubmitRequirement> submitRequirements) {
-    List<CommitValidationMessage> validationMessages = new ArrayList<>();
-    for (SubmitRequirement submitRequirement : submitRequirements) {
-      validateSubmitRequirementExpression(
-          validationMessages,
-          submitRequirement,
-          submitRequirement.submittabilityExpression(),
-          ProjectConfig.KEY_SR_SUBMITTABILITY_EXPRESSION);
-      submitRequirement
-          .applicabilityExpression()
-          .ifPresent(
-              expression ->
-                  validateSubmitRequirementExpression(
-                      validationMessages,
-                      submitRequirement,
-                      expression,
-                      ProjectConfig.KEY_SR_APPLICABILITY_EXPRESSION));
-      submitRequirement
-          .overrideExpression()
-          .ifPresent(
-              expression ->
-                  validateSubmitRequirementExpression(
-                      validationMessages,
-                      submitRequirement,
-                      expression,
-                      ProjectConfig.KEY_SR_OVERRIDE_EXPRESSION));
-    }
+  public ImmutableList<String> validateExpressions(SubmitRequirement submitRequirement) {
+    List<String> validationMessages = new ArrayList<>();
+    validateSubmitRequirementExpression(
+        validationMessages,
+        submitRequirement,
+        submitRequirement.submittabilityExpression(),
+        ProjectConfig.KEY_SR_SUBMITTABILITY_EXPRESSION);
+    submitRequirement
+        .applicabilityExpression()
+        .ifPresent(
+            expression ->
+                validateSubmitRequirementExpression(
+                    validationMessages,
+                    submitRequirement,
+                    expression,
+                    ProjectConfig.KEY_SR_APPLICABILITY_EXPRESSION));
+    submitRequirement
+        .overrideExpression()
+        .ifPresent(
+            expression ->
+                validateSubmitRequirementExpression(
+                    validationMessages,
+                    submitRequirement,
+                    expression,
+                    ProjectConfig.KEY_SR_OVERRIDE_EXPRESSION));
     return ImmutableList.copyOf(validationMessages);
   }
 
   private void validateSubmitRequirementExpression(
-      List<CommitValidationMessage> validationMessages,
+      List<String> validationMessages,
       SubmitRequirement submitRequirement,
       SubmitRequirementExpression expression,
       String configKey) {
@@ -160,23 +75,19 @@
       submitRequirementsEvaluator.validateExpression(expression);
     } catch (QueryParseException e) {
       if (validationMessages.isEmpty()) {
-        validationMessages.add(
-            new CommitValidationMessage(
-                "Invalid project configuration", ValidationMessage.Type.ERROR));
+        validationMessages.add("Invalid project configuration");
       }
       validationMessages.add(
-          new CommitValidationMessage(
-              String.format(
-                  "  %s: Expression '%s' of submit requirement '%s' (parameter %s.%s.%s) is"
-                      + " invalid: %s",
-                  ProjectConfig.PROJECT_CONFIG,
-                  expression.expressionString(),
-                  submitRequirement.name(),
-                  ProjectConfig.SUBMIT_REQUIREMENT,
-                  submitRequirement.name(),
-                  configKey,
-                  e.getMessage()),
-              ValidationMessage.Type.ERROR));
+          String.format(
+              "  %s: Expression '%s' of submit requirement '%s' (parameter %s.%s.%s) is"
+                  + " invalid: %s",
+              ProjectConfig.PROJECT_CONFIG,
+              expression.expressionString(),
+              submitRequirement.name(),
+              ProjectConfig.SUBMIT_REQUIREMENT,
+              submitRequirement.name(),
+              configKey,
+              e.getMessage()));
     }
   }
 }
diff --git a/java/com/google/gerrit/server/project/SubmitRequirementJson.java b/java/com/google/gerrit/server/project/SubmitRequirementJson.java
new file mode 100644
index 0000000..5593ff4
--- /dev/null
+++ b/java/com/google/gerrit/server/project/SubmitRequirementJson.java
@@ -0,0 +1,38 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.project;
+
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.extensions.common.SubmitRequirementInfo;
+import com.google.inject.Singleton;
+
+/** Converts a {@link SubmitRequirement} to a {@link SubmitRequirementInfo}. */
+@Singleton
+public class SubmitRequirementJson {
+  public static SubmitRequirementInfo format(SubmitRequirement sr) {
+    SubmitRequirementInfo info = new SubmitRequirementInfo();
+    info.name = sr.name();
+    info.description = sr.description().orElse(null);
+    if (sr.applicabilityExpression().isPresent()) {
+      info.applicabilityExpression = sr.applicabilityExpression().get().expressionString();
+    }
+    if (sr.overrideExpression().isPresent()) {
+      info.overrideExpression = sr.overrideExpression().get().expressionString();
+    }
+    info.submittabilityExpression = sr.submittabilityExpression().expressionString();
+    info.allowOverrideInChildProjects = sr.allowOverrideInChildProjects();
+    return info;
+  }
+}
diff --git a/java/com/google/gerrit/server/project/SubmitRequirementResource.java b/java/com/google/gerrit/server/project/SubmitRequirementResource.java
new file mode 100644
index 0000000..d075cd7
--- /dev/null
+++ b/java/com/google/gerrit/server/project/SubmitRequirementResource.java
@@ -0,0 +1,41 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.project;
+
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.extensions.restapi.RestResource;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.inject.TypeLiteral;
+
+public class SubmitRequirementResource implements RestResource {
+  public static final TypeLiteral<RestView<SubmitRequirementResource>> SUBMIT_REQUIREMENT_KIND =
+      new TypeLiteral<>() {};
+
+  private final ProjectResource project;
+  private final SubmitRequirement submitRequirement;
+
+  public SubmitRequirementResource(ProjectResource project, SubmitRequirement submitRequirement) {
+    this.project = project;
+    this.submitRequirement = submitRequirement;
+  }
+
+  public ProjectResource getProject() {
+    return project;
+  }
+
+  public SubmitRequirement getSubmitRequirement() {
+    return submitRequirement;
+  }
+}
diff --git a/java/com/google/gerrit/server/project/SubmitRequirementsAdapter.java b/java/com/google/gerrit/server/project/SubmitRequirementsAdapter.java
index 1361122..39ba8b4 100644
--- a/java/com/google/gerrit/server/project/SubmitRequirementsAdapter.java
+++ b/java/com/google/gerrit/server/project/SubmitRequirementsAdapter.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.project;
 
+import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
@@ -103,6 +104,7 @@
     return result.build();
   }
 
+  @VisibleForTesting
   static List<SubmitRequirementResult> createResult(
       SubmitRecord record, List<LabelType> labelTypes, ObjectId psCommitId, boolean isForced) {
     List<SubmitRequirementResult> results;
diff --git a/java/com/google/gerrit/server/project/SubmitRequirementsEvaluator.java b/java/com/google/gerrit/server/project/SubmitRequirementsEvaluator.java
index df836e0..b3278c9 100644
--- a/java/com/google/gerrit/server/project/SubmitRequirementsEvaluator.java
+++ b/java/com/google/gerrit/server/project/SubmitRequirementsEvaluator.java
@@ -28,11 +28,8 @@
    * from the project config of the project containing the change as well as parent projects.
    *
    * @param cd change data corresponding to a specific gerrit change
-   * @param includeLegacy if set to true, evaluate legacy {@link
-   *     com.google.gerrit.entities.SubmitRecord}s and convert them to submit requirements.
    */
-  ImmutableMap<SubmitRequirement, SubmitRequirementResult> evaluateAllRequirements(
-      ChangeData cd, boolean includeLegacy);
+  ImmutableMap<SubmitRequirement, SubmitRequirementResult> evaluateAllRequirements(ChangeData cd);
 
   /** Evaluate a single {@link SubmitRequirement} using change data. */
   SubmitRequirementResult evaluateRequirement(SubmitRequirement sr, ChangeData cd);
diff --git a/java/com/google/gerrit/server/project/SubmitRequirementsEvaluatorImpl.java b/java/com/google/gerrit/server/project/SubmitRequirementsEvaluatorImpl.java
index c57fe27..d749fd3 100644
--- a/java/com/google/gerrit/server/project/SubmitRequirementsEvaluatorImpl.java
+++ b/java/com/google/gerrit/server/project/SubmitRequirementsEvaluatorImpl.java
@@ -46,7 +46,6 @@
   private final Provider<SubmitRequirementChangeQueryBuilder> queryBuilder;
   private final ProjectCache projectCache;
   private final PluginSetContext<SubmitRequirement> globalSubmitRequirements;
-  private final SubmitRequirementsUtil submitRequirementsUtil;
   private final OneOffRequestContext requestContext;
 
   public static Module module() {
@@ -65,12 +64,10 @@
       Provider<SubmitRequirementChangeQueryBuilder> queryBuilder,
       ProjectCache projectCache,
       PluginSetContext<SubmitRequirement> globalSubmitRequirements,
-      SubmitRequirementsUtil submitRequirementsUtil,
       OneOffRequestContext requestContext) {
     this.queryBuilder = queryBuilder;
     this.projectCache = projectCache;
     this.globalSubmitRequirements = globalSubmitRequirements;
-    this.submitRequirementsUtil = submitRequirementsUtil;
     this.requestContext = requestContext;
   }
 
@@ -82,16 +79,8 @@
 
   @Override
   public ImmutableMap<SubmitRequirement, SubmitRequirementResult> evaluateAllRequirements(
-      ChangeData cd, boolean includeLegacy) {
-    ImmutableMap<SubmitRequirement, SubmitRequirementResult> projectConfigRequirements =
-        getRequirements(cd);
-    if (!includeLegacy) {
-      return projectConfigRequirements;
-    }
-    Map<SubmitRequirement, SubmitRequirementResult> legacyReqs =
-        SubmitRequirementsAdapter.getLegacyRequirements(cd);
-    return submitRequirementsUtil.mergeLegacyAndNonLegacyRequirements(
-        projectConfigRequirements, legacyReqs, cd);
+      ChangeData cd) {
+    return getRequirements(cd);
   }
 
   @Override
@@ -105,8 +94,14 @@
           sr.applicabilityExpression().isPresent()
               ? Optional.of(evaluateExpression(sr.applicabilityExpression().get(), cd))
               : Optional.empty();
-      Optional<SubmitRequirementExpressionResult> submittabilityResult = Optional.empty();
-      Optional<SubmitRequirementExpressionResult> overrideResult = 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));
diff --git a/java/com/google/gerrit/server/project/SubmitRequirementsUtil.java b/java/com/google/gerrit/server/project/SubmitRequirementsUtil.java
index c234c8c..e54e5af 100644
--- a/java/com/google/gerrit/server/project/SubmitRequirementsUtil.java
+++ b/java/com/google/gerrit/server/project/SubmitRequirementsUtil.java
@@ -15,9 +15,10 @@
 package com.google.gerrit.server.project;
 
 import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.SubmitRequirement;
 import com.google.gerrit.entities.SubmitRequirementResult;
-import com.google.gerrit.metrics.Counter2;
+import com.google.gerrit.metrics.Counter1;
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.Field;
 import com.google.gerrit.metrics.MetricMaker;
@@ -29,6 +30,7 @@
 import java.util.HashMap;
 import java.util.Map;
 import java.util.Set;
+import java.util.regex.Pattern;
 import java.util.stream.Collectors;
 
 /**
@@ -38,12 +40,18 @@
 @Singleton
 public class SubmitRequirementsUtil {
 
+  /**
+   * Submit requirement name can only contain alphanumeric characters or hyphen. Name cannot start
+   * with a hyphen or number.
+   */
+  private static final Pattern SUBMIT_REQ_NAME_PATTERN = Pattern.compile("[a-zA-Z][a-zA-Z0-9\\-]*");
+
   @Singleton
   static class Metrics {
-    final Counter2<String, String> submitRequirementsMatchingWithLegacy;
-    final Counter2<String, String> submitRequirementsMismatchingWithLegacy;
-    final Counter2<String, String> legacyNotInSrs;
-    final Counter2<String, String> srsNotInLegacy;
+    final Counter1<String> submitRequirementsMatchingWithLegacy;
+    final Counter1<String> submitRequirementsMismatchingWithLegacy;
+    final Counter1<String> legacyNotInSrs;
+    final Counter1<String> srsNotInLegacy;
 
     @Inject
     Metrics(MetricMaker metricMaker) {
@@ -57,7 +65,6 @@
                           + "w.r.t. change submittability.")
                   .setRate()
                   .setUnit("count"),
-              Field.ofString("project", Metadata.Builder::projectName).build(),
               Field.ofString("sr_name", Metadata.Builder::submitRequirementName)
                   .description("Submit requirement name")
                   .build());
@@ -71,7 +78,6 @@
                           + "w.r.t. change submittability.")
                   .setRate()
                   .setUnit("count"),
-              Field.ofString("project", Metadata.Builder::projectName).build(),
               Field.ofString("sr_name", Metadata.Builder::submitRequirementName)
                   .description("Submit requirement name")
                   .build());
@@ -83,7 +89,6 @@
                           + "but not a project config requirement with the same name for a change.")
                   .setRate()
                   .setUnit("count"),
-              Field.ofString("project", Metadata.Builder::projectName).build(),
               Field.ofString("sr_name", Metadata.Builder::submitRequirementName)
                   .description("Submit requirement name")
                   .build());
@@ -95,7 +100,6 @@
                           + "result but not a legacy requirement with the same name for a change.")
                   .setRate()
                   .setUnit("count"),
-              Field.ofString("project", Metadata.Builder::projectName).build(),
               Field.ofString("sr_name", Metadata.Builder::submitRequirementName)
                   .description("Submit requirement name")
                   .build());
@@ -134,6 +138,10 @@
     result.putAll(projectConfigRequirements);
     Map<String, SubmitRequirementResult> requirementsByName =
         projectConfigRequirements.entrySet().stream()
+            // filter out legacy entries as a safety guard for duplicate entries
+            // (projectConfigRequirements should not contain legacy entries)
+            // TODO(ghareeb): remove the filter statement
+            .filter(entry -> !entry.getValue().isLegacy())
             .collect(Collectors.toMap(sr -> sr.getKey().name().toLowerCase(), sr -> sr.getValue()));
     for (Map.Entry<SubmitRequirement, SubmitRequirementResult> legacy :
         legacyRequirements.entrySet()) {
@@ -145,7 +153,7 @@
       if (projectConfigResult == null) {
         result.put(legacy.getKey(), legacy.getValue());
         if (shouldReportMetric(cd)) {
-          metrics.legacyNotInSrs.increment(cd.project().get(), srName);
+          metrics.legacyNotInSrs.increment(srName);
         }
         continue;
       }
@@ -154,14 +162,14 @@
         // matching in result. No need to include the legacy SR in the output since the project
         // config SR is already there.
         if (shouldReportMetric(cd)) {
-          metrics.submitRequirementsMatchingWithLegacy.increment(cd.project().get(), srName);
+          metrics.submitRequirementsMatchingWithLegacy.increment(srName);
         }
         continue;
       }
       // There exists a project config SR with the same name as the legacy SR but they are not
       // matching in their result. Increment the mismatch count and add the legacy SR to the result.
       if (shouldReportMetric(cd)) {
-        metrics.submitRequirementsMismatchingWithLegacy.increment(cd.project().get(), srName);
+        metrics.submitRequirementsMismatchingWithLegacy.increment(srName);
       }
       result.put(legacy.getKey(), legacy.getValue());
     }
@@ -172,13 +180,27 @@
             .collect(Collectors.toSet());
     for (String projectConfigSrName : requirementsByName.keySet()) {
       if (!legacyNames.contains(projectConfigSrName) && shouldReportMetric(cd)) {
-        metrics.srsNotInLegacy.increment(cd.project().get(), projectConfigSrName);
+        metrics.srsNotInLegacy.increment(projectConfigSrName);
       }
     }
 
     return ImmutableMap.copyOf(result);
   }
 
+  /** Validates the name of submit requirements. */
+  public static void validateName(@Nullable String name) throws IllegalArgumentException {
+    if (name == null || name.isEmpty()) {
+      throw new IllegalArgumentException("Empty submit requirement name");
+    }
+    if (!SUBMIT_REQ_NAME_PATTERN.matcher(name).matches()) {
+      throw new IllegalArgumentException(
+          String.format(
+              "Illegal submit requirement name \"%s\". Name can only consist of "
+                  + "alphanumeric characters and '-'. Name cannot start with '-' or number.",
+              name));
+    }
+  }
+
   private static boolean shouldReportMetric(ChangeData cd) {
     // We only care about recording differences in old and new requirements for open changes
     // that did not have their data retrieved from the (potentially stale) change index.
diff --git a/java/com/google/gerrit/server/query/account/AccountPredicates.java b/java/com/google/gerrit/server/query/account/AccountPredicates.java
index 8f94089..433abe6 100644
--- a/java/com/google/gerrit/server/query/account/AccountPredicates.java
+++ b/java/com/google/gerrit/server/query/account/AccountPredicates.java
@@ -18,8 +18,8 @@
 import com.google.common.primitives.Ints;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Project;
-import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.Schema;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
 import com.google.gerrit.index.query.IndexPredicate;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryBuilder;
@@ -31,7 +31,8 @@
 /** Utility class to create predicates for account index queries. */
 public class AccountPredicates {
   public static boolean hasActive(Predicate<AccountState> p) {
-    return QueryBuilder.find(p, AccountPredicate.class, AccountField.ACTIVE.getName()) != null;
+    return QueryBuilder.find(p, AccountPredicate.class, AccountField.ACTIVE_FIELD_SPEC.getName())
+        != null;
   }
 
   public static Predicate<AccountState> andActive(Predicate<AccountState> p) {
@@ -49,11 +50,11 @@
     if (canSeeSecondaryEmails) {
       preds.add(equalsNameIncludingSecondaryEmails(query));
     } else {
-      if (schema.hasField(AccountField.NAME_PART_NO_SECONDARY_EMAIL)) {
+      if (schema.hasField(AccountField.NAME_PART_NO_SECONDARY_EMAIL_SPEC)) {
         preds.add(equalsName(query));
       } else {
         preds.add(AccountPredicates.fullName(query));
-        if (schema.hasField(AccountField.PREFERRED_EMAIL)) {
+        if (schema.hasField(AccountField.PREFERRED_EMAIL_LOWER_CASE_SPEC)) {
           preds.add(AccountPredicates.preferredEmail(query));
         }
       }
@@ -66,63 +67,67 @@
 
   public static Predicate<AccountState> id(Schema<AccountState> schema, Account.Id accountId) {
     return new AccountPredicate(
-        schema.useLegacyNumericFields() ? AccountField.ID : AccountField.ID_STR,
+        schema.hasField(AccountField.ID_FIELD_SPEC)
+            ? AccountField.ID_FIELD_SPEC
+            : AccountField.ID_STR_FIELD_SPEC,
         AccountQueryBuilder.FIELD_ACCOUNT,
         accountId.toString());
   }
 
   public static Predicate<AccountState> emailIncludingSecondaryEmails(String email) {
     return new AccountPredicate(
-        AccountField.EMAIL, AccountQueryBuilder.FIELD_EMAIL, email.toLowerCase());
+        AccountField.EMAIL_SPEC, AccountQueryBuilder.FIELD_EMAIL, email.toLowerCase());
   }
 
   public static Predicate<AccountState> preferredEmail(String email) {
     return new AccountPredicate(
-        AccountField.PREFERRED_EMAIL,
+        AccountField.PREFERRED_EMAIL_LOWER_CASE_SPEC,
         AccountQueryBuilder.FIELD_PREFERRED_EMAIL,
         email.toLowerCase());
   }
 
   public static Predicate<AccountState> preferredEmailExact(String email) {
     return new AccountPredicate(
-        AccountField.PREFERRED_EMAIL_EXACT, AccountQueryBuilder.FIELD_PREFERRED_EMAIL_EXACT, email);
+        AccountField.PREFERRED_EMAIL_EXACT_SPEC,
+        AccountQueryBuilder.FIELD_PREFERRED_EMAIL_EXACT,
+        email);
   }
 
   public static Predicate<AccountState> equalsNameIncludingSecondaryEmails(String name) {
     return new AccountPredicate(
-        AccountField.NAME_PART, AccountQueryBuilder.FIELD_NAME, name.toLowerCase());
+        AccountField.NAME_PART_SPEC, AccountQueryBuilder.FIELD_NAME, name.toLowerCase());
   }
 
   public static Predicate<AccountState> equalsName(String name) {
     return new AccountPredicate(
-        AccountField.NAME_PART_NO_SECONDARY_EMAIL,
+        AccountField.NAME_PART_NO_SECONDARY_EMAIL_SPEC,
         AccountQueryBuilder.FIELD_NAME,
         name.toLowerCase());
   }
 
   public static Predicate<AccountState> externalIdIncludingSecondaryEmails(String externalId) {
-    return new AccountPredicate(AccountField.EXTERNAL_ID, externalId);
+    return new AccountPredicate(AccountField.EXTERNAL_ID_FIELD_SPEC, externalId);
   }
 
   public static Predicate<AccountState> fullName(String fullName) {
-    return new AccountPredicate(AccountField.FULL_NAME, fullName);
+    return new AccountPredicate(AccountField.FULL_NAME_SPEC, fullName);
   }
 
   public static Predicate<AccountState> isActive() {
-    return new AccountPredicate(AccountField.ACTIVE, "1");
+    return new AccountPredicate(AccountField.ACTIVE_FIELD_SPEC, "1");
   }
 
   public static Predicate<AccountState> isNotActive() {
-    return new AccountPredicate(AccountField.ACTIVE, "0");
+    return new AccountPredicate(AccountField.ACTIVE_FIELD_SPEC, "0");
   }
 
   public static Predicate<AccountState> username(String username) {
     return new AccountPredicate(
-        AccountField.USERNAME, AccountQueryBuilder.FIELD_USERNAME, username.toLowerCase());
+        AccountField.USERNAME_SPEC, AccountQueryBuilder.FIELD_USERNAME, username.toLowerCase());
   }
 
   public static Predicate<AccountState> watchedProject(Project.NameKey project) {
-    return new AccountPredicate(AccountField.WATCHED_PROJECT, project.get());
+    return new AccountPredicate(AccountField.WATCHED_PROJECT_SPEC, project.get());
   }
 
   public static Predicate<AccountState> cansee(
@@ -132,11 +137,11 @@
 
   /** Predicate that is mapped to a field in the account index. */
   static class AccountPredicate extends IndexPredicate<AccountState> {
-    AccountPredicate(FieldDef<AccountState, ?> def, String value) {
+    AccountPredicate(SchemaField<AccountState, ?> def, String value) {
       super(def, value);
     }
 
-    AccountPredicate(FieldDef<AccountState, ?> def, String name, String value) {
+    AccountPredicate(SchemaField<AccountState, ?> def, String name, String value) {
       super(def, name, value);
     }
   }
diff --git a/java/com/google/gerrit/server/query/account/AccountQueryBuilder.java b/java/com/google/gerrit/server/query/account/AccountQueryBuilder.java
index 6ab51c5..ed950c8 100644
--- a/java/com/google/gerrit/server/query/account/AccountQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/account/AccountQueryBuilder.java
@@ -20,7 +20,6 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.exceptions.NotSignedInException;
 import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.index.Index;
 import com.google.gerrit.index.Schema;
 import com.google.gerrit.index.query.LimitPredicate;
@@ -121,15 +120,12 @@
       throw error(String.format("change %s not found", change));
     }
 
-    try {
-      args.permissionBackend
-          .user(args.getUser())
-          .change(changeNotes.get())
-          .check(ChangePermission.READ);
-    } catch (AuthException e) {
-      throw error(String.format("change %s not found", change), e);
+    if (!args.permissionBackend
+        .user(args.getUser())
+        .change(changeNotes.get())
+        .test(ChangePermission.READ)) {
+      throw error(String.format("change %s not found", change));
     }
-
     return AccountPredicates.cansee(args, changeNotes.get());
   }
 
@@ -140,7 +136,7 @@
       return AccountPredicates.emailIncludingSecondaryEmails(email);
     }
 
-    if (args.schema().hasField(AccountField.PREFERRED_EMAIL)) {
+    if (args.schema().hasField(AccountField.PREFERRED_EMAIL_LOWER_CASE_SPEC)) {
       return AccountPredicates.preferredEmail(email);
     }
 
@@ -174,7 +170,7 @@
       return AccountPredicates.equalsNameIncludingSecondaryEmails(name);
     }
 
-    if (args.schema().hasField(AccountField.NAME_PART_NO_SECONDARY_EMAIL)) {
+    if (args.schema().hasField(AccountField.NAME_PART_NO_SECONDARY_EMAIL_SPEC)) {
       return AccountPredicates.equalsName(name);
     }
 
@@ -219,12 +215,7 @@
   }
 
   private boolean canSeeSecondaryEmails() throws PermissionBackendException, QueryParseException {
-    try {
-      args.permissionBackend.user(args.getUser()).check(GlobalPermission.MODIFY_ACCOUNT);
-      return true;
-    } catch (AuthException e) {
-      return false;
-    }
+    return args.permissionBackend.user(args.getUser()).test(GlobalPermission.MODIFY_ACCOUNT);
   }
 
   private boolean checkedCanSeeSecondaryEmails() {
diff --git a/java/com/google/gerrit/server/query/account/InternalAccountQuery.java b/java/com/google/gerrit/server/query/account/InternalAccountQuery.java
index a0a9f71..98a12d5 100644
--- a/java/com/google/gerrit/server/query/account/InternalAccountQuery.java
+++ b/java/com/google/gerrit/server/query/account/InternalAccountQuery.java
@@ -25,9 +25,9 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.entities.Project;
-import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.index.Schema;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
 import com.google.gerrit.index.query.InternalQuery;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.account.externalids.ExternalId;
@@ -151,16 +151,16 @@
     return query(AccountPredicates.watchedProject(project));
   }
 
-  private boolean hasField(FieldDef<AccountState, ?> field) {
+  private boolean hasField(SchemaField<AccountState, ?> field) {
     Schema<AccountState> s = schema();
     return (s != null && s.hasField(field));
   }
 
   private boolean hasPreferredEmail() {
-    return hasField(AccountField.PREFERRED_EMAIL);
+    return hasField(AccountField.PREFERRED_EMAIL_LOWER_CASE_SPEC);
   }
 
   private boolean hasPreferredEmailExact() {
-    return hasField(AccountField.PREFERRED_EMAIL_EXACT);
+    return hasField(AccountField.PREFERRED_EMAIL_EXACT_SPEC);
   }
 }
diff --git a/java/com/google/gerrit/server/query/approval/ApprovalContext.java b/java/com/google/gerrit/server/query/approval/ApprovalContext.java
index 6e433c5..901c51f 100644
--- a/java/com/google/gerrit/server/query/approval/ApprovalContext.java
+++ b/java/com/google/gerrit/server/query/approval/ApprovalContext.java
@@ -17,8 +17,9 @@
 import static com.google.common.base.Preconditions.checkState;
 
 import com.google.auto.value.AutoValue;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.PatchSet;
-import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.extensions.client.ChangeKind;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import org.eclipse.jgit.lib.Config;
@@ -27,15 +28,21 @@
 /** Entity representing all required information to match predicates for copying approvals. */
 @AutoValue
 public abstract class ApprovalContext {
-  /** Approval on the source patch set to be copied. */
-  public abstract PatchSetApproval patchSetApproval();
+  public abstract PatchSet.Id sourcePatchSetId();
+
+  public abstract Account.Id approverId();
+
+  public abstract LabelType labelType();
+
+  /** Value of the approval on the source patch set to be copied. */
+  public abstract short approvalValue();
 
   /**
    * Target change and patch set for the approval. This must be used instead of getting the PatchSet
    * from {@link #changeNotes()} because it is possible we are now creating the patch-set, so it
    * doesn't exist in changeNotes yet.
    */
-  public abstract PatchSet target();
+  public abstract PatchSet targetPatchSet();
 
   /** {@link ChangeNotes} of the change in question. */
   public abstract ChangeNotes changeNotes();
@@ -54,17 +61,20 @@
 
   public static ApprovalContext create(
       ChangeNotes changeNotes,
-      PatchSetApproval psa,
-      PatchSet patchSet,
+      PatchSet.Id sourcePatchSetId,
+      Account.Id approverId,
+      LabelType labelType,
+      short approvalValue,
+      PatchSet targetPatchSet,
       ChangeKind changeKind,
       boolean isMerge,
       RevWalk revWalk,
       Config repoConfig) {
     checkState(
-        psa.patchSetId().changeId().equals(patchSet.id().changeId()),
+        sourcePatchSetId.changeId().equals(targetPatchSet.id().changeId()),
         "approval and target must be the same change. got: %s, %s",
-        psa.patchSetId(),
-        patchSet.id());
+        sourcePatchSetId,
+        targetPatchSet.id());
     // TODO(ekempin): Use checkState to verify that psa.patchSetId().get() + 1 == id.get() so that
     // it's ensured that approvals are only copied to the next consecutive patch set. To add back
     // this verification https://gerrit-review.googlesource.com/c/gerrit/+/312633 can be reverted.
@@ -72,6 +82,15 @@
     // are no changes with gaps in patch set numbers. Since it's planned to fix-up old changes with
     // gaps in patch set numbers, this todo is a reminder to add back the check once this is done.
     return new AutoValue_ApprovalContext(
-        psa, patchSet, changeNotes, changeKind, isMerge, revWalk, repoConfig);
+        sourcePatchSetId,
+        approverId,
+        labelType,
+        approvalValue,
+        targetPatchSet,
+        changeNotes,
+        changeKind,
+        isMerge,
+        revWalk,
+        repoConfig);
   }
 }
diff --git a/java/com/google/gerrit/server/query/approval/ChangeKindPredicate.java b/java/com/google/gerrit/server/query/approval/ChangeKindPredicate.java
index f519b16..daf437b 100644
--- a/java/com/google/gerrit/server/query/approval/ChangeKindPredicate.java
+++ b/java/com/google/gerrit/server/query/approval/ChangeKindPredicate.java
@@ -32,39 +32,7 @@
 
   @Override
   public boolean match(ApprovalContext ctx) {
-    if (ctx.changeKind().equals(changeKind)) {
-      // The configured change kind (changeKind) on which approvals should be copied matches the
-      // actual change kind (ctx.changeKind()).
-      return true;
-    }
-
-    // If the configured change kind (changeKind) is REWORK it means that all kind of change kinds
-    // should be matched, since any other change kind is just a more trivial version of a rework.
-    if (changeKind == ChangeKind.REWORK) {
-      return true;
-    }
-
-    // If the actual change kind (ctx.changeKind()) is NO_CHANGE it is also matched if the
-    // configured change kind (changeKind) is:
-    // * TRIVIAL_REBASE: since NO_CHANGE is a special kind of a trivial rebase
-    // * NO_CODE_CHANGE: if there is no change, there is also no code change
-    // * MERGE_FIRST_PARENT_UPDATE (only if the new patch set is a merge commit): if votes should be
-    //   copied on first parent update, they should also be copied if there was no change
-    //
-    // Motivation:
-    // * https://gerrit-review.googlesource.com/c/gerrit/+/74690
-    // * There is no practical use case where you would want votes to be copied on
-    //   TRIVIAL_REBASE|NO_CODE_CHANGE|MERGE_FIRST_PARENT_UPDATE but not on NO_CHANGE. Matching
-    //   NO_CHANGE implicitly for these change kinds makes configuring copy conditions easier (as
-    //   users can simply configure "changekind:<CHANGE-KIND>", rather than
-    //   "changekind:<CHANGE-KIND> OR changekind:NO_CHANGE").
-    // * This preserves backwards compatibility with the deprecated boolean flags for copying
-    //   approvals based on the change kind ('copyAllScoresOnTrivialRebase',
-    //   'copyAllScoresIfNoCodeChange' and 'copyAllScoresOnMergeFirstParentUpdate').
-    return ctx.changeKind() == ChangeKind.NO_CHANGE
-        && (changeKind == ChangeKind.TRIVIAL_REBASE
-            || changeKind == ChangeKind.NO_CODE_CHANGE
-            || (ctx.isMerge() && changeKind == ChangeKind.MERGE_FIRST_PARENT_UPDATE));
+    return ctx.changeKind().matches(changeKind, ctx.isMerge());
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/query/approval/ExactValuePredicate.java b/java/com/google/gerrit/server/query/approval/ExactValuePredicate.java
index 1f36f8a..3021534 100644
--- a/java/com/google/gerrit/server/query/approval/ExactValuePredicate.java
+++ b/java/com/google/gerrit/server/query/approval/ExactValuePredicate.java
@@ -27,7 +27,7 @@
 
   @Override
   public boolean match(ApprovalContext approvalContext) {
-    return votingValue == approvalContext.patchSetApproval().value();
+    return votingValue == approvalContext.approvalValue();
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/query/approval/ListOfFilesUnchangedPredicate.java b/java/com/google/gerrit/server/query/approval/ListOfFilesUnchangedPredicate.java
index 2a72c49..958011c 100644
--- a/java/com/google/gerrit/server/query/approval/ListOfFilesUnchangedPredicate.java
+++ b/java/com/google/gerrit/server/query/approval/ListOfFilesUnchangedPredicate.java
@@ -52,9 +52,8 @@
 
   @Override
   public boolean match(ApprovalContext ctx) {
-    PatchSet targetPatchSet = ctx.target();
-    PatchSet sourcePatchSet =
-        ctx.changeNotes().getPatchSets().get(ctx.patchSetApproval().patchSetId());
+    PatchSet targetPatchSet = ctx.targetPatchSet();
+    PatchSet sourcePatchSet = ctx.changeNotes().getPatchSets().get(ctx.sourcePatchSetId());
 
     Integer parentNum =
         isInitialCommit(ctx.changeNotes().getProjectName(), targetPatchSet.commitId()) ? 0 : 1;
@@ -87,8 +86,7 @@
     } catch (DiffNotAvailableException ex) {
       throw new StorageException(
           "failed to compute difference in files, so won't copy"
-              + " votes on labels even if list of files is the same and "
-              + "copyAllIfListOfFilesDidNotChange",
+              + " votes on labels even if list of files is the same",
           ex);
     }
   }
diff --git a/java/com/google/gerrit/server/query/approval/MagicValuePredicate.java b/java/com/google/gerrit/server/query/approval/MagicValuePredicate.java
index 326620d..98471da 100644
--- a/java/com/google/gerrit/server/query/approval/MagicValuePredicate.java
+++ b/java/com/google/gerrit/server/query/approval/MagicValuePredicate.java
@@ -14,16 +14,12 @@
 
 package com.google.gerrit.server.query.approval;
 
-import com.google.gerrit.entities.LabelId;
-import com.google.gerrit.entities.LabelType;
-import com.google.gerrit.entities.Project;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.util.Collection;
 import java.util.Objects;
-import java.util.Optional;
 
 /** Predicate that matches patch set approvals we want to copy based on the value. */
 public class MagicValuePredicate extends ApprovalPredicate {
@@ -48,36 +44,20 @@
 
   @Override
   public boolean match(ApprovalContext ctx) {
-    Optional<LabelType> lt =
-        getLabelType(ctx.changeNotes().getProjectName(), ctx.patchSetApproval().labelId());
     short pValue;
     switch (value) {
       case ANY:
         return true;
       case MIN:
-        if (!lt.isPresent()) {
-          return false;
-        }
-        pValue = lt.get().getMaxNegative();
+        pValue = ctx.labelType().getMaxNegative();
         break;
       case MAX:
-        if (!lt.isPresent()) {
-          return false;
-        }
-        pValue = lt.get().getMaxPositive();
+        pValue = ctx.labelType().getMaxPositive();
         break;
       default:
         throw new IllegalArgumentException("unrecognized label value: " + value);
     }
-    return pValue == ctx.patchSetApproval().value();
-  }
-
-  private Optional<LabelType> getLabelType(Project.NameKey project, LabelId labelId) {
-    return projectCache
-        .get(project)
-        .orElseThrow(() -> new IllegalStateException(project + " absent"))
-        .getLabelTypes()
-        .byLabel(labelId);
+    return pValue == ctx.approvalValue();
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/query/approval/UserInPredicate.java b/java/com/google/gerrit/server/query/approval/UserInPredicate.java
index 2aef703..fda2014 100644
--- a/java/com/google/gerrit/server/query/approval/UserInPredicate.java
+++ b/java/com/google/gerrit/server/query/approval/UserInPredicate.java
@@ -53,10 +53,10 @@
   public boolean match(ApprovalContext ctx) {
     Account.Id accountId;
     if (field == Field.UPLOADER) {
-      PatchSet patchSet = ctx.target();
+      PatchSet patchSet = ctx.targetPatchSet();
       accountId = patchSet.uploader();
     } else if (field == Field.APPROVER) {
-      accountId = ctx.patchSetApproval().accountId();
+      accountId = ctx.approverId();
     } else {
       throw new IllegalStateException("unknown field in group membership check: " + field);
     }
diff --git a/java/com/google/gerrit/server/query/change/ChangeData.java b/java/com/google/gerrit/server/query/change/ChangeData.java
index ad422bc..8ab9786 100644
--- a/java/com/google/gerrit/server/query/change/ChangeData.java
+++ b/java/com/google/gerrit/server/query/change/ChangeData.java
@@ -74,9 +74,10 @@
 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.GerritServerId;
 import com.google.gerrit.server.config.TrackingFooters;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.MergeUtil;
+import com.google.gerrit.server.git.MergeUtilFactory;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.RobotCommentNotes;
 import com.google.gerrit.server.patch.DiffSummary;
@@ -262,15 +263,65 @@
    * <p>Attempting to lazy load data will fail with NPEs. Callers may consider manually setting
    * fields that can be set.
    *
+   * @param project project name
    * @param id change ID
+   * @param currentPatchSetId current patchset number
+   * @param commitId commit SHA1 of the current patchset
    * @return instance for testing.
    */
   public static ChangeData createForTest(
       Project.NameKey project, Change.Id id, int currentPatchSetId, ObjectId commitId) {
+    return createForTest(project, id, currentPatchSetId, commitId, null, null, null);
+  }
+
+  /**
+   * Create an instance for testing only.
+   *
+   * <p>Attempting to lazy load data will fail with NPEs. Callers may consider manually setting
+   * fields that can be set.
+   *
+   * @param project project name
+   * @param id change ID
+   * @param currentPatchSetId current patchset number
+   * @param commitId commit SHA1 of the current patchset
+   * @param serverId Gerrit server id
+   * @param virtualIdAlgo algorithm for virtualising the Change number
+   * @param changeNotes notes associated with the Change
+   * @return instance for testing.
+   */
+  public static ChangeData createForTest(
+      Project.NameKey project,
+      Change.Id id,
+      int currentPatchSetId,
+      ObjectId commitId,
+      @Nullable String serverId,
+      @Nullable ChangeNumberVirtualIdAlgorithm virtualIdAlgo,
+      @Nullable ChangeNotes changeNotes) {
     ChangeData cd =
         new ChangeData(
-            null, null, null, null, null, null, null, null, null, null, null, null, null, null,
-            null, null, null, project, id, null, null);
+            null,
+            null,
+            null,
+            null,
+            null,
+            null,
+            null,
+            null,
+            null,
+            null,
+            null,
+            null,
+            null,
+            null,
+            null,
+            null,
+            null,
+            serverId,
+            virtualIdAlgo,
+            project,
+            id,
+            null,
+            changeNotes);
     cd.currentPatchSet =
         PatchSet.builder()
             .id(PatchSet.id(id, currentPatchSetId))
@@ -289,7 +340,7 @@
   private final ChangeNotes.Factory notesFactory;
   private final CommentsUtil commentsUtil;
   private final GitRepositoryManager repoManager;
-  private final MergeUtil.Factory mergeUtilFactory;
+  private final MergeUtilFactory mergeUtilFactory;
   private final MergeabilityCache mergeabilityCache;
   private final PatchListCache patchListCache;
   private final PatchSetUtil psUtil;
@@ -361,6 +412,9 @@
   private Optional<Instant> mergedOn;
   private ImmutableSetMultimap<NameKey, RefState> refStates;
   private ImmutableList<byte[]> refStatePatterns;
+  private String gerritServerId;
+  private String changeServerId;
+  private ChangeNumberVirtualIdAlgorithm virtualIdFunc;
 
   @Inject
   private ChangeData(
@@ -371,7 +425,7 @@
       ChangeNotes.Factory notesFactory,
       CommentsUtil commentsUtil,
       GitRepositoryManager repoManager,
-      MergeUtil.Factory mergeUtilFactory,
+      MergeUtilFactory mergeUtilFactory,
       MergeabilityCache mergeabilityCache,
       PatchListCache patchListCache,
       PatchSetUtil psUtil,
@@ -381,6 +435,8 @@
       SubmitRequirementsEvaluator submitRequirementsEvaluator,
       SubmitRequirementsUtil submitRequirementsUtil,
       SubmitRuleEvaluator.Factory submitRuleEvaluatorFactory,
+      @GerritServerId String gerritServerId,
+      ChangeNumberVirtualIdAlgorithm virtualIdFunc,
       @Assisted Project.NameKey project,
       @Assisted Change.Id id,
       @Assisted @Nullable Change change,
@@ -408,6 +464,10 @@
 
     this.change = change;
     this.notes = notes;
+
+    this.changeServerId = notes == null ? null : notes.getServerId();
+    this.gerritServerId = gerritServerId;
+    this.virtualIdFunc = virtualIdFunc;
   }
 
   /**
@@ -528,6 +588,14 @@
     return legacyId;
   }
 
+  public Change.Id getVirtualId() {
+    if (virtualIdFunc == null || changeServerId == null || changeServerId.equals(gerritServerId)) {
+      return legacyId;
+    }
+
+    return Change.id(virtualIdFunc.apply(changeServerId, legacyId.get()));
+  }
+
   public Project.NameKey project() {
     return project;
   }
@@ -558,6 +626,7 @@
       throw new StorageException("Unable to load change " + legacyId, e);
     }
     change = notes.getChange();
+    changeServerId = notes.getServerId();
     setPatchSets(null);
     return change;
   }
@@ -942,7 +1011,7 @@
   }
 
   /**
-   * Similar to {@link #submitRequirements}, except that it also converts submit records resulting
+   * Similar to {@link #submitRequirements()}, except that it also converts submit records resulting
    * from the evaluation of legacy submit rules to submit requirements.
    */
   public Map<SubmitRequirement, SubmitRequirementResult> submitRequirementsIncludingLegacy() {
@@ -969,8 +1038,7 @@
       Change c = change();
       if (c == null || !c.isClosed()) {
         // Open changes: Evaluate submit requirements online.
-        submitRequirements =
-            submitRequirementsEvaluator.evaluateAllRequirements(this, /* includeLegacy= */ false);
+        submitRequirements = submitRequirementsEvaluator.evaluateAllRequirements(this);
         return submitRequirements;
       }
       // Closed changes: Load submit requirement results from NoteDb.
@@ -1194,7 +1262,7 @@
     this.stars = ImmutableListMultimap.copyOf(stars);
   }
 
-  public ImmutableMap<Account.Id, StarRef> starRefs() {
+  private ImmutableMap<Account.Id, StarRef> starRefs() {
     if (starRefs == null) {
       if (!lazyload()) {
         return ImmutableMap.of();
@@ -1275,7 +1343,6 @@
                     edit.getRowKey(), edit.getColumnKey().changeId(), edit.getColumnKey()),
                 edit.getValue()));
       }
-      starRefs().values().forEach(r -> result.put(allUsersName, RefState.of(r.ref())));
 
       // TODO: instantiating the notes is too much. We don't want to parse NoteDb, we just want the
       // refs.
@@ -1283,14 +1350,6 @@
       notes().getRobotComments(); // Force loading robot comments.
       RobotCommentNotes robotNotes = notes().getRobotCommentNotes();
       result.put(project, RefState.create(robotNotes.getRefName(), robotNotes.getMetaId()));
-      draftRefs()
-          .entrySet()
-          .forEach(
-              r ->
-                  result.put(
-                      allUsersName,
-                      RefState.create(
-                          RefNames.refsDraftComments(getId(), r.getKey()), r.getValue())));
 
       refStates = result.build();
     }
diff --git a/java/com/google/gerrit/server/query/change/ChangeNumberBitmapMaskAlgorithm.java b/java/com/google/gerrit/server/query/change/ChangeNumberBitmapMaskAlgorithm.java
new file mode 100644
index 0000000..726a376
--- /dev/null
+++ b/java/com/google/gerrit/server/query/change/ChangeNumberBitmapMaskAlgorithm.java
@@ -0,0 +1,76 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.change;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.server.config.GerritImportedServerIds;
+import com.google.inject.Inject;
+import com.google.inject.ProvisionException;
+import com.google.inject.Singleton;
+
+/**
+ * Dictionary-based encoding algorithm for combining a serverId/legacyChangeNum into a virtual
+ * numeric id
+ *
+ * <p>TODO: To be reverted on master and stable-3.8
+ */
+@Singleton
+public class ChangeNumberBitmapMaskAlgorithm implements ChangeNumberVirtualIdAlgorithm {
+  /*
+   * Bit-wise masks for representing the Change's VirtualId as combination of ServerId + ChangeNum:
+   */
+  private static final int CHANGE_NUM_BIT_LEN = 28; // Allows up to 268M changes
+  private static final int LEGACY_ID_BIT_MASK = (1 << CHANGE_NUM_BIT_LEN) - 1;
+  private static final int SERVER_ID_BIT_LEN =
+      Integer.BYTES * 8 - CHANGE_NUM_BIT_LEN; // Allows up to 64 ServerIds
+
+  private final ImmutableMap<String, Integer> serverIdCodes;
+
+  @Inject
+  public ChangeNumberBitmapMaskAlgorithm(
+      @GerritImportedServerIds ImmutableList<String> importedServerIds) {
+    if (importedServerIds.size() >= 1 << SERVER_ID_BIT_LEN) {
+      throw new ProvisionException(
+          String.format(
+              "Too many imported GerritServerIds (%d) to fit into the Change virtual id",
+              importedServerIds.size()));
+    }
+    ImmutableMap.Builder<String, Integer> serverIdCodesBuilder = new ImmutableMap.Builder<>();
+    for (int i = 0; i < importedServerIds.size(); i++) {
+      serverIdCodesBuilder.put(importedServerIds.get(i), i + 1);
+    }
+
+    serverIdCodes = serverIdCodesBuilder.build();
+  }
+
+  @Override
+  public int apply(String changeServerId, int changeNum) {
+    if ((changeNum & LEGACY_ID_BIT_MASK) != changeNum) {
+      throw new IllegalArgumentException(
+          String.format(
+              "Change number %d is too large to be converted into a virtual id", changeNum));
+    }
+
+    Integer encodedServerId = serverIdCodes.get(changeServerId);
+    if (encodedServerId == null) {
+      throw new IllegalArgumentException(
+          String.format("ServerId %s is not part of the GerritImportedServerIds", changeServerId));
+    }
+    int virtualId = (changeNum & LEGACY_ID_BIT_MASK) | (encodedServerId << CHANGE_NUM_BIT_LEN);
+
+    return virtualId;
+  }
+}
diff --git a/java/com/google/gerrit/server/query/change/ChangeNumberVirtualIdAlgorithm.java b/java/com/google/gerrit/server/query/change/ChangeNumberVirtualIdAlgorithm.java
new file mode 100644
index 0000000..ab21705
--- /dev/null
+++ b/java/com/google/gerrit/server/query/change/ChangeNumberVirtualIdAlgorithm.java
@@ -0,0 +1,35 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.change;
+
+import com.google.inject.ImplementedBy;
+
+/**
+ * Algorithm for encoding a serverId/legacyChangeNum into a virtual numeric id
+ *
+ * <p>TODO: To be reverted on master and stable-3.8
+ */
+@ImplementedBy(ChangeNumberBitmapMaskAlgorithm.class)
+public interface ChangeNumberVirtualIdAlgorithm {
+
+  /**
+   * Convert a serverId/legacyChangeNum tuple into a virtual numeric id
+   *
+   * @param serverId Gerrit serverId
+   * @param legacyChangeNum legacy change number
+   * @return virtual id which combines serverId and legacyChangeNum together
+   */
+  int apply(String serverId, int legacyChangeNum);
+}
diff --git a/java/com/google/gerrit/server/query/change/ChangePredicates.java b/java/com/google/gerrit/server/query/change/ChangePredicates.java
index 259239b..5f9abc3 100644
--- a/java/com/google/gerrit/server/query/change/ChangePredicates.java
+++ b/java/com/google/gerrit/server/query/change/ChangePredicates.java
@@ -81,11 +81,7 @@
    * Returns a predicate that matches changes where the provided {@link
    * com.google.gerrit.entities.Account.Id} has a pending draft comment.
    */
-  public static Predicate<ChangeData> draftBy(
-      boolean computeFromAllUsersRepository, CommentsUtil commentsUtil, Account.Id id) {
-    if (!computeFromAllUsersRepository) {
-      return new ChangeIndexCardinalPredicate(ChangeField.DRAFTBY, id.toString(), 20);
-    }
+  public static Predicate<ChangeData> draftBy(CommentsUtil commentsUtil, Account.Id id) {
     Set<Predicate<ChangeData>> changeIdPredicates =
         commentsUtil.getChangesWithDrafts(id).stream()
             .map(ChangePredicates::idStr)
@@ -100,13 +96,7 @@
    * com.google.gerrit.entities.Account.Id} has starred changes with {@code label}.
    */
   public static Predicate<ChangeData> starBy(
-      boolean computeFromAllUsersRepository,
-      StarredChangesUtil starredChangesUtil,
-      Account.Id id,
-      String label) {
-    if (!computeFromAllUsersRepository) {
-      return new StarPredicate(id, label);
-    }
+      StarredChangesUtil starredChangesUtil, Account.Id id, String label) {
     Set<Predicate<ChangeData>> starredChanges =
         starredChangesUtil.byAccountId(id, label).stream()
             .map(ChangePredicates::idStr)
@@ -136,15 +126,6 @@
    * Returns a predicate that matches the change with the provided {@link
    * com.google.gerrit.entities.Change.Id}.
    */
-  public static Predicate<ChangeData> id(Change.Id id) {
-    return new ChangeIndexCardinalPredicate(
-        ChangeField.LEGACY_ID, ChangeQueryBuilder.FIELD_CHANGE, id.toString(), 1);
-  }
-
-  /**
-   * Returns a predicate that matches the change with the provided {@link
-   * com.google.gerrit.entities.Change.Id}.
-   */
   public static Predicate<ChangeData> idStr(Change.Id id) {
     return new ChangeIndexCardinalPredicate(
         ChangeField.LEGACY_ID_STR, ChangeQueryBuilder.FIELD_CHANGE, id.toString(), 1);
diff --git a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
index d6b26b9f..6c874f9 100644
--- a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
@@ -17,7 +17,6 @@
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.gerrit.entities.Change.CHANGE_ID_PATTERN;
 import static com.google.gerrit.server.account.AccountResolver.isSelf;
-import static com.google.gerrit.server.experiments.ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_COMPUTE_FROM_ALL_USERS_REPOSITORY;
 import static com.google.gerrit.server.query.change.ChangeData.asChanges;
 import static java.util.stream.Collectors.toList;
 import static java.util.stream.Collectors.toSet;
@@ -561,9 +560,7 @@
     if (PAT_LEGACY_ID.matcher(query).matches()) {
       Integer id = Ints.tryParse(query);
       if (id != null) {
-        return args.getSchema().useLegacyNumericFields()
-            ? ChangePredicates.id(Change.id(id))
-            : ChangePredicates.idStr(Change.id(id));
+        return ChangePredicates.idStr(Change.id(id));
       }
     } else if (PAT_CHANGE_ID.matcher(query).matches()) {
       return ChangePredicates.idPrefix(parseChangeId(query));
@@ -735,10 +732,6 @@
       return new IsSubmittablePredicate();
     }
 
-    if ("ignored".equalsIgnoreCase(value)) {
-      return ignoredBySelf();
-    }
-
     if ("started".equalsIgnoreCase(value)) {
       checkFieldAvailable(ChangeField.STARTED, "is:started");
       return new BooleanPredicate(ChangeField.STARTED);
@@ -1143,41 +1136,13 @@
     return ChangePredicates.message(text);
   }
 
-  @Operator
-  public Predicate<ChangeData> star(String label) throws QueryParseException {
-    if ("ignore".equalsIgnoreCase(label)) {
-      return ignoredBySelf();
-    }
-    if ("star".equalsIgnoreCase(label)) {
-      return starredBySelf();
-    }
-    throw new IllegalArgumentException();
-  }
-
-  private Predicate<ChangeData> ignoredBySelf() throws QueryParseException {
-    return ChangePredicates.starBy(
-        args.experimentFeatures.isFeatureEnabled(
-            GERRIT_BACKEND_REQUEST_FEATURE_COMPUTE_FROM_ALL_USERS_REPOSITORY),
-        args.starredChangesUtil,
-        self(),
-        StarredChangesUtil.IGNORE_LABEL);
-  }
-
   private Predicate<ChangeData> starredBySelf() throws QueryParseException {
     return ChangePredicates.starBy(
-        args.experimentFeatures.isFeatureEnabled(
-            GERRIT_BACKEND_REQUEST_FEATURE_COMPUTE_FROM_ALL_USERS_REPOSITORY),
-        args.starredChangesUtil,
-        self(),
-        StarredChangesUtil.DEFAULT_LABEL);
+        args.starredChangesUtil, self(), StarredChangesUtil.DEFAULT_LABEL);
   }
 
   private Predicate<ChangeData> draftBySelf() throws QueryParseException {
-    return ChangePredicates.draftBy(
-        args.experimentFeatures.isFeatureEnabled(
-            GERRIT_BACKEND_REQUEST_FEATURE_COMPUTE_FROM_ALL_USERS_REPOSITORY),
-        args.commentsUtil,
-        self());
+    return ChangePredicates.draftBy(args.commentsUtil, self());
   }
 
   @Operator
diff --git a/java/com/google/gerrit/server/query/change/ConflictsPredicate.java b/java/com/google/gerrit/server/query/change/ConflictsPredicate.java
index f95dbb0..fc4c1d0 100644
--- a/java/com/google/gerrit/server/query/change/ConflictsPredicate.java
+++ b/java/com/google/gerrit/server/query/change/ConflictsPredicate.java
@@ -89,11 +89,7 @@
     List<Predicate<ChangeData>> and = new ArrayList<>(5);
     and.add(ChangePredicates.project(c.getProject()));
     and.add(ChangePredicates.ref(c.getDest().branch()));
-    and.add(
-        Predicate.not(
-            args.getSchema().useLegacyNumericFields()
-                ? ChangePredicates.id(c.getId())
-                : ChangePredicates.idStr(c.getId())));
+    and.add(Predicate.not(ChangePredicates.idStr(c.getId())));
     and.add(Predicate.or(filePredicates));
 
     ChangeDataCache changeDataCache = new ChangeDataCache(cd, args.projectCache);
diff --git a/java/com/google/gerrit/server/query/change/InternalChangeQuery.java b/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
index e7b25fb..99c1ca1 100644
--- a/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
+++ b/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
@@ -56,11 +56,6 @@
  * holding on to a single instance.
  */
 public class InternalChangeQuery extends InternalQuery<ChangeData, InternalChangeQuery> {
-  @FunctionalInterface
-  static interface ChangeIdPredicateFactory {
-    Predicate<ChangeData> create(Change.Id id);
-  }
-
   private static Predicate<ChangeData> ref(BranchNameKey branch) {
     return ChangePredicates.ref(branch.branch());
   }
@@ -84,9 +79,6 @@
   private final ChangeData.Factory changeDataFactory;
   private final ChangeNotes.Factory notesFactory;
 
-  // TODO(davido): Remove the below fields when support for legacy numeric fields is removed.
-  private final ChangeIdPredicateFactory predicateFactory;
-
   @Inject
   InternalChangeQuery(
       ChangeQueryProcessor queryProcessor,
@@ -97,11 +89,6 @@
     super(queryProcessor, indexes, indexConfig);
     this.changeDataFactory = changeDataFactory;
     this.notesFactory = notesFactory;
-    predicateFactory =
-        (id) ->
-            schema().useLegacyNumericFields()
-                ? ChangePredicates.id(id)
-                : ChangePredicates.idStr(id);
   }
 
   public List<ChangeData> byKey(Change.Key key) {
@@ -113,13 +100,13 @@
   }
 
   public List<ChangeData> byLegacyChangeId(Change.Id id) {
-    return query(predicateFactory.create(id));
+    return query(ChangePredicates.idStr(id));
   }
 
   public List<ChangeData> byLegacyChangeIds(Collection<Change.Id> ids) {
     List<Predicate<ChangeData>> preds = new ArrayList<>(ids.size());
     for (Change.Id id : ids) {
-      preds.add(predicateFactory.create(id));
+      preds.add(ChangePredicates.idStr(id));
     }
     return query(or(preds));
   }
diff --git a/java/com/google/gerrit/server/query/change/StarPredicate.java b/java/com/google/gerrit/server/query/change/StarPredicate.java
deleted file mode 100644
index 548ab29..0000000
--- a/java/com/google/gerrit/server/query/change/StarPredicate.java
+++ /dev/null
@@ -1,46 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.query.change;
-
-import com.google.gerrit.entities.Account;
-import com.google.gerrit.index.query.HasCardinality;
-import com.google.gerrit.server.StarredChangesUtil;
-import com.google.gerrit.server.index.change.ChangeField;
-
-public class StarPredicate extends ChangeIndexPredicate implements HasCardinality {
-  protected final Account.Id accountId;
-  protected final String label;
-
-  public StarPredicate(Account.Id accountId, String label) {
-    super(ChangeField.STAR, StarredChangesUtil.StarField.create(accountId, label).toString());
-    this.accountId = accountId;
-    this.label = label;
-  }
-
-  @Override
-  public boolean match(ChangeData cd) {
-    return cd.stars().get(accountId).contains(label);
-  }
-
-  @Override
-  public int getCardinality() {
-    return 10;
-  }
-
-  @Override
-  public String toString() {
-    return ChangeQueryBuilder.FIELD_STAR + ":" + label;
-  }
-}
diff --git a/java/com/google/gerrit/server/query/group/GroupPredicates.java b/java/com/google/gerrit/server/query/group/GroupPredicates.java
index 8a2dc8d..e742cba 100644
--- a/java/com/google/gerrit/server/query/group/GroupPredicates.java
+++ b/java/com/google/gerrit/server/query/group/GroupPredicates.java
@@ -17,7 +17,7 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.InternalGroup;
-import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
 import com.google.gerrit.index.query.IndexPredicate;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.server.index.group.GroupField;
@@ -26,51 +26,51 @@
 /** Utility class to create predicates for group index queries. */
 public class GroupPredicates {
   public static Predicate<InternalGroup> id(AccountGroup.Id groupId) {
-    return new GroupPredicate(GroupField.ID, groupId.toString());
+    return new GroupPredicate(GroupField.ID_FIELD_SPEC, groupId.toString());
   }
 
   public static Predicate<InternalGroup> uuid(AccountGroup.UUID uuid) {
-    return new GroupPredicate(GroupField.UUID, GroupQueryBuilder.FIELD_UUID, uuid.get());
+    return new GroupPredicate(GroupField.UUID_FIELD_SPEC, GroupQueryBuilder.FIELD_UUID, uuid.get());
   }
 
   public static Predicate<InternalGroup> description(String description) {
     return new GroupPredicate(
-        GroupField.DESCRIPTION, GroupQueryBuilder.FIELD_DESCRIPTION, description);
+        GroupField.DESCRIPTION_SPEC, GroupQueryBuilder.FIELD_DESCRIPTION, description);
   }
 
   public static Predicate<InternalGroup> inname(String name) {
     return new GroupPredicate(
-        GroupField.NAME_PART, GroupQueryBuilder.FIELD_INNAME, name.toLowerCase(Locale.US));
+        GroupField.NAME_PART_SPEC, GroupQueryBuilder.FIELD_INNAME, name.toLowerCase(Locale.US));
   }
 
   public static Predicate<InternalGroup> name(String name) {
-    return new GroupPredicate(GroupField.NAME, name);
+    return new GroupPredicate(GroupField.NAME_SPEC, name);
   }
 
   public static Predicate<InternalGroup> owner(AccountGroup.UUID ownerUuid) {
     return new GroupPredicate(
-        GroupField.OWNER_UUID, GroupQueryBuilder.FIELD_OWNER, ownerUuid.get());
+        GroupField.OWNER_UUID_SPEC, GroupQueryBuilder.FIELD_OWNER, ownerUuid.get());
   }
 
   public static Predicate<InternalGroup> isVisibleToAll() {
-    return new GroupPredicate(GroupField.IS_VISIBLE_TO_ALL, "1");
+    return new GroupPredicate(GroupField.IS_VISIBLE_TO_ALL_SPEC, "1");
   }
 
   public static Predicate<InternalGroup> member(Account.Id memberId) {
-    return new GroupPredicate(GroupField.MEMBER, memberId.toString());
+    return new GroupPredicate(GroupField.MEMBER_SPEC, memberId.toString());
   }
 
   public static Predicate<InternalGroup> subgroup(AccountGroup.UUID subgroupUuid) {
-    return new GroupPredicate(GroupField.SUBGROUP, subgroupUuid.get());
+    return new GroupPredicate(GroupField.SUBGROUP_SPEC, subgroupUuid.get());
   }
 
   /** Predicate that is mapped to a field in the group index. */
   static class GroupPredicate extends IndexPredicate<InternalGroup> {
-    GroupPredicate(FieldDef<InternalGroup, ?> def, String value) {
+    GroupPredicate(SchemaField<InternalGroup, ?> def, String value) {
       super(def, value);
     }
 
-    GroupPredicate(FieldDef<InternalGroup, ?> def, String name, String value) {
+    GroupPredicate(SchemaField<InternalGroup, ?> def, String name, String value) {
       super(def, name, value);
     }
   }
diff --git a/java/com/google/gerrit/server/restapi/BUILD b/java/com/google/gerrit/server/restapi/BUILD
index 2566b72..62da2f2 100644
--- a/java/com/google/gerrit/server/restapi/BUILD
+++ b/java/com/google/gerrit/server/restapi/BUILD
@@ -31,6 +31,7 @@
         "//lib:jgit",
         "//lib:servlet-api",
         "//lib/antlr:java-runtime",
+        "//lib/auto:auto-factory",
         "//lib/auto:auto-value",
         "//lib/auto:auto-value-annotations",
         "//lib/commons:compress",
diff --git a/java/com/google/gerrit/server/restapi/account/DeleteDraftComments.java b/java/com/google/gerrit/server/restapi/account/DeleteDraftComments.java
index fbd99eb..e35ffdb 100644
--- a/java/com/google/gerrit/server/restapi/account/DeleteDraftComments.java
+++ b/java/com/google/gerrit/server/restapi/account/DeleteDraftComments.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.restapi.account;
 
 import static com.google.common.collect.ImmutableList.toImmutableList;
-import static com.google.gerrit.server.experiments.ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_COMPUTE_FROM_ALL_USERS_REPOSITORY;
 
 import com.google.common.base.CharMatcher;
 import com.google.common.base.Strings;
@@ -40,7 +39,6 @@
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.account.AccountResource;
 import com.google.gerrit.server.change.ChangeJson;
-import com.google.gerrit.server.experiments.ExperimentFeatures;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangePredicates;
@@ -70,37 +68,34 @@
 
   private final Provider<CurrentUser> userProvider;
   private final BatchUpdate.Factory batchUpdateFactory;
-  private final Provider<ChangeQueryBuilder> queryBuilderProvider;
+  private final ChangeQueryBuilder queryBuilder;
   private final Provider<InternalChangeQuery> queryProvider;
   private final ChangeData.Factory changeDataFactory;
   private final ChangeJson.Factory changeJsonFactory;
   private final Provider<CommentJson> commentJsonProvider;
   private final CommentsUtil commentsUtil;
   private final PatchSetUtil psUtil;
-  private final ExperimentFeatures experimentFeatures;
 
   @Inject
   DeleteDraftComments(
       Provider<CurrentUser> userProvider,
       BatchUpdate.Factory batchUpdateFactory,
-      Provider<ChangeQueryBuilder> queryBuilderProvider,
+      ChangeQueryBuilder queryBuilder,
       Provider<InternalChangeQuery> queryProvider,
       ChangeData.Factory changeDataFactory,
       ChangeJson.Factory changeJsonFactory,
       Provider<CommentJson> commentJsonProvider,
       CommentsUtil commentsUtil,
-      PatchSetUtil psUtil,
-      ExperimentFeatures experimentFeatures) {
+      PatchSetUtil psUtil) {
     this.userProvider = userProvider;
     this.batchUpdateFactory = batchUpdateFactory;
-    this.queryBuilderProvider = queryBuilderProvider;
+    this.queryBuilder = queryBuilder;
     this.queryProvider = queryProvider;
     this.changeDataFactory = changeDataFactory;
     this.changeJsonFactory = changeJsonFactory;
     this.commentJsonProvider = commentJsonProvider;
     this.commentsUtil = commentsUtil;
     this.psUtil = psUtil;
-    this.experimentFeatures = experimentFeatures;
   }
 
   @Override
@@ -151,17 +146,12 @@
 
   private Predicate<ChangeData> predicate(Account.Id accountId, DeleteDraftCommentsInput input)
       throws BadRequestException {
-    Predicate<ChangeData> hasDraft =
-        ChangePredicates.draftBy(
-            experimentFeatures.isFeatureEnabled(
-                GERRIT_BACKEND_REQUEST_FEATURE_COMPUTE_FROM_ALL_USERS_REPOSITORY),
-            commentsUtil,
-            accountId);
+    Predicate<ChangeData> hasDraft = ChangePredicates.draftBy(commentsUtil, accountId);
     if (CharMatcher.whitespace().trimFrom(Strings.nullToEmpty(input.query)).isEmpty()) {
       return hasDraft;
     }
     try {
-      return Predicate.and(hasDraft, queryBuilderProvider.get().parse(input.query));
+      return Predicate.and(hasDraft, queryBuilder.parse(input.query));
     } catch (QueryParseException e) {
       throw new BadRequestException("Invalid query: " + e.getMessage(), e);
     }
diff --git a/java/com/google/gerrit/server/restapi/account/DeleteSshKey.java b/java/com/google/gerrit/server/restapi/account/DeleteSshKey.java
index f73f00a..e09e48f 100644
--- a/java/com/google/gerrit/server/restapi/account/DeleteSshKey.java
+++ b/java/com/google/gerrit/server/restapi/account/DeleteSshKey.java
@@ -23,6 +23,7 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountResource;
+import com.google.gerrit.server.account.AccountSshKey;
 import com.google.gerrit.server.account.VersionedAuthorizedKeys;
 import com.google.gerrit.server.mail.send.DeleteKeySender;
 import com.google.gerrit.server.permissions.GlobalPermission;
@@ -74,10 +75,14 @@
       permissionBackend.currentUser().check(GlobalPermission.ADMINISTRATE_SERVER);
     }
 
-    IdentifiedUser user = rsrc.getUser();
-    authorizedKeys.deleteKey(user.getAccountId(), rsrc.getSshKey().seq());
+    return apply(rsrc.getUser(), rsrc.getSshKey());
+  }
+
+  public Response<?> apply(IdentifiedUser user, AccountSshKey sshKey)
+      throws RepositoryNotFoundException, IOException, ConfigInvalidException {
+    authorizedKeys.deleteKey(user.getAccountId(), sshKey.seq());
     try {
-      deleteKeySenderFactory.create(user, rsrc.getSshKey()).send();
+      deleteKeySenderFactory.create(user, sshKey).send();
     } catch (EmailException e) {
       logger.atSevere().withCause(e).log(
           "Cannot send SSH key deletion message to %s", user.getAccount().preferredEmail());
diff --git a/java/com/google/gerrit/server/restapi/account/GetAgreements.java b/java/com/google/gerrit/server/restapi/account/GetAgreements.java
index db6ad48..eb2be10 100644
--- a/java/com/google/gerrit/server/restapi/account/GetAgreements.java
+++ b/java/com/google/gerrit/server/restapi/account/GetAgreements.java
@@ -87,10 +87,8 @@
 
     IdentifiedUser user = self.get().asIdentifiedUser();
     if (user != resource.getUser()) {
-      try {
-        permissionBackend.user(user).check(GlobalPermission.ADMINISTRATE_SERVER);
-      } catch (AuthException e) {
-        throw new AuthException("not allowed to get contributor agreements", e);
+      if (!permissionBackend.user(user).test(GlobalPermission.ADMINISTRATE_SERVER)) {
+        throw new AuthException("not allowed to get contributor agreements");
       }
     }
 
diff --git a/java/com/google/gerrit/server/restapi/account/QueryAccounts.java b/java/com/google/gerrit/server/restapi/account/QueryAccounts.java
index c671562..30534b5 100644
--- a/java/com/google/gerrit/server/restapi/account/QueryAccounts.java
+++ b/java/com/google/gerrit/server/restapi/account/QueryAccounts.java
@@ -202,6 +202,9 @@
     }
 
     if (start != null) {
+      if (start < 0) {
+        throw new BadRequestException("'start' parameter cannot be less than zero");
+      }
       queryProcessor.setStart(start);
     }
 
diff --git a/java/com/google/gerrit/server/restapi/account/StarredChanges.java b/java/com/google/gerrit/server/restapi/account/StarredChanges.java
index 39c1fef..12abf3d 100644
--- a/java/com/google/gerrit/server/restapi/account/StarredChanges.java
+++ b/java/com/google/gerrit/server/restapi/account/StarredChanges.java
@@ -134,8 +134,7 @@
             self.get().getAccountId(),
             change.getProject(),
             change.getId(),
-            StarredChangesUtil.DEFAULT_LABELS,
-            null);
+            StarredChangesUtil.Operation.ADD);
       } catch (MutuallyExclusiveLabelsException e) {
         throw new ResourceConflictException(e.getMessage());
       } catch (IllegalLabelException e) {
@@ -186,8 +185,7 @@
           self.get().getAccountId(),
           rsrc.getChange().getProject(),
           rsrc.getChange().getId(),
-          null,
-          StarredChangesUtil.DEFAULT_LABELS);
+          StarredChangesUtil.Operation.REMOVE);
       return Response.none();
     }
   }
diff --git a/java/com/google/gerrit/server/restapi/change/AddToAttentionSet.java b/java/com/google/gerrit/server/restapi/change/AddToAttentionSet.java
index a21431e..03d383f 100644
--- a/java/com/google/gerrit/server/restapi/change/AddToAttentionSet.java
+++ b/java/com/google/gerrit/server/restapi/change/AddToAttentionSet.java
@@ -81,13 +81,11 @@
           String.format(
               "%s is a robot, and robots can't be added to the attention set.", input.user));
     }
-    try {
-      permissionBackend
-          .absentUser(attentionUserId)
-          .change(changeResource.getNotes())
-          .check(ChangePermission.READ);
-    } catch (AuthException e) {
-      throw new AuthException("read not permitted for " + attentionUserId, e);
+    if (!permissionBackend
+        .absentUser(attentionUserId)
+        .change(changeResource.getNotes())
+        .test(ChangePermission.READ)) {
+      throw new AuthException("read not permitted for " + attentionUserId);
     }
 
     try (BatchUpdate bu =
diff --git a/java/com/google/gerrit/server/restapi/change/ApplyFix.java b/java/com/google/gerrit/server/restapi/change/ApplyProvidedFix.java
similarity index 73%
copy from java/com/google/gerrit/server/restapi/change/ApplyFix.java
copy to java/com/google/gerrit/server/restapi/change/ApplyProvidedFix.java
index a1c51e8..a55ef84b 100644
--- a/java/com/google/gerrit/server/restapi/change/ApplyFix.java
+++ b/java/com/google/gerrit/server/restapi/change/ApplyProvidedFix.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2017 The Android Open Source Project
+// Copyright (C) 2022 The Android Open Source Project
 //
 // Licensed under the Apache License, Version 2.0 (the "License");
 // you may not use this file except in compliance with the License.
@@ -16,17 +16,18 @@
 
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
 
+import com.google.gerrit.entities.Comment.Range;
+import com.google.gerrit.entities.FixReplacement;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.common.ApplyProvidedFixInput;
 import com.google.gerrit.extensions.common.EditInfo;
-import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.server.change.FixResource;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.edit.ChangeEdit;
 import com.google.gerrit.server.edit.ChangeEditJson;
@@ -34,6 +35,7 @@
 import com.google.gerrit.server.edit.CommitModification;
 import com.google.gerrit.server.fixes.FixReplacementInterpreter;
 import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.InvalidChangeOperationException;
 import com.google.gerrit.server.project.ProjectCache;
@@ -41,11 +43,13 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
+import java.util.List;
+import java.util.stream.Collectors;
 import org.eclipse.jgit.lib.Repository;
 
+/** Applies a fix that is provided as part of the request body. */
 @Singleton
-public class ApplyFix implements RestModifyView<FixResource, Input> {
-
+public class ApplyProvidedFix implements RestModifyView<RevisionResource, ApplyProvidedFixInput> {
   private final GitRepositoryManager gitRepositoryManager;
   private final FixReplacementInterpreter fixReplacementInterpreter;
   private final ChangeEditModifier changeEditModifier;
@@ -53,7 +57,7 @@
   private final ProjectCache projectCache;
 
   @Inject
-  public ApplyFix(
+  public ApplyProvidedFix(
       GitRepositoryManager gitRepositoryManager,
       FixReplacementInterpreter fixReplacementInterpreter,
       ChangeEditModifier changeEditModifier,
@@ -67,21 +71,35 @@
   }
 
   @Override
-  public Response<EditInfo> apply(FixResource fixResource, Input nothing)
+  public Response<EditInfo> apply(
+      RevisionResource revisionResource, ApplyProvidedFixInput applyProvidedFixInput)
       throws AuthException, BadRequestException, ResourceConflictException, IOException,
           ResourceNotFoundException, PermissionBackendException {
-    RevisionResource revisionResource = fixResource.getRevisionResource();
+    if (applyProvidedFixInput == null) {
+      throw new BadRequestException("applyProvidedFixInput is required");
+    }
+    if (applyProvidedFixInput.fixReplacementInfos == null) {
+      throw new BadRequestException("applyProvidedFixInput.fixReplacementInfos is required");
+    }
     Project.NameKey project = revisionResource.getProject();
     ProjectState projectState = projectCache.get(project).orElseThrow(illegalState(project));
     PatchSet patchSet = revisionResource.getPatchSet();
 
+    ChangeNotes changeNotes = revisionResource.getNotes();
+
+    List<FixReplacement> fixReplacements =
+        applyProvidedFixInput.fixReplacementInfos.stream()
+            .map(fix -> new FixReplacement(fix.path, new Range(fix.range), fix.replacement))
+            .collect(Collectors.toList());
+
     try (Repository repository = gitRepositoryManager.openRepository(project)) {
       CommitModification commitModification =
           fixReplacementInterpreter.toCommitModification(
-              repository, projectState, patchSet.commitId(), fixResource.getFixReplacements());
+              repository, projectState, patchSet.commitId(), fixReplacements);
       ChangeEdit changeEdit =
           changeEditModifier.combineWithModifiedPatchSetTree(
-              repository, revisionResource.getNotes(), patchSet, commitModification);
+              repository, changeNotes, patchSet, commitModification);
+
       return Response.ok(changeEditJson.toEditInfo(changeEdit, false));
     } catch (InvalidChangeOperationException e) {
       throw new ResourceConflictException(e.getMessage());
diff --git a/java/com/google/gerrit/server/restapi/change/ApplyFix.java b/java/com/google/gerrit/server/restapi/change/ApplyStoredFix.java
similarity index 97%
rename from java/com/google/gerrit/server/restapi/change/ApplyFix.java
rename to java/com/google/gerrit/server/restapi/change/ApplyStoredFix.java
index a1c51e8..2d87dcf 100644
--- a/java/com/google/gerrit/server/restapi/change/ApplyFix.java
+++ b/java/com/google/gerrit/server/restapi/change/ApplyStoredFix.java
@@ -44,7 +44,7 @@
 import org.eclipse.jgit.lib.Repository;
 
 @Singleton
-public class ApplyFix implements RestModifyView<FixResource, Input> {
+public class ApplyStoredFix implements RestModifyView<FixResource, Input> {
 
   private final GitRepositoryManager gitRepositoryManager;
   private final FixReplacementInterpreter fixReplacementInterpreter;
@@ -53,7 +53,7 @@
   private final ProjectCache projectCache;
 
   @Inject
-  public ApplyFix(
+  public ApplyStoredFix(
       GitRepositoryManager gitRepositoryManager,
       FixReplacementInterpreter fixReplacementInterpreter,
       ChangeEditModifier changeEditModifier,
diff --git a/java/com/google/gerrit/server/restapi/change/ChangeRestApiModule.java b/java/com/google/gerrit/server/restapi/change/ChangeRestApiModule.java
index e09f2f4..718759a 100644
--- a/java/com/google/gerrit/server/restapi/change/ChangeRestApiModule.java
+++ b/java/com/google/gerrit/server/restapi/change/ChangeRestApiModule.java
@@ -118,8 +118,6 @@
     post(CHANGE_KIND, "private").to(PostPrivate.class);
     post(CHANGE_KIND, "private.delete").to(DeletePrivateByPost.class);
     delete(CHANGE_KIND, "private").to(DeletePrivate.class);
-    put(CHANGE_KIND, "ignore").to(Ignore.class);
-    put(CHANGE_KIND, "unignore").to(Unignore.class);
     post(CHANGE_KIND, "wip").to(SetWorkInProgress.class);
     post(CHANGE_KIND, "ready").to(SetReadyForReview.class);
     put(CHANGE_KIND, "message").to(PutMessage.class);
@@ -170,8 +168,10 @@
     child(REVISION_KIND, "robotcomments").to(RobotComments.class);
     get(ROBOT_COMMENT_KIND).to(GetRobotComment.class);
     child(REVISION_KIND, "fixes").to(Fixes.class);
-    post(FIX_KIND, "apply").to(ApplyFix.class);
-    get(FIX_KIND, "preview").to(GetFixPreview.class);
+    post(FIX_KIND, "apply").to(ApplyStoredFix.class);
+    get(FIX_KIND, "preview").to(PreviewFix.Stored.class);
+    post(REVISION_KIND, "fix:apply").to(ApplyProvidedFix.class);
+    post(REVISION_KIND, "fix:preview").to(PreviewFix.Provided.class);
 
     get(REVISION_KIND, "ported_comments").to(ListPortedComments.class);
     get(REVISION_KIND, "ported_drafts").to(ListPortedDrafts.class);
@@ -210,9 +210,12 @@
     factory(DeleteChangeOp.Factory.class);
     factory(DeleteReviewerByEmailOp.Factory.class);
     factory(DeleteReviewerOp.Factory.class);
+    factory(DeleteVoteOp.Factory.class);
     factory(EmailReviewComments.Factory.class);
     factory(PatchSetInserter.Factory.class);
     factory(AddReviewersOp.Factory.class);
+    factory(PostReviewOp.Factory.class);
+    factory(PreviewFix.Factory.class);
     factory(RebaseChangeOp.Factory.class);
     factory(ReviewerResource.Factory.class);
     factory(SetAssigneeOp.Factory.class);
diff --git a/java/com/google/gerrit/server/restapi/change/CheckSubmitRequirement.java b/java/com/google/gerrit/server/restapi/change/CheckSubmitRequirement.java
index 55b234c..e5c47a7a 100644
--- a/java/com/google/gerrit/server/restapi/change/CheckSubmitRequirement.java
+++ b/java/com/google/gerrit/server/restapi/change/CheckSubmitRequirement.java
@@ -14,39 +14,102 @@
 
 package com.google.gerrit.server.restapi.change;
 
+import com.google.common.collect.Iterables;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.entities.SubmitRequirement;
 import com.google.gerrit.entities.SubmitRequirementExpression;
 import com.google.gerrit.entities.SubmitRequirementResult;
 import com.google.gerrit.extensions.common.SubmitRequirementInput;
 import com.google.gerrit.extensions.common.SubmitRequirementResultInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.restapi.TopLevelResource;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.SubmitRequirementsJson;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.SubmitRequirementsEvaluator;
+import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
+import java.io.IOException;
+import java.util.List;
+import java.util.Map.Entry;
 import java.util.Optional;
+import java.util.stream.Collectors;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+import org.kohsuke.args4j.Option;
 
 /**
  * A rest view to evaluate (test) a {@link com.google.gerrit.entities.SubmitRequirement} on a given
- * change.
+ * change. The submit requirement can be supplied in one of two ways:
  *
- * <p>TODO(ghareeb): Can this class be made singleton?
+ * <p>1) Using the {@link SubmitRequirementInput}.
+ *
+ * <p>2) From a change to the {@link RefNames#REFS_CONFIG} branch and the name of the
+ * submit-requirement.
  */
 public class CheckSubmitRequirement
     implements RestModifyView<ChangeResource, SubmitRequirementInput> {
   private final SubmitRequirementsEvaluator evaluator;
 
+  @Option(name = "--sr-name")
+  private String srName;
+
+  @Option(name = "--refs-config-change-id")
+  private String refsConfigChangeId;
+
+  private final GitRepositoryManager repoManager;
+  private final ProjectConfig.Factory projectConfigFactory;
+  private final ChangeData.Factory changeDataFactory;
+  private final ChangesCollection changesCollection;
+  private final ChangeNotes.Factory changeNotesFactory;
+
+  public void setSrName(String srName) {
+    this.srName = srName;
+  }
+
+  public void setRefsConfigChangeId(String refsConfigChangeId) {
+    this.refsConfigChangeId = refsConfigChangeId;
+  }
+
   @Inject
-  public CheckSubmitRequirement(SubmitRequirementsEvaluator evaluator) {
+  public CheckSubmitRequirement(
+      SubmitRequirementsEvaluator evaluator,
+      GitRepositoryManager repoManager,
+      ProjectConfig.Factory projectConfigFactory,
+      ChangeData.Factory changeDataFactory,
+      ChangeNotes.Factory changeNotesFactory,
+      ChangesCollection changesCollection) {
     this.evaluator = evaluator;
+    this.repoManager = repoManager;
+    this.projectConfigFactory = projectConfigFactory;
+    this.changeDataFactory = changeDataFactory;
+    this.changeNotesFactory = changeNotesFactory;
+    this.changesCollection = changesCollection;
   }
 
   @Override
   public Response<SubmitRequirementResultInfo> apply(
-      ChangeResource resource, SubmitRequirementInput input) throws BadRequestException {
-    SubmitRequirement requirement = createSubmitRequirement(input);
+      ChangeResource resource, SubmitRequirementInput input)
+      throws IOException, PermissionBackendException, RestApiException {
+    if ((srName == null || refsConfigChangeId == null)
+        && !(srName == null && refsConfigChangeId == null)) {
+      throw new BadRequestException(
+          "Both 'sr-name' and 'refs-config-change-id' parameters must be set");
+    }
+    SubmitRequirement requirement =
+        srName != null && refsConfigChangeId != null
+            ? createSubmitRequirementFromRequestParams()
+            : createSubmitRequirement(input);
     SubmitRequirementResult res =
         evaluator.evaluateRequirement(requirement, resource.getChangeData());
     return Response.ok(SubmitRequirementsJson.toInfo(requirement, res));
@@ -67,6 +130,57 @@
         .build();
   }
 
+  /**
+   * Loads the submit-requirement identified by the name {@link #srName} from the latest patch-set
+   * of the change with ID {@link #refsConfigChangeId}.
+   *
+   * @return a {@link SubmitRequirement} entity.
+   * @throws BadRequestException If {@link #refsConfigChangeId} is a non-existent change or not in
+   *     the {@link RefNames#REFS_CONFIG} branch, if the submit-requirement with name {@link
+   *     #srName} does not exist or if the server failed to load the project due to other
+   *     exceptions.
+   */
+  private SubmitRequirement createSubmitRequirementFromRequestParams()
+      throws IOException, PermissionBackendException, RestApiException {
+    ChangeResource refsConfigChange;
+    try {
+      refsConfigChange =
+          changesCollection.parse(
+              TopLevelResource.INSTANCE, IdString.fromDecoded(refsConfigChangeId));
+    } catch (ResourceNotFoundException e) {
+      throw new BadRequestException(
+          String.format("Change '%s' does not exist", refsConfigChangeId), e);
+    }
+    ChangeNotes notes = changeNotesFactory.createCheckedUsingIndexLookup(refsConfigChange.getId());
+    ChangeData changeData = changeDataFactory.create(notes);
+    try (Repository git = repoManager.openRepository(changeData.project())) {
+      if (!changeData.change().getDest().branch().equals(RefNames.REFS_CONFIG)) {
+        throw new BadRequestException(
+            String.format("Change '%s' is not in refs/meta/config branch.", refsConfigChangeId));
+      }
+      ObjectId revisionId = changeData.currentPatchSet().commitId();
+      ProjectConfig cfg = projectConfigFactory.create(changeData.project());
+      try {
+        cfg.load(git, revisionId);
+      } catch (ConfigInvalidException e) {
+        throw new ResourceConflictException(
+            String.format(
+                "Failed to load project config for change '%s' from revision '%s'",
+                refsConfigChangeId, revisionId),
+            e);
+      }
+      List<Entry<String, SubmitRequirement>> submitRequirements =
+          cfg.getSubmitRequirementSections().entrySet().stream()
+              .filter(entry -> entry.getKey().equals(srName))
+              .collect(Collectors.toList());
+      if (submitRequirements.isEmpty()) {
+        throw new BadRequestException(
+            String.format("No submit requirement matching name '%s'", srName));
+      }
+      return Iterables.getOnlyElement(submitRequirements).getValue();
+    }
+  }
+
   private void validateSubmitRequirementInput(SubmitRequirementInput input)
       throws BadRequestException {
     if (input.name == null) {
diff --git a/java/com/google/gerrit/server/restapi/change/CherryPickChange.java b/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
index 6a25095..66f8be7 100644
--- a/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
+++ b/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
@@ -49,6 +49,7 @@
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.GroupCollector;
 import com.google.gerrit.server.git.MergeUtil;
+import com.google.gerrit.server.git.MergeUtilFactory;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
 import com.google.gerrit.server.notedb.Sequences;
@@ -69,11 +70,11 @@
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.time.Instant;
+import java.time.ZoneId;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
-import java.util.TimeZone;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.InvalidObjectIdException;
 import org.eclipse.jgit.errors.MissingObjectException;
@@ -103,12 +104,12 @@
   private final Sequences seq;
   private final Provider<InternalChangeQuery> queryProvider;
   private final GitRepositoryManager gitManager;
-  private final TimeZone serverTimeZone;
+  private final ZoneId serverZoneId;
   private final Provider<IdentifiedUser> user;
   private final ChangeInserter.Factory changeInserterFactory;
   private final PatchSetInserter.Factory patchSetInserterFactory;
   private final SetCherryPickOp.Factory setCherryPickOfFactory;
-  private final MergeUtil.Factory mergeUtilFactory;
+  private final MergeUtilFactory mergeUtilFactory;
   private final ChangeNotes.Factory changeNotesFactory;
   private final ProjectCache projectCache;
   private final ApprovalsUtil approvalsUtil;
@@ -125,7 +126,7 @@
       ChangeInserter.Factory changeInserterFactory,
       PatchSetInserter.Factory patchSetInserterFactory,
       SetCherryPickOp.Factory setCherryPickOfFactory,
-      MergeUtil.Factory mergeUtilFactory,
+      MergeUtilFactory mergeUtilFactory,
       ChangeNotes.Factory changeNotesFactory,
       ProjectCache projectCache,
       ApprovalsUtil approvalsUtil,
@@ -134,7 +135,7 @@
     this.seq = seq;
     this.queryProvider = queryProvider;
     this.gitManager = gitManager;
-    this.serverTimeZone = myIdent.getTimeZone();
+    this.serverZoneId = myIdent.getZoneId();
     this.user = user;
     this.changeInserterFactory = changeInserterFactory;
     this.patchSetInserterFactory = patchSetInserterFactory;
@@ -306,7 +307,7 @@
       CodeReviewCommit cherryPickCommit;
       ProjectState projectState =
           projectCache.get(dest.project()).orElseThrow(noSuchProject(dest.project()));
-      PersonIdent committerIdent = identifiedUser.newCommitterIdent(timestamp, serverTimeZone);
+      PersonIdent committerIdent = identifiedUser.newCommitterIdent(timestamp, serverZoneId);
 
       try {
         MergeUtil mergeUtil;
diff --git a/java/com/google/gerrit/server/restapi/change/CreateChange.java b/java/com/google/gerrit/server/restapi/change/CreateChange.java
index 6a637b3..760d99d 100644
--- a/java/com/google/gerrit/server/restapi/change/CreateChange.java
+++ b/java/com/google/gerrit/server/restapi/change/CreateChange.java
@@ -63,6 +63,7 @@
 import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.MergeUtil;
+import com.google.gerrit.server.git.MergeUtilFactory;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.Sequences;
 import com.google.gerrit.server.permissions.ChangePermission;
@@ -85,11 +86,10 @@
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.time.Instant;
+import java.time.ZoneId;
 import java.util.Collections;
-import java.util.Date;
 import java.util.List;
 import java.util.Optional;
-import java.util.TimeZone;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.InvalidObjectIdException;
 import org.eclipse.jgit.errors.MissingObjectException;
@@ -117,7 +117,7 @@
   private final String anonymousCowardName;
   private final GitRepositoryManager gitManager;
   private final Sequences seq;
-  private final TimeZone serverTimeZone;
+  private final ZoneId serverZoneId;
   private final PermissionBackend permissionBackend;
   private final Provider<CurrentUser> user;
   private final ProjectsCollection projectsCollection;
@@ -127,7 +127,7 @@
   private final ChangeFinder changeFinder;
   private final Provider<InternalChangeQuery> queryProvider;
   private final PatchSetUtil psUtil;
-  private final MergeUtil.Factory mergeUtilFactory;
+  private final MergeUtilFactory mergeUtilFactory;
   private final SubmitType submitType;
   private final NotifyResolver notifyResolver;
   private final ContributorAgreementsChecker contributorAgreements;
@@ -150,14 +150,14 @@
       Provider<InternalChangeQuery> queryProvider,
       PatchSetUtil psUtil,
       @GerritServerConfig Config config,
-      MergeUtil.Factory mergeUtilFactory,
+      MergeUtilFactory mergeUtilFactory,
       NotifyResolver notifyResolver,
       ContributorAgreementsChecker contributorAgreements) {
     this.updateFactory = updateFactory;
     this.anonymousCowardName = anonymousCowardName;
     this.gitManager = gitManager;
     this.seq = seq;
-    this.serverTimeZone = myIdent.getTimeZone();
+    this.serverZoneId = myIdent.getZoneId();
     this.permissionBackend = permissionBackend;
     this.user = user;
     this.projectsCollection = projectsCollection;
@@ -309,10 +309,8 @@
       Project.NameKey project, String refName, @Nullable AccountInput author)
       throws ResourceNotFoundException, AuthException, PermissionBackendException {
     PermissionBackend.ForRef forRef = permissionBackend.currentUser().project(project).ref(refName);
-    try {
-      forRef.check(RefPermission.READ);
-    } catch (AuthException e) {
-      throw new ResourceNotFoundException(String.format("ref %s not found", refName), e);
+    if (!forRef.test(RefPermission.READ)) {
+      throw new ResourceNotFoundException(String.format("ref %s not found", refName));
     }
     forRef.check(RefPermission.CREATE_CHANGE);
     if (author != null) {
@@ -320,9 +318,6 @@
     }
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   private ChangeInfo createNewChange(
       ChangeInput input,
       IdentifiedUser me,
@@ -359,12 +354,11 @@
 
       Instant now = TimeUtil.now();
 
-      PersonIdent committer = me.newCommitterIdent(now, serverTimeZone);
+      PersonIdent committer = me.newCommitterIdent(now, serverZoneId);
       PersonIdent author =
           input.author == null
               ? committer
-              : new PersonIdent(
-                  input.author.name, input.author.email, Date.from(now), serverTimeZone);
+              : new PersonIdent(input.author.name, input.author.email, now, serverZoneId);
 
       String commitMessage = getCommitMessage(input.subject, me);
 
diff --git a/java/com/google/gerrit/server/restapi/change/CreateMergePatchSet.java b/java/com/google/gerrit/server/restapi/change/CreateMergePatchSet.java
index 651bf7b..4b66cdc 100644
--- a/java/com/google/gerrit/server/restapi/change/CreateMergePatchSet.java
+++ b/java/com/google/gerrit/server/restapi/change/CreateMergePatchSet.java
@@ -51,6 +51,7 @@
 import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.MergeUtil;
+import com.google.gerrit.server.git.MergeUtilFactory;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.permissions.ChangePermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
@@ -68,9 +69,8 @@
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.time.Instant;
-import java.util.Date;
+import java.time.ZoneId;
 import java.util.List;
-import java.util.TimeZone;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.ObjectReader;
@@ -85,11 +85,11 @@
   private final BatchUpdate.Factory updateFactory;
   private final GitRepositoryManager gitManager;
   private final CommitsCollection commits;
-  private final TimeZone serverTimeZone;
+  private final ZoneId serverZoneId;
   private final Provider<CurrentUser> user;
   private final ChangeJson.Factory jsonFactory;
   private final PatchSetUtil psUtil;
-  private final MergeUtil.Factory mergeUtilFactory;
+  private final MergeUtilFactory mergeUtilFactory;
   private final PatchSetInserter.Factory patchSetInserterFactory;
   private final ProjectCache projectCache;
   private final ChangeFinder changeFinder;
@@ -104,7 +104,7 @@
       Provider<CurrentUser> user,
       ChangeJson.Factory json,
       PatchSetUtil psUtil,
-      MergeUtil.Factory mergeUtilFactory,
+      MergeUtilFactory mergeUtilFactory,
       PatchSetInserter.Factory patchSetInserterFactory,
       ProjectCache projectCache,
       ChangeFinder changeFinder,
@@ -112,7 +112,7 @@
     this.updateFactory = updateFactory;
     this.gitManager = gitManager;
     this.commits = commits;
-    this.serverTimeZone = myIdent.getTimeZone();
+    this.serverZoneId = myIdent.getZoneId();
     this.user = user;
     this.jsonFactory = json;
     this.psUtil = psUtil;
@@ -123,9 +123,6 @@
     this.permissionBackend = permissionBackend;
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   @Override
   public Response<ChangeInfo> apply(ChangeResource rsrc, MergePatchSetInput in)
       throws IOException, RestApiException, UpdateException, PermissionBackendException {
@@ -184,8 +181,8 @@
       IdentifiedUser me = user.get().asIdentifiedUser();
       PersonIdent author =
           in.author == null
-              ? me.newCommitterIdent(now, serverTimeZone)
-              : new PersonIdent(in.author.name, in.author.email, Date.from(now), serverTimeZone);
+              ? me.newCommitterIdent(now, serverZoneId)
+              : new PersonIdent(in.author.name, in.author.email, now, serverZoneId);
       CodeReviewCommit newCommit =
           createMergeCommit(
               in,
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteVote.java b/java/com/google/gerrit/server/restapi/change/DeleteVote.java
index 208cecf..9fa3160 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteVote.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteVote.java
@@ -15,102 +15,51 @@
 package com.google.gerrit.server.restapi.change;
 
 import static com.google.common.base.MoreObjects.firstNonNull;
-import static com.google.gerrit.server.project.ProjectCache.illegalState;
-import static java.util.Objects.requireNonNull;
 
-import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.LabelTypes;
-import com.google.gerrit.entities.PatchSet;
-import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.extensions.api.changes.DeleteVoteInput;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
-import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.PatchSetUtil;
-import com.google.gerrit.server.account.AccountState;
-import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.change.AddToAttentionSetOp;
 import com.google.gerrit.server.change.AttentionSetUnchangedOp;
 import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.change.ReviewerResource;
 import com.google.gerrit.server.change.VoteResource;
-import com.google.gerrit.server.extensions.events.VoteDeleted;
-import com.google.gerrit.server.mail.send.DeleteVoteSender;
-import com.google.gerrit.server.mail.send.MessageIdGenerator;
-import com.google.gerrit.server.mail.send.ReplyToChangeSender;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.server.project.ProjectState;
-import com.google.gerrit.server.project.RemoveReviewerControl;
 import com.google.gerrit.server.update.BatchUpdate;
-import com.google.gerrit.server.update.BatchUpdateOp;
-import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.gerrit.server.update.UpdateException;
-import com.google.gerrit.server.util.AccountTemplateUtil;
-import com.google.gerrit.server.util.LabelVote;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.util.HashMap;
-import java.util.Map;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
 @Singleton
 public class DeleteVote implements RestModifyView<VoteResource, DeleteVoteInput> {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
   private final BatchUpdate.Factory updateFactory;
-  private final ApprovalsUtil approvalsUtil;
-  private final PatchSetUtil psUtil;
-  private final ChangeMessagesUtil cmUtil;
-  private final VoteDeleted voteDeleted;
-  private final DeleteVoteSender.Factory deleteVoteSenderFactory;
   private final NotifyResolver notifyResolver;
-  private final RemoveReviewerControl removeReviewerControl;
-  private final ProjectCache projectCache;
-  private final MessageIdGenerator messageIdGenerator;
+
   private final AddToAttentionSetOp.Factory attentionSetOpFactory;
   private final Provider<CurrentUser> currentUserProvider;
+  private final DeleteVoteOp.Factory deleteVoteOpFactory;
 
   @Inject
   DeleteVote(
       BatchUpdate.Factory updateFactory,
-      ApprovalsUtil approvalsUtil,
-      PatchSetUtil psUtil,
-      ChangeMessagesUtil cmUtil,
-      VoteDeleted voteDeleted,
-      DeleteVoteSender.Factory deleteVoteSenderFactory,
       NotifyResolver notifyResolver,
-      RemoveReviewerControl removeReviewerControl,
-      ProjectCache projectCache,
-      MessageIdGenerator messageIdGenerator,
       AddToAttentionSetOp.Factory attentionSetOpFactory,
-      Provider<CurrentUser> currentUserProvider) {
+      Provider<CurrentUser> currentUserProvider,
+      DeleteVoteOp.Factory deleteVoteOpFactory) {
     this.updateFactory = updateFactory;
-    this.approvalsUtil = approvalsUtil;
-    this.psUtil = psUtil;
-    this.cmUtil = cmUtil;
-    this.voteDeleted = voteDeleted;
-    this.deleteVoteSenderFactory = deleteVoteSenderFactory;
     this.notifyResolver = notifyResolver;
-    this.removeReviewerControl = removeReviewerControl;
-    this.projectCache = projectCache;
-    this.messageIdGenerator = messageIdGenerator;
     this.attentionSetOpFactory = attentionSetOpFactory;
     this.currentUserProvider = currentUserProvider;
+    this.deleteVoteOpFactory = deleteVoteOpFactory;
   }
 
   @Override
@@ -140,13 +89,12 @@
               firstNonNull(input.notify, NotifyHandling.ALL), input.notifyDetails));
       bu.addOp(
           change.getId(),
-          new Op(
-              projectCache
-                  .get(r.getChange().getProject())
-                  .orElseThrow(illegalState(r.getChange().getProject())),
+          deleteVoteOpFactory.create(
+              r.getChange().getProject(),
               r.getReviewerUser().state(),
               rsrc.getLabel(),
-              input));
+              input,
+              true));
       if (!input.ignoreAutomaticAttentionSetRules
           && !r.getReviewerUser().getAccountId().equals(currentUserProvider.get().getAccountId())) {
         bu.addOp(
@@ -164,109 +112,4 @@
 
     return Response.none();
   }
-
-  private class Op implements BatchUpdateOp {
-    private final ProjectState projectState;
-    private final AccountState accountState;
-    private final String label;
-    private final DeleteVoteInput input;
-
-    private String mailMessage;
-    private Change change;
-    private PatchSet ps;
-    private Map<String, Short> newApprovals = new HashMap<>();
-    private Map<String, Short> oldApprovals = new HashMap<>();
-
-    private Op(
-        ProjectState projectState, AccountState accountState, String label, DeleteVoteInput input) {
-      this.projectState = projectState;
-      this.accountState = accountState;
-      this.label = label;
-      this.input = input;
-    }
-
-    @Override
-    public boolean updateChange(ChangeContext ctx)
-        throws AuthException, ResourceNotFoundException, IOException, PermissionBackendException {
-      change = ctx.getChange();
-      PatchSet.Id psId = change.currentPatchSetId();
-      ps = psUtil.current(ctx.getNotes());
-
-      boolean found = false;
-      LabelTypes labelTypes = projectState.getLabelTypes(ctx.getNotes());
-
-      Account.Id accountId = accountState.account().id();
-
-      for (PatchSetApproval a : approvalsUtil.byPatchSetUser(ctx.getNotes(), psId, accountId)) {
-        if (!labelTypes.byLabel(a.labelId()).isPresent()) {
-          continue; // Ignore undefined labels.
-        } else if (!a.label().equals(label)) {
-          // Populate map for non-matching labels, needed by VoteDeleted.
-          newApprovals.put(a.label(), a.value());
-          continue;
-        } else {
-          try {
-            removeReviewerControl.checkRemoveReviewer(ctx.getNotes(), ctx.getUser(), a);
-          } catch (AuthException e) {
-            throw new AuthException("delete vote not permitted", e);
-          }
-        }
-        // Set the approval to 0 if vote is being removed.
-        newApprovals.put(a.label(), (short) 0);
-        found = true;
-
-        // Set old value, as required by VoteDeleted.
-        oldApprovals.put(a.label(), a.value());
-        break;
-      }
-      if (!found) {
-        throw new ResourceNotFoundException();
-      }
-
-      ctx.getUpdate(psId).removeApprovalFor(accountId, label);
-
-      StringBuilder msg = new StringBuilder();
-      msg.append("Removed ");
-      LabelVote.appendTo(msg, label, requireNonNull(oldApprovals.get(label)));
-      msg.append(" by ").append(AccountTemplateUtil.getAccountTemplate(accountId)).append("\n");
-      mailMessage =
-          cmUtil.setChangeMessage(ctx, msg.toString(), ChangeMessagesUtil.TAG_DELETE_VOTE);
-      return true;
-    }
-
-    @Override
-    public void postUpdate(PostUpdateContext ctx) {
-      if (mailMessage == null) {
-        return;
-      }
-
-      IdentifiedUser user = ctx.getIdentifiedUser();
-      try {
-        NotifyResolver.Result notify = ctx.getNotify(change.getId());
-        if (notify.shouldNotify()) {
-          ReplyToChangeSender emailSender =
-              deleteVoteSenderFactory.create(ctx.getProject(), change.getId());
-          emailSender.setFrom(user.getAccountId());
-          emailSender.setChangeMessage(mailMessage, ctx.getWhen());
-          emailSender.setNotify(notify);
-          emailSender.setMessageId(
-              messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), change.currentPatchSetId()));
-          emailSender.send();
-        }
-      } catch (Exception e) {
-        logger.atSevere().withCause(e).log("Cannot email update for change %s", change.getId());
-      }
-
-      voteDeleted.fire(
-          ctx.getChangeData(change),
-          ps,
-          accountState,
-          newApprovals,
-          oldApprovals,
-          input.notify,
-          mailMessage,
-          user.state(),
-          ctx.getWhen());
-    }
-  }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteVoteOp.java b/java/com/google/gerrit/server/restapi/change/DeleteVoteOp.java
new file mode 100644
index 0000000..432f0da
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/DeleteVoteOp.java
@@ -0,0 +1,214 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import static com.google.gerrit.server.project.ProjectCache.illegalState;
+import static java.util.Objects.requireNonNull;
+
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.LabelTypes;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.api.changes.DeleteVoteInput;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.approval.ApprovalsUtil;
+import com.google.gerrit.server.change.NotifyResolver;
+import com.google.gerrit.server.extensions.events.VoteDeleted;
+import com.google.gerrit.server.mail.send.DeleteVoteSender;
+import com.google.gerrit.server.mail.send.MessageIdGenerator;
+import com.google.gerrit.server.mail.send.ReplyToChangeSender;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.RemoveReviewerControl;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.PostUpdateContext;
+import com.google.gerrit.server.util.AccountTemplateUtil;
+import com.google.gerrit.server.util.LabelVote;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.Map;
+
+/** Updates the storage to delete vote(s). */
+public class DeleteVoteOp implements BatchUpdateOp {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  /** Factory to create {@link DeleteVoteOp} instances. */
+  public interface Factory {
+    DeleteVoteOp create(
+        Project.NameKey projectState,
+        AccountState reviewerToDeleteVoteFor,
+        String label,
+        DeleteVoteInput input,
+        boolean enforcePermissions);
+  }
+
+  private final Project.NameKey projectName;
+  private final AccountState reviewerToDeleteVoteFor;
+
+  private final ProjectCache projectCache;
+  private final ApprovalsUtil approvalsUtil;
+  private final PatchSetUtil psUtil;
+  private final ChangeMessagesUtil cmUtil;
+  private final VoteDeleted voteDeleted;
+  private final DeleteVoteSender.Factory deleteVoteSenderFactory;
+
+  private final RemoveReviewerControl removeReviewerControl;
+  private final MessageIdGenerator messageIdGenerator;
+
+  private final String label;
+  private final DeleteVoteInput input;
+  private final boolean enforcePermissions;
+
+  private String mailMessage;
+  private Change change;
+  private PatchSet ps;
+  private Map<String, Short> newApprovals = new HashMap<>();
+  private Map<String, Short> oldApprovals = new HashMap<>();
+
+  @Inject
+  public DeleteVoteOp(
+      ProjectCache projectCache,
+      ApprovalsUtil approvalsUtil,
+      PatchSetUtil psUtil,
+      ChangeMessagesUtil cmUtil,
+      VoteDeleted voteDeleted,
+      DeleteVoteSender.Factory deleteVoteSenderFactory,
+      RemoveReviewerControl removeReviewerControl,
+      MessageIdGenerator messageIdGenerator,
+      @Assisted Project.NameKey projectName,
+      @Assisted AccountState reviewerToDeleteVoteFor,
+      @Assisted String label,
+      @Assisted DeleteVoteInput input,
+      @Assisted boolean enforcePermissions) {
+    this.projectCache = projectCache;
+    this.approvalsUtil = approvalsUtil;
+    this.psUtil = psUtil;
+    this.cmUtil = cmUtil;
+    this.voteDeleted = voteDeleted;
+    this.deleteVoteSenderFactory = deleteVoteSenderFactory;
+    this.removeReviewerControl = removeReviewerControl;
+    this.messageIdGenerator = messageIdGenerator;
+
+    this.projectName = projectName;
+    this.reviewerToDeleteVoteFor = reviewerToDeleteVoteFor;
+    this.label = label;
+    this.input = input;
+    this.enforcePermissions = enforcePermissions;
+  }
+
+  @Override
+  public boolean updateChange(ChangeContext ctx)
+      throws AuthException, ResourceNotFoundException, IOException, PermissionBackendException {
+    change = ctx.getChange();
+    PatchSet.Id psId = change.currentPatchSetId();
+    ps = psUtil.current(ctx.getNotes());
+
+    boolean found = false;
+    LabelTypes labelTypes =
+        projectCache
+            .get(projectName)
+            .orElseThrow(illegalState(projectName))
+            .getLabelTypes(ctx.getNotes());
+
+    Account.Id accountId = reviewerToDeleteVoteFor.account().id();
+
+    for (PatchSetApproval a : approvalsUtil.byPatchSetUser(ctx.getNotes(), psId, accountId)) {
+      if (!labelTypes.byLabel(a.labelId()).isPresent()) {
+        continue; // Ignore undefined labels.
+      } else if (!a.label().equals(label)) {
+        // Populate map for non-matching labels, needed by VoteDeleted.
+        newApprovals.put(a.label(), a.value());
+        continue;
+      } else if (enforcePermissions) {
+        // For regular users, check if they are allowed to remove the vote.
+        try {
+          removeReviewerControl.checkRemoveReviewer(ctx.getNotes(), ctx.getUser(), a);
+        } catch (AuthException e) {
+          throw new AuthException("delete vote not permitted", e);
+        }
+      }
+      // Set the approval to 0 if vote is being removed.
+      newApprovals.put(a.label(), (short) 0);
+      found = true;
+
+      // Set old value, as required by VoteDeleted.
+      oldApprovals.put(a.label(), a.value());
+      break;
+    }
+    if (!found) {
+      throw new ResourceNotFoundException();
+    }
+
+    ctx.getUpdate(psId).removeApprovalFor(accountId, label);
+
+    StringBuilder msg = new StringBuilder();
+    msg.append("Removed ");
+    LabelVote.appendTo(msg, label, requireNonNull(oldApprovals.get(label)));
+    msg.append(" by ").append(AccountTemplateUtil.getAccountTemplate(accountId));
+    if (input.reason != null) {
+      msg.append("\n\n" + input.reason);
+    }
+    msg.append("\n");
+    mailMessage = cmUtil.setChangeMessage(ctx, msg.toString(), ChangeMessagesUtil.TAG_DELETE_VOTE);
+    return true;
+  }
+
+  @Override
+  public void postUpdate(PostUpdateContext ctx) {
+    if (mailMessage == null) {
+      return;
+    }
+
+    CurrentUser user = ctx.getUser();
+    try {
+      NotifyResolver.Result notify = ctx.getNotify(change.getId());
+      if (notify.shouldNotify()) {
+        ReplyToChangeSender emailSender =
+            deleteVoteSenderFactory.create(ctx.getProject(), change.getId());
+        if (user.isIdentifiedUser()) {
+          emailSender.setFrom(user.getAccountId());
+        }
+        emailSender.setChangeMessage(mailMessage, ctx.getWhen());
+        emailSender.setNotify(notify);
+        emailSender.setMessageId(
+            messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), change.currentPatchSetId()));
+        emailSender.send();
+      }
+    } catch (Exception e) {
+      logger.atSevere().withCause(e).log("Cannot email update for change %s", change.getId());
+    }
+    voteDeleted.fire(
+        ctx.getChangeData(change),
+        ps,
+        reviewerToDeleteVoteFor,
+        newApprovals,
+        oldApprovals,
+        input.notify,
+        mailMessage,
+        user.isIdentifiedUser() ? user.asIdentifiedUser().state() : null,
+        ctx.getWhen());
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/GetFixPreview.java b/java/com/google/gerrit/server/restapi/change/GetFixPreview.java
deleted file mode 100644
index 95e26a23..0000000
--- a/java/com/google/gerrit/server/restapi/change/GetFixPreview.java
+++ /dev/null
@@ -1,148 +0,0 @@
-// Copyright (C) 2019 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.restapi.change;
-
-import static com.google.gerrit.server.project.ProjectCache.illegalState;
-import static java.util.stream.Collectors.groupingBy;
-
-import com.google.common.base.MoreObjects;
-import com.google.common.collect.ImmutableList;
-import com.google.gerrit.common.data.PatchScript;
-import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.FixReplacement;
-import com.google.gerrit.entities.PatchSet;
-import com.google.gerrit.extensions.client.DiffPreferencesInfo;
-import com.google.gerrit.extensions.common.DiffInfo;
-import com.google.gerrit.extensions.common.DiffWebLinkInfo;
-import com.google.gerrit.extensions.common.WebLinkInfo;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.server.change.FixResource;
-import com.google.gerrit.server.diff.DiffInfoCreator;
-import com.google.gerrit.server.diff.DiffSide;
-import com.google.gerrit.server.diff.DiffWebLinksProvider;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.LargeObjectException;
-import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.patch.PatchScriptFactoryForAutoFix;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.project.InvalidChangeOperationException;
-import com.google.gerrit.server.project.NoSuchChangeException;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.server.project.ProjectState;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import org.eclipse.jgit.lib.Repository;
-
-@Singleton
-public class GetFixPreview implements RestReadView<FixResource> {
-
-  private final ProjectCache projectCache;
-  private final GitRepositoryManager repoManager;
-  private final PatchScriptFactoryForAutoFix.Factory patchScriptFactoryFactory;
-
-  @Inject
-  GetFixPreview(
-      ProjectCache projectCache,
-      GitRepositoryManager repoManager,
-      PatchScriptFactoryForAutoFix.Factory patchScriptFactoryFactory) {
-    this.projectCache = projectCache;
-    this.repoManager = repoManager;
-    this.patchScriptFactoryFactory = patchScriptFactoryFactory;
-  }
-
-  @Override
-  public Response<Map<String, DiffInfo>> apply(FixResource resource)
-      throws PermissionBackendException, ResourceNotFoundException, ResourceConflictException,
-          AuthException, IOException, InvalidChangeOperationException {
-    Map<String, DiffInfo> result = new HashMap<>();
-    PatchSet patchSet = resource.getRevisionResource().getPatchSet();
-    ChangeNotes notes = resource.getRevisionResource().getNotes();
-    Change change = notes.getChange();
-    ProjectState state =
-        projectCache.get(change.getProject()).orElseThrow(illegalState(change.getProject()));
-    Map<String, List<FixReplacement>> fixReplacementsPerFilePath =
-        resource.getFixReplacements().stream()
-            .collect(groupingBy(fixReplacement -> fixReplacement.path));
-    try {
-      try (Repository git = repoManager.openRepository(notes.getProjectName())) {
-        for (Map.Entry<String, List<FixReplacement>> entry :
-            fixReplacementsPerFilePath.entrySet()) {
-          String fileName = entry.getKey();
-          DiffInfo diffInfo =
-              getFixPreviewForSingleFile(
-                  git, patchSet, state, notes, fileName, ImmutableList.copyOf(entry.getValue()));
-          result.put(fileName, diffInfo);
-        }
-      }
-    } catch (NoSuchChangeException e) {
-      throw new ResourceNotFoundException(e.getMessage(), e);
-    } catch (LargeObjectException e) {
-      throw new ResourceConflictException(e.getMessage(), e);
-    }
-    return Response.ok(result);
-  }
-
-  private DiffInfo getFixPreviewForSingleFile(
-      Repository git,
-      PatchSet patchSet,
-      ProjectState state,
-      ChangeNotes notes,
-      String fileName,
-      ImmutableList<FixReplacement> fixReplacements)
-      throws PermissionBackendException, AuthException, LargeObjectException,
-          InvalidChangeOperationException, IOException, ResourceNotFoundException {
-    PatchScriptFactoryForAutoFix psf =
-        patchScriptFactoryFactory.create(
-            git, notes, fileName, patchSet, fixReplacements, DiffPreferencesInfo.defaults());
-    PatchScript ps = psf.call();
-
-    DiffSide sideA =
-        DiffSide.create(
-            ps.getFileInfoA(),
-            MoreObjects.firstNonNull(ps.getOldName(), ps.getNewName()),
-            DiffSide.Type.SIDE_A);
-    DiffSide sideB = DiffSide.create(ps.getFileInfoB(), ps.getNewName(), DiffSide.Type.SIDE_B);
-
-    DiffInfoCreator diffInfoCreator =
-        new DiffInfoCreator(state, new DiffWebLinksProviderImpl(), true);
-    return diffInfoCreator.create(ps, sideA, sideB);
-  }
-
-  private static class DiffWebLinksProviderImpl implements DiffWebLinksProvider {
-
-    @Override
-    public ImmutableList<DiffWebLinkInfo> getDiffLinks() {
-      return ImmutableList.of();
-    }
-
-    @Override
-    public ImmutableList<WebLinkInfo> getEditWebLinks() {
-      return ImmutableList.of();
-    }
-
-    @Override
-    public ImmutableList<WebLinkInfo> getFileWebLinks(DiffSide.Type fileInfoType) {
-      return ImmutableList.of();
-    }
-  }
-}
diff --git a/java/com/google/gerrit/server/restapi/change/GetRelated.java b/java/com/google/gerrit/server/restapi/change/GetRelated.java
index 7a1808b..6471a62 100644
--- a/java/com/google/gerrit/server/restapi/change/GetRelated.java
+++ b/java/com/google/gerrit/server/restapi/change/GetRelated.java
@@ -21,6 +21,8 @@
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.SubmitRequirementResult;
+import com.google.gerrit.extensions.api.changes.GetRelatedOption;
 import com.google.gerrit.extensions.api.changes.RelatedChangeAndCommitInfo;
 import com.google.gerrit.extensions.api.changes.RelatedChangesInfo;
 import com.google.gerrit.extensions.common.CommitInfo;
@@ -35,19 +37,19 @@
 import com.google.gerrit.server.project.NoSuchProjectException;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
-import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Locale;
 import org.eclipse.jgit.revwalk.RevCommit;
+import org.kohsuke.args4j.Option;
 
-@Singleton
 public class GetRelated implements RestReadView<RevisionResource> {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   private final ChangeData.Factory changeDataFactory;
   private final GetRelatedChangesUtil getRelatedChangesUtil;
+  private boolean computeSubmittable = false;
 
   @Inject
   GetRelated(ChangeData.Factory changeDataFactory, GetRelatedChangesUtil getRelatedChangesUtil) {
@@ -55,6 +57,15 @@
     this.getRelatedChangesUtil = getRelatedChangesUtil;
   }
 
+  @Option(name = "-o", usage = "Options")
+  public void addOption(GetRelatedOption option) {
+    if (option == GetRelatedOption.SUBMITTABLE) {
+      computeSubmittable = true;
+    } else {
+      throw new IllegalArgumentException("option not recognized: " + option);
+    }
+  }
+
   @Override
   public Response<RelatedChangesInfo> apply(RevisionResource rsrc)
       throws IOException, NoSuchProjectException, PermissionBackendException {
@@ -86,7 +97,7 @@
       } else {
         commit = d.commit();
       }
-      result.add(newChangeAndCommit(rsrc.getProject(), d.data().change(), ps, commit));
+      result.add(newChangeAndCommit(rsrc.getProject(), d.data(), ps, commit));
     }
 
     if (result.size() == 1) {
@@ -98,11 +109,12 @@
     return ImmutableList.copyOf(result);
   }
 
-  static RelatedChangeAndCommitInfo newChangeAndCommit(
-      Project.NameKey project, @Nullable Change change, @Nullable PatchSet ps, RevCommit c) {
+  private RelatedChangeAndCommitInfo newChangeAndCommit(
+      Project.NameKey project, ChangeData cd, @Nullable PatchSet ps, RevCommit c) {
     RelatedChangeAndCommitInfo info = new RelatedChangeAndCommitInfo();
     info.project = project.get();
 
+    Change change = cd.change();
     if (change != null) {
       info.changeId = change.getKey().get();
       info._changeNumber = change.getChangeId();
@@ -110,6 +122,7 @@
       PatchSet.Id curr = change.currentPatchSetId();
       info._currentRevisionNumber = curr != null ? curr.get() : null;
       info.status = ChangeUtil.status(change).toUpperCase(Locale.US);
+      info.submittable = computeSubmittable ? submittable(cd) : null;
     }
 
     info.commit = new CommitInfo();
@@ -124,4 +137,9 @@
     info.commit.subject = c.getShortMessage();
     return info;
   }
+
+  private static boolean submittable(ChangeData cd) {
+    return cd.submitRequirementsIncludingLegacy().values().stream()
+        .allMatch(SubmitRequirementResult::fulfilled);
+  }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/Ignore.java b/java/com/google/gerrit/server/restapi/change/Ignore.java
deleted file mode 100644
index a049e54..0000000
--- a/java/com/google/gerrit/server/restapi/change/Ignore.java
+++ /dev/null
@@ -1,81 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.restapi.change;
-
-import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.extensions.common.Input;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.extensions.webui.UiAction;
-import com.google.gerrit.server.StarredChangesUtil;
-import com.google.gerrit.server.StarredChangesUtil.IllegalLabelException;
-import com.google.gerrit.server.StarredChangesUtil.MutuallyExclusiveLabelsException;
-import com.google.gerrit.server.change.ChangeResource;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-
-@Singleton
-public class Ignore implements RestModifyView<ChangeResource, Input>, UiAction<ChangeResource> {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
-  private final StarredChangesUtil stars;
-
-  @Inject
-  Ignore(StarredChangesUtil stars) {
-    this.stars = stars;
-  }
-
-  @Override
-  public Description getDescription(ChangeResource rsrc) {
-    return new UiAction.Description()
-        .setLabel("Ignore")
-        .setTitle("Ignore the change")
-        .setVisible(canIgnore(rsrc));
-  }
-
-  @Override
-  public Response<String> apply(ChangeResource rsrc, Input input)
-      throws RestApiException, IllegalLabelException {
-    try {
-      if (rsrc.isUserOwner()) {
-        throw new BadRequestException("cannot ignore own change");
-      }
-
-      if (!isIgnored(rsrc)) {
-        stars.ignore(rsrc);
-      }
-      return Response.ok();
-    } catch (MutuallyExclusiveLabelsException e) {
-      throw new ResourceConflictException(e.getMessage());
-    }
-  }
-
-  private boolean canIgnore(ChangeResource rsrc) {
-    return !rsrc.isUserOwner() && !isIgnored(rsrc);
-  }
-
-  private boolean isIgnored(ChangeResource rsrc) {
-    try {
-      return stars.isIgnored(rsrc);
-    } catch (StorageException e) {
-      logger.atSevere().withCause(e).log("failed to check ignored star");
-    }
-    return false;
-  }
-}
diff --git a/java/com/google/gerrit/server/restapi/change/Mergeable.java b/java/com/google/gerrit/server/restapi/change/Mergeable.java
index 7683ab7..8aa2554 100644
--- a/java/com/google/gerrit/server/restapi/change/Mergeable.java
+++ b/java/com/google/gerrit/server/restapi/change/Mergeable.java
@@ -31,7 +31,7 @@
 import com.google.gerrit.server.change.MergeabilityCache;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.MergeUtil;
+import com.google.gerrit.server.git.MergeUtilFactory;
 import com.google.gerrit.server.index.change.ChangeIndexer;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
@@ -61,7 +61,7 @@
 
   private final GitRepositoryManager gitManager;
   private final ProjectCache projectCache;
-  private final MergeUtil.Factory mergeUtilFactory;
+  private final MergeUtilFactory mergeUtilFactory;
   private final ChangeData.Factory changeDataFactory;
   private final ChangeIndexer indexer;
   private final MergeabilityCache cache;
@@ -71,7 +71,7 @@
   Mergeable(
       GitRepositoryManager gitManager,
       ProjectCache projectCache,
-      MergeUtil.Factory mergeUtilFactory,
+      MergeUtilFactory mergeUtilFactory,
       ChangeData.Factory changeDataFactory,
       ChangeIndexer indexer,
       MergeabilityCache cache,
diff --git a/java/com/google/gerrit/server/restapi/change/Move.java b/java/com/google/gerrit/server/restapi/change/Move.java
index 900b9e5..c1b36d7 100644
--- a/java/com/google/gerrit/server/restapi/change/Move.java
+++ b/java/com/google/gerrit/server/restapi/change/Move.java
@@ -141,12 +141,9 @@
     // discussion in
     // https://gerrit-review.googlesource.com/c/gerrit/+/129171
     // Only administrators are allowed to keep all labels at their own risk.
-    try {
-      if (input.keepAllVotes) {
-        permissionBackend.user(caller).check(GlobalPermission.ADMINISTRATE_SERVER);
-      }
-    } catch (AuthException denied) {
-      throw new AuthException("move is not permitted with keepAllVotes option", denied);
+    if (input.keepAllVotes
+        && !permissionBackend.user(caller).test(GlobalPermission.ADMINISTRATE_SERVER)) {
+      throw new AuthException("move is not permitted with keepAllVotes option");
     }
 
     // Move requires abandoning this change, and creating a new change.
diff --git a/java/com/google/gerrit/server/restapi/change/PostReview.java b/java/com/google/gerrit/server/restapi/change/PostReview.java
index 4605d7c..7a6ac0d 100644
--- a/java/com/google/gerrit/server/restapi/change/PostReview.java
+++ b/java/com/google/gerrit/server/restapi/change/PostReview.java
@@ -15,23 +15,17 @@
 package com.google.gerrit.server.restapi.change;
 
 import static com.google.common.base.MoreObjects.firstNonNull;
-import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.gerrit.entities.Patch.PATCHSET_LEVEL;
-import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
 import static com.google.gerrit.server.permissions.LabelPermission.ForUser.ON_BEHALF_OF;
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.stream.Collectors.groupingBy;
-import static java.util.stream.Collectors.joining;
 import static java.util.stream.Collectors.toList;
-import static java.util.stream.Collectors.toSet;
 import static javax.servlet.http.HttpServletResponse.SC_BAD_REQUEST;
 
 import com.google.auto.value.AutoValue;
-import com.google.common.base.Joiner;
 import com.google.common.base.Strings;
-import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Maps;
 import com.google.common.collect.Ordering;
@@ -44,16 +38,11 @@
 import com.google.gerrit.entities.Address;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Comment;
-import com.google.gerrit.entities.FixReplacement;
-import com.google.gerrit.entities.FixSuggestion;
 import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.LabelTypes;
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.PatchSet;
-import com.google.gerrit.entities.PatchSetApproval;
-import com.google.gerrit.entities.RobotComment;
-import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
@@ -75,29 +64,20 @@
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.extensions.restapi.Url;
-import com.google.gerrit.extensions.validators.CommentForValidation;
-import com.google.gerrit.extensions.validators.CommentValidationContext;
-import com.google.gerrit.extensions.validators.CommentValidationFailure;
-import com.google.gerrit.extensions.validators.CommentValidator;
 import com.google.gerrit.metrics.Counter1;
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.Field;
 import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.server.ChangeMessagesUtil;
-import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.PatchSetUtil;
-import com.google.gerrit.server.PublishCommentUtil;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountResolver;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.change.ChangeResource;
-import com.google.gerrit.server.change.EmailReviewComments;
 import com.google.gerrit.server.change.ModifyReviewersEmail;
 import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.change.ReviewerModifier;
@@ -106,12 +86,10 @@
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.change.WorkInProgressOp;
 import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.extensions.events.CommentAdded;
 import com.google.gerrit.server.extensions.events.ReviewerAdded;
 import com.google.gerrit.server.logging.Metadata;
 import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.patch.DiffSummary;
 import com.google.gerrit.server.patch.DiffSummaryKey;
 import com.google.gerrit.server.patch.PatchListCache;
@@ -121,26 +99,17 @@
 import com.google.gerrit.server.permissions.LabelPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.update.BatchUpdate;
-import com.google.gerrit.server.update.BatchUpdateOp;
-import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.CommentsRejectedException;
-import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.gerrit.server.update.UpdateException;
-import com.google.gerrit.server.util.LabelVote;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.sql.Timestamp;
 import java.time.Instant;
 import java.util.ArrayList;
-import java.util.Collection;
-import java.util.Collections;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.Iterator;
@@ -150,7 +119,6 @@
 import java.util.Optional;
 import java.util.Set;
 import java.util.stream.Collectors;
-import java.util.stream.Stream;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
@@ -186,21 +154,16 @@
   public static final String ERROR_WIP_READY_MUTUALLY_EXCLUSIVE =
       "work_in_progress and ready are mutually exclusive";
 
-  public static final String START_REVIEW_MESSAGE = "This change is ready for review.";
-
   private final BatchUpdate.Factory updateFactory;
+  private final PostReviewOp.Factory postReviewOpFactory;
+  private final PostReviewCopyApprovalsOpFactory postReviewCopyApprovalsOpFactory;
   private final ChangeResource.Factory changeResourceFactory;
   private final ChangeData.Factory changeDataFactory;
   private final AccountCache accountCache;
   private final ApprovalsUtil approvalsUtil;
-  private final ChangeMessagesUtil cmUtil;
   private final CommentsUtil commentsUtil;
-  private final PublishCommentUtil publishCommentUtil;
-  private final PatchSetUtil psUtil;
   private final PatchListCache patchListCache;
   private final AccountResolver accountResolver;
-  private final EmailReviewComments.Factory email;
-  private final CommentAdded commentAdded;
   private final ReviewerModifier reviewerModifier;
   private final Metrics metrics;
   private final ModifyReviewersEmail modifyReviewersEmail;
@@ -208,28 +171,23 @@
   private final WorkInProgressOp.Factory workInProgressOpFactory;
   private final ProjectCache projectCache;
   private final PermissionBackend permissionBackend;
-  private final PluginSetContext<CommentValidator> commentValidators;
-  private final PluginSetContext<OnPostReview> onPostReviews;
+
   private final ReplyAttentionSetUpdates replyAttentionSetUpdates;
   private final ReviewerAdded reviewerAdded;
   private final boolean strictLabels;
-  private final boolean publishPatchSetLevelComment;
 
   @Inject
   PostReview(
       BatchUpdate.Factory updateFactory,
+      PostReviewOp.Factory postReviewOpFactory,
+      PostReviewCopyApprovalsOpFactory postReviewCopyApprovalsOpFactory,
       ChangeResource.Factory changeResourceFactory,
       ChangeData.Factory changeDataFactory,
       AccountCache accountCache,
       ApprovalsUtil approvalsUtil,
-      ChangeMessagesUtil cmUtil,
       CommentsUtil commentsUtil,
-      PublishCommentUtil publishCommentUtil,
-      PatchSetUtil psUtil,
       PatchListCache patchListCache,
       AccountResolver accountResolver,
-      EmailReviewComments.Factory email,
-      CommentAdded commentAdded,
       ReviewerModifier reviewerModifier,
       Metrics metrics,
       ModifyReviewersEmail modifyReviewersEmail,
@@ -238,23 +196,18 @@
       WorkInProgressOp.Factory workInProgressOpFactory,
       ProjectCache projectCache,
       PermissionBackend permissionBackend,
-      PluginSetContext<CommentValidator> commentValidators,
-      PluginSetContext<OnPostReview> onPostReviews,
       ReplyAttentionSetUpdates replyAttentionSetUpdates,
       ReviewerAdded reviewerAdded) {
     this.updateFactory = updateFactory;
+    this.postReviewOpFactory = postReviewOpFactory;
+    this.postReviewCopyApprovalsOpFactory = postReviewCopyApprovalsOpFactory;
     this.changeResourceFactory = changeResourceFactory;
     this.changeDataFactory = changeDataFactory;
     this.accountCache = accountCache;
     this.commentsUtil = commentsUtil;
-    this.publishCommentUtil = publishCommentUtil;
-    this.psUtil = psUtil;
     this.patchListCache = patchListCache;
     this.approvalsUtil = approvalsUtil;
-    this.cmUtil = cmUtil;
     this.accountResolver = accountResolver;
-    this.email = email;
-    this.commentAdded = commentAdded;
     this.reviewerModifier = reviewerModifier;
     this.metrics = metrics;
     this.modifyReviewersEmail = modifyReviewersEmail;
@@ -262,13 +215,9 @@
     this.workInProgressOpFactory = workInProgressOpFactory;
     this.projectCache = projectCache;
     this.permissionBackend = permissionBackend;
-    this.commentValidators = commentValidators;
-    this.onPostReviews = onPostReviews;
     this.replyAttentionSetUpdates = replyAttentionSetUpdates;
     this.reviewerAdded = reviewerAdded;
     this.strictLabels = gerritConfig.getBoolean("change", "strictLabels", false);
-    this.publishPatchSetLevelComment =
-        gerritConfig.getBoolean("event", "comment-added", "publishPatchSetLevelComment", true);
   }
 
   @Override
@@ -354,8 +303,13 @@
     }
     output.labels = input.labels;
 
+    // Notify based on ReviewInput, ignoring the notify settings from any ReviewerInputs.
+    NotifyResolver.Result notify = notifyResolver.resolve(input.notify, input.notifyDetails);
+
     try (BatchUpdate bu =
         updateFactory.create(revision.getChange().getProject(), revision.getUser(), ts)) {
+      bu.setNotify(notify);
+
       Account account = revision.getUser().asIdentifiedUser().getAccount();
       boolean ccOrReviewer = false;
       if (input.labels != null && !input.labels.isEmpty()) {
@@ -425,32 +379,32 @@
         bu.addOp(revision.getChange().getId(), wipOp);
       }
 
-      // Add the review op.
+      // Add the review ops.
       logger.atFine().log("posting review");
+      PostReviewOp postReviewOp =
+          postReviewOpFactory.create(projectState, revision.getPatchSet().id(), input);
+      bu.addOp(revision.getChange().getId(), postReviewOp);
       bu.addOp(
-          revision.getChange().getId(), new Op(projectState, revision.getPatchSet().id(), input));
-
-      // Notify based on ReviewInput, ignoring the notify settings from any ReviewerInputs.
-      NotifyResolver.Result notify = notifyResolver.resolve(input.notify, input.notifyDetails);
-      bu.setNotify(notify);
+          revision.getChange().getId(),
+          postReviewCopyApprovalsOpFactory.create(revision.getPatchSet().id()));
 
       // Adjust the attention set based on the input
       replyAttentionSetUpdates.updateAttentionSet(
           bu, revision.getNotes(), input, revision.getUser());
       bu.execute();
-
-      // Re-read change to take into account results of the update.
-      ChangeData cd = changeDataFactory.create(revision.getProject(), revision.getChange().getId());
-      for (ReviewerModification reviewerResult : reviewerResults) {
-        reviewerResult.gatherResults(cd);
-      }
-
-      // Sending emails and events from ReviewersOps was suppressed so we can send a single batch
-      // email/event here.
-      batchEmailReviewers(revision.getUser(), revision.getChange(), reviewerResults, notify);
-      batchReviewerEvents(revision.getUser(), cd, revision.getPatchSet(), reviewerResults, ts);
     }
 
+    // Re-read change to take into account results of the update.
+    ChangeData cd = changeDataFactory.create(revision.getProject(), revision.getChange().getId());
+    for (ReviewerModification reviewerResult : reviewerResults) {
+      reviewerResult.gatherResults(cd);
+    }
+
+    // Sending emails and events from ReviewersOps was suppressed so we can send a single batch
+    // email/event here.
+    batchEmailReviewers(revision.getUser(), revision.getChange(), reviewerResults, notify);
+    batchReviewerEvents(revision.getUser(), cd, revision.getPatchSet(), reviewerResults, ts);
+
     return Response.ok(output);
   }
 
@@ -487,7 +441,9 @@
       Change change,
       List<ReviewerModification> reviewerModifications,
       NotifyResolver.Result notify) {
-    try (TraceContext.TraceTimer ignored = newTimer("batchEmailReviewers")) {
+    try (TraceContext.TraceTimer ignored =
+        TraceContext.newTimer(
+            getClass().getSimpleName() + "#batchEmailReviewers", Metadata.empty())) {
       List<Account.Id> to = new ArrayList<>();
       List<Account.Id> cc = new ArrayList<>();
       List<Account.Id> removed = new ArrayList<>();
@@ -557,7 +513,8 @@
 
   private RevisionResource onBehalfOf(RevisionResource rev, LabelTypes labelTypes, ReviewInput in)
       throws BadRequestException, AuthException, UnprocessableEntityException,
-          PermissionBackendException, IOException, ConfigInvalidException {
+          ResourceConflictException, PermissionBackendException, IOException,
+          ConfigInvalidException {
     logger.atFine().log("request is executed on behalf of %s", in.onBehalfOf);
 
     if (in.labels == null || in.labels.isEmpty()) {
@@ -615,7 +572,7 @@
     try {
       permissionBackend.user(reviewer).change(rev.getNotes()).check(ChangePermission.READ);
     } catch (AuthException e) {
-      throw new UnprocessableEntityException(
+      throw new ResourceConflictException(
           String.format("on_behalf_of account %s cannot see change", reviewer.getAccountId()), e);
     }
 
@@ -698,10 +655,6 @@
         .collect(toList());
   }
 
-  private TraceContext.TraceTimer newTimer(String method) {
-    return TraceContext.newTimer(getClass().getSimpleName() + "#" + method, Metadata.empty());
-  }
-
   private <T extends com.google.gerrit.extensions.client.Comment> void checkComments(
       RevisionResource revision, Map<String, List<T>> commentsPerPath)
       throws BadRequestException, PatchListNotAvailableException {
@@ -1008,643 +961,4 @@
     @Nullable
     abstract Comment.Range range();
   }
-
-  private class Op implements BatchUpdateOp {
-    private final ProjectState projectState;
-    private final PatchSet.Id psId;
-    private final ReviewInput in;
-
-    private IdentifiedUser user;
-    private ChangeNotes notes;
-    private PatchSet ps;
-    private String mailMessage;
-    private List<Comment> comments = new ArrayList<>();
-    private List<LabelVote> labelDelta = new ArrayList<>();
-    private Map<String, Short> approvals = new HashMap<>();
-    private Map<String, Short> oldApprovals = new HashMap<>();
-
-    private Op(ProjectState projectState, PatchSet.Id psId, ReviewInput in) {
-      this.projectState = projectState;
-      this.psId = psId;
-      this.in = in;
-    }
-
-    @Override
-    public boolean updateChange(ChangeContext ctx)
-        throws ResourceConflictException, UnprocessableEntityException, IOException,
-            CommentsRejectedException {
-      user = ctx.getIdentifiedUser();
-      notes = ctx.getNotes();
-      ps = psUtil.get(ctx.getNotes(), psId);
-      List<RobotComment> newRobotComments =
-          in.robotComments == null ? ImmutableList.of() : getNewRobotComments(ctx);
-      boolean dirty = false;
-      try (TraceContext.TraceTimer ignored = newTimer("insertComments")) {
-        dirty |= insertComments(ctx, newRobotComments);
-      }
-      try (TraceContext.TraceTimer ignored = newTimer("insertRobotComments")) {
-        dirty |= insertRobotComments(ctx, newRobotComments);
-      }
-      try (TraceContext.TraceTimer ignored = newTimer("updateLabels")) {
-        dirty |= updateLabels(projectState, ctx);
-      }
-      try (TraceContext.TraceTimer ignored = newTimer("insertMessage")) {
-        dirty |= insertMessage(ctx);
-      }
-      return dirty;
-    }
-
-    @Override
-    public void postUpdate(PostUpdateContext ctx) {
-      if (mailMessage == null) {
-        return;
-      }
-      NotifyResolver.Result notify = ctx.getNotify(notes.getChangeId());
-      if (notify.shouldNotify()) {
-        try {
-          email
-              .create(
-                  notify,
-                  notes,
-                  ps,
-                  user,
-                  mailMessage,
-                  ctx.getWhen(),
-                  comments,
-                  in.message,
-                  labelDelta,
-                  ctx.getRepoView())
-              .sendAsync();
-        } catch (IOException ex) {
-          throw new StorageException(
-              String.format("Repository %s not found", ctx.getProject().get()), ex);
-        }
-      }
-      String comment = mailMessage;
-      if (publishPatchSetLevelComment) {
-        // TODO(davido): Remove this workaround when patch set level comments are exposed in comment
-        // added event. For backwards compatibility, patchset level comment has a higher priority
-        // than change message and should be used as comment in comment added event.
-        if (in.comments != null && in.comments.containsKey(PATCHSET_LEVEL)) {
-          List<CommentInput> patchSetLevelComments = in.comments.get(PATCHSET_LEVEL);
-          if (patchSetLevelComments != null && !patchSetLevelComments.isEmpty()) {
-            CommentInput firstComment = patchSetLevelComments.get(0);
-            if (!Strings.isNullOrEmpty(firstComment.message)) {
-              comment = String.format("Patch Set %s:\n\n%s", psId.get(), firstComment.message);
-            }
-          }
-        }
-      }
-      commentAdded.fire(
-          ctx.getChangeData(notes),
-          ps,
-          user.state(),
-          comment,
-          approvals,
-          oldApprovals,
-          ctx.getWhen());
-    }
-
-    /**
-     * Publishes draft and input comments. Input comments are those passed as input in the request
-     * body.
-     *
-     * @param ctx context for performing the change update.
-     * @param newRobotComments robot comments. Used only for validation in this method.
-     * @return true if any input comments where published.
-     */
-    private boolean insertComments(ChangeContext ctx, List<RobotComment> newRobotComments)
-        throws CommentsRejectedException {
-      Map<String, List<CommentInput>> inputComments = in.comments;
-      if (inputComments == null) {
-        inputComments = Collections.emptyMap();
-      }
-
-      // Use HashMap to avoid warnings when calling remove() in resolveInputCommentsAndDrafts().
-      Map<String, HumanComment> drafts = new HashMap<>();
-
-      if (!inputComments.isEmpty() || in.drafts != DraftHandling.KEEP) {
-        drafts =
-            in.drafts == DraftHandling.PUBLISH_ALL_REVISIONS
-                ? changeDrafts(ctx)
-                : patchSetDrafts(ctx);
-      }
-
-      // Existing published comments
-      Set<CommentSetEntry> existingComments =
-          in.omitDuplicateComments ? readExistingComments(ctx) : Collections.emptySet();
-
-      // Input comments should be deduplicated from existing drafts
-      List<HumanComment> inputCommentsToPublish =
-          resolveInputCommentsAndDrafts(inputComments, existingComments, drafts, ctx);
-
-      switch (in.drafts) {
-        case PUBLISH:
-        case PUBLISH_ALL_REVISIONS:
-          Collection<HumanComment> filteredDrafts =
-              in.draftIdsToPublish == null
-                  ? drafts.values()
-                  : drafts.values().stream()
-                      .filter(draft -> in.draftIdsToPublish.contains(draft.key.uuid))
-                      .collect(Collectors.toList());
-
-          validateComments(
-              ctx,
-              Streams.concat(
-                  drafts.values().stream(),
-                  inputCommentsToPublish.stream(),
-                  newRobotComments.stream()));
-          publishCommentUtil.publish(ctx, ctx.getUpdate(psId), filteredDrafts, in.tag);
-          comments.addAll(drafts.values());
-          break;
-        case KEEP:
-          validateComments(
-              ctx, Streams.concat(inputCommentsToPublish.stream(), newRobotComments.stream()));
-          break;
-      }
-      commentsUtil.putHumanComments(
-          ctx.getUpdate(psId), HumanComment.Status.PUBLISHED, inputCommentsToPublish);
-      comments.addAll(inputCommentsToPublish);
-      return !inputCommentsToPublish.isEmpty();
-    }
-
-    /**
-     * Returns the subset of {@code inputComments} that do not have a matching comment (with same
-     * id) neither in {@code existingComments} nor in {@code drafts}.
-     *
-     * <p>Entries in {@code drafts} that have a matching entry in {@code inputComments} will be
-     * removed.
-     *
-     * @param inputComments new comments provided as {@link CommentInput} entries in the API.
-     * @param existingComments existing published comments in the database.
-     * @param drafts existing draft comments in the database. This map can be modified.
-     */
-    private List<HumanComment> resolveInputCommentsAndDrafts(
-        Map<String, List<CommentInput>> inputComments,
-        Set<CommentSetEntry> existingComments,
-        Map<String, HumanComment> drafts,
-        ChangeContext ctx) {
-      List<HumanComment> inputCommentsToPublish = new ArrayList<>();
-      for (Map.Entry<String, List<CommentInput>> entry : inputComments.entrySet()) {
-        String path = entry.getKey();
-        for (CommentInput inputComment : entry.getValue()) {
-          HumanComment comment = drafts.remove(Url.decode(inputComment.id));
-          if (comment == null) {
-            String parent = Url.decode(inputComment.inReplyTo);
-            comment =
-                commentsUtil.newHumanComment(
-                    ctx.getNotes(),
-                    ctx.getUser(),
-                    ctx.getWhen(),
-                    path,
-                    psId,
-                    inputComment.side(),
-                    inputComment.message,
-                    inputComment.unresolved,
-                    parent);
-          } else {
-            // In ChangeUpdate#putComment() the draft with the same ID will be deleted.
-            comment.writtenOn = Timestamp.from(ctx.getWhen());
-            comment.side = inputComment.side();
-            comment.message = inputComment.message;
-          }
-
-          commentsUtil.setCommentCommitId(comment, ctx.getChange(), ps);
-          comment.setLineNbrAndRange(inputComment.line, inputComment.range);
-          comment.tag = in.tag;
-
-          if (existingComments.contains(CommentSetEntry.create(comment))) {
-            continue;
-          }
-          inputCommentsToPublish.add(comment);
-        }
-      }
-      return inputCommentsToPublish;
-    }
-
-    /**
-     * Validates all comments and the change message in a single call to fulfill the interface
-     * contract of {@link CommentValidator#validateComments(CommentValidationContext,
-     * ImmutableList)}.
-     */
-    private void validateComments(ChangeContext ctx, Stream<? extends Comment> comments)
-        throws CommentsRejectedException {
-      CommentValidationContext commentValidationCtx =
-          CommentValidationContext.create(
-              ctx.getChange().getChangeId(),
-              ctx.getChange().getProject().get(),
-              ctx.getChange().getDest().branch());
-      String changeMessage = Strings.nullToEmpty(in.message).trim();
-      ImmutableList<CommentForValidation> draftsForValidation =
-          Stream.concat(
-                  comments.map(
-                      comment ->
-                          CommentForValidation.create(
-                              comment instanceof RobotComment
-                                  ? CommentForValidation.CommentSource.ROBOT
-                                  : CommentForValidation.CommentSource.HUMAN,
-                              comment.lineNbr > 0
-                                  ? CommentForValidation.CommentType.INLINE_COMMENT
-                                  : CommentForValidation.CommentType.FILE_COMMENT,
-                              comment.message,
-                              comment.getApproximateSize())),
-                  Stream.of(
-                      CommentForValidation.create(
-                          CommentForValidation.CommentSource.HUMAN,
-                          CommentForValidation.CommentType.CHANGE_MESSAGE,
-                          changeMessage,
-                          changeMessage.length())))
-              .collect(toImmutableList());
-      ImmutableList<CommentValidationFailure> draftValidationFailures =
-          PublishCommentUtil.findInvalidComments(
-              commentValidationCtx, commentValidators, draftsForValidation);
-      if (!draftValidationFailures.isEmpty()) {
-        throw new CommentsRejectedException(draftValidationFailures);
-      }
-    }
-
-    private boolean insertRobotComments(ChangeContext ctx, List<RobotComment> newRobotComments) {
-      if (in.robotComments == null) {
-        return false;
-      }
-      commentsUtil.putRobotComments(ctx.getUpdate(psId), newRobotComments);
-      comments.addAll(newRobotComments);
-      return !newRobotComments.isEmpty();
-    }
-
-    private List<RobotComment> getNewRobotComments(ChangeContext ctx) {
-      List<RobotComment> toAdd = new ArrayList<>(in.robotComments.size());
-
-      Set<CommentSetEntry> existingIds =
-          in.omitDuplicateComments ? readExistingRobotComments(ctx) : Collections.emptySet();
-
-      for (Map.Entry<String, List<RobotCommentInput>> ent : in.robotComments.entrySet()) {
-        String path = ent.getKey();
-        for (RobotCommentInput c : ent.getValue()) {
-          RobotComment e = createRobotCommentFromInput(ctx, path, c);
-          if (existingIds.contains(CommentSetEntry.create(e))) {
-            continue;
-          }
-          toAdd.add(e);
-        }
-      }
-      return toAdd;
-    }
-
-    private RobotComment createRobotCommentFromInput(
-        ChangeContext ctx, String path, RobotCommentInput robotCommentInput) {
-      RobotComment robotComment =
-          commentsUtil.newRobotComment(
-              ctx,
-              path,
-              psId,
-              robotCommentInput.side(),
-              robotCommentInput.message,
-              robotCommentInput.robotId,
-              robotCommentInput.robotRunId);
-      robotComment.parentUuid = Url.decode(robotCommentInput.inReplyTo);
-      robotComment.url = robotCommentInput.url;
-      robotComment.properties = robotCommentInput.properties;
-      robotComment.setLineNbrAndRange(robotCommentInput.line, robotCommentInput.range);
-      robotComment.tag = in.tag;
-      commentsUtil.setCommentCommitId(robotComment, ctx.getChange(), ps);
-      robotComment.fixSuggestions = createFixSuggestionsFromInput(robotCommentInput.fixSuggestions);
-      return robotComment;
-    }
-
-    private ImmutableList<FixSuggestion> createFixSuggestionsFromInput(
-        List<FixSuggestionInfo> fixSuggestionInfos) {
-      if (fixSuggestionInfos == null) {
-        return ImmutableList.of();
-      }
-
-      ImmutableList.Builder<FixSuggestion> fixSuggestions =
-          ImmutableList.builderWithExpectedSize(fixSuggestionInfos.size());
-      for (FixSuggestionInfo fixSuggestionInfo : fixSuggestionInfos) {
-        fixSuggestions.add(createFixSuggestionFromInput(fixSuggestionInfo));
-      }
-      return fixSuggestions.build();
-    }
-
-    private FixSuggestion createFixSuggestionFromInput(FixSuggestionInfo fixSuggestionInfo) {
-      List<FixReplacement> fixReplacements = toFixReplacements(fixSuggestionInfo.replacements);
-      String fixId = ChangeUtil.messageUuid();
-      return new FixSuggestion(fixId, fixSuggestionInfo.description, fixReplacements);
-    }
-
-    private List<FixReplacement> toFixReplacements(List<FixReplacementInfo> fixReplacementInfos) {
-      return fixReplacementInfos.stream().map(this::toFixReplacement).collect(toList());
-    }
-
-    private FixReplacement toFixReplacement(FixReplacementInfo fixReplacementInfo) {
-      Comment.Range range = new Comment.Range(fixReplacementInfo.range);
-      return new FixReplacement(fixReplacementInfo.path, range, fixReplacementInfo.replacement);
-    }
-
-    private Set<CommentSetEntry> readExistingComments(ChangeContext ctx) {
-      return commentsUtil.publishedHumanCommentsByChange(ctx.getNotes()).stream()
-          .map(CommentSetEntry::create)
-          .collect(toSet());
-    }
-
-    private Set<CommentSetEntry> readExistingRobotComments(ChangeContext ctx) {
-      return commentsUtil.robotCommentsByChange(ctx.getNotes()).stream()
-          .map(CommentSetEntry::create)
-          .collect(toSet());
-    }
-
-    private Map<String, HumanComment> changeDrafts(ChangeContext ctx) {
-      return commentsUtil.draftByChangeAuthor(ctx.getNotes(), user.getAccountId()).stream()
-          .collect(Collectors.toMap(c -> c.key.uuid, c -> c));
-    }
-
-    private Map<String, HumanComment> patchSetDrafts(ChangeContext ctx) {
-      return commentsUtil.draftByPatchSetAuthor(psId, user.getAccountId(), ctx.getNotes()).stream()
-          .collect(Collectors.toMap(c -> c.key.uuid, c -> c));
-    }
-
-    private Map<String, Short> approvalsByKey(Collection<PatchSetApproval> patchsetApprovals) {
-      Map<String, Short> labels = new HashMap<>();
-      for (PatchSetApproval psa : patchsetApprovals) {
-        labels.put(psa.label(), psa.value());
-      }
-      return labels;
-    }
-
-    private Map<String, Short> getAllApprovals(
-        LabelTypes labelTypes, Map<String, Short> current, Map<String, Short> input) {
-      Map<String, Short> allApprovals = new HashMap<>();
-      for (LabelType lt : labelTypes.getLabelTypes()) {
-        allApprovals.put(lt.getName(), (short) 0);
-      }
-      // set approvals to existing votes
-      if (current != null) {
-        allApprovals.putAll(current);
-      }
-      // set approvals to new votes
-      if (input != null) {
-        allApprovals.putAll(input);
-      }
-      return allApprovals;
-    }
-
-    private Map<String, Short> getPreviousApprovals(
-        Map<String, Short> allApprovals, Map<String, Short> current) {
-      Map<String, Short> previous = new HashMap<>();
-      for (Map.Entry<String, Short> approval : allApprovals.entrySet()) {
-        // assume vote is 0 if there is no vote
-        if (!current.containsKey(approval.getKey())) {
-          previous.put(approval.getKey(), (short) 0);
-        } else {
-          previous.put(approval.getKey(), current.get(approval.getKey()));
-        }
-      }
-      return previous;
-    }
-
-    private boolean isReviewer(ChangeContext ctx) {
-      return approvalsUtil
-          .getReviewers(ctx.getNotes())
-          .byState(REVIEWER)
-          .contains(ctx.getAccountId());
-    }
-
-    private boolean updateLabels(ProjectState projectState, ChangeContext ctx)
-        throws ResourceConflictException {
-      Map<String, Short> inLabels = firstNonNull(in.labels, Collections.emptyMap());
-
-      // If no labels were modified and change is closed, abort early.
-      // This avoids trying to record a modified label caused by a user
-      // losing access to a label after the change was submitted.
-      if (inLabels.isEmpty() && ctx.getChange().isClosed()) {
-        return false;
-      }
-
-      List<PatchSetApproval> del = new ArrayList<>();
-      List<PatchSetApproval> ups = new ArrayList<>();
-      Map<String, PatchSetApproval> current = scanLabels(projectState, ctx, del);
-      LabelTypes labelTypes = projectState.getLabelTypes(ctx.getNotes());
-      Map<String, Short> allApprovals =
-          getAllApprovals(labelTypes, approvalsByKey(current.values()), inLabels);
-      Map<String, Short> previous =
-          getPreviousApprovals(allApprovals, approvalsByKey(current.values()));
-
-      ChangeUpdate update = ctx.getUpdate(psId);
-      for (Map.Entry<String, Short> ent : allApprovals.entrySet()) {
-        String name = ent.getKey();
-        LabelType lt =
-            labelTypes
-                .byLabel(name)
-                .orElseThrow(() -> new IllegalStateException("no label config for " + name));
-
-        PatchSetApproval c = current.remove(lt.getName());
-        String normName = lt.getName();
-        approvals.put(normName, (short) 0);
-        if (ent.getValue() == null || ent.getValue() == 0) {
-          // User requested delete of this label.
-          oldApprovals.put(normName, null);
-          if (c != null) {
-            if (c.value() != 0) {
-              addLabelDelta(normName, (short) 0);
-              oldApprovals.put(normName, previous.get(normName));
-            }
-            del.add(c);
-            update.putApproval(normName, (short) 0);
-          }
-          // Only allow voting again if the vote is copied over from a past patch-set, or the
-          // values are different.
-        } else if (c != null
-            && (c.value() != ent.getValue() || isApprovalCopiedOver(c, ctx.getNotes()))) {
-          PatchSetApproval.Builder b =
-              c.toBuilder()
-                  .value(ent.getValue())
-                  .granted(ctx.getWhen())
-                  .tag(Optional.ofNullable(in.tag));
-          ctx.getUser().updateRealAccountId(b::realAccountId);
-          c = b.build();
-          ups.add(c);
-          addLabelDelta(normName, c.value());
-          oldApprovals.put(normName, previous.get(normName));
-          approvals.put(normName, c.value());
-          update.putApproval(normName, ent.getValue());
-        } else if (c != null && c.value() == ent.getValue()) {
-          current.put(normName, c);
-          oldApprovals.put(normName, null);
-          approvals.put(normName, c.value());
-        } else if (c == null) {
-          c =
-              ApprovalsUtil.newApproval(psId, user, lt.getLabelId(), ent.getValue(), ctx.getWhen())
-                  .tag(Optional.ofNullable(in.tag))
-                  .granted(ctx.getWhen())
-                  .build();
-          ups.add(c);
-          addLabelDelta(normName, c.value());
-          oldApprovals.put(normName, previous.get(normName));
-          approvals.put(normName, c.value());
-          update.putReviewer(user.getAccountId(), REVIEWER);
-          update.putApproval(normName, ent.getValue());
-        }
-      }
-
-      validatePostSubmitLabels(ctx, labelTypes, previous, ups, del);
-
-      // Return early if user is not a reviewer and not posting any labels.
-      // This allows us to preserve their CC status.
-      if (current.isEmpty() && del.isEmpty() && ups.isEmpty() && !isReviewer(ctx)) {
-        return false;
-      }
-
-      return !del.isEmpty() || !ups.isEmpty();
-    }
-
-    /** Approval is copied over if it doesn't exist in the approvals of the current patch-set. */
-    private boolean isApprovalCopiedOver(
-        PatchSetApproval patchSetApproval, ChangeNotes changeNotes) {
-      return !changeNotes.getApprovals().get(changeNotes.getChange().currentPatchSetId()).stream()
-          .anyMatch(p -> p.equals(patchSetApproval));
-    }
-
-    private void validatePostSubmitLabels(
-        ChangeContext ctx,
-        LabelTypes labelTypes,
-        Map<String, Short> previous,
-        List<PatchSetApproval> ups,
-        List<PatchSetApproval> del)
-        throws ResourceConflictException {
-      if (ctx.getChange().isNew()) {
-        return; // Not closed, nothing to validate.
-      } else if (del.isEmpty() && ups.isEmpty()) {
-        return; // No new votes.
-      } else if (!ctx.getChange().isMerged()) {
-        throw new ResourceConflictException("change is closed");
-      }
-
-      // Disallow reducing votes on any labels post-submit. This assumes the
-      // high values were broadly necessary to submit, so reducing them would
-      // make it possible to take a merged change and make it no longer
-      // submittable.
-      List<PatchSetApproval> reduced = new ArrayList<>(ups.size() + del.size());
-      List<String> disallowed = new ArrayList<>(labelTypes.getLabelTypes().size());
-
-      for (PatchSetApproval psa : del) {
-        LabelType lt =
-            labelTypes
-                .byLabel(psa.label())
-                .orElseThrow(() -> new IllegalStateException("no label config for " + psa.label()));
-        String normName = lt.getName();
-        if (!lt.isAllowPostSubmit()) {
-          disallowed.add(normName);
-        }
-        Short prev = previous.get(normName);
-        if (prev != null && prev != 0) {
-          reduced.add(psa);
-        }
-      }
-
-      for (PatchSetApproval psa : ups) {
-        LabelType lt =
-            labelTypes
-                .byLabel(psa.label())
-                .orElseThrow(() -> new IllegalStateException("no label config for " + psa.label()));
-        String normName = lt.getName();
-        if (!lt.isAllowPostSubmit()) {
-          disallowed.add(normName);
-        }
-        Short prev = previous.get(normName);
-        if (prev == null) {
-          continue;
-        }
-        if (prev > psa.value()) {
-          reduced.add(psa);
-        }
-        // No need to set postSubmit bit, which is set automatically when parsing from NoteDb.
-      }
-
-      if (!disallowed.isEmpty()) {
-        throw new ResourceConflictException(
-            "Voting on labels disallowed after submit: "
-                + disallowed.stream().distinct().sorted().collect(joining(", ")));
-      }
-      if (!reduced.isEmpty()) {
-        throw new ResourceConflictException(
-            "Cannot reduce vote on labels for closed change: "
-                + reduced.stream()
-                    .map(PatchSetApproval::label)
-                    .distinct()
-                    .sorted()
-                    .collect(joining(", ")));
-      }
-    }
-
-    private Map<String, PatchSetApproval> scanLabels(
-        ProjectState projectState, ChangeContext ctx, List<PatchSetApproval> del) {
-      LabelTypes labelTypes = projectState.getLabelTypes(ctx.getNotes());
-      Map<String, PatchSetApproval> current = new HashMap<>();
-
-      for (PatchSetApproval a :
-          approvalsUtil.byPatchSetUser(ctx.getNotes(), psId, user.getAccountId())) {
-        if (a.isLegacySubmit()) {
-          continue;
-        }
-
-        Optional<LabelType> lt = labelTypes.byLabel(a.labelId());
-        if (lt.isPresent()) {
-          current.put(lt.get().getName(), a);
-        } else {
-          del.add(a);
-        }
-      }
-      return current;
-    }
-
-    private boolean insertMessage(ChangeContext ctx) {
-      String msg = Strings.nullToEmpty(in.message).trim();
-
-      StringBuilder buf = new StringBuilder();
-      for (LabelVote d : labelDelta) {
-        buf.append(" ").append(d.format());
-      }
-      if (comments.size() == 1) {
-        buf.append("\n\n(1 comment)");
-      } else if (comments.size() > 1) {
-        buf.append(String.format("\n\n(%d comments)", comments.size()));
-      }
-      if (!msg.isEmpty()) {
-        // Message was already validated when validating comments, since validators need to see
-        // everything in a single call.
-        buf.append("\n\n").append(msg);
-      } else if (in.ready) {
-        buf.append("\n\n" + START_REVIEW_MESSAGE);
-      }
-
-      List<String> pluginMessages = new ArrayList<>();
-      onPostReviews.runEach(
-          onPostReview ->
-              onPostReview
-                  .getChangeMessageAddOn(user, ctx.getNotes(), ps, oldApprovals, approvals)
-                  .ifPresent(
-                      pluginMessage ->
-                          pluginMessages.add(
-                              !pluginMessage.endsWith("\n")
-                                  ? pluginMessage + "\n"
-                                  : pluginMessage)));
-      if (!pluginMessages.isEmpty()) {
-        buf.append("\n\n");
-        buf.append(Joiner.on("\n").join(pluginMessages));
-      }
-
-      if (buf.length() == 0) {
-        return false;
-      }
-
-      mailMessage =
-          cmUtil.setChangeMessage(
-              ctx.getUpdate(psId), "Patch Set " + psId.get() + ":" + buf, in.tag);
-      return true;
-    }
-
-    private void addLabelDelta(String name, short value) {
-      labelDelta.add(LabelVote.create(name, value));
-    }
-  }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/PostReviewCopyApprovalsOp.java b/java/com/google/gerrit/server/restapi/change/PostReviewCopyApprovalsOp.java
new file mode 100644
index 0000000..88d2d7b
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/PostReviewCopyApprovalsOp.java
@@ -0,0 +1,236 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
+import com.google.auto.factory.AutoFactory;
+import com.google.auto.factory.Provided;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableTable;
+import com.google.common.collect.Table.Cell;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.approval.ApprovalCopier;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import java.io.IOException;
+import java.util.Optional;
+
+/**
+ * Batch update operation that copy approvals that have been newly applied on outdated patch sets to
+ * the follow-up patch sets if they are copyable and no non-copied approvals prevent the copying.
+ *
+ * <p>Must be invoked after the batch update operation which applied new approvals on outdated patch
+ * sets (e.g. after {@link PostReviewOp}.
+ */
+@AutoFactory
+public class PostReviewCopyApprovalsOp implements BatchUpdateOp {
+  private final ApprovalCopier approvalCopier;
+  private final PatchSetUtil patchSetUtil;
+  private final PatchSet.Id patchSetId;
+
+  private ChangeContext ctx;
+  private ImmutableList<PatchSet.Id> followUpPatchSets;
+
+  PostReviewCopyApprovalsOp(
+      @Provided ApprovalCopier approvalCopier,
+      @Provided PatchSetUtil patchSetUtil,
+      PatchSet.Id patchSetId) {
+    this.approvalCopier = approvalCopier;
+    this.patchSetUtil = patchSetUtil;
+    this.patchSetId = patchSetId;
+  }
+
+  @Override
+  public boolean updateChange(ChangeContext ctx) throws IOException {
+    if (ctx.getNotes().getCurrentPatchSet().id().equals(patchSetId)) {
+      // the updated patch set is the current patch, there a no follow-up patch set to which new
+      // approvals could be copied
+      return false;
+    }
+
+    init(ctx);
+
+    boolean dirty = false;
+    ImmutableTable<String, Account.Id, Optional<PatchSetApproval>> newApprovals =
+        ctx.getUpdate(patchSetId).getApprovals();
+    for (Cell<String, Account.Id, Optional<PatchSetApproval>> cell : newApprovals.cellSet()) {
+      String label = cell.getRowKey();
+      Account.Id approverId = cell.getColumnKey();
+      PatchSetApproval.Key psaKey =
+          PatchSetApproval.key(patchSetId, approverId, LabelId.create(label));
+
+      if (isRemoval(cell)) {
+        if (removeCopies(psaKey)) {
+          dirty = true;
+        }
+        continue;
+      }
+
+      PatchSet patchSet = patchSetUtil.get(ctx.getNotes(), patchSetId);
+      PatchSetApproval psaOrig = cell.getValue().get();
+
+      // Target patch sets to which the approval is copyable.
+      ImmutableList<PatchSet.Id> targetPatchSets =
+          approvalCopier.forApproval(
+              ctx.getNotes(),
+              patchSet,
+              psaKey.accountId(),
+              psaKey.labelId().get(),
+              psaOrig.value());
+
+      // Iterate over all follow-up patch sets, in patch set order.
+      for (PatchSet.Id followUpPatchSetId : followUpPatchSets) {
+        if (hasOverrideOf(followUpPatchSetId, psaKey)) {
+          // a non-copied approval exists that overrides any copied approval
+          // -> do not copy the approval to this patch set nor to any follow-up patch sets
+          break;
+        }
+
+        if (targetPatchSets.contains(followUpPatchSetId)) {
+          // The approval is copyable to the new patch set.
+
+          if (hasCopyOfWithValue(followUpPatchSetId, psaKey, psaOrig.value())) {
+            // a copy approval with the exact value already exists
+            continue;
+          }
+
+          // add/update the copied approval on the target patch set
+          PatchSetApproval copiedPatchSetApproval = psaOrig.copyWithPatchSet(followUpPatchSetId);
+          ctx.getUpdate(followUpPatchSetId).putCopiedApproval(copiedPatchSetApproval);
+          dirty = true;
+        } else {
+          // The approval is not copyable to the new patch set.
+
+          if (hasCopyOf(followUpPatchSetId, psaKey)) {
+            // a copy approval exists and should be removed
+            removeCopy(followUpPatchSetId, psaKey);
+            dirty = true;
+          }
+        }
+      }
+    }
+
+    return dirty;
+  }
+
+  private void init(ChangeContext ctx) {
+    this.ctx = ctx;
+
+    // compute follow-up patch sets (sorted by patch set ID)
+    this.followUpPatchSets =
+        ctx.getNotes().getPatchSets().keySet().stream()
+            .filter(psId -> psId.get() > patchSetId.get())
+            .collect(toImmutableList());
+  }
+
+  /**
+   * Whether the given cell entry from the approval table represents the removal of an approval.
+   *
+   * @param cell cell entry from the approval table
+   * @return {@code true} if the approval is not set or the approval has {@code 0} as the value,
+   *     otherwise {@code false}
+   */
+  private boolean isRemoval(Cell<String, Account.Id, Optional<PatchSetApproval>> cell) {
+    return cell.getValue().isEmpty() || cell.getValue().get().value() == 0;
+  }
+
+  /**
+   * Removes copies of the given approval from all follow-up patch sets.
+   *
+   * @param psaKey the key of the patch set approval for which copies should be removed from all
+   *     follow-up patch sets
+   * @return whether any copy approval has been removed
+   */
+  private boolean removeCopies(PatchSetApproval.Key psaKey) {
+    boolean dirty = false;
+    for (PatchSet.Id followUpPatchSet : followUpPatchSets) {
+      if (hasCopyOf(followUpPatchSet, psaKey)) {
+        removeCopy(followUpPatchSet, psaKey);
+      } else {
+        // Do not remove copy from this follow-up patch sets and also not from any further follow-up
+        // patch sets (if the further follow-up patch sets have copies they are copies of a
+        // non-copied approval on this follow-up patch set and hence those should not be removed).
+        break;
+      }
+    }
+    return dirty;
+  }
+
+  /**
+   * Removes the copy approval with the given key from the given patch set.
+   *
+   * @param patchSet patch set from which the copy approval with the given key should be removed
+   * @param psaKey the key of the patch set approval for which copies should be removed from the
+   *     given patch set
+   */
+  private void removeCopy(PatchSet.Id patchSet, PatchSetApproval.Key psaKey) {
+    ctx.getUpdate(patchSet)
+        .removeCopiedApprovalFor(
+            ctx.getIdentifiedUser().getRealUser().isIdentifiedUser()
+                ? ctx.getIdentifiedUser().getRealUser().getAccountId()
+                : null,
+            psaKey.accountId(),
+            psaKey.labelId().get());
+  }
+
+  /**
+   * Whether the given patch set has a copy approval with the given key.
+   *
+   * @param patchSetId the ID of the patch for which it should be checked whether it has a copy
+   *     approval with the given key
+   * @param psaKey the key of the patch set approval
+   */
+  private boolean hasCopyOf(PatchSet.Id patchSetId, PatchSetApproval.Key psaKey) {
+    return ctx.getNotes().getApprovals().onlyCopied().get(patchSetId).stream()
+        .anyMatch(psa -> areAccountAndLabelTheSame(psa.key(), psaKey));
+  }
+
+  /**
+   * Whether the given patch set has a copy approval with the given key and value.
+   *
+   * @param patchSetId the ID of the patch for which it should be checked whether it has a copy
+   *     approval with the given key and value
+   * @param psaKey the key of the patch set approval
+   */
+  private boolean hasCopyOfWithValue(
+      PatchSet.Id patchSetId, PatchSetApproval.Key psaKey, short value) {
+    return ctx.getNotes().getApprovals().onlyCopied().get(patchSetId).stream()
+        .anyMatch(psa -> areAccountAndLabelTheSame(psa.key(), psaKey) && psa.value() == value);
+  }
+
+  /**
+   * Whether the given patch set has a normal approval with the given key that overrides copy
+   * approvals with that key.
+   *
+   * @param patchSetId the ID of the patch for which it should be checked whether it has a normal
+   *     approval with the given key that overrides copy approvals with that key
+   * @param psaKey the key of the patch set approval
+   */
+  private boolean hasOverrideOf(PatchSet.Id patchSetId, PatchSetApproval.Key psaKey) {
+    return ctx.getNotes().getApprovals().onlyNonCopied().get(patchSetId).stream()
+        .anyMatch(psa -> areAccountAndLabelTheSame(psa.key(), psaKey));
+  }
+
+  private boolean areAccountAndLabelTheSame(
+      PatchSetApproval.Key psaKey1, PatchSetApproval.Key psaKey2) {
+    return psaKey1.accountId().equals(psaKey2.accountId())
+        && psaKey1.labelId().equals(psaKey2.labelId());
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/PostReviewOp.java b/java/com/google/gerrit/server/restapi/change/PostReviewOp.java
new file mode 100644
index 0000000..9274f52
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/PostReviewOp.java
@@ -0,0 +1,758 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import static com.google.common.base.MoreObjects.firstNonNull;
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.gerrit.entities.Patch.PATCHSET_LEVEL;
+import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
+import static java.util.stream.Collectors.joining;
+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.Joiner;
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Streams;
+import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.FixReplacement;
+import com.google.gerrit.entities.FixSuggestion;
+import com.google.gerrit.entities.HumanComment;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.LabelTypes;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.entities.RobotComment;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput.DraftHandling;
+import com.google.gerrit.extensions.api.changes.ReviewInput.RobotCommentInput;
+import com.google.gerrit.extensions.common.FixReplacementInfo;
+import com.google.gerrit.extensions.common.FixSuggestionInfo;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.extensions.restapi.Url;
+import com.google.gerrit.extensions.validators.CommentForValidation;
+import com.google.gerrit.extensions.validators.CommentValidationContext;
+import com.google.gerrit.extensions.validators.CommentValidationFailure;
+import com.google.gerrit.extensions.validators.CommentValidator;
+import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.ChangeUtil;
+import com.google.gerrit.server.CommentsUtil;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.PublishCommentUtil;
+import com.google.gerrit.server.approval.ApprovalsUtil;
+import com.google.gerrit.server.change.EmailReviewComments;
+import com.google.gerrit.server.change.NotifyResolver;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.extensions.events.CommentAdded;
+import com.google.gerrit.server.logging.Metadata;
+import com.google.gerrit.server.logging.TraceContext;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.plugincontext.PluginSetContext;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.restapi.change.PostReview.CommentSetEntry;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.CommentsRejectedException;
+import com.google.gerrit.server.update.PostUpdateContext;
+import com.google.gerrit.server.util.LabelVote;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.io.IOException;
+import java.sql.Timestamp;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+import org.eclipse.jgit.lib.Config;
+
+public class PostReviewOp implements BatchUpdateOp {
+  interface Factory {
+    PostReviewOp create(ProjectState projectState, PatchSet.Id psId, ReviewInput in);
+  }
+
+  @VisibleForTesting
+  public static final String START_REVIEW_MESSAGE = "This change is ready for review.";
+
+  private final ApprovalsUtil approvalsUtil;
+  private final ChangeMessagesUtil cmUtil;
+  private final CommentsUtil commentsUtil;
+  private final PublishCommentUtil publishCommentUtil;
+  private final PatchSetUtil psUtil;
+  private final EmailReviewComments.Factory email;
+  private final CommentAdded commentAdded;
+  private final PluginSetContext<CommentValidator> commentValidators;
+  private final PluginSetContext<OnPostReview> onPostReviews;
+
+  private final ProjectState projectState;
+  private final PatchSet.Id psId;
+  private final ReviewInput in;
+  private final boolean publishPatchSetLevelComment;
+
+  private IdentifiedUser user;
+  private ChangeNotes notes;
+  private PatchSet ps;
+  private String mailMessage;
+  private List<Comment> comments = new ArrayList<>();
+  private List<LabelVote> labelDelta = new ArrayList<>();
+  private Map<String, Short> approvals = new HashMap<>();
+  private Map<String, Short> oldApprovals = new HashMap<>();
+
+  @Inject
+  PostReviewOp(
+      @GerritServerConfig Config gerritConfig,
+      ApprovalsUtil approvalsUtil,
+      ChangeMessagesUtil cmUtil,
+      CommentsUtil commentsUtil,
+      PublishCommentUtil publishCommentUtil,
+      PatchSetUtil psUtil,
+      EmailReviewComments.Factory email,
+      CommentAdded commentAdded,
+      PluginSetContext<CommentValidator> commentValidators,
+      PluginSetContext<OnPostReview> onPostReviews,
+      @Assisted ProjectState projectState,
+      @Assisted PatchSet.Id psId,
+      @Assisted ReviewInput in) {
+    this.approvalsUtil = approvalsUtil;
+    this.publishCommentUtil = publishCommentUtil;
+    this.psUtil = psUtil;
+    this.cmUtil = cmUtil;
+    this.commentsUtil = commentsUtil;
+    this.email = email;
+    this.commentAdded = commentAdded;
+    this.commentValidators = commentValidators;
+    this.onPostReviews = onPostReviews;
+    this.publishPatchSetLevelComment =
+        gerritConfig.getBoolean("event", "comment-added", "publishPatchSetLevelComment", true);
+
+    this.projectState = projectState;
+    this.psId = psId;
+    this.in = in;
+  }
+
+  @Override
+  public boolean updateChange(ChangeContext ctx)
+      throws ResourceConflictException, UnprocessableEntityException, IOException,
+          CommentsRejectedException {
+    user = ctx.getIdentifiedUser();
+    notes = ctx.getNotes();
+    ps = psUtil.get(ctx.getNotes(), psId);
+    List<RobotComment> newRobotComments =
+        in.robotComments == null ? ImmutableList.of() : getNewRobotComments(ctx);
+    boolean dirty = false;
+    try (TraceContext.TraceTimer ignored = newTimer("insertComments")) {
+      dirty |= insertComments(ctx, newRobotComments);
+    }
+    try (TraceContext.TraceTimer ignored = newTimer("insertRobotComments")) {
+      dirty |= insertRobotComments(ctx, newRobotComments);
+    }
+    try (TraceContext.TraceTimer ignored = newTimer("updateLabels")) {
+      dirty |= updateLabels(projectState, ctx);
+    }
+    try (TraceContext.TraceTimer ignored = newTimer("insertMessage")) {
+      dirty |= insertMessage(ctx);
+    }
+    return dirty;
+  }
+
+  @Override
+  public void postUpdate(PostUpdateContext ctx) {
+    if (mailMessage == null) {
+      return;
+    }
+    NotifyResolver.Result notify = ctx.getNotify(notes.getChangeId());
+    if (notify.shouldNotify()) {
+      email
+          .create(ctx, ps, notes.getMetaId(), mailMessage, comments, in.message, labelDelta)
+          .sendAsync();
+    }
+    String comment = mailMessage;
+    if (publishPatchSetLevelComment) {
+      // TODO(davido): Remove this workaround when patch set level comments are exposed in comment
+      // added event. For backwards compatibility, patchset level comment has a higher priority
+      // than change message and should be used as comment in comment added event.
+      String patchSetLevelComment =
+          comments.stream()
+              .filter(c -> c.key.filename.equals(PATCHSET_LEVEL))
+              .map(c -> Strings.nullToEmpty(c.message))
+              .collect(Collectors.joining("\n"))
+              .trim();
+      if (!patchSetLevelComment.isEmpty()) {
+        comment = String.format("Patch Set %s:\n\n%s", psId.get(), patchSetLevelComment);
+      }
+    }
+    commentAdded.fire(
+        ctx.getChangeData(notes),
+        ps,
+        user.state(),
+        comment,
+        approvals,
+        oldApprovals,
+        ctx.getWhen());
+  }
+
+  /**
+   * Publishes draft and input comments. Input comments are those passed as input in the request
+   * body.
+   *
+   * @param ctx context for performing the change update.
+   * @param newRobotComments robot comments. Used only for validation in this method.
+   * @return true if any input comments where published.
+   */
+  private boolean insertComments(ChangeContext ctx, List<RobotComment> newRobotComments)
+      throws CommentsRejectedException {
+    Map<String, List<CommentInput>> inputComments = in.comments;
+    if (inputComments == null) {
+      inputComments = Collections.emptyMap();
+    }
+
+    // Use HashMap to avoid warnings when calling remove() in resolveInputCommentsAndDrafts().
+    Map<String, HumanComment> drafts = new HashMap<>();
+
+    if (!inputComments.isEmpty() || in.drafts != DraftHandling.KEEP) {
+      drafts =
+          in.drafts == DraftHandling.PUBLISH_ALL_REVISIONS
+              ? changeDrafts(ctx)
+              : patchSetDrafts(ctx);
+    }
+
+    // Existing published comments
+    Set<CommentSetEntry> existingComments =
+        in.omitDuplicateComments ? readExistingComments(ctx) : Collections.emptySet();
+
+    // Input comments should be deduplicated from existing drafts
+    List<HumanComment> inputCommentsToPublish =
+        resolveInputCommentsAndDrafts(inputComments, existingComments, drafts, ctx);
+
+    switch (in.drafts) {
+      case PUBLISH:
+      case PUBLISH_ALL_REVISIONS:
+        Collection<HumanComment> filteredDrafts =
+            in.draftIdsToPublish == null
+                ? drafts.values()
+                : drafts.values().stream()
+                    .filter(draft -> in.draftIdsToPublish.contains(draft.key.uuid))
+                    .collect(Collectors.toList());
+
+        validateComments(
+            ctx,
+            Streams.concat(
+                drafts.values().stream(),
+                inputCommentsToPublish.stream(),
+                newRobotComments.stream()));
+        publishCommentUtil.publish(ctx, ctx.getUpdate(psId), filteredDrafts, in.tag);
+        comments.addAll(drafts.values());
+        break;
+      case KEEP:
+        validateComments(
+            ctx, Streams.concat(inputCommentsToPublish.stream(), newRobotComments.stream()));
+        break;
+    }
+    commentsUtil.putHumanComments(
+        ctx.getUpdate(psId), HumanComment.Status.PUBLISHED, inputCommentsToPublish);
+    comments.addAll(inputCommentsToPublish);
+    return !inputCommentsToPublish.isEmpty();
+  }
+
+  /**
+   * Returns the subset of {@code inputComments} that do not have a matching comment (with same id)
+   * neither in {@code existingComments} nor in {@code drafts}.
+   *
+   * <p>Entries in {@code drafts} that have a matching entry in {@code inputComments} will be
+   * removed.
+   *
+   * @param inputComments new comments provided as {@link CommentInput} entries in the API.
+   * @param existingComments existing published comments in the database.
+   * @param drafts existing draft comments in the database. This map can be modified.
+   */
+  private List<HumanComment> resolveInputCommentsAndDrafts(
+      Map<String, List<CommentInput>> inputComments,
+      Set<CommentSetEntry> existingComments,
+      Map<String, HumanComment> drafts,
+      ChangeContext ctx) {
+    List<HumanComment> inputCommentsToPublish = new ArrayList<>();
+    for (Map.Entry<String, List<CommentInput>> entry : inputComments.entrySet()) {
+      String path = entry.getKey();
+      for (CommentInput inputComment : entry.getValue()) {
+        HumanComment comment = drafts.remove(Url.decode(inputComment.id));
+        if (comment == null) {
+          String parent = Url.decode(inputComment.inReplyTo);
+          comment =
+              commentsUtil.newHumanComment(
+                  ctx.getNotes(),
+                  ctx.getUser(),
+                  ctx.getWhen(),
+                  path,
+                  psId,
+                  inputComment.side(),
+                  inputComment.message,
+                  inputComment.unresolved,
+                  parent);
+        } else {
+          // In ChangeUpdate#putComment() the draft with the same ID will be deleted.
+          comment.writtenOn = Timestamp.from(ctx.getWhen());
+          comment.side = inputComment.side();
+          comment.message = inputComment.message;
+        }
+
+        commentsUtil.setCommentCommitId(comment, ctx.getChange(), ps);
+        comment.setLineNbrAndRange(inputComment.line, inputComment.range);
+        comment.tag = in.tag;
+
+        if (existingComments.contains(CommentSetEntry.create(comment))) {
+          continue;
+        }
+        inputCommentsToPublish.add(comment);
+      }
+    }
+    return inputCommentsToPublish;
+  }
+
+  /**
+   * Validates all comments and the change message in a single call to fulfill the interface
+   * contract of {@link CommentValidator#validateComments(CommentValidationContext, ImmutableList)}.
+   */
+  private void validateComments(ChangeContext ctx, Stream<? extends Comment> comments)
+      throws CommentsRejectedException {
+    CommentValidationContext commentValidationCtx =
+        CommentValidationContext.create(
+            ctx.getChange().getChangeId(),
+            ctx.getChange().getProject().get(),
+            ctx.getChange().getDest().branch());
+    String changeMessage = Strings.nullToEmpty(in.message).trim();
+    ImmutableList<CommentForValidation> draftsForValidation =
+        Stream.concat(
+                comments.map(
+                    comment ->
+                        CommentForValidation.create(
+                            comment instanceof RobotComment
+                                ? CommentForValidation.CommentSource.ROBOT
+                                : CommentForValidation.CommentSource.HUMAN,
+                            comment.lineNbr > 0
+                                ? CommentForValidation.CommentType.INLINE_COMMENT
+                                : CommentForValidation.CommentType.FILE_COMMENT,
+                            comment.message,
+                            comment.getApproximateSize())),
+                Stream.of(
+                    CommentForValidation.create(
+                        CommentForValidation.CommentSource.HUMAN,
+                        CommentForValidation.CommentType.CHANGE_MESSAGE,
+                        changeMessage,
+                        changeMessage.length())))
+            .collect(toImmutableList());
+    ImmutableList<CommentValidationFailure> draftValidationFailures =
+        PublishCommentUtil.findInvalidComments(
+            commentValidationCtx, commentValidators, draftsForValidation);
+    if (!draftValidationFailures.isEmpty()) {
+      throw new CommentsRejectedException(draftValidationFailures);
+    }
+  }
+
+  private boolean insertRobotComments(ChangeContext ctx, List<RobotComment> newRobotComments) {
+    if (in.robotComments == null) {
+      return false;
+    }
+    commentsUtil.putRobotComments(ctx.getUpdate(psId), newRobotComments);
+    comments.addAll(newRobotComments);
+    return !newRobotComments.isEmpty();
+  }
+
+  private List<RobotComment> getNewRobotComments(ChangeContext ctx) {
+    List<RobotComment> toAdd = new ArrayList<>(in.robotComments.size());
+
+    Set<CommentSetEntry> existingIds =
+        in.omitDuplicateComments ? readExistingRobotComments(ctx) : Collections.emptySet();
+
+    for (Map.Entry<String, List<RobotCommentInput>> ent : in.robotComments.entrySet()) {
+      String path = ent.getKey();
+      for (RobotCommentInput c : ent.getValue()) {
+        RobotComment e = createRobotCommentFromInput(ctx, path, c);
+        if (existingIds.contains(CommentSetEntry.create(e))) {
+          continue;
+        }
+        toAdd.add(e);
+      }
+    }
+    return toAdd;
+  }
+
+  private RobotComment createRobotCommentFromInput(
+      ChangeContext ctx, String path, RobotCommentInput robotCommentInput) {
+    RobotComment robotComment =
+        commentsUtil.newRobotComment(
+            ctx,
+            path,
+            psId,
+            robotCommentInput.side(),
+            robotCommentInput.message,
+            robotCommentInput.robotId,
+            robotCommentInput.robotRunId);
+    robotComment.parentUuid = Url.decode(robotCommentInput.inReplyTo);
+    robotComment.url = robotCommentInput.url;
+    robotComment.properties = robotCommentInput.properties;
+    robotComment.setLineNbrAndRange(robotCommentInput.line, robotCommentInput.range);
+    robotComment.tag = in.tag;
+    commentsUtil.setCommentCommitId(robotComment, ctx.getChange(), ps);
+    robotComment.fixSuggestions = createFixSuggestionsFromInput(robotCommentInput.fixSuggestions);
+    return robotComment;
+  }
+
+  private ImmutableList<FixSuggestion> createFixSuggestionsFromInput(
+      List<FixSuggestionInfo> fixSuggestionInfos) {
+    if (fixSuggestionInfos == null) {
+      return ImmutableList.of();
+    }
+
+    ImmutableList.Builder<FixSuggestion> fixSuggestions =
+        ImmutableList.builderWithExpectedSize(fixSuggestionInfos.size());
+    for (FixSuggestionInfo fixSuggestionInfo : fixSuggestionInfos) {
+      fixSuggestions.add(createFixSuggestionFromInput(fixSuggestionInfo));
+    }
+    return fixSuggestions.build();
+  }
+
+  private FixSuggestion createFixSuggestionFromInput(FixSuggestionInfo fixSuggestionInfo) {
+    List<FixReplacement> fixReplacements = toFixReplacements(fixSuggestionInfo.replacements);
+    String fixId = ChangeUtil.messageUuid();
+    return new FixSuggestion(fixId, fixSuggestionInfo.description, fixReplacements);
+  }
+
+  private List<FixReplacement> toFixReplacements(List<FixReplacementInfo> fixReplacementInfos) {
+    return fixReplacementInfos.stream().map(this::toFixReplacement).collect(toList());
+  }
+
+  private FixReplacement toFixReplacement(FixReplacementInfo fixReplacementInfo) {
+    Comment.Range range = new Comment.Range(fixReplacementInfo.range);
+    return new FixReplacement(fixReplacementInfo.path, range, fixReplacementInfo.replacement);
+  }
+
+  private Set<CommentSetEntry> readExistingComments(ChangeContext ctx) {
+    return commentsUtil.publishedHumanCommentsByChange(ctx.getNotes()).stream()
+        .map(CommentSetEntry::create)
+        .collect(toSet());
+  }
+
+  private Set<CommentSetEntry> readExistingRobotComments(ChangeContext ctx) {
+    return commentsUtil.robotCommentsByChange(ctx.getNotes()).stream()
+        .map(CommentSetEntry::create)
+        .collect(toSet());
+  }
+
+  private Map<String, HumanComment> changeDrafts(ChangeContext ctx) {
+    return commentsUtil.draftByChangeAuthor(ctx.getNotes(), user.getAccountId()).stream()
+        .collect(Collectors.toMap(c -> c.key.uuid, c -> c));
+  }
+
+  private Map<String, HumanComment> patchSetDrafts(ChangeContext ctx) {
+    return commentsUtil.draftByPatchSetAuthor(psId, user.getAccountId(), ctx.getNotes()).stream()
+        .collect(Collectors.toMap(c -> c.key.uuid, c -> c));
+  }
+
+  private Map<String, Short> approvalsByKey(Collection<PatchSetApproval> patchsetApprovals) {
+    Map<String, Short> labels = new HashMap<>();
+    for (PatchSetApproval psa : patchsetApprovals) {
+      labels.put(psa.label(), psa.value());
+    }
+    return labels;
+  }
+
+  private Map<String, Short> getAllApprovals(
+      LabelTypes labelTypes, Map<String, Short> current, Map<String, Short> input) {
+    Map<String, Short> allApprovals = new HashMap<>();
+    for (LabelType lt : labelTypes.getLabelTypes()) {
+      allApprovals.put(lt.getName(), (short) 0);
+    }
+    // set approvals to existing votes
+    if (current != null) {
+      allApprovals.putAll(current);
+    }
+    // set approvals to new votes
+    if (input != null) {
+      allApprovals.putAll(input);
+    }
+    return allApprovals;
+  }
+
+  private Map<String, Short> getPreviousApprovals(
+      Map<String, Short> allApprovals, Map<String, Short> current) {
+    Map<String, Short> previous = new HashMap<>();
+    for (Map.Entry<String, Short> approval : allApprovals.entrySet()) {
+      // assume vote is 0 if there is no vote
+      if (!current.containsKey(approval.getKey())) {
+        previous.put(approval.getKey(), (short) 0);
+      } else {
+        previous.put(approval.getKey(), current.get(approval.getKey()));
+      }
+    }
+    return previous;
+  }
+
+  private boolean isReviewer(ChangeContext ctx) {
+    return approvalsUtil
+        .getReviewers(ctx.getNotes())
+        .byState(REVIEWER)
+        .contains(ctx.getAccountId());
+  }
+
+  private boolean updateLabels(ProjectState projectState, ChangeContext ctx)
+      throws ResourceConflictException {
+    Map<String, Short> inLabels = firstNonNull(in.labels, Collections.emptyMap());
+
+    // If no labels were modified and change is closed, abort early.
+    // This avoids trying to record a modified label caused by a user
+    // losing access to a label after the change was submitted.
+    if (inLabels.isEmpty() && ctx.getChange().isClosed()) {
+      return false;
+    }
+
+    List<PatchSetApproval> del = new ArrayList<>();
+    List<PatchSetApproval> ups = new ArrayList<>();
+    Map<String, PatchSetApproval> current = scanLabels(projectState, ctx, del);
+    LabelTypes labelTypes = projectState.getLabelTypes(ctx.getNotes());
+    Map<String, Short> allApprovals =
+        getAllApprovals(labelTypes, approvalsByKey(current.values()), inLabels);
+    Map<String, Short> previous =
+        getPreviousApprovals(allApprovals, approvalsByKey(current.values()));
+
+    ChangeUpdate update = ctx.getUpdate(psId);
+    for (Map.Entry<String, Short> ent : allApprovals.entrySet()) {
+      String name = ent.getKey();
+      LabelType lt =
+          labelTypes
+              .byLabel(name)
+              .orElseThrow(() -> new IllegalStateException("no label config for " + name));
+
+      PatchSetApproval c = current.remove(lt.getName());
+      String normName = lt.getName();
+      approvals.put(normName, (short) 0);
+      if (ent.getValue() == null || ent.getValue() == 0) {
+        // User requested delete of this label.
+        oldApprovals.put(normName, null);
+        if (c != null) {
+          if (c.value() != 0) {
+            addLabelDelta(normName, (short) 0);
+            oldApprovals.put(normName, previous.get(normName));
+          }
+          del.add(c);
+          update.putApproval(normName, (short) 0);
+        }
+        // Only allow voting again if the vote is copied over from a past patch-set, or the
+        // values are different.
+      } else if (c != null
+          && (c.value() != ent.getValue()
+              || (inLabels.containsKey(c.label()) && isApprovalCopiedOver(c, ctx.getNotes())))) {
+        PatchSetApproval.Builder b =
+            c.toBuilder()
+                .value(ent.getValue())
+                .granted(ctx.getWhen())
+                .tag(Optional.ofNullable(in.tag));
+        ctx.getUser().updateRealAccountId(b::realAccountId);
+        c = b.build();
+        ups.add(c);
+        addLabelDelta(normName, c.value());
+        oldApprovals.put(normName, previous.get(normName));
+        approvals.put(normName, c.value());
+        update.putApproval(normName, ent.getValue());
+      } else if (c != null && c.value() == ent.getValue()) {
+        current.put(normName, c);
+        oldApprovals.put(normName, null);
+        approvals.put(normName, c.value());
+      } else if (c == null) {
+        c =
+            ApprovalsUtil.newApproval(psId, user, lt.getLabelId(), ent.getValue(), ctx.getWhen())
+                .tag(Optional.ofNullable(in.tag))
+                .granted(ctx.getWhen())
+                .build();
+        ups.add(c);
+        addLabelDelta(normName, c.value());
+        oldApprovals.put(normName, previous.get(normName));
+        approvals.put(normName, c.value());
+        update.putReviewer(user.getAccountId(), REVIEWER);
+        update.putApproval(normName, ent.getValue());
+      }
+    }
+
+    validatePostSubmitLabels(ctx, labelTypes, previous, ups, del);
+
+    // Return early if user is not a reviewer and not posting any labels.
+    // This allows us to preserve their CC status.
+    if (current.isEmpty() && del.isEmpty() && ups.isEmpty() && !isReviewer(ctx)) {
+      return false;
+    }
+
+    return !del.isEmpty() || !ups.isEmpty();
+  }
+
+  /** Approval is copied over if it doesn't exist in the approvals of the current patch-set. */
+  private boolean isApprovalCopiedOver(PatchSetApproval patchSetApproval, ChangeNotes changeNotes) {
+    return !changeNotes.getApprovals().onlyNonCopied()
+        .get(changeNotes.getChange().currentPatchSetId()).stream()
+        .anyMatch(p -> p.equals(patchSetApproval));
+  }
+
+  private void validatePostSubmitLabels(
+      ChangeContext ctx,
+      LabelTypes labelTypes,
+      Map<String, Short> previous,
+      List<PatchSetApproval> ups,
+      List<PatchSetApproval> del)
+      throws ResourceConflictException {
+    if (ctx.getChange().isNew()) {
+      return; // Not closed, nothing to validate.
+    } else if (del.isEmpty() && ups.isEmpty()) {
+      return; // No new votes.
+    } else if (!ctx.getChange().isMerged()) {
+      throw new ResourceConflictException("change is closed");
+    }
+
+    // Disallow reducing votes on any labels post-submit. This assumes the
+    // high values were broadly necessary to submit, so reducing them would
+    // make it possible to take a merged change and make it no longer
+    // submittable.
+    List<PatchSetApproval> reduced = new ArrayList<>(ups.size() + del.size());
+    List<String> disallowed = new ArrayList<>(labelTypes.getLabelTypes().size());
+
+    for (PatchSetApproval psa : del) {
+      LabelType lt =
+          labelTypes
+              .byLabel(psa.label())
+              .orElseThrow(() -> new IllegalStateException("no label config for " + psa.label()));
+      String normName = lt.getName();
+      if (!lt.isAllowPostSubmit()) {
+        disallowed.add(normName);
+      }
+      Short prev = previous.get(normName);
+      if (prev != null && prev != 0) {
+        reduced.add(psa);
+      }
+    }
+
+    for (PatchSetApproval psa : ups) {
+      LabelType lt =
+          labelTypes
+              .byLabel(psa.label())
+              .orElseThrow(() -> new IllegalStateException("no label config for " + psa.label()));
+      String normName = lt.getName();
+      if (!lt.isAllowPostSubmit()) {
+        disallowed.add(normName);
+      }
+      Short prev = previous.get(normName);
+      if (prev == null) {
+        continue;
+      }
+      if (prev > psa.value()) {
+        reduced.add(psa);
+      }
+      // No need to set postSubmit bit, which is set automatically when parsing from NoteDb.
+    }
+
+    if (!disallowed.isEmpty()) {
+      throw new ResourceConflictException(
+          "Voting on labels disallowed after submit: "
+              + disallowed.stream().distinct().sorted().collect(joining(", ")));
+    }
+    if (!reduced.isEmpty()) {
+      throw new ResourceConflictException(
+          "Cannot reduce vote on labels for closed change: "
+              + reduced.stream()
+                  .map(PatchSetApproval::label)
+                  .distinct()
+                  .sorted()
+                  .collect(joining(", ")));
+    }
+  }
+
+  private Map<String, PatchSetApproval> scanLabels(
+      ProjectState projectState, ChangeContext ctx, List<PatchSetApproval> del) {
+    LabelTypes labelTypes = projectState.getLabelTypes(ctx.getNotes());
+    Map<String, PatchSetApproval> current = new HashMap<>();
+
+    for (PatchSetApproval a :
+        approvalsUtil.byPatchSetUser(ctx.getNotes(), psId, user.getAccountId())) {
+      if (a.isLegacySubmit()) {
+        continue;
+      }
+
+      Optional<LabelType> lt = labelTypes.byLabel(a.labelId());
+      if (lt.isPresent()) {
+        current.put(lt.get().getName(), a);
+      } else {
+        del.add(a);
+      }
+    }
+    return current;
+  }
+
+  private boolean insertMessage(ChangeContext ctx) {
+    String msg = Strings.nullToEmpty(in.message).trim();
+
+    StringBuilder buf = new StringBuilder();
+    for (LabelVote d : labelDelta) {
+      buf.append(" ").append(d.format());
+    }
+    if (comments.size() == 1) {
+      buf.append("\n\n(1 comment)");
+    } else if (comments.size() > 1) {
+      buf.append(String.format("\n\n(%d comments)", comments.size()));
+    }
+    if (!msg.isEmpty()) {
+      // Message was already validated when validating comments, since validators need to see
+      // everything in a single call.
+      buf.append("\n\n").append(msg);
+    } else if (in.ready) {
+      buf.append("\n\n" + START_REVIEW_MESSAGE);
+    }
+
+    List<String> pluginMessages = new ArrayList<>();
+    onPostReviews.runEach(
+        onPostReview ->
+            onPostReview
+                .getChangeMessageAddOn(user, ctx.getNotes(), ps, oldApprovals, approvals)
+                .ifPresent(
+                    pluginMessage ->
+                        pluginMessages.add(
+                            !pluginMessage.endsWith("\n") ? pluginMessage + "\n" : pluginMessage)));
+    if (!pluginMessages.isEmpty()) {
+      buf.append("\n\n");
+      buf.append(Joiner.on("\n").join(pluginMessages));
+    }
+
+    if (buf.length() == 0) {
+      return false;
+    }
+
+    mailMessage =
+        cmUtil.setChangeMessage(ctx.getUpdate(psId), "Patch Set " + psId.get() + ":" + buf, in.tag);
+    return true;
+  }
+
+  private void addLabelDelta(String name, short value) {
+    labelDelta.add(LabelVote.create(name, value));
+  }
+
+  private TraceContext.TraceTimer newTimer(String method) {
+    return TraceContext.newTimer(getClass().getSimpleName() + "#" + method, Metadata.empty());
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/PreviewFix.java b/java/com/google/gerrit/server/restapi/change/PreviewFix.java
new file mode 100644
index 0000000..e771898
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/PreviewFix.java
@@ -0,0 +1,201 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,//
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import static com.google.gerrit.server.project.ProjectCache.illegalState;
+import static java.util.stream.Collectors.groupingBy;
+
+import com.google.common.base.MoreObjects;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.data.PatchScript;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Comment.Range;
+import com.google.gerrit.entities.FixReplacement;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.extensions.client.DiffPreferencesInfo;
+import com.google.gerrit.extensions.common.ApplyProvidedFixInput;
+import com.google.gerrit.extensions.common.DiffInfo;
+import com.google.gerrit.extensions.common.DiffWebLinkInfo;
+import com.google.gerrit.extensions.common.WebLinkInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.change.FixResource;
+import com.google.gerrit.server.change.RevisionResource;
+import com.google.gerrit.server.diff.DiffInfoCreator;
+import com.google.gerrit.server.diff.DiffSide;
+import com.google.gerrit.server.diff.DiffWebLinksProvider;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.LargeObjectException;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.patch.PatchScriptFactoryForAutoFix;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.InvalidChangeOperationException;
+import com.google.gerrit.server.project.NoSuchChangeException;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import com.google.inject.assistedinject.Assisted;
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import org.eclipse.jgit.lib.Repository;
+
+public class PreviewFix {
+  public interface Factory {
+    PreviewFix create(RevisionResource revisionResource);
+  }
+
+  private final GitRepositoryManager repoManager;
+  private final PatchScriptFactoryForAutoFix.Factory patchScriptFactoryFactory;
+  private final PatchSet patchSet;
+  private final ChangeNotes notes;
+  private final ProjectState state;
+
+  @Inject
+  PreviewFix(
+      GitRepositoryManager repoManager,
+      PatchScriptFactoryForAutoFix.Factory patchScriptFactoryFactory,
+      ProjectCache projectCache,
+      @Assisted RevisionResource revisionResource) {
+    this.repoManager = repoManager;
+    this.patchScriptFactoryFactory = patchScriptFactoryFactory;
+    patchSet = revisionResource.getPatchSet();
+    notes = revisionResource.getNotes();
+    Change change = notes.getChange();
+    state = projectCache.get(change.getProject()).orElseThrow(illegalState(change.getProject()));
+  }
+
+  @Singleton
+  public static class Stored implements RestReadView<FixResource> {
+    private final PreviewFix.Factory previewFixFactory;
+
+    @Inject
+    Stored(PreviewFix.Factory previewFixFactory) {
+      this.previewFixFactory = previewFixFactory;
+    }
+
+    @Override
+    public Response<Map<String, DiffInfo>> apply(FixResource fixResource)
+        throws PermissionBackendException, ResourceNotFoundException, ResourceConflictException,
+            AuthException, IOException, InvalidChangeOperationException {
+
+      PreviewFix previewFix = previewFixFactory.create(fixResource.getRevisionResource());
+
+      Map<String, List<FixReplacement>> fixReplacementsPerFilePath =
+          fixResource.getFixReplacements().stream()
+              .collect(groupingBy(fixReplacement -> fixReplacement.path));
+
+      return Response.ok(previewFix.previewAllFiles(fixReplacementsPerFilePath));
+    }
+  }
+
+  @Singleton
+  public static class Provided implements RestModifyView<RevisionResource, ApplyProvidedFixInput> {
+    private final PreviewFix.Factory previewFixFactory;
+
+    @Inject
+    Provided(PreviewFix.Factory previewFixFactory) {
+      this.previewFixFactory = previewFixFactory;
+    }
+
+    @Override
+    public Response<Map<String, DiffInfo>> apply(
+        RevisionResource revisionResource, ApplyProvidedFixInput applyProvidedFixInput)
+        throws BadRequestException, PermissionBackendException, ResourceNotFoundException,
+            ResourceConflictException, AuthException, IOException, InvalidChangeOperationException {
+      if (applyProvidedFixInput == null) {
+        throw new BadRequestException("applyProvidedFixInput is required");
+      }
+      if (applyProvidedFixInput.fixReplacementInfos == null) {
+        throw new BadRequestException("applyProvidedFixInput.fixReplacementInfos is required");
+      }
+
+      PreviewFix previewFix = previewFixFactory.create(revisionResource);
+
+      Map<String, List<FixReplacement>> fixReplacementsPerFilePath =
+          applyProvidedFixInput.fixReplacementInfos.stream()
+              .map(fix -> new FixReplacement(fix.path, new Range(fix.range), fix.replacement))
+              .collect(groupingBy(fixReplacement -> fixReplacement.path));
+
+      return Response.ok(previewFix.previewAllFiles(fixReplacementsPerFilePath));
+    }
+  }
+
+  private Map<String, DiffInfo> previewAllFiles(
+      Map<String, List<FixReplacement>> fixReplacementsPerFilePath)
+      throws PermissionBackendException, ResourceNotFoundException, ResourceConflictException,
+          AuthException, IOException, InvalidChangeOperationException {
+    Map<String, DiffInfo> result = new HashMap<>();
+    try (Repository git = repoManager.openRepository(notes.getProjectName())) {
+      for (Map.Entry<String, List<FixReplacement>> entry : fixReplacementsPerFilePath.entrySet()) {
+        String fileName = entry.getKey();
+        DiffInfo diffInfo =
+            previewSingleFile(git, fileName, ImmutableList.copyOf(entry.getValue()));
+        result.put(fileName, diffInfo);
+      }
+    } catch (NoSuchChangeException e) {
+      throw new ResourceNotFoundException(e.getMessage(), e);
+    } catch (LargeObjectException e) {
+      throw new ResourceConflictException(e.getMessage(), e);
+    }
+    return result;
+  }
+
+  private DiffInfo previewSingleFile(
+      Repository git, String fileName, ImmutableList<FixReplacement> fixReplacements)
+      throws PermissionBackendException, AuthException, LargeObjectException,
+          InvalidChangeOperationException, IOException, ResourceNotFoundException {
+    PatchScriptFactoryForAutoFix psf =
+        patchScriptFactoryFactory.create(
+            git, notes, fileName, patchSet, fixReplacements, DiffPreferencesInfo.defaults());
+    PatchScript ps = psf.call();
+
+    DiffSide sideA =
+        DiffSide.create(
+            ps.getFileInfoA(),
+            MoreObjects.firstNonNull(ps.getOldName(), ps.getNewName()),
+            DiffSide.Type.SIDE_A);
+    DiffSide sideB = DiffSide.create(ps.getFileInfoB(), ps.getNewName(), DiffSide.Type.SIDE_B);
+
+    DiffInfoCreator diffInfoCreator =
+        new DiffInfoCreator(state, new DiffWebLinksProviderImpl(), true);
+    return diffInfoCreator.create(ps, sideA, sideB);
+  }
+
+  private static class DiffWebLinksProviderImpl implements DiffWebLinksProvider {
+
+    @Override
+    public ImmutableList<DiffWebLinkInfo> getDiffLinks() {
+      return ImmutableList.of();
+    }
+
+    @Override
+    public ImmutableList<WebLinkInfo> getEditWebLinks() {
+      return ImmutableList.of();
+    }
+
+    @Override
+    public ImmutableList<WebLinkInfo> getFileWebLinks(DiffSide.Type fileInfoType) {
+      return ImmutableList.of();
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/PutMessage.java b/java/com/google/gerrit/server/restapi/change/PutMessage.java
index c62200a..f898dca 100644
--- a/java/com/google/gerrit/server/restapi/change/PutMessage.java
+++ b/java/com/google/gerrit/server/restapi/change/PutMessage.java
@@ -48,7 +48,7 @@
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.time.Instant;
-import java.util.TimeZone;
+import java.time.ZoneId;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.ObjectId;
@@ -64,7 +64,7 @@
   private final BatchUpdate.Factory updateFactory;
   private final GitRepositoryManager repositoryManager;
   private final Provider<CurrentUser> userProvider;
-  private final TimeZone tz;
+  private final ZoneId zoneId;
   private final PatchSetInserter.Factory psInserterFactory;
   private final PermissionBackend permissionBackend;
   private final PatchSetUtil psUtil;
@@ -86,7 +86,7 @@
     this.repositoryManager = repositoryManager;
     this.userProvider = userProvider;
     this.psInserterFactory = psInserterFactory;
-    this.tz = gerritIdent.getTimeZone();
+    this.zoneId = gerritIdent.getZoneId();
     this.permissionBackend = permissionBackend;
     this.psUtil = psUtil;
     this.notifyResolver = notifyResolver;
@@ -167,7 +167,8 @@
     builder.setTreeId(basePatchSetCommit.getTree());
     builder.setParentIds(basePatchSetCommit.getParents());
     builder.setAuthor(basePatchSetCommit.getAuthorIdent());
-    builder.setCommitter(userProvider.get().asIdentifiedUser().newCommitterIdent(timestamp, tz));
+    builder.setCommitter(
+        userProvider.get().asIdentifiedUser().newCommitterIdent(timestamp, zoneId));
     builder.setMessage(commitMessage);
     ObjectId newCommitId = objectInserter.insert(builder);
     objectInserter.flush();
diff --git a/java/com/google/gerrit/server/restapi/change/QueryChanges.java b/java/com/google/gerrit/server/restapi/change/QueryChanges.java
index 2c15bc9..6ce4b39 100644
--- a/java/com/google/gerrit/server/restapi/change/QueryChanges.java
+++ b/java/com/google/gerrit/server/restapi/change/QueryChanges.java
@@ -159,7 +159,8 @@
     return Response.ok(out.size() == 1 ? out.get(0) : out);
   }
 
-  private List<List<ChangeInfo>> query() throws QueryParseException, PermissionBackendException {
+  private List<List<ChangeInfo>> query()
+      throws BadRequestException, QueryParseException, PermissionBackendException {
     ChangeQueryProcessor queryProcessor = queryProcessorProvider.get();
     if (queryProcessor.isDisabled()) {
       throw new QueryParseException("query disabled");
@@ -169,6 +170,9 @@
       queryProcessor.setUserProvidedLimit(limit);
     }
     if (start != null) {
+      if (start < 0) {
+        throw new BadRequestException("'start' parameter cannot be less than zero");
+      }
       queryProcessor.setStart(start);
     }
     if (noLimit != null && !AnonymousUser.class.isAssignableFrom(userProvider.get().getClass())) {
diff --git a/java/com/google/gerrit/server/restapi/change/ReplyAttentionSetUpdates.java b/java/com/google/gerrit/server/restapi/change/ReplyAttentionSetUpdates.java
index 49286fc..3d9d588 100644
--- a/java/com/google/gerrit/server/restapi/change/ReplyAttentionSetUpdates.java
+++ b/java/com/google/gerrit/server/restapi/change/ReplyAttentionSetUpdates.java
@@ -284,9 +284,8 @@
    */
   private void addToAttentionSet(
       BatchUpdate bu, ChangeNotes changeNotes, Account.Id user, String reason, boolean notify) {
-    AddToAttentionSetOp addOwnerToAttentionSet =
-        addToAttentionSetOpFactory.create(user, reason, notify);
-    bu.addOp(changeNotes.getChangeId(), addOwnerToAttentionSet);
+    AddToAttentionSetOp addToAttentionSet = addToAttentionSetOpFactory.create(user, reason, notify);
+    bu.addOp(changeNotes.getChangeId(), addToAttentionSet);
   }
 
   /**
diff --git a/java/com/google/gerrit/server/restapi/change/ReviewersUtil.java b/java/com/google/gerrit/server/restapi/change/ReviewersUtil.java
index e3cf4db..418eb9c 100644
--- a/java/com/google/gerrit/server/restapi/change/ReviewersUtil.java
+++ b/java/com/google/gerrit/server/restapi/change/ReviewersUtil.java
@@ -28,13 +28,14 @@
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.client.ReviewerState;
+import com.google.gerrit.extensions.common.AccountVisibility;
 import com.google.gerrit.extensions.common.GroupBaseInfo;
 import com.google.gerrit.extensions.common.SuggestedReviewerInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.Url;
-import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.index.QueryOptions;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
 import com.google.gerrit.index.query.FieldBundle;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
@@ -57,6 +58,7 @@
 import com.google.gerrit.server.index.account.AccountIndexCollection;
 import com.google.gerrit.server.index.account.AccountIndexRewriter;
 import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.NoSuchProjectException;
 import com.google.gerrit.server.project.ProjectState;
@@ -120,6 +122,7 @@
     }
   }
 
+  private final AccountVisibility accountVisibility;
   private final AccountLoader.Factory accountLoaderFactory;
   private final AccountQueryBuilder accountQueryBuilder;
   private final AccountIndexRewriter accountIndexRewriter;
@@ -135,6 +138,7 @@
 
   @Inject
   ReviewersUtil(
+      AccountVisibility accountVisibility,
       AccountLoader.Factory accountLoaderFactory,
       AccountQueryBuilder accountQueryBuilder,
       AccountIndexRewriter accountIndexRewriter,
@@ -147,6 +151,7 @@
       AccountControl.Factory accountControlFactory,
       Provider<CurrentUser> self,
       ServiceUserClassifier serviceUserClassifier) {
+    this.accountVisibility = accountVisibility;
     this.accountLoaderFactory = accountLoaderFactory;
     this.accountQueryBuilder = accountQueryBuilder;
     this.accountIndexRewriter = accountIndexRewriter;
@@ -192,13 +197,20 @@
       logger.atFine().log("Reviewer suggestion is disabled.");
       return Collections.emptyList();
     }
+    AccountControl accountControl = accountControlFactory.get();
+
+    if (accountVisibility == AccountVisibility.NONE && !accountControl.canViewAll()) {
+      logger.atFine().log(
+          "Not suggesting reviewers: accountVisibility = %s and the user does not have %s capability",
+          AccountVisibility.NONE, GlobalPermission.VIEW_ALL_ACCOUNTS);
+      return Collections.emptyList();
+    }
 
     List<Account.Id> candidateList = new ArrayList<>();
     if (!Strings.isNullOrEmpty(query)) {
       candidateList = suggestAccounts(suggestReviewers);
       logger.atFine().log("Candidate list: %s", candidateList);
     }
-
     List<Account.Id> sortedRecommendations =
         recommendAccounts(
             reviewerState, changeNotes, suggestReviewers, projectState, candidateList);
@@ -216,8 +228,7 @@
           continue;
         }
         // Check if change is visible to reviewer and if the current user can see reviewer
-        if (visibilityControl.isVisibleTo(reviewer)
-            && accountControlFactory.get().canSee(reviewer)) {
+        if (visibilityControl.isVisibleTo(reviewer) && accountControl.canSee(reviewer)) {
           filteredRecommendations.add(reviewer);
         }
       }
@@ -238,9 +249,9 @@
 
   private static Account.Id fromIdField(FieldBundle f, boolean useLegacyNumericFields) {
     if (useLegacyNumericFields) {
-      return Account.id(f.getValue(AccountField.ID).intValue());
+      return Account.id(f.<Integer>getValue(AccountField.ID_FIELD_SPEC).intValue());
     }
-    return Account.id(Integer.valueOf(f.getValue(AccountField.ID_STR)));
+    return Account.id(Integer.valueOf(f.<String>getValue(AccountField.ID_STR_FIELD_SPEC)));
   }
 
   private List<Account.Id> suggestAccounts(SuggestReviewers suggestReviewers)
@@ -256,9 +267,9 @@
       logger.atFine().log("accounts index query: %s", pred);
       accountIndexRewriter.validateMaxTermsInQuery(pred);
       boolean useLegacyNumericFields =
-          accountIndexes.getSearchIndex().getSchema().useLegacyNumericFields();
-      FieldDef<AccountState, ?> idField =
-          useLegacyNumericFields ? AccountField.ID : AccountField.ID_STR;
+          accountIndexes.getSearchIndex().getSchema().hasField(AccountField.ID_FIELD_SPEC);
+      SchemaField<AccountState, ?> idField =
+          useLegacyNumericFields ? AccountField.ID_FIELD_SPEC : AccountField.ID_STR_FIELD_SPEC;
       ResultSet<FieldBundle> result =
           accountIndexes
               .getSearchIndex()
diff --git a/java/com/google/gerrit/server/restapi/change/Revisions.java b/java/com/google/gerrit/server/restapi/change/Revisions.java
index 41fecaf..bdc6816 100644
--- a/java/com/google/gerrit/server/restapi/change/Revisions.java
+++ b/java/com/google/gerrit/server/restapi/change/Revisions.java
@@ -106,18 +106,11 @@
   }
 
   private boolean visible(ChangeResource change) throws PermissionBackendException {
-    try {
-      permissionBackend
-          .user(change.getUser())
-          .change(change.getNotes())
-          .check(ChangePermission.READ);
-      return projectCache
-          .get(change.getProject())
-          .map(ProjectState::statePermitsRead)
-          .orElse(false);
-    } catch (AuthException e) {
-      return false;
-    }
+    return permissionBackend
+            .user(change.getUser())
+            .change(change.getNotes())
+            .test(ChangePermission.READ)
+        && projectCache.get(change.getProject()).map(ProjectState::statePermitsRead).orElse(false);
   }
 
   private ImmutableList<RevisionResource> find(ChangeResource change, String id)
@@ -154,9 +147,6 @@
     return ImmutableList.of();
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   private ImmutableList<RevisionResource> loadEdit(
       ChangeResource change, @Nullable ObjectId commitId) throws AuthException, IOException {
     Optional<ChangeEdit> edit = editUtil.byChange(change.getNotes(), change.getUser());
@@ -167,7 +157,7 @@
               .id(PatchSet.id(change.getId(), 0))
               .commitId(editCommit)
               .uploader(change.getUser().getAccountId())
-              .createdOn(editCommit.getCommitterIdent().getWhen().toInstant())
+              .createdOn(editCommit.getCommitterIdent().getWhenAsInstant())
               .build();
       if (commitId == null || editCommit.equals(commitId)) {
         return ImmutableList.of(new RevisionResource(change, ps, edit));
diff --git a/java/com/google/gerrit/server/restapi/change/Submit.java b/java/com/google/gerrit/server/restapi/change/Submit.java
index 9597dde..560f4e0 100644
--- a/java/com/google/gerrit/server/restapi/change/Submit.java
+++ b/java/com/google/gerrit/server/restapi/change/Submit.java
@@ -24,6 +24,7 @@
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.common.data.ParameterizedString;
 import com.google.gerrit.entities.BranchNameKey;
@@ -168,9 +169,12 @@
   }
 
   @Override
-  public Response<ChangeInfo> apply(RevisionResource rsrc, SubmitInput input)
+  public Response<ChangeInfo> apply(RevisionResource rsrc, @Nullable SubmitInput input)
       throws RestApiException, RepositoryNotFoundException, IOException, PermissionBackendException,
           UpdateException, ConfigInvalidException {
+    if (input == null) {
+      input = new SubmitInput();
+    }
     input.onBehalfOf = Strings.emptyToNull(input.onBehalfOf);
     IdentifiedUser submitter;
     if (input.onBehalfOf != null) {
diff --git a/java/com/google/gerrit/server/restapi/change/SuggestChangeReviewers.java b/java/com/google/gerrit/server/restapi/change/SuggestChangeReviewers.java
index 26c7297..26a0415 100644
--- a/java/com/google/gerrit/server/restapi/change/SuggestChangeReviewers.java
+++ b/java/com/google/gerrit/server/restapi/change/SuggestChangeReviewers.java
@@ -93,6 +93,7 @@
       throw new BadRequestException(
           String.format("Unsupported reviewer state: %s", ReviewerState.REMOVED));
     }
+
     return Response.ok(
         reviewersUtil.suggestReviewers(
             reviewerState,
diff --git a/java/com/google/gerrit/server/restapi/change/SuggestReviewers.java b/java/com/google/gerrit/server/restapi/change/SuggestReviewers.java
index 74f5290..0035a03 100644
--- a/java/com/google/gerrit/server/restapi/change/SuggestReviewers.java
+++ b/java/com/google/gerrit/server/restapi/change/SuggestReviewers.java
@@ -93,11 +93,7 @@
         cfg.getInt("suggest", "maxSuggestedReviewers", DEFAULT_MAX_SUGGESTED);
     this.limit = this.maxSuggestedReviewers;
     String suggest = cfg.getString("suggest", null, "accounts");
-    if ("OFF".equalsIgnoreCase(suggest) || "false".equalsIgnoreCase(suggest)) {
-      this.suggestAccounts = false;
-    } else {
-      this.suggestAccounts = (av != AccountVisibility.NONE);
-    }
+    this.suggestAccounts = !"OFF".equalsIgnoreCase(suggest) && !"false".equalsIgnoreCase(suggest);
 
     this.maxAllowed =
         cfg.getInt("addreviewer", "maxAllowed", ReviewerModifier.DEFAULT_MAX_REVIEWERS);
diff --git a/java/com/google/gerrit/server/restapi/change/Unignore.java b/java/com/google/gerrit/server/restapi/change/Unignore.java
deleted file mode 100644
index 999e736..0000000
--- a/java/com/google/gerrit/server/restapi/change/Unignore.java
+++ /dev/null
@@ -1,64 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.restapi.change;
-
-import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.extensions.common.Input;
-import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.extensions.webui.UiAction;
-import com.google.gerrit.server.StarredChangesUtil;
-import com.google.gerrit.server.StarredChangesUtil.IllegalLabelException;
-import com.google.gerrit.server.change.ChangeResource;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-
-@Singleton
-public class Unignore implements RestModifyView<ChangeResource, Input>, UiAction<ChangeResource> {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
-  private final StarredChangesUtil stars;
-
-  @Inject
-  Unignore(StarredChangesUtil stars) {
-    this.stars = stars;
-  }
-
-  @Override
-  public Description getDescription(ChangeResource rsrc) {
-    return new UiAction.Description()
-        .setLabel("Unignore")
-        .setTitle("Unignore the change")
-        .setVisible(isIgnored(rsrc));
-  }
-
-  @Override
-  public Response<String> apply(ChangeResource rsrc, Input input) throws IllegalLabelException {
-    if (isIgnored(rsrc)) {
-      stars.unignore(rsrc);
-    }
-    return Response.ok();
-  }
-
-  private boolean isIgnored(ChangeResource rsrc) {
-    try {
-      return stars.isIgnored(rsrc);
-    } catch (StorageException e) {
-      logger.atSevere().withCause(e).log("failed to check ignored star");
-    }
-    return false;
-  }
-}
diff --git a/java/com/google/gerrit/server/restapi/config/IndexChanges.java b/java/com/google/gerrit/server/restapi/config/IndexChanges.java
index 904c44f..caca5bc 100644
--- a/java/com/google/gerrit/server/restapi/config/IndexChanges.java
+++ b/java/com/google/gerrit/server/restapi/config/IndexChanges.java
@@ -16,6 +16,7 @@
 
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
@@ -27,6 +28,8 @@
 import com.google.gerrit.server.restapi.config.IndexChanges.Input;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
+import java.util.List;
+import java.util.Optional;
 import java.util.Set;
 
 @RequiresCapability(GlobalCapability.ADMINISTRATE_SERVER)
@@ -36,6 +39,7 @@
 
   public static class Input {
     public Set<String> changes;
+    boolean deleteMissing;
   }
 
   private final ChangeFinder changeFinder;
@@ -57,7 +61,21 @@
     }
 
     for (String id : input.changes) {
-      for (ChangeNotes n : changeFinder.find(id)) {
+      List<ChangeNotes> notes = changeFinder.find(id);
+
+      if (notes.isEmpty()) {
+        logger.atWarning().log("Change %s missing in NoteDb", id);
+        if (input.deleteMissing) {
+          Optional<Change.Id> changeId = Change.Id.tryParse(id);
+          if (changeId.isPresent()) {
+            logger.atWarning().log("Deleting change %s from index", changeId.get());
+            indexer.delete(changeId.get());
+          }
+        }
+        continue;
+      }
+
+      for (ChangeNotes n : notes) {
         indexer.index(changeDataFactory.create(n));
         logger.atFine().log("Indexed change %s", id);
       }
diff --git a/java/com/google/gerrit/server/restapi/config/ListTasks.java b/java/com/google/gerrit/server/restapi/config/ListTasks.java
index dcc44ae..8ada657 100644
--- a/java/com/google/gerrit/server/restapi/config/ListTasks.java
+++ b/java/com/google/gerrit/server/restapi/config/ListTasks.java
@@ -72,11 +72,8 @@
     }
 
     List<TaskInfo> allTasks = getTasks();
-    try {
-      permissionBackend.user(user).check(GlobalPermission.VIEW_QUEUE);
+    if (permissionBackend.user(user).test(GlobalPermission.VIEW_QUEUE)) {
       return Response.ok(allTasks);
-    } catch (AuthException e) {
-      // Fall through to filter tasks.
     }
 
     Map<String, Boolean> visibilityCache = new HashMap<>();
@@ -90,10 +87,9 @@
           if (!state.isPresent() || !state.get().statePermitsRead()) {
             visible = false;
           } else {
-            try {
-              permissionBackend.user(user).project(nameKey).check(ProjectPermission.ACCESS);
+            if (permissionBackend.user(user).project(nameKey).test(ProjectPermission.ACCESS)) {
               visible = true;
-            } catch (AuthException e) {
+            } else {
               visible = false;
             }
           }
diff --git a/java/com/google/gerrit/server/restapi/config/TasksCollection.java b/java/com/google/gerrit/server/restapi/config/TasksCollection.java
index 409aa9c..29a0033 100644
--- a/java/com/google/gerrit/server/restapi/config/TasksCollection.java
+++ b/java/com/google/gerrit/server/restapi/config/TasksCollection.java
@@ -94,21 +94,14 @@
       }
 
       state.get().checkStatePermitsRead();
-
-      try {
-        permissionBackend.user(user).project(nameKey).check(ProjectPermission.ACCESS);
+      if (permissionBackend.user(user).project(nameKey).test(ProjectPermission.ACCESS)) {
         return new TaskResource(task);
-      } catch (AuthException e) {
-        // Fall through and try view queue permission.
       }
     }
 
     if (task != null) {
-      try {
-        permissionBackend.user(user).check(GlobalPermission.VIEW_QUEUE);
+      if (permissionBackend.user(user).test(GlobalPermission.VIEW_QUEUE)) {
         return new TaskResource(task);
-      } catch (AuthException e) {
-        // Fall through and return not found.
       }
     }
 
diff --git a/java/com/google/gerrit/server/restapi/group/CreateGroup.java b/java/com/google/gerrit/server/restapi/group/CreateGroup.java
index f257f86..e617931 100644
--- a/java/com/google/gerrit/server/restapi/group/CreateGroup.java
+++ b/java/com/google/gerrit/server/restapi/group/CreateGroup.java
@@ -61,13 +61,13 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
+import java.time.ZoneId;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.List;
 import java.util.Locale;
 import java.util.Optional;
-import java.util.TimeZone;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.PersonIdent;
@@ -77,7 +77,7 @@
 public class CreateGroup
     implements RestCollectionCreateView<TopLevelResource, GroupResource, GroupInput> {
   private final Provider<IdentifiedUser> self;
-  private final TimeZone serverTimeZone;
+  private final ZoneId serverZoneId;
   private final Provider<GroupsUpdate> groupsUpdateProvider;
   private final GroupCache groupCache;
   private final GroupResolver groups;
@@ -102,7 +102,7 @@
       @GerritServerConfig Config cfg,
       Sequences sequences) {
     this.self = self;
-    this.serverTimeZone = serverIdent.get().getTimeZone();
+    this.serverZoneId = serverIdent.get().getZoneId();
     this.groupsUpdateProvider = groupsUpdateProvider;
     this.groupCache = groupCache;
     this.groups = groups;
@@ -212,7 +212,7 @@
             createGroupArgs.uuid,
             GroupUuid.make(
                 createGroupArgs.getGroupName(),
-                self.get().newCommitterIdent(TimeUtil.now(), serverTimeZone)));
+                self.get().newCommitterIdent(TimeUtil.now(), serverZoneId)));
     InternalGroupCreation groupCreation =
         InternalGroupCreation.builder()
             .setGroupUUID(uuid)
diff --git a/java/com/google/gerrit/server/restapi/group/QueryGroups.java b/java/com/google/gerrit/server/restapi/group/QueryGroups.java
index befccfe..fed2302 100644
--- a/java/com/google/gerrit/server/restapi/group/QueryGroups.java
+++ b/java/com/google/gerrit/server/restapi/group/QueryGroups.java
@@ -107,6 +107,10 @@
       throw new MethodNotAllowedException("query disabled");
     }
 
+    if (start < 0) {
+      throw new BadRequestException("'start' parameter cannot be less than zero");
+    }
+
     if (start != 0) {
       queryProcessor.setStart(start);
     }
diff --git a/java/com/google/gerrit/server/restapi/project/BranchesCollection.java b/java/com/google/gerrit/server/restapi/project/BranchesCollection.java
index 2d78bb0..00d8658 100644
--- a/java/com/google/gerrit/server/restapi/project/BranchesCollection.java
+++ b/java/com/google/gerrit/server/restapi/project/BranchesCollection.java
@@ -17,7 +17,6 @@
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ChildCollection;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
@@ -75,14 +74,16 @@
       // ListBranches checks the target of a symbolic reference to determine access
       // rights on the symbolic reference itself. This check prevents seeing a hidden
       // branch simply because the symbolic reference name was visible.
-      permissionBackend
-          .currentUser()
-          .project(project)
-          .ref(ref.isSymbolic() ? ref.getTarget().getName() : ref.getName())
-          .check(RefPermission.READ);
-      return new BranchResource(parent.getProjectState(), parent.getUser(), ref);
-    } catch (AuthException notAllowed) {
-      throw new ResourceNotFoundException(id, notAllowed);
+      boolean canRead =
+          permissionBackend
+              .currentUser()
+              .project(project)
+              .ref(ref.isSymbolic() ? ref.getTarget().getName() : ref.getName())
+              .test(RefPermission.READ);
+      if (canRead) {
+        return new BranchResource(parent.getProjectState(), parent.getUser(), ref);
+      }
+      throw new ResourceNotFoundException(id);
     } catch (RepositoryNotFoundException noRepo) {
       throw new ResourceNotFoundException(id, noRepo);
     }
diff --git a/java/com/google/gerrit/server/restapi/project/CheckAccess.java b/java/com/google/gerrit/server/restapi/project/CheckAccess.java
index 5c2f932..160dbdc 100644
--- a/java/com/google/gerrit/server/restapi/project/CheckAccess.java
+++ b/java/com/google/gerrit/server/restapi/project/CheckAccess.java
@@ -98,7 +98,6 @@
                 HttpServletResponse.SC_FORBIDDEN,
                 String.format("user %s cannot see project %s", match, rsrc.getName())));
       }
-
       RefPermission refPerm;
       if (!Strings.isNullOrEmpty(input.permission)) {
         if (Strings.isNullOrEmpty(input.ref)) {
diff --git a/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java b/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java
index 8a0cc39..977bfdb 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java
@@ -113,8 +113,10 @@
         .checkStatePermitsWrite();
 
     MetaDataUpdate.User metaDataUpdateUser = metaDataUpdateFactory.get();
-    ImmutableList<AccessSection> removals = setAccess.getAccessSections(input.remove);
-    ImmutableList<AccessSection> additions = setAccess.getAccessSections(input.add);
+    ImmutableList<AccessSection> removals =
+        setAccess.getAccessSections(input.remove, /* rejectNonResolvableGroups= */ false);
+    ImmutableList<AccessSection> additions =
+        setAccess.getAccessSections(input.add, /* rejectNonResolvableGroups= */ true);
 
     Project.NameKey newParentProjectName =
         input.parent == null ? null : Project.nameKey(input.parent);
diff --git a/java/com/google/gerrit/server/restapi/project/CreateBranch.java b/java/com/google/gerrit/server/restapi/project/CreateBranch.java
index 018ed86..c39b1f4 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateBranch.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateBranch.java
@@ -29,6 +29,7 @@
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestCollectionCreateView;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.git.LockFailureException;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
@@ -85,8 +86,9 @@
 
   @Override
   public Response<BranchInfo> apply(ProjectResource rsrc, IdString id, BranchInput input)
-      throws BadRequestException, AuthException, ResourceConflictException, IOException,
-          PermissionBackendException, NoSuchProjectException {
+      throws BadRequestException, AuthException, ResourceConflictException,
+          UnprocessableEntityException, IOException, PermissionBackendException,
+          NoSuchProjectException {
     String ref = id.get();
     if (input == null) {
       input = new BranchInput();
@@ -122,7 +124,7 @@
 
     BranchNameKey name = BranchNameKey.create(rsrc.getNameKey(), ref);
     try (Repository repo = repoManager.openRepository(rsrc.getNameKey())) {
-      ObjectId revid = RefUtil.parseBaseRevision(repo, rsrc.getNameKey(), input.revision);
+      ObjectId revid = RefUtil.parseBaseRevision(repo, input.revision);
       RevWalk rw = RefUtil.verifyConnected(repo, revid);
       RevObject object = rw.parseAny(revid);
 
@@ -211,8 +213,6 @@
                 : null;
       }
       return Response.created(info);
-    } catch (RefUtil.InvalidRevisionException e) {
-      throw new BadRequestException("invalid revision \"" + input.revision + "\"", e);
     }
   }
 
diff --git a/java/com/google/gerrit/server/restapi/project/CreateLabel.java b/java/com/google/gerrit/server/restapi/project/CreateLabel.java
index 01686ff..ad32f4f 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateLabel.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateLabel.java
@@ -173,10 +173,6 @@
       labelType.setCanOverride(input.canOverride);
     }
 
-    if (input.copyAnyScore != null) {
-      labelType.setCopyAnyScore(input.copyAnyScore);
-    }
-
     if (input.copyCondition != null) {
       try {
         approvalQueryBuilder.parse(input.copyCondition);
@@ -194,40 +190,6 @@
       labelType.setCopyCondition(null);
     }
 
-    if (input.copyMinScore != null) {
-      labelType.setCopyMinScore(input.copyMinScore);
-    }
-
-    if (input.copyMaxScore != null) {
-      labelType.setCopyMaxScore(input.copyMaxScore);
-    }
-
-    if (input.copyAllScoresIfListOfFilesDidNotChange != null) {
-      labelType.setCopyAllScoresIfListOfFilesDidNotChange(
-          input.copyAllScoresIfListOfFilesDidNotChange);
-    }
-
-    if (input.copyAllScoresIfNoChange != null) {
-      labelType.setCopyAllScoresIfNoChange(input.copyAllScoresIfNoChange);
-    }
-
-    if (input.copyAllScoresIfNoCodeChange != null) {
-      labelType.setCopyAllScoresIfNoCodeChange(input.copyAllScoresIfNoCodeChange);
-    }
-
-    if (input.copyAllScoresOnTrivialRebase != null) {
-      labelType.setCopyAllScoresOnTrivialRebase(input.copyAllScoresOnTrivialRebase);
-    }
-
-    if (input.copyAllScoresOnMergeFirstParentUpdate != null) {
-      labelType.setCopyAllScoresOnMergeFirstParentUpdate(
-          input.copyAllScoresOnMergeFirstParentUpdate);
-    }
-
-    if (input.copyValues != null) {
-      labelType.setCopyValues(input.copyValues);
-    }
-
     if (input.allowPostSubmit != null) {
       labelType.setAllowPostSubmit(input.allowPostSubmit);
     }
diff --git a/java/com/google/gerrit/server/restapi/project/CreateSubmitRequirement.java b/java/com/google/gerrit/server/restapi/project/CreateSubmitRequirement.java
new file mode 100644
index 0000000..2aeba89
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/CreateSubmitRequirement.java
@@ -0,0 +1,168 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementExpression;
+import com.google.gerrit.extensions.common.SubmitRequirementInfo;
+import com.google.gerrit.extensions.common.SubmitRequirementInput;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestCollectionCreateView;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.git.meta.MetaDataUpdate;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.ProjectPermission;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectConfig;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.gerrit.server.project.SubmitRequirementExpressionsValidator;
+import com.google.gerrit.server.project.SubmitRequirementJson;
+import com.google.gerrit.server.project.SubmitRequirementResource;
+import com.google.gerrit.server.project.SubmitRequirementsUtil;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.List;
+import java.util.Optional;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+/** A rest create view that creates a "submit requirement" for a project. */
+@Singleton
+public class CreateSubmitRequirement
+    implements RestCollectionCreateView<
+        ProjectResource, SubmitRequirementResource, SubmitRequirementInput> {
+  private final Provider<CurrentUser> user;
+  private final PermissionBackend permissionBackend;
+  private final MetaDataUpdate.User updateFactory;
+  private final ProjectConfig.Factory projectConfigFactory;
+  private final ProjectCache projectCache;
+  private final SubmitRequirementExpressionsValidator submitRequirementExpressionsValidator;
+
+  @Inject
+  public CreateSubmitRequirement(
+      Provider<CurrentUser> user,
+      PermissionBackend permissionBackend,
+      MetaDataUpdate.User updateFactory,
+      ProjectConfig.Factory projectConfigFactory,
+      ProjectCache projectCache,
+      SubmitRequirementExpressionsValidator submitRequirementExpressionsValidator) {
+    this.user = user;
+    this.permissionBackend = permissionBackend;
+    this.updateFactory = updateFactory;
+    this.projectConfigFactory = projectConfigFactory;
+    this.projectCache = projectCache;
+    this.submitRequirementExpressionsValidator = submitRequirementExpressionsValidator;
+  }
+
+  @Override
+  public Response<SubmitRequirementInfo> apply(
+      ProjectResource rsrc, IdString id, SubmitRequirementInput input)
+      throws AuthException, BadRequestException, IOException, PermissionBackendException {
+    if (!user.get().isIdentifiedUser()) {
+      throw new AuthException("Authentication required");
+    }
+
+    permissionBackend
+        .currentUser()
+        .project(rsrc.getNameKey())
+        .check(ProjectPermission.WRITE_CONFIG);
+
+    if (input == null) {
+      input = new SubmitRequirementInput();
+    }
+
+    if (input.name != null && !input.name.equals(id.get())) {
+      throw new BadRequestException("name in input must match name in URL");
+    }
+
+    try (MetaDataUpdate md = updateFactory.create(rsrc.getNameKey())) {
+      ProjectConfig config = projectConfigFactory.read(md);
+
+      SubmitRequirement submitRequirement = createSubmitRequirement(config, id.get(), input);
+
+      md.setMessage(String.format("Create Submit Requirement %s", submitRequirement.name()));
+      config.commit(md);
+
+      projectCache.evict(rsrc.getProjectState().getProject().getNameKey());
+
+      return Response.created(SubmitRequirementJson.format(submitRequirement));
+    } catch (ConfigInvalidException e) {
+      throw new IOException("Failed to read project config", e);
+    } catch (ResourceConflictException e) {
+      throw new BadRequestException("Failed to create submit requirement", e);
+    }
+  }
+
+  public SubmitRequirement createSubmitRequirement(
+      ProjectConfig config, String name, SubmitRequirementInput input)
+      throws BadRequestException, ResourceConflictException {
+    validateSRName(name);
+    ensureSRUnique(name, config);
+    if (Strings.isNullOrEmpty(input.submittabilityExpression)) {
+      throw new BadRequestException("submittability_expression is required");
+    }
+    if (input.allowOverrideInChildProjects == null) {
+      // default is false
+      input.allowOverrideInChildProjects = false;
+    }
+    SubmitRequirement submitRequirement =
+        SubmitRequirement.builder()
+            .setName(name)
+            .setDescription(Optional.ofNullable(input.description))
+            .setApplicabilityExpression(
+                SubmitRequirementExpression.of(input.applicabilityExpression))
+            .setSubmittabilityExpression(
+                SubmitRequirementExpression.create(input.submittabilityExpression))
+            .setOverrideExpression(SubmitRequirementExpression.of(input.overrideExpression))
+            .setAllowOverrideInChildProjects(input.allowOverrideInChildProjects)
+            .build();
+
+    List<String> validationMessages =
+        submitRequirementExpressionsValidator.validateExpressions(submitRequirement);
+    if (!validationMessages.isEmpty()) {
+      throw new BadRequestException(
+          String.format("Invalid submit requirement input: %s", validationMessages));
+    }
+
+    config.upsertSubmitRequirement(submitRequirement);
+    return submitRequirement;
+  }
+
+  private void validateSRName(String name) throws BadRequestException {
+    try {
+      SubmitRequirementsUtil.validateName(name);
+    } catch (IllegalArgumentException e) {
+      throw new BadRequestException(e.getMessage(), e);
+    }
+  }
+
+  private void ensureSRUnique(String name, ProjectConfig config) throws ResourceConflictException {
+    for (String srName : config.getSubmitRequirementSections().keySet()) {
+      if (srName.equalsIgnoreCase(name)) {
+        throw new ResourceConflictException(
+            String.format(
+                "submit requirement \"%s\" conflicts with existing submit requirement \"%s\"",
+                name, srName));
+      }
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/CreateTag.java b/java/com/google/gerrit/server/restapi/project/CreateTag.java
index 6980006..63734bb 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateTag.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateTag.java
@@ -38,13 +38,12 @@
 import com.google.gerrit.server.project.NoSuchProjectException;
 import com.google.gerrit.server.project.ProjectResource;
 import com.google.gerrit.server.project.RefUtil;
-import com.google.gerrit.server.project.RefUtil.InvalidRevisionException;
 import com.google.gerrit.server.project.TagResource;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.util.TimeZone;
+import java.time.ZoneId;
 import org.eclipse.jgit.api.Git;
 import org.eclipse.jgit.api.TagCommand;
 import org.eclipse.jgit.api.errors.GitAPIException;
@@ -100,7 +99,7 @@
         permissionBackend.currentUser().project(resource.getNameKey()).ref(ref);
 
     try (Repository repo = repoManager.openRepository(resource.getNameKey())) {
-      ObjectId revid = RefUtil.parseBaseRevision(repo, resource.getNameKey(), input.revision);
+      ObjectId revid = RefUtil.parseBaseRevision(repo, input.revision);
       RevWalk rw = RefUtil.verifyConnected(repo, revid);
       // Reachability through tags does not influence a commit's visibility, so no need to check for
       // visibility.
@@ -136,7 +135,7 @@
                   resource
                       .getUser()
                       .asIdentifiedUser()
-                      .newCommitterIdent(TimeUtil.now(), TimeZone.getDefault()));
+                      .newCommitterIdent(TimeUtil.now(), ZoneId.systemDefault()));
         }
 
         Ref result = tag.call();
@@ -153,8 +152,6 @@
               ListTags.createTagInfo(perm, result, w, resource.getProjectState(), links));
         }
       }
-    } catch (InvalidRevisionException e) {
-      throw new BadRequestException("Invalid base revision", e);
     } catch (GitAPIException e) {
       logger.atSevere().withCause(e).log("Cannot create tag \"%s\"", ref);
       throw new IOException(e);
diff --git a/java/com/google/gerrit/server/restapi/project/DeleteSubmitRequirement.java b/java/com/google/gerrit/server/restapi/project/DeleteSubmitRequirement.java
new file mode 100644
index 0000000..1be4a5f
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/DeleteSubmitRequirement.java
@@ -0,0 +1,102 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+import com.google.gerrit.extensions.common.Input;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.git.meta.MetaDataUpdate;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.ProjectPermission;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectConfig;
+import com.google.gerrit.server.project.SubmitRequirementResource;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+@Singleton
+public class DeleteSubmitRequirement implements RestModifyView<SubmitRequirementResource, Input> {
+  private final Provider<CurrentUser> user;
+  private final PermissionBackend permissionBackend;
+  private final MetaDataUpdate.User updateFactory;
+  private final ProjectConfig.Factory projectConfigFactory;
+  private final ProjectCache projectCache;
+
+  @Inject
+  public DeleteSubmitRequirement(
+      Provider<CurrentUser> user,
+      PermissionBackend permissionBackend,
+      MetaDataUpdate.User updateFactory,
+      ProjectConfig.Factory projectConfigFactory,
+      ProjectCache projectCache) {
+    this.user = user;
+    this.permissionBackend = permissionBackend;
+    this.updateFactory = updateFactory;
+    this.projectConfigFactory = projectConfigFactory;
+    this.projectCache = projectCache;
+  }
+
+  @Override
+  public Response<?> apply(SubmitRequirementResource rsrc, Input input) throws Exception {
+    if (!user.get().isIdentifiedUser()) {
+      throw new AuthException("Authentication required");
+    }
+
+    permissionBackend
+        .currentUser()
+        .project(rsrc.getProject().getNameKey())
+        .check(ProjectPermission.WRITE_CONFIG);
+
+    try (MetaDataUpdate md = updateFactory.create(rsrc.getProject().getNameKey())) {
+      ProjectConfig config = projectConfigFactory.read(md);
+
+      if (!deleteSubmitRequirement(config, rsrc.getSubmitRequirement().name())) {
+        // This code is unreachable because the exception is thrown when rsrc was parsed
+        throw new ResourceNotFoundException(
+            String.format(
+                "Submit requirement '%s' not found",
+                IdString.fromDecoded(rsrc.getSubmitRequirement().name())));
+      }
+
+      md.setMessage("Delete submit requirement");
+      config.commit(md);
+    }
+
+    projectCache.evict(rsrc.getProject().getProjectState().getProject().getNameKey());
+
+    return Response.none();
+  }
+
+  /**
+   * Delete the given submit requirement from the project config.
+   *
+   * @param config the project config from which the submit-requirement should be deleted
+   * @param srName the name of the submit requirement that should be deleted
+   * @return {@code true} if the submit-requirement was deleted, {@code false} if the
+   *     submit-requirement was not found
+   */
+  public boolean deleteSubmitRequirement(ProjectConfig config, String srName) {
+    if (!config.getSubmitRequirementSections().containsKey(srName)) {
+      return false;
+    }
+    config.getSubmitRequirementSections().remove(srName);
+    return true;
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/GetReflog.java b/java/com/google/gerrit/server/restapi/project/GetReflog.java
index e0131ee..967b3c5 100644
--- a/java/com/google/gerrit/server/restapi/project/GetReflog.java
+++ b/java/com/google/gerrit/server/restapi/project/GetReflog.java
@@ -89,9 +89,6 @@
     this.permissionBackend = permissionBackend;
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   @Override
   public Response<List<ReflogEntryInfo>> apply(BranchResource rsrc)
       throws RestApiException, IOException, PermissionBackendException {
@@ -118,7 +115,7 @@
       } else {
         entries = limit > 0 ? new ArrayList<>(limit) : new ArrayList<>();
         for (ReflogEntry e : r.getReverseEntries()) {
-          Instant timestamp = e.getWho().getWhen().toInstant();
+          Instant timestamp = e.getWho().getWhenAsInstant();
           if ((from == null || from.isBefore(timestamp)) && (to == null || to.isAfter(timestamp))) {
             entries.add(e);
           }
diff --git a/java/com/google/gerrit/server/restapi/project/GetSubmitRequirement.java b/java/com/google/gerrit/server/restapi/project/GetSubmitRequirement.java
new file mode 100644
index 0000000..ce482e3
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/GetSubmitRequirement.java
@@ -0,0 +1,31 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+import com.google.gerrit.extensions.common.SubmitRequirementInfo;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.project.SubmitRequirementJson;
+import com.google.gerrit.server.project.SubmitRequirementResource;
+import com.google.inject.Singleton;
+
+/** A rest read view that retrieves a "submit requirement" for a project by its name. */
+@Singleton
+public class GetSubmitRequirement implements RestReadView<SubmitRequirementResource> {
+  @Override
+  public Response<SubmitRequirementInfo> apply(SubmitRequirementResource rsrc) {
+    return Response.ok(SubmitRequirementJson.format(rsrc.getSubmitRequirement()));
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/ListProjects.java b/java/com/google/gerrit/server/restapi/project/ListProjects.java
index cd68a2f..c0185a7 100644
--- a/java/com/google/gerrit/server/restapi/project/ListProjects.java
+++ b/java/com/google/gerrit/server/restapi/project/ListProjects.java
@@ -421,14 +421,8 @@
     if (all && state != null) {
       throw new BadRequestException("'all' and 'state' may not be used together");
     }
-    if (groupUuid != null) {
-      try {
-        if (!groupControlFactory.controlFor(groupUuid).isVisible()) {
-          return Collections.emptySortedMap();
-        }
-      } catch (NoSuchGroupException ex) {
-        return Collections.emptySortedMap();
-      }
+    if (!isGroupVisible()) {
+      return Collections.emptySortedMap();
     }
 
     int foundIndex = 0;
@@ -554,6 +548,14 @@
     }
   }
 
+  private boolean isGroupVisible() {
+    try {
+      return groupUuid == null || groupControlFactory.controlFor(groupUuid).isVisible();
+    } catch (NoSuchGroupException ex) {
+      return false;
+    }
+  }
+
   private void printProjectBranches(PrintWriter stdout, ProjectInfo info) {
     for (String name : showBranch) {
       String ref = info.branches != null ? info.branches.get(name) : null;
diff --git a/java/com/google/gerrit/server/restapi/project/ListSubmitRequirements.java b/java/com/google/gerrit/server/restapi/project/ListSubmitRequirements.java
new file mode 100644
index 0000000..69e2cd3
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/ListSubmitRequirements.java
@@ -0,0 +1,86 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.extensions.common.SubmitRequirementInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.ProjectPermission;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.project.SubmitRequirementJson;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.util.ArrayList;
+import java.util.List;
+import org.kohsuke.args4j.Option;
+
+/** List submit requirements in a project. */
+public class ListSubmitRequirements implements RestReadView<ProjectResource> {
+  private final Provider<CurrentUser> user;
+  private final PermissionBackend permissionBackend;
+
+  @Inject
+  public ListSubmitRequirements(Provider<CurrentUser> user, PermissionBackend permissionBackend) {
+    this.user = user;
+    this.permissionBackend = permissionBackend;
+  }
+
+  @Option(name = "--inherited", usage = "to include inherited submit requirements")
+  private boolean inherited;
+
+  public ListSubmitRequirements withInherited(boolean inherited) {
+    this.inherited = inherited;
+    return this;
+  }
+
+  @Override
+  public Response<List<SubmitRequirementInfo>> apply(ProjectResource rsrc)
+      throws AuthException, PermissionBackendException {
+    if (!user.get().isIdentifiedUser()) {
+      throw new AuthException("Authentication required");
+    }
+
+    if (inherited) {
+      List<SubmitRequirementInfo> allSubmitRequirements = new ArrayList<>();
+      for (ProjectState projectState : rsrc.getProjectState().treeInOrder()) {
+        try {
+          permissionBackend
+              .currentUser()
+              .project(projectState.getNameKey())
+              .check(ProjectPermission.READ_CONFIG);
+        } catch (AuthException e) {
+          throw new AuthException(projectState.getNameKey() + ": " + e.getMessage(), e);
+        }
+        allSubmitRequirements.addAll(listSubmitRequirements(projectState));
+      }
+      return Response.ok(allSubmitRequirements);
+    }
+
+    permissionBackend.currentUser().project(rsrc.getNameKey()).check(ProjectPermission.READ_CONFIG);
+    return Response.ok(listSubmitRequirements(rsrc.getProjectState()));
+  }
+
+  private ImmutableList<SubmitRequirementInfo> listSubmitRequirements(ProjectState projectState) {
+    return projectState.getConfig().getSubmitRequirementSections().values().stream()
+        .map(SubmitRequirementJson::format)
+        .collect(ImmutableList.toImmutableList());
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/ListTags.java b/java/com/google/gerrit/server/restapi/project/ListTags.java
index eccdcfc..ac0dff9 100644
--- a/java/com/google/gerrit/server/restapi/project/ListTags.java
+++ b/java/com/google/gerrit/server/restapi/project/ListTags.java
@@ -172,9 +172,6 @@
     throw new ResourceNotFoundException(id);
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   public static TagInfo createTagInfo(
       PermissionBackend.ForRef perm, Ref ref, RevWalk rw, ProjectState projectState, WebLinks links)
       throws IOException {
@@ -200,12 +197,12 @@
           tagger != null ? CommonConverters.toGitPerson(tagger) : null,
           canDelete,
           webLinks.isEmpty() ? null : webLinks,
-          tagger != null ? tagger.getWhen().toInstant() : null);
+          tagger != null ? tagger.getWhenAsInstant() : null);
     }
 
     Instant timestamp =
         object instanceof RevCommit
-            ? ((RevCommit) object).getCommitterIdent().getWhen().toInstant()
+            ? ((RevCommit) object).getCommitterIdent().getWhenAsInstant()
             : null;
 
     // Lightweight tag
diff --git a/java/com/google/gerrit/server/restapi/project/ProjectRestApiModule.java b/java/com/google/gerrit/server/restapi/project/ProjectRestApiModule.java
index e50a494..d188bc8 100644
--- a/java/com/google/gerrit/server/restapi/project/ProjectRestApiModule.java
+++ b/java/com/google/gerrit/server/restapi/project/ProjectRestApiModule.java
@@ -21,6 +21,7 @@
 import static com.google.gerrit.server.project.FileResource.FILE_KIND;
 import static com.google.gerrit.server.project.LabelResource.LABEL_KIND;
 import static com.google.gerrit.server.project.ProjectResource.PROJECT_KIND;
+import static com.google.gerrit.server.project.SubmitRequirementResource.SUBMIT_REQUIREMENT_KIND;
 import static com.google.gerrit.server.project.TagResource.TAG_KIND;
 
 import com.google.gerrit.extensions.registration.DynamicMap;
@@ -46,6 +47,7 @@
     DynamicMap.mapOf(binder(), COMMIT_KIND);
     DynamicMap.mapOf(binder(), TAG_KIND);
     DynamicMap.mapOf(binder(), LABEL_KIND);
+    DynamicMap.mapOf(binder(), SUBMIT_REQUIREMENT_KIND);
 
     DynamicSet.bind(binder(), GerritConfigListener.class).to(SetParent.class);
     DynamicSet.bind(binder(), ProjectCreationValidationListener.class)
@@ -78,6 +80,12 @@
     delete(LABEL_KIND).to(DeleteLabel.class);
     postOnCollection(LABEL_KIND).to(PostLabels.class);
 
+    child(PROJECT_KIND, "submit_requirements").to(SubmitRequirementsCollection.class);
+    create(SUBMIT_REQUIREMENT_KIND).to(CreateSubmitRequirement.class);
+    put(SUBMIT_REQUIREMENT_KIND).to(UpdateSubmitRequirement.class);
+    get(SUBMIT_REQUIREMENT_KIND).to(GetSubmitRequirement.class);
+    delete(SUBMIT_REQUIREMENT_KIND).to(DeleteSubmitRequirement.class);
+
     get(PROJECT_KIND, "HEAD").to(GetHead.class);
     put(PROJECT_KIND, "HEAD").to(SetHead.class);
 
diff --git a/java/com/google/gerrit/server/restapi/project/PutConfig.java b/java/com/google/gerrit/server/restapi/project/PutConfig.java
index 30d667c..d4077c8 100644
--- a/java/com/google/gerrit/server/restapi/project/PutConfig.java
+++ b/java/com/google/gerrit/server/restapi/project/PutConfig.java
@@ -18,6 +18,9 @@
 import static com.google.gerrit.server.project.ProjectConfig.KEY_ENABLED;
 import static com.google.gerrit.server.project.ProjectConfig.KEY_LINK;
 import static com.google.gerrit.server.project.ProjectConfig.KEY_MATCH;
+import static com.google.gerrit.server.project.ProjectConfig.KEY_PREFIX;
+import static com.google.gerrit.server.project.ProjectConfig.KEY_SUFFIX;
+import static com.google.gerrit.server.project.ProjectConfig.KEY_TEXT;
 
 import com.google.common.base.Joiner;
 import com.google.common.base.Strings;
@@ -300,6 +303,15 @@
         Config cfg = new Config();
         cfg.setString(COMMENTLINK, name, KEY_MATCH, value.match);
         cfg.setString(COMMENTLINK, name, KEY_LINK, value.link);
+        if (!Strings.isNullOrEmpty(value.prefix)) {
+          cfg.setString(COMMENTLINK, name, KEY_PREFIX, value.prefix);
+        }
+        if (!Strings.isNullOrEmpty(value.suffix)) {
+          cfg.setString(COMMENTLINK, name, KEY_SUFFIX, value.suffix);
+        }
+        if (!Strings.isNullOrEmpty(value.text)) {
+          cfg.setString(COMMENTLINK, name, KEY_TEXT, value.text);
+        }
         cfg.setBoolean(COMMENTLINK, name, KEY_ENABLED, value.enabled == null || value.enabled);
         projectConfig.addCommentLinkSection(ProjectConfig.buildCommentLink(cfg, name, false));
       } else {
diff --git a/java/com/google/gerrit/server/restapi/project/QueryProjects.java b/java/com/google/gerrit/server/restapi/project/QueryProjects.java
index a9d818d..b219085 100644
--- a/java/com/google/gerrit/server/restapi/project/QueryProjects.java
+++ b/java/com/google/gerrit/server/restapi/project/QueryProjects.java
@@ -106,6 +106,10 @@
 
     ProjectQueryProcessor queryProcessor = queryProcessorProvider.get();
 
+    if (start < 0) {
+      throw new BadRequestException("'start' parameter cannot be less than zero");
+    }
+
     if (start != 0) {
       queryProcessor.setStart(start);
     }
diff --git a/java/com/google/gerrit/server/restapi/project/SetAccess.java b/java/com/google/gerrit/server/restapi/project/SetAccess.java
index 07dbeca..23d60fe 100644
--- a/java/com/google/gerrit/server/restapi/project/SetAccess.java
+++ b/java/com/google/gerrit/server/restapi/project/SetAccess.java
@@ -82,8 +82,10 @@
 
     ProjectConfig config;
 
-    List<AccessSection> removals = accessUtil.getAccessSections(input.remove);
-    List<AccessSection> additions = accessUtil.getAccessSections(input.add);
+    List<AccessSection> removals =
+        accessUtil.getAccessSections(input.remove, /* rejectNonResolvableGroups= */ false);
+    List<AccessSection> additions =
+        accessUtil.getAccessSections(input.add, /* rejectNonResolvableGroups= */ true);
     try (MetaDataUpdate md = metaDataUpdateUser.create(rsrc.getNameKey())) {
       config = projectConfigFactory.read(md);
 
diff --git a/java/com/google/gerrit/server/restapi/project/SetAccessUtil.java b/java/com/google/gerrit/server/restapi/project/SetAccessUtil.java
index 205420c..547a214 100644
--- a/java/com/google/gerrit/server/restapi/project/SetAccessUtil.java
+++ b/java/com/google/gerrit/server/restapi/project/SetAccessUtil.java
@@ -18,6 +18,7 @@
 import com.google.common.collect.Iterables;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.entities.AccessSection;
+import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.entities.GroupReference;
 import com.google.gerrit.entities.LabelType;
@@ -65,7 +66,8 @@
     this.pluginPermissionsUtil = pluginPermissionsUtil;
   }
 
-  ImmutableList<AccessSection> getAccessSections(Map<String, AccessSectionInfo> sectionInfos)
+  ImmutableList<AccessSection> getAccessSections(
+      Map<String, AccessSectionInfo> sectionInfos, boolean rejectNonResolvableGroups)
       throws UnprocessableEntityException {
     if (sectionInfos == null) {
       return ImmutableList.of();
@@ -93,13 +95,20 @@
         for (Map.Entry<String, PermissionRuleInfo> permissionRuleInfoEntry :
             permissionEntry.getValue().rules.entrySet()) {
           GroupDescription.Basic group = groupResolver.parseId(permissionRuleInfoEntry.getKey());
-          if (group == null) {
-            throw new UnprocessableEntityException(
-                permissionRuleInfoEntry.getKey() + " is not a valid group ID");
+          GroupReference groupReference;
+          if (group != null) {
+            groupReference = GroupReference.forGroup(group);
+          } else {
+            if (rejectNonResolvableGroups) {
+              throw new UnprocessableEntityException(
+                  permissionRuleInfoEntry.getKey() + " is not a valid group ID");
+            }
+            AccountGroup.UUID uuid = AccountGroup.UUID.parse(permissionRuleInfoEntry.getKey());
+            groupReference = GroupReference.create(uuid, uuid.get());
           }
 
           PermissionRuleInfo pri = permissionRuleInfoEntry.getValue();
-          PermissionRule.Builder r = PermissionRule.builder(GroupReference.forGroup(group));
+          PermissionRule.Builder r = PermissionRule.builder(groupReference);
           if (pri != null) {
             if (pri.max != null) {
               r.setMax(pri.max);
diff --git a/java/com/google/gerrit/server/restapi/project/SetLabel.java b/java/com/google/gerrit/server/restapi/project/SetLabel.java
index 79bb4ee..10589cc 100644
--- a/java/com/google/gerrit/server/restapi/project/SetLabel.java
+++ b/java/com/google/gerrit/server/restapi/project/SetLabel.java
@@ -206,53 +206,6 @@
       dirty = true;
     }
 
-    if (input.copyAnyScore != null) {
-      labelTypeBuilder.setCopyAnyScore(input.copyAnyScore);
-      dirty = true;
-    }
-
-    if (input.copyMinScore != null) {
-      labelTypeBuilder.setCopyMinScore(input.copyMinScore);
-      dirty = true;
-    }
-
-    if (input.copyMaxScore != null) {
-      labelTypeBuilder.setCopyMaxScore(input.copyMaxScore);
-      dirty = true;
-    }
-
-    if (input.copyAllScoresIfListOfFilesDidNotChange != null) {
-      labelTypeBuilder.setCopyAllScoresIfListOfFilesDidNotChange(
-          input.copyAllScoresIfListOfFilesDidNotChange);
-      dirty = true;
-    }
-
-    if (input.copyAllScoresIfNoChange != null) {
-      labelTypeBuilder.setCopyAllScoresIfNoChange(input.copyAllScoresIfNoChange);
-      dirty = true;
-    }
-
-    if (input.copyAllScoresIfNoCodeChange != null) {
-      labelTypeBuilder.setCopyAllScoresIfNoCodeChange(input.copyAllScoresIfNoCodeChange);
-      dirty = true;
-    }
-
-    if (input.copyAllScoresOnTrivialRebase != null) {
-      labelTypeBuilder.setCopyAllScoresOnTrivialRebase(input.copyAllScoresOnTrivialRebase);
-      dirty = true;
-    }
-
-    if (input.copyAllScoresOnMergeFirstParentUpdate != null) {
-      labelTypeBuilder.setCopyAllScoresOnMergeFirstParentUpdate(
-          input.copyAllScoresOnMergeFirstParentUpdate);
-      dirty = true;
-    }
-
-    if (input.copyValues != null) {
-      labelTypeBuilder.setCopyValues(input.copyValues);
-      dirty = true;
-    }
-
     if (input.allowPostSubmit != null) {
       labelTypeBuilder.setAllowPostSubmit(input.allowPostSubmit);
       dirty = true;
diff --git a/java/com/google/gerrit/server/restapi/project/SubmitRequirementsCollection.java b/java/com/google/gerrit/server/restapi/project/SubmitRequirementsCollection.java
new file mode 100644
index 0000000..1388033
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/SubmitRequirementsCollection.java
@@ -0,0 +1,86 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ChildCollection;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.ProjectPermission;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.gerrit.server.project.SubmitRequirementResource;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+@Singleton
+public class SubmitRequirementsCollection
+    implements ChildCollection<ProjectResource, SubmitRequirementResource> {
+  private final Provider<CurrentUser> user;
+  private final PermissionBackend permissionBackend;
+  private final DynamicMap<RestView<SubmitRequirementResource>> views;
+  private final Provider<ListSubmitRequirements> list;
+
+  @Inject
+  SubmitRequirementsCollection(
+      Provider<CurrentUser> user,
+      PermissionBackend permissionBackend,
+      DynamicMap<RestView<SubmitRequirementResource>> views,
+      Provider<ListSubmitRequirements> list) {
+    this.user = user;
+    this.permissionBackend = permissionBackend;
+    this.list = list;
+    this.views = views;
+  }
+
+  @Override
+  public RestView<ProjectResource> list() throws RestApiException {
+    return list.get();
+  }
+
+  @Override
+  public SubmitRequirementResource parse(ProjectResource parent, IdString id)
+      throws AuthException, ResourceNotFoundException, PermissionBackendException {
+    if (!user.get().isIdentifiedUser()) {
+      throw new AuthException("Authentication required");
+    }
+
+    permissionBackend
+        .currentUser()
+        .project(parent.getNameKey())
+        .check(ProjectPermission.READ_CONFIG);
+
+    SubmitRequirement submitRequirement =
+        parent.getProjectState().getConfig().getSubmitRequirementSections().get(id.get());
+
+    if (submitRequirement == null) {
+      throw new ResourceNotFoundException(
+          String.format("Submit requirement '%s' does not exist", id));
+    }
+    return new SubmitRequirementResource(parent, submitRequirement);
+  }
+
+  @Override
+  public DynamicMap<RestView<SubmitRequirementResource>> views() {
+    return views;
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/UpdateSubmitRequirement.java b/java/com/google/gerrit/server/restapi/project/UpdateSubmitRequirement.java
new file mode 100644
index 0000000..bbd617c
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/UpdateSubmitRequirement.java
@@ -0,0 +1,152 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementExpression;
+import com.google.gerrit.extensions.common.SubmitRequirementInfo;
+import com.google.gerrit.extensions.common.SubmitRequirementInput;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.git.meta.MetaDataUpdate;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.ProjectPermission;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectConfig;
+import com.google.gerrit.server.project.SubmitRequirementExpressionsValidator;
+import com.google.gerrit.server.project.SubmitRequirementJson;
+import com.google.gerrit.server.project.SubmitRequirementResource;
+import com.google.gerrit.server.project.SubmitRequirementsUtil;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.List;
+import java.util.Optional;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+/**
+ * A rest modify view that updates the definition of an existing submit requirement for a project.
+ */
+@Singleton
+public class UpdateSubmitRequirement
+    implements RestModifyView<SubmitRequirementResource, SubmitRequirementInput> {
+  private final Provider<CurrentUser> user;
+  private final PermissionBackend permissionBackend;
+  private final MetaDataUpdate.User updateFactory;
+  private final ProjectConfig.Factory projectConfigFactory;
+  private final ProjectCache projectCache;
+  private final SubmitRequirementExpressionsValidator submitRequirementExpressionsValidator;
+
+  @Inject
+  public UpdateSubmitRequirement(
+      Provider<CurrentUser> user,
+      PermissionBackend permissionBackend,
+      MetaDataUpdate.User updateFactory,
+      ProjectConfig.Factory projectConfigFactory,
+      ProjectCache projectCache,
+      SubmitRequirementExpressionsValidator submitRequirementExpressionsValidator) {
+    this.user = user;
+    this.permissionBackend = permissionBackend;
+    this.updateFactory = updateFactory;
+    this.projectConfigFactory = projectConfigFactory;
+    this.projectCache = projectCache;
+    this.submitRequirementExpressionsValidator = submitRequirementExpressionsValidator;
+  }
+
+  @Override
+  public Response<SubmitRequirementInfo> apply(
+      SubmitRequirementResource rsrc, SubmitRequirementInput input)
+      throws AuthException, BadRequestException, PermissionBackendException, IOException {
+    if (!user.get().isIdentifiedUser()) {
+      throw new AuthException("Authentication required");
+    }
+
+    permissionBackend
+        .currentUser()
+        .project(rsrc.getProject().getNameKey())
+        .check(ProjectPermission.WRITE_CONFIG);
+
+    if (input == null) {
+      input = new SubmitRequirementInput();
+    }
+
+    if (input.name != null && !input.name.equals(rsrc.getSubmitRequirement().name())) {
+      throw new BadRequestException("name in input must match name in URL");
+    }
+
+    try (MetaDataUpdate md = updateFactory.create(rsrc.getProject().getNameKey())) {
+      ProjectConfig config = projectConfigFactory.read(md);
+
+      SubmitRequirement submitRequirement =
+          createSubmitRequirement(config, rsrc.getSubmitRequirement().name(), input);
+
+      md.setMessage(String.format("Update Submit Requirement %s", submitRequirement.name()));
+      config.commit(md);
+
+      projectCache.evict(rsrc.getProject().getNameKey());
+
+      return Response.created(SubmitRequirementJson.format(submitRequirement));
+    } catch (ConfigInvalidException e) {
+      throw new IOException("Failed to read project config", e);
+    }
+  }
+
+  public SubmitRequirement createSubmitRequirement(
+      ProjectConfig config, String name, SubmitRequirementInput input) throws BadRequestException {
+    validateSRName(name);
+    if (Strings.isNullOrEmpty(input.submittabilityExpression)) {
+      throw new BadRequestException("submittability_expression is required");
+    }
+    if (input.allowOverrideInChildProjects == null) {
+      // default is false
+      input.allowOverrideInChildProjects = false;
+    }
+    SubmitRequirement submitRequirement =
+        SubmitRequirement.builder()
+            .setName(name)
+            .setDescription(Optional.ofNullable(input.description))
+            .setApplicabilityExpression(
+                SubmitRequirementExpression.of(input.applicabilityExpression))
+            .setSubmittabilityExpression(
+                SubmitRequirementExpression.create(input.submittabilityExpression))
+            .setOverrideExpression(SubmitRequirementExpression.of(input.overrideExpression))
+            .setAllowOverrideInChildProjects(input.allowOverrideInChildProjects)
+            .build();
+
+    List<String> validationMessages =
+        submitRequirementExpressionsValidator.validateExpressions(submitRequirement);
+    if (!validationMessages.isEmpty()) {
+      throw new BadRequestException(
+          String.format("Invalid submit requirement input: %s", validationMessages));
+    }
+
+    config.upsertSubmitRequirement(submitRequirement);
+    return submitRequirement;
+  }
+
+  private void validateSRName(String name) throws BadRequestException {
+    try {
+      SubmitRequirementsUtil.validateName(name);
+    } catch (IllegalArgumentException e) {
+      throw new BadRequestException(e.getMessage(), e);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/schema/AllProjectsInput.java b/java/com/google/gerrit/server/schema/AllProjectsInput.java
index c040347..a079050 100644
--- a/java/com/google/gerrit/server/schema/AllProjectsInput.java
+++ b/java/com/google/gerrit/server/schema/AllProjectsInput.java
@@ -22,6 +22,7 @@
 import com.google.gerrit.entities.GroupReference;
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.LabelValue;
+import com.google.gerrit.extensions.client.ChangeKind;
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.server.notedb.Sequences;
 import java.util.Optional;
@@ -54,8 +55,10 @@
                 LabelValue.create((short) 0, "No score"),
                 LabelValue.create((short) -1, "I would prefer this is not submitted as is"),
                 LabelValue.create((short) -2, "This shall not be submitted")))
-        .setCopyMinScore(true)
-        .setCopyAllScoresOnTrivialRebase(true)
+        .setCopyCondition(
+            String.format(
+                "changekind:%s OR changekind:%s OR is:MIN",
+                ChangeKind.NO_CHANGE, ChangeKind.TRIVIAL_REBASE.name()))
         .build();
   }
 
diff --git a/java/com/google/gerrit/server/schema/MigrateLabelConfigToCopyCondition.java b/java/com/google/gerrit/server/schema/MigrateLabelConfigToCopyCondition.java
new file mode 100644
index 0000000..46a6857
--- /dev/null
+++ b/java/com/google/gerrit/server/schema/MigrateLabelConfigToCopyCondition.java
@@ -0,0 +1,330 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.schema;
+
+import static java.util.stream.Collectors.joining;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Splitter;
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
+import com.google.common.primitives.Shorts;
+import com.google.gerrit.entities.PermissionRule;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.client.ChangeKind;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.meta.MetaDataUpdate;
+import com.google.gerrit.server.project.ProjectConfig;
+import com.google.gerrit.server.project.ProjectLevelConfig;
+import com.google.inject.Inject;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.function.Consumer;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+/**
+ * Migrates all label configurations of a project to copy conditions.
+ *
+ * <p>The label configuration in {@code project.config} controls under which conditions approvals
+ * should be copied to new patch sets:
+ *
+ * <ul>
+ *   <li>old way: by setting boolean flags and copy values (fields {@code copyAnyScore}, {@code
+ *       copyMinScore}, {@code copyMaxScore}, {@code copyAllScoresIfNoChange}, {@code
+ *       copyAllScoresIfNoCodeChange}, {@code copyAllScoresOnMergeFirstParentUpdate}, {@code
+ *       copyAllScoresOnTrivialRebase}, {@code copyAllScoresIfListOfFilesDidNotChange}, {@code
+ *       copyValue})
+ *   <li>new way: by setting a query as a copy condition (field {@code copyCondition})
+ * </ul>
+ *
+ * <p>This class updates all label configurations in the {@code project.config} of the given
+ * project:
+ *
+ * <ul>
+ *   <li>it stores the conditions under which approvals should be copied to new patchs as a copy
+ *       condition query (field {@code copyCondition})
+ *   <li>it unsets all deprecated fields to control approval copying (fields {@code copyAnyScore},
+ *       {@code copyMinScore}, {@code copyMaxScore}, {@code copyAllScoresIfNoChange}, {@code
+ *       copyAllScoresIfNoCodeChange}, {@code copyAllScoresOnMergeFirstParentUpdate}, {@code
+ *       copyAllScoresOnTrivialRebase}, {@code copyAllScoresIfListOfFilesDidNotChange}, {@code
+ *       copyValue})
+ * </ul>
+ *
+ * <p>This migration assumes {@code true} as default value for the {@code copyAllScoresIfNoChange}
+ * flag since this default value was used for all labels that were created before this migration has
+ * been run (for labels that are created after this migration has been run the default value for
+ * this flag has been changed to {@code false}).
+ */
+public class MigrateLabelConfigToCopyCondition {
+  public static final String COMMIT_MESSAGE = "Migrate label configs to copy conditions";
+
+  @VisibleForTesting public static final String KEY_COPY_ANY_SCORE = "copyAnyScore";
+
+  @VisibleForTesting public static final String KEY_COPY_MIN_SCORE = "copyMinScore";
+
+  @VisibleForTesting public static final String KEY_COPY_MAX_SCORE = "copyMaxScore";
+
+  @VisibleForTesting public static final String KEY_COPY_VALUE = "copyValue";
+
+  @VisibleForTesting
+  public static final String KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE =
+      "copyAllScoresOnMergeFirstParentUpdate";
+
+  @VisibleForTesting
+  public static final String KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE = "copyAllScoresOnTrivialRebase";
+
+  @VisibleForTesting
+  public static final String KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE = "copyAllScoresIfNoCodeChange";
+
+  @VisibleForTesting
+  public static final String KEY_COPY_ALL_SCORES_IF_NO_CHANGE = "copyAllScoresIfNoChange";
+
+  @VisibleForTesting
+  public static final String KEY_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE =
+      "copyAllScoresIfListOfFilesDidNotChange";
+
+  private final GitRepositoryManager repoManager;
+  private final PersonIdent serverUser;
+
+  @Inject
+  public MigrateLabelConfigToCopyCondition(
+      GitRepositoryManager repoManager, @GerritPersonIdent PersonIdent serverUser) {
+    this.repoManager = repoManager;
+    this.serverUser = serverUser;
+  }
+
+  /**
+   * Executes the migration for the given project.
+   *
+   * @param projectName the name of the project for which the migration should be executed
+   * @throws IOException thrown if an IO error occurs
+   * @throws ConfigInvalidException thrown if the existing project.config is invalid and cannot be
+   *     parsed
+   */
+  public void execute(Project.NameKey projectName) throws IOException, ConfigInvalidException {
+    ProjectLevelConfig.Bare projectConfig =
+        new ProjectLevelConfig.Bare(ProjectConfig.PROJECT_CONFIG);
+    try (Repository repo = repoManager.openRepository(projectName);
+        MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED, projectName, repo)) {
+      boolean isAlreadyMigrated = hasMigrationAlreadyRun(repo);
+
+      projectConfig.load(projectName, repo);
+
+      Config cfg = projectConfig.getConfig();
+      String orgConfigAsText = cfg.toText();
+      for (String labelName : cfg.getSubsections(ProjectConfig.LABEL)) {
+        String newCopyCondition = computeCopyCondition(isAlreadyMigrated, cfg, labelName);
+        if (!Strings.isNullOrEmpty(newCopyCondition)) {
+          cfg.setString(
+              ProjectConfig.LABEL, labelName, ProjectConfig.KEY_COPY_CONDITION, newCopyCondition);
+        }
+
+        unsetDeprecatedFields(cfg, labelName);
+      }
+
+      if (cfg.toText().equals(orgConfigAsText)) {
+        // Config was not changed (ie. none of the label definitions had any deprecated field set).
+        return;
+      }
+
+      md.getCommitBuilder().setAuthor(serverUser);
+      md.getCommitBuilder().setCommitter(serverUser);
+      md.setMessage(COMMIT_MESSAGE + "\n");
+      projectConfig.commit(md);
+    }
+  }
+
+  private static String computeCopyCondition(
+      boolean isAlreadyMigrated, Config cfg, String labelName) {
+    List<String> copyConditions = new ArrayList<>();
+
+    ifTrue(cfg, labelName, KEY_COPY_ANY_SCORE, () -> copyConditions.add("is:ANY"));
+    ifTrue(cfg, labelName, KEY_COPY_MIN_SCORE, () -> copyConditions.add("is:MIN"));
+    ifTrue(cfg, labelName, KEY_COPY_MAX_SCORE, () -> copyConditions.add("is:MAX"));
+    forEachSkipNullValues(
+        cfg,
+        labelName,
+        KEY_COPY_VALUE,
+        value -> copyConditions.add("is:" + quoteIfNegative(parseCopyValue(value))));
+    ifTrue(
+        cfg,
+        labelName,
+        KEY_COPY_ALL_SCORES_IF_NO_CHANGE,
+        () -> copyConditions.add("changekind:" + ChangeKind.NO_CHANGE.name()));
+
+    // If the migration has already been run on this project we must no longer assume true as
+    // default value when copyAllScoresIfNoChange is unset. This is to ensure that the migration is
+    // idempotent when copyAllScoresIfNoChange is set to false:
+    //
+    // 1. migration run:
+    // Removes the copyAllScoresIfNoChange flag, but doesn't add anything to the copy condition.
+    //
+    // 2. migration run:
+    // Since the copyAllScoresIfNoChange flag is no longer set, we would wrongly assume true now and
+    // wrongly include "changekind:NO_CHANGE" into the copy condition. To prevent this we assume
+    // false as default for copyAllScoresIfNoChange once the migration has been run, so that the 2.
+    // migration run is a no-op.
+    if (!isAlreadyMigrated) {
+      // The default value for copyAllScoresIfNoChange is true, hence if this parameter is not set
+      // we need to include "changekind:NO_CHANGE" into the copy condition.
+      ifUnset(
+          cfg,
+          labelName,
+          KEY_COPY_ALL_SCORES_IF_NO_CHANGE,
+          () -> copyConditions.add("changekind:" + ChangeKind.NO_CHANGE.name()));
+    }
+
+    ifTrue(
+        cfg,
+        labelName,
+        KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE,
+        () -> copyConditions.add("changekind:" + ChangeKind.NO_CODE_CHANGE.name()));
+    ifTrue(
+        cfg,
+        labelName,
+        KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE,
+        () -> copyConditions.add("changekind:" + ChangeKind.MERGE_FIRST_PARENT_UPDATE.name()));
+    ifTrue(
+        cfg,
+        labelName,
+        KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE,
+        () -> copyConditions.add("changekind:" + ChangeKind.TRIVIAL_REBASE.name()));
+    ifTrue(
+        cfg,
+        labelName,
+        KEY_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE,
+        () -> copyConditions.add("has:unchanged-files"));
+
+    if (copyConditions.isEmpty()) {
+      // No copy conditions need to be added. Simply return the current copy condition as it is.
+      // Returning here prevents that OR conditions are reordered and that parentheses are added
+      // unnecessarily.
+      return cfg.getString(ProjectConfig.LABEL, labelName, ProjectConfig.KEY_COPY_CONDITION);
+    }
+
+    ifSet(
+        cfg,
+        labelName,
+        ProjectConfig.KEY_COPY_CONDITION,
+        copyCondition -> copyConditions.addAll(splitOrConditions(copyCondition)));
+
+    return copyConditions.stream()
+        .map(MigrateLabelConfigToCopyCondition::encloseInParenthesesIfNeeded)
+        .sorted()
+        // Remove duplicated OR conditions
+        .distinct()
+        .collect(joining(" OR "));
+  }
+
+  private static void ifSet(Config cfg, String labelName, String key, Consumer<String> consumer) {
+    Optional.ofNullable(cfg.getString(ProjectConfig.LABEL, labelName, key)).ifPresent(consumer);
+  }
+
+  private static void ifUnset(Config cfg, String labelName, String key, Runnable runnable) {
+    Optional<String> value =
+        Optional.ofNullable(cfg.getString(ProjectConfig.LABEL, labelName, key));
+    if (!value.isPresent()) {
+      runnable.run();
+    }
+  }
+
+  private static void ifTrue(Config cfg, String labelName, String key, Runnable runnable) {
+    if (cfg.getBoolean(ProjectConfig.LABEL, labelName, key, /* defaultValue= */ false)) {
+      runnable.run();
+    }
+  }
+
+  private static void forEachSkipNullValues(
+      Config cfg, String labelName, String key, Consumer<String> consumer) {
+    Arrays.stream(cfg.getStringList(ProjectConfig.LABEL, labelName, key))
+        .filter(Objects::nonNull)
+        .forEach(consumer);
+  }
+
+  private static void unsetDeprecatedFields(Config cfg, String labelName) {
+    cfg.unset(ProjectConfig.LABEL, labelName, KEY_COPY_ANY_SCORE);
+    cfg.unset(ProjectConfig.LABEL, labelName, KEY_COPY_MIN_SCORE);
+    cfg.unset(ProjectConfig.LABEL, labelName, KEY_COPY_MAX_SCORE);
+    cfg.unset(ProjectConfig.LABEL, labelName, KEY_COPY_VALUE);
+    cfg.unset(ProjectConfig.LABEL, labelName, KEY_COPY_ALL_SCORES_IF_NO_CHANGE);
+    cfg.unset(ProjectConfig.LABEL, labelName, KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE);
+    cfg.unset(ProjectConfig.LABEL, labelName, KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE);
+    cfg.unset(ProjectConfig.LABEL, labelName, KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE);
+    cfg.unset(ProjectConfig.LABEL, labelName, KEY_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE);
+  }
+
+  private static ImmutableList<String> splitOrConditions(String copyCondition) {
+    if (copyCondition.contains("(") || copyCondition.contains(")")) {
+      // cannot parse complex predicate tree
+      return ImmutableList.of(copyCondition);
+    }
+
+    // split query on OR, this way we can detect and remove duplicate OR conditions later
+    return ImmutableList.copyOf(Splitter.on(" OR ").splitToList(copyCondition));
+  }
+
+  /**
+   * Add parentheses around the given copyCondition if it consists out of 2 or more predicates and
+   * if it isn't enclosed in parentheses yet.
+   */
+  private static String encloseInParenthesesIfNeeded(String copyCondition) {
+    if (copyCondition.contains(" ")
+        && !(copyCondition.startsWith("(") && copyCondition.endsWith(")"))) {
+      return "(" + copyCondition + ")";
+    }
+    return copyCondition;
+  }
+
+  private static short parseCopyValue(String value) {
+    return Shorts.checkedCast(PermissionRule.parseInt(value));
+  }
+
+  private static String quoteIfNegative(short value) {
+    if (value < 0) {
+      return "\"" + value + "\"";
+    }
+    return Integer.toString(value);
+  }
+
+  public static boolean hasMigrationAlreadyRun(Repository repo) throws IOException {
+    try (RevWalk revWalk = new RevWalk(repo)) {
+      Ref refsMetaConfig = repo.exactRef(RefNames.REFS_CONFIG);
+      if (refsMetaConfig == null) {
+        return false;
+      }
+      revWalk.markStart(revWalk.parseCommit(refsMetaConfig.getObjectId()));
+      RevCommit commit;
+      while ((commit = revWalk.next()) != null) {
+        if (COMMIT_MESSAGE.equals(commit.getShortMessage())) {
+          return true;
+        }
+      }
+      return false;
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/schema/NoteDbSchemaVersions.java b/java/com/google/gerrit/server/schema/NoteDbSchemaVersions.java
index 209ff89..d84ae60 100644
--- a/java/com/google/gerrit/server/schema/NoteDbSchemaVersions.java
+++ b/java/com/google/gerrit/server/schema/NoteDbSchemaVersions.java
@@ -33,7 +33,8 @@
               Schema_181.class,
               Schema_182.class,
               Schema_183.class,
-              Schema_184.class)
+              Schema_184.class,
+              Schema_185.class)
           .collect(toImmutableSortedMap(naturalOrder(), v -> guessVersion(v).get(), v -> v));
 
   public static final int FIRST = ALL.firstKey();
diff --git a/java/com/google/gerrit/server/schema/SchemaModule.java b/java/com/google/gerrit/server/schema/SchemaModule.java
index ff2073d..9593522 100644
--- a/java/com/google/gerrit/server/schema/SchemaModule.java
+++ b/java/com/google/gerrit/server/schema/SchemaModule.java
@@ -16,6 +16,7 @@
 
 import static com.google.inject.Scopes.SINGLETON;
 
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.extensions.config.FactoryModule;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.GerritPersonIdentProvider;
@@ -25,9 +26,12 @@
 import com.google.gerrit.server.config.AllUsersNameProvider;
 import com.google.gerrit.server.config.AnonymousCowardName;
 import com.google.gerrit.server.config.AnonymousCowardNameProvider;
+import com.google.gerrit.server.config.GerritImportedServerIds;
+import com.google.gerrit.server.config.GerritImportedServerIdsProvider;
 import com.google.gerrit.server.config.GerritServerId;
 import com.google.gerrit.server.config.GerritServerIdProvider;
 import com.google.gerrit.server.index.group.GroupIndexCollection;
+import com.google.inject.TypeLiteral;
 import org.eclipse.jgit.lib.PersonIdent;
 
 /** Bindings for low-level Gerrit schema data. */
@@ -51,6 +55,11 @@
         .toProvider(GerritServerIdProvider.class)
         .in(SINGLETON);
 
+    bind(new TypeLiteral<ImmutableList<String>>() {})
+        .annotatedWith(GerritImportedServerIds.class)
+        .toProvider(GerritImportedServerIdsProvider.class)
+        .in(SINGLETON);
+
     // It feels wrong to have this binding in a seemingly unrelated module, but it's a dependency of
     // SchemaCreatorImpl, so it's needed.
     // TODO(dborowitz): Is there any way to untangle this?
diff --git a/java/com/google/gerrit/server/schema/Schema_185.java b/java/com/google/gerrit/server/schema/Schema_185.java
new file mode 100644
index 0000000..264914f
--- /dev/null
+++ b/java/com/google/gerrit/server/schema/Schema_185.java
@@ -0,0 +1,122 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.schema;
+
+import com.google.common.base.Stopwatch;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Sets;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.project.ProjectConfig;
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.util.List;
+import java.util.NavigableSet;
+import java.util.Set;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.PersonIdent;
+
+/**
+ * Migrates the label configurations of all projects to copy conditions.
+ *
+ * @see MigrateLabelConfigToCopyCondition
+ */
+public class Schema_185 implements NoteDbSchemaVersion {
+  private AtomicInteger i = new AtomicInteger();
+  private Stopwatch sw = Stopwatch.createStarted();
+  private int size;
+
+  @Override
+  public void upgrade(Arguments args, UpdateUI ui) throws Exception {
+    ui.message("Migrating label configurations");
+
+    NavigableSet<Project.NameKey> projects = args.repoManager.list();
+    size = projects.size();
+
+    Set<List<Project.NameKey>> batches = Sets.newHashSet(Iterables.partition(projects, 50));
+    ExecutorService pool = createExecutor(ui);
+    try {
+      batches.stream()
+          .forEach(
+              batch -> {
+                @SuppressWarnings("unused")
+                Future<?> possiblyIgnoredError =
+                    pool.submit(() -> processBatch(args.repoManager, args.serverUser, batch, ui));
+              });
+      pool.shutdown();
+      pool.awaitTermination(Long.MAX_VALUE, TimeUnit.DAYS);
+    } catch (InterruptedException e) {
+      throw new RuntimeException(e);
+    }
+    ui.message(
+        String.format(
+            "... (%.3f s) Migrated label configurations of all %d projects to schema 185",
+            elapsed(), i.get()));
+  }
+
+  private ExecutorService createExecutor(UpdateUI ui) {
+    int threads;
+    try {
+      threads = Integer.parseInt(System.getProperty("threadcount"));
+    } catch (NumberFormatException e) {
+      threads = Runtime.getRuntime().availableProcessors();
+    }
+    ui.message(String.format("... using %d threads ...", threads));
+    return Executors.newFixedThreadPool(threads);
+  }
+
+  private void processBatch(
+      GitRepositoryManager repoManager,
+      PersonIdent serverUser,
+      List<Project.NameKey> batch,
+      UpdateUI ui) {
+    try {
+      for (Project.NameKey project : batch) {
+        try {
+          new MigrateLabelConfigToCopyCondition(repoManager, serverUser).execute(project);
+          int count = i.incrementAndGet();
+          showProgress(ui, count);
+        } catch (ConfigInvalidException e) {
+          ui.message(
+              String.format(
+                  "WARNING: Skipping migration of label configurations for project %s"
+                      + " since its %s file is invalid: %s",
+                  project, ProjectConfig.PROJECT_CONFIG, e.getMessage()));
+        }
+      }
+    } catch (IOException e) {
+      throw new UncheckedIOException(
+          String.format("Failed to migrate batch of projects to schema 185: %s", batch), e);
+    }
+  }
+
+  private double elapsed() {
+    return sw.elapsed(TimeUnit.MILLISECONDS) / 1000d;
+  }
+
+  private void showProgress(UpdateUI ui, int count) {
+    if (count % 100 == 0) {
+      ui.message(
+          String.format(
+              "... (%.3f s) migrated label configurations of %d%% (%d/%d) projects",
+              elapsed(), Math.round(100.0 * count / size), count, size));
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/schema/testing/AllProjectsCreatorTestUtil.java b/java/com/google/gerrit/server/schema/testing/AllProjectsCreatorTestUtil.java
index e7d8337..7243bdf 100644
--- a/java/com/google/gerrit/server/schema/testing/AllProjectsCreatorTestUtil.java
+++ b/java/com/google/gerrit/server/schema/testing/AllProjectsCreatorTestUtil.java
@@ -102,8 +102,7 @@
           "[label \"Code-Review\"]",
           "  function = MaxWithBlock",
           "  defaultValue = 0",
-          "  copyMinScore = true",
-          "  copyAllScoresOnTrivialRebase = true",
+          "  copyCondition = changekind:NO_CHANGE OR changekind:TRIVIAL_REBASE OR is:MIN",
           "  value = -2 This shall not be submitted",
           "  value = -1 I would prefer this is not submitted as is",
           "  value = 0 No score",
diff --git a/java/com/google/gerrit/server/submit/MergeOp.java b/java/com/google/gerrit/server/submit/MergeOp.java
index 58db331..27eb0a4 100644
--- a/java/com/google/gerrit/server/submit/MergeOp.java
+++ b/java/com/google/gerrit/server/submit/MergeOp.java
@@ -16,7 +16,6 @@
 
 import static com.google.common.base.MoreObjects.firstNonNull;
 import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.gerrit.server.project.ProjectCache.illegalState;
 import static java.util.Comparator.comparing;
 import static java.util.Objects.requireNonNull;
 import static java.util.stream.Collectors.toSet;
@@ -72,8 +71,6 @@
 import com.google.gerrit.server.notedb.StoreSubmitRequirementsOp;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.NoSuchProjectException;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.project.SubmitRuleOptions;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
@@ -247,7 +244,6 @@
   private final RetryHelper retryHelper;
   private final ChangeData.Factory changeDataFactory;
   private final StoreSubmitRequirementsOp.Factory storeSubmitRequirementsOpFactory;
-  private final ProjectCache projectCache;
 
   // Changes that were updated by this MergeOp.
   private final Map<Change.Id, Change> updatedChanges;
@@ -282,8 +278,7 @@
       TopicMetrics topicMetrics,
       RetryHelper retryHelper,
       ChangeData.Factory changeDataFactory,
-      StoreSubmitRequirementsOp.Factory storeSubmitRequirementsOpFactory,
-      ProjectCache projectCache) {
+      StoreSubmitRequirementsOp.Factory storeSubmitRequirementsOpFactory) {
     this.cmUtil = cmUtil;
     this.batchUpdateFactory = batchUpdateFactory;
     this.internalUserFactory = internalUserFactory;
@@ -301,7 +296,6 @@
     this.changeDataFactory = changeDataFactory;
     this.updatedChanges = new HashMap<>();
     this.storeSubmitRequirementsOpFactory = storeSubmitRequirementsOpFactory;
-    this.projectCache = projectCache;
   }
 
   @Override
@@ -654,12 +648,12 @@
         Project.NameKey project = entry.getValue().project();
         Change.Id changeId = entry.getKey();
         ChangeData cd = entry.getValue();
-        Collection<SubmitRequirementResult> srResults =
-            cd.submitRequirementsIncludingLegacy().values();
         batchUpdatesByProject
             .get(project)
-            .addOp(changeId, storeSubmitRequirementsOpFactory.create(srResults, cd));
-        crossCheckSubmitRequirementResults(cd, srResults, project);
+            .addOp(
+                changeId,
+                storeSubmitRequirementsOpFactory.create(
+                    cd.submitRequirementsIncludingLegacy().values(), cd));
       }
       try {
         submissionExecutor.setAdditionalBatchUpdateListeners(
@@ -730,7 +724,7 @@
       OpenRepo or = orm.getRepo(branch.project());
       if (toSubmit.containsKey(branch)) {
         BranchBatch submitting = toSubmit.get(branch);
-        logger.atFine().log("adding ops for branch batch %s", submitting);
+        logger.atFine().log("adding ops for branch %s, batch = %s", branch, submitting);
         OpenBranch ob = or.getBranch(branch);
         requireNonNull(
             submitting.submitType(),
@@ -1009,28 +1003,4 @@
         + " projects involved; some projects may have submitted successfully, but others may have"
         + " failed";
   }
-
-  /**
-   * Make sure that for every project config submit requirement there exists a corresponding result
-   * with the same name in {@code srResults}. If no result is found, log a warning message.
-   */
-  private void crossCheckSubmitRequirementResults(
-      ChangeData cd, Collection<SubmitRequirementResult> srResults, Project.NameKey project) {
-    ProjectState state = projectCache.get(project).orElseThrow(illegalState(project));
-    Map<String, SubmitRequirement> projectConfigRequirements = state.getSubmitRequirements();
-    for (String srName : projectConfigRequirements.keySet()) {
-      boolean hasResult = false;
-      for (SubmitRequirementResult srResult : srResults) {
-        if (!srResult.isLegacy() && srResult.submitRequirement().name().equals(srName)) {
-          hasResult = true;
-          break;
-        }
-      }
-      if (!hasResult) {
-        logger.atWarning().log(
-            "Change %d: No result found for project config submit requirement '%s'",
-            cd.getId().get(), srName);
-      }
-    }
-  }
 }
diff --git a/java/com/google/gerrit/server/submit/SubmitDryRun.java b/java/com/google/gerrit/server/submit/SubmitDryRun.java
index cee0ad9..a3bb58b 100644
--- a/java/com/google/gerrit/server/submit/SubmitDryRun.java
+++ b/java/com/google/gerrit/server/submit/SubmitDryRun.java
@@ -28,6 +28,7 @@
 import com.google.gerrit.server.git.CodeReviewCommit;
 import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
 import com.google.gerrit.server.git.MergeUtil;
+import com.google.gerrit.server.git.MergeUtilFactory;
 import com.google.gerrit.server.project.NoSuchProjectException;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
@@ -96,13 +97,13 @@
   }
 
   private final ProjectCache projectCache;
-  private final MergeUtil.Factory mergeUtilFactory;
+  private final MergeUtilFactory mergeUtilFactory;
   private final Provider<InternalChangeQuery> queryProvider;
 
   @Inject
   SubmitDryRun(
       ProjectCache projectCache,
-      MergeUtil.Factory mergeUtilFactory,
+      MergeUtilFactory mergeUtilFactory,
       Provider<InternalChangeQuery> queryProvider) {
     this.projectCache = projectCache;
     this.mergeUtilFactory = mergeUtilFactory;
diff --git a/java/com/google/gerrit/server/submit/SubmitStrategy.java b/java/com/google/gerrit/server/submit/SubmitStrategy.java
index f593db4..cd91c55 100644
--- a/java/com/google/gerrit/server/submit/SubmitStrategy.java
+++ b/java/com/google/gerrit/server/submit/SubmitStrategy.java
@@ -44,6 +44,7 @@
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.MergeTip;
 import com.google.gerrit.server.git.MergeUtil;
+import com.google.gerrit.server.git.MergeUtilFactory;
 import com.google.gerrit.server.git.TagCache;
 import com.google.gerrit.server.git.validators.OnSubmitValidators;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
@@ -152,7 +153,7 @@
         EmailMerge.Factory mergedSenderFactory,
         GitRepositoryManager repoManager,
         LabelNormalizer labelNormalizer,
-        MergeUtil.Factory mergeUtilFactory,
+        MergeUtilFactory mergeUtilFactory,
         PatchSetInfoFactory patchSetInfoFactory,
         PatchSetUtil psUtil,
         @GerritPersonIdent PersonIdent serverIdent,
diff --git a/java/com/google/gerrit/server/update/BatchUpdate.java b/java/com/google/gerrit/server/update/BatchUpdate.java
index f26882a..a17d015 100644
--- a/java/com/google/gerrit/server/update/BatchUpdate.java
+++ b/java/com/google/gerrit/server/update/BatchUpdate.java
@@ -26,6 +26,7 @@
 import com.google.common.base.Throwables;
 import com.google.common.collect.ArrayListMultimap;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.MultimapBuilder;
@@ -34,10 +35,13 @@
 import com.google.common.util.concurrent.Futures;
 import com.google.common.util.concurrent.ListenableFuture;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.ProjectChangeKey;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.config.FactoryModule;
 import com.google.gerrit.extensions.restapi.BadRequestException;
@@ -48,6 +52,7 @@
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.change.NotifyResolver;
+import com.google.gerrit.server.extensions.events.AttentionSetObserver;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.validators.OnSubmitValidators;
@@ -69,6 +74,7 @@
 import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
 import java.time.Instant;
+import java.time.ZoneId;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.HashMap;
@@ -76,7 +82,6 @@
 import java.util.Map;
 import java.util.Objects;
 import java.util.Optional;
-import java.util.TimeZone;
 import java.util.TreeMap;
 import java.util.function.Function;
 import org.eclipse.jgit.lib.BatchRefUpdate;
@@ -148,7 +153,9 @@
         }
         for (ChangesHandle h : changesHandles) {
           h.execute();
-          indexFutures.addAll(h.startIndexFutures());
+          if (h.requiresReindex()) {
+            indexFutures.addAll(h.startIndexFutures());
+          }
         }
         notifyAfterUpdateRefs(listeners);
         notifyAfterUpdateChanges(listeners);
@@ -257,8 +264,8 @@
     }
 
     @Override
-    public TimeZone getTimeZone() {
-      return tz;
+    public ZoneId getZoneId() {
+      return zoneId;
     }
 
     @Override
@@ -354,6 +361,12 @@
     }
 
     @Override
+    public ChangeData getChangeData(Project.NameKey projectName, Change.Id changeId) {
+      return changeDatas.computeIfAbsent(
+          changeId, id -> changeDataFactory.create(projectName, changeId));
+    }
+
+    @Override
     public ChangeData getChangeData(Change change) {
       return changeDatas.computeIfAbsent(change.getId(), id -> changeDataFactory.create(change));
     }
@@ -377,7 +390,7 @@
   private final Project.NameKey project;
   private final CurrentUser user;
   private final Instant when;
-  private final TimeZone tz;
+  private final ZoneId zoneId;
 
   private final ListMultimap<Change.Id, BatchUpdateOp> ops =
       MultimapBuilder.linkedHashKeys().arrayListValues().build();
@@ -387,11 +400,15 @@
 
   private RepoView repoView;
   private BatchRefUpdate batchRefUpdate;
+  private ImmutableListMultimap<ProjectChangeKey, AttentionSetUpdate> attentionSetUpdates;
+
   private boolean executed;
   private OnSubmitValidators onSubmitValidators;
   private PushCertificate pushCert;
   private String refLogMessage;
   private NotifyResolver.Result notify = NotifyResolver.Result.all();
+  // Batch operations doesn't need observer
+  private AttentionSetObserver attentionSetObserver;
 
   @Inject
   BatchUpdate(
@@ -403,6 +420,7 @@
       NoteDbUpdateManager.Factory updateManagerFactory,
       ChangeIndexer indexer,
       GitReferenceUpdated gitRefUpdated,
+      AttentionSetObserver attentionSetObserver,
       @Assisted Project.NameKey project,
       @Assisted CurrentUser user,
       @Assisted Instant when) {
@@ -416,7 +434,8 @@
     this.project = project;
     this.user = user;
     this.when = when;
-    tz = serverIdent.getTimeZone();
+    this.attentionSetObserver = attentionSetObserver;
+    zoneId = serverIdent.getZoneId();
   }
 
   @Override
@@ -589,6 +608,16 @@
     }
   }
 
+  private void fireAttentionSetUpdateEvents(PostUpdateContext ctx) {
+    for (ProjectChangeKey key : attentionSetUpdates.keySet()) {
+      ChangeData change = ctx.getChangeData(key.projectName(), key.changeId());
+      AccountState account = getAccount().orElse(null);
+      for (AttentionSetUpdate update : attentionSetUpdates.get(key)) {
+        attentionSetObserver.fire(change, account, update, ctx.getWhen());
+      }
+    }
+  }
+
   private class ChangesHandle implements AutoCloseable {
     private final NoteDbUpdateManager manager;
     private final boolean dryrun;
@@ -613,6 +642,17 @@
     void execute() throws IOException {
       BatchUpdate.this.batchRefUpdate = manager.execute(dryrun);
       BatchUpdate.this.executed = manager.isExecuted();
+      BatchUpdate.this.attentionSetUpdates = manager.attentionSetUpdates();
+    }
+
+    boolean requiresReindex() {
+      // We do not need to reindex changes if there are no ref updates, or if updated refs
+      // are all draft comment refs (since draft fields are not stored in the change index).
+      BatchRefUpdate bru = BatchUpdate.this.batchRefUpdate;
+      return !(bru == null
+          || bru.getCommands().isEmpty()
+          || bru.getCommands().stream()
+              .allMatch(cmd -> RefNames.isRefsDraftsComments(cmd.getRefName())));
     }
 
     ImmutableList<ListenableFuture<ChangeData>> startIndexFutures() {
@@ -660,7 +700,7 @@
                     repo, repoView.getRevWalk(), repoView.getInserter(), repoView.getCommands()),
             dryrun);
     if (user.isIdentifiedUser()) {
-      handle.manager.setRefLogIdent(user.asIdentifiedUser().newRefLogIdent(when, tz));
+      handle.manager.setRefLogIdent(user.asIdentifiedUser().newRefLogIdent(when, zoneId));
     }
     handle.manager.setRefLogMessage(refLogMessage);
     handle.manager.setPushCertificate(pushCert);
@@ -730,6 +770,10 @@
         op.postUpdate(ctx);
       }
     }
+    try (TraceContext.TraceTimer ignored =
+        TraceContext.newTimer("fireAttentionSetUpdates#postUpdate", Metadata.empty())) {
+      fireAttentionSetUpdateEvents(ctx);
+    }
   }
 
   private static void logDebug(String msg) {
diff --git a/java/com/google/gerrit/server/update/Context.java b/java/com/google/gerrit/server/update/Context.java
index 57ebedd..aa41d90 100644
--- a/java/com/google/gerrit/server/update/Context.java
+++ b/java/com/google/gerrit/server/update/Context.java
@@ -25,7 +25,7 @@
 import com.google.gerrit.server.change.NotifyResolver;
 import java.io.IOException;
 import java.time.Instant;
-import java.util.TimeZone;
+import java.time.ZoneId;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.revwalk.RevWalk;
 
@@ -70,13 +70,13 @@
   Instant getWhen();
 
   /**
-   * Get the time zone in which this update takes place.
+   * Get the time zone ID in which this update takes place.
    *
-   * <p>In the current implementation, this is always the time zone of the server.
+   * <p>In the current implementation, this is always the time zone ID of the server.
    *
-   * @return time zone.
+   * @return zone ID.
    */
-  TimeZone getTimeZone();
+  ZoneId getZoneId();
 
   /**
    * Get the user performing the update.
@@ -162,6 +162,6 @@
    * @return the created committer {@link PersonIdent}
    */
   default PersonIdent newCommitterIdent(IdentifiedUser user) {
-    return user.newCommitterIdent(getWhen(), getTimeZone());
+    return user.newCommitterIdent(getWhen(), getZoneId());
   }
 }
diff --git a/java/com/google/gerrit/server/update/PostUpdateContext.java b/java/com/google/gerrit/server/update/PostUpdateContext.java
index d4d1f62..25af264 100644
--- a/java/com/google/gerrit/server/update/PostUpdateContext.java
+++ b/java/com/google/gerrit/server/update/PostUpdateContext.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.update;
 
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.query.change.ChangeData;
 
@@ -27,9 +28,13 @@
    * an update or because this method has been invoked before, the cached change data instance is
    * returned.
    *
-   * @param change the change for which the change data should be returned
+   * @param changeId the ID of the change for which the change data should be returned
    */
-  ChangeData getChangeData(Change change);
+  ChangeData getChangeData(Project.NameKey projectName, Change.Id changeId);
+
+  default ChangeData getChangeData(Change change) {
+    return getChangeData(change.getProject(), change.getId());
+  }
 
   default ChangeData getChangeData(ChangeNotes changeNotes) {
     return getChangeData(changeNotes.getChange());
diff --git a/java/com/google/gerrit/server/util/AttentionSetEmail.java b/java/com/google/gerrit/server/util/AttentionSetEmail.java
index 48ddd31..1b36139 100644
--- a/java/com/google/gerrit/server/util/AttentionSetEmail.java
+++ b/java/com/google/gerrit/server/util/AttentionSetEmail.java
@@ -18,19 +18,24 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.config.SendEmailExecutor;
 import com.google.gerrit.server.mail.send.AddToAttentionSetSender;
 import com.google.gerrit.server.mail.send.AttentionSetSender;
 import com.google.gerrit.server.mail.send.MessageIdGenerator;
+import com.google.gerrit.server.mail.send.MessageIdGenerator.MessageId;
 import com.google.gerrit.server.mail.send.RemoveFromAttentionSetSender;
 import com.google.gerrit.server.update.Context;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.util.Optional;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Future;
 
-public class AttentionSetEmail implements Runnable, RequestContext {
+public class AttentionSetEmail {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   public interface Factory {
@@ -43,7 +48,6 @@
      * @param ctx context for sending the email.
      * @param change the change that the user was added/removed in.
      * @param reason reason for adding/removing the user.
-     * @param messageId messageId for tracking the email.
      * @param attentionUserId the user added/removed.
      */
     AttentionSetEmail create(
@@ -51,70 +55,117 @@
         Context ctx,
         Change change,
         String reason,
-        MessageIdGenerator.MessageId messageId,
         Account.Id attentionUserId);
   }
 
-  private ExecutorService sendEmailsExecutor;
-  private AccountTemplateUtil accountTemplateUtil;
-  private AttentionSetSender sender;
-  private Context ctx;
-  private Change change;
-  private String reason;
-
-  private MessageIdGenerator.MessageId messageId;
-  private Account.Id attentionUserId;
+  private final ExecutorService sendEmailsExecutor;
+  private final AsyncSender asyncSender;
 
   @Inject
   AttentionSetEmail(
       @SendEmailExecutor ExecutorService executor,
+      ThreadLocalRequestContext requestContext,
+      MessageIdGenerator messageIdGenerator,
       AccountTemplateUtil accountTemplateUtil,
       @Assisted AttentionSetSender sender,
       @Assisted Context ctx,
       @Assisted Change change,
       @Assisted String reason,
-      @Assisted MessageIdGenerator.MessageId messageId,
       @Assisted Account.Id attentionUserId) {
     this.sendEmailsExecutor = executor;
-    this.accountTemplateUtil = accountTemplateUtil;
-    this.sender = sender;
-    this.ctx = ctx;
-    this.change = change;
-    this.reason = reason;
-    this.messageId = messageId;
-    this.attentionUserId = attentionUserId;
+
+    MessageId messageId;
+    try {
+      messageId =
+          messageIdGenerator.fromChangeUpdateAndReason(
+              ctx.getRepoView(), change.currentPatchSetId(), "AttentionSetEmail");
+    } catch (IOException e) {
+      throw new UncheckedIOException(e);
+    }
+
+    this.asyncSender =
+        new AsyncSender(
+            requestContext,
+            ctx.getIdentifiedUser(),
+            sender,
+            messageId,
+            ctx.getNotify(change.getId()),
+            attentionUserId,
+            accountTemplateUtil.replaceTemplates(reason),
+            change.getId());
   }
 
   public void sendAsync() {
     @SuppressWarnings("unused")
-    Future<?> possiblyIgnoredError = sendEmailsExecutor.submit(this);
+    Future<?> possiblyIgnoredError = sendEmailsExecutor.submit(asyncSender);
   }
 
-  @Override
-  public void run() {
-    try {
-      AccountState accountState =
-          ctx.getUser().isIdentifiedUser() ? ctx.getUser().asIdentifiedUser().state() : null;
-      if (accountState != null) {
-        sender.setFrom(accountState.account().id());
-      }
-      sender.setNotify(ctx.getNotify(change.getId()));
-      sender.setAttentionSetUser(attentionUserId);
-      sender.setReason(accountTemplateUtil.replaceTemplates(reason));
-      sender.setMessageId(messageId);
-      sender.send();
-    } catch (Exception e) {
-      logger.atSevere().withCause(e).log("Cannot email update for change %s", change.getId());
+  /**
+   * {@link Runnable} that sends the email asynchonously.
+   *
+   * <p>Only pass objects into this class that are thread-safe (e.g. immutable) so that they can be
+   * safely accessed from the background thread.
+   */
+  private static class AsyncSender implements Runnable, RequestContext {
+    private final ThreadLocalRequestContext requestContext;
+    private final IdentifiedUser user;
+    private final AttentionSetSender sender;
+    private final MessageIdGenerator.MessageId messageId;
+    private final NotifyResolver.Result notify;
+    private final Account.Id attentionUserId;
+    private final String reason;
+    private final Change.Id changeId;
+
+    AsyncSender(
+        ThreadLocalRequestContext requestContext,
+        IdentifiedUser user,
+        AttentionSetSender sender,
+        MessageIdGenerator.MessageId messageId,
+        NotifyResolver.Result notify,
+        Account.Id attentionUserId,
+        String reason,
+        Change.Id changeId) {
+      this.requestContext = requestContext;
+      this.user = user;
+      this.sender = sender;
+      this.messageId = messageId;
+      this.notify = notify;
+      this.attentionUserId = attentionUserId;
+      this.reason = reason;
+      this.changeId = changeId;
     }
-  }
 
-  @Override
-  public String toString() {
-    return "send-email comments";
-  }
+    @Override
+    public void run() {
+      RequestContext old = requestContext.setContext(this);
+      try {
+        Optional<Account.Id> accountId =
+            user.isIdentifiedUser()
+                ? Optional.of(user.asIdentifiedUser().getAccountId())
+                : Optional.empty();
+        if (accountId.isPresent()) {
+          sender.setFrom(accountId.get());
+        }
+        sender.setNotify(notify);
+        sender.setAttentionSetUser(attentionUserId);
+        sender.setReason(reason);
+        sender.setMessageId(messageId);
+        sender.send();
+      } catch (Exception e) {
+        logger.atSevere().withCause(e).log("Cannot email update for change %s", changeId);
+      } finally {
+        requestContext.setContext(old);
+      }
+    }
 
-  @Override
-  public CurrentUser getUser() {
-    return ctx.getUser();
+    @Override
+    public String toString() {
+      return "send-email attention-set-update";
+    }
+
+    @Override
+    public CurrentUser getUser() {
+      return user;
+    }
   }
 }
diff --git a/java/com/google/gerrit/sshd/commands/CheckProjectAccessCommand.java b/java/com/google/gerrit/sshd/commands/CheckProjectAccessCommand.java
new file mode 100644
index 0000000..39f9ef2
--- /dev/null
+++ b/java/com/google/gerrit/sshd/commands/CheckProjectAccessCommand.java
@@ -0,0 +1,116 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.sshd.commands;
+
+import static com.google.gerrit.sshd.CommandMetaData.Mode.MASTER_OR_SLAVE;
+
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.AccountResolver;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.ProjectPermission;
+import com.google.gerrit.sshd.CommandMetaData;
+import com.google.gerrit.sshd.SshCommand;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.io.IOException;
+import java.util.Set;
+import java.util.stream.Collectors;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.kohsuke.args4j.Option;
+
+@CommandMetaData(
+    name = "check-project-access",
+    description = "Check project readability for specified user(s)",
+    runsAt = MASTER_OR_SLAVE)
+public class CheckProjectAccessCommand extends SshCommand {
+  @Inject private AccountResolver accountResolver;
+  @Inject private IdentifiedUser.GenericFactory userFactory;
+  @Inject private PermissionBackend permissionBackend;
+  @Inject private Provider<CurrentUser> userProvider;
+
+  @Option(
+      name = "--project",
+      aliases = {"-p"},
+      metaVar = "PROJECT",
+      required = true,
+      usage = "project name to check")
+  private String projectName;
+
+  @Option(
+      name = "--user",
+      aliases = {"-u"},
+      metaVar = "USER",
+      required = true,
+      usage = "Account identifier used to find the user(s) for which to check access.")
+  private String userName;
+
+  @Override
+  protected void run() throws Failure, ConfigInvalidException, IOException {
+    PermissionBackend.WithUser userPermission = permissionBackend.user(userProvider.get());
+
+    boolean isAdmin = userPermission.testOrFalse(GlobalPermission.ADMINISTRATE_SERVER);
+    boolean canViewAccount = isAdmin || userPermission.testOrFalse(GlobalPermission.VIEW_ACCESS);
+
+    if (!user.hasSameAccountId(userProvider.get()) && !canViewAccount) {
+      throw die("This command requires 'view access' or 'administrate server' capabilities.");
+    }
+
+    try {
+      for (IdentifiedUser user : getUserList(userName)) {
+        stdout.println(
+            String.format(
+                "Username: '%s', Email: '%s', Full Name: '%s', Result: %b\n",
+                user.getLoggableName(),
+                user.getNameEmail()
+                    .substring(
+                        user.getNameEmail().indexOf("<") + 1, user.getNameEmail().indexOf(">")),
+                user.getName(),
+                permissionBackend
+                    .user(user)
+                    .project(Project.nameKey(projectName))
+                    .test(ProjectPermission.READ)));
+      }
+    } catch (ConfigInvalidException
+        | ResourceNotFoundException
+        | IllegalArgumentException
+        | PermissionBackendException e) {
+      throw die(e);
+    }
+  }
+
+  private Set<IdentifiedUser> getUserList(String userName)
+      throws ConfigInvalidException, IOException, ResourceNotFoundException {
+    return getIdList(userName).stream().map(userFactory::create).collect(Collectors.toSet());
+  }
+
+  private Set<Account.Id> getIdList(String userName)
+      throws ConfigInvalidException, IOException, ResourceNotFoundException {
+    Set<Account.Id> idList = accountResolver.resolve(userName).asIdSet();
+    if (idList.isEmpty()) {
+      throw new ResourceNotFoundException(
+          "No accounts found for your query: \""
+              + userName
+              + "\""
+              + " Tip: Try double-escaping spaces, for example: \"--user Last,\\\\ First\"");
+    }
+    return idList;
+  }
+}
diff --git a/java/com/google/gerrit/sshd/commands/DefaultCommandModule.java b/java/com/google/gerrit/sshd/commands/DefaultCommandModule.java
index e7fe22f..42e7c0f 100644
--- a/java/com/google/gerrit/sshd/commands/DefaultCommandModule.java
+++ b/java/com/google/gerrit/sshd/commands/DefaultCommandModule.java
@@ -46,6 +46,7 @@
     command(gerrit).toProvider(new DispatchCommandProvider(gerrit));
     command(gerrit, AproposCommand.class);
     command(gerrit, BanCommitCommand.class);
+    command(gerrit, CheckProjectAccessCommand.class);
     command(gerrit, CloseConnection.class);
     command(gerrit, ConvertRefStorage.class);
     command(gerrit, FlushCaches.class);
diff --git a/java/com/google/gerrit/testing/FakeAccountCache.java b/java/com/google/gerrit/testing/FakeAccountCache.java
index 49a8d71..2c01548 100644
--- a/java/com/google/gerrit/testing/FakeAccountCache.java
+++ b/java/com/google/gerrit/testing/FakeAccountCache.java
@@ -24,6 +24,7 @@
 import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
+import org.eclipse.jgit.lib.ObjectId;
 
 /** Fake implementation of {@link AccountCache} for testing. */
 public class FakeAccountCache implements AccountCache {
@@ -60,6 +61,11 @@
     throw new UnsupportedOperationException();
   }
 
+  @Override
+  public AccountState getFromMetaId(Account.Id accountId, ObjectId metaId) {
+    return get(accountId).get();
+  }
+
   public synchronized void put(Account account) {
     AccountState state = newState(account);
     byId.put(account.id(), state);
diff --git a/java/com/google/gerrit/testing/InMemoryModule.java b/java/com/google/gerrit/testing/InMemoryModule.java
index 49edd4c..3513a74 100644
--- a/java/com/google/gerrit/testing/InMemoryModule.java
+++ b/java/com/google/gerrit/testing/InMemoryModule.java
@@ -19,6 +19,7 @@
 import static com.google.inject.Scopes.SINGLETON;
 
 import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
 import com.google.common.util.concurrent.ListeningExecutorService;
 import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
 import com.google.gerrit.acceptance.testsuite.group.GroupOperationsImpl;
@@ -63,6 +64,8 @@
 import com.google.gerrit.server.config.FileBasedAllProjectsConfigProvider;
 import com.google.gerrit.server.config.FileBasedGlobalPluginConfigProvider;
 import com.google.gerrit.server.config.GerritGlobalModule;
+import com.google.gerrit.server.config.GerritImportedServerIds;
+import com.google.gerrit.server.config.GerritImportedServerIdsProvider;
 import com.google.gerrit.server.config.GerritInstanceIdModule;
 import com.google.gerrit.server.config.GerritInstanceNameModule;
 import com.google.gerrit.server.config.GerritOptions;
@@ -314,6 +317,17 @@
 
   @Provides
   @Singleton
+  @GerritImportedServerIds
+  public ImmutableList<String> createImportedServerIds() {
+    ImmutableList<String> serverIds =
+        ImmutableList.copyOf(
+            cfg.getStringList(
+                GerritServerIdProvider.SECTION, null, GerritImportedServerIdsProvider.KEY));
+    return serverIds;
+  }
+
+  @Provides
+  @Singleton
   @SendEmailExecutor
   public ExecutorService createSendEmailExecutor() {
     return newDirectExecutorService();
diff --git a/java/com/google/gerrit/testing/IndexConfig.java b/java/com/google/gerrit/testing/IndexConfig.java
index 13b346f..45b54ce 100644
--- a/java/com/google/gerrit/testing/IndexConfig.java
+++ b/java/com/google/gerrit/testing/IndexConfig.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.testing;
 
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.server.change.MergeabilityComputationBehavior;
 import org.eclipse.jgit.lib.Config;
 
@@ -31,9 +32,14 @@
     cfg.setString("trackingid", "query-bug", "footer", "Bug:");
     cfg.setString("trackingid", "query-bug", "match", "QUERY\\d{2,8}");
     cfg.setString("trackingid", "query-bug", "system", "querytests");
-    cfg.setString("trackingid", "query-feature", "footer", "Feature");
-    cfg.setString("trackingid", "query-feature", "match", "QUERY\\d{2,8}");
-    cfg.setString("trackingid", "query-feature", "system", "querytests");
+    cfg.setStringList(
+        "trackingid", "query-google", "footer", ImmutableList.of("Issue", "Google-Bug-Id"));
+    cfg.setString(
+        "trackingid",
+        "query-google",
+        "match",
+        "(?:[Bb]ug|[Ii]ssue|b/)[ \\t]*\\r?\\n?[ \\t]*#?(\\d+)");
+    cfg.setString("trackingid", "query-google", "system", "querygo");
     return cfg;
   }
 
diff --git a/java/com/google/gerrit/testing/TestChanges.java b/java/com/google/gerrit/testing/TestChanges.java
index 8bd02b8..4a97bc5 100644
--- a/java/com/google/gerrit/testing/TestChanges.java
+++ b/java/com/google/gerrit/testing/TestChanges.java
@@ -31,7 +31,7 @@
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Injector;
-import java.util.TimeZone;
+import java.time.ZoneId;
 import java.util.concurrent.atomic.AtomicInteger;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.ObjectId;
@@ -109,7 +109,7 @@
     try (Repository repo = repoManager.openRepository(c.getProject());
         TestRepository<Repository> tr = new TestRepository<>(repo)) {
       PersonIdent ident =
-          user.asIdentifiedUser().newCommitterIdent(update.getWhen(), TimeZone.getDefault());
+          user.asIdentifiedUser().newCommitterIdent(update.getWhen(), ZoneId.systemDefault());
       TestRepository<Repository>.CommitBuilder cb =
           tr.commit()
               .author(ident)
diff --git a/java/com/google/gerrit/testing/TestUpdateUI.java b/java/com/google/gerrit/testing/TestUpdateUI.java
index 76671fb..08c9a14 100644
--- a/java/com/google/gerrit/testing/TestUpdateUI.java
+++ b/java/com/google/gerrit/testing/TestUpdateUI.java
@@ -14,12 +14,29 @@
 
 package com.google.gerrit.testing;
 
+import static java.util.stream.Collectors.joining;
+
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.server.schema.UpdateUI;
+import java.util.ArrayList;
+import java.util.List;
 import java.util.Set;
 
 public class TestUpdateUI implements UpdateUI {
+  private final List<String> messages = new ArrayList<>();
+
   @Override
-  public void message(String message) {}
+  public void message(String message) {
+    messages.add(message);
+  }
+
+  public ImmutableList<String> getMessages() {
+    return ImmutableList.copyOf(messages);
+  }
+
+  public String getOutput() {
+    return messages.stream().collect(joining("\n"));
+  }
 
   @Override
   public boolean yesno(boolean defaultValue, String message) {
diff --git a/javatests/com/google/gerrit/acceptance/ProjectResetterTest.java b/javatests/com/google/gerrit/acceptance/ProjectResetterTest.java
index 877ccd5..b6e5b74 100644
--- a/javatests/com/google/gerrit/acceptance/ProjectResetterTest.java
+++ b/javatests/com/google/gerrit/acceptance/ProjectResetterTest.java
@@ -36,7 +36,6 @@
 import com.google.gerrit.testing.InMemoryRepositoryManager;
 import com.google.gerrit.testing.TestTimeUtil;
 import java.io.IOException;
-import java.util.Date;
 import java.util.concurrent.TimeUnit;
 import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.Constants;
@@ -333,13 +332,10 @@
     assertThat(repo.exactRef(ref.getName())).isNull();
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   private ObjectId createCommit(Repository repo) throws IOException {
     try (ObjectInserter oi = repo.newObjectInserter()) {
       PersonIdent ident =
-          new PersonIdent(new PersonIdent("Foo Bar", "foo.bar@baz.com"), Date.from(TimeUtil.now()));
+          new PersonIdent(new PersonIdent("Foo Bar", "foo.bar@baz.com"), TimeUtil.now());
       CommitBuilder cb = new CommitBuilder();
       cb.setTreeId(oi.insert(Constants.OBJ_TREE, new byte[] {}));
       cb.setCommitter(ident);
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
index 78a0eeb..c2b779b 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance.api.accounts;
 
 import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.common.truth.Truth8.assertThat;
@@ -32,7 +33,11 @@
 import static com.google.gerrit.gpg.testing.TestKeys.validKeyWithSecondUserId;
 import static com.google.gerrit.gpg.testing.TestKeys.validKeyWithoutExpiration;
 import static com.google.gerrit.server.StarredChangesUtil.DEFAULT_LABEL;
+import static com.google.gerrit.server.account.AccountProperties.ACCOUNT;
+import static com.google.gerrit.server.account.AccountProperties.ACCOUNT_CONFIG;
 import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_GPGKEY;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_MAILTO;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
@@ -101,6 +106,7 @@
 import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo.ConsistencyProblemInfo;
 import com.google.gerrit.extensions.api.config.ConsistencyCheckInput;
 import com.google.gerrit.extensions.api.config.ConsistencyCheckInput.CheckAccountsInput;
+import com.google.gerrit.extensions.client.ProjectWatchInfo;
 import com.google.gerrit.extensions.common.AccountDetailInfo;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
@@ -158,7 +164,6 @@
 import java.security.KeyPair;
 import java.util.ArrayList;
 import java.util.Arrays;
-import java.util.Date;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.Iterator;
@@ -504,16 +509,16 @@
       assertThat(timestampDiffMs).isAtMost(SECONDS.toMillis(1));
 
       // Check the 'account.config' file.
-      try (TreeWalk tw = TreeWalk.forPath(or, AccountProperties.ACCOUNT_CONFIG, c.getTree())) {
+      try (TreeWalk tw = TreeWalk.forPath(or, ACCOUNT_CONFIG, c.getTree())) {
         if (name != null || status != null) {
           assertThat(tw).isNotNull();
           Config cfg = new Config();
           cfg.fromText(new String(or.open(tw.getObjectId(0), OBJ_BLOB).getBytes(), UTF_8));
           assertThat(cfg)
-              .stringValue(AccountProperties.ACCOUNT, null, AccountProperties.KEY_FULL_NAME)
+              .stringValue(ACCOUNT, null, AccountProperties.KEY_FULL_NAME)
               .isEqualTo(name);
           assertThat(cfg)
-              .stringValue(AccountProperties.ACCOUNT, null, AccountProperties.KEY_STATUS)
+              .stringValue(ACCOUNT, null, AccountProperties.KEY_STATUS)
               .isEqualTo(status);
         } else {
           // No account properties were set, hence an 'account.config' file was not created.
@@ -797,58 +802,6 @@
   }
 
   @Test
-  public void ignoreChange() throws Exception {
-    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
-    try (Registration registration =
-        extensionRegistry.newRegistration().add(accountIndexedCounter)) {
-      TestAccount user2 = accountCreator.user2();
-      accountIndexedCounter.clear();
-
-      PushOneCommit.Result r = createChange();
-
-      ReviewerInput in = new ReviewerInput();
-      in.reviewer = user.email();
-      gApi.changes().id(r.getChangeId()).addReviewer(in);
-
-      in = new ReviewerInput();
-      in.reviewer = user2.email();
-      gApi.changes().id(r.getChangeId()).addReviewer(in);
-
-      requestScopeOperations.setApiUser(user.id());
-      gApi.changes().id(r.getChangeId()).ignore(true);
-
-      sender.clear();
-      requestScopeOperations.setApiUser(admin.id());
-      gApi.changes().id(r.getChangeId()).abandon();
-      List<Message> messages = sender.getMessages();
-      assertThat(messages).hasSize(1);
-      assertThat(messages.get(0).rcpt()).containsExactly(user2.getNameEmail());
-      accountIndexedCounter.assertNoReindex();
-    }
-  }
-
-  @Test
-  public void addReviewerToIgnoredChange() throws Exception {
-    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
-    try (Registration registration =
-        extensionRegistry.newRegistration().add(accountIndexedCounter)) {
-      PushOneCommit.Result r = createChange();
-
-      requestScopeOperations.setApiUser(user.id());
-      gApi.changes().id(r.getChangeId()).ignore(true);
-
-      sender.clear();
-      requestScopeOperations.setApiUser(admin.id());
-
-      ReviewerInput in = new ReviewerInput();
-      in.reviewer = user.email();
-      gApi.changes().id(r.getChangeId()).addReviewer(in);
-      List<Message> messages = sender.getMessages();
-      assertThat(messages).hasSize(0);
-    }
-  }
-
-  @Test
   public void addExistingReviewersUsingPostReview() throws Exception {
     PushOneCommit.Result r = createChange();
 
@@ -2466,9 +2419,6 @@
   }
 
   @Test
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   public void stalenessChecker() throws Exception {
     // Newly created account is not stale.
     AccountInfo accountInfo = gApi.accounts().create(name("foo")).get();
@@ -2482,7 +2432,7 @@
         RevWalk rw = new RevWalk(repo)) {
       RevCommit commit = rw.parseCommit(repo.exactRef(userRef).getObjectId());
 
-      PersonIdent ident = new PersonIdent(serverIdent.get(), Date.from(TimeUtil.now()));
+      PersonIdent ident = new PersonIdent(serverIdent.get(), TimeUtil.now());
       CommitBuilder cb = new CommitBuilder();
       cb.setTreeId(commit.getTree());
       cb.setCommitter(ident);
@@ -2936,6 +2886,24 @@
     assertThat(gApi.accounts().id("secondary").get()._accountId).isEqualTo(foo.id().get());
   }
 
+  @Test
+  public void getAccountFromMetaId() throws RestApiException {
+    AccountState preUpdateState = accountCache.get(admin.id()).get();
+    requestScopeOperations.setApiUser(admin.id());
+    gApi.accounts().self().setStatus("New status");
+
+    AccountState postUpdateStatus = accountCache.get(admin.id()).get();
+    assertThat(postUpdateStatus).isNotEqualTo(preUpdateState);
+    assertThat(
+            accountCache.getFromMetaId(
+                admin.id(), ObjectId.fromString(preUpdateState.account().metaId())))
+        .isEqualTo(preUpdateState);
+    assertThat(
+            accountCache.getFromMetaId(
+                admin.id(), ObjectId.fromString(postUpdateStatus.account().metaId())))
+        .isEqualTo(postUpdateStatus);
+  }
+
   private void createDraft(PushOneCommit.Result r, String path, String message) throws Exception {
     DraftInput in = new DraftInput();
     in.path = path;
@@ -2958,6 +2926,206 @@
     }
   }
 
+  @Test
+  public void projectWatchesUpdate_refsUsersUpdated() throws RestApiException {
+    AccountState preUpdateState = accountCache.get(admin.id()).get();
+    requestScopeOperations.setApiUser(admin.id());
+
+    ProjectWatchInfo projectWatchInfo = new ProjectWatchInfo();
+    projectWatchInfo.project = project.get();
+    projectWatchInfo.notifyAllComments = true;
+    gApi.accounts().self().setWatchedProjects(ImmutableList.of(projectWatchInfo));
+
+    AccountState updatedState1 = accountCache.get(admin.id()).get();
+    assertThat(preUpdateState.account().metaId()).isNotEqualTo(updatedState1.account().metaId());
+
+    gApi.accounts().self().deleteWatchedProjects(ImmutableList.of(projectWatchInfo));
+
+    AccountState updatedState2 = accountCache.get(admin.id()).get();
+    assertThat(updatedState1.account().metaId()).isNotEqualTo(updatedState2.account().metaId());
+  }
+
+  @Test
+  public void updateExternalId_externalIdApiUpdate_refsUsersUpdated() throws Exception {
+    AccountState preUpdateState = accountCache.get(admin.id()).get();
+    requestScopeOperations.setApiUser(admin.id());
+
+    gApi.accounts().self().addEmail(newEmailInput("secondary@google.com"));
+    assertExternalIds(
+        admin.id(),
+        ImmutableSet.of(
+            "mailto:admin@example.com", "username:admin", "mailto:secondary@google.com"));
+
+    AccountState updatedState1 = accountCache.get(admin.id()).get();
+    assertThat(preUpdateState.account().metaId()).isNotEqualTo(updatedState1.account().metaId());
+
+    gApi.accounts().self().deleteExternalIds(ImmutableList.of("mailto:secondary@google.com"));
+
+    AccountState updatedState2 = accountCache.get(admin.id()).get();
+    assertThat(updatedState1.account().metaId()).isNotEqualTo(updatedState2.account().metaId());
+  }
+
+  @Test
+  public void addExternalId_accountUpdate_refsUsersUpdated() throws Exception {
+    AccountState preUpdateState = accountCache.get(admin.id()).get();
+    requestScopeOperations.setApiUser(admin.id());
+
+    ExternalId externalId = externalIdFactory.create("custom", "value", admin.id());
+    accountsUpdateProvider
+        .get()
+        .update("Add External ID", admin.id(), (a, u) -> u.addExternalId(externalId));
+    assertExternalIds(
+        admin.id(), ImmutableSet.of("mailto:admin@example.com", "username:admin", "custom:value"));
+
+    AccountState updatedState = accountCache.get(admin.id()).get();
+    assertThat(preUpdateState.account().metaId()).isNotEqualTo(updatedState.account().metaId());
+  }
+
+  @Test
+  public void deleteExternalId_accountUpdate_refsUsersUpdated() throws Exception {
+    AccountState preUpdateState = accountCache.get(admin.id()).get();
+    requestScopeOperations.setApiUser(admin.id());
+
+    ExternalId externalId = externalIdFactory.create("mailto", "admin@example.com", admin.id());
+    accountsUpdateProvider
+        .get()
+        .update("Remove External ID", admin.id(), (a, u) -> u.deleteExternalId(externalId));
+    assertExternalIds(admin.id(), ImmutableSet.of("username:admin"));
+
+    AccountState updatedState = accountCache.get(admin.id()).get();
+    assertThat(preUpdateState.account().metaId()).isNotEqualTo(updatedState.account().metaId());
+  }
+
+  @Test
+  public void updateExternalId_accountUpdate_refsUsersUpdated() throws Exception {
+    AccountState preUpdateState = accountCache.get(admin.id()).get();
+    requestScopeOperations.setApiUser(admin.id());
+
+    ExternalId externalId =
+        externalIdFactory.createWithEmail(
+            SCHEME_USERNAME, "admin", admin.id(), "secondary@example.com");
+    accountsUpdateProvider
+        .get()
+        .update("Remove External ID", admin.id(), (a, u) -> u.updateExternalId(externalId));
+    assertExternalIds(admin.id(), ImmutableSet.of("mailto:admin@example.com", "username:admin"));
+
+    AccountState updatedState = accountCache.get(admin.id()).get();
+    assertThat(preUpdateState.account().metaId()).isNotEqualTo(updatedState.account().metaId());
+  }
+
+  @Test
+  public void replaceExternalId_accountUpdate_refsUsersUpdated() throws Exception {
+    AccountState preUpdateState = accountCache.get(admin.id()).get();
+    requestScopeOperations.setApiUser(admin.id());
+
+    ExternalId externalId =
+        externalIdFactory.createWithEmail(
+            SCHEME_USERNAME, "admin", admin.id(), "secondary@example.com");
+    accountsUpdateProvider
+        .get()
+        .update(
+            "Remove External ID",
+            admin.id(),
+            (a, u) ->
+                u.replaceExternalId(
+                    externalIds.get(externalIdKeyFactory.create(SCHEME_USERNAME, "admin")).get(),
+                    externalId));
+    assertExternalIds(admin.id(), ImmutableSet.of("mailto:admin@example.com", "username:admin"));
+
+    AccountState updatedState = accountCache.get(admin.id()).get();
+    assertThat(preUpdateState.account().metaId()).isNotEqualTo(updatedState.account().metaId());
+  }
+
+  @Test
+  public void accountUpdate_updateBatch_allUsersExternalIdsUpdated_refsUsersUpdated()
+      throws Exception {
+    AccountState preUpdateAdminState = accountCache.get(admin.id()).get();
+    AccountState preUpdateUserState = accountCache.get(user.id()).get();
+
+    requestScopeOperations.setApiUser(admin.id());
+    ExternalId extId1 =
+        externalIdFactory.createWithEmail("custom", "admin-id", admin.id(), "admin-id@test.com");
+
+    ExternalId extId2 =
+        externalIdFactory.createWithEmail("custom", "user-id", user.id(), "user-id@test.com");
+
+    AccountsUpdate.UpdateArguments ua1 =
+        new AccountsUpdate.UpdateArguments(
+            "Add External ID", admin.id(), (a, u) -> u.addExternalId(extId1));
+    AccountsUpdate.UpdateArguments ua2 =
+        new AccountsUpdate.UpdateArguments(
+            "Add External ID", user.id(), (a, u) -> u.addExternalId(extId2));
+    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(accountIndexedCounter)) {
+      accountsUpdateProvider.get().updateBatch(ImmutableList.of(ua1, ua2));
+    }
+    accountIndexedCounter.assertReindexOf(admin.id(), 1);
+    accountIndexedCounter.assertReindexOf(user.id(), 1);
+
+    assertExternalIds(
+        admin.id(),
+        ImmutableSet.of("mailto:admin@example.com", "username:admin", "custom:admin-id"));
+    assertExternalIds(
+        user.id(), ImmutableSet.of("username:user1", "mailto:user1@example.com", "custom:user-id"));
+    // Assert reindexing has worked on the updated accounts.
+    assertThat(
+            Iterables.getOnlyElement(gApi.accounts().query("admin-id@test.com").get())._accountId)
+        .isEqualTo(admin.id().get());
+    assertThat(Iterables.getOnlyElement(gApi.accounts().query("user-id@test.com").get())._accountId)
+        .isEqualTo(user.id().get());
+    AccountState updatedAdminState = accountCache.get(admin.id()).get();
+    AccountState updatedUserState = accountCache.get(user.id()).get();
+    assertThat(preUpdateAdminState.account().metaId())
+        .isNotEqualTo(updatedAdminState.account().metaId());
+    assertThat(preUpdateUserState.account().metaId())
+        .isNotEqualTo(updatedUserState.account().metaId());
+  }
+
+  @Test
+  public void accountUpdate_updateBatch_someUsersExternalIdsUpdated_refsUsersUpdated()
+      throws Exception {
+    AccountState preUpdateAdminState = accountCache.get(admin.id()).get();
+    AccountState preUpdateUserState = accountCache.get(user.id()).get();
+
+    requestScopeOperations.setApiUser(admin.id());
+    AccountsUpdate.UpdateArguments ua1 =
+        new AccountsUpdate.UpdateArguments(
+            "Update Display Name", admin.id(), (a, u) -> u.setDisplayName("DN"));
+    AccountsUpdate.UpdateArguments ua2 =
+        new AccountsUpdate.UpdateArguments(
+            "Remove external Id",
+            user.id(),
+            (a, u) ->
+                u.deleteExternalId(
+                    externalIdFactory.createWithEmail(
+                        SCHEME_MAILTO, user.email(), user.id(), user.email())));
+    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(accountIndexedCounter)) {
+      accountsUpdateProvider.get().updateBatch(ImmutableList.of(ua1, ua2));
+    }
+    accountIndexedCounter.assertReindexOf(admin.id(), 1);
+    accountIndexedCounter.assertReindexOf(user.id(), 1);
+
+    // Only the version in config of the user with external id update was updated.
+    AccountState updatedAdminState = accountCache.get(admin.id()).get();
+    AccountState updatedUserState = accountCache.get(user.id()).get();
+    assertThat(preUpdateAdminState.account().metaId())
+        .isNotEqualTo(updatedAdminState.account().metaId());
+    assertThat(preUpdateUserState.account().metaId())
+        .isNotEqualTo(updatedUserState.account().metaId());
+  }
+
+  private void assertExternalIds(Account.Id accountId, ImmutableSet<String> extIds)
+      throws Exception {
+    assertThat(
+            gApi.accounts().id(accountId.get()).getExternalIds().stream()
+                .map(e -> e.identity)
+                .collect(toImmutableSet()))
+        .isEqualTo(extIds);
+  }
+
   private static Correspondence<GroupInfo, String> getGroupToNameCorrespondence() {
     return NullAwareCorrespondence.transforming(groupInfo -> groupInfo.name, "has name");
   }
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java
index 08ae9a6..52e2121 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java
@@ -62,6 +62,7 @@
             new MenuItem("Edits", "#/q/has:edit", null),
             new MenuItem("Watched Changes", "#/q/is:watched+is:open", null),
             new MenuItem("Starred Changes", "#/q/is:starred", null),
+            new MenuItem("All Visible Changes", "#/q/is:visible", null),
             new MenuItem("Groups", "#/settings/#Groups", null));
     assertThat(o.changeTable).isEmpty();
 
@@ -83,6 +84,7 @@
     i.legacycidInChangeTable ^= true;
     i.muteCommonPathPrefixes ^= true;
     i.signedOffBy ^= true;
+    i.allowBrowserNotifications ^= false;
     i.diffView = DiffView.UNIFIED_DIFF;
     i.my = new ArrayList<>();
     i.my.add(new MenuItem("name", "url"));
@@ -94,6 +96,7 @@
     assertThat(o.my).containsExactlyElementsIn(i.my);
     assertThat(o.changeTable).containsExactlyElementsIn(i.changeTable);
     assertThat(o.theme).isEqualTo(i.theme);
+    assertThat(o.allowBrowserNotifications).isEqualTo(i.allowBrowserNotifications);
     assertThat(o.disableKeyboardShortcuts).isEqualTo(i.disableKeyboardShortcuts);
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/api/change/BUILD b/javatests/com/google/gerrit/acceptance/api/change/BUILD
index 94fb0dc..306852a 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/BUILD
+++ b/javatests/com/google/gerrit/acceptance/api/change/BUILD
@@ -6,5 +6,8 @@
     labels = [
         "api",
     ],
-    deps = ["//java/com/google/gerrit/server/util/time"],
+    deps = [
+        "//java/com/google/gerrit/server/util/time",
+        "//javatests/com/google/gerrit/acceptance/server/change:util",
+    ],
 ) for f in glob(["*IT.java"])]
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
index c5f0d23..bd156f4 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
@@ -83,7 +83,9 @@
 import com.google.gerrit.acceptance.UseClockStep;
 import com.google.gerrit.acceptance.UseTimezone;
 import com.google.gerrit.acceptance.VerifyNoPiiInChangeNotes;
+import com.google.gerrit.acceptance.api.change.ChangeIT.TestAttentionSetListenerModule.TestAttentionSetListener;
 import com.google.gerrit.acceptance.config.GerritConfig;
+import com.google.gerrit.acceptance.server.change.CommentsUtil;
 import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
 import com.google.gerrit.acceptance.testsuite.change.IndexOperations;
 import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
@@ -95,6 +97,7 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.Address;
+import com.google.gerrit.entities.BooleanProjectConfig;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.LabelFunction;
@@ -147,7 +150,9 @@
 import com.google.gerrit.extensions.common.LabelInfo;
 import com.google.gerrit.extensions.common.RevisionInfo;
 import com.google.gerrit.extensions.common.TrackingIdInfo;
+import com.google.gerrit.extensions.events.AttentionSetListener;
 import com.google.gerrit.extensions.events.WorkInProgressStateChangedListener;
+import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.BinaryResult;
@@ -160,7 +165,6 @@
 import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.index.query.PostFilterPredicate;
 import com.google.gerrit.server.ChangeMessagesUtil;
-import com.google.gerrit.server.StarredChangesUtil;
 import com.google.gerrit.server.change.ChangeMessages;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.testing.TestChangeETagComputation;
@@ -1536,6 +1540,38 @@
   }
 
   @Test
+  public void attentionSetListener_firesOnChange() throws Exception {
+    PushOneCommit.Result r1 = createChange();
+    AttentionSetInput addUser = new AttentionSetInput(user.email(), "some reason");
+    TestAttentionSetListener attentionSetListener = new TestAttentionSetListener();
+
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(attentionSetListener)) {
+
+      gApi.changes().id(r1.getChangeId()).addReviewer(user.email());
+      assertThat(attentionSetListener.firedCount).isEqualTo(1);
+      assertThat(attentionSetListener.lastEvent.usersAdded().size()).isEqualTo(1);
+      attentionSetListener
+          .lastEvent
+          .usersAdded()
+          .forEach(u -> assertThat(u).isEqualTo(user.id().get()));
+
+      gApi.changes().id(r1.getChangeId()).addToAttentionSet(addUser);
+      assertThat(attentionSetListener.firedCount).isEqualTo(1);
+
+      gApi.changes().id(r1.getChangeId()).attention(user.username()).remove(addUser);
+
+      assertThat(attentionSetListener.firedCount).isEqualTo(2);
+      assertThat(attentionSetListener.lastEvent.usersAdded()).isEmpty();
+      assertThat(attentionSetListener.lastEvent.usersRemoved().size()).isEqualTo(1);
+      attentionSetListener
+          .lastEvent
+          .usersRemoved()
+          .forEach(u -> assertThat(u).isEqualTo(user.id().get()));
+    }
+  }
+
+  @Test
   public void rebaseChangeBase() throws Exception {
     PushOneCommit.Result r1 = createChange();
     PushOneCommit.Result r2 = createChange();
@@ -2505,6 +2541,36 @@
   }
 
   @Test
+  public void removeChangeOwnerAsReviewerByDelete() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    // vote on the change so that the change owner becomes a reviewer
+    approve(changeId);
+    assertThat(getReviewers(gApi.changes().id(changeId).get().reviewers.get(REVIEWER)))
+        .containsExactly(admin.id());
+
+    gApi.changes().id(changeId).reviewer(admin.id().toString()).remove();
+    assertThat(getReviewers(gApi.changes().id(changeId).get().reviewers.get(REVIEWER))).isEmpty();
+  }
+
+  @Test
+  public void removeChangeOwnerAsReviewerByPostReview() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    // vote on the change so that the change owner becomes a reviewer
+    approve(changeId);
+    assertThat(getReviewers(gApi.changes().id(changeId).get().reviewers.get(REVIEWER)))
+        .containsExactly(admin.id());
+
+    ReviewInput reviewInput = new ReviewInput();
+    reviewInput.reviewer(admin.id().toString(), ReviewerState.REMOVED, /* confirmed= */ false);
+    gApi.changes().id(changeId).current().review(reviewInput);
+    assertThat(getReviewers(gApi.changes().id(changeId).get().reviewers.get(REVIEWER))).isEmpty();
+  }
+
+  @Test
   public void removeCC() throws Exception {
     PushOneCommit.Result result = createChange();
     String changeId = result.getChangeId();
@@ -2731,6 +2797,27 @@
   }
 
   @Test
+  public void deleteVoteWithReason() throws Exception {
+    PushOneCommit.Result r = createChange();
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
+
+    requestScopeOperations.setApiUser(user.id());
+    recommend(r.getChangeId());
+
+    requestScopeOperations.setApiUser(admin.id());
+    sender.clear();
+    DeleteVoteInput in = new DeleteVoteInput();
+    in.label = LabelId.CODE_REVIEW;
+    in.reason = "Internal conflict resolved";
+    gApi.changes().id(r.getChangeId()).reviewer(user.id().toString()).deleteVote(in);
+    assertThat(Iterables.getLast(gApi.changes().id(r.getChangeId()).messages()).message)
+        .isEqualTo(
+            "Removed Code-Review+1 by User1 <user1@example.com>\n"
+                + "\n"
+                + "Internal conflict resolved\n");
+  }
+
+  @Test
   public void deleteVoteNotifyAccount() throws Exception {
     PushOneCommit.Result r = createChange();
     gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
@@ -4567,118 +4654,41 @@
       ChangeInfo change = info(triplet);
       assertThat(change.starred).isTrue();
       assertThat(change.stars).contains(DEFAULT_LABEL);
-      changeIndexedCounter.assertReindexOf(change);
+      // change was not re-indexed
+      changeIndexedCounter.assertReindexOf(change, 0);
 
       gApi.accounts().self().unstarChange(triplet);
       change = info(triplet);
       assertThat(change.starred).isNull();
       assertThat(change.stars).isNull();
-      changeIndexedCounter.assertReindexOf(change);
+      // change was not re-indexed
+      changeIndexedCounter.assertReindexOf(change, 0);
     }
   }
 
   @Test
-  public void ignore() throws Exception {
-    String email = "user2@example.com";
-    String fullname = "User2";
-    accountOperations
-        .newAccount()
-        .username("user2")
-        .preferredEmail(email)
-        .fullname(fullname)
-        .create();
+  public void createAndDeleteDraftCommentDoesNotTriggerChangeReindex() throws Exception {
+    ChangeIndexedCounter changeIndexedCounter = new ChangeIndexedCounter();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(changeIndexedCounter)) {
+      PushOneCommit.Result r = createChange();
+      String changeId = r.getChangeId();
+      String revId = r.getCommit().getName();
+      String triplet = project.get() + "~master~" + r.getChangeId();
+      changeIndexedCounter.clear();
 
-    PushOneCommit.Result r = createChange();
+      // Create the draft. Change is not re-indexed
+      DraftInput draftInput =
+          CommentsUtil.newDraft("file1", Side.REVISION, /* line= */ 1, "comment 1");
+      CommentInfo draftInfo =
+          gApi.changes().id(changeId).revision(revId).createDraft(draftInput).get();
+      ChangeInfo change = info(triplet);
+      changeIndexedCounter.assertReindexOf(change, 0);
 
-    ReviewerInput in = new ReviewerInput();
-    in.reviewer = user.email();
-    gApi.changes().id(r.getChangeId()).addReviewer(in);
-
-    in = new ReviewerInput();
-    in.reviewer = email;
-    gApi.changes().id(r.getChangeId()).addReviewer(in);
-
-    requestScopeOperations.setApiUser(user.id());
-    gApi.changes().id(r.getChangeId()).ignore(true);
-    assertThat(gApi.changes().id(r.getChangeId()).ignored()).isTrue();
-
-    // New patch set notification is not sent to users ignoring the change
-    sender.clear();
-    requestScopeOperations.setApiUser(admin.id());
-    amendChange(r.getChangeId());
-    List<Message> messages = sender.getMessages();
-    assertThat(messages).hasSize(1);
-    Address address = Address.create(fullname, email);
-    assertThat(messages.get(0).rcpt()).containsExactly(address);
-
-    // Review notification is not sent to users ignoring the change
-    sender.clear();
-    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
-    messages = sender.getMessages();
-    assertThat(messages).hasSize(1);
-    assertThat(messages.get(0).rcpt()).containsExactly(address);
-
-    // Abandoned notification is not sent to users ignoring the change
-    sender.clear();
-    gApi.changes().id(r.getChangeId()).abandon();
-    messages = sender.getMessages();
-    assertThat(messages).hasSize(1);
-    assertThat(messages.get(0).rcpt()).containsExactly(address);
-
-    requestScopeOperations.setApiUser(user.id());
-    gApi.changes().id(r.getChangeId()).ignore(false);
-    assertThat(gApi.changes().id(r.getChangeId()).ignored()).isFalse();
-  }
-
-  @Test
-  public void cannotIgnoreOwnChange() throws Exception {
-    String changeId = createChange().getChangeId();
-
-    BadRequestException thrown =
-        assertThrows(BadRequestException.class, () -> gApi.changes().id(changeId).ignore(true));
-    assertThat(thrown).hasMessageThat().contains("cannot ignore own change");
-  }
-
-  @Test
-  public void cannotIgnoreStarredChange() throws Exception {
-    String changeId = createChange().getChangeId();
-
-    requestScopeOperations.setApiUser(user.id());
-    gApi.accounts().self().starChange(changeId);
-    assertThat(gApi.changes().id(changeId).get().starred).isTrue();
-
-    ResourceConflictException thrown =
-        assertThrows(
-            ResourceConflictException.class, () -> gApi.changes().id(changeId).ignore(true));
-    assertThat(thrown)
-        .hasMessageThat()
-        .contains(
-            "The labels "
-                + StarredChangesUtil.DEFAULT_LABEL
-                + " and "
-                + StarredChangesUtil.IGNORE_LABEL
-                + " are mutually exclusive. Only one of them can be set.");
-  }
-
-  @Test
-  public void cannotStarIgnoredChange() throws Exception {
-    String changeId = createChange().getChangeId();
-
-    requestScopeOperations.setApiUser(user.id());
-    gApi.changes().id(changeId).ignore(true);
-    assertThat(gApi.changes().id(changeId).ignored()).isTrue();
-
-    ResourceConflictException thrown =
-        assertThrows(
-            ResourceConflictException.class, () -> gApi.accounts().self().starChange(changeId));
-    assertThat(thrown)
-        .hasMessageThat()
-        .contains(
-            "The labels "
-                + StarredChangesUtil.DEFAULT_LABEL
-                + " and "
-                + StarredChangesUtil.IGNORE_LABEL
-                + " are mutually exclusive. Only one of them can be set.");
+      // Delete the draft comment. Change is not re-indexed
+      gApi.changes().id(changeId).revision(revId).draft(draftInfo.id).delete();
+      changeIndexedCounter.assertReindexOf(change, 0);
+    }
   }
 
   @Test
@@ -4721,6 +4731,137 @@
     assertThat(gApi.changes().query(changeId).get().get(0).mergeable).isNull();
   }
 
+  @Test
+  public void ccUserThatCannotSeeTheChange() throws Exception {
+    // Create a project that is only visible to admin users.
+    Project.NameKey project = projectOperations.newProject().create();
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/*").group(adminGroupUuid()))
+        .add(block(Permission.READ).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+
+    // Create a change
+    TestRepository<?> adminTestRepo = cloneProject(project, admin);
+    PushOneCommit push = pushFactory.create(admin.newIdent(), adminTestRepo);
+    PushOneCommit.Result r = push.to("refs/for/master");
+    r.assertOkStatus();
+
+    // Check that the change is not visible to user.
+    requestScopeOperations.setApiUser(user.id());
+    assertThrows(ResourceNotFoundException.class, () -> gApi.changes().id(r.getChangeId()).get());
+
+    // Add user as a CC.
+    requestScopeOperations.setApiUser(admin.id());
+    ReviewerInput reviewerInput = new ReviewerInput();
+    reviewerInput.state = CC;
+    reviewerInput.reviewer = user.id().toString();
+    gApi.changes().id(r.getChangeId()).addReviewer(reviewerInput);
+
+    // Check that user was not added as a CC since they cannot see the change. Note,
+    // ChangeInfo#reviewers is a map that also contains CCs (if any are present).
+    assertThat(gApi.changes().id(r.getChangeId()).get().reviewers).isEmpty();
+
+    // Check that the change is still not visible to user.
+    requestScopeOperations.setApiUser(user.id());
+    assertThrows(ResourceNotFoundException.class, () -> gApi.changes().id(r.getChangeId()).get());
+  }
+
+  @Test
+  public void ccNonExistentAccountByEmailThenRemoveByDelete() throws Exception {
+    // Create a project that allows reviewers by email.
+    Project.NameKey project = projectOperations.newProject().create();
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig()
+          .updateProject(
+              b ->
+                  b.setBooleanConfig(
+                      BooleanProjectConfig.ENABLE_REVIEWER_BY_EMAIL, InheritableBoolean.TRUE));
+      u.save();
+    }
+
+    // Create a change
+    TestRepository<?> testRepo = cloneProject(project, admin);
+    PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
+    PushOneCommit.Result r = push.to("refs/for/master");
+    r.assertOkStatus();
+
+    // Add an email as a CC for which no Gerrit account exists.
+    sender.clear();
+    ReviewerInput reviewerInput = new ReviewerInput();
+    reviewerInput.state = CC;
+    reviewerInput.reviewer = "email-without-account@example.com";
+    gApi.changes().id(r.getChangeId()).addReviewer(reviewerInput);
+
+    // Check that the email was added as a CC and an email was sent.
+    AccountInfo ccedAccountInfo =
+        Iterables.getOnlyElement(
+            gApi.changes().id(r.getChangeId()).get().reviewers.get(ReviewerState.CC));
+    assertThat(ccedAccountInfo.email).isEqualTo(reviewerInput.reviewer);
+    assertThat(ccedAccountInfo._accountId).isNull();
+    assertThat(ccedAccountInfo.name).isNull();
+    assertThat(Iterables.getOnlyElement(sender.getMessages()).body())
+        .contains(String.format("%s has uploaded this change for review", admin.fullName()));
+
+    // Remove the CC.
+    sender.clear();
+    gApi.changes().id(r.getChangeId()).reviewer(reviewerInput.reviewer).remove();
+
+    // Check that the email was removed as a CC and an email was sent.
+    assertThat(gApi.changes().id(r.getChangeId()).get().reviewers).isEmpty();
+    assertThat(Iterables.getOnlyElement(sender.getMessages()).body())
+        .contains(String.format("%s has removed %s", admin.fullName(), reviewerInput.reviewer));
+  }
+
+  @Test
+  public void ccNonExistentAccountByEmailThenRemoveByPostReview() throws Exception {
+    // Create a project that allows reviewers by email.
+    Project.NameKey project = projectOperations.newProject().create();
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig()
+          .updateProject(
+              b ->
+                  b.setBooleanConfig(
+                      BooleanProjectConfig.ENABLE_REVIEWER_BY_EMAIL, InheritableBoolean.TRUE));
+      u.save();
+    }
+
+    // Create a change
+    TestRepository<?> testRepo = cloneProject(project, admin);
+    PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
+    PushOneCommit.Result r = push.to("refs/for/master");
+    r.assertOkStatus();
+
+    // Add an email as a CC for which no Gerrit account exists.
+    sender.clear();
+    ReviewerInput reviewerInput = new ReviewerInput();
+    reviewerInput.state = CC;
+    reviewerInput.reviewer = "email-without-account@example.com";
+    gApi.changes().id(r.getChangeId()).addReviewer(reviewerInput);
+
+    // Check that the email was added as a CC and an email was sent.
+    AccountInfo ccedAccountInfo =
+        Iterables.getOnlyElement(
+            gApi.changes().id(r.getChangeId()).get().reviewers.get(ReviewerState.CC));
+    assertThat(ccedAccountInfo.email).isEqualTo(reviewerInput.reviewer);
+    assertThat(ccedAccountInfo._accountId).isNull();
+    assertThat(ccedAccountInfo.name).isNull();
+    assertThat(Iterables.getOnlyElement(sender.getMessages()).body())
+        .contains(String.format("%s has uploaded this change for review", admin.fullName()));
+
+    // Remove the CC.
+    sender.clear();
+    ReviewInput reviewInput = new ReviewInput();
+    reviewInput.reviewer(reviewerInput.reviewer, ReviewerState.REMOVED, /* confirmed= */ false);
+    gApi.changes().id(r.getChangeId()).current().review(reviewInput);
+
+    // Check that the email was removed as a CC and an email was sent.
+    assertThat(gApi.changes().id(r.getChangeId()).get().reviewers).isEmpty();
+    assertThat(Iterables.getOnlyElement(sender.getMessages()).body())
+        .contains(String.format("%s has removed %s", admin.fullName(), reviewerInput.reviewer));
+  }
+
   private PushOneCommit.Result createWorkInProgressChange() throws Exception {
     return pushTo("refs/for/master%wip");
   }
@@ -4745,13 +4886,34 @@
     Boolean wip;
 
     @Override
-    public void onWorkInProgressStateChanged(Event event) {
+    public void onWorkInProgressStateChanged(WorkInProgressStateChangedListener.Event event) {
       this.invoked = true;
       this.wip =
           event.getChange().workInProgress != null ? event.getChange().workInProgress : false;
     }
   }
 
+  public static class TestAttentionSetListenerModule extends AbstractModule {
+    @Override
+    public void configure() {
+      DynamicSet.bind(binder(), AttentionSetListener.class).to(TestAttentionSetListener.class);
+    }
+
+    public static class TestAttentionSetListener implements AttentionSetListener {
+      AttentionSetListener.Event lastEvent;
+      int firedCount;
+
+      @Inject
+      public TestAttentionSetListener() {}
+
+      @Override
+      public void onAttentionSetChanged(AttentionSetListener.Event event) {
+        firedCount++;
+        lastEvent = event;
+      }
+    }
+  }
+
   private void voteLabel(String changeId, String labelName, int score) throws RestApiException {
     gApi.changes().id(changeId).current().review(new ReviewInput().label(labelName, score));
   }
diff --git a/javatests/com/google/gerrit/acceptance/api/change/CopiedApprovalsInChangeMessageIT.java b/javatests/com/google/gerrit/acceptance/api/change/CopiedApprovalsInChangeMessageIT.java
new file mode 100644
index 0000000..f8cf5fd
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/change/CopiedApprovalsInChangeMessageIT.java
@@ -0,0 +1,981 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.api.change;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth8.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.server.project.testing.TestLabels.labelBuilder;
+import static com.google.gerrit.server.project.testing.TestLabels.value;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.RawInputUtil;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.LabelTypes;
+import com.google.gerrit.entities.LabelValue;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.server.approval.ApprovalCopier;
+import com.google.gerrit.server.approval.ApprovalsUtil;
+import com.google.gerrit.server.project.testing.TestLabels;
+import com.google.gerrit.server.util.AccountTemplateUtil;
+import com.google.gerrit.server.util.time.TimeUtil;
+import com.google.inject.Inject;
+import org.junit.Test;
+
+public class CopiedApprovalsInChangeMessageIT extends AbstractDaemonTest {
+  @Inject private ApprovalsUtil approvalsUtil;
+  @Inject private ProjectOperations projectOperations;
+  @Inject private RequestScopeOperations requestScopeOperations;
+
+  @Test
+  public void cannotFormatWithNullApprovalCopierResult() throws Exception {
+    LabelTypes labelTypes = projectCache.get(project).get().getLabelTypes();
+    NullPointerException exception =
+        assertThrows(
+            NullPointerException.class,
+            () ->
+                approvalsUtil.formatApprovalCopierResult(
+                    /* approvalCopierResult= */ null, labelTypes));
+    assertThat(exception).hasMessageThat().isEqualTo("approvalCopierResult");
+  }
+
+  @Test
+  public void cannotFormatWithNullLabelTypes() throws Exception {
+    ApprovalCopier.Result approvalCopierResult =
+        ApprovalCopier.Result.create(
+            /* copiedApprovals= */ ImmutableSet.of(), /* outdatedApprovals= */ ImmutableSet.of());
+    NullPointerException exception =
+        assertThrows(
+            NullPointerException.class,
+            () ->
+                approvalsUtil.formatApprovalCopierResult(
+                    approvalCopierResult, /* labelTypes= */ null));
+    assertThat(exception).hasMessageThat().isEqualTo("labelTypes");
+  }
+
+  @Test
+  public void format_noCopiedApprovals_noOutdatedApprovals() throws Exception {
+    LabelTypes labelTypes = projectCache.get(project).get().getLabelTypes();
+    ApprovalCopier.Result approvalCopierResult =
+        ApprovalCopier.Result.create(
+            /* copiedApprovals= */ ImmutableSet.of(), /* outdatedApprovals= */ ImmutableSet.of());
+    assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
+        .isEmpty();
+  }
+
+  @Test
+  public void formatCopiedApproval_missingLabelType() throws Exception {
+    LabelTypes labelTypes = new LabelTypes(ImmutableList.of());
+    PatchSetApproval patchSetApproval = createPatchSetApproval(admin, "Code-Review", 1);
+    ApprovalCopier.Result approvalCopierResult =
+        ApprovalCopier.Result.create(
+            /* copiedApprovals= */ ImmutableSet.of(patchSetApproval),
+            /* outdatedApprovals= */ ImmutableSet.of());
+    assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
+        .hasValue("Copied Votes:\n* Code-Review+1 (label type is missing)\n");
+  }
+
+  @Test
+  public void formatOutdatedApproval_missingLabelType() throws Exception {
+    LabelTypes labelTypes = new LabelTypes(ImmutableList.of());
+    PatchSetApproval patchSetApproval = createPatchSetApproval(admin, "Code-Review", 1);
+    ApprovalCopier.Result approvalCopierResult =
+        ApprovalCopier.Result.create(
+            /* copiedApprovals= */ ImmutableSet.of(),
+            /* outdatedApprovals= */ ImmutableSet.of(patchSetApproval));
+    assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
+        .hasValue("Outdated Votes:\n* Code-Review+1 (label type is missing)\n");
+  }
+
+  @Test
+  public void formatCopiedApproval_noCopyCondition() throws Exception {
+    LabelTypes labelTypes =
+        new LabelTypes(
+            ImmutableList.of(
+                createLabelType(/* labelName= */ "Code-Review", /* copyCondition= */ null)));
+    PatchSetApproval patchSetApproval = createPatchSetApproval(admin, "Code-Review", 1);
+    ApprovalCopier.Result approvalCopierResult =
+        ApprovalCopier.Result.create(
+            /* copiedApprovals= */ ImmutableSet.of(patchSetApproval),
+            /* outdatedApprovals= */ ImmutableSet.of());
+    assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
+        .hasValue("Copied Votes:\n* Code-Review+1\n");
+  }
+
+  @Test
+  public void formatOutdatedApproval_noCopyCondition() throws Exception {
+    LabelTypes labelTypes =
+        new LabelTypes(
+            ImmutableList.of(
+                createLabelType(/* labelName= */ "Code-Review", /* copyCondition= */ null)));
+    PatchSetApproval patchSetApproval = createPatchSetApproval(admin, "Code-Review", 1);
+    ApprovalCopier.Result approvalCopierResult =
+        ApprovalCopier.Result.create(
+            /* copiedApprovals= */ ImmutableSet.of(),
+            /* outdatedApprovals= */ ImmutableSet.of(patchSetApproval));
+    assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
+        .hasValue("Outdated Votes:\n* Code-Review+1\n");
+  }
+
+  @Test
+  public void formatCopiedApproval_withCopyCondition_noUserInPredicate() throws Exception {
+    LabelTypes labelTypes =
+        new LabelTypes(
+            ImmutableList.of(
+                createLabelType(
+                    /* labelName= */ "Code-Review", /* copyCondition= */ "is:MIN OR is:MAX")));
+    PatchSetApproval patchSetApproval = createPatchSetApproval(admin, "Code-Review", 1);
+    ApprovalCopier.Result approvalCopierResult =
+        ApprovalCopier.Result.create(
+            /* copiedApprovals= */ ImmutableSet.of(patchSetApproval),
+            /* outdatedApprovals= */ ImmutableSet.of());
+    assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
+        .hasValue("Copied Votes:\n* Code-Review+1 (copy condition: \"is:MIN OR is:MAX\")\n");
+  }
+
+  @Test
+  public void formatOutdatedApproval_withCopyCondition_noUserInPredicate() throws Exception {
+    LabelTypes labelTypes =
+        new LabelTypes(
+            ImmutableList.of(
+                createLabelType(
+                    /* labelName= */ "Code-Review", /* copyCondition= */ "is:MIN OR is:MAX")));
+    PatchSetApproval patchSetApproval = createPatchSetApproval(admin, "Code-Review", 1);
+    ApprovalCopier.Result approvalCopierResult =
+        ApprovalCopier.Result.create(
+            /* copiedApprovals= */ ImmutableSet.of(),
+            /* outdatedApprovals= */ ImmutableSet.of(patchSetApproval));
+    assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
+        .hasValue("Outdated Votes:\n* Code-Review+1 (copy condition: \"is:MIN OR is:MAX\")\n");
+  }
+
+  @Test
+  public void formatCopiedApproval_withNonParseableCopyCondition_noUserInPredicate()
+      throws Exception {
+    LabelTypes labelTypes =
+        new LabelTypes(
+            ImmutableList.of(
+                createLabelType(
+                    /* labelName= */ "Code-Review", /* copyCondition= */ "foo bar baz")));
+    PatchSetApproval patchSetApproval = createPatchSetApproval(admin, "Code-Review", 1);
+    ApprovalCopier.Result approvalCopierResult =
+        ApprovalCopier.Result.create(
+            /* copiedApprovals= */ ImmutableSet.of(patchSetApproval),
+            /* outdatedApprovals= */ ImmutableSet.of());
+    assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
+        .hasValue(
+            "Copied Votes:\n* Code-Review+1 (non-parseable copy condition: \"foo bar baz\")\n");
+  }
+
+  @Test
+  public void formatOutdatedApproval_withNonParseableCopyCondition_noUserInPredicate()
+      throws Exception {
+    LabelTypes labelTypes =
+        new LabelTypes(
+            ImmutableList.of(
+                createLabelType(
+                    /* labelName= */ "Code-Review", /* copyCondition= */ "foo bar baz")));
+    PatchSetApproval patchSetApproval = createPatchSetApproval(admin, "Code-Review", 1);
+    ApprovalCopier.Result approvalCopierResult =
+        ApprovalCopier.Result.create(
+            /* copiedApprovals= */ ImmutableSet.of(),
+            /* outdatedApprovals= */ ImmutableSet.of(patchSetApproval));
+    assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
+        .hasValue(
+            "Outdated Votes:\n* Code-Review+1 (non-parseable copy condition: \"foo bar baz\")\n");
+  }
+
+  @Test
+  public void formatCopiedApproval_withCopyCondition_withUserInPredicate() throws Exception {
+    String groupUuid =
+        groupCache.get(AccountGroup.nameKey("Administrators")).get().getGroupUUID().get();
+    LabelTypes labelTypes =
+        new LabelTypes(
+            ImmutableList.of(
+                createLabelType(
+                    /* labelName= */ "Code-Review",
+                    /* copyCondition= */ String.format(
+                        "is:MIN OR (is:MAX approverin:%s)", groupUuid))));
+    PatchSetApproval patchSetApproval = createPatchSetApproval(admin, "Code-Review", 1);
+    ApprovalCopier.Result approvalCopierResult =
+        ApprovalCopier.Result.create(
+            /* copiedApprovals= */ ImmutableSet.of(patchSetApproval),
+            /* outdatedApprovals= */ ImmutableSet.of());
+    assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
+        .hasValue(
+            String.format(
+                "Copied Votes:\n"
+                    + "* Code-Review+1 by %s"
+                    + " (copy condition: \"is:MIN OR (is:MAX approverin:%s)\")\n",
+                AccountTemplateUtil.getAccountTemplate(admin.id()), groupUuid));
+  }
+
+  @Test
+  public void formatOutdatedpproval_withCopyCondition_withUserInPredicate() throws Exception {
+    String groupUuid =
+        groupCache.get(AccountGroup.nameKey("Administrators")).get().getGroupUUID().get();
+    LabelTypes labelTypes =
+        new LabelTypes(
+            ImmutableList.of(
+                createLabelType(
+                    /* labelName= */ "Code-Review",
+                    /* copyCondition= */ String.format(
+                        "is:MIN OR (is:MAX approverin:%s)", groupUuid))));
+    PatchSetApproval patchSetApproval = createPatchSetApproval(admin, "Code-Review", 1);
+    ApprovalCopier.Result approvalCopierResult =
+        ApprovalCopier.Result.create(
+            /* copiedApprovals= */ ImmutableSet.of(),
+            /* outdatedApprovals= */ ImmutableSet.of(patchSetApproval));
+    assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
+        .hasValue(
+            String.format(
+                "Outdated Votes:\n"
+                    + "* Code-Review+1 by %s (copy condition: \"is:MIN OR (is:MAX approverin:%s)\")\n",
+                AccountTemplateUtil.getAccountTemplate(admin.id()), groupUuid));
+  }
+
+  @Test
+  public void formatCopiedApproval_withCopyCondition_withUserInPredicateThatContainNonVisibleGroup()
+      throws Exception {
+    String groupUuid =
+        groupCache.get(AccountGroup.nameKey("Administrators")).get().getGroupUUID().get();
+    LabelTypes labelTypes =
+        new LabelTypes(
+            ImmutableList.of(
+                createLabelType(
+                    /* labelName= */ "Code-Review",
+                    /* copyCondition= */ String.format(
+                        "is:MIN OR (is:MAX approverin:%s)", groupUuid))));
+    PatchSetApproval patchSetApproval = createPatchSetApproval(admin, "Code-Review", 1);
+    ApprovalCopier.Result approvalCopierResult =
+        ApprovalCopier.Result.create(
+            /* copiedApprovals= */ ImmutableSet.of(patchSetApproval),
+            /* outdatedApprovals= */ ImmutableSet.of());
+
+    // Set 'user' as the current user in the request scope.
+    // 'user' cannot see the Administrators group that is used in the copy condition.
+    // Parsing the copy condition should still succeed since ApprovalsUtil should use the internal
+    // user that can see everything when parsing the copy condition.
+    requestScopeOperations.setApiUser(user.id());
+
+    assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
+        .hasValue(
+            String.format(
+                "Copied Votes:\n"
+                    + "* Code-Review+1 by %s"
+                    + " (copy condition: \"is:MIN OR (is:MAX approverin:%s)\")\n",
+                AccountTemplateUtil.getAccountTemplate(admin.id()), groupUuid));
+  }
+
+  @Test
+  public void
+      formatOutdatedpproval_withCopyCondition_withUserInPredicateThatContainNonVisibleGroup()
+          throws Exception {
+    String groupUuid =
+        groupCache.get(AccountGroup.nameKey("Administrators")).get().getGroupUUID().get();
+    LabelTypes labelTypes =
+        new LabelTypes(
+            ImmutableList.of(
+                createLabelType(
+                    /* labelName= */ "Code-Review",
+                    /* copyCondition= */ String.format(
+                        "is:MIN OR (is:MAX approverin:%s)", groupUuid))));
+    PatchSetApproval patchSetApproval = createPatchSetApproval(admin, "Code-Review", 1);
+    ApprovalCopier.Result approvalCopierResult =
+        ApprovalCopier.Result.create(
+            /* copiedApprovals= */ ImmutableSet.of(),
+            /* outdatedApprovals= */ ImmutableSet.of(patchSetApproval));
+
+    // Set 'user' as the current user in the request scope.
+    // 'user' cannot see the Administrators group that is used in the copy condition.
+    // Parsing the copy condition should still succeed since ApprovalsUtil should use the internal
+    // user that can see everything when parsing the copy condition.
+    requestScopeOperations.setApiUser(user.id());
+
+    assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
+        .hasValue(
+            String.format(
+                "Outdated Votes:\n"
+                    + "* Code-Review+1 by %s (copy condition: \"is:MIN OR (is:MAX approverin:%s)\")\n",
+                AccountTemplateUtil.getAccountTemplate(admin.id()), groupUuid));
+  }
+
+  @Test
+  public void formatCopiedApproval_withNonParseableCopyCondition_withUserInPredicate()
+      throws Exception {
+    String groupUuid =
+        groupCache.get(AccountGroup.nameKey("Administrators")).get().getGroupUUID().get();
+    LabelTypes labelTypes =
+        new LabelTypes(
+            ImmutableList.of(
+                createLabelType(
+                    /* labelName= */ "Code-Review",
+                    /* copyCondition= */ String.format(
+                        "is:MIN OR (is:MAX approverin:%s) OR foo bar baz", groupUuid))));
+    PatchSetApproval patchSetApproval = createPatchSetApproval(admin, "Code-Review", 1);
+    ApprovalCopier.Result approvalCopierResult =
+        ApprovalCopier.Result.create(
+            /* copiedApprovals= */ ImmutableSet.of(patchSetApproval),
+            /* outdatedApprovals= */ ImmutableSet.of());
+    assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
+        .hasValue(
+            String.format(
+                "Copied Votes:\n"
+                    + "* Code-Review+1 (non-parseable copy condition: \"is:MIN"
+                    + " OR (is:MAX approverin:%s) OR foo bar baz\")\n",
+                groupUuid));
+  }
+
+  @Test
+  public void formatOutdatedApproval_withNonParseableCopyCondition_withUserInPredicate()
+      throws Exception {
+    String groupUuid =
+        groupCache.get(AccountGroup.nameKey("Administrators")).get().getGroupUUID().get();
+    LabelTypes labelTypes =
+        new LabelTypes(
+            ImmutableList.of(
+                createLabelType(
+                    /* labelName= */ "Code-Review",
+                    /* copyCondition= */ String.format(
+                        "is:MIN OR (is:MAX approverin:%s) OR foo bar baz", groupUuid))));
+    PatchSetApproval patchSetApproval = createPatchSetApproval(admin, "Code-Review", 1);
+    ApprovalCopier.Result approvalCopierResult =
+        ApprovalCopier.Result.create(
+            /* copiedApprovals= */ ImmutableSet.of(),
+            /* outdatedApprovals= */ ImmutableSet.of(patchSetApproval));
+    assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
+        .hasValue(
+            String.format(
+                "Outdated Votes:\n"
+                    + "* Code-Review+1 (non-parseable copy condition: \"is:MIN"
+                    + " OR (is:MAX approverin:%s) OR foo bar baz\")\n",
+                groupUuid));
+  }
+
+  @Test
+  public void formatMultipleApprovals_sameVote_missingLabelType() throws Exception {
+    LabelTypes labelTypes = new LabelTypes(ImmutableList.of());
+    PatchSetApproval patchSetApproval1 = createPatchSetApproval(admin, "Code-Review", 1);
+    PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Code-Review", 1);
+    ApprovalCopier.Result approvalCopierResult =
+        ApprovalCopier.Result.create(
+            /* copiedApprovals= */ ImmutableSet.of(patchSetApproval1, patchSetApproval2),
+            /* outdatedApprovals= */ ImmutableSet.of());
+    assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
+        .hasValue("Copied Votes:\n* Code-Review+1 (label type is missing)\n");
+  }
+
+  @Test
+  public void formatMultipleApprovals_differentLabels_missingLabelType() throws Exception {
+    LabelTypes labelTypes = new LabelTypes(ImmutableList.of());
+    PatchSetApproval patchSetApproval1 = createPatchSetApproval(admin, "Code-Review", 1);
+    PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Verified", 1);
+    ApprovalCopier.Result approvalCopierResult =
+        ApprovalCopier.Result.create(
+            /* copiedApprovals= */ ImmutableSet.of(patchSetApproval1, patchSetApproval2),
+            /* outdatedApprovals= */ ImmutableSet.of());
+    assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
+        .hasValue(
+            "Copied Votes:\n"
+                + "* Code-Review+1 (label type is missing)\n"
+                + "* Verified+1 (label type is missing)\n");
+  }
+
+  @Test
+  public void formatMultipleApprovals_differentValues_missingLabelType() throws Exception {
+    LabelTypes labelTypes = new LabelTypes(ImmutableList.of());
+    PatchSetApproval patchSetApproval1 = createPatchSetApproval(admin, "Code-Review", 2);
+    PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Code-Review", 1);
+    ApprovalCopier.Result approvalCopierResult =
+        ApprovalCopier.Result.create(
+            /* copiedApprovals= */ ImmutableSet.of(patchSetApproval1, patchSetApproval2),
+            /* outdatedApprovals= */ ImmutableSet.of());
+    assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
+        .hasValue("Copied Votes:\n* Code-Review+1, Code-Review+2 (label type is missing)\n");
+  }
+
+  @Test
+  public void formatMultipleApprovals_sameVote_noCopyCondition() throws Exception {
+    LabelTypes labelTypes =
+        new LabelTypes(
+            ImmutableList.of(
+                createLabelType(/* labelName= */ "Code-Review", /* copyCondition= */ null)));
+    PatchSetApproval patchSetApproval1 = createPatchSetApproval(admin, "Code-Review", 1);
+    PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Code-Review", 1);
+    ApprovalCopier.Result approvalCopierResult =
+        ApprovalCopier.Result.create(
+            /* copiedApprovals= */ ImmutableSet.of(patchSetApproval1, patchSetApproval2),
+            /* outdatedApprovals= */ ImmutableSet.of());
+    assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
+        .hasValue("Copied Votes:\n* Code-Review+1\n");
+  }
+
+  @Test
+  public void formatMultipleApprovals_differentLabel_noCopyCondition() throws Exception {
+    LabelTypes labelTypes =
+        new LabelTypes(
+            ImmutableList.of(
+                createLabelType(/* labelName= */ "Code-Review", /* copyCondition= */ null),
+                createLabelType(/* labelName= */ "Verified", /* copyCondition= */ null)));
+    PatchSetApproval patchSetApproval1 = createPatchSetApproval(admin, "Code-Review", 1);
+    PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Verified", 1);
+    ApprovalCopier.Result approvalCopierResult =
+        ApprovalCopier.Result.create(
+            /* copiedApprovals= */ ImmutableSet.of(patchSetApproval1, patchSetApproval2),
+            /* outdatedApprovals= */ ImmutableSet.of());
+    assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
+        .hasValue("Copied Votes:\n* Code-Review+1\n* Verified+1\n");
+  }
+
+  @Test
+  public void formatMultipleApprovals_differentValue_noCopyCondition() throws Exception {
+    LabelTypes labelTypes =
+        new LabelTypes(
+            ImmutableList.of(
+                createLabelType(/* labelName= */ "Code-Review", /* copyCondition= */ null)));
+    PatchSetApproval patchSetApproval1 = createPatchSetApproval(admin, "Code-Review", 2);
+    PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Code-Review", 1);
+    ApprovalCopier.Result approvalCopierResult =
+        ApprovalCopier.Result.create(
+            /* copiedApprovals= */ ImmutableSet.of(patchSetApproval1, patchSetApproval2),
+            /* outdatedApprovals= */ ImmutableSet.of());
+    assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
+        .hasValue("Copied Votes:\n* Code-Review+1, Code-Review+2\n");
+  }
+
+  @Test
+  public void formatMultipleApprovals_sameVote_withCopyCondition_noUserInPredicate()
+      throws Exception {
+    LabelTypes labelTypes =
+        new LabelTypes(
+            ImmutableList.of(
+                createLabelType(
+                    /* labelName= */ "Code-Review", /* copyCondition= */ "is:MIN OR is:MAX")));
+    PatchSetApproval patchSetApproval1 = createPatchSetApproval(admin, "Code-Review", 1);
+    PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Code-Review", 1);
+    ApprovalCopier.Result approvalCopierResult =
+        ApprovalCopier.Result.create(
+            /* copiedApprovals= */ ImmutableSet.of(patchSetApproval1, patchSetApproval2),
+            /* outdatedApprovals= */ ImmutableSet.of());
+    assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
+        .hasValue("Copied Votes:\n* Code-Review+1 (copy condition: \"is:MIN OR is:MAX\")\n");
+  }
+
+  @Test
+  public void formatMultipleApprovals_differentLabel_withCopyCondition_noUserInPredicate()
+      throws Exception {
+    LabelTypes labelTypes =
+        new LabelTypes(
+            ImmutableList.of(
+                createLabelType(
+                    /* labelName= */ "Code-Review", /* copyCondition= */ "is:MIN OR is:MAX"),
+                createLabelType(
+                    /* labelName= */ "Verified", /* copyCondition= */ "is:MIN OR is:MAX")));
+    PatchSetApproval patchSetApproval1 = createPatchSetApproval(admin, "Code-Review", 1);
+    PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Verified", 1);
+    ApprovalCopier.Result approvalCopierResult =
+        ApprovalCopier.Result.create(
+            /* copiedApprovals= */ ImmutableSet.of(patchSetApproval1, patchSetApproval2),
+            /* outdatedApprovals= */ ImmutableSet.of());
+    assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
+        .hasValue(
+            "Copied Votes:\n"
+                + "* Code-Review+1 (copy condition: \"is:MIN OR is:MAX\")\n"
+                + "* Verified+1 (copy condition: \"is:MIN OR is:MAX\")\n");
+  }
+
+  @Test
+  public void formatMultipleApprovals_differentValue_withCopyCondition_noUserInPredicate()
+      throws Exception {
+    LabelTypes labelTypes =
+        new LabelTypes(
+            ImmutableList.of(
+                createLabelType(
+                    /* labelName= */ "Code-Review", /* copyCondition= */ "is:MIN OR is:MAX")));
+    PatchSetApproval patchSetApproval1 = createPatchSetApproval(admin, "Code-Review", 2);
+    PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Code-Review", 1);
+    ApprovalCopier.Result approvalCopierResult =
+        ApprovalCopier.Result.create(
+            /* copiedApprovals= */ ImmutableSet.of(patchSetApproval1, patchSetApproval2),
+            /* outdatedApprovals= */ ImmutableSet.of());
+    assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
+        .hasValue(
+            "Copied Votes:\n"
+                + "* Code-Review+1, Code-Review+2 (copy condition: \"is:MIN OR is:MAX\")\n");
+  }
+
+  @Test
+  public void formatMultipleApprovals_sameVote_withNonParseableCopyCondition_noUserInPredicate()
+      throws Exception {
+    LabelTypes labelTypes =
+        new LabelTypes(
+            ImmutableList.of(
+                createLabelType(
+                    /* labelName= */ "Code-Review", /* copyCondition= */ "foo bar baz")));
+    PatchSetApproval patchSetApproval1 = createPatchSetApproval(admin, "Code-Review", 1);
+    PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Code-Review", 1);
+    ApprovalCopier.Result approvalCopierResult =
+        ApprovalCopier.Result.create(
+            /* copiedApprovals= */ ImmutableSet.of(patchSetApproval1, patchSetApproval2),
+            /* outdatedApprovals= */ ImmutableSet.of());
+    assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
+        .hasValue(
+            "Copied Votes:\n* Code-Review+1 (non-parseable copy condition: \"foo bar baz\")\n");
+  }
+
+  @Test
+  public void
+      formatMultipleApprovals_differentLabel_withNonParseableCopyCondition_noUserInPredicate()
+          throws Exception {
+    LabelTypes labelTypes =
+        new LabelTypes(
+            ImmutableList.of(
+                createLabelType(/* labelName= */ "Code-Review", /* copyCondition= */ "foo bar baz"),
+                createLabelType(/* labelName= */ "Verified", /* copyCondition= */ "foo bar baz")));
+    PatchSetApproval patchSetApproval1 = createPatchSetApproval(admin, "Code-Review", 1);
+    PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Verified", 1);
+    ApprovalCopier.Result approvalCopierResult =
+        ApprovalCopier.Result.create(
+            /* copiedApprovals= */ ImmutableSet.of(patchSetApproval1, patchSetApproval2),
+            /* outdatedApprovals= */ ImmutableSet.of());
+    assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
+        .hasValue(
+            "Copied Votes:\n"
+                + "* Code-Review+1 (non-parseable copy condition: \"foo bar baz\")\n"
+                + "* Verified+1 (non-parseable copy condition: \"foo bar baz\")\n");
+  }
+
+  @Test
+  public void
+      formatMultipleApprovals_differentValue_withNonParseableCopyCondition_noUserInPredicate()
+          throws Exception {
+    LabelTypes labelTypes =
+        new LabelTypes(
+            ImmutableList.of(
+                createLabelType(
+                    /* labelName= */ "Code-Review", /* copyCondition= */ "foo bar baz")));
+    PatchSetApproval patchSetApproval1 = createPatchSetApproval(admin, "Code-Review", 2);
+    PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Code-Review", 1);
+    ApprovalCopier.Result approvalCopierResult =
+        ApprovalCopier.Result.create(
+            /* copiedApprovals= */ ImmutableSet.of(patchSetApproval1, patchSetApproval2),
+            /* outdatedApprovals= */ ImmutableSet.of());
+    assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
+        .hasValue(
+            "Copied Votes:\n"
+                + "* Code-Review+1, Code-Review+2"
+                + " (non-parseable copy condition: \"foo bar baz\")\n");
+  }
+
+  @Test
+  public void formatMultipleApprovals_sameVote_withCopyCondition_withUserInPredicate()
+      throws Exception {
+    String groupUuid =
+        groupCache.get(AccountGroup.nameKey("Administrators")).get().getGroupUUID().get();
+    LabelTypes labelTypes =
+        new LabelTypes(
+            ImmutableList.of(
+                createLabelType(
+                    /* labelName= */ "Code-Review",
+                    /* copyCondition= */ String.format(
+                        "is:MIN OR (is:MAX approverin:%s)", groupUuid))));
+    PatchSetApproval patchSetApproval1 = createPatchSetApproval(user, "Code-Review", 1);
+    PatchSetApproval patchSetApproval2 = createPatchSetApproval(admin, "Code-Review", 1);
+    ApprovalCopier.Result approvalCopierResult =
+        ApprovalCopier.Result.create(
+            /* copiedApprovals= */ ImmutableSet.of(patchSetApproval1, patchSetApproval2),
+            /* outdatedApprovals= */ ImmutableSet.of());
+    assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
+        .hasValue(
+            String.format(
+                "Copied Votes:\n"
+                    + "* Code-Review+1 by %s, %s"
+                    + " (copy condition: \"is:MIN OR (is:MAX approverin:%s)\")\n",
+                AccountTemplateUtil.getAccountTemplate(admin.id()),
+                AccountTemplateUtil.getAccountTemplate(user.id()),
+                groupUuid));
+  }
+
+  @Test
+  public void formatMultipleApprovals_differentLabel_withCopyCondition_withUserInPredicate()
+      throws Exception {
+    String groupUuid =
+        groupCache.get(AccountGroup.nameKey("Administrators")).get().getGroupUUID().get();
+    LabelTypes labelTypes =
+        new LabelTypes(
+            ImmutableList.of(
+                createLabelType(
+                    /* labelName= */ "Code-Review",
+                    /* copyCondition= */ String.format(
+                        "is:MIN OR (is:MAX approverin:%s)", groupUuid)),
+                createLabelType(
+                    /* labelName= */ "Verified",
+                    /* copyCondition= */ String.format(
+                        "is:MIN OR (is:MAX approverin:%s)", groupUuid))));
+    PatchSetApproval patchSetApproval1 = createPatchSetApproval(user, "Code-Review", 1);
+    PatchSetApproval patchSetApproval2 = createPatchSetApproval(admin, "Verified", 1);
+    ApprovalCopier.Result approvalCopierResult =
+        ApprovalCopier.Result.create(
+            /* copiedApprovals= */ ImmutableSet.of(patchSetApproval1, patchSetApproval2),
+            /* outdatedApprovals= */ ImmutableSet.of());
+    assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
+        .hasValue(
+            String.format(
+                "Copied Votes:\n"
+                    + "* Code-Review+1 by %s (copy condition: \"is:MIN OR (is:MAX approverin:%s)\")\n"
+                    + "* Verified+1 by %s (copy condition: \"is:MIN OR (is:MAX approverin:%s)\")\n",
+                AccountTemplateUtil.getAccountTemplate(user.id()),
+                groupUuid,
+                AccountTemplateUtil.getAccountTemplate(admin.id()),
+                groupUuid));
+  }
+
+  @Test
+  public void formatMultipleApprovals_differentValue_withCopyCondition_withUserInPredicate()
+      throws Exception {
+    String groupUuid =
+        groupCache.get(AccountGroup.nameKey("Administrators")).get().getGroupUUID().get();
+    LabelTypes labelTypes =
+        new LabelTypes(
+            ImmutableList.of(
+                createLabelType(
+                    /* labelName= */ "Code-Review",
+                    /* copyCondition= */ String.format(
+                        "is:MIN OR (is:MAX approverin:%s)", groupUuid))));
+    PatchSetApproval patchSetApproval1 = createPatchSetApproval(admin, "Code-Review", 2);
+    PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Code-Review", 1);
+    ApprovalCopier.Result approvalCopierResult =
+        ApprovalCopier.Result.create(
+            /* copiedApprovals= */ ImmutableSet.of(patchSetApproval1, patchSetApproval2),
+            /* outdatedApprovals= */ ImmutableSet.of());
+    assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
+        .hasValue(
+            String.format(
+                "Copied Votes:\n"
+                    + "* Code-Review+1 by %s, Code-Review+2 by %s"
+                    + " (copy condition: \"is:MIN OR (is:MAX approverin:%s)\")\n",
+                AccountTemplateUtil.getAccountTemplate(user.id()),
+                AccountTemplateUtil.getAccountTemplate(admin.id()),
+                groupUuid));
+  }
+
+  @Test
+  public void formatMultipleApprovals_differentAndSameValue_withCopyCondition_withUserInPredicate()
+      throws Exception {
+    TestAccount user2 = accountCreator.user2();
+    String groupUuid =
+        groupCache.get(AccountGroup.nameKey("Administrators")).get().getGroupUUID().get();
+    LabelTypes labelTypes =
+        new LabelTypes(
+            ImmutableList.of(
+                createLabelType(
+                    /* labelName= */ "Code-Review",
+                    /* copyCondition= */ String.format(
+                        "is:MIN OR (is:MAX approverin:%s)", groupUuid))));
+    PatchSetApproval patchSetApproval1 = createPatchSetApproval(admin, "Code-Review", 2);
+    PatchSetApproval patchSetApproval2 = createPatchSetApproval(user2, "Code-Review", 1);
+    PatchSetApproval patchSetApproval3 = createPatchSetApproval(user, "Code-Review", 1);
+    ApprovalCopier.Result approvalCopierResult =
+        ApprovalCopier.Result.create(
+            /* copiedApprovals= */ ImmutableSet.of(
+                patchSetApproval1, patchSetApproval2, patchSetApproval3),
+            /* outdatedApprovals= */ ImmutableSet.of());
+    assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
+        .hasValue(
+            String.format(
+                "Copied Votes:\n"
+                    + "* Code-Review+1 by %s, %s, Code-Review+2 by %s"
+                    + " (copy condition: \"is:MIN OR (is:MAX approverin:%s)\")\n",
+                AccountTemplateUtil.getAccountTemplate(user.id()),
+                AccountTemplateUtil.getAccountTemplate(user2.id()),
+                AccountTemplateUtil.getAccountTemplate(admin.id()),
+                groupUuid));
+  }
+
+  @Test
+  public void formatMultipleApprovals_sameVote_withNonParseableCopyCondition_withUserInPredicate()
+      throws Exception {
+    String groupUuid =
+        groupCache.get(AccountGroup.nameKey("Administrators")).get().getGroupUUID().get();
+    LabelTypes labelTypes =
+        new LabelTypes(
+            ImmutableList.of(
+                createLabelType(
+                    /* labelName= */ "Code-Review",
+                    /* copyCondition= */ String.format(
+                        "is:MIN OR (is:MAX approverin:%s) OR foo bar baz", groupUuid))));
+    PatchSetApproval patchSetApproval1 = createPatchSetApproval(admin, "Code-Review", 1);
+    PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Code-Review", 1);
+    ApprovalCopier.Result approvalCopierResult =
+        ApprovalCopier.Result.create(
+            /* copiedApprovals= */ ImmutableSet.of(patchSetApproval1, patchSetApproval2),
+            /* outdatedApprovals= */ ImmutableSet.of());
+    assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
+        .hasValue(
+            String.format(
+                "Copied Votes:\n"
+                    + "* Code-Review+1 (non-parseable copy condition: \"is:MIN"
+                    + " OR (is:MAX approverin:%s) OR foo bar baz\")\n",
+                groupUuid));
+  }
+
+  @Test
+  public void
+      formatMultipleApprovals_differentLabel_withNonParseableCopyCondition_withUserInPredicate()
+          throws Exception {
+    String groupUuid =
+        groupCache.get(AccountGroup.nameKey("Administrators")).get().getGroupUUID().get();
+    LabelTypes labelTypes =
+        new LabelTypes(
+            ImmutableList.of(
+                createLabelType(
+                    /* labelName= */ "Code-Review",
+                    /* copyCondition= */ String.format(
+                        "is:MIN OR (is:MAX approverin:%s) OR foo bar baz", groupUuid)),
+                createLabelType(
+                    /* labelName= */ "Verified",
+                    /* copyCondition= */ String.format(
+                        "is:MIN OR (is:MAX approverin:%s) OR foo bar baz", groupUuid))));
+    PatchSetApproval patchSetApproval1 = createPatchSetApproval(admin, "Code-Review", 1);
+    PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Verified", 1);
+    ApprovalCopier.Result approvalCopierResult =
+        ApprovalCopier.Result.create(
+            /* copiedApprovals= */ ImmutableSet.of(patchSetApproval1, patchSetApproval2),
+            /* outdatedApprovals= */ ImmutableSet.of());
+    assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
+        .hasValue(
+            String.format(
+                "Copied Votes:\n"
+                    + "* Code-Review+1 (non-parseable copy condition: \"is:MIN"
+                    + " OR (is:MAX approverin:%s) OR foo bar baz\")\n"
+                    + "* Verified+1 (non-parseable copy condition: \"is:MIN"
+                    + " OR (is:MAX approverin:%s) OR foo bar baz\")\n",
+                groupUuid, groupUuid));
+  }
+
+  @Test
+  public void
+      formatMultipleApprovals_differentValue_withNonParseableCopyCondition_withUserInPredicate()
+          throws Exception {
+    String groupUuid =
+        groupCache.get(AccountGroup.nameKey("Administrators")).get().getGroupUUID().get();
+    LabelTypes labelTypes =
+        new LabelTypes(
+            ImmutableList.of(
+                createLabelType(
+                    /* labelName= */ "Code-Review",
+                    /* copyCondition= */ String.format(
+                        "is:MIN OR (is:MAX approverin:%s) OR foo bar baz", groupUuid))));
+    PatchSetApproval patchSetApproval1 = createPatchSetApproval(admin, "Code-Review", 2);
+    PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Code-Review", 1);
+    ApprovalCopier.Result approvalCopierResult =
+        ApprovalCopier.Result.create(
+            /* copiedApprovals= */ ImmutableSet.of(patchSetApproval1, patchSetApproval2),
+            /* outdatedApprovals= */ ImmutableSet.of());
+    assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
+        .hasValue(
+            String.format(
+                "Copied Votes:\n"
+                    + "* Code-Review+1, Code-Review+2 (non-parseable copy condition: \"is:MIN"
+                    + " OR (is:MAX approverin:%s) OR foo bar baz\")\n",
+                groupUuid));
+  }
+
+  @Test
+  public void copiedAndOutdatedApprovalsAreIncludedInChangeMessageOnPatchSetCreationByPush()
+      throws Exception {
+    // Add Verified label without copy condition.
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      LabelType.Builder verified =
+          labelBuilder(
+              LabelId.VERIFIED, value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
+      u.getConfig().upsertLabelType(verified.build());
+      u.save();
+    }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(TestLabels.verified().getName())
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-1, 1))
+        .update();
+
+    PushOneCommit.Result r = createChange();
+
+    // Vote Code-Review-2 (sticky because it's a veto vote and the Code-Review label has "is:MIN" as
+    // part of its copy condition)
+    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.reject());
+
+    // Vote Verified+1 (not sticky because the Verified label has no copy condition)
+    gApi.changes().id(r.getChangeId()).current().review(new ReviewInput().label("Verified", 1));
+
+    amendChange(r.getChangeId()).assertOkStatus();
+
+    ChangeInfo change = change(r).get();
+    assertThat(Iterables.getLast(change.messages).message)
+        .isEqualTo(
+            "Uploaded patch set 2.\n"
+                + "\n"
+                + "Copied Votes:\n"
+                + "* Code-Review-2 (copy condition: \"changekind:NO_CHANGE"
+                + " OR changekind:TRIVIAL_REBASE OR is:MIN\")\n"
+                + "\n"
+                + "Outdated Votes:\n"
+                + "* Verified+1\n");
+  }
+
+  @Test
+  public void
+      copiedAndOutdatedApprovalsAreIncludedInChangeMessageOnPatchSetCreationByPush_withReviewMessage()
+          throws Exception {
+    // Add Verified label without copy condition.
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      LabelType.Builder verified =
+          labelBuilder(
+              LabelId.VERIFIED, value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
+      u.getConfig().upsertLabelType(verified.build());
+      u.save();
+    }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(TestLabels.verified().getName())
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-1, 1))
+        .update();
+
+    PushOneCommit.Result r = createChange();
+
+    // Vote Code-Review-2 (sticky because it's a veto vote and the Code-Review label has "is:MIN" as
+    // part of its copy condition)
+    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.reject());
+
+    // Vote Verified+1 (not sticky because the Verified label has no copy condition)
+    gApi.changes().id(r.getChangeId()).current().review(new ReviewInput().label("Verified", 1));
+
+    String reviewMessage = "Foo-Bar-Baz";
+
+    amendChange(r.getChangeId(), "refs/for/master%m=" + reviewMessage, admin, testRepo)
+        .assertOkStatus();
+
+    ChangeInfo change = change(r).get();
+    assertThat(Iterables.getLast(change.messages).message)
+        .isEqualTo(
+            "Uploaded patch set 2.\n"
+                + "\n"
+                + "Foo-Bar-Baz\n"
+                + "\n"
+                + "Copied Votes:\n"
+                + "* Code-Review-2 (copy condition: \"changekind:NO_CHANGE"
+                + " OR changekind:TRIVIAL_REBASE OR is:MIN\")\n"
+                + "\n"
+                + "Outdated Votes:\n"
+                + "* Verified+1\n");
+  }
+
+  @Test
+  public void copiedAndOutdatedApprovalsAreIncludedInChangeMessageOnPatchSetCreationByApi()
+      throws Exception {
+    // Add Verified label without copy condition.
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      LabelType.Builder verified =
+          labelBuilder(
+              LabelId.VERIFIED, value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
+      u.getConfig().upsertLabelType(verified.build());
+      u.save();
+    }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(TestLabels.verified().getName())
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-1, 1))
+        .update();
+
+    PushOneCommit.Result r = createChange();
+
+    // Vote Code-Review-2 (sticky because it's a veto vote and the Code-Review label has "is:MIN" as
+    // part of its copy condition)
+    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.reject());
+
+    // Vote Verified+1 (not sticky because the Verified label has no copy condition)
+    gApi.changes().id(r.getChangeId()).current().review(new ReviewInput().label("Verified", 1));
+
+    gApi.changes().id(r.getChangeId()).edit().modifyFile("a.txt", RawInputUtil.create("content"));
+    gApi.changes().id(r.getChangeId()).edit().publish();
+
+    ChangeInfo change = change(r).get();
+    assertThat(Iterables.getLast(change.messages).message)
+        .isEqualTo(
+            "Patch Set 2: Published edit on patch set 1.\n"
+                + "\n"
+                + "Copied Votes:\n"
+                + "* Code-Review-2 (copy condition: \"changekind:NO_CHANGE"
+                + " OR changekind:TRIVIAL_REBASE OR is:MIN\")\n"
+                + "\n"
+                + "Outdated Votes:\n"
+                + "* Verified+1\n");
+  }
+
+  private PatchSetApproval createPatchSetApproval(
+      TestAccount testAccount, String label, int value) {
+    return PatchSetApproval.builder()
+        .key(
+            PatchSetApproval.key(
+                PatchSet.id(Change.id(1), 1), testAccount.id(), LabelId.create(label)))
+        .value(value)
+        .granted(TimeUtil.now())
+        .build();
+  }
+
+  private LabelType createLabelType(String labelName, @Nullable String copyCondition) {
+    LabelType.Builder labelTypeBuilder =
+        LabelType.builder(
+            labelName,
+            ImmutableList.of(
+                LabelValue.create((short) -2, "Vetoed"),
+                LabelValue.create((short) -1, "Disliked"),
+                LabelValue.create((short) 0, "No Vote"),
+                LabelValue.create((short) 1, "Liked"),
+                LabelValue.create((short) 2, "Approved")));
+    if (copyCondition != null) {
+      labelTypeBuilder.setCopyCondition(copyCondition);
+    }
+    return labelTypeBuilder.build();
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/change/CopyApprovalsToFollowUpPatchSetsIT.java b/javatests/com/google/gerrit/acceptance/api/change/CopyApprovalsToFollowUpPatchSetsIT.java
new file mode 100644
index 0000000..9d0e10a
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/change/CopyApprovalsToFollowUpPatchSetsIT.java
@@ -0,0 +1,1060 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.api.change;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+import static com.google.common.truth.Truth8.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
+import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_COMMIT;
+import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_REVISION;
+import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_LABELS;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.server.project.testing.TestLabels.labelBuilder;
+import static com.google.gerrit.server.project.testing.TestLabels.value;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.common.ApprovalInfo;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.project.testing.TestLabels;
+import com.google.inject.Inject;
+import java.util.Optional;
+import java.util.function.Consumer;
+import org.junit.Before;
+import org.junit.Test;
+
+/**
+ * Test to verify that {@link com.google.gerrit.server.restapi.change.PostReviewCopyApprovalsOp}
+ * copies approvals to follow-up patch sets if possible.
+ */
+public class CopyApprovalsToFollowUpPatchSetsIT extends AbstractDaemonTest {
+  @Inject private ProjectOperations projectOperations;
+  @Inject private RequestScopeOperations requestScopeOperations;
+
+  @Before
+  public void setup() throws Exception {
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      // Overwrite "Code-Review" label that is inherited from All-Projects.
+      // This way changes to the "Code Review" label don't affect other tests.
+      LabelType.Builder codeReview =
+          labelBuilder(
+              LabelId.CODE_REVIEW,
+              value(2, "Looks good to me, approved"),
+              value(1, "Looks good to me, but someone else must approve"),
+              value(0, "No score"),
+              value(-1, "I would prefer this is not submitted as is"),
+              value(-2, "This shall not be submitted"));
+      u.getConfig().upsertLabelType(codeReview.build());
+
+      LabelType.Builder verified =
+          labelBuilder(
+              LabelId.VERIFIED, value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
+      u.getConfig().upsertLabelType(verified.build());
+
+      u.save();
+    }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(TestLabels.codeReview().getName())
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-2, 2))
+        .add(
+            allowLabel(TestLabels.verified().getName())
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-1, 1))
+        .update();
+  }
+
+  /**
+   * Tests that new approvals on an outdated patch set are not copied to the follow-up patch set if
+   * the new approvals are not copyable because no matching copy rule is configured.
+   */
+  @Test
+  public void newApprovals_notCopied_copyingNotEnabled() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    PatchSet patchSet1 = r.getChange().currentPatchSet();
+
+    r = amendChange(changeId);
+    r.assertOkStatus();
+    PatchSet patchSet2 = r.getChange().currentPatchSet();
+
+    // Vote on the first patch set.
+    vote(admin, changeId, patchSet1.number(), 2, 1);
+    vote(user, changeId, patchSet1.number(), -2, -1);
+
+    // Verify that no votes have been copied to the current patch set.
+    ChangeInfo c = detailedChange(changeId);
+    assertCurrentVotes(c, admin, 0, 0);
+    assertCurrentVotes(c, user, 0, 0);
+
+    // Verify the approvals in NoteDb.
+    assertApprovals(patchSet1.id(), admin, 2, 1, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet1.id(), user, -2, -1, /* expectedToBeCopied= */ false);
+    assertNoApprovals(patchSet2.id(), admin);
+    assertNoApprovals(patchSet2.id(), user);
+  }
+
+  /**
+   * Tests that new approvals on an outdated patch set are copied to the follow-up patch set if the
+   * follow-up patch set has no regular votes (non-copied votes that override copied votes).
+   */
+  @Test
+  public void newApprovals_copied_noCurrentVote() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:any"));
+    updateVerifiedLabel(b -> b.setCopyCondition("is:any"));
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    PatchSet patchSet1 = r.getChange().currentPatchSet();
+
+    r = amendChange(changeId);
+    r.assertOkStatus();
+    PatchSet patchSet2 = r.getChange().currentPatchSet();
+
+    // Vote on the first patch set.
+    vote(admin, changeId, patchSet1.number(), 2, 1);
+    vote(user, changeId, patchSet1.number(), -2, -1);
+
+    // Verify that the votes have been copied to the current patch set.
+    ChangeInfo c = detailedChange(changeId);
+    assertCurrentVotes(c, admin, 2, 1);
+    assertCurrentVotes(c, user, -2, -1);
+
+    // Verify the approvals in NoteDb.
+    assertApprovals(patchSet1.id(), admin, 2, 1, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet1.id(), user, -2, -1, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet2.id(), admin, 2, 1, /* expectedToBeCopied= */ true);
+    assertApprovals(patchSet2.id(), user, -2, -1, /* expectedToBeCopied= */ true);
+  }
+
+  /**
+   * Tests that new approvals on an outdated patch set are not copied to the follow-up patch set if
+   * the follow-up patch set has regular votes (non-copied votes that override copied votes).
+   */
+  @Test
+  public void newApprovals_notCopied_currentVote() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:any"));
+    updateVerifiedLabel(b -> b.setCopyCondition("is:any"));
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    PatchSet patchSet1 = r.getChange().currentPatchSet();
+
+    r = amendChange(changeId);
+    r.assertOkStatus();
+    PatchSet patchSet2 = r.getChange().currentPatchSet();
+
+    // Vote on the current patch set.
+    vote(admin, changeId, patchSet2.number(), 2, 1);
+    vote(user, changeId, patchSet2.number(), -2, -1);
+
+    // Vote on the first patch set.
+    vote(admin, changeId, patchSet1.number(), 1, -1);
+    vote(user, changeId, patchSet1.number(), -1, 1);
+
+    // Verify that the votes have not been copied to the current patch set (since a current vote
+    // already exists).
+    ChangeInfo c = detailedChange(changeId);
+    assertCurrentVotes(c, admin, 2, 1);
+    assertCurrentVotes(c, user, -2, -1);
+
+    // Verify the approvals in NoteDb.
+    assertApprovals(patchSet1.id(), admin, 1, -1, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet1.id(), user, -1, 1, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet2.id(), admin, 2, 1, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet2.id(), user, -2, -1, /* expectedToBeCopied= */ false);
+  }
+
+  /**
+   * Tests that new approvals on an outdated patch set are not copied to the follow-up patch set if
+   * the follow-up patch set has deletions of regular votes (non-copied deletion votes that override
+   * copied votes).
+   */
+  @Test
+  public void newApprovals_notCopied_currentDeletedVote() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:any"));
+    updateVerifiedLabel(b -> b.setCopyCondition("is:any"));
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    PatchSet patchSet1 = r.getChange().currentPatchSet();
+
+    r = amendChange(changeId);
+    r.assertOkStatus();
+    PatchSet patchSet2 = r.getChange().currentPatchSet();
+
+    // Vote on the current patch set.
+    vote(admin, changeId, patchSet2.number(), 2, 1);
+    vote(user, changeId, patchSet2.number(), -2, -1);
+
+    // Delete the votes on the current patch set.
+    deleteCurrentVotes(admin, changeId);
+    deleteCurrentVotes(user, changeId);
+
+    // Vote on the first patch set.
+    vote(admin, changeId, patchSet1.number(), 1, -1);
+    vote(user, changeId, patchSet1.number(), -1, 1);
+
+    // Verify that the votes have not been copied to the current patch set (since a deletion vote
+    // already exists on the current patch set).
+    ChangeInfo c = detailedChange(changeId);
+    assertCurrentVotes(c, admin, 0, 0);
+    assertCurrentVotes(c, user, 0, 0);
+
+    // Verify the approvals in NoteDb.
+    assertApprovals(patchSet1.id(), admin, 1, -1, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet1.id(), user, -1, 1, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet2.id(), admin, 0, 0, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet2.id(), user, 0, 0, /* expectedToBeCopied= */ false);
+  }
+
+  /**
+   * Tests that updated approvals on an outdated patch set are not copied to the follow-up patch set
+   * if the updated approvals are not copyable because no matching copy rule is configured.
+   */
+  @Test
+  public void updatedApprovals_notCopied_copyingNotEnabled() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    PatchSet patchSet1 = r.getChange().currentPatchSet();
+
+    // Vote on the first patch set.
+    vote(admin, changeId, patchSet1.number(), 1, 1);
+    vote(user, changeId, patchSet1.number(), -2, -1);
+
+    r = amendChange(changeId);
+    r.assertOkStatus();
+    PatchSet patchSet2 = r.getChange().currentPatchSet();
+
+    // Update the votes on the first patch set.
+    vote(admin, changeId, patchSet1.number(), 2, -1);
+    vote(user, changeId, patchSet1.number(), -1, 1);
+
+    // Verify that no votes have been copied to the current patch set.
+    ChangeInfo c = detailedChange(changeId);
+    assertCurrentVotes(c, admin, 0, 0);
+    assertCurrentVotes(c, user, 0, 0);
+
+    // Verify the approvals in NoteDb.
+    assertApprovals(patchSet1.id(), admin, 2, -1, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet1.id(), user, -1, 1, /* expectedToBeCopied= */ false);
+    assertNoApprovals(patchSet2.id(), admin);
+    assertNoApprovals(patchSet2.id(), user);
+  }
+
+  /**
+   * Tests that if updated approvals on an outdated patch set are not copied to the follow-up patch
+   * set that existing copies of the approvals on the follow-up patch sets are unset.
+   */
+  @Test
+  public void updatedApprovals_notCopied_copyingNotEnabled_unsetsCopiedApprovals()
+      throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:1 OR is:2"));
+    updateVerifiedLabel(b -> b.setCopyCondition("is:max"));
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    PatchSet patchSet1 = r.getChange().currentPatchSet();
+
+    // Vote on the first patch set with votes that are copied.
+    vote(admin, changeId, patchSet1.number(), 1, 1);
+    vote(user, changeId, patchSet1.number(), 2, 1);
+
+    r = amendChange(changeId);
+    r.assertOkStatus();
+    PatchSet patchSet2 = r.getChange().currentPatchSet();
+
+    // Verify that the votes have been copied to the current patch set.
+    ChangeInfo c = detailedChange(changeId);
+    assertCurrentVotes(c, admin, 1, 1);
+    assertCurrentVotes(c, user, 2, 1);
+
+    // Update the votes on the first patch set with votes that are not copied
+    vote(admin, changeId, patchSet1.number(), -1, -1);
+    vote(user, changeId, patchSet1.number(), -2, -1);
+
+    // Verify that the copied votes on the current patch set have been unset.
+    c = detailedChange(changeId);
+    assertCurrentVotes(c, admin, 0, 0);
+    assertCurrentVotes(c, user, 0, 0);
+
+    // Verify the approvals in NoteDb.
+    assertApprovals(patchSet1.id(), admin, -1, -1, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet1.id(), user, -2, -1, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet2.id(), admin, 0, 0, /* expectedToBeCopied= */ true);
+    assertApprovals(patchSet2.id(), user, 0, 0, /* expectedToBeCopied= */ true);
+  }
+
+  /**
+   * Tests that updated approvals on an outdated patch set are copied to the follow-up patch set if
+   * the follow-up patch set has no regular votes (non-copied votes that override copied votes).
+   */
+  @Test
+  public void updatedApprovals_copied_noCurrentVote() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:1 OR is:2"));
+    updateVerifiedLabel(b -> b.setCopyCondition("is:max"));
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    PatchSet patchSet1 = r.getChange().currentPatchSet();
+
+    // Vote on the first patch set with votes that are not copied.
+    vote(admin, changeId, patchSet1.number(), -2, -1);
+    vote(user, changeId, patchSet1.number(), -1, -1);
+
+    r = amendChange(changeId);
+    r.assertOkStatus();
+    PatchSet patchSet2 = r.getChange().currentPatchSet();
+
+    // Verify that the votes have not been copied to the current patch set.
+    ChangeInfo c = detailedChange(changeId);
+    assertCurrentVotes(c, admin, 0, 0);
+    assertCurrentVotes(c, user, 0, 0);
+
+    // Update the votes on the first patch set with votes that are copied.
+    vote(admin, changeId, patchSet1.number(), 2, 1);
+    vote(user, changeId, patchSet1.number(), 1, 1);
+
+    // Verify that the votes have been copied to the current patch set.
+    c = detailedChange(changeId);
+    assertCurrentVotes(c, admin, 2, 1);
+    assertCurrentVotes(c, user, 1, 1);
+
+    // Verify the approvals in NoteDb.
+    assertApprovals(patchSet1.id(), admin, 2, 1, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet1.id(), user, 1, 1, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet2.id(), admin, 2, 1, /* expectedToBeCopied= */ true);
+    assertApprovals(patchSet2.id(), user, 1, 1, /* expectedToBeCopied= */ true);
+  }
+
+  /**
+   * Tests that updated approvals on an outdated patch set are not copied to the follow-up patch set
+   * if the follow-up patch set has regular votes (non-copied votes that override copied votes).
+   */
+  @Test
+  public void updatedApprovals_notCopied_currentVote() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:any"));
+    updateVerifiedLabel(b -> b.setCopyCondition("is:any"));
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    PatchSet patchSet1 = r.getChange().currentPatchSet();
+
+    // Vote on the first patch set.
+    vote(admin, changeId, patchSet1.number(), 1, -1);
+    vote(user, changeId, patchSet1.number(), -1, 1);
+
+    r = amendChange(changeId);
+    r.assertOkStatus();
+    PatchSet patchSet2 = r.getChange().currentPatchSet();
+
+    // Vote on the current patch set (overrides the copied votes).
+    vote(admin, changeId, patchSet2.number(), 2, 1);
+    vote(user, changeId, patchSet2.number(), -2, -1);
+
+    // Update the votes on the first patch set.
+    vote(admin, changeId, patchSet1.number(), -1, 1);
+    vote(user, changeId, patchSet1.number(), 1, -1);
+
+    // Verify that the votes have not been copied to the current patch set (since a current vote
+    // already exists).
+    ChangeInfo c = detailedChange(changeId);
+    assertCurrentVotes(c, admin, 2, 1);
+    assertCurrentVotes(c, user, -2, -1);
+
+    // Verify the approvals in NoteDb.
+    assertApprovals(patchSet1.id(), admin, -1, 1, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet1.id(), user, 1, -1, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet2.id(), admin, 2, 1, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet2.id(), user, -2, -1, /* expectedToBeCopied= */ false);
+  }
+
+  /**
+   * Tests that updated approvals on an outdated patch set are not copied to the follow-up patch set
+   * if the follow-up patch set has deletions of regular votes (non-copied deletion votes that
+   * override copied votes).
+   */
+  @Test
+  public void updatedApprovals_notCopied_currentDeletedVote() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:any"));
+    updateVerifiedLabel(b -> b.setCopyCondition("is:any"));
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    PatchSet patchSet1 = r.getChange().currentPatchSet();
+
+    // Vote on the first patch set.
+    vote(admin, changeId, patchSet1.number(), 1, -1);
+    vote(user, changeId, patchSet1.number(), -1, 1);
+
+    r = amendChange(changeId);
+    r.assertOkStatus();
+    PatchSet patchSet2 = r.getChange().currentPatchSet();
+
+    // Vote on the current patch set (overrides the copied approvals).
+    vote(admin, changeId, patchSet2.number(), 2, 1);
+    vote(user, changeId, patchSet2.number(), -2, -1);
+
+    // Delete the votes on the current patch set.
+    deleteCurrentVotes(admin, changeId);
+    deleteCurrentVotes(user, changeId);
+
+    // Update the votes on the first patch set.
+    vote(admin, changeId, patchSet1.number(), -1, 1);
+    vote(user, changeId, patchSet1.number(), 1, -1);
+
+    // Verify that the votes have not been copied to the current patch set (since a deletion vote
+    // already exists on the current patch set).
+    ChangeInfo c = detailedChange(changeId);
+    assertCurrentVotes(c, admin, 0, 0);
+    assertCurrentVotes(c, user, 0, 0);
+
+    // Verify the approvals in NoteDb.
+    assertApprovals(patchSet1.id(), admin, -1, 1, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet1.id(), user, 1, -1, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet2.id(), admin, 0, 0, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet2.id(), user, 0, 0, /* expectedToBeCopied= */ false);
+  }
+
+  /**
+   * Tests that updated approvals on an outdated patch set are copied to the follow-up patch set if
+   * the follow-up patch set has copied votes (the copied votes on the follow-up patch set are
+   * updated).
+   */
+  @Test
+  public void updatedApprovals_copied_currentCopiedVote() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:any"));
+    updateVerifiedLabel(b -> b.setCopyCondition("is:any"));
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    PatchSet patchSet1 = r.getChange().currentPatchSet();
+
+    // Vote on the first patch set.
+    vote(admin, changeId, patchSet1.number(), -2, -1);
+    vote(user, changeId, patchSet1.number(), 2, 1);
+
+    r = amendChange(changeId);
+    r.assertOkStatus();
+    PatchSet patchSet2 = r.getChange().currentPatchSet();
+
+    // Verify that the votes have been copied to the current patch set.
+    ChangeInfo c = detailedChange(changeId);
+    assertCurrentVotes(c, admin, -2, -1);
+    assertCurrentVotes(c, user, 2, 1);
+
+    // Update the votes on the first patch set with votes that are copied.
+    vote(admin, changeId, patchSet1.number(), 2, 1);
+    vote(user, changeId, patchSet1.number(), -2, -1);
+
+    // Verify that the votes have been copied to the current patch set.
+    c = detailedChange(changeId);
+    assertCurrentVotes(c, admin, 2, 1);
+    assertCurrentVotes(c, user, -2, -1);
+
+    // Verify the approvals in NoteDb.
+    assertApprovals(patchSet1.id(), admin, 2, 1, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet1.id(), user, -2, -1, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet2.id(), admin, 2, 1, /* expectedToBeCopied= */ true);
+    assertApprovals(patchSet2.id(), user, -2, -1, /* expectedToBeCopied= */ true);
+  }
+
+  /**
+   * Tests that deleted approvals on an outdated patch set are not copied to the follow-up patch set
+   * if the deleted approvals are not copyable because no matching copy rule is configured.
+   */
+  @Test
+  public void deletedApprovals_notCopied_copyingNotEnabled() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    PatchSet patchSet1 = r.getChange().currentPatchSet();
+
+    // Vote on the first patch set.
+    vote(admin, changeId, patchSet1.number(), 2, 1);
+    vote(user, changeId, patchSet1.number(), -2, -1);
+
+    r = amendChange(changeId);
+    r.assertOkStatus();
+    PatchSet patchSet2 = r.getChange().currentPatchSet();
+
+    // Vote on the current patch set.
+    vote(admin, changeId, patchSet2.number(), -2, -1);
+    vote(user, changeId, patchSet2.number(), 2, 1);
+
+    // Delete the votes on the first patch set.
+    vote(admin, changeId, patchSet1.number(), 0, 0);
+    vote(user, changeId, patchSet1.number(), 0, 0);
+
+    // Verify that the vote deletions have not been copied to the current patch set.
+    ChangeInfo c = detailedChange(changeId);
+    assertCurrentVotes(c, admin, -2, -1);
+    assertCurrentVotes(c, user, 2, 1);
+
+    // Verify the approvals in NoteDb.
+    assertApprovals(patchSet1.id(), admin, 0, 0, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet1.id(), user, 0, 0, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet2.id(), admin, -2, -1, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet2.id(), user, 2, 1, /* expectedToBeCopied= */ false);
+  }
+
+  /**
+   * Tests that deleted approvals on an outdated patch set are not copied to the follow-up patch set
+   * if the follow-up patch set has no regular votes (non-copied votes that override copied votes).
+   */
+  @Test
+  public void deletedApprovals_notCopied_noCurrentVote() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:0 OR is:1 OR is:2"));
+    updateVerifiedLabel(b -> b.setCopyCondition("is:0 OR is:1"));
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    PatchSet patchSet1 = r.getChange().currentPatchSet();
+
+    // Vote on the first patch set with votes that are not copied.
+    vote(admin, changeId, patchSet1.number(), -2, -1);
+    vote(user, changeId, patchSet1.number(), -1, -1);
+
+    r = amendChange(changeId);
+    r.assertOkStatus();
+    PatchSet patchSet2 = r.getChange().currentPatchSet();
+
+    // Verify that the votes have not been copied to the current patch set.
+    ChangeInfo c = detailedChange(changeId);
+    assertCurrentVotes(c, admin, 0, 0);
+    assertCurrentVotes(c, user, 0, 0);
+
+    // Delete the votes on the first patch set.
+    vote(admin, changeId, patchSet1.number(), 0, 0);
+    vote(user, changeId, patchSet1.number(), 0, 0);
+
+    // Verify that there are still no votes on the current patch set.
+    c = detailedChange(changeId);
+    assertCurrentVotes(c, admin, 0, 0);
+    assertCurrentVotes(c, user, 0, 0);
+
+    // Verify the approvals in NoteDb (the deletion votes have not been copied).
+    assertApprovals(patchSet1.id(), admin, 0, 0, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet1.id(), user, 0, 0, /* expectedToBeCopied= */ false);
+    assertNoApprovals(patchSet2.id(), admin);
+    assertNoApprovals(patchSet2.id(), user);
+  }
+
+  /**
+   * Tests that deleted approvals on an outdated patch set are not copied to the follow-up patch set
+   * if the follow-up patch set has regular votes (non-copied votes that override copied votes).
+   */
+  @Test
+  public void deletedApprovals_notCopied_currentVote() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:any"));
+    updateVerifiedLabel(b -> b.setCopyCondition("is:any"));
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    PatchSet patchSet1 = r.getChange().currentPatchSet();
+
+    // Vote on the first patch set.
+    vote(admin, changeId, patchSet1.number(), 1, -1);
+    vote(user, changeId, patchSet1.number(), -1, 1);
+
+    r = amendChange(changeId);
+    r.assertOkStatus();
+    PatchSet patchSet2 = r.getChange().currentPatchSet();
+
+    // Vote on the current patch set (overrides the copied votes).
+    vote(admin, changeId, patchSet2.number(), 2, 1);
+    vote(user, changeId, patchSet2.number(), -2, -1);
+
+    // Delete the votes on the first patch set.
+    vote(admin, changeId, patchSet1.number(), 0, 0);
+    vote(user, changeId, patchSet1.number(), 0, 0);
+
+    // Verify that the vote deletions have not been copied to the current patch set (since a current
+    // vote already exists).
+    ChangeInfo c = detailedChange(changeId);
+    assertCurrentVotes(c, admin, 2, 1);
+    assertCurrentVotes(c, user, -2, -1);
+
+    // Verify the approvals in NoteDb.
+    assertApprovals(patchSet1.id(), admin, 0, 0, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet1.id(), user, 0, 0, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet2.id(), admin, 2, 1, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet2.id(), user, -2, -1, /* expectedToBeCopied= */ false);
+  }
+
+  /**
+   * Tests that deleted approvals on an outdated patch set are not copied to the follow-up patch set
+   * if the follow-up patch set has deletions of regular votes (non-copied deletion votes that
+   * override copied votes).
+   */
+  @Test
+  public void deletedApprovals_notCopied_currentDeletedVote() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:any"));
+    updateVerifiedLabel(b -> b.setCopyCondition("is:any"));
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    PatchSet patchSet1 = r.getChange().currentPatchSet();
+
+    // Vote on the first patch set.
+    vote(admin, changeId, patchSet1.number(), 1, -1);
+    vote(user, changeId, patchSet1.number(), -1, 1);
+
+    r = amendChange(changeId);
+    r.assertOkStatus();
+    PatchSet patchSet2 = r.getChange().currentPatchSet();
+
+    // Vote on the current patch set (overrides the copied approvals).
+    vote(admin, changeId, patchSet2.number(), 2, 1);
+    vote(user, changeId, patchSet2.number(), -2, -1);
+
+    // Delete the votes on the current patch set.
+    deleteCurrentVotes(admin, changeId);
+    deleteCurrentVotes(user, changeId);
+
+    // Delete the votes on the first patch set.
+    vote(admin, changeId, patchSet1.number(), 0, 0);
+    vote(user, changeId, patchSet1.number(), 0, 0);
+
+    // Verify that there are still no votes on the current patch set.
+    ChangeInfo c = detailedChange(changeId);
+    assertCurrentVotes(c, admin, 0, 0);
+    assertCurrentVotes(c, user, 0, 0);
+
+    // Verify the approvals in NoteDb (the deletion votes have not been copied).
+    assertApprovals(patchSet1.id(), admin, 0, 0, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet1.id(), user, 0, 0, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet2.id(), admin, 0, 0, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet2.id(), user, 0, 0, /* expectedToBeCopied= */ false);
+  }
+
+  /**
+   * Tests that deleted approvals on an outdated patch set are copied to the follow-up patch set if
+   * the follow-up patch set has copied votes (the copied votes on the follow-up patch set are
+   * removed).
+   */
+  @Test
+  public void deletedApprovals_copied_currentCopiedVote() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:any"));
+    updateVerifiedLabel(b -> b.setCopyCondition("is:any"));
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    PatchSet patchSet1 = r.getChange().currentPatchSet();
+
+    // Vote on the first patch set.
+    vote(admin, changeId, patchSet1.number(), -2, -1);
+    vote(user, changeId, patchSet1.number(), 2, 1);
+
+    r = amendChange(changeId);
+    r.assertOkStatus();
+    PatchSet patchSet2 = r.getChange().currentPatchSet();
+
+    // Verify that the votes have been copied to the current patch set.
+    ChangeInfo c = detailedChange(changeId);
+    assertCurrentVotes(c, admin, -2, -1);
+    assertCurrentVotes(c, user, 2, 1);
+
+    // Delete the votes on the first patch set.
+    vote(admin, changeId, patchSet1.number(), 0, 0);
+    vote(user, changeId, patchSet1.number(), 0, 0);
+
+    // Verify that the vote deletions have been copied to the current patch set.
+    c = detailedChange(changeId);
+    assertCurrentVotes(c, admin, 0, 0);
+    assertCurrentVotes(c, user, 0, 0);
+
+    // Verify the approvals in NoteDb.
+    assertApprovals(patchSet1.id(), admin, 0, 0, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet1.id(), user, 0, 0, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet2.id(), admin, 0, 0, /* expectedToBeCopied= */ true);
+    assertApprovals(patchSet2.id(), user, 0, 0, /* expectedToBeCopied= */ true);
+  }
+
+  /** Tests that new approvals on an outdated patch set are copied to all follow-up patch sets. */
+  @Test
+  public void copyNewApprovalAcrossMultipleFollowUpPatchSets() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:any"));
+    updateVerifiedLabel(b -> b.setCopyCondition("is:any"));
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    PatchSet patchSet1 = r.getChange().currentPatchSet();
+
+    r = amendChange(changeId);
+    r.assertOkStatus();
+    PatchSet patchSet2 = r.getChange().currentPatchSet();
+
+    r = amendChange(changeId);
+    r.assertOkStatus();
+    PatchSet patchSet3 = r.getChange().currentPatchSet();
+
+    r = amendChange(changeId);
+    r.assertOkStatus();
+    PatchSet patchSet4 = r.getChange().currentPatchSet();
+
+    // Vote on the first patch set.
+    vote(admin, changeId, patchSet1.number(), 2, 1);
+    vote(user, changeId, patchSet1.number(), -2, -1);
+
+    // Verify that votes have been copied to the current patch set.
+    ChangeInfo c = detailedChange(changeId);
+    assertCurrentVotes(c, admin, 2, 1);
+    assertCurrentVotes(c, user, -2, -1);
+
+    // Verify the approvals in NoteDb.
+    assertApprovals(patchSet1.id(), admin, 2, 1, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet1.id(), user, -2, -1, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet2.id(), admin, 2, 1, /* expectedToBeCopied= */ true);
+    assertApprovals(patchSet2.id(), user, -2, -1, /* expectedToBeCopied= */ true);
+    assertApprovals(patchSet3.id(), admin, 2, 1, /* expectedToBeCopied= */ true);
+    assertApprovals(patchSet3.id(), user, -2, -1, /* expectedToBeCopied= */ true);
+    assertApprovals(patchSet4.id(), admin, 2, 1, /* expectedToBeCopied= */ true);
+    assertApprovals(patchSet4.id(), user, -2, -1, /* expectedToBeCopied= */ true);
+  }
+
+  /**
+   * Tests that new approvals on an outdated patch set are copied to all follow-up patch sets, but
+   * not across patch sets have non-copied votes.
+   */
+  @Test
+  public void
+      copyNewApprovalAcrossMultipleFollowUpPatchSets_stopOnFirstFollowUpPatchSetToWhichTheVoteIsNotCopyable()
+          throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:1 or is:2"));
+    updateVerifiedLabel(b -> b.setCopyCondition("is:any"));
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    PatchSet patchSet1 = r.getChange().currentPatchSet();
+
+    r = amendChange(changeId);
+    r.assertOkStatus();
+    PatchSet patchSet2 = r.getChange().currentPatchSet();
+
+    r = amendChange(changeId);
+    r.assertOkStatus();
+    PatchSet patchSet3 = r.getChange().currentPatchSet();
+
+    // Vote on the third patch set with non-copyable Code-Review votes and copyable Verified votes.
+    vote(admin, changeId, patchSet3.number(), -2, -1);
+    vote(user, changeId, patchSet3.number(), -1, -1);
+
+    r = amendChange(changeId);
+    r.assertOkStatus();
+    PatchSet patchSet4 = r.getChange().currentPatchSet();
+
+    // Verify that the Verified votes from patch set 3 have been copied to the current patch
+    // set.
+    ChangeInfo c = detailedChange(changeId);
+    assertCurrentVotes(c, admin, 0, -1);
+    assertCurrentVotes(c, user, 0, -1);
+
+    // Vote on the first patch set with copyable votes.
+    vote(admin, changeId, patchSet1.number(), 2, 1);
+    vote(user, changeId, patchSet1.number(), 1, 1);
+
+    // Verify that votes have been not copied to the current patch set.
+    c = detailedChange(changeId);
+    assertCurrentVotes(c, admin, 0, -1);
+    assertCurrentVotes(c, user, 0, -1);
+
+    // Verify the approvals in NoteDb.
+    assertApprovals(patchSet1.id(), admin, 2, 1, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet1.id(), user, 1, 1, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet2.id(), admin, 2, 1, /* expectedToBeCopied= */ true);
+    assertApprovals(patchSet2.id(), user, 1, 1, /* expectedToBeCopied= */ true);
+    assertApprovals(patchSet3.id(), admin, -2, -1, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet3.id(), user, -1, -1, /* expectedToBeCopied= */ false);
+    assertNoApproval(patchSet4.id(), admin, LabelId.CODE_REVIEW);
+    assertApproval(patchSet4.id(), admin, LabelId.VERIFIED, -1, /* expectedToBeCopied= */ true);
+    assertNoApproval(patchSet4.id(), user, LabelId.CODE_REVIEW);
+    assertApproval(patchSet4.id(), user, LabelId.VERIFIED, -1, /* expectedToBeCopied= */ true);
+  }
+
+  /**
+   * Tests that deleted approvals on an outdated patch set are copied to all follow-up patch sets.
+   */
+  @Test
+  public void copyApprovalDeletionAcrossMultipleFollowUpPatchSets() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:any"));
+    updateVerifiedLabel(b -> b.setCopyCondition("is:any"));
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    PatchSet patchSet1 = r.getChange().currentPatchSet();
+
+    // Vote on the first patch set.
+    vote(admin, changeId, patchSet1.number(), 2, 1);
+    vote(user, changeId, patchSet1.number(), -2, -1);
+
+    r = amendChange(changeId);
+    r.assertOkStatus();
+    PatchSet patchSet2 = r.getChange().currentPatchSet();
+
+    r = amendChange(changeId);
+    r.assertOkStatus();
+    PatchSet patchSet3 = r.getChange().currentPatchSet();
+
+    r = amendChange(changeId);
+    r.assertOkStatus();
+    PatchSet patchSet4 = r.getChange().currentPatchSet();
+
+    // Verify that votes have been copied to the current patch set.
+    ChangeInfo c = detailedChange(changeId);
+    assertCurrentVotes(c, admin, 2, 1);
+    assertCurrentVotes(c, user, -2, -1);
+
+    // Delete the votes on the first patch set.
+    vote(admin, changeId, patchSet1.number(), 0, 0);
+    vote(user, changeId, patchSet1.number(), 0, 0);
+
+    // Verify that the votes has been copied to the current patch set.
+    c = detailedChange(changeId);
+    assertCurrentVotes(c, admin, 0, 0);
+    assertCurrentVotes(c, user, 0, 0);
+
+    // Verify the approvals in NoteDb.
+    assertApprovals(patchSet1.id(), admin, 0, 0, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet1.id(), user, 0, 0, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet2.id(), admin, 0, 0, /* expectedToBeCopied= */ true);
+    assertApprovals(patchSet2.id(), user, 0, 0, /* expectedToBeCopied= */ true);
+    assertApprovals(patchSet3.id(), admin, 0, 0, /* expectedToBeCopied= */ true);
+    assertApprovals(patchSet3.id(), user, 0, 0, /* expectedToBeCopied= */ true);
+    assertApprovals(patchSet4.id(), admin, 0, 0, /* expectedToBeCopied= */ true);
+    assertApprovals(patchSet4.id(), user, 0, 0, /* expectedToBeCopied= */ true);
+  }
+
+  /**
+   * Tests that deleted approvals on an outdated patch set are copied to all follow-up patch sets,
+   * but not across patch sets have non-copied votes.
+   */
+  @Test
+  public void
+      copyApprovalDeletionAcrossMultipleFollowUpPatchSets_stopOnFirstFollowUpPatchSetToWhichTheVoteIsNotCopyable()
+          throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:1 or is:2"));
+    updateVerifiedLabel(b -> b.setCopyCondition("is:any"));
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    PatchSet patchSet1 = r.getChange().currentPatchSet();
+
+    // Vote on the first patch set with copyable votes.
+    vote(admin, changeId, patchSet1.number(), 2, 1);
+    vote(user, changeId, patchSet1.number(), 1, -1);
+
+    r = amendChange(changeId);
+    r.assertOkStatus();
+    PatchSet patchSet2 = r.getChange().currentPatchSet();
+
+    r = amendChange(changeId);
+    r.assertOkStatus();
+    PatchSet patchSet3 = r.getChange().currentPatchSet();
+
+    // Vote on the third patch set with non-copyable Code-Review votes and copyable Verified votes.
+    vote(admin, changeId, patchSet3.number(), -2, -1);
+    vote(user, changeId, patchSet3.number(), -1, -1);
+
+    r = amendChange(changeId);
+    r.assertOkStatus();
+    PatchSet patchSet4 = r.getChange().currentPatchSet();
+
+    // Verify that the Verified votes from patch set 3 have been copied to the current patch set.
+    ChangeInfo c = detailedChange(changeId);
+    assertCurrentVotes(c, admin, 0, -1);
+    assertCurrentVotes(c, user, 0, -1);
+
+    // Delete the votes on the first patch set.
+    vote(admin, changeId, patchSet1.number(), 0, 0);
+    vote(user, changeId, patchSet1.number(), 0, 0);
+
+    // Verify that the vote deletions have been not copied to the current patch set.
+    c = detailedChange(changeId);
+    assertCurrentVotes(c, admin, 0, -1);
+    assertCurrentVotes(c, user, 0, -1);
+
+    // Verify the approvals in NoteDb.
+    assertApprovals(patchSet1.id(), admin, 0, 0, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet1.id(), user, 0, 0, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet2.id(), admin, 0, 0, /* expectedToBeCopied= */ true);
+    assertApprovals(patchSet2.id(), user, 0, 0, /* expectedToBeCopied= */ true);
+    assertApprovals(patchSet3.id(), admin, -2, -1, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet3.id(), user, -1, -1, /* expectedToBeCopied= */ false);
+    assertNoApproval(patchSet4.id(), admin, LabelId.CODE_REVIEW);
+    assertApproval(patchSet4.id(), admin, LabelId.VERIFIED, -1, /* expectedToBeCopied= */ true);
+    assertNoApproval(patchSet4.id(), user, LabelId.CODE_REVIEW);
+    assertApproval(patchSet4.id(), user, LabelId.VERIFIED, -1, /* expectedToBeCopied= */ true);
+  }
+
+  /** Tests that new approvals on an outdated patch set are not copied to predecessor patch sets. */
+  @Test
+  public void notCopyToPredecessorPatchSets() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:any"));
+    updateVerifiedLabel(b -> b.setCopyCondition("is:any"));
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    PatchSet patchSet1 = r.getChange().currentPatchSet();
+
+    r = amendChange(changeId);
+    r.assertOkStatus();
+    PatchSet patchSet2 = r.getChange().currentPatchSet();
+
+    r = amendChange(changeId);
+    r.assertOkStatus();
+    PatchSet patchSet3 = r.getChange().currentPatchSet();
+
+    r = amendChange(changeId);
+    r.assertOkStatus();
+    PatchSet patchSet4 = r.getChange().currentPatchSet();
+
+    // Vote on the third patch set.
+    vote(admin, changeId, patchSet3.number(), 2, 1);
+    vote(user, changeId, patchSet3.number(), -2, -1);
+
+    // Verify that votes have been copied to the current patch set.
+    ChangeInfo c = detailedChange(changeId);
+    assertCurrentVotes(c, admin, 2, 1);
+    assertCurrentVotes(c, user, -2, -1);
+
+    // Verify the approvals in NoteDb.
+    assertNoApprovals(patchSet1.id(), admin);
+    assertNoApprovals(patchSet1.id(), user);
+    assertNoApprovals(patchSet2.id(), admin);
+    assertNoApprovals(patchSet2.id(), user);
+    assertApprovals(patchSet3.id(), admin, 2, 1, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet3.id(), user, -2, -1, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet4.id(), admin, 2, 1, /* expectedToBeCopied= */ true);
+    assertApprovals(patchSet4.id(), user, -2, -1, /* expectedToBeCopied= */ true);
+  }
+
+  private void updateCodeReviewLabel(Consumer<LabelType.Builder> update) throws Exception {
+    updateLabel(LabelId.CODE_REVIEW, update);
+  }
+
+  private void updateVerifiedLabel(Consumer<LabelType.Builder> update) throws Exception {
+    updateLabel(LabelId.VERIFIED, update);
+  }
+
+  private void updateLabel(String labelName, Consumer<LabelType.Builder> update) throws Exception {
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig().updateLabelType(labelName, update);
+      u.save();
+    }
+  }
+
+  private ChangeInfo detailedChange(String changeId) throws Exception {
+    return gApi.changes().id(changeId).get(DETAILED_LABELS, CURRENT_REVISION, CURRENT_COMMIT);
+  }
+
+  private void vote(
+      TestAccount user, String changeId, int psNum, int codeReviewVote, int verifiedVote)
+      throws Exception {
+    requestScopeOperations.setApiUser(user.id());
+    ReviewInput in =
+        new ReviewInput()
+            .label(LabelId.CODE_REVIEW, codeReviewVote)
+            .label(LabelId.VERIFIED, verifiedVote);
+    gApi.changes().id(changeId).revision(psNum).review(in);
+  }
+
+  private void deleteCurrentVotes(TestAccount user, String changeId) throws Exception {
+    requestScopeOperations.setApiUser(user.id());
+    deleteCurrentVote(user, changeId, LabelId.CODE_REVIEW);
+    deleteCurrentVote(user, changeId, LabelId.VERIFIED);
+  }
+
+  private void deleteCurrentVote(TestAccount user, String changeId, String label) throws Exception {
+    requestScopeOperations.setApiUser(user.id());
+    gApi.changes().id(changeId).reviewer(user.id().toString()).deleteVote(label);
+  }
+
+  private void assertCurrentVotes(
+      ChangeInfo c, TestAccount user, int codeReviewVote, int verifiedVote) {
+    assertCurrentVote(c, user, LabelId.CODE_REVIEW, codeReviewVote);
+    assertCurrentVote(c, user, LabelId.VERIFIED, verifiedVote);
+  }
+
+  private void assertCurrentVote(ChangeInfo c, TestAccount user, String label, int expectedVote) {
+    Integer vote = 0;
+    if (c.labels.get(label) != null && c.labels.get(label).all != null) {
+      for (ApprovalInfo approval : c.labels.get(label).all) {
+        if (approval._accountId == user.id().get()) {
+          vote = approval.value;
+          break;
+        }
+      }
+    }
+
+    assertWithMessage("label = " + label).that(vote).isEqualTo(expectedVote);
+  }
+
+  private void assertNoApprovals(PatchSet.Id patchSetId, TestAccount user) {
+    assertNoApproval(patchSetId, user, LabelId.CODE_REVIEW);
+    assertNoApproval(patchSetId, user, LabelId.VERIFIED);
+  }
+
+  private void assertNoApproval(PatchSet.Id patchSetId, TestAccount user, String label) {
+    ChangeNotes notes = notesFactory.create(project, patchSetId.changeId());
+    Optional<PatchSetApproval> patchSetApproval =
+        notes.getApprovals().all().get(patchSetId).stream()
+            .filter(psa -> psa.accountId().equals(user.id()) && psa.label().equals(label))
+            .findAny();
+    assertThat(patchSetApproval).isEmpty();
+  }
+
+  private void assertApprovals(
+      PatchSet.Id patchSetId,
+      TestAccount user,
+      int expectedCodeReviewVote,
+      int expectedVerifiedVote,
+      boolean expectedToBeCopied) {
+    assertApproval(
+        patchSetId, user, LabelId.CODE_REVIEW, expectedCodeReviewVote, expectedToBeCopied);
+    assertApproval(patchSetId, user, LabelId.VERIFIED, expectedVerifiedVote, expectedToBeCopied);
+  }
+
+  private void assertApproval(
+      PatchSet.Id patchSetId,
+      TestAccount user,
+      String label,
+      int expectedVote,
+      boolean expectedToBeCopied) {
+    ChangeNotes notes = notesFactory.create(project, patchSetId.changeId());
+    Optional<PatchSetApproval> patchSetApproval =
+        notes.getApprovals().all().get(patchSetId).stream()
+            .filter(psa -> psa.accountId().equals(user.id()) && psa.label().equals(label))
+            .findAny();
+    assertThat(patchSetApproval).isPresent();
+    assertThat(patchSetApproval.get().value()).isEqualTo((short) expectedVote);
+    assertThat(patchSetApproval.get().copied()).isEqualTo(expectedToBeCopied);
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java b/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java
index dcd8f77f..9e7a693 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java
@@ -67,6 +67,7 @@
 import com.google.gerrit.extensions.validators.CommentValidationContext;
 import com.google.gerrit.extensions.validators.CommentValidator;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.change.EmailReviewComments;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.restapi.change.OnPostReview;
@@ -79,6 +80,7 @@
 import com.google.inject.Module;
 import java.sql.Timestamp;
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.Collection;
 import java.util.List;
 import java.util.Map;
@@ -1060,9 +1062,17 @@
 
     @Override
     public Optional<SubmitRecord> evaluate(ChangeData changeData) {
-      count++;
+      if (!isAsyncCallForSendingReviewCommentsEmail()) {
+        count++;
+      }
       return Optional.empty();
     }
+
+    private boolean isAsyncCallForSendingReviewCommentsEmail() {
+      return Arrays.stream(Thread.currentThread().getStackTrace())
+          .map(StackTraceElement::getClassName)
+          .anyMatch(className -> EmailReviewComments.class.getName().equals(className));
+    }
   }
 
   private static class TestReviewerAddedListener implements ReviewerAddedListener {
@@ -1100,6 +1110,6 @@
   private static void assertAttentionSet(
       ImmutableSet<AttentionSetUpdate> attentionSet, Account.Id... accounts) {
     assertThat(attentionSet.stream().map(AttentionSetUpdate::account).collect(Collectors.toList()))
-        .containsExactly(accounts);
+        .containsExactlyElementsIn(accounts);
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java b/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java
index 407d04e..9de33be 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java
@@ -20,7 +20,11 @@
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.util.stream.Collectors.toList;
 
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.ExtensionRegistry;
+import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestProjectInput;
 import com.google.gerrit.acceptance.config.GerritConfig;
@@ -46,6 +50,10 @@
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.server.ChangeUtil;
+import com.google.gerrit.server.events.CommitReceivedEvent;
+import com.google.gerrit.server.git.validators.CommitValidationException;
+import com.google.gerrit.server.git.validators.CommitValidationListener;
+import com.google.gerrit.server.git.validators.CommitValidationMessage;
 import com.google.gerrit.server.permissions.PermissionDeniedException;
 import com.google.gerrit.testing.FakeEmailSender.Message;
 import com.google.inject.Inject;
@@ -64,6 +72,7 @@
 
   @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
+  @Inject private ExtensionRegistry extensionRegistry;
 
   @Test
   public void pureRevertFactBlocksSubmissionOfNonReverts() throws Exception {
@@ -513,6 +522,24 @@
   }
 
   @Test
+  public void revertWithValidationOptions() throws Exception {
+    PushOneCommit.Result result = createChange();
+    approve(result.getChangeId());
+    gApi.changes().id(result.getChangeId()).current().submit();
+
+    RevertInput revertInput = new RevertInput();
+    revertInput.validationOptions = ImmutableMap.of("key", "value");
+
+    TestCommitValidationListener testCommitValidationListener = new TestCommitValidationListener();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(testCommitValidationListener)) {
+      gApi.changes().id(result.getChangeId()).revert(revertInput);
+      assertThat(testCommitValidationListener.receiveEvent.pushOptions)
+          .containsExactly("key", "value");
+    }
+  }
+
+  @Test
   @GerritConfig(name = "change.submitWholeTopic", value = "true")
   public void cantCreateRevertSubmissionWithoutProjectWritePermission() throws Exception {
     String secondProject = "secondProject";
@@ -1463,4 +1490,15 @@
     input.workInProgress = true;
     return input;
   }
+
+  private static class TestCommitValidationListener implements CommitValidationListener {
+    public CommitReceivedEvent receiveEvent;
+
+    @Override
+    public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
+        throws CommitValidationException {
+      this.receiveEvent = receiveEvent;
+      return ImmutableList.of();
+    }
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java b/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java
index 8dbef88..2668d1f 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java
@@ -33,7 +33,6 @@
 import static java.util.Comparator.comparing;
 
 import com.google.common.cache.Cache;
-import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.MoreCollectors;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
@@ -113,13 +112,11 @@
               value(0, "No score"),
               value(-1, "I would prefer this is not submitted as is"),
               value(-2, "This shall not be submitted"));
-      codeReview.setCopyAllScoresIfNoChange(false);
       u.getConfig().upsertLabelType(codeReview.build());
 
       LabelType.Builder verified =
           labelBuilder(
               LabelId.VERIFIED, value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
-      verified.setCopyAllScoresIfNoChange(false);
       u.getConfig().upsertLabelType(verified.build());
 
       u.save();
@@ -147,19 +144,9 @@
   }
 
   @Test
-  public void stickyOnAnyScore_withoutCopyCondition() throws Exception {
-    updateCodeReviewLabel(b -> b.setCopyAnyScore(true));
-    testStickyOnAnyScore();
-  }
-
-  @Test
-  public void stickyOnAnyScore_withCopyCondition() throws Exception {
+  public void stickyOnAnyScore() throws Exception {
     updateCodeReviewLabel(b -> b.setCopyCondition("is:any"));
-    testStickyOnAnyScore();
-  }
 
-  @Test
-  public void stickyOnRework() throws Exception {
     updateCodeReviewLabel(b -> b.setCopyCondition("changekind:REWORK"));
 
     // changekind:REWORK should match all kind of changes so that approvals are always copied.
@@ -185,18 +172,9 @@
   }
 
   @Test
-  public void stickyOnMinScore_withoutCopyCondition() throws Exception {
-    updateCodeReviewLabel(b -> b.setCopyMinScore(true));
-    testStickyOnMinScore();
-  }
-
-  @Test
-  public void stickyOnMinScore_withCopyCondition() throws Exception {
+  public void stickyOnMinScore() throws Exception {
     updateCodeReviewLabel(b -> b.setCopyCondition("is:min"));
-    testStickyOnMinScore();
-  }
 
-  private void testStickyOnMinScore() throws Exception {
     for (ChangeKind changeKind :
         EnumSet.of(REWORK, TRIVIAL_REBASE, NO_CODE_CHANGE, MERGE_FIRST_PARENT_UPDATE, NO_CHANGE)) {
       testRepo.reset(projectOperations.project(project).getHead("master"));
@@ -213,18 +191,9 @@
   }
 
   @Test
-  public void stickyOnMaxScore_withoutCopyCondition() throws Exception {
-    updateCodeReviewLabel(b -> b.setCopyMaxScore(true));
-    testStickyOnMaxScore();
-  }
-
-  @Test
-  public void stickyOnMaxScore_withCopyCondition() throws Exception {
+  public void stickyOnMaxScore() throws Exception {
     updateCodeReviewLabel(b -> b.setCopyCondition("is:max"));
-    testStickyOnMaxScore();
-  }
 
-  private void testStickyOnMaxScore() throws Exception {
     for (ChangeKind changeKind :
         EnumSet.of(REWORK, TRIVIAL_REBASE, NO_CODE_CHANGE, MERGE_FIRST_PARENT_UPDATE, NO_CHANGE)) {
       testRepo.reset(projectOperations.project(project).getHead("master"));
@@ -241,18 +210,9 @@
   }
 
   @Test
-  public void stickyOnCopyValues_withoutCopyCondition() throws Exception {
-    updateCodeReviewLabel(b -> b.setCopyValues(ImmutableList.of((short) -1, (short) 1)));
-    testStickyOnCopyValues();
-  }
-
-  @Test
-  public void stickyOnCopyValues_withCopyCondition() throws Exception {
+  public void stickyOnCopyValues() throws Exception {
     updateCodeReviewLabel(b -> b.setCopyCondition("is:\"-1\" OR is:1"));
-    testStickyOnCopyValues();
-  }
 
-  private void testStickyOnCopyValues() throws Exception {
     TestAccount user2 = accountCreator.user2();
 
     for (ChangeKind changeKind :
@@ -273,18 +233,9 @@
   }
 
   @Test
-  public void stickyOnTrivialRebase_withoutCopyCondition() throws Exception {
-    updateCodeReviewLabel(b -> b.setCopyAllScoresOnTrivialRebase(true));
-    testStickyOnTrivialRebase();
-  }
-
-  @Test
-  public void stickyOnTrivialRebase_withCopyCondition() throws Exception {
+  public void stickyOnTrivialRebase() throws Exception {
     updateCodeReviewLabel(b -> b.setCopyCondition("changekind:" + TRIVIAL_REBASE.name()));
-    testStickyOnTrivialRebase();
-  }
 
-  private void testStickyOnTrivialRebase() throws Exception {
     String changeId = changeKindCreator.createChange(TRIVIAL_REBASE, testRepo, admin);
     vote(admin, changeId, 2, 1);
     vote(user, changeId, -2, -1);
@@ -326,18 +277,9 @@
   }
 
   @Test
-  public void stickyOnNoCodeChange_withoutCopyCondition() throws Exception {
-    updateVerifiedLabel(b -> b.setCopyAllScoresIfNoCodeChange(true));
-    testStickyOnNoCodeChange();
-  }
-
-  @Test
-  public void stickyOnNoCodeChange_withCopyCondition() throws Exception {
+  public void stickyOnNoCodeChange() throws Exception {
     updateVerifiedLabel(b -> b.setCopyCondition("changekind:" + NO_CODE_CHANGE.name()));
-    testStickyOnNoCodeChange();
-  }
 
-  private void testStickyOnNoCodeChange() throws Exception {
     String changeId = changeKindCreator.createChange(NO_CODE_CHANGE, testRepo, admin);
     vote(admin, changeId, 2, 1);
     vote(user, changeId, -2, -1);
@@ -356,19 +298,10 @@
   }
 
   @Test
-  public void stickyOnMergeFirstParentUpdate_withoutCopyCondition() throws Exception {
-    updateCodeReviewLabel(b -> b.setCopyAllScoresOnMergeFirstParentUpdate(true));
-    testStickyOnMergeFirstParentUpdate();
-  }
-
-  @Test
-  public void stickyOnMergeFirstParentUpdate_withCopyCondition() throws Exception {
+  public void stickyOnMergeFirstParentUpdate() throws Exception {
     updateCodeReviewLabel(
         b -> b.setCopyCondition("changekind:" + MERGE_FIRST_PARENT_UPDATE.name()));
-    testStickyOnMergeFirstParentUpdate();
-  }
 
-  private void testStickyOnMergeFirstParentUpdate() throws Exception {
     String changeId = changeKindCreator.createChange(MERGE_FIRST_PARENT_UPDATE, testRepo, admin);
     vote(admin, changeId, 2, 1);
     vote(user, changeId, -2, -1);
@@ -387,24 +320,11 @@
   }
 
   @Test
-  public void
-      notStickyOnNoChangeForNonMergeIfCopyingIsConfiguredForMergeFirstParentUpdate_withoutCopyCondition()
-          throws Exception {
-    updateCodeReviewLabel(b -> b.setCopyAllScoresOnMergeFirstParentUpdate(true));
-    testNotStickyOnNoChangeForNonMergeIfCopyingIsConfiguredForMergeFirstParentUpdate();
-  }
-
-  @Test
-  public void
-      notStickyOnNoChangeForNonMergeIfCopyingIsConfiguredForMergeFirstParentUpdate_withCopyCondition()
-          throws Exception {
+  public void notStickyOnNoChangeForNonMergeIfCopyingIsConfiguredForMergeFirstParentUpdate()
+      throws Exception {
     updateCodeReviewLabel(
         b -> b.setCopyCondition("changekind:" + MERGE_FIRST_PARENT_UPDATE.name()));
-    testNotStickyOnNoChangeForNonMergeIfCopyingIsConfiguredForMergeFirstParentUpdate();
-  }
 
-  private void testNotStickyOnNoChangeForNonMergeIfCopyingIsConfiguredForMergeFirstParentUpdate()
-      throws Exception {
     // Create a change with a non-merge commit
     String changeId = changeKindCreator.createChange(REWORK, testRepo, admin);
     vote(admin, changeId, 2, 1);
@@ -418,20 +338,9 @@
   }
 
   @Test
-  public void notStickyWithCopyOnNoChangeWhenSecondParentIsUpdated_withoutCopyCondition()
-      throws Exception {
-    updateCodeReviewLabel(b -> b.setCopyAllScoresIfNoChange(true));
-    testNotStickyWithCopyOnNoChangeWhenSecondParentIsUpdated();
-  }
-
-  @Test
-  public void notStickyWithCopyOnNoChangeWhenSecondParentIsUpdated_withCopyCondition()
-      throws Exception {
+  public void notStickyWithCopyOnNoChangeWhenSecondParentIsUpdated() throws Exception {
     updateCodeReviewLabel(b -> b.setCopyCondition("changekind:" + NO_CHANGE.name()));
-    testNotStickyWithCopyOnNoChangeWhenSecondParentIsUpdated();
-  }
 
-  private void testNotStickyWithCopyOnNoChangeWhenSecondParentIsUpdated() throws Exception {
     String changeId = changeKindCreator.createChangeForMergeCommit(testRepo, admin);
     vote(admin, changeId, 2, 1);
     vote(user, changeId, -2, -1);
@@ -443,22 +352,10 @@
   }
 
   @Test
-  public void
-      notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsAdded_withoutCopyCondition()
-          throws Exception {
-    updateCodeReviewLabel(b -> b.setCopyAllScoresIfListOfFilesDidNotChange(true));
-    testNotStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsAdded();
-  }
-
-  @Test
-  public void notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsAdded_withCopyCondition()
+  public void notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsAdded()
       throws Exception {
     updateCodeReviewLabel(b -> b.setCopyCondition("has:unchanged-files"));
-    testNotStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsAdded();
-  }
 
-  private void testNotStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsAdded()
-      throws Exception {
     Change.Id changeId =
         changeOperations.newChange().project(project).file("file").content("content").create();
     vote(admin, changeId.toString(), 2, 1);
@@ -478,23 +375,10 @@
   }
 
   @Test
-  public void
-      notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileAlreadyExists_withoutCopyCondition()
-          throws Exception {
-    updateCodeReviewLabel(b -> b.setCopyAllScoresIfListOfFilesDidNotChange(true));
-    testNotStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileAlreadyExists();
-  }
-
-  @Test
-  public void
-      notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileAlreadyExists_withCopyCondition()
-          throws Exception {
-    updateCodeReviewLabel(b -> b.setCopyCondition("has:unchanged-files"));
-    testNotStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileAlreadyExists();
-  }
-
-  private void testNotStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileAlreadyExists()
+  public void notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileAlreadyExists()
       throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("has:unchanged-files"));
+
     // create "existing file" and submit it.
     String existingFile = "existing file";
     Change.Id prep =
@@ -526,23 +410,10 @@
   }
 
   @Test
-  public void
-      notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsDeleted_withoutCopyCondition()
-          throws Exception {
-    updateCodeReviewLabel(b -> b.setCopyAllScoresIfListOfFilesDidNotChange(true));
-    testNotStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsDeleted();
-  }
-
-  @Test
-  public void
-      notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsDeleted_withCopyCondition()
-          throws Exception {
-    updateCodeReviewLabel(b -> b.setCopyCondition("has:unchanged-files"));
-    testNotStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsDeleted();
-  }
-
-  private void testNotStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsDeleted()
+  public void notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsDeleted()
       throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("has:unchanged-files"));
+
     Change.Id changeId =
         changeOperations.newChange().project(project).file("file").content("content").create();
     vote(admin, changeId.toString(), 2, 1);
@@ -557,22 +428,10 @@
   }
 
   @Test
-  public void
-      stickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModified_withoutCopyCondition()
-          throws Exception {
-    updateCodeReviewLabel(b -> b.setCopyAllScoresIfListOfFilesDidNotChange(true));
-    testStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModified();
-  }
-
-  @Test
-  public void stickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModified_withCopyCondition()
+  public void stickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModified()
       throws Exception {
     updateCodeReviewLabel(b -> b.setCopyCondition("has:unchanged-files"));
-    testStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModified();
-  }
 
-  private void testStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModified()
-      throws Exception {
     Change.Id changeId =
         changeOperations.newChange().project(project).file("file").content("content").create();
     vote(admin, changeId.toString(), 2, 1);
@@ -588,23 +447,10 @@
   }
 
   @Test
-  public void
-      stickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedDueToRebase_withoutCopyCondition()
-          throws Exception {
-    updateCodeReviewLabel(b -> b.setCopyAllScoresIfListOfFilesDidNotChange(true));
-    testStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedDueToRebase();
-  }
-
-  @Test
-  public void
-      stickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedDueToRebase_withCopyCondition()
-          throws Exception {
-    updateCodeReviewLabel(b -> b.setCopyCondition("has:unchanged-files"));
-    testStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedDueToRebase();
-  }
-
-  private void testStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedDueToRebase()
+  public void stickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedDueToRebase()
       throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("has:unchanged-files"));
+
     // Create two changes both with the same parent
     PushOneCommit.Result r = createChange();
     testRepo.reset("HEAD~1");
@@ -626,7 +472,7 @@
     // The code-review approval is copied for the second change between PS1 and PS2 since the only
     // modified file is due to rebase.
     List<PatchSetApproval> patchSetApprovals =
-        r2.getChange().notes().getApprovalsWithCopied().values().stream()
+        r2.getChange().notes().getApprovals().all().values().stream()
             .sorted(comparing(a -> a.patchSetId().get()))
             .collect(toImmutableList());
     PatchSetApproval nonCopied = patchSetApprovals.get(0);
@@ -637,25 +483,10 @@
 
   @Test
   @TestProjectInput(createEmptyCommit = false)
-  public void
-      stickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedAsInitialCommit_withoutCopyCondition()
-          throws Exception {
-    updateCodeReviewLabel(b -> b.setCopyAllScoresIfListOfFilesDidNotChange(true));
-    testStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedAsInitialCommit();
-  }
-
-  @Test
-  @TestProjectInput(createEmptyCommit = false)
-  public void
-      stickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedAsInitialCommit_withCopyCondition()
-          throws Exception {
+  public void stickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedAsInitialCommit()
+      throws Exception {
     updateCodeReviewLabel(b -> b.setCopyCondition("has:unchanged-files"));
-    testStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedAsInitialCommit();
-  }
 
-  private void
-      testStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedAsInitialCommit()
-          throws Exception {
     Change.Id changeId =
         changeOperations.newChange().project(project).file("file").content("content").create();
     vote(admin, changeId.toString(), 2, 1);
@@ -672,23 +503,10 @@
 
   @Test
   public void
-      notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedOnEarlierPatchset_withoutCopyCondition()
-          throws Exception {
-    updateCodeReviewLabel(b -> b.setCopyAllScoresIfListOfFilesDidNotChange(true));
-    testNotStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedOnEarlierPatchset();
-  }
-
-  @Test
-  public void
-      notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedOnEarlierPatchset_withCopyCondition()
+      notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedOnEarlierPatchset()
           throws Exception {
     updateCodeReviewLabel(b -> b.setCopyCondition("has:unchanged-files"));
-    testNotStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedOnEarlierPatchset();
-  }
 
-  private void
-      testNotStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedOnEarlierPatchset()
-          throws Exception {
     Change.Id changeId =
         changeOperations.newChange().project(project).file("file").content("content").create();
     vote(admin, changeId.toString(), 2, 1);
@@ -709,23 +527,10 @@
   }
 
   @Test
-  public void
-      notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsRenamed_withoutCopyCondition()
-          throws Exception {
-    updateCodeReviewLabel(b -> b.setCopyAllScoresIfListOfFilesDidNotChange(true));
-    testNotStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsRenamed();
-  }
-
-  @Test
-  public void
-      notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsRenamed_withCopyCondition()
-          throws Exception {
-    updateCodeReviewLabel(b -> b.setCopyCondition("has:unchanged-files"));
-    testNotStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsRenamed();
-  }
-
-  private void testNotStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsRenamed()
+  public void notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsRenamed()
       throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("has:unchanged-files"));
+
     Change.Id changeId =
         changeOperations.newChange().project(project).file("file").content("content").create();
     vote(admin, changeId.toString(), 2, 1);
@@ -740,18 +545,9 @@
   }
 
   @Test
-  public void copyWithListOfFilesUnchanged_withoutCopyCondition() throws Exception {
-    updateCodeReviewLabel(b -> b.setCopyAllScoresIfListOfFilesDidNotChange(true));
-    testCopyWithListOfFilesUnchanged();
-  }
-
-  @Test
-  public void copyWithListOfFilesUnchanged_withCopyCondition() throws Exception {
+  public void copyWithListOfFilesUnchanged() throws Exception {
     updateCodeReviewLabel(b -> b.setCopyCondition("has:unchanged-files"));
-    testCopyWithListOfFilesUnchanged();
-  }
 
-  private void testCopyWithListOfFilesUnchanged() throws Exception {
     Change.Id changeId =
         changeOperations.newChange().project(project).file("file").content("content").create();
     vote(admin, changeId.toString(), 2, 1);
@@ -969,20 +765,10 @@
   }
 
   @Test
-  public void removedVotesNotSticky_withoutCopyCondition() throws Exception {
-    updateCodeReviewLabel(b -> b.setCopyAllScoresOnTrivialRebase(true));
-    updateVerifiedLabel(b -> b.setCopyAllScoresIfNoCodeChange(true));
-    testRemovedVotesNotSticky();
-  }
-
-  @Test
-  public void removedVotesNotSticky_withCopyCondition() throws Exception {
+  public void removedVotesNotSticky() throws Exception {
     updateCodeReviewLabel(b -> b.setCopyCondition("changekind:" + TRIVIAL_REBASE.name()));
     updateVerifiedLabel(b -> b.setCopyCondition("changekind:" + NO_CODE_CHANGE.name()));
-    testRemovedVotesNotSticky();
-  }
 
-  private void testRemovedVotesNotSticky() throws Exception {
     for (ChangeKind changeKind :
         EnumSet.of(REWORK, TRIVIAL_REBASE, NO_CODE_CHANGE, MERGE_FIRST_PARENT_UPDATE, NO_CHANGE)) {
       testRepo.reset(projectOperations.project(project).getHead("master"));
@@ -1006,20 +792,10 @@
   }
 
   @Test
-  public void stickyAcrossMultiplePatchSets_withoutCopyCondition() throws Exception {
-    updateCodeReviewLabel(b -> b.setCopyMaxScore(true));
-    updateVerifiedLabel(b -> b.setCopyAllScoresIfNoCodeChange(true));
-    testStickyAcrossMultiplePatchSets();
-  }
-
-  @Test
-  public void stickyAcrossMultiplePatchSets_withCopyCondition() throws Exception {
+  public void stickyAcrossMultiplePatchSets() throws Exception {
     updateCodeReviewLabel(b -> b.setCopyCondition("is:MAX"));
     updateVerifiedLabel(b -> b.setCopyCondition("changekind:" + NO_CODE_CHANGE.name()));
-    testStickyAcrossMultiplePatchSets();
-  }
 
-  private void testStickyAcrossMultiplePatchSets() throws Exception {
     String changeId = changeKindCreator.createChange(REWORK, testRepo, admin);
     vote(admin, changeId, 2, 1);
 
@@ -1035,27 +811,15 @@
   }
 
   @Test
-  public void stickyAcrossMultiplePatchSetsDoNotRegressPerformance_withoutCopyCondition()
-      throws Exception {
-    updateCodeReviewLabel(b -> b.setCopyMaxScore(true));
-    updateVerifiedLabel(b -> b.setCopyAllScoresIfNoCodeChange(true));
-    testStickyAcrossMultiplePatchSetsDoNotRegressPerformance();
-  }
-
-  @Test
-  public void stickyAcrossMultiplePatchSetsDoNotRegressPerformance_withCopyCondition()
-      throws Exception {
-    updateCodeReviewLabel(b -> b.setCopyCondition("is:MAX"));
-    updateVerifiedLabel(b -> b.setCopyCondition("changekind:" + NO_CODE_CHANGE.name()));
-    testStickyAcrossMultiplePatchSetsDoNotRegressPerformance();
-  }
-
-  private void testStickyAcrossMultiplePatchSetsDoNotRegressPerformance() throws Exception {
+  public void stickyAcrossMultiplePatchSetsDoNotRegressPerformance() throws Exception {
     // The purpose of this test is to make sure that we compute change kind only against the parent
     // patch set. Change kind is a heavy operation. In prior version of Gerrit, we computed the
     // change kind against all prior patch sets. This is a regression that made Gerrit do expensive
     // work in O(num-patch-sets). This test ensures that we aren't regressing.
 
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:MAX"));
+    updateVerifiedLabel(b -> b.setCopyCondition("changekind:" + NO_CODE_CHANGE.name()));
+
     String changeId = changeKindCreator.createChange(REWORK, testRepo, admin);
     vote(admin, changeId, 2, 1);
     changeKindCreator.updateChange(changeId, NO_CODE_CHANGE, testRepo, admin, project);
@@ -1081,18 +845,9 @@
   }
 
   @Test
-  public void copyMinMaxAcrossMultiplePatchSets_withoutCopyCondition() throws Exception {
-    updateCodeReviewLabel(b -> b.setCopyMaxScore(true).setCopyMinScore(true));
-    testCopyMinMaxAcrossMultiplePatchSets();
-  }
-
-  @Test
-  public void copyMinMaxAcrossMultiplePatchSets_withCopyCondition() throws Exception {
+  public void copyMinMaxAcrossMultiplePatchSets() throws Exception {
     updateCodeReviewLabel(b -> b.setCopyCondition("is:MAX OR is:MIN"));
-    testCopyMinMaxAcrossMultiplePatchSets();
-  }
 
-  private void testCopyMinMaxAcrossMultiplePatchSets() throws Exception {
     // Vote max score on PS1
     String changeId = changeKindCreator.createChange(REWORK, testRepo, admin);
     vote(admin, changeId, 2, 1);
@@ -1126,18 +881,9 @@
   }
 
   @Test
-  public void deleteStickyVote_withoutCopyCondition() throws Exception {
-    updateCodeReviewLabel(b -> b.setCopyMaxScore(true));
-    testDeleteStickyVote();
-  }
-
-  @Test
-  public void deleteStickyVote_withCopyCondition() throws Exception {
+  public void deleteStickyVote() throws Exception {
     updateCodeReviewLabel(b -> b.setCopyCondition("is:MAX"));
-    testDeleteStickyVote();
-  }
 
-  private void testDeleteStickyVote() throws Exception {
     String label = LabelId.CODE_REVIEW;
 
     // Vote max score on PS1
@@ -1153,18 +899,9 @@
   }
 
   @Test
-  public void canVoteMultipleTimesOnNewPatchsets_withoutCopyCondition() throws Exception {
-    updateCodeReviewLabel(b -> b.setCopyAnyScore(true));
-    testCanVoteMultipleTimesOnNewPatchsets();
-  }
-
-  @Test
-  public void canVoteMultipleTimesOnNewPatchsets_withCopyCondition() throws Exception {
+  public void canVoteMultipleTimesOnNewPatchsets() throws Exception {
     updateCodeReviewLabel(b -> b.setCopyCondition("is:ANY"));
-    testCanVoteMultipleTimesOnNewPatchsets();
-  }
 
-  private void testCanVoteMultipleTimesOnNewPatchsets() throws Exception {
     PushOneCommit.Result r = createChange();
 
     // Add a new vote.
@@ -1172,30 +909,110 @@
     gApi.changes().id(r.getChangeId()).current().review(input);
 
     // Make a new patchset, keeping the Code-Review +2 vote.
-    amendChange(r.getChangeId());
+    r = amendChange(r.getChangeId());
+
+    // Verify that the approval has been copied to patch set 2 (use the currentApprovals() method
+    // rather than the approvals() method since the latter one filters out copied approvals).
+    List<PatchSetApproval> approvalsPs2 = r.getChange().currentApprovals();
+    assertThat(approvalsPs2).hasSize(1);
+    assertThat(Iterables.getOnlyElement(approvalsPs2).copied()).isTrue();
 
     // Post without changing the vote.
     input = new ReviewInput().label(LabelId.CODE_REVIEW, 2);
     gApi.changes().id(r.getChangeId()).current().review(input);
 
-    // There is a vote both on patchset 1 and on patchset 2, although both votes are Code-Review +2.
+    // There is a vote both on patch set 1 and on patch set 2, although both votes are Code-Review
+    // +2. The approval on patch set 2 is no longer copied since it was reapplied.
     assertThat(r.getChange().approvals().get(PatchSet.id(r.getChange().getId(), 1))).hasSize(1);
-    assertThat(r.getChange().approvals().get(PatchSet.id(r.getChange().getId(), 2))).hasSize(1);
+    approvalsPs2 = r.getChange().currentApprovals();
+    assertThat(approvalsPs2).hasSize(1);
+    assertThat(Iterables.getOnlyElement(approvalsPs2).copied()).isFalse();
   }
 
   @Test
-  public void stickyVoteStoredOnUpload_withoutCopyCondition() throws Exception {
-    updateCodeReviewLabel(b -> b.setCopyAnyScore(true));
-    testStickyVoteStoredOnUpload();
-  }
-
-  @Test
-  public void stickyVoteStoredOnUpload_withCopyCondition() throws Exception {
+  public void copiedVoteIsNotReapplied_onVoteOnOtherLabel() throws Exception {
     updateCodeReviewLabel(b -> b.setCopyCondition("is:ANY"));
-    testStickyVoteStoredOnUpload();
+
+    PushOneCommit.Result r = createChange();
+
+    // Add vote that will be copied.
+    ReviewInput input = new ReviewInput().label(LabelId.CODE_REVIEW, 2);
+    gApi.changes().id(r.getChangeId()).current().review(input);
+
+    // Create a new patchset, the Code-Review +2 vote is copied.
+    r = amendChange(r.getChangeId());
+
+    // Verify that the approval has been copied to patch set 2 (use the currentApprovals() method
+    // rather than the approvals() method since the latter one filters out copied approvals).
+    List<PatchSetApproval> approvalsPs2 = r.getChange().currentApprovals();
+    assertThat(approvalsPs2).hasSize(1);
+    assertThat(Iterables.getOnlyElement(approvalsPs2).copied()).isTrue();
+
+    // Vote on another label. This shouldn't touch the copied approval.
+    input = new ReviewInput().label(LabelId.VERIFIED, 1);
+    gApi.changes().id(r.getChangeId()).current().review(input);
+
+    // Patch set 2 has 2 approvals now, one copied approval for the Code-Review label and one
+    // non-copied
+    // approval for the Verified label.
+    approvalsPs2 = r.getChange().currentApprovals();
+    assertThat(approvalsPs2).hasSize(2);
+    assertThat(
+            Iterables.getOnlyElement(
+                    approvalsPs2.stream()
+                        .filter(psa -> LabelId.CODE_REVIEW.equals(psa.label()))
+                        .collect(toImmutableList()))
+                .copied())
+        .isTrue();
+    assertThat(
+            Iterables.getOnlyElement(
+                    approvalsPs2.stream()
+                        .filter(psa -> LabelId.VERIFIED.equals(psa.label()))
+                        .collect(toImmutableList()))
+                .copied())
+        .isFalse();
   }
 
-  private void testStickyVoteStoredOnUpload() throws Exception {
+  @Test
+  public void copiedVoteIsNotReapplied_onRebase() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:ANY"));
+
+    PushOneCommit.Result r = createChange();
+
+    // Create a sibling change
+    testRepo.reset("HEAD~1");
+    PushOneCommit.Result r2 = createChange();
+
+    // Add vote that will be copied.
+    approve(r2.getChangeId());
+
+    // Verify that that the approval exists and is not copied.
+    List<PatchSetApproval> approvalsPs2 = r2.getChange().currentApprovals();
+    assertThat(approvalsPs2).hasSize(1);
+    assertThat(Iterables.getOnlyElement(approvalsPs2).copied()).isFalse();
+
+    // Approve, verify and submit the first change.
+    approve(r.getChangeId());
+    gApi.changes()
+        .id(r.getChangeId())
+        .current()
+        .review(new ReviewInput().label(LabelId.VERIFIED, 1));
+    gApi.changes().id(r.getChangeId()).current().submit();
+
+    // Rebase the second change, the approval should be sticky.
+    gApi.changes().id(r2.getChangeId()).rebase();
+
+    // Verify that the approval has been copied to patch set 2 (use the currentApprovals() method
+    // rather than the approvals() method since the latter one filters out copied approvals).
+    approvalsPs2 = changeDataFactory.create(project, r2.getChange().getId()).currentApprovals();
+    assertThat(approvalsPs2).hasSize(1);
+    assertThat(Iterables.getOnlyElement(approvalsPs2).copied()).isTrue();
+  }
+
+  @Test
+  public void stickyVoteStoredOnUpload() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:ANY"));
+
     PushOneCommit.Result r = createChange();
     // Add a new vote.
     ReviewInput input = new ReviewInput().label(LabelId.CODE_REVIEW, 2);
@@ -1208,7 +1025,7 @@
     }
 
     List<PatchSetApproval> patchSetApprovals =
-        r.getChange().notes().getApprovalsWithCopied().values().stream()
+        r.getChange().notes().getApprovals().all().values().stream()
             .sorted(comparing(a -> a.patchSetId().get()))
             .collect(toImmutableList());
 
@@ -1229,18 +1046,9 @@
   }
 
   @Test
-  public void stickyVoteStoredOnRebase_withoutCopyCondition() throws Exception {
-    updateCodeReviewLabel(b -> b.setCopyAnyScore(true));
-    testStickyVoteStoredOnRebase();
-  }
-
-  @Test
-  public void stickyVoteStoredOnRebase_withCopyCondition() throws Exception {
+  public void stickyVoteStoredOnRebase() throws Exception {
     updateCodeReviewLabel(b -> b.setCopyCondition("is:ANY"));
-    testStickyVoteStoredOnRebase();
-  }
 
-  private void testStickyVoteStoredOnRebase() throws Exception {
     // Create two changes both with the same parent
     PushOneCommit.Result r = createChange();
     testRepo.reset("HEAD~1");
@@ -1258,7 +1066,7 @@
     gApi.changes().id(r2.getChangeId()).rebase();
 
     List<PatchSetApproval> patchSetApprovals =
-        r2.getChange().notes().getApprovalsWithCopied().values().stream()
+        r2.getChange().notes().getApprovals().all().values().stream()
             .sorted(comparing(a -> a.patchSetId().get()))
             .collect(toImmutableList());
     PatchSetApproval nonCopied = patchSetApprovals.get(0);
@@ -1268,18 +1076,9 @@
   }
 
   @Test
-  public void stickyVoteStoredOnUploadWithRealAccount_withoutCopyCondition() throws Exception {
-    updateCodeReviewLabel(b -> b.setCopyAnyScore(true));
-    testStickyVoteStoredOnUploadWithRealAccount();
-  }
-
-  @Test
-  public void stickyVoteStoredOnUploadWithRealAccount_withCopyCondition() throws Exception {
+  public void stickyVoteStoredOnUploadWithRealAccount() throws Exception {
     updateCodeReviewLabel(b -> b.setCopyCondition("is:ANY"));
-    testStickyVoteStoredOnUploadWithRealAccount();
-  }
 
-  private void testStickyVoteStoredOnUploadWithRealAccount() throws Exception {
     // Give "user" permission to vote on behalf of other users.
     projectOperations
         .project(project)
@@ -1304,7 +1103,7 @@
     amendChange(r.getChangeId());
 
     List<PatchSetApproval> patchSetApprovals =
-        r.getChange().notes().getApprovalsWithCopied().values().stream()
+        r.getChange().notes().getApprovals().all().values().stream()
             .sorted(comparing(a -> a.patchSetId().get()))
             .collect(toImmutableList());
 
@@ -1326,19 +1125,9 @@
   }
 
   @Test
-  public void stickyVoteStoredOnUploadWithRealAccountAndTag_withoutCopyCondition()
-      throws Exception {
-    updateCodeReviewLabel(b -> b.setCopyAnyScore(true));
-    testStickyVoteStoredOnUploadWithRealAccountAndTag();
-  }
-
-  @Test
-  public void stickyVoteStoredOnUploadWithRealAccountAndTag_withCopyCondition() throws Exception {
+  public void stickyVoteStoredOnUploadWithRealAccountAndTag() throws Exception {
     updateCodeReviewLabel(b -> b.setCopyCondition("is:ANY"));
-    testStickyVoteStoredOnUploadWithRealAccountAndTag();
-  }
 
-  private void testStickyVoteStoredOnUploadWithRealAccountAndTag() throws Exception {
     // Give "user" permission to vote on behalf of other users.
     projectOperations
         .project(project)
@@ -1364,7 +1153,7 @@
     amendChange(r.getChangeId());
 
     List<PatchSetApproval> patchSetApprovals =
-        r.getChange().notes().getApprovalsWithCopied().values().stream()
+        r.getChange().notes().getApprovals().all().values().stream()
             .sorted(comparing(a -> a.patchSetId().get()))
             .collect(toImmutableList());
 
@@ -1388,18 +1177,9 @@
   }
 
   @Test
-  public void stickyVoteStoredCanBeRemoved_withoutCopyCondition() throws Exception {
-    updateCodeReviewLabel(b -> b.setCopyAnyScore(true));
-    testStickyVoteStoredCanBeRemoved();
-  }
-
-  @Test
-  public void stickyVoteStoredCanBeRemoved_withCopyCondition() throws Exception {
+  public void stickyVoteStoredCanBeRemoved() throws Exception {
     updateCodeReviewLabel(b -> b.setCopyCondition("is:ANY"));
-    testStickyVoteStoredCanBeRemoved();
-  }
 
-  private void testStickyVoteStoredCanBeRemoved() throws Exception {
     PushOneCommit.Result r = createChange();
 
     // Add a new vote
@@ -1416,7 +1196,8 @@
         Iterables.getOnlyElement(
             r.getChange()
                 .notes()
-                .getApprovalsWithCopied()
+                .getApprovals()
+                .all()
                 .get(r.getChange().change().currentPatchSetId()));
 
     assertThat(nonCopiedSecondPatchsetRemovedVote.patchSetId().get()).isEqualTo(2);
@@ -1428,18 +1209,9 @@
   }
 
   @Test
-  public void reviewerStickyVotingCanBeRemoved_withoutCopyConfition() throws Exception {
-    updateCodeReviewLabel(b -> b.setCopyAnyScore(true));
-    testReviewerStickyVotingCanBeRemoved();
-  }
-
-  @Test
-  public void reviewerStickyVotingCanBeRemoved_withCopyCondition() throws Exception {
+  public void reviewerStickyVotingCanBeRemoved() throws Exception {
     updateCodeReviewLabel(b -> b.setCopyCondition("is:ANY"));
-    testReviewerStickyVotingCanBeRemoved();
-  }
 
-  private void testReviewerStickyVotingCanBeRemoved() throws Exception {
     PushOneCommit.Result r = createChange();
 
     // Add a new vote by user
@@ -1453,7 +1225,7 @@
 
     gApi.changes().id(r.getChangeId()).reviewer(user.email()).remove();
 
-    assertThat(r.getChange().notes().getApprovalsWithCopied()).isEmpty();
+    assertThat(r.getChange().notes().getApprovals().all()).isEmpty();
 
     // Changes message has info about vote removed.
     assertThat(Iterables.getLast(gApi.changes().id(r.getChangeId()).messages()).message)
@@ -1461,25 +1233,6 @@
   }
 
   @Test
-  public void stickyWhenEitherBooleanConfigsOrCopyConditionAreTrue() throws Exception {
-    updateCodeReviewLabel(b -> b.setCopyCondition("is:MAX").setCopyMinScore(true));
-
-    for (ChangeKind changeKind :
-        EnumSet.of(REWORK, TRIVIAL_REBASE, NO_CODE_CHANGE, MERGE_FIRST_PARENT_UPDATE, NO_CHANGE)) {
-      testRepo.reset(projectOperations.project(project).getHead("master"));
-
-      String changeId = changeKindCreator.createChange(changeKind, testRepo, admin);
-      vote(admin, changeId, 2, 1);
-      vote(user, changeId, -2, -1);
-
-      changeKindCreator.updateChange(changeId, changeKind, testRepo, admin, project);
-      ChangeInfo c = detailedChange(changeId);
-      assertVotes(c, admin, 2, 0, changeKind);
-      assertVotes(c, user, -2, 0, changeKind);
-    }
-  }
-
-  @Test
   public void sticky_copiedToLatestPatchSetFromSubmitRecords() throws Exception {
     updateVerifiedLabel(b -> b.setFunction(LabelFunction.NO_BLOCK));
 
@@ -1536,8 +1289,7 @@
       vote(admin, changeId, 2, 1);
 
       List<PatchSetApproval> patchSetApprovals =
-          notesFactory.create(project, r.getChange().getId()).getApprovalsWithCopied().values()
-              .stream()
+          notesFactory.create(project, r.getChange().getId()).getApprovals().all().values().stream()
               .sorted(comparing(a -> a.patchSetId().get()))
               .collect(toImmutableList());
 
@@ -1559,8 +1311,7 @@
       gApi.changes().id(changeId).current().submit();
 
       patchSetApprovals =
-          notesFactory.create(project, r.getChange().getId()).getApprovalsWithCopied().values()
-              .stream()
+          notesFactory.create(project, r.getChange().getId()).getApprovals().all().values().stream()
               .sorted(comparing(a -> a.patchSetId().get()))
               .collect(toImmutableList());
 
diff --git a/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementIT.java b/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementIT.java
index 865dd6c..242c278 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementIT.java
@@ -21,6 +21,7 @@
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.server.project.testing.TestLabels.label;
 import static com.google.gerrit.server.project.testing.TestLabels.value;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
@@ -66,6 +67,7 @@
 import com.google.gerrit.extensions.common.SubmitRequirementInput;
 import com.google.gerrit.extensions.common.SubmitRequirementResultInfo;
 import com.google.gerrit.extensions.common.SubmitRequirementResultInfo.Status;
+import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.httpd.raw.IndexPreloadingUtil;
 import com.google.gerrit.server.notedb.ChangeNotes;
@@ -82,10 +84,12 @@
 import java.util.stream.IntStream;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevObject;
+import org.eclipse.jgit.transport.RefSpec;
 import org.eclipse.jgit.util.RawParseUtils;
 import org.junit.Test;
 
@@ -144,6 +148,198 @@
   }
 
   @Test
+  public void checkSubmitRequirement_fromRefsConfigChange_satisfied() throws Exception {
+    String oldHead = projectOperations.project(project).getHead("master").name();
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.OWNER).ref("refs/*").group(REGISTERED_USERS))
+        .add(allow(Permission.PUSH).ref("refs/for/refs/meta/config").group(REGISTERED_USERS))
+        .add(allow(Permission.READ).ref("refs/meta/config").group(REGISTERED_USERS))
+        .add(allow(Permission.SUBMIT).ref(RefNames.REFS_CONFIG).group(REGISTERED_USERS))
+        .update();
+    fetchRefsMetaConfig();
+    PushOneCommit.Result configResult =
+        createConfigChangeWithSubmitRequirement("Foo", "label:Code-Review=+2");
+
+    // Upload a normal change. Check the SR against it.
+    testRepo.reset(oldHead);
+    PushOneCommit.Result r2 = createChange();
+    String changeId = r2.getChangeId();
+    SubmitRequirementResultInfo info =
+        gApi.changes()
+            .id(changeId)
+            .checkSubmitRequirementRequest()
+            .srName("Foo")
+            .refsConfigChangeId(configResult.getChange().getId().toString())
+            .get();
+    assertThat(info.status).isEqualTo(SubmitRequirementResultInfo.Status.UNSATISFIED);
+    voteLabel(changeId, "Code-Review", 2);
+    info =
+        gApi.changes()
+            .id(changeId)
+            .checkSubmitRequirementRequest()
+            .srName("Foo")
+            .refsConfigChangeId(configResult.getChange().getId().toString())
+            .get();
+    assertThat(info.status).isEqualTo(SubmitRequirementResultInfo.Status.SATISFIED);
+  }
+
+  @Test
+  public void checkSubmitRequirement_fromRefsConfigChangeOfAnotherProject_satisfied()
+      throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.OWNER).ref("refs/*").group(REGISTERED_USERS))
+        .add(allow(Permission.PUSH).ref("refs/for/refs/meta/config").group(REGISTERED_USERS))
+        .add(allow(Permission.READ).ref("refs/meta/config").group(REGISTERED_USERS))
+        .add(allow(Permission.SUBMIT).ref(RefNames.REFS_CONFIG).group(REGISTERED_USERS))
+        .update();
+    fetchRefsMetaConfig();
+    PushOneCommit.Result configResult =
+        createConfigChangeWithSubmitRequirement("Foo", "label:Code-Review=+2");
+
+    // Upload a normal change in another project. Check the SR against it.
+    Project.NameKey otherProject = projectOperations.newProject().create();
+    TestRepository<InMemoryRepository> otherRepo = cloneProject(otherProject, admin);
+    PushOneCommit.Result r2 = createChange(otherRepo);
+    String changeId = r2.getChangeId();
+    SubmitRequirementResultInfo info =
+        gApi.changes()
+            .id(changeId)
+            .checkSubmitRequirementRequest()
+            .srName("Foo")
+            .refsConfigChangeId(configResult.getChange().getId().toString())
+            .get();
+    assertThat(info.status).isEqualTo(SubmitRequirementResultInfo.Status.UNSATISFIED);
+    voteLabel(changeId, "Code-Review", 2);
+    info =
+        gApi.changes()
+            .id(changeId)
+            .checkSubmitRequirementRequest()
+            .srName("Foo")
+            .refsConfigChangeId(configResult.getChange().getId().toString())
+            .get();
+    assertThat(info.status).isEqualTo(SubmitRequirementResultInfo.Status.SATISFIED);
+  }
+
+  @Test
+  public void checkSubmitRequirement_fromRefsConfigChange_failsForNonExistingSR() throws Exception {
+    String oldHead = projectOperations.project(project).getHead("master").name();
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.OWNER).ref("refs/*").group(REGISTERED_USERS))
+        .add(allow(Permission.PUSH).ref("refs/for/refs/meta/config").group(REGISTERED_USERS))
+        .add(allow(Permission.READ).ref("refs/meta/config").group(REGISTERED_USERS))
+        .add(allow(Permission.SUBMIT).ref(RefNames.REFS_CONFIG).group(REGISTERED_USERS))
+        .update();
+    fetchRefsMetaConfig();
+    PushOneCommit.Result configResult =
+        createConfigChangeWithSubmitRequirement("Foo", "label:Code-Review=+2");
+
+    // Upload a normal change. Check the SR against it.
+    testRepo.reset(oldHead);
+    PushOneCommit.Result r2 = createChange();
+    String changeId = r2.getChangeId();
+    Exception thrown =
+        assertThrows(
+            BadRequestException.class,
+            () ->
+                gApi.changes()
+                    .id(changeId)
+                    .checkSubmitRequirementRequest()
+                    .srName("Bar")
+                    .refsConfigChangeId(configResult.getChange().getId().toString())
+                    .get());
+    assertThat(thrown).hasMessageThat().isEqualTo("No submit requirement matching name 'Bar'");
+  }
+
+  @Test
+  public void checkSubmitRequirement_notAllowedFromNonRefsConfigChange() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    Exception thrown =
+        assertThrows(
+            BadRequestException.class,
+            () ->
+                gApi.changes()
+                    .id(changeId)
+                    .checkSubmitRequirementRequest()
+                    .srName("Foo")
+                    .refsConfigChangeId(r.getChange().getId().toString())
+                    .get());
+    assertThat(thrown)
+        .hasMessageThat()
+        .isEqualTo(
+            String.format(
+                "Change '%s' is not in refs/meta/config branch.", r.getChange().getId().get()));
+  }
+
+  @Test
+  public void checkSubmitRequirement_notAllowedFromNonExistingChange() throws Exception {
+    String invalidChangeNumber = "2134789";
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    Exception thrown =
+        assertThrows(
+            BadRequestException.class,
+            () ->
+                gApi.changes()
+                    .id(changeId)
+                    .checkSubmitRequirementRequest()
+                    .srName("Foo")
+                    .refsConfigChangeId(invalidChangeNumber)
+                    .get());
+    assertThat(thrown)
+        .hasMessageThat()
+        .isEqualTo(String.format("Change '%s' does not exist", invalidChangeNumber));
+  }
+
+  @Test
+  public void checkSubmitRequirement_fromRefsConfigChange_failsIfBothParametersAreNotSet()
+      throws Exception {
+    String oldHead = projectOperations.project(project).getHead("master").name();
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.OWNER).ref("refs/*").group(REGISTERED_USERS))
+        .add(allow(Permission.PUSH).ref("refs/for/refs/meta/config").group(REGISTERED_USERS))
+        .add(allow(Permission.READ).ref("refs/meta/config").group(REGISTERED_USERS))
+        .add(allow(Permission.SUBMIT).ref(RefNames.REFS_CONFIG).group(REGISTERED_USERS))
+        .update();
+    fetchRefsMetaConfig();
+    PushOneCommit.Result configResult =
+        createConfigChangeWithSubmitRequirement("Foo", "label:Code-Review=+2");
+
+    // Upload a normal change. Check the SR against it.
+    testRepo.reset(oldHead);
+    PushOneCommit.Result r2 = createChange();
+    String changeId = r2.getChangeId();
+    Exception thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.changes().id(changeId).checkSubmitRequirementRequest().srName("Bar").get());
+    assertThat(thrown)
+        .hasMessageThat()
+        .isEqualTo("Both 'sr-name' and 'refs-config-change-id' parameters must be set");
+
+    thrown =
+        assertThrows(
+            BadRequestException.class,
+            () ->
+                gApi.changes()
+                    .id(changeId)
+                    .checkSubmitRequirementRequest()
+                    .refsConfigChangeId(configResult.getChangeId())
+                    .get());
+    assertThat(thrown)
+        .hasMessageThat()
+        .isEqualTo("Both 'sr-name' and 'refs-config-change-id' parameters must be set");
+  }
+
+  @Test
   public void checkSubmitRequirement_satisfied() throws Exception {
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
@@ -1894,6 +2090,7 @@
         /* expression= */ null,
         /* passingAtoms= */ null,
         /* failingAtoms= */ null,
+        /* status= */ SubmitRequirementExpressionInfo.Status.PASS,
         /* fulfilled= */ true);
     assertThat(requirement.submittabilityExpressionResult).isNotNull();
   }
@@ -1927,9 +2124,16 @@
         /* expression= */ null,
         /* passingAtoms= */ null,
         /* failingAtoms= */ null,
+        /* status= */ SubmitRequirementExpressionInfo.Status.FAIL,
         /* fulfilled= */ false);
-    assertThat(requirement.submittabilityExpressionResult).isNull();
-    assertThat(requirement.overrideExpressionResult).isNull();
+    assertThat(requirement.submittabilityExpressionResult.status)
+        .isEqualTo(SubmitRequirementExpressionInfo.Status.NOT_EVALUATED);
+    assertThat(requirement.submittabilityExpressionResult.expression)
+        .isEqualTo(SubmitRequirementExpression.maxCodeReview().expressionString());
+    assertThat(requirement.overrideExpressionResult.status)
+        .isEqualTo(SubmitRequirementExpressionInfo.Status.NOT_EVALUATED);
+    assertThat(requirement.overrideExpressionResult.expression)
+        .isEqualTo("project:" + project.get());
   }
 
   @Test
@@ -1962,12 +2166,14 @@
         /* passingAtoms= */ ImmutableList.of(
             SubmitRequirementExpression.maxCodeReview().expressionString()),
         /* failingAtoms= */ ImmutableList.of(),
+        /* status= */ SubmitRequirementExpressionInfo.Status.PASS,
         /* fulfilled= */ true);
     assertSubmitRequirementExpression(
         requirement.overrideExpressionResult,
         /* expression= */ "project:non-existent",
         /* passingAtoms= */ ImmutableList.of(),
         /* failingAtoms= */ ImmutableList.of("project:non-existent"),
+        /* status= */ SubmitRequirementExpressionInfo.Status.FAIL,
         /* fulfilled= */ false);
   }
 
@@ -2002,12 +2208,14 @@
         /* passingAtoms= */ ImmutableList.of(),
         /* failingAtoms= */ ImmutableList.of(
             SubmitRequirementExpression.maxCodeReview().expressionString()),
+        /* status= */ SubmitRequirementExpressionInfo.Status.FAIL,
         /* fulfilled= */ false);
     assertSubmitRequirementExpression(
         requirement.overrideExpressionResult,
         /* expression= */ "project:" + project.get(),
         /* passingAtoms= */ ImmutableList.of("project:" + project.get()),
         /* failingAtoms= */ ImmutableList.of(),
+        /* status= */ SubmitRequirementExpressionInfo.Status.PASS,
         /* fulfilled= */ true);
   }
 
@@ -2809,6 +3017,7 @@
       @Nullable String expression,
       @Nullable List<String> passingAtoms,
       @Nullable List<String> failingAtoms,
+      SubmitRequirementExpressionInfo.Status status,
       boolean fulfilled) {
     assertThat(result.expression).isEqualTo(expression);
     if (passingAtoms == null) {
@@ -2821,6 +3030,7 @@
     } else {
       assertThat(result.failingAtoms).containsExactlyElementsIn(failingAtoms);
     }
+    assertThat(result.status).isEqualTo(status);
     assertThat(result.fulfilled).isEqualTo(fulfilled);
   }
 
@@ -2900,4 +3110,30 @@
     in.comments = ImmutableMap.of("foo", ImmutableList.of(ci));
     gApi.changes().id(changeId).current().review(in);
   }
+
+  private void fetchRefsMetaConfig() throws Exception {
+    git().fetch().setRefSpecs(new RefSpec("refs/meta/config:refs/meta/config")).call();
+    testRepo.reset(RefNames.REFS_CONFIG);
+  }
+
+  private PushOneCommit.Result createConfigChangeWithSubmitRequirement(
+      String srName, String submitExpression) throws Exception {
+    Config cfg = projectOperations.project(project).getConfig();
+    cfg.setString(
+        ProjectConfig.SUBMIT_REQUIREMENT,
+        srName,
+        ProjectConfig.KEY_SR_SUBMITTABILITY_EXPRESSION,
+        submitExpression);
+    return createConfigChange(cfg);
+  }
+
+  private PushOneCommit.Result createConfigChange(Config cfg) throws Exception {
+    PushOneCommit.Result r =
+        pushFactory
+            .create(
+                admin.newIdent(), testRepo, "Update project config", "project.config", cfg.toText())
+            .to("refs/for/refs/meta/config");
+    r.assertOkStatus();
+    return r;
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/change/SubmitWithStickyApprovalDiffIT.java b/javatests/com/google/gerrit/acceptance/api/change/SubmitWithStickyApprovalDiffIT.java
index 77582c6..651130e 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/SubmitWithStickyApprovalDiffIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/SubmitWithStickyApprovalDiffIT.java
@@ -63,7 +63,7 @@
               value(0, "No score"),
               value(-1, "I would prefer this is not submitted as is"),
               value(-2, "This shall not be submitted"));
-      codeReview.setCopyAnyScore(true);
+      codeReview.setCopyCondition("is:ANY");
       u.getConfig().upsertLabelType(codeReview.build());
       u.save();
     }
diff --git a/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java b/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
index 63b67f8..d630296 100644
--- a/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
@@ -112,7 +112,6 @@
 import java.time.Instant;
 import java.util.Arrays;
 import java.util.Collection;
-import java.util.Date;
 import java.util.List;
 import java.util.Map;
 import java.util.stream.Stream;
@@ -1608,9 +1607,6 @@
     return createCommit(repo, commitMessage, null);
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   private ObjectId createCommit(Repository repo, String commitMessage, @Nullable ObjectId treeId)
       throws IOException {
     try (ObjectInserter oi = repo.newObjectInserter()) {
@@ -1618,7 +1614,7 @@
         treeId = oi.insert(Constants.OBJ_TREE, new byte[] {});
       }
 
-      PersonIdent ident = new PersonIdent(serverIdent.get(), Date.from(TimeUtil.now()));
+      PersonIdent ident = new PersonIdent(serverIdent.get(), TimeUtil.now());
       CommitBuilder cb = new CommitBuilder();
       cb.setTreeId(treeId);
       cb.setCommitter(ident);
diff --git a/javatests/com/google/gerrit/acceptance/api/group/GroupsUpdateIT.java b/javatests/com/google/gerrit/acceptance/api/group/GroupsUpdateIT.java
index e57c82e..2796488 100644
--- a/javatests/com/google/gerrit/acceptance/api/group/GroupsUpdateIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/group/GroupsUpdateIT.java
@@ -102,6 +102,7 @@
     return groups.getAllGroupReferences().map(GroupReference::getName);
   }
 
+  @SuppressWarnings("MathAbsoluteNegative")
   private static InternalGroupCreation getGroupCreation(String groupName, String groupUuid) {
     return InternalGroupCreation.builder()
         .setGroupUUID(AccountGroup.uuid(groupUuid))
diff --git a/javatests/com/google/gerrit/acceptance/api/project/AccessIT.java b/javatests/com/google/gerrit/acceptance/api/project/AccessIT.java
index df5a094..7c33ec2 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/AccessIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/AccessIT.java
@@ -36,6 +36,7 @@
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.entities.AccessSection;
+import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.entities.GroupReference;
 import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.Permission;
@@ -62,6 +63,7 @@
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.group.SystemGroupBackend;
+import com.google.gerrit.server.group.testing.TestGroupBackend;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.schema.GrantRevertPermission;
 import com.google.inject.Inject;
@@ -467,6 +469,40 @@
   }
 
   @Test
+  public void removePermissionRuleForNonExistingeExternalGroup() throws Exception {
+    // Register a group backend with an external group
+    TestGroupBackend testGroupBackend = new TestGroupBackend();
+    GroupDescription.Basic externalGroup = testGroupBackend.create("External Group");
+    try (ExtensionRegistry.Registration registration =
+        extensionRegistry.newRegistration().add(testGroupBackend)) {
+      // Add a permission for the external group.
+      ProjectAccessInput accessInput = newProjectAccessInput();
+      AccessSectionInfo accessSectionInfo = newAccessSectionInfo();
+      PermissionInfo push = newPermissionInfo();
+      PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
+      push.rules.put(externalGroup.getGroupUUID().get(), pri);
+      accessSectionInfo.permissions.put(Permission.PUSH, push);
+      accessInput.add.put(REFS_HEADS, accessSectionInfo);
+      pApi().access(accessInput);
+      assertThat(pApi().access().local).isNotEmpty();
+
+      // Remove the external group.
+      testGroupBackend.remove(externalGroup.getGroupUUID());
+
+      // Remove the permission rule for the external group that no longer exists.
+      AccessSectionInfo accessSectionToRemove = newAccessSectionInfo();
+      push = newPermissionInfo();
+      pri = new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
+      push.rules.put(externalGroup.getGroupUUID().get(), pri);
+      accessSectionToRemove.permissions.put(Permission.PUSH, push);
+      ProjectAccessInput removal = newProjectAccessInput();
+      removal.remove.put(REFS_HEADS, accessSectionToRemove);
+      pApi().access(removal);
+      assertThat(pApi().access().local).isEmpty();
+    }
+  }
+
+  @Test
   public void removePermissionRulesAndCleanupEmptyEntries() throws Exception {
     // Add initial permission set
     ProjectAccessInput accessInput = newProjectAccessInput();
diff --git a/javatests/com/google/gerrit/acceptance/api/project/ProjectConfigIT.java b/javatests/com/google/gerrit/acceptance/api/project/ProjectConfigIT.java
new file mode 100644
index 0000000..168819c
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/project/ProjectConfigIT.java
@@ -0,0 +1,841 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.api.project;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.GitUtil.fetch;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.RawInputUtil;
+import com.google.gerrit.entities.LabelFunction;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.api.changes.PublishChangeEditInput;
+import com.google.gerrit.extensions.client.ChangeStatus;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.ChangeInput;
+import com.google.gerrit.git.ObjectIds;
+import com.google.gerrit.server.project.LabelConfigValidator;
+import com.google.gerrit.server.project.ProjectConfig;
+import com.google.inject.Inject;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.AnyObjectId;
+import org.eclipse.jgit.lib.Repository;
+import org.junit.Test;
+
+public class ProjectConfigIT extends AbstractDaemonTest {
+  private static final String INVALID_PRROJECT_CONFIG =
+      "[label \"Foo\"]\n"
+          // copyAllScoresOnTrivialRebase is deprecated and no longer allowed to be set
+          + "  copyAllScoresOnTrivialRebase = true";
+
+  @Inject private ProjectOperations projectOperations;
+
+  @Test
+  public void noLabelValidationForNonRefsMetaConfigChange() throws Exception {
+    PushOneCommit.Result r =
+        createChange(
+            testRepo,
+            "refs/heads/master",
+            "Test Change",
+            ProjectConfig.PROJECT_CONFIG,
+            INVALID_PRROJECT_CONFIG,
+            /* topic= */ null);
+    r.assertOkStatus();
+    approve(r.getChangeId());
+    gApi.changes().id(r.getChangeId()).current().submit();
+    assertThat(gApi.changes().id(r.getChangeId()).get().status).isEqualTo(ChangeStatus.MERGED);
+  }
+
+  @Test
+  public void noLabelValidationForNoneProjectConfigChange() throws Exception {
+    fetchRefsMetaConfig();
+    PushOneCommit.Result r =
+        createChange(
+            testRepo,
+            RefNames.REFS_CONFIG,
+            "Test Change",
+            "foo.config",
+            INVALID_PRROJECT_CONFIG,
+            /* topic= */ null);
+    r.assertOkStatus();
+    approve(r.getChangeId());
+    gApi.changes().id(r.getChangeId()).current().submit();
+    assertThat(gApi.changes().id(r.getChangeId()).get().status).isEqualTo(ChangeStatus.MERGED);
+  }
+
+  @Test
+  public void validateNoIssues_push() throws Exception {
+    fetchRefsMetaConfig();
+    PushOneCommit push =
+        pushFactory.create(
+            admin.newIdent(),
+            testRepo,
+            "Test Change",
+            ProjectConfig.PROJECT_CONFIG,
+            "[label \"Foo\"]\n  description = Foo Label");
+    PushOneCommit.Result r = push.to("refs/for/" + RefNames.REFS_CONFIG);
+    r.assertOkStatus();
+
+    approve(r.getChangeId());
+    gApi.changes().id(r.getChangeId()).current().submit();
+    assertThat(gApi.changes().id(r.getChangeId()).get().status).isEqualTo(ChangeStatus.MERGED);
+  }
+
+  @Test
+  public void validateNoIssues_createChangeApi() throws Exception {
+    ChangeInput changeInput = new ChangeInput();
+    changeInput.project = project.get();
+    changeInput.branch = RefNames.REFS_CONFIG;
+    changeInput.subject = "A change";
+    changeInput.status = ChangeStatus.NEW;
+    ChangeInfo changeInfo = gApi.changes().create(changeInput).get();
+
+    gApi.changes().id(changeInfo.id).edit().create();
+    gApi.changes()
+        .id(changeInfo.id)
+        .edit()
+        .modifyFile(
+            ProjectConfig.PROJECT_CONFIG,
+            RawInputUtil.create("[label \"Foo\"]\n  description = Foo Label"));
+
+    PublishChangeEditInput publishInput = new PublishChangeEditInput();
+    gApi.changes().id(changeInfo.id).edit().publish(publishInput);
+
+    approve(changeInfo.id);
+    gApi.changes().id(changeInfo.id).current().submit();
+    assertThat(gApi.changes().id(changeInfo.id).get().status).isEqualTo(ChangeStatus.MERGED);
+  }
+
+  @Test
+  public void rejectSettingCopyAnyScore() throws Exception {
+    testRejectSettingLabelFlag(
+        LabelConfigValidator.KEY_COPY_ANY_SCORE, /* value= */ true, "is:ANY");
+    testRejectSettingLabelFlag(
+        LabelConfigValidator.KEY_COPY_ANY_SCORE, /* value= */ false, "is:ANY");
+  }
+
+  @Test
+  public void rejectSettingCopyMinScore() throws Exception {
+    testRejectSettingLabelFlag(
+        LabelConfigValidator.KEY_COPY_MIN_SCORE, /* value= */ true, "is:MIN");
+    testRejectSettingLabelFlag(
+        LabelConfigValidator.KEY_COPY_MIN_SCORE, /* value= */ false, "is:MIN");
+  }
+
+  @Test
+  public void rejectSettingCopyMaxScore() throws Exception {
+    testRejectSettingLabelFlag(
+        LabelConfigValidator.KEY_COPY_MAX_SCORE, /* value= */ true, "is:MAX");
+    testRejectSettingLabelFlag(
+        LabelConfigValidator.KEY_COPY_MAX_SCORE, /* value= */ false, "is:MAX");
+  }
+
+  @Test
+  public void rejectSettingCopyAllScoresIfNoChange() throws Exception {
+    testRejectSettingLabelFlag(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_IF_NO_CHANGE,
+        /* value= */ true,
+        "changekind:NO_CHANGE");
+    testRejectSettingLabelFlag(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_IF_NO_CHANGE,
+        /* value= */ false,
+        "changekind:NO_CHANGE");
+  }
+
+  @Test
+  public void rejectSettingCopyAllScoresIfNoCodeChange() throws Exception {
+    testRejectSettingLabelFlag(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE,
+        /* value= */ true,
+        "changekind:NO_CODE_CHANGE");
+    testRejectSettingLabelFlag(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE,
+        /* value= */ false,
+        "changekind:NO_CODE_CHANGE");
+  }
+
+  @Test
+  public void rejectSettingCopyAllScoresOnMergeFirstParentUpdate() throws Exception {
+    testRejectSettingLabelFlag(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE,
+        /* value= */ true,
+        "changekind:MERGE_FIRST_PARENT_UPDATE");
+    testRejectSettingLabelFlag(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE,
+        /* value= */ false,
+        "changekind:MERGE_FIRST_PARENT_UPDATE");
+  }
+
+  @Test
+  public void rejectSettingCopyAllScoresOnTrivialRebase() throws Exception {
+    testRejectSettingLabelFlag(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE,
+        /* value= */ true,
+        "changekind:TRIVIAL_REBASE");
+    testRejectSettingLabelFlag(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE,
+        /* value= */ false,
+        "changekind:TRIVIAL_REBASE");
+  }
+
+  @Test
+  public void rejectSettingCopyAllScoresIfListOfFilesDidNotChange() throws Exception {
+    testRejectSettingLabelFlag(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE,
+        /* value= */ true,
+        "has:unchanged-files");
+    testRejectSettingLabelFlag(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE,
+        /* value= */ false,
+        "has:unchanged-files");
+  }
+
+  private void testRejectSettingLabelFlag(
+      String key, boolean value, String expectedPredicateSuggestion) throws Exception {
+    fetchRefsMetaConfig();
+    PushOneCommit push =
+        pushFactory.create(
+            admin.newIdent(),
+            testRepo,
+            "Test Change",
+            ProjectConfig.PROJECT_CONFIG,
+            String.format("[label \"Foo\"]\n  %s = %s", key, value));
+    PushOneCommit.Result r = push.to(RefNames.REFS_CONFIG);
+    r.assertErrorStatus(
+        String.format(
+            "invalid %s file in revision %s", ProjectConfig.PROJECT_CONFIG, r.getCommit().name()));
+    r.assertMessage(
+        String.format(
+            "ERROR: commit %s: Parameter 'label.Foo.%s' is deprecated and cannot be set,"
+                + " use '%s' in 'label.Foo.copyCondition' instead.",
+            abbreviateName(r.getCommit()), key, expectedPredicateSuggestion));
+  }
+
+  @Test
+  public void rejectSettingCopyValues() throws Exception {
+    fetchRefsMetaConfig();
+    PushOneCommit push =
+        pushFactory.create(
+            admin.newIdent(),
+            testRepo,
+            "Test Change",
+            ProjectConfig.PROJECT_CONFIG,
+            String.format(
+                "[label \"Foo\"]\n  %s = 1\n  %s = 2",
+                LabelConfigValidator.KEY_COPY_VALUE, LabelConfigValidator.KEY_COPY_VALUE));
+    PushOneCommit.Result r = push.to(RefNames.REFS_CONFIG);
+    r.assertErrorStatus(
+        String.format(
+            "invalid %s file in revision %s", ProjectConfig.PROJECT_CONFIG, r.getCommit().name()));
+    r.assertMessage(
+        String.format(
+            "ERROR: commit %s: Parameter 'label.Foo.%s' is deprecated and cannot be set,"
+                + " use 'is:<copy-value>' in 'label.Foo.copyCondition' instead.",
+            abbreviateName(r.getCommit()), LabelConfigValidator.KEY_COPY_VALUE));
+  }
+
+  @Test
+  public void rejectChangingCopyAnyScore() throws Exception {
+    testRejectChangingLabelFlag(
+        LabelConfigValidator.KEY_COPY_ANY_SCORE, /* value= */ true, "is:ANY");
+    testRejectChangingLabelFlag(
+        LabelConfigValidator.KEY_COPY_ANY_SCORE, /* value= */ false, "is:ANY");
+  }
+
+  @Test
+  public void rejectChangingCopyMinScore() throws Exception {
+    testRejectChangingLabelFlag(
+        LabelConfigValidator.KEY_COPY_MIN_SCORE, /* value= */ true, "is:MIN");
+    testRejectChangingLabelFlag(
+        LabelConfigValidator.KEY_COPY_MIN_SCORE, /* value= */ false, "is:MIN");
+  }
+
+  @Test
+  public void rejectChangingCopyMaxScore() throws Exception {
+    testRejectChangingLabelFlag(
+        LabelConfigValidator.KEY_COPY_MAX_SCORE, /* value= */ true, "is:MAX");
+    testRejectChangingLabelFlag(
+        LabelConfigValidator.KEY_COPY_MAX_SCORE, /* value= */ false, "is:MAX");
+  }
+
+  @Test
+  public void rejectChangingCopyAllScoresIfNoChange() throws Exception {
+    testRejectChangingLabelFlag(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_IF_NO_CHANGE,
+        /* value= */ true,
+        "changekind:NO_CHANGE");
+    testRejectChangingLabelFlag(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_IF_NO_CHANGE,
+        /* value= */ false,
+        "changekind:NO_CHANGE");
+  }
+
+  @Test
+  public void rejectChangingCopyAllScoresIfNoCodeChange() throws Exception {
+    testRejectChangingLabelFlag(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE,
+        /* value= */ true,
+        "changekind:NO_CODE_CHANGE");
+    testRejectChangingLabelFlag(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE,
+        /* value= */ false,
+        "changekind:NO_CODE_CHANGE");
+  }
+
+  @Test
+  public void rejectChangingCopyAllScoresOnMergeFirstParentUpdate() throws Exception {
+    testRejectChangingLabelFlag(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE,
+        /* value= */ true,
+        "changekind:MERGE_FIRST_PARENT_UPDATE");
+    testRejectChangingLabelFlag(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE,
+        /* value= */ false,
+        "changekind:MERGE_FIRST_PARENT_UPDATE");
+  }
+
+  @Test
+  public void rejectChangingCopyAllScoresOnTrivialRebase() throws Exception {
+    testRejectChangingLabelFlag(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE,
+        /* value= */ true,
+        "changekind:TRIVIAL_REBASE");
+    testRejectChangingLabelFlag(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE,
+        /* value= */ false,
+        "changekind:TRIVIAL_REBASE");
+  }
+
+  @Test
+  public void rejectChangingCopyAllScoresIfListOfFilesDidNotChange() throws Exception {
+    testRejectChangingLabelFlag(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE,
+        /* value= */ true,
+        "has:unchanged-files");
+    testRejectChangingLabelFlag(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE,
+        /* value= */ false,
+        "has:unchanged-files");
+  }
+
+  private void testRejectChangingLabelFlag(
+      String key, boolean value, String expectedPredicateSuggestion) throws Exception {
+    try (TestRepository<Repository> testRepo =
+        new TestRepository<>(repoManager.openRepository(project))) {
+      testRepo
+          .branch(RefNames.REFS_CONFIG)
+          .commit()
+          .add(
+              ProjectConfig.PROJECT_CONFIG,
+              String.format("[label \"Foo\"]\n  %s = %s", key, !value))
+          .parent(projectOperations.project(project).getHead(RefNames.REFS_CONFIG))
+          .create();
+    }
+
+    testRejectSettingLabelFlag(key, value, expectedPredicateSuggestion);
+  }
+
+  @Test
+  public void rejectChangingCopyValues() throws Exception {
+    try (TestRepository<Repository> testRepo =
+        new TestRepository<>(repoManager.openRepository(project))) {
+      testRepo
+          .branch(RefNames.REFS_CONFIG)
+          .commit()
+          .add(
+              ProjectConfig.PROJECT_CONFIG,
+              String.format(
+                  "[label \"Foo\"]\n  %s = 1\n  %s = 2",
+                  LabelConfigValidator.KEY_COPY_VALUE, LabelConfigValidator.KEY_COPY_VALUE))
+          .parent(projectOperations.project(project).getHead(RefNames.REFS_CONFIG))
+          .create();
+    }
+
+    fetchRefsMetaConfig();
+    PushOneCommit push =
+        pushFactory.create(
+            admin.newIdent(),
+            testRepo,
+            "Test Change",
+            ProjectConfig.PROJECT_CONFIG,
+            String.format(
+                "[label \"Foo\"]\n  %s = -1\n  %s = -2",
+                LabelConfigValidator.KEY_COPY_VALUE, LabelConfigValidator.KEY_COPY_VALUE));
+    PushOneCommit.Result r = push.to(RefNames.REFS_CONFIG);
+    r.assertErrorStatus(
+        String.format(
+            "invalid %s file in revision %s", ProjectConfig.PROJECT_CONFIG, r.getCommit().name()));
+    r.assertMessage(
+        String.format(
+            "ERROR: commit %s: Parameter 'label.Foo.%s' is deprecated and cannot be set,"
+                + " use 'is:<copy-value>' in 'label.Foo.copyCondition' instead.",
+            abbreviateName(r.getCommit()), LabelConfigValidator.KEY_COPY_VALUE));
+  }
+
+  @Test
+  public void testRejectChangingLabelFunction_toMaxWithBlock() throws Exception {
+    testChangingLabelFunction(
+        /* initialLabelFunction= */ LabelFunction.NO_BLOCK,
+        /* newLabelFunction= */ LabelFunction.MAX_WITH_BLOCK,
+        /* errorMessage= */ String.format(
+            "Value '%s' of 'label.foo.function' is not allowed and cannot be set."
+                + " Label functions can only be set to {no_block, no_op, patch_set_lock}."
+                + " Use submit requirements instead of label functions.",
+            LabelFunction.MAX_WITH_BLOCK.getFunctionName()));
+  }
+
+  @Test
+  public void testRejectChangingLabelFunction_toMaxNoBlock() throws Exception {
+    testChangingLabelFunction(
+        /* initialLabelFunction= */ LabelFunction.NO_BLOCK,
+        /* newLabelFunction= */ LabelFunction.MAX_NO_BLOCK,
+        /* errorMessage= */ String.format(
+            "Value '%s' of 'label.foo.function' is not allowed and cannot be set."
+                + " Label functions can only be set to {no_block, no_op, patch_set_lock}."
+                + " Use submit requirements instead of label functions.",
+            LabelFunction.MAX_NO_BLOCK.getFunctionName()));
+  }
+
+  @Test
+  public void testRejectChangingLabelFunction_toAnyWithBlock() throws Exception {
+    testChangingLabelFunction(
+        /* initialLabelFunction= */ LabelFunction.NO_BLOCK,
+        /* newLabelFunction= */ LabelFunction.ANY_WITH_BLOCK,
+        /* errorMessage= */ String.format(
+            "Value '%s' of 'label.foo.function' is not allowed and cannot be set."
+                + " Label functions can only be set to {no_block, no_op, patch_set_lock}."
+                + " Use submit requirements instead of label functions.",
+            LabelFunction.ANY_WITH_BLOCK.getFunctionName()));
+  }
+
+  @Test
+  public void testChangingLabelFunction_toNoBlock() throws Exception {
+    testChangingLabelFunction(
+        /* initialLabelFunction= */ LabelFunction.MAX_WITH_BLOCK,
+        /* newLabelFunction= */ LabelFunction.NO_BLOCK,
+        /* errorMessage= */ null);
+  }
+
+  @Test
+  public void testChangingLabelFunction_toNoOp() throws Exception {
+    testChangingLabelFunction(
+        /* initialLabelFunction= */ LabelFunction.MAX_WITH_BLOCK,
+        /* newLabelFunction= */ LabelFunction.NO_OP,
+        /* errorMessage= */ null);
+  }
+
+  @Test
+  public void testChangingLabelFunction_toPatchSetLock() throws Exception {
+    testChangingLabelFunction(
+        /* initialLabelFunction= */ LabelFunction.MAX_WITH_BLOCK,
+        /* newLabelFunction= */ LabelFunction.PATCH_SET_LOCK,
+        /* errorMessage= */ null);
+  }
+
+  @Test
+  public void testRejectRemovingLabelFunction() throws Exception {
+    testChangingLabelFunction(
+        /* initialLabelFunction= */ LabelFunction.MAX_WITH_BLOCK,
+        /* newLabelFunction= */ null,
+        /* errorMessage= */ String.format(
+            "Cannot delete '%s.%s.%s'."
+                + " Label functions can only be set to {%s, %s, %s}."
+                + " Use submit requirements instead of label functions.",
+            ProjectConfig.LABEL,
+            "Foo",
+            ProjectConfig.KEY_FUNCTION,
+            LabelFunction.NO_BLOCK,
+            LabelFunction.NO_OP,
+            LabelFunction.PATCH_SET_LOCK));
+  }
+
+  private void testChangingLabelFunction(
+      LabelFunction initialLabelFunction,
+      @Nullable LabelFunction newLabelFunction,
+      @Nullable String errorMessage)
+      throws Exception {
+    try (TestRepository<Repository> testRepo =
+        new TestRepository<>(repoManager.openRepository(project))) {
+      testRepo
+          .branch(RefNames.REFS_CONFIG)
+          .commit()
+          .add(
+              ProjectConfig.PROJECT_CONFIG,
+              String.format(
+                  "[label \"Foo\"]\n  %s = %s\n",
+                  ProjectConfig.KEY_FUNCTION, initialLabelFunction.getFunctionName()))
+          .parent(projectOperations.project(project).getHead(RefNames.REFS_CONFIG))
+          .create();
+    }
+
+    fetchRefsMetaConfig();
+    PushOneCommit push =
+        pushFactory.create(
+            admin.newIdent(),
+            testRepo,
+            "Test Change",
+            ProjectConfig.PROJECT_CONFIG,
+            newLabelFunction == null
+                ? "[label \"Foo\"]\n"
+                : String.format(
+                    "[label \"Foo\"]\n  %s = %s\n",
+                    ProjectConfig.KEY_FUNCTION, newLabelFunction.getFunctionName()));
+    PushOneCommit.Result r = push.to(RefNames.REFS_CONFIG);
+    if (errorMessage == null) {
+      r.assertOkStatus();
+      return;
+    }
+    r.assertErrorStatus(
+        String.format(
+            "invalid %s file in revision %s", ProjectConfig.PROJECT_CONFIG, r.getCommit().name()));
+    r.assertMessage(errorMessage);
+  }
+
+  @Test
+  public void unsetCopyAnyScore() throws Exception {
+    testUnsetLabelFlag(LabelConfigValidator.KEY_COPY_ANY_SCORE, /* previousValue= */ true);
+    testUnsetLabelFlag(LabelConfigValidator.KEY_COPY_ANY_SCORE, /* previousValue= */ false);
+  }
+
+  @Test
+  public void unsetCopyMinScore() throws Exception {
+    testUnsetLabelFlag(LabelConfigValidator.KEY_COPY_MIN_SCORE, /* previousValue= */ true);
+    testUnsetLabelFlag(LabelConfigValidator.KEY_COPY_MIN_SCORE, /* previousValue= */ false);
+  }
+
+  @Test
+  public void unsetCopyMaxScore() throws Exception {
+    testUnsetLabelFlag(LabelConfigValidator.KEY_COPY_MAX_SCORE, /* previousValue= */ true);
+    testUnsetLabelFlag(LabelConfigValidator.KEY_COPY_MAX_SCORE, /* previousValue= */ false);
+  }
+
+  @Test
+  public void unsetCopyAllScoresIfNoChange() throws Exception {
+    testUnsetLabelFlag(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_IF_NO_CHANGE, /* previousValue= */ true);
+    testUnsetLabelFlag(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_IF_NO_CHANGE, /* previousValue= */ false);
+  }
+
+  @Test
+  public void unsetCopyAllScoresIfNoCodeChange() throws Exception {
+    testUnsetLabelFlag(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE, /* previousValue= */ true);
+    testUnsetLabelFlag(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE, /* previousValue= */ false);
+  }
+
+  @Test
+  public void unsetCopyAllScoresOnMergeFirstParentUpdate() throws Exception {
+    testUnsetLabelFlag(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE,
+        /* previousValue= */ true);
+    testUnsetLabelFlag(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE,
+        /* previousValue= */ false);
+  }
+
+  @Test
+  public void unsetCopyAllScoresOnTrivialRebase() throws Exception {
+    testUnsetLabelFlag(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE, /* previousValue= */ true);
+    testUnsetLabelFlag(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE, /* previousValue= */ false);
+  }
+
+  @Test
+  public void unsetCopyAllScoresIfListOfFilesDidNotChange() throws Exception {
+    testUnsetLabelFlag(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE,
+        /* previousValue= */ true);
+    testUnsetLabelFlag(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE,
+        /* previousValue= */ false);
+  }
+
+  private void testUnsetLabelFlag(String key, boolean previousValue) throws Exception {
+    try (TestRepository<Repository> testRepo =
+        new TestRepository<>(repoManager.openRepository(project))) {
+      testRepo
+          .branch(RefNames.REFS_CONFIG)
+          .commit()
+          .add(
+              ProjectConfig.PROJECT_CONFIG,
+              String.format("[label \"Foo\"]\n  %s = %s", key, previousValue))
+          .parent(projectOperations.project(project).getHead(RefNames.REFS_CONFIG))
+          .create();
+    }
+
+    fetchRefsMetaConfig();
+    PushOneCommit push =
+        pushFactory.create(
+            admin.newIdent(),
+            testRepo,
+            "Test Change",
+            ProjectConfig.PROJECT_CONFIG,
+            String.format("[label \"Foo\"]\n  otherKey = value"));
+    PushOneCommit.Result r = push.to(RefNames.REFS_CONFIG);
+    r.assertOkStatus();
+  }
+
+  @Test
+  public void unsetCopyValues() throws Exception {
+    try (TestRepository<Repository> testRepo =
+        new TestRepository<>(repoManager.openRepository(project))) {
+      testRepo
+          .branch(RefNames.REFS_CONFIG)
+          .commit()
+          .add(
+              ProjectConfig.PROJECT_CONFIG,
+              String.format(
+                  "[label \"Foo\"]\n  %s = 1\n  %s = 2",
+                  LabelConfigValidator.KEY_COPY_VALUE, LabelConfigValidator.KEY_COPY_VALUE))
+          .parent(projectOperations.project(project).getHead(RefNames.REFS_CONFIG))
+          .create();
+    }
+
+    fetchRefsMetaConfig();
+    PushOneCommit push =
+        pushFactory.create(
+            admin.newIdent(),
+            testRepo,
+            "Test Change",
+            ProjectConfig.PROJECT_CONFIG,
+            String.format("[label \"Foo\"]\n  otherKey = value"));
+    PushOneCommit.Result r = push.to(RefNames.REFS_CONFIG);
+    r.assertOkStatus();
+  }
+
+  @Test
+  public void keepCopyAnyScoreUnchanged() throws Exception {
+    testKeepLabelFlagUnchanged(LabelConfigValidator.KEY_COPY_ANY_SCORE, /* value= */ true);
+    testKeepLabelFlagUnchanged(LabelConfigValidator.KEY_COPY_ANY_SCORE, /* value= */ false);
+  }
+
+  @Test
+  public void keepCopyMinScoreUnchanged() throws Exception {
+    testKeepLabelFlagUnchanged(LabelConfigValidator.KEY_COPY_MIN_SCORE, /* value= */ true);
+    testKeepLabelFlagUnchanged(LabelConfigValidator.KEY_COPY_MIN_SCORE, /* value= */ false);
+  }
+
+  @Test
+  public void keepCopyMaxScoreUnchanged() throws Exception {
+    testKeepLabelFlagUnchanged(LabelConfigValidator.KEY_COPY_MAX_SCORE, /* value= */ true);
+    testKeepLabelFlagUnchanged(LabelConfigValidator.KEY_COPY_MAX_SCORE, /* value= */ false);
+  }
+
+  @Test
+  public void keepCopyAllScoresIfNoChangeUnchanged() throws Exception {
+    testKeepLabelFlagUnchanged(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_IF_NO_CHANGE, /* value= */ true);
+    testKeepLabelFlagUnchanged(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_IF_NO_CHANGE, /* value= */ false);
+  }
+
+  @Test
+  public void keepCopyAllScoresIfNoCodeChangeUnchanged() throws Exception {
+    testKeepLabelFlagUnchanged(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE, /* value= */ true);
+    testKeepLabelFlagUnchanged(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE, /* value= */ false);
+  }
+
+  @Test
+  public void keepCopyAllScoresOnMergeFirstParentUpdateUnchanged() throws Exception {
+    testKeepLabelFlagUnchanged(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE, /* value= */ true);
+    testKeepLabelFlagUnchanged(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE, /* value= */ false);
+  }
+
+  @Test
+  public void keepCopyAllScoresOnTrivialRebaseUnchanged() throws Exception {
+    testKeepLabelFlagUnchanged(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE, /* value= */ true);
+    testKeepLabelFlagUnchanged(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE, /* value= */ false);
+  }
+
+  @Test
+  public void keepCopyAllScoresIfListOfFilesDidNotChangeUnchanged() throws Exception {
+    testKeepLabelFlagUnchanged(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE,
+        /* value= */ true);
+    testKeepLabelFlagUnchanged(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE,
+        /* value= */ false);
+  }
+
+  private void testKeepLabelFlagUnchanged(String key, boolean value) throws Exception {
+    try (TestRepository<Repository> testRepo =
+        new TestRepository<>(repoManager.openRepository(project))) {
+      testRepo
+          .branch(RefNames.REFS_CONFIG)
+          .commit()
+          .add(
+              ProjectConfig.PROJECT_CONFIG, String.format("[label \"Foo\"]\n  %s = %s", key, value))
+          .parent(projectOperations.project(project).getHead(RefNames.REFS_CONFIG))
+          .create();
+    }
+
+    fetchRefsMetaConfig();
+    PushOneCommit push =
+        pushFactory.create(
+            admin.newIdent(),
+            testRepo,
+            "Test Change",
+            ProjectConfig.PROJECT_CONFIG,
+            String.format("[label \"Foo\"]\n  %s = %s\n  otherKey = value", key, value));
+    PushOneCommit.Result r = push.to(RefNames.REFS_CONFIG);
+    r.assertOkStatus();
+  }
+
+  @Test
+  public void keepCopyValuesUnchanged() throws Exception {
+    try (TestRepository<Repository> testRepo =
+        new TestRepository<>(repoManager.openRepository(project))) {
+      testRepo
+          .branch(RefNames.REFS_CONFIG)
+          .commit()
+          .add(
+              ProjectConfig.PROJECT_CONFIG,
+              String.format(
+                  "[label \"Foo\"]\n  %s = 1\n  %s = 2",
+                  LabelConfigValidator.KEY_COPY_VALUE, LabelConfigValidator.KEY_COPY_VALUE))
+          .parent(projectOperations.project(project).getHead(RefNames.REFS_CONFIG))
+          .create();
+    }
+
+    fetchRefsMetaConfig();
+    PushOneCommit push =
+        pushFactory.create(
+            admin.newIdent(),
+            testRepo,
+            "Test Change",
+            ProjectConfig.PROJECT_CONFIG,
+            String.format(
+                "[label \"Foo\"]\n  %s = 1\n  %s = 2\n  otherKey = value",
+                LabelConfigValidator.KEY_COPY_VALUE, LabelConfigValidator.KEY_COPY_VALUE));
+    PushOneCommit.Result r = push.to(RefNames.REFS_CONFIG);
+    r.assertOkStatus();
+  }
+
+  @Test
+  public void keepCopyValuesUnchanged_differentOrder() throws Exception {
+    try (TestRepository<Repository> testRepo =
+        new TestRepository<>(repoManager.openRepository(project))) {
+      testRepo
+          .branch(RefNames.REFS_CONFIG)
+          .commit()
+          .add(
+              ProjectConfig.PROJECT_CONFIG,
+              String.format(
+                  "[label \"Foo\"]\n  %s = 1\n  %s = 2",
+                  LabelConfigValidator.KEY_COPY_VALUE, LabelConfigValidator.KEY_COPY_VALUE))
+          .parent(projectOperations.project(project).getHead(RefNames.REFS_CONFIG))
+          .create();
+    }
+
+    fetchRefsMetaConfig();
+    PushOneCommit push =
+        pushFactory.create(
+            admin.newIdent(),
+            testRepo,
+            "Test Change",
+            ProjectConfig.PROJECT_CONFIG,
+            String.format(
+                "[label \"Foo\"]\n  %s = 2\n  %s = 1",
+                LabelConfigValidator.KEY_COPY_VALUE, LabelConfigValidator.KEY_COPY_VALUE));
+    PushOneCommit.Result r = push.to(RefNames.REFS_CONFIG);
+    r.assertOkStatus();
+  }
+
+  @Test
+  public void rejectMultipleLabelFlags() throws Exception {
+    fetchRefsMetaConfig();
+    PushOneCommit push =
+        pushFactory.create(
+            admin.newIdent(),
+            testRepo,
+            "Test Change",
+            ProjectConfig.PROJECT_CONFIG,
+            String.format(
+                "[label \"Foo\"]\n  %s = true\n  %s = true",
+                LabelConfigValidator.KEY_COPY_MIN_SCORE, LabelConfigValidator.KEY_COPY_MAX_SCORE));
+    PushOneCommit.Result r = push.to(RefNames.REFS_CONFIG);
+    r.assertErrorStatus(
+        String.format(
+            "invalid %s file in revision %s", ProjectConfig.PROJECT_CONFIG, r.getCommit().name()));
+    r.assertMessage(
+        String.format(
+            "ERROR: commit %s: Parameter 'label.Foo.%s' is deprecated and cannot be set,"
+                + " use 'is:MIN' in 'label.Foo.copyCondition' instead.",
+            abbreviateName(r.getCommit()), LabelConfigValidator.KEY_COPY_MIN_SCORE));
+    r.assertMessage(
+        String.format(
+            "ERROR: commit %s: Parameter 'label.Foo.%s' is deprecated and cannot be set,"
+                + " use 'is:MAX' in 'label.Foo.copyCondition' instead.",
+            abbreviateName(r.getCommit()), LabelConfigValidator.KEY_COPY_MAX_SCORE));
+  }
+
+  @Test
+  public void setCopyCondition() throws Exception {
+    fetchRefsMetaConfig();
+    PushOneCommit push =
+        pushFactory.create(
+            admin.newIdent(),
+            testRepo,
+            "Test Change",
+            ProjectConfig.PROJECT_CONFIG,
+            String.format("[label \"Foo\"]\n  %s = is:ANY", ProjectConfig.KEY_COPY_CONDITION));
+    PushOneCommit.Result r = push.to(RefNames.REFS_CONFIG);
+    r.assertOkStatus();
+  }
+
+  @Test
+  public void validateLabelConfigInInitialCommit() throws Exception {
+    try (TestRepository<Repository> testRepo =
+        new TestRepository<>(repoManager.openRepository(project))) {
+      testRepo.delete(RefNames.REFS_CONFIG);
+    }
+
+    PushOneCommit push =
+        pushFactory
+            .create(
+                admin.newIdent(),
+                testRepo,
+                "Test Change",
+                ProjectConfig.PROJECT_CONFIG,
+                INVALID_PRROJECT_CONFIG)
+            .setParents(ImmutableList.of());
+    PushOneCommit.Result r = push.to(RefNames.REFS_CONFIG);
+    r.assertErrorStatus(
+        String.format(
+            "invalid %s file in revision %s", ProjectConfig.PROJECT_CONFIG, r.getCommit().name()));
+  }
+
+  private void fetchRefsMetaConfig() throws Exception {
+    fetch(testRepo, RefNames.REFS_CONFIG + ":" + RefNames.REFS_CONFIG);
+    testRepo.reset(RefNames.REFS_CONFIG);
+  }
+
+  private String abbreviateName(AnyObjectId id) throws Exception {
+    return ObjectIds.abbreviateName(id, testRepo.getRevWalk().getObjectReader());
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java b/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java
index 58d1628..f997c77 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java
@@ -788,6 +788,39 @@
   }
 
   @Test
+  public void projectConfigUsesLocallySetCommentlinksWithOptionalFields() throws Exception {
+    ConfigInput input = new ConfigInput();
+    CommentLinkInput bugzillaInput = new CommentLinkInput();
+    String match = "(^|\\\\s)(bug\\\\s+#?)(\\\\d+)($|\\\\s)";
+    String link = "http://bugzilla.example.com/?id=$3";
+    String prefix = "$1";
+    String suffix = "$4";
+    String text = "$2$3";
+    bugzillaInput.match = match;
+    bugzillaInput.link = link;
+    bugzillaInput.prefix = prefix;
+    bugzillaInput.suffix = suffix;
+    bugzillaInput.text = text;
+    addCommentLink(input, BUGZILLA, bugzillaInput);
+    addCommentLink(input, JIRA, JIRA_MATCH, JIRA_LINK);
+
+    ConfigInfo info = setConfig(project, input);
+
+    Map<String, CommentLinkInfo> expected = new HashMap<>();
+    CommentLinkInfo bugzillaInfo = new CommentLinkInfo();
+    bugzillaInfo.name = BUGZILLA;
+    bugzillaInfo.match = match;
+    bugzillaInfo.link = link;
+    bugzillaInfo.prefix = prefix;
+    bugzillaInfo.suffix = suffix;
+    bugzillaInfo.text = text;
+    expected.put(BUGZILLA, bugzillaInfo);
+    expected.put(JIRA, commentLinkInfo(JIRA, JIRA_MATCH, JIRA_LINK));
+    assertCommentLinks(info, expected);
+    assertCommentLinks(getConfig(), expected);
+  }
+
+  @Test
   @GerritConfig(name = "commentlink.bugzilla.match", value = BUGZILLA_MATCH)
   @GerritConfig(name = "commentlink.bugzilla.link", value = BUGZILLA_LINK)
   public void projectConfigUsesCommentLinksFromGlobalAndLocal() throws Exception {
diff --git a/javatests/com/google/gerrit/acceptance/api/project/SubmitRequirementsAPIIT.java b/javatests/com/google/gerrit/acceptance/api/project/SubmitRequirementsAPIIT.java
new file mode 100644
index 0000000..97a2d2b
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/project/SubmitRequirementsAPIIT.java
@@ -0,0 +1,651 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.api.project;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.extensions.common.SubmitRequirementInfo;
+import com.google.gerrit.extensions.common.SubmitRequirementInput;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.inject.Inject;
+import java.util.List;
+import java.util.stream.Collectors;
+import org.junit.Test;
+
+@NoHttpd
+public class SubmitRequirementsAPIIT extends AbstractDaemonTest {
+  @Inject private RequestScopeOperations requestScopeOperations;
+  @Inject private ProjectOperations projectOperations;
+
+  @Test
+  public void cannotGetANonExistingSR() throws Exception {
+    ResourceNotFoundException thrown =
+        assertThrows(
+            ResourceNotFoundException.class,
+            () -> gApi.projects().name(project.get()).submitRequirement("code-review").get());
+    assertThat(thrown)
+        .hasMessageThat()
+        .isEqualTo("Submit requirement 'code-review' does not exist");
+  }
+
+  @Test
+  public void getExistingSR() throws Exception {
+    SubmitRequirementInput input = new SubmitRequirementInput();
+    input.name = "code-review";
+    input.applicabilityExpression = "topic:foo";
+    input.submittabilityExpression = "label:code-review=+2";
+    gApi.projects().name(project.get()).submitRequirement("code-review").create(input).get();
+
+    SubmitRequirementInfo info =
+        gApi.projects().name(project.get()).submitRequirement("code-review").get();
+    assertThat(info.name).isEqualTo("code-review");
+    assertThat(info.applicabilityExpression).isEqualTo("topic:foo");
+    assertThat(info.submittabilityExpression).isEqualTo("label:code-review=+2");
+    assertThat(info.allowOverrideInChildProjects).isEqualTo(false);
+  }
+
+  @Test
+  public void updateSubmitRequirement() throws Exception {
+    SubmitRequirementInput input = new SubmitRequirementInput();
+    input.name = "code-review";
+    input.applicabilityExpression = "topic:foo";
+    input.submittabilityExpression = "label:code-review=+2";
+    gApi.projects().name(project.get()).submitRequirement("code-review").create(input).get();
+
+    input.submittabilityExpression = "label:code-review=+1";
+    SubmitRequirementInfo info =
+        gApi.projects().name(project.get()).submitRequirement("code-review").update(input);
+    assertThat(info.submittabilityExpression).isEqualTo("label:code-review=+1");
+  }
+
+  @Test
+  public void updateSRWithEmptyApplicabilityExpression_isAllowed() throws Exception {
+    SubmitRequirementInput input = new SubmitRequirementInput();
+    input.name = "code-review";
+    input.applicabilityExpression = "topic:foo";
+    input.submittabilityExpression = "label:code-review=+2";
+    gApi.projects().name(project.get()).submitRequirement("code-review").create(input).get();
+
+    input.applicabilityExpression = null;
+    SubmitRequirementInfo info =
+        gApi.projects().name(project.get()).submitRequirement("code-review").update(input);
+    assertThat(info.applicabilityExpression).isNull();
+  }
+
+  @Test
+  public void updateSRWithEmptyOverrideExpression_isAllowed() throws Exception {
+    SubmitRequirementInput input = new SubmitRequirementInput();
+    input.name = "code-review";
+    input.overrideExpression = "topic:foo";
+    input.submittabilityExpression = "label:code-review=+2";
+    gApi.projects().name(project.get()).submitRequirement("code-review").create(input).get();
+
+    input.overrideExpression = null;
+    SubmitRequirementInfo info =
+        gApi.projects().name(project.get()).submitRequirement("code-review").update(input);
+    assertThat(info.overrideExpression).isNull();
+  }
+
+  @Test
+  public void allowOverrideInChildProjectsDefaultsToFalse_updateSR() throws Exception {
+    SubmitRequirementInput input = new SubmitRequirementInput();
+    input.name = "code-review";
+    input.submittabilityExpression = "label:code-review=+2";
+    gApi.projects().name(project.get()).submitRequirement("code-review").create(input).get();
+
+    input.overrideExpression = "topic:foo";
+    SubmitRequirementInfo info =
+        gApi.projects().name(project.get()).submitRequirement("code-review").update(input);
+    assertThat(info.allowOverrideInChildProjects).isFalse();
+  }
+
+  @Test
+  public void cannotUpdateSRAsAnonymousUser() throws Exception {
+    SubmitRequirementInput input = new SubmitRequirementInput();
+    input.name = "code-review";
+    input.submittabilityExpression = "label:code-review=+2";
+
+    gApi.projects().name(project.get()).submitRequirement("code-review").create(input).get();
+    input.submittabilityExpression = "label:code-review=+1";
+    requestScopeOperations.setApiUserAnonymous();
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () ->
+                gApi.projects()
+                    .name(project.get())
+                    .submitRequirement("code-review")
+                    .update(new SubmitRequirementInput()));
+    assertThat(thrown).hasMessageThat().contains("Authentication required");
+  }
+
+  @Test
+  public void cannotUpdateSRtIfSRDoesNotExist() throws Exception {
+    SubmitRequirementInput input = new SubmitRequirementInput();
+    input.name = "code-review";
+    input.description = "At least one +2 vote to the code-review label";
+    input.submittabilityExpression = "label:code-review=+2";
+
+    ResourceNotFoundException thrown =
+        assertThrows(
+            ResourceNotFoundException.class,
+            () ->
+                gApi.projects().name(project.get()).submitRequirement("code-review").update(input));
+    assertThat(thrown)
+        .hasMessageThat()
+        .isEqualTo("Submit requirement 'code-review' does not exist");
+  }
+
+  @Test
+  public void cannotUpdateSRWithEmptySubmittableIf() throws Exception {
+    SubmitRequirementInput input = new SubmitRequirementInput();
+    input.name = "code-review";
+    input.submittabilityExpression = "project:foo AND branch:refs/heads/main";
+
+    gApi.projects().name(project.get()).submitRequirement("code-review").create(input).get();
+    input.submittabilityExpression = null;
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () ->
+                gApi.projects().name(project.get()).submitRequirement("code-review").update(input));
+
+    assertThat(thrown).hasMessageThat().isEqualTo("submittability_expression is required");
+  }
+
+  @Test
+  public void cannotUpdateSRWithInvalidSubmittableIfExpression() throws Exception {
+    SubmitRequirementInput input = new SubmitRequirementInput();
+    input.name = "code-review";
+    input.submittabilityExpression = "project:foo AND branch:refs/heads/main";
+
+    gApi.projects().name(project.get()).submitRequirement("code-review").create(input).get();
+    input.submittabilityExpression = "invalid_field:invalid_value";
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () ->
+                gApi.projects().name(project.get()).submitRequirement("code-review").update(input));
+    assertThat(thrown)
+        .hasMessageThat()
+        .isEqualTo(
+            "Invalid submit requirement input: "
+                + "[Invalid project configuration,   "
+                + "project.config: Expression 'invalid_field:invalid_value' of "
+                + "submit requirement 'code-review' "
+                + "(parameter submit-requirement.code-review.submittableIf) is invalid: "
+                + "Unsupported operator invalid_field:invalid_value]");
+  }
+
+  @Test
+  public void cannotUpdateSRWithInvalidOverrideIfExpression() throws Exception {
+    SubmitRequirementInput input = new SubmitRequirementInput();
+    input.name = "code-review";
+    input.submittabilityExpression = "project:foo AND branch:refs/heads/main";
+
+    gApi.projects().name(project.get()).submitRequirement("code-review").create(input).get();
+    input.overrideExpression = "invalid_field:invalid_value";
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () ->
+                gApi.projects().name(project.get()).submitRequirement("code-review").update(input));
+    assertThat(thrown)
+        .hasMessageThat()
+        .isEqualTo(
+            "Invalid submit requirement input: "
+                + "[Invalid project configuration,   "
+                + "project.config: Expression 'invalid_field:invalid_value' of "
+                + "submit requirement 'code-review' "
+                + "(parameter submit-requirement.code-review.overrideIf) is invalid: "
+                + "Unsupported operator invalid_field:invalid_value]");
+  }
+
+  @Test
+  public void cannotUpdateSRWithInvalidApplicableIfExpression() throws Exception {
+    SubmitRequirementInput input = new SubmitRequirementInput();
+    input.name = "code-review";
+    input.submittabilityExpression = "project:foo AND branch:refs/heads/main";
+
+    gApi.projects().name(project.get()).submitRequirement("code-review").create(input).get();
+    input.applicabilityExpression = "invalid_field:invalid_value";
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () ->
+                gApi.projects().name(project.get()).submitRequirement("code-review").update(input));
+    assertThat(thrown)
+        .hasMessageThat()
+        .isEqualTo(
+            "Invalid submit requirement input: "
+                + "[Invalid project configuration,   "
+                + "project.config: Expression 'invalid_field:invalid_value' of "
+                + "submit requirement 'code-review' "
+                + "(parameter submit-requirement.code-review.applicableIf) is invalid: "
+                + "Unsupported operator invalid_field:invalid_value]");
+  }
+
+  @Test
+  public void createSubmitRequirement() throws Exception {
+    SubmitRequirementInput input = new SubmitRequirementInput();
+    input.name = "code-review";
+    input.description = "At least one +2 vote to the code-review label";
+    input.applicabilityExpression = "project:foo AND branch:refs/heads/main";
+    input.submittabilityExpression = "label:code-review=+2";
+    input.overrideExpression = "label:build-cop-override=+1";
+    input.allowOverrideInChildProjects = true;
+
+    SubmitRequirementInfo info =
+        gApi.projects().name(project.get()).submitRequirement("code-review").create(input).get();
+
+    assertThat(info.name).isEqualTo("code-review");
+    assertThat(info.description).isEqualTo(input.description);
+    assertThat(info.applicabilityExpression).isEqualTo(input.applicabilityExpression);
+    assertThat(info.applicabilityExpression).isEqualTo(input.applicabilityExpression);
+    assertThat(info.submittabilityExpression).isEqualTo(input.submittabilityExpression);
+    assertThat(info.overrideExpression).isEqualTo(input.overrideExpression);
+    assertThat(info.allowOverrideInChildProjects).isEqualTo(true);
+  }
+
+  @Test
+  public void createSRWithEmptyApplicabilityExpression_isAllowed() throws Exception {
+    SubmitRequirementInput input = new SubmitRequirementInput();
+    input.name = "code-review";
+    input.description = "At least one +2 vote to the code-review label";
+    input.submittabilityExpression = "label:code-review=+2";
+    input.overrideExpression = "label:build-cop-override=+1";
+
+    SubmitRequirementInfo info =
+        gApi.projects().name(project.get()).submitRequirement("code-review").create(input).get();
+
+    assertThat(info.name).isEqualTo("code-review");
+    assertThat(info.applicabilityExpression).isNull();
+  }
+
+  @Test
+  public void createSRWithEmptyOverrideExpression_isAllowed() throws Exception {
+    SubmitRequirementInput input = new SubmitRequirementInput();
+    input.name = "code-review";
+    input.description = "At least one +2 vote to the code-review label";
+    input.applicabilityExpression = "project:foo AND branch:refs/heads/main";
+    input.submittabilityExpression = "label:code-review=+2";
+
+    SubmitRequirementInfo info =
+        gApi.projects().name(project.get()).submitRequirement("code-review").create(input).get();
+
+    assertThat(info.name).isEqualTo("code-review");
+    assertThat(info.overrideExpression).isNull();
+  }
+
+  @Test
+  public void allowOverrideInChildProjectsDefaultsToFalse_createSR() throws Exception {
+    SubmitRequirementInput input = new SubmitRequirementInput();
+    input.name = "code-review";
+    input.description = "At least one +2 vote to the code-review label";
+    input.submittabilityExpression = "label:code-review=+2";
+
+    SubmitRequirementInfo info =
+        gApi.projects().name(project.get()).submitRequirement("code-review").create(input).get();
+
+    assertThat(info.allowOverrideInChildProjects).isEqualTo(false);
+  }
+
+  @Test
+  public void cannotCreateSRAsAnonymousUser() throws Exception {
+    SubmitRequirementInput input = new SubmitRequirementInput();
+    input.name = "code-review";
+    input.description = "At least one +2 vote to the code-review label";
+    input.applicabilityExpression = "project:foo AND branch:refs/heads/main";
+    input.submittabilityExpression = "label:code-review=+2";
+    input.overrideExpression = "label:build-cop-override=+1";
+
+    requestScopeOperations.setApiUserAnonymous();
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () ->
+                gApi.projects()
+                    .name(project.get())
+                    .submitRequirement("code-review")
+                    .create(new SubmitRequirementInput()));
+    assertThat(thrown).hasMessageThat().contains("Authentication required");
+  }
+
+  @Test
+  public void cannotCreateSRtIfNameInInputDoesNotMatchResource() throws Exception {
+    SubmitRequirementInput input = new SubmitRequirementInput();
+    input.name = "code-review";
+    input.description = "At least one +2 vote to the code-review label";
+    input.submittabilityExpression = "label:code-review=+2";
+
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () ->
+                gApi.projects()
+                    .name(project.get())
+                    .submitRequirement("other-requirement")
+                    .create(input)
+                    .get());
+    assertThat(thrown).hasMessageThat().isEqualTo("name in input must match name in URL");
+  }
+
+  @Test
+  public void cannotCreateSRWithInvalidName() throws Exception {
+    SubmitRequirementInput input = new SubmitRequirementInput();
+    input.name = "wrong$%";
+    input.submittabilityExpression = "label:code-review=+2";
+
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () ->
+                gApi.projects()
+                    .name(project.get())
+                    .submitRequirement("wrong$%")
+                    .create(input)
+                    .get());
+    assertThat(thrown)
+        .hasMessageThat()
+        .isEqualTo(
+            "Illegal submit requirement name \"wrong$%\". "
+                + "Name can only consist of alphanumeric characters and '-'."
+                + " Name cannot start with '-' or number.");
+  }
+
+  @Test
+  public void cannotCreateSRWithEmptySubmittableIf() throws Exception {
+    SubmitRequirementInput input = new SubmitRequirementInput();
+    input.name = "code-review";
+    input.description = "At least one +2 vote to the code-review label";
+    input.applicabilityExpression = "project:foo AND branch:refs/heads/main";
+    input.overrideExpression = "label:build-cop-override=+1";
+
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () ->
+                gApi.projects()
+                    .name(project.get())
+                    .submitRequirement("code-review")
+                    .create(input)
+                    .get());
+
+    assertThat(thrown).hasMessageThat().isEqualTo("submittability_expression is required");
+  }
+
+  @Test
+  public void cannotCreateSRWithInvalidSubmittableIfExpression() throws Exception {
+    SubmitRequirementInput input = new SubmitRequirementInput();
+    input.name = "code-review";
+    input.description = "At least one +2 vote to the code-review label";
+    input.submittabilityExpression = "invalid_field:invalid_value";
+    BadRequestException exception =
+        assertThrows(
+            BadRequestException.class,
+            () ->
+                gApi.projects()
+                    .name(project.get())
+                    .submitRequirement("code-review")
+                    .create(input)
+                    .get());
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo(
+            "Invalid submit requirement input: "
+                + "[Invalid project configuration,   "
+                + "project.config: Expression 'invalid_field:invalid_value' of "
+                + "submit requirement 'code-review' "
+                + "(parameter submit-requirement.code-review.submittableIf) is invalid: "
+                + "Unsupported operator invalid_field:invalid_value]");
+  }
+
+  @Test
+  public void cannotCreateSRWithInvalidOverrideIfExpression() throws Exception {
+    SubmitRequirementInput input = new SubmitRequirementInput();
+    input.name = "code-review";
+    input.description = "At least one +2 vote to the code-review label";
+    input.submittabilityExpression = "label:Code-Review=+2";
+    input.overrideExpression = "invalid_field:invalid_value";
+    BadRequestException exception =
+        assertThrows(
+            BadRequestException.class,
+            () ->
+                gApi.projects()
+                    .name(project.get())
+                    .submitRequirement("code-review")
+                    .create(input)
+                    .get());
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo(
+            "Invalid submit requirement input: "
+                + "[Invalid project configuration,   "
+                + "project.config: Expression 'invalid_field:invalid_value' of "
+                + "submit requirement 'code-review' "
+                + "(parameter submit-requirement.code-review.overrideIf) is invalid: "
+                + "Unsupported operator invalid_field:invalid_value]");
+  }
+
+  @Test
+  public void cannotCreateSRWithInvalidApplicableIfExpression() throws Exception {
+    SubmitRequirementInput input = new SubmitRequirementInput();
+    input.name = "code-review";
+    input.description = "At least one +2 vote to the code-review label";
+    input.applicabilityExpression = "invalid_field:invalid_value";
+    input.submittabilityExpression = "label:Code-Review=+2";
+    BadRequestException exception =
+        assertThrows(
+            BadRequestException.class,
+            () ->
+                gApi.projects()
+                    .name(project.get())
+                    .submitRequirement("code-review")
+                    .create(input)
+                    .get());
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo(
+            "Invalid submit requirement input: "
+                + "[Invalid project configuration,   "
+                + "project.config: Expression 'invalid_field:invalid_value' of "
+                + "submit requirement 'code-review' "
+                + "(parameter submit-requirement.code-review.applicableIf) is invalid: "
+                + "Unsupported operator invalid_field:invalid_value]");
+  }
+
+  @Test
+  public void cannotListSRsAsAnonymous() throws Exception {
+    requestScopeOperations.setApiUserAnonymous();
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () -> gApi.projects().name(project.get()).submitRequirements().get());
+    assertThat(thrown).hasMessageThat().contains("Authentication required");
+  }
+
+  @Test
+  public void cannotListSRs_withMissingReadPermissionsToRefsConfig() throws Exception {
+    requestScopeOperations.setApiUser(user.id());
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () -> gApi.projects().name(project.get()).submitRequirements().get());
+    assertThat(thrown).hasMessageThat().contains("read refs/meta/config not permitted");
+  }
+
+  @Test
+  public void cannotListSRs_withMissingReadPermissionsInParent_withInheritance() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/meta/config").group(REGISTERED_USERS))
+        .update();
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.READ).ref("refs/meta/config").group(REGISTERED_USERS))
+        .update();
+    requestScopeOperations.setApiUser(user.id());
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () ->
+                gApi.projects().name(project.get()).submitRequirements().withInherited(true).get());
+    assertThat(thrown).hasMessageThat().contains("read refs/meta/config not permitted");
+  }
+
+  @Test
+  public void canListSRs_withReadPermissionsInAllParentProjects_withInheritance() throws Exception {
+    projectOperations
+        .project(allProjects)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/meta/config").group(REGISTERED_USERS))
+        .update();
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/meta/config").group(REGISTERED_USERS))
+        .update();
+    requestScopeOperations.setApiUser(user.id());
+    gApi.projects().name(project.get()).submitRequirements().get();
+  }
+
+  @Test
+  public void canListSRs_withMissingReadPermissionsInParent_withoutInheritance() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/meta/config").group(REGISTERED_USERS))
+        .update();
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.READ).ref("refs/meta/config").group(REGISTERED_USERS))
+        .update();
+    requestScopeOperations.setApiUser(user.id());
+    gApi.projects().name(project.get()).submitRequirements().withInherited(false).get();
+  }
+
+  @Test
+  public void listSRs() throws Exception {
+    createSubmitRequirement("sr-1");
+    createSubmitRequirement("sr-2");
+
+    List<SubmitRequirementInfo> infos =
+        gApi.projects().name(project.get()).submitRequirements().get();
+
+    assertThat(names(infos)).containsExactly("sr-1", "sr-2");
+  }
+
+  @Test
+  public void listSRsWithInheritance() throws Exception {
+    createSubmitRequirement(allProjects.get(), "base-sr");
+    createSubmitRequirement(project.get(), "sr-1");
+    createSubmitRequirement(project.get(), "sr-2");
+
+    List<SubmitRequirementInfo> infos =
+        gApi.projects().name(project.get()).submitRequirements().withInherited(false).get();
+
+    assertThat(names(infos)).containsExactly("sr-1", "sr-2");
+
+    infos = gApi.projects().name(project.get()).submitRequirements().withInherited(true).get();
+
+    assertThat(names(infos)).containsExactly("base-sr", "sr-1", "sr-2");
+  }
+
+  @Test
+  public void cannotDeleteSRAsAnonymousUser() throws Exception {
+    createSubmitRequirement("code-review");
+
+    requestScopeOperations.setApiUserAnonymous();
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () -> gApi.projects().name(project.get()).submitRequirement("code-review").delete());
+    assertThat(thrown).hasMessageThat().contains("Authentication required");
+  }
+
+  @Test
+  public void cannotDeleteSRWithMissingWritePermissionsToRefsConfig() throws Exception {
+    createSubmitRequirement("sr-1");
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/meta/config").group(REGISTERED_USERS))
+        .add(block("write").ref("refs/meta/config").group(REGISTERED_USERS))
+        .update();
+    requestScopeOperations.setApiUser(user.id());
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () -> gApi.projects().name(project.get()).submitRequirement("sr-1").delete());
+    assertThat(thrown).hasMessageThat().contains("write refs/meta/config not permitted");
+  }
+
+  @Test
+  public void cannotDeleteNonExistingSR() throws Exception {
+    ResourceNotFoundException thrown =
+        assertThrows(
+            ResourceNotFoundException.class,
+            () -> gApi.projects().name(project.get()).submitRequirement("non-existing").delete());
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("Submit requirement 'non-existing' does not exist");
+  }
+
+  @Test
+  public void deleteSubmitRequirement() throws Exception {
+    createSubmitRequirement("code-review");
+    createSubmitRequirement("verified");
+
+    List<SubmitRequirementInfo> infos =
+        gApi.projects().name(project.get()).submitRequirements().get();
+    assertThat(names(infos)).containsExactly("code-review", "verified");
+
+    gApi.projects().name(project.get()).submitRequirement("code-review").delete();
+    infos = gApi.projects().name(project.get()).submitRequirements().get();
+    assertThat(names(infos)).containsExactly("verified");
+  }
+
+  private SubmitRequirementInfo createSubmitRequirement(String srName) throws RestApiException {
+    return createSubmitRequirement(project.get(), srName);
+  }
+
+  private SubmitRequirementInfo createSubmitRequirement(String project, String srName)
+      throws RestApiException {
+    SubmitRequirementInput input = new SubmitRequirementInput();
+    input.name = srName;
+    input.submittabilityExpression = "label:dummy=+2";
+
+    return gApi.projects().name(project).submitRequirement(srName).create(input).get();
+  }
+
+  private List<String> names(List<SubmitRequirementInfo> infos) {
+    return infos.stream().map(sr -> sr.name).collect(Collectors.toList());
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/ApplyProvidedFixIT.java b/javatests/com/google/gerrit/acceptance/api/revision/ApplyProvidedFixIT.java
new file mode 100644
index 0000000..7c0b713
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/revision/ApplyProvidedFixIT.java
@@ -0,0 +1,581 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.api.revision;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
+import static com.google.gerrit.extensions.common.testing.EditInfoSubject.assertThat;
+import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.entities.Patch;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.api.changes.PublishChangeEditInput;
+import com.google.gerrit.extensions.api.changes.ReviewResult;
+import com.google.gerrit.extensions.client.Comment;
+import com.google.gerrit.extensions.common.ApplyProvidedFixInput;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.EditInfo;
+import com.google.gerrit.extensions.common.FixReplacementInfo;
+import com.google.gerrit.extensions.common.RevisionInfo;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.BinaryResult;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.testing.BinaryResultSubject;
+import com.google.gson.stream.JsonReader;
+import com.google.inject.Inject;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Optional;
+import org.junit.Before;
+import org.junit.Test;
+
+public class ApplyProvidedFixIT extends AbstractDaemonTest {
+  @Inject private ProjectOperations projectOperations;
+
+  private static final String FILE_NAME = "file_to_fix.txt";
+  private static final String FILE_NAME2 = "another_file_to_fix.txt";
+  private static final String FILE_NAME3 = "file_without_newline_at_end.txt";
+  private static final String FILE_CONTENT =
+      "First line\nSecond line\nThird line\nFourth line\nFifth line\nSixth line"
+          + "\nSeventh line\nEighth line\nNinth line\nTenth line\n";
+  private static final String FILE_CONTENT2 = "1st line\n2nd line\n3rd line\n";
+  private static final String FILE_CONTENT3 = "1st line\n2nd line";
+
+  private String changeId;
+  private String commitId;
+
+  @Before
+  public void setUp() throws Exception {
+    PushOneCommit push =
+        pushFactory.create(
+            admin.newIdent(),
+            testRepo,
+            "Provide files which can be used for fixes",
+            ImmutableMap.of(
+                FILE_NAME, FILE_CONTENT, FILE_NAME2, FILE_CONTENT2, FILE_NAME3, FILE_CONTENT3));
+    PushOneCommit.Result changeResult = push.to("refs/for/master");
+    changeId = changeResult.getChangeId();
+    commitId = changeResult.getCommit().getName();
+  }
+
+  @Test
+  public void applyProvidedFixWithinALineCanBeApplied() throws Exception {
+    ApplyProvidedFixInput applyProvidedFixInput =
+        createApplyProvidedFixInput(FILE_NAME, "Modified content", 3, 1, 3, 3);
+    gApi.changes().id(changeId).current().applyFix(applyProvidedFixInput);
+
+    Optional<BinaryResult> file = gApi.changes().id(changeId).edit().getFile(FILE_NAME);
+    BinaryResultSubject.assertThat(file)
+        .value()
+        .asString()
+        .isEqualTo(
+            "First line\nSecond line\nTModified contentrd line\nFourth line\nFifth line\n"
+                + "Sixth line\nSeventh line\nEighth line\nNinth line\nTenth line\n");
+  }
+
+  @Test
+  public void applyProvidedFixRestAPItestForASimpleFix() throws Exception {
+    ApplyProvidedFixInput applyProvidedFixInput =
+        createApplyProvidedFixInput(FILE_NAME, "Modified content", 3, 1, 3, 3);
+    RestResponse resp =
+        adminRestSession.post(
+            "/changes/" + changeId + "/revisions/" + commitId + "/fix:apply",
+            applyProvidedFixInput);
+    readContentFromJson(resp, 200, ReviewResult.class);
+    Optional<BinaryResult> file = gApi.changes().id(changeId).edit().getFile(FILE_NAME);
+    BinaryResultSubject.assertThat(file)
+        .value()
+        .asString()
+        .isEqualTo(
+            "First line\nSecond line\nTModified contentrd line\nFourth line\nFifth line\n"
+                + "Sixth line\nSeventh line\nEighth line\nNinth line\nTenth line\n");
+  }
+
+  @Test
+  public void applyProvidedFixSpanningMultipleLinesCanBeApplied() throws Exception {
+    ApplyProvidedFixInput applyProvidedFixInput =
+        createApplyProvidedFixInput(FILE_NAME, "Modified content\n5", 3, 2, 5, 3);
+
+    gApi.changes().id(changeId).current().applyFix(applyProvidedFixInput);
+
+    Optional<BinaryResult> file = gApi.changes().id(changeId).edit().getFile(FILE_NAME);
+    BinaryResultSubject.assertThat(file)
+        .value()
+        .asString()
+        .isEqualTo(
+            "First line\nSecond line\nThModified content\n5th line\nSixth line\nSeventh line\n"
+                + "Eighth line\nNinth line\nTenth line\n");
+  }
+
+  @Test
+  public void applyProvidedFixWithTwoCloseReplacementsOnSameFileCanBeApplied() throws Exception {
+    FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
+    fixReplacementInfo1.path = FILE_NAME;
+    fixReplacementInfo1.range = createRange(2, 0, 3, 0);
+    fixReplacementInfo1.replacement = "First modification\n";
+
+    FixReplacementInfo fixReplacementInfo2 = new FixReplacementInfo();
+    fixReplacementInfo2.path = FILE_NAME;
+    fixReplacementInfo2.range = createRange(3, 0, 4, 0);
+    fixReplacementInfo2.replacement = "Some other modified content\n";
+
+    List<FixReplacementInfo> fixReplacementInfoList =
+        Arrays.asList(fixReplacementInfo1, fixReplacementInfo2);
+    ApplyProvidedFixInput applyProvidedFixInput = new ApplyProvidedFixInput();
+    applyProvidedFixInput.fixReplacementInfos = fixReplacementInfoList;
+
+    gApi.changes().id(changeId).current().applyFix(applyProvidedFixInput);
+
+    Optional<BinaryResult> file = gApi.changes().id(changeId).edit().getFile(FILE_NAME);
+    BinaryResultSubject.assertThat(file)
+        .value()
+        .asString()
+        .isEqualTo(
+            "First line\nFirst modification\nSome other modified content\nFourth line\nFifth line\n"
+                + "Sixth line\nSeventh line\nEighth line\nNinth line\nTenth line\n");
+  }
+
+  @Test
+  public void twoApplyProvidedFixesOnSameFileCanBeApplied() throws Exception {
+    FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
+    fixReplacementInfo1.path = FILE_NAME;
+    fixReplacementInfo1.range = createRange(2, 0, 3, 0);
+    fixReplacementInfo1.replacement = "First modification\n";
+
+    FixReplacementInfo fixReplacementInfo2 = new FixReplacementInfo();
+    fixReplacementInfo2.path = FILE_NAME;
+    fixReplacementInfo2.range = createRange(8, 0, 9, 0);
+    fixReplacementInfo2.replacement = "Some other modified content\n";
+
+    List<FixReplacementInfo> fixReplacementInfoList =
+        Arrays.asList(fixReplacementInfo1, fixReplacementInfo2);
+    ApplyProvidedFixInput applyProvidedFixInput = new ApplyProvidedFixInput();
+    applyProvidedFixInput.fixReplacementInfos = fixReplacementInfoList;
+
+    gApi.changes().id(changeId).current().applyFix(applyProvidedFixInput);
+
+    Optional<BinaryResult> file = gApi.changes().id(changeId).edit().getFile(FILE_NAME);
+    BinaryResultSubject.assertThat(file)
+        .value()
+        .asString()
+        .isEqualTo(
+            "First line\nFirst modification\nThird line\nFourth line\nFifth line\nSixth line\n"
+                + "Seventh line\nSome other modified content\nNinth line\nTenth line\n");
+  }
+
+  @Test
+  public void twoConflictingApplyProvidedFixesOnSameFileCannotBeApplied() throws Exception {
+    FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
+    fixReplacementInfo1.path = FILE_NAME;
+    fixReplacementInfo1.range = createRange(2, 0, 3, 1);
+    fixReplacementInfo1.replacement = "First modification\n";
+
+    FixReplacementInfo fixReplacementInfo2 = new FixReplacementInfo();
+    fixReplacementInfo2.path = FILE_NAME;
+    fixReplacementInfo2.range = createRange(3, 0, 4, 0);
+    fixReplacementInfo2.replacement = "Some other modified content\n";
+
+    List<FixReplacementInfo> fixReplacementInfoList =
+        Arrays.asList(fixReplacementInfo1, fixReplacementInfo2);
+    ApplyProvidedFixInput applyProvidedFixInput = new ApplyProvidedFixInput();
+    applyProvidedFixInput.fixReplacementInfos = fixReplacementInfoList;
+
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.changes().id(changeId).current().applyFix(applyProvidedFixInput));
+    assertThat(thrown).hasMessageThat().contains("Cannot calculate fix replacement");
+  }
+
+  @Test
+  public void applyProvidedFixInvolvingTwoFilesCanBeApplied() throws Exception {
+    FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
+    fixReplacementInfo1.path = FILE_NAME;
+    fixReplacementInfo1.range = createRange(2, 0, 3, 0);
+    fixReplacementInfo1.replacement = "First modification\n";
+
+    FixReplacementInfo fixReplacementInfo2 = new FixReplacementInfo();
+    fixReplacementInfo2.path = FILE_NAME2;
+    fixReplacementInfo2.range = createRange(1, 0, 2, 0);
+    fixReplacementInfo2.replacement = "Different file modification\n";
+
+    List<FixReplacementInfo> fixReplacementInfoList =
+        Arrays.asList(fixReplacementInfo1, fixReplacementInfo2);
+    ApplyProvidedFixInput applyProvidedFixInput = new ApplyProvidedFixInput();
+    applyProvidedFixInput.fixReplacementInfos = fixReplacementInfoList;
+
+    gApi.changes().id(changeId).current().applyFix(applyProvidedFixInput);
+
+    Optional<BinaryResult> file = gApi.changes().id(changeId).edit().getFile(FILE_NAME);
+    BinaryResultSubject.assertThat(file)
+        .value()
+        .asString()
+        .isEqualTo(
+            "First line\nFirst modification\nThird line\nFourth line\nFifth line\nSixth line\n"
+                + "Seventh line\nEighth line\nNinth line\nTenth line\n");
+    Optional<BinaryResult> file2 = gApi.changes().id(changeId).edit().getFile(FILE_NAME2);
+    BinaryResultSubject.assertThat(file2)
+        .value()
+        .asString()
+        .isEqualTo("Different file modification\n2nd line\n3rd line\n");
+  }
+
+  @Test
+  public void applyProvidedFixReferringToNonExistentFileCannotBeApplied() throws Exception {
+    ApplyProvidedFixInput applyProvidedFixInput =
+        createApplyProvidedFixInput("a_non_existent_file.txt", "Modified content\n", 1, 0, 2, 0);
+
+    assertThrows(
+        ResourceNotFoundException.class,
+        () -> gApi.changes().id(changeId).current().applyFix(applyProvidedFixInput));
+  }
+
+  @Test
+  public void applyProvidedFixRestAPIcallWithoutAddPatchSetPermissionCannotBeApplied()
+      throws Exception {
+    ApplyProvidedFixInput applyProvidedFixInput =
+        createApplyProvidedFixInput(FILE_NAME, "Modified content", 3, 1, 3, 3);
+
+    String allRefs = RefNames.REFS + "*";
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.ADD_PATCH_SET).ref(allRefs).group(ANONYMOUS_USERS))
+        .add(block(Permission.ADD_PATCH_SET).ref(allRefs).group(REGISTERED_USERS))
+        .update();
+
+    RestResponse resp =
+        userRestSession.post(
+            "/changes/" + changeId + "/revisions/" + commitId + "/fix:apply",
+            applyProvidedFixInput);
+    resp.assertStatus(403);
+  }
+
+  @Test
+  public void applyProvidedFixOnCurrentPatchSetWithExistingChangeEditCanBeApplied()
+      throws Exception {
+    FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
+    fixReplacementInfo1.path = FILE_NAME;
+    fixReplacementInfo1.range = createRange(2, 0, 3, 0);
+    fixReplacementInfo1.replacement = "First modification\n";
+
+    List<FixReplacementInfo> fixReplacementInfoList = Arrays.asList(fixReplacementInfo1);
+    ApplyProvidedFixInput applyProvidedFixInput = new ApplyProvidedFixInput();
+    applyProvidedFixInput.fixReplacementInfos = fixReplacementInfoList;
+
+    // Create an empty change edit.
+    gApi.changes().id(changeId).edit().create();
+
+    gApi.changes().id(changeId).current().applyFix(applyProvidedFixInput);
+
+    Optional<BinaryResult> file = gApi.changes().id(changeId).edit().getFile(FILE_NAME);
+    BinaryResultSubject.assertThat(file)
+        .value()
+        .asString()
+        .isEqualTo(
+            "First line\nFirst modification\nThird line\nFourth line\nFifth line\nSixth line\n"
+                + "Seventh line\nEighth line\nNinth line\nTenth line\n");
+  }
+
+  @Test
+  public void applyProvidedFixOnPreviousPatchSetCannotBeApplied() throws Exception {
+    // Remember patch set and add another one.
+    String previousRevision = gApi.changes().id(changeId).get().currentRevision;
+    amendChange(changeId);
+    ApplyProvidedFixInput applyProvidedFixInput =
+        createApplyProvidedFixInput(FILE_NAME, "Modified content", 3, 1, 3, 3);
+
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () ->
+                gApi.changes()
+                    .id(changeId)
+                    .revision(previousRevision)
+                    .applyFix(applyProvidedFixInput));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("A change edit may only be created for the current patch set");
+  }
+
+  @Test
+  public void applyProvidedFixOnCurrentPatchSetWithChangeEditOnPreviousPatchSetCannotBeApplied()
+      throws Exception {
+    // Create an empty change edit.
+    gApi.changes().id(changeId).edit().create();
+    // Add another patch set.
+    amendChange(changeId);
+    ApplyProvidedFixInput applyProvidedFixInput =
+        createApplyProvidedFixInput(FILE_NAME, "Modified content", 3, 1, 3, 3);
+
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.changes().id(changeId).current().applyFix(applyProvidedFixInput));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("on which the existing change edit is based may be modified");
+  }
+
+  @Test
+  public void applyProvidedFixOnCommitMessageCanBeApplied() throws Exception {
+    // Set a dedicated commit message.
+    String footer = "\nChange-Id: " + changeId + "\n";
+    String originalCommitMessage = "Line 1 of commit message\nLine 2 of commit message\n" + footer;
+    gApi.changes().id(changeId).edit().modifyCommitMessage(originalCommitMessage);
+    gApi.changes().id(changeId).edit().publish();
+    ApplyProvidedFixInput applyProvidedFixInput =
+        createApplyProvidedFixInput(Patch.COMMIT_MSG, "Modified line\n", 7, 0, 8, 0);
+
+    gApi.changes().id(changeId).current().applyFix(applyProvidedFixInput);
+    String commitMessage = gApi.changes().id(changeId).edit().getCommitMessage();
+    assertThat(commitMessage).isEqualTo("Modified line\nLine 2 of commit message\n" + footer);
+  }
+
+  @Test
+  public void applyProvidedFixOnHeaderPartOfCommitMessageCannotBeApplied() throws Exception {
+    // Set a dedicated commit message.
+    String footer = "Change-Id: " + changeId;
+    String originalCommitMessage =
+        "Line 1 of commit message\nLine 2 of commit message\n" + "\n" + footer + "\n";
+    gApi.changes().id(changeId).edit().modifyCommitMessage(originalCommitMessage);
+    gApi.changes().id(changeId).edit().publish();
+
+    ApplyProvidedFixInput applyProvidedFixInput =
+        createApplyProvidedFixInput(Patch.COMMIT_MSG, "Modified line\n", 1, 0, 2, 0);
+    ResourceConflictException exception =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.changes().id(changeId).current().applyFix(applyProvidedFixInput));
+    assertThat(exception).hasMessageThat().contains("header");
+  }
+
+  @Test
+  public void applyProvidedFixContainingSeveralModificationsOfCommitMessageCanBeApplied()
+      throws Exception {
+    // Set a dedicated commit message.
+    String footer = "\nChange-Id: " + changeId + "\n";
+    String originalCommitMessage =
+        "Line 1 of commit message\nLine 2 of commit message\nLine 3 of commit message\n" + footer;
+    gApi.changes().id(changeId).edit().modifyCommitMessage(originalCommitMessage);
+    gApi.changes().id(changeId).edit().publish();
+    FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
+    fixReplacementInfo1.path = Patch.COMMIT_MSG;
+    fixReplacementInfo1.range = createRange(7, 0, 8, 0);
+    fixReplacementInfo1.replacement = "Modified line 1\n";
+    FixReplacementInfo fixReplacementInfo2 = new FixReplacementInfo();
+    fixReplacementInfo2.path = Patch.COMMIT_MSG;
+    fixReplacementInfo2.range = createRange(9, 0, 10, 0);
+    fixReplacementInfo2.replacement = "Modified line 3\n";
+    List<FixReplacementInfo> fixReplacementInfoList =
+        Arrays.asList(fixReplacementInfo1, fixReplacementInfo2);
+    ApplyProvidedFixInput applyProvidedFixInput = new ApplyProvidedFixInput();
+    applyProvidedFixInput.fixReplacementInfos = fixReplacementInfoList;
+    gApi.changes().id(changeId).current().applyFix(applyProvidedFixInput);
+    String commitMessage = gApi.changes().id(changeId).edit().getCommitMessage();
+    assertThat(commitMessage)
+        .isEqualTo("Modified line 1\nLine 2 of commit message\nModified line 3\n" + footer);
+  }
+
+  @Test
+  public void applyProvidedFixModifyingTheCommitMessageAndAFileCanBeApplied() throws Exception {
+    // Set a dedicated commit message.
+    String footer = "\nChange-Id: " + changeId + "\n";
+    String originalCommitMessage = "Line 1 of commit message\nLine 2 of commit message\n" + footer;
+    gApi.changes().id(changeId).edit().modifyCommitMessage(originalCommitMessage);
+    gApi.changes().id(changeId).edit().publish();
+    FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
+    fixReplacementInfo1.path = Patch.COMMIT_MSG;
+    fixReplacementInfo1.range = createRange(7, 0, 8, 0);
+    fixReplacementInfo1.replacement = "Modified line 1\n";
+    FixReplacementInfo fixReplacementInfo2 = new FixReplacementInfo();
+    fixReplacementInfo2.path = FILE_NAME2;
+    fixReplacementInfo2.range = createRange(1, 0, 2, 0);
+    fixReplacementInfo2.replacement = "File modification\n";
+    List<FixReplacementInfo> fixReplacementInfoList =
+        Arrays.asList(fixReplacementInfo1, fixReplacementInfo2);
+    ApplyProvidedFixInput applyProvidedFixInput = new ApplyProvidedFixInput();
+    applyProvidedFixInput.fixReplacementInfos = fixReplacementInfoList;
+    gApi.changes().id(changeId).current().applyFix(applyProvidedFixInput);
+    String commitMessage = gApi.changes().id(changeId).edit().getCommitMessage();
+    assertThat(commitMessage).isEqualTo("Modified line 1\nLine 2 of commit message\n" + footer);
+    Optional<BinaryResult> file = gApi.changes().id(changeId).edit().getFile(FILE_NAME2);
+    BinaryResultSubject.assertThat(file)
+        .value()
+        .asString()
+        .isEqualTo("File modification\n2nd line\n3rd line\n");
+  }
+
+  @Test
+  public void twoApplyProvidedFixesNonOverlappingOnCommitMessageCanBeAppliedSubsequently()
+      throws Exception {
+    // Set a dedicated commit message.
+    String footer = "\nChange-Id: " + changeId + "\n";
+    String originalCommitMessage =
+        "Line 1 of commit message\nLine 2 of commit message\nLine 3 of commit message\n" + footer;
+    gApi.changes().id(changeId).edit().modifyCommitMessage(originalCommitMessage);
+    gApi.changes().id(changeId).edit().publish();
+    FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
+    fixReplacementInfo1.path = Patch.COMMIT_MSG;
+    fixReplacementInfo1.range = createRange(7, 0, 8, 0);
+    fixReplacementInfo1.replacement = "Modified line 1\n";
+    FixReplacementInfo fixReplacementInfo2 = new FixReplacementInfo();
+    fixReplacementInfo2.path = Patch.COMMIT_MSG;
+    fixReplacementInfo2.range = createRange(9, 0, 10, 0);
+    fixReplacementInfo2.replacement = "Modified line 3\n";
+    List<FixReplacementInfo> fixReplacementInfoList1 = Arrays.asList(fixReplacementInfo1);
+    ApplyProvidedFixInput applyProvidedFixInput1 = new ApplyProvidedFixInput();
+    applyProvidedFixInput1.fixReplacementInfos = fixReplacementInfoList1;
+    List<FixReplacementInfo> fixReplacementInfoList2 = Arrays.asList(fixReplacementInfo2);
+    ApplyProvidedFixInput applyProvidedFixInput2 = new ApplyProvidedFixInput();
+    applyProvidedFixInput2.fixReplacementInfos = fixReplacementInfoList2;
+    gApi.changes().id(changeId).current().applyFix(applyProvidedFixInput1);
+    gApi.changes().id(changeId).current().applyFix(applyProvidedFixInput2);
+
+    String commitMessage = gApi.changes().id(changeId).edit().getCommitMessage();
+    assertThat(commitMessage)
+        .isEqualTo("Modified line 1\nLine 2 of commit message\nModified line 3\n" + footer);
+  }
+
+  @Test
+  public void applyingStoredFixTwiceIsIdempotent() throws Exception {
+    ApplyProvidedFixInput applyProvidedFixInput =
+        createApplyProvidedFixInput(FILE_NAME, "Modified content", 3, 1, 3, 3);
+    gApi.changes().id(changeId).current().applyFix(applyProvidedFixInput);
+    String expectedEditCommit =
+        gApi.changes().id(changeId).edit().get().map(edit -> edit.commit.commit).orElse("");
+
+    // Apply the fix again.
+    gApi.changes().id(changeId).current().applyFix(applyProvidedFixInput);
+    Optional<EditInfo> editInfo = gApi.changes().id(changeId).edit().get();
+    assertThat(editInfo).value().commit().commit().isEqualTo(expectedEditCommit);
+  }
+
+  @Test
+  public void applyProvidedFixReturnsEditInfoForCreatedChangeEdit() throws Exception {
+    ApplyProvidedFixInput applyProvidedFixInput =
+        createApplyProvidedFixInput(FILE_NAME, "Modified content", 3, 1, 3, 3);
+    EditInfo editInfo = gApi.changes().id(changeId).current().applyFix(applyProvidedFixInput);
+    Optional<EditInfo> expectedEditInfo = gApi.changes().id(changeId).edit().get();
+    String expectedEditCommit = expectedEditInfo.map(edit -> edit.commit.commit).orElse("");
+    assertThat(editInfo).commit().commit().isEqualTo(expectedEditCommit);
+    String expectedBaseRevision = expectedEditInfo.map(edit -> edit.baseRevision).orElse("");
+    assertThat(editInfo).baseRevision().isEqualTo(expectedBaseRevision);
+  }
+
+  @Test
+  public void createdApplyProvidedFixChangeEditIsBasedOnCurrentPatchSet() throws Exception {
+    String currentRevision = gApi.changes().id(changeId).get().currentRevision;
+    ApplyProvidedFixInput applyProvidedFixInput =
+        createApplyProvidedFixInput(FILE_NAME, "Modified content", 3, 1, 3, 3);
+    EditInfo editInfo = gApi.changes().id(changeId).current().applyFix(applyProvidedFixInput);
+    assertThat(editInfo).baseRevision().isEqualTo(currentRevision);
+  }
+
+  @Test
+  public void applyProvidedFixRestCallWithDifferentUserTheUserBecomesUploader() throws Exception {
+    ApplyProvidedFixInput applyProvidedFixInput =
+        createApplyProvidedFixInput(FILE_NAME, "Modified content", 3, 1, 3, 3);
+
+    ChangeInfo changeInfo = gApi.changes().id(changeId).get();
+    RevisionInfo rev = changeInfo.revisions.get(changeInfo.currentRevision);
+    assertThat(rev.uploader.username).isEqualTo(admin.username());
+
+    RestResponse resp =
+        userRestSession.post(
+            "/changes/" + changeId + "/revisions/" + commitId + "/fix:apply",
+            applyProvidedFixInput);
+    resp.assertStatus(200);
+
+    PublishChangeEditInput publishInput = new PublishChangeEditInput();
+    RestResponse resp2 =
+        userRestSession.post("/changes/" + changeId + "/edit:publish", publishInput);
+    resp2.assertStatus(204);
+
+    changeInfo = gApi.changes().id(changeId).get();
+    RevisionInfo rev2 = changeInfo.revisions.get(changeInfo.currentRevision);
+    assertThat(rev2.uploader.username).isEqualTo(user.username());
+  }
+
+  @Test
+  public void applyProvidedFixInputNullReturnsBadRequestException() throws Exception {
+    ApplyProvidedFixInput applyProvidedFixInput = null;
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.changes().id(changeId).current().applyFix(applyProvidedFixInput));
+    assertThat(thrown).hasMessageThat().contains("applyProvidedFixInput is required");
+  }
+
+  @Test
+  public void applyProvidedFixInputFixReplacementInfosNullReturnsBadRequestException()
+      throws Exception {
+    ApplyProvidedFixInput applyProvidedFixInput = new ApplyProvidedFixInput();
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.changes().id(changeId).current().applyFix(applyProvidedFixInput));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("applyProvidedFixInput.fixReplacementInfos is required");
+  }
+
+  private ApplyProvidedFixInput createApplyProvidedFixInput(
+      String file_name,
+      String replacement,
+      int startLine,
+      int startCharacter,
+      int endLine,
+      int endCharacter) {
+    FixReplacementInfo fixReplacementInfo = new FixReplacementInfo();
+    fixReplacementInfo.path = file_name;
+    fixReplacementInfo.replacement = replacement;
+    fixReplacementInfo.range = createRange(startLine, startCharacter, endLine, endCharacter);
+
+    List<FixReplacementInfo> fixReplacementInfoList = Arrays.asList(fixReplacementInfo);
+    ApplyProvidedFixInput applyProvidedFixInput = new ApplyProvidedFixInput();
+    applyProvidedFixInput.fixReplacementInfos = fixReplacementInfoList;
+
+    return applyProvidedFixInput;
+  }
+
+  private static Comment.Range createRange(
+      int startLine, int startCharacter, int endLine, int endCharacter) {
+    Comment.Range range = new Comment.Range();
+    range.startLine = startLine;
+    range.startCharacter = startCharacter;
+    range.endLine = endLine;
+    range.endCharacter = endCharacter;
+    return range;
+  }
+
+  private static <T> T readContentFromJson(RestResponse r, int expectedStatus, Class<T> clazz)
+      throws Exception {
+    r.assertStatus(expectedStatus);
+    try (JsonReader jsonReader = new JsonReader(r.getReader())) {
+      jsonReader.setLenient(true);
+      return newGson().fromJson(jsonReader, clazz);
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/PreviewProvidedFixIT.java b/javatests/com/google/gerrit/acceptance/api/revision/PreviewProvidedFixIT.java
new file mode 100644
index 0000000..c257e703
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/revision/PreviewProvidedFixIT.java
@@ -0,0 +1,278 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.api.revision;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.extensions.common.testing.DiffInfoSubject.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.entities.Patch;
+import com.google.gerrit.extensions.api.changes.PublishChangeEditInput;
+import com.google.gerrit.extensions.client.Comment;
+import com.google.gerrit.extensions.common.ApplyProvidedFixInput;
+import com.google.gerrit.extensions.common.ChangeType;
+import com.google.gerrit.extensions.common.DiffInfo;
+import com.google.gerrit.extensions.common.DiffInfo.IntraLineStatus;
+import com.google.gerrit.extensions.common.FixReplacementInfo;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+import org.junit.Before;
+import org.junit.Test;
+
+public class PreviewProvidedFixIT extends AbstractDaemonTest {
+  private static final String PLAIN_TEXT_CONTENT_TYPE = "text/plain";
+  private static final String GERRIT_COMMIT_MESSAGE_TYPE = "text/x-gerrit-commit-message";
+
+  private static final String FILE_NAME = "file_to_fix.txt";
+  private static final String FILE_NAME2 = "another_file_to_fix.txt";
+  private static final String FILE_NAME3 = "file_without_newline_at_end.txt";
+  private static final String FILE_CONTENT =
+      "First line\nSecond line\nThird line\nFourth line\nFifth line\nSixth line"
+          + "\nSeventh line\nEighth line\nNinth line\nTenth line\n";
+  private static final String FILE_CONTENT2 = "1st line\n2nd line\n3rd line\n";
+  private static final String FILE_CONTENT3 = "1st line\n2nd line";
+
+  private String changeId;
+  private String commitId;
+
+  @Before
+  public void setUp() throws Exception {
+    PushOneCommit push =
+        pushFactory.create(
+            admin.newIdent(),
+            testRepo,
+            "Provide files which can be used for fixes",
+            ImmutableMap.of(
+                FILE_NAME, FILE_CONTENT, FILE_NAME2, FILE_CONTENT2, FILE_NAME3, FILE_CONTENT3));
+    PushOneCommit.Result changeResult = push.to("refs/for/master");
+    changeId = changeResult.getChangeId();
+    commitId = changeResult.getCommit().getName();
+  }
+
+  @Test
+  public void previewFixDetailedCheck() throws Exception {
+    FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
+    fixReplacementInfo1.path = FILE_NAME;
+    fixReplacementInfo1.replacement = "some replacement code";
+    fixReplacementInfo1.range = createRange(3, 9, 8, 4);
+
+    FixReplacementInfo fixReplacementInfo2 = new FixReplacementInfo();
+    fixReplacementInfo2.path = FILE_NAME2;
+    fixReplacementInfo2.replacement = "New line\n";
+    fixReplacementInfo2.range = createRange(2, 0, 2, 0);
+
+    List<FixReplacementInfo> fixReplacementInfoList =
+        Arrays.asList(fixReplacementInfo1, fixReplacementInfo2);
+    ApplyProvidedFixInput applyProvidedFixInput = new ApplyProvidedFixInput();
+    applyProvidedFixInput.fixReplacementInfos = fixReplacementInfoList;
+
+    Map<String, DiffInfo> fixPreview =
+        gApi.changes().id(changeId).current().getFixPreview(applyProvidedFixInput);
+
+    DiffInfo diff = fixPreview.get(FILE_NAME);
+    assertThat(diff).intralineStatus().isEqualTo(IntraLineStatus.OK);
+    assertThat(diff).webLinks().isNull();
+    assertThat(diff).binary().isNull();
+    assertThat(diff).diffHeader().isNull();
+    assertThat(diff).changeType().isEqualTo(ChangeType.MODIFIED);
+    assertThat(diff).metaA().totalLineCount().isEqualTo(11);
+    assertThat(diff).metaA().name().isEqualTo(FILE_NAME);
+    assertThat(diff).metaA().commitId().isEqualTo(commitId);
+    assertThat(diff).metaA().contentType().isEqualTo(PLAIN_TEXT_CONTENT_TYPE);
+    assertThat(diff).metaA().webLinks().isNull();
+    assertThat(diff).metaB().totalLineCount().isEqualTo(6);
+    assertThat(diff).metaB().name().isEqualTo(FILE_NAME);
+    assertThat(diff).metaB().commitId().isNull();
+    assertThat(diff).metaB().contentType().isEqualTo(PLAIN_TEXT_CONTENT_TYPE);
+    assertThat(diff).metaB().webLinks().isNull();
+
+    assertThat(diff).content().hasSize(3);
+    assertThat(diff)
+        .content()
+        .element(0)
+        .commonLines()
+        .containsExactly("First line", "Second line");
+    assertThat(diff).content().element(0).linesOfA().isNull();
+    assertThat(diff).content().element(0).linesOfB().isNull();
+
+    assertThat(diff).content().element(1).commonLines().isNull();
+    assertThat(diff)
+        .content()
+        .element(1)
+        .linesOfA()
+        .containsExactly(
+            "Third line", "Fourth line", "Fifth line", "Sixth line", "Seventh line", "Eighth line");
+    assertThat(diff)
+        .content()
+        .element(1)
+        .linesOfB()
+        .containsExactly("Third linsome replacement codeth line");
+
+    assertThat(diff)
+        .content()
+        .element(2)
+        .commonLines()
+        .containsExactly("Ninth line", "Tenth line", "");
+    assertThat(diff).content().element(2).linesOfA().isNull();
+    assertThat(diff).content().element(2).linesOfB().isNull();
+
+    DiffInfo diff2 = fixPreview.get(FILE_NAME2);
+    assertThat(diff2).intralineStatus().isEqualTo(IntraLineStatus.OK);
+    assertThat(diff2).webLinks().isNull();
+    assertThat(diff2).binary().isNull();
+    assertThat(diff2).diffHeader().isNull();
+    assertThat(diff2).changeType().isEqualTo(ChangeType.MODIFIED);
+    assertThat(diff2).metaA().totalLineCount().isEqualTo(4);
+    assertThat(diff2).metaA().name().isEqualTo(FILE_NAME2);
+    assertThat(diff2).metaA().commitId().isEqualTo(commitId);
+    assertThat(diff2).metaA().contentType().isEqualTo(PLAIN_TEXT_CONTENT_TYPE);
+    assertThat(diff2).metaA().webLinks().isNull();
+    assertThat(diff2).metaB().totalLineCount().isEqualTo(5);
+    assertThat(diff2).metaB().name().isEqualTo(FILE_NAME2);
+    assertThat(diff2).metaB().commitId().isNull();
+    assertThat(diff2).metaA().contentType().isEqualTo(PLAIN_TEXT_CONTENT_TYPE);
+    assertThat(diff2).metaB().webLinks().isNull();
+
+    assertThat(diff2).content().hasSize(3);
+    assertThat(diff2).content().element(0).commonLines().containsExactly("1st line");
+    assertThat(diff2).content().element(0).linesOfA().isNull();
+    assertThat(diff2).content().element(0).linesOfB().isNull();
+
+    assertThat(diff2).content().element(1).commonLines().isNull();
+    assertThat(diff2).content().element(1).linesOfA().isNull();
+    assertThat(diff2).content().element(1).linesOfB().containsExactly("New line");
+
+    assertThat(diff2)
+        .content()
+        .element(2)
+        .commonLines()
+        .containsExactly("2nd line", "3rd line", "");
+    assertThat(diff2).content().element(2).linesOfA().isNull();
+    assertThat(diff2).content().element(2).linesOfB().isNull();
+  }
+
+  @Test
+  public void previewFixForCommitMsg() throws Exception {
+    String footer = "Change-Id: " + changeId;
+    updateCommitMessage(
+        changeId,
+        "Commit title\n\nCommit message line 1\nLine 2\nLine 3\nLast line\n\n" + footer + "\n");
+    // The test assumes that the first 5 lines is a header.
+    // Line 10 has content "Line 2"
+    ApplyProvidedFixInput applyProvidedFixInput =
+        createApplyProvidedFixInput(Patch.COMMIT_MSG, "New content\n", 10, 0, 11, 0);
+    Map<String, DiffInfo> fixPreview =
+        gApi.changes().id(changeId).current().getFixPreview(applyProvidedFixInput);
+    assertThat(fixPreview).hasSize(1);
+    assertThat(fixPreview).containsKey(Patch.COMMIT_MSG);
+
+    DiffInfo diff = fixPreview.get(Patch.COMMIT_MSG);
+    assertThat(diff).metaA().name().isEqualTo(Patch.COMMIT_MSG);
+    assertThat(diff).metaA().contentType().isEqualTo(GERRIT_COMMIT_MESSAGE_TYPE);
+    assertThat(diff).metaB().name().isEqualTo(Patch.COMMIT_MSG);
+    assertThat(diff).metaB().contentType().isEqualTo(GERRIT_COMMIT_MESSAGE_TYPE);
+
+    assertThat(diff).content().element(0).commonLines().hasSize(9);
+    // Header has a dynamic content, do not check it
+    assertThat(diff).content().element(0).commonLines().element(6).isEqualTo("Commit title");
+    assertThat(diff).content().element(0).commonLines().element(7).isEqualTo("");
+    assertThat(diff)
+        .content()
+        .element(0)
+        .commonLines()
+        .element(8)
+        .isEqualTo("Commit message line 1");
+    assertThat(diff).content().element(1).linesOfA().containsExactly("Line 2");
+    assertThat(diff).content().element(1).linesOfB().containsExactly("New content");
+    assertThat(diff)
+        .content()
+        .element(2)
+        .commonLines()
+        .containsExactly("Line 3", "Last line", "", footer, "");
+  }
+
+  @Test
+  public void previewFixForNonExistingFile() throws Exception {
+    ApplyProvidedFixInput applyProvidedFixInput =
+        createApplyProvidedFixInput("a_non_existent_file.txt", "Modified content\n", 1, 0, 2, 0);
+
+    assertThrows(
+        ResourceNotFoundException.class,
+        () -> gApi.changes().id(changeId).current().getFixPreview(applyProvidedFixInput));
+  }
+
+  @Test
+  public void previewFixAddNewLineAtEnd() throws Exception {
+    ApplyProvidedFixInput applyProvidedFixInput =
+        createApplyProvidedFixInput(FILE_NAME3, "\n", 2, 8, 2, 8);
+    Map<String, DiffInfo> fixPreview =
+        gApi.changes().id(changeId).current().getFixPreview(applyProvidedFixInput);
+    assertThat(fixPreview).hasSize(1);
+    assertThat(fixPreview).containsKey(FILE_NAME3);
+
+    DiffInfo diff = fixPreview.get(FILE_NAME3);
+    assertThat(diff).metaA().totalLineCount().isEqualTo(2);
+    // Original file doesn't have EOL marker at the end of file.
+    // Due to the additional EOL mark diff has one additional line
+    // This behavior is in line with ordinary get diff API.
+    assertThat(diff).metaB().totalLineCount().isEqualTo(3);
+
+    assertThat(diff).content().hasSize(2);
+    assertThat(diff).content().element(0).commonLines().containsExactly("1st line");
+    assertThat(diff).content().element(1).linesOfA().containsExactly("2nd line");
+    assertThat(diff).content().element(1).linesOfB().containsExactly("2nd line", "");
+  }
+
+  private ApplyProvidedFixInput createApplyProvidedFixInput(
+      String file_name,
+      String replacement,
+      int startLine,
+      int startCharacter,
+      int endLine,
+      int endCharacter) {
+    FixReplacementInfo fixReplacementInfo = new FixReplacementInfo();
+    fixReplacementInfo.path = file_name;
+    fixReplacementInfo.replacement = replacement;
+    fixReplacementInfo.range = createRange(startLine, startCharacter, endLine, endCharacter);
+
+    List<FixReplacementInfo> fixReplacementInfoList = Arrays.asList(fixReplacementInfo);
+    ApplyProvidedFixInput applyProvidedFixInput = new ApplyProvidedFixInput();
+    applyProvidedFixInput.fixReplacementInfos = fixReplacementInfoList;
+
+    return applyProvidedFixInput;
+  }
+
+  private void updateCommitMessage(String changeId, String newCommitMessage) throws Exception {
+    gApi.changes().id(changeId).edit().create();
+    gApi.changes().id(changeId).edit().modifyCommitMessage(newCommitMessage);
+    PublishChangeEditInput publishInput = new PublishChangeEditInput();
+    gApi.changes().id(changeId).edit().publish(publishInput);
+  }
+
+  private static Comment.Range createRange(
+      int startLine, int startCharacter, int endLine, int endCharacter) {
+    Comment.Range range = new Comment.Range();
+    range.startLine = startLine;
+    range.startCharacter = startCharacter;
+    range.endLine = endLine;
+    range.endCharacter = endCharacter;
+    return range;
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
index efd3cea..1919810 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
@@ -3005,9 +3005,6 @@
     return "An unchanged patchset\n\nChange-Id: " + changeId;
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   private void assertDiffForNewFile(
       PushOneCommit.Result pushResult, String path, String expectedContentSideB) throws Exception {
     DiffInfo diff =
@@ -3030,14 +3027,14 @@
       DateTimeFormatter fmt =
           DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss Z")
               .withLocale(Locale.US)
-              .withZone(author.getTimeZone().toZoneId());
+              .withZone(author.getZoneId());
       headers.add("Author:     " + author.getName() + " <" + author.getEmailAddress() + ">");
-      headers.add("AuthorDate: " + fmt.format(author.getWhen().toInstant()));
+      headers.add("AuthorDate: " + fmt.format(author.getWhenAsInstant()));
 
       PersonIdent committer = c.getCommitterIdent();
-      fmt = fmt.withZone(committer.getTimeZone().toZoneId());
+      fmt = fmt.withZone(committer.getZoneId());
       headers.add("Commit:     " + committer.getName() + " <" + committer.getEmailAddress() + ">");
-      headers.add("CommitDate: " + fmt.format(committer.getWhen().toInstant()));
+      headers.add("CommitDate: " + fmt.format(committer.getWhenAsInstant()));
       headers.add("");
     }
 
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
index 4a7849f..804516a 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
@@ -506,7 +506,7 @@
     PushOneCommit.Result r1 = createChange();
 
     // Push another new change (change 2)
-    String subject = "Test change\n\nChange-Id: Ideadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
+    String subject = "Test change";
     PushOneCommit push =
         pushFactory.create(
             admin.newIdent(), testRepo, subject, "another_file.txt", "another content");
@@ -520,7 +520,7 @@
     ChangeApi orig = gApi.changes().id(triplet);
     CherryPickInput in = new CherryPickInput();
     in.destination = "master";
-    in.message = subject;
+    in.message = subject + "\n\nChange-Id: " + r2.getChangeId();
     ChangeApi cherry = orig.revision(r2.getCommit().name()).cherryPick(in);
     ChangeInfo cherryInfo = cherry.get();
     assertThat(cherryInfo.messages).hasSize(2);
@@ -1706,15 +1706,13 @@
     }
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
   // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
   // Instant
   @SuppressWarnings("JdkObsolete")
   private void assertPersonIdent(GitPerson gitPerson, PersonIdent expectedIdent) {
     assertThat(gitPerson.name).isEqualTo(expectedIdent.getName());
     assertThat(gitPerson.email).isEqualTo(expectedIdent.getEmailAddress());
-    assertThat(gitPerson.date.getTime()).isEqualTo(expectedIdent.getWhen().getTime());
+    assertThat(gitPerson.date.getTime()).isEqualTo(expectedIdent.getWhenAsInstant().toEpochMilli());
     assertThat(gitPerson.tz).isEqualTo(expectedIdent.getTimeZoneOffset());
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RobotCommentsIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RobotCommentsIT.java
index 875ce97..a16cdb6 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/RobotCommentsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RobotCommentsIT.java
@@ -716,7 +716,7 @@
   }
 
   @Test
-  public void fixWithinALineCanBeApplied() throws Exception {
+  public void storedFixWithinALineCanBeApplied() throws Exception {
     fixReplacementInfo.path = FILE_NAME;
     fixReplacementInfo.replacement = "Modified content";
     fixReplacementInfo.range = createRange(3, 1, 3, 3);
@@ -739,7 +739,7 @@
   }
 
   @Test
-  public void fixSpanningMultipleLinesCanBeApplied() throws Exception {
+  public void storedFixSpanningMultipleLinesCanBeApplied() throws Exception {
     fixReplacementInfo.path = FILE_NAME;
     fixReplacementInfo.replacement = "Modified content\n5";
     fixReplacementInfo.range = createRange(3, 2, 5, 3);
@@ -761,7 +761,7 @@
   }
 
   @Test
-  public void fixWithTwoCloseReplacementsOnSameFileCanBeApplied() throws Exception {
+  public void storedFixWithTwoCloseReplacementsOnSameFileCanBeApplied() throws Exception {
     FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
     fixReplacementInfo1.path = FILE_NAME;
     fixReplacementInfo1.range = createRange(2, 0, 3, 0);
@@ -793,7 +793,7 @@
   }
 
   @Test
-  public void twoFixesOnSameFileCanBeApplied() throws Exception {
+  public void twoStoredFixesOnSameFileCanBeApplied() throws Exception {
     FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
     fixReplacementInfo1.path = FILE_NAME;
     fixReplacementInfo1.range = createRange(2, 0, 3, 0);
@@ -828,7 +828,7 @@
   }
 
   @Test
-  public void twoConflictingFixesOnSameFileCannotBeApplied() throws Exception {
+  public void twoConflictingStoredFixesOnSameFileCannotBeApplied() throws Exception {
     FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
     fixReplacementInfo1.path = FILE_NAME;
     fixReplacementInfo1.range = createRange(2, 0, 3, 1);
@@ -859,7 +859,7 @@
   }
 
   @Test
-  public void twoFixesOfSameRobotCommentCanBeApplied() throws Exception {
+  public void twoStoredFixesOfSameRobotCommentCanBeApplied() throws Exception {
     FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
     fixReplacementInfo1.path = FILE_NAME;
     fixReplacementInfo1.range = createRange(2, 0, 3, 0);
@@ -892,7 +892,7 @@
   }
 
   @Test
-  public void fixReferringToDifferentFileThanRobotCommentCanBeApplied() throws Exception {
+  public void storedFixReferringToDifferentFileThanRobotCommentCanBeApplied() throws Exception {
     fixReplacementInfo.path = FILE_NAME2;
     fixReplacementInfo.range = createRange(2, 0, 3, 0);
     fixReplacementInfo.replacement = "Modified content\n";
@@ -912,7 +912,7 @@
   }
 
   @Test
-  public void fixInvolvingTwoFilesCanBeApplied() throws Exception {
+  public void storedFixInvolvingTwoFilesCanBeApplied() throws Exception {
     FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
     fixReplacementInfo1.path = FILE_NAME;
     fixReplacementInfo1.range = createRange(2, 0, 3, 0);
@@ -949,7 +949,7 @@
   }
 
   @Test
-  public void fixReferringToNonExistentFileCannotBeApplied() throws Exception {
+  public void storedFixReferringToNonExistentFileCannotBeApplied() throws Exception {
     fixReplacementInfo.path = "a_non_existent_file.txt";
     fixReplacementInfo.range = createRange(1, 0, 2, 0);
     fixReplacementInfo.replacement = "Modified content\n";
@@ -965,7 +965,7 @@
   }
 
   @Test
-  public void fixOnPreviousPatchSetWithoutChangeEditCannotBeApplied() throws Exception {
+  public void storedFixOnPreviousPatchSetWithoutChangeEditCannotBeApplied() throws Exception {
     fixReplacementInfo.path = FILE_NAME;
     fixReplacementInfo.replacement = "Modified content";
     fixReplacementInfo.range = createRange(3, 1, 3, 3);
@@ -988,7 +988,7 @@
   }
 
   @Test
-  public void fixOnPreviousPatchSetWithExistingChangeEditCanBeApplied() throws Exception {
+  public void storedFixOnPreviousPatchSetWithExistingChangeEditCanBeApplied() throws Exception {
     // Create an empty change edit.
     gApi.changes().id(changeId).edit().create();
 
@@ -1019,7 +1019,7 @@
   }
 
   @Test
-  public void fixOnCurrentPatchSetWithChangeEditOnPreviousPatchSetCannotBeApplied()
+  public void storedFixOnCurrentPatchSetWithChangeEditOnPreviousPatchSetCannotBeApplied()
       throws Exception {
     // Create an empty change edit.
     gApi.changes().id(changeId).edit().create();
@@ -1045,7 +1045,7 @@
   }
 
   @Test
-  public void fixDoesNotModifyCommitMessageOfChangeEdit() throws Exception {
+  public void storedFixDoesNotModifyCommitMessageOfChangeEdit() throws Exception {
     String changeEditCommitMessage =
         "This is the commit message of the change edit.\n\nChange-Id: " + changeId + "\n";
     gApi.changes().id(changeId).edit().modifyCommitMessage(changeEditCommitMessage);
@@ -1064,7 +1064,7 @@
   }
 
   @Test
-  public void fixOnCommitMessageCanBeApplied() throws Exception {
+  public void storedFixOnCommitMessageCanBeApplied() throws Exception {
     // Set a dedicated commit message.
     String footer = "\nChange-Id: " + changeId + "\n";
     String originalCommitMessage = "Line 1 of commit message\nLine 2 of commit message\n" + footer;
@@ -1086,7 +1086,7 @@
   }
 
   @Test
-  public void fixOnHeaderPartOfCommitMessageCannotBeApplied() throws Exception {
+  public void storedFixOnHeaderPartOfCommitMessageCannotBeApplied() throws Exception {
     // Set a dedicated commit message.
     String footer = "Change-Id: " + changeId;
     String originalCommitMessage =
@@ -1110,7 +1110,8 @@
   }
 
   @Test
-  public void fixContainingSeveralModificationsOfCommitMessageCanBeApplied() throws Exception {
+  public void storedFixContainingSeveralModificationsOfCommitMessageCanBeApplied()
+      throws Exception {
     // Set a dedicated commit message.
     String footer = "\nChange-Id: " + changeId + "\n";
     String originalCommitMessage =
@@ -1144,7 +1145,7 @@
   }
 
   @Test
-  public void fixModifyingTheCommitMessageAndAFileCanBeApplied() throws Exception {
+  public void storedFixModifyingTheCommitMessageAndAFileCanBeApplied() throws Exception {
     // Set a dedicated commit message.
     String footer = "\nChange-Id: " + changeId + "\n";
     String originalCommitMessage = "Line 1 of commit message\nLine 2 of commit message\n" + footer;
@@ -1180,7 +1181,7 @@
   }
 
   @Test
-  public void twoFixesOnCommitMessageCanBeAppliedOneAfterTheOther() throws Exception {
+  public void twoStoredFixesOnCommitMessageCanBeAppliedOneAfterTheOther() throws Exception {
     // Set a dedicated commit message.
     String footer = "\nChange-Id: " + changeId + "\n";
     String originalCommitMessage =
@@ -1217,7 +1218,8 @@
   }
 
   @Test
-  public void twoConflictingFixesOnCommitMessageCanNotBeAppliedOneAfterTheOther() throws Exception {
+  public void twoConflictingStoredFixesOnCommitMessageCanNotBeAppliedOneAfterTheOther()
+      throws Exception {
     // Set a dedicated commit message.
     String footer = "Change-Id: " + changeId;
     String originalCommitMessage =
@@ -1254,7 +1256,7 @@
   }
 
   @Test
-  public void applyingFixTwiceIsIdempotent() throws Exception {
+  public void applyingStoredFixTwiceIsIdempotent() throws Exception {
     fixReplacementInfo.path = FILE_NAME;
     fixReplacementInfo.replacement = "Modified content";
     fixReplacementInfo.range = createRange(3, 1, 3, 3);
@@ -1277,7 +1279,7 @@
   }
 
   @Test
-  public void nonExistentFixCannotBeApplied() throws Exception {
+  public void nonExistentStoredFixCannotBeApplied() throws Exception {
     fixReplacementInfo.path = FILE_NAME;
     fixReplacementInfo.replacement = "Modified content";
     fixReplacementInfo.range = createRange(3, 1, 3, 3);
@@ -1295,7 +1297,7 @@
   }
 
   @Test
-  public void applyingFixReturnsEditInfoForCreatedChangeEdit() throws Exception {
+  public void applyingStoredFixReturnsEditInfoForCreatedChangeEdit() throws Exception {
     fixReplacementInfo.path = FILE_NAME;
     fixReplacementInfo.replacement = "Modified content";
     fixReplacementInfo.range = createRange(3, 1, 3, 3);
@@ -1316,7 +1318,8 @@
   }
 
   @Test
-  public void applyingFixOnTopOfChangeEditReturnsEditInfoForUpdatedChangeEdit() throws Exception {
+  public void applyingStoredFixOnTopOfChangeEditReturnsEditInfoForUpdatedChangeEdit()
+      throws Exception {
     gApi.changes().id(changeId).edit().create();
 
     fixReplacementInfo.path = FILE_NAME;
@@ -1379,7 +1382,7 @@
   }
 
   @Test
-  public void getFixPreviewWithNonexistingFixId() throws Exception {
+  public void previewStoredFixWithNonexistentFixId() throws Exception {
     testCommentHelper.addRobotComment(changeId, withFixRobotCommentInput);
 
     assertThrows(
@@ -1388,7 +1391,7 @@
   }
 
   @Test
-  public void getFixPreviewForCommitMsg() throws Exception {
+  public void previewStoredFixForCommitMsg() throws Exception {
     String footer = "Change-Id: " + changeId;
     updateCommitMessage(
         changeId,
@@ -1447,7 +1450,7 @@
   }
 
   @Test
-  public void getFixPreviewForNonExistingFile() throws Exception {
+  public void PreviewStoredFixForNonExistingFile() throws Exception {
     FixReplacementInfo replacement = new FixReplacementInfo();
     replacement.path = "a_non_existent_file.txt";
     replacement.range = createRange(1, 0, 2, 0);
@@ -1468,7 +1471,7 @@
   }
 
   @Test
-  public void getFixPreview() throws Exception {
+  public void PreviewStoredFix() throws Exception {
     FixReplacementInfo fixReplacementInfoFile1 = new FixReplacementInfo();
     fixReplacementInfoFile1.path = FILE_NAME;
     fixReplacementInfoFile1.replacement = "some replacement code";
@@ -1578,7 +1581,7 @@
   }
 
   @Test
-  public void getFixPreviewAddNewLineAtEnd() throws Exception {
+  public void PreviewStoredFixAddNewLineAtEnd() throws Exception {
     FixReplacementInfo replacement = new FixReplacementInfo();
     replacement.path = FILE_NAME3;
     replacement.range = createRange(2, 8, 2, 8);
diff --git a/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java b/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java
index 0e0168e..9fae6c0 100644
--- a/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java
+++ b/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java
@@ -52,6 +52,7 @@
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.ReviewerInput;
 import com.google.gerrit.extensions.client.ChangeEditDetailOption;
+import com.google.gerrit.extensions.client.ChangeKind;
 import com.google.gerrit.extensions.client.ChangeStatus;
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.common.ApprovalInfo;
@@ -810,7 +811,9 @@
       LabelType codeReview = TestLabels.codeReview();
       u.getConfig().upsertLabelType(codeReview);
       u.getConfig()
-          .updateLabelType(codeReview.getName(), lt -> lt.setCopyAllScoresIfNoCodeChange(true));
+          .updateLabelType(
+              codeReview.getName(),
+              lt -> lt.setCopyCondition("changekind:" + ChangeKind.NO_CODE_CHANGE.name()));
       u.save();
     }
 
diff --git a/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java b/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
index ba1e1a7..cd1d911 100644
--- a/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
+++ b/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
@@ -46,6 +46,7 @@
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
@@ -1062,6 +1063,9 @@
     assertThat(Iterables.getLast(ci.messages).message)
         .isEqualTo("Uploaded patch set 1: Code-Review+1.");
 
+    // Check that the user who pushed the change was added as a reviewer since they added a vote.
+    assertThatUserIsOnlyReviewer(ci, admin);
+
     PushOneCommit push =
         pushFactory.create(
             admin.newIdent(),
@@ -1071,12 +1075,18 @@
             "anotherContent",
             r.getChangeId());
     r = push.to("refs/for/master%l=Code-Review+2");
+    r.assertOkStatus();
 
     ci = get(r.getChangeId(), DETAILED_LABELS, MESSAGES, DETAILED_ACCOUNTS);
     cr = ci.labels.get(LabelId.CODE_REVIEW);
     assertThat(Iterables.getLast(ci.messages).message)
-        .isEqualTo("Uploaded patch set 2: Code-Review+2.");
-    // Check that the user who pushed the change was added as a reviewer since they added a vote
+        .isEqualTo(
+            "Uploaded patch set 2: Code-Review+2.\n"
+                + "\n"
+                + "Outdated Votes:\n"
+                + "* Code-Review+1 (copy condition: \"changekind:NO_CHANGE"
+                + " OR changekind:TRIVIAL_REBASE OR is:MIN\")\n");
+    // Check that the user who pushed the change was added as a reviewer since they added a vote.
     assertThatUserIsOnlyReviewer(ci, admin);
 
     assertThat(cr.all).hasSize(1);
@@ -1092,8 +1102,15 @@
             "moreContent",
             r.getChangeId());
     r = push.to("refs/for/master%l=Code-Review+2");
+    r.assertOkStatus();
     ci = get(r.getChangeId(), MESSAGES);
-    assertThat(Iterables.getLast(ci.messages).message).isEqualTo("Uploaded patch set 3.");
+    assertThat(Iterables.getLast(ci.messages).message)
+        .isEqualTo(
+            "Uploaded patch set 3.\n"
+                + "\n"
+                + "Outdated Votes:\n"
+                + "* Code-Review+2 (copy condition: \"changekind:NO_CHANGE"
+                + " OR changekind:TRIVIAL_REBASE OR is:MIN\")\n");
   }
 
   @Test
@@ -1110,6 +1127,7 @@
             "anotherContent",
             r.getChangeId());
     r = push.to("refs/for/master%l=Code-Review+2");
+    r.assertOkStatus();
 
     ChangeInfo ci = get(r.getChangeId(), DETAILED_LABELS, MESSAGES, DETAILED_ACCOUNTS);
     LabelInfo cr = ci.labels.get(LabelId.CODE_REVIEW);
@@ -1117,7 +1135,7 @@
         .isEqualTo("Uploaded patch set 2: Code-Review+2.");
 
     // Check that the user who pushed the new patch set was added as a reviewer since they added
-    // a vote
+    // a vote.
     assertThatUserIsOnlyReviewer(ci, admin);
 
     assertThat(cr.all).hasSize(1);
@@ -1244,7 +1262,7 @@
     assertThat(cr.all).hasSize(1);
     cr = ci.labels.get("Custom-Label");
     assertThat(cr.all).hasSize(1);
-    // Check that the user who pushed the change was added as a reviewer since they added a vote
+    // Check that the user who pushed the change was added as a reviewer since they added a vote.
     assertThatUserIsOnlyReviewer(ci, admin);
   }
 
@@ -1936,7 +1954,7 @@
   @Test
   public void pushNewPatchsetOverridingStickyLabel() throws Exception {
     try (ProjectConfigUpdate u = updateProject(project)) {
-      LabelType codeReview = TestLabels.codeReview().toBuilder().setCopyMaxScore(true).build();
+      LabelType codeReview = TestLabels.codeReview().toBuilder().setCopyCondition("is:MAX").build();
       u.getConfig().upsertLabelType(codeReview);
       u.save();
     }
@@ -2423,8 +2441,8 @@
     }
 
     @Nullable
-    public CommitReceivedEvent getReceivedEvent() {
-      return receivedEvent;
+    public ImmutableListMultimap<String, String> pushOptions() {
+      return receivedEvent != null ? receivedEvent.pushOptions : null;
     }
   }
 
@@ -2520,7 +2538,7 @@
       push.setPushOptions(ImmutableList.of("trace=123", "gerrit~foo", "gerrit~bar=456"));
       PushOneCommit.Result r = push.to("refs/for/master");
       r.assertOkStatus();
-      assertThat(validator.getReceivedEvent().pushOptions)
+      assertThat(validator.pushOptions())
           .containsExactly("trace", "123", "gerrit~foo", "", "gerrit~bar", "456");
     }
   }
diff --git a/javatests/com/google/gerrit/acceptance/pgm/AbstractReindexTests.java b/javatests/com/google/gerrit/acceptance/pgm/AbstractReindexTests.java
index cac376f..7386a03 100644
--- a/javatests/com/google/gerrit/acceptance/pgm/AbstractReindexTests.java
+++ b/javatests/com/google/gerrit/acceptance/pgm/AbstractReindexTests.java
@@ -32,10 +32,12 @@
 import com.google.gerrit.extensions.api.GerritApi;
 import com.google.gerrit.extensions.common.ChangeInput;
 import com.google.gerrit.index.IndexDefinition;
+import com.google.gerrit.index.Schema;
 import com.google.gerrit.launcher.GerritLauncher;
 import com.google.gerrit.server.index.GerritIndexStatus;
 import com.google.gerrit.server.index.change.ChangeIndexCollection;
 import com.google.gerrit.server.index.change.ChangeSchemaDefinitions;
+import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.inject.Injector;
 import com.google.inject.Key;
@@ -48,6 +50,7 @@
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.storage.file.FileBasedConfig;
 import org.eclipse.jgit.util.FS;
+import org.junit.Assume;
 import org.junit.Test;
 
 @NoHttpd
@@ -175,7 +178,9 @@
 
   @Test
   public void onlineUpgradeChanges() throws Exception {
-    int prevVersion = ChangeSchemaDefinitions.INSTANCE.getPrevious().getVersion();
+    Schema<ChangeData> previous = ChangeSchemaDefinitions.INSTANCE.getPrevious();
+    Assume.assumeNotNull(previous);
+    int prevVersion = previous.getVersion();
     int currVersion = ChangeSchemaDefinitions.INSTANCE.getLatest().getVersion();
 
     // Before storing any changes, switch back to the previous version.
diff --git a/javatests/com/google/gerrit/acceptance/pgm/MigrateLabelConfigToCopyConditionIT.java b/javatests/com/google/gerrit/acceptance/pgm/MigrateLabelConfigToCopyConditionIT.java
new file mode 100644
index 0000000..eec2811
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/pgm/MigrateLabelConfigToCopyConditionIT.java
@@ -0,0 +1,840 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.pgm;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.server.project.testing.TestLabels.labelBuilder;
+import static com.google.gerrit.server.project.testing.TestLabels.value;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Sets;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.client.ChangeKind;
+import com.google.gerrit.server.project.ProjectConfig;
+import com.google.gerrit.server.project.testing.TestLabels;
+import com.google.gerrit.server.schema.MigrateLabelConfigToCopyCondition;
+import com.google.inject.Inject;
+import java.util.Arrays;
+import java.util.List;
+import java.util.function.Consumer;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.Before;
+import org.junit.Test;
+
+public class MigrateLabelConfigToCopyConditionIT extends AbstractDaemonTest {
+  private static final ImmutableSet<String> DEPRECATED_FIELDS =
+      ImmutableSet.<String>builder()
+          .add(MigrateLabelConfigToCopyCondition.KEY_COPY_ANY_SCORE)
+          .add(MigrateLabelConfigToCopyCondition.KEY_COPY_MIN_SCORE)
+          .add(MigrateLabelConfigToCopyCondition.KEY_COPY_MAX_SCORE)
+          .add(MigrateLabelConfigToCopyCondition.KEY_COPY_VALUE)
+          .add(MigrateLabelConfigToCopyCondition.KEY_COPY_ALL_SCORES_IF_NO_CHANGE)
+          .add(MigrateLabelConfigToCopyCondition.KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE)
+          .add(MigrateLabelConfigToCopyCondition.KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE)
+          .add(MigrateLabelConfigToCopyCondition.KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE)
+          .add(
+              MigrateLabelConfigToCopyCondition.KEY_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE)
+          .build();
+
+  @Inject private ProjectOperations projectOperations;
+
+  @Before
+  public void setup() throws Exception {
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      // Overwrite "Code-Review" label that is inherited from All-Projects.
+      // This way changes to the "Code Review" label don't affect other tests.
+      LabelType.Builder codeReview =
+          labelBuilder(
+              LabelId.CODE_REVIEW,
+              value(2, "Looks good to me, approved"),
+              value(1, "Looks good to me, but someone else must approve"),
+              value(0, "No score"),
+              value(-1, "I would prefer this is not submitted as is"),
+              value(-2, "This shall not be submitted"));
+      u.getConfig().upsertLabelType(codeReview.build());
+
+      LabelType.Builder verified =
+          labelBuilder(
+              LabelId.VERIFIED, value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
+      u.getConfig().upsertLabelType(verified.build());
+
+      u.save();
+    }
+
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(TestLabels.codeReview().getName())
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-2, 2))
+        .add(
+            allowLabel(TestLabels.verified().getName())
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-1, 1))
+        .update();
+
+    // overwrite the default value for copyAllScoresIfNoChange which is true for the migration
+    updateProjectConfig(
+        cfg -> {
+          cfg.setBoolean(
+              ProjectConfig.LABEL,
+              LabelId.CODE_REVIEW,
+              MigrateLabelConfigToCopyCondition.KEY_COPY_ALL_SCORES_IF_NO_CHANGE,
+              /* value= */ false);
+          cfg.setBoolean(
+              ProjectConfig.LABEL,
+              LabelId.VERIFIED,
+              MigrateLabelConfigToCopyCondition.KEY_COPY_ALL_SCORES_IF_NO_CHANGE,
+              /* value= */ false);
+        });
+  }
+
+  @Test
+  public void nothingToMigrate_noLabels() throws Exception {
+    Project.NameKey projectWithoutLabelDefinitions = projectOperations.newProject().create();
+    RevCommit refsMetaConfigHead =
+        projectOperations.project(projectWithoutLabelDefinitions).getHead(RefNames.REFS_CONFIG);
+
+    runMigration(projectWithoutLabelDefinitions);
+
+    // verify that refs/meta/config was not touched
+    assertThat(
+            projectOperations.project(projectWithoutLabelDefinitions).getHead(RefNames.REFS_CONFIG))
+        .isEqualTo(refsMetaConfigHead);
+  }
+
+  @Test
+  public void noFieldsToMigrate() throws Exception {
+    assertThat(projectOperations.project(project).getConfig().getSubsections(ProjectConfig.LABEL))
+        .containsExactly(LabelId.CODE_REVIEW, LabelId.VERIFIED);
+
+    // copyAllScoresIfNoChange=false is set in the test setup to override the default value
+    assertDeprecatedFieldsUnset(
+        LabelId.CODE_REVIEW, MigrateLabelConfigToCopyCondition.KEY_COPY_ALL_SCORES_IF_NO_CHANGE);
+    assertDeprecatedFieldsUnset(
+        LabelId.VERIFIED, MigrateLabelConfigToCopyCondition.KEY_COPY_ALL_SCORES_IF_NO_CHANGE);
+
+    runMigration();
+
+    // verify that copyAllScoresIfNoChange=false (that was set in the test setup to override to
+    // default value) was removed
+    assertDeprecatedFieldsUnset(LabelId.CODE_REVIEW);
+    assertDeprecatedFieldsUnset(LabelId.VERIFIED);
+  }
+
+  @Test
+  public void noFieldsToMigrate_copyConditionExists() throws Exception {
+    String copyCondition = "is:MIN";
+    setCopyConditionOnCodeReviewLabel(copyCondition);
+
+    runMigration();
+
+    // verify that copyAllScoresIfNoChange=false (that was set in the test setup to override to
+    // default value) was removed
+    assertDeprecatedFieldsUnset(LabelId.CODE_REVIEW);
+
+    // verify that the copy condition was not changed
+    assertThat(getCopyConditionOfCodeReviewLabel()).isEqualTo(copyCondition);
+  }
+
+  @Test
+  public void noFieldsToMigrate_complexCopyConditionExists() throws Exception {
+    String copyCondition = "is:MIN has:unchanged-files";
+    setCopyConditionOnCodeReviewLabel(copyCondition);
+
+    runMigration();
+
+    // verify that copyAllScoresIfNoChange=false (that was set in the test setup to override to
+    // default value) was removed
+    assertDeprecatedFieldsUnset(LabelId.CODE_REVIEW);
+
+    // verify that the copy condition was not changed (e.g. no parentheses have been added around
+    // the
+    // copy condition)
+    assertThat(getCopyConditionOfCodeReviewLabel()).isEqualTo(copyCondition);
+  }
+
+  @Test
+  public void noFieldsToMigrate_nonOrderedCopyConditionExists() throws Exception {
+    String copyCondition = "is:MIN OR has:unchanged-files";
+    setCopyConditionOnCodeReviewLabel(copyCondition);
+
+    runMigration();
+
+    // verify that copyAllScoresIfNoChange=false (that was set in the test setup to override to
+    // default value) was removed
+    assertDeprecatedFieldsUnset(LabelId.CODE_REVIEW);
+
+    // verify that the copy condition was not changed (e.g. the order of OR conditions has not be
+    // changed and no parentheses have been added around the copy condition)
+    assertThat(getCopyConditionOfCodeReviewLabel()).isEqualTo(copyCondition);
+  }
+
+  @Test
+  public void migrateCopyAnyScore() throws Exception {
+    testFlagMigration(
+        MigrateLabelConfigToCopyCondition.KEY_COPY_ANY_SCORE,
+        copyCondition -> assertThat(copyCondition).isEqualTo("is:ANY"));
+  }
+
+  @Test
+  public void migrateCopyMinScore() throws Exception {
+    testFlagMigration(
+        MigrateLabelConfigToCopyCondition.KEY_COPY_MIN_SCORE,
+        copyCondition -> assertThat(copyCondition).isEqualTo("is:MIN"));
+  }
+
+  @Test
+  public void migrateCopyMaxScore() throws Exception {
+    testFlagMigration(
+        MigrateLabelConfigToCopyCondition.KEY_COPY_MAX_SCORE,
+        copyCondition -> assertThat(copyCondition).isEqualTo("is:MAX"));
+  }
+
+  @Test
+  public void migrateCopyValues_singleValue() throws Exception {
+    testCopyValueMigration(
+        ImmutableList.of(1), copyCondition -> assertThat(copyCondition).isEqualTo("is:1"));
+  }
+
+  @Test
+  public void migrateCopyValues_negativeValue() throws Exception {
+    testCopyValueMigration(
+        ImmutableList.of(-1), copyCondition -> assertThat(copyCondition).isEqualTo("is:\"-1\""));
+  }
+
+  @Test
+  public void migrateCopyValues_multipleValues() throws Exception {
+    testCopyValueMigration(
+        ImmutableList.of(-1, 1),
+        copyCondition -> assertThat(copyCondition).isEqualTo("is:\"-1\" OR is:1"));
+  }
+
+  @Test
+  public void migrateCopyValues_manyValues() throws Exception {
+    testCopyValueMigration(
+        ImmutableList.of(-2, -1, 1, 2),
+        copyCondition ->
+            assertThat(copyCondition).isEqualTo("is:\"-1\" OR is:\"-2\" OR is:1 OR is:2"));
+  }
+
+  @Test
+  public void migrateCopyAllScoresIfNoCange() throws Exception {
+    testFlagMigration(
+        MigrateLabelConfigToCopyCondition.KEY_COPY_ALL_SCORES_IF_NO_CHANGE,
+        copyCondition ->
+            assertThat(copyCondition).isEqualTo("changekind:" + ChangeKind.NO_CHANGE.toString()));
+  }
+
+  @Test
+  public void migrateCopyAllScoresIfNoCodeCange() throws Exception {
+    testFlagMigration(
+        MigrateLabelConfigToCopyCondition.KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE,
+        copyCondition ->
+            assertThat(copyCondition)
+                .isEqualTo("changekind:" + ChangeKind.NO_CODE_CHANGE.toString()));
+  }
+
+  @Test
+  public void migrateCopyAllScoresOnMergeFirstParentUpdate() throws Exception {
+    testFlagMigration(
+        MigrateLabelConfigToCopyCondition.KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE,
+        copyCondition ->
+            assertThat(copyCondition)
+                .isEqualTo("changekind:" + ChangeKind.MERGE_FIRST_PARENT_UPDATE.toString()));
+  }
+
+  @Test
+  public void migrateCopyAllScoresOnTrivialRebase() throws Exception {
+    testFlagMigration(
+        MigrateLabelConfigToCopyCondition.KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE,
+        copyCondition ->
+            assertThat(copyCondition)
+                .isEqualTo("changekind:" + ChangeKind.TRIVIAL_REBASE.toString()));
+  }
+
+  @Test
+  public void migrateCopyAllScoresIfListOfFilesDidNotChange() throws Exception {
+    testFlagMigration(
+        MigrateLabelConfigToCopyCondition.KEY_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE,
+        copyCondition -> assertThat(copyCondition).isEqualTo("has:unchanged-files"));
+  }
+
+  @Test
+  public void migrateDefaultValues() throws Exception {
+    // remove copyAllScoresIfNoChange=false that was set in the test setup to override to default
+    // value
+    unset(LabelId.CODE_REVIEW, MigrateLabelConfigToCopyCondition.KEY_COPY_ALL_SCORES_IF_NO_CHANGE);
+
+    assertDeprecatedFieldsUnset(LabelId.CODE_REVIEW);
+
+    runMigration();
+
+    assertDeprecatedFieldsUnset(LabelId.CODE_REVIEW);
+
+    // expect that the copy condition was set to "changekind:NO_CHANGE" since
+    // copyAllScoresIfNoChange was not set and has true as default value
+    assertThat(getCopyConditionOfCodeReviewLabel()).isEqualTo("changekind:NO_CHANGE");
+  }
+
+  @Test
+  public void migrateDefaultValues_copyConditionExists() throws Exception {
+    setCopyConditionOnCodeReviewLabel("is:MIN");
+
+    // remove copyAllScoresIfNoChange=false that was set in the test setup to override to default
+    // value
+    unset(LabelId.CODE_REVIEW, MigrateLabelConfigToCopyCondition.KEY_COPY_ALL_SCORES_IF_NO_CHANGE);
+
+    assertDeprecatedFieldsUnset(LabelId.CODE_REVIEW);
+
+    runMigration();
+
+    assertDeprecatedFieldsUnset(LabelId.CODE_REVIEW);
+
+    // expect that the copy condition includes "changekind:NO_CHANGE" since
+    // copyAllScoresIfNoChange was not set and has true as default value
+    assertThat(getCopyConditionOfCodeReviewLabel()).isEqualTo("changekind:NO_CHANGE OR is:MIN");
+  }
+
+  @Test
+  public void migrateAll() throws Exception {
+    setFlagOnCodeReviewLabel(MigrateLabelConfigToCopyCondition.KEY_COPY_ANY_SCORE);
+    setFlagOnCodeReviewLabel(MigrateLabelConfigToCopyCondition.KEY_COPY_MIN_SCORE);
+    setFlagOnCodeReviewLabel(MigrateLabelConfigToCopyCondition.KEY_COPY_MAX_SCORE);
+    setCopyValuesOnCodeReviewLabel(-2, -1, 1, 2);
+    setFlagOnCodeReviewLabel(MigrateLabelConfigToCopyCondition.KEY_COPY_ALL_SCORES_IF_NO_CHANGE);
+    setFlagOnCodeReviewLabel(
+        MigrateLabelConfigToCopyCondition.KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE);
+    setFlagOnCodeReviewLabel(
+        MigrateLabelConfigToCopyCondition.KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE);
+    setFlagOnCodeReviewLabel(
+        MigrateLabelConfigToCopyCondition.KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE);
+    setFlagOnCodeReviewLabel(
+        MigrateLabelConfigToCopyCondition.KEY_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE);
+
+    runMigration();
+
+    assertDeprecatedFieldsUnset(LabelId.CODE_REVIEW);
+    assertThat(getCopyConditionOfCodeReviewLabel())
+        .isEqualTo(
+            "changekind:MERGE_FIRST_PARENT_UPDATE"
+                + " OR changekind:NO_CHANGE"
+                + " OR changekind:NO_CODE_CHANGE"
+                + " OR changekind:TRIVIAL_REBASE"
+                + " OR has:unchanged-files"
+                + " OR is:\"-1\""
+                + " OR is:\"-2\""
+                + " OR is:1"
+                + " OR is:2"
+                + " OR is:ANY"
+                + " OR is:MAX"
+                + " OR is:MIN");
+  }
+
+  @Test
+  public void migrationMergesFlagsIntoExistingCopyCondition_mutualllyExclusive() throws Exception {
+    setCopyConditionOnCodeReviewLabel("is:ANY");
+    setFlagOnCodeReviewLabel(MigrateLabelConfigToCopyCondition.KEY_COPY_MIN_SCORE);
+
+    runMigration();
+
+    assertDeprecatedFieldsUnset(LabelId.CODE_REVIEW);
+    assertThat(getCopyConditionOfCodeReviewLabel()).isEqualTo("is:ANY OR is:MIN");
+  }
+
+  @Test
+  public void migrationMergesFlagsIntoExistingCopyCondition_noDuplicatePredicate()
+      throws Exception {
+    setCopyConditionOnCodeReviewLabel("is:ANY");
+    setFlagOnCodeReviewLabel(MigrateLabelConfigToCopyCondition.KEY_COPY_ANY_SCORE);
+    setFlagOnCodeReviewLabel(MigrateLabelConfigToCopyCondition.KEY_COPY_MIN_SCORE);
+
+    runMigration();
+
+    assertDeprecatedFieldsUnset(LabelId.CODE_REVIEW);
+    assertThat(getCopyConditionOfCodeReviewLabel()).isEqualTo("is:ANY OR is:MIN");
+  }
+
+  @Test
+  public void migrationMergesFlagsIntoExistingCopyCondition_noDuplicatePredicates()
+      throws Exception {
+    setCopyConditionOnCodeReviewLabel("is:ANY OR is:MIN");
+    setFlagOnCodeReviewLabel(MigrateLabelConfigToCopyCondition.KEY_COPY_ANY_SCORE);
+    setFlagOnCodeReviewLabel(MigrateLabelConfigToCopyCondition.KEY_COPY_MIN_SCORE);
+    setFlagOnCodeReviewLabel(MigrateLabelConfigToCopyCondition.KEY_COPY_MAX_SCORE);
+
+    runMigration();
+
+    assertDeprecatedFieldsUnset(LabelId.CODE_REVIEW);
+    assertThat(getCopyConditionOfCodeReviewLabel()).isEqualTo("is:ANY OR is:MAX OR is:MIN");
+  }
+
+  @Test
+  public void migrationMergesFlagsIntoExistingCopyCondition_complexCopyCondition_v1()
+      throws Exception {
+    setCopyConditionOnCodeReviewLabel("is:ANY changekind:TRIVIAL_REBASE");
+    setFlagOnCodeReviewLabel(MigrateLabelConfigToCopyCondition.KEY_COPY_ANY_SCORE);
+    setFlagOnCodeReviewLabel(
+        MigrateLabelConfigToCopyCondition.KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE);
+
+    runMigration();
+
+    assertDeprecatedFieldsUnset(LabelId.CODE_REVIEW);
+    assertThat(getCopyConditionOfCodeReviewLabel())
+        .isEqualTo("(is:ANY changekind:TRIVIAL_REBASE) OR changekind:TRIVIAL_REBASE OR is:ANY");
+  }
+
+  @Test
+  public void migrationMergesFlagsIntoExistingCopyCondition_complexCopyCondition_v2()
+      throws Exception {
+    setCopyConditionOnCodeReviewLabel(
+        "is:ANY AND (changekind:TRIVIAL_REBASE OR changekind:NO_CODE_CHANGE)");
+    setFlagOnCodeReviewLabel(MigrateLabelConfigToCopyCondition.KEY_COPY_ANY_SCORE);
+    setFlagOnCodeReviewLabel(
+        MigrateLabelConfigToCopyCondition.KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE);
+
+    runMigration();
+
+    assertDeprecatedFieldsUnset(LabelId.CODE_REVIEW);
+    assertThat(getCopyConditionOfCodeReviewLabel())
+        .isEqualTo(
+            "(is:ANY AND (changekind:TRIVIAL_REBASE OR changekind:NO_CODE_CHANGE)) OR changekind:TRIVIAL_REBASE OR is:ANY");
+  }
+
+  @Test
+  public void migrationMergesFlagsIntoExistingCopyCondition_noUnnecessaryParenthesesAdded()
+      throws Exception {
+    setCopyConditionOnCodeReviewLabel("(is:ANY changekind:TRIVIAL_REBASE)");
+    setFlagOnCodeReviewLabel(MigrateLabelConfigToCopyCondition.KEY_COPY_ANY_SCORE);
+    setFlagOnCodeReviewLabel(
+        MigrateLabelConfigToCopyCondition.KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE);
+
+    runMigration();
+
+    assertDeprecatedFieldsUnset(LabelId.CODE_REVIEW);
+    assertThat(getCopyConditionOfCodeReviewLabel())
+        .isEqualTo("(is:ANY changekind:TRIVIAL_REBASE) OR changekind:TRIVIAL_REBASE OR is:ANY");
+  }
+
+  @Test
+  public void migrationMergesFlagsIntoExistingCopyCondition_existingCopyConditionIsNotParseable()
+      throws Exception {
+    setCopyConditionOnCodeReviewLabel("NOT-PARSEABLE");
+    setFlagOnCodeReviewLabel(MigrateLabelConfigToCopyCondition.KEY_COPY_ANY_SCORE);
+    setFlagOnCodeReviewLabel(
+        MigrateLabelConfigToCopyCondition.KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE);
+
+    runMigration();
+
+    assertDeprecatedFieldsUnset(LabelId.CODE_REVIEW);
+    assertThat(getCopyConditionOfCodeReviewLabel())
+        .isEqualTo("NOT-PARSEABLE OR changekind:TRIVIAL_REBASE OR is:ANY");
+  }
+
+  @Test
+  public void
+      migrationMergesFlagsIntoExistingCopyCondition_existingComplexCopyConditionIsNotParseable()
+          throws Exception {
+    setCopyConditionOnCodeReviewLabel("NOT PARSEABLE");
+    setFlagOnCodeReviewLabel(MigrateLabelConfigToCopyCondition.KEY_COPY_ANY_SCORE);
+    setFlagOnCodeReviewLabel(
+        MigrateLabelConfigToCopyCondition.KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE);
+
+    runMigration();
+
+    assertDeprecatedFieldsUnset(LabelId.CODE_REVIEW);
+    assertThat(getCopyConditionOfCodeReviewLabel())
+        .isEqualTo("(NOT PARSEABLE) OR changekind:TRIVIAL_REBASE OR is:ANY");
+  }
+
+  @Test
+  public void migrateMultipleLabels() throws Exception {
+    setFlagOnCodeReviewLabel(MigrateLabelConfigToCopyCondition.KEY_COPY_MIN_SCORE);
+    setFlagOnCodeReviewLabel(MigrateLabelConfigToCopyCondition.KEY_COPY_ALL_SCORES_IF_NO_CHANGE);
+
+    setFlagOnVerifiedLabel(MigrateLabelConfigToCopyCondition.KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE);
+    setFlagOnVerifiedLabel(MigrateLabelConfigToCopyCondition.KEY_COPY_MAX_SCORE);
+
+    runMigration();
+
+    assertDeprecatedFieldsUnset(LabelId.CODE_REVIEW);
+    assertThat(getCopyConditionOfCodeReviewLabel()).isEqualTo("changekind:NO_CHANGE OR is:MIN");
+
+    assertDeprecatedFieldsUnset(LabelId.VERIFIED);
+    assertThat(getCopyConditionOfVerifiedLabel()).isEqualTo("changekind:TRIVIAL_REBASE OR is:MAX");
+  }
+
+  @Test
+  public void deprecatedFlagsThatAreSetToFalseAreUnset() throws Exception {
+    // set all flags to false
+    updateProjectConfig(
+        cfg -> {
+          cfg.setBoolean(
+              ProjectConfig.LABEL,
+              LabelId.CODE_REVIEW,
+              MigrateLabelConfigToCopyCondition.KEY_COPY_ANY_SCORE,
+              /* value= */ false);
+          cfg.setBoolean(
+              ProjectConfig.LABEL,
+              LabelId.CODE_REVIEW,
+              MigrateLabelConfigToCopyCondition.KEY_COPY_MIN_SCORE,
+              /* value= */ false);
+          cfg.setBoolean(
+              ProjectConfig.LABEL,
+              LabelId.CODE_REVIEW,
+              MigrateLabelConfigToCopyCondition.KEY_COPY_MAX_SCORE,
+              /* value= */ false);
+          cfg.setBoolean(
+              ProjectConfig.LABEL,
+              LabelId.CODE_REVIEW,
+              MigrateLabelConfigToCopyCondition.KEY_COPY_ALL_SCORES_IF_NO_CHANGE,
+              /* value= */ false);
+          cfg.setBoolean(
+              ProjectConfig.LABEL,
+              LabelId.CODE_REVIEW,
+              MigrateLabelConfigToCopyCondition.KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE,
+              /* value= */ false);
+          cfg.setBoolean(
+              ProjectConfig.LABEL,
+              LabelId.CODE_REVIEW,
+              MigrateLabelConfigToCopyCondition.KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE,
+              /* value= */ false);
+          cfg.setBoolean(
+              ProjectConfig.LABEL,
+              LabelId.CODE_REVIEW,
+              MigrateLabelConfigToCopyCondition.KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE,
+              /* value= */ false);
+          cfg.setBoolean(
+              ProjectConfig.LABEL,
+              LabelId.CODE_REVIEW,
+              MigrateLabelConfigToCopyCondition.KEY_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE,
+              /* value= */ false);
+        });
+  }
+
+  @Test
+  public void emptyCopyValueParameterIsUnset() throws Exception {
+    updateProjectConfig(
+        cfg ->
+            cfg.setString(
+                ProjectConfig.LABEL,
+                LabelId.CODE_REVIEW,
+                MigrateLabelConfigToCopyCondition.KEY_COPY_VALUE,
+                /* value= */ ""));
+
+    runMigration();
+
+    assertDeprecatedFieldsUnset(LabelId.CODE_REVIEW);
+  }
+
+  @Test
+  public void migrationCreatesASingleCommit() throws Exception {
+    // Set flags on 2 labels (the migrations for both labels are expected to be done in a single
+    // commit)
+    setFlagOnCodeReviewLabel(MigrateLabelConfigToCopyCondition.KEY_COPY_MIN_SCORE);
+    setFlagOnVerifiedLabel(MigrateLabelConfigToCopyCondition.KEY_COPY_MAX_SCORE);
+
+    RevCommit refsMetaConfigHead = projectOperations.project(project).getHead(RefNames.REFS_CONFIG);
+
+    runMigration();
+
+    // verify that the new commit in refs/meta/config is a successor of the old head
+    assertThat(projectOperations.project(project).getHead(RefNames.REFS_CONFIG).getParent(0))
+        .isEqualTo(refsMetaConfigHead);
+  }
+
+  @Test
+  public void commitMessageIsDistinct() throws Exception {
+    // Set a flag so that the migration has to do something.
+    setFlagOnCodeReviewLabel(MigrateLabelConfigToCopyCondition.KEY_COPY_MIN_SCORE);
+
+    runMigration();
+
+    // Verify that the commit message is distinct (e.g. this is important in case there is an issue
+    // with the migration, having a distinct commit message allows to identify the commit that was
+    // done for the migration and would allow to revert it)
+    RevCommit refsMetaConfigHead = projectOperations.project(project).getHead(RefNames.REFS_CONFIG);
+    assertThat(refsMetaConfigHead.getShortMessage())
+        .isEqualTo(MigrateLabelConfigToCopyCondition.COMMIT_MESSAGE);
+  }
+
+  @Test
+  public void gerritIsAuthorAndCommitterOfTheMigrationCommit() throws Exception {
+    // Set a flag so that the migration has to do something.
+    setFlagOnCodeReviewLabel(MigrateLabelConfigToCopyCondition.KEY_COPY_MIN_SCORE);
+
+    runMigration();
+
+    RevCommit refsMetaConfigHead = projectOperations.project(project).getHead(RefNames.REFS_CONFIG);
+    assertThat(refsMetaConfigHead.getAuthorIdent().getEmailAddress())
+        .isEqualTo(serverIdent.get().getEmailAddress());
+    assertThat(refsMetaConfigHead.getAuthorIdent().getName())
+        .isEqualTo(serverIdent.get().getName());
+    assertThat(refsMetaConfigHead.getCommitterIdent())
+        .isEqualTo(refsMetaConfigHead.getAuthorIdent());
+  }
+
+  @Test
+  public void migrationFailsIfProjectConfigIsNotParseable() throws Exception {
+    projectOperations.project(project).forInvalidation().makeProjectConfigInvalid().invalidate();
+    RevCommit refsMetaConfigHead = projectOperations.project(project).getHead(RefNames.REFS_CONFIG);
+
+    ConfigInvalidException exception =
+        assertThrows(ConfigInvalidException.class, () -> runMigration());
+    assertThat(exception)
+        .hasMessageThat()
+        .contains(String.format("Invalid config file project.config in project %s", project));
+
+    // verify that refs/meta/config was not touched
+    assertThat(projectOperations.project(project).getHead(RefNames.REFS_CONFIG))
+        .isEqualTo(refsMetaConfigHead);
+  }
+
+  @Test
+  public void migrateWhenProjectConfigIsMissing() throws Exception {
+    deleteProjectConfig();
+    RevCommit refsMetaConfigHead = projectOperations.project(project).getHead(RefNames.REFS_CONFIG);
+
+    runMigration();
+
+    // verify that refs/meta/config was not touched (e.g. project.config was not created)
+    assertThat(projectOperations.project(project).getHead(RefNames.REFS_CONFIG))
+        .isEqualTo(refsMetaConfigHead);
+  }
+
+  @Test
+  public void migrateWhenRefsMetaConfigIsMissing() throws Exception {
+    try (TestRepository<Repository> testRepo =
+        new TestRepository<>(repoManager.openRepository(project))) {
+      testRepo.delete(RefNames.REFS_CONFIG);
+    }
+
+    runMigration();
+
+    // verify that refs/meta/config was not created
+    try (TestRepository<Repository> testRepo =
+        new TestRepository<>(repoManager.openRepository(project))) {
+      assertThat(testRepo.getRepository().exactRef(RefNames.REFS_CONFIG)).isNull();
+    }
+  }
+
+  @Test
+  public void migrationIsIdempotent_copyAllScoresIfNoChangeIsUnset() throws Exception {
+    testMigrationIsIdempotent(/* copyAllScoresIfNoChangeValue= */ null);
+  }
+
+  @Test
+  public void migrationIsIdempotent_copyAllScoresIfNoChangeIsFalse() throws Exception {
+    testMigrationIsIdempotent(/* copyAllScoresIfNoChangeValue= */ false);
+  }
+
+  @Test
+  public void migrationIsIdempotent_copyAllScoresIfNoChangeIsTrue() throws Exception {
+    testMigrationIsIdempotent(/* copyAllScoresIfNoChangeValue= */ true);
+  }
+
+  private void testMigrationIsIdempotent(@Nullable Boolean copyAllScoresIfNoChangeValue)
+      throws Exception {
+    updateProjectConfig(
+        cfg -> {
+          if (copyAllScoresIfNoChangeValue != null) {
+            cfg.setBoolean(
+                ProjectConfig.LABEL,
+                LabelId.CODE_REVIEW,
+                MigrateLabelConfigToCopyCondition.KEY_COPY_ALL_SCORES_IF_NO_CHANGE,
+                copyAllScoresIfNoChangeValue);
+            cfg.setBoolean(
+                ProjectConfig.LABEL,
+                LabelId.VERIFIED,
+                MigrateLabelConfigToCopyCondition.KEY_COPY_ALL_SCORES_IF_NO_CHANGE,
+                copyAllScoresIfNoChangeValue);
+          } else {
+            cfg.unset(
+                ProjectConfig.LABEL,
+                LabelId.CODE_REVIEW,
+                MigrateLabelConfigToCopyCondition.KEY_COPY_ALL_SCORES_IF_NO_CHANGE);
+            cfg.unset(
+                ProjectConfig.LABEL,
+                LabelId.VERIFIED,
+                MigrateLabelConfigToCopyCondition.KEY_COPY_ALL_SCORES_IF_NO_CHANGE);
+          }
+        });
+
+    // Run the migration to update the label configuration.
+    runMigration();
+
+    assertDeprecatedFieldsUnset(LabelId.CODE_REVIEW);
+    assertDeprecatedFieldsUnset(LabelId.VERIFIED);
+
+    // default value for copyAllScoresIfNoChangeValue is true
+    if (copyAllScoresIfNoChangeValue == null || copyAllScoresIfNoChangeValue) {
+      assertThat(getCopyConditionOfCodeReviewLabel()).isEqualTo("changekind:NO_CHANGE");
+    } else {
+      assertThat(getCopyConditionOfCodeReviewLabel()).isNull();
+    }
+
+    // Running the migration again doesn't change anything.
+    RevCommit head = projectOperations.project(project).getHead(RefNames.REFS_CONFIG);
+    runMigration();
+    assertThat(projectOperations.project(project).getHead(RefNames.REFS_CONFIG)).isEqualTo(head);
+  }
+
+  private void testFlagMigration(String key, Consumer<String> copyConditionValidator)
+      throws Exception {
+    setFlagOnCodeReviewLabel(key);
+    runMigration();
+    assertDeprecatedFieldsUnset(LabelId.CODE_REVIEW);
+    copyConditionValidator.accept(getCopyConditionOfCodeReviewLabel());
+  }
+
+  private void testCopyValueMigration(List<Integer> values, Consumer<String> copyConditionValidator)
+      throws Exception {
+    setCopyValuesOnCodeReviewLabel(values.toArray(new Integer[0]));
+    runMigration();
+    assertDeprecatedFieldsUnset(LabelId.CODE_REVIEW);
+    copyConditionValidator.accept(getCopyConditionOfCodeReviewLabel());
+  }
+
+  private void runMigration() throws Exception {
+    runMigration(project);
+  }
+
+  private void runMigration(Project.NameKey project) throws Exception {
+    new MigrateLabelConfigToCopyCondition(repoManager, serverIdent.get()).execute(project);
+  }
+
+  private void setFlagOnCodeReviewLabel(String key) throws Exception {
+    setFlag(LabelId.CODE_REVIEW, key);
+  }
+
+  private void setFlagOnVerifiedLabel(String key) throws Exception {
+    setFlag(LabelId.VERIFIED, key);
+  }
+
+  private void setFlag(String labelName, String key) throws Exception {
+    updateProjectConfig(
+        cfg -> cfg.setBoolean(ProjectConfig.LABEL, labelName, key, /* value= */ true));
+  }
+
+  private void unset(String labelName, String key) throws Exception {
+    updateProjectConfig(cfg -> cfg.unset(ProjectConfig.LABEL, labelName, key));
+  }
+
+  private void setCopyValuesOnCodeReviewLabel(Integer... values) throws Exception {
+    setCopyValues(LabelId.CODE_REVIEW, values);
+  }
+
+  private void setCopyValues(String labelName, Integer... values) throws Exception {
+    updateProjectConfig(
+        cfg ->
+            cfg.setStringList(
+                ProjectConfig.LABEL,
+                labelName,
+                MigrateLabelConfigToCopyCondition.KEY_COPY_VALUE,
+                Arrays.stream(values).map(Object::toString).collect(toImmutableList())));
+  }
+
+  private void setCopyConditionOnCodeReviewLabel(String copyCondition) throws Exception {
+    setCopyCondition(LabelId.CODE_REVIEW, copyCondition);
+  }
+
+  private void setCopyCondition(String labelName, String copyCondition) throws Exception {
+    updateProjectConfig(
+        cfg ->
+            cfg.setString(
+                ProjectConfig.LABEL, labelName, ProjectConfig.KEY_COPY_CONDITION, copyCondition));
+  }
+
+  private void updateProjectConfig(Consumer<Config> configUpdater) throws Exception {
+    Config projectConfig = projectOperations.project(project).getConfig();
+    configUpdater.accept(projectConfig);
+    try (TestRepository<Repository> testRepo =
+        new TestRepository<>(repoManager.openRepository(project))) {
+      Ref ref = testRepo.getRepository().exactRef(RefNames.REFS_CONFIG);
+      RevCommit head = testRepo.getRevWalk().parseCommit(ref.getObjectId());
+      testRepo.update(
+          RefNames.REFS_CONFIG,
+          testRepo
+              .commit()
+              .parent(head)
+              .message("Update label config")
+              .add(ProjectConfig.PROJECT_CONFIG, projectConfig.toText()));
+    }
+    projectCache.evictAndReindex(project);
+  }
+
+  private void deleteProjectConfig() throws Exception {
+    try (TestRepository<Repository> testRepo =
+        new TestRepository<>(repoManager.openRepository(project))) {
+      Ref ref = testRepo.getRepository().exactRef(RefNames.REFS_CONFIG);
+      RevCommit head = testRepo.getRevWalk().parseCommit(ref.getObjectId());
+      testRepo.update(
+          RefNames.REFS_CONFIG,
+          testRepo
+              .commit()
+              .parent(head)
+              .message("Update label config")
+              .rm(ProjectConfig.PROJECT_CONFIG));
+    }
+    projectCache.evictAndReindex(project);
+  }
+
+  private void assertDeprecatedFieldsUnset(String labelName, String... excludedFields) {
+    for (String field :
+        Sets.difference(DEPRECATED_FIELDS, Sets.newHashSet(Arrays.asList(excludedFields)))) {
+      assertUnset(labelName, field);
+    }
+  }
+
+  private void assertUnset(String labelName, String key) {
+    assertThat(
+            projectOperations.project(project).getConfig().getNames(ProjectConfig.LABEL, labelName))
+        .doesNotContain(key);
+  }
+
+  private String getCopyConditionOfCodeReviewLabel() {
+    return getCopyCondition(LabelId.CODE_REVIEW);
+  }
+
+  private String getCopyConditionOfVerifiedLabel() {
+    return getCopyCondition(LabelId.VERIFIED);
+  }
+
+  private String getCopyCondition(String labelName) {
+    return projectOperations
+        .project(project)
+        .getConfig()
+        .getString(ProjectConfig.LABEL, labelName, ProjectConfig.KEY_COPY_CONDITION);
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/pgm/Schema_184IT.java b/javatests/com/google/gerrit/acceptance/pgm/Schema_184IT.java
index 093711f..fd9054c 100644
--- a/javatests/com/google/gerrit/acceptance/pgm/Schema_184IT.java
+++ b/javatests/com/google/gerrit/acceptance/pgm/Schema_184IT.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2009 The Android Open Source Project
+// Copyright (C) 2020 The Android Open Source Project
 //
 // Licensed under the Apache License, Version 2.0 (the "License");
 // you may not use this file except in compliance with the License.
diff --git a/javatests/com/google/gerrit/acceptance/pgm/Schema_185IT.java b/javatests/com/google/gerrit/acceptance/pgm/Schema_185IT.java
new file mode 100644
index 0000000..afaf530
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/pgm/Schema_185IT.java
@@ -0,0 +1,261 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.pgm;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.server.project.testing.TestLabels.labelBuilder;
+import static com.google.gerrit.server.project.testing.TestLabels.value;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.Sandboxed;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.server.project.ProjectConfig;
+import com.google.gerrit.server.schema.MigrateLabelConfigToCopyCondition;
+import com.google.gerrit.server.schema.NoteDbSchemaVersion;
+import com.google.gerrit.server.schema.Schema_185;
+import com.google.gerrit.testing.TestUpdateUI;
+import com.google.inject.Inject;
+import java.util.function.Consumer;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.Test;
+
+@Sandboxed
+public class Schema_185IT extends AbstractDaemonTest {
+  private static final String KEY_COPY_ALL_SCORES_IF_NO_CHANGE = "copyAllScoresIfNoChange";
+  private static final String KEY_COPY_MIN_SCORE = "copyMinScore";
+
+  @Inject private ProjectOperations projectOperations;
+  @Inject private NoteDbSchemaVersion.Arguments args;
+
+  private final TestUpdateUI testUpdateUI = new TestUpdateUI();
+
+  @Test
+  public void nothingToMigrate() throws Exception {
+    RevCommit oldHeadAllProjects = getHead(allProjects);
+    RevCommit oldHeadAllUsers = getHead(allUsers);
+    RevCommit oldHeadProject = getHead(project);
+
+    runMigration();
+
+    // All-Projects and All-Users both contain a label definition for Code-Review but without
+    // boolean flags, hence those don't need to be migrated (the migration assumes true for
+    // copyAllScoresIfNoChange if unset, but the copyCondition already contains
+    // 'changekind:NO_CHANGE' so copyCondition doesn't need to be changed).
+    assertThatMigrationHasNotRun(allProjects, oldHeadAllProjects);
+    assertThatMigrationHasNotRun(allUsers, oldHeadAllUsers);
+
+    // Check that the migration was not executed for the projects that do not contain label
+    // definitions.
+    assertThatMigrationHasNotRun(project, oldHeadProject);
+  }
+
+  @Test
+  public void labelConfigsAreMigrated() throws Exception {
+    addLabelThatNeedsToBeMigrated(project);
+
+    RevCommit projectOldHead = getHead(project);
+    runMigration();
+    assertThatMigrationHasRun(project, projectOldHead);
+  }
+
+  @Test
+  public void upgradeIsIdempotent() throws Exception {
+    addLabelThatNeedsToBeMigrated(project);
+
+    // Run the migration to update the label configuration.
+    runMigration();
+
+    // Running the migration again, doesn't change anything.
+    RevCommit projectOldHead = getHead(project);
+    runMigration();
+    assertThatMigrationHasNotRun(project, projectOldHead);
+  }
+
+  @Test
+  public void upgradeIsIdempotent_onlyDefaultFlagIsMigrated() throws Exception {
+    addLabelThatNeedsToBeMigratedDueToDefaultFlag(project);
+
+    // Run the migration to update the label configuration.
+    runMigration();
+
+    // Running the migration again, doesn't change anything.
+    RevCommit projectOldHead = getHead(project);
+    runMigration();
+    assertThatMigrationHasNotRun(project, projectOldHead);
+  }
+
+  @Test
+  public void migrateMultipleProjects() throws Exception {
+    Project.NameKey project1 = createProjectWithLabelConfigThatNeedsToBeMigrated();
+    Project.NameKey project2 = createProjectWithLabelConfigThatNeedsToBeMigrated();
+    Project.NameKey project3 = createProjectWithLabelConfigThatNeedsToBeMigrated();
+
+    RevCommit oldHeadProject1 = getHead(project1);
+    RevCommit oldHeadProject2 = getHead(project2);
+    RevCommit oldHeadProject3 = getHead(project3);
+
+    runMigration();
+
+    assertThatMigrationHasRun(project1, oldHeadProject1);
+    assertThatMigrationHasRun(project2, oldHeadProject2);
+    assertThatMigrationHasRun(project3, oldHeadProject3);
+  }
+
+  @Test
+  public void migrationPrintsOutProgress() throws Exception {
+    // Create 197 projects so that in total we have 200 projects (197 + All-Projects + All-Users +
+    // test project).
+    for (int i = 0; i < 197; i++) {
+      createProjectWithLabelConfigThatNeedsToBeMigrated();
+    }
+
+    runMigration();
+    String output = testUpdateUI.getOutput();
+    assertThat(output).contains("Migrating label configurations");
+    assertThat(output).contains("migrated label configurations of 50% (100/200) projects");
+    assertThat(output).contains("migrated label configurations of 100% (200/200) projects");
+    assertThat(output).contains("Migrated label configurations of all 200 projects to schema 185");
+  }
+
+  @Test
+  public void projectsWithInvalidConfigurationAreSkipped() throws Exception {
+    Project.NameKey projectWithInvalidConfig = createProjectWithLabelConfigThatNeedsToBeMigrated();
+    projectOperations
+        .project(projectWithInvalidConfig)
+        .forInvalidation()
+        .makeProjectConfigInvalid()
+        .invalidate();
+
+    Project.NameKey otherProject1 = createProjectWithLabelConfigThatNeedsToBeMigrated();
+    Project.NameKey otherProject2 = createProjectWithLabelConfigThatNeedsToBeMigrated();
+
+    RevCommit oldHeadProjectWithInvalidConfig = getHead(projectWithInvalidConfig);
+    RevCommit oldHeadOtherProject1 = getHead(otherProject1);
+    RevCommit oldHeadOtherProject2 = getHead(otherProject2);
+
+    runMigration();
+
+    assertThatMigrationHasNotRun(projectWithInvalidConfig, oldHeadProjectWithInvalidConfig);
+    assertThatMigrationHasRun(otherProject1, oldHeadOtherProject1);
+    assertThatMigrationHasRun(otherProject2, oldHeadOtherProject2);
+
+    String output = testUpdateUI.getOutput();
+    assertThat(output)
+        .contains(
+            String.format(
+                "WARNING: Skipping migration of label configurations for project %s"
+                    + " since its %s file is invalid:",
+                projectWithInvalidConfig, ProjectConfig.PROJECT_CONFIG));
+  }
+
+  private void runMigration() throws Exception {
+    Schema_185 upgrade = new Schema_185();
+    upgrade.upgrade(args, testUpdateUI);
+  }
+
+  private RevCommit getHead(Project.NameKey project) {
+    return projectOperations.project(project).getHead(RefNames.REFS_CONFIG);
+  }
+
+  private void assertThatMigrationHasRun(Project.NameKey project, RevCommit oldHead) {
+    RevCommit newHead = getHead(project);
+    assertThat(getHead(project)).isNotEqualTo(oldHead);
+    assertThat(newHead.getShortMessage())
+        .isEqualTo(MigrateLabelConfigToCopyCondition.COMMIT_MESSAGE);
+  }
+
+  private void assertThatMigrationHasNotRun(Project.NameKey project, RevCommit oldHead) {
+    assertThat(getHead(project)).isEqualTo(oldHead);
+  }
+
+  private Project.NameKey createProjectWithLabelConfigThatNeedsToBeMigrated() throws Exception {
+    Project.NameKey project = projectOperations.newProject().create();
+    addLabelThatNeedsToBeMigrated(project);
+    return project;
+  }
+
+  private void addLabelThatNeedsToBeMigrated(Project.NameKey project) throws Exception {
+    // create a label which needs to be migrated because flags have been set
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      LabelType.Builder codeReview =
+          labelBuilder(
+              LabelId.CODE_REVIEW,
+              value(2, "Looks good to me, approved"),
+              value(1, "Looks good to me, but someone else must approve"),
+              value(0, "No score"),
+              value(-1, "I would prefer this is not submitted as is"),
+              value(-2, "This shall not be submitted"));
+      u.getConfig().upsertLabelType(codeReview.build());
+      u.save();
+    }
+
+    updateProjectConfig(
+        cfg -> {
+          // override the default value
+          cfg.setBoolean(
+              ProjectConfig.LABEL,
+              LabelId.CODE_REVIEW,
+              KEY_COPY_ALL_SCORES_IF_NO_CHANGE,
+              /* value= */ false);
+          // set random flag
+          cfg.setBoolean(
+              ProjectConfig.LABEL, LabelId.CODE_REVIEW, KEY_COPY_MIN_SCORE, /* value= */ true);
+        });
+  }
+
+  private void addLabelThatNeedsToBeMigratedDueToDefaultFlag(Project.NameKey project)
+      throws Exception {
+    // create a label which needs to be migrated (copyAllScoresIfNoChange is unset, the migration
+    // assumes true as default and hence sets copyCondition to "changekind:NO_CHANGE").
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      LabelType.Builder codeReview =
+          labelBuilder(
+              LabelId.CODE_REVIEW,
+              value(2, "Looks good to me, approved"),
+              value(1, "Looks good to me, but someone else must approve"),
+              value(0, "No score"),
+              value(-1, "I would prefer this is not submitted as is"),
+              value(-2, "This shall not be submitted"));
+      u.getConfig().upsertLabelType(codeReview.build());
+      u.save();
+    }
+  }
+
+  private void updateProjectConfig(Consumer<Config> configUpdater) throws Exception {
+    Config projectConfig = projectOperations.project(project).getConfig();
+    configUpdater.accept(projectConfig);
+    try (TestRepository<Repository> testRepo =
+        new TestRepository<>(repoManager.openRepository(project))) {
+      Ref ref = testRepo.getRepository().exactRef(RefNames.REFS_CONFIG);
+      RevCommit head = testRepo.getRevWalk().parseCommit(ref.getObjectId());
+      testRepo.update(
+          RefNames.REFS_CONFIG,
+          testRepo
+              .commit()
+              .parent(head)
+              .message("Update label config")
+              .add(ProjectConfig.PROJECT_CONFIG, projectConfig.toText()));
+    }
+    projectCache.evictAndReindex(project);
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/DeleteSshKeyIT.java b/javatests/com/google/gerrit/acceptance/rest/account/DeleteSshKeyIT.java
new file mode 100644
index 0000000..4e9b2af
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/account/DeleteSshKeyIT.java
@@ -0,0 +1,122 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest.account;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.UseSsh;
+import com.google.gerrit.acceptance.testsuite.account.TestSshKeys;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.server.account.AccountSshKey;
+import com.google.gerrit.server.account.VersionedAuthorizedKeys;
+import com.google.gerrit.server.restapi.account.DeleteSshKey;
+import com.google.inject.Inject;
+import java.security.KeyPair;
+import java.util.List;
+import org.junit.Before;
+import org.junit.Test;
+
+public class DeleteSshKeyIT extends AbstractDaemonTest {
+
+  private static final String KEY1 =
+      "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQCgug5VyMXQGnem2H1KVC4/HcRcD4zzBqS"
+          + "uJBRWVonSSoz3RoAZ7bWXCVVGwchtXwUURD689wFYdiPecOrWOUgeeyRq754YWRhU+W28"
+          + "vf8IZixgjCmiBhaL2gt3wff6pP+NXJpTSA4aeWE5DfNK5tZlxlSxqkKOS8JRSUeNQov5T"
+          + "w== john.doe@example.com";
+
+  @Inject VersionedAuthorizedKeys.Accessor authorizedKeys;
+  @Inject DeleteSshKey deleteSshKey;
+
+  private AccountSshKey userSshKey;
+  private AccountSshKey adminSshKey;
+
+  @Before
+  public void setup() throws Exception {
+    addUserSshKeys();
+    addAdminSshKeys();
+  }
+
+  @Test
+  @UseSsh
+  public void assertUsersHaveSshKeysPreconditions() throws Exception {
+    List<AccountSshKey> userSshKeys = authorizedKeys.getKeys(user.id());
+    assertThat(userSshKeys).containsExactly(userSshKey, AccountSshKey.create(user.id(), 2, KEY1));
+    List<AccountSshKey> adminSshKeys = authorizedKeys.getKeys(admin.id());
+    assertThat(adminSshKeys).containsExactly(adminSshKey);
+  }
+
+  @Test
+  @UseSsh
+  public void deleteSshKeyRestApi() throws Exception {
+    gApi.accounts().id(user.id().get()).deleteSshKey(userSshKey.seq());
+    List<AccountSshKey> sshKeysAfterDel = authorizedKeys.getKeys(user.id());
+    assertThat(sshKeysAfterDel).containsExactly(AccountSshKey.create(user.id(), 2, KEY1));
+  }
+
+  @Test
+  @UseSsh
+  public void adminCanDeleteUserSshKey() throws Exception {
+    adminRestSession
+        .delete(String.format("/accounts/%s/sshkeys/%d", user.id(), userSshKey.seq()))
+        .assertNoContent();
+    List<AccountSshKey> sshKeysAfterDel = authorizedKeys.getKeys(user.id());
+    assertThat(sshKeysAfterDel).containsExactly(AccountSshKey.create(user.id(), 2, KEY1));
+  }
+
+  @Test
+  @UseSsh
+  public void deleteSshKeyOnBehalf() throws Exception {
+    assertThat(deleteSshKey.apply(identifiedUserFactory.create(user.id()), userSshKey))
+        .isEqualTo(Response.none());
+    List<AccountSshKey> sshKeysAfterDel = authorizedKeys.getKeys(user.id());
+    assertThat(sshKeysAfterDel).containsExactly(AccountSshKey.create(user.id(), 2, KEY1));
+  }
+
+  @Test
+  @UseSsh
+  public void userCanDeleteOwnSshKey() throws Exception {
+    userRestSession
+        .delete(String.format("/accounts/self/sshkeys/%d", userSshKey.seq()))
+        .assertNoContent();
+    List<AccountSshKey> sshKeysAfterDel = authorizedKeys.getKeys(user.id());
+    assertThat(sshKeysAfterDel).containsExactly(AccountSshKey.create(user.id(), 2, KEY1));
+  }
+
+  @Test
+  @UseSsh
+  public void userCannotDeleteOtherUsersSshKey() throws Exception {
+    userRestSession
+        .delete(String.format("/accounts/%s/sshkeys/%d", admin.id(), adminSshKey.seq()))
+        .assertNotFound();
+    List<AccountSshKey> sshKeysAfterDel = authorizedKeys.getKeys(admin.id());
+    assertThat(sshKeysAfterDel).containsExactly(adminSshKey);
+  }
+
+  private void addUserSshKeys() throws Exception {
+    KeyPair keyPair = sshKeys.getKeyPair(user);
+    userSshKey =
+        authorizedKeys.addKey(
+            user(user).getAccountId(), TestSshKeys.publicKey(keyPair, user.email()));
+    gApi.accounts().id(user.id().get()).addSshKey(KEY1);
+  }
+
+  private void addAdminSshKeys() throws Exception {
+    KeyPair keyPair = sshKeys.getKeyPair(admin);
+    adminSshKey =
+        authorizedKeys.addKey(
+            user(admin).getAccountId(), TestSshKeys.publicKey(keyPair, admin.email()));
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java b/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java
index 4da4da4..eb827c0 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java
@@ -62,6 +62,7 @@
 import com.google.gerrit.extensions.common.GroupInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.CommentsUtil;
@@ -313,8 +314,8 @@
     in.onBehalfOf = user.id().toString();
     in.label("Code-Review", 1);
 
-    UnprocessableEntityException thrown =
-        assertThrows(UnprocessableEntityException.class, () -> revision.review(in));
+    ResourceConflictException thrown =
+        assertThrows(ResourceConflictException.class, () -> revision.review(in));
     assertThat(thrown)
         .hasMessageThat()
         .contains("on_behalf_of account " + user.id() + " cannot see change");
diff --git a/javatests/com/google/gerrit/acceptance/rest/binding/ChangesRestApiBindingsIT.java b/javatests/com/google/gerrit/acceptance/rest/binding/ChangesRestApiBindingsIT.java
index f3b13d2..2d663df 100644
--- a/javatests/com/google/gerrit/acceptance/rest/binding/ChangesRestApiBindingsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/binding/ChangesRestApiBindingsIT.java
@@ -74,8 +74,6 @@
           RestCall.delete("/changes/%s/private"),
           RestCall.post("/changes/%s/wip"),
           RestCall.post("/changes/%s/ready"),
-          RestCall.put("/changes/%s/ignore"),
-          RestCall.put("/changes/%s/unignore"),
           RestCall.get("/changes/%s/messages"),
           RestCall.put("/changes/%s/message"),
           RestCall.post("/changes/%s/merge"),
@@ -150,6 +148,8 @@
           RestCall.post("/changes/%s/revisions/%s/test.submit_rule"),
           RestCall.post("/changes/%s/revisions/%s/test.submit_type"),
           RestCall.post("/changes/%s/revisions/%s/rebase"),
+          RestCall.post("/changes/%s/revisions/%s/fix:apply"),
+          RestCall.post("/changes/%s/revisions/%s/fix:preview"),
           RestCall.get("/changes/%s/revisions/%s/description"),
           RestCall.put("/changes/%s/revisions/%s/description"),
           RestCall.get("/changes/%s/revisions/%s/patch"),
diff --git a/javatests/com/google/gerrit/acceptance/rest/binding/ProjectsRestApiBindingsIT.java b/javatests/com/google/gerrit/acceptance/rest/binding/ProjectsRestApiBindingsIT.java
index f1c0110..cffcc2f 100644
--- a/javatests/com/google/gerrit/acceptance/rest/binding/ProjectsRestApiBindingsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/binding/ProjectsRestApiBindingsIT.java
@@ -32,6 +32,8 @@
 import com.google.gerrit.entities.LabelFunction;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementExpression;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.api.projects.TagInput;
@@ -85,7 +87,9 @@
               .build(),
           RestCall.get("/projects/%s/dashboards"),
           RestCall.put("/projects/%s/labels/new-label"),
-          RestCall.post("/projects/%s/labels/"));
+          RestCall.post("/projects/%s/labels/"),
+          RestCall.put("/projects/%s/submit_requirements/new-sr"),
+          RestCall.get("/projects/%s/submit_requirements"));
 
   /**
    * Child project REST endpoints to be tested, each URL contains placeholders for the parent
@@ -175,6 +179,18 @@
           // Label deletion must be tested last
           RestCall.delete("/projects/%s/labels/%s"));
 
+  /**
+   * Submit requirement REST endpoints to be tested, each URL contains placeholders for the project
+   * identifier and the submit requirement name.
+   */
+  private static final ImmutableList<RestCall> SUBMIT_REQUIREMENT_ENDPOINTS =
+      ImmutableList.of(
+          RestCall.get("/projects/%s/submit_requirements/%s"),
+          RestCall.put("/projects/%s/submit_requirements/%s"),
+
+          // Submit requirement deletion must be tested last
+          RestCall.delete("/projects/%s/submit_requirements/%s"));
+
   private static final String FILENAME = "test.txt";
   @Inject private ProjectOperations projectOperations;
 
@@ -236,6 +252,20 @@
     RestApiCallHelper.execute(adminRestSession, LABEL_ENDPOINTS, project.get(), label);
   }
 
+  @Test
+  public void submitRequirementsEndpoints() throws Exception {
+    // Create the SR, so that the GET endpoint succeeds
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("code-review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.maxCodeReview())
+            .setAllowOverrideInChildProjects(false)
+            .build());
+    RestApiCallHelper.execute(
+        adminRestSession, SUBMIT_REQUIREMENT_ENDPOINTS, project.get(), "code-review");
+  }
+
   private String createAndSubmitChange(String filename) throws Exception {
     RevCommit c =
         testRepo
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
index 8eada79..0e4f212 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
@@ -1361,14 +1361,11 @@
     assertThat(actual.getTimeZone()).isEqualTo(expected.getTimeZone());
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   protected void assertAuthorAndCommitDateEquals(RevCommit commit) {
-    assertThat(commit.getAuthorIdent().getWhen().getTime())
-        .isEqualTo(commit.getCommitterIdent().getWhen().getTime());
-    assertThat(commit.getAuthorIdent().getTimeZone())
-        .isEqualTo(commit.getCommitterIdent().getTimeZone());
+    assertThat(commit.getAuthorIdent().getWhenAsInstant())
+        .isEqualTo(commit.getCommitterIdent().getWhenAsInstant());
+    assertThat(commit.getAuthorIdent().getZoneId())
+        .isEqualTo(commit.getCommitterIdent().getZoneId());
   }
 
   protected void assertSubmitter(String changeId, int psId) throws Throwable {
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java b/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
index e013267..ea52690 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
@@ -15,8 +15,12 @@
 package com.google.gerrit.acceptance.rest.change;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
 import static com.google.gerrit.extensions.restapi.testing.AttentionSetUpdateSubject.assertThat;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.server.project.testing.TestLabels.labelBuilder;
+import static com.google.gerrit.server.project.testing.TestLabels.value;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.common.collect.ImmutableList;
@@ -42,8 +46,11 @@
 import com.google.gerrit.entities.AttentionSetUpdate.Operation;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.Patch;
+import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.changes.AttentionSetInput;
 import com.google.gerrit.extensions.api.changes.DeleteReviewerInput;
 import com.google.gerrit.extensions.api.changes.DeleteVoteInput;
@@ -60,11 +67,13 @@
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.server.account.ServiceUserClassifier;
+import com.google.gerrit.server.project.testing.TestLabels;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.gerrit.server.restapi.change.GetAttentionSet;
 import com.google.gerrit.server.util.AccountTemplateUtil;
 import com.google.gerrit.server.util.time.TimeUtil;
+import com.google.gerrit.testing.FakeEmailSender.Message;
 import com.google.gerrit.testing.TestCommentHelper;
 import com.google.gerrit.truth.NullAwareCorrespondence;
 import com.google.inject.Inject;
@@ -2029,6 +2038,715 @@
     sender.clear();
   }
 
+  @Test
+  public void approverOfOutdatedApprovalAddedToAttentionSet() throws Exception {
+    PushOneCommit.Result r = createChange();
+    assertThat(r.getChange().attentionSet()).isEmpty();
+
+    // Add an approval that gets outdated when a new patch set is created (i.e. an approval that is
+    // not copied).
+    requestScopeOperations.setApiUser(user.id());
+    recommend(r.getChangeId());
+    requestScopeOperations.setApiUser(admin.id());
+
+    // Voting added the admin user to the attention set.
+    assertThat(changeDataFactory.create(project, r.getChange().getId()).attentionSet())
+        .containsExactly(
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(),
+                admin.id(),
+                AttentionSetUpdate.Operation.ADD,
+                "Someone else replied on the change"));
+
+    // Remove admin user from attention set.
+    change(r).attention(admin.id().toString()).remove(new AttentionSetInput("removed"));
+    assertThat(changeDataFactory.create(project, r.getChange().getId()).attentionSet())
+        .containsExactly(
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(), admin.id(), AttentionSetUpdate.Operation.REMOVE, "removed"));
+
+    // Amend the change, this removes the vote from user, as it is not copied to the new patch set.
+    sender.clear();
+    r = amendChange(r.getChangeId(), "refs/for/master", admin, testRepo);
+    r.assertOkStatus();
+
+    // Verify that the approval has been removed.
+    assertThat(r.getChange().currentApprovals()).isEmpty();
+
+    // User got added to the attention set because users approval got outdated and was removed and
+    // user now needs to re-review the change and renew the approval.
+    assertThat(r.getChange().attentionSet())
+        .containsExactly(
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(), admin.id(), AttentionSetUpdate.Operation.REMOVE, "removed"),
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(),
+                user.id(),
+                AttentionSetUpdate.Operation.ADD,
+                "Vote got outdated and was removed: Code-Review+1"));
+
+    // Expect that the email notification contains the outdated vote.
+    Message message = Iterables.getOnlyElement(sender.getMessages());
+    assertThat(message.body())
+        .contains(
+            String.format(
+                "Attention is currently required from: %s.\n"
+                    + "\n"
+                    + "Hello %s, \n"
+                    + "\n"
+                    + "I'd like you to reexamine a change."
+                    + " Please visit",
+                user.fullName(), user.fullName()));
+    assertThat(message.body())
+        .contains(
+            String.format(
+                "The following approvals got outdated and were removed:\n"
+                    + "Code-Review+1 by %s\n",
+                user.fullName()));
+    assertThat(message.htmlBody())
+        .contains(
+            String.format(
+                "<p> Attention is currently required from: %s. </p>\n"
+                    + "<p>%s <strong>uploaded patch set #2</strong> to this change.</p>",
+                user.fullName(), admin.fullName()));
+    assertThat(message.htmlBody())
+        .contains(
+            String.format(
+                "View Change</a></p>"
+                    + "<p>The following approvals got outdated and were removed:\n"
+                    + "Code-Review+1 by %s</p>",
+                user.fullName()));
+  }
+
+  @Test
+  public void approverOfMultipleOutdatedApprovalsAddedToAttentionSet() throws Exception {
+    // Create a Verify and a Foo-Var label and allow voting on it.
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      LabelType.Builder verified =
+          labelBuilder(
+              LabelId.VERIFIED, value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
+      u.getConfig().upsertLabelType(verified.build());
+
+      LabelType.Builder fooBar =
+          labelBuilder("Foo-Bar", value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
+      u.getConfig().upsertLabelType(fooBar.build());
+
+      u.save();
+    }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(LabelId.VERIFIED)
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-1, 1))
+        .add(
+            allowLabel("Foo-Bar")
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-1, 1))
+        .update();
+
+    PushOneCommit.Result r = createChange();
+    assertThat(r.getChange().attentionSet()).isEmpty();
+
+    // Add multiple approvals from one user that gets outdated when a new patch set is created (i.e.
+    // approvals that are not copied).
+    requestScopeOperations.setApiUser(user.id());
+    recommend(r.getChangeId());
+    gApi.changes()
+        .id(r.getChangeId())
+        .current()
+        .review(new ReviewInput().label(LabelId.VERIFIED, 1));
+    gApi.changes().id(r.getChangeId()).current().review(new ReviewInput().label("Foo-Bar", -1));
+    requestScopeOperations.setApiUser(admin.id());
+
+    // Voting added the admin user to the attention set.
+    assertThat(changeDataFactory.create(project, r.getChange().getId()).attentionSet())
+        .containsExactly(
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(),
+                admin.id(),
+                AttentionSetUpdate.Operation.ADD,
+                "Someone else replied on the change"));
+
+    // Remove admin user from attention set.
+    change(r).attention(admin.id().toString()).remove(new AttentionSetInput("removed"));
+    assertThat(changeDataFactory.create(project, r.getChange().getId()).attentionSet())
+        .containsExactly(
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(), admin.id(), AttentionSetUpdate.Operation.REMOVE, "removed"));
+
+    // Amend the change, this removes the vote from user, as it is not copied to the new patch set.
+    sender.clear();
+    r = amendChange(r.getChangeId(), "refs/for/master", admin, testRepo);
+    r.assertOkStatus();
+
+    // Verify that the approvals have been removed.
+    assertThat(r.getChange().currentApprovals()).isEmpty();
+
+    // User got added to the attention set because users approvals got outdated and were removed and
+    // user now needs to re-review the change and renew the approvals.
+    assertThat(r.getChange().attentionSet())
+        .containsExactly(
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(), admin.id(), AttentionSetUpdate.Operation.REMOVE, "removed"),
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(),
+                user.id(),
+                AttentionSetUpdate.Operation.ADD,
+                "Votes got outdated and were removed: Code-Review+1, Foo-Bar-1, Verified+1"));
+
+    // Expect that the email notification contains the outdated votes.
+    Message message = Iterables.getOnlyElement(sender.getMessages());
+    assertThat(message.body())
+        .contains(
+            String.format(
+                "Attention is currently required from: %s.\n"
+                    + "\n"
+                    + "Hello %s, \n"
+                    + "\n"
+                    + "I'd like you to reexamine a change."
+                    + " Please visit",
+                user.fullName(), user.fullName()));
+    assertThat(message.body())
+        .contains(
+            String.format(
+                "The following approvals got outdated and were removed:\n"
+                    + "Code-Review+1 by %s, Foo-Bar-1 by %s, Verified+1 by %s\n",
+                user.fullName(), user.fullName(), user.fullName()));
+    assertThat(message.htmlBody())
+        .contains(
+            String.format(
+                "<p> Attention is currently required from: %s. </p>\n"
+                    + "<p>%s <strong>uploaded patch set #2</strong> to this change.</p>",
+                user.fullName(), admin.fullName()));
+    assertThat(message.htmlBody())
+        .contains(
+            String.format(
+                "View Change</a></p>"
+                    + "<p>The following approvals got outdated and were removed:\n"
+                    + "Code-Review+1 by %s, Foo-Bar-1 by %s, Verified+1 by %s</p>",
+                user.fullName(), user.fullName(), user.fullName()));
+  }
+
+  @Test
+  public void multipleApproverOfOutdatedApprovalsAddedToAttentionSet() throws Exception {
+    // Create Verify label and allow voting on it.
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      LabelType.Builder verified =
+          labelBuilder(
+              LabelId.VERIFIED, value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
+      u.getConfig().upsertLabelType(verified.build());
+      u.save();
+    }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(LabelId.VERIFIED)
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-1, 1))
+        .update();
+
+    PushOneCommit.Result r = createChange();
+    assertThat(r.getChange().attentionSet()).isEmpty();
+
+    // Add approvals from multiple users that gets outdated when a new patch set is created (i.e.
+    // approvals that are not copied).
+    requestScopeOperations.setApiUser(user.id());
+    recommend(r.getChangeId());
+    TestAccount user2 = accountCreator.user2();
+    requestScopeOperations.setApiUser(user2.id());
+    gApi.changes()
+        .id(r.getChangeId())
+        .current()
+        .review(new ReviewInput().label(LabelId.VERIFIED, 1));
+    requestScopeOperations.setApiUser(admin.id());
+
+    // Voting added the admin user to the attention set.
+    assertThat(changeDataFactory.create(project, r.getChange().getId()).attentionSet())
+        .containsExactly(
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(),
+                admin.id(),
+                AttentionSetUpdate.Operation.ADD,
+                "Someone else replied on the change"));
+
+    // Remove admin user from attention set.
+    change(r).attention(admin.id().toString()).remove(new AttentionSetInput("removed"));
+    assertThat(changeDataFactory.create(project, r.getChange().getId()).attentionSet())
+        .containsExactly(
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(), admin.id(), AttentionSetUpdate.Operation.REMOVE, "removed"));
+
+    // Amend the change, this removes the vote from user, as it is not copied to the new patch set.
+    sender.clear();
+    r = amendChange(r.getChangeId(), "refs/for/master", admin, testRepo);
+    r.assertOkStatus();
+
+    // Verify that the approvals have been removed.
+    assertThat(r.getChange().currentApprovals()).isEmpty();
+
+    // User got added to the attention set because users approvals got outdated and were removed and
+    // user now needs to re-review the change and renew the approvals.
+    assertThat(r.getChange().attentionSet())
+        .containsExactly(
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(), admin.id(), AttentionSetUpdate.Operation.REMOVE, "removed"),
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(),
+                user.id(),
+                AttentionSetUpdate.Operation.ADD,
+                "Vote got outdated and was removed: Code-Review+1"),
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(),
+                user2.id(),
+                AttentionSetUpdate.Operation.ADD,
+                "Vote got outdated and was removed: Verified+1"));
+
+    // Expect that the email notification contains the outdated votes.
+    assertThat(sender.getMessages()).hasSize(1);
+    Message message = Iterables.getOnlyElement(sender.getMessages());
+    assertThat(message.body())
+        .contains(
+            String.format(
+                "Attention is currently required from: %s, %s.\n"
+                    + "\n"
+                    + "Hello %s, %s, \n"
+                    + "\n"
+                    + "I'd like you to reexamine a change."
+                    + " Please visit",
+                user.fullName(), user2.fullName(), user.fullName(), user2.fullName()));
+    assertThat(message.body())
+        .contains(
+            String.format(
+                "The following approvals got outdated and were removed:\n"
+                    + "Code-Review+1 by %s, Verified+1 by %s\n",
+                user.fullName(), user2.fullName()));
+    assertThat(message.htmlBody())
+        .contains(
+            String.format(
+                "<p> Attention is currently required from: %s, %s. </p>\n"
+                    + "<p>%s <strong>uploaded patch set #2</strong> to this change.</p>",
+                user.fullName(), user2.fullName(), admin.fullName()));
+    assertThat(message.htmlBody())
+        .contains(
+            String.format(
+                "View Change</a></p>"
+                    + "<p>The following approvals got outdated and were removed:\n"
+                    + "Code-Review+1 by %s, Verified+1 by %s</p>",
+                user.fullName(), user2.fullName()));
+  }
+
+  @Test
+  public void robotApproverOfOutdatedApprovalIsNotAddedToAttentionSet() throws Exception {
+    // Create robot account
+    TestAccount robot =
+        accountCreator.create(
+            "robot-X",
+            "robot-x@example.com",
+            "Ro Bot X",
+            "RoX",
+            ServiceUserClassifier.SERVICE_USERS,
+            "Administrators");
+
+    PushOneCommit.Result r = createChange();
+    assertThat(r.getChange().attentionSet()).isEmpty();
+
+    // Add an approval by a robot that gets outdated when a new patch set is created (i.e. an
+    // approval that is not copied).
+    requestScopeOperations.setApiUser(robot.id());
+    recommend(r.getChangeId());
+    requestScopeOperations.setApiUser(admin.id());
+
+    // A robot vote doesn't add the admin user to the attention set.
+    assertThat(changeDataFactory.create(project, r.getChange().getId()).attentionSet()).isEmpty();
+
+    // Amend the change, this removes the vote from the robot, as it is not copied to the new patch
+    // set.
+    sender.clear();
+    r = amendChange(r.getChangeId(), "refs/for/master", admin, testRepo);
+    r.assertOkStatus();
+
+    // Verify that the approval has been removed.
+    assertThat(r.getChange().currentApprovals()).isEmpty();
+
+    // The robot was not added to the attention set because users service users are never added to
+    // the attention set.
+    assertThat(r.getChange().attentionSet()).isEmpty();
+
+    // Verify the email for the new patch set.
+    Message message = Iterables.getOnlyElement(sender.getMessages());
+    String emailBody = message.body();
+    assertThat(emailBody)
+        .doesNotContain(
+            String.format("Attention is currently required from: %s", robot.fullName()));
+    assertThat(emailBody)
+        .contains(
+            String.format(
+                "Hello %s, \n\nI'd like you to reexamine a change. Please visit",
+                robot.fullName()));
+    assertThat(emailBody)
+        .contains(
+            String.format(
+                "The following approvals got outdated and were removed:\nCode-Review+1 by %s",
+                robot.fullName()));
+    assertThat(message.htmlBody())
+        .doesNotContain(
+            String.format("Attention is currently required from: %s", robot.fullName()));
+    assertThat(message.htmlBody())
+        .contains(
+            String.format(
+                "<p>%s <strong>uploaded patch set #2</strong> to this change.</p>",
+                admin.fullName()));
+    assertThat(message.htmlBody())
+        .contains(
+            String.format(
+                "View Change</a></p>"
+                    + "<p>The following approvals got outdated and were removed:\n"
+                    + "Code-Review+1 by %s</p>",
+                robot.fullName()));
+  }
+
+  @Test
+  public void approverOfCopiedApprovelNotAddedToAttentionSet() throws Exception {
+    // Allow user to make veto votes.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(LabelId.CODE_REVIEW)
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-2, 1))
+        .update();
+
+    PushOneCommit.Result r = createChange();
+    assertThat(r.getChange().attentionSet()).isEmpty();
+
+    // Add a veto vote that will be copied over to a new patch set.
+    requestScopeOperations.setApiUser(user.id());
+    gApi.changes()
+        .id(r.getChangeId())
+        .current()
+        .review(new ReviewInput().label(LabelId.CODE_REVIEW, -2));
+    requestScopeOperations.setApiUser(admin.id());
+
+    // Voting added the admin user to the attention set.
+    assertThat(changeDataFactory.create(project, r.getChange().getId()).attentionSet())
+        .containsExactly(
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(),
+                admin.id(),
+                AttentionSetUpdate.Operation.ADD,
+                "Someone else replied on the change"));
+
+    // Remove admin user from attention set.
+    change(r).attention(admin.id().toString()).remove(new AttentionSetInput("removed"));
+    assertThat(changeDataFactory.create(project, r.getChange().getId()).attentionSet())
+        .containsExactly(
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(), admin.id(), AttentionSetUpdate.Operation.REMOVE, "removed"));
+
+    // Amend the change, this copies the vote from user to the new patch set.
+    sender.clear();
+    r = amendChange(r.getChangeId(), "refs/for/master", admin, testRepo);
+    r.assertOkStatus();
+
+    // Verify that the approval has been copied.
+    List<PatchSetApproval> approvalsPs2 = r.getChange().currentApprovals();
+    assertThat(approvalsPs2).hasSize(1);
+    assertThat(Iterables.getOnlyElement(approvalsPs2).copied()).isTrue();
+
+    // Attention set wasn't changed.
+    assertThat(r.getChange().attentionSet())
+        .containsExactly(
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(), admin.id(), AttentionSetUpdate.Operation.REMOVE, "removed"));
+
+    // Verify the email for the new patch set.
+    Message message = Iterables.getOnlyElement(sender.getMessages());
+    assertThat(message.body())
+        .doesNotContain(String.format("Attention is currently required from: %s", user.fullName()));
+    assertThat(message.body())
+        .contains(
+            String.format(
+                "Hello %s, \n\nI'd like you to reexamine a change. Please visit", user.fullName()));
+    assertThat(message.body())
+        .doesNotContain("The following approvals got outdated and were removed:");
+    assertThat(message.htmlBody())
+        .doesNotContain(String.format("Attention is currently required from: %s", user.fullName()));
+    assertThat(message.htmlBody())
+        .contains(
+            String.format(
+                "<p>%s <strong>uploaded patch set #2</strong> to this change.</p>",
+                admin.fullName()));
+    assertThat(message.htmlBody())
+        .doesNotContain("The following approvals got outdated and were removed:");
+  }
+
+  @Test
+  public void ownerAndUploaderAreAddedToAttentionSetWhenChangeBecomesUnsubmittable_approvalRemoved()
+      throws Exception {
+    // Allow all users to approve.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(TestLabels.codeReview().getName())
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-1, 2))
+        .update();
+
+    // Create change with admin as the owner and upload a new patch set with user as uploader.
+    PushOneCommit.Result r = createChange();
+    r = amendChangeWithUploader(r, project, user);
+    r.assertOkStatus();
+
+    // Attention set is empty.
+    assertThat(r.getChange().attentionSet()).isEmpty();
+
+    // Approve the change.
+    TestAccount approver = accountCreator.user2();
+    requestScopeOperations.setApiUser(approver.id());
+    approve(r.getChangeId());
+    requestScopeOperations.setApiUser(admin.id());
+
+    // Voting added the owner (admin) and the uploader (user) to the attention set.
+    assertThat(changeDataFactory.create(project, r.getChange().getId()).attentionSet())
+        .containsExactly(
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(),
+                admin.id(),
+                AttentionSetUpdate.Operation.ADD,
+                "Someone else replied on the change"),
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(),
+                user.id(),
+                AttentionSetUpdate.Operation.ADD,
+                "Someone else replied on the change"));
+
+    // Remove the owner (admin) and the uploader (user) from the attention set.
+    change(r).attention(admin.id().toString()).remove(new AttentionSetInput("removed"));
+    change(r).attention(user.id().toString()).remove(new AttentionSetInput("removed"));
+    assertThat(changeDataFactory.create(project, r.getChange().getId()).attentionSet())
+        .containsExactly(
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(), admin.id(), AttentionSetUpdate.Operation.REMOVE, "removed"),
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(), user.id(), AttentionSetUpdate.Operation.REMOVE, "removed"));
+
+    // Revoke the approval
+    requestScopeOperations.setApiUser(approver.id());
+    sender.clear();
+    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.noScore());
+
+    // Removing the approval added the owner (admin) and the uploader (user) to the attention set.
+    assertThat(changeDataFactory.create(project, r.getChange().getId()).attentionSet())
+        .containsExactly(
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(),
+                admin.id(),
+                AttentionSetUpdate.Operation.ADD,
+                "Someone else replied on the change"),
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(),
+                user.id(),
+                AttentionSetUpdate.Operation.ADD,
+                "Someone else replied on the change"));
+
+    // Verify the email notification that has been sent for removing the approval.
+    Message message = Iterables.getOnlyElement(sender.getMessages());
+    assertThat(message.body())
+        .contains(
+            String.format(
+                "Attention is currently required from: %s, %s.\n"
+                    + "\n"
+                    + "%s has posted comments on this change.",
+                admin.fullName(), user.fullName(), approver.fullName()));
+    assertThat(message.body()).doesNotContain("\nPatch Set 2: Code-Review+2\n");
+    assertThat(message.body())
+        .contains("The change is no longer submittable: Code-Review is unsatisfied now.\n");
+  }
+
+  @Test
+  public void
+      ownerAndUploaderAreAddedToAttentionSetWhenChangeBecomesUnsubmittable_approvalDowngraded()
+          throws Exception {
+    // Allow all users to approve.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(TestLabels.codeReview().getName())
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-1, 2))
+        .update();
+
+    // Create change with admin as the owner and upload a new patch set with user as uploader.
+    PushOneCommit.Result r = createChange();
+    r = amendChangeWithUploader(r, project, user);
+    r.assertOkStatus();
+
+    // Attention set is empty.
+    assertThat(r.getChange().attentionSet()).isEmpty();
+
+    // Approve the change.
+    TestAccount approver = accountCreator.user2();
+    requestScopeOperations.setApiUser(approver.id());
+    approve(r.getChangeId());
+    requestScopeOperations.setApiUser(admin.id());
+
+    // Voting added the owner (admin) and the uploader (user) to the attention set.
+    assertThat(changeDataFactory.create(project, r.getChange().getId()).attentionSet())
+        .containsExactly(
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(),
+                admin.id(),
+                AttentionSetUpdate.Operation.ADD,
+                "Someone else replied on the change"),
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(),
+                user.id(),
+                AttentionSetUpdate.Operation.ADD,
+                "Someone else replied on the change"));
+
+    // Remove the owner (admin) and the uploader (user) from the attention set.
+    change(r).attention(admin.id().toString()).remove(new AttentionSetInput("removed"));
+    change(r).attention(user.id().toString()).remove(new AttentionSetInput("removed"));
+    assertThat(changeDataFactory.create(project, r.getChange().getId()).attentionSet())
+        .containsExactly(
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(), admin.id(), AttentionSetUpdate.Operation.REMOVE, "removed"),
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(), user.id(), AttentionSetUpdate.Operation.REMOVE, "removed"));
+
+    // Downgrade the approval
+    requestScopeOperations.setApiUser(approver.id());
+    sender.clear();
+    recommend(r.getChangeId());
+
+    // Changing the approval added the owner (admin) and the uploader (user) to the attention set.
+    assertThat(changeDataFactory.create(project, r.getChange().getId()).attentionSet())
+        .containsExactly(
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(),
+                admin.id(),
+                AttentionSetUpdate.Operation.ADD,
+                "Someone else replied on the change"),
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(),
+                user.id(),
+                AttentionSetUpdate.Operation.ADD,
+                "Someone else replied on the change"));
+
+    // Verify the email notification that has been sent for downgrading the approval.
+    Message message = Iterables.getOnlyElement(sender.getMessages());
+    assertThat(message.body())
+        .contains(
+            String.format(
+                "Attention is currently required from: %s, %s.\n"
+                    + "\n"
+                    + "%s has posted comments on this change.",
+                admin.fullName(), user.fullName(), approver.fullName()));
+    assertThat(message.body()).doesNotContain("\nPatch Set 2: Code-Review+2\n");
+    assertThat(message.body()).contains("\nPatch Set 2: Code-Review+1\n");
+    assertThat(message.body())
+        .contains("The change is no longer submittable: Code-Review is unsatisfied now.\n");
+  }
+
+  @Test
+  public void ownerAndUploaderAreAddedToAttentionSetWhenChangeBecomesUnsubmittable_vetoApplied()
+      throws Exception {
+    // Allow all users to approve and veto.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(TestLabels.codeReview().getName())
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-2, 2))
+        .update();
+
+    // Create change with admin as the owner and upload a new patch set with user as uploader.
+    PushOneCommit.Result r = createChange();
+    r = amendChangeWithUploader(r, project, user);
+    r.assertOkStatus();
+
+    // Attention set is empty.
+    assertThat(r.getChange().attentionSet()).isEmpty();
+
+    // Approve the change.
+    TestAccount approver = accountCreator.user2();
+    requestScopeOperations.setApiUser(approver.id());
+    approve(r.getChangeId());
+    requestScopeOperations.setApiUser(admin.id());
+
+    // Voting added the owner (admin) and the uploader (user) to the attention set.
+    assertThat(changeDataFactory.create(project, r.getChange().getId()).attentionSet())
+        .containsExactly(
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(),
+                admin.id(),
+                AttentionSetUpdate.Operation.ADD,
+                "Someone else replied on the change"),
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(),
+                user.id(),
+                AttentionSetUpdate.Operation.ADD,
+                "Someone else replied on the change"));
+
+    // Remove the owner (admin) and the uploader (user) from the attention set.
+    change(r).attention(admin.id().toString()).remove(new AttentionSetInput("removed"));
+    change(r).attention(user.id().toString()).remove(new AttentionSetInput("removed"));
+    assertThat(changeDataFactory.create(project, r.getChange().getId()).attentionSet())
+        .containsExactly(
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(), admin.id(), AttentionSetUpdate.Operation.REMOVE, "removed"),
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(), user.id(), AttentionSetUpdate.Operation.REMOVE, "removed"));
+
+    // Apply veto by another user.
+    TestAccount approver2 = accountCreator.user2();
+    sender.clear();
+    requestScopeOperations.setApiUser(approver2.id());
+    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.reject());
+
+    // Adding the veto approval added the owner (admin) and the uploader (user) to the attention
+    // set.
+    assertThat(changeDataFactory.create(project, r.getChange().getId()).attentionSet())
+        .containsExactly(
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(),
+                admin.id(),
+                AttentionSetUpdate.Operation.ADD,
+                "Someone else replied on the change"),
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(),
+                user.id(),
+                AttentionSetUpdate.Operation.ADD,
+                "Someone else replied on the change"));
+
+    // Verify the email notification that has been sent for adding the veto.
+    Message message = Iterables.getOnlyElement(sender.getMessages());
+    assertThat(message.body())
+        .contains(
+            String.format(
+                "Attention is currently required from: %s, %s.\n"
+                    + "\n"
+                    + "%s has posted comments on this change.",
+                admin.fullName(), user.fullName(), approver.fullName()));
+    assertThat(message.body()).contains("\nPatch Set 2: Code-Review-2\n");
+    assertThat(message.body())
+        .contains("The change is no longer submittable: Code-Review is unsatisfied now.\n");
+  }
+
   private void setEmailStrategyForUser(EmailStrategy es) throws Exception {
     requestScopeOperations.setApiUser(user.id());
     GeneralPreferencesInfo prefs = gApi.accounts().self().getPreferences();
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ChangeNoLongerSubmittableIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ChangeNoLongerSubmittableIT.java
new file mode 100644
index 0000000..1094a42
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ChangeNoLongerSubmittableIT.java
@@ -0,0 +1,910 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest.change;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.server.project.testing.TestLabels.labelBuilder;
+import static com.google.gerrit.server.project.testing.TestLabels.value;
+
+import com.google.common.collect.Iterables;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.GitUtil;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.server.project.testing.TestLabels;
+import com.google.gerrit.testing.FakeEmailSender.Message;
+import com.google.inject.Inject;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
+import org.junit.Test;
+
+/**
+ * Integration test to verify that change-no-longer-submittable emails are sent when a change
+ * becomes not submittable, and that they are sent only in this case (and not when the change
+ * becomes submittable or stays submittable/unsubmittable).
+ */
+public class ChangeNoLongerSubmittableIT extends AbstractDaemonTest {
+  @Inject private ProjectOperations projectOperations;
+  @Inject private RequestScopeOperations requestScopeOperations;
+
+  @Test
+  public void postReview_changeBecomesUnsubmittable_approvalRemoved() throws Exception {
+    // Allow all users to approve.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(TestLabels.codeReview().getName())
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-1, 2))
+        .update();
+
+    // Create change with admin as the owner and upload a new patch set with user as uploader.
+    PushOneCommit.Result r = createChange();
+    r = amendChangeWithUploader(r, project, user);
+    r.assertOkStatus();
+
+    // Approve the change.
+    TestAccount approver = accountCreator.user2();
+    requestScopeOperations.setApiUser(approver.id());
+    approve(r.getChangeId());
+    requestScopeOperations.setApiUser(admin.id());
+
+    // Revoke the approval
+    requestScopeOperations.setApiUser(approver.id());
+    sender.clear();
+    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.noScore());
+
+    // Verify the email notifications that have been sent for removing the approval.
+    Message message = Iterables.getOnlyElement(sender.getMessages());
+    assertThat(message.body())
+        .contains(
+            String.format(
+                "Attention is currently required from: %s, %s.\n"
+                    + "\n"
+                    + "%s has posted comments on this change.",
+                admin.fullName(), user.fullName(), approver.fullName()));
+    assertThat(message.body())
+        .contains("The change is no longer submittable: Code-Review is unsatisfied now.\n");
+    assertThat(message.htmlBody())
+        .contains("<p>The change is no longer submittable: Code-Review is unsatisfied now.</p>");
+  }
+
+  @Test
+  public void postReview_changeBecomesUnsubmittable_approvalDowngraded() throws Exception {
+    // Allow all users to approve.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(TestLabels.codeReview().getName())
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-1, 2))
+        .update();
+
+    // Create change with admin as the owner and upload a new patch set with user as uploader.
+    PushOneCommit.Result r = createChange();
+    r = amendChangeWithUploader(r, project, user);
+    r.assertOkStatus();
+
+    // Approve the change.
+    TestAccount approver = accountCreator.user2();
+    requestScopeOperations.setApiUser(approver.id());
+    approve(r.getChangeId());
+    requestScopeOperations.setApiUser(admin.id());
+
+    // Downgrade the approval
+    requestScopeOperations.setApiUser(approver.id());
+    sender.clear();
+    recommend(r.getChangeId());
+
+    // Verify the email notification that has been sent for downgrading the approval.
+    Message message = Iterables.getOnlyElement(sender.getMessages());
+    assertThat(message.body())
+        .contains(
+            String.format(
+                "Attention is currently required from: %s, %s.\n"
+                    + "\n"
+                    + "%s has posted comments on this change.",
+                admin.fullName(), user.fullName(), approver.fullName()));
+    assertThat(message.body())
+        .contains("The change is no longer submittable: Code-Review is unsatisfied now.\n");
+    assertThat(message.htmlBody())
+        .contains("<p>The change is no longer submittable: Code-Review is unsatisfied now.</p>");
+  }
+
+  @Test
+  public void postReview_changeBecomesUnsubmittable_vetoApplied() throws Exception {
+    // Allow all users to approve and veto.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(TestLabels.codeReview().getName())
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-2, 2))
+        .update();
+
+    // Create change with admin as the owner and upload a new patch set with user as uploader.
+    PushOneCommit.Result r = createChange();
+    r = amendChangeWithUploader(r, project, user);
+    r.assertOkStatus();
+
+    // Approve the change.
+    TestAccount approver = accountCreator.user2();
+    requestScopeOperations.setApiUser(approver.id());
+    approve(r.getChangeId());
+    requestScopeOperations.setApiUser(admin.id());
+
+    // Apply veto by another user.
+    TestAccount approver2 = accountCreator.user2();
+    sender.clear();
+    requestScopeOperations.setApiUser(approver2.id());
+    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.reject());
+
+    // Verify the email notification that has been sent for adding the veto.
+    Message message = Iterables.getOnlyElement(sender.getMessages());
+    assertThat(message.body())
+        .contains(
+            String.format(
+                "Attention is currently required from: %s, %s.\n"
+                    + "\n"
+                    + "%s has posted comments on this change.",
+                admin.fullName(), user.fullName(), approver.fullName()));
+    assertThat(message.body())
+        .contains("The change is no longer submittable: Code-Review is unsatisfied now.\n");
+    assertThat(message.htmlBody())
+        .contains("<p>The change is no longer submittable: Code-Review is unsatisfied now.</p>");
+  }
+
+  @Test
+  public void postReview_changeBecomesUnsubmittable_multipleSubmitRequirementsNoLongerSatisfied()
+      throws Exception {
+    // Create a Verify, a Foo-Bar and a Bar-Baz label and allow voting on it.
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      LabelType.Builder verified =
+          labelBuilder(
+              LabelId.VERIFIED, value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
+      u.getConfig().upsertLabelType(verified.build());
+
+      LabelType.Builder fooBar =
+          labelBuilder("Foo-Bar", value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
+      u.getConfig().upsertLabelType(fooBar.build());
+
+      LabelType.Builder barBaz =
+          labelBuilder("Bar-Baz", value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
+      u.getConfig().upsertLabelType(barBaz.build());
+
+      u.save();
+    }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(LabelId.VERIFIED)
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-1, 1))
+        .add(
+            allowLabel("Foo-Bar")
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-1, 1))
+        .add(
+            allowLabel("Bar-Baz")
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-1, 1))
+        .update();
+
+    // Allow all users to approve.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(TestLabels.codeReview().getName())
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-1, 2))
+        .update();
+
+    // Create change with admin as the owner and upload a new patch set with user as uploader.
+    PushOneCommit.Result r = createChange();
+    r = amendChangeWithUploader(r, project, user);
+    r.assertOkStatus();
+
+    // Approve all labels.
+    TestAccount approver = accountCreator.user2();
+    requestScopeOperations.setApiUser(approver.id());
+    approve(r.getChangeId());
+    gApi.changes()
+        .id(r.getChangeId())
+        .current()
+        .review(new ReviewInput().label(LabelId.VERIFIED, 1));
+    gApi.changes().id(r.getChangeId()).current().review(new ReviewInput().label("Foo-Bar", 1));
+    gApi.changes().id(r.getChangeId()).current().review(new ReviewInput().label("Bar-Baz", 1));
+    requestScopeOperations.setApiUser(admin.id());
+
+    // Revoke several approval
+    requestScopeOperations.setApiUser(approver.id());
+    sender.clear();
+    gApi.changes()
+        .id(r.getChangeId())
+        .current()
+        .review(new ReviewInput().label("Code-Review", 0).label("Foo-Bar", 0).label("Verified", 0));
+
+    // Verify the email notification that have been sent for removing the approval.
+    Message message = Iterables.getOnlyElement(sender.getMessages());
+    assertThat(message.body())
+        .contains(
+            String.format(
+                "Attention is currently required from: %s, %s.\n"
+                    + "\n"
+                    + "%s has posted comments on this change.",
+                admin.fullName(), user.fullName(), approver.fullName()));
+    assertThat(message.body())
+        .contains(
+            "The change is no longer submittable:"
+                + " Code-Review, Foo-Bar and Verified are unsatisfied now.\n");
+    assertThat(message.htmlBody())
+        .contains(
+            "<p>The change is no longer submittable:"
+                + " Code-Review, Foo-Bar and Verified are unsatisfied now.</p>");
+  }
+
+  @Test
+  public void postReview_changeStaysSubmittable_approvalRemoved() throws Exception {
+    // Allow all users to approve.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(TestLabels.codeReview().getName())
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-1, 2))
+        .update();
+
+    // Create change with admin as the owner and upload a new patch set with user as uploader.
+    PushOneCommit.Result r = createChange();
+    r = amendChangeWithUploader(r, project, user);
+    r.assertOkStatus();
+
+    // Approve the change by 2 users.
+    TestAccount approver = accountCreator.user2();
+    requestScopeOperations.setApiUser(approver.id());
+    approve(r.getChangeId());
+    TestAccount approver2 =
+        accountCreator.create("user3", "user3@email.com", "User3", /* displayName= */ null);
+    requestScopeOperations.setApiUser(approver2.id());
+    approve(r.getChangeId());
+    requestScopeOperations.setApiUser(admin.id());
+
+    // Revoke one approval.
+    requestScopeOperations.setApiUser(approver.id());
+    sender.clear();
+    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.noScore());
+
+    // Verify the email notification.
+    Message message = Iterables.getOnlyElement(sender.getMessages());
+    assertThat(message.body())
+        .contains(
+            String.format(
+                "Attention is currently required from: %s, %s.\n"
+                    + "\n"
+                    + "%s has posted comments on this change.",
+                admin.fullName(), user.fullName(), approver.fullName()));
+    assertThat(message.body()).doesNotContain("The change is no longer submittable");
+    assertThat(message.htmlBody()).doesNotContain("The change is no longer submittable");
+  }
+
+  @Test
+  public void postReview_changeStaysSubmittable_approvalDowngraded() throws Exception {
+    // Allow all users to approve.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(TestLabels.codeReview().getName())
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-1, 2))
+        .update();
+
+    // Create change with admin as the owner and upload a new patch set with user as uploader.
+    PushOneCommit.Result r = createChange();
+    r = amendChangeWithUploader(r, project, user);
+    r.assertOkStatus();
+
+    // Approve the change by 2 users.
+    TestAccount approver = accountCreator.user2();
+    requestScopeOperations.setApiUser(approver.id());
+    approve(r.getChangeId());
+    TestAccount approver2 =
+        accountCreator.create("user3", "user3@email.com", "User3", /* displayName= */ null);
+    requestScopeOperations.setApiUser(approver2.id());
+    approve(r.getChangeId());
+    requestScopeOperations.setApiUser(admin.id());
+
+    // Downgrade one approval
+    requestScopeOperations.setApiUser(approver.id());
+    sender.clear();
+    recommend(r.getChangeId());
+
+    // Verify the email notification.
+    Message message = Iterables.getOnlyElement(sender.getMessages());
+    assertThat(message.body())
+        .contains(
+            String.format(
+                "Attention is currently required from: %s, %s.\n"
+                    + "\n"
+                    + "%s has posted comments on this change.",
+                admin.fullName(), user.fullName(), approver.fullName()));
+    assertThat(message.body()).doesNotContain("The change is no longer submittable");
+    assertThat(message.htmlBody()).doesNotContain("The change is no longer submittable");
+  }
+
+  @Test
+  public void postReview_changeStaysUnsubmittable_approvalAdded() throws Exception {
+    // Allow all users to approve.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(TestLabels.codeReview().getName())
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-1, 2))
+        .update();
+
+    // Create change with admin as the owner and upload a new patch set with user as uploader.
+    PushOneCommit.Result r = createChange();
+    r = amendChangeWithUploader(r, project, user);
+    r.assertOkStatus();
+
+    // Add a vote that doesn't make the change submittable.
+    TestAccount approver = accountCreator.user2();
+    requestScopeOperations.setApiUser(approver.id());
+    sender.clear();
+    recommend(r.getChangeId());
+
+    // Verify the email notification.
+    Message message = Iterables.getOnlyElement(sender.getMessages());
+    assertThat(message.body())
+        .contains(
+            String.format(
+                "Attention is currently required from: %s, %s.\n"
+                    + "\n"
+                    + "%s has posted comments on this change.",
+                admin.fullName(), user.fullName(), approver.fullName()));
+    assertThat(message.body()).doesNotContain("The change is no longer submittable");
+    assertThat(message.htmlBody()).doesNotContain("The change is no longer submittable");
+  }
+
+  @Test
+  public void postReview_changeBecomesSubmittable_approvalAdded() throws Exception {
+    // Allow all users to approve.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(TestLabels.codeReview().getName())
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-1, 2))
+        .update();
+
+    // Create change with admin as the owner and upload a new patch set with user as uploader.
+    PushOneCommit.Result r = createChange();
+    r = amendChangeWithUploader(r, project, user);
+    r.assertOkStatus();
+
+    // Add a vote that makes the change submittable.
+    TestAccount approver = accountCreator.user2();
+    requestScopeOperations.setApiUser(approver.id());
+    sender.clear();
+    approve(r.getChangeId());
+
+    // Verify the email notification.
+    Message message = Iterables.getOnlyElement(sender.getMessages());
+    assertThat(message.body())
+        .contains(
+            String.format(
+                "Attention is currently required from: %s, %s.\n"
+                    + "\n"
+                    + "%s has posted comments on this change.",
+                admin.fullName(), user.fullName(), approver.fullName()));
+    assertThat(message.body()).doesNotContain("The change is no longer submittable");
+    assertThat(message.htmlBody()).doesNotContain("The change is no longer submittable");
+  }
+
+  @Test
+  public void pushNewPatchSet_changeBecomesUnsubmittable_approvalNotCopied() throws Exception {
+    // Allow all users to approve.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(TestLabels.codeReview().getName())
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-1, 2))
+        .update();
+
+    // Create change with admin as the owner and upload a new patch set with user as uploader.
+    PushOneCommit.Result r = createChange();
+    r = amendChangeWithUploader(r, project, user);
+    r.assertOkStatus();
+
+    // Approve the change.
+    TestAccount approver = accountCreator.user2();
+    requestScopeOperations.setApiUser(approver.id());
+    approve(r.getChangeId());
+    requestScopeOperations.setApiUser(admin.id());
+
+    // Upload a new patch set that removes the approval.
+    TestAccount uploaderPs3 =
+        accountCreator.create("user3", "user3@email.com", "User3", /* displayName= */ null);
+    sender.clear();
+    r = amendChangeWithUploader(r, project, uploaderPs3);
+    r.assertOkStatus();
+
+    r.assertMessage(
+        "The following approvals got outdated and were removed:\n* Code-Review+2 by user2\n");
+
+    // Verify the email notification that has been sent for uploading the new patch set.
+    Message message = Iterables.getOnlyElement(sender.getMessages());
+    assertThat(message.body())
+        .contains(
+            String.format(
+                "Attention is currently required from: %s, %s, %s.\n"
+                    + "\n"
+                    + "%s has uploaded a new patch set (#3) to the change originally created by %s.",
+                admin.fullName(),
+                user.fullName(),
+                approver.fullName(),
+                uploaderPs3.fullName(),
+                admin.fullName()));
+    assertThat(message.body())
+        .contains("The change is no longer submittable: Code-Review is unsatisfied now.\n");
+    assertThat(message.htmlBody())
+        .contains("<p>The change is no longer submittable: Code-Review is unsatisfied now.</p>");
+  }
+
+  @Test
+  public void pushNewPatchSet_changeBecomesUnsubmittable_approvalCopiedAndRevoked()
+      throws Exception {
+    // Make Code-Review approvals sticky.
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      LabelType.Builder codeReview =
+          labelBuilder(
+              LabelId.CODE_REVIEW,
+              value(2, "Looks good to me, approved"),
+              value(1, "Looks good to me, but someone else must approve"),
+              value(0, "No score"),
+              value(-1, "I would prefer this is not submitted as is"),
+              value(-2, "This shall not be submitted"));
+      codeReview.setCopyCondition("is:MAX");
+      u.getConfig().upsertLabelType(codeReview.build());
+      u.save();
+    }
+
+    // Allow all users to veto and approve.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(TestLabels.codeReview().getName())
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-2, 2))
+        .update();
+
+    // Create change with admin as the owner and upload a new patch set with user as uploader.
+    PushOneCommit.Result r = createChange();
+    r = amendChangeWithUploader(r, project, user);
+    r.assertOkStatus();
+
+    // Approve the change.
+    TestAccount approver = accountCreator.user2();
+    requestScopeOperations.setApiUser(approver.id());
+    approve(r.getChangeId());
+    requestScopeOperations.setApiUser(admin.id());
+
+    // Upload a new patch set that copies the approval, but revoke it on push.
+    sender.clear();
+    TestRepository<InMemoryRepository> repo = cloneProject(project, approver);
+    GitUtil.fetch(repo, "refs/*:refs/*");
+    repo.reset(r.getCommit());
+    r =
+        amendChange(
+            r.getChangeId(),
+            "refs/for/master%l=-Code-Review",
+            approver,
+            repo,
+            "new subject",
+            "new file",
+            "new content");
+    r.assertOkStatus();
+
+    // Verify the email notification that has been sent for uploading the new patch set.
+    Message message = Iterables.getOnlyElement(sender.getMessages());
+    assertThat(message.body())
+        .contains(
+            String.format(
+                "Attention is currently required from: %s, %s.\n"
+                    + "\n"
+                    + "%s has uploaded a new patch set (#3) to the change originally created by %s.",
+                admin.fullName(), user.fullName(), approver.fullName(), admin.fullName()));
+    assertThat(message.body())
+        .contains("The change is no longer submittable: Code-Review is unsatisfied now.\n");
+    assertThat(message.htmlBody())
+        .contains("<p>The change is no longer submittable: Code-Review is unsatisfied now.</p>");
+  }
+
+  @Test
+  public void pushNewPatchSet_changeBecomesUnsubmittable_approvalCopiedAndDowngraded()
+      throws Exception {
+    // Make Code-Review approvals sticky.
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      LabelType.Builder codeReview =
+          labelBuilder(
+              LabelId.CODE_REVIEW,
+              value(2, "Looks good to me, approved"),
+              value(1, "Looks good to me, but someone else must approve"),
+              value(0, "No score"),
+              value(-1, "I would prefer this is not submitted as is"),
+              value(-2, "This shall not be submitted"));
+      codeReview.setCopyCondition("is:MAX");
+      u.getConfig().upsertLabelType(codeReview.build());
+      u.save();
+    }
+
+    // Allow all users to veto and approve.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(TestLabels.codeReview().getName())
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-2, 2))
+        .update();
+
+    // Create change with admin as the owner and upload a new patch set with user as uploader.
+    PushOneCommit.Result r = createChange();
+    r = amendChangeWithUploader(r, project, user);
+    r.assertOkStatus();
+
+    // Approve the change.
+    TestAccount approver = accountCreator.user2();
+    requestScopeOperations.setApiUser(approver.id());
+    approve(r.getChangeId());
+    requestScopeOperations.setApiUser(admin.id());
+
+    // Upload a new patch set that copies the approval, but downgrade it on push.
+    sender.clear();
+    TestRepository<InMemoryRepository> repo = cloneProject(project, approver);
+    GitUtil.fetch(repo, "refs/*:refs/*");
+    repo.reset(r.getCommit());
+    r =
+        amendChange(
+            r.getChangeId(),
+            "refs/for/master%l=Code-Review+1",
+            approver,
+            repo,
+            "new subject",
+            "new file",
+            "new content");
+    r.assertOkStatus();
+
+    // Verify the email notification that has been sent for uploading the new patch set.
+    Message message = Iterables.getOnlyElement(sender.getMessages());
+    assertThat(message.body())
+        .contains(
+            String.format(
+                "Attention is currently required from: %s, %s.\n"
+                    + "\n"
+                    + "%s has uploaded a new patch set (#3) to the change originally created by %s.",
+                admin.fullName(), user.fullName(), approver.fullName(), admin.fullName()));
+    assertThat(message.body())
+        .contains("The change is no longer submittable: Code-Review is unsatisfied now.\n");
+    assertThat(message.htmlBody())
+        .contains("<p>The change is no longer submittable: Code-Review is unsatisfied now.</p>");
+  }
+
+  @Test
+  public void pushNewPatchSet_changeBecomesUnsubmittable_approvalCopiedVetoApplied()
+      throws Exception {
+    // Make Code-Review approvals sticky.
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      LabelType.Builder codeReview =
+          labelBuilder(
+              LabelId.CODE_REVIEW,
+              value(2, "Looks good to me, approved"),
+              value(1, "Looks good to me, but someone else must approve"),
+              value(0, "No score"),
+              value(-1, "I would prefer this is not submitted as is"),
+              value(-2, "This shall not be submitted"));
+      codeReview.setCopyCondition("is:MAX");
+      u.getConfig().upsertLabelType(codeReview.build());
+      u.save();
+    }
+
+    // Allow all users to veto and approve.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(TestLabels.codeReview().getName())
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-2, 2))
+        .update();
+
+    // Create change with admin as the owner and upload a new patch set with user as uploader.
+    PushOneCommit.Result r = createChange();
+    r = amendChangeWithUploader(r, project, user);
+    r.assertOkStatus();
+
+    // Approve the change.
+    TestAccount approver = accountCreator.user2();
+    requestScopeOperations.setApiUser(approver.id());
+    approve(r.getChangeId());
+    requestScopeOperations.setApiUser(admin.id());
+
+    // Upload a new patch set that copies the approval, but apply a new veto on push.
+    TestAccount uploaderPs3 =
+        accountCreator.create("user3", "user3@email.com", "User3", /* displayName= */ null);
+    sender.clear();
+    TestRepository<InMemoryRepository> repo = cloneProject(project, uploaderPs3);
+    GitUtil.fetch(repo, "refs/*:refs/*");
+    repo.reset(r.getCommit());
+    r =
+        amendChange(
+            r.getChangeId(),
+            "refs/for/master%l=Code-Review-2",
+            uploaderPs3,
+            repo,
+            "new subject",
+            "new file",
+            "new content");
+    r.assertOkStatus();
+
+    // Verify the email notification that has been sent for uploading the new patch set.
+    Message message = Iterables.getOnlyElement(sender.getMessages());
+    assertThat(message.body())
+        .contains(
+            String.format(
+                "Attention is currently required from: %s, %s, %s.\n"
+                    + "\n"
+                    + "%s has uploaded a new patch set (#3) to the change originally created by %s.",
+                admin.fullName(),
+                user.fullName(),
+                uploaderPs3.fullName(),
+                uploaderPs3.fullName(),
+                admin.fullName()));
+    assertThat(message.body())
+        .contains("The change is no longer submittable: Code-Review is unsatisfied now.\n");
+    assertThat(message.htmlBody())
+        .contains("<p>The change is no longer submittable: Code-Review is unsatisfied now.</p>");
+  }
+
+  @Test
+  public void pushNewPatchSet_changeStaysSubmittable_approvalCopied() throws Exception {
+    // Make Code-Review approvals sticky.
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      LabelType.Builder codeReview =
+          labelBuilder(
+              LabelId.CODE_REVIEW,
+              value(2, "Looks good to me, approved"),
+              value(1, "Looks good to me, but someone else must approve"),
+              value(0, "No score"),
+              value(-1, "I would prefer this is not submitted as is"),
+              value(-2, "This shall not be submitted"));
+      codeReview.setCopyCondition("is:MAX");
+      u.getConfig().upsertLabelType(codeReview.build());
+      u.save();
+    }
+
+    // Allow all users to approve.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(TestLabels.codeReview().getName())
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-1, 2))
+        .update();
+
+    // Create change with admin as the owner and upload a new patch set with user as uploader.
+    PushOneCommit.Result r = createChange();
+    r = amendChangeWithUploader(r, project, user);
+    r.assertOkStatus();
+
+    // Approve the change.
+    TestAccount approver = accountCreator.user2();
+    requestScopeOperations.setApiUser(approver.id());
+    approve(r.getChangeId());
+    requestScopeOperations.setApiUser(admin.id());
+
+    // Upload a new patch set, the approval is copied.
+    TestAccount uploaderPs3 =
+        accountCreator.create("user3", "user3@email.com", "User3", /* displayName= */ null);
+    sender.clear();
+    r = amendChangeWithUploader(r, project, uploaderPs3);
+    r.assertOkStatus();
+
+    // Verify the email notification that has been sent for uploading the new patch set.
+    Message message = Iterables.getOnlyElement(sender.getMessages());
+    assertThat(message.body())
+        .contains(
+            String.format(
+                "Attention is currently required from: %s, %s.\n"
+                    + "\n"
+                    + "%s has uploaded a new patch set (#3) to the change originally created by %s.",
+                admin.fullName(), user.fullName(), uploaderPs3.fullName(), admin.fullName()));
+    assertThat(message.body()).doesNotContain("The change is no longer submittable");
+    assertThat(message.htmlBody()).doesNotContain("The change is no longer submittable");
+  }
+
+  @Test
+  public void pushNewPatchSet_changeStaysSubmittable_approvalReapplied() throws Exception {
+    // Allow all users to approve.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(TestLabels.codeReview().getName())
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-1, 2))
+        .update();
+
+    // Create change with admin as the owner and upload a new patch set with user as uploader.
+    PushOneCommit.Result r = createChange();
+    r = amendChangeWithUploader(r, project, user);
+    r.assertOkStatus();
+
+    // Approve the change.
+    TestAccount approver = accountCreator.user2();
+    requestScopeOperations.setApiUser(approver.id());
+    approve(r.getChangeId());
+    requestScopeOperations.setApiUser(admin.id());
+
+    // Upload a new patch set that removes the approval, but re-apply a new approval on push.
+    TestAccount uploaderPs3 =
+        accountCreator.create("user3", "user3@email.com", "User3", /* displayName= */ null);
+    sender.clear();
+    TestRepository<InMemoryRepository> repo = cloneProject(project, uploaderPs3);
+    GitUtil.fetch(repo, "refs/*:refs/*");
+    repo.reset(r.getCommit());
+    r =
+        amendChange(
+            r.getChangeId(),
+            "refs/for/master%l=Code-Review+2",
+            uploaderPs3,
+            repo,
+            "new subject",
+            "new file",
+            "new content");
+    r.assertOkStatus();
+
+    // Verify the email notification that has been sent for uploading the new patch set.
+    Message message = Iterables.getOnlyElement(sender.getMessages());
+    // uploaderPs3 gets added to the attention set because this user is a new reviewer on the change
+    assertThat(message.body())
+        .contains(
+            String.format(
+                "Attention is currently required from: %s, %s, %s, %s.\n"
+                    + "\n"
+                    + "%s has uploaded a new patch set (#3) to the change originally created by %s.",
+                admin.fullName(),
+                user.fullName(),
+                approver.fullName(),
+                uploaderPs3.fullName(),
+                uploaderPs3.fullName(),
+                admin.fullName()));
+    assertThat(message.body()).doesNotContain("The change is no longer submittable");
+    assertThat(message.htmlBody()).doesNotContain("The change is no longer submittable");
+  }
+
+  @Test
+  public void pushNewPatchSet_changeStaysUnsubmittable() throws Exception {
+    // Create change with admin as the owner and upload a new patch set with user as uploader.
+    PushOneCommit.Result r = createChange();
+    r = amendChangeWithUploader(r, project, user);
+    r.assertOkStatus();
+
+    // Upload a new patch set.
+    TestAccount uploaderPs3 =
+        accountCreator.create("user3", "user3@email.com", "User3", /* displayName= */ null);
+    sender.clear();
+    r = amendChangeWithUploader(r, project, uploaderPs3);
+    r.assertOkStatus();
+
+    // Verify the email notification that has been sent for uploading the new patch set.
+    Message message = Iterables.getOnlyElement(sender.getMessages());
+    assertThat(message.body())
+        .contains(
+            String.format(
+                "%s has uploaded a new patch set (#3) to the change originally created by %s.",
+                uploaderPs3.fullName(), admin.fullName()));
+    assertThat(message.body()).doesNotContain("The change is no longer submittable");
+    assertThat(message.htmlBody()).doesNotContain("The change is no longer submittable");
+  }
+
+  @Test
+  public void pushNewPatchSet_changeBecomesSubmittable() throws Exception {
+    // Allow all users to approve.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(TestLabels.codeReview().getName())
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-1, 2))
+        .update();
+
+    // Create change with admin as the owner and upload a new patch set with user as uploader.
+    PushOneCommit.Result r = createChange();
+    r = amendChangeWithUploader(r, project, user);
+    r.assertOkStatus();
+
+    // Upload a new patch set and approve it.
+    TestAccount uploaderPs3 =
+        accountCreator.create("user3", "user3@email.com", "User3", /* displayName= */ null);
+    sender.clear();
+    TestRepository<InMemoryRepository> repo = cloneProject(project, uploaderPs3);
+    GitUtil.fetch(repo, "refs/*:refs/*");
+    repo.reset(r.getCommit());
+    r =
+        amendChange(
+            r.getChangeId(),
+            "refs/for/master%l=Code-Review+2",
+            uploaderPs3,
+            repo,
+            "new subject",
+            "new file",
+            "new content");
+    r.assertOkStatus();
+
+    // Verify the email notification that has been sent for uploading the new patch set.
+    Message message = Iterables.getOnlyElement(sender.getMessages());
+    // uploaderPs3 gets added to the attention set because this user is a new reviewer on the change
+    assertThat(message.body())
+        .contains(
+            String.format(
+                "Attention is currently required from: %s.\n"
+                    + "\n"
+                    + "%s has uploaded a new patch set (#3) to the change originally created by %s.",
+                uploaderPs3.fullName(), uploaderPs3.fullName(), admin.fullName()));
+    assertThat(message.body()).doesNotContain("The change is no longer submittable");
+    assertThat(message.htmlBody()).doesNotContain("The change is no longer submittable");
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java
index b3592e3..c712b14 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java
@@ -414,8 +414,7 @@
   public void stickyVoteStoredOnSubmitOnNewPatchset_withoutCopyCondition() throws Exception {
     try (ProjectConfigUpdate u = updateProject(NameKey.parse("All-Projects"))) {
       u.getConfig()
-          .updateLabelType(
-              LabelId.CODE_REVIEW, b -> b.setCopyAllScoresIfListOfFilesDidNotChange(true));
+          .updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyCondition("has:unchanged-files"));
       u.save();
     }
     stickyVoteStoredOnSubmitOnNewPatchset();
@@ -446,7 +445,7 @@
     // during submit.
     PatchSetApproval patchSetApprovals =
         Iterables.getLast(
-            r.getChange().notes().getApprovalsWithCopied().values().stream()
+            r.getChange().notes().getApprovals().all().values().stream()
                 .filter(a -> a.labelId().equals(LabelId.create(LabelId.CODE_REVIEW)))
                 .sorted(comparing(a -> a.patchSetId().get()))
                 .collect(toImmutableList()));
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByRebaseAlwaysIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByRebaseAlwaysIT.java
index 2eade27..d58ad11 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByRebaseAlwaysIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByRebaseAlwaysIT.java
@@ -170,8 +170,7 @@
   public void stickyVoteStoredOnSubmitOnNewPatchset_withoutCopyCondition() throws Exception {
     try (ProjectConfigUpdate u = updateProject(NameKey.parse("All-Projects"))) {
       u.getConfig()
-          .updateLabelType(
-              LabelId.CODE_REVIEW, b -> b.setCopyAllScoresIfListOfFilesDidNotChange(true));
+          .updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyCondition("has:unchanged-files"));
       u.save();
     }
     stickyVoteStoredOnSubmitOnNewPatchset();
@@ -202,7 +201,7 @@
     // submit.
     PatchSetApproval patchSetApprovals =
         Iterables.getLast(
-            r.getChange().notes().getApprovalsWithCopied().values().stream()
+            r.getChange().notes().getApprovals().all().values().stream()
                 .filter(a -> a.labelId().equals(LabelId.create(LabelId.CODE_REVIEW)))
                 .sorted(comparing(a -> a.patchSetId().get()))
                 .collect(toImmutableList()));
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java
index 3850e13..80bedcd 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java
@@ -21,6 +21,7 @@
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
 import static com.google.gerrit.entities.Permission.READ;
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.util.stream.Collectors.toList;
 
@@ -81,23 +82,58 @@
   }
 
   @Test
-  @GerritConfig(name = "accounts.visibility", value = "NONE")
-  public void suggestReviewersNoResult1() throws Exception {
+  @GerritConfig(name = "suggest.accounts", value = "false")
+  public void suggestReviewers_withSuggestDisabled() throws Exception {
     String changeId = createChange().getChangeId();
+
     List<SuggestedReviewerInfo> reviewers = suggestReviewers(changeId, name("u"), 6);
+
+    assertThat(reviewers).isEmpty();
+  }
+
+  @Test
+  @GerritConfig(name = "accounts.visibility", value = "NONE")
+  public void suggestReviewers_accountVisibilityNone_noAccountsSuggested() throws Exception {
+    // Change is created by admin
+    String changeId = createChange().getChangeId();
+
+    requestScopeOperations.setApiUser(user2.id());
+    List<SuggestedReviewerInfo> reviewers = suggestReviewers(changeId, name("u"), 6);
+
     assertThat(reviewers).isEmpty();
   }
 
   @Test
   @GerritConfig(name = "suggest.from", value = "1")
   @GerritConfig(name = "accounts.visibility", value = "NONE")
-  public void suggestReviewersNoResult2() throws Exception {
+  public void suggestReviewers_accountVisibilityNone_withSuggestFrom_noAccountsSuggested()
+      throws Exception {
+    // Change is created by admin
     String changeId = createChange().getChangeId();
+
+    requestScopeOperations.setApiUser(user2.id());
     List<SuggestedReviewerInfo> reviewers = suggestReviewers(changeId, name("u"), 6);
+
     assertThat(reviewers).isEmpty();
   }
 
   @Test
+  @GerritConfig(name = "accounts.visibility", value = "NONE")
+  public void suggestReviewers_accountVisibilityNone_withGlobalCapability_allAccountsSuggested()
+      throws Exception {
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.VIEW_ALL_ACCOUNTS).group(REGISTERED_USERS))
+        .update();
+    String changeId = createChange().getChangeId();
+
+    requestScopeOperations.setApiUser(user2.id());
+    List<SuggestedReviewerInfo> reviewers = suggestReviewers(changeId, name("u"), 6);
+
+    assertReviewers(reviewers, ImmutableList.of(user1, user2, user3), ImmutableList.of(group2));
+  }
+
+  @Test
   public void suggestReviewersChange() throws Exception {
     String changeId = createChange().getChangeId();
     testSuggestReviewersChange(changeId);
@@ -137,7 +173,7 @@
   }
 
   @Test
-  @GerritConfig(name = "index.maxTerms", value = "10")
+  @GerritConfig(name = "index.maxTerms", value = "20")
   public void suggestReviewersTooManyQueryTerms() throws Exception {
     String changeId = createChange().getChangeId();
 
@@ -147,14 +183,19 @@
     for (int i = 1; i <= 9; i++) {
       query.append(name("u")).append(" ");
     }
+    // The query expands to (2 * predicates + 1) terms = 2 * 9 + 1 = 19:
+    // (2 * predicates) since the default predicate expands to two "name" OR "username" predicates.
+    // + 1 since the query processor appends a predicate to search for active accounts only.
     assertThat(suggestReviewers(changeId, query.toString())).isNotEmpty();
 
-    // Do a query which exceed index.maxTerms succeeds (10 terms plus 'inactive:1' term which is
+    // Do a query which exceed index.maxTerms succeeds (10 * 2 terms plus 'inactive:1' term which is
     // implicitly added).
     query.append(name("u"));
     BadRequestException exception =
         assertThrows(BadRequestException.class, () -> suggestReviewers(changeId, query.toString()));
-    assertThat(exception).hasMessageThat().isEqualTo("too many terms in query");
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo("too many terms in query: 21 terms (max = 20)");
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/CreateBranchIT.java b/javatests/com/google/gerrit/acceptance/rest/project/CreateBranchIT.java
index df899ce..d45c90b 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/CreateBranchIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/CreateBranchIT.java
@@ -50,6 +50,7 @@
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.server.events.RefReceivedEvent;
 import com.google.gerrit.server.git.validators.RefOperationValidationListener;
 import com.google.gerrit.server.git.validators.ValidationMessage;
@@ -334,8 +335,8 @@
     assertCreateFails(
         testBranch,
         "refs/heads/non-existing",
-        BadRequestException.class,
-        "invalid revision \"refs/heads/non-existing\"");
+        UnprocessableEntityException.class,
+        "base revision \"refs/heads/non-existing\" not found");
   }
 
   @Test
@@ -343,8 +344,8 @@
     assertCreateFails(
         testBranch,
         "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef",
-        BadRequestException.class,
-        "invalid revision \"deadbeefdeadbeefdeadbeefdeadbeefdeadbeef\"");
+        UnprocessableEntityException.class,
+        "base revision \"deadbeefdeadbeefdeadbeefdeadbeefdeadbeef\" not found");
   }
 
   @Test
@@ -352,8 +353,23 @@
     assertCreateFails(
         testBranch,
         "invalid\trevision",
+        UnprocessableEntityException.class,
+        "base revision \"invalid\trevision\" is invalid");
+  }
+
+  @Test
+  public void cannotCreateWithNonCommitAsRevision() throws Exception {
+    String treeId =
+        projectOperations
+            .project(testBranch.project())
+            .getHead("refs/heads/master")
+            .getTree()
+            .name();
+    assertCreateFails(
+        testBranch,
+        treeId,
         BadRequestException.class,
-        "invalid revision \"invalid\trevision\"");
+        "base revision \"" + treeId + "\" is not a commit");
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/CreateLabelIT.java b/javatests/com/google/gerrit/acceptance/rest/project/CreateLabelIT.java
index 755c2e1..462c76f 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/CreateLabelIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/CreateLabelIT.java
@@ -251,15 +251,6 @@
     assertThat(createdLabel.defaultValue).isEqualTo(0);
     assertThat(createdLabel.branches).isNull();
     assertThat(createdLabel.canOverride).isTrue();
-    assertThat(createdLabel.copyAnyScore).isNull();
-    assertThat(createdLabel.copyMinScore).isNull();
-    assertThat(createdLabel.copyMaxScore).isNull();
-    assertThat(createdLabel.copyAllScoresIfListOfFilesDidNotChange).isNull();
-    assertThat(createdLabel.copyAllScoresIfNoChange).isTrue();
-    assertThat(createdLabel.copyAllScoresIfNoCodeChange).isNull();
-    assertThat(createdLabel.copyAllScoresOnTrivialRebase).isNull();
-    assertThat(createdLabel.copyAllScoresOnMergeFirstParentUpdate).isNull();
-    assertThat(createdLabel.copyValues).isNull();
     assertThat(createdLabel.allowPostSubmit).isTrue();
     assertThat(createdLabel.ignoreSelfApproval).isNull();
   }
@@ -399,50 +390,6 @@
   }
 
   @Test
-  public void createWithCopyAnyScore() throws Exception {
-    LabelDefinitionInput input = new LabelDefinitionInput();
-    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
-    input.copyAnyScore = true;
-
-    LabelDefinitionInfo createdLabel =
-        gApi.projects().name(project.get()).label("foo").create(input).get();
-    assertThat(createdLabel.copyAnyScore).isTrue();
-  }
-
-  @Test
-  public void createWithoutCopyAnyScore() throws Exception {
-    LabelDefinitionInput input = new LabelDefinitionInput();
-    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
-    input.copyAnyScore = false;
-
-    LabelDefinitionInfo createdLabel =
-        gApi.projects().name(project.get()).label("foo").create(input).get();
-    assertThat(createdLabel.copyAnyScore).isNull();
-  }
-
-  @Test
-  public void createWithCopyMinScore() throws Exception {
-    LabelDefinitionInput input = new LabelDefinitionInput();
-    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
-    input.copyMinScore = true;
-
-    LabelDefinitionInfo createdLabel =
-        gApi.projects().name(project.get()).label("foo").create(input).get();
-    assertThat(createdLabel.copyMinScore).isTrue();
-  }
-
-  @Test
-  public void createWithoutCopyMinScore() throws Exception {
-    LabelDefinitionInput input = new LabelDefinitionInput();
-    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
-    input.copyMinScore = false;
-
-    LabelDefinitionInfo createdLabel =
-        gApi.projects().name(project.get()).label("foo").create(input).get();
-    assertThat(createdLabel.copyMinScore).isNull();
-  }
-
-  @Test
   public void createWithCopyCondition() throws Exception {
     LabelDefinitionInput input = new LabelDefinitionInput();
     input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
@@ -494,149 +441,6 @@
   }
 
   @Test
-  public void createWithCopyMaxScore() throws Exception {
-    LabelDefinitionInput input = new LabelDefinitionInput();
-    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
-    input.copyMaxScore = true;
-
-    LabelDefinitionInfo createdLabel =
-        gApi.projects().name(project.get()).label("foo").create(input).get();
-    assertThat(createdLabel.copyMaxScore).isTrue();
-  }
-
-  @Test
-  public void createWithoutCopyMaxScore() throws Exception {
-    LabelDefinitionInput input = new LabelDefinitionInput();
-    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
-    input.copyMaxScore = false;
-
-    LabelDefinitionInfo createdLabel =
-        gApi.projects().name(project.get()).label("foo").create(input).get();
-    assertThat(createdLabel.copyMaxScore).isNull();
-  }
-
-  @Test
-  public void createWithCopyAllScoresIfListOfFilesDidNotChange() throws Exception {
-    LabelDefinitionInput input = new LabelDefinitionInput();
-    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
-    input.copyAllScoresIfListOfFilesDidNotChange = true;
-
-    LabelDefinitionInfo createdLabel =
-        gApi.projects().name(project.get()).label("foo").create(input).get();
-    assertThat(createdLabel.copyAllScoresIfListOfFilesDidNotChange).isTrue();
-  }
-
-  @Test
-  public void createWithoutCopyAllScoresIfListOfFilesDidNotChange() throws Exception {
-    LabelDefinitionInput input = new LabelDefinitionInput();
-    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
-    input.copyAllScoresIfListOfFilesDidNotChange = false;
-
-    LabelDefinitionInfo createdLabel =
-        gApi.projects().name(project.get()).label("foo").create(input).get();
-    assertThat(createdLabel.copyAllScoresIfListOfFilesDidNotChange).isNull();
-  }
-
-  @Test
-  public void createWithCopyAllScoresIfNoChange() throws Exception {
-    LabelDefinitionInput input = new LabelDefinitionInput();
-    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
-    input.copyAllScoresIfNoChange = true;
-
-    LabelDefinitionInfo createdLabel =
-        gApi.projects().name(project.get()).label("foo").create(input).get();
-    assertThat(createdLabel.copyAllScoresIfNoChange).isTrue();
-  }
-
-  @Test
-  public void createWithoutCopyAllScoresIfNoChange() throws Exception {
-    LabelDefinitionInput input = new LabelDefinitionInput();
-    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
-    input.copyAllScoresIfNoChange = false;
-
-    LabelDefinitionInfo createdLabel =
-        gApi.projects().name(project.get()).label("foo").create(input).get();
-    assertThat(createdLabel.copyAllScoresIfNoChange).isNull();
-  }
-
-  @Test
-  public void createWithCopyAllScoresIfNoCodeChange() throws Exception {
-    LabelDefinitionInput input = new LabelDefinitionInput();
-    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
-    input.copyAllScoresIfNoCodeChange = true;
-
-    LabelDefinitionInfo createdLabel =
-        gApi.projects().name(project.get()).label("foo").create(input).get();
-    assertThat(createdLabel.copyAllScoresIfNoCodeChange).isTrue();
-  }
-
-  @Test
-  public void createWithoutCopyAllScoresIfNoCodeChange() throws Exception {
-    LabelDefinitionInput input = new LabelDefinitionInput();
-    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
-    input.copyAllScoresIfNoCodeChange = false;
-
-    LabelDefinitionInfo createdLabel =
-        gApi.projects().name(project.get()).label("foo").create(input).get();
-    assertThat(createdLabel.copyAllScoresIfNoCodeChange).isNull();
-  }
-
-  @Test
-  public void createWithCopyAllScoresOnTrivialRebase() throws Exception {
-    LabelDefinitionInput input = new LabelDefinitionInput();
-    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
-    input.copyAllScoresOnTrivialRebase = true;
-
-    LabelDefinitionInfo createdLabel =
-        gApi.projects().name(project.get()).label("foo").create(input).get();
-    assertThat(createdLabel.copyAllScoresOnTrivialRebase).isTrue();
-  }
-
-  @Test
-  public void createWithoutCopyAllScoresOnTrivialRebase() throws Exception {
-    LabelDefinitionInput input = new LabelDefinitionInput();
-    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
-    input.copyAllScoresOnTrivialRebase = false;
-
-    LabelDefinitionInfo createdLabel =
-        gApi.projects().name(project.get()).label("foo").create(input).get();
-    assertThat(createdLabel.copyAllScoresOnTrivialRebase).isNull();
-  }
-
-  @Test
-  public void createWithCopyAllScoresOnMergeFirstParentUpdate() throws Exception {
-    LabelDefinitionInput input = new LabelDefinitionInput();
-    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
-    input.copyAllScoresOnMergeFirstParentUpdate = true;
-
-    LabelDefinitionInfo createdLabel =
-        gApi.projects().name(project.get()).label("foo").create(input).get();
-    assertThat(createdLabel.copyAllScoresOnMergeFirstParentUpdate).isTrue();
-  }
-
-  @Test
-  public void createWithoutCopyAllScoresOnMergeFirstParentUpdate() throws Exception {
-    LabelDefinitionInput input = new LabelDefinitionInput();
-    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
-    input.copyAllScoresOnMergeFirstParentUpdate = false;
-
-    LabelDefinitionInfo createdLabel =
-        gApi.projects().name(project.get()).label("foo").create(input).get();
-    assertThat(createdLabel.copyAllScoresOnMergeFirstParentUpdate).isNull();
-  }
-
-  @Test
-  public void createWithCopyValues() throws Exception {
-    LabelDefinitionInput input = new LabelDefinitionInput();
-    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
-    input.copyValues = ImmutableList.of((short) -1, (short) 1);
-
-    LabelDefinitionInfo createdLabel =
-        gApi.projects().name(project.get()).label("foo").create(input).get();
-    assertThat(createdLabel.copyValues).containsExactly((short) -1, (short) 1).inOrder();
-  }
-
-  @Test
   public void createWithAllowPostSubmit() throws Exception {
     LabelDefinitionInput input = new LabelDefinitionInput();
     input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/GetLabelIT.java b/javatests/com/google/gerrit/acceptance/rest/project/GetLabelIT.java
index 302d827..c29762e 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/GetLabelIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/GetLabelIT.java
@@ -103,7 +103,6 @@
               "foo",
               labelType -> {
                 labelType.setCanOverride(false);
-                labelType.setCopyAllScoresIfNoChange(false);
                 labelType.setAllowPostSubmit(false);
               });
       u.save();
@@ -111,15 +110,6 @@
 
     LabelDefinitionInfo fooLabel = gApi.projects().name(project.get()).label("foo").get();
     assertThat(fooLabel.canOverride).isNull();
-    assertThat(fooLabel.copyAnyScore).isNull();
-    assertThat(fooLabel.copyMinScore).isNull();
-    assertThat(fooLabel.copyMaxScore).isNull();
-    assertThat(fooLabel.copyAllScoresIfListOfFilesDidNotChange).isNull();
-    assertThat(fooLabel.copyAllScoresIfNoChange).isNull();
-    assertThat(fooLabel.copyAllScoresIfNoCodeChange).isNull();
-    assertThat(fooLabel.copyAllScoresOnTrivialRebase).isNull();
-    assertThat(fooLabel.copyAllScoresOnMergeFirstParentUpdate).isNull();
-    assertThat(fooLabel.copyValues).isNull();
     assertThat(fooLabel.allowPostSubmit).isNull();
     assertThat(fooLabel.ignoreSelfApproval).isNull();
   }
@@ -134,14 +124,7 @@
           .updateLabelType(
               "foo",
               labelType -> {
-                labelType.setCopyAnyScore(true);
-                labelType.setCopyMinScore(true);
-                labelType.setCopyMaxScore(true);
-                labelType.setCopyAllScoresIfListOfFilesDidNotChange(true);
-                labelType.setCopyAllScoresIfNoCodeChange(true);
-                labelType.setCopyAllScoresOnTrivialRebase(true);
-                labelType.setCopyAllScoresOnMergeFirstParentUpdate(true);
-                labelType.setCopyValues(ImmutableList.of((short) -1, (short) 1));
+                labelType.setCopyCondition("is:MIN OR is:MAX");
                 labelType.setIgnoreSelfApproval(true);
               });
       u.save();
@@ -149,16 +132,8 @@
 
     LabelDefinitionInfo fooLabel = gApi.projects().name(project.get()).label("foo").get();
     assertThat(fooLabel.canOverride).isTrue();
-    assertThat(fooLabel.copyAnyScore).isTrue();
-    assertThat(fooLabel.copyMinScore).isTrue();
-    assertThat(fooLabel.copyMaxScore).isTrue();
-    assertThat(fooLabel.copyAllScoresIfListOfFilesDidNotChange).isTrue();
-    assertThat(fooLabel.copyAllScoresIfNoChange).isTrue();
-    assertThat(fooLabel.copyAllScoresIfNoCodeChange).isTrue();
-    assertThat(fooLabel.copyAllScoresOnTrivialRebase).isTrue();
-    assertThat(fooLabel.copyAllScoresOnMergeFirstParentUpdate).isTrue();
-    assertThat(fooLabel.copyValues).containsExactly((short) -1, (short) 1).inOrder();
     assertThat(fooLabel.allowPostSubmit).isTrue();
+    assertThat(fooLabel.copyCondition).isEqualTo("is:MIN OR is:MAX");
     assertThat(fooLabel.ignoreSelfApproval).isTrue();
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/LabelAssert.java b/javatests/com/google/gerrit/acceptance/rest/project/LabelAssert.java
index 40e5d50..7f2a924 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/LabelAssert.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/LabelAssert.java
@@ -18,6 +18,7 @@
 
 import com.google.gerrit.entities.LabelFunction;
 import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.extensions.client.ChangeKind;
 import com.google.gerrit.extensions.common.LabelDefinitionInfo;
 import com.google.gerrit.server.config.AllProjectsNameProvider;
 
@@ -41,15 +42,11 @@
     assertThat(codeReviewLabel.defaultValue).isEqualTo(0);
     assertThat(codeReviewLabel.branches).isNull();
     assertThat(codeReviewLabel.canOverride).isTrue();
-    assertThat(codeReviewLabel.copyAnyScore).isNull();
-    assertThat(codeReviewLabel.copyMinScore).isTrue();
-    assertThat(codeReviewLabel.copyMaxScore).isNull();
-    assertThat(codeReviewLabel.copyAllScoresIfListOfFilesDidNotChange).isNull();
-    assertThat(codeReviewLabel.copyAllScoresIfNoChange).isTrue();
-    assertThat(codeReviewLabel.copyAllScoresIfNoCodeChange).isNull();
-    assertThat(codeReviewLabel.copyAllScoresOnTrivialRebase).isTrue();
-    assertThat(codeReviewLabel.copyAllScoresOnMergeFirstParentUpdate).isNull();
-    assertThat(codeReviewLabel.copyValues).isNull();
+    assertThat(codeReviewLabel.copyCondition)
+        .isEqualTo(
+            String.format(
+                "changekind:%s OR changekind:%s OR is:MIN",
+                ChangeKind.NO_CHANGE, ChangeKind.TRIVIAL_REBASE.name()));
     assertThat(codeReviewLabel.allowPostSubmit).isTrue();
     assertThat(codeReviewLabel.ignoreSelfApproval).isNull();
   }
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/ListLabelsIT.java b/javatests/com/google/gerrit/acceptance/rest/project/ListLabelsIT.java
index fbec664..7a717d1 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/ListLabelsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/ListLabelsIT.java
@@ -141,7 +141,6 @@
               "foo",
               labelType -> {
                 labelType.setCanOverride(false);
-                labelType.setCopyAllScoresIfNoChange(false);
                 labelType.setAllowPostSubmit(false);
               });
       u.save();
@@ -152,15 +151,6 @@
 
     LabelDefinitionInfo fooLabel = Iterables.getOnlyElement(labels);
     assertThat(fooLabel.canOverride).isNull();
-    assertThat(fooLabel.copyAnyScore).isNull();
-    assertThat(fooLabel.copyMinScore).isNull();
-    assertThat(fooLabel.copyMaxScore).isNull();
-    assertThat(fooLabel.copyAllScoresIfListOfFilesDidNotChange).isNull();
-    assertThat(fooLabel.copyAllScoresIfNoChange).isNull();
-    assertThat(fooLabel.copyAllScoresIfNoCodeChange).isNull();
-    assertThat(fooLabel.copyAllScoresOnTrivialRebase).isNull();
-    assertThat(fooLabel.copyAllScoresOnMergeFirstParentUpdate).isNull();
-    assertThat(fooLabel.copyValues).isNull();
     assertThat(fooLabel.allowPostSubmit).isNull();
     assertThat(fooLabel.ignoreSelfApproval).isNull();
   }
@@ -175,14 +165,7 @@
           .updateLabelType(
               "foo",
               labelType -> {
-                labelType.setCopyAnyScore(true);
-                labelType.setCopyMinScore(true);
-                labelType.setCopyMaxScore(true);
-                labelType.setCopyAllScoresIfListOfFilesDidNotChange(true);
-                labelType.setCopyAllScoresIfNoCodeChange(true);
-                labelType.setCopyAllScoresOnTrivialRebase(true);
-                labelType.setCopyAllScoresOnMergeFirstParentUpdate(true);
-                labelType.setCopyValues(ImmutableList.of((short) -1, (short) 1));
+                labelType.setCopyCondition("is:MIN OR is:MAX");
                 labelType.setIgnoreSelfApproval(true);
               });
       u.save();
@@ -193,15 +176,7 @@
 
     LabelDefinitionInfo fooLabel = Iterables.getOnlyElement(labels);
     assertThat(fooLabel.canOverride).isTrue();
-    assertThat(fooLabel.copyAnyScore).isTrue();
-    assertThat(fooLabel.copyMinScore).isTrue();
-    assertThat(fooLabel.copyMaxScore).isTrue();
-    assertThat(fooLabel.copyAllScoresIfListOfFilesDidNotChange).isTrue();
-    assertThat(fooLabel.copyAllScoresIfNoChange).isTrue();
-    assertThat(fooLabel.copyAllScoresIfNoCodeChange).isTrue();
-    assertThat(fooLabel.copyAllScoresOnTrivialRebase).isTrue();
-    assertThat(fooLabel.copyAllScoresOnMergeFirstParentUpdate).isTrue();
-    assertThat(fooLabel.copyValues).containsExactly((short) -1, (short) 1).inOrder();
+    assertThat(fooLabel.copyCondition).isEqualTo("is:MIN OR is:MAX");
     assertThat(fooLabel.allowPostSubmit).isTrue();
     assertThat(fooLabel.ignoreSelfApproval).isTrue();
   }
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/SetLabelIT.java b/javatests/com/google/gerrit/acceptance/rest/project/SetLabelIT.java
index cfca936..b4731db 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/SetLabelIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/SetLabelIT.java
@@ -496,40 +496,6 @@
   }
 
   @Test
-  public void setCopyAnyScore() throws Exception {
-    configLabel("foo", LabelFunction.NO_OP);
-    assertThat(gApi.projects().name(project.get()).label("foo").get().copyAnyScore).isNull();
-
-    LabelDefinitionInput input = new LabelDefinitionInput();
-    input.copyAnyScore = true;
-
-    LabelDefinitionInfo updatedLabel =
-        gApi.projects().name(project.get()).label("foo").update(input);
-    assertThat(updatedLabel.copyAnyScore).isTrue();
-
-    assertThat(gApi.projects().name(project.get()).label("foo").get().copyAnyScore).isTrue();
-  }
-
-  @Test
-  public void unsetCopyAnyScore() throws Exception {
-    configLabel("foo", LabelFunction.NO_OP);
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().updateLabelType("foo", lt -> lt.setCopyAnyScore(true));
-      u.save();
-    }
-    assertThat(gApi.projects().name(project.get()).label("foo").get().copyAnyScore).isTrue();
-
-    LabelDefinitionInput input = new LabelDefinitionInput();
-    input.copyAnyScore = false;
-
-    LabelDefinitionInfo updatedLabel =
-        gApi.projects().name(project.get()).label("foo").update(input);
-    assertThat(updatedLabel.copyAnyScore).isNull();
-
-    assertThat(gApi.projects().name(project.get()).label("foo").get().copyAnyScore).isNull();
-  }
-
-  @Test
   public void setCopyCondition() throws Exception {
     configLabel("foo", LabelFunction.NO_OP);
     assertThat(gApi.projects().name(project.get()).label("foo").get().copyCondition).isNull();
@@ -607,361 +573,6 @@
   }
 
   @Test
-  public void setCopyMinScore() throws Exception {
-    configLabel("foo", LabelFunction.NO_OP);
-    assertThat(gApi.projects().name(project.get()).label("foo").get().copyMinScore).isNull();
-
-    LabelDefinitionInput input = new LabelDefinitionInput();
-    input.copyMinScore = true;
-
-    LabelDefinitionInfo updatedLabel =
-        gApi.projects().name(project.get()).label("foo").update(input);
-    assertThat(updatedLabel.copyMinScore).isTrue();
-
-    assertThat(gApi.projects().name(project.get()).label("foo").get().copyMinScore).isTrue();
-  }
-
-  @Test
-  public void unsetCopyMinScore() throws Exception {
-    configLabel("foo", LabelFunction.NO_OP);
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().updateLabelType("foo", lt -> lt.setCopyMinScore(true));
-      u.save();
-    }
-    assertThat(gApi.projects().name(project.get()).label("foo").get().copyMinScore).isTrue();
-
-    LabelDefinitionInput input = new LabelDefinitionInput();
-    input.copyMinScore = false;
-
-    LabelDefinitionInfo updatedLabel =
-        gApi.projects().name(project.get()).label("foo").update(input);
-    assertThat(updatedLabel.copyMinScore).isNull();
-
-    assertThat(gApi.projects().name(project.get()).label("foo").get().copyMinScore).isNull();
-  }
-
-  @Test
-  public void setCopyMaxScore() throws Exception {
-    configLabel("foo", LabelFunction.NO_OP);
-    assertThat(gApi.projects().name(project.get()).label("foo").get().copyMaxScore).isNull();
-
-    LabelDefinitionInput input = new LabelDefinitionInput();
-    input.copyMaxScore = true;
-
-    LabelDefinitionInfo updatedLabel =
-        gApi.projects().name(project.get()).label("foo").update(input);
-    assertThat(updatedLabel.copyMaxScore).isTrue();
-
-    assertThat(gApi.projects().name(project.get()).label("foo").get().copyMaxScore).isTrue();
-  }
-
-  @Test
-  public void unsetCopyMaxScore() throws Exception {
-    configLabel("foo", LabelFunction.NO_OP);
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().updateLabelType("foo", lt -> lt.setCopyMaxScore(true));
-      u.save();
-    }
-    assertThat(gApi.projects().name(project.get()).label("foo").get().copyMaxScore).isTrue();
-
-    LabelDefinitionInput input = new LabelDefinitionInput();
-    input.copyMaxScore = false;
-
-    LabelDefinitionInfo updatedLabel =
-        gApi.projects().name(project.get()).label("foo").update(input);
-    assertThat(updatedLabel.copyMaxScore).isNull();
-
-    assertThat(gApi.projects().name(project.get()).label("foo").get().copyMaxScore).isNull();
-  }
-
-  @Test
-  public void setCopyAllScoresIfListOfFilesDidNotChange() throws Exception {
-    configLabel("foo", LabelFunction.NO_OP);
-    assertThat(
-            gApi.projects()
-                .name(project.get())
-                .label("foo")
-                .get()
-                .copyAllScoresIfListOfFilesDidNotChange)
-        .isNull();
-
-    LabelDefinitionInput input = new LabelDefinitionInput();
-    input.copyAllScoresIfListOfFilesDidNotChange = true;
-
-    LabelDefinitionInfo updatedLabel =
-        gApi.projects().name(project.get()).label("foo").update(input);
-    assertThat(updatedLabel.copyAllScoresIfListOfFilesDidNotChange).isTrue();
-
-    assertThat(
-            gApi.projects()
-                .name(project.get())
-                .label("foo")
-                .get()
-                .copyAllScoresIfListOfFilesDidNotChange)
-        .isTrue();
-  }
-
-  @Test
-  public void unsetCopyAllScoresIfListOfFilesDidNotChange() throws Exception {
-    configLabel("foo", LabelFunction.NO_OP);
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig()
-          .updateLabelType("foo", lt -> lt.setCopyAllScoresIfListOfFilesDidNotChange(true));
-      u.save();
-    }
-    assertThat(
-            gApi.projects()
-                .name(project.get())
-                .label("foo")
-                .get()
-                .copyAllScoresIfListOfFilesDidNotChange)
-        .isTrue();
-
-    LabelDefinitionInput input = new LabelDefinitionInput();
-    input.copyAllScoresIfListOfFilesDidNotChange = false;
-
-    LabelDefinitionInfo updatedLabel =
-        gApi.projects().name(project.get()).label("foo").update(input);
-    assertThat(updatedLabel.copyAllScoresIfListOfFilesDidNotChange).isNull();
-
-    assertThat(
-            gApi.projects()
-                .name(project.get())
-                .label("foo")
-                .get()
-                .copyAllScoresIfListOfFilesDidNotChange)
-        .isNull();
-  }
-
-  @Test
-  public void setCopyAllScoresIfNoChange() throws Exception {
-    configLabel("foo", LabelFunction.NO_OP);
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().updateLabelType("foo", lt -> lt.setCopyAllScoresIfNoChange(false));
-      u.save();
-    }
-    assertThat(gApi.projects().name(project.get()).label("foo").get().copyAllScoresIfNoChange)
-        .isNull();
-
-    LabelDefinitionInput input = new LabelDefinitionInput();
-    input.copyAllScoresIfNoChange = true;
-
-    LabelDefinitionInfo updatedLabel =
-        gApi.projects().name(project.get()).label("foo").update(input);
-    assertThat(updatedLabel.copyAllScoresIfNoChange).isTrue();
-
-    assertThat(gApi.projects().name(project.get()).label("foo").get().copyAllScoresIfNoChange)
-        .isTrue();
-  }
-
-  @Test
-  public void unsetCopyAllScoresIfNoChange() throws Exception {
-    configLabel("foo", LabelFunction.NO_OP);
-    assertThat(gApi.projects().name(project.get()).label("foo").get().copyAllScoresIfNoChange)
-        .isTrue();
-
-    LabelDefinitionInput input = new LabelDefinitionInput();
-    input.copyAllScoresIfNoChange = false;
-
-    LabelDefinitionInfo updatedLabel =
-        gApi.projects().name(project.get()).label("foo").update(input);
-    assertThat(updatedLabel.copyAllScoresIfNoChange).isNull();
-
-    assertThat(gApi.projects().name(project.get()).label("foo").get().copyAllScoresIfNoChange)
-        .isNull();
-  }
-
-  @Test
-  public void setCopyAllScoresIfNoCodeChange() throws Exception {
-    configLabel("foo", LabelFunction.NO_OP);
-    assertThat(gApi.projects().name(project.get()).label("foo").get().copyAllScoresIfNoCodeChange)
-        .isNull();
-
-    LabelDefinitionInput input = new LabelDefinitionInput();
-    input.copyAllScoresIfNoCodeChange = true;
-
-    LabelDefinitionInfo updatedLabel =
-        gApi.projects().name(project.get()).label("foo").update(input);
-    assertThat(updatedLabel.copyAllScoresIfNoCodeChange).isTrue();
-
-    assertThat(gApi.projects().name(project.get()).label("foo").get().copyAllScoresIfNoCodeChange)
-        .isTrue();
-  }
-
-  @Test
-  public void unsetCopyAllScoresIfNoCodeChange() throws Exception {
-    configLabel("foo", LabelFunction.NO_OP);
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().updateLabelType("foo", lt -> lt.setCopyAllScoresIfNoCodeChange(true));
-      u.save();
-    }
-    assertThat(gApi.projects().name(project.get()).label("foo").get().copyAllScoresIfNoCodeChange)
-        .isTrue();
-
-    LabelDefinitionInput input = new LabelDefinitionInput();
-    input.copyAllScoresIfNoCodeChange = false;
-
-    LabelDefinitionInfo updatedLabel =
-        gApi.projects().name(project.get()).label("foo").update(input);
-    assertThat(updatedLabel.copyAllScoresIfNoCodeChange).isNull();
-
-    assertThat(gApi.projects().name(project.get()).label("foo").get().copyAllScoresIfNoCodeChange)
-        .isNull();
-  }
-
-  @Test
-  public void setCopyAllScoresOnTrivialRebase() throws Exception {
-    configLabel("foo", LabelFunction.NO_OP);
-    assertThat(gApi.projects().name(project.get()).label("foo").get().copyAllScoresOnTrivialRebase)
-        .isNull();
-
-    LabelDefinitionInput input = new LabelDefinitionInput();
-    input.copyAllScoresOnTrivialRebase = true;
-
-    LabelDefinitionInfo updatedLabel =
-        gApi.projects().name(project.get()).label("foo").update(input);
-    assertThat(updatedLabel.copyAllScoresOnTrivialRebase).isTrue();
-
-    assertThat(gApi.projects().name(project.get()).label("foo").get().copyAllScoresOnTrivialRebase)
-        .isTrue();
-  }
-
-  @Test
-  public void unsetCopyAllScoresOnTrivialRebase() throws Exception {
-    configLabel("foo", LabelFunction.NO_OP);
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().updateLabelType("foo", lt -> lt.setCopyAllScoresOnTrivialRebase(true));
-      u.save();
-    }
-    assertThat(gApi.projects().name(project.get()).label("foo").get().copyAllScoresOnTrivialRebase)
-        .isTrue();
-
-    LabelDefinitionInput input = new LabelDefinitionInput();
-    input.copyAllScoresOnTrivialRebase = false;
-
-    LabelDefinitionInfo updatedLabel =
-        gApi.projects().name(project.get()).label("foo").update(input);
-    assertThat(updatedLabel.copyAllScoresOnTrivialRebase).isNull();
-
-    assertThat(gApi.projects().name(project.get()).label("foo").get().copyAllScoresOnTrivialRebase)
-        .isNull();
-  }
-
-  @Test
-  public void setCopyAllScoresOnMergeFirstParentUpdate() throws Exception {
-    configLabel("foo", LabelFunction.NO_OP);
-    assertThat(
-            gApi.projects()
-                .name(project.get())
-                .label("foo")
-                .get()
-                .copyAllScoresOnMergeFirstParentUpdate)
-        .isNull();
-
-    LabelDefinitionInput input = new LabelDefinitionInput();
-    input.copyAllScoresOnMergeFirstParentUpdate = true;
-
-    LabelDefinitionInfo updatedLabel =
-        gApi.projects().name(project.get()).label("foo").update(input);
-    assertThat(updatedLabel.copyAllScoresOnMergeFirstParentUpdate).isTrue();
-
-    assertThat(
-            gApi.projects()
-                .name(project.get())
-                .label("foo")
-                .get()
-                .copyAllScoresOnMergeFirstParentUpdate)
-        .isTrue();
-  }
-
-  @Test
-  public void unsetCopyAllScoresOnMergeFirstParentUpdate() throws Exception {
-    configLabel("foo", LabelFunction.NO_OP);
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().updateLabelType("foo", lt -> lt.setCopyAllScoresOnMergeFirstParentUpdate(true));
-      u.save();
-    }
-    assertThat(
-            gApi.projects()
-                .name(project.get())
-                .label("foo")
-                .get()
-                .copyAllScoresOnMergeFirstParentUpdate)
-        .isTrue();
-
-    LabelDefinitionInput input = new LabelDefinitionInput();
-    input.copyAllScoresOnMergeFirstParentUpdate = false;
-
-    LabelDefinitionInfo updatedLabel =
-        gApi.projects().name(project.get()).label("foo").update(input);
-    assertThat(updatedLabel.copyAllScoresOnMergeFirstParentUpdate).isNull();
-
-    assertThat(
-            gApi.projects()
-                .name(project.get())
-                .label("foo")
-                .get()
-                .copyAllScoresOnMergeFirstParentUpdate)
-        .isNull();
-  }
-
-  @Test
-  public void setCopyValues() throws Exception {
-    configLabel("foo", LabelFunction.NO_OP);
-    assertThat(gApi.projects().name(project.get()).label("foo").get().copyValues).isNull();
-
-    LabelDefinitionInput input = new LabelDefinitionInput();
-    input.copyValues = ImmutableList.of((short) -1, (short) 1);
-
-    LabelDefinitionInfo updatedLabel =
-        gApi.projects().name(project.get()).label("foo").update(input);
-    assertThat(updatedLabel.copyValues).containsExactly((short) -1, (short) 1).inOrder();
-
-    assertThat(gApi.projects().name(project.get()).label("foo").get().copyValues)
-        .containsExactly((short) -1, (short) 1)
-        .inOrder();
-  }
-
-  @Test
-  public void unsetCopyValues() throws Exception {
-    configLabel("foo", LabelFunction.NO_OP);
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig()
-          .updateLabelType("foo", lt -> lt.setCopyValues(ImmutableList.of((short) -1, (short) 1)));
-      u.save();
-    }
-    assertThat(gApi.projects().name(project.get()).label("foo").get().copyValues).isNotEmpty();
-
-    LabelDefinitionInput input = new LabelDefinitionInput();
-    input.copyValues = ImmutableList.of();
-
-    LabelDefinitionInfo updatedLabel =
-        gApi.projects().name(project.get()).label("foo").update(input);
-    assertThat(updatedLabel.copyValues).isNull();
-
-    assertThat(gApi.projects().name(project.get()).label("foo").get().copyValues).isNull();
-  }
-
-  @Test
-  public void setAllowPostSubmit() throws Exception {
-    configLabel("foo", LabelFunction.NO_OP);
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().updateLabelType("foo", lt -> lt.setAllowPostSubmit(false));
-      u.save();
-    }
-    assertThat(gApi.projects().name(project.get()).label("foo").get().allowPostSubmit).isNull();
-
-    LabelDefinitionInput input = new LabelDefinitionInput();
-    input.allowPostSubmit = true;
-
-    LabelDefinitionInfo updatedLabel =
-        gApi.projects().name(project.get()).label("foo").update(input);
-    assertThat(updatedLabel.allowPostSubmit).isTrue();
-
-    assertThat(gApi.projects().name(project.get()).label("foo").get().allowPostSubmit).isTrue();
-  }
-
-  @Test
   public void unsetAllowPostSubmit() throws Exception {
     configLabel("foo", LabelFunction.NO_OP);
     assertThat(gApi.projects().name(project.get()).label("foo").get().allowPostSubmit).isTrue();
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/TagsIT.java b/javatests/com/google/gerrit/acceptance/rest/project/TagsIT.java
index 044da19..795e22c 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/TagsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/TagsIT.java
@@ -39,6 +39,7 @@
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.inject.Inject;
 import java.time.Instant;
@@ -366,16 +367,49 @@
   }
 
   @Test
+  public void nonExistingBaseRevision() throws Exception {
+    grantTagPermissions();
+
+    TagInput input = new TagInput();
+    input.ref = "test";
+    input.revision = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
+
+    UnprocessableEntityException thrown =
+        assertThrows(UnprocessableEntityException.class, () -> tag(input.ref).create(input));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("base revision \"deadbeefdeadbeefdeadbeefdeadbeefdeadbeef\" not found");
+  }
+
+  @Test
   public void invalidBaseRevision() throws Exception {
     grantTagPermissions();
 
     TagInput input = new TagInput();
     input.ref = "test";
-    input.revision = "abcdefg";
+    input.revision = "invalid\trevision";
+
+    UnprocessableEntityException thrown =
+        assertThrows(UnprocessableEntityException.class, () -> tag(input.ref).create(input));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("base revision \"" + input.revision + "\" is invalid");
+  }
+
+  @Test
+  public void nonCommitRevision() throws Exception {
+    grantTagPermissions();
+
+    TagInput input = new TagInput();
+    input.ref = "test";
+    input.revision =
+        projectOperations.project(project).getHead("refs/heads/master").getTree().name();
 
     BadRequestException thrown =
         assertThrows(BadRequestException.class, () -> tag(input.ref).create(input));
-    assertThat(thrown).hasMessageThat().contains("Invalid base revision");
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("base revision \"" + input.revision + "\" is not a commit");
   }
 
   @Test
@@ -457,11 +491,8 @@
     return gApi.projects().name(project.get()).tag(tagname);
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   private Instant instant(PushOneCommit.Result r) {
-    return r.getCommit().getCommitterIdent().getWhen().toInstant();
+    return r.getCommit().getCommitterIdent().getWhenAsInstant();
   }
 
   private void assertBadRequest(ListRefsRequest<TagInfo> req) throws Exception {
diff --git a/javatests/com/google/gerrit/acceptance/server/change/ApprovalCopierIT.java b/javatests/com/google/gerrit/acceptance/server/change/ApprovalCopierIT.java
new file mode 100644
index 0000000..0d06946
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/change/ApprovalCopierIT.java
@@ -0,0 +1,432 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.server.change;
+
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
+import static com.google.common.truth.Truth.assertAbout;
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.server.change.ApprovalCopierIT.PatchSetApprovalSubject.assertThatList;
+import static com.google.gerrit.acceptance.server.change.ApprovalCopierIT.PatchSetApprovalSubject.hasTestId;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.server.project.testing.TestLabels.labelBuilder;
+import static com.google.gerrit.server.project.testing.TestLabels.value;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.truth.Correspondence;
+import com.google.common.truth.FailureMetadata;
+import com.google.common.truth.Subject;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.approval.ApprovalCopier;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.truth.ListSubject;
+import com.google.gerrit.truth.NullAwareCorrespondence;
+import com.google.inject.Inject;
+import java.io.IOException;
+import java.util.Set;
+import java.util.function.Predicate;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.junit.Before;
+import org.junit.Test;
+
+/**
+ * Tests of the {@link ApprovalCopier} API.
+ *
+ * <p>This class doesn't verify the copy condition predicates, as they are already covered by {@code
+ * StickyApprovalsIT}.
+ */
+@NoHttpd
+public class ApprovalCopierIT extends AbstractDaemonTest {
+  @Inject private ApprovalCopier approvalCopier;
+  @Inject private ProjectOperations projectOperations;
+  @Inject private RequestScopeOperations requestScopeOperations;
+
+  @Before
+  public void setup() throws Exception {
+    // Add Verified label.
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      LabelType.Builder verified =
+          labelBuilder(
+                  LabelId.VERIFIED, value(1, "Passes"), value(0, "No score"), value(-1, "Failed"))
+              .setCopyCondition("is:MIN");
+      u.getConfig().upsertLabelType(verified.build());
+      u.save();
+    }
+
+    // Grant permissions to vote on the verified label.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(LabelId.VERIFIED)
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-1, 1))
+        .update();
+  }
+
+  @Test
+  public void forInitialPatchSet_noApprovals() throws Exception {
+    ChangeData changeData = createChange().getChange();
+    try (Repository repo = repoManager.openRepository(project);
+        RevWalk revWalk = new RevWalk(repo)) {
+      ApprovalCopier.Result approvalCopierResult =
+          approvalCopier.forPatchSet(
+              changeData.notes(), changeData.currentPatchSet(), revWalk, repo.getConfig());
+      assertThat(approvalCopierResult.copiedApprovals()).isEmpty();
+      assertThat(approvalCopierResult.outdatedApprovals()).isEmpty();
+    }
+  }
+
+  @Test
+  public void forInitialPatchSet_currentApprovals() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    // Add some current approvals.
+    vote(r.getChangeId(), admin, LabelId.CODE_REVIEW, 2);
+    vote(r.getChangeId(), admin, LabelId.VERIFIED, 1);
+    vote(r.getChangeId(), user, LabelId.CODE_REVIEW, -1);
+    vote(r.getChangeId(), user, LabelId.VERIFIED, -1);
+
+    ApprovalCopier.Result approvalCopierResult =
+        invokeApprovalCopierForCurrentPatchSet(
+            r.getChange().getId(), /* expectedCurrentPatchSetNum= */ 1);
+    assertThatList(approvalCopierResult.copiedApprovals()).isEmpty();
+    assertThatList(approvalCopierResult.outdatedApprovals()).isEmpty();
+  }
+
+  @Test
+  public void forPatchSet_noApprovals() throws Exception {
+    PushOneCommit.Result r = createChange();
+    amendChange(r.getChangeId(), "refs/for/master", admin, testRepo).assertOkStatus();
+    ApprovalCopier.Result approvalCopierResult =
+        invokeApprovalCopierForCurrentPatchSet(
+            r.getChange().getId(), /* expectedCurrentPatchSetNum= */ 2);
+    assertThatList(approvalCopierResult.copiedApprovals()).isEmpty();
+    assertThatList(approvalCopierResult.outdatedApprovals()).isEmpty();
+  }
+
+  @Test
+  public void forPatchSet_outdatedApprovals() throws Exception {
+    PushOneCommit.Result r = createChange();
+    PatchSet.Id patchSet1Id = r.getPatchSetId();
+
+    // Add some approvals that are not copied.
+    vote(r.getChangeId(), admin, LabelId.CODE_REVIEW, 2);
+    vote(r.getChangeId(), user, LabelId.VERIFIED, 1);
+
+    amendChange(r.getChangeId(), "refs/for/master", admin, testRepo).assertOkStatus();
+    ApprovalCopier.Result approvalCopierResult =
+        invokeApprovalCopierForCurrentPatchSet(
+            r.getChange().getId(), /* expectedCurrentPatchSetNum= */ 2);
+    assertThat(approvalCopierResult.copiedApprovals()).isEmpty();
+    assertThat(approvalCopierResult.outdatedApprovals())
+        .comparingElementsUsing(hasTestId())
+        .containsExactly(
+            PatchSetApprovalTestId.create(patchSet1Id, admin.id(), LabelId.CODE_REVIEW, 2),
+            PatchSetApprovalTestId.create(patchSet1Id, user.id(), LabelId.VERIFIED, 1));
+  }
+
+  @Test
+  public void forPatchSet_copiedApprovals() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    // Add some approvals that are copied.
+    vote(r.getChangeId(), admin, LabelId.CODE_REVIEW, -2);
+    vote(r.getChangeId(), user, LabelId.VERIFIED, -1);
+
+    r = amendChange(r.getChangeId(), "refs/for/master", admin, testRepo);
+    r.assertOkStatus();
+    PatchSet.Id patchSet2Id = r.getPatchSetId();
+
+    ApprovalCopier.Result approvalCopierResult =
+        invokeApprovalCopierForCurrentPatchSet(
+            r.getChange().getId(), /* expectedCurrentPatchSetNum= */ 2);
+    assertThatList(approvalCopierResult.copiedApprovals())
+        .comparingElementsUsing(hasTestId())
+        .containsExactly(
+            PatchSetApprovalTestId.create(patchSet2Id, admin.id(), LabelId.CODE_REVIEW, -2),
+            PatchSetApprovalTestId.create(patchSet2Id, user.id(), LabelId.VERIFIED, -1));
+    assertThatList(approvalCopierResult.outdatedApprovals()).isEmpty();
+  }
+
+  @Test
+  public void forPatchSet_currentApprovals() throws Exception {
+    PushOneCommit.Result r = createChange();
+    amendChange(r.getChangeId(), "refs/for/master", admin, testRepo).assertOkStatus();
+
+    // Add some current approvals.
+    vote(r.getChangeId(), admin, LabelId.CODE_REVIEW, 2);
+    vote(r.getChangeId(), admin, LabelId.VERIFIED, 1);
+    vote(r.getChangeId(), user, LabelId.CODE_REVIEW, -1);
+    vote(r.getChangeId(), user, LabelId.VERIFIED, -1);
+
+    ApprovalCopier.Result approvalCopierResult =
+        invokeApprovalCopierForCurrentPatchSet(
+            r.getChange().getId(), /* expectedCurrentPatchSetNum= */ 2);
+    assertThatList(approvalCopierResult.copiedApprovals()).isEmpty();
+    assertThatList(approvalCopierResult.outdatedApprovals()).isEmpty();
+  }
+
+  @Test
+  public void forPatchSet_allKindOfApprovals() throws Exception {
+    PushOneCommit.Result r = createChange();
+    PatchSet.Id patchSet1Id = r.getPatchSetId();
+
+    // Add some approvals that are copied.
+    vote(r.getChangeId(), admin, LabelId.CODE_REVIEW, -2);
+    vote(r.getChangeId(), user, LabelId.VERIFIED, -1);
+
+    // Add some approvals that are not copied.
+    vote(r.getChangeId(), user, LabelId.CODE_REVIEW, 1);
+    vote(r.getChangeId(), admin, LabelId.VERIFIED, 1);
+
+    r = amendChange(r.getChangeId(), "refs/for/master", admin, testRepo);
+    r.assertOkStatus();
+    PatchSet.Id patchSet2Id = r.getPatchSetId();
+
+    // Add some current approvals.
+    vote(r.getChangeId(), user, LabelId.CODE_REVIEW, -1);
+    vote(r.getChangeId(), admin, LabelId.VERIFIED, -1);
+
+    ApprovalCopier.Result approvalCopierResult =
+        invokeApprovalCopierForCurrentPatchSet(
+            r.getChange().getId(), /* expectedCurrentPatchSetNum= */ 2);
+    assertThatList(approvalCopierResult.copiedApprovals())
+        .comparingElementsUsing(hasTestId())
+        .containsExactly(
+            PatchSetApprovalTestId.create(patchSet2Id, admin.id(), LabelId.CODE_REVIEW, -2),
+            PatchSetApprovalTestId.create(patchSet2Id, user.id(), LabelId.VERIFIED, -1));
+    assertThatList(approvalCopierResult.outdatedApprovals())
+        .comparingElementsUsing(hasTestId())
+        .containsExactly(
+            PatchSetApprovalTestId.create(patchSet1Id, user.id(), LabelId.CODE_REVIEW, 1),
+            PatchSetApprovalTestId.create(patchSet1Id, admin.id(), LabelId.VERIFIED, 1));
+  }
+
+  @Test
+  public void forPatchSet_copiedApprovalOverriddenByCurrentApproval() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    // Add approval that is copied.
+    vote(r.getChangeId(), admin, LabelId.CODE_REVIEW, -2);
+
+    amendChange(r.getChangeId(), "refs/for/master", admin, testRepo).assertOkStatus();
+
+    // Override the copied approval.
+    vote(r.getChangeId(), admin, LabelId.CODE_REVIEW, 1);
+
+    ApprovalCopier.Result approvalCopierResult =
+        invokeApprovalCopierForCurrentPatchSet(
+            r.getChange().getId(), /* expectedCurrentPatchSetNum= */ 2);
+    assertThatList(approvalCopierResult.copiedApprovals()).isEmpty();
+    assertThatList(approvalCopierResult.outdatedApprovals()).isEmpty();
+  }
+
+  @Test
+  public void forPatchSet_approvalForNonExistingLabel() throws Exception {
+    PushOneCommit.Result r = createChange();
+    PatchSet.Id patchSet1Id = r.getPatchSetId();
+
+    // Add approval that could be copied.
+    vote(r.getChangeId(), admin, LabelId.CODE_REVIEW, -2);
+
+    // Delete the Code-Review label (override it with an empty label definition).
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig().upsertLabelType(labelBuilder(LabelId.CODE_REVIEW).build());
+      u.save();
+    }
+
+    amendChange(r.getChangeId(), "refs/for/master", admin, testRepo).assertOkStatus();
+
+    ApprovalCopier.Result approvalCopierResult =
+        invokeApprovalCopierForCurrentPatchSet(
+            r.getChange().getId(), /* expectedCurrentPatchSetNum= */ 2);
+    assertThatList(approvalCopierResult.copiedApprovals()).isEmpty();
+    assertThatList(approvalCopierResult.outdatedApprovals())
+        .comparingElementsUsing(hasTestId())
+        .containsExactly(
+            PatchSetApprovalTestId.create(patchSet1Id, admin.id(), LabelId.CODE_REVIEW, -2));
+  }
+
+  @Test
+  public void forPatchSet_copyableZeroApproval() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    // Override the inherited Code-Review label to make all votes copyable, including zero votes.
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      LabelType.Builder codeReview =
+          labelBuilder(
+                  LabelId.CODE_REVIEW,
+                  value(2, "Looks good to me, approved"),
+                  value(1, "Looks good to me, but someone else must approve"),
+                  value(0, "No score"),
+                  value(-1, "I would prefer this is not submitted as is"),
+                  value(-2, "This shall not be submitted"))
+              .setCopyCondition("is:ANY");
+      u.getConfig().upsertLabelType(codeReview.build());
+      u.save();
+    }
+
+    // Create a zero approval that is copyable, by adding an approval and removing it again.
+    vote(r.getChangeId(), admin, LabelId.CODE_REVIEW, 2);
+    vote(r.getChangeId(), admin, LabelId.CODE_REVIEW, 0);
+
+    amendChange(r.getChangeId(), "refs/for/master", admin, testRepo).assertOkStatus();
+
+    ApprovalCopier.Result approvalCopierResult =
+        invokeApprovalCopierForCurrentPatchSet(
+            r.getChange().getId(), /* expectedCurrentPatchSetNum= */ 2);
+    assertThatList(approvalCopierResult.copiedApprovals()).isEmpty();
+    assertThatList(approvalCopierResult.outdatedApprovals()).isEmpty();
+  }
+
+  @Test
+  public void forPatchSet_nonCopyableZeroApproval() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    // Create a zero approval that is non-copyable, by adding an approval and removing it again.
+    vote(r.getChangeId(), admin, LabelId.CODE_REVIEW, 2);
+    vote(r.getChangeId(), admin, LabelId.CODE_REVIEW, 0);
+
+    amendChange(r.getChangeId(), "refs/for/master", admin, testRepo).assertOkStatus();
+
+    ApprovalCopier.Result approvalCopierResult =
+        invokeApprovalCopierForCurrentPatchSet(
+            r.getChange().getId(), /* expectedCurrentPatchSetNum= */ 2);
+    assertThatList(approvalCopierResult.copiedApprovals()).isEmpty();
+    assertThatList(approvalCopierResult.outdatedApprovals()).isEmpty();
+  }
+
+  @Test
+  public void copiedFlagSetOnCopiedApprovals() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    // Add approvals that are copied.
+    vote(r.getChangeId(), admin, LabelId.CODE_REVIEW, -2);
+    vote(r.getChangeId(), user, LabelId.VERIFIED, -1);
+
+    r = amendChange(r.getChangeId(), "refs/for/master", admin, testRepo);
+    r.assertOkStatus();
+    PatchSet.Id patchSet2Id = r.getPatchSetId();
+
+    // Override copied approval.
+    vote(r.getChangeId(), admin, LabelId.CODE_REVIEW, 1);
+
+    // Add new current approval.
+    vote(r.getChangeId(), admin, LabelId.VERIFIED, 1);
+
+    ApprovalCopier.Result approvalCopierResult =
+        invokeApprovalCopierForCurrentPatchSet(
+            r.getChange().getId(), /* expectedCurrentPatchSetNum= */ 2);
+    ImmutableSet<PatchSetApproval> copiedApprovals = approvalCopierResult.copiedApprovals();
+    assertThatList(filter(copiedApprovals, PatchSetApproval::copied))
+        .comparingElementsUsing(hasTestId())
+        .containsExactly(
+            PatchSetApprovalTestId.create(patchSet2Id, user.id(), LabelId.VERIFIED, -1));
+    assertThatList(filter(copiedApprovals, psa -> !psa.copied())).isEmpty();
+  }
+
+  private void vote(String changeId, TestAccount testAccount, String label, int value)
+      throws RestApiException {
+    requestScopeOperations.setApiUser(testAccount.id());
+    gApi.changes().id(changeId).current().review(new ReviewInput().label(label, value));
+    requestScopeOperations.setApiUser(admin.id());
+  }
+
+  private ImmutableSet<PatchSetApproval> filter(
+      Set<PatchSetApproval> approvals, Predicate<PatchSetApproval> filter) {
+    return approvals.stream().filter(filter).collect(toImmutableSet());
+  }
+
+  private ApprovalCopier.Result invokeApprovalCopierForCurrentPatchSet(
+      Change.Id changeId, int expectedCurrentPatchSetNum) throws IOException {
+    ChangeData changeData = changeDataFactory.create(project, changeId);
+    assertThat(changeData.currentPatchSet().id().get()).isEqualTo(expectedCurrentPatchSetNum);
+    try (Repository repo = repoManager.openRepository(project);
+        RevWalk revWalk = new RevWalk(repo)) {
+      return approvalCopier.forPatchSet(
+          changeData.notes(), changeData.currentPatchSet(), revWalk, repo.getConfig());
+    }
+  }
+
+  public static class PatchSetApprovalSubject extends Subject {
+    public static Correspondence<PatchSetApproval, PatchSetApprovalTestId> hasTestId() {
+      return NullAwareCorrespondence.transforming(PatchSetApprovalTestId::create, "has test ID");
+    }
+
+    public static PatchSetApprovalSubject assertThat(PatchSetApproval patchSetApproval) {
+      return assertAbout(patchSetApprovals()).that(patchSetApproval);
+    }
+
+    public static ListSubject<PatchSetApprovalSubject, PatchSetApproval> assertThatList(
+        ImmutableSet<PatchSetApproval> patchSetApprovals) {
+      return ListSubject.assertThat(patchSetApprovals.asList(), patchSetApprovals());
+    }
+
+    private static Factory<PatchSetApprovalSubject, PatchSetApproval> patchSetApprovals() {
+      return PatchSetApprovalSubject::new;
+    }
+
+    private PatchSetApprovalSubject(FailureMetadata metadata, PatchSetApproval patchSetApproval) {
+      super(metadata, patchSetApproval);
+    }
+  }
+
+  /**
+   * AutoValue class that contains all properties of a PatchSetApproval that are relevant to do
+   * assertions in tests (patch set ID, account ID, label name, voting value).
+   */
+  @AutoValue
+  public abstract static class PatchSetApprovalTestId {
+    public abstract PatchSet.Id patchSetId();
+
+    public abstract Account.Id accountId();
+
+    public abstract LabelId labelId();
+
+    public abstract short value();
+
+    public static PatchSetApprovalTestId create(PatchSetApproval patchSetApproval) {
+      return new AutoValue_ApprovalCopierIT_PatchSetApprovalTestId(
+          patchSetApproval.patchSetId(),
+          patchSetApproval.accountId(),
+          patchSetApproval.labelId(),
+          patchSetApproval.value());
+    }
+
+    public static PatchSetApprovalTestId create(
+        PatchSet.Id patchSetId, Account.Id accountId, String labelId, int value) {
+      return new AutoValue_ApprovalCopierIT_PatchSetApprovalTestId(
+          patchSetId, accountId, LabelId.create(labelId), (short) value);
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/server/change/BUILD b/javatests/com/google/gerrit/acceptance/server/change/BUILD
index 19ca946..4514ea3 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/BUILD
+++ b/javatests/com/google/gerrit/acceptance/server/change/BUILD
@@ -14,6 +14,7 @@
 java_library(
     name = "util",
     srcs = ["CommentsUtil.java"],
+    visibility = ["//javatests/com/google/gerrit/acceptance/api/change:__subpackages__"],
     deps = [
         "//java/com/google/gerrit/acceptance:lib",
         "//java/com/google/gerrit/entities",
diff --git a/javatests/com/google/gerrit/acceptance/server/change/CommentsUtil.java b/javatests/com/google/gerrit/acceptance/server/change/CommentsUtil.java
index c4927f0..f32cf32 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/CommentsUtil.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/CommentsUtil.java
@@ -34,7 +34,7 @@
  * A utility class for creating {@link CommentInput} objects, publishing comments and creating draft
  * comments. Used by tests that require dealing with comments.
  */
-class CommentsUtil {
+public class CommentsUtil {
   static CommentInput addComment(GerritApi gApi, String changeId) throws Exception {
     ReviewInput input = new ReviewInput();
     CommentInput comment = CommentsUtil.newComment(FILE_NAME, Side.REVISION, 0, "a message", false);
@@ -88,7 +88,7 @@
     return populate(c, path, Side.PARENT, parent, line, message);
   }
 
-  static DraftInput newDraft(String path, Side side, int line, String message) {
+  public static DraftInput newDraft(String path, Side side, int line, String message) {
     DraftInput d = new DraftInput();
     d.unresolved = false;
     return populate(d, path, side, null, line, message);
diff --git a/javatests/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java b/javatests/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java
index 5b6da36..bcde618 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java
@@ -825,14 +825,11 @@
     assertThat(ru.delete()).isEqualTo(RefUpdate.Result.FORCED);
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   private void addNoteDbCommit(Change.Id id, String commitMessage) throws Exception {
     PersonIdent committer = serverIdent.get();
     PersonIdent author =
         noteUtil.newAccountIdIdent(
-            getAccount(admin.id()).id(), committer.getWhen().toInstant(), committer);
+            getAccount(admin.id()).id(), committer.getWhenAsInstant(), committer);
     serverSideTestRepo
         .branch(RefNames.changeMetaRef(id))
         .commit()
diff --git a/javatests/com/google/gerrit/acceptance/server/change/DeleteZombieDraftIT.java b/javatests/com/google/gerrit/acceptance/server/change/DeleteZombieDraftIT.java
new file mode 100644
index 0000000..1eef944
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/change/DeleteZombieDraftIT.java
@@ -0,0 +1,246 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.server.change;
+
+import static com.google.common.truth.Truth.assertThat;
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Iterables;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.entities.HumanComment;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.api.changes.DraftInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput.DraftHandling;
+import com.google.gerrit.extensions.client.Side;
+import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.server.notedb.ChangeNoteJson;
+import com.google.gerrit.server.notedb.DeleteZombieCommentsRefs;
+import com.google.gerrit.testing.ConfigSuite;
+import com.google.gson.JsonParser;
+import com.google.inject.Inject;
+import java.util.List;
+import org.apache.commons.lang3.reflect.TypeLiteral;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectLoader;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevTree;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.treewalk.TreeWalk;
+import org.junit.Before;
+import org.junit.Test;
+
+/** Test for {@link com.google.gerrit.server.notedb.DeleteZombieCommentsRefs}. */
+public class DeleteZombieDraftIT extends AbstractDaemonTest {
+  private static final String TEST_PARAMETER_MARKER = "test_only_parameter";
+
+  @Inject private DeleteZombieCommentsRefs.Factory deleteZombieDraftsFactory;
+  @Inject private ChangeNoteJson changeNoteJson;
+  private boolean dryRun;
+
+  @ConfigSuite.Default
+  public static Config dryRunMode() {
+    Config config = new Config();
+    config.setBoolean(TEST_PARAMETER_MARKER, null, "dryRun", true);
+    return config;
+  }
+
+  @ConfigSuite.Config
+  public static Config deleteMode() {
+    Config config = new Config();
+    config.setBoolean(TEST_PARAMETER_MARKER, null, "dryRun", false);
+    return config;
+  }
+
+  @Before
+  public void setUp() throws Exception {
+    dryRun = baseConfig.getBoolean(TEST_PARAMETER_MARKER, "dryRun", true);
+  }
+
+  @Test
+  public void draftRefWithOneZombie() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    String revId = r.getCommit().getName();
+
+    // Create a draft. A draft ref is created for this draft comment.
+    addDraft(changeId, revId, "comment 1");
+    Ref draftRef = getOnlyDraftRef();
+    assertThat(getDraftsByParsingDraftRef(draftRef.getName(), revId)).hasSize(1);
+    // Publish the draft. The draft ref is deleted.
+    publishAllDrafts(r);
+    assertNumDrafts(changeId, 0);
+    assertThat(getDraftsByParsingDraftRef(draftRef.getName(), revId)).isEmpty();
+    assertNumPublishedComments(changeId, 1);
+
+    // Restore the draft ref. Now the same comment exists as draft and published -> zombie.
+    restoreRef(draftRef.getName(), draftRef.getObjectId());
+    draftRef = getOnlyDraftRef();
+    assertThat(getDraftsByParsingDraftRef(draftRef.getName(), revId)).hasSize(1);
+
+    // Run the cleanup logic. The zombie draft is cleared. The published comment is untouched.
+    DeleteZombieCommentsRefs worker =
+        deleteZombieDraftsFactory.create(/* cleanupPercentage= */ 100, dryRun);
+    assertThat(worker.deleteDraftCommentsThatAreAlsoPublished()).isEqualTo(1);
+    if (dryRun) {
+      assertThat(getDraftsByParsingDraftRef(draftRef.getName(), revId)).hasSize(1);
+    } else {
+      assertThat(getDraftsByParsingDraftRef(draftRef.getName(), revId)).isEmpty();
+    }
+    assertNumPublishedComments(changeId, 1);
+  }
+
+  @Test
+  public void draftRefWithOneDraftAndOneZombie() throws Exception {
+    PushOneCommit.Result r1 = createChange();
+    String changeId = r1.getChangeId();
+    PushOneCommit.Result r2 = amendChange(changeId);
+
+    // Add two draft comments: one on PS1, the other on PS2
+    addDraft(changeId, r1.getCommit().getName(), "comment 1");
+    CommentInfo c2 = addDraft(changeId, r2.getCommit().getName(), "comment 2");
+    Ref draftRef = getOnlyDraftRef();
+
+    // Publish the draft on PS2. Now PS1 still has one draft, PS2 has no drafts
+    publishDraft(r2, c2.id);
+    assertNumDrafts(changeId, 1);
+    assertNumPublishedComments(changeId, 1);
+    assertThat(getDraftsByParsingDraftRef(draftRef.getName(), r1.getCommit().name())).hasSize(1);
+    assertThat(getDraftsByParsingDraftRef(draftRef.getName(), r2.getCommit().name())).isEmpty();
+
+    // Restore the draft ref for PS2 draft. Now draft on PS2 is zombie because it is also published.
+    restoreRef(draftRef.getName(), draftRef.getObjectId());
+    draftRef = getOnlyDraftRef();
+    assertThat(getDraftsByParsingDraftRef(draftRef.getName(), r1.getCommit().name())).hasSize(1);
+    assertThat(getDraftsByParsingDraftRef(draftRef.getName(), r2.getCommit().name())).hasSize(1);
+
+    // Run the zombie cleanup logic. Zombie draft ref for PS2 will be removed.
+    DeleteZombieCommentsRefs worker =
+        deleteZombieDraftsFactory.create(/* cleanupPercentage= */ 100, dryRun);
+    assertThat(worker.deleteDraftCommentsThatAreAlsoPublished()).isEqualTo(1);
+    assertThat(getDraftsByParsingDraftRef(draftRef.getName(), r1.getCommit().name())).hasSize(1);
+    if (dryRun) {
+      assertThat(getDraftsByParsingDraftRef(draftRef.getName(), r2.getCommit().name())).hasSize(1);
+    } else {
+      assertThat(getDraftsByParsingDraftRef(draftRef.getName(), r2.getCommit().name())).isEmpty();
+    }
+    assertNumPublishedComments(changeId, 1);
+
+    // Re-run the worker: nothing happens.
+    assertThat(worker.deleteDraftCommentsThatAreAlsoPublished()).isEqualTo(dryRun ? 1 : 0);
+    assertNumDrafts(changeId, 1);
+    assertThat(getDraftsByParsingDraftRef(draftRef.getName(), r1.getCommit().name())).hasSize(1);
+    if (dryRun) {
+      assertThat(getDraftsByParsingDraftRef(draftRef.getName(), r2.getCommit().name())).hasSize(1);
+    } else {
+      assertThat(getDraftsByParsingDraftRef(draftRef.getName(), r2.getCommit().name())).isEmpty();
+    }
+    assertNumPublishedComments(changeId, 1);
+  }
+
+  private Ref getOnlyDraftRef() throws Exception {
+    try (Repository allUsersRepo = repoManager.openRepository(allUsers)) {
+      return Iterables.getOnlyElement(
+          allUsersRepo.getRefDatabase().getRefsByPrefix(RefNames.REFS_DRAFT_COMMENTS));
+    }
+  }
+
+  private void publishAllDrafts(PushOneCommit.Result r) throws Exception {
+    ReviewInput reviewInput = new ReviewInput();
+    reviewInput.drafts = DraftHandling.PUBLISH_ALL_REVISIONS;
+    reviewInput.message = "foo";
+    revision(r).review(reviewInput);
+  }
+
+  private void publishDraft(PushOneCommit.Result r, String draftId) throws Exception {
+    ReviewInput reviewInput = new ReviewInput();
+    reviewInput.drafts = DraftHandling.PUBLISH_ALL_REVISIONS;
+    reviewInput.message = "foo";
+    reviewInput.draftIdsToPublish = ImmutableList.of(draftId);
+    revision(r).review(reviewInput);
+  }
+
+  private List<CommentInfo> getDraftComments(String changeId) throws Exception {
+    return gApi.changes().id(changeId).draftsRequest().getAsList();
+  }
+
+  private List<CommentInfo> getPublishedComments(String changeId) throws Exception {
+    return gApi.changes().id(changeId).commentsRequest().getAsList();
+  }
+
+  private CommentInfo addDraft(String changeId, String revId, String commentText) throws Exception {
+    DraftInput comment = CommentsUtil.newDraft("f1.txt", Side.REVISION, /* line= */ 1, commentText);
+    return gApi.changes().id(changeId).revision(revId).createDraft(comment).get();
+  }
+
+  private void restoreRef(String refName, ObjectId id) throws Exception {
+    try (Repository allUsersRepo = repoManager.openRepository(allUsers)) {
+      RefUpdate u = allUsersRepo.updateRef(refName);
+      u.setNewObjectId(id);
+      u.forceUpdate();
+    }
+  }
+
+  /**
+   * Returns all draft comments that are stored in {@code draftRefStr} for a specific revision
+   * (patchset) identified by its {@code blobFile} SHA-1.
+   *
+   * <p>Background: This ref points to a tree containing one or more blob files, each named after
+   * the patchset revision SHA-1, that is drafts for each patchset are stored in a separate blob
+   * file.
+   */
+  private List<HumanComment> getDraftsByParsingDraftRef(String draftRefStr, String blobFile)
+      throws Exception {
+    try (Repository allUsersRepo = repoManager.openRepository(allUsers);
+        RevWalk rw = new RevWalk(allUsersRepo)) {
+      Ref draftRef = allUsersRepo.exactRef(draftRefStr);
+      if (draftRef == null) {
+        // draft ref does not exist, i.e. no draft comments stored for this ref.
+        return ImmutableList.of();
+      }
+      RevTree revTree = rw.parseTree(draftRef.getObjectId());
+      TreeWalk tw = TreeWalk.forPath(allUsersRepo, blobFile, revTree);
+      if (tw == null) {
+        // blobFile does not exist, i.e. no draft comments for this revision.
+        return ImmutableList.of();
+      }
+      ObjectLoader open = allUsersRepo.open(tw.getObjectId(0));
+      String content = new String(open.getBytes(), UTF_8);
+      List<HumanComment> drafts =
+          changeNoteJson
+              .getGson()
+              .fromJson(
+                  JsonParser.parseString(content)
+                      .getAsJsonObject()
+                      .getAsJsonArray("comments")
+                      .toString(),
+                  new TypeLiteral<ImmutableList<HumanComment>>() {}.getType());
+      return drafts;
+    }
+  }
+
+  private void assertNumDrafts(String changeId, int num) throws Exception {
+    assertThat(getDraftComments(changeId)).hasSize(num);
+  }
+
+  private void assertNumPublishedComments(String changeId, int num) throws Exception {
+    assertThat(getPublishedComments(changeId)).hasSize(num);
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/server/change/GetRelatedIT.java b/javatests/com/google/gerrit/acceptance/server/change/GetRelatedIT.java
index 8bf7443..70b5701 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/GetRelatedIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/GetRelatedIT.java
@@ -41,6 +41,7 @@
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.extensions.api.changes.GetRelatedOption;
 import com.google.gerrit.extensions.api.changes.RelatedChangeAndCommitInfo;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.common.CommitInfo;
@@ -58,8 +59,10 @@
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
+import java.util.EnumSet;
 import java.util.List;
 import java.util.Optional;
+import javax.annotation.Nullable;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
@@ -482,6 +485,7 @@
     gApi.changes().id(changeId2).edit().modifyFile("a.txt", RawInputUtil.create(new byte[] {'a'}));
     Optional<EditInfo> edit = getEdit(changeId2);
     assertThat(edit).isPresent();
+    @SuppressWarnings("OptionalGetWithoutIsPresent")
     ObjectId editRev = ObjectId.fromString(edit.get().commit.commit);
 
     PatchSet.Id ps1_1 = getPatchSetId(c1_1);
@@ -632,6 +636,34 @@
         .containsExactly("NEW", "ABANDONED", "MERGED");
   }
 
+  @Test
+  public void submittable() throws Exception {
+    RevCommit c1 = commitBuilder().add("a.txt", "1").message("subject: 1").create();
+    RevCommit c2 = commitBuilder().add("b.txt", "2").message("subject: 2").create();
+    RevCommit c3 = commitBuilder().add("c.txt", "3").message("subject: 3").create();
+    pushHead(testRepo, "refs/for/master", false);
+    PatchSet.Id ps1 = getPatchSetId(c1);
+    PatchSet.Id ps2 = getPatchSetId(c2);
+    PatchSet.Id ps3 = getPatchSetId(c3);
+
+    for (RevCommit c : ImmutableList.of(c1, c3)) {
+      gApi.changes()
+          .id(getChange(c).change().getChangeId())
+          .current()
+          .review(ReviewInput.approve());
+    }
+
+    for (PatchSet.Id ps : ImmutableList.of(ps3, ps2, ps1)) {
+      assertRelated(
+          ps,
+          Arrays.asList(
+              changeAndCommit(ps3, c3, 1, true),
+              changeAndCommit(ps2, c2, 1, false),
+              changeAndCommit(ps1, c1, 1, true)),
+          GetRelatedOption.SUBMITTABLE);
+    }
+  }
+
   private static Correspondence<RelatedChangeAndCommitInfo, String>
       getRelatedChangeToStatusCorrespondence() {
     return Correspondence.transforming(
@@ -643,16 +675,21 @@
     return c;
   }
 
-  private PatchSet.Id getPatchSetId(ObjectId c) throws Exception {
+  private PatchSet.Id getPatchSetId(ObjectId c) {
     return getChange(c).change().currentPatchSetId();
   }
 
-  private ChangeData getChange(ObjectId c) throws Exception {
+  private ChangeData getChange(ObjectId c) {
     return Iterables.getOnlyElement(queryProvider.get().byCommit(c));
   }
 
   private RelatedChangeAndCommitInfo changeAndCommit(
       PatchSet.Id psId, ObjectId commitId, int currentRevisionNum) {
+    return changeAndCommit(psId, commitId, currentRevisionNum, null);
+  }
+
+  private RelatedChangeAndCommitInfo changeAndCommit(
+      PatchSet.Id psId, ObjectId commitId, int currentRevisionNum, @Nullable Boolean submittable) {
     RelatedChangeAndCommitInfo result = new RelatedChangeAndCommitInfo();
     result.project = project.get();
     result._changeNumber = psId.changeId().get();
@@ -661,6 +698,7 @@
     result._revisionNumber = psId.get();
     result._currentRevisionNumber = currentRevisionNum;
     result.status = "NEW";
+    result.submittable = submittable;
     return result;
   }
 
@@ -684,10 +722,18 @@
     assertRelated(psId, Arrays.asList(expected));
   }
 
-  private void assertRelated(PatchSet.Id psId, List<RelatedChangeAndCommitInfo> expected)
+  private void assertRelated(
+      PatchSet.Id psId, List<RelatedChangeAndCommitInfo> expected, GetRelatedOption... options)
       throws Exception {
     List<RelatedChangeAndCommitInfo> actual =
-        gApi.changes().id(psId.changeId().get()).revision(psId.get()).related().changes;
+        gApi.changes()
+            .id(psId.changeId().get())
+            .revision(psId.get())
+            .related(
+                options.length > 0
+                    ? EnumSet.copyOf(Arrays.asList(options))
+                    : EnumSet.noneOf(GetRelatedOption.class))
+            .changes;
     assertWithMessage("related to " + psId).that(actual).hasSize(expected.size());
     for (int i = 0; i < actual.size(); i++) {
       String name = "index " + i + " related to " + psId;
@@ -702,6 +748,7 @@
           .that(a._currentRevisionNumber)
           .isEqualTo(e._currentRevisionNumber);
       assertThat(a.status).isEqualTo(e.status);
+      assertThat(a.submittable).isEqualTo(e.submittable);
     }
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/server/git/receive/ReceiveCommitsChangeIdValidationIT.java b/javatests/com/google/gerrit/acceptance/server/git/receive/ReceiveCommitsChangeIdValidationIT.java
new file mode 100644
index 0000000..b2836fd
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/git/receive/ReceiveCommitsChangeIdValidationIT.java
@@ -0,0 +1,58 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.server.git.receive;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.Test;
+
+/** Tests for checking the validation of Change-Id during receive-commits. */
+public class ReceiveCommitsChangeIdValidationIT extends AbstractDaemonTest {
+
+  @Test
+  public void disallowTruncatingChangeIdAcrossPatchSets() throws Exception {
+    // Create the parent.
+    RevCommit parent =
+        commitBuilder().add("foo.txt", "foo content").message("base commit").create();
+    testRepo.reset(parent);
+
+    String changeId = "I0000000000000000000000000000000000000012";
+    String truncatedChangeId = "I000000000000000000000000000000000000001";
+
+    // The initial Change PS1 is accepted
+    pushFactory
+        .create(
+            admin.newIdent(),
+            testRepo,
+            "blah",
+            ImmutableMap.of("foo.txt", "first patch-set"),
+            changeId)
+        .setParent(parent)
+        .to("refs/for/master")
+        .assertOkStatus();
+
+    // The Change PS2 is rejected because the Change-Id is truncated
+    pushFactory
+        .create(
+            admin.newIdent(),
+            testRepo,
+            "blah\n\nChange-Id: " + truncatedChangeId,
+            ImmutableMap.of("foo.txt", "second patch-set"))
+        .setParent(parent)
+        .to("refs/for/master")
+        .assertErrorStatus("invalid Change-Id");
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java b/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
index 6013862..7603aec 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
@@ -33,6 +33,7 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.truth.Truth;
 import com.google.gerrit.acceptance.AbstractNotificationTest;
+import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
@@ -56,8 +57,9 @@
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.CommitInfo;
 import com.google.gerrit.extensions.common.CommitMessageInput;
-import com.google.gerrit.server.restapi.change.PostReview;
+import com.google.gerrit.server.restapi.change.PostReviewOp;
 import com.google.inject.Inject;
+import java.util.UUID;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Repository;
 import org.junit.Before;
@@ -302,32 +304,6 @@
     addReviewerToReviewableChange(batch());
   }
 
-  private void addReviewerToIgnoredChange(Adder adder) throws Exception {
-    StagedChange sc = stageReviewableChange();
-    requestScopeOperations.setApiUser(sc.reviewer.id());
-    gApi.changes().id(sc.changeId).ignore(true);
-    TestAccount addedReviewer = accountCreator.create("added", "added@example.com", "added", null);
-    addReviewer(adder, sc.changeId, sc.owner, addedReviewer.email(), CC_ON_OWN_COMMENTS, null);
-
-    assertThat(sender)
-        .sent("newchange", sc)
-        .to(addedReviewer)
-        .cc(sc.owner)
-        .cc(StagedUsers.REVIEWER_BY_EMAIL, StagedUsers.CC_BY_EMAIL)
-        .noOneElse();
-    assertThat(sender).didNotSend();
-  }
-
-  @Test
-  public void addReviewerToIgnoredChangeSingly() throws Exception {
-    addReviewerToIgnoredChange(singly());
-  }
-
-  @Test
-  public void addReviewerToIgnoredChangeBatch() throws Exception {
-    addReviewerToIgnoredChange(batch());
-  }
-
   private void addReviewerToReviewableChangeByOwnerCcingSelf(Adder adder) throws Exception {
     StagedChange sc = stageReviewableChange();
     TestAccount reviewer = accountCreator.create("added", "added@example.com", "added", null);
@@ -952,13 +928,13 @@
     // TODO(logan): Remove this test check once PolyGerrit workaround is rolled back.
     StagedChange sc = stageWipChange();
     ReviewInput in =
-        ReviewInput.noScore().message(PostReview.START_REVIEW_MESSAGE).setWorkInProgress(false);
+        ReviewInput.noScore().message(PostReviewOp.START_REVIEW_MESSAGE).setWorkInProgress(false);
     gApi.changes().id(sc.changeId).current().review(in);
     Truth.assertThat(sender.getMessages()).isNotEmpty();
     String body = sender.getMessages().get(0).body();
-    int idx = body.indexOf(PostReview.START_REVIEW_MESSAGE);
+    int idx = body.indexOf(PostReviewOp.START_REVIEW_MESSAGE);
     Truth.assertThat(idx).isAtLeast(0);
-    Truth.assertThat(body.indexOf(PostReview.START_REVIEW_MESSAGE, idx + 1)).isEqualTo(-1);
+    Truth.assertThat(body.indexOf(PostReviewOp.START_REVIEW_MESSAGE, idx + 1)).isEqualTo(-1);
   }
 
   private void review(TestAccount account, String changeId, EmailStrategy strategy)
@@ -2004,7 +1980,19 @@
   private void pushTo(StagedChange sc, String ref, TestAccount by, EmailStrategy emailStrategy)
       throws Exception {
     setEmailStrategy(by, emailStrategy);
-    pushFactory.create(by.newIdent(), sc.repo, sc.changeId).to(ref).assertOkStatus();
+
+    // Use random file content to avoid that change kind is NO_CHANGE.
+    String randomContent = UUID.randomUUID().toString();
+    pushFactory
+        .create(
+            by.newIdent(),
+            sc.repo,
+            "New Patch Set",
+            PushOneCommit.FILE_NAME,
+            randomContent,
+            sc.changeId)
+        .to(ref)
+        .assertOkStatus();
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/MailMetadataIT.java b/javatests/com/google/gerrit/acceptance/server/mail/MailMetadataIT.java
index 4e490a7..1ad27eb 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/MailMetadataIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/MailMetadataIT.java
@@ -54,7 +54,7 @@
     assertThat(emails).hasSize(1);
     FakeEmailSender.Message message = emails.get(0);
 
-    String changeURL = "<" + getChangeUrl(newChange.getChange()) + ">";
+    String changeURL = "<" + getChangeUrl(newChange.getChange()) + "?usp=email>";
 
     Map<String, Object> expectedHeaders = new HashMap<>();
     expectedHeaders.put("Gerrit-PatchSet", "1");
@@ -91,7 +91,7 @@
     assertThat(emails).hasSize(1);
     FakeEmailSender.Message message = emails.get(0);
 
-    String changeURL = "<" + getChangeUrl(newChange.getChange()) + ">";
+    String changeURL = "<" + getChangeUrl(newChange.getChange()) + "?usp=email>";
     Map<String, Object> expectedHeaders = new HashMap<>();
     expectedHeaders.put("Gerrit-PatchSet", "1");
     expectedHeaders.put(
diff --git a/javatests/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java b/javatests/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java
index d911512..1900158 100644
--- a/javatests/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java
@@ -461,39 +461,6 @@
   }
 
   @Test
-  public void watchProjectNoNotificationForIgnoredChange() throws Exception {
-    // watch project
-    String watchedProject = projectOperations.newProject().create().get();
-    requestScopeOperations.setApiUser(user.id());
-    watch(watchedProject);
-
-    // push a change to watched project
-    requestScopeOperations.setApiUser(admin.id());
-    TestRepository<InMemoryRepository> watchedRepo =
-        cloneProject(Project.nameKey(watchedProject), admin);
-    PushOneCommit.Result r =
-        pushFactory
-            .create(admin.newIdent(), watchedRepo, "ignored change", "a", "a1")
-            .to("refs/for/master");
-    r.assertOkStatus();
-
-    // ignore the change
-    requestScopeOperations.setApiUser(user.id());
-    gApi.changes().id(r.getChangeId()).ignore(true);
-
-    sender.clear();
-
-    // post a comment -> should not trigger email notification since user ignored the change
-    requestScopeOperations.setApiUser(admin.id());
-    ReviewInput in = new ReviewInput();
-    in.message = "comment";
-    gApi.changes().id(r.getChangeId()).current().review(in);
-
-    // assert email notification
-    assertThat(sender.getMessages()).isEmpty();
-  }
-
-  @Test
   public void watchProjectNoNotificationForPrivateChange() throws Exception {
     // watch project
     String watchedProject = projectOperations.newProject().create().get();
diff --git a/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java b/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java
index 731e0df..d3c4949 100644
--- a/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java
@@ -168,7 +168,7 @@
               false);
       configSubmitRequirement(project, projectSubmitRequirement);
       Map<SubmitRequirement, SubmitRequirementResult> results =
-          evaluator.evaluateAllRequirements(changeData, /* includeLegacy= */ false);
+          evaluator.evaluateAllRequirements(changeData);
       assertThat(results).hasSize(2);
       assertThat(results.get(globalSubmitRequirement).status())
           .isEqualTo(SubmitRequirementResult.Status.SATISFIED);
@@ -199,7 +199,7 @@
               false);
       configSubmitRequirement(project, projectSubmitRequirement);
       Map<SubmitRequirement, SubmitRequirementResult> results =
-          evaluator.evaluateAllRequirements(changeData, /* includeLegacy= */ false);
+          evaluator.evaluateAllRequirements(changeData);
       assertThat(results).hasSize(1);
       assertThat(results.get(projectSubmitRequirement).status())
           .isEqualTo(SubmitRequirementResult.Status.SATISFIED);
@@ -228,7 +228,7 @@
               false);
       configSubmitRequirement(project, projectSubmitRequirement);
       Map<SubmitRequirement, SubmitRequirementResult> results =
-          evaluator.evaluateAllRequirements(changeData, /* includeLegacy= */ false);
+          evaluator.evaluateAllRequirements(changeData);
       assertThat(results).hasSize(1);
       assertThat(results.get(globalSubmitRequirement).status())
           .isEqualTo(SubmitRequirementResult.Status.SATISFIED);
@@ -283,7 +283,10 @@
     SubmitRequirementResult result = evaluator.evaluateRequirement(sr, changeData);
     assertThat(result.status()).isEqualTo(SubmitRequirementResult.Status.NOT_APPLICABLE);
     assertThat(result.applicabilityExpressionResult().get().status()).isEqualTo(Status.FAIL);
-    assertThat(result.submittabilityExpressionResult().isPresent()).isFalse();
+    assertThat(result.submittabilityExpressionResult().get().status())
+        .isEqualTo(Status.NOT_EVALUATED);
+    assertThat(result.submittabilityExpressionResult().get().expression().expressionString())
+        .isEqualTo("message:\"Fix bug\"");
     assertThat(result.overrideExpressionResult().isPresent()).isFalse();
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsValidationIT.java b/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsValidationIT.java
index d8aa789..a643d56 100644
--- a/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsValidationIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsValidationIT.java
@@ -20,6 +20,7 @@
 import static com.google.gerrit.acceptance.GitUtil.pushHead;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.server.project.ProjectConfig;
 import java.util.Locale;
@@ -377,6 +378,115 @@
             invalidValue));
   }
 
+  @Test
+  public void validSubmitRequirementCanBePushedForReview_optionalParametersNotSet()
+      throws Exception {
+    fetchRefsMetaConfig();
+
+    String submitRequirementName = "Code-Review";
+    RevCommit head = getHead(testRepo.getRepository(), RefNames.REFS_CONFIG);
+    Config projectConfig = readProjectConfig(head);
+    projectConfig.setString(
+        ProjectConfig.SUBMIT_REQUIREMENT,
+        /* subsection= */ submitRequirementName,
+        /* name= */ ProjectConfig.KEY_SR_SUBMITTABILITY_EXPRESSION,
+        /* value= */ "label:\"Code-Review=+2\"");
+
+    PushOneCommit.Result r =
+        createChange(
+            testRepo,
+            RefNames.REFS_CONFIG,
+            "Add submit requirement",
+            ProjectConfig.PROJECT_CONFIG,
+            projectConfig.toText(),
+            /* topic= */ null);
+    r.assertOkStatus();
+  }
+
+  @Test
+  public void validSubmitRequirementCanBePushedForReview_allParametersSet() throws Exception {
+    fetchRefsMetaConfig();
+
+    String submitRequirementName = "Code-Review";
+    RevCommit head = getHead(testRepo.getRepository(), RefNames.REFS_CONFIG);
+    Config projectConfig = readProjectConfig(head);
+    projectConfig.setString(
+        ProjectConfig.SUBMIT_REQUIREMENT,
+        /* subsection= */ submitRequirementName,
+        /* name= */ ProjectConfig.KEY_SR_DESCRIPTION,
+        /* value= */ "foo bar description");
+    projectConfig.setString(
+        ProjectConfig.SUBMIT_REQUIREMENT,
+        /* subsection= */ submitRequirementName,
+        /* name= */ ProjectConfig.KEY_SR_APPLICABILITY_EXPRESSION,
+        /* value= */ "branch:refs/heads/master");
+    projectConfig.setString(
+        ProjectConfig.SUBMIT_REQUIREMENT,
+        /* subsection= */ submitRequirementName,
+        /* name= */ ProjectConfig.KEY_SR_SUBMITTABILITY_EXPRESSION,
+        /* value= */ "label:\"Code-Review=+2\"");
+    projectConfig.setString(
+        ProjectConfig.SUBMIT_REQUIREMENT,
+        /* subsection= */ submitRequirementName,
+        /* name= */ ProjectConfig.KEY_SR_OVERRIDE_EXPRESSION,
+        /* value= */ "label:\"override=+1\"");
+    projectConfig.setBoolean(
+        ProjectConfig.SUBMIT_REQUIREMENT,
+        /* subsection= */ submitRequirementName,
+        /* name= */ ProjectConfig.KEY_SR_OVERRIDE_IN_CHILD_PROJECTS,
+        /* value= */ false);
+
+    PushOneCommit.Result r =
+        createChange(
+            testRepo,
+            RefNames.REFS_CONFIG,
+            "Add submit requirement",
+            ProjectConfig.PROJECT_CONFIG,
+            projectConfig.toText(),
+            /* topic= */ null);
+    r.assertOkStatus();
+  }
+
+  @Test
+  public void invalidSubmitRequirementIsRejectedWhenPushingForReview() throws Exception {
+    fetchRefsMetaConfig();
+
+    String submitRequirementName = "Code-Review";
+    String invalidExpression = "invalid_field:invalid_value";
+
+    RevCommit head = getHead(testRepo.getRepository(), RefNames.REFS_CONFIG);
+    Config projectConfig = readProjectConfig(head);
+    projectConfig.setString(
+        ProjectConfig.SUBMIT_REQUIREMENT,
+        /* subsection= */ submitRequirementName,
+        /* name= */ ProjectConfig.KEY_SR_SUBMITTABILITY_EXPRESSION,
+        /* value= */ invalidExpression);
+    PushOneCommit.Result r =
+        createChange(
+            testRepo,
+            RefNames.REFS_CONFIG,
+            "Add submit requirement",
+            ProjectConfig.PROJECT_CONFIG,
+            projectConfig.toText(),
+            /* topic= */ null);
+    r.assertErrorStatus(
+        String.format(
+            "invalid submit requirement expressions in project.config (revision = %s)",
+            r.getCommit().name()));
+    assertThat(r.getMessage()).contains("Invalid project configuration");
+    assertThat(r.getMessage())
+        .contains(
+            String.format(
+                "project.config: Expression '%s' of submit requirement '%s' (parameter %s.%s.%s) is"
+                    + " invalid: Unsupported operator %s",
+                invalidExpression,
+                submitRequirementName,
+                ProjectConfig.SUBMIT_REQUIREMENT,
+                submitRequirementName,
+                ProjectConfig.KEY_SR_SUBMITTABILITY_EXPRESSION,
+                invalidExpression));
+  }
+
   private void fetchRefsMetaConfig() throws Exception {
     fetch(testRepo, RefNames.REFS_CONFIG + ":" + RefNames.REFS_CONFIG);
     testRepo.reset(RefNames.REFS_CONFIG);
diff --git a/javatests/com/google/gerrit/acceptance/server/query/ApprovalQueryIT.java b/javatests/com/google/gerrit/acceptance/server/query/ApprovalQueryIT.java
index b019354..4ce62d2 100644
--- a/javatests/com/google/gerrit/acceptance/server/query/ApprovalQueryIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/query/ApprovalQueryIT.java
@@ -25,9 +25,7 @@
 import com.google.gerrit.acceptance.testsuite.change.ChangeOperations;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.PatchSet;
-import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.extensions.client.ChangeKind;
 import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.server.change.ChangeKindCache;
@@ -35,7 +33,6 @@
 import com.google.gerrit.server.query.approval.ApprovalContext;
 import com.google.gerrit.server.query.approval.ApprovalQueryBuilder;
 import com.google.inject.Inject;
-import java.time.Instant;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.junit.Test;
@@ -300,18 +297,14 @@
     ChangeKind changeKind =
         changeKindCache.getChangeKind(
             changeNotes.getChange(), changeNotes.getPatchSets().get(newPsId));
-    PatchSetApproval approval =
-        PatchSetApproval.builder()
-            .postSubmit(false)
-            .granted(Instant.now())
-            .key(PatchSetApproval.key(psId, approver, LabelId.create("Code-Review")))
-            .value(value)
-            .build();
     try (Repository repo = repoManager.openRepository(project);
         RevWalk rw = new RevWalk(repo.newObjectReader())) {
       return ApprovalContext.create(
           changeNotes,
-          approval,
+          psId,
+          approver,
+          projectCache.get(project).get().getLabelTypes().byLabel("Code-Review").get(),
+          (short) value,
           changeNotes.getPatchSets().get(newPsId),
           changeKind,
           /* isMerge= */ false,
diff --git a/javatests/com/google/gerrit/acceptance/ssh/SshCommandsIT.java b/javatests/com/google/gerrit/acceptance/ssh/SshCommandsIT.java
index ab84e70..dd300058 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/SshCommandsIT.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/SshCommandsIT.java
@@ -44,6 +44,7 @@
   private static final ImmutableList<String> COMMON_ROOT_COMMANDS =
       ImmutableList.of(
           "apropos",
+          "check-project-access",
           "close-connection",
           "convert-ref-storage",
           "flush-caches",
diff --git a/javatests/com/google/gerrit/acceptance/ssh/StreamEventsIT.java b/javatests/com/google/gerrit/acceptance/ssh/StreamEventsIT.java
index 13a9e0c..80b8ff0 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/StreamEventsIT.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/StreamEventsIT.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance.ssh;
 
 import static com.google.gerrit.acceptance.WaitUtil.waitUntil;
+import static com.google.gerrit.entities.Patch.PATCHSET_LEVEL;
 
 import com.google.common.base.Splitter;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
@@ -22,13 +23,17 @@
 import com.google.gerrit.acceptance.Sandboxed;
 import com.google.gerrit.acceptance.UseSsh;
 import com.google.gerrit.extensions.api.changes.ChangeApi;
+import com.google.gerrit.extensions.api.changes.DraftInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput.DraftHandling;
+import com.google.gerrit.server.query.change.ChangeData;
 import java.io.IOException;
 import java.io.Reader;
 import java.time.Duration;
 import java.util.List;
 import java.util.function.Supplier;
 import java.util.stream.Collectors;
+import java.util.stream.Stream;
 import java.util.stream.StreamSupport;
 import org.junit.After;
 import org.junit.Before;
@@ -40,7 +45,9 @@
 public class StreamEventsIT extends AbstractDaemonTest {
   private static final Duration MAX_DURATION_FOR_RECEIVING_EVENTS = Duration.ofSeconds(2);
   private static final String TEST_REVIEW_COMMENT = "any comment";
+  private static final String TEST_REVIEW_DRAFT_COMMENT = "any draft comment";
   private Reader streamEventsReader;
+  private ChangeData change;
 
   @Before
   public void setup() throws Exception {
@@ -59,6 +66,23 @@
   }
 
   @Test
+  public void publishedDraftPatchSetLevelCommentShowsUpInStreamEvents() throws Exception {
+    change = createChange().getChange();
+
+    String firstDraftComment = String.format("%s 1", TEST_REVIEW_DRAFT_COMMENT);
+    String secondDraftComment = String.format("%s 2", TEST_REVIEW_DRAFT_COMMENT);
+
+    draftReviewChange(PATCHSET_LEVEL, firstDraftComment);
+    draftReviewChange(PATCHSET_LEVEL, secondDraftComment);
+    publishDraftReviews();
+
+    waitForEvent(
+        () ->
+            pollEventsContaining("comment-added", firstDraftComment, secondDraftComment).size()
+                == 1);
+  }
+
+  @Test
   public void batchRefsUpdatedShowSeparatelyInStreamEvents() throws Exception {
     String refName = createChange().getChange().currentPatchSet().refName();
     waitForEvent(
@@ -77,7 +101,22 @@
     changeApi.current().review(reviewInput);
   }
 
-  private List<String> pollEventsContaining(String eventType, String expectedContent) {
+  private void draftReviewChange(String path, String reviewMessage) throws Exception {
+    DraftInput draftInput = new DraftInput();
+    draftInput.message = reviewMessage;
+    draftInput.path = path;
+    ChangeApi changeApi = gApi.changes().id(change.getId().get());
+    changeApi.current().createDraft(draftInput).get();
+  }
+
+  private void publishDraftReviews() throws Exception {
+    ReviewInput reviewInput = new ReviewInput();
+    reviewInput.tag = "new_tag";
+    reviewInput.drafts = DraftHandling.PUBLISH;
+    gApi.changes().id(change.getId().get()).current().review(reviewInput);
+  }
+
+  private List<String> pollEventsContaining(String eventType, String... expectedContent) {
     try {
       char[] cbuf = new char[2048];
       StringBuilder eventsOutput = new StringBuilder();
@@ -90,7 +129,7 @@
           .filter(
               event ->
                   event.contains(String.format("\"type\":\"%s\"", eventType))
-                      && event.contains(expectedContent))
+                      && Stream.of(expectedContent).allMatch(event::contains))
           .collect(Collectors.toList());
     } catch (IOException e) {
       throw new IllegalStateException(e);
diff --git a/javatests/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImplTest.java b/javatests/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImplTest.java
index 0bd6554..6c629c9 100644
--- a/javatests/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImplTest.java
+++ b/javatests/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImplTest.java
@@ -24,6 +24,7 @@
 import static com.google.gerrit.truth.MapSubject.assertThatMap;
 import static com.google.gerrit.truth.OptionalSubject.assertThat;
 
+import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.truth.Correspondence;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
@@ -610,6 +611,25 @@
   }
 
   @Test
+  public void createdChangeHasSpecifiedTopic() throws Exception {
+    Change.Id changeId = changeOperations.newChange().topic("test-topic").create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    assertThat(change.topic).isEqualTo("test-topic");
+  }
+
+  @Test
+  public void createdChangeHasSpecifiedApprovals() throws Exception {
+    Change.Id changeId =
+        changeOperations.newChange().approvals(ImmutableMap.of("Code-Review", (short) 1)).create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    assertThat(change.labels).hasSize(1);
+    assertThat(change.labels.get("Code-Review").recommended._accountId)
+        .isEqualTo(change.owner._accountId);
+  }
+
+  @Test
   public void createdChangeHasSpecifiedCommitMessage() throws Exception {
     Change.Id changeId =
         changeOperations
diff --git a/javatests/com/google/gerrit/entities/LabelTypeTest.java b/javatests/com/google/gerrit/entities/LabelTypeTest.java
index f31f2c9..fcbe386 100644
--- a/javatests/com/google/gerrit/entities/LabelTypeTest.java
+++ b/javatests/com/google/gerrit/entities/LabelTypeTest.java
@@ -30,18 +30,6 @@
   }
 
   @Test
-  public void sortCopyValues() {
-    LabelValue v0 = LabelValue.create((short) 0, "Zero");
-    LabelValue v1 = LabelValue.create((short) 1, "One");
-    LabelValue v2 = LabelValue.create((short) 2, "Two");
-    LabelType types =
-        LabelType.builder("Label", ImmutableList.of(v2, v0, v1))
-            .setCopyValues(ImmutableList.of((short) 2, (short) 0, (short) 1))
-            .build();
-    assertThat(types.getCopyValues()).containsExactly((short) 0, (short) 1, (short) 2).inOrder();
-  }
-
-  @Test
   public void insertMissingLabelValues() {
     LabelValue v0 = LabelValue.create((short) 0, "Zero");
     LabelValue v2 = LabelValue.create((short) 2, "Two");
diff --git a/javatests/com/google/gerrit/index/BUILD b/javatests/com/google/gerrit/index/BUILD
index 861e768..7cb86e7 100644
--- a/javatests/com/google/gerrit/index/BUILD
+++ b/javatests/com/google/gerrit/index/BUILD
@@ -9,6 +9,7 @@
         "//antlr3:query_parser",
         "//java/com/google/gerrit/index",
         "//java/com/google/gerrit/index:query_exception",
+        "//java/com/google/gerrit/index/project",
         "//java/com/google/gerrit/index/query/testing",
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/testing:gerrit-test-util",
diff --git a/javatests/com/google/gerrit/index/IndexUpgradeTest.java b/javatests/com/google/gerrit/index/IndexUpgradeTest.java
new file mode 100644
index 0000000..724964b
--- /dev/null
+++ b/javatests/com/google/gerrit/index/IndexUpgradeTest.java
@@ -0,0 +1,67 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.index;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.index.project.ProjectSchemaDefinitions;
+import com.google.gerrit.server.index.account.AccountSchemaDefinitions;
+import com.google.gerrit.server.index.change.ChangeSchemaDefinitions;
+import com.google.gerrit.server.index.group.GroupSchemaDefinitions;
+import java.util.Collection;
+import java.util.Map.Entry;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.Parameterized;
+import org.junit.runners.Parameterized.Parameter;
+import org.junit.runners.Parameterized.Parameters;
+
+/** Validates index upgrades; see {@link IndexUpgradeValidator} for details. */
+@RunWith(Parameterized.class)
+public class IndexUpgradeTest {
+  /** This is the first version to which {@link IndexUpgradeValidator} is applied. */
+  private static final ImmutableMap<Class<? extends SchemaDefinitions<?>>, Integer>
+      ENFORCE_UPDATE_RESTRICTIONS_FROM_VERSION =
+          ImmutableMap.of(
+              AccountSchemaDefinitions.class, 12,
+              ChangeSchemaDefinitions.class, 78,
+              GroupSchemaDefinitions.class, 8,
+              ProjectSchemaDefinitions.class, 4);
+
+  @Parameter public SchemaDefinitions<?> schemaDefinitions;
+
+  @Parameters(name = "schema: {0}")
+  public static Collection<SchemaDefinitions<?>> indexes() {
+    return ImmutableList.of(
+        AccountSchemaDefinitions.INSTANCE,
+        ChangeSchemaDefinitions.INSTANCE,
+        GroupSchemaDefinitions.INSTANCE,
+        ProjectSchemaDefinitions.INSTANCE);
+  }
+
+  @Test
+  public void upgradesValid() {
+    Schema<?> previousSchema = null;
+    for (Entry<Integer, ? extends Schema<?>> entry : schemaDefinitions.getSchemas().entrySet()) {
+      Schema<?> schema = entry.getValue();
+      if (previousSchema != null
+          && schema.getVersion()
+              >= ENFORCE_UPDATE_RESTRICTIONS_FROM_VERSION.get(schemaDefinitions.getClass())) {
+        IndexUpgradeValidator.assertValid(previousSchema, schema);
+      }
+      previousSchema = schema;
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/index/IndexUpgradeValidator.java b/javatests/com/google/gerrit/index/IndexUpgradeValidator.java
new file mode 100644
index 0000000..5bcc6ff
--- /dev/null
+++ b/javatests/com/google/gerrit/index/IndexUpgradeValidator.java
@@ -0,0 +1,64 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.index;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.truth.Truth.assertWithMessage;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Sets;
+import com.google.common.collect.Sets.SetView;
+import org.junit.Ignore;
+
+/**
+ * Validates index upgrades to enforce the following constraints: Upgrades may only add or remove
+ * fields. They may not do both, and may not change field types.
+ */
+@Ignore
+public class IndexUpgradeValidator {
+
+  public static void assertValid(Schema<?> previousSchema, Schema<?> schema) {
+    assertValid(previousSchema.getSchemaFields(), schema.getSchemaFields(), schema.getVersion());
+    assertValid(previousSchema.getIndexFields(), schema.getIndexFields(), schema.getVersion());
+  }
+
+  private static void assertValid(
+      ImmutableMap<String, ?> previousSchemaFields,
+      ImmutableMap<String, ?> schemaFields,
+      int schemaVersion) {
+    SetView<String> addedFields =
+        Sets.difference(schemaFields.keySet(), previousSchemaFields.keySet());
+    SetView<String> removedFields =
+        Sets.difference(previousSchemaFields.keySet(), schemaFields.keySet());
+    SetView<String> keptFields =
+        Sets.intersection(previousSchemaFields.keySet(), schemaFields.keySet());
+    assertWithMessage(
+            "Schema upgrade to version "
+                + schemaVersion
+                + " may either add or remove fields, but not both")
+        .that(addedFields.isEmpty() || removedFields.isEmpty())
+        .isTrue();
+    ImmutableList<String> modifiedFields =
+        keptFields.stream()
+            .filter(fieldName -> previousSchemaFields.get(fieldName) != schemaFields.get(fieldName))
+            .collect(toImmutableList());
+    assertWithMessage("Fields may not be modified (create a new field instead)")
+        .that(modifiedFields)
+        .isEmpty();
+  }
+
+  private IndexUpgradeValidator() {}
+}
diff --git a/javatests/com/google/gerrit/index/IndexUpgradeValidatorTest.java b/javatests/com/google/gerrit/index/IndexUpgradeValidatorTest.java
new file mode 100644
index 0000000..c2caff8
--- /dev/null
+++ b/javatests/com/google/gerrit/index/IndexUpgradeValidatorTest.java
@@ -0,0 +1,89 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.index;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.index.SchemaUtil.schema;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.gerrit.index.SchemaFieldDefs.Getter;
+import com.google.gerrit.server.index.change.ChangeField;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.ChangeQueryBuilder;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/** Tests {@link IndexUpgradeValidator}. */
+@RunWith(JUnit4.class)
+public class IndexUpgradeValidatorTest {
+
+  // TODO(mariasavtchouk): adopt this test to verity IndexedFields follow the same constraints as
+  // SchemaFields.
+  @Test
+  public void valid() {
+    IndexUpgradeValidator.assertValid(schema(1, ChangeField.ID), schema(2, ChangeField.ID));
+    IndexUpgradeValidator.assertValid(
+        schema(1, ChangeField.ID), schema(2, ChangeField.ID, ChangeField.OWNER));
+    IndexUpgradeValidator.assertValid(
+        schema(1, ChangeField.ID),
+        schema(2, ChangeField.ID, ChangeField.OWNER, ChangeField.COMMITTER));
+  }
+
+  @Test
+  public void invalid_addAndRemove() {
+    AssertionError e =
+        assertThrows(
+            AssertionError.class,
+            () ->
+                IndexUpgradeValidator.assertValid(
+                    schema(1, ChangeField.ID), schema(2, ChangeField.OWNER)));
+    assertThat(e)
+        .hasMessageThat()
+        .contains("Schema upgrade to version 2 may either add or remove fields, but not both");
+  }
+
+  @Test
+  public void invalid_modify() {
+    // Change value type from String to Integer.
+    FieldDef<ChangeData, Integer> ID_MODIFIED =
+        new FieldDef.Builder<>(FieldType.INTEGER, ChangeQueryBuilder.FIELD_CHANGE_ID)
+            .build(cd -> 42);
+    AssertionError e =
+        assertThrows(
+            AssertionError.class,
+            () ->
+                IndexUpgradeValidator.assertValid(
+                    schema(1, ChangeField.ID), schema(2, ID_MODIFIED)));
+    assertThat(e).hasMessageThat().contains("Fields may not be modified");
+    assertThat(e).hasMessageThat().contains(ChangeQueryBuilder.FIELD_CHANGE_ID);
+  }
+
+  @Test
+  public void invalid_modify_referenceEquality() {
+    // Comparison uses Object.equals(), i.e. reference equality.
+    Getter<ChangeData, String> getter = cd -> cd.change().getKey().get();
+    FieldDef<ChangeData, String> ID_1 =
+        new FieldDef.Builder<>(FieldType.PREFIX, ChangeQueryBuilder.FIELD_CHANGE_ID).build(getter);
+    FieldDef<ChangeData, String> ID_2 =
+        new FieldDef.Builder<>(FieldType.PREFIX, ChangeQueryBuilder.FIELD_CHANGE_ID).build(getter);
+    AssertionError e =
+        assertThrows(
+            AssertionError.class,
+            () -> IndexUpgradeValidator.assertValid(schema(1, ID_1), schema(2, ID_2)));
+    assertThat(e).hasMessageThat().contains("Fields may not be modified");
+    assertThat(e).hasMessageThat().contains(ChangeQueryBuilder.FIELD_CHANGE_ID);
+  }
+}
diff --git a/javatests/com/google/gerrit/index/SchemaUtilTest.java b/javatests/com/google/gerrit/index/SchemaUtilTest.java
index 698e00a..a92ee0c 100644
--- a/javatests/com/google/gerrit/index/SchemaUtilTest.java
+++ b/javatests/com/google/gerrit/index/SchemaUtilTest.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.index;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.index.FieldDef.exact;
 import static com.google.gerrit.index.SchemaUtil.getNameParts;
 import static com.google.gerrit.index.SchemaUtil.getPersonParts;
 import static com.google.gerrit.index.SchemaUtil.schema;
@@ -23,17 +24,37 @@
 import java.util.Map;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
 
+@RunWith(JUnit4.class)
 public class SchemaUtilTest {
+
+  private static final FieldDef<String, String> TEST_DEF =
+      exact("test_id").stored().build(id -> id);
+
+  private static final FieldDef<String, String> OTHER_TEST_DEF =
+      exact("other_test_id").stored().build(id -> id);
+
+  private static final IndexedField<String, String> TEST_FIELD =
+      IndexedField.<String>stringBuilder("TestId").build(a -> a);
+
+  private static final IndexedField<String, String> TEST_FIELD_DUPLICATE_NAME =
+      IndexedField.<String>stringBuilder(TEST_DEF.getName()).build(a -> a);
+
+  private static final IndexedField<String, String>.SearchSpec TEST_FIELD_SPEC =
+      TEST_FIELD.exact(TEST_DEF.getName());
+
   static class TestSchemas {
-    static final Schema<String> V1 = schema();
-    static final Schema<String> V2 = schema();
-    static Schema<String> V3 = schema(); // Not final, ignored.
-    private static final Schema<String> V4 = schema();
+
+    static final Schema<String> V1 = schema(/* version= */ 1);
+    static final Schema<String> V2 = schema(/* version= */ 2);
+    static Schema<String> V3 = schema(V2); // Not final, ignored.
+    private static final Schema<String> V4 = schema(V3);
 
     // Ignored.
-    static Schema<String> V10 = schema();
-    final Schema<String> V11 = schema();
+    static Schema<String> V10 = schema(/* version= */ 10);
+    final Schema<String> V11 = schema(V10);
   }
 
   @Test
@@ -49,6 +70,14 @@
   }
 
   @Test
+  public void schemaVersion_incrementedOnVersionUpgrades() {
+    Schema<String> initialSchemaVersion = schema(/* version= */ 1);
+    Schema<String> schemaVersionUpgrade = schema(initialSchemaVersion);
+    assertThat(initialSchemaVersion.getVersion()).isEqualTo(1);
+    assertThat(schemaVersionUpgrade.getVersion()).isEqualTo(2);
+  }
+
+  @Test
   public void getPersonPartsExtractsParts() {
     // PersonIdent allows empty email, which should be extracted as the empty
     // string. However, it converts empty names to null internally.
@@ -77,4 +106,169 @@
     assertThat(getNameParts("foO-bAr_Baz a.b@c/d"))
         .containsExactly("foo", "bar", "baz", "a", "b", "c", "d");
   }
+
+  @Test
+  public void canAddFieldSpecAndFieldDef() {
+    Schema<String> schema0 =
+        new Schema.Builder<String>()
+            .version(0)
+            .addIndexedFields(TEST_FIELD)
+            .addSearchSpecs(TEST_FIELD_SPEC)
+            .add(OTHER_TEST_DEF)
+            .build();
+
+    assertThat(schema0.hasField(TEST_FIELD_SPEC)).isTrue();
+    assertThat(schema0.hasField(OTHER_TEST_DEF)).isTrue();
+    assertThat(schema0.getIndexFields().values()).contains(TEST_FIELD);
+  }
+
+  @Test
+  public void canRemoveIndexedField() {
+    Schema<String> schema0 =
+        new Schema.Builder<String>()
+            .version(0)
+            .addIndexedFields(TEST_FIELD)
+            .addSearchSpecs(TEST_FIELD_SPEC)
+            .build();
+
+    Schema<String> schema1 =
+        new Schema.Builder<String>()
+            .add(schema0)
+            .remove(TEST_FIELD_SPEC)
+            .remove(TEST_FIELD)
+            .build();
+    assertThat(schema1.hasField(TEST_FIELD_SPEC)).isFalse();
+    assertThat(schema1.getIndexFields().values()).doesNotContain(TEST_FIELD);
+  }
+
+  @Test
+  public void canRemoveSearchSpec() {
+    Schema<String> schema0 =
+        new Schema.Builder<String>()
+            .version(0)
+            .addIndexedFields(TEST_FIELD)
+            .addSearchSpecs(TEST_FIELD_SPEC)
+            .build();
+
+    Schema<String> schema1 =
+        new Schema.Builder<String>().add(schema0).remove(TEST_FIELD_SPEC).build();
+    assertThat(schema1.hasField(TEST_FIELD_SPEC)).isFalse();
+    assertThat(schema1.getIndexFields().values()).contains(TEST_FIELD);
+  }
+
+  @Test
+  public void canRemoveFieldDef() {
+    Schema<String> schema0 =
+        new Schema.Builder<String>()
+            .version(0)
+            .addIndexedFields(TEST_FIELD)
+            .addSearchSpecs(TEST_FIELD_SPEC)
+            .add(OTHER_TEST_DEF)
+            .build();
+
+    Schema<String> schema1 =
+        new Schema.Builder<String>().add(schema0).remove(OTHER_TEST_DEF).build();
+    assertThat(schema1.hasField(TEST_FIELD_SPEC)).isTrue();
+    assertThat(schema1.hasField(OTHER_TEST_DEF)).isFalse();
+    assertThat(schema1.getIndexFields().values()).contains(TEST_FIELD);
+  }
+
+  @Test
+  public void addSearchWithoutStoredField_disallowed() {
+    IllegalArgumentException thrown =
+        assertThrows(
+            IllegalArgumentException.class,
+            () -> new Schema.Builder<String>().version(0).addSearchSpecs(TEST_FIELD_SPEC).build());
+    assertThat(thrown)
+        .hasMessageThat()
+        .isEqualTo("test_id spec can only be added to the schema that contains TestId field");
+  }
+
+  @Test
+  public void addDuplicateIndexField_disallowed() {
+    IllegalArgumentException thrown =
+        assertThrows(
+            IllegalArgumentException.class,
+            () ->
+                new Schema.Builder<String>()
+                    .version(0)
+                    .addIndexedFields(TEST_FIELD)
+                    .addIndexedFields(TEST_FIELD)
+                    .build());
+    assertThat(thrown).hasMessageThat().contains("Multiple entries with same key: TestId");
+  }
+
+  @Test
+  public void addDuplicateSearchSpec_disallowed() {
+    IllegalArgumentException thrown =
+        assertThrows(
+            IllegalArgumentException.class,
+            () ->
+                new Schema.Builder<String>()
+                    .version(0)
+                    .addIndexedFields(TEST_FIELD)
+                    .addSearchSpecs(TEST_FIELD_SPEC)
+                    .addSearchSpecs(TEST_FIELD_SPEC)
+                    .build());
+    assertThat(thrown).hasMessageThat().contains("Multiple entries with same key: test_id");
+  }
+
+  @Test
+  public void addFieldDefWithDuplicateSearchName_disallowed() {
+    IllegalArgumentException thrown =
+        assertThrows(
+            IllegalArgumentException.class,
+            () ->
+                new Schema.Builder<String>()
+                    .version(0)
+                    .addIndexedFields(TEST_FIELD)
+                    .addSearchSpecs(TEST_FIELD_SPEC)
+                    .add(TEST_DEF)
+                    .build());
+    assertThat(thrown).hasMessageThat().contains("Multiple entries with same key: test_id");
+  }
+
+  @Test
+  public void addFieldDefWithDuplicateFieldName_disallowed() {
+    IllegalArgumentException thrown =
+        assertThrows(
+            IllegalArgumentException.class,
+            () ->
+                new Schema.Builder<String>()
+                    .version(0)
+                    .addIndexedFields(TEST_FIELD_DUPLICATE_NAME)
+                    .add(TEST_DEF)
+                    .build());
+    assertThat(thrown)
+        .hasMessageThat()
+        .isEqualTo("DuplicateKeys found [test_id], indexFields:[test_id], schemaFields: [test_id]");
+  }
+
+  @Test
+  public void removeFieldWithExistingSearchSpec_disallowed() {
+    Schema<String> schema0 =
+        new Schema.Builder<String>()
+            .version(0)
+            .addIndexedFields(TEST_FIELD)
+            .addSearchSpecs(TEST_FIELD_SPEC)
+            .build();
+
+    IllegalArgumentException thrown =
+        assertThrows(
+            IllegalArgumentException.class,
+            () -> new Schema.Builder<String>().add(schema0).remove(TEST_FIELD).build());
+    assertThat(thrown)
+        .hasMessageThat()
+        .isEqualTo(
+            "Field TestId can be only removed from schema after all of its searches are removed.");
+
+    Schema<String> schema1 =
+        new Schema.Builder<String>()
+            .add(schema0)
+            .remove(TEST_FIELD_SPEC)
+            .remove(TEST_FIELD)
+            .build();
+    assertThat(schema1.hasField(TEST_FIELD_SPEC)).isFalse();
+    assertThat(schema1.getIndexFields().values()).doesNotContain(TEST_FIELD);
+  }
 }
diff --git a/javatests/com/google/gerrit/index/query/QueryBuilderTest.java b/javatests/com/google/gerrit/index/query/QueryBuilderTest.java
index f653759..eb3358d 100644
--- a/javatests/com/google/gerrit/index/query/QueryBuilderTest.java
+++ b/javatests/com/google/gerrit/index/query/QueryBuilderTest.java
@@ -57,6 +57,7 @@
     }
 
     @Operator
+    @SuppressWarnings("unused")
     public Predicate<Object> a(String value) {
       return new TestPredicate("a", value);
     }
diff --git a/javatests/com/google/gerrit/index/query/QueryParserTest.java b/javatests/com/google/gerrit/index/query/QueryParserTest.java
index 2ff56a8..268c388 100644
--- a/javatests/com/google/gerrit/index/query/QueryParserTest.java
+++ b/javatests/com/google/gerrit/index/query/QueryParserTest.java
@@ -20,6 +20,7 @@
 import static com.google.gerrit.index.query.QueryParser.EXACT_PHRASE;
 import static com.google.gerrit.index.query.QueryParser.FIELD_NAME;
 import static com.google.gerrit.index.query.QueryParser.NOT;
+import static com.google.gerrit.index.query.QueryParser.OR;
 import static com.google.gerrit.index.query.QueryParser.SINGLE_WORD;
 import static com.google.gerrit.index.query.QueryParser.parse;
 import static com.google.gerrit.index.query.testing.TreeSubject.assertThat;
@@ -244,6 +245,36 @@
   }
 
   @Test
+  public void upperCaseParsedAsOperators() throws Exception {
+    Tree r = parse("project:foo:bar AND file:baz");
+    assertThat(r).hasType(AND);
+    assertThat(r).hasChildCount(2);
+
+    r = parse("project:foo:bar OR file:baz");
+    assertThat(r).hasType(OR);
+    assertThat(r).hasChildCount(2);
+
+    r = parse("NOT project:foo:bar");
+    assertThat(r).hasType(NOT);
+    assertThat(r).hasChildCount(1);
+  }
+
+  @Test
+  public void lowerCaseParsedAsOperators() throws Exception {
+    Tree r = parse("project:foo:bar and file:baz");
+    assertThat(r).hasType(AND);
+    assertThat(r).hasChildCount(2);
+
+    r = parse("project:foo:bar or file:baz");
+    assertThat(r).hasType(OR);
+    assertThat(r).hasChildCount(2);
+
+    r = parse("not project:foo:bar");
+    assertThat(r).hasType(NOT);
+    assertThat(r).hasChildCount(1);
+  }
+
+  @Test
   public void fieldNameWithNot() throws Exception {
     Tree r = parse("-foo:bar");
     assertThat(r).hasType(NOT);
diff --git a/javatests/com/google/gerrit/pgm/http/jetty/ProjectQoSFilterTest.java b/javatests/com/google/gerrit/pgm/http/jetty/ProjectQoSFilterTest.java
index b969d68..7cc4b2e 100644
--- a/javatests/com/google/gerrit/pgm/http/jetty/ProjectQoSFilterTest.java
+++ b/javatests/com/google/gerrit/pgm/http/jetty/ProjectQoSFilterTest.java
@@ -49,6 +49,7 @@
   @Mock ServletContext context;
 
   @Test
+  @SuppressWarnings("DoNotCall")
   public void shouldCallTaskEndOnListenerCompleteFromDifferentThread() {
     ProjectQoSFilter.TaskThunk taskThunk = getTaskThunk();
     ScheduledThreadPoolExecutor scheduledThreadPoolExecutor = new ScheduledThreadPoolExecutor(1);
@@ -71,6 +72,7 @@
   }
 
   @Test
+  @SuppressWarnings("DoNotCall")
   public void shouldCallTaskEndOnListenerTimeoutFromDifferentThread() {
     ProjectQoSFilter.TaskThunk taskThunk = getTaskThunk();
     ScheduledThreadPoolExecutor scheduledThreadPoolExecutor = new ScheduledThreadPoolExecutor(1);
@@ -93,6 +95,7 @@
   }
 
   @Test
+  @SuppressWarnings("DoNotCall")
   public void shouldCallTaskEndOnListenerErrorFromDifferentThread() {
     ProjectQoSFilter.TaskThunk taskThunk = getTaskThunk();
     ScheduledThreadPoolExecutor scheduledThreadPoolExecutor = new ScheduledThreadPoolExecutor(1);
diff --git a/javatests/com/google/gerrit/server/account/UniversalGroupBackendTest.java b/javatests/com/google/gerrit/server/account/UniversalGroupBackendTest.java
index 1e3063e..5f062be 100644
--- a/javatests/com/google/gerrit/server/account/UniversalGroupBackendTest.java
+++ b/javatests/com/google/gerrit/server/account/UniversalGroupBackendTest.java
@@ -30,6 +30,7 @@
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.AccountGroup.UUID;
 import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.metrics.DisabledMetricMaker;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.gerrit.server.plugincontext.PluginContext.PluginMetrics;
@@ -56,7 +57,8 @@
     backends.add("gerrit", new SystemGroupBackend(new Config()));
     backend =
         new UniversalGroupBackend(
-            new PluginSetContext<>(backends, PluginMetrics.DISABLED_INSTANCE));
+            new PluginSetContext<>(backends, PluginMetrics.DISABLED_INSTANCE),
+            new DisabledMetricMaker());
   }
 
   @Test
@@ -124,7 +126,8 @@
     backends.add("gerrit", backend);
     backend =
         new UniversalGroupBackend(
-            new PluginSetContext<>(backends, PluginMetrics.DISABLED_INSTANCE));
+            new PluginSetContext<>(backends, PluginMetrics.DISABLED_INSTANCE),
+            new DisabledMetricMaker());
 
     GroupMembership checker = backend.membershipsOf(member);
     assertFalse(checker.contains(REGISTERED_USERS));
diff --git a/javatests/com/google/gerrit/server/cache/PerThreadCacheTest.java b/javatests/com/google/gerrit/server/cache/PerThreadCacheTest.java
index e6974d1..6966302 100644
--- a/javatests/com/google/gerrit/server/cache/PerThreadCacheTest.java
+++ b/javatests/com/google/gerrit/server/cache/PerThreadCacheTest.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.cache;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth8.assertThat;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import java.util.function.Supplier;
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/LabelTypeSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/LabelTypeSerializerTest.java
index b759fec..872ced9 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/entities/LabelTypeSerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/LabelTypeSerializerTest.java
@@ -38,17 +38,6 @@
           .setRefPatterns(ImmutableList.of("refs/heads/*", "refs/tags/*"))
           .setDefaultValue((short) 1)
           .setCopyCondition("is:ANY")
-          .setCopyAnyScore(!LabelType.DEF_COPY_ANY_SCORE)
-          .setCopyMaxScore(!LabelType.DEF_COPY_MAX_SCORE)
-          .setCopyMinScore(!LabelType.DEF_COPY_MIN_SCORE)
-          .setCopyAllScoresIfListOfFilesDidNotChange(
-              !LabelType.DEF_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE)
-          .setCopyAllScoresOnMergeFirstParentUpdate(
-              !LabelType.DEF_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE)
-          .setCopyAllScoresOnTrivialRebase(!LabelType.DEF_COPY_ALL_SCORES_ON_TRIVIAL_REBASE)
-          .setCopyAllScoresIfNoCodeChange(!LabelType.DEF_COPY_ALL_SCORES_IF_NO_CODE_CHANGE)
-          .setCopyAllScoresIfNoChange(!LabelType.DEF_COPY_ALL_SCORES_IF_NO_CHANGE)
-          .setCopyValues(ImmutableList.of((short) 0, (short) 1))
           .setMaxNegative((short) -1)
           .setMaxPositive((short) 1)
           .build();
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementExpressionResultSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementExpressionResultSerializerTest.java
index 4705c55..93f18d6 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementExpressionResultSerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementExpressionResultSerializerTest.java
@@ -22,6 +22,7 @@
 import com.google.gerrit.entities.SubmitRequirementExpression;
 import com.google.gerrit.entities.SubmitRequirementExpressionResult;
 import com.google.gerrit.entities.SubmitRequirementExpressionResult.Status;
+import com.google.gerrit.server.cache.proto.Cache.SubmitRequirementExpressionResultProto;
 import java.util.Optional;
 import org.junit.Test;
 
@@ -50,4 +51,12 @@
   public void roundTrip_withErrorMessage() throws Exception {
     assertThat(deserialize(serialize(r2))).isEqualTo(r2);
   }
+
+  @Test
+  public void deserializeUnknownStatus() throws Exception {
+    SubmitRequirementExpressionResultProto proto =
+        serialize(r1).toBuilder().setStatus("unknown").build();
+    assertThat(deserialize(proto).status())
+        .isEqualTo(SubmitRequirementExpressionResult.Status.ERROR);
+  }
 }
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementJsonSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementJsonSerializerTest.java
index 7b8db25..7e71a3e 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementJsonSerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementJsonSerializerTest.java
@@ -191,6 +191,18 @@
   }
 
   @Test
+  public void submitRequirementExpressionResult_deserializeUnrecognizedStatus() throws Exception {
+    // If the status field has an unrecognized value while deserialization, we set the status field
+    // to ERROR.
+    String serial = srExpResultSerial.replace("FAIL", "UNKNOWN");
+    SubmitRequirementExpressionResult entity =
+        srExpResult.toBuilder().status(SubmitRequirementExpressionResult.Status.ERROR).build();
+    TypeAdapter<SubmitRequirementExpressionResult> adapter =
+        SubmitRequirementExpressionResult.typeAdapter(gson);
+    assertThat(adapter.fromJson(serial)).isEqualTo(entity);
+  }
+
+  @Test
   public void submitRequirementResult_serialize() throws Exception {
     assertThat(SubmitRequirementResult.typeAdapter(gson).toJson(srReqResult))
         .isEqualTo(srReqResultSerial);
diff --git a/javatests/com/google/gerrit/server/change/LabelNormalizerTest.java b/javatests/com/google/gerrit/server/change/LabelNormalizerTest.java
index b048163..6cbbd26 100644
--- a/javatests/com/google/gerrit/server/change/LabelNormalizerTest.java
+++ b/javatests/com/google/gerrit/server/change/LabelNormalizerTest.java
@@ -21,7 +21,7 @@
 import static com.google.gerrit.server.project.testing.TestLabels.value;
 import static org.junit.Assert.assertEquals;
 
-import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.entities.AccessSection;
 import com.google.gerrit.entities.Account;
@@ -50,7 +50,6 @@
 import com.google.inject.Guice;
 import com.google.inject.Inject;
 import com.google.inject.Injector;
-import java.util.List;
 import org.eclipse.jgit.lib.Repository;
 import org.junit.After;
 import org.junit.Before;
@@ -149,7 +148,7 @@
 
     PatchSetApproval cr = psa(userId, LabelId.CODE_REVIEW, 2);
     PatchSetApproval v = psa(userId, LabelId.VERIFIED, 1);
-    assertEquals(Result.create(list(cr, v), list(), list()), norm.normalize(notes, list(cr, v)));
+    assertEquals(Result.create(set(cr, v), set(), set()), norm.normalize(notes, set(cr, v)));
   }
 
   @Test
@@ -167,15 +166,15 @@
     PatchSetApproval cr = psa(userId, LabelId.CODE_REVIEW, 5);
     PatchSetApproval v = psa(userId, LabelId.VERIFIED, 5);
     assertEquals(
-        Result.create(list(), list(copy(cr, 2), copy(v, 1)), list()),
-        norm.normalize(notes, list(cr, v)));
+        Result.create(set(), set(copy(cr, 2), copy(v, 1)), set()),
+        norm.normalize(notes, set(cr, v)));
   }
 
   @Test
   public void emptyPermissionRangeKeepsResult() throws Exception {
     PatchSetApproval cr = psa(userId, LabelId.CODE_REVIEW, 1);
     PatchSetApproval v = psa(userId, LabelId.VERIFIED, 1);
-    assertEquals(Result.create(list(cr, v), list(), list()), norm.normalize(notes, list(cr, v)));
+    assertEquals(Result.create(set(cr, v), set(), set()), norm.normalize(notes, set(cr, v)));
   }
 
   @Test
@@ -191,7 +190,7 @@
 
     PatchSetApproval cr = psa(userId, LabelId.CODE_REVIEW, 0);
     PatchSetApproval v = psa(userId, LabelId.VERIFIED, 0);
-    assertEquals(Result.create(list(cr, v), list(), list()), norm.normalize(notes, list(cr, v)));
+    assertEquals(Result.create(set(cr, v), set(), set()), norm.normalize(notes, set(cr, v)));
   }
 
   private ProjectConfig loadAllProjects() throws Exception {
@@ -221,7 +220,7 @@
     return src.toBuilder().value(newValue).build();
   }
 
-  private static List<PatchSetApproval> list(PatchSetApproval... psas) {
-    return ImmutableList.copyOf(psas);
+  private static ImmutableSet<PatchSetApproval> set(PatchSetApproval... psas) {
+    return ImmutableSet.copyOf(psas);
   }
 }
diff --git a/javatests/com/google/gerrit/server/events/EventJsonTest.java b/javatests/com/google/gerrit/server/events/EventJsonTest.java
index 3c9a355..c2b67c3 100644
--- a/javatests/com/google/gerrit/server/events/EventJsonTest.java
+++ b/javatests/com/google/gerrit/server/events/EventJsonTest.java
@@ -593,6 +593,24 @@
                 .build());
   }
 
+  @Test
+  public void projectHeadUpdatedEvent() {
+    ProjectHeadUpdatedEvent event = new ProjectHeadUpdatedEvent();
+    event.projectName = PROJECT;
+    event.oldHead = "refs/heads/master";
+    event.newHead = REF;
+
+    assertThatJsonMap(event)
+        .isEqualTo(
+            ImmutableMap.builder()
+                .put("projectName", PROJECT)
+                .put("oldHead", "refs/heads/master")
+                .put("newHead", REF)
+                .put("type", "project-head-updated")
+                .put("eventCreatedOn", TS1)
+                .build());
+  }
+
   private Supplier<AccountAttribute> newAccount(String name) {
     AccountAttribute account = new AccountAttribute();
     account.name = name;
diff --git a/javatests/com/google/gerrit/server/fixes/fixCalculator/FixCalculatorVariousTest.java b/javatests/com/google/gerrit/server/fixes/fixCalculator/FixCalculatorVariousTest.java
index fa5c47f..42a80c3 100644
--- a/javatests/com/google/gerrit/server/fixes/fixCalculator/FixCalculatorVariousTest.java
+++ b/javatests/com/google/gerrit/server/fixes/fixCalculator/FixCalculatorVariousTest.java
@@ -248,4 +248,30 @@
     assertThat(edit).internalEdits().element(0).isReplace(6, 12, 6, 9);
     assertThat(edit).internalEdits().element(1).isReplace(18, 10, 15, 7);
   }
+
+  @Test
+  public void overlappingChangesInMiddleOfLineRaisesException() throws Exception {
+    FixReplacement firstReplace =
+        new FixReplacement("path", new Range(2, 0, 3, 5), "First modification\n");
+    FixReplacement secondReplace =
+        new FixReplacement("path", new Range(3, 4, 4, 0), "Some other modified content\n");
+    assertThrows(
+        ResourceConflictException.class,
+        () ->
+            FixCalculator.calculateFix(
+                multilineContent, ImmutableList.of(firstReplace, secondReplace)));
+  }
+
+  @Test
+  public void overlappingChangesInBeginningOfLineRaisesException() throws Exception {
+    FixReplacement firstReplace =
+        new FixReplacement("path", new Range(2, 0, 3, 1), "First modification\n");
+    FixReplacement secondReplace =
+        new FixReplacement("path", new Range(3, 0, 4, 0), "Some other modified content\n");
+    assertThrows(
+        ResourceConflictException.class,
+        () ->
+            FixCalculator.calculateFix(
+                multilineContent, ImmutableList.of(firstReplace, secondReplace)));
+  }
 }
diff --git a/javatests/com/google/gerrit/server/git/DeleteZombieCommentsRefsTest.java b/javatests/com/google/gerrit/server/git/DeleteZombieCommentsRefsTest.java
index 29dbe58..6bdf80f 100644
--- a/javatests/com/google/gerrit/server/git/DeleteZombieCommentsRefsTest.java
+++ b/javatests/com/google/gerrit/server/git/DeleteZombieCommentsRefsTest.java
@@ -29,7 +29,6 @@
 import com.google.gerrit.testing.InMemoryRepositoryManager;
 import java.io.IOException;
 import java.util.ArrayList;
-import java.util.Date;
 import java.util.List;
 import org.eclipse.jgit.lib.BatchRefUpdate;
 import org.eclipse.jgit.lib.CommitBuilder;
@@ -60,7 +59,8 @@
       Ref ref2 = createRefWithEmptyTreeCommit(usersRepo, 1, 1000002);
 
       DeleteZombieCommentsRefs clean =
-          new DeleteZombieCommentsRefs(new AllUsersName("All-Users"), repoManager, null);
+          new DeleteZombieCommentsRefs(
+              new AllUsersName("All-Users"), repoManager, null, (msg) -> {});
       clean.execute();
 
       /* Check that ref1 still exists, and ref2 is deleted */
@@ -81,7 +81,7 @@
       int cleanupPercentage = 50;
       DeleteZombieCommentsRefs clean =
           new DeleteZombieCommentsRefs(
-              new AllUsersName("All-Users"), repoManager, cleanupPercentage);
+              new AllUsersName("All-Users"), repoManager, cleanupPercentage, (msg) -> {});
       clean.execute();
 
       /* ref1 not deleted, ref2 deleted, ref3 not deleted because of the clean percentage */
@@ -101,7 +101,7 @@
       cleanupPercentage = 70;
       clean =
           new DeleteZombieCommentsRefs(
-              new AllUsersName("All-Users"), repoManager, cleanupPercentage);
+              new AllUsersName("All-Users"), repoManager, cleanupPercentage, (msg) -> {});
 
       clean.execute();
 
@@ -137,7 +137,8 @@
           .isEqualTo(goodRefs.size() + badRefs.size());
 
       DeleteZombieCommentsRefs clean =
-          new DeleteZombieCommentsRefs(new AllUsersName("All-Users"), repoManager, null);
+          new DeleteZombieCommentsRefs(
+              new AllUsersName("All-Users"), repoManager, null, (msg) -> {});
       clean.execute();
 
       assertThat(
@@ -204,14 +205,11 @@
     return repo.exactRef(refName);
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   private static ObjectId createCommit(Repository repo, ObjectId treeId, ObjectId parentCommit)
       throws IOException {
     try (ObjectInserter oi = repo.newObjectInserter()) {
       PersonIdent committer =
-          new PersonIdent(new PersonIdent("Foo Bar", "foo.bar@baz.com"), Date.from(TimeUtil.now()));
+          new PersonIdent(new PersonIdent("Foo Bar", "foo.bar@baz.com"), TimeUtil.now());
       CommitBuilder cb = new CommitBuilder();
       cb.setTreeId(treeId);
       cb.setCommitter(committer);
diff --git a/javatests/com/google/gerrit/server/git/RepoRefCacheTest.java b/javatests/com/google/gerrit/server/git/RepoRefCacheTest.java
index 2bc6b92..c09d8d5 100644
--- a/javatests/com/google/gerrit/server/git/RepoRefCacheTest.java
+++ b/javatests/com/google/gerrit/server/git/RepoRefCacheTest.java
@@ -60,6 +60,7 @@
   private static class TestRepositoryWithRefCounting extends Repository {
     private int refCounter;
 
+    @SuppressWarnings("resource")
     static TestRepositoryWithRefCounting createWithBranch(String branchName) throws Exception {
       InMemoryRepository.Builder builder =
           new InMemoryRepository.Builder()
@@ -196,6 +197,7 @@
         }
 
         @Override
+        @Deprecated
         public Map<String, Ref> getRefs(String prefix) throws IOException {
           checkIsOpen();
           return refDatabase.getRefs(prefix);
diff --git a/javatests/com/google/gerrit/server/git/meta/VersionedMetaDataTest.java b/javatests/com/google/gerrit/server/git/meta/VersionedMetaDataTest.java
index 6792703..91d5596 100644
--- a/javatests/com/google/gerrit/server/git/meta/VersionedMetaDataTest.java
+++ b/javatests/com/google/gerrit/server/git/meta/VersionedMetaDataTest.java
@@ -29,10 +29,9 @@
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.testing.TestTimeUtil;
 import java.io.IOException;
+import java.time.ZoneId;
 import java.util.Arrays;
-import java.util.Date;
 import java.util.Optional;
-import java.util.TimeZone;
 import java.util.concurrent.TimeUnit;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.internal.storage.dfs.DfsRepositoryDescription;
@@ -56,7 +55,7 @@
   // instead coming up with a replacement interface for
   // VersionedMetaData/BatchMetaDataUpdate/MetaDataUpdate that is easier to use correctly.
 
-  private static final TimeZone TZ = TimeZone.getTimeZone("America/Los_Angeles");
+  private static final ZoneId ZONE_ID = ZoneId.of("America/Los_Angeles");
   private static final String DEFAULT_REF = "refs/meta/config";
 
   private Project.NameKey project;
@@ -221,13 +220,10 @@
     return u;
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   private CommitBuilder newCommitBuilder() {
     CommitBuilder cb = new CommitBuilder();
     PersonIdent author =
-        new PersonIdent("J. Author", "author@example.com", Date.from(TimeUtil.now()), TZ);
+        new PersonIdent("J. Author", "author@example.com", TimeUtil.now(), ZONE_ID);
     cb.setAuthor(author);
     cb.setCommitter(
         new PersonIdent(
diff --git a/javatests/com/google/gerrit/server/group/db/AbstractGroupTest.java b/javatests/com/google/gerrit/server/group/db/AbstractGroupTest.java
index 54407ca..11f3528 100644
--- a/javatests/com/google/gerrit/server/group/db/AbstractGroupTest.java
+++ b/javatests/com/google/gerrit/server/group/db/AbstractGroupTest.java
@@ -31,9 +31,8 @@
 import com.google.gerrit.testing.InMemoryRepositoryManager;
 import java.io.IOException;
 import java.time.Instant;
-import java.util.Date;
+import java.time.ZoneId;
 import java.util.Optional;
-import java.util.TimeZone;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.Ref;
@@ -45,7 +44,7 @@
 
 @Ignore
 public class AbstractGroupTest {
-  protected static final TimeZone TZ = TimeZone.getTimeZone("America/Los_Angeles");
+  protected static final ZoneId ZONE_ID = ZoneId.of("America/Los_Angeles");
   protected static final String SERVER_ID = "server-id";
   protected static final String SERVER_NAME = "Gerrit Server";
   protected static final String SERVER_EMAIL = "noreply@gerritcodereview.com";
@@ -60,16 +59,13 @@
   protected Account.Id userId;
   protected PersonIdent userIdent;
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   @Before
   public void abstractGroupTestSetUp() throws Exception {
     allUsersName = new AllUsersName(AllUsersNameProvider.DEFAULT);
     repoManager = new InMemoryRepositoryManager();
     allUsersRepo = repoManager.createRepository(allUsersName);
     serverAccountId = Account.id(SERVER_ACCOUNT_NUMBER);
-    serverIdent = new PersonIdent(SERVER_NAME, SERVER_EMAIL, Date.from(TimeUtil.now()), TZ);
+    serverIdent = new PersonIdent(SERVER_NAME, SERVER_EMAIL, TimeUtil.now(), ZONE_ID);
     userId = Account.id(USER_ACCOUNT_NUMBER);
     userIdent = newPersonIdent(userId, serverIdent);
   }
@@ -79,15 +75,12 @@
     allUsersRepo.close();
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   protected Instant getTipTimestamp(AccountGroup.UUID uuid) throws Exception {
     try (RevWalk rw = new RevWalk(allUsersRepo)) {
       Ref ref = allUsersRepo.exactRef(RefNames.refsGroups(uuid));
       return ref == null
           ? null
-          : rw.parseCommit(ref.getObjectId()).getAuthorIdent().getWhen().toInstant();
+          : rw.parseCommit(ref.getObjectId()).getAuthorIdent().getWhenAsInstant();
     }
   }
 
@@ -116,11 +109,8 @@
     return md;
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   protected static PersonIdent newPersonIdent() {
-    return new PersonIdent(SERVER_NAME, SERVER_EMAIL, Date.from(TimeUtil.now()), TZ);
+    return new PersonIdent(SERVER_NAME, SERVER_EMAIL, TimeUtil.now(), ZONE_ID);
   }
 
   protected static PersonIdent newPersonIdent(Account.Id id, PersonIdent ident) {
diff --git a/javatests/com/google/gerrit/server/group/db/GroupConfigTest.java b/javatests/com/google/gerrit/server/group/db/GroupConfigTest.java
index a8f9ff5..8c19732 100644
--- a/javatests/com/google/gerrit/server/group/db/GroupConfigTest.java
+++ b/javatests/com/google/gerrit/server/group/db/GroupConfigTest.java
@@ -40,9 +40,7 @@
 import java.time.LocalDateTime;
 import java.time.Month;
 import java.time.ZoneId;
-import java.util.Date;
 import java.util.Optional;
-import java.util.TimeZone;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.internal.storage.dfs.DfsRepositoryDescription;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
@@ -64,7 +62,7 @@
   private final AccountGroup.Id groupId = AccountGroup.id(123);
   private final AuditLogFormatter auditLogFormatter =
       AuditLogFormatter.createBackedBy(ImmutableSet.of(), ImmutableSet.of(), "server-id");
-  private final TimeZone timeZone = TimeZone.getTimeZone("America/Los_Angeles");
+  private final ZoneId zoneId = ZoneId.of("America/Los_Angeles");
 
   @Before
   public void setUp() throws Exception {
@@ -1044,9 +1042,6 @@
     assertThat(revCommit.getCommitTime()).isEqualTo(createdOnAsSecondsSinceEpoch);
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   @Test
   public void timestampOfCommitterMatchesSpecifiedCreatedOnOfNewGroup() throws Exception {
     Instant committerTimestamp =
@@ -1068,23 +1063,18 @@
     groupConfig.setGroupDelta(groupDelta, auditLogFormatter);
 
     PersonIdent committerIdent =
-        new PersonIdent(
-            "Jane", "Jane@gerritcodereview.com", Date.from(committerTimestamp), timeZone);
+        new PersonIdent("Jane", "Jane@gerritcodereview.com", committerTimestamp, zoneId);
     try (MetaDataUpdate metaDataUpdate = createMetaDataUpdate()) {
       metaDataUpdate.getCommitBuilder().setCommitter(committerIdent);
       groupConfig.commit(metaDataUpdate);
     }
 
     RevCommit revCommit = getLatestCommitForGroup(groupUuid);
-    assertThat(revCommit.getCommitterIdent().getWhen().getTime())
-        .isEqualTo(createdOn.toEpochMilli());
-    assertThat(revCommit.getCommitterIdent().getTimeZone().getRawOffset())
-        .isEqualTo(timeZone.getRawOffset());
+    assertThat(revCommit.getCommitterIdent().getWhenAsInstant()).isEqualTo(createdOn);
+    assertThat(revCommit.getCommitterIdent().getZoneId().getRules().getOffset(createdOn))
+        .isEqualTo(zoneId.getRules().getOffset(createdOn));
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   @Test
   public void timestampOfAuthorMatchesSpecifiedCreatedOnOfNewGroup() throws Exception {
     Instant authorTimestamp = toInstant(LocalDate.of(2017, Month.DECEMBER, 13).atTime(15, 5, 27));
@@ -1105,16 +1095,16 @@
     groupConfig.setGroupDelta(groupDelta, auditLogFormatter);
 
     PersonIdent authorIdent =
-        new PersonIdent("Jane", "Jane@gerritcodereview.com", Date.from(authorTimestamp), timeZone);
+        new PersonIdent("Jane", "Jane@gerritcodereview.com", authorTimestamp, zoneId);
     try (MetaDataUpdate metaDataUpdate = createMetaDataUpdate()) {
       metaDataUpdate.getCommitBuilder().setAuthor(authorIdent);
       groupConfig.commit(metaDataUpdate);
     }
 
     RevCommit revCommit = getLatestCommitForGroup(groupUuid);
-    assertThat(revCommit.getAuthorIdent().getWhen().getTime()).isEqualTo(createdOn.toEpochMilli());
-    assertThat(revCommit.getAuthorIdent().getTimeZone().getRawOffset())
-        .isEqualTo(timeZone.getRawOffset());
+    assertThat(revCommit.getAuthorIdent().getWhenAsInstant()).isEqualTo(createdOn);
+    assertThat(revCommit.getAuthorIdent().getZoneId().getRules().getOffset(createdOn))
+        .isEqualTo(zoneId.getRules().getOffset(createdOn));
   }
 
   @Test
@@ -1149,9 +1139,6 @@
   }
 
   @Test
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   public void timestampOfCommitterMatchesSpecifiedUpdatedOnOfUpdatedGroup() throws Exception {
     Instant committerTimestamp =
         toInstant(LocalDate.of(2017, Month.DECEMBER, 13).atTime(15, 5, 27));
@@ -1167,24 +1154,19 @@
     groupConfig.setGroupDelta(groupDelta, auditLogFormatter);
 
     PersonIdent committerIdent =
-        new PersonIdent(
-            "Jane", "Jane@gerritcodereview.com", Date.from(committerTimestamp), timeZone);
+        new PersonIdent("Jane", "Jane@gerritcodereview.com", committerTimestamp, zoneId);
     try (MetaDataUpdate metaDataUpdate = createMetaDataUpdate()) {
       metaDataUpdate.getCommitBuilder().setCommitter(committerIdent);
       groupConfig.commit(metaDataUpdate);
     }
 
     RevCommit revCommit = getLatestCommitForGroup(groupUuid);
-    assertThat(revCommit.getCommitterIdent().getWhen().getTime())
-        .isEqualTo(updatedOn.toEpochMilli());
-    assertThat(revCommit.getCommitterIdent().getTimeZone().getRawOffset())
-        .isEqualTo(timeZone.getRawOffset());
+    assertThat(revCommit.getCommitterIdent().getWhenAsInstant()).isEqualTo(updatedOn);
+    assertThat(revCommit.getCommitterIdent().getZoneId().getRules().getOffset(updatedOn))
+        .isEqualTo(zoneId.getRules().getOffset(updatedOn));
   }
 
   @Test
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   public void timestampOfAuthorMatchesSpecifiedUpdatedOnOfUpdatedGroup() throws Exception {
     Instant authorTimestamp = toInstant(LocalDate.of(2017, Month.DECEMBER, 13).atTime(15, 5, 27));
     Instant updatedOn = toInstant(LocalDate.of(2016, Month.MARCH, 11).atTime(23, 49, 11));
@@ -1199,16 +1181,16 @@
     groupConfig.setGroupDelta(groupDelta, auditLogFormatter);
 
     PersonIdent authorIdent =
-        new PersonIdent("Jane", "Jane@gerritcodereview.com", Date.from(authorTimestamp), timeZone);
+        new PersonIdent("Jane", "Jane@gerritcodereview.com", authorTimestamp, zoneId);
     try (MetaDataUpdate metaDataUpdate = createMetaDataUpdate()) {
       metaDataUpdate.getCommitBuilder().setAuthor(authorIdent);
       groupConfig.commit(metaDataUpdate);
     }
 
     RevCommit revCommit = getLatestCommitForGroup(groupUuid);
-    assertThat(revCommit.getAuthorIdent().getWhen().getTime()).isEqualTo(updatedOn.toEpochMilli());
-    assertThat(revCommit.getAuthorIdent().getTimeZone().getRawOffset())
-        .isEqualTo(timeZone.getRawOffset());
+    assertThat(revCommit.getAuthorIdent().getWhenAsInstant()).isEqualTo(updatedOn);
+    assertThat(revCommit.getAuthorIdent().getZoneId().getRules().getOffset(updatedOn))
+        .isEqualTo(zoneId.getRules().getOffset(updatedOn));
   }
 
   @Test
@@ -1557,13 +1539,9 @@
     }
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   private MetaDataUpdate createMetaDataUpdate() {
     PersonIdent serverIdent =
-        new PersonIdent(
-            "Gerrit Server", "noreply@gerritcodereview.com", Date.from(TimeUtil.now()), timeZone);
+        new PersonIdent("Gerrit Server", "noreply@gerritcodereview.com", TimeUtil.now(), zoneId);
 
     MetaDataUpdate metaDataUpdate =
         new MetaDataUpdate(
diff --git a/javatests/com/google/gerrit/server/group/db/GroupNameNotesTest.java b/javatests/com/google/gerrit/server/group/db/GroupNameNotesTest.java
index afc56ff..9d8f260 100644
--- a/javatests/com/google/gerrit/server/group/db/GroupNameNotesTest.java
+++ b/javatests/com/google/gerrit/server/group/db/GroupNameNotesTest.java
@@ -44,11 +44,10 @@
 import com.google.gerrit.truth.ListSubject;
 import com.google.gerrit.truth.OptionalSubject;
 import java.io.IOException;
+import java.time.ZoneId;
 import java.util.Arrays;
-import java.util.Date;
 import java.util.List;
 import java.util.Optional;
-import java.util.TimeZone;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicInteger;
 import org.eclipse.jgit.errors.ConfigInvalidException;
@@ -72,7 +71,7 @@
 public class GroupNameNotesTest {
   private static final String SERVER_NAME = "Gerrit Server";
   private static final String SERVER_EMAIL = "noreply@gerritcodereview.com";
-  private static final TimeZone TZ = TimeZone.getTimeZone("America/Los_Angeles");
+  private static final ZoneId ZONE_ID = ZoneId.of("America/Los_Angeles");
 
   private final AccountGroup.UUID groupUuid = AccountGroup.uuid("users-XYZ");
   private final AccountGroup.NameKey groupName = AccountGroup.nameKey("users");
@@ -558,11 +557,8 @@
     return GroupReference.create(AccountGroup.uuid(name + "-" + id), name);
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   private static PersonIdent newPersonIdent() {
-    return new PersonIdent(SERVER_NAME, SERVER_EMAIL, Date.from(TimeUtil.now()), TZ);
+    return new PersonIdent(SERVER_NAME, SERVER_EMAIL, TimeUtil.now(), ZONE_ID);
   }
 
   private static ObjectId getNoteKey(GroupReference g) {
diff --git a/javatests/com/google/gerrit/server/index/IndexedFieldTest.java b/javatests/com/google/gerrit/server/index/IndexedFieldTest.java
new file mode 100644
index 0000000..6a62ed1
--- /dev/null
+++ b/javatests/com/google/gerrit/server/index/IndexedFieldTest.java
@@ -0,0 +1,176 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.index;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.index.testing.TestIndexedFields.INTEGER_FIELD;
+import static com.google.gerrit.index.testing.TestIndexedFields.INTEGER_FIELD_SPEC;
+import static com.google.gerrit.index.testing.TestIndexedFields.INTEGER_RANGE_FIELD_SPEC;
+import static com.google.gerrit.index.testing.TestIndexedFields.ITERABLE_INTEGER_FIELD_SPEC;
+import static com.google.gerrit.index.testing.TestIndexedFields.ITERABLE_INTEGER_RANGE_FIELD_SPEC;
+import static com.google.gerrit.index.testing.TestIndexedFields.ITERABLE_LONG_FIELD_SPEC;
+import static com.google.gerrit.index.testing.TestIndexedFields.ITERABLE_LONG_RANGE_FIELD_SPEC;
+import static com.google.gerrit.index.testing.TestIndexedFields.ITERABLE_PROTO_FIELD_SPEC;
+import static com.google.gerrit.index.testing.TestIndexedFields.ITERABLE_STORED_BYTE_FIELD;
+import static com.google.gerrit.index.testing.TestIndexedFields.ITERABLE_STORED_BYTE_SPEC;
+import static com.google.gerrit.index.testing.TestIndexedFields.ITERABLE_STORED_PROTO_FIELD;
+import static com.google.gerrit.index.testing.TestIndexedFields.ITERABLE_STRING_FIELD;
+import static com.google.gerrit.index.testing.TestIndexedFields.ITERABLE_STRING_FIELD_SPEC;
+import static com.google.gerrit.index.testing.TestIndexedFields.LONG_FIELD_SPEC;
+import static com.google.gerrit.index.testing.TestIndexedFields.LONG_RANGE_FIELD_SPEC;
+import static com.google.gerrit.index.testing.TestIndexedFields.STORED_BYTE_FIELD;
+import static com.google.gerrit.index.testing.TestIndexedFields.STORED_BYTE_SPEC;
+import static com.google.gerrit.index.testing.TestIndexedFields.STORED_PROTO_FIELD;
+import static com.google.gerrit.index.testing.TestIndexedFields.STORED_PROTO_FIELD_SPEC;
+import static com.google.gerrit.index.testing.TestIndexedFields.STRING_FIELD_SPEC;
+import static com.google.gerrit.index.testing.TestIndexedFields.TIMESTAMP_FIELD_SPEC;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.index.IndexedField;
+import com.google.gerrit.index.StoredValue;
+import com.google.gerrit.index.testing.FakeStoredValue;
+import com.google.gerrit.index.testing.TestIndexedFields;
+import com.google.gerrit.index.testing.TestIndexedFields.TestIndexedData;
+import com.google.gerrit.proto.Entities;
+import com.google.gerrit.proto.Protos;
+import java.io.Serializable;
+import java.nio.charset.StandardCharsets;
+import java.sql.Timestamp;
+import java.util.Map.Entry;
+import org.junit.Test;
+import org.junit.experimental.theories.DataPoints;
+import org.junit.experimental.theories.FromDataPoints;
+import org.junit.experimental.theories.Theories;
+import org.junit.experimental.theories.Theory;
+import org.junit.runner.RunWith;
+
+/** Tests for {@link com.google.gerrit.index.IndexedField} */
+@SuppressWarnings("serial")
+@RunWith(Theories.class)
+public class IndexedFieldTest {
+
+  @DataPoints("nonProtoTypes")
+  public static final ImmutableList<
+          Entry<IndexedField<TestIndexedData, ?>.SearchSpec, Serializable>>
+      fieldToStoredValue =
+          new ImmutableMap.Builder<IndexedField<TestIndexedData, ?>.SearchSpec, Serializable>()
+              .put(INTEGER_FIELD_SPEC, 123456)
+              .put(INTEGER_RANGE_FIELD_SPEC, 123456)
+              .put(ITERABLE_INTEGER_FIELD_SPEC, ImmutableList.of(123456, 654321))
+              .put(ITERABLE_INTEGER_RANGE_FIELD_SPEC, ImmutableList.of(123456, 654321))
+              .put(LONG_FIELD_SPEC, 123456L)
+              .put(LONG_RANGE_FIELD_SPEC, 123456L)
+              .put(ITERABLE_LONG_FIELD_SPEC, ImmutableList.of(123456L, 654321L))
+              .put(ITERABLE_LONG_RANGE_FIELD_SPEC, ImmutableList.of(123456L, 654321L))
+              .put(TIMESTAMP_FIELD_SPEC, new Timestamp(1234567L))
+              .put(STRING_FIELD_SPEC, "123456")
+              .put(ITERABLE_STRING_FIELD_SPEC, ImmutableList.of("123456"))
+              .put(
+                  ITERABLE_STORED_BYTE_SPEC,
+                  ImmutableList.of("123456".getBytes(StandardCharsets.UTF_8)))
+              .put(STORED_BYTE_SPEC, "123456".getBytes(StandardCharsets.UTF_8))
+              .build()
+              .entrySet()
+              .asList();
+
+  @DataPoints("protoTypes")
+  public static final ImmutableList<
+          Entry<IndexedField<TestIndexedData, ?>.SearchSpec, Serializable>>
+      protoFieldToStoredValue =
+          ImmutableMap.<IndexedField<TestIndexedData, ?>.SearchSpec, Serializable>of(
+                  STORED_PROTO_FIELD_SPEC,
+                  TestIndexedFields.createChangeProto(12345),
+                  ITERABLE_PROTO_FIELD_SPEC,
+                  ImmutableList.of(
+                      TestIndexedFields.createChangeProto(12345),
+                      TestIndexedFields.createChangeProto(54321)))
+              .entrySet()
+              .asList();
+
+  @Theory
+  public void testSetIfPossible(
+      @FromDataPoints("nonProtoTypes")
+          Entry<IndexedField<TestIndexedData, StoredValue>.SearchSpec, StoredValue>
+              fieldToStoredValue) {
+    Object docValue = fieldToStoredValue.getValue();
+    IndexedField<TestIndexedData, StoredValue>.SearchSpec searchSpec = fieldToStoredValue.getKey();
+    StoredValue storedValue = new FakeStoredValue(fieldToStoredValue.getValue());
+    TestIndexedData testIndexedData = new TestIndexedData();
+    searchSpec.setIfPossible(testIndexedData, storedValue);
+    assertThat(testIndexedData.getTestField()).isEqualTo(docValue);
+  }
+
+  @Test
+  public void testSetIfPossible_protoFromBytes() {
+    Entities.Change changeProto = TestIndexedFields.createChangeProto(12345);
+    StoredValue storedValue = new FakeStoredValue(Protos.toByteArray(changeProto));
+    TestIndexedData testIndexedData = new TestIndexedData();
+    STORED_PROTO_FIELD_SPEC.setIfPossible(testIndexedData, storedValue);
+    assertThat(testIndexedData.getTestField()).isEqualTo(changeProto);
+  }
+
+  @Test
+  public void testSetIfPossible_iterableProtoFromIterableBytes() {
+    ImmutableList<Entities.Change> changeProtos =
+        ImmutableList.of(
+            TestIndexedFields.createChangeProto(12345), TestIndexedFields.createChangeProto(54321));
+    StoredValue storedValue =
+        new FakeStoredValue(
+            changeProtos.stream()
+                .map(proto -> Protos.toByteArray(proto))
+                .collect(toImmutableList()));
+    TestIndexedData testIndexedData = new TestIndexedData();
+    ITERABLE_STORED_PROTO_FIELD.setIfPossible(testIndexedData, storedValue);
+    assertThat(testIndexedData.getTestField()).isEqualTo(changeProtos);
+  }
+
+  @Theory
+  public void testSetIfPossible_fromProto(
+      @FromDataPoints("protoTypes")
+          Entry<IndexedField<TestIndexedData, StoredValue>.SearchSpec, StoredValue>
+              fieldToStoredValue) {
+    Object docValue = fieldToStoredValue.getValue();
+    IndexedField<TestIndexedData, StoredValue>.SearchSpec searchSpec = fieldToStoredValue.getKey();
+    StoredValue storedValue = new FakeStoredValue(fieldToStoredValue.getValue(), /*isProto=*/ true);
+    TestIndexedData testIndexedData = new TestIndexedData();
+    searchSpec.setIfPossible(testIndexedData, storedValue);
+    assertThat(testIndexedData.getTestField()).isEqualTo(docValue);
+  }
+
+  @Test
+  public void test_isProtoType() {
+    assertThat(STORED_PROTO_FIELD.isProtoType()).isTrue();
+
+    assertThat(ITERABLE_STORED_PROTO_FIELD.isProtoType()).isFalse();
+    assertThat(INTEGER_FIELD.isProtoType()).isFalse();
+    assertThat(ITERABLE_STRING_FIELD.isProtoType()).isFalse();
+    assertThat(STORED_BYTE_FIELD.isProtoType()).isFalse();
+    assertThat(ITERABLE_STORED_BYTE_FIELD.isProtoType()).isFalse();
+  }
+
+  @Test
+  public void test_isProtoIterableType() {
+
+    assertThat(ITERABLE_STORED_PROTO_FIELD.isProtoIterableType()).isTrue();
+
+    assertThat(STORED_PROTO_FIELD.isProtoIterableType()).isFalse();
+    assertThat(INTEGER_FIELD.isProtoIterableType()).isFalse();
+    assertThat(ITERABLE_STRING_FIELD.isProtoIterableType()).isFalse();
+    assertThat(STORED_BYTE_FIELD.isProtoIterableType()).isFalse();
+    assertThat(ITERABLE_STORED_BYTE_FIELD.isProtoType()).isFalse();
+  }
+}
diff --git a/javatests/com/google/gerrit/server/index/account/AccountFieldTest.java b/javatests/com/google/gerrit/server/index/account/AccountFieldTest.java
index 4d9cb76..65eb3b8 100644
--- a/javatests/com/google/gerrit/server/index/account/AccountFieldTest.java
+++ b/javatests/com/google/gerrit/server/index/account/AccountFieldTest.java
@@ -39,9 +39,10 @@
     Account.Builder account = Account.builder(Account.id(1), TimeUtil.now());
     String metaId = "0e39795bb25dc914118224995c53c5c36923a461";
     account.setMetaId(metaId);
-    List<String> values =
-        toStrings(AccountField.REF_STATE.get(AccountState.forAccount(account.build())));
-    assertThat(values).hasSize(1);
+    Iterable<byte[]> refStates =
+        (Iterable<byte[]>)
+            AccountField.REF_STATE_SPEC.get(AccountState.forAccount(account.build()));
+    List<String> values = toStrings(refStates);
     String expectedValue =
         allUsersName.get() + ":" + RefNames.refsUsers(account.id()) + ":" + metaId;
     assertThat(Iterables.getOnlyElement(values)).isEqualTo(expectedValue);
@@ -77,7 +78,7 @@
             ObjectId.fromString("483ea804e84282e15ddcdd1d15a797eb4796a760"));
     List<String> values =
         toStrings(
-            AccountField.EXTERNAL_ID_STATE.get(
+            AccountField.EXTERNAL_ID_STATE_FIELD.get(
                 AccountState.forAccount(account, ImmutableSet.of(extId1, extId2, extId3))));
     String expectedValue1 = extId1.key().sha1().name() + ":" + extId1.blobId().name();
     String expectedValue2 = extId2.key().sha1().name() + ":" + extId2.blobId().name();
diff --git a/javatests/com/google/gerrit/server/index/change/ChangeFieldTest.java b/javatests/com/google/gerrit/server/index/change/ChangeFieldTest.java
index 0bdf5cd..e35941c 100644
--- a/javatests/com/google/gerrit/server/index/change/ChangeFieldTest.java
+++ b/javatests/com/google/gerrit/server/index/change/ChangeFieldTest.java
@@ -157,14 +157,16 @@
   @Test
   public void tolerateNullValuesForInsertion() {
     Project.NameKey project = Project.nameKey("project");
-    ChangeData cd = ChangeData.createForTest(project, Change.id(1), 1, ObjectId.zeroId());
+    ChangeData cd =
+        ChangeData.createForTest(project, Change.id(1), 1, ObjectId.zeroId(), null, null, null);
     assertThat(ChangeField.ADDED.setIfPossible(cd, new FakeStoredValue(null))).isTrue();
   }
 
   @Test
   public void tolerateNullValuesForDeletion() {
     Project.NameKey project = Project.nameKey("project");
-    ChangeData cd = ChangeData.createForTest(project, Change.id(1), 1, ObjectId.zeroId());
+    ChangeData cd =
+        ChangeData.createForTest(project, Change.id(1), 1, ObjectId.zeroId(), null, null, null);
     assertThat(ChangeField.DELETED.setIfPossible(cd, new FakeStoredValue(null))).isTrue();
   }
 
diff --git a/javatests/com/google/gerrit/server/index/change/FakeChangeIndex.java b/javatests/com/google/gerrit/server/index/change/FakeChangeIndex.java
index fc56a3c..f70c97a 100644
--- a/javatests/com/google/gerrit/server/index/change/FakeChangeIndex.java
+++ b/javatests/com/google/gerrit/server/index/change/FakeChangeIndex.java
@@ -14,7 +14,8 @@
 
 package com.google.gerrit.server.index.change;
 
-import com.google.common.collect.ImmutableList;
+import static com.google.gerrit.index.SchemaUtil.schema;
+
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.index.QueryOptions;
 import com.google.gerrit.index.Schema;
@@ -28,11 +29,10 @@
 
 @Ignore
 public class FakeChangeIndex implements ChangeIndex {
-  static final Schema<ChangeData> V1 = new Schema<>(1, false, ImmutableList.of(ChangeField.STATUS));
+  static final Schema<ChangeData> V1 = schema(1, ChangeField.STATUS);
 
   static final Schema<ChangeData> V2 =
-      new Schema<>(
-          2, false, ImmutableList.of(ChangeField.STATUS, ChangeField.PATH, ChangeField.UPDATED));
+      schema(2, ChangeField.STATUS, ChangeField.PATH, ChangeField.UPDATED);
 
   private static class Source implements ChangeDataSource {
     private final Predicate<ChangeData> p;
diff --git a/javatests/com/google/gerrit/server/mail/send/CommentSenderTest.java b/javatests/com/google/gerrit/server/mail/send/CommentSenderTest.java
index 78cefdf..d7a6282 100644
--- a/javatests/com/google/gerrit/server/mail/send/CommentSenderTest.java
+++ b/javatests/com/google/gerrit/server/mail/send/CommentSenderTest.java
@@ -22,7 +22,7 @@
 public class CommentSenderTest {
   private static class TestSender extends CommentSender {
     TestSender() {
-      super(null, null, null, null, null);
+      super(null, null, null, null, null, null, null);
     }
   }
 
diff --git a/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java b/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java
index 222be83..4ad18b5 100644
--- a/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java
+++ b/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java
@@ -39,6 +39,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.approval.PatchSetApprovalUuidGenerator;
 import com.google.gerrit.server.approval.testing.TestPatchSetApprovalUuidGenerator;
 import com.google.gerrit.server.config.AllUsersName;
@@ -48,11 +50,13 @@
 import com.google.gerrit.server.config.CanonicalWebUrl;
 import com.google.gerrit.server.config.DefaultUrlFormatter.DefaultUrlFormatterModule;
 import com.google.gerrit.server.config.EnablePeerIPInReflogRecord;
+import com.google.gerrit.server.config.GerritImportedServerIds;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.GerritServerId;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.GitModule;
 import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.RepositoryCaseMismatchException;
 import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.gerrit.server.project.NullProjectCache;
 import com.google.gerrit.server.project.ProjectCache;
@@ -67,10 +71,12 @@
 import com.google.inject.Guice;
 import com.google.inject.Inject;
 import com.google.inject.Injector;
+import com.google.inject.Module;
+import com.google.inject.TypeLiteral;
 import java.time.Instant;
-import java.util.Date;
-import java.util.TimeZone;
+import java.time.ZoneId;
 import java.util.concurrent.ExecutorService;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
@@ -85,10 +91,13 @@
 @Ignore
 @RunWith(ConfigSuite.class)
 public abstract class AbstractChangeNotesTest {
-  private static final TimeZone TZ = TimeZone.getTimeZone("America/Los_Angeles");
+  protected static final String LOCAL_SERVER_ID = "gerrit";
+
+  private static final ZoneId ZONE_ID = ZoneId.of("America/Los_Angeles");
 
   @ConfigSuite.Parameter public Config testConfig;
 
+  protected Account.Id changeOwnerId;
   protected Account.Id otherUserId;
   protected FakeAccountCache accountCache;
   protected IdentifiedUser changeOwner;
@@ -112,18 +121,26 @@
 
   @Inject @GerritServerId protected String serverId;
 
+  @Inject protected ExternalIdCache externalIdCache;
+
   protected Injector injector;
   private String systemTimeZone;
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
+  @Inject protected ChangeNotes.Factory changeNotesFactory;
+
   @Before
   public void setUpTestEnvironment() throws Exception {
+    setupTestPrerequisites();
+
+    injector = createTestInjector(LOCAL_SERVER_ID);
+    createAllUsers(injector);
+    injector.injectMembers(this);
+  }
+
+  protected void setupTestPrerequisites() throws Exception {
     setTimeForTesting();
 
-    serverIdent =
-        new PersonIdent("Gerrit Server", "noreply@gerrit.com", Date.from(TimeUtil.now()), TZ);
+    serverIdent = new PersonIdent("Gerrit Server", "noreply@gerrit.com", TimeUtil.now(), ZONE_ID);
     project = Project.nameKey("test-project");
     repoManager = new InMemoryRepositoryManager();
     repo = repoManager.createRepository(project);
@@ -139,60 +156,77 @@
     ou.setPreferredEmail("other@account.com");
     accountCache.put(ou.build());
     assertableFanOutExecutor = new AssertableExecutorService();
-
-    injector =
-        Guice.createInjector(
-            new FactoryModule() {
-              @Override
-              public void configure() {
-                install(new GitModule());
-
-                install(new DefaultUrlFormatterModule());
-                install(NoteDbModule.forTest());
-                bind(AllUsersName.class).toProvider(AllUsersNameProvider.class);
-                bind(String.class).annotatedWith(GerritServerId.class).toInstance("gerrit");
-                bind(GitRepositoryManager.class).toInstance(repoManager);
-                bind(ProjectCache.class).to(NullProjectCache.class);
-                bind(Config.class).annotatedWith(GerritServerConfig.class).toInstance(testConfig);
-                bind(String.class)
-                    .annotatedWith(AnonymousCowardName.class)
-                    .toProvider(AnonymousCowardNameProvider.class);
-                bind(String.class)
-                    .annotatedWith(CanonicalWebUrl.class)
-                    .toInstance("http://localhost:8080/");
-                bind(Boolean.class)
-                    .annotatedWith(EnablePeerIPInReflogRecord.class)
-                    .toInstance(Boolean.FALSE);
-                bind(Realm.class).to(FakeRealm.class);
-                bind(GroupBackend.class).to(SystemGroupBackend.class).in(SINGLETON);
-                bind(AccountCache.class).toInstance(accountCache);
-                bind(PersonIdent.class)
-                    .annotatedWith(GerritPersonIdent.class)
-                    .toInstance(serverIdent);
-                bind(GitReferenceUpdated.class).toInstance(GitReferenceUpdated.DISABLED);
-                bind(MetricMaker.class).to(DisabledMetricMaker.class);
-                bind(ExecutorService.class)
-                    .annotatedWith(FanOutExecutor.class)
-                    .toInstance(assertableFanOutExecutor);
-                bind(ServiceUserClassifier.class).to(ServiceUserClassifier.NoOp.class);
-                bind(InternalChangeQuery.class)
-                    .toProvider(
-                        () -> {
-                          throw new UnsupportedOperationException();
-                        });
-                bind(PatchSetApprovalUuidGenerator.class)
-                    .to(TestPatchSetApprovalUuidGenerator.class);
-              }
-            });
-
-    injector.injectMembers(this);
-    repoManager.createRepository(allUsers);
-    changeOwner = userFactory.create(co.id());
-    otherUser = userFactory.create(ou.id());
-    otherUserId = otherUser.getAccountId();
+    changeOwnerId = co.id();
+    otherUserId = ou.id();
     internalUser = new InternalUser();
   }
 
+  protected Injector createTestInjector(String serverId, String... importedServerIds)
+      throws Exception {
+    return createTestInjector(DisabledExternalIdCache.module(), serverId, importedServerIds);
+  }
+
+  protected Injector createTestInjector(
+      Module extraGuiceModule, String serverId, String... importedServerIds) throws Exception {
+
+    return Guice.createInjector(
+        new FactoryModule() {
+          @Override
+          public void configure() {
+            install(extraGuiceModule);
+            install(new GitModule());
+
+            install(new DefaultUrlFormatterModule());
+            install(NoteDbModule.forTest());
+            bind(AllUsersName.class).toProvider(AllUsersNameProvider.class);
+            bind(String.class).annotatedWith(GerritServerId.class).toInstance(serverId);
+            bind(new TypeLiteral<ImmutableList<String>>() {})
+                .annotatedWith(GerritImportedServerIds.class)
+                .toInstance(new ImmutableList.Builder<String>().add(importedServerIds).build());
+            bind(GitRepositoryManager.class).toInstance(repoManager);
+            bind(ProjectCache.class).to(NullProjectCache.class);
+            bind(Config.class).annotatedWith(GerritServerConfig.class).toInstance(testConfig);
+            bind(String.class)
+                .annotatedWith(AnonymousCowardName.class)
+                .toProvider(AnonymousCowardNameProvider.class);
+            bind(String.class)
+                .annotatedWith(CanonicalWebUrl.class)
+                .toInstance("http://localhost:8080/");
+            bind(Boolean.class)
+                .annotatedWith(EnablePeerIPInReflogRecord.class)
+                .toInstance(Boolean.FALSE);
+            bind(Realm.class).to(FakeRealm.class);
+            bind(GroupBackend.class).to(SystemGroupBackend.class).in(SINGLETON);
+            bind(AccountCache.class).toInstance(accountCache);
+            bind(PersonIdent.class).annotatedWith(GerritPersonIdent.class).toInstance(serverIdent);
+            bind(GitReferenceUpdated.class).toInstance(GitReferenceUpdated.DISABLED);
+            bind(MetricMaker.class).to(DisabledMetricMaker.class);
+            bind(ExecutorService.class)
+                .annotatedWith(FanOutExecutor.class)
+                .toInstance(assertableFanOutExecutor);
+            bind(ServiceUserClassifier.class).to(ServiceUserClassifier.NoOp.class);
+            bind(InternalChangeQuery.class)
+                .toProvider(
+                    () -> {
+                      throw new UnsupportedOperationException();
+                    });
+            bind(PatchSetApprovalUuidGenerator.class).to(TestPatchSetApprovalUuidGenerator.class);
+          }
+        });
+  }
+
+  protected void createAllUsers(Injector injector)
+      throws RepositoryCaseMismatchException, RepositoryNotFoundException {
+    AllUsersName allUsersName = injector.getInstance(AllUsersName.class);
+
+    repoManager.createRepository(allUsersName);
+
+    IdentifiedUser.GenericFactory identifiedUserFactory =
+        injector.getInstance(IdentifiedUser.GenericFactory.class);
+    changeOwner = identifiedUserFactory.create(changeOwnerId);
+    otherUser = identifiedUserFactory.create(otherUserId);
+  }
+
   private void setTimeForTesting() {
     systemTimeZone = System.setProperty("user.timezone", "US/Eastern");
     TestTimeUtil.resetWithClockStep(1, SECONDS);
@@ -205,8 +239,12 @@
   }
 
   protected Change newChange(boolean workInProgress) throws Exception {
+    return newChange(injector, workInProgress);
+  }
+
+  protected Change newChange(Injector injector, boolean workInProgress) throws Exception {
     Change c = TestChanges.newChange(project, changeOwner.getAccountId());
-    ChangeUpdate u = newUpdateForNewChange(c, changeOwner);
+    ChangeUpdate u = newUpdate(injector, c, changeOwner, false);
     u.setChangeId(c.getKey().get());
     u.setBranch(c.getDest().branch());
     u.setWorkInProgress(workInProgress);
@@ -223,15 +261,20 @@
   }
 
   protected ChangeUpdate newUpdateForNewChange(Change c, CurrentUser user) throws Exception {
-    return newUpdate(c, user, false);
+    return newUpdate(injector, c, user, false);
   }
 
   protected ChangeUpdate newUpdate(Change c, CurrentUser user) throws Exception {
-    return newUpdate(c, user, true);
+    return newUpdate(injector, c, user, true);
   }
 
   protected ChangeUpdate newUpdate(Change c, CurrentUser user, boolean shouldExist)
       throws Exception {
+    return newUpdate(injector, c, user, shouldExist);
+  }
+
+  protected ChangeUpdate newUpdate(
+      Injector injector, Change c, CurrentUser user, boolean shouldExist) throws Exception {
     ChangeUpdate update = TestChanges.newUpdate(injector, c, user, shouldExist);
     update.setPatchSetId(c.currentPatchSetId());
     update.setAllowWriteToNewRef(true);
@@ -242,6 +285,10 @@
     return new ChangeNotes(args, c, true, null).load();
   }
 
+  protected ChangeNotes newNotes(AbstractChangeNotes.Args cArgs, Change c) {
+    return new ChangeNotes(cArgs, c, true, null).load();
+  }
+
   protected static SubmitRecord submitRecord(
       String status, String errorMessage, SubmitRecord.Label... labels) {
     SubmitRecord rec = new SubmitRecord();
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesCommitTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesCommitTest.java
index 666b8fc..9445f4a 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesCommitTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesCommitTest.java
@@ -98,7 +98,8 @@
   private ChangeNotesParser newParser(ObjectId tip) throws Exception {
     walk.reset();
     ChangeNoteJson changeNoteJson = injector.getInstance(ChangeNoteJson.class);
-    return new ChangeNotesParser(newChange().getId(), tip, walk, changeNoteJson, args.metrics);
+    return new ChangeNotesParser(
+        newChange().getId(), tip, walk, changeNoteJson, args.metrics, serverId, externalIdCache);
   }
 
   private RevCommit writeCommit(String body) throws Exception {
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesParserTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesParserTest.java
index c33a87f..4543b50 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesParserTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesParserTest.java
@@ -201,10 +201,12 @@
             + "Branch: refs/heads/master\n"
             + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
             + "Patch-set: 1\n"
-            + "Copied-Label: Label1=+1 Account <1@gerrit>,Other Account <2@Gerrit>\n"
+            + "Copied-Label: Label1=+1 Account <1@gerrit>,Other Account <2@gerrit>\n"
             + "Copied-Label: Label2=+1 Account <1@gerrit>\n"
-            + "Copied-Label: Label3=+1 Account <1@gerrit>,Other Account <2@Gerrit> :\"tag\"\n"
-            + "Copied-Label: Label4=+1 Account <1@Gerrit> :\"tag with characters %^#@^( *::!\"\n"
+            + "Copied-Label: Label3=+1 Account <1@gerrit>,Other Account <2@gerrit> :\"tag\"\n"
+            + "Copied-Label: Label4=+1 Account <1@gerrit> :\"tag with characters %^#@^( *::!\"\n"
+            + "Copied-Label: -Label1 Account <1@gerrit>,Other Account <2@gerrit>\\n"
+            + "Copied-Label: -Label1 Account <1@gerrit>\n"
             + "Subject: This is a test change\n");
 
     assertParseFails("Update change\n\nPatch-set: 1\nCopied-Label: Label1=X\n");
@@ -220,6 +222,7 @@
         "Update change\n\nPatch-set: 1\nCopied-Label: Label1 Other Account <2@gerrit>,Other "
             + "Account <2@gerrit>,Other Account <2@gerrit> \n");
     assertParseFails("Update change\n\nPatch-set: 1\nCopied-Label: Label1 non-user\n");
+    assertParseFails("Update change\n\nPatch-set: 1\nCopied-Label: -Label1\n");
   }
 
   @Test
@@ -264,6 +267,14 @@
         "Update change\n\nPatch-set: 1\nCopied-Label: Label1=+1, 577fb248e474018276351785930358ec0450e9f7");
     assertParseFails(
         "Update change\n\nPatch-set: 1\nCopied-Label: Label1=+1, 577fb248e474018276351785930358ec0450e9f7 :\"tag\"\n");
+
+    // UUID for removals is not supported.
+    assertParseFails(
+        "Update change\n\nPatch-set: 1\nCopied-Label: -Label1,"
+            + " 577fb248e474018276351785930358ec0450e9f7\n");
+    assertParseFails(
+        "Update change\n\nPatch-set: 1\nCopied-Label: -Label1,"
+            + " 577fb248e474018276351785930358ec0450e9f7 Other Account <2@gerrit>\n");
   }
 
   @Test
@@ -771,6 +782,7 @@
   private ChangeNotesParser newParser(ObjectId tip) throws Exception {
     walk.reset();
     ChangeNoteJson changeNoteJson = injector.getInstance(ChangeNoteJson.class);
-    return new ChangeNotesParser(newChange().getId(), tip, walk, changeNoteJson, args.metrics);
+    return new ChangeNotesParser(
+        newChange().getId(), tip, walk, changeNoteJson, args.metrics, serverId, externalIdCache);
   }
 }
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
index 3295828..976ffc8 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
@@ -744,6 +744,7 @@
                 ImmutableList.of(
                     SubmitRequirementResult.builder()
                         .legacy(Optional.of(true))
+                        .hidden(Optional.of(true))
                         .patchSetCommitId(
                             ObjectId.fromString("26e50c7d315a33a13e5cc00902781fa876bc36cd"))
                         .submitRequirement(
@@ -774,6 +775,7 @@
             .addSubmitRequirementResult(
                 SubmitRequirementResultProto.newBuilder()
                     .setLegacy(true)
+                    .setHidden(true)
                     .setCommit(
                         ObjectIdConverter.create()
                             .toByteString(
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
index 09c8059..4edfa8b4 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
@@ -163,7 +163,7 @@
 
     ChangeNotes notes = newNotes(c);
 
-    ImmutableListMultimap<PatchSet.Id, PatchSetApproval> approvals = notes.getApprovals();
+    ImmutableListMultimap<PatchSet.Id, PatchSetApproval> approvals = notes.getApprovals().all();
     assertThat(approvals).hasSize(1);
     assertThat(approvals.entries().asList().get(0).getValue().tag()).hasValue(tag2);
   }
@@ -209,7 +209,7 @@
 
     ChangeNotes notes = newNotes(c);
 
-    ImmutableListMultimap<PatchSet.Id, PatchSetApproval> approvals = notes.getApprovals();
+    ImmutableListMultimap<PatchSet.Id, PatchSetApproval> approvals = notes.getApprovals().all();
     assertThat(approvals).hasSize(1);
     PatchSetApproval approval = approvals.entries().asList().get(0).getValue();
     assertThat(approval.tag()).hasValue(integrationTag);
@@ -235,8 +235,8 @@
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    assertThat(notes.getApprovals().keySet()).containsExactly(c.currentPatchSetId());
-    List<PatchSetApproval> psas = notes.getApprovals().get(c.currentPatchSetId());
+    assertThat(notes.getApprovals().all().keySet()).containsExactly(c.currentPatchSetId());
+    List<PatchSetApproval> psas = notes.getApprovals().all().get(c.currentPatchSetId());
     assertThat(psas).hasSize(2);
 
     assertThat(psas.get(0).patchSetId()).isEqualTo(c.currentPatchSetId());
@@ -269,7 +269,7 @@
     PatchSet.Id ps2 = c.currentPatchSetId();
 
     ChangeNotes notes = newNotes(c);
-    ListMultimap<PatchSet.Id, PatchSetApproval> psas = notes.getApprovals();
+    ListMultimap<PatchSet.Id, PatchSetApproval> psas = notes.getApprovals().all();
     assertThat(psas).hasSize(2);
 
     PatchSetApproval psa1 = Iterables.getOnlyElement(psas.get(ps1));
@@ -298,7 +298,7 @@
 
     ChangeNotes notes = newNotes(c);
     PatchSetApproval psa =
-        Iterables.getOnlyElement(notes.getApprovals().get(c.currentPatchSetId()));
+        Iterables.getOnlyElement(notes.getApprovals().all().get(c.currentPatchSetId()));
     assertThat(psa.label()).isEqualTo(LabelId.CODE_REVIEW);
     assertThat(psa.value()).isEqualTo((short) -1);
     assertParsedUuid(psa);
@@ -308,7 +308,7 @@
     update.commit();
 
     notes = newNotes(c);
-    psa = Iterables.getOnlyElement(notes.getApprovals().get(c.currentPatchSetId()));
+    psa = Iterables.getOnlyElement(notes.getApprovals().all().get(c.currentPatchSetId()));
     assertThat(psa.label()).isEqualTo(LabelId.CODE_REVIEW);
     assertThat(psa.value()).isEqualTo((short) 1);
     assertParsedUuid(psa);
@@ -326,8 +326,8 @@
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    assertThat(notes.getApprovals().keySet()).containsExactly(c.currentPatchSetId());
-    List<PatchSetApproval> psas = notes.getApprovals().get(c.currentPatchSetId());
+    assertThat(notes.getApprovals().all().keySet()).containsExactly(c.currentPatchSetId());
+    List<PatchSetApproval> psas = notes.getApprovals().all().get(c.currentPatchSetId());
     assertThat(psas).hasSize(2);
 
     assertThat(psas.get(0).patchSetId()).isEqualTo(c.currentPatchSetId());
@@ -354,7 +354,7 @@
 
     ChangeNotes notes = newNotes(c);
     PatchSetApproval psa =
-        Iterables.getOnlyElement(notes.getApprovals().get(c.currentPatchSetId()));
+        Iterables.getOnlyElement(notes.getApprovals().all().get(c.currentPatchSetId()));
     assertThat(psa.accountId().get()).isEqualTo(1);
     assertThat(psa.label()).isEqualTo("Not-For-Long");
     assertThat(psa.value()).isEqualTo((short) 1);
@@ -365,7 +365,7 @@
     update.commit();
 
     notes = newNotes(c);
-    assertThat(notes.getApprovals())
+    assertThat(notes.getApprovals().all())
         .containsExactlyEntriesIn(
             ImmutableListMultimap.of(
                 psa.patchSetId(),
@@ -386,7 +386,7 @@
 
       ChangeNotes notes = newNotes(c);
       PatchSetApproval psa =
-          Iterables.getOnlyElement(notes.getApprovals().get(c.currentPatchSetId()));
+          Iterables.getOnlyElement(notes.getApprovals().all().get(c.currentPatchSetId()));
       assertThat(psa.accountId()).isEqualTo(changeOwner.getAccountId());
       assertThat(psa.label()).isEqualTo(LabelId.CODE_REVIEW);
       assertThat(psa.value()).isEqualTo((short) value);
@@ -403,7 +403,7 @@
 
     ChangeNotes notes = newNotes(c);
     PatchSetApproval psa =
-        Iterables.getOnlyElement(notes.getApprovals().get(c.currentPatchSetId()));
+        Iterables.getOnlyElement(notes.getApprovals().all().get(c.currentPatchSetId()));
     assertThat(psa.accountId()).isEqualTo(changeOwner.getAccountId());
     assertThat(psa.label()).isEqualTo(LabelId.CODE_REVIEW);
     assertThat(psa.value()).isEqualTo((short) -1);
@@ -415,7 +415,7 @@
 
     notes = newNotes(c);
     PatchSetApproval emptyPsa =
-        Iterables.getOnlyElement(notes.getApprovals().get(psa.patchSetId()));
+        Iterables.getOnlyElement(notes.getApprovals().all().get(psa.patchSetId()));
     assertThat(emptyPsa.key()).isEqualTo(psa.key());
     assertThat(emptyPsa.value()).isEqualTo((short) 0);
     assertThat(emptyPsa.label()).isEqualTo(psa.label());
@@ -431,7 +431,7 @@
 
     ChangeNotes notes = newNotes(c);
     PatchSetApproval psa =
-        Iterables.getOnlyElement(notes.getApprovals().get(c.currentPatchSetId()));
+        Iterables.getOnlyElement(notes.getApprovals().all().get(c.currentPatchSetId()));
     assertThat(psa.accountId()).isEqualTo(changeOwner.getAccountId());
     assertThat(psa.label()).isEqualTo(LabelId.CODE_REVIEW);
     assertThat(psa.value()).isEqualTo((short) -1);
@@ -443,7 +443,7 @@
 
     notes = newNotes(c);
     PatchSetApproval removedPsa =
-        Iterables.getOnlyElement(notes.getApprovals().get(psa.patchSetId()));
+        Iterables.getOnlyElement(notes.getApprovals().all().get(psa.patchSetId()));
     assertThat(removedPsa.key()).isEqualTo(psa.key());
     assertThat(removedPsa.value()).isEqualTo((short) 0);
     assertThat(removedPsa.label()).isEqualTo(psa.label());
@@ -458,9 +458,9 @@
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    assertThat(notes.getApprovals().keySet()).containsExactly(c.currentPatchSetId());
+    assertThat(notes.getApprovals().all().keySet()).containsExactly(c.currentPatchSetId());
     PatchSetApproval originalPsa =
-        Iterables.getOnlyElement(notes.getApprovals().get(c.currentPatchSetId()));
+        Iterables.getOnlyElement(notes.getApprovals().all().get(c.currentPatchSetId()));
 
     assertThat(originalPsa.patchSetId()).isEqualTo(c.currentPatchSetId());
     assertThat(originalPsa.accountId()).isEqualTo(changeOwner.getAccountId());
@@ -474,9 +474,9 @@
     update.commit();
 
     notes = newNotes(c);
-    assertThat(notes.getApprovals().keySet()).hasSize(1);
+    assertThat(notes.getApprovals().all().keySet()).hasSize(1);
     PatchSetApproval removedPsa =
-        Iterables.getOnlyElement(notes.getApprovals().get(c.currentPatchSetId()));
+        Iterables.getOnlyElement(notes.getApprovals().all().get(c.currentPatchSetId()));
     assertThat(removedPsa.key()).isEqualTo(originalPsa.key());
     assertThat(removedPsa.value()).isEqualTo(0);
     // Add approval with the same author, label, value to the current patch set
@@ -485,9 +485,9 @@
     update.commit();
 
     notes = newNotes(c);
-    assertThat(notes.getApprovals().keySet()).hasSize(1);
+    assertThat(notes.getApprovals().all().keySet()).hasSize(1);
     PatchSetApproval reAddedPsa =
-        Iterables.getOnlyElement(notes.getApprovals().get(c.currentPatchSetId()));
+        Iterables.getOnlyElement(notes.getApprovals().all().get(c.currentPatchSetId()));
 
     assertThat(reAddedPsa.key()).isEqualTo(originalPsa.key());
     assertThat(reAddedPsa.value()).isEqualTo(originalPsa.value());
@@ -504,9 +504,9 @@
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    assertThat(notes.getApprovals().keySet()).containsExactly(c.currentPatchSetId());
+    assertThat(notes.getApprovals().all().keySet()).containsExactly(c.currentPatchSetId());
     PatchSetApproval originalPsa =
-        Iterables.getOnlyElement(notes.getApprovals().get(c.currentPatchSetId()));
+        Iterables.getOnlyElement(notes.getApprovals().all().get(c.currentPatchSetId()));
 
     assertThat(originalPsa.patchSetId()).isEqualTo(c.currentPatchSetId());
     assertThat(originalPsa.accountId().get()).isEqualTo(1);
@@ -521,11 +521,11 @@
     update.commit();
 
     notes = newNotes(c);
-    assertThat(notes.getApprovals().keySet()).hasSize(2);
+    assertThat(notes.getApprovals().all().keySet()).hasSize(2);
     PatchSetApproval postUpdateOriginalPsa =
-        Iterables.getOnlyElement(notes.getApprovals().get(originalPsa.patchSetId()));
+        Iterables.getOnlyElement(notes.getApprovals().all().get(originalPsa.patchSetId()));
     PatchSetApproval reAddedPsa =
-        Iterables.getOnlyElement(notes.getApprovals().get(c.currentPatchSetId()));
+        Iterables.getOnlyElement(notes.getApprovals().all().get(c.currentPatchSetId()));
 
     // Same patch set approval for the original patch set is returned after the vote was re-issued
     // on the next patch set
@@ -549,8 +549,9 @@
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    assertThat(notes.getApprovals().keySet()).containsExactly(c.currentPatchSetId());
-    List<PatchSetApproval> patchSetApprovals = notes.getApprovals().get(c.currentPatchSetId());
+    assertThat(notes.getApprovals().all().keySet()).containsExactly(c.currentPatchSetId());
+    List<PatchSetApproval> patchSetApprovals =
+        notes.getApprovals().all().get(c.currentPatchSetId());
     assertThat(patchSetApprovals).hasSize(2);
     assertThat(
             patchSetApprovals.stream()
@@ -573,8 +574,9 @@
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    assertThat(notes.getApprovals().keySet()).containsExactly(c.currentPatchSetId());
-    List<PatchSetApproval> patchSetApprovals = notes.getApprovals().get(c.currentPatchSetId());
+    assertThat(notes.getApprovals().all().keySet()).containsExactly(c.currentPatchSetId());
+    List<PatchSetApproval> patchSetApprovals =
+        notes.getApprovals().all().get(c.currentPatchSetId());
     assertThat(patchSetApprovals).hasSize(2);
     assertThat(
             patchSetApprovals.stream()
@@ -606,10 +608,10 @@
 
     ChangeNotes notes1 = newNotes(c1);
     PatchSetApproval psa1 =
-        Iterables.getOnlyElement(notes1.getApprovals().get(c1.currentPatchSetId()));
+        Iterables.getOnlyElement(notes1.getApprovals().all().get(c1.currentPatchSetId()));
     ChangeNotes notes2 = newNotes(c2);
     PatchSetApproval psa2 =
-        Iterables.getOnlyElement(notes2.getApprovals().get(c2.currentPatchSetId()));
+        Iterables.getOnlyElement(notes2.getApprovals().all().get(c2.currentPatchSetId()));
     assertThat(psa1.label()).isEqualTo(psa2.label());
     assertThat(psa1.accountId()).isEqualTo(psa2.accountId());
     assertThat(psa1.value()).isEqualTo(psa2.value());
@@ -627,7 +629,7 @@
 
     ChangeNotes notes = newNotes(c);
     PatchSetApproval psa =
-        Iterables.getOnlyElement(notes.getApprovals().get(c.currentPatchSetId()));
+        Iterables.getOnlyElement(notes.getApprovals().all().get(c.currentPatchSetId()));
     assertThat(psa.accountId()).isEqualTo(otherUserId);
     assertThat(psa.label()).isEqualTo("Not-For-Long");
     assertThat(psa.value()).isEqualTo((short) 1);
@@ -639,7 +641,7 @@
 
     notes = newNotes(c);
     PatchSetApproval removedPsa =
-        Iterables.getOnlyElement(notes.getApprovals().get(psa.patchSetId()));
+        Iterables.getOnlyElement(notes.getApprovals().all().get(psa.patchSetId()));
     assertThat(removedPsa.key()).isEqualTo(psa.key());
     assertThat(removedPsa.value()).isEqualTo((short) 0);
     assertThat(removedPsa.label()).isEqualTo(psa.label());
@@ -651,7 +653,7 @@
     update.commit();
 
     notes = newNotes(c);
-    psa = Iterables.getOnlyElement(notes.getApprovals().get(c.currentPatchSetId()));
+    psa = Iterables.getOnlyElement(notes.getApprovals().all().get(c.currentPatchSetId()));
     assertThat(psa.accountId()).isEqualTo(otherUserId);
     assertThat(psa.label()).isEqualTo("Not-For-Long");
     assertThat(psa.value()).isEqualTo((short) 2);
@@ -668,7 +670,7 @@
 
     ChangeNotes notes = newNotes(c);
     ImmutableList<PatchSetApproval> approvals =
-        notes.getApprovals().get(c.currentPatchSetId()).stream()
+        notes.getApprovals().all().get(c.currentPatchSetId()).stream()
             .sorted(comparing(a -> a.accountId().get()))
             .collect(toImmutableList());
     assertThat(approvals).hasSize(2);
@@ -710,7 +712,7 @@
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    List<PatchSetApproval> approvals = Lists.newArrayList(notes.getApprovals().values());
+    List<PatchSetApproval> approvals = Lists.newArrayList(notes.getApprovals().all().values());
     assertThat(approvals).hasSize(2);
     assertThat(approvals.get(0).label()).isEqualTo(LabelId.VERIFIED);
     assertThat(approvals.get(0).value()).isEqualTo((short) 1);
@@ -753,7 +755,7 @@
 
     ChangeNotes notes = newNotes(c);
 
-    List<PatchSetApproval> approvals = Lists.newArrayList(notes.getApprovals().values());
+    List<PatchSetApproval> approvals = Lists.newArrayList(notes.getApprovals().all().values());
     assertThat(approvals).hasSize(3);
     assertThat(approvals.get(0).accountId()).isEqualTo(ownerId);
     assertThat(approvals.get(0).label()).isEqualTo(LabelId.VERIFIED);
@@ -783,7 +785,7 @@
 
     ChangeNotes notes = newNotes(c);
     PatchSetApproval originalPsa =
-        Iterables.getOnlyElement(notes.getApprovals().get(c.currentPatchSetId()));
+        Iterables.getOnlyElement(notes.getApprovals().all().get(c.currentPatchSetId()));
     assertThat(originalPsa.accountId()).isEqualTo(changeOwner.getAccountId());
     assertThat(originalPsa.label()).isEqualTo(LabelId.CODE_REVIEW);
     assertThat(originalPsa.value()).isEqualTo(2);
@@ -797,15 +799,15 @@
     addCopiedApproval(c, changeOwner, originalPsa);
 
     notes = newNotes(c);
-    assertThat(notes.getApprovalsWithCopied().keySet()).hasSize(2);
+    assertThat(notes.getApprovals().all().keySet()).hasSize(2);
     PatchSetApproval copiedApproval =
         Iterables.getOnlyElement(
-            notes.getApprovalsWithCopied().get(c.currentPatchSetId()).stream()
+            notes.getApprovals().all().get(c.currentPatchSetId()).stream()
                 .filter(a -> a.copied())
                 .collect(toImmutableList()));
     PatchSetApproval nonCopiedApproval =
         Iterables.getOnlyElement(
-            notes.getApprovalsWithCopied().get(originalPsa.patchSetId()).stream()
+            notes.getApprovals().all().get(originalPsa.patchSetId()).stream()
                 .filter(a -> !a.copied())
                 .collect(toImmutableList()));
 
@@ -829,7 +831,7 @@
       update.commit();
       ChangeNotes notes = newNotes(c);
       PatchSetApproval originalPsa =
-          Iterables.getOnlyElement(notes.getApprovals().get(c.currentPatchSetId()));
+          Iterables.getOnlyElement(notes.getApprovals().all().get(c.currentPatchSetId()));
       assertThat(originalPsa.accountId()).isEqualTo(changeOwner.getAccountId());
       assertThat(originalPsa.label()).isEqualTo(LabelId.CODE_REVIEW);
       assertThat(originalPsa.value()).isEqualTo(2);
@@ -843,15 +845,15 @@
       addCopiedApproval(c, changeOwner, originalPsa);
 
       notes = newNotes(c);
-      assertThat(notes.getApprovalsWithCopied().keySet()).hasSize(2);
+      assertThat(notes.getApprovals().all().keySet()).hasSize(2);
       PatchSetApproval copiedApproval =
           Iterables.getOnlyElement(
-              notes.getApprovalsWithCopied().get(c.currentPatchSetId()).stream()
+              notes.getApprovals().all().get(c.currentPatchSetId()).stream()
                   .filter(a -> a.copied())
                   .collect(toImmutableList()));
       PatchSetApproval nonCopiedApproval =
           Iterables.getOnlyElement(
-              notes.getApprovalsWithCopied().get(originalPsa.patchSetId()).stream()
+              notes.getApprovals().all().get(originalPsa.patchSetId()).stream()
                   .filter(a -> !a.copied())
                   .collect(toImmutableList()));
 
@@ -876,7 +878,7 @@
 
       ChangeNotes notes = newNotes(c);
       PatchSetApproval originalPsa =
-          Iterables.getOnlyElement(notes.getApprovals().get(c.currentPatchSetId()));
+          Iterables.getOnlyElement(notes.getApprovals().all().get(c.currentPatchSetId()));
       assertThat(originalPsa.accountId()).isEqualTo(changeOwner.getAccountId());
       assertThat(originalPsa.label()).isEqualTo(LabelId.CODE_REVIEW);
       assertThat(originalPsa.value()).isEqualTo(2);
@@ -889,15 +891,15 @@
       addCopiedApproval(c, changeOwner, originalPsa);
 
       notes = newNotes(c);
-      assertThat(notes.getApprovalsWithCopied().keySet()).hasSize(2);
+      assertThat(notes.getApprovals().all().keySet()).hasSize(2);
       PatchSetApproval copiedApproval =
           Iterables.getOnlyElement(
-              notes.getApprovalsWithCopied().get(c.currentPatchSetId()).stream()
+              notes.getApprovals().all().get(c.currentPatchSetId()).stream()
                   .filter(a -> a.copied())
                   .collect(toImmutableList()));
       PatchSetApproval nonCopiedApproval =
           Iterables.getOnlyElement(
-              notes.getApprovalsWithCopied().get(originalPsa.patchSetId()).stream()
+              notes.getApprovals().all().get(originalPsa.patchSetId()).stream()
                   .filter(a -> !a.copied())
                   .collect(toImmutableList()));
 
@@ -929,18 +931,16 @@
     update.putApprovalFor(otherUserId, LabelId.CODE_REVIEW, (short) -1);
     update.commit();
 
-    // Only the non copied approval is reachable by getApprovals.
     ChangeNotes notes = newNotes(c);
     PatchSetApproval approval =
-        Iterables.getOnlyElement(notes.getApprovals().get(c.currentPatchSetId()));
+        Iterables.getOnlyElement(notes.getApprovals().onlyNonCopied().get(c.currentPatchSetId()));
     assertThat(approval.accountId()).isEqualTo(otherUser.getAccountId());
     assertThat(approval.label()).isEqualTo(LabelId.CODE_REVIEW);
     assertThat(approval.value()).isEqualTo((short) -1);
     assertThat(approval.copied()).isFalse();
 
-    // Get approvals with copied gets all of the approvals (including copied).
     ImmutableList<PatchSetApproval> approvals =
-        notes.getApprovalsWithCopied().get(c.currentPatchSetId()).stream()
+        notes.getApprovals().all().get(c.currentPatchSetId()).stream()
             .sorted(comparing(a -> a.accountId().get()))
             .collect(toImmutableList());
     assertThat(approvals).hasSize(2);
@@ -983,7 +983,7 @@
 
     ChangeNotes notes = newNotes(c);
     PatchSetApproval approval =
-        Iterables.getOnlyElement(notes.getApprovalsWithCopied().get(c.currentPatchSetId()));
+        Iterables.getOnlyElement(notes.getApprovals().all().get(c.currentPatchSetId()));
     assertThat(approval.accountId()).isEqualTo(changeOwner.getAccountId());
     assertThat(approval.label()).isEqualTo(LabelId.CODE_REVIEW);
     // The vote got removed since the latest patch-set only has one vote and it's "0". The copied
@@ -1014,7 +1014,7 @@
 
     ChangeNotes notes = newNotes(c);
     PatchSetApproval approval =
-        Iterables.getOnlyElement(notes.getApprovalsWithCopied().get(c.currentPatchSetId()));
+        Iterables.getOnlyElement(notes.getApprovals().all().get(c.currentPatchSetId()));
     assertThat(approval.accountId()).isEqualTo(changeOwner.getAccountId());
     assertThat(approval.label()).isEqualTo(LabelId.CODE_REVIEW);
     assertThat(approval.value()).isEqualTo((short) 1);
@@ -1077,7 +1077,7 @@
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    List<PatchSetApproval> approvals = Lists.newArrayList(notes.getApprovalsWithCopied().values());
+    List<PatchSetApproval> approvals = Lists.newArrayList(notes.getApprovals().all().values());
     assertThat(approvals).hasSize(2);
     assertThat(approvals.get(0).label()).isEqualTo(LabelId.VERIFIED);
     assertThat(approvals.get(0).value()).isEqualTo((short) 1);
@@ -1163,7 +1163,7 @@
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    List<PatchSetApproval> psas = notes.getApprovals().get(c.currentPatchSetId());
+    List<PatchSetApproval> psas = notes.getApprovals().all().get(c.currentPatchSetId());
     assertThat(psas).hasSize(2);
     assertThat(psas.get(0).accountId()).isEqualTo(changeOwner.getAccount().id());
     assertThat(psas.get(1).accountId()).isEqualTo(otherUser.getAccount().id());
@@ -1173,7 +1173,7 @@
     update.commit();
 
     notes = newNotes(c);
-    psas = notes.getApprovals().get(c.currentPatchSetId());
+    psas = notes.getApprovals().all().get(c.currentPatchSetId());
     assertThat(psas).hasSize(1);
     assertThat(psas.get(0).accountId()).isEqualTo(changeOwner.getAccount().id());
   }
@@ -1895,7 +1895,7 @@
 
     ChangeNotes notes = newNotes(c);
     assertThat(notes.getPatchSets().keySet()).containsExactly(psId1, psId2);
-    assertThat(notes.getApprovals()).isNotEmpty();
+    assertThat(notes.getApprovals().all()).isNotEmpty();
     assertThat(notes.getChangeMessages()).isNotEmpty();
     assertThat(notes.getHumanComments()).isNotEmpty();
 
@@ -1911,7 +1911,7 @@
 
     notes = newNotes(c);
     assertThat(notes.getPatchSets().keySet()).containsExactly(psId1);
-    assertThat(notes.getApprovals()).isEmpty();
+    assertThat(notes.getApprovals().all()).isEmpty();
     assertThat(notes.getChangeMessages()).isEmpty();
     assertThat(notes.getHumanComments()).isEmpty();
   }
@@ -2024,7 +2024,7 @@
     }
 
     ChangeNotes notes = newNotes(c);
-    List<PatchSetApproval> psas = notes.getApprovals().get(c.currentPatchSetId());
+    List<PatchSetApproval> psas = notes.getApprovals().all().get(c.currentPatchSetId());
     assertThat(psas).hasSize(2);
 
     assertThat(psas.get(0).accountId()).isEqualTo(changeOwner.getAccount().id());
@@ -2084,7 +2084,13 @@
     try (ChangeNotesRevWalk rw = ChangeNotesCommit.newRevWalk(repo)) {
       ChangeNotesParser notesWithComments =
           new ChangeNotesParser(
-              c.getId(), commitWithComments.copy(), rw, changeNoteJson, args.metrics);
+              c.getId(),
+              commitWithComments.copy(),
+              rw,
+              changeNoteJson,
+              args.metrics,
+              serverId,
+              externalIdCache);
       ChangeNotesState state = notesWithComments.parseAll();
       assertThat(state.approvals()).isEmpty();
       assertThat(state.publishedComments()).hasSize(1);
@@ -2093,7 +2099,13 @@
     try (ChangeNotesRevWalk rw = ChangeNotesCommit.newRevWalk(repo)) {
       ChangeNotesParser notesWithApprovals =
           new ChangeNotesParser(
-              c.getId(), commitWithApprovals.copy(), rw, changeNoteJson, args.metrics);
+              c.getId(),
+              commitWithApprovals.copy(),
+              rw,
+              changeNoteJson,
+              args.metrics,
+              serverId,
+              externalIdCache);
 
       ChangeNotesState state = notesWithApprovals.parseAll();
       assertThat(state.approvals()).hasSize(1);
@@ -2130,11 +2142,11 @@
     assertThat(ref2.getObjectId()).isNotEqualTo(initial2.getObjectId());
 
     PatchSetApproval approval1 =
-        newNotes(c1).getApprovals().get(c1.currentPatchSetId()).iterator().next();
+        newNotes(c1).getApprovals().all().get(c1.currentPatchSetId()).iterator().next();
     assertThat(approval1.label()).isEqualTo(LabelId.VERIFIED);
 
     PatchSetApproval approval2 =
-        newNotes(c2).getApprovals().get(c2.currentPatchSetId()).iterator().next();
+        newNotes(c2).getApprovals().all().get(c2.currentPatchSetId()).iterator().next();
     assertThat(approval2.label()).isEqualTo(LabelId.CODE_REVIEW);
   }
 
@@ -3528,7 +3540,7 @@
     ChangeNotes notes = newNotes(c);
     int numMessages = notes.getChangeMessages().size();
     int numPatchSets = notes.getPatchSets().size();
-    int numApprovals = notes.getApprovals().size();
+    int numApprovals = notes.getApprovals().all().size();
     int numComments = notes.getHumanComments().size();
 
     ChangeUpdate update = newUpdate(c, changeOwner);
@@ -3556,7 +3568,7 @@
     notes = newNotes(c);
     assertThat(notes.getChangeMessages()).hasSize(numMessages);
     assertThat(notes.getPatchSets()).hasSize(numPatchSets);
-    assertThat(notes.getApprovals()).hasSize(numApprovals);
+    assertThat(notes.getApprovals().all()).hasSize(numApprovals);
     assertThat(notes.getHumanComments()).hasSize(numComments);
   }
 
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeUpdateTest.java b/javatests/com/google/gerrit/server/notedb/ChangeUpdateTest.java
index cf1b5ae..0bb0578 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeUpdateTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeUpdateTest.java
@@ -17,11 +17,13 @@
 import static com.google.common.truth.Truth.assertThat;
 
 import com.google.common.collect.ImmutableSet;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.entities.Address;
 import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.CommentRange;
 import com.google.gerrit.entities.HumanComment;
+import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.util.time.TimeUtil;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.junit.Test;
@@ -183,10 +185,89 @@
     assertThat(updateWithVote.bypassMaxUpdates()).isFalse();
   }
 
-  private void addToAttentionSet(ChangeUpdate update) {
+  @Test
+  public void commitChangeUpdateWithoutTouchingAttentionSet() throws Exception {
+    Change c = newChangeWithEmptyAttentionSet();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.putApproval("Code-Review", (short) 1);
+    update.commit();
+
+    assertThat(update.getAttentionSetUpdates()).isEmpty();
+  }
+
+  @Test
+  public void nonCommittedChangeUpdateReturnsEmptyAttentionSetUpdates() throws Exception {
+    Change c = newChangeWithEmptyAttentionSet();
+
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    addToAttentionSet(update, otherUser);
+
+    assertThat(update.getAttentionSetUpdates()).isEmpty();
+  }
+
+  @Test
+  public void committedChangeUpdateReturnsAttentionSetUpdates() throws Exception {
+    Change c = newChangeWithEmptyAttentionSet();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    AttentionSetUpdate attentionSetUpdate = addToAttentionSet(update, otherUser);
+    update.commit();
+
+    assertThat(update.getAttentionSetUpdates()).containsExactly(attentionSetUpdate);
+  }
+
+  @Test
+  public void committedChangeUpdateReturnsMultipleAttentionSetUpdates() throws Exception {
+    Change c = newChangeWithEmptyAttentionSet();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    AttentionSetUpdate attentionSetUpdate1 = addToAttentionSet(update, otherUser);
+    AttentionSetUpdate attentionSetUpdate2 = addToAttentionSet(update, changeOwner);
+    update.commit();
+
+    assertThat(update.getAttentionSetUpdates())
+        .containsExactly(attentionSetUpdate1, attentionSetUpdate2);
+  }
+
+  @Test
+  public void changeUpdateDoesntReturnAttentionSetUpdateForUserAlreadyAddedInAttentionSet()
+      throws Exception {
+    Change c = newChangeWithEmptyAttentionSet();
+    ChangeUpdate update1 = newUpdate(c, changeOwner);
+    addToAttentionSet(update1, otherUser);
+    update1.commit();
+
+    ChangeUpdate update2 = newUpdate(c, changeOwner);
+    addToAttentionSet(update2, otherUser);
+    update2.commit();
+
+    assertThat(update2.getAttentionSetUpdates()).isEmpty();
+  }
+
+  /**
+   * Creates a change with an empty attention set
+   *
+   * <p>Method ensures that changeOwner and otherUser can be added to the attention set later. (only
+   * users active on the change can be added to the attention set - see {@link
+   * ChangeUpdate#isActiveOnChange})
+   */
+  private Change newChangeWithEmptyAttentionSet() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.putReviewer(otherUser.getAccountId(), ReviewerStateInternal.CC);
+    update.commit();
+    return c;
+  }
+
+  @CanIgnoreReturnValue
+  private AttentionSetUpdate addToAttentionSet(ChangeUpdate update) {
+    return addToAttentionSet(update, otherUser);
+  }
+
+  @CanIgnoreReturnValue
+  private AttentionSetUpdate addToAttentionSet(ChangeUpdate update, IdentifiedUser user) {
     AttentionSetUpdate attentionSetUpdate =
         AttentionSetUpdate.createForWrite(
-            otherUser.getAccountId(), AttentionSetUpdate.Operation.ADD, "test");
+            user.getAccountId(), AttentionSetUpdate.Operation.ADD, "test");
     update.addToPlannedAttentionSetUpdates(ImmutableSet.of(attentionSetUpdate));
+    return attentionSetUpdate;
   }
 }
diff --git a/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java b/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java
index 5e2e1f2..b53de89 100644
--- a/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java
+++ b/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java
@@ -27,7 +27,7 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.testing.TestChanges;
-import java.util.TimeZone;
+import java.time.ZoneId;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.revwalk.RevCommit;
@@ -35,15 +35,12 @@
 import org.junit.Test;
 
 public class CommitMessageOutputTest extends AbstractChangeNotesTest {
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   @Test
   public void approvalsCommitFormatSimple() throws Exception {
     Change c = TestChanges.newChange(project, changeOwner.getAccountId(), 1);
     ChangeUpdate update = newUpdateForNewChange(c, changeOwner);
-    update.putApproval(LabelId.VERIFIED, (short) 1);
     update.putApproval(LabelId.CODE_REVIEW, (short) -1);
+    update.putApproval(LabelId.VERIFIED, (short) 1);
     update.putReviewer(changeOwner.getAccount().id(), REVIEWER);
     update.putReviewer(otherUser.getAccount().id(), CC);
     update.commit();
@@ -70,14 +67,15 @@
     PersonIdent author = commit.getAuthorIdent();
     assertThat(author.getName()).isEqualTo("Gerrit User 1");
     assertThat(author.getEmailAddress()).isEqualTo("1@gerrit");
-    assertThat(author.getWhen().getTime()).isEqualTo(c.getCreatedOn().toEpochMilli() + 1000);
-    assertThat(author.getTimeZone()).isEqualTo(TimeZone.getTimeZone("GMT-7:00"));
+    assertThat(author.getWhenAsInstant().toEpochMilli())
+        .isEqualTo(c.getCreatedOn().toEpochMilli() + 1000);
+    assertThat(author.getZoneId()).isEqualTo(ZoneId.of("GMT-7"));
 
     PersonIdent committer = commit.getCommitterIdent();
     assertThat(committer.getName()).isEqualTo("Gerrit Server");
     assertThat(committer.getEmailAddress()).isEqualTo("noreply@gerrit.com");
-    assertThat(committer.getWhen().getTime()).isEqualTo(author.getWhen().getTime());
-    assertThat(committer.getTimeZone()).isEqualTo(author.getTimeZone());
+    assertThat(committer.getWhenAsInstant()).isEqualTo(author.getWhenAsInstant());
+    assertThat(committer.getZoneId()).isEqualTo(author.getZoneId());
   }
 
   @Test
@@ -145,9 +143,6 @@
   }
 
   @Test
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   public void submitCommitFormat() throws Exception {
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, changeOwner);
@@ -189,14 +184,15 @@
     PersonIdent author = commit.getAuthorIdent();
     assertThat(author.getName()).isEqualTo("Gerrit User 1");
     assertThat(author.getEmailAddress()).isEqualTo("1@gerrit");
-    assertThat(author.getWhen().getTime()).isEqualTo(c.getCreatedOn().toEpochMilli() + 2000);
-    assertThat(author.getTimeZone()).isEqualTo(TimeZone.getTimeZone("GMT-7:00"));
+    assertThat(author.getWhenAsInstant().toEpochMilli())
+        .isEqualTo(c.getCreatedOn().toEpochMilli() + 2000);
+    assertThat(author.getZoneId()).isEqualTo(ZoneId.of("GMT-7"));
 
     PersonIdent committer = commit.getCommitterIdent();
     assertThat(committer.getName()).isEqualTo("Gerrit Server");
     assertThat(committer.getEmailAddress()).isEqualTo("noreply@gerrit.com");
-    assertThat(committer.getWhen().getTime()).isEqualTo(author.getWhen().getTime());
-    assertThat(committer.getTimeZone()).isEqualTo(author.getTimeZone());
+    assertThat(committer.getWhenAsInstant()).isEqualTo(author.getWhenAsInstant());
+    assertThat(committer.getZoneId()).isEqualTo(author.getZoneId());
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/server/notedb/CommitRewriterTest.java b/javatests/com/google/gerrit/server/notedb/CommitRewriterTest.java
index 3b18183..5e6803e 100644
--- a/javatests/com/google/gerrit/server/notedb/CommitRewriterTest.java
+++ b/javatests/com/google/gerrit/server/notedb/CommitRewriterTest.java
@@ -49,7 +49,6 @@
 import com.google.inject.Inject;
 import java.time.Instant;
 import java.util.Arrays;
-import java.util.Date;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
@@ -321,9 +320,6 @@
     assertThat(secondRunResult.fixedRefDiff.keySet().size()).isEqualTo(expectedSecondRunResult);
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   @Test
   public void fixAuthorIdent() throws Exception {
     Change c = newChange();
@@ -332,8 +328,8 @@
         new PersonIdent(
             changeOwner.getName(),
             changeNoteUtil.getAccountIdAsEmailAddress(changeOwner.getAccountId()),
-            Date.from(when),
-            serverIdent.getTimeZone());
+            when,
+            serverIdent.getZoneId());
     RevCommit invalidUpdateCommit =
         writeUpdate(
             RefNames.changeMetaRef(c.getId()),
@@ -374,8 +370,8 @@
     assertThat(fixedUpdateCommit.getAuthorIdent().getName())
         .isEqualTo("Gerrit User " + changeOwner.getAccountId());
     assertThat(originalAuthorIdent.getEmailAddress()).isEqualTo(fixedAuthorIdent.getEmailAddress());
-    assertThat(originalAuthorIdent.getWhen().getTime())
-        .isEqualTo(fixedAuthorIdent.getWhen().getTime());
+    assertThat(originalAuthorIdent.getWhenAsInstant())
+        .isEqualTo(fixedAuthorIdent.getWhenAsInstant());
     assertThat(originalAuthorIdent.getTimeZone()).isEqualTo(fixedAuthorIdent.getTimeZone());
     assertThat(invalidUpdateCommit.getFullMessage()).isEqualTo(fixedUpdateCommit.getFullMessage());
     assertThat(invalidUpdateCommit.getCommitterIdent())
@@ -453,9 +449,6 @@
     assertThat(secondRunResult.refsFailedToFix).isEmpty();
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   @Test
   public void fixReviewerFooterIdent() throws Exception {
     Change c = newChange();
@@ -502,7 +495,7 @@
     BackfillResult result = rewriter.backfillProject(project, repo, options);
     assertThat(result.fixedRefDiff.keySet()).containsExactly(RefNames.changeMetaRef(c.getId()));
 
-    Instant updateTimestamp = serverIdent.getWhen().toInstant();
+    Instant updateTimestamp = serverIdent.getWhenAsInstant();
     ImmutableList<ReviewerStatusUpdate> expectedReviewerUpdates =
         ImmutableList.of(
             ReviewerStatusUpdate.create(
@@ -539,9 +532,6 @@
     assertThat(secondRunResult.refsFailedToFix).isEmpty();
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   @Test
   public void fixReviewerMessage() throws Exception {
     Change c = newChange();
@@ -589,7 +579,7 @@
     BackfillResult result = rewriter.backfillProject(project, repo, options);
     assertThat(result.fixedRefDiff.keySet()).containsExactly(RefNames.changeMetaRef(c.getId()));
 
-    Instant updateTimestamp = serverIdent.getWhen().toInstant();
+    Instant updateTimestamp = serverIdent.getWhenAsInstant();
     ImmutableList<ReviewerStatusUpdate> expectedReviewerUpdates =
         ImmutableList.of(
             ReviewerStatusUpdate.create(
@@ -669,9 +659,6 @@
     assertThat(secondRunResult.refsFailedToFix).isEmpty();
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   @Test
   public void fixLabelFooterIdent() throws Exception {
     Change c = newChange();
@@ -722,7 +709,7 @@
     BackfillResult result = rewriter.backfillProject(project, repo, options);
     assertThat(result.fixedRefDiff.keySet()).containsExactly(RefNames.changeMetaRef(c.getId()));
 
-    Instant updateTimestamp = serverIdent.getWhen().toInstant();
+    Instant updateTimestamp = serverIdent.getWhenAsInstant();
     ImmutableList<PatchSetApproval> expectedApprovals =
         ImmutableList.of(
             PatchSetApproval.builder()
@@ -773,9 +760,9 @@
                 .build());
     ChangeNotes notesAfterRewrite = newNotes(c);
 
-    assertThat(notesBeforeRewrite.getApprovals().get(c.currentPatchSetId()))
+    assertThat(notesBeforeRewrite.getApprovals().all().get(c.currentPatchSetId()))
         .containsExactlyElementsIn(expectedApprovals);
-    assertThat(notesAfterRewrite.getApprovals().get(c.currentPatchSetId()))
+    assertThat(notesAfterRewrite.getApprovals().all().get(c.currentPatchSetId()))
         .containsExactlyElementsIn(expectedApprovals);
 
     Ref metaRefAfterRewrite = repo.exactRef(RefNames.changeMetaRef(c.getId()));
@@ -806,9 +793,6 @@
     assertThat(secondRunResult.refsFailedToFix).isEmpty();
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   @Test
   public void fixRemoveVoteChangeMessage() throws Exception {
     Change c = newChange();
@@ -862,7 +846,7 @@
     BackfillResult result = rewriter.backfillProject(project, repo, options);
     assertThat(result.fixedRefDiff.keySet()).containsExactly(RefNames.changeMetaRef(c.getId()));
 
-    Instant updateTimestamp = serverIdent.getWhen().toInstant();
+    Instant updateTimestamp = serverIdent.getWhenAsInstant();
     ImmutableList<PatchSetApproval> expectedApprovals =
         ImmutableList.of(
             PatchSetApproval.builder()
@@ -895,14 +879,14 @@
             "Removed Custom-Label-1 by Other Account <other@account.com>",
             "Removed Verified+2 by Change Owner <change@owner.com>");
 
-    assertThat(notesBeforeRewrite.getApprovals().get(c.currentPatchSetId()))
+    assertThat(notesBeforeRewrite.getApprovals().all().get(c.currentPatchSetId()))
         .containsExactlyElementsIn(expectedApprovals);
     assertThat(changeMessages(notesAfterRewrite))
         .containsExactly(
             "Removed Code-Review+2 by <GERRIT_ACCOUNT_2>",
             "Removed Custom-Label-1 by <GERRIT_ACCOUNT_2>",
             "Removed Verified+2 by <GERRIT_ACCOUNT_1>");
-    assertThat(notesAfterRewrite.getApprovals().get(c.currentPatchSetId()))
+    assertThat(notesAfterRewrite.getApprovals().all().get(c.currentPatchSetId()))
         .containsExactlyElementsIn(expectedApprovals);
 
     Ref metaRefAfterRewrite = repo.exactRef(RefNames.changeMetaRef(c.getId()));
@@ -932,18 +916,12 @@
     assertThat(secondRunResult.refsFailedToFix).isEmpty();
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   @Test
   public void fixRemoveVoteChangeMessageWithUnparsableAuthorIdent() throws Exception {
     Change c = newChange();
     PersonIdent invalidAuthorIdent =
         new PersonIdent(
-            changeOwner.getName(),
-            "server@" + serverId,
-            Date.from(TimeUtil.now()),
-            serverIdent.getTimeZone());
+            changeOwner.getName(), "server@" + serverId, TimeUtil.now(), serverIdent.getZoneId());
     writeUpdate(
         RefNames.changeMetaRef(c.getId()),
         getChangeUpdateBody(
@@ -1188,9 +1166,6 @@
     assertThat(secondRunResult.refsFailedToFix).isEmpty();
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   @Test
   public void fixAttentionFooter() throws Exception {
     Change c = newChange();
@@ -1271,7 +1246,7 @@
     BackfillResult result = rewriter.backfillProject(project, repo, options);
     assertThat(result.fixedRefDiff.keySet()).containsExactly(RefNames.changeMetaRef(c.getId()));
     notesBeforeRewrite.getAttentionSetUpdates();
-    Instant updateTimestamp = serverIdent.getWhen().toInstant();
+    Instant updateTimestamp = serverIdent.getWhenAsInstant();
     ImmutableList<AttentionSetUpdate> attentionSetUpdatesBeforeRewrite =
         ImmutableList.of(
             AttentionSetUpdate.createFromRead(
@@ -1569,9 +1544,6 @@
     assertThat(secondRunResult.refsFailedToFix).isEmpty();
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   @Test
   public void fixSubmitChangeMessageAndFooters() throws Exception {
     Change c = newChange();
@@ -1579,8 +1551,8 @@
         new PersonIdent(
             changeOwner.getName(),
             changeNoteUtil.getAccountIdAsEmailAddress(changeOwner.getAccountId()),
-            Date.from(TimeUtil.now()),
-            serverIdent.getTimeZone());
+            TimeUtil.now(),
+            serverIdent.getZoneId());
     String changeOwnerIdentToFix = getAccountIdentToFix(changeOwner.getAccount());
     writeUpdate(
         RefNames.changeMetaRef(c.getId()),
@@ -2281,9 +2253,6 @@
     assertThat(secondRunResult.refsFailedToFix).isEmpty();
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   @Test
   public void singleRunFixesAll() throws Exception {
     Change c = newChange();
@@ -2293,8 +2262,8 @@
         new PersonIdent(
             changeOwner.getName(),
             changeNoteUtil.getAccountIdAsEmailAddress(changeOwner.getAccountId()),
-            Date.from(when),
-            serverIdent.getTimeZone());
+            when,
+            serverIdent.getZoneId());
 
     RevCommit invalidUpdateCommit =
         writeUpdate(
diff --git a/javatests/com/google/gerrit/server/notedb/ImportedChangeNotesTest.java b/javatests/com/google/gerrit/server/notedb/ImportedChangeNotesTest.java
new file mode 100644
index 0000000..bb49a6d
--- /dev/null
+++ b/javatests/com/google/gerrit/server/notedb/ImportedChangeNotesTest.java
@@ -0,0 +1,114 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIdCache;
+import com.google.gerrit.server.git.RepositoryCaseMismatchException;
+import com.google.inject.AbstractModule;
+import java.util.Optional;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.junit.Before;
+import org.junit.Test;
+
+public class ImportedChangeNotesTest extends AbstractChangeNotesTest {
+
+  private static final String FOREIGN_SERVER_ID = "foreign-server-id";
+  private static final String IMPORTED_SERVER_ID = "gerrit-imported-1";
+
+  private ExternalIdCache externalIdCacheMock;
+
+  @Before
+  @Override
+  public void setUpTestEnvironment() throws Exception {
+    setupTestPrerequisites();
+  }
+
+  private void initServerIds(String serverId, String... importedServerIds)
+      throws Exception, RepositoryCaseMismatchException, RepositoryNotFoundException {
+    externalIdCacheMock = mock(ExternalIdCache.class);
+    injector =
+        createTestInjector(
+            new AbstractModule() {
+              @Override
+              protected void configure() {
+                bind(ExternalIdCache.class).toInstance(externalIdCacheMock);
+              }
+            },
+            serverId,
+            importedServerIds);
+    injector.injectMembers(this);
+    createAllUsers(injector);
+  }
+
+  @Test
+  public void allowChangeFromImportedServerId() throws Exception {
+    initServerIds(LOCAL_SERVER_ID, IMPORTED_SERVER_ID);
+
+    ExternalId.Key importedAccountIdKey =
+        ExternalId.Key.create(
+            ExternalId.SCHEME_IMPORTED,
+            changeOwner.getAccountId() + "@" + IMPORTED_SERVER_ID,
+            false);
+    ExternalId importedAccountId =
+        ExternalId.create(importedAccountIdKey, changeOwner.getAccountId(), null, null, null);
+
+    when(externalIdCacheMock.byKey(eq(importedAccountIdKey)))
+        .thenReturn(Optional.of(importedAccountId));
+
+    Change importedChange = newChange(createTestInjector(IMPORTED_SERVER_ID), false);
+    Change localChange = newChange();
+
+    assertThat(newNotes(importedChange).getServerId()).isEqualTo(IMPORTED_SERVER_ID);
+    assertThat(newNotes(localChange).getServerId()).isEqualTo(LOCAL_SERVER_ID);
+  }
+
+  @Test
+  public void rejectChangeWithForeignServerId() throws Exception {
+    initServerIds(LOCAL_SERVER_ID);
+    when(externalIdCacheMock.byKey(any())).thenReturn(Optional.empty());
+
+    Change foreignChange = newChange(createTestInjector(FOREIGN_SERVER_ID), false);
+
+    InvalidServerIdException invalidServerIdEx =
+        assertThrows(InvalidServerIdException.class, () -> newNotes(foreignChange));
+
+    String invalidServerIdMessage = invalidServerIdEx.getMessage();
+    assertThat(invalidServerIdMessage).contains("expected " + LOCAL_SERVER_ID);
+    assertThat(invalidServerIdMessage).contains("actual: " + FOREIGN_SERVER_ID);
+  }
+
+  @Test
+  public void changeFromImportedServerIdWithUnknownAccountId() throws Exception {
+    initServerIds(LOCAL_SERVER_ID, IMPORTED_SERVER_ID);
+
+    when(externalIdCacheMock.byKey(any())).thenReturn(Optional.empty());
+
+    Change importedChange = newChange(createTestInjector(IMPORTED_SERVER_ID), false);
+    assertThat(newNotes(importedChange).getServerId()).isEqualTo(IMPORTED_SERVER_ID);
+
+    assertThat(newNotes(importedChange).getChange().getOwner())
+        .isEqualTo(Account.UNKNOWN_ACCOUNT_ID);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/notedb/OpenRepoTest.java b/javatests/com/google/gerrit/server/notedb/OpenRepoTest.java
index 507b71f..323aee9 100644
--- a/javatests/com/google/gerrit/server/notedb/OpenRepoTest.java
+++ b/javatests/com/google/gerrit/server/notedb/OpenRepoTest.java
@@ -244,6 +244,19 @@
     }
   }
 
+  @Test
+  public void canCreateChangeNotesFromOpenRepoAndChangeid() throws Exception {
+    try (OpenRepo openRepo = openRepo()) {
+      Change change = newChange();
+
+      ChangeNotes changeNotes =
+          changeNotesFactory.createChecked(openRepo.repo, project, change.getId(), null);
+
+      assertThat(changeNotes).isNotNull();
+      assertThat(changeNotes.getChangeId()).isEqualTo(change.getId());
+    }
+  }
+
   private void addToAttentionSet(ChangeUpdate update) {
     AttentionSetUpdate attentionSetUpdate =
         AttentionSetUpdate.createForWrite(
diff --git a/javatests/com/google/gerrit/server/patch/DiffOperationsTest.java b/javatests/com/google/gerrit/server/patch/DiffOperationsTest.java
index fa04cf8..1c28690 100644
--- a/javatests/com/google/gerrit/server/patch/DiffOperationsTest.java
+++ b/javatests/com/google/gerrit/server/patch/DiffOperationsTest.java
@@ -32,7 +32,6 @@
 import com.google.inject.Inject;
 import com.google.inject.Injector;
 import java.io.IOException;
-import java.util.Date;
 import java.util.Map;
 import java.util.Optional;
 import org.eclipse.jgit.lib.CommitBuilder;
@@ -93,7 +92,7 @@
   }
 
   @Test
-  public void diffAgainstAutoMergePersistsAutoMergeInRepo() throws Exception {
+  public void diffAgainstAutoMergeDoesNotPersistAutoMergeInRepo() throws Exception {
     ObjectId parent1 =
         createCommit(repo, null, ImmutableList.of(new FileEntity("file_1.txt", "file 1 content")));
     ObjectId parent2 =
@@ -117,8 +116,7 @@
             testProjectName, merge, /* parentNum=*/ 0, DiffOptions.DEFAULTS);
     assertThat(changedFiles.keySet()).containsExactly("/COMMIT_MSG", "/MERGE_LIST", "file_3.txt");
 
-    // Requesting diff against auto-merge had the side effect of updating the auto-merge ref
-    assertThat(repo.getRefDatabase().exactRef(autoMergeRef)).isNotNull();
+    assertThat(repo.getRefDatabase().exactRef(autoMergeRef)).isNull();
   }
 
   @Test
@@ -257,14 +255,11 @@
         : createCommitInRepo(repo, treeId, parentCommit);
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   private static ObjectId createCommitInRepo(Repository repo, ObjectId treeId, ObjectId... parents)
       throws IOException {
     try (ObjectInserter oi = repo.newObjectInserter()) {
       PersonIdent committer =
-          new PersonIdent(new PersonIdent("Foo Bar", "foo.bar@baz.com"), Date.from(TimeUtil.now()));
+          new PersonIdent(new PersonIdent("Foo Bar", "foo.bar@baz.com"), TimeUtil.now());
       CommitBuilder cb = new CommitBuilder();
       cb.setTreeId(treeId);
       cb.setCommitter(committer);
diff --git a/javatests/com/google/gerrit/server/patch/MagicFileTest.java b/javatests/com/google/gerrit/server/patch/MagicFileTest.java
index 21ea641..b0050b0 100644
--- a/javatests/com/google/gerrit/server/patch/MagicFileTest.java
+++ b/javatests/com/google/gerrit/server/patch/MagicFileTest.java
@@ -22,9 +22,8 @@
 import java.time.Instant;
 import java.time.LocalDateTime;
 import java.time.Month;
+import java.time.ZoneId;
 import java.time.ZoneOffset;
-import java.util.Date;
-import java.util.TimeZone;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectReader;
@@ -95,9 +94,6 @@
     assertThat(magicFile.getStartLineOfModifiableContent()).isEqualTo(1);
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   @Test
   public void commitMessageFileOfRootCommitContainsCorrectContent() throws Exception {
     try (Repository repository = repositoryManager.createRepository(Project.nameKey("repo1"));
@@ -107,20 +103,12 @@
       Instant authorTime =
           LocalDateTime.of(2020, Month.APRIL, 23, 19, 30, 27).atZone(ZoneOffset.UTC).toInstant();
       PersonIdent author =
-          new PersonIdent(
-              "Alfred",
-              "alfred@example.com",
-              Date.from(authorTime),
-              TimeZone.getTimeZone(ZoneOffset.UTC));
+          new PersonIdent("Alfred", "alfred@example.com", authorTime, ZoneId.of("UTC"));
 
       Instant committerTime =
           LocalDateTime.of(2021, Month.JANUARY, 6, 5, 12, 55).atZone(ZoneOffset.UTC).toInstant();
       PersonIdent committer =
-          new PersonIdent(
-              "Luise",
-              "luise@example.com",
-              Date.from(committerTime),
-              TimeZone.getTimeZone(ZoneOffset.UTC));
+          new PersonIdent("Luise", "luise@example.com", committerTime, ZoneId.of("UTC"));
 
       ObjectId commit =
           testRepo
@@ -149,9 +137,6 @@
     }
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   @Test
   public void commitMessageFileOfNonMergeCommitContainsCorrectContent() throws Exception {
     try (Repository repository = repositoryManager.createRepository(Project.nameKey("repo1"));
@@ -161,20 +146,12 @@
       Instant authorTime =
           LocalDateTime.of(2020, Month.APRIL, 23, 19, 30, 27).atZone(ZoneOffset.UTC).toInstant();
       PersonIdent author =
-          new PersonIdent(
-              "Alfred",
-              "alfred@example.com",
-              Date.from(authorTime),
-              TimeZone.getTimeZone(ZoneOffset.UTC));
+          new PersonIdent("Alfred", "alfred@example.com", authorTime, ZoneId.of("UTC"));
 
       Instant committerTime =
           LocalDateTime.of(2021, Month.JANUARY, 6, 5, 12, 55).atZone(ZoneOffset.UTC).toInstant();
       PersonIdent committer =
-          new PersonIdent(
-              "Luise",
-              "luise@example.com",
-              Date.from(committerTime),
-              TimeZone.getTimeZone(ZoneOffset.UTC));
+          new PersonIdent("Luise", "luise@example.com", committerTime, ZoneId.of("UTC"));
 
       RevCommit parent =
           testRepo.commit().message("Parent subject\n\nParent further details.").create();
@@ -208,9 +185,6 @@
     }
   }
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   @Test
   public void commitMessageFileOfMergeCommitContainsCorrectContent() throws Exception {
     try (Repository repository = repositoryManager.createRepository(Project.nameKey("repo1"));
@@ -220,20 +194,12 @@
       Instant authorTime =
           LocalDateTime.of(2020, Month.APRIL, 23, 19, 30, 27).atZone(ZoneOffset.UTC).toInstant();
       PersonIdent author =
-          new PersonIdent(
-              "Alfred",
-              "alfred@example.com",
-              Date.from(authorTime),
-              TimeZone.getTimeZone(ZoneOffset.UTC));
+          new PersonIdent("Alfred", "alfred@example.com", authorTime, ZoneId.of("UTC"));
 
       Instant committerTime =
           LocalDateTime.of(2021, Month.JANUARY, 6, 5, 12, 55).atZone(ZoneOffset.UTC).toInstant();
       PersonIdent committer =
-          new PersonIdent(
-              "Luise",
-              "luise@example.com",
-              Date.from(committerTime),
-              TimeZone.getTimeZone(ZoneOffset.UTC));
+          new PersonIdent("Luise", "luise@example.com", committerTime, ZoneId.of("UTC"));
 
       RevCommit parent1 = testRepo.commit().message("Parent 1\n\nExplanation 1.").create();
       RevCommit parent2 = testRepo.commit().message("Parent 2\n\nExplanation 2.").create();
diff --git a/javatests/com/google/gerrit/server/project/ProjectConfigTest.java b/javatests/com/google/gerrit/server/project/ProjectConfigTest.java
index 26f7d60..ef92139 100644
--- a/javatests/com/google/gerrit/server/project/ProjectConfigTest.java
+++ b/javatests/com/google/gerrit/server/project/ProjectConfigTest.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
+import static com.google.common.truth.Truth8.assertThat;
 import static com.google.gerrit.entities.BooleanProjectConfig.REQUIRE_CHANGE_ID;
 
 import com.google.common.collect.ImmutableList;
@@ -75,31 +76,8 @@
 
 @RunWith(JUnit4.class)
 public class ProjectConfigTest {
-  private static final String LABEL_SCORES_CONFIG =
-      "  copyAnyScore = "
-          + !LabelType.DEF_COPY_ANY_SCORE
-          + "\n"
-          + "  copyMinScore = "
-          + !LabelType.DEF_COPY_MIN_SCORE
-          + "\n"
-          + "  copyMaxScore = "
-          + !LabelType.DEF_COPY_MAX_SCORE
-          + "\n"
-          + "  copyAllScoresIfListOfFilesDidNotChange = "
-          + !LabelType.DEF_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE
-          + "\n"
-          + "  copyAllScoresOnMergeFirstParentUpdate = "
-          + !LabelType.DEF_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE
-          + "\n"
-          + "  copyAllScoresOnTrivialRebase = "
-          + !LabelType.DEF_COPY_ALL_SCORES_ON_TRIVIAL_REBASE
-          + "\n"
-          + "  copyAllScoresIfNoCodeChange = "
-          + !LabelType.DEF_COPY_ALL_SCORES_IF_NO_CODE_CHANGE
-          + "\n"
-          + "  copyAllScoresIfNoChange = "
-          + !LabelType.DEF_COPY_ALL_SCORES_IF_NO_CHANGE
-          + "\n";
+  private static final String COPY_CONDITION = "is:MIN OR is:MAX";
+  private static final String LABEL_SCORES_CONFIG = "  copyCondition = " + COPY_CONDITION + "\n";
 
   private static final AllProjectsName ALL_PROJECTS = new AllProjectsName("All-The-Projects");
 
@@ -422,7 +400,7 @@
   }
 
   @Test
-  public void readConfigLabelScores() throws Exception {
+  public void readConfigCondition() throws Exception {
     RevCommit rev =
         tr.commit()
             .add("groups", group(developers))
@@ -432,19 +410,7 @@
     ProjectConfig cfg = read(rev);
     Map<String, LabelType> labels = cfg.getLabelSections();
     LabelType type = labels.entrySet().iterator().next().getValue();
-    assertThat(type.isCopyAnyScore()).isNotEqualTo(LabelType.DEF_COPY_ANY_SCORE);
-    assertThat(type.isCopyMinScore()).isNotEqualTo(LabelType.DEF_COPY_MIN_SCORE);
-    assertThat(type.isCopyMaxScore()).isNotEqualTo(LabelType.DEF_COPY_MAX_SCORE);
-    assertThat(type.isCopyAllScoresIfListOfFilesDidNotChange())
-        .isNotEqualTo(LabelType.DEF_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE);
-    assertThat(type.isCopyAllScoresOnMergeFirstParentUpdate())
-        .isNotEqualTo(LabelType.DEF_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE);
-    assertThat(type.isCopyAllScoresOnTrivialRebase())
-        .isNotEqualTo(LabelType.DEF_COPY_ALL_SCORES_ON_TRIVIAL_REBASE);
-    assertThat(type.isCopyAllScoresIfNoCodeChange())
-        .isNotEqualTo(LabelType.DEF_COPY_ALL_SCORES_IF_NO_CODE_CHANGE);
-    assertThat(type.isCopyAllScoresIfNoChange())
-        .isNotEqualTo(LabelType.DEF_COPY_ALL_SCORES_IF_NO_CHANGE);
+    assertThat(type.getCopyCondition()).hasValue(COPY_CONDITION);
   }
 
   @Test
@@ -736,15 +702,21 @@
             .add(
                 "project.config",
                 "[commentlink \"bugzilla\"]\n"
-                    + "\tmatch = \"(bug\\\\s+#?)(\\\\d+)\"\n"
-                    + "\tlink = http://bugs.example.com/show_bug.cgi?id=$2")
+                    + "\tmatch = \"(^|\\\\s)(bug\\\\s+#?)(\\\\d+)($|\\\\s)\"\n"
+                    + "\tlink = http://bugs.example.com/show_bug.cgi?id=$3\n"
+                    + "\tprefix = $1\n"
+                    + "\ttext = $2$3\n"
+                    + "\tsuffix = $4\n")
             .create();
     ProjectConfig cfg = read(rev);
     assertThat(cfg.getCommentLinkSections())
         .containsExactly(
             StoredCommentLinkInfo.builder("bugzilla")
-                .setMatch("(bug\\s+#?)(\\d+)")
-                .setLink("http://bugs.example.com/show_bug.cgi?id=$2")
+                .setMatch("(^|\\s)(bug\\s+#?)(\\d+)($|\\s)")
+                .setLink("http://bugs.example.com/show_bug.cgi?id=$3")
+                .setPrefix("$1")
+                .setSuffix("$4")
+                .setText("$2$3")
                 .setOverrideOnly(false)
                 .build());
   }
@@ -1059,24 +1031,6 @@
             });
   }
 
-  @Test
-  public void readCopyValues_emptyValueIsIgnored() throws Exception {
-    RevCommit rev =
-        tr.commit()
-            .add(
-                "project.config",
-                "[label \"CustomLabel\"]\n"
-                    + "  copyValue = 1\n"
-                    + "  copyValue = 2\n"
-                    + "  copyValue = \n")
-            .create();
-
-    ProjectConfig cfg = read(rev);
-    Map<String, LabelType> labels = cfg.getLabelSections();
-    assertThat(labels.entrySet().iterator().next().getValue().getCopyValues())
-        .containsExactly((short) 1, (short) 2);
-  }
-
   private Path writeDefaultAllProjectsConfig(String... lines) throws IOException {
     Path dir = sitePaths.etc_dir.resolve(ALL_PROJECTS.get());
     Files.createDirectories(dir);
diff --git a/javatests/com/google/gerrit/server/project/SubmitRequirementNameValidatorTest.java b/javatests/com/google/gerrit/server/project/SubmitRequirementNameValidatorTest.java
new file mode 100644
index 0000000..98ee71d
--- /dev/null
+++ b/javatests/com/google/gerrit/server/project/SubmitRequirementNameValidatorTest.java
@@ -0,0 +1,57 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.project;
+
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/** Test for {@link SubmitRequirementsUtil#validateName(String)}. */
+@RunWith(JUnit4.class)
+public class SubmitRequirementNameValidatorTest {
+  @Test
+  public void canStartWithSmallLetter() throws Exception {
+    SubmitRequirementsUtil.validateName("abc");
+  }
+
+  @Test
+  public void canStartWithCapitalLetter() throws Exception {
+    SubmitRequirementsUtil.validateName("Abc");
+  }
+
+  @Test
+  public void canBeEqualToOneLetter() throws Exception {
+    SubmitRequirementsUtil.validateName("a");
+  }
+
+  @Test
+  public void cannotStartWithNumber() throws Exception {
+    assertThrows(
+        IllegalArgumentException.class, () -> SubmitRequirementsUtil.validateName("98abc"));
+  }
+
+  @Test
+  public void cannotStartWithHyphen() throws Exception {
+    assertThrows(IllegalArgumentException.class, () -> SubmitRequirementsUtil.validateName("-abc"));
+  }
+
+  @Test
+  public void cannotContainNonAlphanumericOrHyphen() throws Exception {
+    assertThrows(
+        IllegalArgumentException.class, () -> SubmitRequirementsUtil.validateName("a&^bc"));
+  }
+}
diff --git a/javatests/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java b/javatests/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java
index e0a69a0..b0e705b 100644
--- a/javatests/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java
+++ b/javatests/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java
@@ -45,7 +45,6 @@
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.index.QueryOptions;
 import com.google.gerrit.index.Schema;
@@ -269,19 +268,7 @@
     AccountInfo user2 = newAccount("user");
     requestContext.setContext(newRequestContext(Account.id(user2._accountId)));
 
-    if (getSchemaVersion() < 5) {
-      assertMissingField(AccountField.PREFERRED_EMAIL);
-      assertFailingQuery("email:foo", "'email' operator is not supported by account index version");
-      return;
-    }
-
-    // This at least needs the PREFERRED_EMAIL field which is available from schema version 5.
-    if (getSchemaVersion() >= 5) {
-      assertQuery(preferredEmail, user1);
-    } else {
-      assertQuery(preferredEmail);
-    }
-
+    assertQuery(preferredEmail, user1);
     assertQuery(secondaryEmail);
 
     assertQuery("email:" + preferredEmail, user1);
@@ -369,14 +356,6 @@
     assertQuery("self", user3);
     assertQuery("me", user3);
 
-    if (getSchemaVersion() < 8) {
-      assertMissingField(AccountField.NAME_PART_NO_SECONDARY_EMAIL);
-
-      // prefix queries only work if the NAME_PART_NO_SECONDARY_EMAIL field is available
-      assertQuery("john");
-      return;
-    }
-
     assertQuery("John", user1);
     assertQuery("john", user1);
     assertQuery("Doe", user1);
@@ -468,6 +447,12 @@
   }
 
   @Test
+  public void withStartCannotBeLessThanZero() throws Exception {
+    assertFailingQuery(
+        newQuery("self").withStart(-1), "'start' parameter cannot be less than zero");
+  }
+
+  @Test
   public void sortedByFullname() throws Exception {
     String appendix = name("name");
 
@@ -646,22 +631,15 @@
             .getRaw(
                 Account.id(userInfo._accountId),
                 QueryOptions.create(
-                    IndexConfig.fromConfig(config).build(),
-                    0,
-                    1,
-                    schema.getStoredFields().keySet()));
+                    IndexConfig.fromConfig(config).build(), 0, 1, schema.getStoredFields()));
 
     assertThat(rawFields).isPresent();
-    if (schema.useLegacyNumericFields()) {
-      assertThat(rawFields.get().getValue(AccountField.ID)).isEqualTo(userInfo._accountId);
-    } else {
-      assertThat(Integer.valueOf(rawFields.get().getValue(AccountField.ID_STR)))
+    if (schema.hasField(AccountField.ID_FIELD_SPEC)) {
+      assertThat(rawFields.get().getValue(AccountField.ID_FIELD_SPEC))
           .isEqualTo(userInfo._accountId);
-    }
-
-    // The field EXTERNAL_ID_STATE is only supported from schema version 6.
-    if (getSchemaVersion() < 6) {
-      return;
+    } else {
+      assertThat(Integer.valueOf(rawFields.get().<String>getValue(AccountField.ID_STR_FIELD_SPEC)))
+          .isEqualTo(userInfo._accountId);
     }
 
     List<AccountExternalIdInfo> externalIdInfos = gApi.accounts().self().getExternalIds();
@@ -671,11 +649,10 @@
       assertThat(extId).isPresent();
       blobs.add(new ByteArrayWrapper(extId.get().toByteArray()));
     }
-    assertThat(rawFields.get().getValue(AccountField.EXTERNAL_ID_STATE)).hasSize(blobs.size());
-    assertThat(
-            Streams.stream(rawFields.get().getValue(AccountField.EXTERNAL_ID_STATE))
-                .map(ByteArrayWrapper::new)
-                .collect(toList()))
+    Iterable<byte[]> externalIdStates =
+        rawFields.get().<Iterable<byte[]>>getValue(AccountField.EXTERNAL_ID_STATE_SPEC);
+    assertThat(externalIdStates).hasSize(blobs.size());
+    assertThat(Streams.stream(externalIdStates).map(b -> new ByteArrayWrapper(b)).collect(toList()))
         .containsExactlyElementsIn(blobs);
   }
 
@@ -879,13 +856,7 @@
     return accounts.stream().map(a -> a._accountId).collect(toList());
   }
 
-  protected void assertMissingField(FieldDef<AccountState, ?> field) {
-    assertWithMessage("schema %s has field %s", getSchemaVersion(), field.getName())
-        .that(getSchema().hasField(field))
-        .isFalse();
-  }
-
-  protected void assertFailingQuery(String query, String expectedMessage) throws Exception {
+  protected void assertFailingQuery(QueryRequest query, String expectedMessage) throws Exception {
     try {
       assertQuery(query);
       fail("expected BadRequestException for query '" + query + "'");
@@ -894,14 +865,6 @@
     }
   }
 
-  protected int getSchemaVersion() {
-    return getSchema().getVersion();
-  }
-
-  protected Schema<AccountState> getSchema() {
-    return indexes.getSearchIndex().getSchema();
-  }
-
   /** Boiler plate code to check two byte arrays for equality */
   private static class ByteArrayWrapper {
     private byte[] arr;
diff --git a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
index 081fe02..bfd7b63 100644
--- a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
+++ b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
@@ -93,7 +93,6 @@
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.httpd.raw.IndexPreloadingUtil;
-import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.index.Schema;
 import com.google.gerrit.index.query.IndexPredicate;
@@ -105,7 +104,6 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.ServerInitiated;
-import com.google.gerrit.server.StarredChangesUtil;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountManager;
 import com.google.gerrit.server.account.Accounts;
@@ -119,7 +117,6 @@
 import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.change.PatchSetInserter;
 import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.experiments.ExperimentFeaturesConstants;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.group.testing.TestGroupBackend;
 import com.google.gerrit.server.index.change.ChangeField;
@@ -180,7 +177,7 @@
   @Inject protected AllUsersName allUsersName;
   @Inject protected BatchUpdate.Factory updateFactory;
   @Inject protected ChangeInserter.Factory changeFactory;
-  @Inject protected Provider<ChangeQueryBuilder> queryBuilderProvider;
+  @Inject protected ChangeQueryBuilder queryBuilder;
   @Inject protected GerritApi gApi;
   @Inject protected IdentifiedUser.GenericFactory userFactory;
   @Inject protected ChangeIndexCollection indexes;
@@ -202,7 +199,6 @@
   @Inject protected TestGroupBackend testGroupBackend;
   @Inject protected ProjectCache projectCache;
   @Inject protected MetaDataUpdate.Server metaDataUpdateFactory;
-  @Inject protected IdentifiedUser.GenericFactory identifiedUserFactory;
   @Inject protected AuthRequest.Factory authRequestFactory;
   @Inject protected ExternalIdFactory externalIdFactory;
   @Inject protected ProjectOperations projectOperations;
@@ -360,6 +356,18 @@
   }
 
   @Test
+  public void byStatusOr() throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+    ChangeInserter ins1 = newChangeWithStatus(repo, Change.Status.NEW);
+    Change change1 = insert(repo, ins1);
+    ChangeInserter ins2 = newChangeWithStatus(repo, Change.Status.MERGED);
+    Change change2 = insert(repo, ins2);
+
+    assertQuery("status:new OR status:merged", change2, change1);
+    assertQuery("status:new or status:merged", change2, change1);
+  }
+
+  @Test
   public void byStatusOpen() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
     ChangeInserter ins1 = newChangeWithStatus(repo, Change.Status.NEW);
@@ -644,7 +652,6 @@
 
   @Test
   public void byAuthorExact() throws Exception {
-    assume().that(getSchema().hasField(ChangeField.EXACT_AUTHOR)).isTrue();
     byAuthorOrCommitterExact("author:");
   }
 
@@ -655,7 +662,6 @@
 
   @Test
   public void byCommitterExact() throws Exception {
-    assume().that(getSchema().hasField(ChangeField.EXACT_COMMITTER)).isTrue();
     byAuthorOrCommitterExact("committer:");
   }
 
@@ -1566,6 +1572,12 @@
   }
 
   @Test
+  public void startCannotBeLessThanZero() throws Exception {
+    assertFailingQuery(
+        newQuery("owner:self").withStart(-1), "'start' parameter cannot be less than zero");
+  }
+
+  @Test
   public void startWithLimit() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
     List<Change> changes = new ArrayList<>();
@@ -1731,11 +1743,9 @@
     assertQuery("ext:.jAvA", change4);
     assertQuery("ext:cc", change3, change2, change1);
 
-    if (getSchemaVersion() >= 56) {
-      // matching changes with files that have no extension is possible
-      assertQuery("ext:\"\"", change5, change4);
-      assertFailingQuery("ext:");
-    }
+    // matching changes with files that have no extension is possible
+    assertQuery("ext:\"\"", change5, change4);
+    assertFailingQuery("ext:");
   }
 
   @Test
@@ -2099,21 +2109,6 @@
   }
 
   @Test
-  public void mergedOperatorSupportedByIndexVersion() throws Exception {
-    if (getSchemaVersion() < 61) {
-      assertMissingField(ChangeField.MERGED_ON);
-      assertFailingQuery(
-          "mergedbefore:2009-10-01",
-          "'mergedbefore' operator is not supported by change index version");
-      assertFailingQuery(
-          "mergedafter:2009-10-01",
-          "'mergedafter' operator is not supported by change index version");
-    } else {
-      assertThat(getSchema().hasField(ChangeField.MERGED_ON)).isTrue();
-    }
-  }
-
-  @Test
   public void byMergedBefore() throws Exception {
     assume().that(getSchema().hasField(ChangeField.MERGED_ON)).isTrue();
     long thirtyHoursInMs = MILLISECONDS.convert(30, HOURS);
@@ -2597,10 +2592,6 @@
 
   @Test
   public void bySubmitRuleResult() throws Exception {
-    if (getSchemaVersion() < 68) {
-      assertMissingField(ChangeField.SUBMIT_RULE_RESULT);
-      return;
-    }
     try (Registration registration =
         extensionRegistry.newRegistration().add(new FakeSubmitRule())) {
       TestRepository<Repo> repo = createProject("repo");
@@ -2621,13 +2612,6 @@
 
   @Test
   public void byNonExistingSubmitRule_returnsEmpty() throws Exception {
-    // Some submit rules could be removed from the gerrit.config but there can be records for
-    // merged changes in NoteDb for these rules. We allow querying for non-existent rules to handle
-    // this case.
-    if (getSchemaVersion() < 68) {
-      assertMissingField(ChangeField.SUBMIT_RULE_RESULT);
-      return;
-    }
     try (Registration registration =
         extensionRegistry.newRegistration().add(new FakeSubmitRule())) {
       TestRepository<Repo> repo = createProject("repo");
@@ -2637,21 +2621,7 @@
   }
 
   @Test
-  public void byHasDraft_draftsComputedFromIndex() throws Exception {
-    byHasDraft();
-  }
-
-  @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      value =
-          ExperimentFeaturesConstants
-              .GERRIT_BACKEND_REQUEST_FEATURE_COMPUTE_FROM_ALL_USERS_REPOSITORY)
-  public void byHasDraft_draftsComputedFromAllUsersRepository() throws Exception {
-    byHasDraft();
-  }
-
-  private void byHasDraft() throws Exception {
+  public void byHasDraft() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
     Change change1 = insert(repo, newChange(repo));
     Change change2 = insert(repo, newChange(repo));
@@ -2721,20 +2691,8 @@
     assertQuery("has:draft");
   }
 
-  public void byHasDraftWithManyDrafts_draftsComputedFromIndex() throws Exception {
-    byHasDraftWithManyDrafts();
-  }
-
-  @GerritConfig(
-      name = "experiments.enabled",
-      value =
-          ExperimentFeaturesConstants
-              .GERRIT_BACKEND_REQUEST_FEATURE_COMPUTE_FROM_ALL_USERS_REPOSITORY)
-  public void byHasDraftWithManyDrafts_draftsComputedFromAllUsersRepository() throws Exception {
-    byHasDraftWithManyDrafts();
-  }
-
-  private void byHasDraftWithManyDrafts() throws Exception {
+  @Test
+  public void byHasDraftWithManyDrafts() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
     Change[] changesWithDrafts = new Change[30];
 
@@ -2762,21 +2720,7 @@
   }
 
   @Test
-  public void byStarredBy_starsComputedFromIndex() throws Exception {
-    byStarredBy();
-  }
-
-  @GerritConfig(
-      name = "experiments.enabled",
-      value =
-          ExperimentFeaturesConstants
-              .GERRIT_BACKEND_REQUEST_FEATURE_COMPUTE_FROM_ALL_USERS_REPOSITORY)
-  @Test
-  public void byStarredBy_starsComputedFromAllUsersRepository() throws Exception {
-    byStarredBy();
-  }
-
-  private void byStarredBy() throws Exception {
+  public void byStarredBy() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
     Change change1 = insert(repo, newChange(repo));
     Change change2 = insert(repo, newChange(repo));
@@ -2789,102 +2733,29 @@
         accountManager.authenticate(authRequestFactory.createForUser("anotheruser")).getAccountId();
 
     assertQuery("has:star", change2, change1);
-    assertQuery("star:star", change2, change1);
 
     requestContext.setContext(newRequestContext(user2));
     assertQuery("has:star");
-    assertQuery("star:star");
   }
 
   @Test
-  public void byStar_starsComputedFromIndex() throws Exception {
-    byStar();
-  }
-
-  @GerritConfig(
-      name = "experiments.enabled",
-      value =
-          ExperimentFeaturesConstants
-              .GERRIT_BACKEND_REQUEST_FEATURE_COMPUTE_FROM_ALL_USERS_REPOSITORY)
-  @Test
-  public void byStar_starsComputedFromAllUsersRepository() throws Exception {
-    byStar();
-  }
-
-  private void byStar() throws Exception {
+  public void byStar() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
     Change change1 = insert(repo, newChangeWithStatus(repo, Change.Status.MERGED));
-    Change change2 = insert(repo, newChangeWithStatus(repo, Change.Status.MERGED));
-    Change change3 = insert(repo, newChange(repo));
 
     Account.Id user2 =
         accountManager.authenticate(authRequestFactory.createForUser("anotheruser")).getAccountId();
     requestContext.setContext(newRequestContext(user2));
 
     gApi.accounts().self().starChange(change1.getId().toString());
-    gApi.changes().id(change3.getChangeId()).ignore(true);
 
     // check default star
     assertQuery("has:star", change1);
     assertQuery("is:starred", change1);
-    assertQuery("star:" + StarredChangesUtil.DEFAULT_LABEL, change1);
-
-    // check ignored
-    assertQuery("is:ignored", change3);
-    assertQuery("-is:ignored", change2, change1);
-    assertQuery("star:ignore", change3);
-    assertQuery("-star:ignore", change2, change1);
   }
 
   @Test
-  public void byIgnore_starsComputedFromIndex() throws Exception {
-    byIgnore();
-  }
-
-  @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      value =
-          ExperimentFeaturesConstants
-              .GERRIT_BACKEND_REQUEST_FEATURE_COMPUTE_FROM_ALL_USERS_REPOSITORY)
-  public void byIgnore_starsComputedFromAllUsersRepository() throws Exception {
-    byIgnore();
-  }
-
-  private void byIgnore() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Account.Id user2 =
-        accountManager.authenticate(authRequestFactory.createForUser("anotheruser")).getAccountId();
-    Change change1 = insert(repo, newChange(repo), user2);
-    Change change2 = insert(repo, newChange(repo), user2);
-
-    gApi.changes().id(change1.getId().toString()).ignore(true);
-    assertQuery("is:ignored", change1);
-    assertQuery("-is:ignored", change2);
-    assertQuery("star:ignore", change1);
-    assertQuery("-star:ignore", change2);
-
-    gApi.changes().id(change1.getId().toString()).ignore(false);
-    assertQuery("is:ignored");
-    assertQuery("-is:ignored", change2, change1);
-    assertQuery("star:ignore");
-    assertQuery("-star:ignore", change2, change1);
-  }
-
-  public void byStarWithManyStars_starsComputedFromIndex() throws Exception {
-    byStarWithManyStars();
-  }
-
-  @GerritConfig(
-      name = "experiments.enabled",
-      value =
-          ExperimentFeaturesConstants
-              .GERRIT_BACKEND_REQUEST_FEATURE_COMPUTE_FROM_ALL_USERS_REPOSITORY)
-  public void byStarWithManyStars_starsComputedFromAllUsersRepository() throws Exception {
-    byStarWithManyStars();
-  }
-
-  private void byStarWithManyStars() throws Exception {
+  public void byStarWithManyStars() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
     Change[] changesWithDrafts = new Change[30];
     for (int i = 0; i < changesWithDrafts.length; i++) {
@@ -2895,15 +2766,9 @@
       gApi.accounts()
           .self()
           .starChange(changesWithDrafts[changesWithDrafts.length - 1 - i].getId().toString());
-
-      // ignore the change
-      gApi.changes()
-          .id(changesWithDrafts[changesWithDrafts.length - 1 - i].getId().toString())
-          .ignore(true);
     }
 
     // all changes are both starred and ignored.
-    assertQuery("is:ignored", changesWithDrafts);
     assertQuery("is:starred", changesWithDrafts);
   }
 
@@ -3419,13 +3284,19 @@
         repo.parseBody(repo.commit().message("Change one\n\nBug:QUERY123").create());
     Change change1 = insert(repo, newChangeForCommit(repo, commit1));
     RevCommit commit2 =
-        repo.parseBody(repo.commit().message("Change two\n\nFeature:QUERY456").create());
+        repo.parseBody(repo.commit().message("Change two\n\nIssue: Issue 16038\n").create());
     Change change2 = insert(repo, newChangeForCommit(repo, commit2));
 
+    RevCommit commit3 =
+        repo.parseBody(repo.commit().message("Change two\n\nGoogle-Bug-Id: b/16039\n").create());
+    Change change3 = insert(repo, newChangeForCommit(repo, commit3));
+
     assertQuery("tr:QUERY123", change1);
     assertQuery("bug:QUERY123", change1);
-    assertQuery("tr:QUERY456", change2);
-    assertQuery("bug:QUERY456", change2);
+    assertQuery("tr:16038", change2);
+    assertQuery("bug:16038", change2);
+    assertQuery("tr:16039", change3);
+    assertQuery("bug:16039", change3);
     assertQuery("tr:QUERY-123");
     assertQuery("bug:QUERY-123");
     assertQuery("tr:QUERY12");
@@ -3477,7 +3348,6 @@
     private final Account.Id ownerId;
     private final List<Account.Id> reviewedBy;
     private final List<Account.Id> cced;
-    private final List<Account.Id> ignoredBy;
     private final List<Account.Id> draftCommentBy;
     private final List<Account.Id> deleteDraftCommentBy;
     private boolean wip;
@@ -3491,7 +3361,6 @@
       this.ownerId = ownerId;
       reviewedBy = new ArrayList<>();
       cced = new ArrayList<>();
-      ignoredBy = new ArrayList<>();
       draftCommentBy = new ArrayList<>();
       deleteDraftCommentBy = new ArrayList<>();
     }
@@ -3516,11 +3385,6 @@
       return this;
     }
 
-    DashboardChangeState ignoreBy(Account.Id ignorerId) {
-      ignoredBy.add(ignorerId);
-      return this;
-    }
-
     DashboardChangeState addReviewer(Account.Id reviewerId) {
       reviewedBy.add(reviewerId);
       return this;
@@ -3566,10 +3430,6 @@
         in.state = ReviewerState.CC;
         cApi.addReviewer(in);
       }
-      for (Account.Id ignorerId : ignoredBy) {
-        requestContext.setContext(newRequestContext(ignorerId));
-        gApi.changes().id(change.getChangeId()).ignore(true);
-      }
       DraftInput in = new DraftInput();
       in.path = Patch.COMMIT_MSG;
       in.message = "message";
@@ -3647,9 +3507,6 @@
     new DashboardChangeState(user.getAccountId()).assignTo(user.getAccountId()).abandon();
     new DashboardChangeState(user.getAccountId())
         .assignTo(user.getAccountId())
-        .ignoreBy(user.getAccountId());
-    new DashboardChangeState(user.getAccountId())
-        .assignTo(user.getAccountId())
         .mergeBy(user.getAccountId());
 
     assertDashboardQuery(
@@ -3682,19 +3539,13 @@
     Account.Id otherAccountId = createAccount("other");
     DashboardChangeState ownedOpenReviewable =
         new DashboardChangeState(user.getAccountId()).create(repo);
-    DashboardChangeState ownedOpenReviewableIgnoredByOther =
-        new DashboardChangeState(user.getAccountId()).ignoreBy(otherAccountId).create(repo);
 
     // Create changes that should not be returned by any queries in this test.
     new DashboardChangeState(user.getAccountId()).wip().create(repo);
     new DashboardChangeState(otherAccountId).create(repo);
 
     // Viewing one's own dashboard.
-    assertDashboardQuery(
-        "self",
-        IndexPreloadingUtil.DASHBOARD_OUTGOING_QUERY,
-        ownedOpenReviewableIgnoredByOther,
-        ownedOpenReviewable);
+    assertDashboardQuery("self", IndexPreloadingUtil.DASHBOARD_OUTGOING_QUERY, ownedOpenReviewable);
 
     // Viewing another user's dashboard.
     requestContext.setContext(newRequestContext(otherAccountId));
@@ -3710,18 +3561,8 @@
     Account.Id otherAccountId = createAccount("other");
     DashboardChangeState reviewingReviewable =
         new DashboardChangeState(otherAccountId).addReviewer(user.getAccountId()).create(repo);
-    DashboardChangeState reviewingReviewableIgnoredByReviewer =
-        new DashboardChangeState(otherAccountId)
-            .addReviewer(user.getAccountId())
-            .ignoreBy(user.getAccountId())
-            .create(repo);
     DashboardChangeState assignedReviewable =
         new DashboardChangeState(otherAccountId).assignTo(user.getAccountId()).create(repo);
-    DashboardChangeState assignedReviewableIgnoredByAssignee =
-        new DashboardChangeState(otherAccountId)
-            .assignTo(user.getAccountId())
-            .ignoreBy(user.getAccountId())
-            .create(repo);
 
     // Create changes that should not be returned by any queries in this test.
     new DashboardChangeState(otherAccountId).wip().addReviewer(user.getAccountId()).create(repo);
@@ -3744,9 +3585,7 @@
     assertDashboardQuery(
         user.getUserName().get(),
         IndexPreloadingUtil.DASHBOARD_INCOMING_QUERY,
-        assignedReviewableIgnoredByAssignee,
         assignedReviewable,
-        reviewingReviewableIgnoredByReviewer,
         reviewingReviewable);
   }
 
@@ -3756,22 +3595,11 @@
     Account.Id otherAccountId = createAccount("other");
     DashboardChangeState mergedOwned =
         new DashboardChangeState(user.getAccountId()).mergeBy(user.getAccountId()).create(repo);
-    DashboardChangeState mergedOwnedIgnoredByOther =
-        new DashboardChangeState(user.getAccountId())
-            .ignoreBy(otherAccountId)
-            .mergeBy(user.getAccountId())
-            .create(repo);
     DashboardChangeState mergedReviewing =
         new DashboardChangeState(otherAccountId)
             .addReviewer(user.getAccountId())
             .mergeBy(user.getAccountId())
             .create(repo);
-    DashboardChangeState mergedReviewingIgnoredByUser =
-        new DashboardChangeState(otherAccountId)
-            .addReviewer(user.getAccountId())
-            .ignoreBy(user.getAccountId())
-            .mergeBy(user.getAccountId())
-            .create(repo);
     DashboardChangeState mergedCced =
         new DashboardChangeState(otherAccountId)
             .addCc(user.getAccountId())
@@ -3782,62 +3610,26 @@
             .assignTo(user.getAccountId())
             .mergeBy(user.getAccountId())
             .create(repo);
-    DashboardChangeState mergedAssignedIgnoredByUser =
-        new DashboardChangeState(otherAccountId)
-            .assignTo(user.getAccountId())
-            .ignoreBy(user.getAccountId())
-            .mergeBy(user.getAccountId())
-            .create(repo);
     DashboardChangeState abandonedOwned =
         new DashboardChangeState(user.getAccountId()).abandon().create(repo);
-    DashboardChangeState abandonedOwnedIgnoredByOther =
-        new DashboardChangeState(user.getAccountId())
-            .ignoreBy(otherAccountId)
-            .abandon()
-            .create(repo);
     DashboardChangeState abandonedOwnedWip =
         new DashboardChangeState(user.getAccountId()).wip().abandon().create(repo);
-    DashboardChangeState abandonedOwnedWipIgnoredByOther =
-        new DashboardChangeState(user.getAccountId())
-            .ignoreBy(otherAccountId)
-            .wip()
-            .abandon()
-            .create(repo);
     DashboardChangeState abandonedReviewing =
         new DashboardChangeState(otherAccountId)
             .addReviewer(user.getAccountId())
             .abandon()
             .create(repo);
-    DashboardChangeState abandonedReviewingIgnoredByUser =
-        new DashboardChangeState(otherAccountId)
-            .addReviewer(user.getAccountId())
-            .ignoreBy(user.getAccountId())
-            .abandon()
-            .create(repo);
     DashboardChangeState abandonedAssigned =
         new DashboardChangeState(otherAccountId)
             .assignTo(user.getAccountId())
             .abandon()
             .create(repo);
-    DashboardChangeState abandonedAssignedIgnoredByUser =
-        new DashboardChangeState(otherAccountId)
-            .assignTo(user.getAccountId())
-            .ignoreBy(user.getAccountId())
-            .abandon()
-            .create(repo);
     DashboardChangeState abandonedAssignedWip =
         new DashboardChangeState(otherAccountId)
             .assignTo(user.getAccountId())
             .wip()
             .abandon()
             .create(repo);
-    DashboardChangeState abandonedAssignedWipIgnoredByUser =
-        new DashboardChangeState(otherAccountId)
-            .assignTo(user.getAccountId())
-            .ignoreBy(user.getAccountId())
-            .wip()
-            .abandon()
-            .create(repo);
 
     // Create changes that should not be returned by any queries in this test.
     new DashboardChangeState(otherAccountId)
@@ -3845,12 +3637,6 @@
         .wip()
         .abandon()
         .create(repo);
-    new DashboardChangeState(otherAccountId)
-        .addReviewer(user.getAccountId())
-        .ignoreBy(user.getAccountId())
-        .wip()
-        .abandon()
-        .create(repo);
 
     // Viewing one's own dashboard.
     assertDashboardQuery(
@@ -3858,39 +3644,24 @@
         IndexPreloadingUtil.DASHBOARD_RECENTLY_CLOSED_QUERY,
         abandonedAssigned,
         abandonedReviewing,
-        abandonedOwnedWipIgnoredByOther,
         abandonedOwnedWip,
-        abandonedOwnedIgnoredByOther,
         abandonedOwned,
         mergedAssigned,
         mergedCced,
         mergedReviewing,
-        mergedOwnedIgnoredByOther);
-
-    assertDashboardQueryWithStart(
-        "self", IndexPreloadingUtil.DASHBOARD_RECENTLY_CLOSED_QUERY, 10, mergedOwned);
+        mergedOwned);
 
     // Viewing another user's dashboard.
     requestContext.setContext(newRequestContext(otherAccountId));
     assertDashboardQuery(
         user.getUserName().get(),
         IndexPreloadingUtil.DASHBOARD_RECENTLY_CLOSED_QUERY,
-        abandonedAssignedWipIgnoredByUser,
         abandonedAssignedWip,
-        abandonedAssignedIgnoredByUser,
         abandonedAssigned,
-        abandonedReviewingIgnoredByUser,
         abandonedReviewing,
         abandonedOwned,
-        mergedAssignedIgnoredByUser,
         mergedAssigned,
-        mergedCced);
-
-    assertDashboardQueryWithStart(
-        user.getUserName().get(),
-        IndexPreloadingUtil.DASHBOARD_RECENTLY_CLOSED_QUERY,
-        10,
-        mergedReviewingIgnoredByUser,
+        mergedCced,
         mergedReviewing,
         mergedOwned);
   }
@@ -4195,7 +3966,7 @@
 
   @Test
   public void selfFailsForAnonymousUser() throws Exception {
-    for (String query : ImmutableList.of("assignee:self", "has:star", "is:starred", "star:star")) {
+    for (String query : ImmutableList.of("assignee:self", "has:star", "is:starred")) {
       assertQuery(query);
       RequestContext oldContext = requestContext.setContext(anonymousUserProvider::get);
 
@@ -4236,7 +4007,6 @@
 
     assertQuery(ChangeIndexPredicate.none());
 
-    ChangeQueryBuilder queryBuilder = queryBuilderProvider.get();
     for (Predicate<ChangeData> matchingOneChange :
         ImmutableList.of(
             // One index query, one post-filtering query.
@@ -4606,16 +4376,19 @@
     }
   }
 
-  protected void assertMissingField(FieldDef<ChangeData, ?> field) {
-    assertWithMessage("schema %s has field %s", getSchemaVersion(), field.getName())
-        .that(getSchema().hasField(field))
-        .isFalse();
-  }
-
   protected void assertFailingQuery(String query) throws Exception {
     assertFailingQuery(query, null);
   }
 
+  protected void assertFailingQuery(QueryRequest query, String expectedMessage) throws Exception {
+    try {
+      assertQuery(query);
+      fail("expected BadRequestException for query '" + query + "'");
+    } catch (BadRequestException e) {
+      assertThat(e.getMessage()).isEqualTo(expectedMessage);
+    }
+  }
+
   protected void assertFailingQuery(String query, @Nullable String expectedMessage)
       throws Exception {
     try {
@@ -4628,10 +4401,6 @@
     }
   }
 
-  protected int getSchemaVersion() {
-    return getSchema().getVersion();
-  }
-
   protected Schema<ChangeData> getSchema() {
     return indexes.getSearchIndex().getSchema();
   }
diff --git a/javatests/com/google/gerrit/server/query/change/ChangeDataTest.java b/javatests/com/google/gerrit/server/query/change/ChangeDataTest.java
index e48d4af..5124021 100644
--- a/javatests/com/google/gerrit/server/query/change/ChangeDataTest.java
+++ b/javatests/com/google/gerrit/server/query/change/ChangeDataTest.java
@@ -15,18 +15,29 @@
 package com.google.gerrit.server.query.change;
 
 import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.Mockito.when;
 
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.testing.TestChanges;
+import java.util.UUID;
 import org.eclipse.jgit.lib.ObjectId;
 import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnitRunner;
 
+@RunWith(MockitoJUnitRunner.class)
 public class ChangeDataTest {
+  private static final String GERRIT_SERVER_ID = UUID.randomUUID().toString();
+
+  @Mock private ChangeNotes changeNotesMock;
+
   @Test
   public void setPatchSetsClearsCurrentPatchSet() throws Exception {
     Project.NameKey project = Project.nameKey("project");
@@ -41,6 +52,26 @@
     assertThat(curr2).isNotSameInstanceAs(curr1);
   }
 
+  @Test
+  public void getChangeVirtualIdUsingAlgorithm() throws Exception {
+    Project.NameKey project = Project.nameKey("project");
+    final int encodedChangeNum = 12345678;
+
+    when(changeNotesMock.getServerId()).thenReturn(UUID.randomUUID().toString());
+
+    ChangeData cd =
+        ChangeData.createForTest(
+            project,
+            Change.id(1),
+            1,
+            ObjectId.zeroId(),
+            GERRIT_SERVER_ID,
+            (s, c) -> encodedChangeNum,
+            changeNotesMock);
+
+    assertThat(cd.getVirtualId().get()).isEqualTo(encodedChangeNum);
+  }
+
   private static PatchSet newPatchSet(Change.Id changeId, int num) {
     return PatchSet.builder()
         .id(PatchSet.id(changeId, num))
diff --git a/javatests/com/google/gerrit/server/query/change/FakeQueryChangesTest.java b/javatests/com/google/gerrit/server/query/change/FakeQueryChangesTest.java
index 3968a33..d69fe9e 100644
--- a/javatests/com/google/gerrit/server/query/change/FakeQueryChangesTest.java
+++ b/javatests/com/google/gerrit/server/query/change/FakeQueryChangesTest.java
@@ -129,7 +129,7 @@
     // 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(queryBuilderProvider.get().parse("status:new"));
+    queryProvider.get().query(queryBuilder.parse("status:new"));
     assertThat(idx.getQueryCount()).isEqualTo(2);
   }
 }
diff --git a/javatests/com/google/gerrit/server/query/group/AbstractQueryGroupsTest.java b/javatests/com/google/gerrit/server/query/group/AbstractQueryGroupsTest.java
index 6e4fec1..1ca4571 100644
--- a/javatests/com/google/gerrit/server/query/group/AbstractQueryGroupsTest.java
+++ b/javatests/com/google/gerrit/server/query/group/AbstractQueryGroupsTest.java
@@ -20,6 +20,7 @@
 import static com.google.common.truth.TruthJUnit.assume;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.util.stream.Collectors.toList;
+import static org.junit.Assert.fail;
 
 import com.google.common.base.CharMatcher;
 import com.google.gerrit.entities.Account;
@@ -326,6 +327,13 @@
   }
 
   @Test
+  public void withStartCannotBeLessThanZero() throws Exception {
+    GroupInfo group1 = createGroup(name("group1"));
+    assertFailingQuery(
+        newQuery("uuid:" + group1.id).withStart(-1), "'start' parameter cannot be less than zero");
+  }
+
+  @Test
   public void sortedByUuid() throws Exception {
     GroupInfo group1 = createGroup(name("group1"));
     GroupInfo group2 = createGroup(name("group2"));
@@ -377,10 +385,10 @@
                     IndexConfig.fromConfig(config).build(),
                     0,
                     10,
-                    indexes.getSearchIndex().getSchema().getStoredFields().keySet()));
+                    indexes.getSearchIndex().getSchema().getStoredFields()));
 
     assertThat(rawFields).isPresent();
-    assertThat(rawFields.get().getValue(GroupField.UUID)).isEqualTo(uuid.get());
+    assertThat(rawFields.get().getValue(GroupField.UUID_FIELD_SPEC)).isEqualTo(uuid.get());
   }
 
   @Test
@@ -477,6 +485,15 @@
     return result;
   }
 
+  protected void assertFailingQuery(QueryRequest query, String expectedMessage) throws Exception {
+    try {
+      assertQuery(query);
+      fail("expected BadRequestException for query '" + query + "'");
+    } catch (BadRequestException e) {
+      assertThat(e.getMessage()).isEqualTo(expectedMessage);
+    }
+  }
+
   protected QueryRequest newQuery(Object query) {
     return gApi.groups().query(query.toString());
   }
diff --git a/javatests/com/google/gerrit/server/query/project/AbstractQueryProjectsTest.java b/javatests/com/google/gerrit/server/query/project/AbstractQueryProjectsTest.java
index 60d1655..c06fcde 100644
--- a/javatests/com/google/gerrit/server/query/project/AbstractQueryProjectsTest.java
+++ b/javatests/com/google/gerrit/server/query/project/AbstractQueryProjectsTest.java
@@ -19,6 +19,7 @@
 import static com.google.common.truth.TruthJUnit.assume;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.util.stream.Collectors.toList;
+import static org.junit.Assert.fail;
 
 import com.google.common.base.CharMatcher;
 import com.google.common.collect.ImmutableMap;
@@ -290,6 +291,13 @@
   }
 
   @Test
+  public void withStartCannotBeLessThanZero() throws Exception {
+    assertFailingQuery(
+        newQuery("name:" + allProjects.get()).withStart(-1),
+        "'start' parameter cannot be less than zero");
+  }
+
+  @Test
   public void sortedByName() throws Exception {
     ProjectInfo projectFoo = createProject("foo-" + name("project1"));
     ProjectInfo projectBar = createProject("bar-" + name("project2"));
@@ -396,6 +404,15 @@
     return result;
   }
 
+  protected void assertFailingQuery(QueryRequest query, String expectedMessage) throws Exception {
+    try {
+      assertQuery(query);
+      fail("expected BadRequestException for query '" + query + "'");
+    } catch (BadRequestException e) {
+      assertThat(e.getMessage()).isEqualTo(expectedMessage);
+    }
+  }
+
   protected QueryRequest newQuery(Object query) {
     return gApi.projects().query(query.toString());
   }
diff --git a/javatests/com/google/gerrit/server/schema/SchemaCreatorImplTest.java b/javatests/com/google/gerrit/server/schema/SchemaCreatorImplTest.java
index 9cba362..1304c53 100644
--- a/javatests/com/google/gerrit/server/schema/SchemaCreatorImplTest.java
+++ b/javatests/com/google/gerrit/server/schema/SchemaCreatorImplTest.java
@@ -23,6 +23,7 @@
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.LabelTypes;
 import com.google.gerrit.entities.LabelValue;
+import com.google.gerrit.extensions.client.ChangeKind;
 import com.google.gerrit.server.account.ServiceUserClassifier;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.config.AllUsersName;
@@ -77,7 +78,11 @@
     assertThat(codeReview.getName()).isEqualTo("Code-Review");
     assertThat(codeReview.getDefaultValue()).isEqualTo(0);
     assertThat(codeReview.getFunction()).isEqualTo(LabelFunction.MAX_WITH_BLOCK);
-    assertThat(codeReview.isCopyMinScore()).isTrue();
+    assertThat(codeReview.getCopyCondition())
+        .hasValue(
+            String.format(
+                "changekind:%s OR changekind:%s OR is:MIN",
+                ChangeKind.NO_CHANGE, ChangeKind.TRIVIAL_REBASE.name()));
     assertValueRange(codeReview, -2, -1, 0, 1, 2);
   }
 
diff --git a/javatests/com/google/gerrit/server/update/BUILD b/javatests/com/google/gerrit/server/update/BUILD
index 4fe4ab04..6d96c10 100644
--- a/javatests/com/google/gerrit/server/update/BUILD
+++ b/javatests/com/google/gerrit/server/update/BUILD
@@ -1,8 +1,8 @@
 load("//tools/bzl:junit.bzl", "junit_tests")
 
 junit_tests(
-    name = "small_tests",
-    size = "small",
+    name = "update_tests",
+    size = "medium",
     srcs = glob(["*.java"]),
     runtime_deps = [
         "//java/com/google/gerrit/lucene",
diff --git a/javatests/com/google/gerrit/server/update/BatchUpdateTest.java b/javatests/com/google/gerrit/server/update/BatchUpdateTest.java
index 1f22564..fcb680f 100644
--- a/javatests/com/google/gerrit/server/update/BatchUpdateTest.java
+++ b/javatests/com/google/gerrit/server/update/BatchUpdateTest.java
@@ -18,18 +18,30 @@
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
 
 import com.google.common.cache.Cache;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.entities.SubmissionId;
 import com.google.gerrit.entities.SubmitRecord;
+import com.google.gerrit.extensions.client.ReviewerState;
+import com.google.gerrit.extensions.events.AttentionSetListener;
+import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.InternalUser;
+import com.google.gerrit.server.account.AccountManager;
+import com.google.gerrit.server.account.AuthRequest;
+import com.google.gerrit.server.change.AbandonOp;
+import com.google.gerrit.server.change.AddReviewersOp;
 import com.google.gerrit.server.change.ChangeInserter;
 import com.google.gerrit.server.change.PatchSetInserter;
 import com.google.gerrit.server.git.GitRepositoryManager;
@@ -51,6 +63,11 @@
 import org.junit.Before;
 import org.junit.Rule;
 import org.junit.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Captor;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnit;
+import org.mockito.junit.MockitoRule;
 
 public class BatchUpdateTest {
   private static final int MAX_UPDATES = 4;
@@ -75,6 +92,17 @@
   @Inject private PatchSetInserter.Factory patchSetInserterFactory;
   @Inject private Provider<CurrentUser> user;
   @Inject private Sequences sequences;
+  @Inject private AddReviewersOp.Factory addReviewersOpFactory;
+  @Inject private DynamicSet<AttentionSetListener> attentionSetListeners;
+  @Inject private AccountManager accountManager;
+  @Inject private AuthRequest.Factory authRequestFactory;
+  @Inject private InternalUser.Factory internalUserFactory;
+  @Inject private AbandonOp.Factory abandonOpFactory;
+
+  @Rule public final MockitoRule mockito = MockitoJUnit.rule();
+
+  @Captor ArgumentCaptor<AttentionSetListener.Event> attentionSetEventCaptor;
+  @Mock private AttentionSetListener attentionSetListener;
 
   @Inject
   private @Named("diff_summary") Cache<DiffSummaryKey, DiffSummary> diffSummaryCache;
@@ -111,6 +139,40 @@
   }
 
   @Test
+  public void batchUpdateThatChangeAttentionSetAsInternalUser() throws Exception {
+    Change.Id id = createChangeWithUpdates(1);
+    attentionSetListeners.add("test", attentionSetListener);
+
+    Account.Id reviewer =
+        accountManager.authenticate(authRequestFactory.createForUser("user")).getAccountId();
+
+    try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.now())) {
+      bu.addOp(
+          id,
+          addReviewersOpFactory.create(
+              ImmutableSet.of(reviewer), ImmutableList.of(), ReviewerState.REVIEWER, false));
+      bu.execute();
+    }
+
+    try (BatchUpdate bu =
+        batchUpdateFactory.create(project, internalUserFactory.create(), TimeUtil.now())) {
+      bu.addOp(id, abandonOpFactory.create(null, "test abandon"));
+      bu.execute();
+    }
+
+    verify(attentionSetListener, times(2)).onAttentionSetChanged(attentionSetEventCaptor.capture());
+    AttentionSetListener.Event event = attentionSetEventCaptor.getAllValues().get(0);
+    assertThat(event.getChange()._number).isEqualTo(id.get());
+    assertThat(event.usersAdded()).containsExactly(reviewer.get());
+    assertThat(event.usersRemoved()).isEmpty();
+
+    event = attentionSetEventCaptor.getAllValues().get(1);
+    assertThat(event.getChange()._number).isEqualTo(id.get());
+    assertThat(event.usersAdded()).isEmpty();
+    assertThat(event.usersRemoved()).containsExactly(reviewer.get());
+  }
+
+  @Test
   public void cannotExceedMaxUpdates() throws Exception {
     Change.Id id = createChangeWithUpdates(MAX_UPDATES);
     ObjectId oldMetaId = getMetaId(id);
@@ -143,6 +205,39 @@
   }
 
   @Test
+  public void attentionSetUpdateEventsFiredForSeveralChangesInSingleBatch() throws Exception {
+    Change.Id id1 = createChangeWithUpdates(1);
+    Change.Id id2 = createChangeWithUpdates(1);
+    attentionSetListeners.add("test", attentionSetListener);
+
+    Account.Id reviewer1 =
+        accountManager.authenticate(authRequestFactory.createForUser("user1")).getAccountId();
+    Account.Id reviewer2 =
+        accountManager.authenticate(authRequestFactory.createForUser("user2")).getAccountId();
+
+    try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.now())) {
+      bu.addOp(
+          id1,
+          addReviewersOpFactory.create(
+              ImmutableSet.of(reviewer1), ImmutableList.of(), ReviewerState.REVIEWER, false));
+      bu.addOp(
+          id2,
+          addReviewersOpFactory.create(
+              ImmutableSet.of(reviewer2), ImmutableList.of(), ReviewerState.REVIEWER, false));
+      bu.execute();
+    }
+    verify(attentionSetListener, times(2)).onAttentionSetChanged(attentionSetEventCaptor.capture());
+    AttentionSetListener.Event event1 = attentionSetEventCaptor.getAllValues().get(0);
+    assertThat(event1.getChange()._number).isEqualTo(id1.get());
+    assertThat(event1.usersAdded()).containsExactly(reviewer1.get());
+    assertThat(event1.usersRemoved()).isEmpty();
+
+    AttentionSetListener.Event event2 = attentionSetEventCaptor.getAllValues().get(1);
+    assertThat(event2.getChange()._number).isEqualTo(id2.get());
+    assertThat(event2.usersRemoved()).isEmpty();
+  }
+
+  @Test
   public void exceedingMaxUpdatesAllowedWithCompleteNoOp() throws Exception {
     Change.Id id = createChangeWithUpdates(MAX_UPDATES);
     ObjectId oldMetaId = getMetaId(id);
diff --git a/lib/auto/BUILD b/lib/auto/BUILD
index 18b9b91..6e5418e 100644
--- a/lib/auto/BUILD
+++ b/lib/auto/BUILD
@@ -10,6 +10,23 @@
 )
 
 java_plugin(
+    name = "auto-factory-plugin",
+    generates_api = 1,
+    processor_class = "com.google.auto.factory.processor.AutoFactoryProcessor",
+    visibility = ["//visibility:private"],
+    deps = [
+        "@auto-common//jar",
+        "@auto-factory//jar",
+        "@auto-service-annotations//jar",
+        "@auto-value-annotations//jar",
+        "@auto-value//jar",
+        "@guava//jar",
+        "@javapoet//jar",
+        "@javax_inject//jar",
+    ],
+)
+
+java_plugin(
     name = "auto-value-plugin",
     processor_class = "com.google.auto.value.processor.AutoValueProcessor",
     deps = [
@@ -43,6 +60,16 @@
 )
 
 java_library(
+    name = "auto-factory",
+    data = ["//lib:LICENSE-Apache2.0"],
+    exported_plugins = [
+        ":auto-factory-plugin",
+    ],
+    visibility = ["//visibility:public"],
+    exports = ["@auto-factory//jar"],
+)
+
+java_library(
     name = "auto-value",
     data = ["//lib:LICENSE-Apache2.0"],
     exported_plugins = [
@@ -74,8 +101,6 @@
     ],
     visibility = ["//visibility:public"],
     exports = [
-        "@auto-value-gson-extension//jar",
-        "@auto-value-gson-factory//jar",
         "@auto-value-gson-runtime//jar",
     ],
 )
diff --git a/lib/fonts/BUILD b/lib/fonts/BUILD
index 41d0273..f13a064 100644
--- a/lib/fonts/BUILD
+++ b/lib/fonts/BUILD
@@ -26,3 +26,12 @@
     data = ["//lib:LICENSE-Apache2.0"],
     visibility = ["//visibility:public"],
 )
+
+filegroup(
+    name = "material-icons",
+    srcs = [
+        "material-icons.woff2",
+    ],
+    data = ["//lib:LICENSE-Apache2.0"],
+    visibility = ["//visibility:public"],
+)
diff --git a/lib/fonts/material-icons.woff2 b/lib/fonts/material-icons.woff2
new file mode 100644
index 0000000..11074da
--- /dev/null
+++ b/lib/fonts/material-icons.woff2
Binary files differ
diff --git a/package.json b/package.json
index 895dd87..745a34e 100644
--- a/package.json
+++ b/package.json
@@ -3,56 +3,61 @@
   "version": "3.1.0-SNAPSHOT",
   "description": "Gerrit Code Review",
   "dependencies": {
-    "@bazel/concatjs": "^5.1.0",
-    "@bazel/rollup": "^5.1.0",
-    "@bazel/terser": "^5.1.0",
-    "@bazel/typescript": "^5.1.0",
-    "twinkie": "^1.1.3"
+    "@bazel/concatjs": "^5.5.0",
+    "@bazel/rollup": "^5.5.0",
+    "@bazel/terser": "^5.5.0",
+    "@bazel/typescript": "^5.5.0"
   },
   "devDependencies": {
-    "@typescript-eslint/eslint-plugin": "^4.29.0",
-    "eslint": "^7.24.0",
+    "@koa/cors": "^3.3.0",
+    "@types/page": "^1.11.5",
+    "@typescript-eslint/eslint-plugin": "^5.27.0",
+    "@web/dev-server": "^0.1.33",
+    "@web/dev-server-esbuild": "^0.3.2",
+    "eslint": "^8.16.0",
     "eslint-config-google": "^0.14.0",
-    "eslint-plugin-html": "^6.1.2",
-    "eslint-plugin-import": "^2.22.1",
-    "eslint-plugin-jsdoc": "^32.3.0",
-    "eslint-plugin-lit": "^1.5.1",
+    "eslint-plugin-html": "^6.2.0",
+    "eslint-plugin-import": "^2.26.0",
+    "eslint-plugin-jsdoc": "^39.6.4",
+    "eslint-plugin-lit": "^1.6.1",
     "eslint-plugin-node": "^11.1.0",
-    "eslint-plugin-prettier": "^3.4.0",
-    "eslint-plugin-regex": "^1.8.0",
+    "eslint-plugin-prettier": "^4.0.0",
+    "eslint-plugin-regex": "^1.9.0",
     "gts": "^3.1.0",
     "lit-analyzer": "^1.2.1",
     "npm-run-all": "^4.1.5",
-    "prettier": "2.3.1",
+    "prettier": "2.6.2",
     "rollup": "^2.45.2",
     "terser": "^5.6.1",
     "ts-lit-plugin": "^1.2.1",
-    "typescript": "4.3.2"
+    "typescript": "^4.7.2"
   },
   "scripts": {
     "clean": "git clean -fdx && bazel clean --expunge",
     "compile:local": "tsc --project ./polygerrit-ui/app/tsconfig.json",
     "compile:watch": "npm run compile:local -- --preserveWatchOutput --watch",
-    "start": "polygerrit-ui/run-server.sh",
-    "test": "npm run safe_bazelisk test //polygerrit-ui:karma_test -- --test_verbose_timeout_warnings --test_output=all",
+    "start": "run-p -rl compile:watch start:server",
+    "start:server": "web-dev-server",
+    "test": "yarn --cwd=polygerrit-ui test",
+    "test:screenshot": "yarn --cwd=polygerrit-ui test:screenshot",
+    "test:screenshot-update": "yarn --cwd=polygerrit-ui test:screenshot-update",
+    "test:coverage": "yarn --cwd=polygerrit-ui test:coverage",
+    "test:watch": "yarn --cwd=polygerrit-ui test:watch",
+    "test:single": "yarn --cwd=polygerrit-ui test:single",
+    "test:single:coverage": "yarn --cwd=polygerrit-ui test:single:coverage",
     "safe_bazelisk": "if which bazelisk >/dev/null; then bazel_bin=bazelisk; else bazel_bin=bazel; fi && $bazel_bin",
     "eslint": "npm run safe_bazelisk test polygerrit-ui/app:lint_test",
     "eslintfix": "npm run safe_bazelisk run polygerrit-ui/app:lint_bin -- -- --fix $(pwd)/polygerrit-ui/app",
-    "litlint": "npm run safe_bazelisk run polygerrit-ui/app:lit_analysis",
-    "test:debug": "npm run compile:local && npm run safe_bazelisk run //polygerrit-ui:karma_bin -- -- start $(pwd)/polygerrit-ui/karma.conf.js --root '.ts-out/polygerrit-ui/app/' --browsers ChromeDev --no-single-run --test-files",
-    "test:single": "npm run compile:local && npm run safe_bazelisk run //polygerrit-ui:karma_bin -- -- start $(pwd)/polygerrit-ui/karma.conf.js --root '.ts-out/polygerrit-ui/app/' --test-files",
-    "test:watch": "npm run safe_bazelisk run //polygerrit-ui:karma_bin -- -- start $(pwd)/polygerrit-ui/karma.conf.js --root '.ts-out/polygerrit-ui/app/' --auto-watch --no-single-run --test-files",
-    "polylint": "npm run safe_bazelisk test //polygerrit-ui/app:polylint_test",
-    "polylint:dev": "rm -rf ./polygerrit-ui/app/tmpl_out && npm run safe_bazelisk build //polygerrit-ui/app:template_test_tar && mkdir ./polygerrit-ui/app/tmpl_out && tar -xf bazel-bin/polygerrit-ui/app/template_test_tar.tar -C ./polygerrit-ui/app/tmpl_out",
-    "watch": "npm run compile:local && run-p -r compile:watch \"test:watch -- {*}\" --"
+    "litlint": "npm run safe_bazelisk run polygerrit-ui/app:lit_analysis"
   },
   "repository": {
     "type": "git",
     "url": "https://gerrit.googlesource.com/gerrit"
   },
   "resolutions": {
-    "lodash": "4.17.21",
-    "twinkie/typescript": "4.3.2"
+    "eslint": "^8.16.0",
+    "@typescript-eslint/eslint-plugin": "^5.27.0",
+    "@typescript-eslint/parser": "^5.27.0"
   },
   "author": "",
   "license": "Apache-2.0"
diff --git a/plugins/codemirror-editor b/plugins/codemirror-editor
index c5bda5b..3af12c5 160000
--- a/plugins/codemirror-editor
+++ b/plugins/codemirror-editor
@@ -1 +1 @@
-Subproject commit c5bda5b6b5fe91a2f7cd40c5a917dd2280b04814
+Subproject commit 3af12c5a5e65861830b42bd07933e275c33b9159
diff --git a/plugins/delete-project b/plugins/delete-project
index 5717bad..b183ee5 160000
--- a/plugins/delete-project
+++ b/plugins/delete-project
@@ -1 +1 @@
-Subproject commit 5717badf4250dfe900c05fc00d0758a09ba77297
+Subproject commit b183ee5230273670f3235cc5b3cf32562ccfb7ee
diff --git a/plugins/package.json b/plugins/package.json
index 6fdb0fc..79bb7665 100644
--- a/plugins/package.json
+++ b/plugins/package.json
@@ -1,13 +1,15 @@
 {
-    "name": "gerrit-plugin-dependencies",
-    "description": "Gerrit Code Review - frontend plugin dependencies, each plugin may depend on a subset of these",
-    "browser": true,
-    "dependencies": {
-        "@gerritcodereview/typescript-api": "3.4.4",
-        "@polymer/decorators": "^3.0.0",
-        "@polymer/polymer": "^3.4.1",
-        "lit": "^2.2.3"
-    },
-    "license": "Apache-2.0",
-    "private": true
+  "name": "gerrit-plugin-dependencies",
+  "description": "Gerrit Code Review - frontend plugin dependencies, each plugin may depend on a subset of these",
+  "browser": true,
+  "dependencies": {
+    "@gerritcodereview/typescript-api": "3.7.0",
+    "@polymer/decorators": "^3.0.0",
+    "@polymer/polymer": "^3.4.1",
+    "@open-wc/testing": "^3.1.6",
+    "lit": "^2.2.3",
+    "rxjs": "^6.6.7"
+  },
+  "license": "Apache-2.0",
+  "private": true
 }
diff --git a/plugins/reviewnotes b/plugins/reviewnotes
index 10db2cf..4198fe8 160000
--- a/plugins/reviewnotes
+++ b/plugins/reviewnotes
@@ -1 +1 @@
-Subproject commit 10db2cf772989d031c6f3558010c51fe07cf9722
+Subproject commit 4198fe8df1c1b86d812f32da63e891b1c2fc6f3e
diff --git a/plugins/yarn.lock b/plugins/yarn.lock
index 4cb70a6..e012bd1 100644
--- a/plugins/yarn.lock
+++ b/plugins/yarn.lock
@@ -2,16 +2,127 @@
 # yarn lockfile v1
 
 
-"@gerritcodereview/typescript-api@3.4.4":
-  version "3.4.4"
-  resolved "https://registry.yarnpkg.com/@gerritcodereview/typescript-api/-/typescript-api-3.4.4.tgz#9f09687038088dd7edd3b4e30d249502eb21bfbc"
-  integrity sha512-MAiQwntcQ59b92yYDsVIXj3oBbAB4C7HELkLFFbYs4ZjzC43XqqtR9VF0dh5OUC8wzFZttgUiOmGehk9edpPuw==
+"@babel/code-frame@^7.12.11":
+  version "7.18.6"
+  resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.18.6.tgz#3b25d38c89600baa2dcc219edfa88a74eb2c427a"
+  integrity sha512-TDCmlK5eOvH+eH7cdAFlNXeVJqWIQ7gW9tY1GJIpUtFb6CmjVyq2VM3u71bOyR8CRihcCgMUYoDNyLXao3+70Q==
+  dependencies:
+    "@babel/highlight" "^7.18.6"
+
+"@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.18.6":
+  version "7.18.6"
+  resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.18.6.tgz#81158601e93e2563795adcbfbdf5d64be3f2ecdf"
+  integrity sha512-u7stbOuYjaPezCuLj29hNW1v64M2Md2qupEKP1fHc7WdOA3DgLh37suiSrZYY7haUB7iBeQZ9P1uiRF359do3g==
+  dependencies:
+    "@babel/helper-validator-identifier" "^7.18.6"
+    chalk "^2.0.0"
+    js-tokens "^4.0.0"
+
+"@esm-bundle/chai@^4.3.4-fix.0":
+  version "4.3.4-fix.0"
+  resolved "https://registry.yarnpkg.com/@esm-bundle/chai/-/chai-4.3.4-fix.0.tgz#3084cff7eb46d741749f47f3a48dbbdcbaf30a92"
+  integrity sha512-26SKdM4uvDWlY8/OOOxSB1AqQWeBosCX3wRYUZO7enTAj03CtVxIiCimYVG2WpULcyV51qapK4qTovwkUr5Mlw==
+  dependencies:
+    "@types/chai" "^4.2.12"
+
+"@gerritcodereview/typescript-api@3.7.0":
+  version "3.7.0"
+  resolved "https://registry.yarnpkg.com/@gerritcodereview/typescript-api/-/typescript-api-3.7.0.tgz#ae3886b5c4ddc6a02659a11d577e1df0b6158727"
+  integrity sha512-8zeZClN1gur+Isrn02bRXJ0wUjYvK99jQxg36ZbDelrGDglXKddf8QQkZmaH9sYIRcCFDLlh5+ZlRUTcXTuDVA==
+
+"@lit/reactive-element@^1.0.0", "@lit/reactive-element@^1.4.0":
+  version "1.4.1"
+  resolved "https://registry.yarnpkg.com/@lit/reactive-element/-/reactive-element-1.4.1.tgz#3f587eec5708692135bc9e94cf396130604979f3"
+  integrity sha512-qDv4851VFSaBWzpS02cXHclo40jsbAjRXnebNXpm0uVg32kCneZPo9RYVQtrTNICtZ+1wAYHu1ZtxWSWMbKrBw==
 
 "@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==
 
+"@nodelib/fs.scandir@2.1.5":
+  version "2.1.5"
+  resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5"
+  integrity sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==
+  dependencies:
+    "@nodelib/fs.stat" "2.0.5"
+    run-parallel "^1.1.9"
+
+"@nodelib/fs.stat@2.0.5", "@nodelib/fs.stat@^2.0.2":
+  version "2.0.5"
+  resolved "https://registry.yarnpkg.com/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz#5bd262af94e9d25bd1e71b05deed44876a222e8b"
+  integrity sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==
+
+"@nodelib/fs.walk@^1.2.3":
+  version "1.2.8"
+  resolved "https://registry.yarnpkg.com/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz#e95737e8bb6746ddedf69c556953494f196fe69a"
+  integrity sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==
+  dependencies:
+    "@nodelib/fs.scandir" "2.1.5"
+    fastq "^1.6.0"
+
+"@open-wc/chai-dom-equals@^0.12.36":
+  version "0.12.36"
+  resolved "https://registry.yarnpkg.com/@open-wc/chai-dom-equals/-/chai-dom-equals-0.12.36.tgz#ed0eb56b9e98c4d7f7280facce6215654aae9f4c"
+  integrity sha512-Gt1fa37h4rtWPQGETSU4n1L678NmMi9KwHM1sH+JCGcz45rs8DBPx7MUVeGZ+HxRlbEI5t9LU2RGGv6xT2OlyA==
+  dependencies:
+    "@open-wc/semantic-dom-diff" "^0.13.16"
+    "@types/chai" "^4.1.7"
+
+"@open-wc/dedupe-mixin@^1.3.0":
+  version "1.3.1"
+  resolved "https://registry.yarnpkg.com/@open-wc/dedupe-mixin/-/dedupe-mixin-1.3.1.tgz#5c1a1eeb0386b344290ebe3f1fca0c4869933dbf"
+  integrity sha512-ukowSvzpZQDUH0Y3znJTsY88HkiGk3Khc0WGpIPhap1xlerieYi27QBg6wx/nTurpWfU6XXXsx9ocxDYCdtw0Q==
+
+"@open-wc/scoped-elements@^2.1.3":
+  version "2.1.3"
+  resolved "https://registry.yarnpkg.com/@open-wc/scoped-elements/-/scoped-elements-2.1.3.tgz#c4f06fa16091c6ebf2a69b3f40afc03821f42535"
+  integrity sha512-WoQD5T8Me9obek+iyjgrAMw9wxZZg4ytIteIN1i9LXW2KohezUp0LTOlWgBajWJo0/bpjUKiODX73cMYL2i3hw==
+  dependencies:
+    "@lit/reactive-element" "^1.0.0"
+    "@open-wc/dedupe-mixin" "^1.3.0"
+
+"@open-wc/semantic-dom-diff@^0.13.16":
+  version "0.13.21"
+  resolved "https://registry.yarnpkg.com/@open-wc/semantic-dom-diff/-/semantic-dom-diff-0.13.21.tgz#718b9ec5f9a98935fc775e577ad094ae8d8b7dea"
+  integrity sha512-BONpjHcGX2zFa9mfnwBCLEmlDsOHzT+j6Qt1yfK3MzFXFtAykfzFjAgaxPetu0YbBlCfXuMlfxI4vlRGCGMvFg==
+
+"@open-wc/semantic-dom-diff@^0.19.7":
+  version "0.19.7"
+  resolved "https://registry.yarnpkg.com/@open-wc/semantic-dom-diff/-/semantic-dom-diff-0.19.7.tgz#92361f0d2dcb54a8d5cf11d5ea40b8e7ffa58eb4"
+  integrity sha512-ahwHb7arQXXnkIGCrOsM895FJQrU47VWZryCsSSzl5nB3tJKcJ8yjzQ3D/yqZn6v8atqOz61vaY05aNsqoz3oA==
+  dependencies:
+    "@types/chai" "^4.3.1"
+    "@web/test-runner-commands" "^0.6.1"
+
+"@open-wc/testing-helpers@^2.1.2":
+  version "2.1.3"
+  resolved "https://registry.yarnpkg.com/@open-wc/testing-helpers/-/testing-helpers-2.1.3.tgz#85a133ac8637ed1d880d523b07650788eab4a128"
+  integrity sha512-hQujGaWncmWLx/974jq5yf2jydBNNTwnkISw2wLGiYgX34+3R6/ns301Oi9S3Il96Kzd8B7avdExp/gDgqcF5w==
+  dependencies:
+    "@open-wc/scoped-elements" "^2.1.3"
+    lit "^2.0.0"
+    lit-html "^2.0.0"
+
+"@open-wc/testing@^3.1.6":
+  version "3.1.6"
+  resolved "https://registry.yarnpkg.com/@open-wc/testing/-/testing-3.1.6.tgz#89f71710e5530d74f0c478b0a9239d68dcdb9f5e"
+  integrity sha512-MIf9cBtac4/UBE5a+R5cXiRhOGfzetsV+ZPFc188AfkPDPbmffHqjrRoCyk4B/qS6fLEulSBMLSaQ+6ze971gQ==
+  dependencies:
+    "@esm-bundle/chai" "^4.3.4-fix.0"
+    "@open-wc/chai-dom-equals" "^0.12.36"
+    "@open-wc/semantic-dom-diff" "^0.19.7"
+    "@open-wc/testing-helpers" "^2.1.2"
+    "@types/chai" "^4.2.11"
+    "@types/chai-dom" "^0.0.12"
+    "@types/sinon-chai" "^3.2.3"
+    chai-a11y-axe "^1.3.2"
+
 "@polymer/decorators@^3.0.0":
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/@polymer/decorators/-/decorators-3.0.0.tgz#e4212ac976d9abd1210f560b6e1be4165c1c0183"
@@ -26,16 +137,938 @@
   dependencies:
     "@webcomponents/shadycss" "^1.9.1"
 
+"@types/accepts@*":
+  version "1.3.5"
+  resolved "https://registry.yarnpkg.com/@types/accepts/-/accepts-1.3.5.tgz#c34bec115cfc746e04fe5a059df4ce7e7b391575"
+  integrity sha512-jOdnI/3qTpHABjM5cx1Hc0sKsPoYCp+DP/GJRGtDlPd7fiV9oXGGIcjW/ZOxLIvjGz8MA+uMZI9metHlgqbgwQ==
+  dependencies:
+    "@types/node" "*"
+
+"@types/babel__code-frame@^7.0.2":
+  version "7.0.3"
+  resolved "https://registry.yarnpkg.com/@types/babel__code-frame/-/babel__code-frame-7.0.3.tgz#eda94e1b7c9326700a4b69c485ebbc9498a0b63f"
+  integrity sha512-2TN6oiwtNjOezilFVl77zwdNPwQWaDBBCCWWxyo1ctiO3vAtd7H/aB/CBJdw9+kqq3+latD0SXoedIuHySSZWw==
+
+"@types/body-parser@*":
+  version "1.19.2"
+  resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.19.2.tgz#aea2059e28b7658639081347ac4fab3de166e6f0"
+  integrity sha512-ALYone6pm6QmwZoAgeyNksccT9Q4AWZQ6PvfwR37GT6r6FWUPguq6sUmNGSMV2Wr761oQoBxwGGa6DR5o1DC9g==
+  dependencies:
+    "@types/connect" "*"
+    "@types/node" "*"
+
+"@types/chai-dom@^0.0.12":
+  version "0.0.12"
+  resolved "https://registry.yarnpkg.com/@types/chai-dom/-/chai-dom-0.0.12.tgz#fdd7a52bed4dd235ed1c94d3d2d31d4e7db1d03a"
+  integrity sha512-4rE7sDw713cV61TYzQbMrPjC4DjNk3x4vk9nAVRNXcSD4p0/5lEEfm0OgoCz5eNuWUXNKA0YiKiH/JDTuKivkA==
+  dependencies:
+    "@types/chai" "*"
+
+"@types/chai@*", "@types/chai@^4.1.7", "@types/chai@^4.2.11", "@types/chai@^4.2.12", "@types/chai@^4.3.1":
+  version "4.3.3"
+  resolved "https://registry.yarnpkg.com/@types/chai/-/chai-4.3.3.tgz#3c90752792660c4b562ad73b3fbd68bf3bc7ae07"
+  integrity sha512-hC7OMnszpxhZPduX+m+nrx+uFoLkWOMiR4oa/AZF3MuSETYTZmFfJAHqZEM8MVlvfG7BEUcgvtwoCTxBp6hm3g==
+
+"@types/co-body@^6.1.0":
+  version "6.1.0"
+  resolved "https://registry.yarnpkg.com/@types/co-body/-/co-body-6.1.0.tgz#b52625390eb0d113c9b697ea92c3ffae7740cdb9"
+  integrity sha512-3e0q2jyDAnx/DSZi0z2H0yoZ2wt5yRDZ+P7ymcMObvq0ufWRT4tsajyO+Q1VwVWiv9PRR4W3YEjEzBjeZlhF+w==
+  dependencies:
+    "@types/node" "*"
+    "@types/qs" "*"
+
+"@types/connect@*":
+  version "3.4.35"
+  resolved "https://registry.yarnpkg.com/@types/connect/-/connect-3.4.35.tgz#5fcf6ae445e4021d1fc2219a4873cc73a3bb2ad1"
+  integrity sha512-cdeYyv4KWoEgpBISTxWvqYsVy444DOqehiF3fM3ne10AmJ62RSyNkUnxMJXHQWRQQX2eR94m5y1IZyDwBjV9FQ==
+  dependencies:
+    "@types/node" "*"
+
+"@types/content-disposition@*":
+  version "0.5.5"
+  resolved "https://registry.yarnpkg.com/@types/content-disposition/-/content-disposition-0.5.5.tgz#650820e95de346e1f84e30667d168c8fd25aa6e3"
+  integrity sha512-v6LCdKfK6BwcqMo+wYW05rLS12S0ZO0Fl4w1h4aaZMD7bqT3gVUns6FvLJKGZHQmYn3SX55JWGpziwJRwVgutA==
+
+"@types/convert-source-map@^1.5.1":
+  version "1.5.2"
+  resolved "https://registry.yarnpkg.com/@types/convert-source-map/-/convert-source-map-1.5.2.tgz#318dc22d476632a4855594c16970c6dc3ed086e7"
+  integrity sha512-tHs++ZeXer40kCF2JpE51Hg7t4HPa18B1b1Dzy96S0eCw8QKECNMYMfwa1edK/x8yCN0r4e6ewvLcc5CsVGkdg==
+
+"@types/cookies@*":
+  version "0.7.7"
+  resolved "https://registry.yarnpkg.com/@types/cookies/-/cookies-0.7.7.tgz#7a92453d1d16389c05a5301eef566f34946cfd81"
+  integrity sha512-h7BcvPUogWbKCzBR2lY4oqaZbO3jXZksexYJVFvkrFeLgbZjQkU4x8pRq6eg2MHXQhY0McQdqmmsxRWlVAHooA==
+  dependencies:
+    "@types/connect" "*"
+    "@types/express" "*"
+    "@types/keygrip" "*"
+    "@types/node" "*"
+
+"@types/debounce@^1.2.0":
+  version "1.2.1"
+  resolved "https://registry.yarnpkg.com/@types/debounce/-/debounce-1.2.1.tgz#79b65710bc8b6d44094d286aecf38e44f9627852"
+  integrity sha512-epMsEE85fi4lfmJUH/89/iV/LI+F5CvNIvmgs5g5jYFPfhO2S/ae8WSsLOKWdwtoaZw9Q2IhJ4tQ5tFCcS/4HA==
+
+"@types/express-serve-static-core@^4.17.18":
+  version "4.17.31"
+  resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-4.17.31.tgz#a1139efeab4e7323834bb0226e62ac019f474b2f"
+  integrity sha512-DxMhY+NAsTwMMFHBTtJFNp5qiHKJ7TeqOo23zVEM9alT1Ml27Q3xcTH0xwxn7Q0BbMcVEJOs/7aQtUWupUQN3Q==
+  dependencies:
+    "@types/node" "*"
+    "@types/qs" "*"
+    "@types/range-parser" "*"
+
+"@types/express@*":
+  version "4.17.14"
+  resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.14.tgz#143ea0557249bc1b3b54f15db4c81c3d4eb3569c"
+  integrity sha512-TEbt+vaPFQ+xpxFLFssxUDXj5cWCxZJjIcB7Yg0k0GMHGtgtQgpvx/MUQUeAkNbA9AAGrwkAsoeItdTgS7FMyg==
+  dependencies:
+    "@types/body-parser" "*"
+    "@types/express-serve-static-core" "^4.17.18"
+    "@types/qs" "*"
+    "@types/serve-static" "*"
+
+"@types/http-assert@*":
+  version "1.5.3"
+  resolved "https://registry.yarnpkg.com/@types/http-assert/-/http-assert-1.5.3.tgz#ef8e3d1a8d46c387f04ab0f2e8ab8cb0c5078661"
+  integrity sha512-FyAOrDuQmBi8/or3ns4rwPno7/9tJTijVW6aQQjK02+kOQ8zmoNg2XJtAuQhvQcy1ASJq38wirX5//9J1EqoUA==
+
+"@types/http-errors@*":
+  version "1.8.2"
+  resolved "https://registry.yarnpkg.com/@types/http-errors/-/http-errors-1.8.2.tgz#7315b4c4c54f82d13fa61c228ec5c2ea5cc9e0e1"
+  integrity sha512-EqX+YQxINb+MeXaIqYDASb6U6FCHbWjkj4a1CKDBks3d/QiB2+PqBLyO72vLDgAO1wUI4O+9gweRcQK11bTL/w==
+
+"@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.3":
+  version "2.0.4"
+  resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.4.tgz#8467d4b3c087805d63580480890791277ce35c44"
+  integrity sha512-z/QT1XN4K4KYuslS23k62yDIDLwLFkzxOuMplDtObz0+y7VqJCaO2o+SPwHCvLFZh7xazvvoor2tA/hPz9ee7g==
+
+"@types/istanbul-lib-report@*":
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.0.tgz#c14c24f18ea8190c118ee7562b7ff99a36552686"
+  integrity sha512-plGgXAPfVKFoYfa9NpYDAkseG+g6Jr294RqeqcqDixSbU34MZVJRi/P+7Y8GDpzkEwLaGZZOpKIEmeVZNtKsrg==
+  dependencies:
+    "@types/istanbul-lib-coverage" "*"
+
+"@types/istanbul-reports@^3.0.0":
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/@types/istanbul-reports/-/istanbul-reports-3.0.1.tgz#9153fe98bba2bd565a63add9436d6f0d7f8468ff"
+  integrity sha512-c3mAZEuK0lvBp8tmuL74XRKn1+y2dcwOUpH7x4WrF6gk1GIgiluDRgMYQtw2OFcBvAJWlt6ASU3tSqxp0Uu0Aw==
+  dependencies:
+    "@types/istanbul-lib-report" "*"
+
+"@types/keygrip@*":
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/@types/keygrip/-/keygrip-1.0.2.tgz#513abfd256d7ad0bf1ee1873606317b33b1b2a72"
+  integrity sha512-GJhpTepz2udxGexqos8wgaBx4I/zWIDPh/KOGEwAqtuGDkOUJu5eFvwmdBX4AmB8Odsr+9pHCQqiAqDL/yKMKw==
+
+"@types/koa-compose@*":
+  version "3.2.5"
+  resolved "https://registry.yarnpkg.com/@types/koa-compose/-/koa-compose-3.2.5.tgz#85eb2e80ac50be95f37ccf8c407c09bbe3468e9d"
+  integrity sha512-B8nG/OoE1ORZqCkBVsup/AKcvjdgoHnfi4pZMn5UwAPCbhk/96xyv284eBYW8JlQbQ7zDmnpFr68I/40mFoIBQ==
+  dependencies:
+    "@types/koa" "*"
+
+"@types/koa@*", "@types/koa@^2.11.6":
+  version "2.13.5"
+  resolved "https://registry.yarnpkg.com/@types/koa/-/koa-2.13.5.tgz#64b3ca4d54e08c0062e89ec666c9f45443b21a61"
+  integrity sha512-HSUOdzKz3by4fnqagwthW/1w/yJspTgppyyalPVbgZf8jQWvdIXcVW5h2DGtw4zYntOaeRGx49r1hxoPWrD4aA==
+  dependencies:
+    "@types/accepts" "*"
+    "@types/content-disposition" "*"
+    "@types/cookies" "*"
+    "@types/http-assert" "*"
+    "@types/http-errors" "*"
+    "@types/keygrip" "*"
+    "@types/koa-compose" "*"
+    "@types/node" "*"
+
+"@types/mime@*":
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/@types/mime/-/mime-3.0.1.tgz#5f8f2bca0a5863cb69bc0b0acd88c96cb1d4ae10"
+  integrity sha512-Y4XFY5VJAuw0FgAqPNd6NNoV44jbq9Bz2L7Rh/J6jLTiHBSBJa9fxqQIvkIld4GsoDOcCbvzOUAbLPsSKKg+uA==
+
+"@types/node@*":
+  version "18.7.18"
+  resolved "https://registry.yarnpkg.com/@types/node/-/node-18.7.18.tgz#633184f55c322e4fb08612307c274ee6d5ed3154"
+  integrity sha512-m+6nTEOadJZuTPkKR/SYK3A2d7FZrgElol9UP1Kae90VVU4a6mxnPuLiIW1m4Cq4gZ/nWb9GrdVXJCoCazDAbg==
+
+"@types/parse5@^6.0.1":
+  version "6.0.3"
+  resolved "https://registry.yarnpkg.com/@types/parse5/-/parse5-6.0.3.tgz#705bb349e789efa06f43f128cef51240753424cb"
+  integrity sha512-SuT16Q1K51EAVPz1K29DJ/sXjhSQ0zjvsypYJ6tlwVsRV9jwW5Adq2ch8Dq8kDBCkYnELS7N7VNCSB5nC56t/g==
+
+"@types/qs@*":
+  version "6.9.7"
+  resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.9.7.tgz#63bb7d067db107cc1e457c303bc25d511febf6cb"
+  integrity sha512-FGa1F62FT09qcrueBA6qYTrJPVDzah9a+493+o2PCXsesWHIn27G98TsSMs3WPNbZIEj4+VJf6saSFpvD+3Zsw==
+
+"@types/range-parser@*":
+  version "1.2.4"
+  resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.4.tgz#cd667bcfdd025213aafb7ca5915a932590acdcdc"
+  integrity sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==
+
+"@types/serve-static@*":
+  version "1.15.0"
+  resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-1.15.0.tgz#c7930ff61afb334e121a9da780aac0d9b8f34155"
+  integrity sha512-z5xyF6uh8CbjAu9760KDKsH2FcDxZ2tFCsA4HIMWE6IkiYMXfVoa+4f9KX+FN0ZLsaMw1WNG2ETLA6N+/YA+cg==
+  dependencies:
+    "@types/mime" "*"
+    "@types/node" "*"
+
+"@types/sinon-chai@^3.2.3":
+  version "3.2.8"
+  resolved "https://registry.yarnpkg.com/@types/sinon-chai/-/sinon-chai-3.2.8.tgz#5871d09ab50d671d8e6dd72e9073f8e738ac61dc"
+  integrity sha512-d4ImIQbT/rKMG8+AXpmcan5T2/PNeSjrYhvkwet6z0p8kzYtfgA32xzOBlbU0yqJfq+/0Ml805iFoODO0LP5/g==
+  dependencies:
+    "@types/chai" "*"
+    "@types/sinon" "*"
+
+"@types/sinon@*":
+  version "10.0.13"
+  resolved "https://registry.yarnpkg.com/@types/sinon/-/sinon-10.0.13.tgz#60a7a87a70d9372d0b7b38cc03e825f46981fb83"
+  integrity sha512-UVjDqJblVNQYvVNUsj0PuYYw0ELRmgt1Nt5Vk0pT5f16ROGfcKJY8o1HVuMOJOpD727RrGB9EGvoaTQE5tgxZQ==
+  dependencies:
+    "@types/sinonjs__fake-timers" "*"
+
+"@types/sinonjs__fake-timers@*":
+  version "8.1.2"
+  resolved "https://registry.yarnpkg.com/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-8.1.2.tgz#bf2e02a3dbd4aecaf95942ecd99b7402e03fad5e"
+  integrity sha512-9GcLXF0/v3t80caGs5p2rRfkB+a8VBGLJZVih6CNFkx8IZ994wiKKLSRs9nuFwk1HevWs/1mnUmkApGrSGsShA==
+
 "@types/trusted-types@^2.0.2":
   version "2.0.2"
   resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-2.0.2.tgz#fc25ad9943bcac11cceb8168db4f275e0e72e756"
   integrity sha512-F5DIZ36YVLE+PN+Zwws4kJogq47hNgX3Nx6WyDJ3kcplxyke3XIzB8uK5n/Lpm1HBsbGzd6nmGehL8cPekP+Tg==
 
+"@types/ws@^7.4.0":
+  version "7.4.7"
+  resolved "https://registry.yarnpkg.com/@types/ws/-/ws-7.4.7.tgz#f7c390a36f7a0679aa69de2d501319f4f8d9b702"
+  integrity sha512-JQbbmxZTZehdc2iszGKs5oC3NFnjeay7mtAWrdt7qNtAVK0g19muApzAy4bm9byz79xa2ZnO/BOBC2R8RC5Lww==
+  dependencies:
+    "@types/node" "*"
+
+"@web/browser-logs@^0.2.1":
+  version "0.2.5"
+  resolved "https://registry.yarnpkg.com/@web/browser-logs/-/browser-logs-0.2.5.tgz#0895efb641eacb0fbc1138c6092bd18c01df2734"
+  integrity sha512-Qxo1wY/L7yILQqg0jjAaueh+tzdORXnZtxQgWH23SsTCunz9iq9FvsZa8Q5XlpjnZ3vLIsFEuEsCMqFeohJnEg==
+  dependencies:
+    errorstacks "^2.2.0"
+
+"@web/dev-server-core@^0.3.18":
+  version "0.3.19"
+  resolved "https://registry.yarnpkg.com/@web/dev-server-core/-/dev-server-core-0.3.19.tgz#b61f9a0b92351371347a758b30ba19e683c72e94"
+  integrity sha512-Q/Xt4RMVebLWvALofz1C0KvP8qHbzU1EmdIA2Y1WMPJwiFJFhPxdr75p9YxK32P2t0hGs6aqqS5zE0HW9wYzYA==
+  dependencies:
+    "@types/koa" "^2.11.6"
+    "@types/ws" "^7.4.0"
+    "@web/parse5-utils" "^1.2.0"
+    chokidar "^3.4.3"
+    clone "^2.1.2"
+    es-module-lexer "^1.0.0"
+    get-stream "^6.0.0"
+    is-stream "^2.0.0"
+    isbinaryfile "^4.0.6"
+    koa "^2.13.0"
+    koa-etag "^4.0.0"
+    koa-send "^5.0.1"
+    koa-static "^5.0.0"
+    lru-cache "^6.0.0"
+    mime-types "^2.1.27"
+    parse5 "^6.0.1"
+    picomatch "^2.2.2"
+    ws "^7.4.2"
+
+"@web/parse5-utils@^1.2.0":
+  version "1.3.0"
+  resolved "https://registry.yarnpkg.com/@web/parse5-utils/-/parse5-utils-1.3.0.tgz#e2e9e98b31a4ca948309f74891bda8d77399f6bd"
+  integrity sha512-Pgkx3ECc8EgXSlS5EyrgzSOoUbM6P8OKS471HLAyvOBcP1NCBn0to4RN/OaKASGq8qa3j+lPX9H14uA5AHEnQg==
+  dependencies:
+    "@types/parse5" "^6.0.1"
+    parse5 "^6.0.1"
+
+"@web/test-runner-commands@^0.6.1":
+  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.27":
+  version "0.10.27"
+  resolved "https://registry.yarnpkg.com/@web/test-runner-core/-/test-runner-core-0.10.27.tgz#8d1430f2364fb36b3ac15b9b43034fae9d94e177"
+  integrity sha512-ClV/hSxs4wDm/ANFfQOdRRFb/c0sYywC1QfUXG/nS4vTp3nnt7x7mjydtMGGLmvK9f6Zkubkc1aa+7ryfmVwNA==
+  dependencies:
+    "@babel/code-frame" "^7.12.11"
+    "@types/babel__code-frame" "^7.0.2"
+    "@types/co-body" "^6.1.0"
+    "@types/convert-source-map" "^1.5.1"
+    "@types/debounce" "^1.2.0"
+    "@types/istanbul-lib-coverage" "^2.0.3"
+    "@types/istanbul-reports" "^3.0.0"
+    "@web/browser-logs" "^0.2.1"
+    "@web/dev-server-core" "^0.3.18"
+    chokidar "^3.4.3"
+    cli-cursor "^3.1.0"
+    co-body "^6.1.0"
+    convert-source-map "^1.7.0"
+    debounce "^1.2.0"
+    dependency-graph "^0.11.0"
+    globby "^11.0.1"
+    ip "^1.1.5"
+    istanbul-lib-coverage "^3.0.0"
+    istanbul-lib-report "^3.0.0"
+    istanbul-reports "^3.0.2"
+    log-update "^4.0.0"
+    nanocolors "^0.2.1"
+    nanoid "^3.1.25"
+    open "^8.0.2"
+    picomatch "^2.2.2"
+    source-map "^0.7.3"
+
 "@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==
 
+accepts@^1.3.5:
+  version "1.3.8"
+  resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.8.tgz#0bf0be125b67014adcb0b0921e62db7bffe16b2e"
+  integrity sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==
+  dependencies:
+    mime-types "~2.1.34"
+    negotiator "0.6.3"
+
+ansi-escapes@^4.3.0:
+  version "4.3.2"
+  resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-4.3.2.tgz#6b2291d1db7d98b6521d5f1efa42d0f3a9feb65e"
+  integrity sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==
+  dependencies:
+    type-fest "^0.21.3"
+
+ansi-regex@^5.0.1:
+  version "5.0.1"
+  resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304"
+  integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==
+
+ansi-styles@^3.2.1:
+  version "3.2.1"
+  resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d"
+  integrity sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==
+  dependencies:
+    color-convert "^1.9.0"
+
+ansi-styles@^4.0.0:
+  version "4.3.0"
+  resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937"
+  integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==
+  dependencies:
+    color-convert "^2.0.1"
+
+anymatch@~3.1.2:
+  version "3.1.2"
+  resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.2.tgz#c0557c096af32f106198f4f4e2a383537e378716"
+  integrity sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg==
+  dependencies:
+    normalize-path "^3.0.0"
+    picomatch "^2.0.4"
+
+array-union@^2.1.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/array-union/-/array-union-2.1.0.tgz#b798420adbeb1de828d84acd8a2e23d3efe85e8d"
+  integrity sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==
+
+astral-regex@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/astral-regex/-/astral-regex-2.0.0.tgz#483143c567aeed4785759c0865786dc77d7d2e31"
+  integrity sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==
+
+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==
+
+binary-extensions@^2.0.0:
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d"
+  integrity sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==
+
+braces@^3.0.2, braces@~3.0.2:
+  version "3.0.2"
+  resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107"
+  integrity sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==
+  dependencies:
+    fill-range "^7.0.1"
+
+bytes@3.1.2:
+  version "3.1.2"
+  resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5"
+  integrity sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==
+
+cache-content-type@^1.0.0:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/cache-content-type/-/cache-content-type-1.0.1.tgz#035cde2b08ee2129f4a8315ea8f00a00dba1453c"
+  integrity sha512-IKufZ1o4Ut42YUrZSo8+qnMTrFuKkvyoLXUywKz9GJ5BrhOFGhLdkx9sG4KAnVvbY6kEcSFjLQul+DVmBm2bgA==
+  dependencies:
+    mime-types "^2.1.18"
+    ylru "^1.2.0"
+
+call-bind@^1.0.0:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.2.tgz#b1d4e89e688119c3c9a903ad30abb2f6a919be3c"
+  integrity sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==
+  dependencies:
+    function-bind "^1.1.1"
+    get-intrinsic "^1.0.2"
+
+chai-a11y-axe@^1.3.2:
+  version "1.4.0"
+  resolved "https://registry.yarnpkg.com/chai-a11y-axe/-/chai-a11y-axe-1.4.0.tgz#e584af967727a8656e27c32e845f5db21f2bf2e0"
+  integrity sha512-m7J6DVAl1ePL2ifPKHmwQyHXdCZ+Qfv+qduh6ScqcDfBnJEzpV1K49TblujM45j1XciZOFeFNqMb2sShXMg/mw==
+  dependencies:
+    axe-core "^4.3.3"
+
+chalk@^2.0.0:
+  version "2.4.2"
+  resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424"
+  integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==
+  dependencies:
+    ansi-styles "^3.2.1"
+    escape-string-regexp "^1.0.5"
+    supports-color "^5.3.0"
+
+chokidar@^3.4.3:
+  version "3.5.3"
+  resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.3.tgz#1cf37c8707b932bd1af1ae22c0432e2acd1903bd"
+  integrity sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==
+  dependencies:
+    anymatch "~3.1.2"
+    braces "~3.0.2"
+    glob-parent "~5.1.2"
+    is-binary-path "~2.1.0"
+    is-glob "~4.0.1"
+    normalize-path "~3.0.0"
+    readdirp "~3.6.0"
+  optionalDependencies:
+    fsevents "~2.3.2"
+
+cli-cursor@^3.1.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-3.1.0.tgz#264305a7ae490d1d03bf0c9ba7c925d1753af307"
+  integrity sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==
+  dependencies:
+    restore-cursor "^3.1.0"
+
+clone@^2.1.2:
+  version "2.1.2"
+  resolved "https://registry.yarnpkg.com/clone/-/clone-2.1.2.tgz#1b7f4b9f591f1e8f83670401600345a02887435f"
+  integrity sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==
+
+co-body@^6.1.0:
+  version "6.1.0"
+  resolved "https://registry.yarnpkg.com/co-body/-/co-body-6.1.0.tgz#d87a8efc3564f9bfe3aced8ef5cd04c7a8766547"
+  integrity sha512-m7pOT6CdLN7FuXUcpuz/8lfQ/L77x8SchHCF4G0RBTJO20Wzmhn5Sp4/5WsKy8OSpifBSUrmg83qEqaDHdyFuQ==
+  dependencies:
+    inflation "^2.0.0"
+    qs "^6.5.2"
+    raw-body "^2.3.3"
+    type-is "^1.6.16"
+
+co@^4.6.0:
+  version "4.6.0"
+  resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184"
+  integrity sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==
+
+color-convert@^1.9.0:
+  version "1.9.3"
+  resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8"
+  integrity sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==
+  dependencies:
+    color-name "1.1.3"
+
+color-convert@^2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3"
+  integrity sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==
+  dependencies:
+    color-name "~1.1.4"
+
+color-name@1.1.3:
+  version "1.1.3"
+  resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25"
+  integrity 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==
+
+content-disposition@~0.5.2:
+  version "0.5.4"
+  resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.4.tgz#8b82b4efac82512a02bb0b1dcec9d2c5e8eb5bfe"
+  integrity sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==
+  dependencies:
+    safe-buffer "5.2.1"
+
+content-type@^1.0.4:
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.4.tgz#e138cc75e040c727b1966fe5e5f8c9aee256fe3b"
+  integrity sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==
+
+convert-source-map@^1.7.0:
+  version "1.8.0"
+  resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.8.0.tgz#f3373c32d21b4d780dd8004514684fb791ca4369"
+  integrity sha512-+OQdjP49zViI/6i7nIJpA8rAl4sV/JdPfU9nZs3VqOwGIgizICvuN2ru6fMd+4llL0tar18UYJXfZ/TWtmhUjA==
+  dependencies:
+    safe-buffer "~5.1.1"
+
+cookies@~0.8.0:
+  version "0.8.0"
+  resolved "https://registry.yarnpkg.com/cookies/-/cookies-0.8.0.tgz#1293ce4b391740a8406e3c9870e828c4b54f3f90"
+  integrity sha512-8aPsApQfebXnuI+537McwYsDtjVxGm8gTIzQI3FDW6t5t/DAhERxtnbEPN/8RX+uZthoz4eCOgloXaE5cYyNow==
+  dependencies:
+    depd "~2.0.0"
+    keygrip "~1.1.0"
+
+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@^3.1.0:
+  version "3.2.7"
+  resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.7.tgz#72580b7e9145fb39b6676f9c5e5fb100b934179a"
+  integrity sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==
+  dependencies:
+    ms "^2.1.1"
+
+debug@^4.1.1, debug@^4.3.2:
+  version "4.3.4"
+  resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865"
+  integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==
+  dependencies:
+    ms "2.1.2"
+
+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==
+
+define-lazy-prop@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz#3f7ae421129bcaaac9bc74905c98a0009ec9ee7f"
+  integrity sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==
+
+delegates@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a"
+  integrity sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==
+
+depd@2.0.0, depd@^2.0.0, depd@~2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df"
+  integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==
+
+depd@~1.1.2:
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9"
+  integrity sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==
+
+dependency-graph@^0.11.0:
+  version "0.11.0"
+  resolved "https://registry.yarnpkg.com/dependency-graph/-/dependency-graph-0.11.0.tgz#ac0ce7ed68a54da22165a85e97a01d53f5eb2e27"
+  integrity sha512-JeMq7fEshyepOWDfcfHK06N3MhyPhz++vtqWhMT5O9A3K42rdsEDpfdVqjaqaAhsw6a+ZqeDvQVtD0hFHQWrzg==
+
+destroy@^1.0.4:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.2.0.tgz#4803735509ad8be552934c67df614f94e66fa015"
+  integrity sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==
+
+dir-glob@^3.0.1:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/dir-glob/-/dir-glob-3.0.1.tgz#56dbf73d992a4a93ba1584f4534063fd2e41717f"
+  integrity sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==
+  dependencies:
+    path-type "^4.0.0"
+
+ee-first@1.1.1:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d"
+  integrity sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==
+
+emoji-regex@^8.0.0:
+  version "8.0.0"
+  resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37"
+  integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==
+
+encodeurl@^1.0.2:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59"
+  integrity sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==
+
+errorstacks@^2.2.0:
+  version "2.4.0"
+  resolved "https://registry.yarnpkg.com/errorstacks/-/errorstacks-2.4.0.tgz#2155674dd9e741aacda3f3b8b967d9c40a4a0baf"
+  integrity sha512-5ecWhU5gt0a5G05nmQcgCxP5HperSMxLDzvWlT5U+ZSKkuDK0rJ3dbCQny6/vSCIXjwrhwSecXBbw1alr295hQ==
+
+es-module-lexer@^1.0.0:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-1.0.3.tgz#f0d8d35b36d13024110000d5e6fadc8eeaeb66b8"
+  integrity sha512-iC67eXHToclrlVhQfpRawDiF8D8sQxNxmbqw5oebegOaJkyx/w9C/k57/5e6yJR2zIByRt9OXdqX50DV2t6ZKw==
+
+escape-html@^1.0.3:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988"
+  integrity sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==
+
+escape-string-regexp@^1.0.5:
+  version "1.0.5"
+  resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4"
+  integrity sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==
+
+etag@^1.8.1:
+  version "1.8.1"
+  resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887"
+  integrity sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==
+
+fast-glob@^3.2.9:
+  version "3.2.12"
+  resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.2.12.tgz#7f39ec99c2e6ab030337142da9e0c18f37afae80"
+  integrity sha512-DVj4CQIYYow0BlaelwK1pHl5n5cRSJfM60UA0zK891sVInoPri2Ekj7+e1CT3/3qxXenpI+nBBmQAcJPJgaj4w==
+  dependencies:
+    "@nodelib/fs.stat" "^2.0.2"
+    "@nodelib/fs.walk" "^1.2.3"
+    glob-parent "^5.1.2"
+    merge2 "^1.3.0"
+    micromatch "^4.0.4"
+
+fastq@^1.6.0:
+  version "1.13.0"
+  resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.13.0.tgz#616760f88a7526bdfc596b7cab8c18938c36b98c"
+  integrity sha512-YpkpUnK8od0o1hmeSc7UUs/eB/vIPWJYjKck2QKIzAf71Vm1AAQ3EbuZB3g2JIy+pg+ERD0vqI79KyZiB2e2Nw==
+  dependencies:
+    reusify "^1.0.4"
+
+fill-range@^7.0.1:
+  version "7.0.1"
+  resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.0.1.tgz#1919a6a7c75fe38b2c7c77e5198535da9acdda40"
+  integrity sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==
+  dependencies:
+    to-regex-range "^5.0.1"
+
+fresh@~0.5.2:
+  version "0.5.2"
+  resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7"
+  integrity sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==
+
+fsevents@~2.3.2:
+  version "2.3.2"
+  resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a"
+  integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==
+
+function-bind@^1.1.1:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d"
+  integrity sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==
+
+get-intrinsic@^1.0.2:
+  version "1.1.3"
+  resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.1.3.tgz#063c84329ad93e83893c7f4f243ef63ffa351385"
+  integrity sha512-QJVz1Tj7MS099PevUG5jvnt9tSkXN8K14dxQlikJuPt4uD9hHAHjLyLBiLR5zELelBdD9QNRAXZzsJx0WaDL9A==
+  dependencies:
+    function-bind "^1.1.1"
+    has "^1.0.3"
+    has-symbols "^1.0.3"
+
+get-stream@^6.0.0:
+  version "6.0.1"
+  resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-6.0.1.tgz#a262d8eef67aced57c2852ad6167526a43cbf7b7"
+  integrity sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==
+
+glob-parent@^5.1.2, glob-parent@~5.1.2:
+  version "5.1.2"
+  resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4"
+  integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==
+  dependencies:
+    is-glob "^4.0.1"
+
+globby@^11.0.1:
+  version "11.1.0"
+  resolved "https://registry.yarnpkg.com/globby/-/globby-11.1.0.tgz#bd4be98bb042f83d796f7e3811991fbe82a0d34b"
+  integrity sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==
+  dependencies:
+    array-union "^2.1.0"
+    dir-glob "^3.0.1"
+    fast-glob "^3.2.9"
+    ignore "^5.2.0"
+    merge2 "^1.4.1"
+    slash "^3.0.0"
+
+has-flag@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd"
+  integrity sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==
+
+has-flag@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b"
+  integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==
+
+has-symbols@^1.0.2, has-symbols@^1.0.3:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.3.tgz#bb7b2c4349251dce87b125f7bdf874aa7c8b39f8"
+  integrity sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==
+
+has-tostringtag@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/has-tostringtag/-/has-tostringtag-1.0.0.tgz#7e133818a7d394734f941e73c3d3f9291e658b25"
+  integrity sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==
+  dependencies:
+    has-symbols "^1.0.2"
+
+has@^1.0.3:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/has/-/has-1.0.3.tgz#722d7cbfc1f6aa8241f16dd814e011e1f41e8796"
+  integrity sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==
+  dependencies:
+    function-bind "^1.1.1"
+
+html-escaper@^2.0.0:
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/html-escaper/-/html-escaper-2.0.2.tgz#dfd60027da36a36dfcbe236262c00a5822681453"
+  integrity sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==
+
+http-assert@^1.3.0:
+  version "1.5.0"
+  resolved "https://registry.yarnpkg.com/http-assert/-/http-assert-1.5.0.tgz#c389ccd87ac16ed2dfa6246fd73b926aa00e6b8f"
+  integrity sha512-uPpH7OKX4H25hBmU6G1jWNaqJGpTXxey+YOUizJUAgu0AjLUeC8D73hTrhvDS5D+GJN1DN1+hhc/eF/wpxtp0w==
+  dependencies:
+    deep-equal "~1.0.1"
+    http-errors "~1.8.0"
+
+http-errors@2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-2.0.0.tgz#b7774a1486ef73cf7667ac9ae0858c012c57b9d3"
+  integrity sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==
+  dependencies:
+    depd "2.0.0"
+    inherits "2.0.4"
+    setprototypeof "1.2.0"
+    statuses "2.0.1"
+    toidentifier "1.0.1"
+
+http-errors@^1.6.3, http-errors@^1.7.3, http-errors@~1.8.0:
+  version "1.8.1"
+  resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.8.1.tgz#7c3f28577cbc8a207388455dbd62295ed07bd68c"
+  integrity sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==
+  dependencies:
+    depd "~1.1.2"
+    inherits "2.0.4"
+    setprototypeof "1.2.0"
+    statuses ">= 1.5.0 < 2"
+    toidentifier "1.0.1"
+
+http-errors@~1.6.2:
+  version "1.6.3"
+  resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.6.3.tgz#8b55680bb4be283a0b5bf4ea2e38580be1d9320d"
+  integrity sha512-lks+lVC8dgGyh97jxvxeYTWQFvh4uw4yC12gVl63Cg30sjPX4wuGcdkICVXDAESr6OJGjqGA8Iz5mkeN6zlD7A==
+  dependencies:
+    depd "~1.1.2"
+    inherits "2.0.3"
+    setprototypeof "1.1.0"
+    statuses ">= 1.4.0 < 2"
+
+iconv-lite@0.4.24:
+  version "0.4.24"
+  resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b"
+  integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==
+  dependencies:
+    safer-buffer ">= 2.1.2 < 3"
+
+ignore@^5.2.0:
+  version "5.2.0"
+  resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.2.0.tgz#6d3bac8fa7fe0d45d9f9be7bac2fc279577e345a"
+  integrity sha512-CmxgYGiEPCLhfLnpPp1MoRmifwEIOgjcHXxOBjv7mY96c+eWScsOP9c112ZyLdWHi0FxHjI+4uVhKYp/gcdRmQ==
+
+inflation@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/inflation/-/inflation-2.0.0.tgz#8b417e47c28f925a45133d914ca1fd389107f30f"
+  integrity sha512-m3xv4hJYR2oXw4o4Y5l6P5P16WYmazYof+el6Al3f+YlggGj6qT9kImBAnzDelRALnP5d3h4jGBPKzYCizjZZw==
+
+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:
+  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"
+  integrity sha512-PuExPYUiu6qMBQb4l06ecm6T6ujzhmh+MeJcW9wa89PoAz5pvd4zPgN5WJV104mb6S2T1AwNIAaB70JNrLQWhg==
+
+is-binary-path@~2.1.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-2.1.0.tgz#ea1f7f3b80f064236e83470f86c09c254fb45b09"
+  integrity sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==
+  dependencies:
+    binary-extensions "^2.0.0"
+
+is-docker@^2.0.0, is-docker@^2.1.1:
+  version "2.2.1"
+  resolved "https://registry.yarnpkg.com/is-docker/-/is-docker-2.2.1.tgz#33eeabe23cfe86f14bde4408a02c0cfb853acdaa"
+  integrity sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==
+
+is-extglob@^2.1.1:
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2"
+  integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==
+
+is-fullwidth-code-point@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d"
+  integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==
+
+is-generator-function@^1.0.7:
+  version "1.0.10"
+  resolved "https://registry.yarnpkg.com/is-generator-function/-/is-generator-function-1.0.10.tgz#f1558baf1ac17e0deea7c0415c438351ff2b3c72"
+  integrity sha512-jsEjy9l3yiXEQ+PsXdmBwEPcOxaXWLspKdplFUVI9vq1iZgIekeC0L167qeu86czQaxed3q/Uzuw0swL0irL8A==
+  dependencies:
+    has-tostringtag "^1.0.0"
+
+is-glob@^4.0.1, is-glob@~4.0.1:
+  version "4.0.3"
+  resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084"
+  integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==
+  dependencies:
+    is-extglob "^2.1.1"
+
+is-number@^7.0.0:
+  version "7.0.0"
+  resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b"
+  integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==
+
+is-stream@^2.0.0:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-2.0.1.tgz#fac1e3d53b97ad5a9d0ae9cef2389f5810a5c077"
+  integrity sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==
+
+is-wsl@^2.2.0:
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/is-wsl/-/is-wsl-2.2.0.tgz#74a4c76e77ca9fd3f932f290c17ea326cd157271"
+  integrity sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==
+  dependencies:
+    is-docker "^2.0.0"
+
+isbinaryfile@^4.0.6:
+  version "4.0.10"
+  resolved "https://registry.yarnpkg.com/isbinaryfile/-/isbinaryfile-4.0.10.tgz#0c5b5e30c2557a2f06febd37b7322946aaee42b3"
+  integrity sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==
+
+istanbul-lib-coverage@^3.0.0:
+  version "3.2.0"
+  resolved "https://registry.yarnpkg.com/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.0.tgz#189e7909d0a39fa5a3dfad5b03f71947770191d3"
+  integrity sha512-eOeJ5BHCmHYvQK7xt9GkdHuzuCGS1Y6g9Gvnx3Ym33fz/HpLRYxiS0wHNr+m/MBC8B647Xt608vCDEvhl9c6Mw==
+
+istanbul-lib-report@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/istanbul-lib-report/-/istanbul-lib-report-3.0.0.tgz#7518fe52ea44de372f460a76b5ecda9ffb73d8a6"
+  integrity sha512-wcdi+uAKzfiGT2abPpKZ0hSU1rGQjUQnLvtY5MpQ7QCTahD3VODhcu4wcfY1YtkGaDD5yuydOLINXsfbus9ROw==
+  dependencies:
+    istanbul-lib-coverage "^3.0.0"
+    make-dir "^3.0.0"
+    supports-color "^7.1.0"
+
+istanbul-reports@^3.0.2:
+  version "3.1.5"
+  resolved "https://registry.yarnpkg.com/istanbul-reports/-/istanbul-reports-3.1.5.tgz#cc9a6ab25cb25659810e4785ed9d9fb742578bae"
+  integrity sha512-nUsEMa9pBt/NOHqbcbeJEgqIlY/K7rVWUX6Lql2orY5e9roQOthbR3vtY4zzf2orPELg80fnxxk9zUyPlgwD1w==
+  dependencies:
+    html-escaper "^2.0.0"
+    istanbul-lib-report "^3.0.0"
+
+js-tokens@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"
+  integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==
+
+keygrip@~1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/keygrip/-/keygrip-1.1.0.tgz#871b1681d5e159c62a445b0c74b615e0917e7226"
+  integrity sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==
+  dependencies:
+    tsscmp "1.0.6"
+
+koa-compose@^4.1.0:
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/koa-compose/-/koa-compose-4.1.0.tgz#507306b9371901db41121c812e923d0d67d3e877"
+  integrity sha512-8ODW8TrDuMYvXRwra/Kh7/rJo9BtOfPc6qO8eAfC80CnCvSjSl0bkRM24X6/XBBEyj0v1nRUQ1LyOy3dbqOWXw==
+
+koa-convert@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/koa-convert/-/koa-convert-2.0.0.tgz#86a0c44d81d40551bae22fee6709904573eea4f5"
+  integrity sha512-asOvN6bFlSnxewce2e/DK3p4tltyfC4VM7ZwuTuepI7dEQVcvpyFuBcEARu1+Hxg8DIwytce2n7jrZtRlPrARA==
+  dependencies:
+    co "^4.6.0"
+    koa-compose "^4.1.0"
+
+koa-etag@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/koa-etag/-/koa-etag-4.0.0.tgz#2c2bb7ae69ca1ac6ced09ba28dcb78523c810414"
+  integrity sha512-1cSdezCkBWlyuB9l6c/IFoe1ANCDdPBxkDkRiaIup40xpUub6U/wwRXoKBZw/O5BifX9OlqAjYnDyzM6+l+TAg==
+  dependencies:
+    etag "^1.8.1"
+
+koa-send@^5.0.0, koa-send@^5.0.1:
+  version "5.0.1"
+  resolved "https://registry.yarnpkg.com/koa-send/-/koa-send-5.0.1.tgz#39dceebfafb395d0d60beaffba3a70b4f543fe79"
+  integrity sha512-tmcyQ/wXXuxpDxyNXv5yNNkdAMdFRqwtegBXUaowiQzUKqJehttS0x2j0eOZDQAyloAth5w6wwBImnFzkUz3pQ==
+  dependencies:
+    debug "^4.1.1"
+    http-errors "^1.7.3"
+    resolve-path "^1.4.0"
+
+koa-static@^5.0.0:
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/koa-static/-/koa-static-5.0.0.tgz#5e92fc96b537ad5219f425319c95b64772776943"
+  integrity sha512-UqyYyH5YEXaJrf9S8E23GoJFQZXkBVJ9zYYMPGz919MSX1KuvAcycIuS0ci150HCoPf4XQVhQ84Qf8xRPWxFaQ==
+  dependencies:
+    debug "^3.1.0"
+    koa-send "^5.0.0"
+
+koa@^2.13.0:
+  version "2.13.4"
+  resolved "https://registry.yarnpkg.com/koa/-/koa-2.13.4.tgz#ee5b0cb39e0b8069c38d115139c774833d32462e"
+  integrity sha512-43zkIKubNbnrULWlHdN5h1g3SEKXOEzoAlRsHOTFpnlDu8JlAOZSMJBLULusuXRequboiwJcj5vtYXKB3k7+2g==
+  dependencies:
+    accepts "^1.3.5"
+    cache-content-type "^1.0.0"
+    content-disposition "~0.5.2"
+    content-type "^1.0.4"
+    cookies "~0.8.0"
+    debug "^4.3.2"
+    delegates "^1.0.0"
+    depd "^2.0.0"
+    destroy "^1.0.4"
+    encodeurl "^1.0.2"
+    escape-html "^1.0.3"
+    fresh "~0.5.2"
+    http-assert "^1.3.0"
+    http-errors "^1.6.3"
+    is-generator-function "^1.0.7"
+    koa-compose "^4.1.0"
+    koa-convert "^2.0.0"
+    on-finished "^2.3.0"
+    only "~0.0.2"
+    parseurl "^1.3.2"
+    statuses "^1.5.0"
+    type-is "^1.6.16"
+    vary "^1.1.2"
+
 lit-element@^3.2.0:
   version "3.2.0"
   resolved "https://registry.yarnpkg.com/lit-element/-/lit-element-3.2.0.tgz#9c981c55dfd9a8f124dc863edb62cc529d434db7"
@@ -44,6 +1077,13 @@
     "@lit/reactive-element" "^1.3.0"
     lit-html "^2.2.0"
 
+lit-html@^2.0.0, lit-html@^2.3.0:
+  version "2.3.1"
+  resolved "https://registry.yarnpkg.com/lit-html/-/lit-html-2.3.1.tgz#56f15104ea75c0a702904893e3409d0e89e2a2b9"
+  integrity sha512-FyKH6LTW6aBdkfNhNSHyZTnLgJSTe5hMk7HFtc/+DcN1w74C215q8B+Cfxc2OuIEpBNcEKxgF64qL8as30FDHA==
+  dependencies:
+    "@types/trusted-types" "^2.0.2"
+
 lit-html@^2.2.0:
   version "2.2.6"
   resolved "https://registry.yarnpkg.com/lit-html/-/lit-html-2.2.6.tgz#e70679605420a34c4f3cbd0c483b2fb1fff781df"
@@ -51,6 +1091,15 @@
   dependencies:
     "@types/trusted-types" "^2.0.2"
 
+lit@^2.0.0:
+  version "2.3.1"
+  resolved "https://registry.yarnpkg.com/lit/-/lit-2.3.1.tgz#2cf1c2042da1e44c7a7cc72dff2d72303fd26f48"
+  integrity sha512-TejktDR4mqG3qB32Y8Lm5Lye3c8SUehqz7qRsxe1PqGYL6me2Ef+jeQAEqh20BnnGncv4Yxy2njEIT0kzK1WCw==
+  dependencies:
+    "@lit/reactive-element" "^1.4.0"
+    lit-element "^3.2.0"
+    lit-html "^2.3.0"
+
 lit@^2.2.3:
   version "2.2.6"
   resolved "https://registry.yarnpkg.com/lit/-/lit-2.2.6.tgz#4ef223e88517c000b0c01baf2e3535e61a75a5b5"
@@ -59,3 +1108,391 @@
     "@lit/reactive-element" "^1.3.0"
     lit-element "^3.2.0"
     lit-html "^2.2.0"
+
+log-update@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/log-update/-/log-update-4.0.0.tgz#589ecd352471f2a1c0c570287543a64dfd20e0a1"
+  integrity sha512-9fkkDevMefjg0mmzWFBW8YkFP91OrizzkW3diF7CpG+S2EYdy4+TVfGwz1zeF8x7hCx1ovSPTOE9Ngib74qqUg==
+  dependencies:
+    ansi-escapes "^4.3.0"
+    cli-cursor "^3.1.0"
+    slice-ansi "^4.0.0"
+    wrap-ansi "^6.2.0"
+
+lru-cache@^6.0.0:
+  version "6.0.0"
+  resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-6.0.0.tgz#6d6fe6570ebd96aaf90fcad1dafa3b2566db3a94"
+  integrity sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==
+  dependencies:
+    yallist "^4.0.0"
+
+make-dir@^3.0.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-3.1.0.tgz#415e967046b3a7f1d185277d84aa58203726a13f"
+  integrity sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==
+  dependencies:
+    semver "^6.0.0"
+
+media-typer@0.3.0:
+  version "0.3.0"
+  resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748"
+  integrity sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==
+
+merge2@^1.3.0, merge2@^1.4.1:
+  version "1.4.1"
+  resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae"
+  integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==
+
+micromatch@^4.0.4:
+  version "4.0.5"
+  resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.5.tgz#bc8999a7cbbf77cdc89f132f6e467051b49090c6"
+  integrity sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==
+  dependencies:
+    braces "^3.0.2"
+    picomatch "^2.3.1"
+
+mime-db@1.52.0:
+  version "1.52.0"
+  resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70"
+  integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==
+
+mime-types@^2.1.18, mime-types@^2.1.27, mime-types@~2.1.24, mime-types@~2.1.34:
+  version "2.1.35"
+  resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a"
+  integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==
+  dependencies:
+    mime-db "1.52.0"
+
+mimic-fn@^2.1.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b"
+  integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==
+
+mkdirp@^1.0.4:
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e"
+  integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==
+
+ms@2.1.2:
+  version "2.1.2"
+  resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009"
+  integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==
+
+ms@^2.1.1:
+  version "2.1.3"
+  resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2"
+  integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==
+
+nanocolors@^0.2.1:
+  version "0.2.13"
+  resolved "https://registry.yarnpkg.com/nanocolors/-/nanocolors-0.2.13.tgz#dfd1ed0bfab05e9fe540eb6874525f0a1684099b"
+  integrity sha512-0n3mSAQLPpGLV9ORXT5+C/D4mwew7Ebws69Hx4E2sgz2ZA5+32Q80B9tL8PbL7XHnRDiAxH/pnrUJ9a4fkTNTA==
+
+nanoid@^3.1.25:
+  version "3.3.4"
+  resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.4.tgz#730b67e3cd09e2deacf03c027c81c9d9dbc5e8ab"
+  integrity sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==
+
+negotiator@0.6.3:
+  version "0.6.3"
+  resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.3.tgz#58e323a72fedc0d6f9cd4d31fe49f51479590ccd"
+  integrity sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==
+
+normalize-path@^3.0.0, normalize-path@~3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65"
+  integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==
+
+object-inspect@^1.9.0:
+  version "1.12.2"
+  resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.12.2.tgz#c0641f26394532f28ab8d796ab954e43c009a8ea"
+  integrity sha512-z+cPxW0QGUp0mcqcsgQyLVRDoXFQbXOwBaqyF7VIgI4TWNQsDHrBpUQslRmIfAoYWdYzs6UlKJtB2XJpTaNSpQ==
+
+on-finished@^2.3.0:
+  version "2.4.1"
+  resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.4.1.tgz#58c8c44116e54845ad57f14ab10b03533184ac3f"
+  integrity sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==
+  dependencies:
+    ee-first "1.1.1"
+
+onetime@^5.1.0:
+  version "5.1.2"
+  resolved "https://registry.yarnpkg.com/onetime/-/onetime-5.1.2.tgz#d0e96ebb56b07476df1dd9c4806e5237985ca45e"
+  integrity sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==
+  dependencies:
+    mimic-fn "^2.1.0"
+
+only@~0.0.2:
+  version "0.0.2"
+  resolved "https://registry.yarnpkg.com/only/-/only-0.0.2.tgz#2afde84d03e50b9a8edc444e30610a70295edfb4"
+  integrity sha512-Fvw+Jemq5fjjyWz6CpKx6w9s7xxqo3+JCyM0WXWeCSOboZ8ABkyvP8ID4CZuChA/wxSx+XSJmdOm8rGVyJ1hdQ==
+
+open@^8.0.2:
+  version "8.4.0"
+  resolved "https://registry.yarnpkg.com/open/-/open-8.4.0.tgz#345321ae18f8138f82565a910fdc6b39e8c244f8"
+  integrity sha512-XgFPPM+B28FtCCgSb9I+s9szOC1vZRSwgWsRUA5ylIxRTgKozqjOCrVOqGsYABPYK5qnfqClxZTFBa8PKt2v6Q==
+  dependencies:
+    define-lazy-prop "^2.0.0"
+    is-docker "^2.1.1"
+    is-wsl "^2.2.0"
+
+parse5@^6.0.1:
+  version "6.0.1"
+  resolved "https://registry.yarnpkg.com/parse5/-/parse5-6.0.1.tgz#e1a1c085c569b3dc08321184f19a39cc27f7c30b"
+  integrity sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==
+
+parseurl@^1.3.2:
+  version "1.3.3"
+  resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4"
+  integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==
+
+path-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==
+
+path-type@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b"
+  integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==
+
+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==
+
+qs@^6.5.2:
+  version "6.11.0"
+  resolved "https://registry.yarnpkg.com/qs/-/qs-6.11.0.tgz#fd0d963446f7a65e1367e01abd85429453f0c37a"
+  integrity sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==
+  dependencies:
+    side-channel "^1.0.4"
+
+queue-microtask@^1.2.2:
+  version "1.2.3"
+  resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243"
+  integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==
+
+raw-body@^2.3.3:
+  version "2.5.1"
+  resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.5.1.tgz#fe1b1628b181b700215e5fd42389f98b71392857"
+  integrity sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig==
+  dependencies:
+    bytes "3.1.2"
+    http-errors "2.0.0"
+    iconv-lite "0.4.24"
+    unpipe "1.0.0"
+
+readdirp@~3.6.0:
+  version "3.6.0"
+  resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.6.0.tgz#74a370bd857116e245b29cc97340cd431a02a6c7"
+  integrity sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==
+  dependencies:
+    picomatch "^2.2.1"
+
+resolve-path@^1.4.0:
+  version "1.4.0"
+  resolved "https://registry.yarnpkg.com/resolve-path/-/resolve-path-1.4.0.tgz#c4bda9f5efb2fce65247873ab36bb4d834fe16f7"
+  integrity sha512-i1xevIst/Qa+nA9olDxLWnLk8YZbi8R/7JPbCMcgyWaFR6bKWaexgJgEB5oc2PKMjYdrHynyz0NY+if+H98t1w==
+  dependencies:
+    http-errors "~1.6.2"
+    path-is-absolute "1.0.1"
+
+restore-cursor@^3.1.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-3.1.0.tgz#39f67c54b3a7a58cea5236d95cf0034239631f7e"
+  integrity sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==
+  dependencies:
+    onetime "^5.1.0"
+    signal-exit "^3.0.2"
+
+reusify@^1.0.4:
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.0.4.tgz#90da382b1e126efc02146e90845a88db12925d76"
+  integrity sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==
+
+run-parallel@^1.1.9:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/run-parallel/-/run-parallel-1.2.0.tgz#66d1368da7bdf921eb9d95bd1a9229e7f21a43ee"
+  integrity sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==
+  dependencies:
+    queue-microtask "^1.2.2"
+
+rxjs@^6.6.7:
+  version "6.6.7"
+  resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-6.6.7.tgz#90ac018acabf491bf65044235d5863c4dab804c9"
+  integrity sha512-hTdwr+7yYNIT5n4AMYp85KA6yw2Va0FLa3Rguvbpa4W3I5xynaBZo41cM3XM+4Q6fRMj3sBYIR1VAmZMXYJvRQ==
+  dependencies:
+    tslib "^1.9.0"
+
+safe-buffer@5.2.1:
+  version "5.2.1"
+  resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6"
+  integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==
+
+safe-buffer@~5.1.1:
+  version "5.1.2"
+  resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d"
+  integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==
+
+"safer-buffer@>= 2.1.2 < 3":
+  version "2.1.2"
+  resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a"
+  integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==
+
+semver@^6.0.0:
+  version "6.3.0"
+  resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d"
+  integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==
+
+setprototypeof@1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.1.0.tgz#d0bd85536887b6fe7c0d818cb962d9d91c54e656"
+  integrity sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ==
+
+setprototypeof@1.2.0:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.2.0.tgz#66c9a24a73f9fc28cbe66b09fed3d33dcaf1b424"
+  integrity sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==
+
+side-channel@^1.0.4:
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.4.tgz#efce5c8fdc104ee751b25c58d4290011fa5ea2cf"
+  integrity sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==
+  dependencies:
+    call-bind "^1.0.0"
+    get-intrinsic "^1.0.2"
+    object-inspect "^1.9.0"
+
+signal-exit@^3.0.2:
+  version "3.0.7"
+  resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.7.tgz#a9a1767f8af84155114eaabd73f99273c8f59ad9"
+  integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==
+
+slash@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634"
+  integrity sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==
+
+slice-ansi@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-4.0.0.tgz#500e8dd0fd55b05815086255b3195adf2a45fe6b"
+  integrity sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==
+  dependencies:
+    ansi-styles "^4.0.0"
+    astral-regex "^2.0.0"
+    is-fullwidth-code-point "^3.0.0"
+
+source-map@^0.7.3:
+  version "0.7.4"
+  resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.7.4.tgz#a9bbe705c9d8846f4e08ff6765acf0f1b0898656"
+  integrity sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==
+
+statuses@2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.1.tgz#55cb000ccf1d48728bd23c685a063998cf1a1b63"
+  integrity sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==
+
+"statuses@>= 1.4.0 < 2", "statuses@>= 1.5.0 < 2", statuses@^1.5.0:
+  version "1.5.0"
+  resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c"
+  integrity sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==
+
+string-width@^4.1.0:
+  version "4.2.3"
+  resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
+  integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
+  dependencies:
+    emoji-regex "^8.0.0"
+    is-fullwidth-code-point "^3.0.0"
+    strip-ansi "^6.0.1"
+
+strip-ansi@^6.0.0, strip-ansi@^6.0.1:
+  version "6.0.1"
+  resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
+  integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
+  dependencies:
+    ansi-regex "^5.0.1"
+
+supports-color@^5.3.0:
+  version "5.5.0"
+  resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f"
+  integrity sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==
+  dependencies:
+    has-flag "^3.0.0"
+
+supports-color@^7.1.0:
+  version "7.2.0"
+  resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da"
+  integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==
+  dependencies:
+    has-flag "^4.0.0"
+
+to-regex-range@^5.0.1:
+  version "5.0.1"
+  resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-5.0.1.tgz#1648c44aae7c8d988a326018ed72f5b4dd0392e4"
+  integrity sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==
+  dependencies:
+    is-number "^7.0.0"
+
+toidentifier@1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35"
+  integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==
+
+tslib@^1.9.0:
+  version "1.14.1"
+  resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00"
+  integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==
+
+tsscmp@1.0.6:
+  version "1.0.6"
+  resolved "https://registry.yarnpkg.com/tsscmp/-/tsscmp-1.0.6.tgz#85b99583ac3589ec4bfef825b5000aa911d605eb"
+  integrity sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==
+
+type-fest@^0.21.3:
+  version "0.21.3"
+  resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.21.3.tgz#d260a24b0198436e133fa26a524a6d65fa3b2e37"
+  integrity sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==
+
+type-is@^1.6.16:
+  version "1.6.18"
+  resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131"
+  integrity sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==
+  dependencies:
+    media-typer "0.3.0"
+    mime-types "~2.1.24"
+
+unpipe@1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec"
+  integrity sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==
+
+vary@^1.1.2:
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc"
+  integrity sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==
+
+wrap-ansi@^6.2.0:
+  version "6.2.0"
+  resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz#e9393ba07102e6c91a3b221478f0257cd2856e53"
+  integrity sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==
+  dependencies:
+    ansi-styles "^4.0.0"
+    string-width "^4.1.0"
+    strip-ansi "^6.0.0"
+
+ws@^7.4.2:
+  version "7.5.9"
+  resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.9.tgz#54fa7db29f4c7cec68b1ddd3a89de099942bb591"
+  integrity sha512-F+P9Jil7UiSKSkppIiD94dN07AwvFixvLIj1Og1Rl9GGMuNipJnV9JzjD6XuqmAeiswGvUmNLjr5cFuXwNS77Q==
+
+yallist@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72"
+  integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==
+
+ylru@^1.2.0:
+  version "1.3.2"
+  resolved "https://registry.yarnpkg.com/ylru/-/ylru-1.3.2.tgz#0de48017473275a4cbdfc83a1eaf67c01af8a785"
+  integrity sha512-RXRJzMiK6U2ye0BlGGZnmpwJDPgakn6aNQ0A7gHRbD4I0uvK4TW6UqkK1V0pp9jskjJBAXd3dRrbzWkqJ+6cxA==
diff --git a/polygerrit-ui/BUILD b/polygerrit-ui/BUILD
index 62d1d92..049f1d3 100644
--- a/polygerrit-ui/BUILD
+++ b/polygerrit-ui/BUILD
@@ -1,12 +1,12 @@
-load("@io_bazel_rules_go//go:def.bzl", "go_binary")
 load("//tools/bzl:genrule2.bzl", "genrule2")
-load("//tools/bzl:js.bzl", "karma_test")
+load("@build_bazel_rules_nodejs//:index.bzl", "nodejs_test")
 
 package(default_visibility = ["//visibility:public"])
 
 genrule2(
     name = "fonts",
     srcs = [
+        "//lib/fonts:material-icons",
         "//lib/fonts:robotofonts",
     ],
     outs = ["fonts.zip"],
@@ -20,26 +20,38 @@
     output_to_bindir = 1,
 )
 
-go_binary(
-    name = "devserver",
-    srcs = ["server.go"],
+filegroup(
+    name = "web-test-runner_config-sources",
+    srcs = glob([
+        "package.json",
+        "web-test-runner.config.mjs",
+    ]),
+)
+
+nodejs_test(
+    name = "web-test-runner",
+    size = "large",
+    chdir = package_name(),
     data = [
-        ":fonts.zip",
+        ":web-test-runner_config-sources",
+        "//polygerrit-ui/app:web-test-runner_app-sources",
         "@ui_dev_npm//:node_modules",
         "@ui_npm//:node_modules",
     ],
-    deps = [
-        "@org_golang_x_tools//godoc/vfs/httpfs:go_default_library",
-        "@org_golang_x_tools//godoc/vfs/zipfs:go_default_library",
+    entry_point = "@ui_dev_npm//:node_modules/@web/test-runner/dist/bin.js",
+    tags = [
+        "local",
+        "manual",
     ],
 )
 
+# This is a dependency for karma_test rule in js.bzl that is only used by
+# plugins.
 sh_binary(
     name = "karma_bin",
     srcs = ["@ui_dev_npm//:node_modules/karma/bin/karma"],
     data = [
         "@ui_dev_npm//@open-wc/karma-esm",
-        "@ui_dev_npm//chai",
         "@ui_dev_npm//karma-chrome-launcher",
         "@ui_dev_npm//karma-mocha",
         "@ui_dev_npm//karma-mocha-reporter",
@@ -48,8 +60,5 @@
     ],
 )
 
-karma_test(
-    name = "karma_test",
-    srcs = ["karma_test.sh"],
-    data = ["//polygerrit-ui/app:test-srcs-fg"],
-)
+# This is used by plugins.
+exports_files(["karma.conf.js"])
diff --git a/polygerrit-ui/README.md b/polygerrit-ui/README.md
index cd88f52..ac8712b 100644
--- a/polygerrit-ui/README.md
+++ b/polygerrit-ui/README.md
@@ -28,24 +28,7 @@
 
 ## Installing [Node.js](https://nodejs.org/en/download/) and npm packages
 
-**Note**: Switch between an old branch with bower_components and a new branch with ui-npm
-packages (or vice versa) can lead to some build errors. To avoid such errors clean up the build
-repository:
-```sh
-rm -rf node_modules/ \
-    polygerrit-ui/node_modules/ \
-    polygerrit-ui/app/node_modules \
-    tools/node_tools/node_modules
-
-bazel clean
-```
-
-If it doesn't help also try to run
-```sh
-bazel clean --expunge
-```
-
-The minimum nodejs version supported is 8.x+
+The minimum nodejs version supported is 10.x+.
 
 ```sh
 # Debian experimental
@@ -53,7 +36,7 @@
 sudo apt-get install npm
 
 # OS X with Homebrew
-brew install node
+brew install node@16
 brew install npm
 ```
 
@@ -66,9 +49,12 @@
 
 We have several bazel commands to install packages we may need for FE development.
 
-For first time users to get the local server up, `npm start` should be enough and will take care of all of them for you.
+For first time users to get the local server up, `bazel build gerrit` should be enough and will take care of all of them for you.
 
 ```sh
+# Install yarn package manager
+npm install -g yarn
+
 # Install packages from root-level packages.json
 bazel fetch @npm//:node_modules
 
@@ -94,8 +80,8 @@
 
 ## Setup typescript support in the IDE
 
-Modern IDE should automatically handle typescript settings from the 
-`pollygerrit-ui/app/tsconfig.json` files. IDE places compiled files in the
+Modern IDE should automatically handle typescript settings from the
+`polygerrit-ui/app/tsconfig.json` files. IDE places compiled files in the
 `.ts-out/pg` directory at the root of gerrit workspace and you can configure IDE
 to exclude the whole .ts-out directory. To do it in the IntelliJ IDEA click on
 this directory and select "Mark Directory As > Excluded" in the context menu.
@@ -108,39 +94,22 @@
 
 ## Serving files locally
 
-#### Go server
+#### Web Dev Server
 
-To test the local Polymer frontend against production data or a local test site execute:
+To test the local frontend against production data or a local test site execute:
 
 ```sh
-./polygerrit-ui/run-server.sh
-
-// or
-npm run start
+yarn start
 ```
 
-These commands start the [simple hand-written Go webserver](https://gerrit.googlesource.com/gerrit/+/master/polygerrit-ui/server.go).
-Mostly it just switches between serving files locally and proxying the real
-server based on the file name. It also does some basic response rewriting, e.g.
-it patches the `config/server/info` response with plugin information provided on
-the command line:
-
-```sh
-./polygerrit-ui/run-server.sh --plugins=plugins/my_plugin/static/my_plugin.js
-```
+This command starts the [Web Dev Server](https://modern-web.dev/docs/dev-server/overview/).
+To inject plugins or other files, we use the [Gerrit FE Dev Helper](https://chrome.google.com/webstore/detail/gerrit-fe-dev-helper/jimgomcnodkialnpmienbomamgomglkd) Chrome extension.
 
 If any issues occured, please refer to the Troubleshooting section at the bottom or contact the team!
 
 ## Running locally against production data
 
-### Local website
-
-Start [Go server](#go-server) and then visit http://localhost:8081
-
-The biggest draw back of this method is that you cannot log in, so cannot test
-scenarios that require it.
-
-#### Chrome extension: Gerrit FE Dev Helper
+### Chrome extension: Gerrit FE Dev Helper
 
 To be able to bypass the auth and also help improve the productivity of Gerrit FE developers,
 we created this chrome extension: [Gerrit FE Dev Helper](https://chrome.google.com/webstore/detail/gerrit-fe-dev-helper/jimgomcnodkialnpmienbomamgomglkd).
@@ -163,7 +132,7 @@
 [this command](https://gerrit-review.googlesource.com/Documentation/dev-readme.html#run_daemon).
 
 If you want to serve the Polymer frontend directly from the sources in `polygerrit_ui/app/` instead of from the war:
-1. Start [Go server](#go-server)
+1. Start [Web Dev Server](#web-dev-server)
 2. Add the `--dev-cdn` option:
 
 ```sh
@@ -182,90 +151,33 @@
 For daily development you typically only want to run and debug individual tests.
 There are several ways to run tests.
 
-* Run all tests in headless mode (exactly like CI does):
+* Run all tests:
 ```sh
-npm run test
+yarn test
 ```
-This command uses bazel rules for running frontend tests. Bazel fetches
-all nessecary dependencies and runs all required rules.
 
-* Run all tests in debug mode (the command opens Chrome browser with
-the default Karma page; you should click the "Debug" button to start testing):
+* Run all tests under bazel:
 ```sh
-# The following command doesn't compile code before tests
-npm run test:debug
+./polygerrit-ui/app/run_test.sh
 ```
 
 * Run a single test file:
 ```
-# Headless mode (doesn't compile code before run)
-npm run test:single async-foreach-behavior_test.js
-
-# Debug mode (doesn't compile code before run)
-npm run test:debug async-foreach-behavior_test.js
+yarn test:single "**/async-foreach-behavior_test.js"
 ```
 
-When converting a test file to typescript, the command for running tests is
-still using the .js suffix and not the new .ts suffix.
-
-Commands `test:debug` and `test:single` assumes that compiled code is located
-in the `./ts-out/polygerrit-ui/app` directory. It's up to you how to achieve it.
-For example, the following options are possible:
-* You can configure IDE for recompiling source code on changes
-* You can use `compile:local` command for running compiler once and
-`compile:watch` for running compiler in watch mode (`compile:...` places
-compile code exactly in the `./ts-out/polygerrit-ui/app` directory)
-
+Compiling code:
 ```sh
-# Compile frontend once and run tests from a file:
-npm run compile:local && npm run test:single async-foreach-behavior_test.js
+# Compile frontend once to check for type errors:
+yarn compile:local
 
 # Watch mode:
 ## Terminal 1:
-npm run compile:watch
-## Terminal 2:
-npm run test:debug async-foreach-behavior_test.js
+yarn compile:watch
+## Terminal 2, test & watch a file for example:
+yarn test:single "**/async-foreach-behavior_test.js"
 ```
 
-* You can run tests in IDE. :
-  - [IntelliJ: running unit tests on Karma](https://www.jetbrains.com/help/idea/running-unit-tests-on-karma.html#ws_karma_running)
-  - You should configure IDE to compile typescript before running tests.
-
-**NOTE**: Bazel plugin for IntelliJ has a bug - it recompiles typescript
-project only if .ts and/or .d.ts files have been changed. If only .js files
-were changed, the plugin doesn't run compiler. As a workaround, setup
-"Run npm script 'compile:local" action instead of the "Compile Typescript" in
-the "Before launch" section for IntelliJ. This is a temporary problem until
-typescript migration is complete.
-
-## Running Templates Test
-The templates test validates polymer templates. The test convert polymer
-templates into a plain typescript code and then run TS compiler. The test fails
-if TS compiler reports errors; in this case you will see TS errors in
-the log/output. Gerrit-CI automatically runs templates test.
-
-**Note**: Files defined in `ignore_templates_list` (`polygerrit-ui/app/BUILD`)
-are excluded from code generation and checking. If you don't know how to fix
-a problem, you can add a problematic template in the list.
-
-* To run test locally, use npm command:
-``` sh
-npm run polytest
-```
-
-* Often, the output from the previous command is not clear (cryptic TS errors).
-In this case, run the command
-```sh
-npm run polytest:dev
-```
-This command (re)creates the `polygerrit-ui/app/tmpl_out` directory and put
-generated files into it. For each polygerrit .ts file there is a generated file
-in the `tmpl_out` directory. If an original file doesn't contain a polymer
-template, the generated file is empty.
-
-You can open a problematic file in IDE and fix the problem. Ensure, that IDE
-uses `polygerrit-ui/app/tsconfig.json` as a project (usually, this is default).
-
 ### Generated file overview
 
 A generated file starts with imports followed by a static content with
@@ -286,7 +198,7 @@
 additional functions are added. For example, `<element x=[[y.a]]>` converts into
 `el.x = y!.a` if y is a simple type. However, if y has a union type, like - `y:A|B`,
 then the generated code looks like `el.x=__f(y)!.a` (`y!.a` may result in a TS error
-if `a` is defined only in one type of a union). 
+if `a` is defined only in one type of a union).
 
 ## Style guide
 
@@ -313,7 +225,7 @@
 * To run ESLint on the whole app, less some dependency code:
 
 ```sh
-npm run eslint
+yarn eslint
 ```
 
 * To run ESLint on just the subdirectory you modified:
@@ -328,21 +240,6 @@
 git diff --name-only HEAD | xargs node_modules/eslint/bin/eslint.js --ext .html,.js
 ```
 
-We also use the `polylint` tool to lint use of Polymer. To install polylint,
-execute the following command.
-
-To run polylint, execute the following command.
-
-```sh
-bazel test //polygerrit-ui/app:polylint_test
-```
-
-or
-
-```sh
-npm run polylint
-```
-
 ## Migrating tests to Typescript
 
 You can use the following steps for migrating tests to Typescript:
@@ -352,7 +249,7 @@
    ```
    // Before:
    import ... from 'x/y/z.js`
- 
+
    // After
    import .. from 'x/y/z'
    ```
@@ -421,16 +318,16 @@
 ...
 // The _robotCommentThreads declared as _robotCommentThreads?: CommentThread
 assert.equal(element._robotCommentThreads.length, 2);
-  
+
 // Fix with non-null assertion operator:
 const rows = element
   .shadowRoot!.querySelector('table')! // '!' after shadowRoot and querySelector
   .querySelectorAll('tbody tr');
 
-assert.equal(element._robotCommentThreads!.length, 2); 
+assert.equal(element._robotCommentThreads!.length, 2);
 
 // Fix with nullish coalescing operator:
- assert.equal(element._robotCommentThreads?.length, 2); 
+ assert.equal(element._robotCommentThreads?.length, 2);
 ```
 Usually the fix with `!` is preferable, because it gives more clear error
 when an intermediate property is `null/undefined`. If the _robotComments is
@@ -527,7 +424,7 @@
 
 * If a test imports a library from `polygerrit_ui/node_modules` - update
 `paths` in `polygerrit-ui/app/tsconfig_bazel_test.json`.
- 
+
 ## Contributing
 
 Our users report bugs / feature requests related to the UI through [Monorail Issues - PolyGerrit](https://bugs.chromium.org/p/gerrit/issues/list?q=component%3APolyGerrit).
@@ -556,7 +453,7 @@
 git submodule update --init --recursive
 
 // reset the workspace (please save your local changes before running this command)
-npm run clean
+yarn clean
 
 // install all dependencies and start the server
 npm start
diff --git a/polygerrit-ui/app/.eslint-ts-resolver.js b/polygerrit-ui/app/.eslint-ts-resolver.js
index dc578f9..e4ba115 100644
--- a/polygerrit-ui/app/.eslint-ts-resolver.js
+++ b/polygerrit-ui/app/.eslint-ts-resolver.js
@@ -1,18 +1,7 @@
 /**
  * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 
 /**
diff --git a/polygerrit-ui/app/.eslintignore b/polygerrit-ui/app/.eslintignore
index bb30f23..087a049 100644
--- a/polygerrit-ui/app/.eslintignore
+++ b/polygerrit-ui/app/.eslintignore
@@ -2,4 +2,3 @@
 **/rollup.config.js
 node_modules_licenses
 !.eslintrc-bazel.js
-tmpl_out
diff --git a/polygerrit-ui/app/.eslintrc-bazel.js b/polygerrit-ui/app/.eslintrc-bazel.js
index 9a51242..fa6c274 100644
--- a/polygerrit-ui/app/.eslintrc-bazel.js
+++ b/polygerrit-ui/app/.eslintrc-bazel.js
@@ -1,18 +1,7 @@
 /**
  * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 
 // This file has a special settings for bazel.
diff --git a/polygerrit-ui/app/.eslintrc.js b/polygerrit-ui/app/.eslintrc.js
index 8eaff5c..c519465 100644
--- a/polygerrit-ui/app/.eslintrc.js
+++ b/polygerrit-ui/app/.eslintrc.js
@@ -1,18 +1,7 @@
 /**
  * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 
 // Do not add any bazel-specific properties in this file to keep it clean.
@@ -238,9 +227,13 @@
     'import/no-unused-modules': 2,
     // https://github.com/benmosher/eslint-plugin-import/blob/master/docs/rules/no-default-export.md
     'import/no-default-export': 2,
-    // Prevents certain identifiers being used.
-    // Prefer flush() over flushAsynchronousOperations().
-    'id-blacklist': ['error', 'flushAsynchronousOperations'],
+    'regex/invalid': [
+      'error', [{
+        // eslint-disable-next-line regex/invalid
+        regex: 'Licensed under',
+        message: 'Please use SPDX license headers.',
+      }],
+    ],
   },
 
   // List of allowed globals in all files
@@ -273,9 +266,6 @@
         'jsdoc/require-param-type': 2,
         // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-require-returns-type
         'jsdoc/require-returns-type': 2,
-        // The rule is required for .js files only, because typescript compiler
-        // always checks import.
-        'import/no-unresolved': 2,
         'import/named': 2,
       },
       globals: {
@@ -298,6 +288,19 @@
       files: ['**/*.ts'],
       extends: [require.resolve('gts/.eslintrc.json')],
       rules: {
+        'regex/invalid': [
+          'error', [{
+            regex: '\'lit/decorators\'',
+            message: 'use \'lit/decorators.js\' instead',
+            replacement: '\'lit/decorators.js\'',
+          }, {
+            regex: '\'lit/directives/([^.\']*)\'',
+            message: 'use \'lit/directives/foo.js\' instead',
+            replacement: {
+              function: 'return "\'lit/directives/" + $[1] + ".js\'"',
+            },
+          }],
+        ],
         'no-restricted-imports': ['error', {
           name: 'lit-html/static',
           message: 'Use lit instead',
@@ -314,6 +317,8 @@
         // The following rules is required to match internal google rules
         '@typescript-eslint/restrict-plus-operands': 'error',
         '@typescript-eslint/no-unnecessary-type-assertion': 'error',
+        'require-await': 'off',
+        '@typescript-eslint/require-await': 'error',
         '@typescript-eslint/no-confusing-void-expression': [
           'error',
           {ignoreArrowShorthand: true},
@@ -326,7 +331,7 @@
         'node/no-unsupported-features/node-builtins': 'off',
         // Disable no-invalid-this for ts files, because it incorrectly reports
         // errors in some cases (see https://github.com/typescript-eslint/typescript-eslint/issues/491)
-        // At the same time, we are using typescript in a strict mode and
+        // At the same tigit llme, we are using typescript in a strict mode and
         // it catches almost all errors related to invalid usage of this.
         'no-invalid-this': 'off',
 
@@ -348,6 +353,7 @@
       ],
       rules: {
         '@typescript-eslint/no-explicit-any': 'off',
+        '@typescript-eslint/require-await': 'off',
       },
     },
     {
@@ -367,13 +373,6 @@
         // Global variables from 3rd party test libraries/frameworks.
         // You can extend this list if you want to use other global
         // variables from these libraries and import is not possible
-        MockInteractions: 'readonly',
-        _: 'readonly',
-        axs: 'readonly',
-        a11ySuite: 'readonly',
-        assert: 'readonly',
-        expect: 'readonly',
-        fixture: 'readonly',
         flush: 'readonly',
         setup: 'readonly',
         sinon: 'readonly',
@@ -383,8 +382,6 @@
         suiteTeardown: 'readonly',
         teardown: 'readonly',
         test: 'readonly',
-        fixtureFromElement: 'readonly',
-        fixtureFromTemplate: 'readonly',
       },
     },
     {
diff --git a/polygerrit-ui/app/.gitignore b/polygerrit-ui/app/.gitignore
index c45bac3..38a8371 100644
--- a/polygerrit-ui/app/.gitignore
+++ b/polygerrit-ui/app/.gitignore
@@ -1,4 +1,3 @@
 /node_modules/
 /package-lock.json
 /plugins/
-/tmpl_out/
diff --git a/polygerrit-ui/app/.prettierrc.js b/polygerrit-ui/app/.prettierrc.js
index fbb87c6..8f353bf 100644
--- a/polygerrit-ui/app/.prettierrc.js
+++ b/polygerrit-ui/app/.prettierrc.js
@@ -1,18 +1,7 @@
 /**
  * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 
 module.exports = {
diff --git a/polygerrit-ui/app/BUILD b/polygerrit-ui/app/BUILD
index a2626c2..2807a6d 100644
--- a/polygerrit-ui/app/BUILD
+++ b/polygerrit-ui/app/BUILD
@@ -1,7 +1,6 @@
 load(":rules.bzl", "polygerrit_bundle")
 load("//tools/js:eslint.bzl", "eslint")
-load("//tools/js:template_checker.bzl", "transform_polymer_templates")
-load("@build_bazel_rules_nodejs//:index.bzl", "nodejs_binary", "nodejs_test", "npm_package_bin")
+load("@build_bazel_rules_nodejs//:index.bzl", "nodejs_test")
 load("@npm//@bazel/typescript:index.bzl", "ts_config", "ts_project")
 
 package(default_visibility = ["//visibility:public"])
@@ -73,15 +72,11 @@
         exclude = [
             "node_modules/**",
             "node_modules_licenses/**",
-            "tmpl_out/**",  # This directory is created by template checker in dev-mode
             "rollup.config.js",
         ],
     ),
     allow_js = True,
     incremental = True,
-    # The same outdir also appears in the following files:
-    # wct_test.sh
-    # karma.conf.js
     out_dir = "_pg_with_tests_out",
     tsc = "//tools/node_tools:tsc-bin",
     tsconfig = ":ts_config_bazel_test",
@@ -91,88 +86,6 @@
     ],
 )
 
-# Template checker reports problems in the following files. Ignore the files,
-# so template tests pass.
-# TODO: fix problems reported by template checker in these files.
-ignore_templates_list = [
-    "elements/admin/gr-permission/gr-permission_html.ts",
-    "elements/admin/gr-repo-access/gr-repo-access_html.ts",
-    "elements/admin/gr-rule-editor/gr-rule-editor_html.ts",
-    "elements/change-list/gr-dashboard-view/gr-dashboard-view_html.ts",
-    "elements/change/gr-change-actions/gr-change-actions_html.ts",
-    "elements/change/gr-change-metadata/gr-change-metadata_html.ts",
-    "elements/change/gr-change-requirements/gr-change-requirements_html.ts",
-    "elements/change/gr-change-view/gr-change-view_html.ts",
-    "elements/change/gr-file-list-header/gr-file-list-header_html.ts",
-    "elements/change/gr-file-list/gr-file-list_html.ts",
-    "elements/change/gr-label-score-row/gr-label-score-row_html.ts",
-    "elements/change/gr-message/gr-message_html.ts",
-    "elements/change/gr-messages-list/gr-messages-list_html.ts",
-    "elements/change/gr-reply-dialog/gr-reply-dialog_html.ts",
-    "elements/change/gr-reviewer-list/gr-reviewer-list_html.ts",
-    "elements/change/gr-thread-list/gr-thread-list_html.ts",
-    "elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog_html.ts",
-    "elements/gr-app-element_html.ts",
-    "elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_html.ts",
-    "elements/shared/gr-account-list/gr-account-list_html.ts",
-    "embed/diff/gr-diff-builder/gr-diff-builder-element_html.ts",
-    "embed/diff/gr-diff-host/gr-diff-host_html.ts",
-    "embed/diff/gr-diff-view/gr-diff-view_html.ts",
-    "embed/diff/gr-diff/gr-diff_html.ts",
-    "models/dependency.ts",
-]
-
-sources_for_template_checking = glob(
-    [src_dir + "/**/*" + ext for src_dir in src_dirs for ext in [
-        ".ts",
-    ]],
-    exclude = [
-        "**/*_test.ts",
-    ] + ignore_templates_list,
-)
-
-# Transform templates into a .ts files.
-templates_srcs = transform_polymer_templates(
-    name = "template_test",
-    srcs = sources_for_template_checking,
-    out_tsconfig = "tsconfig_template_test.json",
-    tsconfig = "tsconfig_bazel.json",
-    deps = [
-        "tsconfig.json",
-        "tsconfig_bazel.json",
-        "@ui_npm//:node_modules",
-    ],
-)
-
-# After templates are converted into a typescript code, the TS compiler should check that the
-# converted code doesn't have the error (i.e. templates don't have problems).
-# The input to the compiler is: the converted (i.e. autogenerated) code + original polygerrit code;
-# the output (i.e. js code) is not needed (we only care wheather the code has error or not).
-# The existing ts_project rule can't compile a mix of a generated and a non-generated code, so it
-# can't be used for the purpose of template checking.
-# Because the output of TS compiler is not needed, the simplest workaround is to run typescript
-# compiler from command line using the sh_test rule. The compiler exits with non-zero return code if
-# errors found and sh_test fails.
-sh_test(
-    name = "polylint_test",
-    srcs = [":compile_generated_templates.sh"],
-    args = [
-        "$(location //tools/node_tools:tsc-bin)",
-        "$(location tsconfig_template_test.json)",
-    ],
-    data = [
-        "tsconfig.json",
-        "tsconfig_bazel.json",
-        "tsconfig_template_test.json",
-        "//tools/node_tools:tsc-bin",
-        "@ui_npm//:node_modules",
-    ] + templates_srcs + sources_for_template_checking,
-    tags = [
-        "local",
-        "manual",
-    ],
-)
-
 polygerrit_bundle(
     name = "polygerrit_ui",
     srcs = [":compile_pg"],
@@ -293,3 +206,16 @@
         "--rules.no-unknown-attribute error",
     ],
 )
+
+# app code including tests and tsconfig.json
+filegroup(
+    name = "web-test-runner_app-sources",
+    srcs = glob(
+        [
+            "**/*.ts",
+            "**/*.js",
+            "**/tsconfig.json",
+        ],
+        exclude = ["node_modules/**/*"],
+    ),
+)
diff --git a/polygerrit-ui/app/api/admin.ts b/polygerrit-ui/app/api/admin.ts
index 0606153..823f3dd 100644
--- a/polygerrit-ui/app/api/admin.ts
+++ b/polygerrit-ui/app/api/admin.ts
@@ -1,18 +1,7 @@
 /**
  * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 
 /** Interface for menu link */
diff --git a/polygerrit-ui/app/api/annotation.ts b/polygerrit-ui/app/api/annotation.ts
index 5922e5e..c670c58 100644
--- a/polygerrit-ui/app/api/annotation.ts
+++ b/polygerrit-ui/app/api/annotation.ts
@@ -1,18 +1,7 @@
 /**
  * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import {CoverageRange, Side} from './diff';
 import {ChangeInfo} from './rest-api';
diff --git a/polygerrit-ui/app/api/attribute-helper.ts b/polygerrit-ui/app/api/attribute-helper.ts
index d813beb..56d25d4 100644
--- a/polygerrit-ui/app/api/attribute-helper.ts
+++ b/polygerrit-ui/app/api/attribute-helper.ts
@@ -1,18 +1,7 @@
 /**
  * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 
 export declare interface AttributeHelperPluginApi {
diff --git a/polygerrit-ui/app/api/change-actions.ts b/polygerrit-ui/app/api/change-actions.ts
index e3143b8..721df03 100644
--- a/polygerrit-ui/app/api/change-actions.ts
+++ b/polygerrit-ui/app/api/change-actions.ts
@@ -1,18 +1,7 @@
 /**
  * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import {HttpMethod} from './rest';
 
diff --git a/polygerrit-ui/app/api/change-reply.ts b/polygerrit-ui/app/api/change-reply.ts
index 3d652db..31a9179 100644
--- a/polygerrit-ui/app/api/change-reply.ts
+++ b/polygerrit-ui/app/api/change-reply.ts
@@ -1,18 +1,7 @@
 /**
  * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import {ChangeInfo} from './rest-api';
 
diff --git a/polygerrit-ui/app/api/checks.ts b/polygerrit-ui/app/api/checks.ts
index aa0b05d..b05e70a 100644
--- a/polygerrit-ui/app/api/checks.ts
+++ b/polygerrit-ui/app/api/checks.ts
@@ -1,18 +1,7 @@
 /**
  * @license
- * Copyright (C) 2020 The Android Open Source Settings
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import {CommentRange} from './core';
 import {ChangeInfo} from './rest-api';
@@ -441,6 +430,35 @@
    * Make blocking, Downgrade severity.
    */
   actions?: Action[];
+
+  /**
+   * Optionally you can provide fixes that would solve the issue reported. The
+   * user will then see a "SHOW FIX" button for previewing the fix in a dialog,
+   * whichs allows the user to apply the fix. That will create a new EDIT
+   * patchset or use the exiting EDIT patchset, so the user can also apply fixes
+   * from multiple check results.
+   *
+   * Normally, you would only provide one fix, but you can also provide multiple
+   * different options to the user to choose from. Each fix may contain one or
+   * more replacements, each being a modification of one file. These files do
+   * not have to be part of the change yet.
+   */
+  fixes?: Fix[];
+}
+
+export declare interface Fix {
+  description?: string;
+  replacements: Replacement[];
+}
+
+export declare interface Replacement {
+  /**
+   * For example `polygerrit-ui/app/package.json`.
+   * `/COMMIT_MSG` is not supported yet.
+   */
+  path: string;
+  range: CommentRange;
+  replacement: string;
 }
 
 export enum Category {
diff --git a/polygerrit-ui/app/api/core.ts b/polygerrit-ui/app/api/core.ts
index af7fc40..c44edfb 100644
--- a/polygerrit-ui/app/api/core.ts
+++ b/polygerrit-ui/app/api/core.ts
@@ -4,19 +4,8 @@
  * Core types are types used in many places in Gerrit, such as the Side enum.
  *
  * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 
 /**
diff --git a/polygerrit-ui/app/api/diff.ts b/polygerrit-ui/app/api/diff.ts
index 08e2e66..683638e 100644
--- a/polygerrit-ui/app/api/diff.ts
+++ b/polygerrit-ui/app/api/diff.ts
@@ -5,19 +5,8 @@
  * which are used as inputs to gr-diff.
  *
  * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 
 import {CommentRange, CursorMoveResult} from './core';
@@ -38,10 +27,16 @@
  * If the weblinks-only parameter is specified, only the web_links field is set.
  */
 export declare interface DiffInfo {
-  /** Meta information about the file on side A as a DiffFileMetaInfo entity. */
-  meta_a: DiffFileMetaInfo;
-  /** Meta information about the file on side B as a DiffFileMetaInfo entity. */
-  meta_b: DiffFileMetaInfo;
+  /**
+   * Meta information about the file on side A as a DiffFileMetaInfo entity.
+   * Not set when change_type is ADDED.
+   */
+  meta_a?: DiffFileMetaInfo;
+  /**
+   * Meta information about the file on side B as a DiffFileMetaInfo entity.
+   * Not set when change_type is DELETED.
+   */
+  meta_b?: DiffFileMetaInfo;
   /** The type of change (ADDED, MODIFIED, DELETED, RENAMED COPIED, REWRITE). */
   change_type: ChangeType;
   /** Intraline status (OK, ERROR, TIMEOUT). */
@@ -167,7 +162,7 @@
    * Indicates the range (line numbers) on the other side of the comparison
    * where the code related to the current chunk came from/went to.
    */
-  range: {
+  range?: {
     start: number;
     end: number;
   };
@@ -334,6 +329,11 @@
   lineNum: LineNumber;
 }
 
+// TODO: Currently unused and not fired.
+export declare interface RenderProgressEventDetail {
+  linesRendered: number;
+}
+
 export declare interface DisplayLine {
   side: Side;
   lineNum: LineNumber;
diff --git a/polygerrit-ui/app/api/embed.ts b/polygerrit-ui/app/api/embed.ts
index 520aeec..de2e1bf 100644
--- a/polygerrit-ui/app/api/embed.ts
+++ b/polygerrit-ui/app/api/embed.ts
@@ -5,19 +5,8 @@
  * bundles, which cannot directly import the classes from their modules.
  *
  * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 
 import {
@@ -34,7 +23,7 @@
       GrDiffCursor: {new (): GrDiffCursor};
       TokenHighlightLayer: {
         new (
-          container?: HTMLElement,
+          container: HTMLElement,
           listener?: TokenHighlightListener
         ): DiffLayer;
       };
diff --git a/polygerrit-ui/app/api/event-helper.ts b/polygerrit-ui/app/api/event-helper.ts
index 5dc15dc..5aee59e 100644
--- a/polygerrit-ui/app/api/event-helper.ts
+++ b/polygerrit-ui/app/api/event-helper.ts
@@ -1,18 +1,7 @@
 /**
  * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 export type UnsubscribeCallback = () => void;
 
diff --git a/polygerrit-ui/app/api/gerrit.ts b/polygerrit-ui/app/api/gerrit.ts
index 2091eea..a5f7731 100644
--- a/polygerrit-ui/app/api/gerrit.ts
+++ b/polygerrit-ui/app/api/gerrit.ts
@@ -1,18 +1,7 @@
 /**
  * @license
- * Copyright (C) 2021 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import {PluginApi} from './plugin';
 import {Styles} from './styles';
diff --git a/polygerrit-ui/app/api/hook.ts b/polygerrit-ui/app/api/hook.ts
index e75d83a..c511eb2 100644
--- a/polygerrit-ui/app/api/hook.ts
+++ b/polygerrit-ui/app/api/hook.ts
@@ -1,18 +1,7 @@
 /**
  * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import {ChangeInfo, ConfigInfo, RevisionInfo} from './rest-api';
 import {PluginApi} from './plugin';
diff --git a/polygerrit-ui/app/api/package.json b/polygerrit-ui/app/api/package.json
index ac1e0c8..79c8bb6 100644
--- a/polygerrit-ui/app/api/package.json
+++ b/polygerrit-ui/app/api/package.json
@@ -1,6 +1,6 @@
 {
   "name": "@gerritcodereview/typescript-api",
-  "version": "3.4.5",
+  "version": "3.7.0",
   "description": "Gerrit Code Review - TypeScript API",
   "homepage": "https://www.gerritcodereview.com/",
   "browser": true,
diff --git a/polygerrit-ui/app/api/plugin.ts b/polygerrit-ui/app/api/plugin.ts
index b6fa7ee..b9c065f 100644
--- a/polygerrit-ui/app/api/plugin.ts
+++ b/polygerrit-ui/app/api/plugin.ts
@@ -1,18 +1,7 @@
 /**
  * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import {AdminPluginApi} from './admin';
 import {AnnotationPluginApi} from './annotation';
diff --git a/polygerrit-ui/app/api/popup.ts b/polygerrit-ui/app/api/popup.ts
index d265ee6..d9a9e3c 100644
--- a/polygerrit-ui/app/api/popup.ts
+++ b/polygerrit-ui/app/api/popup.ts
@@ -1,18 +1,7 @@
 /**
  * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 
 export declare interface PopupPluginApi {
diff --git a/polygerrit-ui/app/api/reporting.ts b/polygerrit-ui/app/api/reporting.ts
index 40474e1..59c5cb8 100644
--- a/polygerrit-ui/app/api/reporting.ts
+++ b/polygerrit-ui/app/api/reporting.ts
@@ -1,18 +1,7 @@
 /**
  * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 
 // eslint-disable-next-line @typescript-eslint/no-explicit-any
diff --git a/polygerrit-ui/app/api/rest-api.ts b/polygerrit-ui/app/api/rest-api.ts
index 685151b..3c06eb0 100644
--- a/polygerrit-ui/app/api/rest-api.ts
+++ b/polygerrit-ui/app/api/rest-api.ts
@@ -1,18 +1,7 @@
 /**
  * @license
- * Copyright (C) 2021 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 
 /**
@@ -99,8 +88,9 @@
   RENAMED = 'R',
   COPIED = 'C',
   REWRITTEN = 'W',
-  // Modifed = 'M', // but API not set it if the file was modified
+  MODIFIED = 'M', // Not returned by BE, M is the default
   UNMODIFIED = 'U', // Not returned by BE, but added by UI for certain files
+  REVERTED = 'X', // Not returned by BE, but added by UI for certain files
 }
 
 /**
@@ -209,8 +199,9 @@
 
 // This is a "meta type", so it comes first and is not sored alphabetically with
 // the other types.
-export type BrandType<T, BrandName extends string> = T &
-  {[__brand in BrandName]: never};
+export type BrandType<T, BrandName extends string> = T & {
+  [__brand in BrandName]: never;
+};
 
 export type AccountId = BrandType<number, '_accountId'>;
 
@@ -345,9 +336,7 @@
   width: number;
 }
 
-export type BasePatchSetNum = BrandType<'PARENT' | number, '_patchSet'>;
 // The refs/heads/ prefix is omitted in Branch name
-
 export type BranchName = BrandType<string, '_branchName'>;
 
 /**
@@ -417,9 +406,8 @@
   revert_of?: NumericChangeId;
   submission_id?: ChangeSubmissionId;
   cherry_pick_of_change?: NumericChangeId;
-  cherry_pick_of_patch_set?: PatchSetNum;
+  cherry_pick_of_patch_set?: RevisionPatchSetNum;
   contains_git_conflicts?: boolean;
-  internalHost?: string; // TODO(TS): provide an explanation what is its
   submit_requirements?: SubmitRequirementResultInfo[];
   submit_records?: SubmitRecordInfo[];
 }
@@ -465,6 +453,9 @@
 export declare interface CommentLinkInfo {
   match: string;
   link?: string;
+  prefix?: string;
+  suffix?: string;
+  text?: string;
   enabled?: boolean;
   html?: string;
 }
@@ -626,8 +617,8 @@
   old_path?: string;
   lines_inserted?: number;
   lines_deleted?: number;
-  size_delta: number; // in bytes
-  size: number; // in bytes
+  size_delta?: number; // in bytes
+  size?: number; // in bytes
 }
 
 /**
@@ -789,7 +780,23 @@
   subject: string;
 }
 
-export type PatchSetNum = BrandType<'PARENT' | 'edit' | number, '_patchSet'>;
+export type PatchSetNumber = BrandType<number, '_patchSet'>;
+
+export type EditPatchSet = BrandType<'edit', '_patchSet'>;
+
+export const EDIT = 'edit' as EditPatchSet;
+
+export type ParentPatchSet = BrandType<'PARENT', '_patchSet'>;
+
+export const PARENT = 'PARENT' as ParentPatchSet;
+
+export type PatchSetNum = PatchSetNumber | ParentPatchSet | EditPatchSet;
+
+// for the "left" side of a diff or the base of a patch range
+export type BasePatchSetNum = PatchSetNumber | ParentPatchSet;
+
+// for the "right" side of a diff or the revision of a patch range
+export type RevisionPatchSetNum = PatchSetNumber | EditPatchSet;
 
 /**
  * The PluginConfigInfo entity contains information about Gerrit extensions by
@@ -945,7 +952,7 @@
  */
 export declare interface RevisionInfo {
   kind: RevisionKind;
-  _number: PatchSetNum;
+  _number: RevisionPatchSetNum;
   created: Timestamp;
   uploader: AccountInfo;
   ref: GitRef;
@@ -1176,3 +1183,19 @@
   status: LabelStatus;
   appliedBy: AccountInfo;
 }
+
+/**
+ * Represent a file in a base64 encoding; GrRestApiInterface returns
+ * it from some methods
+ */
+export declare interface Base64FileContent {
+  content: string | null;
+  type: string | null;
+  ok: true;
+}
+
+export function isBase64FileContent(
+  res: Response | Base64FileContent
+): res is Base64FileContent {
+  return (res as Base64FileContent).ok;
+}
diff --git a/polygerrit-ui/app/api/rest.ts b/polygerrit-ui/app/api/rest.ts
index 86f33a9..283e029 100644
--- a/polygerrit-ui/app/api/rest.ts
+++ b/polygerrit-ui/app/api/rest.ts
@@ -1,18 +1,7 @@
 /**
  * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import {AccountDetailInfo, ProjectInfoWithName, ServerInfo} from './rest-api';
 
diff --git a/polygerrit-ui/app/api/styles.ts b/polygerrit-ui/app/api/styles.ts
index 55ac2cc..1e1f60a 100644
--- a/polygerrit-ui/app/api/styles.ts
+++ b/polygerrit-ui/app/api/styles.ts
@@ -1,18 +1,7 @@
 /**
  * @license
- * Copyright (C) 2021 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 
 /**
@@ -36,6 +25,7 @@
 export declare interface Styles {
   font: Style;
   form: Style;
+  icon: Style;
   menuPage: Style;
   spinner: Style;
   subPage: Style;
diff --git a/polygerrit-ui/app/compile_generated_templates.sh b/polygerrit-ui/app/compile_generated_templates.sh
deleted file mode 100755
index 68bf485..0000000
--- a/polygerrit-ui/app/compile_generated_templates.sh
+++ /dev/null
@@ -1 +0,0 @@
-$1 --project $2 --baseUrl ./external/ui_npm/node_modules/ --rootDir null
diff --git a/polygerrit-ui/app/constants/constants.ts b/polygerrit-ui/app/constants/constants.ts
index 8cdd765..bb7b313 100644
--- a/polygerrit-ui/app/constants/constants.ts
+++ b/polygerrit-ui/app/constants/constants.ts
@@ -1,18 +1,7 @@
 /**
  * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 
 /**
@@ -63,7 +52,7 @@
   SERVICE_USER = 'SERVICE_USER',
 }
 
-export enum PrimaryTab {
+export enum Tab {
   FILES = 'files',
   /**
    * When renaming 'comments' or 'findings', UrlFormatter.java must be updated.
@@ -74,18 +63,12 @@
 }
 
 /**
- * Tab names for secondary tabs on change view page.
- */
-export enum SecondaryTab {
-  CHANGE_LOG = '_changeLog',
-}
-
-/**
  * Tag names of change log messages.
  */
 export enum MessageTag {
   TAG_DELETE_REVIEWER = 'autogenerated:gerrit:deleteReviewer',
   TAG_NEW_PATCHSET = 'autogenerated:gerrit:newPatchSet',
+  TAG_NEW_PATCHSET_OUTDATED_VOTES = 'autogenerated:gerrit:newPatchSetOutdatedVotes',
   TAG_NEW_WIP_PATCHSET = 'autogenerated:gerrit:newWipPatchSet',
   TAG_REVIEWER_UPDATE = 'autogenerated:gerrit:reviewerUpdate',
   TAG_SET_PRIVATE = 'autogenerated:gerrit:setPrivate',
@@ -107,6 +90,20 @@
   SUCCESSFUL = 'SUCCESSFUL',
 }
 
+export enum ColumnNames {
+  SUBJECT = 'Subject',
+  // TODO(milutin) - remove once Submit Requirements are rolled out.
+  STATUS = 'Status',
+  OWNER = 'Owner',
+  REVIEWERS = 'Reviewers',
+  COMMENTS = 'Comments',
+  REPO = 'Repo',
+  BRANCH = 'Branch',
+  UPDATED = 'Updated',
+  SIZE = 'Size',
+  STATUS2 = ' Status ', // spaces to differentiate from old 'Status'
+}
+
 /**
  * @description Modes for gr-diff-cursor
  * The scroll behavior for the cursor. Values are 'never' and
@@ -176,6 +173,7 @@
  * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#preferences-input
  */
 export enum AppTheme {
+  AUTO = 'AUTO',
   DARK = 'DARK',
   LIGHT = 'LIGHT',
 }
@@ -266,12 +264,13 @@
     diff_view: DiffViewMode.SIDE_BY_SIDE,
     size_bar_in_change_table: true,
     my: [],
-    theme: AppTheme.LIGHT,
+    theme: AppTheme.AUTO,
     date_format: DateFormat.EURO,
     time_format: TimeFormat.HHMM_24,
     change_table: [],
     email_strategy: EmailStrategy.ATTENTION_SET_ONLY,
     default_base_for_merges: DefaultBase.AUTO_MERGE,
+    allow_browser_notifications: false,
   };
 }
 
diff --git a/polygerrit-ui/app/constants/messages.ts b/polygerrit-ui/app/constants/messages.ts
index 850a5d2..dca1e61 100644
--- a/polygerrit-ui/app/constants/messages.ts
+++ b/polygerrit-ui/app/constants/messages.ts
@@ -1,18 +1,7 @@
 /**
  * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 
 /** Message shown when no threads in gr-thread-list for robot comments */
diff --git a/polygerrit-ui/app/constants/reporting.ts b/polygerrit-ui/app/constants/reporting.ts
index 8818066..0e00d07 100644
--- a/polygerrit-ui/app/constants/reporting.ts
+++ b/polygerrit-ui/app/constants/reporting.ts
@@ -1,18 +1,7 @@
 /**
  * @license
- * Copyright (C) 2021 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http =//www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 
 export enum LifeCycle {
@@ -33,6 +22,8 @@
   METHOD_USED = 'method used',
   CHECKS_API_NOT_LOGGED_IN = 'checks-api not-logged-in',
   CHECKS_API_ERROR = 'checks-api error',
+  USER_PREFERENCES_COLUMNS = 'user-preferences-columns',
+  PREFER_MERGE_FIRST_PARENT = 'prefer-merge-first-parent',
 }
 
 export enum Timing {
@@ -94,12 +85,19 @@
   DRAFT_DISCARD = 'DiscardDraftComment',
   // Time to load checks from all providers for the first time.
   CHECKS_LOAD = 'ChecksLoad',
+  // Webvitals - Cumulative Layout Shift (CLS): measures visual stability
+  CLS = 'CLS',
+  // WebVitals - First Input Delay (FID): measures interactivity
+  FID = 'FID',
+  // WebVitals - Largest Contentful Paint (LCP): measures loading performance.
+  LCP = 'LCP',
 }
 
 export enum Interaction {
   TOGGLE_SHOW_ALL_BUTTON = 'toggle show all button',
   SHOW_TAB = 'show-tab',
   ATTENTION_SET_CHIP = 'attention-set-chip',
+  BULK_ACTION = 'bulk-action',
   SAVE_COMMENT = 'save-comment',
   COMMENT_SAVED = 'comment-saved',
   DISCARD_COMMENT = 'discard-comment',
@@ -122,4 +120,32 @@
   CHECKS_RUNS_PANEL_TOGGLE = 'checks-runs-panel-toggle',
   CHECKS_RUNS_SELECTED_TRIGGERED = 'checks-runs-selected-triggered',
   CHECKS_STATS = 'checks-stats',
+  // The following interactions are logged for investigating a spurious bug of
+  // auto-closing draft comments.
+  COMMENTS_AUTOCLOSE_FIRST_UPDATE = 'comments-autoclose-first-update',
+  COMMENTS_AUTOCLOSE_EDITING_FALSE_SAVE = 'comments-autoclose-editing-false-save',
+  COMMENTS_AUTOCLOSE_EDITING_DISCONNECTED = 'comments-autoclose-editing-disconnected',
+  COMMENTS_AUTOCLOSE_EDITING_THREAD_DISCONNECTED = 'comments-autoclose-editing-thread-disconnected',
+  COMMENTS_AUTOCLOSE_CHECKS_UPDATED = 'comments-autoclose-checks-updated',
+  COMMENTS_AUTOCLOSE_THREADS_UPDATED = 'comments-autoclose-threads-updated',
+  COMMENTS_AUTOCLOSE_COMMENT_REMOVED = 'comments-autoclose-comment-removed',
+  COMMENTS_AUTOCLOSE_MESSAGES_LIST_CREATED = 'comments-autoclose-messages-list-created',
+  COMMENTS_AUTOCLOSE_MESSAGES_LIST_UPDATED = 'comments-autoclose-messages-list-updated',
+  COMMENTS_AUTOCLOSE_THREAD_LIST_CREATED = 'comments-autoclose-thread-list-created',
+  COMMENTS_AUTOCLOSE_THREAD_LIST_UPDATED = 'comments-autoclose-thread-list-updated',
+  // The following interactions are logged for investigating a spurious bug of
+  // auto-closing diffs.
+  DIFF_AUTOCLOSE_DIFF_UNDEFINED = 'diff-autoclose-diff-undefined',
+  DIFF_AUTOCLOSE_DIFF_ONGOING = 'diff-autoclose-diff-ongoing',
+  DIFF_AUTOCLOSE_RELOAD_ON_WHITESPACE = 'diff-autoclose-reload-on-whitespace',
+  DIFF_AUTOCLOSE_RELOAD_ON_SYNTAX = 'diff-autoclose-reload-on-syntax',
+  DIFF_AUTOCLOSE_RELOAD_FILELIST_PREFS = 'diff-autoclose-reload-filelist-prefs',
+  DIFF_AUTOCLOSE_DIFF_HOST_CREATED = 'diff-autoclose-diff-host-created',
+  DIFF_AUTOCLOSE_DIFF_HOST_NOT_RENDERING = 'diff-autoclose-diff-not-rendering',
+  DIFF_AUTOCLOSE_FILE_LIST_UPDATED = 'diff-autoclose-file-list-updated',
+  DIFF_AUTOCLOSE_SHOWN_FILES_CHANGED = 'diff-autoclose-shown-files-changed',
+  // The following interaction is logged for reporting and counting a suspected
+  // Chrome bug that leads to html`` misbehavior.
+  AUTOCLOSE_HTML_PATCHED = 'autoclose-html-patched',
+  CHANGE_ACTION_FIRED = 'change-action-fired',
 }
diff --git a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.ts b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.ts
index 2c83ed3..d5a83a7 100644
--- a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.ts
+++ b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.ts
@@ -1,23 +1,11 @@
 /**
  * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
 import '@polymer/iron-input/iron-input';
 import '../../shared/gr-button/gr-button';
-import '../../shared/gr-icons/gr-icons';
+import '../../shared/gr-icon/gr-icon';
 import '../gr-permission/gr-permission';
 import {
   AccessPermissions,
@@ -42,7 +30,7 @@
 import {formStyles} from '../../../styles/gr-form-styles';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, PropertyValues, html, css} from 'lit';
-import {customElement, property, query, state} from 'lit/decorators';
+import {customElement, property, query, state} from 'lit/decorators.js';
 import {BindValueChangeEvent, ValueChangedEvent} from '../../../types/events';
 import {assertIsDefined, queryAndAssert} from '../../../utils/common-util';
 
@@ -191,7 +179,7 @@
                 class=${this.section?.id === GLOBAL_NAME ? 'global' : ''}
                 @click=${this.editReference}
               >
-                <iron-icon id="icon" icon="gr-icons:create"></iron-icon>
+                <gr-icon id="icon" icon="edit" filled small></gr-icon>
               </gr-button>
             </div>
             <iron-input
@@ -369,6 +357,7 @@
     if (!this.permissions) {
       return;
     }
+    delete this.section?.value.permissions[this.permissions[index].id];
     this.permissions = this.permissions
       .slice(0, index)
       .concat(this.permissions.slice(index + 1, this.permissions.length));
diff --git a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_test.ts b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_test.ts
index 1c4c437..593a1ed 100644
--- a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_test.ts
@@ -1,21 +1,9 @@
 /**
  * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-access-section';
 import {
   AccessPermissions,
@@ -25,7 +13,7 @@
 import {GitRef} from '../../../types/common';
 import {queryAndAssert} from '../../../utils/common-util';
 import {GrButton} from '../../shared/gr-button/gr-button';
-import {fixture, html} from '@open-wc/testing-helpers';
+import {fixture, html, assert} from '@open-wc/testing';
 
 suite('gr-access-section tests', () => {
   let element: GrAccessSection;
@@ -82,6 +70,108 @@
       await element.updateComplete;
     });
 
+    test('render', () => {
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <fieldset class="gr-form-styles" id="section">
+            <div id="mainContainer">
+              <div class="header">
+                <div class="name">
+                  <h3 class="heading-3">Reference: refs/*</h3>
+                  <gr-button
+                    aria-disabled="false"
+                    id="editBtn"
+                    link=""
+                    role="button"
+                    tabindex="0"
+                  >
+                    <gr-icon icon="edit" id="icon" small filled></gr-icon>
+                  </gr-button>
+                </div>
+                <iron-input class="editRefInput">
+                  <input class="editRefInput" type="text" />
+                </iron-input>
+                <gr-button
+                  aria-disabled="false"
+                  id="deleteBtn"
+                  link=""
+                  role="button"
+                  tabindex="0"
+                >
+                  Remove
+                </gr-button>
+              </div>
+              <div class="sectionContent">
+                <gr-permission> </gr-permission>
+                <div id="addPermission">
+                  Add permission:
+                  <select id="permissionSelect">
+                    <option value="label-Code-Review">Label Code-Review</option>
+                    <option value="labelAs-Code-Review">
+                      Label Code-Review (On Behalf Of)
+                    </option>
+                    <option value="abandon">Abandon</option>
+                    <option value="addPatchSet">Add Patch Set</option>
+                    <option value="create">Create Reference</option>
+                    <option value="createSignedTag">Create Signed Tag</option>
+                    <option value="createTag">Create Annotated Tag</option>
+                    <option value="delete">Delete Reference</option>
+                    <option value="deleteChanges">Delete Changes</option>
+                    <option value="deleteOwnChanges">Delete Own Changes</option>
+                    <option value="editHashtags">Edit Hashtags</option>
+                    <option value="editTopicName">Edit Topic Name</option>
+                    <option value="forgeAuthor">Forge Author Identity</option>
+                    <option value="forgeCommitter">
+                      Forge Committer Identity
+                    </option>
+                    <option value="forgeServerAsCommitter">
+                      Forge Server Identity
+                    </option>
+                    <option value="owner">Owner</option>
+                    <option value="push">Push</option>
+                    <option value="pushMerge">Push Merge Commit</option>
+                    <option value="rebase">Rebase</option>
+                    <option value="removeReviewer">Remove Reviewer</option>
+                    <option value="revert">Revert</option>
+                    <option value="submit">Submit</option>
+                    <option value="submitAs">Submit (On Behalf Of)</option>
+                    <option value="toggleWipState">
+                      Toggle Work In Progress State
+                    </option>
+                    <option value="viewPrivateChanges">
+                      View Private Changes
+                    </option>
+                  </select>
+                  <gr-button
+                    aria-disabled="false"
+                    id="addBtn"
+                    link=""
+                    role="button"
+                    tabindex="0"
+                  >
+                    Add
+                  </gr-button>
+                </div>
+              </div>
+            </div>
+            <div id="deletedContainer">
+              <span> Reference: refs/* was deleted </span>
+              <gr-button
+                aria-disabled="false"
+                id="undoRemoveBtn"
+                link=""
+                role="button"
+                tabindex="0"
+              >
+                Undo
+              </gr-button>
+            </div>
+          </fieldset>
+        `
+      );
+    });
+
     test('updateSection', () => {
       // updateSection was called in setup, so just make assertions.
       const expectedPermissions = [
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.ts b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.ts
index ba737a7..5d32d32 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.ts
+++ b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.ts
@@ -1,26 +1,12 @@
 /**
  * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
 import '../../shared/gr-dialog/gr-dialog';
 import '../../shared/gr-list-view/gr-list-view';
 import '../../shared/gr-overlay/gr-overlay';
 import '../gr-create-group-dialog/gr-create-group-dialog';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation';
-import {AppElementAdminParams} from '../../gr-app-types';
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
 import {GroupId, GroupInfo, GroupName} from '../../../types/common';
 import {GrCreateGroupDialog} from '../gr-create-group-dialog/gr-create-group-dialog';
@@ -30,8 +16,10 @@
 import {tableStyles} from '../../../styles/gr-table-styles';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, PropertyValues, css, html} from 'lit';
-import {customElement, query, property, state} from 'lit/decorators';
+import {customElement, query, property, state} from 'lit/decorators.js';
 import {assertIsDefined} from '../../../utils/common-util';
+import {AdminViewState} from '../../../models/views/admin';
+import {createGroupUrl} from '../../../models/views/group';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -48,7 +36,7 @@
   @query('#createNewModal') private createNewModal?: GrCreateGroupDialog;
 
   @property({type: Object})
-  params?: AppElementAdminParams;
+  params?: AdminViewState;
 
   /**
    * Offset of currently visible query results.
@@ -178,7 +166,7 @@
    *
    * private but used in test
    */
-  maybeOpenCreateOverlay(params?: AppElementAdminParams) {
+  maybeOpenCreateOverlay(params?: AdminViewState) {
     if (params?.openCreateModal) {
       assertIsDefined(this.createOverlay, 'createOverlay');
       this.createOverlay.open();
@@ -190,8 +178,9 @@
    *
    * private but used in test
    */
-  computeGroupUrl(id: string) {
-    return GerritNav.getUrlForGroup(decodeURIComponent(id) as GroupId);
+  computeGroupUrl(encodedId: string) {
+    const groupId = decodeURIComponent(encodedId) as GroupId;
+    return createGroupUrl({groupId});
   }
 
   private getCreateGroupCapability() {
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_test.ts b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_test.ts
index 709a0b7..e484489 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_test.ts
@@ -1,38 +1,24 @@
 /**
  * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-admin-group-list';
 import {GrAdminGroupList} from './gr-admin-group-list';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {queryAndAssert, stubRestApi} from '../../../test/test-utils';
 import {
   GroupId,
   GroupName,
   GroupNameToGroupInfoMap,
 } from '../../../types/common';
-import {AppElementAdminParams} from '../../gr-app-types';
 import {GerritView} from '../../../services/router/router-model';
 import {GrListView} from '../../shared/gr-list-view/gr-list-view';
 import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
 import {SHOWN_ITEMS_COUNT} from '../../../constants/constants';
-
-const basicFixture = fixtureFromElement('gr-admin-group-list');
+import {fixture, html, assert} from '@open-wc/testing';
+import {AdminChildView, AdminViewState} from '../../../models/views/admin';
 
 function createGroup(name: string, counter: number) {
   return {
@@ -69,36 +55,58 @@
   let element: GrAdminGroupList;
   let groups: GroupNameToGroupInfoMap;
 
-  const value: AppElementAdminParams = {view: GerritView.ADMIN, adminView: ''};
+  const value: AdminViewState = {
+    view: GerritView.ADMIN,
+    adminView: AdminChildView.GROUPS,
+  };
 
   setup(async () => {
-    element = basicFixture.instantiate();
-    await element.updateComplete;
+    element = await fixture(html`<gr-admin-group-list></gr-admin-group-list>`);
   });
 
-  test('computeGroupUrl', () => {
-    let urlStub = sinon
-      .stub(GerritNav, 'getUrlForGroup')
-      .callsFake(
-        () => '/admin/groups/e2cd66f88a2db4d391ac068a92d987effbe872f5'
-      );
-
-    let group = 'e2cd66f88a2db4d391ac068a92d987effbe872f5';
-    assert.equal(
-      element.computeGroupUrl(group),
-      '/admin/groups/e2cd66f88a2db4d391ac068a92d987effbe872f5'
+  test('render', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <gr-list-view>
+          <table class="genericList" id="list">
+            <tbody>
+              <tr class="headerRow">
+                <th class="name topHeader">Group Name</th>
+                <th class="description topHeader">Group Description</th>
+                <th class="topHeader visibleToAll">Visible To All</th>
+              </tr>
+              <tr class="loading loadingMsg" id="loading">
+                <td>Loading...</td>
+              </tr>
+            </tbody>
+            <tbody class="loading"></tbody>
+          </table>
+        </gr-list-view>
+        <gr-overlay
+          aria-hidden="true"
+          id="createOverlay"
+          style="outline: none; display: none;"
+          tabindex="-1"
+          with-backdrop=""
+        >
+          <gr-dialog
+            class="confirmDialog"
+            confirm-label="Create"
+            confirm-on-enter=""
+            disabled=""
+            id="createDialog"
+            role="dialog"
+          >
+            <div class="header" slot="header">Create Group</div>
+            <div class="main" slot="main">
+              <gr-create-group-dialog id="createNewModal">
+              </gr-create-group-dialog>
+            </div>
+          </gr-dialog>
+        </gr-overlay>
+      `
     );
-
-    urlStub.restore();
-
-    urlStub = sinon
-      .stub(GerritNav, 'getUrlForGroup')
-      .callsFake(() => '/admin/groups/user/test');
-
-    group = 'user%2Ftest';
-    assert.equal(element.computeGroupUrl(group), '/admin/groups/user/test');
-
-    urlStub.restore();
   });
 
   suite('list with groups', () => {
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.ts b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.ts
index 154b470..088002c 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.ts
+++ b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.ts
@@ -1,22 +1,10 @@
 /**
  * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
 import '../../shared/gr-dropdown-list/gr-dropdown-list';
-import '../../shared/gr-icons/gr-icons';
+import '../../shared/gr-icon/gr-icon';
 import '../../shared/gr-page-nav/gr-page-nav';
 import '../gr-admin-group-list/gr-admin-group-list';
 import '../gr-group/gr-group';
@@ -30,11 +18,7 @@
 import '../gr-repo-detail-list/gr-repo-detail-list';
 import '../gr-repo-list/gr-repo-list';
 import {getBaseUrl} from '../../../utils/url-util';
-import {
-  GerritNav,
-  GroupDetailView,
-  RepoDetailView,
-} from '../../core/gr-navigation/gr-navigation';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
 import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 import {
   AdminNavLinksOption,
@@ -43,26 +27,38 @@
   SubsectionInterface,
 } from '../../../utils/admin-nav-util';
 import {
-  AppElementAdminParams,
-  AppElementGroupParams,
-  AppElementRepoParams,
-} from '../../gr-app-types';
-import {
   AccountDetailInfo,
   GroupId,
   GroupName,
   RepoName,
 } from '../../../types/common';
 import {GroupNameChangedDetail} from '../gr-group/gr-group';
-import {ValueChangeDetail} from '../../shared/gr-dropdown-list/gr-dropdown-list';
 import {getAppContext} from '../../../services/app-context';
 import {GerritView} from '../../../services/router/router-model';
 import {menuPageStyles} from '../../../styles/gr-menu-page-styles';
 import {pageNavStyles} from '../../../styles/gr-page-nav-styles';
 import {sharedStyles} from '../../../styles/shared-styles';
-import {LitElement, PropertyValues, css, html} from 'lit';
-import {customElement, property, state} from 'lit/decorators';
-import {ifDefined} from 'lit/directives/if-defined';
+import {LitElement, PropertyValues, css, html, nothing} from 'lit';
+import {customElement, state} from 'lit/decorators.js';
+import {ifDefined} from 'lit/directives/if-defined.js';
+import {ValueChangedEvent} from '../../../types/events';
+import {
+  AdminChildView,
+  adminViewModelToken,
+  AdminViewState,
+} from '../../../models/views/admin';
+import {
+  GroupDetailView,
+  groupViewModelToken,
+  GroupViewState,
+} from '../../../models/views/group';
+import {
+  RepoDetailView,
+  repoViewModelToken,
+  RepoViewState,
+} from '../../../models/views/repo';
+import {resolve} from '../../../models/dependency';
+import {subscribe} from '../../lit/subscription-controller';
 
 const INTERNAL_GROUP_REGEX = /^[\da-f]{40}$/;
 
@@ -75,33 +71,21 @@
   parent?: GroupId | RepoName;
 }
 
-// The type is matched to the _showAdminView function from the gr-app-element
-type AdminViewParams =
-  | AppElementAdminParams
-  | AppElementGroupParams
-  | AppElementRepoParams;
-
-function getAdminViewParamsDetail(
-  params: AdminViewParams
-): GroupDetailView | RepoDetailView | undefined {
-  if (params.view !== GerritView.ADMIN) {
-    return params.detail;
-  }
-  return undefined;
-}
-
 @customElement('gr-admin-view')
 export class GrAdminView extends LitElement {
   private account?: AccountDetailInfo;
 
-  @property({type: Object})
-  params?: AdminViewParams;
+  @state()
+  view?: GerritView;
 
-  @property({type: String})
-  path?: string;
+  @state()
+  adminViewState?: AdminViewState;
 
-  @property({type: String})
-  adminView?: string;
+  @state()
+  groupViewState?: GroupViewState;
+
+  @state()
+  repoViewState?: RepoViewState;
 
   @state() private breadcrumbParentName?: string;
 
@@ -130,6 +114,52 @@
 
   private readonly restApiService = getAppContext().restApiService;
 
+  private readonly getAdminViewModel = resolve(this, adminViewModelToken);
+
+  private readonly getGroupViewModel = resolve(this, groupViewModelToken);
+
+  private readonly getRepoViewModel = resolve(this, repoViewModelToken);
+
+  private readonly routerModel = getAppContext().routerModel;
+
+  private readonly getNavigation = resolve(this, navigationToken);
+
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.getAdminViewModel().state$,
+      state => {
+        this.adminViewState = state;
+        if (this.needsReload()) this.reload();
+      }
+    );
+    subscribe(
+      this,
+      () => this.getGroupViewModel().state$,
+      state => {
+        this.groupViewState = state;
+        if (this.needsReload()) this.reload();
+      }
+    );
+    subscribe(
+      this,
+      () => this.getRepoViewModel().state$,
+      state => {
+        this.repoViewState = state;
+        if (this.needsReload()) this.reload();
+      }
+    );
+    subscribe(
+      this,
+      () => this.routerModel.routerView$,
+      view => {
+        this.view = view;
+        if (this.needsReload()) this.reload();
+      }
+    );
+  }
+
   override connectedCallback() {
     super.connectedCallback();
     this.reload();
@@ -145,7 +175,7 @@
           /* Same as dropdown trigger so chevron spacing is consistent. */
           padding: 5px 4px;
         }
-        iron-icon {
+        gr-icon {
           margin: 0 var(--spacing-xs);
         }
         .breadcrumb {
@@ -171,6 +201,7 @@
   }
 
   override render() {
+    if (!this.isAdminView()) return nothing;
     return html`
       <gr-page-nav class="navStyles">
         <ul class="sectionContent">
@@ -207,7 +238,7 @@
   }
 
   private renderAdminNavSubsection(item: NavLink) {
-    if (!item.subsection) return;
+    if (!item.subsection) return nothing;
 
     return html`
       <!--If a section has a subsection, render that.-->
@@ -245,13 +276,13 @@
   }
 
   private renderSubsectionLinks() {
-    if (!this.subsectionLinks?.length) return;
+    if (!this.subsectionLinks?.length) return nothing;
 
     return html`
       <section class="mainHeader">
         <span class="breadcrumb">
           <span class="breadcrumbText">${this.breadcrumbParentName}</span>
-          <iron-icon icon="gr-icons:chevron-right"></iron-icon>
+          <gr-icon icon="chevron_right"></gr-icon>
         </span>
         <gr-dropdown-list
           id="pageSelect"
@@ -265,82 +296,67 @@
   }
 
   private renderRepoList() {
-    const params = this.params as AppElementAdminParams;
-    if (
-      !(
-        params?.view === GerritView.ADMIN &&
-        params?.adminView === 'gr-repo-list'
-      )
-    )
-      return;
+    if (this.view !== GerritView.ADMIN) return nothing;
+    if (this.adminViewState?.adminView !== AdminChildView.REPOS) return nothing;
 
     return html`
       <div class="main table">
-        <gr-repo-list class="table" .params=${params}></gr-repo-list>
+        <gr-repo-list
+          class="table"
+          .params=${this.adminViewState}
+        ></gr-repo-list>
       </div>
     `;
   }
 
   private renderGroupList() {
-    const params = this.params as AppElementAdminParams;
-    if (
-      !(
-        params?.view === GerritView.ADMIN &&
-        params?.adminView === 'gr-admin-group-list'
-      )
-    )
-      return;
+    if (this.view !== GerritView.ADMIN) return nothing;
+    if (this.adminViewState?.adminView !== AdminChildView.GROUPS)
+      return nothing;
 
     return html`
       <div class="main table">
-        <gr-admin-group-list class="table" .params=${params}>
+        <gr-admin-group-list class="table" .params=${this.adminViewState}>
         </gr-admin-group-list>
       </div>
     `;
   }
 
   private renderPluginList() {
-    const params = this.params as AppElementAdminParams;
-    if (
-      !(
-        params?.view === GerritView.ADMIN &&
-        params?.adminView === 'gr-plugin-list'
-      )
-    )
-      return;
+    if (this.view !== GerritView.ADMIN) return nothing;
+    if (this.adminViewState?.adminView !== AdminChildView.PLUGINS)
+      return nothing;
 
     return html`
       <div class="main table">
-        <gr-plugin-list class="table" .params=${params}></gr-plugin-list>
+        <gr-plugin-list
+          class="table"
+          .params=${this.adminViewState}
+        ></gr-plugin-list>
       </div>
     `;
   }
 
   private renderRepoMain() {
-    const params = this.params as AppElementRepoParams;
-    if (
-      !(
-        params?.view === GerritView.REPO &&
-        (!params?.detail || params?.detail === RepoDetailView.GENERAL)
-      )
-    )
-      return;
+    if (this.view !== GerritView.REPO) return nothing;
+    const detail = this.repoViewState?.detail ?? RepoDetailView.GENERAL;
+    if (detail !== RepoDetailView.GENERAL) return nothing;
 
     return html`
       <div class="main breadcrumbs">
-        <gr-repo .repo=${params.repo}></gr-repo>
+        <gr-repo .repo=${this.repoViewState?.repo}></gr-repo>
       </div>
     `;
   }
 
   private renderGroup() {
-    const params = this.params as AppElementGroupParams;
-    if (!(params?.view === GerritView.GROUP && !params?.detail)) return;
+    if (this.view !== GerritView.GROUP) return nothing;
+    if (this.groupViewState?.detail !== undefined) return nothing;
 
     return html`
       <div class="main breadcrumbs">
         <gr-group
-          .groupId=${params.groupId}
+          .groupId=${this.groupViewState?.groupId}
           @name-changed=${(e: CustomEvent<GroupNameChangedDetail>) => {
             this.updateGroupName(e);
           }}
@@ -350,122 +366,86 @@
   }
 
   private renderGroupMembers() {
-    const params = this.params as AppElementGroupParams;
-    if (
-      !(
-        params?.view === GerritView.GROUP &&
-        params?.detail === GroupDetailView.MEMBERS
-      )
-    )
-      return;
+    if (this.view !== GerritView.GROUP) return nothing;
+    if (this.groupViewState?.detail !== GroupDetailView.MEMBERS) return nothing;
 
     return html`
       <div class="main breadcrumbs">
-        <gr-group-members .groupId=${params.groupId}></gr-group-members>
+        <gr-group-members
+          .groupId=${this.groupViewState?.groupId}
+        ></gr-group-members>
       </div>
     `;
   }
 
   private renderGroupAuditLog() {
-    const params = this.params as AppElementGroupParams;
-    if (
-      !(
-        params?.view === GerritView.GROUP &&
-        params?.detail === GroupDetailView.LOG
-      )
-    )
-      return;
+    if (this.view !== GerritView.GROUP) return nothing;
+    if (this.groupViewState?.detail !== GroupDetailView.LOG) return nothing;
 
     return html`
       <div class="main table breadcrumbs">
         <gr-group-audit-log
           class="table"
-          .groupId=${params.groupId}
+          .groupId=${this.groupViewState?.groupId}
         ></gr-group-audit-log>
       </div>
     `;
   }
 
   private renderRepoDetailList() {
-    const params = this.params as AppElementRepoParams;
-    if (
-      !(
-        params?.view === GerritView.REPO &&
-        (params?.detail === RepoDetailView.BRANCHES ||
-          params?.detail === RepoDetailView.TAGS)
-      )
-    )
-      return;
+    if (this.view !== GerritView.REPO) return nothing;
+    const detail = this.repoViewState?.detail;
+    if (detail !== RepoDetailView.BRANCHES && detail !== RepoDetailView.TAGS) {
+      return nothing;
+    }
 
     return html`
       <div class="main table breadcrumbs">
         <gr-repo-detail-list
           class="table"
-          .params=${params}
+          .params=${this.repoViewState}
         ></gr-repo-detail-list>
       </div>
     `;
   }
 
   private renderRepoCommands() {
-    const params = this.params as AppElementRepoParams;
-    if (
-      !(
-        params?.view === GerritView.REPO &&
-        params?.detail === RepoDetailView.COMMANDS
-      )
-    )
-      return;
+    if (this.view !== GerritView.REPO) return nothing;
+    if (this.repoViewState?.detail !== RepoDetailView.COMMANDS) return nothing;
 
     return html`
       <div class="main breadcrumbs">
-        <gr-repo-commands .repo=${params.repo}></gr-repo-commands>
+        <gr-repo-commands .repo=${this.repoViewState.repo}></gr-repo-commands>
       </div>
     `;
   }
 
   private renderRepoAccess() {
-    const params = this.params as AppElementRepoParams;
-    if (
-      !(
-        params?.view === GerritView.REPO &&
-        params?.detail === RepoDetailView.ACCESS
-      )
-    )
-      return;
+    if (this.view !== GerritView.REPO) return nothing;
+    if (this.repoViewState?.detail !== RepoDetailView.ACCESS) return nothing;
 
     return html`
       <div class="main breadcrumbs">
-        <gr-repo-access
-          .path=${this.path}
-          .repo=${params.repo}
-        ></gr-repo-access>
+        <gr-repo-access .repo=${this.repoViewState.repo}></gr-repo-access>
       </div>
     `;
   }
 
   private renderRepoDashboards() {
-    const params = this.params as AppElementRepoParams;
-    if (
-      !(
-        params?.view === GerritView.REPO &&
-        params?.detail === RepoDetailView.DASHBOARDS
-      )
-    )
-      return;
+    if (this.view !== GerritView.REPO) return nothing;
+    if (this.repoViewState?.detail !== RepoDetailView.DASHBOARDS)
+      return nothing;
 
     return html`
       <div class="main table breadcrumbs">
-        <gr-repo-dashboards .repo=${params.repo}></gr-repo-dashboards>
+        <gr-repo-dashboards
+          .repo=${this.repoViewState.repo}
+        ></gr-repo-dashboards>
       </div>
     `;
   }
 
   override willUpdate(changedProperties: PropertyValues) {
-    if (changedProperties.has('params')) {
-      this.paramsChanged();
-    }
-
     if (changedProperties.has('groupId')) {
       this.computeGroupName();
     }
@@ -536,24 +516,29 @@
     }
   }
 
+  private getDetailView() {
+    if (this.view === GerritView.REPO) return this.repoViewState?.detail;
+    if (this.view === GerritView.GROUP) return this.groupViewState?.detail;
+    return undefined;
+  }
+
   private computeSelectValue() {
-    if (!this.params?.view) return;
-    return `${this.params.view}${getAdminViewParamsDetail(this.params) ?? ''}`;
+    return `${this.view}${this.getDetailView() ?? ''}`;
   }
 
   // private but used in test
   selectedIsCurrentPage(selected: AdminSubsectionLink) {
-    if (!this.params) return false;
+    if (!this.view) return false;
 
     return (
       selected.parent === (this.repoName ?? this.groupId) &&
-      selected.view === this.params.view &&
-      selected.detailType === getAdminViewParamsDetail(this.params)
+      selected.view === this.view &&
+      selected.detailType === this.getDetailView()
     );
   }
 
   // private but used in test
-  handleSubsectionChange(e: CustomEvent<ValueChangeDetail>) {
+  handleSubsectionChange(e: ValueChangedEvent<string>) {
     if (!this.subsectionLinks) return;
 
     // The GrDropdownList items are subsectionLinks, so find(...) always return
@@ -566,26 +551,30 @@
     if (this.selectedIsCurrentPage(selected)) return;
     if (selected.url === undefined) return;
     if (this.reloading) return;
-    GerritNav.navigateToRelativeUrl(selected.url);
+    this.getNavigation().setUrl(selected.url);
   }
 
-  private async paramsChanged() {
-    if (this.needsReload()) await this.reload();
+  isAdminView(): boolean {
+    return (
+      this.view === GerritView.ADMIN ||
+      this.view === GerritView.GROUP ||
+      this.view === GerritView.REPO
+    );
   }
 
   needsReload(): boolean {
-    if (!this.params) return false;
+    if (!this.isAdminView()) return false;
 
     let needsReload = false;
     const newRepoName =
-      this.params.view === GerritView.REPO ? this.params.repo : undefined;
+      this.view === GerritView.REPO ? this.repoViewState?.repo : undefined;
     if (newRepoName !== this.repoName) {
       this.repoName = newRepoName;
       // Reloads the admin menu.
       needsReload = true;
     }
     const newGroupId =
-      this.params.view === GerritView.GROUP ? this.params.groupId : undefined;
+      this.view === GerritView.GROUP ? this.groupViewState?.groupId : undefined;
     if (newGroupId !== this.groupId) {
       this.groupId = newGroupId;
       // Reloads the admin menu.
@@ -593,8 +582,8 @@
     }
     if (
       this.breadcrumbParentName &&
-      (this.params.view !== GerritView.GROUP || !this.params.groupId) &&
-      (this.params.view !== GerritView.REPO || !this.params.repo)
+      (this.view !== GerritView.GROUP || !this.groupViewState?.groupId) &&
+      (this.view !== GerritView.REPO || !this.repoViewState?.repo)
     ) {
       needsReload = true;
     }
@@ -613,42 +602,34 @@
   }
 
   private computeSelectedClass(
-    itemView?: GerritView,
+    itemView?: GerritView | AdminChildView,
     detailType?: GroupDetailView | RepoDetailView
   ) {
-    const params = this.params;
-    if (!params) return '';
-    // Group params are structured differently from admin params. Compute
+    if (!this.view) return '';
+    // Group view state is structured differently than admin view state. Compute
     // selected differently for groups.
-    // TODO(wyatta): Simplify this when all routes work like group params.
-    if (params.view === GerritView.GROUP && itemView === GerritView.GROUP) {
-      if (!params.detail && !detailType) {
+    // TODO(wyatta): Simplify this when all routes work like group view state.
+    if (this.view === GerritView.GROUP && itemView === GerritView.GROUP) {
+      if (!this.groupViewState?.detail && !detailType) {
         return 'selected';
       }
-      if (params.detail === detailType) {
+      if (this.groupViewState?.detail === detailType) {
         return 'selected';
       }
       return '';
     }
 
-    if (params.view === GerritView.REPO && itemView === GerritView.REPO) {
-      if (!params.detail && !detailType) {
+    if (this.view === GerritView.REPO && itemView === GerritView.REPO) {
+      if (!this.repoViewState?.detail && !detailType) {
         return 'selected';
       }
-      if (params.detail === detailType) {
+      if (this.repoViewState?.detail === detailType) {
         return 'selected';
       }
       return '';
     }
-    // TODO(TS): The following condition seems always false, because params
-    // never has detailType property. Remove it.
-    if (
-      (params as unknown as AdminSubsectionLink).detailType &&
-      (params as unknown as AdminSubsectionLink).detailType !== detailType
-    ) {
-      return '';
-    }
-    return params.view === GerritView.ADMIN && itemView === params.adminView
+    return this.view === GerritView.ADMIN &&
+      itemView === this.adminViewState?.adminView
       ? 'selected'
       : '';
   }
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.ts b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.ts
index 0f473c3..d65d171 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.ts
@@ -1,34 +1,25 @@
 /**
  * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-admin-view';
 import {AdminSubsectionLink, GrAdminView} from './gr-admin-view';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
-import {mockPromise, stubBaseUrl, stubRestApi} from '../../../test/test-utils';
+import {stubBaseUrl, stubElement, stubRestApi} from '../../../test/test-utils';
 import {GerritView} from '../../../services/router/router-model';
 import {query, queryAll, queryAndAssert} from '../../../test/test-utils';
 import {GrRepoList} from '../gr-repo-list/gr-repo-list';
 import {GroupId, GroupName, RepoName, Timestamp} from '../../../types/common';
 import {GrDropdownList} from '../../shared/gr-dropdown-list/gr-dropdown-list';
 import {GrGroup} from '../gr-group/gr-group';
-
-const basicFixture = fixtureFromElement('gr-admin-view');
+import {fixture, html, assert} from '@open-wc/testing';
+import {AdminChildView} from '../../../models/views/admin';
+import {GroupDetailView} from '../../../models/views/group';
+import {RepoDetailView} from '../../../models/views/repo';
+import {testResolver} from '../../../test/common-test-setup';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
 
 function createAdminCapabilities() {
   return {
@@ -42,7 +33,7 @@
   let element: GrAdminView;
 
   setup(async () => {
-    element = basicFixture.instantiate();
+    element = await fixture(html`<gr-admin-view></gr-admin-view>`);
     stubRestApi('getProjectConfig').returns(Promise.resolve(undefined));
     const pluginsLoaded = Promise.resolve();
     sinon.stub(getPluginLoader(), 'awaitPluginsLoaded').returns(pluginsLoaded);
@@ -86,9 +77,10 @@
       },
     ];
 
-    element.params = {
+    element.view = GerritView.ADMIN;
+    element.adminViewState = {
       view: GerritView.ADMIN,
-      adminView: 'gr-repo-list',
+      adminView: AdminChildView.REPOS,
     };
 
     await element.updateComplete;
@@ -171,6 +163,7 @@
   });
 
   test('Repo shows up in nav', async () => {
+    element.view = GerritView.REPO;
     element.repoName = 'Test Repo' as RepoName;
     stubRestApi('getAccount').returns(
       Promise.resolve({
@@ -220,7 +213,7 @@
     assert.isNotOk(element.filteredLinks![2].subsection);
   });
 
-  test('Nav is reloaded when repo changes', async () => {
+  test('Needs reload when repo changes', async () => {
     stubRestApi('getAccountCapabilities').returns(
       Promise.resolve(createAdminCapabilities())
     );
@@ -230,16 +223,19 @@
         registered_on: '2015-03-12 18:32:08.000000000' as Timestamp,
       })
     );
-    const reloadStub = sinon.stub(element, 'reload');
-    element.params = {repo: 'Test Repo' as RepoName, view: GerritView.REPO};
+
+    element.view = GerritView.REPO;
+    element.repoViewState = {repo: 'Repo 1' as RepoName, view: GerritView.REPO};
+    assert.isTrue(element.needsReload());
+    await element.reload();
     await element.updateComplete;
-    assert.equal(reloadStub.callCount, 1);
-    element.params = {repo: 'Test Repo 2' as RepoName, view: GerritView.REPO};
+
+    element.repoViewState = {repo: 'Repo 2' as RepoName, view: GerritView.REPO};
+    assert.isTrue(element.needsReload());
     await element.updateComplete;
-    assert.equal(reloadStub.callCount, 2);
   });
 
-  test('Nav is reloaded when group changes', async () => {
+  test('Needs reload when group changes', async () => {
     sinon.stub(element, 'computeGroupName');
     stubRestApi('getAccountCapabilities').returns(
       Promise.resolve(createAdminCapabilities())
@@ -250,13 +246,12 @@
         registered_on: '2015-03-12 18:32:08.000000000' as Timestamp,
       })
     );
-    const reloadStub = sinon.stub(element, 'reload');
-    element.params = {groupId: '1' as GroupId, view: GerritView.GROUP};
-    await element.updateComplete;
-    assert.equal(reloadStub.callCount, 1);
+    element.view = GerritView.GROUP;
+    element.groupViewState = {groupId: '1' as GroupId, view: GerritView.GROUP};
+    assert.isTrue(element.needsReload());
   });
 
-  test('Nav is reloaded when changing from repo to group', async () => {
+  test('Needs reload when changing from repo to group', async () => {
     element.repoName = 'Test Repo' as RepoName;
     stubRestApi('getAccount').returns(
       Promise.resolve({
@@ -271,26 +266,25 @@
     await element.updateComplete;
 
     sinon.stub(element, 'computeGroupName');
-    const reloadStub = sinon.stub(element, 'reload');
     const groupId = '1' as GroupId;
-    element.params = {groupId, view: GerritView.GROUP};
+    element.view = GerritView.GROUP;
+    element.groupViewState = {groupId, view: GerritView.GROUP};
     await element.updateComplete;
 
-    assert.equal(reloadStub.callCount, 1);
+    assert.isTrue(element.needsReload());
     assert.equal(element.groupId, groupId);
   });
 
-  test('Nav is reloaded when group name changes', async () => {
+  test('Needs reload when group name changes', async () => {
     const newName = 'newName' as GroupName;
-    const reloadCalled = mockPromise();
     sinon.stub(element, 'computeGroupName');
-    sinon.stub(element, 'reload').callsFake(() => {
-      reloadCalled.resolve();
-      return Promise.resolve();
-    });
-    element.params = {groupId: '1' as GroupId, view: GerritView.GROUP};
+    element.view = GerritView.GROUP;
+    element.groupViewState = {groupId: '1' as GroupId, view: GerritView.GROUP};
     element.groupName = 'oldName' as GroupName;
+    assert.isTrue(element.needsReload());
+    await element.reload();
     await element.updateComplete;
+
     queryAndAssert<GrGroup>(element, 'gr-group').dispatchEvent(
       new CustomEvent('name-changed', {
         detail: {name: newName},
@@ -298,11 +292,11 @@
         bubbles: true,
       })
     );
-    await reloadCalled;
     assert.equal(element.groupName, newName);
   });
 
   test('dropdown displays if there is a subsection', async () => {
+    element.view = GerritView.REPO;
     assert.isNotOk(query(element, '.mainHeader'));
     element.subsectionLinks = [
       {
@@ -322,10 +316,11 @@
 
   test('Dropdown only triggers navigation on explicit select', async () => {
     element.repoName = 'my-repo' as RepoName;
-    element.params = {
+    element.view = GerritView.REPO;
+    element.repoViewState = {
       repo: 'my-repo' as RepoName,
-      view: GerritNav.View.REPO,
-      detail: GerritNav.RepoDetailView.ACCESS,
+      view: GerritView.REPO,
+      detail: RepoDetailView.ACCESS,
     };
     stubRestApi('getAccountCapabilities').returns(
       Promise.resolve(createAdminCapabilities())
@@ -337,6 +332,7 @@
       })
     );
     await element.updateComplete;
+
     const expectedFilteredLinks = [
       {
         name: 'Repositories',
@@ -351,38 +347,38 @@
             {
               name: 'General',
               view: GerritView.REPO,
-              url: '',
-              detailType: GerritNav.RepoDetailView.GENERAL,
+              url: '/admin/repos/my-repo,general',
+              detailType: RepoDetailView.GENERAL,
             },
             {
               name: 'Access',
               view: GerritView.REPO,
-              detailType: GerritNav.RepoDetailView.ACCESS,
-              url: '',
+              detailType: RepoDetailView.ACCESS,
+              url: '/admin/repos/my-repo,access',
             },
             {
               name: 'Commands',
               view: GerritView.REPO,
-              detailType: GerritNav.RepoDetailView.COMMANDS,
-              url: '',
+              detailType: RepoDetailView.COMMANDS,
+              url: '/admin/repos/my-repo,commands',
             },
             {
               name: 'Branches',
               view: GerritView.REPO,
-              detailType: GerritNav.RepoDetailView.BRANCHES,
-              url: '',
+              detailType: RepoDetailView.BRANCHES,
+              url: '/admin/repos/my-repo,branches',
             },
             {
               name: 'Tags',
               view: GerritView.REPO,
-              detailType: GerritNav.RepoDetailView.TAGS,
-              url: '',
+              detailType: RepoDetailView.TAGS,
+              url: '/admin/repos/my-repo,tags',
             },
             {
               name: 'Dashboards',
               view: GerritView.REPO,
-              detailType: GerritNav.RepoDetailView.DASHBOARDS,
-              url: '',
+              detailType: RepoDetailView.DASHBOARDS,
+              url: '/admin/repos/my-repo,dashboards',
             },
           ],
         },
@@ -416,81 +412,84 @@
         text: 'General',
         value: 'repogeneral',
         view: GerritView.REPO,
-        url: '',
-        detailType: GerritNav.RepoDetailView.GENERAL,
+        url: '/admin/repos/my-repo,general',
+        detailType: RepoDetailView.GENERAL,
         parent: 'my-repo' as RepoName,
       },
       {
         text: 'Access',
         value: 'repoaccess',
         view: GerritView.REPO,
-        url: '',
-        detailType: GerritNav.RepoDetailView.ACCESS,
+        url: '/admin/repos/my-repo,access',
+        detailType: RepoDetailView.ACCESS,
         parent: 'my-repo' as RepoName,
       },
       {
         text: 'Commands',
         value: 'repocommands',
         view: GerritView.REPO,
-        url: '',
-        detailType: GerritNav.RepoDetailView.COMMANDS,
+        url: '/admin/repos/my-repo,commands',
+        detailType: RepoDetailView.COMMANDS,
         parent: 'my-repo' as RepoName,
       },
       {
         text: 'Branches',
         value: 'repobranches',
         view: GerritView.REPO,
-        url: '',
-        detailType: GerritNav.RepoDetailView.BRANCHES,
+        url: '/admin/repos/my-repo,branches',
+        detailType: RepoDetailView.BRANCHES,
         parent: 'my-repo' as RepoName,
       },
       {
         text: 'Tags',
         value: 'repotags',
         view: GerritView.REPO,
-        url: '',
-        detailType: GerritNav.RepoDetailView.TAGS,
+        url: '/admin/repos/my-repo,tags',
+        detailType: RepoDetailView.TAGS,
         parent: 'my-repo' as RepoName,
       },
       {
         text: 'Dashboards',
         value: 'repodashboards',
         view: GerritView.REPO,
-        url: '',
-        detailType: GerritNav.RepoDetailView.DASHBOARDS,
+        url: '/admin/repos/my-repo,dashboards',
+        detailType: RepoDetailView.DASHBOARDS,
         parent: 'my-repo' as RepoName,
       },
     ];
-    const navigateToRelativeUrlStub = sinon.stub(
-      GerritNav,
-      'navigateToRelativeUrl'
-    );
+    const setUrlStub = sinon.stub(testResolver(navigationToken), 'setUrl');
     const selectedIsCurrentPageSpy = sinon.spy(
       element,
       'selectedIsCurrentPage'
     );
     sinon.spy(element, 'handleSubsectionChange');
     await element.reload();
+    await element.updateComplete;
     assert.deepEqual(element.filteredLinks, expectedFilteredLinks);
     assert.deepEqual(element.subsectionLinks, expectedSubsectionLinks);
     assert.equal(
       queryAndAssert<GrDropdownList>(element, '#pageSelect').value,
       'repoaccess'
     );
-    assert.isTrue(selectedIsCurrentPageSpy.calledOnce);
+    assert.equal(selectedIsCurrentPageSpy.callCount, 1);
     // Doesn't trigger navigation from the page select menu.
-    assert.isFalse(navigateToRelativeUrlStub.called);
+    assert.isFalse(setUrlStub.called);
 
     // When explicitly changed, navigation is called
     queryAndAssert<GrDropdownList>(element, '#pageSelect').value =
       'repogeneral';
-    assert.isTrue(selectedIsCurrentPageSpy.calledTwice);
-    assert.isTrue(navigateToRelativeUrlStub.calledOnce);
+    await queryAndAssert<GrDropdownList>(element, '#pageSelect').updateComplete;
+    assert.equal(selectedIsCurrentPageSpy.callCount, 2);
+    assert.isTrue(setUrlStub.calledOnce);
   });
 
   test('selectedIsCurrentPage', () => {
     element.repoName = 'my-repo' as RepoName;
-    element.params = {view: GerritView.REPO, repo: 'my-repo' as RepoName};
+    element.view = GerritView.REPO;
+    element.repoViewState = {
+      view: GerritView.REPO,
+      repo: 'my-repo' as RepoName,
+    };
     const selected = {
       view: GerritView.REPO,
       parent: 'my-repo' as RepoName,
@@ -500,7 +499,7 @@
     assert.isTrue(element.selectedIsCurrentPage(selected));
     selected.parent = 'my-second-repo' as RepoName;
     assert.isFalse(element.selectedIsCurrentPage(selected));
-    selected.detailType = GerritNav.RepoDetailView.GENERAL;
+    selected.detailType = RepoDetailView.GENERAL;
     assert.isFalse(element.selectedIsCurrentPage(selected));
   });
 
@@ -518,17 +517,67 @@
       await element.reload();
     });
 
+    test('render', async () => {
+      element.view = GerritView.ADMIN;
+      element.adminViewState = {
+        view: GerritView.ADMIN,
+        adminView: AdminChildView.REPOS,
+        openCreateModal: false,
+      };
+      await element.updateComplete;
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <gr-page-nav class="navStyles">
+            <ul class="sectionContent">
+              <li class="sectionTitle selected">
+                <a
+                  class="title"
+                  href="//localhost:9876/admin/repos"
+                  rel="noopener"
+                >
+                  Repositories
+                </a>
+              </li>
+              <li class="sectionTitle">
+                <a
+                  class="title"
+                  href="//localhost:9876/admin/groups"
+                  rel="noopener"
+                >
+                  Groups
+                </a>
+              </li>
+              <li class="sectionTitle">
+                <a
+                  class="title"
+                  href="//localhost:9876/admin/plugins"
+                  rel="noopener"
+                >
+                  Plugins
+                </a>
+              </li>
+            </ul>
+          </gr-page-nav>
+          <div class="main table">
+            <gr-repo-list class="table"></gr-repo-list>
+          </div>
+        `
+      );
+    });
+
     suite('repos', () => {
       setup(() => {
-        stub('gr-repo-access', '_repoChanged').callsFake(() =>
+        stubElement('gr-repo-access', '_repoChanged').callsFake(() =>
           Promise.resolve()
         );
       });
 
       test('repo list', async () => {
-        element.params = {
-          view: GerritNav.View.ADMIN,
-          adminView: 'gr-repo-list',
+        element.view = GerritView.ADMIN;
+        element.adminViewState = {
+          view: GerritView.ADMIN,
+          adminView: AdminChildView.REPOS,
           openCreateModal: false,
         };
         await element.updateComplete;
@@ -538,8 +587,9 @@
       });
 
       test('repo', async () => {
-        element.params = {
-          view: GerritNav.View.REPO,
+        element.view = GerritView.REPO;
+        element.repoViewState = {
+          view: GerritView.REPO,
           repo: 'foo' as RepoName,
         };
         element.repoName = 'foo' as RepoName;
@@ -551,9 +601,10 @@
       });
 
       test('repo access', async () => {
-        element.params = {
-          view: GerritNav.View.REPO,
-          detail: GerritNav.RepoDetailView.ACCESS,
+        element.view = GerritView.REPO;
+        element.repoViewState = {
+          view: GerritView.REPO,
+          detail: RepoDetailView.ACCESS,
           repo: 'foo' as RepoName,
         };
         element.repoName = 'foo' as RepoName;
@@ -565,9 +616,10 @@
       });
 
       test('repo dashboards', async () => {
-        element.params = {
-          view: GerritNav.View.REPO,
-          detail: GerritNav.RepoDetailView.DASHBOARDS,
+        element.view = GerritView.REPO;
+        element.repoViewState = {
+          view: GerritView.REPO,
+          detail: RepoDetailView.DASHBOARDS,
           repo: 'foo' as RepoName,
         };
         element.repoName = 'foo' as RepoName;
@@ -583,8 +635,8 @@
       let getGroupConfigStub: sinon.SinonStub;
 
       setup(async () => {
-        stub('gr-group', 'loadGroup').callsFake(() => Promise.resolve());
-        stub('gr-group-members', 'loadGroupDetails').callsFake(() =>
+        stubElement('gr-group', 'loadGroup').callsFake(() => Promise.resolve());
+        stubElement('gr-group-members', 'loadGroupDetails').callsFake(() =>
           Promise.resolve()
         );
 
@@ -600,9 +652,10 @@
       });
 
       test('group list', async () => {
-        element.params = {
-          view: GerritNav.View.ADMIN,
-          adminView: 'gr-admin-group-list',
+        element.view = GerritView.ADMIN;
+        element.adminViewState = {
+          view: GerritView.ADMIN,
+          adminView: AdminChildView.GROUPS,
           openCreateModal: false,
         };
         await element.updateComplete;
@@ -612,12 +665,13 @@
       });
 
       test('internal group', async () => {
-        element.params = {
-          view: GerritNav.View.GROUP,
+        element.view = GerritView.GROUP;
+        element.groupViewState = {
+          view: GerritView.GROUP,
           groupId: '1234' as GroupId,
         };
         element.groupName = 'foo' as GroupName;
-        await element.reload();
+        if (element.needsReload()) await element.reload();
         await element.updateComplete;
         const subsectionItems = queryAll<HTMLLIElement>(
           element,
@@ -637,12 +691,13 @@
             id: 'external-id',
           })
         );
-        element.params = {
-          view: GerritNav.View.GROUP,
+        element.view = GerritView.GROUP;
+        element.groupViewState = {
+          view: GerritView.GROUP,
           groupId: '1234' as GroupId,
         };
         element.groupName = 'foo' as GroupName;
-        await element.reload();
+        if (element.needsReload()) await element.reload();
         await element.updateComplete;
         const subsectionItems = queryAll<HTMLLIElement>(
           element,
@@ -656,13 +711,14 @@
       });
 
       test('group members', async () => {
-        element.params = {
-          view: GerritNav.View.GROUP,
-          detail: GerritNav.GroupDetailView.MEMBERS,
+        element.view = GerritView.GROUP;
+        element.groupViewState = {
+          view: GerritView.GROUP,
+          detail: GroupDetailView.MEMBERS,
           groupId: '1234' as GroupId,
         };
         element.groupName = 'foo' as GroupName;
-        await element.reload();
+        if (element.needsReload()) await element.reload();
         await element.updateComplete;
         const selected = queryAndAssert(element, 'gr-page-nav .selected');
         assert.isOk(selected);
diff --git a/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.ts b/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.ts
index 04d3198..42ec988 100644
--- a/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.ts
+++ b/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.ts
@@ -1,23 +1,12 @@
 /**
  * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '../../shared/gr-dialog/gr-dialog';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {css, html, LitElement} from 'lit';
-import {customElement, property} from 'lit/decorators';
+import {customElement, property} from 'lit/decorators.js';
 
 declare global {
   interface HTMLElementTagNameMap {
diff --git a/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog_test.ts b/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog_test.ts
index e286883..d4b5f03 100644
--- a/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog_test.ts
@@ -1,34 +1,43 @@
 /**
  * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-confirm-delete-item-dialog';
 import {GrConfirmDeleteItemDialog} from './gr-confirm-delete-item-dialog';
 import {queryAndAssert} from '../../../test/test-utils';
 import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
-
-const basicFixture = fixtureFromElement('gr-confirm-delete-item-dialog');
+import {fixture, html, assert} from '@open-wc/testing';
 
 suite('gr-confirm-delete-item-dialog tests', () => {
   let element: GrConfirmDeleteItemDialog;
 
   setup(async () => {
-    element = basicFixture.instantiate();
-    await flush();
+    element = await fixture(
+      html`<gr-confirm-delete-item-dialog></gr-confirm-delete-item-dialog>`
+    );
+  });
+
+  test('render', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <gr-dialog
+          confirm-label="Delete UNKNOWN ITEM TYPE"
+          confirm-on-enter=""
+          role="dialog"
+        >
+          <div class="header" slot="header">UNKNOWN ITEM TYPE Deletion</div>
+          <div class="main" slot="main">
+            <label for="branchInput">
+              Do you really want to delete the following UNKNOWN ITEM TYPE?
+            </label>
+            <div>UNKNOWN ITEM</div>
+          </div>
+        </gr-dialog>
+      `
+    );
   });
 
   test('_handleConfirmTap', () => {
diff --git a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.ts b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.ts
index 8e797b7..cee0fa4 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.ts
+++ b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.ts
@@ -1,26 +1,16 @@
 /**
  * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '@polymer/iron-input/iron-input';
+import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
 import '../../../styles/gr-form-styles';
 import '../../../styles/shared-styles';
 import '../../shared/gr-autocomplete/gr-autocomplete';
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-select/gr-select';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
 import {
   RepoName,
   BranchName,
@@ -32,12 +22,13 @@
 import {formStyles} from '../../../styles/gr-form-styles';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, PropertyValues, css, html} from 'lit';
-import {customElement, property, query, state} from 'lit/decorators';
+import {customElement, property, query, state} from 'lit/decorators.js';
 import {BindValueChangeEvent} from '../../../types/events';
 import {fireEvent} from '../../../utils/event-util';
 import {subscribe} from '../../lit/subscription-controller';
 import {configModelToken} from '../../../models/config/config-model';
 import {resolve} from '../../../models/dependency';
+import {createChangeUrl} from '../../../models/views/change';
 
 const SUGGESTIONS_LIMIT = 15;
 const REF_PREFIX = 'refs/heads/';
@@ -81,19 +72,20 @@
 
   private readonly configModel = resolve(this, configModelToken);
 
+  private readonly getNavigation = resolve(this, navigationToken);
+
   constructor() {
     super();
     this.query = (input: string) => this.getRepoBranchesSuggestions(input);
-  }
 
-  override connectedCallback() {
-    super.connectedCallback();
-    if (!this.repoName) return;
-
-    subscribe(this, this.configModel().serverConfig$, config => {
-      this.privateChangesEnabled =
-        config?.change?.disable_private_changes ?? false;
-    });
+    subscribe(
+      this,
+      () => this.configModel().serverConfig$,
+      config => {
+        this.privateChangesEnabled =
+          config?.change?.disable_private_changes ?? false;
+      }
+    );
   }
 
   static override get styles() {
@@ -185,7 +177,7 @@
               .bindValue=${this.subject}
               placeholder="Insert the description of the change."
               @bind-value-changed=${(e: BindValueChangeEvent) => {
-                this.subject = e.detail.value;
+                this.subject = e.detail.value ?? '';
               }}
             >
             </iron-autogrow-textarea>
@@ -234,9 +226,9 @@
         this.baseChange,
         this.baseCommit || undefined
       )
-      .then(changeCreated => {
-        if (!changeCreated) return;
-        GerritNav.navigateToChange(changeCreated);
+      .then(change => {
+        if (!change) return;
+        this.getNavigation().setUrl(createChangeUrl({change}));
       });
   }
 
diff --git a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_test.ts b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_test.ts
index 626b03f..87916b6 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_test.ts
@@ -1,21 +1,9 @@
 /**
  * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-create-change-dialog';
 import {GrCreateChangeDialog} from './gr-create-change-dialog';
 import {BranchName, GitRef, RepoName} from '../../../types/common';
@@ -23,8 +11,7 @@
 import {createChange} from '../../../test/test-data-generators';
 import {queryAndAssert, stubRestApi} from '../../../test/test-utils';
 import {IronAutogrowTextareaElement} from '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
-
-const basicFixture = fixtureFromElement('gr-create-change-dialog');
+import {fixture, html, assert} from '@open-wc/testing';
 
 suite('gr-create-change-dialog tests', () => {
   let element: GrCreateChangeDialog;
@@ -43,11 +30,79 @@
         return Promise.resolve([]);
       }
     });
-    element = basicFixture.instantiate();
-    await element.updateComplete;
+    element = await fixture(
+      html`<gr-create-change-dialog></gr-create-change-dialog>`
+    );
     element.repoName = 'test-repo' as RepoName;
   });
 
+  test('render', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div class="gr-form-styles">
+          <section>
+            <span class="title"> Select branch for new change </span>
+            <span class="value">
+              <gr-autocomplete
+                id="branchInput"
+                placeholder="Destination branch"
+              >
+              </gr-autocomplete>
+            </span>
+          </section>
+          <section>
+            <span class="title"> Provide base commit sha1 for change </span>
+            <span class="value">
+              <iron-input>
+                <input
+                  id="baseCommitInput"
+                  maxlength="40"
+                  placeholder="(optional)"
+                />
+              </iron-input>
+            </span>
+          </section>
+          <section>
+            <span class="title"> Enter topic for new change </span>
+            <span class="value">
+              <iron-input>
+                <input
+                  id="tagNameInput"
+                  maxlength="1024"
+                  placeholder="(optional)"
+                />
+              </iron-input>
+            </span>
+          </section>
+          <section id="description">
+            <span class="title"> Description </span>
+            <span class="value">
+              <iron-autogrow-textarea
+                aria-disabled="false"
+                autocomplete="on"
+                class="message"
+                id="messageInput"
+                maxrows="15"
+                placeholder="Insert the description of the change."
+                rows="4"
+              >
+              </iron-autogrow-textarea>
+            </span>
+          </section>
+          <section>
+            <label class="title" for="privateChangeCheckBox">
+              Private change
+            </label>
+            <span class="value">
+              <input id="privateChangeCheckBox" type="checkbox" />
+            </span>
+          </section>
+        </div>
+      `
+    );
+  });
+
   test('new change created with default', async () => {
     const configInputObj = {
       branch: 'test-branch',
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 9ab7646..4808d00 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.ts
+++ b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.ts
@@ -1,18 +1,7 @@
 /**
  * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '@polymer/iron-input/iron-input';
 import '../../../styles/gr-form-styles';
@@ -24,7 +13,7 @@
 import {formStyles} from '../../../styles/gr-form-styles';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, PropertyValues, css, html} from 'lit';
-import {customElement, query, property} from 'lit/decorators';
+import {customElement, query, property} from 'lit/decorators.js';
 import {BindValueChangeEvent} from '../../../types/events';
 import {fireEvent} from '../../../utils/event-util';
 
diff --git a/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog_test.ts b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog_test.ts
index f84c76c..2a0b539 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog_test.ts
@@ -1,21 +1,9 @@
 /**
  * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-create-group-dialog';
 import {GrCreateGroupDialog} from './gr-create-group-dialog';
 import {page} from '../../../utils/page-wrapper-utils';
@@ -26,8 +14,7 @@
 } from '../../../test/test-utils';
 import {IronInputElement} from '@polymer/iron-input';
 import {GroupId} from '../../../types/common';
-
-const basicFixture = fixtureFromElement('gr-create-group-dialog');
+import {fixture, html, assert} from '@open-wc/testing';
 
 suite('gr-create-group-dialog tests', () => {
   let element: GrCreateGroupDialog;
@@ -35,8 +22,27 @@
   const GROUP_NAME = 'test-group';
 
   setup(async () => {
-    element = basicFixture.instantiate();
-    await element.updateComplete;
+    element = await fixture(
+      html`<gr-create-group-dialog></gr-create-group-dialog>`
+    );
+  });
+
+  test('render', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div class="gr-form-styles">
+          <div id="form">
+            <section>
+              <span class="title"> Group name </span>
+              <iron-input>
+                <input />
+              </iron-input>
+            </section>
+          </div>
+        </div>
+      `
+    );
   });
 
   test('name is updated correctly', async () => {
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 63b852c..889a859 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog.ts
+++ b/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog.ts
@@ -1,18 +1,7 @@
 /**
  * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '@polymer/iron-input/iron-input';
 import '../../shared/gr-button/gr-button';
@@ -21,13 +10,13 @@
 import {page} from '../../../utils/page-wrapper-utils';
 import {BranchName, RepoName} from '../../../types/common';
 import {getAppContext} from '../../../services/app-context';
-import {RepoDetailView} from '../../core/gr-navigation/gr-navigation';
 import {formStyles} from '../../../styles/gr-form-styles';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, PropertyValues, css, html} from 'lit';
-import {customElement, property, state} from 'lit/decorators';
+import {customElement, property, state} from 'lit/decorators.js';
 import {BindValueChangeEvent} from '../../../types/events';
 import {fireEvent} from '../../../utils/event-util';
+import {RepoDetailView} from '../../../models/views/repo';
 
 declare global {
   interface HTMLElementTagNameMap {
diff --git a/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog_test.ts b/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog_test.ts
index b888c348..9e455d1 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog_test.ts
@@ -1,21 +1,9 @@
 /**
  * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-create-pointer-dialog';
 import {GrCreatePointerDialog} from './gr-create-pointer-dialog';
 import {
@@ -24,10 +12,9 @@
   stubRestApi,
 } from '../../../test/test-utils';
 import {BranchName} from '../../../types/common';
-import {RepoDetailView} from '../../core/gr-navigation/gr-navigation';
 import {IronInputElement} from '@polymer/iron-input';
-
-const basicFixture = fixtureFromElement('gr-create-pointer-dialog');
+import {fixture, html, assert} from '@open-wc/testing';
+import {RepoDetailView} from '../../../models/views/repo';
 
 suite('gr-create-pointer-dialog tests', () => {
   let element: GrCreatePointerDialog;
@@ -36,8 +23,39 @@
     queryAndAssert<IronInputElement>(element, 'iron-input');
 
   setup(async () => {
-    element = basicFixture.instantiate();
-    await element.updateComplete;
+    element = await fixture(
+      html`<gr-create-pointer-dialog></gr-create-pointer-dialog>`
+    );
+  });
+
+  test('render', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div class="gr-form-styles">
+          <div id="form">
+            <section id="itemNameSection">
+              <span class="title"> name </span>
+              <iron-input>
+                <input placeholder=" Name" />
+              </iron-input>
+            </section>
+            <section id="itemRevisionSection">
+              <span class="title"> Initial Revision </span>
+              <iron-input>
+                <input placeholder="Revision (Branch or SHA-1)" />
+              </iron-input>
+            </section>
+            <section id="itemAnnotationSection">
+              <span class="title"> Annotation </span>
+              <iron-input>
+                <input placeholder="Annotation (Optional)" />
+              </iron-input>
+            </section>
+          </div>
+        </div>
+      `
+    );
   });
 
   test('branch created', async () => {
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 617ef99..158419e 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.ts
+++ b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.ts
@@ -1,18 +1,7 @@
 /**
  * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '@polymer/iron-input/iron-input';
 import '../../shared/gr-autocomplete/gr-autocomplete';
@@ -32,7 +21,7 @@
 import {formStyles} from '../../../styles/gr-form-styles';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, css, html} from 'lit';
-import {customElement, query, property, state} from 'lit/decorators';
+import {customElement, query, property, state} from 'lit/decorators.js';
 import {fireEvent} from '../../../utils/event-util';
 
 declare global {
diff --git a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_test.ts b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_test.ts
index d3e2171..63b8c06 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_test.ts
@@ -1,21 +1,9 @@
 /**
  * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-create-repo-dialog';
 import {GrCreateRepoDialog} from './gr-create-repo-dialog';
 import {
@@ -26,15 +14,75 @@
 import {BranchName, GroupId, RepoName} from '../../../types/common';
 import {GrAutocomplete} from '../../shared/gr-autocomplete/gr-autocomplete';
 import {GrSelect} from '../../shared/gr-select/gr-select';
-
-const basicFixture = fixtureFromElement('gr-create-repo-dialog');
+import {fixture, html, assert} from '@open-wc/testing';
 
 suite('gr-create-repo-dialog tests', () => {
   let element: GrCreateRepoDialog;
 
   setup(async () => {
-    element = basicFixture.instantiate();
-    await element.updateComplete;
+    element = await fixture(
+      html`<gr-create-repo-dialog></gr-create-repo-dialog>`
+    );
+  });
+
+  test('render', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div class="gr-form-styles">
+          <div id="form">
+            <section>
+              <span class="title"> Repository name </span>
+              <iron-input>
+                <input autocomplete="on" id="repoNameInput" />
+              </iron-input>
+            </section>
+            <section>
+              <span class="title"> Default Branch </span>
+              <iron-input>
+                <input autocomplete="off" id="defaultBranchNameInput" />
+              </iron-input>
+            </section>
+            <section>
+              <span class="title"> Rights inherit from </span>
+              <span class="value">
+                <gr-autocomplete id="rightsInheritFromInput"> </gr-autocomplete>
+              </span>
+            </section>
+            <section>
+              <span class="title"> Owner </span>
+              <span class="value">
+                <gr-autocomplete id="ownerInput"> </gr-autocomplete>
+              </span>
+            </section>
+            <section>
+              <span class="title"> Create initial empty commit </span>
+              <span class="value">
+                <gr-select id="initialCommit">
+                  <select>
+                    <option value="false">False</option>
+                    <option value="true">True</option>
+                  </select>
+                </gr-select>
+              </span>
+            </section>
+            <section>
+              <span class="title">
+                Only serve as parent for other repositories
+              </span>
+              <span class="value">
+                <gr-select id="parentRepo">
+                  <select>
+                    <option value="false">False</option>
+                    <option value="true">True</option>
+                  </select>
+                </gr-select>
+              </span>
+            </section>
+          </div>
+        </div>
+      `
+    );
   });
 
   test('default values are populated', () => {
diff --git a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.ts b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.ts
index 7a80396..e0c0d30 100644
--- a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.ts
+++ b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.ts
@@ -1,22 +1,9 @@
 /**
  * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
 import '../../shared/gr-account-label/gr-account-label';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {
   GroupInfo,
   AccountInfo,
@@ -31,7 +18,8 @@
 import {sharedStyles} from '../../../styles/shared-styles';
 import {tableStyles} from '../../../styles/gr-table-styles';
 import {LitElement, PropertyValues, css, html} from 'lit';
-import {customElement, property, state} from 'lit/decorators';
+import {customElement, property, state} from 'lit/decorators.js';
+import {createGroupUrl} from '../../../models/views/group';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -180,12 +168,9 @@
     return isGroupAuditGroupEventInfo(event);
   }
 
-  private computeGroupUrl(group: GroupInfo) {
-    if (group && group.url && group.id) {
-      return GerritNav.getUrlForGroup(group.id);
-    }
-
-    return '';
+  private computeGroupUrl(group?: GroupInfo) {
+    if (!group?.id) return '';
+    return createGroupUrl({groupId: group.id});
   }
 
   // private but used in test
diff --git a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_test.ts b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_test.ts
index 79b635a..828a3c6 100644
--- a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_test.ts
@@ -1,21 +1,9 @@
 /**
  * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-group-audit-log';
 import {
   addListenerForTest,
@@ -35,15 +23,34 @@
   createGroupInfo,
 } from '../../../test/test-data-generators';
 import {PageErrorEvent} from '../../../types/events';
-
-const basicFixture = fixtureFromElement('gr-group-audit-log');
+import {fixture, html, assert} from '@open-wc/testing';
 
 suite('gr-group-audit-log tests', () => {
   let element: GrGroupAuditLog;
 
   setup(async () => {
-    element = basicFixture.instantiate();
-    await element.updateComplete;
+    element = await fixture(html`<gr-group-audit-log></gr-group-audit-log>`);
+  });
+
+  test('render', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <table class="genericList" id="list">
+          <tbody>
+            <tr class="headerRow">
+              <th class="date topHeader">Date</th>
+              <th class="topHeader type">Type</th>
+              <th class="member topHeader">Member</th>
+              <th class="by-user topHeader">By User</th>
+            </tr>
+            <tr class="loading loadingMsg" id="loading">
+              <td>Loading...</td>
+            </tr>
+          </tbody>
+        </table>
+      `
+    );
   });
 
   suite('members', () => {
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 ad90759..c716d65 100644
--- a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.ts
+++ b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.ts
@@ -1,18 +1,7 @@
 /**
  * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
 import '../../shared/gr-account-label/gr-account-label';
@@ -28,6 +17,7 @@
   AccountInfo,
   GroupInfo,
   GroupName,
+  ServerInfo,
 } from '../../../types/common';
 import {
   AutocompleteQuery,
@@ -48,10 +38,13 @@
 import {subpageStyles} from '../../../styles/gr-subpage-styles';
 import {tableStyles} from '../../../styles/gr-table-styles';
 import {LitElement, css, html} from 'lit';
-import {customElement, property, query, state} from 'lit/decorators';
-import {ifDefined} from 'lit/directives/if-defined';
+import {customElement, property, query, state} from 'lit/decorators.js';
+import {ifDefined} from 'lit/directives/if-defined.js';
+import {getAccountSuggestions} from '../../../utils/account-util';
+import {subscribe} from '../../lit/subscription-controller';
+import {configModelToken} from '../../../models/config/config-model';
+import {resolve} from '../../../models/dependency';
 
-const SUGGESTIONS_LIMIT = 15;
 const SAVING_ERROR_TEXT =
   'Group may not exist, or you may not have ' + 'permission to add it';
 
@@ -112,9 +105,21 @@
 
   private readonly restApiService = getAppContext().restApiService;
 
+  private readonly getConfigModel = resolve(this, configModelToken);
+
+  private serverConfig?: ServerInfo;
+
   constructor() {
     super();
-    this.queryMembers = input => this.getAccountSuggestions(input);
+    subscribe(
+      this,
+      () => this.getConfigModel().serverConfig$,
+      config => {
+        this.serverConfig = config;
+      }
+    );
+    this.queryMembers = input =>
+      getAccountSuggestions(input, this.restApiService, this.serverConfig);
     this.queryIncludedGroup = input => this.getGroupSuggestions(input);
   }
 
@@ -524,32 +529,6 @@
   }
 
   /* private but used in test */
-  getAccountSuggestions(input: string) {
-    if (input.length === 0) {
-      return Promise.resolve([]);
-    }
-    return this.restApiService
-      .getSuggestedAccounts(input, SUGGESTIONS_LIMIT)
-      .then(accounts => {
-        if (!accounts) return [];
-        const accountSuggestions = [];
-        for (const account of accounts) {
-          let nameAndEmail;
-          if (account.email !== undefined) {
-            nameAndEmail = `${account.name} <${account.email}>`;
-          } else {
-            nameAndEmail = account.name;
-          }
-          accountSuggestions.push({
-            name: nameAndEmail,
-            value: account._account_id?.toString(),
-          });
-        }
-        return accountSuggestions;
-      });
-  }
-
-  /* private but used in test */
   getGroupSuggestions(input: string) {
     return this.restApiService.getSuggestedGroups(input).then(response => {
       const groups: AutocompleteSuggestion[] = [];
diff --git a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.ts b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.ts
index 762bd2d..6c65dd6 100644
--- a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.ts
@@ -1,21 +1,9 @@
 /**
  * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-group-members';
 import {GrGroupMembers, ItemType} from './gr-group-members';
 import {
@@ -37,10 +25,11 @@
 } from '../../../types/common';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {GrAutocomplete} from '../../shared/gr-autocomplete/gr-autocomplete';
-import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
-import {PageErrorEvent} from '../../../types/events.js';
-
-const basicFixture = fixtureFromElement('gr-group-members');
+import {EventType, PageErrorEvent} from '../../../types/events';
+import {getAccountSuggestions} from '../../../utils/account-util';
+import {getAppContext} from '../../../services/app-context';
+import {fixture, html, assert} from '@open-wc/testing';
+import {createServerInfo} from '../../../test/test-data-generators';
 
 suite('gr-group-members tests', () => {
   let element: GrGroupMembers;
@@ -114,6 +103,7 @@
             name: 'test-account',
             email: 'test.account@example.com' as EmailAddress,
             username: 'test123',
+            display_name: 'display-test-account',
           },
           {
             _account_id: 1001439 as AccountId,
@@ -148,14 +138,228 @@
     stubRestApi('getGroupMembers').returns(Promise.resolve(groupMembers));
     stubRestApi('getIsGroupOwner').returns(Promise.resolve(true));
     stubRestApi('getIncludedGroup').returns(Promise.resolve(includedGroups));
-    element = basicFixture.instantiate();
-    await element.updateComplete;
+    element = await fixture(html`<gr-group-members></gr-group-members>`);
     stubBaseUrl('https://test/site');
     element.groupId = 'testId1' as GroupId;
     groupStub = stubRestApi('getGroupConfig').returns(Promise.resolve(groups));
     return element.loadGroupDetails();
   });
 
+  test('render', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div class="gr-form-styles main">
+          <div id="loading">Loading...</div>
+          <div id="loadedContent">
+            <h1 class="heading-1" id="Title">Administrators</h1>
+            <div id="form">
+              <h3 class="heading-3" id="members">Members</h3>
+              <fieldset>
+                <span class="value">
+                  <gr-autocomplete
+                    id="groupMemberSearchInput"
+                    placeholder="Name Or Email"
+                  >
+                  </gr-autocomplete>
+                </span>
+                <gr-button
+                  aria-disabled="true"
+                  disabled=""
+                  id="saveGroupMember"
+                  role="button"
+                  tabindex="-1"
+                >
+                  Add
+                </gr-button>
+                <table id="groupMembers">
+                  <tbody>
+                    <tr class="headerRow">
+                      <th class="nameHeader">Name</th>
+                      <th class="emailAddressHeader">Email Address</th>
+                      <th class="deleteHeader">Delete Member</th>
+                    </tr>
+                  </tbody>
+                  <tbody>
+                    <tr>
+                      <td class="nameColumn">
+                        <gr-account-label clickable="" deselected="">
+                        </gr-account-label>
+                      </td>
+                      <td>jane.roe@example.com</td>
+                      <td class="deleteColumn">
+                        <gr-button
+                          aria-disabled="false"
+                          class="deleteMembersButton"
+                          data-index="0"
+                          role="button"
+                          tabindex="0"
+                        >
+                          Delete
+                        </gr-button>
+                      </td>
+                    </tr>
+                    <tr>
+                      <td class="nameColumn">
+                        <gr-account-label clickable="" deselected="">
+                        </gr-account-label>
+                      </td>
+                      <td>john.doe@example.com</td>
+                      <td class="deleteColumn">
+                        <gr-button
+                          aria-disabled="false"
+                          class="deleteMembersButton"
+                          data-index="1"
+                          role="button"
+                          tabindex="0"
+                        >
+                          Delete
+                        </gr-button>
+                      </td>
+                    </tr>
+                    <tr>
+                      <td class="nameColumn">
+                        <gr-account-label clickable="" deselected="">
+                        </gr-account-label>
+                      </td>
+                      <td></td>
+                      <td class="deleteColumn">
+                        <gr-button
+                          aria-disabled="false"
+                          class="deleteMembersButton"
+                          data-index="2"
+                          role="button"
+                          tabindex="0"
+                        >
+                          Delete
+                        </gr-button>
+                      </td>
+                    </tr>
+                    <tr>
+                      <td class="nameColumn">
+                        <gr-account-label clickable="" deselected="">
+                        </gr-account-label>
+                      </td>
+                      <td></td>
+                      <td class="deleteColumn">
+                        <gr-button
+                          aria-disabled="false"
+                          class="deleteMembersButton"
+                          data-index="3"
+                          role="button"
+                          tabindex="0"
+                        >
+                          Delete
+                        </gr-button>
+                      </td>
+                    </tr>
+                  </tbody>
+                </table>
+              </fieldset>
+              <h3 class="heading-3" id="includedGroups">Included Groups</h3>
+              <fieldset>
+                <span class="value">
+                  <gr-autocomplete
+                    id="includedGroupSearchInput"
+                    placeholder="Group Name"
+                  >
+                  </gr-autocomplete>
+                </span>
+                <gr-button
+                  aria-disabled="true"
+                  disabled=""
+                  id="saveIncludedGroups"
+                  role="button"
+                  tabindex="-1"
+                >
+                  Add
+                </gr-button>
+                <table id="includedGroups">
+                  <tbody>
+                    <tr class="headerRow">
+                      <th class="groupNameHeader">Group Name</th>
+                      <th class="descriptionHeader">Description</th>
+                      <th class="deleteIncludedHeader">Delete Group</th>
+                    </tr>
+                  </tbody>
+                  <tbody>
+                    <tr>
+                      <td class="nameColumn">
+                        <a href="https://group/url" rel="noopener">
+                          testName
+                        </a>
+                      </td>
+                      <td></td>
+                      <td class="deleteColumn">
+                        <gr-button
+                          aria-disabled="false"
+                          class="deleteIncludedGroupButton"
+                          data-index="0"
+                          role="button"
+                          tabindex="0"
+                        >
+                          Delete
+                        </gr-button>
+                      </td>
+                    </tr>
+                    <tr>
+                      <td class="nameColumn">
+                        <a href="https://test/site/group/url" rel="noopener">
+                          testName2
+                        </a>
+                      </td>
+                      <td></td>
+                      <td class="deleteColumn">
+                        <gr-button
+                          aria-disabled="false"
+                          class="deleteIncludedGroupButton"
+                          data-index="1"
+                          role="button"
+                          tabindex="0"
+                        >
+                          Delete
+                        </gr-button>
+                      </td>
+                    </tr>
+                    <tr>
+                      <td class="nameColumn">
+                        <a href="https://test/site/group/url" rel="noopener">
+                          testName3
+                        </a>
+                      </td>
+                      <td></td>
+                      <td class="deleteColumn">
+                        <gr-button
+                          aria-disabled="false"
+                          class="deleteIncludedGroupButton"
+                          data-index="2"
+                          role="button"
+                          tabindex="0"
+                        >
+                          Delete
+                        </gr-button>
+                      </td>
+                    </tr>
+                  </tbody>
+                </table>
+              </fieldset>
+            </div>
+          </div>
+        </div>
+        <gr-overlay
+          aria-hidden="true"
+          id="overlay"
+          style="outline: none; display: none;"
+          tabindex="-1"
+          with-backdrop=""
+        >
+          <gr-confirm-delete-item-dialog class="confirmDialog">
+          </gr-confirm-delete-item-dialog>
+        </gr-overlay>
+      `
+    );
+  });
+
   test('includedGroups', () => {
     assert.equal(element.includedGroups!.length, 3);
     assert.equal(
@@ -248,7 +452,7 @@
 
     const memberName = 'bad-name';
     const alertStub = sinon.stub();
-    element.addEventListener('show-alert', alertStub);
+    element.addEventListener(EventType.SHOW_ALERT, alertStub);
     const errorResponse = {...new Response(), status: 404, ok: false};
     stubRestApi('saveIncludedGroup').callsFake((_, _non, errFn) => {
       if (errFn !== undefined) {
@@ -294,14 +498,25 @@
   });
 
   test('getAccountSuggestions empty', async () => {
-    const accounts = await element.getAccountSuggestions('nonexistent');
+    const accounts = await getAccountSuggestions(
+      'nonexistent',
+      getAppContext().restApiService,
+      createServerInfo()
+    );
     assert.equal(accounts.length, 0);
   });
 
   test('getAccountSuggestions non-empty', async () => {
-    const accounts = await element.getAccountSuggestions('test-');
+    const accounts = await getAccountSuggestions(
+      'test-',
+      getAppContext().restApiService,
+      createServerInfo()
+    );
     assert.equal(accounts.length, 3);
-    assert.equal(accounts[0].name, 'test-account <test.account@example.com>');
+    assert.equal(
+      accounts[0].name,
+      'display-test-account <test.account@example.com>'
+    );
     assert.equal(accounts[1].name, 'test-admin <test.admin@example.com>');
     assert.equal(accounts[2].name, 'test-git');
   });
@@ -322,16 +537,16 @@
 
   test('delete member', () => {
     const deleteBtns = queryAll<GrButton>(element, '.deleteMembersButton');
-    MockInteractions.tap(deleteBtns[0]);
+    deleteBtns[0].click();
     assert.equal(element.itemId, 1000097 as AccountId);
     assert.equal(element.itemName, 'jane');
-    MockInteractions.tap(deleteBtns[1]);
+    deleteBtns[1].click();
     assert.equal(element.itemId, 1000096 as AccountId);
     assert.equal(element.itemName, 'Test User');
-    MockInteractions.tap(deleteBtns[2]);
+    deleteBtns[2].click();
     assert.equal(element.itemId, 1000095 as AccountId);
     assert.equal(element.itemName, 'Gerrit');
-    MockInteractions.tap(deleteBtns[3]);
+    deleteBtns[3].click();
     assert.equal(element.itemId, 1000098 as AccountId);
     assert.equal(element.itemName, '1000098');
   });
@@ -341,13 +556,13 @@
       element,
       '.deleteIncludedGroupButton'
     );
-    MockInteractions.tap(deleteBtns[0]);
+    deleteBtns[0].click();
     assert.equal(element.itemId, 'testId' as GroupId);
     assert.equal(element.itemName, 'testName');
-    MockInteractions.tap(deleteBtns[1]);
+    deleteBtns[1].click();
     assert.equal(element.itemId, 'testId2' as GroupId);
     assert.equal(element.itemName, 'testName2');
-    MockInteractions.tap(deleteBtns[2]);
+    deleteBtns[2].click();
     assert.equal(element.itemId, 'testId3' as GroupId);
     assert.equal(element.itemName, 'testName3');
   });
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 fc53ac6..e65b16b 100644
--- a/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts
+++ b/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts
@@ -1,20 +1,8 @@
 /**
  * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
 import '../../shared/gr-autocomplete/gr-autocomplete';
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-copy-clipboard/gr-copy-clipboard';
@@ -35,7 +23,7 @@
 import {sharedStyles} from '../../../styles/shared-styles';
 import {subpageStyles} from '../../../styles/gr-subpage-styles';
 import {LitElement, PropertyValues, css, html} from 'lit';
-import {customElement, property, state} from 'lit/decorators';
+import {customElement, property, state} from 'lit/decorators.js';
 
 const INTERNAL_GROUP_REGEX = /^[\da-f]{40}$/;
 
@@ -242,7 +230,7 @@
             rows="4"
             monospace
             ?disabled=${this.computeGroupDisabled()}
-            .text=${this.groupConfig?.description}
+            .text=${this.groupConfig?.description ?? ''}
             @text-changed=${this.handleDescriptionTextChanged}
           ></gr-textarea>
         </div>
diff --git a/polygerrit-ui/app/elements/admin/gr-group/gr-group_test.ts b/polygerrit-ui/app/elements/admin/gr-group/gr-group_test.ts
index a6258b0..256c6a9 100644
--- a/polygerrit-ui/app/elements/admin/gr-group/gr-group_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-group/gr-group_test.ts
@@ -1,21 +1,9 @@
 /**
  * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-group';
 import {GrGroup} from './gr-group';
 import {
@@ -25,14 +13,13 @@
   stubRestApi,
   waitUntil,
 } from '../../../test/test-utils';
-import {createGroupInfo} from '../../../test/test-data-generators.js';
+import {createGroupInfo} from '../../../test/test-data-generators';
 import {GroupId, GroupInfo, GroupName} from '../../../types/common';
 import {GrAutocomplete} from '../../shared/gr-autocomplete/gr-autocomplete';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {GrCopyClipboard} from '../../shared/gr-copy-clipboard/gr-copy-clipboard';
 import {GrSelect} from '../../shared/gr-select/gr-select';
-
-const basicFixture = fixtureFromElement('gr-group');
+import {fixture, html, assert} from '@open-wc/testing';
 
 suite('gr-group tests', () => {
   let element: GrGroup;
@@ -52,11 +39,118 @@
   };
 
   setup(async () => {
-    element = basicFixture.instantiate();
-    await element.updateComplete;
+    element = await fixture(html`<gr-group></gr-group>`);
     groupStub = stubRestApi('getGroupConfig').returns(Promise.resolve(group));
   });
 
+  test('render', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div class="gr-form-styles main read-only">
+          <div class="loading" id="loading">Loading...</div>
+          <div class="loading" id="loadedContent">
+            <h1 class="heading-1" id="Title"></h1>
+            <h2 class="heading-2" id="configurations">General</h2>
+            <div id="form">
+              <fieldset>
+                <h3 class="heading-3" id="groupUUID">Group UUID</h3>
+                <fieldset>
+                  <gr-copy-clipboard id="uuid"> </gr-copy-clipboard>
+                </fieldset>
+                <h3 class="heading-3" id="groupName">Group Name</h3>
+                <fieldset>
+                  <span class="value">
+                    <gr-autocomplete disabled="" id="groupNameInput">
+                    </gr-autocomplete>
+                  </span>
+                  <span class="value">
+                    <gr-button
+                      aria-disabled="true"
+                      disabled=""
+                      id="inputUpdateNameBtn"
+                      role="button"
+                      tabindex="-1"
+                    >
+                      Rename Group
+                    </gr-button>
+                  </span>
+                </fieldset>
+                <h3 class="heading-3" id="groupOwner">Owners</h3>
+                <fieldset>
+                  <span class="value">
+                    <gr-autocomplete disabled="" id="groupOwnerInput">
+                    </gr-autocomplete>
+                  </span>
+                  <span class="value">
+                    <gr-button
+                      aria-disabled="true"
+                      disabled=""
+                      id="inputUpdateOwnerBtn"
+                      role="button"
+                      tabindex="-1"
+                    >
+                      Change Owners
+                    </gr-button>
+                  </span>
+                </fieldset>
+                <h3 class="heading-3">Description</h3>
+                <fieldset>
+                  <div>
+                    <gr-textarea
+                      autocomplete="on"
+                      class="description monospace"
+                      disabled=""
+                      monospace=""
+                      rows="4"
+                    >
+                    </gr-textarea>
+                  </div>
+                  <span class="value">
+                    <gr-button
+                      aria-disabled="true"
+                      disabled=""
+                      role="button"
+                      tabindex="-1"
+                    >
+                      Save Description
+                    </gr-button>
+                  </span>
+                </fieldset>
+                <h3 class="heading-3" id="options">Group Options</h3>
+                <fieldset>
+                  <section>
+                    <span class="title">
+                      Make group visible to all registered users
+                    </span>
+                    <span class="value">
+                      <gr-select id="visibleToAll">
+                        <select disabled="">
+                          <option value="false">False</option>
+                          <option value="true">True</option>
+                        </select>
+                      </gr-select>
+                    </span>
+                  </section>
+                  <span class="value">
+                    <gr-button
+                      aria-disabled="true"
+                      disabled=""
+                      role="button"
+                      tabindex="-1"
+                    >
+                      Save Group Options
+                    </gr-button>
+                  </span>
+                </fieldset>
+              </fieldset>
+            </div>
+          </div>
+        </div>
+      `
+    );
+  });
+
   test('loading displays before group config is loaded', () => {
     assert.isTrue(
       queryAndAssert<HTMLDivElement>(element, '#loading').classList.contains(
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 ff3df99..35e50ad 100644
--- a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.ts
+++ b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.ts
@@ -1,20 +1,8 @@
 /**
  * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
 import '@polymer/paper-toggle-button/paper-toggle-button';
 import '../../shared/gr-autocomplete/gr-autocomplete';
 import '../../shared/gr-button/gr-button';
@@ -26,7 +14,7 @@
   PermissionArray,
   AccessPermissionId,
 } from '../../../utils/access-util';
-import {customElement, property, query, state} from 'lit/decorators';
+import {customElement, property, query, state} from 'lit/decorators.js';
 import {
   LabelNameToLabelTypeInfoMap,
   LabelTypeInfoValues,
@@ -51,7 +39,7 @@
 import {paperStyles} from '../../../styles/gr-paper-styles';
 import {formStyles} from '../../../styles/gr-form-styles';
 import {menuPageStyles} from '../../../styles/gr-menu-page-styles';
-import {when} from 'lit/directives/when';
+import {when} from 'lit/directives/when.js';
 import {ValueChangedEvent} from '../../../types/events';
 
 const MAX_AUTOCOMPLETE_RESULTS = 20;
diff --git a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_test.ts b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_test.ts
index b77a9ef0..b6bc3ed 100644
--- a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_test.ts
@@ -1,27 +1,14 @@
 /**
  * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-permission';
 import {GrPermission} from './gr-permission';
-import {query, stubRestApi} from '../../../test/test-utils';
+import {query, stubRestApi, waitEventLoop} from '../../../test/test-utils';
 import {GitRef, GroupId, GroupName} from '../../../types/common';
 import {PermissionAction} from '../../../constants/constants';
-import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
 import {
   AutocompleteCommitEventDetail,
   GrAutocomplete,
@@ -29,14 +16,14 @@
 import {queryAndAssert} from '../../../test/test-utils';
 import {GrRuleEditor} from '../gr-rule-editor/gr-rule-editor';
 import {GrButton} from '../../shared/gr-button/gr-button';
-
-const basicFixture = fixtureFromElement('gr-permission');
+import {fixture, html, assert} from '@open-wc/testing';
+import {PaperToggleButtonElement} from '@polymer/paper-toggle-button';
 
 suite('gr-permission tests', () => {
   let element: GrPermission;
 
-  setup(() => {
-    element = basicFixture.instantiate();
+  setup(async () => {
+    element = await fixture(html`<gr-permission></gr-permission>`);
     stubRestApi('getSuggestedGroups').returns(
       Promise.resolve({
         Administrators: {
@@ -326,7 +313,74 @@
       };
       element.setupValues();
       await element.updateComplete;
-      flush();
+      await waitEventLoop();
+    });
+
+    test('render', () => {
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <section class="gr-form-styles" id="permission">
+            <div id="mainContainer">
+              <div class="header">
+                <span class="title"> Priority </span>
+                <div class="right">
+                  <paper-toggle-button
+                    aria-disabled="true"
+                    aria-pressed="false"
+                    disabled=""
+                    id="exclusiveToggle"
+                    role="button"
+                    style="pointer-events: none; touch-action: none;"
+                    tabindex="-1"
+                    toggles=""
+                  >
+                  </paper-toggle-button>
+                  Not Exclusive
+                  <gr-button
+                    aria-disabled="false"
+                    id="removeBtn"
+                    link=""
+                    role="button"
+                    tabindex="0"
+                  >
+                    Remove
+                  </gr-button>
+                </div>
+              </div>
+              <div class="rules">
+                <gr-rule-editor> </gr-rule-editor>
+                <gr-rule-editor> </gr-rule-editor>
+                <div id="addRule">
+                  <gr-autocomplete
+                    id="groupAutocomplete"
+                    placeholder="Add group"
+                  >
+                  </gr-autocomplete>
+                </div>
+              </div>
+            </div>
+            <div id="deletedContainer">
+              <span> Priority was deleted </span>
+              <gr-button
+                aria-disabled="false"
+                id="undoRemoveBtn"
+                link=""
+                role="button"
+                tabindex="0"
+              >
+                Undo
+              </gr-button>
+            </div>
+          </section>
+        `,
+        // touch-action varies on paper-toggle-button between local and CI
+        {
+          ignoreAttributes: [
+            {tags: ['paper-toggle-button'], attributes: ['style']},
+          ],
+        }
+      );
     });
 
     test('adding a rule', async () => {
@@ -384,7 +438,7 @@
           bubbles: true,
         })
       );
-      await flush();
+      await waitEventLoop();
       assert.equal(element.rules!.length, 1);
     });
 
@@ -396,7 +450,7 @@
       element.section = 'refs/*' as GitRef;
       element.permission!.value.added = true;
       await element.updateComplete;
-      MockInteractions.tap(queryAndAssert<GrButton>(element, '#removeBtn'));
+      queryAndAssert<GrButton>(element, '#removeBtn').click();
       await element.updateComplete;
       assert.isTrue(removeStub.called);
     });
@@ -414,13 +468,14 @@
         queryAndAssert(element, '#permission').classList.contains('deleted')
       );
       assert.isFalse(element.deleted);
-      MockInteractions.tap(queryAndAssert<GrButton>(element, '#removeBtn'));
+      queryAndAssert<GrButton>(element, '#removeBtn').click();
       await element.updateComplete;
       assert.isTrue(
         queryAndAssert(element, '#permission').classList.contains('deleted')
       );
       assert.isTrue(element.deleted);
-      MockInteractions.tap(queryAndAssert<GrButton>(element, '#undoRemoveBtn'));
+      queryAndAssert<GrButton>(element, '#undoRemoveBtn').click();
+
       await element.updateComplete;
       assert.isFalse(
         queryAndAssert(element, '#permission').classList.contains('deleted')
@@ -437,7 +492,10 @@
 
       assert.isFalse(element.originalExclusiveValue);
       assert.isNotOk(element.permission!.value.modified);
-      MockInteractions.tap(queryAndAssert(element, '#exclusiveToggle'));
+      queryAndAssert<PaperToggleButtonElement>(
+        element,
+        '#exclusiveToggle'
+      ).click();
       await element.updateComplete;
       assert.isTrue(element.permission!.value.exclusive);
       assert.isTrue(element.permission!.value.modified);
@@ -456,7 +514,10 @@
       element.addEventListener('access-modified', modifiedHandler);
       await element.updateComplete;
       assert.isNotOk(element.permission.value.modified);
-      MockInteractions.tap(queryAndAssert(element, '#exclusiveToggle'));
+      queryAndAssert<PaperToggleButtonElement>(
+        element,
+        '#exclusiveToggle'
+      ).click();
       await element.updateComplete;
       assert.isTrue(element.permission.value.modified);
       assert.isTrue(modifiedHandler.called);
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 2033180..54a83ee 100644
--- a/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor.ts
+++ b/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor.ts
@@ -1,20 +1,8 @@
 /**
  * @license
- * Copyright (C) 2018 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
 import '@polymer/iron-input/iron-input';
 import '@polymer/paper-toggle-button/paper-toggle-button';
 import '../../shared/gr-button/gr-button';
@@ -25,7 +13,7 @@
 import {formStyles} from '../../../styles/gr-form-styles';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, html, css} from 'lit';
-import {customElement, property, state} from 'lit/decorators';
+import {customElement, property, state} from 'lit/decorators.js';
 import {BindValueChangeEvent} from '../../../types/events';
 
 declare global {
@@ -159,8 +147,7 @@
   }
 
   private handleInputKeydown(e: KeyboardEvent) {
-    // Enter.
-    if (e.keyCode === 13) {
+    if (e.key === 'Enter') {
       e.preventDefault();
       this.handleAdd();
     }
@@ -194,6 +181,6 @@
   }
 
   private handleBindValueChangedNewValue(e: BindValueChangeEvent) {
-    this.newValue = e.detail.value;
+    this.newValue = e.detail.value ?? '';
   }
 }
diff --git a/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor_test.ts b/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor_test.ts
index 5de1f1e..672d58e 100644
--- a/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor_test.ts
@@ -1,28 +1,16 @@
 /**
  * @license
- * Copyright (C) 2018 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import {ConfigParameterInfoType} from '../../../constants/constants.js';
-import '../../../test/common-test-setup-karma';
+import {ConfigParameterInfoType} from '../../../constants/constants';
+import '../../../test/common-test-setup';
 import './gr-plugin-config-array-editor';
 import {GrPluginConfigArrayEditor} from './gr-plugin-config-array-editor';
-import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
-import {queryAll, queryAndAssert} from '../../../test/test-utils.js';
-import {GrButton} from '../../shared/gr-button/gr-button.js';
-import {fixture, html} from '@open-wc/testing-helpers';
+import {queryAll, queryAndAssert, pressKey} from '../../../test/test-utils';
+import {GrButton} from '../../shared/gr-button/gr-button';
+import {fixture, html, assert} from '@open-wc/testing';
+import {Key} from '../../../utils/dom-util';
 
 suite('gr-plugin-config-array-editor tests', () => {
   let element: GrPluginConfigArrayEditor;
@@ -42,6 +30,32 @@
     };
   });
 
+  test('render', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div class="gr-form-styles wrapper">
+          <div class="placeholder row">None configured.</div>
+          <div class="row">
+            <iron-input>
+              <input id="input" />
+            </iron-input>
+            <gr-button
+              aria-disabled="true"
+              disabled=""
+              id="addButton"
+              link=""
+              role="button"
+              tabindex="-1"
+            >
+              Add
+            </gr-button>
+          </div>
+        </div>
+      `
+    );
+  });
+
   suite('adding', () => {
     setup(() => {
       dispatchStub = sinon.stub(element, 'dispatchChanged');
@@ -50,10 +64,7 @@
     test('with enter', async () => {
       element.newValue = '';
       await element.updateComplete;
-      MockInteractions.pressAndReleaseKeyOn(
-        queryAndAssert<HTMLInputElement>(element, '#input'),
-        13
-      ); // Enter
+      pressKey(queryAndAssert<HTMLInputElement>(element, '#input'), Key.ENTER);
       await element.updateComplete;
       assert.isFalse(
         queryAndAssert<HTMLInputElement>(element, '#input').hasAttribute(
@@ -66,10 +77,7 @@
       element.newValue = 'test';
       await element.updateComplete;
 
-      MockInteractions.pressAndReleaseKeyOn(
-        queryAndAssert<HTMLInputElement>(element, '#input'),
-        13
-      ); // Enter
+      pressKey(queryAndAssert<HTMLInputElement>(element, '#input'), Key.ENTER);
       await element.updateComplete;
       assert.isFalse(
         queryAndAssert<HTMLInputElement>(element, '#input').hasAttribute(
@@ -115,7 +123,7 @@
     assert.equal(rows.length, 2);
     const button = queryAndAssert<GrButton>(rows[0], 'gr-button');
 
-    MockInteractions.tap(button);
+    button.click();
     await element.updateComplete;
 
     assert.isFalse(dispatchStub.called);
diff --git a/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.ts b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.ts
index 3393596..383b4a7 100644
--- a/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.ts
+++ b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.ts
@@ -1,20 +1,8 @@
 /**
  * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
 import '../../shared/gr-list-view/gr-list-view';
 import {PluginInfo} from '../../../types/common';
 import {firePageError, fireTitleChange} from '../../../utils/event-util';
@@ -22,11 +10,11 @@
 import {ErrorCallback} from '../../../api/rest';
 import {encodeURL, getBaseUrl} from '../../../utils/url-util';
 import {SHOWN_ITEMS_COUNT} from '../../../constants/constants';
-import {AppElementAdminParams} from '../../gr-app-types';
 import {tableStyles} from '../../../styles/gr-table-styles';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, PropertyValues, css, html} from 'lit';
-import {customElement, property, state} from 'lit/decorators';
+import {customElement, property, state} from 'lit/decorators.js';
+import {AdminViewState} from '../../../models/views/admin';
 
 // Exported for tests
 export interface PluginInfoWithName extends PluginInfo {
@@ -41,7 +29,7 @@
    * URL params passed from the router.
    */
   @property({type: Object})
-  params?: AppElementAdminParams;
+  params?: AdminViewState;
 
   /**
    * Offset of currently visible query results.
diff --git a/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_test.ts b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_test.ts
index ca77a6d..4057e52 100644
--- a/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_test.ts
@@ -1,21 +1,9 @@
 /**
  * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-plugin-list';
 import {GrPluginList, PluginInfoWithName} from './gr-plugin-list';
 import {
@@ -27,12 +15,11 @@
   stubRestApi,
 } from '../../../test/test-utils';
 import {PluginInfo} from '../../../types/common';
-import {AppElementAdminParams} from '../../gr-app-types';
 import {GerritView} from '../../../services/router/router-model';
 import {PageErrorEvent} from '../../../types/events';
 import {SHOWN_ITEMS_COUNT} from '../../../constants/constants';
-
-const basicFixture = fixtureFromElement('gr-plugin-list');
+import {fixture, html, assert} from '@open-wc/testing';
+import {AdminChildView, AdminViewState} from '../../../models/views/admin';
 
 function pluginGenerator(counter: number) {
   const plugin: PluginInfo = {
@@ -72,11 +59,13 @@
   let element: GrPluginList;
   let plugins: {[pluginName: string]: PluginInfo} | undefined;
 
-  const value: AppElementAdminParams = {view: GerritView.ADMIN, adminView: ''};
+  const value: AdminViewState = {
+    view: GerritView.ADMIN,
+    adminView: AdminChildView.PLUGINS,
+  };
 
   setup(async () => {
-    element = basicFixture.instantiate();
-    await element.updateComplete;
+    element = await fixture(html`<gr-plugin-list></gr-plugin-list>`);
   });
 
   suite('list with plugins', async () => {
@@ -88,6 +77,228 @@
       await element.updateComplete;
     });
 
+    test('render', () => {
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <gr-list-view>
+            <table class="genericList" id="list">
+              <tbody>
+                <tr class="headerRow">
+                  <th class="name topHeader">Plugin Name</th>
+                  <th class="topHeader version">Version</th>
+                  <th class="apiVersion topHeader">API Version</th>
+                  <th class="status topHeader">Status</th>
+                </tr>
+              </tbody>
+              <tbody>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/plugins/test0/"> test0 </a>
+                  </td>
+                  <td class="version">version-0</td>
+                  <td class="apiVersion">api-version-0</td>
+                  <td class="status">Enabled</td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/plugins/test1/"> test1 </a>
+                  </td>
+                  <td class="version">version-1</td>
+                  <td class="apiVersion">api-version-1</td>
+                  <td class="status">Enabled</td>
+                </tr>
+                <tr class="table">
+                  <td class="name">test2</td>
+                  <td class="version">version-2</td>
+                  <td class="apiVersion">api-version-2</td>
+                  <td class="status">Enabled</td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/plugins/test3/"> test3 </a>
+                  </td>
+                  <td class="version">version-3</td>
+                  <td class="apiVersion">api-version-3</td>
+                  <td class="status">Enabled</td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/plugins/test4/"> test4 </a>
+                  </td>
+                  <td class="version">version-4</td>
+                  <td class="apiVersion">
+                    <span class="placeholder"> -- </span>
+                  </td>
+                  <td class="status">Enabled</td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/plugins/test5/"> test5 </a>
+                  </td>
+                  <td class="version">version-5</td>
+                  <td class="apiVersion">api-version-5</td>
+                  <td class="status">Enabled</td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/plugins/test6/"> test6 </a>
+                  </td>
+                  <td class="version">version-6</td>
+                  <td class="apiVersion">api-version-6</td>
+                  <td class="status">Enabled</td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/plugins/test7/"> test7 </a>
+                  </td>
+                  <td class="version">version-7</td>
+                  <td class="apiVersion">api-version-7</td>
+                  <td class="status">Enabled</td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/plugins/test8/"> test8 </a>
+                  </td>
+                  <td class="version">version-8</td>
+                  <td class="apiVersion">api-version-8</td>
+                  <td class="status">Enabled</td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/plugins/test9/"> test9 </a>
+                  </td>
+                  <td class="version">version-9</td>
+                  <td class="apiVersion">api-version-9</td>
+                  <td class="status">Enabled</td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/plugins/test10/"> test10 </a>
+                  </td>
+                  <td class="version">version-10</td>
+                  <td class="apiVersion">api-version-10</td>
+                  <td class="status">Enabled</td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/plugins/test11/"> test11 </a>
+                  </td>
+                  <td class="version">version-11</td>
+                  <td class="apiVersion">api-version-11</td>
+                  <td class="status">Enabled</td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/plugins/test12/"> test12 </a>
+                  </td>
+                  <td class="version">version-12</td>
+                  <td class="apiVersion">api-version-12</td>
+                  <td class="status">Enabled</td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/plugins/test13/"> test13 </a>
+                  </td>
+                  <td class="version">version-13</td>
+                  <td class="apiVersion">api-version-13</td>
+                  <td class="status">Enabled</td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/plugins/test14/"> test14 </a>
+                  </td>
+                  <td class="version">version-14</td>
+                  <td class="apiVersion">api-version-14</td>
+                  <td class="status">Enabled</td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/plugins/test15/"> test15 </a>
+                  </td>
+                  <td class="version">version-15</td>
+                  <td class="apiVersion">api-version-15</td>
+                  <td class="status">Enabled</td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/plugins/test16/"> test16 </a>
+                  </td>
+                  <td class="version">version-16</td>
+                  <td class="apiVersion">api-version-16</td>
+                  <td class="status">Enabled</td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/plugins/test17/"> test17 </a>
+                  </td>
+                  <td class="version">version-17</td>
+                  <td class="apiVersion">api-version-17</td>
+                  <td class="status">Enabled</td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/plugins/test18/"> test18 </a>
+                  </td>
+                  <td class="version">version-18</td>
+                  <td class="apiVersion">api-version-18</td>
+                  <td class="status">Enabled</td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/plugins/test19/"> test19 </a>
+                  </td>
+                  <td class="version">version-19</td>
+                  <td class="apiVersion">api-version-19</td>
+                  <td class="status">Enabled</td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/plugins/test20/"> test20 </a>
+                  </td>
+                  <td class="version">version-20</td>
+                  <td class="apiVersion">api-version-20</td>
+                  <td class="status">Enabled</td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/plugins/test21/"> test21 </a>
+                  </td>
+                  <td class="version">version-21</td>
+                  <td class="apiVersion">api-version-21</td>
+                  <td class="status">Enabled</td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/plugins/test22/"> test22 </a>
+                  </td>
+                  <td class="version">version-22</td>
+                  <td class="apiVersion">api-version-22</td>
+                  <td class="status">Enabled</td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/plugins/test23/"> test23 </a>
+                  </td>
+                  <td class="version">version-23</td>
+                  <td class="apiVersion">api-version-23</td>
+                  <td class="status">Enabled</td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/plugins/test24/"> test24 </a>
+                  </td>
+                  <td class="version">version-24</td>
+                  <td class="apiVersion">api-version-24</td>
+                  <td class="status">Enabled</td>
+                </tr>
+              </tbody>
+            </table>
+          </gr-list-view>
+        `
+      );
+    });
+
     test('plugin in the list is formatted correctly', async () => {
       await element.updateComplete;
       assert.equal(element.plugins![5].id, 'test5');
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access-interfaces.ts b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access-interfaces.ts
index 6136f1e1..c7bd36fe 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access-interfaces.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access-interfaces.ts
@@ -1,18 +1,7 @@
 /**
  * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 /**
  * @fileoverview This file contains interfaces shared between gr-repo-access
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 ad15ab6..21ab184 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.ts
@@ -1,23 +1,11 @@
 /**
  * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
 import '../gr-access-section/gr-access-section';
 import {encodeURL, getBaseUrl, singleDecodeURL} from '../../../utils/url-util';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
 import {toSortedPermissionsArray} from '../../../utils/access-util';
 import {
   RepoName,
@@ -49,10 +37,12 @@
 import {subpageStyles} from '../../../styles/gr-subpage-styles';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, PropertyValues, css, html} from 'lit';
-import {customElement, property, query, state} from 'lit/decorators';
+import {customElement, property, query, state} from 'lit/decorators.js';
 import {assertIsDefined} from '../../../utils/common-util';
 import {ValueChangedEvent} from '../../../types/events';
-import {ifDefined} from 'lit/directives/if-defined';
+import {ifDefined} from 'lit/directives/if-defined.js';
+import {resolve} from '../../../models/dependency';
+import {createChangeUrl} from '../../../models/views/change';
 
 const NOTHING_TO_SAVE = 'No changes to save.';
 
@@ -76,9 +66,6 @@
   @property({type: String})
   repo?: RepoName;
 
-  @property({type: String})
-  path?: string;
-
   // private but used in test
   @state() canUpload?: boolean = false; // restAPI can return undefined
 
@@ -124,6 +111,8 @@
 
   private readonly restApiService = getAppContext().restApiService;
 
+  private readonly getNavigation = resolve(this, navigationToken);
+
   constructor() {
     super();
     this.query = () => this.getInheritFromSuggestions();
@@ -428,8 +417,11 @@
     this.editing = !this.editing;
   }
 
-  private handleAddedSectionRemoved(index: number) {
+  // private but used in tests
+  handleAddedSectionRemoved(index: number) {
     if (!this.sections) return;
+    assertIsDefined(this.local, 'local');
+    delete this.local[this.sections[index].id];
     this.sections = this.sections
       .slice(0, index)
       .concat(this.sections.slice(index + 1, this.sections.length));
@@ -623,7 +615,7 @@
     return addRemoveObj;
   }
 
-  private async handleCreateSection() {
+  private handleCreateSection() {
     if (!this.local) return;
     let newRef = 'refs/for/*';
     // Avoid using an already used key for the placeholder, since it
@@ -704,7 +696,7 @@
     return this.restApiService
       .setRepoAccessRightsForReview(this.repo, obj)
       .then(change => {
-        GerritNav.navigateToChange(change);
+        this.getNavigation().setUrl(createChangeUrl({change}));
       })
       .finally(() => {
         this.modified = false;
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 caa2d13..85d5c21 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
@@ -1,24 +1,12 @@
 /**
  * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-repo-access';
 import {GrRepoAccess} from './gr-repo-access';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
 import {toSortedPermissionsArray} from '../../../utils/access-util';
 import {
   addListenerForTest,
@@ -43,7 +31,8 @@
 import {GrAccessSection} from '../gr-access-section/gr-access-section';
 import {GrPermission} from '../gr-permission/gr-permission';
 import {createChange} from '../../../test/test-data-generators';
-import {fixture, html} from '@open-wc/testing-helpers';
+import {fixture, html, assert} from '@open-wc/testing';
+import {testResolver} from '../../../test/common-test-setup';
 
 suite('gr-repo-access tests', () => {
   let element: GrRepoAccess;
@@ -125,6 +114,7 @@
       name: 'Create Account',
     },
   };
+
   setup(async () => {
     element = await fixture<GrRepoAccess>(html`
       <gr-repo-access></gr-repo-access>
@@ -137,6 +127,65 @@
     await element.updateComplete;
   });
 
+  test('render', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div class="main">
+          <div id="loading">Loading...</div>
+          <div id="loadedContent">
+            <h3 class="heading-3" id="inheritsFrom">
+              <span class="rightsText"> Rights Inherit From </span>
+              <a href="" id="inheritFromName" rel="noopener"> </a>
+              <gr-autocomplete id="editInheritFromInput"> </gr-autocomplete>
+            </h3>
+            <div class="weblinks">History:</div>
+            <div class="referenceContainer">
+              <gr-button
+                aria-disabled="false"
+                id="addReferenceBtn"
+                role="button"
+                tabindex="0"
+              >
+                Add Reference
+              </gr-button>
+            </div>
+            <div>
+              <gr-button
+                aria-disabled="false"
+                id="editBtn"
+                role="button"
+                tabindex="0"
+              >
+                Edit
+              </gr-button>
+              <gr-button
+                aria-disabled="false"
+                class="invisible"
+                id="saveBtn"
+                primary=""
+                role="button"
+                tabindex="0"
+              >
+                Save
+              </gr-button>
+              <gr-button
+                aria-disabled="false"
+                class="invisible"
+                id="saveReviewBtn"
+                primary=""
+                role="button"
+                tabindex="0"
+              >
+                Save for review
+              </gr-button>
+            </div>
+          </div>
+        </div>
+      `
+    );
+  });
+
   test('_repoChanged called when repo name changes', async () => {
     const repoChangedStub = sinon.stub(element, '_repoChanged');
     element.repo = 'New Repo' as RepoName;
@@ -922,6 +971,22 @@
       assert.deepEqual(element.computeAddAndRemove(), expectedInput);
     });
 
+    test('add and remove and re-add ref', async () => {
+      // refs/for/* is added
+      queryAndAssert<GrButton>(element, '#addReferenceBtn').click();
+      await element.updateComplete;
+
+      // refs/for/* is removed
+      element.handleAddedSectionRemoved(1);
+      await element.updateComplete;
+
+      // refs/for/* is re-added without extra starts
+      queryAndAssert<GrButton>(element, '#addReferenceBtn').click();
+      await element.updateComplete;
+
+      assert.equal(element.sections![1].id, 'refs/for/*');
+    });
+
     test('computeAddAndRemove new section', async () => {
       // Add a new permission to a section
       let expectedInput = {};
@@ -1376,7 +1441,7 @@
       stubRestApi('getRepoAccessRights').returns(
         Promise.resolve(JSON.parse(JSON.stringify(accessRes)))
       );
-      const navigateToChangeStub = sinon.stub(GerritNav, 'navigateToChange');
+      const setUrlStub = sinon.stub(testResolver(navigationToken), 'setUrl');
       let resolver: (value: Response | PromiseLike<Response>) => void;
       const saveStub = stubRestApi('setRepoAccessRights').returns(
         new Promise(r => (resolver = r))
@@ -1395,7 +1460,7 @@
       resolver!({status: 200} as Response);
       await element.updateComplete;
       assert.isTrue(saveStub.called);
-      assert.isTrue(navigateToChangeStub.notCalled);
+      assert.isTrue(setUrlStub.notCalled);
     });
 
     test('handleSaveForReview', async () => {
@@ -1426,7 +1491,7 @@
       stubRestApi('getRepoAccessRights').returns(
         Promise.resolve(JSON.parse(JSON.stringify(accessRes)))
       );
-      const navigateToChangeStub = sinon.stub(GerritNav, 'navigateToChange');
+      const setUrlStub = sinon.stub(testResolver(navigationToken), 'setUrl');
       let resolver: (value: ChangeInfo | PromiseLike<ChangeInfo>) => void;
       const saveForReviewStub = stubRestApi(
         'setRepoAccessRightsForReview'
@@ -1447,8 +1512,9 @@
       resolver!(createChange());
       await element.updateComplete;
       assert.isTrue(saveForReviewStub.called);
+      assert.isTrue(setUrlStub.called);
       assert.isTrue(
-        navigateToChangeStub.lastCall.calledWithExactly(createChange())
+        setUrlStub.lastCall.args?.[0]?.includes(`${createChange()._number}`)
       );
     });
   });
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.ts b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.ts
index f7ad3b8..9867aa5 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.ts
@@ -1,20 +1,8 @@
 /**
  * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
 import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
 import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
 import '../../plugins/gr-endpoint-param/gr-endpoint-param';
@@ -22,11 +10,11 @@
 import '../../shared/gr-dialog/gr-dialog';
 import '../../shared/gr-overlay/gr-overlay';
 import '../gr-create-change-dialog/gr-create-change-dialog';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
 import {
   BranchName,
   ConfigInfo,
-  PatchSetNum,
+  RevisionPatchSetNum,
   RepoName,
 } from '../../../types/common';
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
@@ -43,14 +31,16 @@
 import {subpageStyles} from '../../../styles/gr-subpage-styles';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, PropertyValues, css, html} from 'lit';
-import {customElement, query, property, state} from 'lit/decorators';
+import {customElement, query, property, state} from 'lit/decorators.js';
 import {assertIsDefined} from '../../../utils/common-util';
+import {createEditUrl} from '../../../models/views/edit';
+import {resolve} from '../../../models/dependency';
 
 const GC_MESSAGE = 'Garbage collection completed successfully.';
 const CONFIG_BRANCH = 'refs/meta/config' as BranchName;
 const CONFIG_PATH = 'project.config';
 const EDIT_CONFIG_SUBJECT = 'Edit Repo Config';
-const INITIAL_PATCHSET = 1 as PatchSetNum;
+const INITIAL_PATCHSET = 1 as RevisionPatchSetNum;
 const CREATE_CHANGE_FAILED_MESSAGE = 'Failed to create change.';
 const CREATE_CHANGE_SUCCEEDED_MESSAGE = 'Navigating to change';
 
@@ -85,6 +75,8 @@
 
   private readonly restApiService = getAppContext().restApiService;
 
+  private readonly getNavigation = resolve(this, navigationToken);
+
   override connectedCallback() {
     super.connectedCallback();
     fireTitleChange(this, 'Repo Commands');
@@ -97,8 +89,12 @@
       subpageStyles,
       sharedStyles,
       css`
-        #form gr-button {
-          margin-bottom: var(--spacing-xxl);
+        #form h2,
+        h3 {
+          margin-top: var(--spacing-xxl);
+        }
+        p {
+          padding: var(--spacing-m) 0;
         }
       `,
     ];
@@ -112,27 +108,44 @@
           Loading...
         </div>
         <div id="loadedContent" class=${this.loading ? 'loading' : ''}>
-          <h2 id="options" class="heading-2">Command</h2>
           <div id="form">
-            <h3 class="heading-3">Create change</h3>
-            <gr-button
-              ?loading=${this.creatingChange}
-              @click=${() => {
-                this.createNewChange();
-              }}
-            >
-              Create change
-            </gr-button>
-            <h3 class="heading-3">Edit repo config</h3>
-            <gr-button
-              id="editRepoConfig"
-              ?loading=${this.editingConfig}
-              @click=${() => {
-                this.handleEditRepoConfig();
-              }}
-            >
-              Edit repo config
-            </gr-button>
+            <h2 class="heading-2">Create change</h2>
+            <div>
+              <p>
+                Creates an empty work-in-progress change that can be used to
+                edit files online and send the modifications for review.
+              </p>
+            </div>
+            <div>
+              <gr-button
+                ?loading=${this.creatingChange}
+                @click=${() => {
+                  this.createNewChange();
+                }}
+              >
+                Create change
+              </gr-button>
+            </div>
+            <h2 class="heading-2">Edit repo config</h2>
+            <div>
+              <p>
+                Creates a work-in-progress change that allows to edit the
+                <code>project.config</code> file in the
+                <code>refs/meta/config</code> branch and send the modifications
+                for review.
+              </p>
+            </div>
+            <div>
+              <gr-button
+                id="editRepoConfig"
+                ?loading=${this.editingConfig}
+                @click=${() => {
+                  this.handleEditRepoConfig();
+                }}
+              >
+                Edit repo config
+              </gr-button>
+            </div>
             ${this.renderRepoGarbageCollector()}
             <gr-endpoint-decorator name="repo-command">
               <gr-endpoint-param name="config" .value=${this.repoConfig}>
@@ -275,8 +288,13 @@
           return;
         }
 
-        GerritNav.navigateToRelativeUrl(
-          GerritNav.getEditUrlForDiff(change, CONFIG_PATH, INITIAL_PATCHSET)
+        this.getNavigation().setUrl(
+          createEditUrl({
+            changeNum: change._number,
+            project: change.project,
+            path: CONFIG_PATH,
+            patchNum: INITIAL_PATCHSET,
+          })
         );
       })
       .finally(() => {
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_test.ts b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_test.ts
index 42d3333..77caf5e 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_test.ts
@@ -1,46 +1,30 @@
 /**
  * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
-import './gr-repo-commands.js';
+import '../../../test/common-test-setup';
+import './gr-repo-commands';
 import {GrRepoCommands} from './gr-repo-commands';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {
   addListenerForTest,
   mockPromise,
   queryAndAssert,
   stubRestApi,
 } from '../../../test/test-utils';
-import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
 import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
-import {PageErrorEvent} from '../../../types/events';
+import {EventType, PageErrorEvent} from '../../../types/events';
 import {RepoName} from '../../../types/common';
 import {GrButton} from '../../shared/gr-button/gr-button';
-
-const basicFixture = fixtureFromElement('gr-repo-commands');
+import {fixture, html, assert} from '@open-wc/testing';
 
 suite('gr-repo-commands tests', () => {
   let element: GrRepoCommands;
   let repoStub: sinon.SinonStub;
 
   setup(async () => {
-    element = basicFixture.instantiate();
-    await element.updateComplete;
+    element = await fixture(html`<gr-repo-commands></gr-repo-commands>`);
     // Note that this probably does not achieve what it is supposed to, because
     // getProjectConfig() is called as soon as the element is attached, so
     // stubbing it here has not effect anymore.
@@ -49,6 +33,79 @@
     );
   });
 
+  test('render', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div class="gr-form-styles main read-only">
+          <h1 class="heading-1" id="Title">Repository Commands</h1>
+          <div class="loading" id="loading">Loading...</div>
+          <div class="loading" id="loadedContent">
+            <div id="form">
+              <h2 class="heading-2">Create change</h2>
+              <div>
+                <p>
+                  Creates an empty work-in-progress change that can be used to
+                  edit files online and send the modifications for review.
+                </p>
+              </div>
+              <div>
+                <gr-button aria-disabled="false" role="button" tabindex="0">
+                  Create change
+                </gr-button>
+              </div>
+              <h2 class="heading-2">Edit repo config</h2>
+              <div>
+                <p>
+                  Creates a work-in-progress change that allows to edit the
+                  <code> project.config </code>
+                  file in the
+                  <code> refs/meta/config </code>
+                  branch and send the modifications for review.
+                </p>
+              </div>
+              <div>
+                <gr-button
+                  aria-disabled="false"
+                  id="editRepoConfig"
+                  role="button"
+                  tabindex="0"
+                >
+                  Edit repo config
+                </gr-button>
+              </div>
+              <gr-endpoint-decorator name="repo-command">
+                <gr-endpoint-param name="config"> </gr-endpoint-param>
+                <gr-endpoint-param name="repoName"> </gr-endpoint-param>
+              </gr-endpoint-decorator>
+            </div>
+          </div>
+        </div>
+        <gr-overlay
+          aria-hidden="true"
+          id="createChangeOverlay"
+          style="outline: none; display: none;"
+          tabindex="-1"
+          with-backdrop=""
+        >
+          <gr-dialog
+            confirm-label="Create"
+            disabled=""
+            id="createChangeDialog"
+            role="dialog"
+          >
+            <div class="header" slot="header">Create Change</div>
+            <div class="main" slot="main">
+              <gr-create-change-dialog id="createNewChangeModal">
+              </gr-create-change-dialog>
+            </div>
+          </gr-dialog>
+        </gr-overlay>
+      `,
+      {ignoreTags: ['p']}
+    );
+  });
+
   suite('create new change dialog', () => {
     test('createNewChange opens modal', () => {
       const openStub = sinon.stub(
@@ -87,26 +144,21 @@
 
   suite('edit repo config', () => {
     let createChangeStub: sinon.SinonStub;
-    let urlStub: sinon.SinonStub;
     let handleSpy: sinon.SinonSpy;
     let alertStub: sinon.SinonStub;
 
     setup(() => {
       createChangeStub = stubRestApi('createChange');
-      urlStub = sinon.stub(GerritNav, 'getEditUrlForDiff');
-      sinon.stub(GerritNav, 'navigateToRelativeUrl');
       handleSpy = sinon.spy(element, 'handleEditRepoConfig');
       alertStub = sinon.stub();
       element.repo = 'test' as RepoName;
-      element.addEventListener('show-alert', alertStub);
+      element.addEventListener(EventType.SHOW_ALERT, alertStub);
     });
 
     test('successful creation of change', async () => {
       const change = {_number: '1'};
       createChangeStub.returns(Promise.resolve(change));
-      MockInteractions.tap(
-        queryAndAssert<GrButton>(element, '#editRepoConfig')
-      );
+      queryAndAssert<GrButton>(element, '#editRepoConfig').click();
       await element.updateComplete;
       assert.isTrue(
         queryAndAssert<GrButton>(element, '#editRepoConfig').loading
@@ -120,8 +172,6 @@
         alertStub.lastCall.args[0].detail.message,
         'Navigating to change'
       );
-      assert.isTrue(urlStub.called);
-      assert.deepEqual(urlStub.lastCall.args, [change, 'project.config', 1]);
       assert.isFalse(
         queryAndAssert<GrButton>(element, '#editRepoConfig').loading
       );
@@ -129,9 +179,7 @@
 
     test('unsuccessful creation of change', async () => {
       createChangeStub.returns(Promise.resolve(null));
-      MockInteractions.tap(
-        queryAndAssert<GrButton>(element, '#editRepoConfig')
-      );
+      queryAndAssert<GrButton>(element, '#editRepoConfig').click();
       await element.updateComplete;
       assert.isTrue(
         queryAndAssert<GrButton>(element, '#editRepoConfig').loading
@@ -145,7 +193,6 @@
         alertStub.lastCall.args[0].detail.message,
         'Failed to create change.'
       );
-      assert.isFalse(urlStub.called);
       assert.isFalse(
         queryAndAssert<GrButton>(element, '#editRepoConfig').loading
       );
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 c2d7615..bff56bdd 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.ts
@@ -1,21 +1,8 @@
 /**
  * @license
- * Copyright (C) 2018 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {RepoName, DashboardId, DashboardInfo} from '../../../types/common';
 import {firePageError} from '../../../utils/event-util';
 import {getAppContext} from '../../../services/app-context';
@@ -23,7 +10,8 @@
 import {sharedStyles} from '../../../styles/shared-styles';
 import {tableStyles} from '../../../styles/gr-table-styles';
 import {LitElement, css, html, PropertyValues} from 'lit';
-import {customElement, property} from 'lit/decorators';
+import {customElement, property} from 'lit/decorators.js';
+import {createDashboardUrl} from '../../../models/views/dashboard';
 
 interface DashboardRef {
   section: string;
@@ -165,12 +153,10 @@
       });
   }
 
-  _getUrl(project?: RepoName, id?: DashboardId) {
-    if (!project || !id) {
-      return '';
-    }
+  _getUrl(project?: RepoName, dashboard?: DashboardId) {
+    if (!project || !dashboard) return '';
 
-    return GerritNav.getUrlForRepoDashboard(project, id);
+    return createDashboardUrl({project, dashboard});
   }
 
   _computeLoadingClass(loading: boolean) {
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards_test.ts b/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards_test.ts
index 7c26909..2bbc28b 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards_test.ts
@@ -1,41 +1,27 @@
 /**
  * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-repo-dashboards';
 import {GrRepoDashboards} from './gr-repo-dashboards';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {
   addListenerForTest,
   mockPromise,
   queryAndAssert,
   stubRestApi,
+  waitEventLoop,
 } from '../../../test/test-utils';
-import {DashboardId, DashboardInfo, RepoName} from '../../../types/common';
-import {PageErrorEvent} from '../../../types/events.js';
-
-const basicFixture = fixtureFromElement('gr-repo-dashboards');
+import {DashboardInfo, RepoName} from '../../../types/common';
+import {PageErrorEvent} from '../../../types/events';
+import {fixture, html, assert} from '@open-wc/testing';
 
 suite('gr-repo-dashboards tests', () => {
   let element: GrRepoDashboards;
 
   setup(async () => {
-    element = basicFixture.instantiate();
-    await flush();
+    element = await fixture(html`<gr-repo-dashboards></gr-repo-dashboards>`);
   });
 
   suite('dashboard table', () => {
@@ -93,6 +79,29 @@
       );
     });
 
+    test('render', () => {
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <table class="genericList loading" id="list">
+            <tbody>
+              <tr class="headerRow">
+                <th class="topHeader">Dashboard name</th>
+                <th class="topHeader">Dashboard title</th>
+                <th class="topHeader">Dashboard description</th>
+                <th class="topHeader">Inherited from</th>
+                <th class="topHeader">Default</th>
+              </tr>
+              <tr id="loadingContainer">
+                <td>Loading...</td>
+              </tr>
+            </tbody>
+            <tbody id="dashboards"></tbody>
+          </table>
+        `
+      );
+    });
+
     test('loading, sections, and ordering', async () => {
       assert.isTrue(element._loading);
       assert.notEqual(
@@ -104,7 +113,7 @@
         'none'
       );
       element.repo = 'test' as RepoName;
-      await flush();
+      await waitEventLoop();
       assert.equal(
         getComputedStyle(queryAndAssert(element, '#loadingContainer')).display,
         'none'
@@ -126,24 +135,6 @@
     });
   });
 
-  suite('test url', () => {
-    test('_getUrl', () => {
-      sinon
-        .stub(GerritNav, 'getUrlForRepoDashboard')
-        .callsFake(() => '/r/p/test/+/dashboard/default:contributor');
-
-      assert.equal(
-        element._getUrl(
-          'test' as RepoName,
-          'default:contributor' as DashboardId
-        ),
-        '/r/p/test/+/dashboard/default:contributor'
-      );
-
-      assert.equal(element._getUrl(undefined, undefined), '');
-    });
-  });
-
   suite('404', () => {
     test('fires page-error', async () => {
       const response = {status: 404} as Response;
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 d72f916..86d4bc5 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.ts
@@ -1,20 +1,8 @@
 /**
  * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
 import '@polymer/iron-input/iron-input';
 import '../../shared/gr-account-label/gr-account-label';
 import '../../shared/gr-button/gr-button';
@@ -36,8 +24,6 @@
   TagInfo,
   WebLinkInfo,
 } from '../../../types/common';
-import {AppElementRepoParams} from '../../gr-app-types';
-import {RepoDetailView} from '../../core/gr-navigation/gr-navigation';
 import {firePageError} from '../../../utils/event-util';
 import {getAppContext} from '../../../services/app-context';
 import {ErrorCallback} from '../../../api/rest';
@@ -46,10 +32,11 @@
 import {tableStyles} from '../../../styles/gr-table-styles';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, PropertyValues, css, html} from 'lit';
-import {customElement, query, property, state} from 'lit/decorators';
+import {customElement, query, property, state} from 'lit/decorators.js';
 import {BindValueChangeEvent} from '../../../types/events';
 import {assertIsDefined} from '../../../utils/common-util';
-import {ifDefined} from 'lit/directives/if-defined';
+import {ifDefined} from 'lit/directives/if-defined.js';
+import {RepoDetailView, RepoViewState} from '../../../models/views/repo';
 
 const PGP_START = '-----BEGIN PGP SIGNATURE-----';
 
@@ -63,7 +50,7 @@
   private readonly createNewModal?: GrCreatePointerDialog;
 
   @property({type: Object})
-  params?: AppElementRepoParams;
+  params?: RepoViewState;
 
   // private but used in test
   @state() detailType?: RepoDetailView.BRANCHES | RepoDetailView.TAGS;
@@ -419,7 +406,7 @@
     repo: RepoName | undefined,
     itemsPerPage: number,
     offset: number | undefined,
-    detailType: string
+    detailType?: string
   ) {
     if (filter === undefined || !repo || offset === undefined) {
       return Promise.reject(new Error('filter or repo or offset undefined'));
@@ -526,7 +513,7 @@
           this.repo,
           this.itemsPerPage,
           this.offset,
-          this.detailType!
+          this.detailType
         );
       }
     });
@@ -559,7 +546,7 @@
               this.repo,
               this.itemsPerPage,
               this.offset,
-              this.detailType!
+              this.detailType
             );
           }
         });
@@ -573,7 +560,7 @@
               this.repo,
               this.itemsPerPage,
               this.offset,
-              this.detailType!
+              this.detailType
             );
           }
         });
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_test.ts b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_test.ts
index a82c4e3..13f6b2b 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_test.ts
@@ -1,22 +1,10 @@
 /**
  * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
-import './gr-repo-detail-list.js';
+import '../../../test/common-test-setup';
+import './gr-repo-detail-list';
 import {GrRepoDetailList} from './gr-repo-detail-list';
 import {page} from '../../../utils/page-wrapper-utils';
 import {
@@ -26,7 +14,6 @@
   queryAndAssert,
   stubRestApi,
 } from '../../../test/test-utils';
-import {RepoDetailView} from '../../core/gr-navigation/gr-navigation';
 import {
   BranchInfo,
   EmailAddress,
@@ -42,14 +29,13 @@
 } from '../../../types/common';
 import {GerritView} from '../../../services/router/router-model';
 import {GrButton} from '../../shared/gr-button/gr-button';
-import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
 import {PageErrorEvent} from '../../../types/events';
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
 import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
 import {GrListView} from '../../shared/gr-list-view/gr-list-view';
 import {SHOWN_ITEMS_COUNT} from '../../../constants/constants';
-
-const basicFixture = fixtureFromElement('gr-repo-detail-list');
+import {fixture, html, assert} from '@open-wc/testing';
+import {RepoDetailView} from '../../../models/views/repo';
 
 function branchGenerator(counter: number) {
   return {
@@ -107,8 +93,9 @@
     let branches: BranchInfo[];
 
     setup(async () => {
-      element = basicFixture.instantiate();
-      await element.updateComplete;
+      element = await fixture(
+        html`<gr-repo-detail-list></gr-repo-detail-list>`
+      );
       element.detailType = RepoDetailView.BRANCHES;
       sinon.stub(page, 'show');
     });
@@ -132,6 +119,1948 @@
         await element.updateComplete;
       });
 
+      test('render', () => {
+        assert.shadowDom.equal(
+          element,
+          /* HTML */ `
+            <gr-list-view>
+              <table class="genericList gr-form-styles" id="list">
+                <tbody>
+                  <tr class="headerRow">
+                    <th class="name topHeader">Name</th>
+                    <th class="revision topHeader">Revision</th>
+                    <th class="hideItem message topHeader">Message</th>
+                    <th class="hideItem tagger topHeader">Tagger</th>
+                    <th class="repositoryBrowser topHeader">
+                      Repository Browser
+                    </th>
+                    <th class="delete topHeader"></th>
+                  </tr>
+                  <tr class="loadingMsg" id="loading">
+                    <td>Loading...</td>
+                  </tr>
+                </tbody>
+                <tbody>
+                  <tr class="table">
+                    <td class="branches name">
+                      <a> HEAD </a>
+                    </td>
+                    <td class="branches revision">
+                      <span class="revisionNoEditing"> master </span>
+                      <span class="revisionEdit">
+                        <span class="revisionWithEditing"> master </span>
+                        <gr-button
+                          aria-disabled="false"
+                          class="editBtn"
+                          data-index="0"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          edit
+                        </gr-button>
+                        <iron-input class="editItem">
+                          <input />
+                        </iron-input>
+                        <gr-button
+                          aria-disabled="false"
+                          class="cancelBtn editItem"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          Cancel
+                        </gr-button>
+                        <gr-button
+                          aria-disabled="true"
+                          class="editItem saveBtn"
+                          data-index="0"
+                          disabled=""
+                          link=""
+                          role="button"
+                          tabindex="-1"
+                        >
+                          Save
+                        </gr-button>
+                      </span>
+                    </td>
+                    <td class="hideItem message"></td>
+                    <td class="hideItem tagger"></td>
+                    <td class="repositoryBrowser"></td>
+                    <td class="delete">
+                      <gr-button
+                        aria-disabled="false"
+                        class="deleteButton"
+                        data-index="0"
+                        link=""
+                        role="button"
+                        tabindex="0"
+                      >
+                        Delete
+                      </gr-button>
+                    </td>
+                  </tr>
+                  <tr class="table">
+                    <td class="branches name">
+                      <a
+                        href="https://git.example.org/branch/test;refs/heads/test0"
+                      >
+                        test0
+                      </a>
+                    </td>
+                    <td class="branches revision">
+                      <span class="revisionNoEditing">
+                        9c9d08a438e55e52f33b608415e6dddd9b18550d
+                      </span>
+                      <span class="revisionEdit">
+                        <span class="revisionWithEditing">
+                          9c9d08a438e55e52f33b608415e6dddd9b18550d
+                        </span>
+                        <gr-button
+                          aria-disabled="false"
+                          class="editBtn"
+                          data-index="1"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          edit
+                        </gr-button>
+                        <iron-input class="editItem">
+                          <input />
+                        </iron-input>
+                        <gr-button
+                          aria-disabled="false"
+                          class="cancelBtn editItem"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          Cancel
+                        </gr-button>
+                        <gr-button
+                          aria-disabled="true"
+                          class="editItem saveBtn"
+                          data-index="1"
+                          disabled=""
+                          link=""
+                          role="button"
+                          tabindex="-1"
+                        >
+                          Save
+                        </gr-button>
+                      </span>
+                    </td>
+                    <td class="hideItem message"></td>
+                    <td class="hideItem tagger"></td>
+                    <td class="repositoryBrowser">
+                      <a
+                        class="webLink"
+                        href="https://git.example.org/branch/test;refs/heads/test0"
+                        rel="noopener"
+                        target="_blank"
+                      >
+                        (diffusion)
+                      </a>
+                    </td>
+                    <td class="delete">
+                      <gr-button
+                        aria-disabled="false"
+                        class="deleteButton"
+                        data-index="1"
+                        link=""
+                        role="button"
+                        tabindex="0"
+                      >
+                        Delete
+                      </gr-button>
+                    </td>
+                  </tr>
+                  <tr class="table">
+                    <td class="branches name">
+                      <a
+                        href="https://git.example.org/branch/test;refs/heads/test1"
+                      >
+                        test1
+                      </a>
+                    </td>
+                    <td class="branches revision">
+                      <span class="revisionNoEditing">
+                        9c9d08a438e55e52f33b608415e6dddd9b18550d
+                      </span>
+                      <span class="revisionEdit">
+                        <span class="revisionWithEditing">
+                          9c9d08a438e55e52f33b608415e6dddd9b18550d
+                        </span>
+                        <gr-button
+                          aria-disabled="false"
+                          class="editBtn"
+                          data-index="2"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          edit
+                        </gr-button>
+                        <iron-input class="editItem">
+                          <input />
+                        </iron-input>
+                        <gr-button
+                          aria-disabled="false"
+                          class="cancelBtn editItem"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          Cancel
+                        </gr-button>
+                        <gr-button
+                          aria-disabled="true"
+                          class="editItem saveBtn"
+                          data-index="2"
+                          disabled=""
+                          link=""
+                          role="button"
+                          tabindex="-1"
+                        >
+                          Save
+                        </gr-button>
+                      </span>
+                    </td>
+                    <td class="hideItem message"></td>
+                    <td class="hideItem tagger"></td>
+                    <td class="repositoryBrowser">
+                      <a
+                        class="webLink"
+                        href="https://git.example.org/branch/test;refs/heads/test1"
+                        rel="noopener"
+                        target="_blank"
+                      >
+                        (diffusion)
+                      </a>
+                    </td>
+                    <td class="delete">
+                      <gr-button
+                        aria-disabled="false"
+                        class="deleteButton"
+                        data-index="2"
+                        link=""
+                        role="button"
+                        tabindex="0"
+                      >
+                        Delete
+                      </gr-button>
+                    </td>
+                  </tr>
+                  <tr class="table">
+                    <td class="branches name">
+                      <a
+                        href="https://git.example.org/branch/test;refs/heads/test2"
+                      >
+                        test2
+                      </a>
+                    </td>
+                    <td class="branches revision">
+                      <span class="revisionNoEditing">
+                        9c9d08a438e55e52f33b608415e6dddd9b18550d
+                      </span>
+                      <span class="revisionEdit">
+                        <span class="revisionWithEditing">
+                          9c9d08a438e55e52f33b608415e6dddd9b18550d
+                        </span>
+                        <gr-button
+                          aria-disabled="false"
+                          class="editBtn"
+                          data-index="3"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          edit
+                        </gr-button>
+                        <iron-input class="editItem">
+                          <input />
+                        </iron-input>
+                        <gr-button
+                          aria-disabled="false"
+                          class="cancelBtn editItem"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          Cancel
+                        </gr-button>
+                        <gr-button
+                          aria-disabled="true"
+                          class="editItem saveBtn"
+                          data-index="3"
+                          disabled=""
+                          link=""
+                          role="button"
+                          tabindex="-1"
+                        >
+                          Save
+                        </gr-button>
+                      </span>
+                    </td>
+                    <td class="hideItem message"></td>
+                    <td class="hideItem tagger"></td>
+                    <td class="repositoryBrowser">
+                      <a
+                        class="webLink"
+                        href="https://git.example.org/branch/test;refs/heads/test2"
+                        rel="noopener"
+                        target="_blank"
+                      >
+                        (diffusion)
+                      </a>
+                    </td>
+                    <td class="delete">
+                      <gr-button
+                        aria-disabled="false"
+                        class="deleteButton"
+                        data-index="3"
+                        link=""
+                        role="button"
+                        tabindex="0"
+                      >
+                        Delete
+                      </gr-button>
+                    </td>
+                  </tr>
+                  <tr class="table">
+                    <td class="branches name">
+                      <a
+                        href="https://git.example.org/branch/test;refs/heads/test3"
+                      >
+                        test3
+                      </a>
+                    </td>
+                    <td class="branches revision">
+                      <span class="revisionNoEditing">
+                        9c9d08a438e55e52f33b608415e6dddd9b18550d
+                      </span>
+                      <span class="revisionEdit">
+                        <span class="revisionWithEditing">
+                          9c9d08a438e55e52f33b608415e6dddd9b18550d
+                        </span>
+                        <gr-button
+                          aria-disabled="false"
+                          class="editBtn"
+                          data-index="4"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          edit
+                        </gr-button>
+                        <iron-input class="editItem">
+                          <input />
+                        </iron-input>
+                        <gr-button
+                          aria-disabled="false"
+                          class="cancelBtn editItem"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          Cancel
+                        </gr-button>
+                        <gr-button
+                          aria-disabled="true"
+                          class="editItem saveBtn"
+                          data-index="4"
+                          disabled=""
+                          link=""
+                          role="button"
+                          tabindex="-1"
+                        >
+                          Save
+                        </gr-button>
+                      </span>
+                    </td>
+                    <td class="hideItem message"></td>
+                    <td class="hideItem tagger"></td>
+                    <td class="repositoryBrowser">
+                      <a
+                        class="webLink"
+                        href="https://git.example.org/branch/test;refs/heads/test3"
+                        rel="noopener"
+                        target="_blank"
+                      >
+                        (diffusion)
+                      </a>
+                    </td>
+                    <td class="delete">
+                      <gr-button
+                        aria-disabled="false"
+                        class="deleteButton"
+                        data-index="4"
+                        link=""
+                        role="button"
+                        tabindex="0"
+                      >
+                        Delete
+                      </gr-button>
+                    </td>
+                  </tr>
+                  <tr class="table">
+                    <td class="branches name">
+                      <a
+                        href="https://git.example.org/branch/test;refs/heads/test4"
+                      >
+                        test4
+                      </a>
+                    </td>
+                    <td class="branches revision">
+                      <span class="revisionNoEditing">
+                        9c9d08a438e55e52f33b608415e6dddd9b18550d
+                      </span>
+                      <span class="revisionEdit">
+                        <span class="revisionWithEditing">
+                          9c9d08a438e55e52f33b608415e6dddd9b18550d
+                        </span>
+                        <gr-button
+                          aria-disabled="false"
+                          class="editBtn"
+                          data-index="5"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          edit
+                        </gr-button>
+                        <iron-input class="editItem">
+                          <input />
+                        </iron-input>
+                        <gr-button
+                          aria-disabled="false"
+                          class="cancelBtn editItem"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          Cancel
+                        </gr-button>
+                        <gr-button
+                          aria-disabled="true"
+                          class="editItem saveBtn"
+                          data-index="5"
+                          disabled=""
+                          link=""
+                          role="button"
+                          tabindex="-1"
+                        >
+                          Save
+                        </gr-button>
+                      </span>
+                    </td>
+                    <td class="hideItem message"></td>
+                    <td class="hideItem tagger"></td>
+                    <td class="repositoryBrowser">
+                      <a
+                        class="webLink"
+                        href="https://git.example.org/branch/test;refs/heads/test4"
+                        rel="noopener"
+                        target="_blank"
+                      >
+                        (diffusion)
+                      </a>
+                    </td>
+                    <td class="delete">
+                      <gr-button
+                        aria-disabled="false"
+                        class="deleteButton"
+                        data-index="5"
+                        link=""
+                        role="button"
+                        tabindex="0"
+                      >
+                        Delete
+                      </gr-button>
+                    </td>
+                  </tr>
+                  <tr class="table">
+                    <td class="branches name">
+                      <a
+                        href="https://git.example.org/branch/test;refs/heads/test5"
+                      >
+                        test5
+                      </a>
+                    </td>
+                    <td class="branches revision">
+                      <span class="revisionNoEditing">
+                        9c9d08a438e55e52f33b608415e6dddd9b18550d
+                      </span>
+                      <span class="revisionEdit">
+                        <span class="revisionWithEditing">
+                          9c9d08a438e55e52f33b608415e6dddd9b18550d
+                        </span>
+                        <gr-button
+                          aria-disabled="false"
+                          class="editBtn"
+                          data-index="6"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          edit
+                        </gr-button>
+                        <iron-input class="editItem">
+                          <input />
+                        </iron-input>
+                        <gr-button
+                          aria-disabled="false"
+                          class="cancelBtn editItem"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          Cancel
+                        </gr-button>
+                        <gr-button
+                          aria-disabled="true"
+                          class="editItem saveBtn"
+                          data-index="6"
+                          disabled=""
+                          link=""
+                          role="button"
+                          tabindex="-1"
+                        >
+                          Save
+                        </gr-button>
+                      </span>
+                    </td>
+                    <td class="hideItem message"></td>
+                    <td class="hideItem tagger"></td>
+                    <td class="repositoryBrowser">
+                      <a
+                        class="webLink"
+                        href="https://git.example.org/branch/test;refs/heads/test5"
+                        rel="noopener"
+                        target="_blank"
+                      >
+                        (diffusion)
+                      </a>
+                    </td>
+                    <td class="delete">
+                      <gr-button
+                        aria-disabled="false"
+                        class="deleteButton"
+                        data-index="6"
+                        link=""
+                        role="button"
+                        tabindex="0"
+                      >
+                        Delete
+                      </gr-button>
+                    </td>
+                  </tr>
+                  <tr class="table">
+                    <td class="branches name">
+                      <a
+                        href="https://git.example.org/branch/test;refs/heads/test6"
+                      >
+                        test6
+                      </a>
+                    </td>
+                    <td class="branches revision">
+                      <span class="revisionNoEditing">
+                        9c9d08a438e55e52f33b608415e6dddd9b18550d
+                      </span>
+                      <span class="revisionEdit">
+                        <span class="revisionWithEditing">
+                          9c9d08a438e55e52f33b608415e6dddd9b18550d
+                        </span>
+                        <gr-button
+                          aria-disabled="false"
+                          class="editBtn"
+                          data-index="7"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          edit
+                        </gr-button>
+                        <iron-input class="editItem">
+                          <input />
+                        </iron-input>
+                        <gr-button
+                          aria-disabled="false"
+                          class="cancelBtn editItem"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          Cancel
+                        </gr-button>
+                        <gr-button
+                          aria-disabled="true"
+                          class="editItem saveBtn"
+                          data-index="7"
+                          disabled=""
+                          link=""
+                          role="button"
+                          tabindex="-1"
+                        >
+                          Save
+                        </gr-button>
+                      </span>
+                    </td>
+                    <td class="hideItem message"></td>
+                    <td class="hideItem tagger"></td>
+                    <td class="repositoryBrowser">
+                      <a
+                        class="webLink"
+                        href="https://git.example.org/branch/test;refs/heads/test6"
+                        rel="noopener"
+                        target="_blank"
+                      >
+                        (diffusion)
+                      </a>
+                    </td>
+                    <td class="delete">
+                      <gr-button
+                        aria-disabled="false"
+                        class="deleteButton"
+                        data-index="7"
+                        link=""
+                        role="button"
+                        tabindex="0"
+                      >
+                        Delete
+                      </gr-button>
+                    </td>
+                  </tr>
+                  <tr class="table">
+                    <td class="branches name">
+                      <a
+                        href="https://git.example.org/branch/test;refs/heads/test7"
+                      >
+                        test7
+                      </a>
+                    </td>
+                    <td class="branches revision">
+                      <span class="revisionNoEditing">
+                        9c9d08a438e55e52f33b608415e6dddd9b18550d
+                      </span>
+                      <span class="revisionEdit">
+                        <span class="revisionWithEditing">
+                          9c9d08a438e55e52f33b608415e6dddd9b18550d
+                        </span>
+                        <gr-button
+                          aria-disabled="false"
+                          class="editBtn"
+                          data-index="8"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          edit
+                        </gr-button>
+                        <iron-input class="editItem">
+                          <input />
+                        </iron-input>
+                        <gr-button
+                          aria-disabled="false"
+                          class="cancelBtn editItem"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          Cancel
+                        </gr-button>
+                        <gr-button
+                          aria-disabled="true"
+                          class="editItem saveBtn"
+                          data-index="8"
+                          disabled=""
+                          link=""
+                          role="button"
+                          tabindex="-1"
+                        >
+                          Save
+                        </gr-button>
+                      </span>
+                    </td>
+                    <td class="hideItem message"></td>
+                    <td class="hideItem tagger"></td>
+                    <td class="repositoryBrowser">
+                      <a
+                        class="webLink"
+                        href="https://git.example.org/branch/test;refs/heads/test7"
+                        rel="noopener"
+                        target="_blank"
+                      >
+                        (diffusion)
+                      </a>
+                    </td>
+                    <td class="delete">
+                      <gr-button
+                        aria-disabled="false"
+                        class="deleteButton"
+                        data-index="8"
+                        link=""
+                        role="button"
+                        tabindex="0"
+                      >
+                        Delete
+                      </gr-button>
+                    </td>
+                  </tr>
+                  <tr class="table">
+                    <td class="branches name">
+                      <a
+                        href="https://git.example.org/branch/test;refs/heads/test8"
+                      >
+                        test8
+                      </a>
+                    </td>
+                    <td class="branches revision">
+                      <span class="revisionNoEditing">
+                        9c9d08a438e55e52f33b608415e6dddd9b18550d
+                      </span>
+                      <span class="revisionEdit">
+                        <span class="revisionWithEditing">
+                          9c9d08a438e55e52f33b608415e6dddd9b18550d
+                        </span>
+                        <gr-button
+                          aria-disabled="false"
+                          class="editBtn"
+                          data-index="9"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          edit
+                        </gr-button>
+                        <iron-input class="editItem">
+                          <input />
+                        </iron-input>
+                        <gr-button
+                          aria-disabled="false"
+                          class="cancelBtn editItem"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          Cancel
+                        </gr-button>
+                        <gr-button
+                          aria-disabled="true"
+                          class="editItem saveBtn"
+                          data-index="9"
+                          disabled=""
+                          link=""
+                          role="button"
+                          tabindex="-1"
+                        >
+                          Save
+                        </gr-button>
+                      </span>
+                    </td>
+                    <td class="hideItem message"></td>
+                    <td class="hideItem tagger"></td>
+                    <td class="repositoryBrowser">
+                      <a
+                        class="webLink"
+                        href="https://git.example.org/branch/test;refs/heads/test8"
+                        rel="noopener"
+                        target="_blank"
+                      >
+                        (diffusion)
+                      </a>
+                    </td>
+                    <td class="delete">
+                      <gr-button
+                        aria-disabled="false"
+                        class="deleteButton"
+                        data-index="9"
+                        link=""
+                        role="button"
+                        tabindex="0"
+                      >
+                        Delete
+                      </gr-button>
+                    </td>
+                  </tr>
+                  <tr class="table">
+                    <td class="branches name">
+                      <a
+                        href="https://git.example.org/branch/test;refs/heads/test9"
+                      >
+                        test9
+                      </a>
+                    </td>
+                    <td class="branches revision">
+                      <span class="revisionNoEditing">
+                        9c9d08a438e55e52f33b608415e6dddd9b18550d
+                      </span>
+                      <span class="revisionEdit">
+                        <span class="revisionWithEditing">
+                          9c9d08a438e55e52f33b608415e6dddd9b18550d
+                        </span>
+                        <gr-button
+                          aria-disabled="false"
+                          class="editBtn"
+                          data-index="10"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          edit
+                        </gr-button>
+                        <iron-input class="editItem">
+                          <input />
+                        </iron-input>
+                        <gr-button
+                          aria-disabled="false"
+                          class="cancelBtn editItem"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          Cancel
+                        </gr-button>
+                        <gr-button
+                          aria-disabled="true"
+                          class="editItem saveBtn"
+                          data-index="10"
+                          disabled=""
+                          link=""
+                          role="button"
+                          tabindex="-1"
+                        >
+                          Save
+                        </gr-button>
+                      </span>
+                    </td>
+                    <td class="hideItem message"></td>
+                    <td class="hideItem tagger"></td>
+                    <td class="repositoryBrowser">
+                      <a
+                        class="webLink"
+                        href="https://git.example.org/branch/test;refs/heads/test9"
+                        rel="noopener"
+                        target="_blank"
+                      >
+                        (diffusion)
+                      </a>
+                    </td>
+                    <td class="delete">
+                      <gr-button
+                        aria-disabled="false"
+                        class="deleteButton"
+                        data-index="10"
+                        link=""
+                        role="button"
+                        tabindex="0"
+                      >
+                        Delete
+                      </gr-button>
+                    </td>
+                  </tr>
+                  <tr class="table">
+                    <td class="branches name">
+                      <a
+                        href="https://git.example.org/branch/test;refs/heads/test10"
+                      >
+                        test10
+                      </a>
+                    </td>
+                    <td class="branches revision">
+                      <span class="revisionNoEditing">
+                        9c9d08a438e55e52f33b608415e6dddd9b18550d
+                      </span>
+                      <span class="revisionEdit">
+                        <span class="revisionWithEditing">
+                          9c9d08a438e55e52f33b608415e6dddd9b18550d
+                        </span>
+                        <gr-button
+                          aria-disabled="false"
+                          class="editBtn"
+                          data-index="11"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          edit
+                        </gr-button>
+                        <iron-input class="editItem">
+                          <input />
+                        </iron-input>
+                        <gr-button
+                          aria-disabled="false"
+                          class="cancelBtn editItem"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          Cancel
+                        </gr-button>
+                        <gr-button
+                          aria-disabled="true"
+                          class="editItem saveBtn"
+                          data-index="11"
+                          disabled=""
+                          link=""
+                          role="button"
+                          tabindex="-1"
+                        >
+                          Save
+                        </gr-button>
+                      </span>
+                    </td>
+                    <td class="hideItem message"></td>
+                    <td class="hideItem tagger"></td>
+                    <td class="repositoryBrowser">
+                      <a
+                        class="webLink"
+                        href="https://git.example.org/branch/test;refs/heads/test10"
+                        rel="noopener"
+                        target="_blank"
+                      >
+                        (diffusion)
+                      </a>
+                    </td>
+                    <td class="delete">
+                      <gr-button
+                        aria-disabled="false"
+                        class="deleteButton"
+                        data-index="11"
+                        link=""
+                        role="button"
+                        tabindex="0"
+                      >
+                        Delete
+                      </gr-button>
+                    </td>
+                  </tr>
+                  <tr class="table">
+                    <td class="branches name">
+                      <a
+                        href="https://git.example.org/branch/test;refs/heads/test11"
+                      >
+                        test11
+                      </a>
+                    </td>
+                    <td class="branches revision">
+                      <span class="revisionNoEditing">
+                        9c9d08a438e55e52f33b608415e6dddd9b18550d
+                      </span>
+                      <span class="revisionEdit">
+                        <span class="revisionWithEditing">
+                          9c9d08a438e55e52f33b608415e6dddd9b18550d
+                        </span>
+                        <gr-button
+                          aria-disabled="false"
+                          class="editBtn"
+                          data-index="12"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          edit
+                        </gr-button>
+                        <iron-input class="editItem">
+                          <input />
+                        </iron-input>
+                        <gr-button
+                          aria-disabled="false"
+                          class="cancelBtn editItem"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          Cancel
+                        </gr-button>
+                        <gr-button
+                          aria-disabled="true"
+                          class="editItem saveBtn"
+                          data-index="12"
+                          disabled=""
+                          link=""
+                          role="button"
+                          tabindex="-1"
+                        >
+                          Save
+                        </gr-button>
+                      </span>
+                    </td>
+                    <td class="hideItem message"></td>
+                    <td class="hideItem tagger"></td>
+                    <td class="repositoryBrowser">
+                      <a
+                        class="webLink"
+                        href="https://git.example.org/branch/test;refs/heads/test11"
+                        rel="noopener"
+                        target="_blank"
+                      >
+                        (diffusion)
+                      </a>
+                    </td>
+                    <td class="delete">
+                      <gr-button
+                        aria-disabled="false"
+                        class="deleteButton"
+                        data-index="12"
+                        link=""
+                        role="button"
+                        tabindex="0"
+                      >
+                        Delete
+                      </gr-button>
+                    </td>
+                  </tr>
+                  <tr class="table">
+                    <td class="branches name">
+                      <a
+                        href="https://git.example.org/branch/test;refs/heads/test12"
+                      >
+                        test12
+                      </a>
+                    </td>
+                    <td class="branches revision">
+                      <span class="revisionNoEditing">
+                        9c9d08a438e55e52f33b608415e6dddd9b18550d
+                      </span>
+                      <span class="revisionEdit">
+                        <span class="revisionWithEditing">
+                          9c9d08a438e55e52f33b608415e6dddd9b18550d
+                        </span>
+                        <gr-button
+                          aria-disabled="false"
+                          class="editBtn"
+                          data-index="13"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          edit
+                        </gr-button>
+                        <iron-input class="editItem">
+                          <input />
+                        </iron-input>
+                        <gr-button
+                          aria-disabled="false"
+                          class="cancelBtn editItem"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          Cancel
+                        </gr-button>
+                        <gr-button
+                          aria-disabled="true"
+                          class="editItem saveBtn"
+                          data-index="13"
+                          disabled=""
+                          link=""
+                          role="button"
+                          tabindex="-1"
+                        >
+                          Save
+                        </gr-button>
+                      </span>
+                    </td>
+                    <td class="hideItem message"></td>
+                    <td class="hideItem tagger"></td>
+                    <td class="repositoryBrowser">
+                      <a
+                        class="webLink"
+                        href="https://git.example.org/branch/test;refs/heads/test12"
+                        rel="noopener"
+                        target="_blank"
+                      >
+                        (diffusion)
+                      </a>
+                    </td>
+                    <td class="delete">
+                      <gr-button
+                        aria-disabled="false"
+                        class="deleteButton"
+                        data-index="13"
+                        link=""
+                        role="button"
+                        tabindex="0"
+                      >
+                        Delete
+                      </gr-button>
+                    </td>
+                  </tr>
+                  <tr class="table">
+                    <td class="branches name">
+                      <a
+                        href="https://git.example.org/branch/test;refs/heads/test13"
+                      >
+                        test13
+                      </a>
+                    </td>
+                    <td class="branches revision">
+                      <span class="revisionNoEditing">
+                        9c9d08a438e55e52f33b608415e6dddd9b18550d
+                      </span>
+                      <span class="revisionEdit">
+                        <span class="revisionWithEditing">
+                          9c9d08a438e55e52f33b608415e6dddd9b18550d
+                        </span>
+                        <gr-button
+                          aria-disabled="false"
+                          class="editBtn"
+                          data-index="14"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          edit
+                        </gr-button>
+                        <iron-input class="editItem">
+                          <input />
+                        </iron-input>
+                        <gr-button
+                          aria-disabled="false"
+                          class="cancelBtn editItem"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          Cancel
+                        </gr-button>
+                        <gr-button
+                          aria-disabled="true"
+                          class="editItem saveBtn"
+                          data-index="14"
+                          disabled=""
+                          link=""
+                          role="button"
+                          tabindex="-1"
+                        >
+                          Save
+                        </gr-button>
+                      </span>
+                    </td>
+                    <td class="hideItem message"></td>
+                    <td class="hideItem tagger"></td>
+                    <td class="repositoryBrowser">
+                      <a
+                        class="webLink"
+                        href="https://git.example.org/branch/test;refs/heads/test13"
+                        rel="noopener"
+                        target="_blank"
+                      >
+                        (diffusion)
+                      </a>
+                    </td>
+                    <td class="delete">
+                      <gr-button
+                        aria-disabled="false"
+                        class="deleteButton"
+                        data-index="14"
+                        link=""
+                        role="button"
+                        tabindex="0"
+                      >
+                        Delete
+                      </gr-button>
+                    </td>
+                  </tr>
+                  <tr class="table">
+                    <td class="branches name">
+                      <a
+                        href="https://git.example.org/branch/test;refs/heads/test14"
+                      >
+                        test14
+                      </a>
+                    </td>
+                    <td class="branches revision">
+                      <span class="revisionNoEditing">
+                        9c9d08a438e55e52f33b608415e6dddd9b18550d
+                      </span>
+                      <span class="revisionEdit">
+                        <span class="revisionWithEditing">
+                          9c9d08a438e55e52f33b608415e6dddd9b18550d
+                        </span>
+                        <gr-button
+                          aria-disabled="false"
+                          class="editBtn"
+                          data-index="15"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          edit
+                        </gr-button>
+                        <iron-input class="editItem">
+                          <input />
+                        </iron-input>
+                        <gr-button
+                          aria-disabled="false"
+                          class="cancelBtn editItem"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          Cancel
+                        </gr-button>
+                        <gr-button
+                          aria-disabled="true"
+                          class="editItem saveBtn"
+                          data-index="15"
+                          disabled=""
+                          link=""
+                          role="button"
+                          tabindex="-1"
+                        >
+                          Save
+                        </gr-button>
+                      </span>
+                    </td>
+                    <td class="hideItem message"></td>
+                    <td class="hideItem tagger"></td>
+                    <td class="repositoryBrowser">
+                      <a
+                        class="webLink"
+                        href="https://git.example.org/branch/test;refs/heads/test14"
+                        rel="noopener"
+                        target="_blank"
+                      >
+                        (diffusion)
+                      </a>
+                    </td>
+                    <td class="delete">
+                      <gr-button
+                        aria-disabled="false"
+                        class="deleteButton"
+                        data-index="15"
+                        link=""
+                        role="button"
+                        tabindex="0"
+                      >
+                        Delete
+                      </gr-button>
+                    </td>
+                  </tr>
+                  <tr class="table">
+                    <td class="branches name">
+                      <a
+                        href="https://git.example.org/branch/test;refs/heads/test15"
+                      >
+                        test15
+                      </a>
+                    </td>
+                    <td class="branches revision">
+                      <span class="revisionNoEditing">
+                        9c9d08a438e55e52f33b608415e6dddd9b18550d
+                      </span>
+                      <span class="revisionEdit">
+                        <span class="revisionWithEditing">
+                          9c9d08a438e55e52f33b608415e6dddd9b18550d
+                        </span>
+                        <gr-button
+                          aria-disabled="false"
+                          class="editBtn"
+                          data-index="16"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          edit
+                        </gr-button>
+                        <iron-input class="editItem">
+                          <input />
+                        </iron-input>
+                        <gr-button
+                          aria-disabled="false"
+                          class="cancelBtn editItem"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          Cancel
+                        </gr-button>
+                        <gr-button
+                          aria-disabled="true"
+                          class="editItem saveBtn"
+                          data-index="16"
+                          disabled=""
+                          link=""
+                          role="button"
+                          tabindex="-1"
+                        >
+                          Save
+                        </gr-button>
+                      </span>
+                    </td>
+                    <td class="hideItem message"></td>
+                    <td class="hideItem tagger"></td>
+                    <td class="repositoryBrowser">
+                      <a
+                        class="webLink"
+                        href="https://git.example.org/branch/test;refs/heads/test15"
+                        rel="noopener"
+                        target="_blank"
+                      >
+                        (diffusion)
+                      </a>
+                    </td>
+                    <td class="delete">
+                      <gr-button
+                        aria-disabled="false"
+                        class="deleteButton"
+                        data-index="16"
+                        link=""
+                        role="button"
+                        tabindex="0"
+                      >
+                        Delete
+                      </gr-button>
+                    </td>
+                  </tr>
+                  <tr class="table">
+                    <td class="branches name">
+                      <a
+                        href="https://git.example.org/branch/test;refs/heads/test16"
+                      >
+                        test16
+                      </a>
+                    </td>
+                    <td class="branches revision">
+                      <span class="revisionNoEditing">
+                        9c9d08a438e55e52f33b608415e6dddd9b18550d
+                      </span>
+                      <span class="revisionEdit">
+                        <span class="revisionWithEditing">
+                          9c9d08a438e55e52f33b608415e6dddd9b18550d
+                        </span>
+                        <gr-button
+                          aria-disabled="false"
+                          class="editBtn"
+                          data-index="17"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          edit
+                        </gr-button>
+                        <iron-input class="editItem">
+                          <input />
+                        </iron-input>
+                        <gr-button
+                          aria-disabled="false"
+                          class="cancelBtn editItem"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          Cancel
+                        </gr-button>
+                        <gr-button
+                          aria-disabled="true"
+                          class="editItem saveBtn"
+                          data-index="17"
+                          disabled=""
+                          link=""
+                          role="button"
+                          tabindex="-1"
+                        >
+                          Save
+                        </gr-button>
+                      </span>
+                    </td>
+                    <td class="hideItem message"></td>
+                    <td class="hideItem tagger"></td>
+                    <td class="repositoryBrowser">
+                      <a
+                        class="webLink"
+                        href="https://git.example.org/branch/test;refs/heads/test16"
+                        rel="noopener"
+                        target="_blank"
+                      >
+                        (diffusion)
+                      </a>
+                    </td>
+                    <td class="delete">
+                      <gr-button
+                        aria-disabled="false"
+                        class="deleteButton"
+                        data-index="17"
+                        link=""
+                        role="button"
+                        tabindex="0"
+                      >
+                        Delete
+                      </gr-button>
+                    </td>
+                  </tr>
+                  <tr class="table">
+                    <td class="branches name">
+                      <a
+                        href="https://git.example.org/branch/test;refs/heads/test17"
+                      >
+                        test17
+                      </a>
+                    </td>
+                    <td class="branches revision">
+                      <span class="revisionNoEditing">
+                        9c9d08a438e55e52f33b608415e6dddd9b18550d
+                      </span>
+                      <span class="revisionEdit">
+                        <span class="revisionWithEditing">
+                          9c9d08a438e55e52f33b608415e6dddd9b18550d
+                        </span>
+                        <gr-button
+                          aria-disabled="false"
+                          class="editBtn"
+                          data-index="18"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          edit
+                        </gr-button>
+                        <iron-input class="editItem">
+                          <input />
+                        </iron-input>
+                        <gr-button
+                          aria-disabled="false"
+                          class="cancelBtn editItem"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          Cancel
+                        </gr-button>
+                        <gr-button
+                          aria-disabled="true"
+                          class="editItem saveBtn"
+                          data-index="18"
+                          disabled=""
+                          link=""
+                          role="button"
+                          tabindex="-1"
+                        >
+                          Save
+                        </gr-button>
+                      </span>
+                    </td>
+                    <td class="hideItem message"></td>
+                    <td class="hideItem tagger"></td>
+                    <td class="repositoryBrowser">
+                      <a
+                        class="webLink"
+                        href="https://git.example.org/branch/test;refs/heads/test17"
+                        rel="noopener"
+                        target="_blank"
+                      >
+                        (diffusion)
+                      </a>
+                    </td>
+                    <td class="delete">
+                      <gr-button
+                        aria-disabled="false"
+                        class="deleteButton"
+                        data-index="18"
+                        link=""
+                        role="button"
+                        tabindex="0"
+                      >
+                        Delete
+                      </gr-button>
+                    </td>
+                  </tr>
+                  <tr class="table">
+                    <td class="branches name">
+                      <a
+                        href="https://git.example.org/branch/test;refs/heads/test18"
+                      >
+                        test18
+                      </a>
+                    </td>
+                    <td class="branches revision">
+                      <span class="revisionNoEditing">
+                        9c9d08a438e55e52f33b608415e6dddd9b18550d
+                      </span>
+                      <span class="revisionEdit">
+                        <span class="revisionWithEditing">
+                          9c9d08a438e55e52f33b608415e6dddd9b18550d
+                        </span>
+                        <gr-button
+                          aria-disabled="false"
+                          class="editBtn"
+                          data-index="19"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          edit
+                        </gr-button>
+                        <iron-input class="editItem">
+                          <input />
+                        </iron-input>
+                        <gr-button
+                          aria-disabled="false"
+                          class="cancelBtn editItem"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          Cancel
+                        </gr-button>
+                        <gr-button
+                          aria-disabled="true"
+                          class="editItem saveBtn"
+                          data-index="19"
+                          disabled=""
+                          link=""
+                          role="button"
+                          tabindex="-1"
+                        >
+                          Save
+                        </gr-button>
+                      </span>
+                    </td>
+                    <td class="hideItem message"></td>
+                    <td class="hideItem tagger"></td>
+                    <td class="repositoryBrowser">
+                      <a
+                        class="webLink"
+                        href="https://git.example.org/branch/test;refs/heads/test18"
+                        rel="noopener"
+                        target="_blank"
+                      >
+                        (diffusion)
+                      </a>
+                    </td>
+                    <td class="delete">
+                      <gr-button
+                        aria-disabled="false"
+                        class="deleteButton"
+                        data-index="19"
+                        link=""
+                        role="button"
+                        tabindex="0"
+                      >
+                        Delete
+                      </gr-button>
+                    </td>
+                  </tr>
+                  <tr class="table">
+                    <td class="branches name">
+                      <a
+                        href="https://git.example.org/branch/test;refs/heads/test19"
+                      >
+                        test19
+                      </a>
+                    </td>
+                    <td class="branches revision">
+                      <span class="revisionNoEditing">
+                        9c9d08a438e55e52f33b608415e6dddd9b18550d
+                      </span>
+                      <span class="revisionEdit">
+                        <span class="revisionWithEditing">
+                          9c9d08a438e55e52f33b608415e6dddd9b18550d
+                        </span>
+                        <gr-button
+                          aria-disabled="false"
+                          class="editBtn"
+                          data-index="20"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          edit
+                        </gr-button>
+                        <iron-input class="editItem">
+                          <input />
+                        </iron-input>
+                        <gr-button
+                          aria-disabled="false"
+                          class="cancelBtn editItem"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          Cancel
+                        </gr-button>
+                        <gr-button
+                          aria-disabled="true"
+                          class="editItem saveBtn"
+                          data-index="20"
+                          disabled=""
+                          link=""
+                          role="button"
+                          tabindex="-1"
+                        >
+                          Save
+                        </gr-button>
+                      </span>
+                    </td>
+                    <td class="hideItem message"></td>
+                    <td class="hideItem tagger"></td>
+                    <td class="repositoryBrowser">
+                      <a
+                        class="webLink"
+                        href="https://git.example.org/branch/test;refs/heads/test19"
+                        rel="noopener"
+                        target="_blank"
+                      >
+                        (diffusion)
+                      </a>
+                    </td>
+                    <td class="delete">
+                      <gr-button
+                        aria-disabled="false"
+                        class="deleteButton"
+                        data-index="20"
+                        link=""
+                        role="button"
+                        tabindex="0"
+                      >
+                        Delete
+                      </gr-button>
+                    </td>
+                  </tr>
+                  <tr class="table">
+                    <td class="branches name">
+                      <a
+                        href="https://git.example.org/branch/test;refs/heads/test20"
+                      >
+                        test20
+                      </a>
+                    </td>
+                    <td class="branches revision">
+                      <span class="revisionNoEditing">
+                        9c9d08a438e55e52f33b608415e6dddd9b18550d
+                      </span>
+                      <span class="revisionEdit">
+                        <span class="revisionWithEditing">
+                          9c9d08a438e55e52f33b608415e6dddd9b18550d
+                        </span>
+                        <gr-button
+                          aria-disabled="false"
+                          class="editBtn"
+                          data-index="21"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          edit
+                        </gr-button>
+                        <iron-input class="editItem">
+                          <input />
+                        </iron-input>
+                        <gr-button
+                          aria-disabled="false"
+                          class="cancelBtn editItem"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          Cancel
+                        </gr-button>
+                        <gr-button
+                          aria-disabled="true"
+                          class="editItem saveBtn"
+                          data-index="21"
+                          disabled=""
+                          link=""
+                          role="button"
+                          tabindex="-1"
+                        >
+                          Save
+                        </gr-button>
+                      </span>
+                    </td>
+                    <td class="hideItem message"></td>
+                    <td class="hideItem tagger"></td>
+                    <td class="repositoryBrowser">
+                      <a
+                        class="webLink"
+                        href="https://git.example.org/branch/test;refs/heads/test20"
+                        rel="noopener"
+                        target="_blank"
+                      >
+                        (diffusion)
+                      </a>
+                    </td>
+                    <td class="delete">
+                      <gr-button
+                        aria-disabled="false"
+                        class="deleteButton"
+                        data-index="21"
+                        link=""
+                        role="button"
+                        tabindex="0"
+                      >
+                        Delete
+                      </gr-button>
+                    </td>
+                  </tr>
+                  <tr class="table">
+                    <td class="branches name">
+                      <a
+                        href="https://git.example.org/branch/test;refs/heads/test21"
+                      >
+                        test21
+                      </a>
+                    </td>
+                    <td class="branches revision">
+                      <span class="revisionNoEditing">
+                        9c9d08a438e55e52f33b608415e6dddd9b18550d
+                      </span>
+                      <span class="revisionEdit">
+                        <span class="revisionWithEditing">
+                          9c9d08a438e55e52f33b608415e6dddd9b18550d
+                        </span>
+                        <gr-button
+                          aria-disabled="false"
+                          class="editBtn"
+                          data-index="22"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          edit
+                        </gr-button>
+                        <iron-input class="editItem">
+                          <input />
+                        </iron-input>
+                        <gr-button
+                          aria-disabled="false"
+                          class="cancelBtn editItem"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          Cancel
+                        </gr-button>
+                        <gr-button
+                          aria-disabled="true"
+                          class="editItem saveBtn"
+                          data-index="22"
+                          disabled=""
+                          link=""
+                          role="button"
+                          tabindex="-1"
+                        >
+                          Save
+                        </gr-button>
+                      </span>
+                    </td>
+                    <td class="hideItem message"></td>
+                    <td class="hideItem tagger"></td>
+                    <td class="repositoryBrowser">
+                      <a
+                        class="webLink"
+                        href="https://git.example.org/branch/test;refs/heads/test21"
+                        rel="noopener"
+                        target="_blank"
+                      >
+                        (diffusion)
+                      </a>
+                    </td>
+                    <td class="delete">
+                      <gr-button
+                        aria-disabled="false"
+                        class="deleteButton"
+                        data-index="22"
+                        link=""
+                        role="button"
+                        tabindex="0"
+                      >
+                        Delete
+                      </gr-button>
+                    </td>
+                  </tr>
+                  <tr class="table">
+                    <td class="branches name">
+                      <a
+                        href="https://git.example.org/branch/test;refs/heads/test22"
+                      >
+                        test22
+                      </a>
+                    </td>
+                    <td class="branches revision">
+                      <span class="revisionNoEditing">
+                        9c9d08a438e55e52f33b608415e6dddd9b18550d
+                      </span>
+                      <span class="revisionEdit">
+                        <span class="revisionWithEditing">
+                          9c9d08a438e55e52f33b608415e6dddd9b18550d
+                        </span>
+                        <gr-button
+                          aria-disabled="false"
+                          class="editBtn"
+                          data-index="23"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          edit
+                        </gr-button>
+                        <iron-input class="editItem">
+                          <input />
+                        </iron-input>
+                        <gr-button
+                          aria-disabled="false"
+                          class="cancelBtn editItem"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          Cancel
+                        </gr-button>
+                        <gr-button
+                          aria-disabled="true"
+                          class="editItem saveBtn"
+                          data-index="23"
+                          disabled=""
+                          link=""
+                          role="button"
+                          tabindex="-1"
+                        >
+                          Save
+                        </gr-button>
+                      </span>
+                    </td>
+                    <td class="hideItem message"></td>
+                    <td class="hideItem tagger"></td>
+                    <td class="repositoryBrowser">
+                      <a
+                        class="webLink"
+                        href="https://git.example.org/branch/test;refs/heads/test22"
+                        rel="noopener"
+                        target="_blank"
+                      >
+                        (diffusion)
+                      </a>
+                    </td>
+                    <td class="delete">
+                      <gr-button
+                        aria-disabled="false"
+                        class="deleteButton"
+                        data-index="23"
+                        link=""
+                        role="button"
+                        tabindex="0"
+                      >
+                        Delete
+                      </gr-button>
+                    </td>
+                  </tr>
+                  <tr class="table">
+                    <td class="branches name">
+                      <a
+                        href="https://git.example.org/branch/test;refs/heads/test23"
+                      >
+                        test23
+                      </a>
+                    </td>
+                    <td class="branches revision">
+                      <span class="revisionNoEditing">
+                        9c9d08a438e55e52f33b608415e6dddd9b18550d
+                      </span>
+                      <span class="revisionEdit">
+                        <span class="revisionWithEditing">
+                          9c9d08a438e55e52f33b608415e6dddd9b18550d
+                        </span>
+                        <gr-button
+                          aria-disabled="false"
+                          class="editBtn"
+                          data-index="24"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          edit
+                        </gr-button>
+                        <iron-input class="editItem">
+                          <input />
+                        </iron-input>
+                        <gr-button
+                          aria-disabled="false"
+                          class="cancelBtn editItem"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          Cancel
+                        </gr-button>
+                        <gr-button
+                          aria-disabled="true"
+                          class="editItem saveBtn"
+                          data-index="24"
+                          disabled=""
+                          link=""
+                          role="button"
+                          tabindex="-1"
+                        >
+                          Save
+                        </gr-button>
+                      </span>
+                    </td>
+                    <td class="hideItem message"></td>
+                    <td class="hideItem tagger"></td>
+                    <td class="repositoryBrowser">
+                      <a
+                        class="webLink"
+                        href="https://git.example.org/branch/test;refs/heads/test23"
+                        rel="noopener"
+                        target="_blank"
+                      >
+                        (diffusion)
+                      </a>
+                    </td>
+                    <td class="delete">
+                      <gr-button
+                        aria-disabled="false"
+                        class="deleteButton"
+                        data-index="24"
+                        link=""
+                        role="button"
+                        tabindex="0"
+                      >
+                        Delete
+                      </gr-button>
+                    </td>
+                  </tr>
+                </tbody>
+              </table>
+              <gr-overlay
+                aria-hidden="true"
+                id="overlay"
+                style="outline: none; display: none;"
+                tabindex="-1"
+                with-backdrop=""
+              >
+                <gr-confirm-delete-item-dialog class="confirmDialog">
+                </gr-confirm-delete-item-dialog>
+              </gr-overlay>
+            </gr-list-view>
+            <gr-overlay
+              aria-hidden="true"
+              id="createOverlay"
+              style="outline: none; display: none;"
+              tabindex="-1"
+              with-backdrop=""
+            >
+              <gr-dialog
+                confirm-label="Create"
+                disabled=""
+                id="createDialog"
+                role="dialog"
+              >
+                <div class="header" slot="header">Create Branch</div>
+                <div class="main" slot="main">
+                  <gr-create-pointer-dialog id="createNewModal">
+                  </gr-create-pointer-dialog>
+                </div>
+              </gr-dialog>
+            </gr-overlay>
+          `
+        );
+      });
+
       test('test for branch in the list', () => {
         assert.equal(element.items![3].ref, 'refs/heads/test2');
       });
@@ -264,7 +2193,7 @@
           assert.equal(getComputedStyle(item).display, 'none');
         }
 
-        MockInteractions.tap(editBtn);
+        editBtn.click();
         await element.updateComplete;
         // The revision and edit button are not visible.
         assert.equal(getComputedStyle(revisionWithEditing).display, 'none');
@@ -293,11 +2222,11 @@
 
         // Save button calls handleSave. since this is stubbed, the edit
         // section remains open.
-        MockInteractions.tap(saveBtn);
+        saveBtn.click();
         assert.isTrue(handleSaveRevisionStub.called);
 
-        // When cancel is tapped, the edit secion closes.
-        MockInteractions.tap(cancelBtn);
+        // When cancel is tapped, the edit section closes.
+        cancelBtn.click();
         await element.updateComplete;
 
         // The revision and edit button are visible.
@@ -419,8 +2348,9 @@
     let tags: TagInfo[];
 
     setup(async () => {
-      element = basicFixture.instantiate();
-      await element.updateComplete;
+      element = await fixture(
+        html`<gr-repo-detail-list></gr-repo-detail-list>`
+      );
       element.detailType = RepoDetailView.TAGS;
       sinon.stub(page, 'show');
     });
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.ts b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.ts
index 0c2ca7c..14fbe92 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.ts
@@ -1,25 +1,12 @@
 /**
  * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '../../shared/gr-dialog/gr-dialog';
 import '../../shared/gr-list-view/gr-list-view';
 import '../../shared/gr-overlay/gr-overlay';
 import '../gr-create-repo-dialog/gr-create-repo-dialog';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation';
-import {AppElementAdminParams} from '../../gr-app-types';
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
 import {
   RepoName,
@@ -34,7 +21,9 @@
 import {tableStyles} from '../../../styles/gr-table-styles';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, PropertyValues, css, html} from 'lit';
-import {customElement, property, query, state} from 'lit/decorators';
+import {customElement, property, query, state} from 'lit/decorators.js';
+import {AdminViewState} from '../../../models/views/admin';
+import {createSearchUrl} from '../../../models/views/search';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -51,7 +40,7 @@
   @query('#createNewModal') private createNewModal?: GrCreateRepoDialog;
 
   @property({type: Object})
-  params?: AppElementAdminParams;
+  params?: AdminViewState;
 
   // private but used in test
   @state() offset = 0;
@@ -215,7 +204,7 @@
    *
    * private but used in test
    */
-  maybeOpenCreateOverlay(params?: AppElementAdminParams) {
+  maybeOpenCreateOverlay(params?: AdminViewState) {
     if (params?.openCreateModal) {
       this.createOverlay?.open();
     }
@@ -226,7 +215,7 @@
   }
 
   private computeChangesLink(name: string) {
-    return GerritNav.getUrlForProjectChanges(name as RepoName);
+    return createSearchUrl({project: name as RepoName});
   }
 
   private async getCreateRepoCapability() {
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.ts b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.ts
index 1d4e360..752e5f2 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.ts
@@ -1,21 +1,9 @@
 /**
  * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-repo-list';
 import {GrRepoList} from './gr-repo-list';
 import {page} from '../../../utils/page-wrapper-utils';
@@ -29,14 +17,13 @@
   ProjectInfoWithName,
   RepoName,
 } from '../../../types/common';
-import {AppElementAdminParams} from '../../gr-app-types';
 import {ProjectState, SHOWN_ITEMS_COUNT} from '../../../constants/constants';
 import {GerritView} from '../../../services/router/router-model';
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
 import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
 import {GrListView} from '../../shared/gr-list-view/gr-list-view';
-
-const basicFixture = fixtureFromElement('gr-repo-list');
+import {fixture, html, assert} from '@open-wc/testing';
+import {AdminChildView, AdminViewState} from '../../../models/views/admin';
 
 function createRepo(name: string, counter: number) {
   return {
@@ -66,8 +53,7 @@
 
   setup(async () => {
     sinon.stub(page, 'show');
-    element = basicFixture.instantiate();
-    await element.updateComplete;
+    element = await fixture(html`<gr-repo-list></gr-repo-list>`);
   });
 
   suite('list with repos', () => {
@@ -78,6 +64,555 @@
       await element.updateComplete;
     });
 
+    test('render', () => {
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <gr-list-view>
+            <table class="genericList" id="list">
+              <tbody>
+                <tr class="headerRow">
+                  <th class="name topHeader">Repository Name</th>
+                  <th class="repositoryBrowser topHeader">
+                    Repository Browser
+                  </th>
+                  <th class="changesLink topHeader">Changes</th>
+                  <th class="readOnly topHeader">Read only</th>
+                  <th class="description topHeader">Repository Description</th>
+                </tr>
+                <tr class="loadingMsg" id="loading">
+                  <td>Loading...</td>
+                </tr>
+              </tbody>
+              <tbody>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/admin/repos/test"> test </a>
+                  </td>
+                  <td class="repositoryBrowser">
+                    <a
+                      class="webLink"
+                      href="https://phabricator.example.org/r/project/test0"
+                      rel="noopener"
+                      target="_blank"
+                    >
+                      diffusion
+                    </a>
+                  </td>
+                  <td class="changesLink">
+                    <a href="/q/project:test"> view all </a>
+                  </td>
+                  <td class="readOnly"></td>
+                  <td class="description"></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/admin/repos/test"> test </a>
+                  </td>
+                  <td class="repositoryBrowser">
+                    <a
+                      class="webLink"
+                      href="https://phabricator.example.org/r/project/test1"
+                      rel="noopener"
+                      target="_blank"
+                    >
+                      diffusion
+                    </a>
+                  </td>
+                  <td class="changesLink">
+                    <a href="/q/project:test"> view all </a>
+                  </td>
+                  <td class="readOnly"></td>
+                  <td class="description"></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/admin/repos/test"> test </a>
+                  </td>
+                  <td class="repositoryBrowser">
+                    <a
+                      class="webLink"
+                      href="https://phabricator.example.org/r/project/test2"
+                      rel="noopener"
+                      target="_blank"
+                    >
+                      diffusion
+                    </a>
+                  </td>
+                  <td class="changesLink">
+                    <a href="/q/project:test"> view all </a>
+                  </td>
+                  <td class="readOnly"></td>
+                  <td class="description"></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/admin/repos/test"> test </a>
+                  </td>
+                  <td class="repositoryBrowser">
+                    <a
+                      class="webLink"
+                      href="https://phabricator.example.org/r/project/test3"
+                      rel="noopener"
+                      target="_blank"
+                    >
+                      diffusion
+                    </a>
+                  </td>
+                  <td class="changesLink">
+                    <a href="/q/project:test"> view all </a>
+                  </td>
+                  <td class="readOnly"></td>
+                  <td class="description"></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/admin/repos/test"> test </a>
+                  </td>
+                  <td class="repositoryBrowser">
+                    <a
+                      class="webLink"
+                      href="https://phabricator.example.org/r/project/test4"
+                      rel="noopener"
+                      target="_blank"
+                    >
+                      diffusion
+                    </a>
+                  </td>
+                  <td class="changesLink">
+                    <a href="/q/project:test"> view all </a>
+                  </td>
+                  <td class="readOnly"></td>
+                  <td class="description"></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/admin/repos/test"> test </a>
+                  </td>
+                  <td class="repositoryBrowser">
+                    <a
+                      class="webLink"
+                      href="https://phabricator.example.org/r/project/test5"
+                      rel="noopener"
+                      target="_blank"
+                    >
+                      diffusion
+                    </a>
+                  </td>
+                  <td class="changesLink">
+                    <a href="/q/project:test"> view all </a>
+                  </td>
+                  <td class="readOnly"></td>
+                  <td class="description"></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/admin/repos/test"> test </a>
+                  </td>
+                  <td class="repositoryBrowser">
+                    <a
+                      class="webLink"
+                      href="https://phabricator.example.org/r/project/test6"
+                      rel="noopener"
+                      target="_blank"
+                    >
+                      diffusion
+                    </a>
+                  </td>
+                  <td class="changesLink">
+                    <a href="/q/project:test"> view all </a>
+                  </td>
+                  <td class="readOnly"></td>
+                  <td class="description"></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/admin/repos/test"> test </a>
+                  </td>
+                  <td class="repositoryBrowser">
+                    <a
+                      class="webLink"
+                      href="https://phabricator.example.org/r/project/test7"
+                      rel="noopener"
+                      target="_blank"
+                    >
+                      diffusion
+                    </a>
+                  </td>
+                  <td class="changesLink">
+                    <a href="/q/project:test"> view all </a>
+                  </td>
+                  <td class="readOnly"></td>
+                  <td class="description"></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/admin/repos/test"> test </a>
+                  </td>
+                  <td class="repositoryBrowser">
+                    <a
+                      class="webLink"
+                      href="https://phabricator.example.org/r/project/test8"
+                      rel="noopener"
+                      target="_blank"
+                    >
+                      diffusion
+                    </a>
+                  </td>
+                  <td class="changesLink">
+                    <a href="/q/project:test"> view all </a>
+                  </td>
+                  <td class="readOnly"></td>
+                  <td class="description"></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/admin/repos/test"> test </a>
+                  </td>
+                  <td class="repositoryBrowser">
+                    <a
+                      class="webLink"
+                      href="https://phabricator.example.org/r/project/test9"
+                      rel="noopener"
+                      target="_blank"
+                    >
+                      diffusion
+                    </a>
+                  </td>
+                  <td class="changesLink">
+                    <a href="/q/project:test"> view all </a>
+                  </td>
+                  <td class="readOnly"></td>
+                  <td class="description"></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/admin/repos/test"> test </a>
+                  </td>
+                  <td class="repositoryBrowser">
+                    <a
+                      class="webLink"
+                      href="https://phabricator.example.org/r/project/test10"
+                      rel="noopener"
+                      target="_blank"
+                    >
+                      diffusion
+                    </a>
+                  </td>
+                  <td class="changesLink">
+                    <a href="/q/project:test"> view all </a>
+                  </td>
+                  <td class="readOnly"></td>
+                  <td class="description"></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/admin/repos/test"> test </a>
+                  </td>
+                  <td class="repositoryBrowser">
+                    <a
+                      class="webLink"
+                      href="https://phabricator.example.org/r/project/test11"
+                      rel="noopener"
+                      target="_blank"
+                    >
+                      diffusion
+                    </a>
+                  </td>
+                  <td class="changesLink">
+                    <a href="/q/project:test"> view all </a>
+                  </td>
+                  <td class="readOnly"></td>
+                  <td class="description"></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/admin/repos/test"> test </a>
+                  </td>
+                  <td class="repositoryBrowser">
+                    <a
+                      class="webLink"
+                      href="https://phabricator.example.org/r/project/test12"
+                      rel="noopener"
+                      target="_blank"
+                    >
+                      diffusion
+                    </a>
+                  </td>
+                  <td class="changesLink">
+                    <a href="/q/project:test"> view all </a>
+                  </td>
+                  <td class="readOnly"></td>
+                  <td class="description"></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/admin/repos/test"> test </a>
+                  </td>
+                  <td class="repositoryBrowser">
+                    <a
+                      class="webLink"
+                      href="https://phabricator.example.org/r/project/test13"
+                      rel="noopener"
+                      target="_blank"
+                    >
+                      diffusion
+                    </a>
+                  </td>
+                  <td class="changesLink">
+                    <a href="/q/project:test"> view all </a>
+                  </td>
+                  <td class="readOnly"></td>
+                  <td class="description"></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/admin/repos/test"> test </a>
+                  </td>
+                  <td class="repositoryBrowser">
+                    <a
+                      class="webLink"
+                      href="https://phabricator.example.org/r/project/test14"
+                      rel="noopener"
+                      target="_blank"
+                    >
+                      diffusion
+                    </a>
+                  </td>
+                  <td class="changesLink">
+                    <a href="/q/project:test"> view all </a>
+                  </td>
+                  <td class="readOnly"></td>
+                  <td class="description"></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/admin/repos/test"> test </a>
+                  </td>
+                  <td class="repositoryBrowser">
+                    <a
+                      class="webLink"
+                      href="https://phabricator.example.org/r/project/test15"
+                      rel="noopener"
+                      target="_blank"
+                    >
+                      diffusion
+                    </a>
+                  </td>
+                  <td class="changesLink">
+                    <a href="/q/project:test"> view all </a>
+                  </td>
+                  <td class="readOnly"></td>
+                  <td class="description"></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/admin/repos/test"> test </a>
+                  </td>
+                  <td class="repositoryBrowser">
+                    <a
+                      class="webLink"
+                      href="https://phabricator.example.org/r/project/test16"
+                      rel="noopener"
+                      target="_blank"
+                    >
+                      diffusion
+                    </a>
+                  </td>
+                  <td class="changesLink">
+                    <a href="/q/project:test"> view all </a>
+                  </td>
+                  <td class="readOnly"></td>
+                  <td class="description"></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/admin/repos/test"> test </a>
+                  </td>
+                  <td class="repositoryBrowser">
+                    <a
+                      class="webLink"
+                      href="https://phabricator.example.org/r/project/test17"
+                      rel="noopener"
+                      target="_blank"
+                    >
+                      diffusion
+                    </a>
+                  </td>
+                  <td class="changesLink">
+                    <a href="/q/project:test"> view all </a>
+                  </td>
+                  <td class="readOnly"></td>
+                  <td class="description"></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/admin/repos/test"> test </a>
+                  </td>
+                  <td class="repositoryBrowser">
+                    <a
+                      class="webLink"
+                      href="https://phabricator.example.org/r/project/test18"
+                      rel="noopener"
+                      target="_blank"
+                    >
+                      diffusion
+                    </a>
+                  </td>
+                  <td class="changesLink">
+                    <a href="/q/project:test"> view all </a>
+                  </td>
+                  <td class="readOnly"></td>
+                  <td class="description"></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/admin/repos/test"> test </a>
+                  </td>
+                  <td class="repositoryBrowser">
+                    <a
+                      class="webLink"
+                      href="https://phabricator.example.org/r/project/test19"
+                      rel="noopener"
+                      target="_blank"
+                    >
+                      diffusion
+                    </a>
+                  </td>
+                  <td class="changesLink">
+                    <a href="/q/project:test"> view all </a>
+                  </td>
+                  <td class="readOnly"></td>
+                  <td class="description"></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/admin/repos/test"> test </a>
+                  </td>
+                  <td class="repositoryBrowser">
+                    <a
+                      class="webLink"
+                      href="https://phabricator.example.org/r/project/test20"
+                      rel="noopener"
+                      target="_blank"
+                    >
+                      diffusion
+                    </a>
+                  </td>
+                  <td class="changesLink">
+                    <a href="/q/project:test"> view all </a>
+                  </td>
+                  <td class="readOnly"></td>
+                  <td class="description"></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/admin/repos/test"> test </a>
+                  </td>
+                  <td class="repositoryBrowser">
+                    <a
+                      class="webLink"
+                      href="https://phabricator.example.org/r/project/test21"
+                      rel="noopener"
+                      target="_blank"
+                    >
+                      diffusion
+                    </a>
+                  </td>
+                  <td class="changesLink">
+                    <a href="/q/project:test"> view all </a>
+                  </td>
+                  <td class="readOnly"></td>
+                  <td class="description"></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/admin/repos/test"> test </a>
+                  </td>
+                  <td class="repositoryBrowser">
+                    <a
+                      class="webLink"
+                      href="https://phabricator.example.org/r/project/test22"
+                      rel="noopener"
+                      target="_blank"
+                    >
+                      diffusion
+                    </a>
+                  </td>
+                  <td class="changesLink">
+                    <a href="/q/project:test"> view all </a>
+                  </td>
+                  <td class="readOnly"></td>
+                  <td class="description"></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/admin/repos/test"> test </a>
+                  </td>
+                  <td class="repositoryBrowser">
+                    <a
+                      class="webLink"
+                      href="https://phabricator.example.org/r/project/test23"
+                      rel="noopener"
+                      target="_blank"
+                    >
+                      diffusion
+                    </a>
+                  </td>
+                  <td class="changesLink">
+                    <a href="/q/project:test"> view all </a>
+                  </td>
+                  <td class="readOnly"></td>
+                  <td class="description"></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/admin/repos/test"> test </a>
+                  </td>
+                  <td class="repositoryBrowser">
+                    <a
+                      class="webLink"
+                      href="https://phabricator.example.org/r/project/test24"
+                      rel="noopener"
+                      target="_blank"
+                    >
+                      diffusion
+                    </a>
+                  </td>
+                  <td class="changesLink">
+                    <a href="/q/project:test"> view all </a>
+                  </td>
+                  <td class="readOnly"></td>
+                  <td class="description"></td>
+                </tr>
+              </tbody>
+            </table>
+          </gr-list-view>
+          <gr-overlay
+            aria-hidden="true"
+            id="createOverlay"
+            style="outline: none; display: none;"
+            tabindex="-1"
+            with-backdrop=""
+          >
+            <gr-dialog
+              class="confirmDialog"
+              confirm-label="Create"
+              disabled=""
+              id="createDialog"
+              role="dialog"
+            >
+              <div class="header" slot="header">Create Repository</div>
+              <div class="main" slot="main">
+                <gr-create-repo-dialog id="createNewModal">
+                </gr-create-repo-dialog>
+              </div>
+            </gr-dialog>
+          </gr-overlay>
+        `
+      );
+    });
+
     test('test for test repo in the list', async () => {
       await element.updateComplete;
       assert.equal(element.repos[0].id, 'test0');
@@ -98,9 +633,9 @@
       assert.isFalse(overlayOpen.called);
       element.maybeOpenCreateOverlay(undefined);
       assert.isFalse(overlayOpen.called);
-      const params: AppElementAdminParams = {
+      const params: AdminViewState = {
         view: GerritView.ADMIN,
-        adminView: '',
+        adminView: AdminChildView.REPOS,
         openCreateModal: true,
       };
       element.maybeOpenCreateOverlay(params);
@@ -134,10 +669,10 @@
       repoStub.returns(Promise.resolve(repos));
       element.params = {
         view: GerritView.ADMIN,
-        adminView: '',
+        adminView: AdminChildView.REPOS,
         filter: 'test',
         offset: 25,
-      } as AppElementAdminParams;
+      } as AdminViewState;
       await element._paramsChanged();
       assert.isTrue(repoStub.lastCall.calledWithExactly('test', 25, 25));
     });
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config-types.ts b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config-types.ts
index d5515f9..319c030 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config-types.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config-types.ts
@@ -1,18 +1,7 @@
 /**
  * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 /**
  * @fileoverview This file contains interfaces shared between
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config.ts b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config.ts
index e23db15..ad87d86 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config.ts
@@ -1,30 +1,18 @@
 /**
  * @license
- * Copyright (C) 2018 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '@polymer/iron-input/iron-input';
 import '@polymer/paper-toggle-button/paper-toggle-button';
 import {formStyles} from '../../../styles/gr-form-styles';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {subpageStyles} from '../../../styles/gr-subpage-styles';
-import '../../shared/gr-icons/gr-icons';
 import '../../shared/gr-select/gr-select';
 import '../../shared/gr-tooltip-content/gr-tooltip-content';
 import '../gr-plugin-config-array-editor/gr-plugin-config-array-editor';
 import {LitElement, css, html} from 'lit';
-import {customElement, property} from 'lit/decorators';
+import {customElement, property} from 'lit/decorators.js';
 import {ConfigParameterInfoType} from '../../../constants/constants';
 import {
   ConfigParameterInfo,
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config_test.ts b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config_test.ts
index fa3a635..3dc6f1e 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config_test.ts
@@ -1,21 +1,9 @@
 /**
  * @license
- * Copyright (C) 2018 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-repo-plugin-config';
 import {GrRepoPluginConfig} from './gr-repo-plugin-config';
 import {PluginParameterToConfigParameterInfoMap} from '../../../types/common';
@@ -23,15 +11,46 @@
 import {queryAndAssert} from '../../../test/test-utils';
 import {GrPluginConfigArrayEditor} from '../gr-plugin-config-array-editor/gr-plugin-config-array-editor';
 import {PaperToggleButtonElement} from '@polymer/paper-toggle-button/paper-toggle-button';
-
-const basicFixture = fixtureFromElement('gr-repo-plugin-config');
+import {fixture, html, assert} from '@open-wc/testing';
 
 suite('gr-repo-plugin-config tests', () => {
   let element: GrRepoPluginConfig;
 
   setup(async () => {
-    element = basicFixture.instantiate();
+    element = await fixture(
+      html`<gr-repo-plugin-config></gr-repo-plugin-config>`
+    );
+  });
+
+  test('render', async () => {
+    element.pluginData = {
+      name: 'testName',
+      config: {
+        plugin: {type: 'STRING' as ConfigParameterInfoType, value: 'test'},
+      },
+    };
     await element.updateComplete;
+
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div class="gr-form-styles">
+          <fieldset>
+            <h4>testName</h4>
+            <section class="STRING section">
+              <span class="title">
+                <span> </span>
+              </span>
+              <span class="value">
+                <iron-input data-option-key="plugin">
+                  <input data-option-key="plugin" disabled="" is="iron-input" />
+                </iron-input>
+              </span>
+            </section>
+          </fieldset>
+        </div>
+      `
+    );
   });
 
   test('_computePluginConfigOptions', () => {
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 06691d7..01d452a 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
@@ -1,18 +1,7 @@
 /**
  * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '@polymer/iron-input/iron-input';
 import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
@@ -22,7 +11,6 @@
 import '../../shared/gr-select/gr-select';
 import '../../shared/gr-textarea/gr-textarea';
 import '../gr-repo-plugin-config/gr-repo-plugin-config';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {
   ConfigInfo,
   RepoName,
@@ -49,8 +37,9 @@
 import {BindValueChangeEvent} from '../../../types/events';
 import {deepClone} from '../../../utils/deep-util';
 import {LitElement, PropertyValues, css, html} from 'lit';
-import {customElement, property, state} from 'lit/decorators';
+import {customElement, property, state} from 'lit/decorators.js';
 import {subscribe} from '../../lit/subscription-controller';
+import {createSearchUrl} from '../../../models/views/search';
 
 const STATES = {
   active: {value: ProjectState.ACTIVE, label: 'Active'},
@@ -127,12 +116,16 @@
 
   constructor() {
     super();
-    subscribe(this, this.userModel.preferences$, prefs => {
-      if (prefs?.download_scheme) {
-        // Note (issue 5180): normalize the download scheme with lower-case.
-        this.selectedScheme = prefs.download_scheme.toLowerCase();
+    subscribe(
+      this,
+      () => this.userModel.preferences$,
+      prefs => {
+        if (prefs?.download_scheme) {
+          // Note (issue 5180): normalize the download scheme with lower-case.
+          this.selectedScheme = prefs.download_scheme.toLowerCase();
+        }
       }
-    });
+    );
   }
 
   override connectedCallback() {
@@ -268,7 +261,7 @@
           rows="4"
           monospace
           ?disabled=${this.readOnly}
-          .text=${this.repoConfig?.description}
+          .text=${this.repoConfig?.description ?? ''}
           @text-changed=${this.handleDescriptionTextChanged}
         ></gr-textarea>
       </fieldset>
@@ -1105,7 +1098,7 @@
 
   private computeChangesUrl(name?: RepoName) {
     if (!name) return '';
-    return GerritNav.getUrlForProjectChanges(name);
+    return createSearchUrl({project: name});
   }
 
   // private but used in test
diff --git a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.ts b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.ts
index 65eee70..38c4a5d 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.ts
@@ -1,21 +1,9 @@
 /**
  * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-repo';
 import {GrRepo} from './gr-repo';
 import {mockPromise} from '../../../test/test-utils';
@@ -51,13 +39,12 @@
   createConfig,
   createDownloadSchemes,
 } from '../../../test/test-data-generators';
-import {PageErrorEvent} from '../../../types/events.js';
+import {PageErrorEvent} from '../../../types/events';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {GrSelect} from '../../shared/gr-select/gr-select';
 import {GrTextarea} from '../../shared/gr-textarea/gr-textarea';
 import {IronInputElement} from '@polymer/iron-input/iron-input';
-
-const basicFixture = fixtureFromElement('gr-repo');
+import {fixture, html, assert} from '@open-wc/testing';
 
 suite('gr-repo tests', () => {
   let element: GrRepo;
@@ -167,8 +154,252 @@
     repoStub = stubRestApi('getProjectConfig').returns(
       Promise.resolve(repoConf)
     );
-    element = basicFixture.instantiate();
-    await element.updateComplete;
+    element = await fixture(html`<gr-repo></gr-repo>`);
+  });
+
+  test('render', () => {
+    // prettier and shadowDom assert do not agree about span.title wrapping
+    assert.shadowDom.equal(
+      element,
+      /* prettier-ignore */ /* HTML */ `
+      <div class="gr-form-styles main read-only">
+        <div class="info">
+          <h1 class="heading-1" id="Title"></h1>
+          <hr />
+          <div>
+            <a href="">
+              <gr-button
+                aria-disabled="true"
+                disabled=""
+                link=""
+                role="button"
+                tabindex="-1"
+              >
+                Browse
+              </gr-button>
+            </a>
+            <a href="">
+              <gr-button
+                aria-disabled="false"
+                link=""
+                role="button"
+                tabindex="0"
+              >
+                View Changes
+              </gr-button>
+            </a>
+          </div>
+        </div>
+        <div class="loading" id="loading">Loading...</div>
+        <div class="loading" id="loadedContent">
+          <div class="hide" id="downloadContent">
+            <h2 class="heading-2" id="download">Download</h2>
+            <fieldset>
+              <gr-download-commands id="downloadCommands">
+              </gr-download-commands>
+            </fieldset>
+          </div>
+          <h2 class="heading-2" id="configurations">Configurations</h2>
+          <div id="form">
+            <fieldset>
+              <h3 class="heading-3" id="Description">Description</h3>
+              <fieldset>
+                <gr-textarea
+                  autocomplete="on"
+                  class="description monospace"
+                  disabled=""
+                  id="descriptionInput"
+                  monospace=""
+                  placeholder="<Insert repo description here>"
+                  rows="4"
+                >
+                </gr-textarea>
+              </fieldset>
+              <h3 class="heading-3" id="Options">Repository Options</h3>
+              <fieldset id="options">
+                <section>
+                  <span class="title"> State </span>
+                  <span class="value">
+                    <gr-select id="stateSelect">
+                      <select disabled="">
+                        <option value="ACTIVE">Active</option>
+                        <option value="READ_ONLY">Read Only</option>
+                        <option value="HIDDEN">Hidden</option>
+                      </select>
+                    </gr-select>
+                  </span>
+                </section>
+                <section>
+                  <span class="title"> Submit type </span>
+                  <span class="value">
+                    <gr-select id="submitTypeSelect">
+                      <select disabled=""></select>
+                    </gr-select>
+                  </span>
+                </section>
+                <section>
+                  <span class="title"> Allow content merges </span>
+                  <span class="value">
+                    <gr-select id="contentMergeSelect">
+                      <select disabled=""></select>
+                    </gr-select>
+                  </span>
+                </section>
+                <section>
+                  <span class="title">
+                    Create a new change for every commit not in the target branch
+                  </span>
+                  <span class="value">
+                    <gr-select id="newChangeSelect">
+                      <select disabled=""></select>
+                    </gr-select>
+                  </span>
+                </section>
+                <section>
+                  <span class="title">
+                    Require Change-Id in commit message
+                  </span>
+                  <span class="value">
+                    <gr-select id="requireChangeIdSelect">
+                      <select disabled=""></select>
+                    </gr-select>
+                  </span>
+                </section>
+                <section
+                  class="repositorySettings"
+                  id="enableSignedPushSettings"
+                >
+                  <span class="title"> Enable signed push </span>
+                  <span class="value">
+                    <gr-select id="enableSignedPush">
+                      <select disabled=""></select>
+                    </gr-select>
+                  </span>
+                </section>
+                <section
+                  class="repositorySettings"
+                  id="requireSignedPushSettings"
+                >
+                  <span class="title"> Require signed push </span>
+                  <span class="value">
+                    <gr-select id="requireSignedPush">
+                      <select disabled=""></select>
+                    </gr-select>
+                  </span>
+                </section>
+                <section>
+                  <span class="title">
+                    Reject implicit merges when changes are pushed for review
+                  </span>
+                  <span class="value">
+                    <gr-select id="rejectImplicitMergesSelect">
+                      <select disabled=""></select>
+                    </gr-select>
+                  </span>
+                </section>
+                <section>
+                  <span class="title">
+                    Enable adding unregistered users as reviewers and CCs on changes
+                  </span>
+                  <span class="value">
+                    <gr-select id="unRegisteredCcSelect">
+                      <select disabled=""></select>
+                    </gr-select>
+                  </span>
+                </section>
+                <section>
+                  <span class="title">
+                    Set all new changes private by default
+                  </span>
+                  <span class="value">
+                    <gr-select id="setAllnewChangesPrivateByDefaultSelect">
+                      <select disabled=""></select>
+                    </gr-select>
+                  </span>
+                </section>
+                <section>
+                  <span class="title">
+                    Set new changes to "work in progress" by default
+                  </span>
+                  <span class="value">
+                    <gr-select
+                      id="setAllNewChangesWorkInProgressByDefaultSelect"
+                    >
+                      <select disabled=""></select>
+                    </gr-select>
+                  </span>
+                </section>
+                <section>
+                  <span class="title"> Maximum Git object size limit </span>
+                  <span class="value">
+                    <iron-input id="maxGitObjSizeIronInput">
+                      <input disabled="" id="maxGitObjSizeInput" type="text" />
+                    </iron-input>
+                  </span>
+                </section>
+                <section>
+                  <span class="title">
+                    Match authored date with committer date upon submit
+                  </span>
+                  <span class="value">
+                    <gr-select id="matchAuthoredDateWithCommitterDateSelect">
+                      <select disabled=""></select>
+                    </gr-select>
+                  </span>
+                </section>
+                <section>
+                  <span class="title"> Reject empty commit upon submit </span>
+                  <span class="value">
+                    <gr-select id="rejectEmptyCommitSelect">
+                      <select disabled=""></select>
+                    </gr-select>
+                  </span>
+                </section>
+              </fieldset>
+              <h3 class="heading-3" id="Options">Contributor Agreements</h3>
+              <fieldset id="agreements">
+                <section>
+                  <span class="title">
+                    Require a valid contributor agreement to upload
+                  </span>
+                  <span class="value">
+                    <gr-select id="contributorAgreementSelect">
+                      <select disabled=""></select>
+                    </gr-select>
+                  </span>
+                </section>
+                <section>
+                  <span class="title">
+                    Require Signed-off-by in commit message
+                  </span>
+                  <span class="value">
+                    <gr-select id="useSignedOffBySelect">
+                      <select disabled=""></select>
+                    </gr-select>
+                  </span>
+                </section>
+              </fieldset>
+              <div class="hide pluginConfig">
+                <h3 class="heading-3">Plugins</h3>
+              </div>
+              <gr-button
+                aria-disabled="true"
+                disabled=""
+                role="button"
+                tabindex="-1"
+              >
+                Save changes
+              </gr-button>
+            </fieldset>
+            <gr-endpoint-decorator name="repo-config">
+              <gr-endpoint-param name="repoName"> </gr-endpoint-param>
+              <gr-endpoint-param name="readOnly"> </gr-endpoint-param>
+            </gr-endpoint-decorator>
+          </div>
+        </div>
+      </div>
+    `
+    );
   });
 
   test('_computePluginData', async () => {
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 1685ca4..975dd3b 100644
--- a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.ts
+++ b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.ts
@@ -1,18 +1,7 @@
 /**
  * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
 import '../../shared/gr-button/gr-button';
@@ -23,9 +12,9 @@
 import {formStyles} from '../../../styles/gr-form-styles';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, PropertyValues, html, css} from 'lit';
-import {customElement, property, state} from 'lit/decorators';
+import {customElement, property, state} from 'lit/decorators.js';
 import {BindValueChangeEvent} from '../../../types/events';
-import {ifDefined} from 'lit/directives/if-defined';
+import {ifDefined} from 'lit/directives/if-defined.js';
 import {EditablePermissionRuleInfo} from '../gr-repo-access/gr-repo-access-interfaces';
 import {PermissionAction} from '../../../constants/constants';
 
diff --git a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.ts b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.ts
index 047bbb6..8066289 100644
--- a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.ts
@@ -1,29 +1,16 @@
 /**
  * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-rule-editor';
 import {GrRuleEditor} from './gr-rule-editor';
 import {AccessPermissionId} from '../../../utils/access-util';
 import {query, queryAll, queryAndAssert} from '../../../test/test-utils';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {GrSelect} from '../../shared/gr-select/gr-select';
-import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
-import {fixture, html} from '@open-wc/testing-helpers';
+import {fixture, html, assert} from '@open-wc/testing';
 import {EditablePermissionRuleInfo} from '../gr-repo-access/gr-repo-access-interfaces';
 import {PermissionAction} from '../../../constants/constants';
 
@@ -39,44 +26,47 @@
 
   suite('dom tests', () => {
     test('default', () => {
-      expect(element).shadowDom.to.equal(/* HTML */ `
-        <div class="gr-form-styles" id="mainContainer">
-          <div id="options">
-            <gr-select id="action">
-              <select disabled="">
-                <option value="ALLOW">ALLOW</option>
-                <option value="DENY">DENY</option>
-                <option value="BLOCK">BLOCK</option>
-              </select>
-            </gr-select>
-            <a class="groupPath"> </a>
-            <gr-select id="force">
-              <select disabled=""></select>
-            </gr-select>
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <div class="gr-form-styles" id="mainContainer">
+            <div id="options">
+              <gr-select id="action">
+                <select disabled="">
+                  <option value="ALLOW">ALLOW</option>
+                  <option value="DENY">DENY</option>
+                  <option value="BLOCK">BLOCK</option>
+                </select>
+              </gr-select>
+              <a class="groupPath"> </a>
+              <gr-select id="force">
+                <select disabled=""></select>
+              </gr-select>
+            </div>
+            <gr-button
+              aria-disabled="false"
+              id="removeBtn"
+              link=""
+              role="button"
+              tabindex="0"
+            >
+              Remove
+            </gr-button>
           </div>
-          <gr-button
-            aria-disabled="false"
-            id="removeBtn"
-            link=""
-            role="button"
-            tabindex="0"
-          >
-            Remove
-          </gr-button>
-        </div>
-        <div class="gr-form-styles" id="deletedContainer">
-          was deleted
-          <gr-button
-            aria-disabled="false"
-            id="undoRemoveBtn"
-            link=""
-            role="button"
-            tabindex="0"
-          >
-            Undo
-          </gr-button>
-        </div>
-      `);
+          <div class="gr-form-styles" id="deletedContainer">
+            was deleted
+            <gr-button
+              aria-disabled="false"
+              id="undoRemoveBtn"
+              link=""
+              role="button"
+              tabindex="0"
+            >
+              Undo
+            </gr-button>
+          </div>
+        `
+      );
     });
 
     test('push options', async () => {
@@ -92,26 +82,31 @@
           .permission=${AccessPermissionId.PUSH}
         ></gr-rule-editor>
       `);
-      expect(queryAndAssert(element, '#options')).dom.to.equal(/* HTML */ `
-        <div id="options">
-          <gr-select id="action">
-            <select>
-              <option value="ALLOW">ALLOW</option>
-              <option value="DENY">DENY</option>
-              <option value="BLOCK">BLOCK</option>
-            </select>
-          </gr-select>
-          <a class="groupPath"> </a>
-          <gr-select class="force" id="force">
-            <select>
-              <option value="false">
-                Allow pushing (but not force pushing)
-              </option>
-              <option value="true">Allow pushing with or without force</option>
-            </select>
-          </gr-select>
-        </div>
-      `);
+      assert.dom.equal(
+        queryAndAssert(element, '#options'),
+        /* HTML */ `
+          <div id="options">
+            <gr-select id="action">
+              <select>
+                <option value="ALLOW">ALLOW</option>
+                <option value="DENY">DENY</option>
+                <option value="BLOCK">BLOCK</option>
+              </select>
+            </gr-select>
+            <a class="groupPath"> </a>
+            <gr-select class="force" id="force">
+              <select>
+                <option value="false">
+                  Allow pushing (but not force pushing)
+                </option>
+                <option value="true">
+                  Allow pushing with or without force
+                </option>
+              </select>
+            </gr-select>
+          </div>
+        `
+      );
     });
   });
 
@@ -367,7 +362,7 @@
           '#deletedContainer'
         ).classList.contains('deleted')
       );
-      MockInteractions.tap(queryAndAssert<GrButton>(element, '#removeBtn'));
+      queryAndAssert<GrButton>(element, '#removeBtn').click();
       await element.updateComplete;
       assert.isTrue(
         queryAndAssert<HTMLDivElement>(
@@ -378,7 +373,7 @@
       assert.isTrue(element.deleted);
       assert.isTrue(element.rule.value!.deleted);
 
-      MockInteractions.tap(queryAndAssert<GrButton>(element, '#undoRemoveBtn'));
+      queryAndAssert<GrButton>(element, '#undoRemoveBtn').click();
       await element.updateComplete;
       assert.isFalse(element.deleted);
       assert.isNotOk(element.rule.value!.deleted);
@@ -401,7 +396,7 @@
 
       element.rule = {value: {action: PermissionAction.ALLOW}};
       await element.updateComplete;
-      MockInteractions.tap(queryAndAssert<GrButton>(element, '#removeBtn'));
+      queryAndAssert<GrButton>(element, '#removeBtn').click();
       await element.updateComplete;
       assert.notEqual(
         getComputedStyle(queryAndAssert<GrButton>(element, '#removeBtn'))
@@ -466,16 +461,16 @@
         added: true,
       };
       assert.deepEqual(element.rule!.value, expectedRuleValue);
-      test('values are set correctly', () => {
-        assert.equal(
-          queryAndAssert<GrSelect>(element, '#action').bindValue,
-          expectedRuleValue.action
-        );
-        assert.equal(
-          queryAndAssert<GrSelect>(element, '#force').bindValue,
-          expectedRuleValue.action
-        );
-      });
+
+      // values are set correctly
+      assert.equal(
+        queryAndAssert<GrSelect>(element, '#action').bindValue,
+        expectedRuleValue.action
+      );
+      assert.equal(
+        queryAndAssert<GrSelect>(element, '#force').bindValue,
+        expectedRuleValue.force
+      );
     });
 
     test('modify value', async () => {
@@ -493,7 +488,7 @@
       element.editing = true;
       const removeStub = sinon.stub();
       element.addEventListener('added-rule-removed', removeStub);
-      MockInteractions.tap(queryAndAssert<GrButton>(element, '#removeBtn'));
+      queryAndAssert<GrButton>(element, '#removeBtn').click();
       await element.updateComplete;
       assert.isTrue(removeStub.called);
     });
@@ -601,20 +596,20 @@
         added: true,
       };
       assert.deepEqual(element.rule!.value, expectedRuleValue);
-      test('values are set correctly', () => {
-        assert.equal(
-          queryAndAssert<GrSelect>(element, '#action').bindValue,
-          expectedRuleValue.action
-        );
-        assert.equal(
-          queryAndAssert<GrSelect>(element, '#labelMin').bindValue,
-          expectedRuleValue.min
-        );
-        assert.equal(
-          queryAndAssert<GrSelect>(element, '#labelMax').bindValue,
-          expectedRuleValue.max
-        );
-      });
+
+      // values are set correctly
+      assert.equal(
+        queryAndAssert<GrSelect>(element, '#action').bindValue,
+        expectedRuleValue.action
+      );
+      assert.equal(
+        queryAndAssert<GrSelect>(element, '#labelMin').bindValue,
+        expectedRuleValue.min
+      );
+      assert.equal(
+        queryAndAssert<GrSelect>(element, '#labelMax').bindValue,
+        expectedRuleValue.max
+      );
     });
 
     test('modify value', async () => {
@@ -700,16 +695,15 @@
         added: true,
       };
       assert.deepEqual(element.rule!.value, expectedRuleValue);
-      test('values are set correctly', () => {
-        assert.equal(
-          queryAndAssert<GrSelect>(element, '#action').bindValue,
-          expectedRuleValue.action
-        );
-        assert.equal(
-          queryAndAssert<GrSelect>(element, '#force').bindValue,
-          expectedRuleValue.action
-        );
-      });
+      // values are set correctly
+      assert.equal(
+        queryAndAssert<GrSelect>(element, '#action').bindValue,
+        expectedRuleValue.action
+      );
+      assert.equal(
+        queryAndAssert<GrSelect>(element, '#force').bindValue,
+        expectedRuleValue.force
+      );
     });
 
     test('modify value', async () => {
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-action-bar/gr-change-list-action-bar.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-action-bar/gr-change-list-action-bar.ts
index fdd7502..318a33b 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-action-bar/gr-change-list-action-bar.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-action-bar/gr-change-list-action-bar.ts
@@ -3,17 +3,17 @@
  * Copyright 2022 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-
 import {css, html, LitElement, nothing} from 'lit';
-import {customElement, state} from 'lit/decorators';
+import {customElement, state} from 'lit/decorators.js';
 import {bulkActionsModelToken} from '../../../models/bulk-actions/bulk-actions-model';
 import {resolve} from '../../../models/dependency';
 import {pluralize} from '../../../utils/string-util';
 import {subscribe} from '../../lit/subscription-controller';
 import '../../shared/gr-button/gr-button';
-import '../gr-change-list-bulk-abandon-flow/gr-change-list-bulk-abandon-flow';
 import '../gr-change-list-reviewer-flow/gr-change-list-reviewer-flow';
 import '../gr-change-list-bulk-vote-flow/gr-change-list-bulk-vote-flow';
+import '../gr-change-list-topic-flow/gr-change-list-topic-flow';
+import '../gr-change-list-hashtag-flow/gr-change-list-hashtag-flow';
 
 /**
  * An action bar for the top of a <gr-change-list-section> element. Assumes it
@@ -26,24 +26,16 @@
       :host {
         display: contents;
       }
+      td {
+        padding: 0;
+      }
       .container {
         display: flex;
         justify-content: space-between;
         align-items: center;
       }
-      /*
-       * checkbox styles match checkboxes in <gr-change-list-item> rows to
-       * vertically align with them.
-       */
-      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: 0px;
-        padding: var(--spacing-s);
-        vertical-align: middle;
+      .actionButtons {
+        margin-right: var(--spacing-l);
       }
     `;
   }
@@ -51,23 +43,15 @@
   @state()
   private numSelected = 0;
 
-  @state()
-  private totalChangeCount = 0;
-
   private readonly getBulkActionsModel = resolve(this, bulkActionsModelToken);
 
-  override connectedCallback(): void {
-    super.connectedCallback();
+  constructor() {
+    super();
     subscribe(
       this,
-      this.getBulkActionsModel().selectedChangeNums$,
+      () => this.getBulkActionsModel().selectedChangeNums$,
       selectedChangeNums => (this.numSelected = selectedChangeNums.length)
     );
-    subscribe(
-      this,
-      this.getBulkActionsModel().totalChangeCount$,
-      totalChangeCount => (this.totalChangeCount = totalChangeCount)
-    );
   }
 
   override render() {
@@ -75,27 +59,7 @@
       this.numSelected,
       'change'
     )} selected`;
-    const checked =
-      this.numSelected > 0 && this.numSelected === this.totalChangeCount;
-    const indeterminate =
-      this.numSelected > 0 && this.numSelected !== this.totalChangeCount;
     return html`
-      <!-- Empty cell added for spacing just like gr-change-list-item rows -->
-      <td></td>
-      <td>
-        <!--
-          The .checked property must be used rather than the attribute because
-          the attribute only controls the default checked state and does not
-          update the current checked state.
-          See: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/checkbox#attr-checked
-        -->
-        <input
-          type="checkbox"
-          .checked=${checked}
-          .indeterminate=${indeterminate}
-          @click=${() => this.getBulkActionsModel().clearSelectedChangeNums()}
-        />
-      </td>
       <!--
         500 chosen to be more than the actual number of columns but less than
         1000 where the browser apparently decides it is an error and reverts
@@ -110,9 +74,9 @@
           </div>
           <div class="actionButtons">
             <gr-change-list-bulk-vote-flow></gr-change-list-bulk-vote-flow>
+            <gr-change-list-topic-flow></gr-change-list-topic-flow>
+            <gr-change-list-hashtag-flow></gr-change-list-hashtag-flow>
             <gr-change-list-reviewer-flow></gr-change-list-reviewer-flow>
-            <gr-change-list-bulk-abandon-flow>
-            </gr-change-list-bulk-abandon-flow>
           </div>
         </div>
       </td>
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-action-bar/gr-change-list-action-bar_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-action-bar/gr-change-list-action-bar_test.ts
index 5804b99..8badced 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-action-bar/gr-change-list-action-bar_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-action-bar/gr-change-list-action-bar_test.ts
@@ -3,14 +3,14 @@
  * Copyright 2022 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-import {fixture, html} from '@open-wc/testing-helpers';
+import {fixture, html, assert} from '@open-wc/testing';
 import {
   BulkActionsModel,
   bulkActionsModelToken,
 } from '../../../models/bulk-actions/bulk-actions-model';
 import {wrapInProvider} from '../../../models/di-provider-element';
 import {getAppContext} from '../../../services/app-context';
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import {createChange} from '../../../test/test-data-generators';
 import {
   query,
@@ -55,23 +55,24 @@
   test('renders action bar', async () => {
     await selectChange(change1);
 
-    expect(element).shadowDom.to.equal(/* HTML */ `
-      <td></td>
-      <td><input type="checkbox" /></td>
-      <td>
-        <div class="container">
-          <div class="selectionInfo">
-            <span>1 change selected</span>
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <td>
+          <div class="container">
+            <div class="selectionInfo">
+              <span>1 change selected</span>
+            </div>
+            <div class="actionButtons">
+              <gr-change-list-bulk-vote-flow></gr-change-list-bulk-vote-flow>
+              <gr-change-list-topic-flow></gr-change-list-topic-flow>
+              <gr-change-list-hashtag-flow></gr-change-list-hashtag-flow>
+              <gr-change-list-reviewer-flow></gr-change-list-reviewer-flow>
+            </div>
           </div>
-          <div class="actionButtons">
-            <gr-change-list-bulk-vote-flow></gr-change-list-bulk-vote-flow>
-            <gr-change-list-reviewer-flow></gr-change-list-reviewer-flow>
-            <gr-change-list-bulk-abandon-flow>
-            </gr-change-list-bulk-abandon-flow>
-          </div>
-        </div>
-      </td>
-    `);
+        </td>
+      `
+    );
   });
 
   test('label reflects number of selected changes', async () => {
@@ -99,42 +100,4 @@
     );
     assert.equal(numSelectedLabel.innerText, '2 changes selected');
   });
-
-  test('checkbox matches partial and fully selected state', async () => {
-    // zero case
-    let checkbox = queryAndAssert<HTMLInputElement>(element, 'input');
-    assert.isFalse(checkbox.checked);
-    assert.isFalse(checkbox.indeterminate);
-
-    // partial case
-    await selectChange(change1);
-    checkbox = queryAndAssert<HTMLInputElement>(element, 'input');
-    assert.isTrue(checkbox.indeterminate);
-
-    // plural case
-    await selectChange(change2);
-
-    checkbox = queryAndAssert<HTMLInputElement>(element, 'input');
-    assert.isFalse(checkbox.indeterminate);
-    assert.isTrue(checkbox.checked);
-  });
-
-  test('clicking checkbox clears selection', async () => {
-    await selectChange(change1);
-    await selectChange(change2);
-    let selectedChangeNums = await waitUntilObserved(
-      model.selectedChangeNums$,
-      s => s.length === 2
-    );
-    assert.sameMembers(selectedChangeNums, [change1._number, change2._number]);
-
-    const checkbox = queryAndAssert<HTMLInputElement>(element, 'input');
-    checkbox.click();
-
-    selectedChangeNums = await waitUntilObserved(
-      model.selectedChangeNums$,
-      s => s.length === 0
-    );
-    assert.isEmpty(selectedChangeNums);
-  });
 });
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-abandon-flow/gr-change-list-bulk-abandon-flow.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-abandon-flow/gr-change-list-bulk-abandon-flow.ts
index 4fd65df..1582b0a 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-abandon-flow/gr-change-list-bulk-abandon-flow.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-abandon-flow/gr-change-list-bulk-abandon-flow.ts
@@ -3,8 +3,7 @@
  * Copyright 2022 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-
-import {customElement, state, query} from 'lit/decorators';
+import {customElement, state, query} from 'lit/decorators.js';
 import {LitElement, html, css} from 'lit';
 import {resolve} from '../../../models/dependency';
 import {bulkActionsModelToken} from '../../../models/bulk-actions/bulk-actions-model';
@@ -35,11 +34,11 @@
     ];
   }
 
-  override connectedCallback() {
-    super.connectedCallback();
+  constructor() {
+    super();
     subscribe(
       this,
-      this.getBulkActionsModel().selectedChanges$,
+      () => this.getBulkActionsModel().selectedChanges$,
       selectedChanges => (this.selectedChanges = selectedChanges)
     );
   }
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-abandon-flow/gr-change-list-bulk-abandon-flow_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-abandon-flow/gr-change-list-bulk-abandon-flow_test.ts
index 9c7523e..4fc2cd8 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-abandon-flow/gr-change-list-bulk-abandon-flow_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-abandon-flow/gr-change-list-bulk-abandon-flow_test.ts
@@ -3,7 +3,6 @@
  * Copyright 2022 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-
 import {createChange} from '../../../test/test-data-generators';
 import {
   NumericChangeId,
@@ -13,14 +12,14 @@
   PatchSetNum,
 } from '../../../api/rest-api';
 import {GrChangeListBulkAbandonFlow} from './gr-change-list-bulk-abandon-flow';
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import {
   BulkActionsModel,
   bulkActionsModelToken,
   LoadingState,
 } from '../../../models/bulk-actions/bulk-actions-model';
 import './gr-change-list-bulk-abandon-flow';
-import {fixture, waitUntil} from '@open-wc/testing-helpers';
+import {fixture, waitUntil, assert} from '@open-wc/testing';
 import {wrapInProvider} from '../../../models/di-provider-element';
 import {html} from 'lit';
 import {getAppContext} from '../../../services/app-context';
@@ -32,7 +31,6 @@
   query,
 } from '../../../test/test-utils';
 import {GrButton} from '../../shared/gr-button/gr-button';
-import {tap} from '@polymer/iron-test-helpers/mock-interactions';
 import {ProgressStatus} from '../../../constants/constants';
 import {RequestPayload} from '../../../types/common';
 import {ErrorCallback} from '../../../api/rest';
@@ -69,6 +67,60 @@
     await element.updateComplete;
   });
 
+  test('render', async () => {
+    const changes: ChangeInfo[] = [{...change1, actions: {abandon: {}}}];
+    getChangesStub.returns(changes);
+    model.sync(changes);
+    await waitUntilObserved(
+      model.loadingState$,
+      state => state === LoadingState.LOADED
+    );
+    await selectChange(change1);
+    await element.updateComplete;
+
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <gr-button
+          aria-disabled="false"
+          flatten=""
+          id="abandon"
+          role="button"
+          tabindex="0"
+        >
+          Abandon
+        </gr-button>
+        <gr-overlay
+          aria-hidden="true"
+          id="actionOverlay"
+          style="outline: none; display: none;"
+          tabindex="-1"
+          with-backdrop=""
+        >
+          <gr-dialog role="dialog">
+            <div slot="header">1 changes to abandon</div>
+            <div slot="main">
+              <table>
+                <thead>
+                  <tr>
+                    <th>Subject</th>
+                    <th>Status</th>
+                  </tr>
+                </thead>
+                <tbody>
+                  <tr>
+                    <td>Change: Test subject</td>
+                    <td id="status">Status: NOT STARTED</td>
+                  </tr>
+                </tbody>
+              </table>
+            </div>
+          </gr-dialog>
+        </gr-overlay>
+      `
+    );
+  });
+
   test('button state updates as changes are updated', async () => {
     const changes: ChangeInfo[] = [{...change1, actions: {abandon: {}}}];
     getChangesStub.returns(changes);
@@ -79,8 +131,7 @@
     );
     await selectChange(change1);
     await element.updateComplete;
-    await flush();
-    // await waitUntil(() => element.selectedChanges.length > 0);
+
     assert.isFalse(queryAndAssert<GrButton>(element, '#abandon').disabled);
 
     changes.push({...change2, actions: {}});
@@ -109,10 +160,10 @@
     );
     await selectChange(change1);
     await element.updateComplete;
-    await flush();
+
     assert.isFalse(queryAndAssert<GrButton>(element, '#abandon').disabled);
 
-    tap(queryAndAssert(query(element, 'gr-dialog'), '#confirm'));
+    queryAndAssert<GrButton>(query(element, 'gr-dialog'), '#confirm').click();
 
     await waitUntil(
       () =>
@@ -152,7 +203,7 @@
       queryAndAssert<GrButton>(query(element, 'gr-dialog'), '#cancel').disabled
     );
 
-    tap(queryAndAssert(query(element, 'gr-dialog'), '#confirm'));
+    queryAndAssert<GrButton>(query(element, 'gr-dialog'), '#confirm').click();
     await element.updateComplete;
 
     assert.isTrue(
@@ -226,7 +277,7 @@
         })
     );
 
-    tap(queryAndAssert(query(element, 'gr-dialog'), '#confirm'));
+    queryAndAssert<GrButton>(query(element, 'gr-dialog'), '#confirm').click();
     await element.updateComplete;
 
     assert.equal(
@@ -283,7 +334,7 @@
     await selectChange(change2);
     await element.updateComplete;
 
-    tap(queryAndAssert(query(element, 'gr-dialog'), '#confirm'));
+    queryAndAssert<GrButton>(query(element, 'gr-dialog'), '#confirm').click();
 
     await waitUntil(
       () => element.progress.get(2 as NumericChangeId) === ProgressStatus.FAILED
@@ -291,7 +342,7 @@
 
     assert.isFalse(fireStub.called);
 
-    tap(queryAndAssert(query(element, 'gr-dialog'), '#cancel'));
+    queryAndAssert<GrButton>(query(element, 'gr-dialog'), '#cancel').click();
 
     await waitUntil(() => fireStub.called);
     assert.equal(fireStub.lastCall.args[0].type, 'reload');
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-vote-flow/gr-change-list-bulk-vote-flow.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-vote-flow/gr-change-list-bulk-vote-flow.ts
index 6790b15..5728529 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-vote-flow/gr-change-list-bulk-vote-flow.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-vote-flow/gr-change-list-bulk-vote-flow.ts
@@ -3,8 +3,7 @@
  * Copyright 2022 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-
-import {customElement, query, state} from 'lit/decorators';
+import {customElement, query, state} from 'lit/decorators.js';
 import {LitElement, html, css, nothing} from 'lit';
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
 import {resolve} from '../../../models/dependency';
@@ -16,7 +15,6 @@
   computeLabels,
   computeOrderedLabelValues,
   mergeLabelInfoMaps,
-  getDefaultValue,
   mergeLabelMaps,
   Label,
   StandardLabels,
@@ -33,8 +31,14 @@
 import {ProgressStatus} from '../../../constants/constants';
 import {fireAlert, fireReload} from '../../../utils/event-util';
 import '../../shared/gr-dialog/gr-dialog';
+import '../../shared/gr-icon/gr-icon';
 import '../../change/gr-label-score-row/gr-label-score-row';
 import {getOverallStatus} from '../../../utils/bulk-flow-util';
+import {allSettled} from '../../../utils/async-util';
+import {pluralize} from '../../../utils/string-util';
+import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
+import {Interaction} from '../../../constants/reporting';
+import {createChangeUrl} from '../../../models/views/change';
 
 @customElement('gr-change-list-bulk-vote-flow')
 export class GrChangeListBulkVoteFlow extends LitElement {
@@ -42,20 +46,28 @@
 
   private readonly userModel = getAppContext().userModel;
 
+  private readonly reportingService = getAppContext().reportingService;
+
   @state() selectedChanges: ChangeInfo[] = [];
 
   @state() progressByChange: Map<NumericChangeId, ProgressStatus> = new Map();
 
   @query('#actionOverlay') actionOverlay!: GrOverlay;
 
+  @query('gr-dialog') dialog?: GrDialog;
+
   @state() account?: AccountInfo;
 
   static override get styles() {
     return [
       fontStyles,
       css`
+        gr-dialog {
+          width: 840px;
+        }
         .scoresTable {
           display: table;
+          width: 100%;
         }
         .scoresTable.newSubmitRequirements {
           table-layout: fixed;
@@ -66,24 +78,62 @@
         gr-label-score-row {
           display: table-row;
         }
-        .heading-3 {
-          padding-left: var(--spacing-xl);
-          margin-bottom: var(--spacing-m);
-          margin-top: var(--spacing-l);
+        /* TODO(dhruvsri): Consider using flex column with gap */
+        .scoresTable:not(:first-of-type) {
+          margin-top: var(--spacing-m);
+        }
+        .vote-type {
+          margin-bottom: var(--spacing-s);
+          margin-top: 0;
           display: table-caption;
         }
-        .heading-3:first-of-type {
-          margin-top: 0;
+        .main-heading {
+          margin-bottom: var(--spacing-m);
+          font-weight: var(--font-weight-h2);
+        }
+        .error-container {
+          background-color: var(--error-background);
+          margin-top: var(--spacing-l);
+        }
+        .code-review-message-container gr-icon,
+        .error-container gr-icon {
+          padding: 10px var(--spacing-xl);
+        }
+        .error-container gr-icon {
+          color: var(--error-foreground);
+        }
+        .code-review-message-container gr-icon {
+          color: var(--selected-foreground);
+        }
+        .error-container .error-text,
+        .code-review-message-container .warning-text {
+          position: relative;
+          top: 10px;
+        }
+        .code-review-message-container {
+          display: table-caption;
+          background-color: var(--code-review-warning-background);
+          margin-bottom: var(--spacing-m);
+        }
+        .code-review-message-layout-container {
+          display: flex;
+        }
+        .code-review-message-container gr-button {
+          margin-top: 6px;
+          margin-right: var(--spacing-xl);
+        }
+        .flex-space {
+          flex-grow: 1;
         }
       `,
     ];
   }
 
-  override connectedCallback() {
-    super.connectedCallback();
+  constructor() {
+    super();
     subscribe(
       this,
-      this.getBulkActionsModel().selectedChanges$,
+      () => this.getBulkActionsModel().selectedChanges$,
       selectedChanges => {
         this.selectedChanges = selectedChanges;
         this.resetFlow();
@@ -91,7 +141,7 @@
     );
     subscribe(
       this,
-      this.userModel.account$,
+      () => this.userModel.account$,
       account => (this.account = account)
     );
   }
@@ -103,46 +153,113 @@
       permittedLabels
     ).filter(label => !triggerLabels.some(l => l.name === label.name));
     return html`
-      <gr-button
-        .disabled=${triggerLabels.length === 0 && nonTriggerLabels.length === 0}
-        id="voteFlowButton"
-        flatten
-        @click=${() => this.actionOverlay.open()}
+      <gr-button id="voteFlowButton" flatten @click=${this.openOverlay}
         >Vote</gr-button
       >
       <gr-overlay id="actionOverlay" with-backdrop="">
         <gr-dialog
           .disableCancel=${!this.isCancelEnabled()}
           .disabled=${!this.isConfirmEnabled()}
+          ?loading=${this.isLoading()}
+          .loadingLabel=${'Voting in progress...'}
           @confirm=${() => this.handleConfirm()}
           @cancel=${() => this.handleClose()}
-          .cancelLabel=${'Close'}
+          .confirmLabel=${'Vote'}
+          .cancelLabel=${'Cancel'}
         >
+          <div slot="header">
+            <span class="main-heading"> Vote on selected changes </span>
+          </div>
           <div slot="main">
             ${this.renderLabels(
               nonTriggerLabels,
               'Submit requirements votes',
-              permittedLabels
+              permittedLabels,
+              true
             )}
             ${this.renderLabels(
               triggerLabels,
               'Trigger Votes',
               permittedLabels
             )}
+            ${this.renderErrors()}
           </div>
-          <!-- TODO: Add error handling status if something fails -->
         </gr-dialog>
       </gr-overlay>
     `;
   }
 
+  private renderCodeReviewMessage() {
+    return html`
+      <div class="code-review-message-container">
+        <div class="code-review-message-layout-container">
+          <div>
+            <gr-icon icon="info" aria-label="Information" role="img"></gr-icon>
+            <span class="warning-text">
+              Code Review vote is only available on the individual change page
+            </span>
+          </div>
+          <div class="flex-space"></div>
+          <div>
+            <gr-button
+              aria-label=${`Open ${pluralize(
+                this.selectedChanges.length,
+                'change'
+              )} in different tabs`}
+              flatten
+              link
+              @click=${this.handleOpenChanges}
+              >Open ${pluralize(this.selectedChanges.length, 'change')}
+            </gr-button>
+          </div>
+        </div>
+      </div>
+    `;
+  }
+
+  private handleOpenChanges() {
+    for (const change of this.selectedChanges) {
+      window.open(createChangeUrl({change, usp: 'bulk-vote'}));
+    }
+  }
+
+  private async openOverlay() {
+    await this.actionOverlay.open();
+    this.actionOverlay.setFocusStops({
+      start: queryAndAssert(this.dialog, 'header'),
+      end: queryAndAssert(this.dialog, 'footer'),
+    });
+  }
+
+  private renderErrors() {
+    if (getOverallStatus(this.progressByChange) !== ProgressStatus.FAILED) {
+      return nothing;
+    }
+    return html`
+      <div class="error-container">
+        <gr-icon icon="error" filled role="img" aria-label="Error"></gr-icon>
+        <span class="error-text">
+          <!-- prettier-ignore -->
+          Failed to vote on ${pluralize(
+            Array.from(this.progressByChange.values()).filter(
+              status => status === ProgressStatus.FAILED
+            ).length,
+            'change'
+          )}
+        </span>
+      </div>
+    `;
+  }
+
   private renderLabels(
     labels: Label[],
     heading: string,
-    permittedLabels?: LabelNameToValuesMap
+    permittedLabels?: LabelNameToValuesMap,
+    showCodeReviewWarning?: boolean
   ) {
     return html` <div class="scoresTable newSubmitRequirements">
-      <h3 class="heading-3">${labels.length ? heading : nothing}</h3>
+      <h3 class="heading-4 vote-type">${labels.length ? heading : nothing}</h3>
+      ${showCodeReviewWarning ? this.renderCodeReviewMessage() : nothing}
       ${labels
         .filter(
           label =>
@@ -170,6 +287,10 @@
     );
   }
 
+  private isLoading() {
+    return getOverallStatus(this.progressByChange) === ProgressStatus.RUNNING;
+  }
+
   private isConfirmEnabled() {
     // Action is allowed if none of the changes have any bulk action performed
     // on them. In case an error happens then we keep the button disabled.
@@ -186,12 +307,15 @@
     this.actionOverlay.close();
     if (getOverallStatus(this.progressByChange) === ProgressStatus.NOT_STARTED)
       return;
-    fireAlert(this, 'Reloading page..');
     fireReload(this, true);
   }
 
-  private handleConfirm() {
+  private async handleConfirm() {
     this.progressByChange.clear();
+    this.reportingService.reportInteraction(Interaction.BULK_ACTION, {
+      type: 'vote',
+      selectedChangeCount: this.selectedChanges.length,
+    });
     const reviewInput: ReviewInput = {
       labels: this.getLabelValues(
         this.computeCommonPermittedLabels(this.computePermittedLabels())
@@ -202,18 +326,36 @@
     }
     this.requestUpdate();
     const promises = this.getBulkActionsModel().voteChanges(reviewInput);
-    for (let index = 0; index < promises.length; index++) {
-      const changeNum = this.selectedChanges[index]._number;
-      promises[index]
-        .then(() => {
-          this.progressByChange.set(changeNum, ProgressStatus.SUCCESSFUL);
-        })
-        .catch(() => {
-          this.progressByChange.set(changeNum, ProgressStatus.FAILED);
-        })
-        .finally(() => {
-          this.requestUpdate();
-        });
+
+    await allSettled(
+      promises.map((promise, index) => {
+        const changeNum = this.selectedChanges[index]._number;
+        return promise
+          .then(() => {
+            this.progressByChange.set(changeNum, ProgressStatus.SUCCESSFUL);
+          })
+          .catch(() => {
+            this.progressByChange.set(changeNum, ProgressStatus.FAILED);
+          })
+          .finally(() => {
+            this.requestUpdate();
+            if (
+              getOverallStatus(this.progressByChange) ===
+              ProgressStatus.SUCCESSFUL
+            ) {
+              fireAlert(this, 'Votes added');
+              this.handleClose();
+            }
+          });
+      })
+    );
+    if (getOverallStatus(this.progressByChange) === ProgressStatus.FAILED) {
+      this.reportingService.reportInteraction('bulk-action-failure', {
+        type: 'vote',
+        count: Array.from(this.progressByChange.values()).filter(
+          status => status === ProgressStatus.FAILED
+        ).length,
+      });
     }
   }
 
@@ -234,14 +376,7 @@
           : selectorEl.selectedValue;
 
       if (selectedVal === undefined) continue;
-
-      const defValNum = getDefaultValue(
-        this.selectedChanges[0].labels,
-        label.name
-      );
-      if (selectedVal !== defValNum) {
-        labels[label.name] = selectedVal;
-      }
+      labels[label.name] = selectedVal;
     }
     return labels;
   }
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 5a965d9..0ca3976 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
@@ -3,8 +3,7 @@
  * Copyright 2022 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import {GrChangeListBulkVoteFlow} from './gr-change-list-bulk-vote-flow';
 import {
   BulkActionsModel,
@@ -18,22 +17,26 @@
   query,
   mockPromise,
   queryAll,
+  stubReporting,
+  waitEventLoop,
 } from '../../../test/test-utils';
 import {ChangeInfo, NumericChangeId, LabelInfo} from '../../../api/rest-api';
 import {getAppContext} from '../../../services/app-context';
-import {fixture, waitUntil} from '@open-wc/testing-helpers';
+import {fixture, waitUntil, assert} from '@open-wc/testing';
 import {wrapInProvider} from '../../../models/di-provider-element';
 import {html} from 'lit';
 import {SinonStubbedMember} from 'sinon';
 import {RestApiService} from '../../../services/gr-rest-api/gr-rest-api';
 import {
   createChange,
+  createDetailedLabelInfo,
   createSubmitRequirementResultInfo,
 } from '../../../test/test-data-generators';
 import './gr-change-list-bulk-vote-flow';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {ProgressStatus} from '../../../constants/constants';
 import {StandardLabels} from '../../../utils/label-util';
+import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
 
 const change1: ChangeInfo = {
   ...createChange(),
@@ -92,9 +95,11 @@
 suite('gr-change-list-bulk-vote-flow tests', () => {
   let element: GrChangeListBulkVoteFlow;
   let model: BulkActionsModel;
+  let dispatchEventStub: sinon.SinonStub;
   let getChangesStub: SinonStubbedMember<
     RestApiService['getDetailedChangesWithActions']
   >;
+  let reportingStub: SinonStubbedMember<ReportingService['reportInteraction']>;
 
   async function selectChange(change: ChangeInfo) {
     model.addSelectedChangeNum(change._number);
@@ -107,7 +112,7 @@
   setup(async () => {
     model = new BulkActionsModel(getAppContext().restApiService);
     getChangesStub = stubRestApi('getDetailedChangesWithActions');
-
+    reportingStub = stubReporting('reportInteraction');
     element = (
       await fixture(
         wrapInProvider(
@@ -118,6 +123,7 @@
       )
     ).querySelector('gr-change-list-bulk-vote-flow')!;
     await element.updateComplete;
+    dispatchEventStub = sinon.stub(element, 'dispatchEvent');
   });
 
   test('renders', async () => {
@@ -130,7 +136,9 @@
     );
     await selectChange(change1);
     await element.updateComplete;
-    expect(element).shadowDom.to.equal(/* HTML */ `<gr-button
+    assert.shadowDom.equal(
+      element,
+      `<gr-button
         aria-disabled="false"
         flatten=""
         id="voteFlowButton"
@@ -147,9 +155,35 @@
         with-backdrop=""
       >
         <gr-dialog role="dialog">
+          <div slot="header">
+            <span class="main-heading"> Vote on selected changes </span>
+          </div>
           <div slot="main">
             <div class="newSubmitRequirements scoresTable">
-              <h3 class="heading-3">Submit requirements votes</h3>
+              <h3 class="heading-4 vote-type">Submit requirements votes</h3>
+              <div class="code-review-message-container">
+                <div class="code-review-message-layout-container">
+                <div>
+                  <gr-icon icon="info" aria-label="Information" role="img"></gr-icon>
+                  <span class="warning-text">
+                    Code Review vote is only available on the individual change page
+                  </span>
+                </div>
+                <div class="flex-space"></div>
+                <div>
+                  <gr-button
+                    aria-disabled="false"
+                    flatten=""
+                    link=""
+                    role="button"
+                    aria-label="Open 1 change in different tabs"
+                    tabindex="0"
+                  >
+                    Open 1 change
+                  </gr-button>
+                </div>
+                </div>
+              </div>
               <gr-label-score-row name="A"> </gr-label-score-row>
               <gr-label-score-row name="B"> </gr-label-score-row>
               <gr-label-score-row name="C"> </gr-label-score-row>
@@ -157,13 +191,109 @@
               </gr-label-score-row>
             </div>
             <div class="newSubmitRequirements scoresTable">
-              <h3 class="heading-3">Trigger Votes</h3>
+              <h3 class="heading-4 vote-type">Trigger Votes</h3>
               <gr-label-score-row name="change1OnlyTriggerLabelE">
               </gr-label-score-row>
             </div>
           </div>
         </gr-dialog>
-      </gr-overlay> `);
+      </gr-overlay> `
+    );
+  });
+
+  test('renders with errors', async () => {
+    const changes: ChangeInfo[] = [change1];
+    getChangesStub.returns(Promise.resolve(changes));
+    model.sync(changes);
+    await waitUntilObserved(
+      model.loadingState$,
+      state => state === LoadingState.LOADED
+    );
+    stubRestApi('saveChangeReview').callsFake(
+      (_changeNum, _patchNum, _review, errFn) =>
+        Promise.resolve(new Response()).then(res => {
+          errFn && errFn();
+          return res;
+        })
+    );
+    await selectChange(change1);
+    await element.updateComplete;
+
+    queryAndAssert<GrButton>(query(element, 'gr-dialog'), '#confirm').click();
+
+    await waitUntil(
+      () =>
+        element.progressByChange.get(1 as NumericChangeId) ===
+        ProgressStatus.FAILED
+    );
+
+    assert.shadowDom.equal(
+      element,
+      `<gr-button
+        aria-disabled="false"
+        flatten=""
+        id="voteFlowButton"
+        role="button"
+        tabindex="0"
+      >
+        Vote
+      </gr-button>
+      <gr-overlay
+        aria-hidden="true"
+        id="actionOverlay"
+        style="outline: none; display: none;"
+        tabindex="-1"
+        with-backdrop=""
+      >
+        <gr-dialog role="dialog">
+          <div slot="header">
+            <span class="main-heading"> Vote on selected changes </span>
+          </div>
+          <div slot="main">
+            <div class="newSubmitRequirements scoresTable">
+              <h3 class="heading-4 vote-type">Submit requirements votes</h3>
+              <div class="code-review-message-container">
+                <div class="code-review-message-layout-container">
+                <div>
+                  <gr-icon icon="info" aria-label="Information" role="img"></gr-icon>
+                  <span class="warning-text">
+                    Code Review vote is only available on the individual change page
+                  </span>
+                </div>
+                <div class="flex-space"></div>
+                <div>
+                  <gr-button
+                    aria-disabled="false"
+                    flatten=""
+                    link=""
+                    role="button"
+                    aria-label="Open 1 change in different tabs"
+                    tabindex="0"
+                  >
+                    Open 1 change
+                  </gr-button>
+                </div>
+                </div>
+              </div>
+              <gr-label-score-row name="A"> </gr-label-score-row>
+              <gr-label-score-row name="B"> </gr-label-score-row>
+              <gr-label-score-row name="C"> </gr-label-score-row>
+              <gr-label-score-row name="change1OnlyLabelD">
+              </gr-label-score-row>
+            </div>
+            <div class="newSubmitRequirements scoresTable">
+              <h3 class="heading-4 vote-type">Trigger Votes</h3>
+              <gr-label-score-row name="change1OnlyTriggerLabelE">
+              </gr-label-score-row>
+            </div>
+            <div class="error-container">
+              <gr-icon icon="error" filled role="img" aria-label="Error"></gr-icon>
+              <span class="error-text"> Failed to vote on 1 change </span>
+            </div>
+          </div>
+        </gr-dialog>
+      </gr-overlay> `
+    );
   });
 
   test('button state updates as changes are updated', async () => {
@@ -176,9 +306,9 @@
     );
     await selectChange(change1);
     await element.updateComplete;
-    await flush();
+    await waitEventLoop();
 
-    assert.isFalse(
+    assert.isNotOk(
       queryAndAssert<GrButton>(element, '#voteFlowButton').disabled
     );
 
@@ -204,24 +334,46 @@
     await selectChange(change2);
     await element.updateComplete;
 
-    assert.isTrue(
+    assert.isNotOk(
       queryAndAssert<GrButton>(element, '#voteFlowButton').disabled
     );
   });
 
   test('progress updates as request is resolved', async () => {
-    const changes: ChangeInfo[] = [{...change1}];
+    const change = {
+      ...change1,
+      labels: {
+        ...change1.labels,
+        C: {
+          ...createDetailedLabelInfo(),
+          all: [
+            {
+              ...element.account!,
+              value: -1,
+            },
+          ],
+        },
+      },
+    };
+    const changes: ChangeInfo[] = [{...change}];
     getChangesStub.returns(Promise.resolve(changes));
     model.sync(changes);
     await waitUntilObserved(
       model.loadingState$,
       state => state === LoadingState.LOADED
     );
-    await selectChange(change1);
+    await selectChange(change);
     await element.updateComplete;
     const saveChangeReview = mockPromise<Response>();
     stubRestApi('saveChangeReview').returns(saveChangeReview);
 
+    const stopsStub = sinon.stub(element.actionOverlay, 'setFocusStops');
+
+    queryAndAssert<GrButton>(element, '#voteFlowButton').click();
+    await waitUntil(() => stopsStub.called);
+
+    await element.updateComplete;
+
     assert.isNotOk(
       queryAndAssert<GrButton>(query(element, 'gr-dialog'), '#confirm').disabled
     );
@@ -232,6 +384,7 @@
     const scores = queryAll(element, 'gr-label-score-row');
     queryAndAssert<GrButton>(scores[0], 'gr-button[data-value="+1"]').click();
     queryAndAssert<GrButton>(scores[1], 'gr-button[data-value="-1"]').click();
+    queryAndAssert<GrButton>(scores[2], 'gr-button[data-value="0"]').click();
 
     await element.updateComplete;
 
@@ -242,6 +395,7 @@
       {
         A: 1,
         B: -1,
+        C: 0,
       }
     );
 
@@ -255,6 +409,11 @@
       queryAndAssert<GrButton>(query(element, 'gr-dialog'), '#cancel').disabled
     );
 
+    assert.deepEqual(reportingStub.lastCall.args[1], {
+      type: 'vote',
+      selectedChangeCount: 1,
+    });
+
     assert.equal(
       element.progressByChange.get(1 as NumericChangeId),
       ProgressStatus.RUNNING
@@ -278,6 +437,13 @@
       element.progressByChange.get(1 as NumericChangeId),
       ProgressStatus.SUCCESSFUL
     );
+
+    // reload event is fired automatically when all requests succeed
+    assert.equal(dispatchEventStub.lastCall.args[0].type, 'reload');
+    assert.equal(
+      dispatchEventStub.firstCall.args[0].detail.message,
+      'Votes added'
+    );
   });
 
   suite('closing dialog triggers reloads', () => {
@@ -285,8 +451,6 @@
       const changes: ChangeInfo[] = [change1, change2];
       getChangesStub.returns(Promise.resolve(changes));
 
-      const fireStub = sinon.stub(element, 'dispatchEvent');
-
       stubRestApi('saveChangeReview').callsFake(
         (_changeNum, _patchNum, _review, errFn) =>
           Promise.resolve(new Response()).then(res => {
@@ -312,20 +476,27 @@
           ProgressStatus.FAILED
       );
 
-      assert.isFalse(fireStub.called);
+      // Dialog does not autoclose and fire reload event if some request fails
+      assert.isFalse(dispatchEventStub.called);
+
+      assert.deepEqual(reportingStub.lastCall.args, [
+        'bulk-action-failure',
+        {
+          type: 'vote',
+          count: 2,
+        },
+      ]);
 
       queryAndAssert<GrButton>(query(element, 'gr-dialog'), '#cancel').click();
 
-      await waitUntil(() => fireStub.called);
-      assert.equal(fireStub.lastCall.args[0].type, 'reload');
+      await waitUntil(() => dispatchEventStub.called);
+      assert.equal(dispatchEventStub.lastCall.args[0].type, 'reload');
     });
 
     test('closing dialog does not trigger reload if no request made', async () => {
       const changes: ChangeInfo[] = [change1, change2];
       getChangesStub.returns(Promise.resolve(changes));
 
-      const fireStub = sinon.stub(element, 'dispatchEvent');
-
       model.sync(changes);
       await waitUntilObserved(
         model.loadingState$,
@@ -337,7 +508,7 @@
 
       queryAndAssert<GrButton>(query(element, 'gr-dialog'), '#cancel').click();
 
-      assert.isFalse(fireStub.called);
+      assert.isFalse(dispatchEventStub.called);
     });
   });
 
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 09b5cc9..13807c8 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
@@ -1,24 +1,13 @@
 /**
  * @license
- * Copyright (C) 2022 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
 import '../../change/gr-submit-requirement-dashboard-hovercard/gr-submit-requirement-dashboard-hovercard';
 import '../../shared/gr-change-status/gr-change-status';
+import '../../shared/gr-icon/gr-icon';
 import {LitElement, css, html} from 'lit';
-import {customElement, property} from 'lit/decorators';
+import {customElement, property} from 'lit/decorators.js';
 import {
   ApprovalInfo,
   ChangeInfo,
@@ -39,7 +28,7 @@
   iconForStatus,
 } from '../../../utils/label-util';
 import {sharedStyles} from '../../../styles/shared-styles';
-import {ifDefined} from 'lit/directives/if-defined';
+import {ifDefined} from 'lit/directives/if-defined.js';
 import {capitalizeFirstLetter} from '../../../utils/string-util';
 
 @customElement('gr-change-list-column-requirement')
@@ -55,9 +44,6 @@
       submitRequirementsStyles,
       sharedStyles,
       css`
-        iron-icon {
-          vertical-align: top;
-        }
         .container {
           display: flex;
           align-items: center;
@@ -152,8 +138,14 @@
   }
 
   private renderStatusIcon(status: SubmitRequirementStatus) {
-    const icon = iconForStatus(status ?? SubmitRequirementStatus.ERROR);
-    return html`<iron-icon class=${icon} icon="gr-icons:${icon}"></iron-icon>`;
+    const icon = iconForStatus(status);
+    return html`
+      <gr-icon
+        class=${icon.icon}
+        icon=${icon.icon}
+        ?filled=${icon.filled}
+      ></gr-icon>
+    `;
   }
 
   private computeClass(): string {
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-column-requirement/gr-change-list-column-requirement_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-column-requirement/gr-change-list-column-requirement_test.ts
index 96240de..82e8048 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-column-requirement/gr-change-list-column-requirement_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-column-requirement/gr-change-list-column-requirement_test.ts
@@ -1,22 +1,10 @@
 /**
  * @license
- * Copyright (C) 2022 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
-import {fixture} from '@open-wc/testing-helpers';
+import '../../../test/common-test-setup';
+import {fixture, assert} from '@open-wc/testing';
 import {html} from 'lit';
 import './gr-change-list-column-requirement';
 import {GrChangeListColumnRequirement} from './gr-change-list-column-requirement';
@@ -66,14 +54,11 @@
       >
       </gr-change-list-column-requirement>`
     );
-    expect(element).shadowDom.to.equal(
+    assert.shadowDom.equal(
+      element,
       /* HTML */
       ` <div class="container" title="Satisfied">
-        <iron-icon
-          class="check-circle-filled"
-          icon="gr-icons:check-circle-filled"
-        >
-        </iron-icon>
+        <gr-icon class="check_circle" filled icon="check_circle"></gr-icon>
       </div>`
     );
   });
@@ -113,14 +98,16 @@
       >
       </gr-change-list-column-requirement>`
     );
-    expect(element).shadowDom.to.equal(
+    assert.shadowDom.equal(
+      element,
       /* HTML */
       ` <div class="container">
         <gr-vote-chip tooltip-with-who-voted=""></gr-vote-chip>
       </div>`
     );
     const voteChip = queryAndAssert(element, 'gr-vote-chip');
-    expect(voteChip).shadowDom.to.equal(
+    assert.shadowDom.equal(
+      voteChip,
       /* HTML */
       ` <gr-tooltip-content
         class="container"
@@ -160,14 +147,16 @@
       >
       </gr-change-list-column-requirement>`
     );
-    expect(element).shadowDom.to.equal(
+    assert.shadowDom.equal(
+      element,
       /* HTML */
       ` <div class="container">
         <gr-vote-chip tooltip-with-who-voted=""></gr-vote-chip>
       </div>`
     );
     const voteChip = queryAndAssert(element, 'gr-vote-chip');
-    expect(voteChip).shadowDom.to.equal(
+    assert.shadowDom.equal(
+      voteChip,
       /* HTML */
       ` <gr-tooltip-content
         class="container"
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-column-requirements-summary/gr-change-list-column-requirements-summary.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-column-requirements-summary/gr-change-list-column-requirements-summary.ts
index 1f8bf51..dbde061 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-column-requirements-summary/gr-change-list-column-requirements-summary.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-column-requirements-summary/gr-change-list-column-requirements-summary.ts
@@ -1,27 +1,20 @@
 /**
  * @license
- * Copyright (C) 2015 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2015 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
 import '../../change/gr-submit-requirement-dashboard-hovercard/gr-submit-requirement-dashboard-hovercard';
 import '../../shared/gr-change-status/gr-change-status';
+import '../../shared/gr-icon/gr-icon';
 import {LitElement, css, html, TemplateResult} from 'lit';
-import {customElement, property} from 'lit/decorators';
+import {customElement, property} from 'lit/decorators.js';
 import {ChangeInfo, SubmitRequirementStatus} from '../../../api/rest-api';
 import {changeStatuses} from '../../../utils/change-util';
-import {getRequirements, iconForStatus} from '../../../utils/label-util';
+import {
+  getRequirements,
+  iconForStatus,
+  SubmitRequirementsIcon,
+} from '../../../utils/label-util';
 import {submitRequirementsStyles} from '../../../styles/gr-submit-requirements-styles';
 import {pluralize} from '../../../utils/string-util';
 
@@ -34,39 +27,18 @@
     return [
       submitRequirementsStyles,
       css`
-        iron-icon {
-          width: var(--line-height-normal, 20px);
-          height: var(--line-height-normal, 20px);
-          vertical-align: top;
+        :host {
+          display: inline-block;
         }
-        iron-icon.block,
-        iron-icon.check-circle-filled {
-          margin-right: var(--spacing-xs);
+        gr-change-status {
+          display: inline-block;
         }
-        iron-icon.commentIcon {
-          color: var(--deemphasized-text-color);
-          margin-left: var(--spacing-s);
-        }
-        span {
-          line-height: var(--line-height-normal);
-        }
-        span.check-circle-filled {
-          color: var(--success-foreground);
+        gr-icon.commentIcon {
+          color: var(--warning-foreground);
         }
         .unsatisfied {
           color: var(--primary-text-color);
         }
-        .total {
-          margin-left: var(--spacing-xs);
-          color: var(--deemphasized-text-color);
-        }
-        :host {
-          align-items: center;
-          display: inline-flex;
-        }
-        .comma {
-          padding-right: var(--spacing-xs);
-        }
         /* Used to hide the leading separator comma for statuses. */
         .comma:first-of-type {
           display: none;
@@ -85,10 +57,9 @@
     const statuses = changeStatuses(this.change);
     if (statuses.length > 0) {
       return statuses.map(
-        status => html`
-          <div class="comma">,</div>
-          <gr-change-status flat .status=${status}></gr-change-status>
-        `
+        status =>
+          html`<span class="comma">, </span
+            ><gr-change-status flat .status=${status}></gr-change-status>`
       );
     }
     return this.renderActiveStatus();
@@ -117,36 +88,43 @@
 
     return this.renderState(
       iconForStatus(SubmitRequirementStatus.UNSATISFIED),
-      this.renderSummary(numUnsatisfied, numRequirements)
+      this.renderSummary(numUnsatisfied)
     );
   }
 
-  renderState(icon: string, aggregation: string | TemplateResult) {
-    return html`<span class=${icon} role="button" tabindex="0">
+  renderState(
+    icon: SubmitRequirementsIcon,
+    aggregation: string | TemplateResult
+  ) {
+    return html`<span class=${icon.icon} role="button" tabindex="0">
       <gr-submit-requirement-dashboard-hovercard .change=${this.change}>
       </gr-submit-requirement-dashboard-hovercard>
-      <iron-icon class=${icon} icon="gr-icons:${icon}" role="img"></iron-icon
-      >${aggregation}</span
+      <gr-icon
+        class=${icon.icon}
+        icon=${icon.icon}
+        ?filled=${icon.filled}
+        role="img"
+      ></gr-icon>
+      ${aggregation}</span
     >`;
   }
 
-  renderSummary(numUnsatisfied: number, numRequirements: number) {
-    return html`<span
-      ><span class="unsatisfied">${numUnsatisfied}</span
-      ><span class="total">(of ${numRequirements})</span></span
-    >`;
+  renderSummary(numUnsatisfied: number) {
+    return html`<span class="unsatisfied">${numUnsatisfied} missing</span>`;
   }
 
   renderCommentIcon() {
     if (!this.change?.unresolved_comment_count) return;
-    return html`<iron-icon
-      icon="gr-icons:comment"
+    return html`<gr-icon
       class="commentIcon"
+      icon="chat_bubble"
+      small
+      filled
       .title=${pluralize(
         this.change?.unresolved_comment_count,
         'unresolved comment'
       )}
-    ></iron-icon>`;
+    ></gr-icon>`;
   }
 }
 
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-column-requirements-summary/gr-change-list-column-requirements-summary_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-column-requirements-summary/gr-change-list-column-requirements-summary_test.ts
index 5cc4e6d..6da91be2 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-column-requirements-summary/gr-change-list-column-requirements-summary_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-column-requirements-summary/gr-change-list-column-requirements-summary_test.ts
@@ -1,22 +1,10 @@
 /**
  * @license
- * Copyright (C) 2022 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
-import {fixture} from '@open-wc/testing-helpers';
+import '../../../test/common-test-setup';
+import {fixture, assert} from '@open-wc/testing';
 import {html} from 'lit';
 import './gr-change-list-column-requirements-summary';
 import {GrChangeListColumnRequirementsSummary} from './gr-change-list-column-requirements-summary';
@@ -69,19 +57,15 @@
       html`<gr-change-list-column-requirements-summary .change=${change}>
       </gr-change-list-column-requirements-summary>`
     );
-    expect(element).shadowDom.to.equal(/* HTML */ ` <span
-      class="block"
-      role="button"
-      tabindex="0"
-    >
-      <gr-submit-requirement-dashboard-hovercard>
-      </gr-submit-requirement-dashboard-hovercard>
-      <iron-icon class="block" icon="gr-icons:block" role="img"></iron-icon>
-      <span>
-        <span class="unsatisfied">1</span>
-        <span class="total">(of 1)</span>
-      </span>
-    </span>`);
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ ` <span class="block" role="button" tabindex="0">
+        <gr-submit-requirement-dashboard-hovercard>
+        </gr-submit-requirement-dashboard-hovercard>
+        <gr-icon class="block" role="img" icon="block"></gr-icon>
+        <span class="unsatisfied">1 missing</span>
+      </span>`
+    );
   });
 
   test('renders comment count', async () => {
@@ -93,23 +77,21 @@
       html`<gr-change-list-column-requirements-summary .change=${change}>
       </gr-change-list-column-requirements-summary>`
     );
-    expect(element).shadowDom.to.equal(/* HTML */ ` <span
-        class="block"
-        role="button"
-        tabindex="0"
-      >
-        <gr-submit-requirement-dashboard-hovercard>
-        </gr-submit-requirement-dashboard-hovercard>
-        <iron-icon class="block" icon="gr-icons:block" role="img"></iron-icon>
-        <span>
-          <span class="unsatisfied">1</span>
-          <span class="total">(of 1)</span>
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ ` <span class="block" role="button" tabindex="0">
+          <gr-submit-requirement-dashboard-hovercard>
+          </gr-submit-requirement-dashboard-hovercard>
+          <gr-icon class="block" role="img" icon="block"></gr-icon>
+          <span class="unsatisfied">1 missing</span>
         </span>
-      </span>
-      <iron-icon
-        class="commentIcon"
-        icon="gr-icons:comment"
-        title="5 unresolved comments"
-      ></iron-icon>`);
+        <gr-icon
+          class="commentIcon"
+          small
+          filled
+          icon="chat_bubble"
+          title="5 unresolved comments"
+        ></gr-icon>`
+    );
   });
 });
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-hashtag-flow/gr-change-list-hashtag-flow.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-hashtag-flow/gr-change-list-hashtag-flow.ts
new file mode 100644
index 0000000..2b3c3df4
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-hashtag-flow/gr-change-list-hashtag-flow.ts
@@ -0,0 +1,373 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {css, html, LitElement, nothing} from 'lit';
+import {customElement, query, state} from 'lit/decorators.js';
+import {bulkActionsModelToken} from '../../../models/bulk-actions/bulk-actions-model';
+import {resolve} from '../../../models/dependency';
+import {ChangeInfo, Hashtag} from '../../../types/common';
+import {subscribe} from '../../lit/subscription-controller';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-icon/gr-icon';
+import '../../shared/gr-autocomplete/gr-autocomplete';
+import '@polymer/iron-dropdown/iron-dropdown';
+import {IronDropdownElement} from '@polymer/iron-dropdown/iron-dropdown';
+import {getAppContext} from '../../../services/app-context';
+import {notUndefined} from '../../../types/types';
+import {unique} from '../../../utils/common-util';
+import {AutocompleteSuggestion} from '../../shared/gr-autocomplete/gr-autocomplete';
+import {when} from 'lit/directives/when.js';
+import {ValueChangedEvent} from '../../../types/events';
+import {classMap} from 'lit/directives/class-map.js';
+import {spinnerStyles} from '../../../styles/gr-spinner-styles';
+import {ProgressStatus} from '../../../constants/constants';
+import {allSettled} from '../../../utils/async-util';
+import {fireAlert} from '../../../utils/event-util';
+import {pluralize} from '../../../utils/string-util';
+import {Interaction} from '../../../constants/reporting';
+
+@customElement('gr-change-list-hashtag-flow')
+export class GrChangeListHashtagFlow extends LitElement {
+  @state() private selectedChanges: ChangeInfo[] = [];
+
+  @state() private hashtagToAdd: Hashtag = '' as Hashtag;
+
+  @state() private existingHashtagSuggestions: Hashtag[] = [];
+
+  @state() private loadingText?: string;
+
+  @state() private errorText?: string;
+
+  /** dropdown status is tracked here to lazy-load the inner DOM contents */
+  @state() private isDropdownOpen = false;
+
+  @state() private overallProgress: ProgressStatus = ProgressStatus.NOT_STARTED;
+
+  @query('iron-dropdown') private dropdown?: IronDropdownElement;
+
+  private selectedExistingHashtags: Set<Hashtag> = new Set();
+
+  private getBulkActionsModel = resolve(this, bulkActionsModelToken);
+
+  private restApiService = getAppContext().restApiService;
+
+  private readonly reportingService = getAppContext().reportingService;
+
+  static override get styles() {
+    return [
+      spinnerStyles,
+      css`
+        iron-dropdown {
+          box-shadow: var(--elevation-level-2);
+          width: 400px;
+          background-color: var(--dialog-background-color);
+          border-radius: 4px;
+        }
+        [slot='dropdown-content'] {
+          padding: var(--spacing-xl) var(--spacing-l) var(--spacing-l);
+        }
+        gr-autocomplete {
+          --prominent-border-color: var(--gray-800);
+        }
+        .footer {
+          display: flex;
+          justify-content: space-between;
+          align-items: baseline;
+        }
+        .buttons {
+          padding-top: var(--spacing-m);
+          display: flex;
+          justify-content: flex-end;
+          gap: var(--spacing-m);
+        }
+        .chips {
+          display: flex;
+          flex-wrap: wrap;
+          gap: 6px;
+          padding-bottom: var(--spacing-l);
+        }
+        .chip {
+          padding: var(--spacing-s) var(--spacing-xl);
+          border-radius: 10px;
+          width: fit-content;
+          cursor: pointer;
+          color: var(--primary-text-color);
+        }
+        .chip:not(.selected) {
+          border: var(--spacing-xxs) solid var(--border-color);
+          background: none;
+        }
+        .chip.selected {
+          border: 0;
+          color: var(--selected-foreground);
+          background-color: var(--selected-chip-background);
+          margin: var(--spacing-xxs);
+        }
+        .loadingOrError {
+          display: flex;
+          gap: var(--spacing-s);
+          align-items: baseline;
+        }
+
+        /* The basics of .loadingSpin are defined in spinnerStyles. */
+        .loadingSpin {
+          vertical-align: top;
+          position: relative;
+          top: 3px;
+        }
+        .error {
+          color: var(--deemphasized-text-color);
+        }
+        gr-icon {
+          color: var(--error-color);
+          /* Center with text by aligning it to the top and then pushing it down
+             to match the text */
+          vertical-align: top;
+          position: relative;
+          top: 7px;
+        }
+      `,
+    ];
+  }
+
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.getBulkActionsModel().selectedChanges$,
+      selectedChanges => {
+        this.selectedChanges = selectedChanges;
+      }
+    );
+  }
+
+  override render() {
+    const isFlowDisabled = this.selectedChanges.length === 0;
+    return html`
+      <gr-button
+        id="start-flow"
+        flatten
+        down-arrow
+        @click=${this.toggleDropdown}
+        .disabled=${isFlowDisabled}
+        >Hashtag</gr-button
+      >
+      <iron-dropdown
+        .horizontalAlign=${'auto'}
+        .verticalAlign=${'auto'}
+        .verticalOffset=${24}
+        @opened-changed=${(e: CustomEvent) =>
+          (this.isDropdownOpen = e.detail.value)}
+      >
+        ${when(
+          this.isDropdownOpen,
+          () => html`
+            <div slot="dropdown-content">
+              ${this.renderExistingHashtags()}
+              <!--
+                The .query function needs to be bound to this because lit's
+                autobind seems to work only for @event handlers.
+              -->
+              <gr-autocomplete
+                .text=${this.hashtagToAdd}
+                .query=${(query: string) => this.getHashtagSuggestions(query)}
+                show-blue-focus-border
+                placeholder="Type hashtag name to create or filter hashtags"
+                @text-changed=${(e: ValueChangedEvent<Hashtag>) =>
+                  (this.hashtagToAdd = e.detail.value)}
+              ></gr-autocomplete>
+              <div class="footer">
+                <div class="loadingOrError" role="progressbar">
+                  ${this.renderLoadingOrError()}
+                </div>
+                <div class="buttons">
+                  ${when(
+                    this.overallProgress !== ProgressStatus.FAILED,
+                    () => html`
+                      <gr-button
+                        id="add-hashtag-button"
+                        flatten
+                        @click=${() => this.applyHashtags('Adding hashtag...')}
+                        .disabled=${this.isAddHashtagDisabled()}
+                        >Add Hashtag</gr-button
+                      >
+                    `,
+                    () => html`
+                      <gr-button
+                        id="cancel-button"
+                        flatten
+                        @click=${this.closeDropdown}
+                        >Cancel</gr-button
+                      >
+                    `
+                  )}
+                </div>
+              </div>
+            </div>
+          `
+        )}
+      </iron-dropdown>
+    `;
+  }
+
+  private renderExistingHashtags() {
+    const hashtags = this.selectedChanges
+      .flatMap(change => change.hashtags ?? [])
+      .filter(notUndefined)
+      .filter(unique);
+    return html`
+      <div class="chips">
+        ${hashtags.map(name => this.renderExistingHashtagChip(name))}
+      </div>
+    `;
+  }
+
+  private renderExistingHashtagChip(name: Hashtag) {
+    const chipClasses = {
+      chip: true,
+      selected: this.selectedExistingHashtags.has(name),
+    };
+    return html`
+      <button
+        role="listbox"
+        aria-label=${`${name as string} selection`}
+        class=${classMap(chipClasses)}
+        @click=${() => this.toggleExistingHashtagSelected(name)}
+      >
+        ${name}
+      </button>
+    `;
+  }
+
+  private renderLoadingOrError() {
+    switch (this.overallProgress) {
+      case ProgressStatus.RUNNING:
+        return html`
+          <span class="loadingSpin"></span>
+          <span class="loadingText">${this.loadingText}</span>
+        `;
+      case ProgressStatus.FAILED:
+        return html`
+          <gr-icon icon="error" filled></gr-icon>
+          <div class="error">${this.errorText}</div>
+        `;
+      default:
+        return nothing;
+    }
+  }
+
+  private isAddHashtagDisabled() {
+    const allHashtagsToAdd = [
+      ...this.selectedExistingHashtags.values(),
+      ...(this.hashtagToAdd === '' ? [] : [this.hashtagToAdd]),
+    ];
+    const allHashtagsAreAlreadyAdded = allHashtagsToAdd.every(hashtag =>
+      this.selectedChanges.every(change => change.hashtags?.includes(hashtag))
+    );
+    return (
+      allHashtagsAreAlreadyAdded ||
+      this.overallProgress === ProgressStatus.RUNNING
+    );
+  }
+
+  private toggleDropdown() {
+    this.isDropdownOpen ? this.closeDropdown() : this.openDropdown();
+  }
+
+  private reset() {
+    this.hashtagToAdd = '' as Hashtag;
+    this.selectedExistingHashtags = new Set();
+    this.overallProgress = ProgressStatus.NOT_STARTED;
+    this.errorText = undefined;
+  }
+
+  private closeDropdown() {
+    this.isDropdownOpen = false;
+    this.dropdown?.close();
+  }
+
+  private openDropdown() {
+    this.reset();
+    this.isDropdownOpen = true;
+    this.dropdown?.open();
+  }
+
+  private async getHashtagSuggestions(
+    query: string
+  ): Promise<AutocompleteSuggestion[]> {
+    const suggestions = await this.restApiService.getChangesWithSimilarHashtag(
+      query
+    );
+    this.existingHashtagSuggestions = (suggestions ?? [])
+      .flatMap(change => change.hashtags ?? [])
+      .filter(notUndefined)
+      .filter(unique);
+    return this.existingHashtagSuggestions.map(hashtag => {
+      return {name: hashtag, value: hashtag};
+    });
+  }
+
+  private applyHashtags(loadingText: string) {
+    let alert = '';
+    const allHashtagsToAdd = [
+      ...this.selectedExistingHashtags.values(),
+      ...(this.hashtagToAdd === '' ? [] : [this.hashtagToAdd]),
+    ];
+
+    if (allHashtagsToAdd.length > 1) {
+      alert = `${allHashtagsToAdd.length} hashtags added to changes`;
+    } else {
+      alert = `${pluralize(this.selectedChanges.length, 'Change')} added to ${
+        allHashtagsToAdd[0]
+      }`;
+    }
+    this.reportingService.reportInteraction(Interaction.BULK_ACTION, {
+      type: 'add-hashtag',
+      selectedChangeCount: this.selectedChanges.length,
+      hashtagsApplied: allHashtagsToAdd.length,
+    });
+    this.loadingText = loadingText;
+    this.trackPromises(
+      this.getBulkActionsModel().addHashtags(allHashtagsToAdd),
+      alert,
+      'Failed to add'
+    );
+  }
+
+  private async trackPromises(
+    promises: Promise<Hashtag[]>[],
+    alert: string,
+    errorText: string
+  ) {
+    this.overallProgress = ProgressStatus.RUNNING;
+    const results = await allSettled(promises);
+    if (results.every(result => result.status === 'fulfilled')) {
+      this.overallProgress = ProgressStatus.SUCCESSFUL;
+      fireAlert(this, alert);
+      this.reset();
+      // iron-dropdown doesn't automatically expand when the new chip adds more
+      // vertical space.
+      this.dropdown?.notifyResize();
+    } else {
+      this.overallProgress = ProgressStatus.FAILED;
+      this.errorText = errorText;
+    }
+  }
+
+  private toggleExistingHashtagSelected(name: Hashtag) {
+    if (this.selectedExistingHashtags.has(name)) {
+      this.selectedExistingHashtags.delete(name);
+    } else {
+      this.selectedExistingHashtags.add(name);
+    }
+    this.requestUpdate();
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-change-list-hashtag-flow': GrChangeListHashtagFlow;
+  }
+}
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-hashtag-flow/gr-change-list-hashtag-flow_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-hashtag-flow/gr-change-list-hashtag-flow_test.ts
new file mode 100644
index 0000000..f7a2531
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-hashtag-flow/gr-change-list-hashtag-flow_test.ts
@@ -0,0 +1,599 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {fixture, html, assert} from '@open-wc/testing';
+import {IronDropdownElement} from '@polymer/iron-dropdown';
+import {SinonStubbedMember} from 'sinon';
+import {
+  BulkActionsModel,
+  bulkActionsModelToken,
+} from '../../../models/bulk-actions/bulk-actions-model';
+import {wrapInProvider} from '../../../models/di-provider-element';
+import {getAppContext} from '../../../services/app-context';
+import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
+import '../../../test/common-test-setup';
+import {createChange} from '../../../test/test-data-generators';
+import {
+  MockPromise,
+  mockPromise,
+  query,
+  queryAll,
+  queryAndAssert,
+  stubReporting,
+  stubRestApi,
+  waitEventLoop,
+  waitUntil,
+  waitUntilCalled,
+  waitUntilObserved,
+} from '../../../test/test-utils';
+import {ChangeInfo, NumericChangeId, Hashtag} from '../../../types/common';
+import {EventType} from '../../../types/events';
+import {GrAutocomplete} from '../../shared/gr-autocomplete/gr-autocomplete';
+import {GrButton} from '../../shared/gr-button/gr-button';
+import './gr-change-list-hashtag-flow';
+import type {GrChangeListHashtagFlow} from './gr-change-list-hashtag-flow';
+
+suite('gr-change-list-hashtag-flow tests', () => {
+  let element: GrChangeListHashtagFlow;
+  let model: BulkActionsModel;
+  let reportingStub: SinonStubbedMember<ReportingService['reportInteraction']>;
+
+  setup(() => {
+    reportingStub = stubReporting('reportInteraction');
+  });
+
+  async function selectChange(change: ChangeInfo) {
+    model.addSelectedChangeNum(change._number);
+    await waitUntilObserved(model.selectedChanges$, selected =>
+      selected.some(other => other._number === change._number)
+    );
+    await element.updateComplete;
+  }
+
+  suite('dropdown closed', () => {
+    const changes: ChangeInfo[] = [
+      {
+        ...createChange(),
+        _number: 1 as NumericChangeId,
+        subject: 'Subject 1',
+      },
+      {
+        ...createChange(),
+        _number: 2 as NumericChangeId,
+        subject: 'Subject 2',
+      },
+    ];
+
+    setup(async () => {
+      stubRestApi('getDetailedChangesWithActions').resolves(changes);
+      model = new BulkActionsModel(getAppContext().restApiService);
+      model.sync(changes);
+
+      element = (
+        await fixture(
+          wrapInProvider(
+            html`<gr-change-list-hashtag-flow></gr-change-list-hashtag-flow>`,
+            bulkActionsModelToken,
+            model
+          )
+        )
+      ).querySelector('gr-change-list-hashtag-flow')!;
+      await selectChange(changes[0]);
+      await selectChange(changes[1]);
+      await waitUntilObserved(model.selectedChanges$, s => s.length === 2);
+      await element.updateComplete;
+    });
+
+    test('skips dropdown render when closed', async () => {
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <gr-button
+            id="start-flow"
+            flatten=""
+            down-arrow=""
+            aria-disabled="false"
+            role="button"
+            tabindex="0"
+            >Hashtag</gr-button
+          >
+          <iron-dropdown
+            aria-disabled="false"
+            aria-hidden="true"
+            style="outline: none; display: none;"
+            vertical-align="auto"
+            horizontal-align="auto"
+          >
+          </iron-dropdown>
+        `
+      );
+    });
+
+    test('dropdown hidden before flow button clicked', async () => {
+      const dropdown = queryAndAssert<IronDropdownElement>(
+        element,
+        'iron-dropdown'
+      );
+      assert.isFalse(dropdown.opened);
+    });
+
+    test('flow button click shows dropdown', async () => {
+      const button = queryAndAssert<GrButton>(element, 'gr-button#start-flow');
+
+      button.click();
+      await element.updateComplete;
+
+      const dropdown = queryAndAssert<IronDropdownElement>(
+        element,
+        'iron-dropdown'
+      );
+      assert.isTrue(dropdown.opened);
+    });
+
+    test('flow button click when open hides dropdown', async () => {
+      queryAndAssert<GrButton>(element, 'gr-button#start-flow').click();
+      await waitUntil(() =>
+        Boolean(
+          queryAndAssert<IronDropdownElement>(element, 'iron-dropdown').opened
+        )
+      );
+      queryAndAssert<GrButton>(element, 'gr-button#start-flow').click();
+      await waitUntil(
+        () =>
+          !queryAndAssert<IronDropdownElement>(element, 'iron-dropdown').opened
+      );
+    });
+  });
+
+  suite('hashtag flow', () => {
+    const changes: ChangeInfo[] = [
+      {
+        ...createChange(),
+        _number: 1 as NumericChangeId,
+        subject: 'Subject 1',
+        hashtags: ['hashtag1' as Hashtag, 'sharedHashtag' as Hashtag],
+      },
+      {
+        ...createChange(),
+        _number: 2 as NumericChangeId,
+        subject: 'Subject 2',
+        hashtags: ['hashtag2' as Hashtag, 'sharedHashtag' as Hashtag],
+      },
+      {
+        ...createChange(),
+        _number: 3 as NumericChangeId,
+        subject: 'Subject 3',
+        hashtags: ['sharedHashtag' as Hashtag],
+      },
+    ];
+    let setChangeHashtagPromises: MockPromise<Hashtag[]>[];
+    let setChangeHashtagStub: sinon.SinonStub;
+
+    async function resolvePromises(newHashtags: Hashtag[]) {
+      setChangeHashtagPromises[0].resolve([
+        ...(changes[0].hashtags ?? []),
+        ...newHashtags,
+      ]);
+      setChangeHashtagPromises[1].resolve([
+        ...(changes[1].hashtags ?? []),
+        ...newHashtags,
+      ]);
+      setChangeHashtagPromises[2].resolve([
+        ...(changes[2].hashtags ?? []),
+        ...newHashtags,
+      ]);
+      await element.updateComplete;
+    }
+
+    async function rejectPromises() {
+      setChangeHashtagPromises[0].reject(new Error('error'));
+      setChangeHashtagPromises[1].reject(new Error('error'));
+      setChangeHashtagPromises[2].reject(new Error('error'));
+      await element.updateComplete;
+    }
+
+    setup(async () => {
+      stubRestApi('getDetailedChangesWithActions').resolves(changes);
+      setChangeHashtagPromises = [];
+      setChangeHashtagStub = stubRestApi('setChangeHashtag');
+      for (let i = 0; i < changes.length; i++) {
+        const promise = mockPromise<Hashtag[]>();
+        setChangeHashtagPromises.push(promise);
+        setChangeHashtagStub
+          .withArgs(changes[i]._number, sinon.match.any)
+          .returns(promise);
+      }
+      model = new BulkActionsModel(getAppContext().restApiService);
+      model.sync(changes);
+
+      element = (
+        await fixture(
+          wrapInProvider(
+            html`<gr-change-list-hashtag-flow></gr-change-list-hashtag-flow>`,
+            bulkActionsModelToken,
+            model
+          )
+        )
+      ).querySelector('gr-change-list-hashtag-flow')!;
+
+      // select changes
+      await selectChange(changes[0]);
+      await selectChange(changes[1]);
+      await selectChange(changes[2]);
+      await waitUntilObserved(model.selectedChanges$, s => s.length === 3);
+      await element.updateComplete;
+
+      // open flow
+      queryAndAssert<GrButton>(element, 'gr-button#start-flow').click();
+      await element.updateComplete;
+      await waitEventLoop();
+    });
+
+    test('renders hashtags flow', () => {
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <gr-button
+            id="start-flow"
+            flatten=""
+            down-arrow=""
+            aria-disabled="false"
+            role="button"
+            tabindex="0"
+            >Hashtag</gr-button
+          >
+          <iron-dropdown
+            aria-disabled="false"
+            vertical-align="auto"
+            horizontal-align="auto"
+          >
+            <div slot="dropdown-content">
+              <div class="chips">
+                <button
+                  role="listbox"
+                  aria-label="hashtag1 selection"
+                  class="chip"
+                >
+                  hashtag1
+                </button>
+                <button
+                  role="listbox"
+                  aria-label="sharedHashtag selection"
+                  class="chip"
+                >
+                  sharedHashtag
+                </button>
+                <button
+                  role="listbox"
+                  aria-label="hashtag2 selection"
+                  class="chip"
+                >
+                  hashtag2
+                </button>
+              </div>
+              <gr-autocomplete
+                placeholder="Type hashtag name to create or filter hashtags"
+                show-blue-focus-border=""
+              ></gr-autocomplete>
+              <div class="footer">
+                <div class="loadingOrError" role="progressbar"></div>
+                <div class="buttons">
+                  <gr-button
+                    id="add-hashtag-button"
+                    flatten=""
+                    aria-disabled="true"
+                    disabled=""
+                    role="button"
+                    tabindex="-1"
+                    >Add Hashtag</gr-button
+                  >
+                </div>
+              </div>
+            </div>
+          </iron-dropdown>
+        `,
+        {
+          // iron-dropdown sizing seems to vary between local & CI
+          ignoreAttributes: [{tags: ['iron-dropdown'], attributes: ['style']}],
+        }
+      );
+    });
+
+    test('add hashtag from selected change', async () => {
+      const alertStub = sinon.stub();
+      element.addEventListener(EventType.SHOW_ALERT, alertStub);
+      // selects "hashtag1"
+      queryAll<HTMLButtonElement>(element, 'button.chip')[0].click();
+      await element.updateComplete;
+
+      queryAndAssert<GrButton>(element, '#add-hashtag-button').click();
+      await element.updateComplete;
+
+      assert.equal(
+        queryAndAssert(element, '.loadingText').textContent,
+        'Adding hashtag...'
+      );
+
+      await resolvePromises(['hashtag1' as Hashtag]);
+      await element.updateComplete;
+
+      assert.isTrue(setChangeHashtagStub.calledThrice);
+      assert.deepEqual(setChangeHashtagStub.firstCall.args, [
+        changes[0]._number,
+        {add: ['hashtag1']},
+      ]);
+      assert.deepEqual(setChangeHashtagStub.secondCall.args, [
+        changes[1]._number,
+        {add: ['hashtag1']},
+      ]);
+      assert.deepEqual(setChangeHashtagStub.thirdCall.args, [
+        changes[2]._number,
+        {add: ['hashtag1']},
+      ]);
+      await waitUntilCalled(alertStub, 'alertStub');
+      assert.deepEqual(alertStub.lastCall.args[0].detail, {
+        message: '3 Changes added to hashtag1',
+        showDismiss: true,
+      });
+      assert.deepEqual(reportingStub.lastCall.args[1], {
+        type: 'add-hashtag',
+        selectedChangeCount: 3,
+        hashtagsApplied: 1,
+      });
+      assert.isTrue(
+        queryAndAssert<IronDropdownElement>(element, 'iron-dropdown').opened
+      );
+    });
+
+    test('shows error when add hashtag fails', async () => {
+      // selects "hashtag1"
+      queryAll<HTMLButtonElement>(element, 'button.chip')[0].click();
+      await element.updateComplete;
+
+      queryAndAssert<GrButton>(element, '#add-hashtag-button').click();
+      await element.updateComplete;
+
+      assert.equal(
+        queryAndAssert(element, '.loadingText').textContent,
+        'Adding hashtag...'
+      );
+
+      await rejectPromises();
+      await element.updateComplete;
+      await waitUntil(() => query(element, '.error') !== undefined);
+
+      assert.equal(
+        queryAndAssert(element, '.error').textContent,
+        'Failed to add'
+      );
+      assert.equal(
+        queryAndAssert(element, 'gr-button#cancel-button').textContent,
+        'Cancel'
+      );
+      assert.isUndefined(query(element, '.loadingText'));
+    });
+
+    test('add multiple hashtag from selected change', async () => {
+      const alertStub = sinon.stub();
+      element.addEventListener(EventType.SHOW_ALERT, alertStub);
+      // selects "hashtag1"
+      queryAll<HTMLButtonElement>(element, 'button.chip')[0].click();
+      await element.updateComplete;
+
+      // selects "hashtag2"
+      queryAll<HTMLButtonElement>(element, 'button.chip')[2].click();
+      await element.updateComplete;
+
+      queryAndAssert<GrButton>(element, '#add-hashtag-button').click();
+      await element.updateComplete;
+
+      assert.equal(
+        queryAndAssert(element, '.loadingText').textContent,
+        'Adding hashtag...'
+      );
+
+      await resolvePromises(['hashtag1' as Hashtag, 'hashtag2' as Hashtag]);
+      await element.updateComplete;
+
+      assert.isTrue(setChangeHashtagStub.calledThrice);
+      assert.deepEqual(setChangeHashtagStub.firstCall.args, [
+        changes[0]._number,
+        {add: ['hashtag1', 'hashtag2']},
+      ]);
+      assert.deepEqual(setChangeHashtagStub.secondCall.args, [
+        changes[1]._number,
+        {add: ['hashtag1', 'hashtag2']},
+      ]);
+      assert.deepEqual(setChangeHashtagStub.thirdCall.args, [
+        changes[2]._number,
+        {add: ['hashtag1', 'hashtag2']},
+      ]);
+
+      await waitUntilCalled(alertStub, 'alertStub');
+      assert.deepEqual(alertStub.lastCall.args[0].detail, {
+        message: '2 hashtags added to changes',
+        showDismiss: true,
+      });
+      assert.deepEqual(reportingStub.lastCall.args[1], {
+        type: 'add-hashtag',
+        selectedChangeCount: 3,
+        hashtagsApplied: 2,
+      });
+    });
+
+    test('add existing hashtag not on selected changes', async () => {
+      const alertStub = sinon.stub();
+      element.addEventListener(EventType.SHOW_ALERT, alertStub);
+
+      const getHashtagsStub = stubRestApi(
+        'getChangesWithSimilarHashtag'
+      ).resolves([{...createChange(), hashtags: ['foo' as Hashtag]}]);
+      const autocomplete = queryAndAssert<GrAutocomplete>(
+        element,
+        'gr-autocomplete'
+      );
+
+      autocomplete.setFocus(true);
+      autocomplete.text = 'foo';
+      await element.updateComplete;
+      await waitUntilCalled(getHashtagsStub, 'getHashtagsStub');
+
+      queryAndAssert<GrButton>(element, '#add-hashtag-button').click();
+      await element.updateComplete;
+
+      assert.equal(
+        queryAndAssert(element, '.loadingText').textContent,
+        'Adding hashtag...'
+      );
+
+      await resolvePromises(['foo' as Hashtag]);
+
+      assert.isTrue(setChangeHashtagStub.calledThrice);
+      assert.deepEqual(setChangeHashtagStub.firstCall.args, [
+        changes[0]._number,
+        {add: ['foo']},
+      ]);
+      assert.deepEqual(setChangeHashtagStub.secondCall.args, [
+        changes[1]._number,
+        {add: ['foo']},
+      ]);
+      assert.deepEqual(setChangeHashtagStub.thirdCall.args, [
+        changes[2]._number,
+        {add: ['foo']},
+      ]);
+
+      await waitUntilCalled(alertStub, 'alertStub');
+      assert.deepEqual(alertStub.lastCall.args[0].detail, {
+        message: '3 Changes added to foo',
+        showDismiss: true,
+      });
+      assert.deepEqual(reportingStub.lastCall.args[1], {
+        type: 'add-hashtag',
+        selectedChangeCount: 3,
+        hashtagsApplied: 1,
+      });
+      assert.isTrue(
+        queryAndAssert<IronDropdownElement>(element, 'iron-dropdown').opened
+      );
+    });
+
+    test('add new hashtag', async () => {
+      const alertStub = sinon.stub();
+      element.addEventListener(EventType.SHOW_ALERT, alertStub);
+
+      const getHashtagsStub = stubRestApi(
+        'getChangesWithSimilarHashtag'
+      ).resolves([]);
+      const autocomplete = queryAndAssert<GrAutocomplete>(
+        element,
+        'gr-autocomplete'
+      );
+      autocomplete.setFocus(true);
+      autocomplete.text = 'foo';
+      await element.updateComplete;
+      await waitUntilCalled(getHashtagsStub, 'getHashtagsStub');
+      assert.isFalse(
+        queryAndAssert<GrButton>(element, '#add-hashtag-button').disabled
+      );
+
+      queryAndAssert<GrButton>(element, '#add-hashtag-button').click();
+      await element.updateComplete;
+
+      assert.equal(
+        queryAndAssert(element, '.loadingText').textContent,
+        'Adding hashtag...'
+      );
+
+      await resolvePromises(['foo' as Hashtag]);
+      await waitUntilObserved(model.selectedChanges$, selected =>
+        selected.every(change => change.hashtags?.includes('foo' as Hashtag))
+      );
+      await element.updateComplete;
+
+      assert.isTrue(setChangeHashtagStub.calledThrice);
+      assert.deepEqual(setChangeHashtagStub.firstCall.args, [
+        changes[0]._number,
+        {add: ['foo']},
+      ]);
+      assert.deepEqual(setChangeHashtagStub.secondCall.args, [
+        changes[1]._number,
+        {add: ['foo']},
+      ]);
+      assert.deepEqual(setChangeHashtagStub.thirdCall.args, [
+        changes[2]._number,
+        {add: ['foo']},
+      ]);
+
+      await waitUntilCalled(alertStub, 'alertStub');
+      assert.deepEqual(alertStub.lastCall.args[0].detail, {
+        message: '3 Changes added to foo',
+        showDismiss: true,
+      });
+      assert.deepEqual(reportingStub.lastCall.args[1], {
+        type: 'add-hashtag',
+        selectedChangeCount: 3,
+        hashtagsApplied: 1,
+      });
+      assert.isTrue(
+        queryAndAssert<IronDropdownElement>(element, 'iron-dropdown').opened
+      );
+      assert.equal(
+        queryAll<HTMLButtonElement>(element, 'button.chip')[2].innerText,
+        'foo'
+      );
+    });
+
+    test('shows error when add hashtag fails', async () => {
+      const getHashtagsStub = stubRestApi(
+        'getChangesWithSimilarHashtag'
+      ).resolves([]);
+      const autocomplete = queryAndAssert<GrAutocomplete>(
+        element,
+        'gr-autocomplete'
+      );
+      autocomplete.setFocus(true);
+      autocomplete.text = 'foo';
+      await element.updateComplete;
+      await waitUntilCalled(getHashtagsStub, 'getHashtagsStub');
+      assert.isFalse(
+        queryAndAssert<GrButton>(element, '#add-hashtag-button').disabled
+      );
+
+      queryAndAssert<GrButton>(element, '#add-hashtag-button').click();
+      await element.updateComplete;
+
+      assert.equal(
+        queryAndAssert(element, '.loadingText').textContent,
+        'Adding hashtag...'
+      );
+
+      await rejectPromises();
+      await element.updateComplete;
+      await waitUntil(() => query(element, '.error') !== undefined);
+
+      assert.equal(
+        queryAndAssert(element, '.error').textContent,
+        'Failed to add'
+      );
+      assert.equal(
+        queryAndAssert(element, 'gr-button#cancel-button').textContent,
+        'Cancel'
+      );
+      assert.isUndefined(query(element, '.loadingText'));
+    });
+
+    test('cannot add existing hashtag already on selected changes', async () => {
+      const alertStub = sinon.stub();
+      element.addEventListener(EventType.SHOW_ALERT, alertStub);
+      // selects "sharedHashtag"
+      queryAll<HTMLButtonElement>(element, 'button.chip')[1].click();
+      await element.updateComplete;
+
+      assert.isTrue(
+        queryAndAssert<GrButton>(element, '#add-hashtag-button').disabled
+      );
+    });
+  });
+});
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts
index 15532d2..2245a09 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts
@@ -1,25 +1,13 @@
 /**
  * @license
- * Copyright (C) 2015 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2015 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
 import '../../shared/gr-account-label/gr-account-label';
 import '../../shared/gr-change-star/gr-change-star';
 import '../../shared/gr-change-status/gr-change-status';
 import '../../shared/gr-date-formatter/gr-date-formatter';
-import '../../shared/gr-icons/gr-icons';
+import '../../shared/gr-icon/gr-icon';
 import '../../shared/gr-limited-text/gr-limited-text';
 import '../../shared/gr-tooltip-content/gr-tooltip-content';
 import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
@@ -27,7 +15,7 @@
 import '../gr-change-list-column-requirements-summary/gr-change-list-column-requirements-summary';
 import '../gr-change-list-column-requirement/gr-change-list-column-requirement';
 import '../../shared/gr-tooltip-content/gr-tooltip-content';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
 import {getDisplayName} from '../../../utils/display-name-util';
 import {getPluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints';
 import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
@@ -40,23 +28,22 @@
   ChangeInfo,
   ServerInfo,
   AccountInfo,
-  QuickLabelInfo,
   Timestamp,
 } from '../../../types/common';
 import {hasOwnProperty, assertIsDefined} from '../../../utils/common-util';
-import {pluralize} from '../../../utils/string-util';
-import {showNewSubmitRequirements} from '../../../utils/label-util';
 import {changeListStyles} from '../../../styles/gr-change-list-styles';
 import {sharedStyles} from '../../../styles/shared-styles';
-import {LitElement, css, html} from 'lit';
-import {customElement, property, state} from 'lit/decorators';
+import {LitElement, css, html, PropertyValues} from 'lit';
+import {customElement, property, state} from 'lit/decorators.js';
 import {submitRequirementsStyles} from '../../../styles/gr-submit-requirements-styles';
-import {ifDefined} from 'lit/directives/if-defined';
-import {KnownExperimentId} from '../../../services/flags/flags';
-import {WAITING} from '../../../constants/constants';
+import {ifDefined} from 'lit/directives/if-defined.js';
+import {ChangeStatus, ColumnNames, WAITING} from '../../../constants/constants';
 import {bulkActionsModelToken} from '../../../models/bulk-actions/bulk-actions-model';
 import {resolve} from '../../../models/dependency';
 import {subscribe} from '../../lit/subscription-controller';
+import {classMap} from 'lit/directives/class-map.js';
+import {createSearchUrl} from '../../../models/views/search';
+import {createChangeUrl} from '../../../models/views/change';
 
 enum ChangeSize {
   XS = 10,
@@ -84,9 +71,7 @@
     'gr-change-list-item': GrChangeListItem;
   }
 }
-/**
- * @attr {Boolean} selected - change list item is selected by cursor
- */
+
 @customElement('gr-change-list-item')
 export class GrChangeListItem extends LitElement {
   /** The logged-in user's account, or null if no user is logged in. */
@@ -110,21 +95,55 @@
   sectionName?: string;
 
   @property({type: Boolean})
-  showStar = false;
-
-  @property({type: Boolean})
   showNumber = false;
 
+  @property({type: String})
+  usp?: string;
+
+  /** Index of the item in the overall list. */
+  @property({type: Number})
+  globalIndex = 0;
+
+  /** Callback to call to request the item to be selected in the list. */
+  @property({type: Function})
+  triggerSelectionCallback?: (globalIndex: number) => void;
+
+  @property({type: Boolean, reflect: true}) selected = false;
+
+  // private but used in tests
+  @property({type: Boolean, reflect: true}) checked = false;
+
   @state() private dynamicCellEndpoints?: string[];
 
+  // Private but used in test.
   reporting: ReportingService = getAppContext().reportingService;
 
-  private readonly flagsService = getAppContext().flagsService;
-
-  @state() private checked = false;
+  // Private but used in test.
+  userModel = getAppContext().userModel;
 
   private readonly getBulkActionsModel = resolve(this, bulkActionsModelToken);
 
+  private readonly getNavigation = resolve(this, navigationToken);
+
+  @state() private isLoggedIn = false;
+
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.getBulkActionsModel().selectedChangeNums$,
+      selectedChangeNums => {
+        if (!this.change) return;
+        this.checked = selectedChangeNums.includes(this.change._number);
+      }
+    );
+    subscribe(
+      this,
+      () => this.userModel.loggedIn$,
+      isLoggedIn => (this.isLoggedIn = isLoggedIn)
+    );
+  }
+
   override connectedCallback() {
     super.connectedCallback();
     getPluginLoader()
@@ -134,14 +153,19 @@
           'change-list-item-cell'
         );
       });
-    subscribe(
-      this,
-      this.getBulkActionsModel().selectedChangeNums$,
-      selectedChangeNums => {
-        if (!this.change) return;
-        this.checked = selectedChangeNums.includes(this.change._number);
-      }
-    );
+    this.addEventListener('click', this.onItemClick);
+  }
+
+  override disconnectedCallback() {
+    this.removeEventListener('click', this.onItemClick);
+  }
+
+  override willUpdate(changedProperties: PropertyValues<this>) {
+    // When the cursor selects this item, give it focus so that the item is read
+    // out by screen readers and lets users start tabbing through the item
+    if (this.selected && changedProperties.has('selected')) {
+      this.focus();
+    }
   }
 
   static override get styles() {
@@ -157,12 +181,17 @@
         :host(:focus) {
           outline: none;
         }
+        :host([checked]),
         :host(:hover) {
           background-color: var(--hover-background-color);
         }
         .container {
           position: relative;
         }
+        .strikethrough {
+          color: var(--deemphasized-text-color);
+          text-decoration: line-through;
+        }
         .content {
           overflow: hidden;
           position: absolute;
@@ -244,22 +273,6 @@
         .subject:hover .content {
           text-decoration: underline;
         }
-        .u-monospace {
-          font-family: var(--monospace-font-family);
-          font-size: var(--font-size-mono);
-          line-height: var(--line-height-mono);
-        }
-        .u-green,
-        .u-green iron-icon {
-          color: var(--positive-green-text-color);
-        }
-        .u-red,
-        .u-red iron-icon {
-          color: var(--negative-red-text-color);
-        }
-        .u-gray-background {
-          background-color: var(--table-header-background-color);
-        }
         .comma,
         .placeholder {
           color: var(--deemphasized-text-color);
@@ -267,10 +280,15 @@
         .cell.selection input {
           vertical-align: middle;
         }
+        .selectionLabel {
+          padding: 10px;
+          margin: -10px;
+          display: block;
+        }
         .cell.label {
           font-weight: var(--font-weight-normal);
         }
-        .cell.label iron-icon {
+        .cell.label gr-icon {
           vertical-align: top;
         }
         /* Requirement child needs whole area */
@@ -307,7 +325,8 @@
   }
 
   private renderCellSelectionBox() {
-    if (!this.flagsService.isEnabled(KnownExperimentId.BULK_ACTIONS)) return;
+    if (!this.isLoggedIn) return;
+
     return html`
       <td class="cell selection">
         <!--
@@ -316,17 +335,19 @@
           update the current checked state.
           See: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/checkbox#attr-checked
         -->
-        <input
-          type="checkbox"
-          .checked=${this.checked}
-          @click=${() => this.handleChangeSelectionClick()}
-        />
+        <label class="selectionLabel">
+          <input
+            type="checkbox"
+            .checked=${this.checked}
+            @click=${this.toggleCheckbox}
+          />
+        </label>
       </td>
     `;
   }
 
   private renderCellStar() {
-    if (!this.showStar) return;
+    if (!this.isLoggedIn) return;
 
     return html`
       <td class="cell star">
@@ -346,7 +367,12 @@
   }
 
   private renderCellSubject(changeUrl: string) {
-    if (this.computeIsColumnHidden('Subject', this.visibleChangeTableColumns))
+    if (
+      this.computeIsColumnHidden(
+        ColumnNames.SUBJECT,
+        this.visibleChangeTableColumns
+      )
+    )
       return;
 
     return html`
@@ -354,10 +380,17 @@
         <a
           title=${ifDefined(this.change?.subject)}
           href=${changeUrl}
-          @click=${() => this.handleChangeClick()}
+          @click=${this.handleChangeClick}
         >
           <div class="container">
-            <div class="content">${this.change?.subject}</div>
+            <div
+              class=${classMap({
+                content: true,
+                strikethrough: this.change?.status === ChangeStatus.ABANDONED,
+              })}
+            >
+              ${this.change?.subject}
+            </div>
             <div class="spacer">${this.change?.subject}</div>
             <span>&nbsp;</span>
           </div>
@@ -367,7 +400,12 @@
   }
 
   private renderCellStatus() {
-    if (this.computeIsColumnHidden('Status', this.visibleChangeTableColumns))
+    if (
+      this.computeIsColumnHidden(
+        ColumnNames.STATUS,
+        this.visibleChangeTableColumns
+      )
+    )
       return;
 
     return html` <td class="cell status">${this.renderChangeStatus()}</td> `;
@@ -387,7 +425,12 @@
   }
 
   private renderCellOwner() {
-    if (this.computeIsColumnHidden('Owner', this.visibleChangeTableColumns))
+    if (
+      this.computeIsColumnHidden(
+        ColumnNames.OWNER,
+        this.visibleChangeTableColumns
+      )
+    )
       return;
 
     return html`
@@ -403,7 +446,12 @@
   }
 
   private renderCellReviewers() {
-    if (this.computeIsColumnHidden('Reviewers', this.visibleChangeTableColumns))
+    if (
+      this.computeIsColumnHidden(
+        ColumnNames.REVIEWERS,
+        this.visibleChangeTableColumns
+      )
+    )
       return;
 
     return html`
@@ -445,7 +493,7 @@
     return html`
       <td class="cell comments">
         ${this.change?.unresolved_comment_count
-          ? html`<iron-icon icon="gr-icons:comment"></iron-icon>`
+          ? html`<gr-icon icon="mode_comment" filled></gr-icon>`
           : ''}
         <span
           >${this.computeComments(this.change?.unresolved_comment_count)}</span
@@ -455,27 +503,33 @@
   }
 
   private renderCellRepo() {
-    if (this.computeIsColumnHidden('Repo', this.visibleChangeTableColumns))
+    if (
+      this.computeIsColumnHidden(
+        ColumnNames.REPO,
+        this.visibleChangeTableColumns
+      )
+    ) {
       return;
+    }
 
+    const repo = this.change?.project ?? '';
     return html`
       <td class="cell repo">
-        <a class="fullRepo" href=${this.computeRepoUrl()}>
-          ${this.computeRepoDisplay()}
-        </a>
-        <a
-          class="truncatedRepo"
-          href=${this.computeRepoUrl()}
-          title=${this.computeRepoDisplay()}
-        >
-          ${this.computeTruncatedRepoDisplay()}
+        <a class="fullRepo" href=${this.computeRepoUrl()}> ${repo} </a>
+        <a class="truncatedRepo" href=${this.computeRepoUrl()} title=${repo}>
+          ${truncatePath(repo, 2)}
         </a>
       </td>
     `;
   }
 
   private renderCellBranch() {
-    if (this.computeIsColumnHidden('Branch', this.visibleChangeTableColumns))
+    if (
+      this.computeIsColumnHidden(
+        ColumnNames.BRANCH,
+        this.visibleChangeTableColumns
+      )
+    )
       return;
 
     return html`
@@ -567,7 +621,12 @@
   }
 
   private renderCellRequirements() {
-    if (this.computeIsColumnHidden(' Status ', this.visibleChangeTableColumns))
+    if (
+      this.computeIsColumnHidden(
+        ColumnNames.STATUS2,
+        this.visibleChangeTableColumns
+      )
+    )
       return;
 
     return html`
@@ -579,32 +638,13 @@
   }
 
   private renderChangeLabels(labelName: string) {
-    if (showNewSubmitRequirements(this.flagsService, this.change)) {
-      return html` <td class="cell label requirement">
-        <gr-change-list-column-requirement
-          .change=${this.change}
-          .labelName=${labelName}
-        >
-        </gr-change-list-column-requirement>
-      </td>`;
-    }
-    return html`
-      <td
-        title=${this.computeLabelTitle(labelName)}
-        class=${this.computeLabelClass(labelName)}
+    return html` <td class="cell label requirement">
+      <gr-change-list-column-requirement
+        .change=${this.change}
+        .labelName=${labelName}
       >
-        ${this.renderChangeHasLabelIcon(labelName)}
-      </td>
-    `;
-  }
-
-  private renderChangeHasLabelIcon(labelName: string) {
-    if (this.computeLabelIcon(labelName) === '')
-      return html`<span>${this.computeLabelValue(labelName)}</span>`;
-
-    return html`
-      <iron-icon icon=${this.computeLabelIcon(labelName)}></iron-icon>
-    `;
+      </gr-change-list-column-requirement>
+    </td>`;
   }
 
   private renderChangePluginEndpoint(pluginEndpointName: string) {
@@ -618,14 +658,17 @@
     `;
   }
 
-  private handleChangeSelectionClick() {
-    assertIsDefined(this.change, 'change');
-    this.checked = !this.checked;
-    if (this.checked)
-      this.getBulkActionsModel().addSelectedChangeNum(this.change._number);
-    else
-      this.getBulkActionsModel().removeSelectedChangeNum(this.change._number);
-  }
+  private readonly onItemClick = (e: Event) => {
+    // Check the path to verify that the item row itself was directly clicked.
+    // This will allow users using screen readers like VoiceOver to select an
+    // item with j/k and go to the selected change with Ctrl+Option+Space, but
+    // not interfere with clicks on interactive elements within the
+    // gr-change-list-item such as account links, which will bubble through
+    // without triggering this extra navigation.
+    if (this.change && e.composedPath()[0] === this) {
+      this.getNavigation().setUrl(createChangeUrl({change: this.change}));
+    }
+  };
 
   private changeStatuses() {
     if (!this.change) return [];
@@ -634,178 +677,32 @@
 
   private computeChangeURL() {
     if (!this.change) return '';
-    return GerritNav.getUrlForChange(this.change);
-  }
-
-  // private but used in test
-  computeLabelTitle(labelName: string) {
-    const label: QuickLabelInfo | undefined = this.change?.labels?.[labelName];
-    const category = this.computeLabelCategory(labelName);
-    if (!label || category === LabelCategory.NOT_APPLICABLE) {
-      return 'Label not applicable';
-    }
-    const titleParts: string[] = [];
-    if (category === LabelCategory.UNRESOLVED_COMMENTS) {
-      const num = this.change?.unresolved_comment_count ?? 0;
-      titleParts.push(pluralize(num, 'unresolved comment'));
-    }
-    const significantLabel =
-      label.rejected || label.approved || label.disliked || label.recommended;
-    if (significantLabel?.name) {
-      titleParts.push(`${labelName} by ${significantLabel.name}`);
-    }
-    if (titleParts.length > 0) {
-      return titleParts.join(',\n');
-    }
-    return labelName;
-  }
-
-  // private but used in test
-  computeLabelClass(labelName: string) {
-    const classes = ['cell', 'label'];
-    const category = this.computeLabelCategory(labelName);
-    switch (category) {
-      case LabelCategory.NOT_APPLICABLE:
-        classes.push('u-gray-background');
-        break;
-      case LabelCategory.APPROVED:
-        classes.push('u-green');
-        break;
-      case LabelCategory.POSITIVE:
-        classes.push('u-monospace');
-        classes.push('u-green');
-        break;
-      case LabelCategory.NEGATIVE:
-        classes.push('u-monospace');
-        classes.push('u-red');
-        break;
-      case LabelCategory.REJECTED:
-        classes.push('u-red');
-        break;
-    }
-    return classes.sort().join(' ');
-  }
-
-  // private but used in test
-  computeLabelIcon(labelName: string): string {
-    const category = this.computeLabelCategory(labelName);
-    switch (category) {
-      case LabelCategory.APPROVED:
-        return 'gr-icons:check';
-      case LabelCategory.UNRESOLVED_COMMENTS:
-        return 'gr-icons:comment';
-      case LabelCategory.REJECTED:
-        return 'gr-icons:close';
-      default:
-        return '';
-    }
-  }
-
-  // private but used in test
-  computeLabelCategory(labelName: string) {
-    const label: QuickLabelInfo | undefined = this.change?.labels?.[labelName];
-    if (!label) {
-      return LabelCategory.NOT_APPLICABLE;
-    }
-    if (label.rejected) {
-      return LabelCategory.REJECTED;
-    }
-    if (label.value && label.value < 0) {
-      return LabelCategory.NEGATIVE;
-    }
-    if (this.change?.unresolved_comment_count && labelName === 'Code-Review') {
-      return LabelCategory.UNRESOLVED_COMMENTS;
-    }
-    if (label.approved) {
-      return LabelCategory.APPROVED;
-    }
-    if (label.value && label.value > 0) {
-      return LabelCategory.POSITIVE;
-    }
-    return LabelCategory.NEUTRAL;
-  }
-
-  // private but used in test
-  computeLabelValue(labelName: string) {
-    const label: QuickLabelInfo | undefined = this.change?.labels?.[labelName];
-    const category = this.computeLabelCategory(labelName);
-    switch (category) {
-      case LabelCategory.NOT_APPLICABLE:
-        return '';
-      case LabelCategory.APPROVED:
-        return '\u2713'; // ✓
-      case LabelCategory.POSITIVE:
-        return `+${label?.value}`;
-      case LabelCategory.NEUTRAL:
-        return '';
-      case LabelCategory.UNRESOLVED_COMMENTS:
-        return 'u';
-      case LabelCategory.NEGATIVE:
-        return `${label?.value}`;
-      case LabelCategory.REJECTED:
-        return '\u2715'; // ✕
-      default:
-        return '';
-    }
+    return createChangeUrl({change: this.change, usp: this.usp});
   }
 
   private computeRepoUrl() {
     if (!this.change) return '';
-    return GerritNav.getUrlForProjectChanges(
-      this.change.project,
-      true,
-      this.change.internalHost
-    );
+    return createSearchUrl({project: this.change.project, statuses: ['open']});
   }
 
   private computeRepoBranchURL() {
     if (!this.change) return '';
-    return GerritNav.getUrlForBranch(
-      this.change.branch,
-      this.change.project,
-      undefined,
-      this.change.internalHost
-    );
+    return createSearchUrl({
+      branch: this.change.branch,
+      project: this.change.project,
+    });
   }
 
   private computeTopicURL() {
     if (!this.change?.topic) return '';
-    return GerritNav.getUrlForTopic(
-      this.change.topic,
-      this.change.internalHost
-    );
+    return createSearchUrl({topic: this.change.topic});
   }
 
-  /**
-   * Computes the display string for the project column. If there is a host
-   * specified in the change detail, the string will be prefixed with it.
-   *
-   * @param truncate whether or not the project name should be
-   * truncated. If this value is truthy, the name will be truncated.
-   *
-   * private but used in test
-   */
-  computeRepoDisplay() {
-    if (!this.change?.project) return '';
-    let str = '';
-    if (this.change.internalHost) {
-      str += this.change.internalHost + '/';
-    }
-    str += this.change.project;
-    return str;
-  }
-
-  // private but used in test
-  computeTruncatedRepoDisplay() {
-    if (!this.change?.project) {
-      return '';
-    }
-    let str = '';
-    if (this.change.internalHost) {
-      str += this.change.internalHost + '/';
-    }
-    str += truncatePath(this.change.project, 2);
-    return str;
+  private toggleCheckbox() {
+    assertIsDefined(this.change, 'change');
+    this.checked = !this.checked;
+    this.triggerSelectionCallback?.(this.globalIndex);
+    this.getBulkActionsModel().toggleSelectedChangeNum(this.change._number);
   }
 
   // private but used in test
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 95a851e..a4c4a94 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.ts
@@ -1,29 +1,18 @@
 /**
  * @license
- * Copyright (C) 2015 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2015 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import {fixture} from '@open-wc/testing-helpers';
+import {fixture, assert} from '@open-wc/testing';
 import {html} from 'lit';
 import {
   SubmitRequirementResultInfo,
   NumericChangeId,
+  Timestamp,
 } from '../../../api/rest-api';
-import {getAppContext} from '../../../services/app-context';
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import {
+  createAccountWithEmail,
   createAccountWithId,
   createChange,
   createSubmitRequirementExpressionInfo,
@@ -34,8 +23,6 @@
 import {
   query,
   queryAndAssert,
-  stubRestApi,
-  stubFlags,
   waitUntilObserved,
 } from '../../../test/test-utils';
 import {
@@ -46,10 +33,9 @@
   TopicName,
 } from '../../../types/common';
 import {StandardLabels} from '../../../utils/label-util';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation';
-import {columnNames} from '../gr-change-list/gr-change-list';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
 import './gr-change-list-item';
-import {GrChangeListItem, LabelCategory} from './gr-change-list-item';
+import {GrChangeListItem} from './gr-change-list-item';
 import {
   DIProviderElement,
   wrapInProvider,
@@ -59,13 +45,13 @@
   BulkActionsModel,
 } from '../../../models/bulk-actions/bulk-actions-model';
 import {createTestAppContext} from '../../../test/test-app-context-init';
-import {tap} from '@polymer/iron-test-helpers/mock-interactions';
+import {ColumnNames} from '../../../constants/constants';
+import {testResolver} from '../../../test/common-test-setup';
 
 suite('gr-change-list-item tests', () => {
   const account = createAccountWithId();
   const change: ChangeInfo = {
     ...createChange(),
-    internalHost: 'host',
     project: 'a/test/repo' as RepoName,
     topic: 'test-topic' as TopicName,
     branch: 'test-branch' as BranchName,
@@ -75,8 +61,6 @@
   let bulkActionsModel: BulkActionsModel;
 
   setup(async () => {
-    stubRestApi('getLoggedIn').returns(Promise.resolve(false));
-
     bulkActionsModel = new BulkActionsModel(
       createTestAppContext().restApiService
     );
@@ -92,226 +76,23 @@
     await element.updateComplete;
   });
 
-  test('computeLabelCategory', () => {
-    element.change = {
-      ...change,
-      labels: {},
-    };
-    assert.equal(
-      element.computeLabelCategory('Verified'),
-      LabelCategory.NOT_APPLICABLE
-    );
-    element.change.labels = {Verified: {approved: account, value: 1}};
-    assert.equal(
-      element.computeLabelCategory('Verified'),
-      LabelCategory.APPROVED
-    );
-    element.change.labels = {Verified: {rejected: account, value: -1}};
-    assert.equal(
-      element.computeLabelCategory('Verified'),
-      LabelCategory.REJECTED
-    );
-    element.change.labels = {'Code-Review': {approved: account, value: 1}};
-    element.change.unresolved_comment_count = 1;
-    assert.equal(
-      element.computeLabelCategory('Code-Review'),
-      LabelCategory.UNRESOLVED_COMMENTS
-    );
-    element.change.labels = {'Code-Review': {value: 1}};
-    element.change.unresolved_comment_count = 0;
-    assert.equal(
-      element.computeLabelCategory('Code-Review'),
-      LabelCategory.POSITIVE
-    );
-    element.change.labels = {'Code-Review': {value: -1}};
-    assert.equal(
-      element.computeLabelCategory('Code-Review'),
-      LabelCategory.NEGATIVE
-    );
-    element.change.labels = {'Code-Review': {value: -1}};
-    assert.equal(
-      element.computeLabelCategory('Verified'),
-      LabelCategory.NOT_APPLICABLE
-    );
-  });
-
-  test('computeLabelClass', () => {
-    element.change = {
-      ...change,
-      labels: {},
-    };
-    assert.equal(
-      element.computeLabelClass('Verified'),
-      'cell label u-gray-background'
-    );
-    element.change.labels = {Verified: {approved: account, value: 1}};
-    assert.equal(element.computeLabelClass('Verified'), 'cell label u-green');
-    element.change.labels = {Verified: {rejected: account, value: -1}};
-    assert.equal(element.computeLabelClass('Verified'), 'cell label u-red');
-    element.change.labels = {'Code-Review': {value: 1}};
-    assert.equal(
-      element.computeLabelClass('Code-Review'),
-      'cell label u-green u-monospace'
-    );
-    element.change.labels = {'Code-Review': {value: -1}};
-    assert.equal(
-      element.computeLabelClass('Code-Review'),
-      'cell label u-monospace u-red'
-    );
-    element.change.labels = {'Code-Review': {value: -1}};
-    assert.equal(
-      element.computeLabelClass('Verified'),
-      'cell label u-gray-background'
-    );
-  });
-
-  test('computeLabelTitle', () => {
-    element.change = {
-      ...change,
-      labels: {},
-    };
-    assert.equal(element.computeLabelTitle('Verified'), 'Label not applicable');
-
-    element.change.labels = {Verified: {approved: {name: 'Diffy'}}};
-    assert.equal(element.computeLabelTitle('Verified'), 'Verified by Diffy');
-
-    element.change.labels = {Verified: {approved: {name: 'Diffy'}}};
-    assert.equal(
-      element.computeLabelTitle('Code-Review'),
-      'Label not applicable'
-    );
-
-    element.change.labels = {Verified: {rejected: {name: 'Diffy'}}};
-    assert.equal(element.computeLabelTitle('Verified'), 'Verified by Diffy');
-
-    element.change.labels = {
-      'Code-Review': {disliked: {name: 'Diffy'}, value: -1},
-    };
-    assert.equal(
-      element.computeLabelTitle('Code-Review'),
-      'Code-Review by Diffy'
-    );
-
-    element.change.labels = {
-      'Code-Review': {recommended: {name: 'Diffy'}, value: 1},
-    };
-    assert.equal(
-      element.computeLabelTitle('Code-Review'),
-      'Code-Review by Diffy'
-    );
-
-    element.change.labels = {
-      'Code-Review': {recommended: {name: 'Diffy'}, rejected: {name: 'Admin'}},
-    };
-    assert.equal(
-      element.computeLabelTitle('Code-Review'),
-      'Code-Review by Admin'
-    );
-
-    element.change.labels = {
-      'Code-Review': {approved: {name: 'Diffy'}, rejected: {name: 'Admin'}},
-    };
-    assert.equal(
-      element.computeLabelTitle('Code-Review'),
-      'Code-Review by Admin'
-    );
-
-    element.change.labels = {
-      'Code-Review': {
-        recommended: {name: 'Diffy'},
-        disliked: {name: 'Admin'},
-        value: -1,
-      },
-    };
-    assert.equal(
-      element.computeLabelTitle('Code-Review'),
-      'Code-Review by Admin'
-    );
-
-    element.change.labels = {
-      'Code-Review': {
-        approved: {name: 'Diffy'},
-        disliked: {name: 'Admin'},
-        value: -1,
-      },
-    };
-    assert.equal(
-      element.computeLabelTitle('Code-Review'),
-      'Code-Review by Diffy'
-    );
-
-    element.change.labels = {'Code-Review': {approved: account, value: 1}};
-    element.change.unresolved_comment_count = 1;
-    assert.equal(
-      element.computeLabelTitle('Code-Review'),
-      '1 unresolved comment'
-    );
-
-    element.change.labels = {
-      'Code-Review': {approved: {name: 'Diffy'}, value: 1},
-    };
-    element.change.unresolved_comment_count = 1;
-    assert.equal(
-      element.computeLabelTitle('Code-Review'),
-      '1 unresolved comment,\nCode-Review by Diffy'
-    );
-
-    element.change.labels = {'Code-Review': {approved: account, value: 1}};
-    element.change.unresolved_comment_count = 2;
-    assert.equal(
-      element.computeLabelTitle('Code-Review'),
-      '2 unresolved comments'
-    );
-  });
-
-  test('computeLabelIcon', () => {
-    element.change = {
-      ...change,
-      labels: {},
-    };
-    assert.equal(element.computeLabelIcon('missingLabel'), '');
-    element.change.labels = {Verified: {approved: account, value: 1}};
-    assert.equal(element.computeLabelIcon('Verified'), 'gr-icons:check');
-    element.change.labels = {'Code-Review': {approved: account, value: 1}};
-    element.change.unresolved_comment_count = 1;
-    assert.equal(element.computeLabelIcon('Code-Review'), 'gr-icons:comment');
-  });
-
-  test('computeLabelValue', () => {
-    element.change = {
-      ...change,
-      labels: {},
-    };
-    assert.equal(element.computeLabelValue('Verified'), '');
-    element.change.labels = {Verified: {approved: account, value: 1}};
-    assert.equal(element.computeLabelValue('Verified'), '✓');
-    element.change.labels = {Verified: {value: 1}};
-    assert.equal(element.computeLabelValue('Verified'), '+1');
-    element.change.labels = {Verified: {value: -1}};
-    assert.equal(element.computeLabelValue('Verified'), '-1');
-    element.change.labels = {Verified: {approved: account}};
-    assert.equal(element.computeLabelValue('Verified'), '✓');
-    element.change.labels = {Verified: {rejected: account}};
-    assert.equal(element.computeLabelValue('Verified'), '✕');
-  });
-
   test('no hidden columns', async () => {
     element.visibleChangeTableColumns = [
-      'Subject',
-      'Status',
-      'Owner',
-      'Reviewers',
-      'Comments',
-      'Repo',
-      'Branch',
-      'Updated',
-      'Size',
-      ' Status ',
+      ColumnNames.SUBJECT,
+      ColumnNames.STATUS,
+      ColumnNames.OWNER,
+      ColumnNames.REVIEWERS,
+      ColumnNames.COMMENTS,
+      ColumnNames.REPO,
+      ColumnNames.BRANCH,
+      ColumnNames.UPDATED,
+      ColumnNames.SIZE,
+      ColumnNames.STATUS2,
     ];
 
     await element.updateComplete;
 
-    for (const column of columnNames) {
+    for (const column of Object.values(ColumnNames)) {
       const elementClass = '.' + column.trim().toLowerCase();
       assert.isFalse(
         queryAndAssert(element, elementClass).hasAttribute('hidden')
@@ -320,25 +101,20 @@
   });
 
   suite('checkbox', () => {
-    test('selection checkbox is only shown if experiment is enabled', async () => {
-      assert.isNotOk(query(element, '.selection'));
-      stubFlags('isEnabled').returns(true);
-      element.requestUpdate();
-      await element.updateComplete;
-      assert.isOk(query(element, '.selection'));
-    });
-
     test('bulk actions checkboxes', async () => {
-      stubFlags('isEnabled').returns(true);
       element.change = {...createChange(), _number: 1 as NumericChangeId};
       bulkActionsModel.sync([element.change]);
+      element.userModel.setAccount({
+        ...createAccountWithEmail('abc@def.com'),
+        registered_on: '2015-03-12 18:32:08.000000000' as Timestamp,
+      });
       await element.updateComplete;
 
       const checkbox = queryAndAssert<HTMLInputElement>(
         element,
-        '.selection > input'
+        '.selection > .selectionLabel > input'
       );
-      tap(checkbox);
+      checkbox.click();
       let selectedChangeNums = await waitUntilObserved(
         bulkActionsModel.selectedChangeNums$,
         s => s.length === 1
@@ -346,7 +122,7 @@
 
       assert.deepEqual(selectedChangeNums, [1]);
 
-      tap(checkbox);
+      checkbox.click();
       selectedChangeNums = await waitUntilObserved(
         bulkActionsModel.selectedChangeNums$,
         s => s.length === 0
@@ -355,8 +131,33 @@
       assert.deepEqual(selectedChangeNums, []);
     });
 
+    test('checkbox click calls list selection callback', async () => {
+      const selectionCallback = sinon.stub();
+      element.triggerSelectionCallback = selectionCallback;
+      element.globalIndex = 5;
+      element.change = {...createChange(), _number: 1 as NumericChangeId};
+      bulkActionsModel.sync([element.change]);
+      element.userModel.setAccount({
+        ...createAccountWithEmail('abc@def.com'),
+        registered_on: '2015-03-12 18:32:08.000000000' as Timestamp,
+      });
+      await element.updateComplete;
+
+      const checkbox = queryAndAssert<HTMLInputElement>(
+        element,
+        '.selection > .selectionLabel > input'
+      );
+      checkbox.click();
+      await element.updateComplete;
+
+      assert.isTrue(selectionCallback.calledWith(5));
+    });
+
     test('checkbox state updates with model updates', async () => {
-      stubFlags('isEnabled').returns(true);
+      element.userModel.setAccount({
+        ...createAccountWithEmail('abc@def.com'),
+        registered_on: '2015-03-12 18:32:08.000000000' as Timestamp,
+      });
       element.requestUpdate();
       await element.updateComplete;
 
@@ -371,7 +172,7 @@
 
       const checkbox = queryAndAssert<HTMLInputElement>(
         element,
-        '.selection > input'
+        '.selection > .selectionLabel > input'
       );
       assert.isTrue(checkbox.checked);
 
@@ -388,20 +189,20 @@
 
   test('repo column hidden', async () => {
     element.visibleChangeTableColumns = [
-      'Subject',
-      'Status',
-      'Owner',
-      'Reviewers',
-      'Comments',
-      'Branch',
-      'Updated',
-      'Size',
-      ' Status ',
+      ColumnNames.SUBJECT,
+      ColumnNames.STATUS,
+      ColumnNames.OWNER,
+      ColumnNames.REVIEWERS,
+      ColumnNames.COMMENTS,
+      ColumnNames.BRANCH,
+      ColumnNames.UPDATED,
+      ColumnNames.SIZE,
+      ColumnNames.STATUS2,
     ];
 
     await element.updateComplete;
 
-    for (const column of columnNames) {
+    for (const column of Object.values(ColumnNames)) {
       const elementClass = '.' + column.trim().toLowerCase();
       if (column === 'Repo') {
         assert.isNotOk(query(element, elementClass));
@@ -537,81 +338,87 @@
     assert.equal(element.computeChangeSize(), 'XL');
   });
 
-  test('change params passed to gr-navigation', async () => {
-    const navStub = sinon.stub(GerritNav);
+  test('clicking item navigates to change', async () => {
+    const setUrlStub = sinon.stub(testResolver(navigationToken), 'setUrl');
+
     element.change = change;
     await element.updateComplete;
 
-    assert.deepEqual(navStub.getUrlForChange.lastCall.args, [change]);
-    assert.deepEqual(navStub.getUrlForProjectChanges.lastCall.args, [
-      change.project,
-      true,
-      change.internalHost,
-    ]);
-    assert.deepEqual(navStub.getUrlForBranch.lastCall.args, [
-      change.branch,
-      change.project,
-      undefined,
-      change.internalHost,
-    ]);
-    assert.deepEqual(navStub.getUrlForTopic.lastCall.args, [
-      change.topic,
-      change.internalHost,
-    ]);
-  });
+    element.click();
+    await element.updateComplete;
 
-  test('computeRepoDisplay', () => {
-    element.change = {...change};
-    assert.equal(element.computeRepoDisplay(), 'host/a/test/repo');
-    assert.equal(element.computeTruncatedRepoDisplay(), 'host/…/test/repo');
-    delete change.internalHost;
-    element.change = {...change};
-    assert.equal(element.computeRepoDisplay(), 'a/test/repo');
-    assert.equal(element.computeTruncatedRepoDisplay(), '…/test/repo');
+    assert.isTrue(setUrlStub.called);
+    assert.equal(setUrlStub.lastCall.firstArg, '/c/a/test/repo/+/42');
   });
 
   test('renders', async () => {
-    element.showStar = true;
+    element.userModel.setAccount({
+      ...createAccountWithEmail('abc@def.com'),
+      registered_on: '2015-03-12 18:32:08.000000000' as Timestamp,
+    });
     element.showNumber = true;
     element.account = createAccountWithId(1);
     element.config = createServerInfo();
     element.change = createChange();
+    element.checked = true;
     await element.updateComplete;
-    expect(element).shadowDom.to.equal(`
-      <gr-change-star></gr-change-star>
-      <a href="">42</a>
-      <a href="" title="Test subject">
-        <div class="container">
-          <div class="content"> Test subject </div>
-          <div class="spacer"> Test subject </div>
-          <span></span>
-        </div>
-      </a>
-      <span class="placeholder"> -- </span>
-      <gr-account-label
-        deselected=""
-        clickable=""
-        highlightattention=""
-      ></gr-account-label>
-      <div></div>
-      <span></span>
-      <a class="fullRepo" href=""> test-project </a>
-      <a class="truncatedRepo" href="" title="test-project"> test-project </a>
-      <a href=""> test-branch </a>
-      <gr-date-formatter withtooltip=""></gr-date-formatter>
-      <gr-date-formatter withtooltip=""></gr-date-formatter>
-      <gr-date-formatter forcerelative="" relativeoptionnoago="" withtooltip="">
-      </gr-date-formatter>
-      <gr-tooltip-content has-tooltip="" title="Size unknown">
+    assert.isTrue(element.hasAttribute('checked'));
+
+    // TODO: Check table elements. The shadowDom helper does not understand
+    // tables interacting with display: contents, even wrapping the element in a
+    // table, does not help.
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <label class="selectionLabel">
+          <input type="checkbox" />
+        </label>
+        <gr-change-star></gr-change-star>
+        <a href="/c/test-project/+/42">42</a>
+        <a href="/c/test-project/+/42" title="Test subject">
+          <div class="container">
+            <div class="content">Test subject</div>
+            <div class="spacer">Test subject</div>
+            <span></span>
+          </div>
+        </a>
         <span class="placeholder"> -- </span>
-      </gr-tooltip-content>
-      <gr-change-list-column-requirements-summary>
-      </gr-change-list-column-requirements-summary>
-    `);
+        <gr-account-label
+          deselected=""
+          clickable=""
+          highlightattention=""
+        ></gr-account-label>
+        <div></div>
+        <span></span>
+        <a class="fullRepo" href="/q/project:test-project+status:open">
+          test-project
+        </a>
+        <a
+          class="truncatedRepo"
+          href="/q/project:test-project+status:open"
+          title="test-project"
+        >
+          test-project
+        </a>
+        <a href="/q/project:test-project+branch:test-branch"> test-branch </a>
+        <gr-date-formatter withtooltip=""></gr-date-formatter>
+        <gr-date-formatter withtooltip=""></gr-date-formatter>
+        <gr-date-formatter
+          forcerelative=""
+          relativeoptionnoago=""
+          withtooltip=""
+        >
+        </gr-date-formatter>
+        <gr-tooltip-content has-tooltip="" title="Size unknown">
+          <span class="placeholder"> -- </span>
+        </gr-tooltip-content>
+        <gr-change-list-column-requirements-summary>
+        </gr-change-list-column-requirements-summary>
+      `
+    );
   });
 
   test('renders requirement with new submit requirements', async () => {
-    sinon.stub(getAppContext().flagsService, 'isEnabled').returns(true);
     const submitRequirement: SubmitRequirementResultInfo = {
       ...createSubmitRequirementResultInfo(),
       name: StandardLabels.CODE_REVIEW,
@@ -639,8 +446,10 @@
     ).element as GrChangeListItem;
 
     const requirement = queryAndAssert(element, '.requirement');
-    expect(requirement).dom.to
-      .equal(/* HTML */ ` <gr-change-list-column-requirement>
-    </gr-change-list-column-requirement>`);
+    assert.dom.equal(
+      requirement,
+      /* HTML */ ` <gr-change-list-column-requirement>
+      </gr-change-list-column-requirement>`
+    );
   });
 });
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow.ts
index 458fe9a..436e435 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow.ts
@@ -4,42 +4,58 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import {css, html, LitElement, nothing} from 'lit';
-import {customElement, query, state} from 'lit/decorators';
+import {customElement, query, state} from 'lit/decorators.js';
 import {ProgressStatus, ReviewerState} from '../../../constants/constants';
 import {bulkActionsModelToken} from '../../../models/bulk-actions/bulk-actions-model';
+import {configModelToken} from '../../../models/config/config-model';
 import {resolve} from '../../../models/dependency';
-import {AccountInfo, ChangeInfo, NumericChangeId} from '../../../types/common';
+import {
+  AccountDetailInfo,
+  AccountInfo,
+  ChangeInfo,
+  NumericChangeId,
+  ServerInfo,
+  SuggestedReviewerGroupInfo,
+} from '../../../types/common';
 import {subscribe} from '../../lit/subscription-controller';
 import '../../shared/gr-overlay/gr-overlay';
 import '../../shared/gr-dialog/gr-dialog';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-icon/gr-icon';
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
 import {getAppContext} from '../../../services/app-context';
 import {
   GrReviewerSuggestionsProvider,
   ReviewerSuggestionsProvider,
-  SUGGESTIONS_PROVIDERS_USERS_TYPES,
 } from '../../../scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider';
 import '../../shared/gr-account-list/gr-account-list';
 import {getOverallStatus} from '../../../utils/bulk-flow-util';
-
-const SUGGESTIONS_PROVIDERS_USERS_TYPES_BY_REVIEWER_STATE: Record<
-  ReviewerState,
-  SUGGESTIONS_PROVIDERS_USERS_TYPES
-> = {
-  REVIEWER: SUGGESTIONS_PROVIDERS_USERS_TYPES.REVIEWER,
-  CC: SUGGESTIONS_PROVIDERS_USERS_TYPES.CC,
-  REMOVED: SUGGESTIONS_PROVIDERS_USERS_TYPES.ANY,
-};
+import {allSettled} from '../../../utils/async-util';
+import {listForSentence, pluralize} from '../../../utils/string-util';
+import {getDisplayName} from '../../../utils/display-name-util';
+import {
+  AccountInput,
+  GrAccountList,
+} from '../../shared/gr-account-list/gr-account-list';
+import {getReplyByReason} from '../../../utils/attention-set-util';
+import {intersection, queryAndAssert} from '../../../utils/common-util';
+import {accountKey, getUserId} from '../../../utils/account-util';
+import {ValueChangedEvent} from '../../../types/events';
+import {fireAlert, fireReload} from '../../../utils/event-util';
+import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
+import {Interaction} from '../../../constants/reporting';
 
 @customElement('gr-change-list-reviewer-flow')
 export class GrChangeListReviewerFlow extends LitElement {
   @state() private selectedChanges: ChangeInfo[] = [];
 
   // contents are given to gr-account-lists to mutate
-  @state() private updatedAccountsByReviewerState: Map<
-    ReviewerState,
-    AccountInfo[]
-  > = new Map();
+  // private but used in tests
+  @state() updatedAccountsByReviewerState: Map<ReviewerState, AccountInput[]> =
+    new Map([
+      [ReviewerState.REVIEWER, []],
+      [ReviewerState.CC, []],
+    ]);
 
   @state() private suggestionsProviderByReviewerState: Map<
     ReviewerState,
@@ -53,42 +69,117 @@
 
   @state() private isOverlayOpen = false;
 
-  @query('gr-overlay') private overlay!: GrOverlay;
+  @state() private serverConfig?: ServerInfo;
+
+  @state()
+  private groupPendingConfirmationByReviewerState: Map<
+    ReviewerState,
+    SuggestedReviewerGroupInfo | null
+  > = new Map([
+    [ReviewerState.REVIEWER, null],
+    [ReviewerState.CC, null],
+  ]);
+
+  @query('gr-overlay#flow') private overlay?: GrOverlay;
+
+  @query('gr-account-list#reviewer-list') private reviewerList?: GrAccountList;
+
+  @query('gr-account-list#cc-list') private ccList?: GrAccountList;
+
+  @query('gr-overlay#confirm-reviewer')
+  private reviewerConfirmOverlay?: GrOverlay;
+
+  @query('gr-overlay#confirm-cc') private ccConfirmOverlay?: GrOverlay;
+
+  @query('gr-dialog') dialog?: GrDialog;
+
+  private readonly reportingService = getAppContext().reportingService;
 
   private getBulkActionsModel = resolve(this, bulkActionsModelToken);
 
+  private getConfigModel = resolve(this, configModelToken);
+
   private restApiService = getAppContext().restApiService;
 
+  private isLoggedIn = false;
+
+  private account?: AccountDetailInfo;
+
   static override get styles() {
-    return css`
-      gr-dialog {
-        width: 60em;
-      }
-      .grid {
-        display: grid;
-        grid-template-columns: min-content 1fr;
-        column-gap: var(--spacing-l);
-      }
-      gr-account-list {
-        display: flex;
-        flex-wrap: wrap;
-      }
-    `;
+    return [
+      css`
+        gr-dialog {
+          width: 60em;
+        }
+        .grid {
+          display: grid;
+          grid-template-columns: min-content 1fr;
+          column-gap: var(--spacing-l);
+        }
+        gr-account-list {
+          display: flex;
+          flex-wrap: wrap;
+        }
+        .warning,
+        .error {
+          display: flex;
+          align-items: center;
+          gap: var(--spacing-xl);
+          padding: var(--spacing-l);
+          padding-left: var(--spacing-xl);
+          background-color: var(--yellow-50);
+        }
+        .error {
+          background-color: var(--error-background);
+        }
+        .grid + .warning,
+        .error {
+          margin-top: var(--spacing-l);
+        }
+        .warning + .warning {
+          margin-top: var(--spacing-s);
+        }
+        gr-icon {
+          color: var(--orange-800);
+          font-size: 18px;
+        }
+        gr-overlay#confirm-cc,
+        gr-overlay#confirm-reviewer {
+          padding: var(--spacing-l);
+          text-align: center;
+        }
+        .confirmation-buttons {
+          margin-top: var(--spacing-l);
+        }
+      `,
+    ];
   }
 
-  override connectedCallback(): void {
-    super.connectedCallback();
+  constructor() {
+    super();
     subscribe(
       this,
-      this.getBulkActionsModel().selectedChanges$,
-      selectedChanges => {
-        this.selectedChanges = selectedChanges;
-      }
+      () => this.getBulkActionsModel().selectedChanges$,
+      selectedChanges => (this.selectedChanges = selectedChanges)
+    );
+    subscribe(
+      this,
+      () => this.getConfigModel().serverConfig$,
+      serverConfig => (this.serverConfig = serverConfig)
+    );
+    subscribe(
+      this,
+      () => getAppContext().userModel.loggedIn$,
+      isLoggedIn => (this.isLoggedIn = isLoggedIn)
+    );
+    subscribe(
+      this,
+      () => getAppContext().userModel.account$,
+      account => (this.account = account)
     );
   }
 
   override render() {
-    // TODO: factor out button+dialog component with promise-progress tracking
     return html`
       <gr-button
         id="start-flow"
@@ -97,7 +188,7 @@
         @click=${() => this.openOverlay()}
         >add reviewer/cc</gr-button
       >
-      <gr-overlay with-backdrop>
+      <gr-overlay id="flow" with-backdrop>
         ${this.isOverlayOpen ? this.renderDialog() : nothing}
       </gr-overlay>
     `;
@@ -109,19 +200,25 @@
       <gr-dialog
         @cancel=${() => this.closeOverlay()}
         @confirm=${() => this.onConfirm(overallStatus)}
-        .confirmLabel=${this.getConfirmLabel(overallStatus)}
+        .confirmLabel=${'Add'}
         .disabled=${overallStatus === ProgressStatus.RUNNING}
+        .loadingLabel=${'Adding Reviewer and CC in progress...'}
+        ?loading=${getOverallStatus(this.progressByChangeNum) ===
+        ProgressStatus.RUNNING}
       >
-        <div slot="header">Add Reviewer / CC</div>
-        <div slot="main" class="grid">
-          <span>Reviewers</span>
-          ${this.renderAccountList(
-            ReviewerState.REVIEWER,
-            'reviewer-list',
-            'Add reviewer'
-          )}
-          <span>CC</span>
-          ${this.renderAccountList(ReviewerState.CC, 'cc-list', 'Add CC')}
+        <div slot="header">Add reviewer / CC</div>
+        <div slot="main">
+          <div class="grid">
+            <span>Reviewers</span>
+            ${this.renderAccountList(
+              ReviewerState.REVIEWER,
+              'reviewer-list',
+              'Add reviewer'
+            )}
+            <span>CC</span>
+            ${this.renderAccountList(ReviewerState.CC, 'cc-list', 'Add CC')}
+          </div>
+          ${this.renderAnyOverwriteWarnings()} ${this.renderErrors()}
         </div>
       </gr-dialog>
     `;
@@ -146,20 +243,151 @@
         .removableValues=${[]}
         .suggestionsProvider=${suggestionsProvider}
         .placeholder=${placeholder}
+        .pendingConfirmation=${this.groupPendingConfirmationByReviewerState.get(
+          reviewerState
+        )}
+        @accounts-changed=${() => this.onAccountsChanged(reviewerState)}
+        @pending-confirmation-changed=${(
+          ev: ValueChangedEvent<SuggestedReviewerGroupInfo | null>
+        ) => this.onPendingConfirmationChanged(reviewerState, ev)}
       >
       </gr-account-list>
+      ${this.renderConfirmationDialog(reviewerState)}
     `;
   }
 
-  private openOverlay() {
+  private renderConfirmationDialog(reviewerState: ReviewerState) {
+    const id =
+      reviewerState === ReviewerState.CC ? 'confirm-cc' : 'confirm-reviewer';
+    const suggestion =
+      this.groupPendingConfirmationByReviewerState.get(reviewerState);
+    return html`
+      <gr-overlay
+        id=${id}
+        @iron-overlay-canceled=${() => this.cancelPendingGroup(reviewerState)}
+      >
+        <div class="confirmation-text">
+          Group
+          <span class="groupName"> ${suggestion?.group.name} </span>
+          has
+          <span class="groupSize"> ${suggestion?.count} </span>
+          members.
+          <br />
+          Are you sure you want to add them all?
+        </div>
+        <div class="confirmation-buttons">
+          <gr-button
+            @click=${() => this.confirmPendingGroup(reviewerState, suggestion)}
+            >Yes</gr-button
+          >
+          <gr-button @click=${() => this.cancelPendingGroup(reviewerState)}
+            >No</gr-button
+          >
+        </div>
+      </gr-overlay>
+    `;
+  }
+
+  private renderAnyOverwriteWarnings() {
+    return html`
+      ${this.renderAnyOverwriteWarning(ReviewerState.REVIEWER)}
+      ${this.renderAnyOverwriteWarning(ReviewerState.CC)}
+    `;
+  }
+
+  private renderErrors() {
+    if (getOverallStatus(this.progressByChangeNum) !== ProgressStatus.FAILED)
+      return nothing;
+    const failedAccounts = [
+      ...(this.updatedAccountsByReviewerState.get(ReviewerState.REVIEWER) ??
+        []),
+      ...(this.updatedAccountsByReviewerState.get(ReviewerState.CC) ?? []),
+    ].map(account => getDisplayName(this.serverConfig, account));
+    if (failedAccounts.length === 0) {
+      return nothing;
+    }
+    return html`
+      <div class="error">
+        <gr-icon icon="error" filled role="img" aria-label="Error"></gr-icon>
+        Failed to add ${listForSentence(failedAccounts)} to changes.
+      </div>
+    `;
+  }
+
+  private renderAnyOverwriteWarning(currentReviewerState: ReviewerState) {
+    const updatedReviewerState =
+      currentReviewerState === ReviewerState.CC
+        ? ReviewerState.REVIEWER
+        : ReviewerState.CC;
+    const overwrittenNames =
+      this.getOverwrittenDisplayNames(currentReviewerState);
+    if (overwrittenNames.length === 0) {
+      return nothing;
+    }
+    const pluralizedVerb = overwrittenNames.length === 1 ? 'is a' : 'are';
+    const currentLabel = `${
+      currentReviewerState === ReviewerState.CC ? 'CC' : 'reviewer'
+    }${overwrittenNames.length > 1 ? 's' : ''}`;
+    const updatedLabel =
+      updatedReviewerState === ReviewerState.CC ? 'CC' : 'reviewer';
+    return html`
+      <div class="warning">
+        <gr-icon
+          icon="warning"
+          filled
+          role="img"
+          aria-label="Warning"
+        ></gr-icon>
+        ${listForSentence(overwrittenNames)} ${pluralizedVerb} ${currentLabel}
+        on some selected changes and will be moved to ${updatedLabel} on all
+        changes.
+      </div>
+    `;
+  }
+
+  private getAccountsInCurrentState(currentReviewerState: ReviewerState) {
+    return this.selectedChanges
+      .flatMap(
+        change =>
+          change.reviewers[currentReviewerState]?.filter(isNotOwner(change)) ??
+          []
+      )
+      .filter(account => account?._account_id !== undefined);
+  }
+
+  private getOverwrittenDisplayNames(
+    currentReviewerState: ReviewerState
+  ): string[] {
+    const updatedReviewerState =
+      currentReviewerState === ReviewerState.CC
+        ? ReviewerState.REVIEWER
+        : ReviewerState.CC;
+    const accountsInCurrentState =
+      this.getAccountsInCurrentState(currentReviewerState);
+    return this.updatedAccountsByReviewerState
+      .get(updatedReviewerState)!
+      .filter(account =>
+        accountsInCurrentState.some(
+          otherAccount => getUserId(otherAccount) === getUserId(account)
+        )
+      )
+      .map(reviewer => getDisplayName(this.serverConfig, reviewer));
+  }
+
+  private async openOverlay() {
     this.resetFlow();
     this.isOverlayOpen = true;
-    this.overlay.open();
+    // Must await the overlay opening because the dialog is lazily rendered.
+    await this.overlay?.open();
+    this.overlay?.setFocusStops({
+      start: queryAndAssert(this.dialog, 'header'),
+      end: queryAndAssert(this.dialog, 'footer'),
+    });
   }
 
   private closeOverlay() {
     this.isOverlayOpen = false;
-    this.overlay.close();
+    this.overlay?.close();
   }
 
   private resetFlow() {
@@ -169,7 +397,7 @@
         ProgressStatus.NOT_STARTED,
       ])
     );
-    for (const state of [ReviewerState.REVIEWER, ReviewerState.CC]) {
+    for (const state of [ReviewerState.REVIEWER, ReviewerState.CC] as const) {
       this.updatedAccountsByReviewerState.set(
         state,
         this.getCurrentAccounts(state)
@@ -184,21 +412,119 @@
     this.requestUpdate();
   }
 
+  /*
+   * Removes accounts from one list when they are added to the other. Also
+   * trigger re-render so warnings will update as accounts are added, removed,
+   * and confirmed.
+   */
+  private onAccountsChanged(reviewerState: ReviewerState) {
+    const reviewerStateKeys = this.updatedAccountsByReviewerState
+      .get(reviewerState)!
+      .map(getUserId);
+    const oppositeReviewerState =
+      reviewerState === ReviewerState.CC
+        ? ReviewerState.REVIEWER
+        : ReviewerState.CC;
+    const oppositeUpdatedAccounts = this.updatedAccountsByReviewerState.get(
+      oppositeReviewerState
+    )!;
+
+    const notOverwrittenOppositeAccounts = oppositeUpdatedAccounts.filter(
+      acc => !reviewerStateKeys.includes(getUserId(acc))
+    );
+    if (
+      notOverwrittenOppositeAccounts.length !== oppositeUpdatedAccounts.length
+    ) {
+      this.updatedAccountsByReviewerState.set(
+        oppositeReviewerState,
+        notOverwrittenOppositeAccounts
+      );
+    }
+    this.requestUpdate();
+  }
+
+  private async onPendingConfirmationChanged(
+    reviewerState: ReviewerState,
+    ev: ValueChangedEvent<SuggestedReviewerGroupInfo | null>
+  ) {
+    this.groupPendingConfirmationByReviewerState.set(
+      reviewerState,
+      ev.detail.value
+    );
+    this.requestUpdate();
+    await this.updateComplete;
+
+    const overlay =
+      reviewerState === ReviewerState.CC
+        ? this.ccConfirmOverlay
+        : this.reviewerConfirmOverlay;
+    if (ev.detail.value === null) {
+      overlay?.close();
+    } else {
+      await overlay?.open();
+    }
+  }
+
+  private cancelPendingGroup(reviewerState: ReviewerState) {
+    const overlay =
+      reviewerState === ReviewerState.CC
+        ? this.ccConfirmOverlay
+        : this.reviewerConfirmOverlay;
+    overlay?.close();
+    this.groupPendingConfirmationByReviewerState.set(reviewerState, null);
+    this.requestUpdate();
+  }
+
+  private confirmPendingGroup(
+    reviewerState: ReviewerState,
+    suggestion: SuggestedReviewerGroupInfo | null | undefined
+  ) {
+    if (!suggestion) return;
+    const accountList =
+      reviewerState === ReviewerState.CC ? this.ccList : this.reviewerList;
+    accountList?.confirmGroup(suggestion.group);
+  }
+
   private onConfirm(overallStatus: ProgressStatus) {
     switch (overallStatus) {
       case ProgressStatus.NOT_STARTED:
         this.saveReviewers();
         break;
       case ProgressStatus.SUCCESSFUL:
-        this.overlay.close();
+        this.overlay?.close();
         break;
       case ProgressStatus.FAILED:
-        this.overlay.close();
+        this.overlay?.close();
         break;
     }
   }
 
-  private saveReviewers() {
+  private fireSuccessToasts() {
+    const numReviewersAdded =
+      (this.updatedAccountsByReviewerState.get(ReviewerState.REVIEWER)
+        ?.length ?? 0) - this.getCurrentAccounts(ReviewerState.REVIEWER).length;
+    const numCcsAdded =
+      (this.updatedAccountsByReviewerState.get(ReviewerState.CC)?.length ?? 0) -
+      this.getCurrentAccounts(ReviewerState.CC).length;
+    let alert = '';
+    if (numReviewersAdded && numCcsAdded) {
+      alert = `${pluralize(numReviewersAdded, 'reviewer')} and ${pluralize(
+        numCcsAdded,
+        'CC'
+      )} added`;
+    } else if (numReviewersAdded) {
+      alert = `${pluralize(numReviewersAdded, 'reviewer')} added`;
+    } else {
+      alert = `${pluralize(numCcsAdded, 'CC')} added`;
+    }
+    fireAlert(this, alert);
+  }
+
+  private async saveReviewers() {
+    this.reportingService.reportInteraction(Interaction.BULK_ACTION, {
+      type: 'add-reviewer',
+      selectedChangeCount: this.selectedChanges.length,
+    });
     this.progressByChangeNum = new Map(
       this.selectedChanges.map(change => [
         change._number,
@@ -206,22 +532,38 @@
       ])
     );
     const inFlightActions = this.getBulkActionsModel().addReviewers(
-      this.updatedAccountsByReviewerState
+      this.updatedAccountsByReviewerState,
+      getReplyByReason(this.account, this.serverConfig)
     );
-    for (let index = 0; index < this.selectedChanges.length; index++) {
-      const change = this.selectedChanges[index];
-      inFlightActions[index]
-        .then(() => {
-          this.progressByChangeNum.set(
-            change._number,
-            ProgressStatus.SUCCESSFUL
-          );
-          this.requestUpdate();
-        })
-        .catch(() => {
-          this.progressByChangeNum.set(change._number, ProgressStatus.FAILED);
-          this.requestUpdate();
-        });
+
+    await allSettled(
+      inFlightActions.map((promise, index) => {
+        const change = this.selectedChanges[index];
+        return promise
+          .then(() => {
+            this.progressByChangeNum.set(
+              change._number,
+              ProgressStatus.SUCCESSFUL
+            );
+            this.requestUpdate();
+          })
+          .catch(() => {
+            this.progressByChangeNum.set(change._number, ProgressStatus.FAILED);
+            this.requestUpdate();
+          });
+      })
+    );
+    if (getOverallStatus(this.progressByChangeNum) === ProgressStatus.FAILED) {
+      this.reportingService.reportInteraction('bulk-action-failure', {
+        type: 'add-reviewer',
+        count: Array.from(this.progressByChangeNum.values()).filter(
+          status => status === ProgressStatus.FAILED
+        ).length,
+      });
+    } else {
+      this.fireSuccessToasts();
+      this.closeOverlay();
+      fireReload(this);
     }
   }
 
@@ -231,41 +573,37 @@
     return this.selectedChanges.length === 0;
   }
 
-  private getConfirmLabel(overallStatus: ProgressStatus) {
-    return overallStatus === ProgressStatus.NOT_STARTED
-      ? 'Add'
-      : overallStatus === ProgressStatus.RUNNING
-      ? 'Running'
-      : 'Close';
-  }
-
-  private getCurrentAccounts(reviewerState: ReviewerState) {
+  // private but used in tests
+  getCurrentAccounts(reviewerState: ReviewerState) {
     const reviewersPerChange = this.selectedChanges.map(
-      change => change.reviewers[reviewerState] ?? []
+      change =>
+        change.reviewers[reviewerState]?.filter(isNotOwner(change)) ?? []
     );
-    if (reviewersPerChange.length === 0) {
-      return [];
-    }
-    // Gets reviewers present in all changes
-    return reviewersPerChange.reduce((a, b) =>
-      a.filter(reviewer => b.includes(reviewer))
+    return intersection(
+      reviewersPerChange,
+      (account1, account2) => accountKey(account1) === accountKey(account2)
     );
   }
 
   private createSuggestionsProvider(
-    state: ReviewerState
+    state: ReviewerState.CC | ReviewerState.REVIEWER
   ): ReviewerSuggestionsProvider {
-    const suggestionsProvider = GrReviewerSuggestionsProvider.create(
+    const suggestionsProvider = new GrReviewerSuggestionsProvider(
       this.restApiService,
-      // TODO: fan out and get suggestions allowed by all changes
-      this.selectedChanges[0]._number,
-      SUGGESTIONS_PROVIDERS_USERS_TYPES_BY_REVIEWER_STATE[state]
+      state,
+      this.serverConfig,
+      this.isLoggedIn,
+      ...this.selectedChanges
     );
-    suggestionsProvider.init();
     return suggestionsProvider;
   }
 }
 
+function isNotOwner(change: ChangeInfo) {
+  return (account: AccountInfo) =>
+    accountKey(change.owner) !== accountKey(account);
+}
+
 declare global {
   interface HTMLElementTagNameMap {
     'gr-change-list-reviewer-flow': GrChangeListReviewerFlow;
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow_test.ts
index afc7b4b..a96085c 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow_test.ts
@@ -3,27 +3,40 @@
  * Copyright 2022 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-import {fixture, html} from '@open-wc/testing-helpers';
-import {AccountInfo, ReviewerState} from '../../../api/rest-api';
+import {fixture, html, assert} from '@open-wc/testing';
+import {SinonStubbedMember} from 'sinon';
+import {
+  AccountInfo,
+  GroupId,
+  GroupInfo,
+  GroupName,
+  ReviewerState,
+} from '../../../api/rest-api';
 import {
   BulkActionsModel,
   bulkActionsModelToken,
 } from '../../../models/bulk-actions/bulk-actions-model';
 import {wrapInProvider} from '../../../models/di-provider-element';
 import {getAppContext} from '../../../services/app-context';
-import '../../../test/common-test-setup-karma';
+import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
+import '../../../test/common-test-setup';
 import {
   createAccountWithIdNameAndEmail,
   createChange,
+  createGroupInfo,
 } from '../../../test/test-data-generators';
 import {
   MockPromise,
   mockPromise,
   queryAndAssert,
+  stubReporting,
   stubRestApi,
+  waitUntil,
   waitUntilObserved,
 } from '../../../test/test-utils';
 import {ChangeInfo, NumericChangeId} from '../../../types/common';
+import {ValueChangedEvent} from '../../../types/events';
+import {query} from '../../../utils/common-util';
 import {GrAccountList} from '../../shared/gr-account-list/gr-account-list';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
@@ -38,14 +51,19 @@
   createAccountWithIdNameAndEmail(3),
   createAccountWithIdNameAndEmail(4),
   createAccountWithIdNameAndEmail(5),
+  createAccountWithIdNameAndEmail(6),
+];
+const groups: GroupInfo[] = [
+  {...createGroupInfo('groupId'), name: 'Group 0' as GroupName},
 ];
 const changes: ChangeInfo[] = [
   {
     ...createChange(),
     _number: 1 as NumericChangeId,
     subject: 'Subject 1',
+    owner: accounts[6],
     reviewers: {
-      REVIEWER: [accounts[0], accounts[1]],
+      REVIEWER: [accounts[0], accounts[1], accounts[6]],
       CC: [accounts[3], accounts[4]],
     },
   },
@@ -53,13 +71,15 @@
     ...createChange(),
     _number: 2 as NumericChangeId,
     subject: 'Subject 2',
-    reviewers: {REVIEWER: [accounts[0]], CC: [accounts[3]]},
+    owner: accounts[6],
+    reviewers: {REVIEWER: [accounts[0], accounts[6]], CC: [accounts[3]]},
   },
 ];
 
 suite('gr-change-list-reviewer-flow tests', () => {
   let element: GrChangeListReviewerFlow;
   let model: BulkActionsModel;
+  let reportingStub: SinonStubbedMember<ReportingService['reportInteraction']>;
 
   async function selectChange(change: ChangeInfo) {
     model.addSelectedChangeNum(change._number);
@@ -71,6 +91,7 @@
 
   setup(async () => {
     stubRestApi('getDetailedChangesWithActions').resolves(changes);
+    reportingStub = stubReporting('reportInteraction');
     model = new BulkActionsModel(getAppContext().restApiService);
     model.sync(changes);
 
@@ -90,22 +111,26 @@
   });
 
   test('skips dialog render when closed', async () => {
-    expect(element).shadowDom.to.equal(/* HTML */ `
-      <gr-button
-        id="start-flow"
-        flatten=""
-        aria-disabled="false"
-        role="button"
-        tabindex="0"
-        >add reviewer/cc</gr-button
-      >
-      <gr-overlay
-        aria-hidden="true"
-        with-backdrop=""
-        tabindex="-1"
-        style="outline: none; display: none;"
-      ></gr-overlay>
-    `);
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <gr-button
+          id="start-flow"
+          flatten=""
+          aria-disabled="false"
+          role="button"
+          tabindex="0"
+          >add reviewer/cc</gr-button
+        >
+        <gr-overlay
+          id="flow"
+          aria-hidden="true"
+          with-backdrop=""
+          tabindex="-1"
+          style="outline: none; display: none;"
+        ></gr-overlay>
+      `
+    );
   });
 
   test('flow button enabled when changes selected', async () => {
@@ -166,31 +191,99 @@
     });
 
     test('renders dialog when opened', async () => {
-      expect(element).shadowDom.to.equal(/* HTML */ `
-        <gr-button
-          id="start-flow"
-          flatten=""
-          aria-disabled="false"
-          role="button"
-          tabindex="0"
-          >add reviewer/cc</gr-button
-        >
-        <gr-overlay
-          with-backdrop=""
-          tabindex="-1"
-          style="outline: none; display: none;"
-        >
-          <gr-dialog role="dialog">
-            <div slot="header">Add Reviewer / CC</div>
-            <div slot="main" class="grid">
-              <span>Reviewers</span>
-              <gr-account-list id="reviewer-list"></gr-account-list>
-              <span>CC</span>
-              <gr-account-list id="cc-list"></gr-account-list>
-            </div>
-          </gr-dialog>
-        </gr-overlay>
-      `);
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <gr-button
+            id="start-flow"
+            flatten=""
+            aria-disabled="false"
+            role="button"
+            tabindex="0"
+            >add reviewer/cc</gr-button
+          >
+          <gr-overlay
+            id="flow"
+            with-backdrop=""
+            tabindex="-1"
+            style="outline: none; display: none;"
+          >
+            <gr-dialog role="dialog">
+              <div slot="header">Add reviewer / CC</div>
+              <div slot="main">
+                <div class="grid">
+                  <span>Reviewers</span>
+                  <gr-account-list id="reviewer-list"></gr-account-list>
+                  <gr-overlay
+                    aria-hidden="true"
+                    id="confirm-reviewer"
+                    style="outline: none; display: none;"
+                  >
+                    <div class="confirmation-text">
+                      Group
+                      <span class="groupName"></span>
+                      has
+                      <span class="groupSize"></span>
+                      members.
+                      <br />
+                      Are you sure you want to add them all?
+                    </div>
+                    <div class="confirmation-buttons">
+                      <gr-button
+                        aria-disabled="false"
+                        role="button"
+                        tabindex="0"
+                      >
+                        Yes
+                      </gr-button>
+                      <gr-button
+                        aria-disabled="false"
+                        role="button"
+                        tabindex="0"
+                      >
+                        No
+                      </gr-button>
+                    </div>
+                  </gr-overlay>
+                  <span>CC</span>
+                  <gr-account-list id="cc-list"></gr-account-list>
+                  <gr-overlay
+                    aria-hidden="true"
+                    id="confirm-cc"
+                    style="outline: none; display: none;"
+                  >
+                    <div class="confirmation-text">
+                      Group
+                      <span class="groupName"></span>
+                      has
+                      <span class="groupSize"></span>
+                      members.
+                      <br />
+                      Are you sure you want to add them all?
+                    </div>
+                    <div class="confirmation-buttons">
+                      <gr-button
+                        aria-disabled="false"
+                        role="button"
+                        tabindex="0"
+                      >
+                        Yes
+                      </gr-button>
+                      <gr-button
+                        aria-disabled="false"
+                        role="button"
+                        tabindex="0"
+                      >
+                        No
+                      </gr-button>
+                    </div>
+                  </gr-overlay>
+                </div>
+              </div>
+            </gr-dialog>
+          </gr-overlay>
+        `
+      );
     });
 
     test('only lists reviewers/CCs shared by all changes', async () => {
@@ -202,7 +295,8 @@
         dialog,
         'gr-account-list#cc-list'
       );
-      // does not include account 1
+      // does not include account 1 because it is not shared, does not include
+      // account 6 because it is the owner
       assert.sameMembers(reviewerList.accounts, [accounts[0]]);
       // does not include account 4
       assert.sameMembers(ccList.accounts, [accounts[3]]);
@@ -217,12 +311,26 @@
         dialog,
         'gr-account-list#cc-list'
       );
-      reviewerList.accounts.push(accounts[2]);
+      reviewerList.accounts.push(accounts[2], groups[0]);
       ccList.accounts.push(accounts[5]);
-      await flush();
+
+      assert.isFalse(dialog.loading);
+
+      await element.updateComplete;
       dialog.confirmButton!.click();
       await element.updateComplete;
 
+      assert.isTrue(dialog.loading);
+      assert.equal(
+        dialog.loadingLabel,
+        'Adding Reviewer and CC in progress...'
+      );
+
+      assert.deepEqual(reportingStub.lastCall.args[1], {
+        type: 'add-reviewer',
+        selectedChangeCount: 2,
+      });
+
       assert.isTrue(saveChangeReviewStub.calledTwice);
       assert.sameDeepOrderedMembers(saveChangeReviewStub.firstCall.args, [
         changes[0]._number,
@@ -230,8 +338,21 @@
         {
           reviewers: [
             {reviewer: accounts[2]._account_id, state: ReviewerState.REVIEWER},
+            {reviewer: groups[0].id, state: ReviewerState.REVIEWER},
             {reviewer: accounts[5]._account_id, state: ReviewerState.CC},
           ],
+          ignore_automatic_attention_set_rules: true,
+          // only the reviewer is added to the attention set, not the cc
+          add_to_attention_set: [
+            {
+              reason: '<GERRIT_ACCOUNT_1> replied on the change',
+              user: accounts[2]._account_id,
+            },
+            {
+              reason: '<GERRIT_ACCOUNT_1> replied on the change',
+              user: groups[0].id,
+            },
+          ],
         },
       ]);
       assert.sameDeepOrderedMembers(saveChangeReviewStub.secondCall.args, [
@@ -240,24 +361,644 @@
         {
           reviewers: [
             {reviewer: accounts[2]._account_id, state: ReviewerState.REVIEWER},
+            {reviewer: groups[0].id, state: ReviewerState.REVIEWER},
             {reviewer: accounts[5]._account_id, state: ReviewerState.CC},
           ],
+          ignore_automatic_attention_set_rules: true,
+          // only the reviewer is added to the attention set, not the cc
+          add_to_attention_set: [
+            {
+              reason: '<GERRIT_ACCOUNT_1> replied on the change',
+              user: accounts[2]._account_id,
+            },
+            {
+              reason: '<GERRIT_ACCOUNT_1> replied on the change',
+              user: groups[0].id,
+            },
+          ],
         },
       ]);
     });
 
-    test('confirm button text updates', async () => {
-      assert.equal(dialog.confirmLabel, 'Add');
+    test('removes from reviewer list when added to cc', async () => {
+      const ccList = queryAndAssert<GrAccountList>(
+        dialog,
+        'gr-account-list#cc-list'
+      );
+      const reviewerList = queryAndAssert<GrAccountList>(
+        dialog,
+        'gr-account-list#reviewer-list'
+      );
+      assert.sameOrderedMembers(reviewerList.accounts, [accounts[0]]);
 
-      dialog.confirmButton!.click();
+      ccList.handleAdd(
+        new CustomEvent('add', {
+          detail: {
+            value: {
+              account: accounts[0],
+              count: 1,
+            },
+          },
+        }) as unknown as ValueChangedEvent<string>
+      );
       await element.updateComplete;
 
-      assert.equal(dialog.confirmLabel, 'Running');
+      assert.isEmpty(reviewerList.accounts);
+    });
+
+    test('removes from cc list when added to reviewer', async () => {
+      const ccList = queryAndAssert<GrAccountList>(
+        dialog,
+        'gr-account-list#cc-list'
+      );
+      const reviewerList = queryAndAssert<GrAccountList>(
+        dialog,
+        'gr-account-list#reviewer-list'
+      );
+      assert.sameOrderedMembers(ccList.accounts, [accounts[3]]);
+
+      reviewerList.handleAdd(
+        new CustomEvent('add', {
+          detail: {
+            value: {
+              account: accounts[3],
+              count: 1,
+            },
+          },
+        }) as unknown as ValueChangedEvent<string>
+      );
+      await element.updateComplete;
+
+      assert.isEmpty(ccList.accounts);
+    });
+
+    suite('success toasts', () => {
+      test('reviewer only', async () => {
+        const dispatchEventStub = sinon.stub(element, 'dispatchEvent');
+        const existingReviewers = element.getCurrentAccounts(
+          ReviewerState.REVIEWER
+        );
+        const getCurrentAccountsStub = sinon.stub(
+          element,
+          'getCurrentAccounts'
+        );
+        getCurrentAccountsStub.withArgs(ReviewerState.CC).returns([]);
+        getCurrentAccountsStub
+          .withArgs(ReviewerState.REVIEWER)
+          .returns(existingReviewers);
+        const reviewerList = queryAndAssert<GrAccountList>(
+          dialog,
+          'gr-account-list#reviewer-list'
+        );
+        reviewerList.accounts.push(accounts[2], groups[0]);
+        element.updatedAccountsByReviewerState.set(ReviewerState.CC, []);
+        await element.updateComplete;
+        dialog.confirmButton!.click();
+
+        await resolvePromises();
+        await element.updateComplete;
+
+        await waitUntil(
+          () => dispatchEventStub.callCount > 0,
+          'dispatchEventStub never called'
+        );
+
+        assert.equal(
+          (dispatchEventStub.firstCall.args[0] as CustomEvent).detail.message,
+          '2 reviewers added'
+        );
+      });
+
+      test('ccs only', async () => {
+        const dispatchEventStub = sinon.stub(element, 'dispatchEvent');
+        const existingCCs = element.getCurrentAccounts(ReviewerState.CC);
+        const getCurrentAccountsStub = sinon.stub(
+          element,
+          'getCurrentAccounts'
+        );
+        getCurrentAccountsStub.withArgs(ReviewerState.CC).returns(existingCCs);
+        getCurrentAccountsStub.withArgs(ReviewerState.REVIEWER).returns([]);
+        const ccsList = queryAndAssert<GrAccountList>(
+          dialog,
+          'gr-account-list#cc-list'
+        );
+        ccsList.accounts.push(accounts[2], groups[0]);
+        ccsList.accounts = [];
+        element.updatedAccountsByReviewerState.set(ReviewerState.REVIEWER, []);
+        await element.updateComplete;
+        dialog.confirmButton!.click();
+
+        await resolvePromises();
+        await element.updateComplete;
+
+        await waitUntil(
+          () => dispatchEventStub.callCount > 0,
+          'dispatchEventStub never called'
+        );
+
+        assert.equal(
+          (dispatchEventStub.firstCall.args[0] as CustomEvent).detail.message,
+          '2 CCs added'
+        );
+      });
+
+      test('reviewers and CC', async () => {
+        const dispatchEventStub = sinon.stub(element, 'dispatchEvent');
+        const existingReviewers = element.getCurrentAccounts(
+          ReviewerState.REVIEWER
+        );
+        const existingCCs = element.getCurrentAccounts(ReviewerState.CC);
+        const getCurrentAccountsStub = sinon.stub(
+          element,
+          'getCurrentAccounts'
+        );
+        getCurrentAccountsStub.withArgs(ReviewerState.CC).returns(existingCCs);
+        getCurrentAccountsStub
+          .withArgs(ReviewerState.REVIEWER)
+          .returns(existingReviewers);
+
+        const reviewerList = queryAndAssert<GrAccountList>(
+          dialog,
+          'gr-account-list#reviewer-list'
+        );
+        const ccsList = queryAndAssert<GrAccountList>(
+          dialog,
+          'gr-account-list#cc-list'
+        );
+
+        reviewerList.accounts.push(accounts[2], groups[0]);
+        ccsList.accounts.push(accounts[2], groups[0]);
+        await element.updateComplete;
+        dialog.confirmButton!.click();
+
+        await resolvePromises();
+        await element.updateComplete;
+
+        await waitUntil(
+          () => dispatchEventStub.callCount > 0,
+          'dispatchEventStub never called'
+        );
+
+        assert.equal(
+          (dispatchEventStub.firstCall.args[0] as CustomEvent).detail.message,
+          '2 reviewers and 2 CCs added'
+        );
+      });
+    });
+
+    test('reloads page on success', async () => {
+      const dispatchEventStub = sinon.stub(element, 'dispatchEvent');
+      const reviewerList = queryAndAssert<GrAccountList>(
+        dialog,
+        'gr-account-list#reviewer-list'
+      );
+
+      reviewerList.accounts.push(accounts[2], groups[0]);
+      await element.updateComplete;
+      dialog.confirmButton!.click();
+      await element.updateComplete;
 
       await resolvePromises();
       await element.updateComplete;
 
-      assert.equal(dialog.confirmLabel, 'Close');
+      await waitUntil(
+        () => dispatchEventStub.callCount > 0,
+        'dispatchEventStub never called'
+      );
+
+      assert.isTrue(dispatchEventStub.calledTwice);
+      assert.equal(dispatchEventStub.secondCall.args[0].type, 'reload');
+    });
+
+    test('does not reload page on failure', async () => {
+      const dispatchEventStub = sinon.stub(element, 'dispatchEvent');
+      const reviewerList = queryAndAssert<GrAccountList>(
+        dialog,
+        'gr-account-list#reviewer-list'
+      );
+
+      reviewerList.accounts.push(accounts[2], groups[0]);
+      await element.updateComplete;
+      dialog.confirmButton!.click();
+      await element.updateComplete;
+      saveChangesPromises[0].reject(new Error('failed!'));
+      saveChangesPromises[1].reject(new Error('failed!'));
+      await element.updateComplete;
+
+      await waitUntil(
+        () => reportingStub.calledWith('bulk-action-failure'),
+        'reporting stub never called'
+      );
+
+      assert.deepEqual(reportingStub.lastCall.args, [
+        'bulk-action-failure',
+        {
+          type: 'add-reviewer',
+          count: 2,
+        },
+      ]);
+      assert.isTrue(dispatchEventStub.notCalled);
+    });
+
+    test('renders warnings when reviewer/cc are overwritten', async () => {
+      const ccList = queryAndAssert<GrAccountList>(
+        dialog,
+        'gr-account-list#cc-list'
+      );
+      const reviewerList = queryAndAssert<GrAccountList>(
+        dialog,
+        'gr-account-list#reviewer-list'
+      );
+
+      reviewerList.handleAdd(
+        new CustomEvent('add', {
+          detail: {
+            value: {
+              account: accounts[4],
+              count: 1,
+            },
+          },
+        }) as unknown as ValueChangedEvent<string>
+      );
+      ccList.handleAdd(
+        new CustomEvent('add', {
+          detail: {
+            value: {
+              account: accounts[1],
+              count: 1,
+            },
+          },
+        }) as unknown as ValueChangedEvent<string>
+      );
+      await element.updateComplete;
+
+      // prettier and shadowDom string don't agree on the long text in divs
+      assert.shadowDom.equal(
+        element,
+        /* prettier-ignore */
+        /* HTML */ `
+          <gr-button
+            id="start-flow"
+            flatten=""
+            aria-disabled="false"
+            role="button"
+            tabindex="0"
+            >add reviewer/cc</gr-button
+          >
+          <gr-overlay id="flow" with-backdrop="" tabindex="-1">
+            <gr-dialog role="dialog">
+              <div slot="header">Add reviewer / CC</div>
+              <div slot="main">
+                <div class="grid">
+                  <span>Reviewers</span>
+                  <gr-account-list id="reviewer-list"></gr-account-list>
+                  <gr-overlay aria-hidden="true" id="confirm-reviewer">
+                    <div class="confirmation-text">
+                      Group
+                      <span class="groupName"></span>
+                      has
+                      <span class="groupSize"></span>
+                      members.
+                      <br>
+                      Are you sure you want to add them all?
+                    </div>
+                    <div class="confirmation-buttons">
+                      <gr-button
+                        aria-disabled="false"
+                        role="button"
+                        tabindex="0">
+                        Yes
+                      </gr-button>
+                      <gr-button
+                        aria-disabled="false"
+                        role="button"
+                        tabindex="0">
+                        No
+                      </gr-button>
+                    </div>
+                  </gr-overlay>
+                  <span>CC</span>
+                  <gr-account-list id="cc-list"></gr-account-list>
+                  <gr-overlay aria-hidden="true" id="confirm-cc">
+                    <div class="confirmation-text">
+                      Group
+                      <span class="groupName"></span>
+                      has
+                      <span class="groupSize"></span>
+                      members.
+                      <br>
+                      Are you sure you want to add them all?
+                    </div>
+                    <div class="confirmation-buttons">
+                      <gr-button
+                        aria-disabled="false"
+                        role="button"
+                        tabindex="0">
+                        Yes
+                      </gr-button>
+                      <gr-button
+                        aria-disabled="false"
+                        role="button"
+                        tabindex="0">
+                        No
+                      </gr-button>
+                    </div>
+                  </gr-overlay>
+                </div>
+                <div class="warning">
+                  <gr-icon icon="warning" filled role="img" aria-label="Warning"
+                  ></gr-icon>
+                  User-1 is a reviewer
+        on some selected changes and will be moved to CC on all
+        changes.
+                </div>
+                <div class="warning">
+                  <gr-icon icon="warning" filled role="img" aria-label="Warning"
+                  ></gr-icon>
+                  User-4 is a CC
+        on some selected changes and will be moved to reviewer on all
+        changes.
+                </div>
+              </div>
+            </gr-dialog>
+          </gr-overlay>
+        `,
+        {
+          // gr-overlay sizing seems to vary between local & CI
+          ignoreAttributes: [{tags: ['gr-overlay'], attributes: ['style']}],
+        }
+      );
+    });
+
+    test('renders errors when requests fail', async () => {
+      const reviewerList = queryAndAssert<GrAccountList>(
+        dialog,
+        'gr-account-list#reviewer-list'
+      );
+
+      reviewerList.accounts.push(accounts[2], groups[0]);
+      await element.updateComplete;
+      dialog.confirmButton!.click();
+      await element.updateComplete;
+      saveChangesPromises[0].reject(new Error('failed!'));
+      saveChangesPromises[1].reject(new Error('failed!'));
+
+      await waitUntil(() => !!query(dialog, '.error'));
+
+      // prettier and shadowDom string don't agree on the long text in divs
+      assert.shadowDom.equal(
+        element,
+        /* prettier-ignore */
+        /* HTML */ `
+          <gr-button
+            aria-disabled="false"
+            flatten=""
+            id="start-flow"
+            role="button"
+            tabindex="0"
+          >
+            add reviewer/cc
+          </gr-button>
+          <gr-overlay
+            id="flow"
+            tabindex="-1"
+            with-backdrop=""
+          >
+            <gr-dialog role="dialog">
+              <div slot="header">Add reviewer / CC</div>
+              <div slot="main">
+                <div class="grid">
+                  <span> Reviewers </span>
+                  <gr-account-list id="reviewer-list"> </gr-account-list>
+                  <gr-overlay aria-hidden="true" id="confirm-reviewer">
+                    <div class="confirmation-text">
+                      Group
+                      <span class="groupName"> </span>
+                      has
+                      <span class="groupSize"> </span>
+                      members.
+                      <br />
+                      Are you sure you want to add them all?
+                    </div>
+                    <div class="confirmation-buttons">
+                      <gr-button
+                        aria-disabled="false"
+                        role="button"
+                        tabindex="0"
+                      >
+                        Yes
+                      </gr-button>
+                      <gr-button
+                        aria-disabled="false"
+                        role="button"
+                        tabindex="0"
+                      >
+                        No
+                      </gr-button>
+                    </div>
+                  </gr-overlay>
+                  <span> CC </span>
+                  <gr-account-list id="cc-list"> </gr-account-list>
+                  <gr-overlay aria-hidden="true" id="confirm-cc">
+                    <div class="confirmation-text">
+                      Group
+                      <span class="groupName"> </span>
+                      has
+                      <span class="groupSize"> </span>
+                      members.
+                      <br />
+                      Are you sure you want to add them all?
+                    </div>
+                    <div class="confirmation-buttons">
+                      <gr-button
+                        aria-disabled="false"
+                        role="button"
+                        tabindex="0"
+                      >
+                        Yes
+                      </gr-button>
+                      <gr-button
+                        aria-disabled="false"
+                        role="button"
+                        tabindex="0"
+                      >
+                        No
+                      </gr-button>
+                    </div>
+                  </gr-overlay>
+                </div>
+                <div class="error">
+                  <gr-icon icon="error" filled role="img" aria-label="Error"></gr-icon>
+                  Failed to add User-0, User-2, Group 0, and User-3 to changes.
+                </div>
+              </div>
+            </gr-dialog>
+          </gr-overlay>
+        `,
+        {
+          // gr-overlay sizing seems to vary between local & CI
+          ignoreAttributes: [{tags: ['gr-overlay'], attributes: ['style']}],
+        }
+      );
+    });
+
+    test('shows confirmation dialog when large group is added', async () => {
+      const reviewerList = queryAndAssert<GrAccountList>(
+        dialog,
+        'gr-account-list#reviewer-list'
+      );
+      reviewerList.handleAdd(
+        new CustomEvent('add', {
+          detail: {
+            value: {
+              group: {
+                id: '5',
+                name: 'large-group',
+              },
+              count: 12,
+              confirm: true,
+            },
+          },
+        }) as unknown as ValueChangedEvent<string>
+      );
+      // reviewerList needs to set the pendingConfirmation property which
+      // triggers an update of ReviewerFlow
+      await reviewerList.updateComplete;
+      await element.updateComplete;
+
+      const confirmDialog = queryAndAssert(
+        element,
+        'gr-overlay#confirm-reviewer'
+      );
+      await waitUntil(
+        () =>
+          getComputedStyle(confirmDialog).getPropertyValue('display') !== 'none'
+      );
+    });
+
+    test('"yes" button confirms large group', async () => {
+      const reviewerList = queryAndAssert<GrAccountList>(
+        dialog,
+        'gr-account-list#reviewer-list'
+      );
+      reviewerList.handleAdd(
+        new CustomEvent('add', {
+          detail: {
+            value: {
+              group: {
+                id: '5',
+                name: 'large-group',
+              },
+              count: 12,
+              confirm: true,
+            },
+          },
+        }) as unknown as ValueChangedEvent<string>
+      );
+      // reviewerList needs to set the pendingConfirmation property which
+      // triggers an update of ReviewerFlow
+      await reviewerList.updateComplete;
+      await element.updateComplete;
+      // "Yes" button is first
+      queryAndAssert<GrButton>(
+        element,
+        '.confirmation-buttons > gr-button:first-of-type'
+      ).click();
+      await element.updateComplete;
+
+      const confirmDialog = queryAndAssert(
+        element,
+        'gr-overlay#confirm-reviewer'
+      );
+      assert.isTrue(
+        getComputedStyle(confirmDialog).getPropertyValue('display') === 'none'
+      );
+
+      assert.deepEqual(reviewerList.accounts[1], {
+        confirmed: true,
+        id: '5' as GroupId,
+        name: 'large-group',
+      });
+    });
+
+    test('confirmation dialog skipped for small group', async () => {
+      const reviewerList = queryAndAssert<GrAccountList>(
+        dialog,
+        'gr-account-list#reviewer-list'
+      );
+      // "confirm" field is used to decide whether to use the confirmation flow,
+      // not the count. "confirm" value comes from server based on count
+      // threshold
+      reviewerList.handleAdd(
+        new CustomEvent('add', {
+          detail: {
+            value: {
+              group: {
+                id: '5',
+                name: 'small-group',
+              },
+              count: 2,
+              confirm: false,
+            },
+          },
+        }) as unknown as ValueChangedEvent<string>
+      );
+      // reviewerList needs to set the pendingConfirmation property which
+      // triggers an update of ReviewerFlow
+      await reviewerList.updateComplete;
+      await element.updateComplete;
+      const confirmDialog = queryAndAssert(
+        element,
+        'gr-overlay#confirm-reviewer'
+      );
+      assert.isTrue(
+        getComputedStyle(confirmDialog).getPropertyValue('display') === 'none'
+      );
+      assert.deepEqual(reviewerList.accounts[1], {
+        id: '5' as GroupId,
+        name: 'small-group',
+      });
+    });
+
+    test('"no" button cancels large group', async () => {
+      const reviewerList = queryAndAssert<GrAccountList>(
+        dialog,
+        'gr-account-list#reviewer-list'
+      );
+      reviewerList.handleAdd(
+        new CustomEvent('add', {
+          detail: {
+            value: {
+              group: {
+                id: '5',
+                name: 'large-group',
+              },
+              count: 12,
+              confirm: true,
+            },
+          },
+        }) as unknown as ValueChangedEvent<string>
+      );
+      // reviewerList needs to set the pendingConfirmation property which
+      // triggers an update of ReviewerFlow
+      await reviewerList.updateComplete;
+      await element.updateComplete;
+      // "No" button is last
+      queryAndAssert<GrButton>(
+        element,
+        '.confirmation-buttons > gr-button:last-of-type'
+      ).click();
+      await element.updateComplete;
+
+      const confirmDialog = queryAndAssert(
+        element,
+        'gr-overlay#confirm-reviewer'
+      );
+      assert.isTrue(
+        getComputedStyle(confirmDialog).getPropertyValue('display') === 'none'
+      );
+      // Group not present
+      assert.sameDeepMembers(reviewerList.accounts, [accounts[0]]);
     });
   });
 });
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-section/gr-change-list-section.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-section/gr-change-list-section.ts
index 85ea644..86adc70 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
@@ -3,17 +3,11 @@
  * Copyright 2022 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-
 import {LitElement, html, css, PropertyValues} from 'lit';
-import {customElement, property, state} from 'lit/decorators';
+import {customElement, property, state} from 'lit/decorators.js';
 import {ChangeListSection} from '../gr-change-list/gr-change-list';
 import '../gr-change-list-action-bar/gr-change-list-action-bar';
-import {
-  CLOSED,
-  YOUR_TURN,
-  GerritNav,
-} from '../../core/gr-navigation/gr-navigation';
-import {KnownExperimentId} from '../../../services/flags/flags';
+import {CLOSED, YOUR_TURN} from '../../../utils/dashboard-util';
 import {getAppContext} from '../../../services/app-context';
 import {ChangeInfo, ServerInfo, AccountInfo} from '../../../api/rest-api';
 import {changeListStyles} from '../../../styles/gr-change-list-styles';
@@ -21,15 +15,16 @@
 import {sharedStyles} from '../../../styles/shared-styles';
 import {Metadata} from '../../../utils/change-metadata-util';
 import {WAITING} from '../../../constants/constants';
-import {ifDefined} from 'lit/directives/if-defined';
 import {provide} from '../../../models/dependency';
 import {
   bulkActionsModelToken,
   BulkActionsModel,
 } from '../../../models/bulk-actions/bulk-actions-model';
+import {createSearchUrl} from '../../../models/views/search';
 import {subscribe} from '../../lit/subscription-controller';
+import {classMap} from 'lit/directives/class-map.js';
 
-const NUMBER_FIXED_COLUMNS = 3;
+const NUMBER_FIXED_COLUMNS = 4;
 const LABEL_PREFIX_INVALID_PROLOG = 'Invalid-Prolog-Rules-Label-Name--';
 const MAX_SHORTCUT_CHARS = 5;
 const INVALID_TOKENS = ['limit:', 'age:', '-age:'];
@@ -57,9 +52,6 @@
   visibleChangeTableColumns?: string[];
 
   @property({type: Boolean})
-  showStar = false;
-
-  @property({type: Boolean})
   showNumber?: boolean; // No default value to prevent flickering.
 
   @property({type: Number})
@@ -87,14 +79,33 @@
   @property({type: Object})
   account: AccountInfo | undefined = undefined;
 
-  @state() showBulkActionsHeader = false;
+  @property({type: String})
+  usp?: string;
 
-  private readonly flagsService = getAppContext().flagsService;
+  /** Index of the first element in the section in the overall list order. */
+  @property({type: Number})
+  startIndex = 0;
+
+  /** Callback to call to request the item to be selected in the list. */
+  @property({type: Function})
+  triggerSelectionCallback?: (globalIndex: number) => void;
+
+  // private but used in tests
+  @state()
+  numSelected = 0;
+
+  @state()
+  private totalChangeCount = 0;
 
   bulkActionsModel: BulkActionsModel = new BulkActionsModel(
     getAppContext().restApiService
   );
 
+  // Private but used in test.
+  userModel = getAppContext().userModel;
+
+  private isLoggedIn = false;
+
   static override get styles() {
     return [
       changeListStyles,
@@ -111,6 +122,23 @@
           font-weight: var(--font-weight-normal);
           line-height: var(--line-height-small);
         }
+        /*
+         * checkbox styles match checkboxes in <gr-change-list-item> rows to
+         * vertically align with them.
+         */
+        input.selection-checkbox {
+          background-color: var(--background-color-primary);
+          border: 1px solid var(--border-color);
+          border-radius: var(--border-radius);
+          box-sizing: border-box;
+          color: var(--primary-text-color);
+          margin: 0px;
+          padding: var(--spacing-s);
+          vertical-align: middle;
+        }
+        .showSelectionBorder {
+          border-bottom: 2px solid var(--input-focus-border-color);
+        }
       `,
     ];
   }
@@ -118,15 +146,22 @@
   constructor() {
     super();
     provide(this, bulkActionsModelToken, () => this.bulkActionsModel);
-  }
-
-  override connectedCallback() {
-    super.connectedCallback();
     subscribe(
       this,
-      this.bulkActionsModel.selectedChangeNums$,
-      selectedChanges =>
-        (this.showBulkActionsHeader = selectedChanges.length > 0)
+      () => this.bulkActionsModel.selectedChangeNums$,
+      selectedChanges => {
+        this.numSelected = selectedChanges.length;
+      }
+    );
+    subscribe(
+      this,
+      () => this.bulkActionsModel.totalChangeCount$,
+      totalChangeCount => (this.totalChangeCount = totalChangeCount)
+    );
+    subscribe(
+      this,
+      () => this.userModel.loggedIn$,
+      isLoggedIn => (this.isLoggedIn = isLoggedIn)
     );
   }
 
@@ -135,9 +170,7 @@
       // In case the list of changes is updated due to auto reloading, we want
       // to ensure the model removes any stale change that is not a part of the
       // new section changes.
-      if (this.flagsService.isEnabled(KnownExperimentId.BULK_ACTIONS)) {
-        this.bulkActionsModel.sync(this.changeSection.results);
-      }
+      this.bulkActionsModel.sync(this.changeSection.results);
     }
   }
 
@@ -163,8 +196,8 @@
         <td class="leftPadding" aria-hidden="true"></td>
         <td
           class="star"
-          ?aria-hidden=${!this.showStar}
-          ?hidden=${!this.showStar}
+          ?aria-hidden=${!this.isLoggedIn}
+          ?hidden=${!this.isLoggedIn}
         ></td>
         <td class="cell" colspan=${colSpan}>
           ${this.changeSection.emptyStateSlotName
@@ -187,8 +220,7 @@
       <tbody>
         <tr class="groupHeader">
           <td aria-hidden="true" class="leftPadding"></td>
-          ${this.renderSelectionHeader()}
-          <td aria-hidden="true" class="star" ?hidden=${!this.showStar}></td>
+          <td aria-hidden="true" class="star" ?hidden=${!this.isLoggedIn}></td>
           <td class="cell" colspan=${colSpan}>
             <h2 class="heading-3">
               <a
@@ -208,17 +240,22 @@
   }
 
   private renderColumnHeaders(columns: string[]) {
+    const showBulkActionsHeader = this.numSelected > 0;
     return html`
-      <tr class="groupTitle">
-        ${this.showBulkActionsHeader &&
-        this.flagsService.isEnabled(KnownExperimentId.BULK_ACTIONS)
+      <tr
+        class=${classMap({
+          groupTitle: true,
+          showSelectionBorder: showBulkActionsHeader,
+        })}
+      >
+        <td class="leftPadding"></td>
+        ${this.renderSelectionHeader()}
+        ${showBulkActionsHeader
           ? html`<gr-change-list-action-bar></gr-change-list-action-bar>`
-          : html` <td class="leftPadding" aria-hidden="true"></td>
-              ${this.renderSelectionHeader()}
-              <td
+          : html` <td
                 class="star"
                 aria-label="Star status column"
-                ?hidden=${!this.showStar}
+                ?hidden=${!this.isLoggedIn}
               ></td>
               <td class="number" ?hidden=${!this.showNumber}>#</td>
               ${columns.map(item => this.renderHeaderCell(item))}
@@ -233,8 +270,26 @@
   }
 
   private renderSelectionHeader() {
-    if (!this.flagsService.isEnabled(KnownExperimentId.BULK_ACTIONS)) return;
-    return html`<td aria-hidden="true" class="selection"></td>`;
+    const checked = this.numSelected > 0;
+    const indeterminate =
+      this.numSelected > 0 && this.numSelected !== this.totalChangeCount;
+    return html`
+      <td class="selection" ?hidden=${!this.isLoggedIn}>
+        <!--
+          The .checked property must be used rather than the attribute because
+          the attribute only controls the default checked state and does not
+          update the current checked state.
+          See: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/checkbox#attr-checked
+        -->
+        <input
+          class="selection-checkbox"
+          type="checkbox"
+          .checked=${checked}
+          .indeterminate=${indeterminate}
+          @click=${this.handleSelectAllCheckboxClicked}
+        />
+      </td>
+    `;
   }
 
   private renderHeaderCell(item: string) {
@@ -264,24 +319,34 @@
   ) {
     const ariaLabel = this.computeAriaLabel(change);
     const selected = this.computeItemSelected(index);
-    const tabindex = this.computeTabIndex(index);
     return html`
       <gr-change-list-item
+        tabindex="0"
         .account=${this.account}
-        ?selected=${selected}
+        .selected=${selected}
         .change=${change}
         .config=${this.config}
         .sectionName=${this.changeSection.name}
         .visibleChangeTableColumns=${columns}
         .showNumber=${this.showNumber}
-        ?showStar=${this.showStar}
-        tabindex=${ifDefined(tabindex)}
+        .usp=${this.usp}
         .labelNames=${this.labelNames}
+        .globalIndex=${this.startIndex + index}
+        .triggerSelectionCallback=${this.triggerSelectionCallback}
         aria-label=${ariaLabel}
+        role="button"
       ></gr-change-list-item>
     `;
   }
 
+  private handleSelectAllCheckboxClicked() {
+    if (this.numSelected === 0) {
+      this.bulkActionsModel.selectAll();
+    } else {
+      this.bulkActionsModel.clearSelectedChangeNums();
+    }
+  }
+
   /**
    * This methods allows us to customize the columns per section.
    * Private but used in test
@@ -301,16 +366,17 @@
     return cols;
   }
 
+  toggleChange(index: number) {
+    this.bulkActionsModel.toggleSelectedChangeNum(
+      this.changeSection.results[index]._number
+    );
+  }
+
   // private but used in test
   computeItemSelected(index: number) {
     return index === this.selectedIndex;
   }
 
-  private computeTabIndex(index: number) {
-    if (this.isCursorMoving) return 0;
-    return this.computeItemSelected(index) ? 0 : undefined;
-  }
-
   // private but used in test
   computeColspan(cols: string[]) {
     if (!cols || !this.labelNames) return 1;
@@ -328,8 +394,8 @@
   }
 
   private sectionHref(query?: string) {
-    if (!query) return;
-    return GerritNav.getUrlForSearchQuery(this.processQuery(query));
+    if (!query) return '';
+    return createSearchUrl({query: this.processQuery(query)});
   }
 
   // private but used in test
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 6a09c45..eec8b1a 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
@@ -3,19 +3,20 @@
  * Copyright 2022 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-
 import {
   GrChangeListSection,
   computeLabelShortcut,
 } from './gr-change-list-section';
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-change-list-section';
+import '../gr-change-list-item/gr-change-list-item';
 import {
   createChange,
   createAccountDetailWithId,
+  createAccountWithEmail,
   createServerInfo,
 } from '../../../test/test-data-generators';
-import {NumericChangeId, ChangeInfoId} from '../../../api/rest-api';
+import {ChangeInfoId, NumericChangeId, Timestamp} from '../../../api/rest-api';
 import {
   queryAll,
   query,
@@ -24,8 +25,9 @@
   waitUntilObserved,
 } from '../../../test/test-utils';
 import {GrChangeListItem} from '../gr-change-list-item/gr-change-list-item';
-import {columnNames, ChangeListSection} from '../gr-change-list/gr-change-list';
-import {fixture, html} from '@open-wc/testing-helpers';
+import {ChangeListSection} from '../gr-change-list/gr-change-list';
+import {fixture, html, assert} from '@open-wc/testing';
+import {ColumnNames} from '../../../constants/constants';
 
 suite('gr-change-list section', () => {
   let element: GrChangeListSection;
@@ -52,23 +54,42 @@
       html`<gr-change-list-section
         .account=${createAccountDetailWithId(1)}
         .config=${createServerInfo()}
-        .visibleChangeTableColumns=${columnNames}
+        .visibleChangeTableColumns=${Object.values(ColumnNames)}
         .changeSection=${changeSection}
       ></gr-change-list-section> `
     );
   });
 
-  test('selection checkbox is only shown if experiment is enabled', async () => {
-    assert.isNotOk(query(element, '.selection'));
-
-    stubFlags('isEnabled').returns(true);
-    element.requestUpdate();
-    await element.updateComplete;
-
-    assert.isOk(query(element, '.selection'));
+  test('renders headers when no changes are selected', () => {
+    // TODO: Check table elements. The shadowDom helper does not understand
+    // tables interacting with display: contents, even wrapping the element in a
+    // table, does not help.
+    assert.shadowDom.equal(
+      element,
+      /* prettier-ignore */ /* HTML */ `
+      <td class="selection">
+        <input class="selection-checkbox" type="checkbox"/>
+      </td>
+      #
+              SubjectStatusOwnerReviewersCommentsRepoBranchUpdatedSize Status
+      <gr-change-list-item
+        aria-label="Test subject, section: test"
+        role="button"
+        tabindex="0"
+      >
+      </gr-change-list-item>
+      <gr-change-list-item
+        aria-label="Test subject, section: test"
+        role="button"
+        tabindex="0"
+      >
+      </gr-change-list-item>
+    `
+    );
   });
 
-  test('selection header is only shown if experiment is enabled', async () => {
+  test('renders action bar when some changes are selected', async () => {
+    assert.isNotOk(query(element, 'gr-change-list-action-bar'));
     element.bulkActionsModel.setState({
       ...element.bulkActionsModel.getState(),
       selectedChangeNums: [1 as NumericChangeId],
@@ -78,11 +99,30 @@
       s => s.length === 1
     );
 
-    assert.isNotOk(query(element, 'gr-change-list-action-bar'));
-    stubFlags('isEnabled').returns(true);
     element.requestUpdate();
     await element.updateComplete;
-    queryAndAssert(element, 'gr-change-list-action-bar');
+    assert.shadowDom.equal(
+      element,
+      /* prettier-ignore */ /* HTML */ `
+        <td class="selection">
+          <input class="selection-checkbox" type="checkbox" />
+        </td>
+        <gr-change-list-action-bar></gr-change-list-action-bar>
+        <gr-change-list-item
+          aria-label="Test subject, section: test"
+          role="button"
+          tabindex="0"
+        >
+        </gr-change-list-item>
+        <gr-change-list-item
+          aria-label="Test subject, section: test"
+          checked=""
+          role="button"
+          tabindex="0"
+        >
+        </gr-change-list-item>
+      `
+    );
   });
 
   suite('bulk actions selection', () => {
@@ -114,27 +154,6 @@
       assert.isTrue(syncStub.called);
     });
 
-    test('changing section does on trigger model sync when flag is disabled', async () => {
-      isEnabled.returns(false);
-      const syncStub = sinon.stub(element.bulkActionsModel, 'sync');
-      assert.isFalse(syncStub.called);
-      element.changeSection = {
-        name: 'test',
-        query: 'test',
-        results: [
-          {
-            ...createChange(),
-            _number: 1 as NumericChangeId,
-            id: '1' as ChangeInfoId,
-          },
-        ],
-        emptyStateSlotName: 'test',
-      };
-      await element.updateComplete;
-
-      assert.isFalse(syncStub.called);
-    });
-
     test('actions header is enabled/disabled based on selected changes', async () => {
       element.bulkActionsModel.setState({
         ...element.bulkActionsModel.getState(),
@@ -144,7 +163,7 @@
         element.bulkActionsModel.selectedChangeNums$,
         s => s.length === 0
       );
-      assert.isFalse(element.showBulkActionsHeader);
+      assert.isFalse(element.numSelected > 0);
 
       element.bulkActionsModel.setState({
         ...element.bulkActionsModel.getState(),
@@ -154,8 +173,138 @@
         element.bulkActionsModel.selectedChangeNums$,
         s => s.length === 1
       );
-      assert.isTrue(element.showBulkActionsHeader);
+      assert.isTrue(element.numSelected > 0);
     });
+
+    test('select all checkbox checks all when none are selected', async () => {
+      element.changeSection = {
+        name: 'test',
+        query: 'test',
+        results: [
+          {
+            ...createChange(),
+            _number: 1 as NumericChangeId,
+            id: '1' as ChangeInfoId,
+          },
+          {
+            ...createChange(),
+            _number: 2 as NumericChangeId,
+            id: '2' as ChangeInfoId,
+          },
+        ],
+        emptyStateSlotName: 'test',
+      };
+      element.userModel.setAccount({
+        ...createAccountWithEmail('abc@def.com'),
+        registered_on: '2015-03-12 18:32:08.000000000' as Timestamp,
+      });
+      await element.updateComplete;
+      let rows = queryAll(element, 'gr-change-list-item');
+      assert.lengthOf(rows, 2);
+      assert.isFalse(
+        queryAndAssert<HTMLInputElement>(rows[0], 'input').checked
+      );
+      assert.isFalse(
+        queryAndAssert<HTMLInputElement>(rows[1], 'input').checked
+      );
+
+      const checkbox = queryAndAssert<HTMLInputElement>(element, 'input');
+      checkbox.click();
+      await waitUntilObserved(
+        element.bulkActionsModel.selectedChangeNums$,
+        s => s.length === 2
+      );
+      await element.updateComplete;
+
+      rows = queryAll(element, 'gr-change-list-item');
+      assert.lengthOf(rows, 2);
+      assert.isTrue(queryAndAssert<HTMLInputElement>(rows[0], 'input').checked);
+      assert.isTrue(queryAndAssert<HTMLInputElement>(rows[1], 'input').checked);
+    });
+
+    test('checkbox matches partial and fully selected state', async () => {
+      element.changeSection = {
+        name: 'test',
+        query: 'test',
+        results: [
+          {
+            ...createChange(),
+            _number: 1 as NumericChangeId,
+            id: '1' as ChangeInfoId,
+          },
+          {
+            ...createChange(),
+            _number: 2 as NumericChangeId,
+            id: '2' as ChangeInfoId,
+          },
+        ],
+        emptyStateSlotName: 'test',
+      };
+      element.userModel.setAccount({
+        ...createAccountWithEmail('abc@def.com'),
+        registered_on: '2015-03-12 18:32:08.000000000' as Timestamp,
+      });
+      await element.updateComplete;
+      const rows = queryAll(element, 'gr-change-list-item');
+
+      // zero case
+      let checkbox = queryAndAssert<HTMLInputElement>(element, 'input');
+      assert.isFalse(checkbox.checked);
+      assert.isFalse(checkbox.indeterminate);
+
+      // partial case
+      queryAndAssert<HTMLInputElement>(rows[0], 'input').click();
+      await element.updateComplete;
+
+      checkbox = queryAndAssert<HTMLInputElement>(element, 'input');
+      assert.isTrue(checkbox.indeterminate);
+
+      // plural case
+      queryAndAssert<HTMLInputElement>(rows[1], 'input').click();
+      await element.updateComplete;
+
+      checkbox = queryAndAssert<HTMLInputElement>(element, 'input');
+      assert.isFalse(checkbox.indeterminate);
+      assert.isTrue(checkbox.checked);
+
+      // Clicking Check All checkbox when all checkboxes selected unselects
+      // all checkboxes
+      queryAndAssert<HTMLInputElement>(element, 'input');
+      checkbox.click();
+      await element.updateComplete;
+
+      assert.isFalse(
+        queryAndAssert<HTMLInputElement>(rows[0], 'input').checked
+      );
+      assert.isFalse(
+        queryAndAssert<HTMLInputElement>(rows[1], 'input').checked
+      );
+    });
+  });
+
+  test('no checkbox when logged out', async () => {
+    element.changeSection = {
+      name: 'test',
+      query: 'test',
+      results: [
+        {
+          ...createChange(),
+          _number: 1 as NumericChangeId,
+          id: '1' as ChangeInfoId,
+        },
+        {
+          ...createChange(),
+          _number: 2 as NumericChangeId,
+          id: '2' as ChangeInfoId,
+        },
+      ],
+      emptyStateSlotName: 'test',
+    };
+    element.userModel.setAccount(undefined);
+    await element.updateComplete;
+    const rows = queryAll(element, 'gr-change-list-item');
+    assert.lengthOf(rows, 2);
+    assert.isUndefined(query<HTMLInputElement>(rows[0], 'input'));
   });
 
   test('colspans', async () => {
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-topic-flow/gr-change-list-topic-flow.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-topic-flow/gr-change-list-topic-flow.ts
new file mode 100644
index 0000000..ac1ba23
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-topic-flow/gr-change-list-topic-flow.ts
@@ -0,0 +1,446 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {css, html, LitElement, nothing} from 'lit';
+import {customElement, query, state} from 'lit/decorators.js';
+import {bulkActionsModelToken} from '../../../models/bulk-actions/bulk-actions-model';
+import {resolve} from '../../../models/dependency';
+import {ChangeInfo, TopicName} from '../../../types/common';
+import {subscribe} from '../../lit/subscription-controller';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-icon/gr-icon';
+import '../../shared/gr-autocomplete/gr-autocomplete';
+import '@polymer/iron-dropdown/iron-dropdown';
+import {IronDropdownElement} from '@polymer/iron-dropdown/iron-dropdown';
+import {getAppContext} from '../../../services/app-context';
+import {notUndefined} from '../../../types/types';
+import {unique} from '../../../utils/common-util';
+import {AutocompleteSuggestion} from '../../shared/gr-autocomplete/gr-autocomplete';
+import {when} from 'lit/directives/when.js';
+import {ValueChangedEvent} from '../../../types/events';
+import {classMap} from 'lit/directives/class-map.js';
+import {spinnerStyles} from '../../../styles/gr-spinner-styles';
+import {ProgressStatus} from '../../../constants/constants';
+import {allSettled} from '../../../utils/async-util';
+import {fireReload} from '../../../utils/event-util';
+import {fireAlert} from '../../../utils/event-util';
+import {pluralize} from '../../../utils/string-util';
+import {Interaction} from '../../../constants/reporting';
+
+@customElement('gr-change-list-topic-flow')
+export class GrChangeListTopicFlow extends LitElement {
+  @state() private selectedChanges: ChangeInfo[] = [];
+
+  @state() private topicToAdd: TopicName = '' as TopicName;
+
+  @state() private existingTopicSuggestions: TopicName[] = [];
+
+  @state() private loadingText?: string;
+
+  @state() private errorText?: string;
+
+  /** dropdown status is tracked here to lazy-load the inner DOM contents */
+  @state() private isDropdownOpen = false;
+
+  @state() private overallProgress: ProgressStatus = ProgressStatus.NOT_STARTED;
+
+  @query('iron-dropdown') private dropdown?: IronDropdownElement;
+
+  private selectedExistingTopics: Set<TopicName> = new Set();
+
+  private getBulkActionsModel = resolve(this, bulkActionsModelToken);
+
+  private restApiService = getAppContext().restApiService;
+
+  private readonly reportingService = getAppContext().reportingService;
+
+  static override get styles() {
+    return [
+      spinnerStyles,
+      css`
+        iron-dropdown {
+          box-shadow: var(--elevation-level-2);
+          width: 400px;
+          background-color: var(--dialog-background-color);
+          border-radius: 4px;
+        }
+        [slot='dropdown-content'] {
+          padding: var(--spacing-xl) var(--spacing-l) var(--spacing-l);
+        }
+        gr-autocomplete {
+          --prominent-border-color: var(--gray-800);
+        }
+        .footer {
+          display: flex;
+          justify-content: space-between;
+          align-items: baseline;
+        }
+        .buttons {
+          padding-top: var(--spacing-m);
+          display: flex;
+          justify-content: flex-end;
+          gap: var(--spacing-m);
+        }
+        .chips {
+          display: flex;
+          flex-wrap: wrap;
+          gap: 6px;
+        }
+        .chip {
+          padding: var(--spacing-s) var(--spacing-xl);
+          border-radius: 10px;
+          width: fit-content;
+          cursor: pointer;
+          color: var(--primary-text-color);
+        }
+        .chip:not(.selected) {
+          border: var(--spacing-xxs) solid var(--border-color);
+          background: none;
+        }
+        .chip.selected {
+          border: 0;
+          color: var(--selected-foreground);
+          background-color: var(--selected-chip-background);
+          margin: var(--spacing-xxs);
+        }
+        .loadingOrError {
+          display: flex;
+          align-items: baseline;
+          gap: var(--spacing-s);
+        }
+
+        /* The basics of .loadingSpin are defined in spinnerStyles. */
+        .loadingSpin {
+          vertical-align: top;
+          position: relative;
+          top: 3px;
+        }
+        .error {
+          color: var(--deemphasized-text-color);
+        }
+        gr-icon {
+          color: var(--error-color);
+          /* Center with text by aligning it to the top and then pushing it down
+             to match the text */
+          vertical-align: top;
+          position: relative;
+          top: 7px;
+        }
+      `,
+    ];
+  }
+
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.getBulkActionsModel().selectedChanges$,
+      selectedChanges => {
+        this.selectedChanges = selectedChanges;
+      }
+    );
+  }
+
+  override render() {
+    const isFlowDisabled = this.selectedChanges.length === 0;
+    return html`
+      <gr-button
+        id="start-flow"
+        flatten
+        down-arrow
+        @click=${this.toggleDropdown}
+        .disabled=${isFlowDisabled}
+        >Topic</gr-button
+      >
+      <iron-dropdown
+        .horizontalAlign=${'auto'}
+        .verticalAlign=${'auto'}
+        .verticalOffset=${24}
+        @opened-changed=${(e: CustomEvent) =>
+          (this.isDropdownOpen = e.detail.value)}
+      >
+        ${when(
+          this.isDropdownOpen,
+          () => html`
+            <div slot="dropdown-content">
+              ${when(
+                this.selectedChanges.some(change => change.topic),
+                () => this.renderExistingTopicsMode(),
+                () => this.renderNoExistingTopicsMode()
+              )}
+            </div>
+          `
+        )}
+      </iron-dropdown>
+    `;
+  }
+
+  private disableApplyToAllButton() {
+    if (this.selectedExistingTopics.size !== 1) return true;
+    // Ensure there is one selected change that does not have this topic
+    // already
+    return !this.selectedChanges
+      .map(change => change.topic)
+      .filter(unique)
+      .some(topic => !topic || !this.selectedExistingTopics.has(topic));
+  }
+
+  private renderExistingTopicsMode() {
+    const topics = this.selectedChanges
+      .map(change => change.topic)
+      .filter(notUndefined)
+      .filter(unique);
+    const removeDisabled =
+      this.selectedExistingTopics.size === 0 ||
+      this.overallProgress === ProgressStatus.RUNNING;
+    return html`
+      <div class="chips">
+        ${topics.map(name => this.renderExistingTopicChip(name))}
+      </div>
+      <div class="footer">
+        <div class="loadingOrError" role="progressbar">
+          ${this.renderLoadingOrError()}
+        </div>
+        <div class="buttons">
+          ${when(
+            this.overallProgress !== ProgressStatus.FAILED,
+            () => html` <gr-button
+                id="apply-to-all-button"
+                flatten
+                ?disabled=${this.disableApplyToAllButton()}
+                @click=${this.applyTopicToAll}
+                >Apply${this.selectedChanges.length > 1
+                  ? ' to all'
+                  : nothing}</gr-button
+              >
+              <gr-button
+                id="remove-topics-button"
+                flatten
+                ?disabled=${removeDisabled}
+                @click=${this.removeTopics}
+                >Remove</gr-button
+              >`,
+            () =>
+              html`
+                <gr-button
+                  id="cancel-button"
+                  flatten
+                  @click=${this.closeDropdown}
+                  >Cancel</gr-button
+                >
+              `
+          )}
+        </div>
+      </div>
+    `;
+  }
+
+  private renderExistingTopicChip(name: TopicName) {
+    const chipClasses = {
+      chip: true,
+      selected: this.selectedExistingTopics.has(name),
+    };
+    return html`
+      <button
+        role="listbox"
+        aria-label=${`${name as string} selection`}
+        class=${classMap(chipClasses)}
+        @click=${() => this.toggleExistingTopicSelected(name)}
+      >
+        ${name}
+      </button>
+    `;
+  }
+
+  private renderLoadingOrError() {
+    switch (this.overallProgress) {
+      case ProgressStatus.RUNNING:
+        return html`
+          <span class="loadingSpin"></span>
+          <span class="loadingText">${this.loadingText}</span>
+        `;
+      case ProgressStatus.FAILED:
+        return html`
+          <gr-icon icon="error" filled></gr-icon>
+          <div class="error">${this.errorText}</div>
+        `;
+      default:
+        return nothing;
+    }
+  }
+
+  private renderNoExistingTopicsMode() {
+    const isApplyTopicDisabled =
+      this.topicToAdd === '' || this.overallProgress === ProgressStatus.RUNNING;
+    return html`
+      <!--
+        The .query function needs to be bound to this because lit's autobind
+        seems to work only for @event handlers.
+        'this.getTopicSuggestions.bind(this)' gets in trouble with our linter
+        even though the bind is necessary here, so an anonymous function is used
+        instead.
+      -->
+      <gr-autocomplete
+        .text=${this.topicToAdd}
+        .query=${(query: string) => this.getTopicSuggestions(query)}
+        show-blue-focus-border
+        placeholder="Type topic name to create or filter topics"
+        @text-changed=${(e: ValueChangedEvent<TopicName>) =>
+          (this.topicToAdd = e.detail.value)}
+      ></gr-autocomplete>
+      <div class="footer">
+        <div class="loadingOrError" role="progressbar">
+          ${this.renderLoadingOrError()}
+        </div>
+        <div class="buttons">
+          ${when(
+            this.overallProgress !== ProgressStatus.FAILED,
+            () => html`
+              <gr-button
+                id="set-topic-button"
+                flatten
+                @click=${() => this.setTopic('Setting topic...')}
+                .disabled=${isApplyTopicDisabled}
+                >Set Topic</gr-button
+              >
+            `,
+            () => html`
+              <gr-button id="cancel-button" flatten @click=${this.closeDropdown}
+                >Cancel</gr-button
+              >
+            `
+          )}
+        </div>
+      </div>
+    `;
+  }
+
+  private toggleDropdown() {
+    this.isDropdownOpen ? this.closeDropdown() : this.openDropdown();
+  }
+
+  private reset() {
+    this.topicToAdd = '' as TopicName;
+    this.selectedExistingTopics = new Set();
+    this.overallProgress = ProgressStatus.NOT_STARTED;
+    this.errorText = undefined;
+  }
+
+  private closeDropdown() {
+    this.isDropdownOpen = false;
+    this.dropdown?.close();
+  }
+
+  private openDropdown() {
+    this.reset();
+    this.isDropdownOpen = true;
+    this.dropdown?.open();
+  }
+
+  private async getTopicSuggestions(
+    query: string
+  ): Promise<AutocompleteSuggestion[]> {
+    const suggestions = await this.restApiService.getChangesWithSimilarTopic(
+      query
+    );
+    this.existingTopicSuggestions = (suggestions ?? [])
+      .map(change => change.topic)
+      .filter(notUndefined)
+      .filter(unique);
+    return this.existingTopicSuggestions.map(topic => {
+      return {name: topic, value: topic};
+    });
+  }
+
+  private removeTopics() {
+    this.reportingService.reportInteraction(Interaction.BULK_ACTION, {
+      type: 'removing-topic',
+      selectedChangeCount: this.selectedChanges.length,
+    });
+    this.loadingText = `Removing topic${
+      this.selectedExistingTopics.size > 1 ? 's' : ''
+    }...`;
+    this.trackPromises(
+      this.selectedChanges
+        .filter(
+          change =>
+            change.topic && this.selectedExistingTopics.has(change.topic)
+        )
+        .map(change => this.restApiService.setChangeTopic(change._number, '')),
+      `${this.selectedChanges[0].topic} removed from changes`,
+      'Failed to remove topic'
+    );
+  }
+
+  private applyTopicToAll() {
+    this.reportingService.reportInteraction(Interaction.BULK_ACTION, {
+      type: 'apply-topic-to-all',
+      selectedChangeCount: this.selectedChanges.length,
+    });
+    this.loadingText = 'Applying to all';
+    const topic = Array.from(this.selectedExistingTopics.values())[0];
+    this.trackPromises(
+      this.selectedChanges.map(change =>
+        this.restApiService.setChangeTopic(change._number, topic)
+      ),
+      `${topic} applied to all changes`,
+      'Failed to apply topic'
+    );
+  }
+
+  private setTopic(loadingText: string) {
+    this.reportingService.reportInteraction(Interaction.BULK_ACTION, {
+      type: 'add-topic',
+      selectedChangeCount: this.selectedChanges.length,
+    });
+    const alert = `${pluralize(
+      this.selectedChanges.length,
+      'Change'
+    )} added to ${this.topicToAdd}`;
+    this.loadingText = loadingText;
+    this.trackPromises(
+      this.selectedChanges.map(change =>
+        this.restApiService.setChangeTopic(change._number, this.topicToAdd)
+      ),
+      alert,
+      'Failed to set topic'
+    );
+  }
+
+  private async trackPromises(
+    promises: Promise<string>[],
+    alert: string,
+    errorMessage: string
+  ) {
+    this.overallProgress = ProgressStatus.RUNNING;
+    const results = await allSettled(promises);
+    if (results.every(result => result.status === 'fulfilled')) {
+      this.overallProgress = ProgressStatus.SUCCESSFUL;
+      this.closeDropdown();
+      if (alert) {
+        fireAlert(this, alert);
+      }
+      fireReload(this);
+    } else {
+      this.overallProgress = ProgressStatus.FAILED;
+      this.errorText = errorMessage;
+    }
+  }
+
+  private toggleExistingTopicSelected(name: TopicName) {
+    if (this.selectedExistingTopics.has(name)) {
+      this.selectedExistingTopics.delete(name);
+    } else {
+      this.selectedExistingTopics.add(name);
+    }
+    this.requestUpdate();
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-change-list-topic-flow': GrChangeListTopicFlow;
+  }
+}
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-topic-flow/gr-change-list-topic-flow_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-topic-flow/gr-change-list-topic-flow_test.ts
new file mode 100644
index 0000000..9125cfd
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-topic-flow/gr-change-list-topic-flow_test.ts
@@ -0,0 +1,771 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {fixture, html, assert} from '@open-wc/testing';
+import {IronDropdownElement} from '@polymer/iron-dropdown';
+import {SinonStubbedMember} from 'sinon';
+import {
+  BulkActionsModel,
+  bulkActionsModelToken,
+} from '../../../models/bulk-actions/bulk-actions-model';
+import {wrapInProvider} from '../../../models/di-provider-element';
+import {getAppContext} from '../../../services/app-context';
+import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
+import '../../../test/common-test-setup';
+import {createChange} from '../../../test/test-data-generators';
+import {
+  MockPromise,
+  mockPromise,
+  query,
+  queryAll,
+  queryAndAssert,
+  stubReporting,
+  stubRestApi,
+  waitEventLoop,
+  waitUntil,
+  waitUntilCalled,
+  waitUntilObserved,
+} from '../../../test/test-utils';
+import {ChangeInfo, NumericChangeId, TopicName} from '../../../types/common';
+import {EventType} from '../../../types/events';
+import {GrAutocomplete} from '../../shared/gr-autocomplete/gr-autocomplete';
+import {GrButton} from '../../shared/gr-button/gr-button';
+import './gr-change-list-topic-flow';
+import type {GrChangeListTopicFlow} from './gr-change-list-topic-flow';
+
+suite('gr-change-list-topic-flow tests', () => {
+  let element: GrChangeListTopicFlow;
+  let model: BulkActionsModel;
+  let reportingStub: SinonStubbedMember<ReportingService['reportInteraction']>;
+
+  setup(() => {
+    reportingStub = stubReporting('reportInteraction');
+  });
+
+  async function selectChange(change: ChangeInfo) {
+    model.addSelectedChangeNum(change._number);
+    await waitUntilObserved(model.selectedChanges$, selected =>
+      selected.some(other => other._number === change._number)
+    );
+    await element.updateComplete;
+  }
+
+  async function deselectChange(change: ChangeInfo) {
+    model.removeSelectedChangeNum(change._number);
+    await waitUntilObserved(
+      model.selectedChanges$,
+      selected => !selected.some(other => other._number === change._number)
+    );
+    await element.updateComplete;
+  }
+
+  suite('dropdown closed', () => {
+    const changes: ChangeInfo[] = [
+      {
+        ...createChange(),
+        _number: 1 as NumericChangeId,
+        subject: 'Subject 1',
+      },
+      {
+        ...createChange(),
+        _number: 2 as NumericChangeId,
+        subject: 'Subject 2',
+      },
+    ];
+
+    setup(async () => {
+      stubRestApi('getDetailedChangesWithActions').resolves(changes);
+      model = new BulkActionsModel(getAppContext().restApiService);
+      model.sync(changes);
+
+      element = (
+        await fixture(
+          wrapInProvider(
+            html`<gr-change-list-topic-flow></gr-change-list-topic-flow>`,
+            bulkActionsModelToken,
+            model
+          )
+        )
+      ).querySelector('gr-change-list-topic-flow')!;
+      await selectChange(changes[0]);
+      await selectChange(changes[1]);
+      await waitUntilObserved(model.selectedChanges$, s => s.length === 2);
+      await element.updateComplete;
+    });
+
+    test('skips dropdown render when closed', async () => {
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <gr-button
+            id="start-flow"
+            flatten=""
+            down-arrow=""
+            aria-disabled="false"
+            role="button"
+            tabindex="0"
+            >Topic</gr-button
+          >
+          <iron-dropdown
+            aria-disabled="false"
+            aria-hidden="true"
+            style="outline: none; display: none;"
+            vertical-align="auto"
+            horizontal-align="auto"
+          >
+          </iron-dropdown>
+        `
+      );
+    });
+
+    test('dropdown hidden before flow button clicked', async () => {
+      const dropdown = queryAndAssert<IronDropdownElement>(
+        element,
+        'iron-dropdown'
+      );
+      assert.isFalse(dropdown.opened);
+    });
+
+    test('flow button click shows dropdown', async () => {
+      const button = queryAndAssert<GrButton>(element, 'gr-button#start-flow');
+
+      button.click();
+      await element.updateComplete;
+
+      const dropdown = queryAndAssert<IronDropdownElement>(
+        element,
+        'iron-dropdown'
+      );
+      assert.isTrue(dropdown.opened);
+    });
+
+    test('flow button click when open hides dropdown', async () => {
+      queryAndAssert<GrButton>(element, 'gr-button#start-flow').click();
+      await waitUntil(() =>
+        Boolean(
+          queryAndAssert<IronDropdownElement>(element, 'iron-dropdown').opened
+        )
+      );
+      queryAndAssert<GrButton>(element, 'gr-button#start-flow').click();
+      await waitUntil(
+        () =>
+          !queryAndAssert<IronDropdownElement>(element, 'iron-dropdown').opened
+      );
+    });
+  });
+
+  suite('changes in existing topics', () => {
+    const changesWithTopics: ChangeInfo[] = [
+      {
+        ...createChange(),
+        _number: 1 as NumericChangeId,
+        subject: 'Subject 1',
+        topic: 'topic1' as TopicName,
+      },
+      {
+        ...createChange(),
+        _number: 2 as NumericChangeId,
+        subject: 'Subject 2',
+        topic: 'topic2' as TopicName,
+      },
+    ];
+    let setChangeTopicPromises: MockPromise<string>[];
+    let setChangeTopicStub: sinon.SinonStub;
+
+    async function resolvePromises() {
+      setChangeTopicPromises[0].resolve('foo');
+      setChangeTopicPromises[1].resolve('foo');
+      await element.updateComplete;
+    }
+
+    async function rejectPromises() {
+      setChangeTopicPromises[0].reject(new Error('error'));
+      setChangeTopicPromises[1].reject(new Error('error'));
+      await element.updateComplete;
+    }
+
+    setup(async () => {
+      stubRestApi('getDetailedChangesWithActions').resolves(changesWithTopics);
+      setChangeTopicPromises = [];
+      setChangeTopicStub = stubRestApi('setChangeTopic');
+      for (let i = 0; i < changesWithTopics.length; i++) {
+        const promise = mockPromise<string>();
+        setChangeTopicPromises.push(promise);
+        setChangeTopicStub
+          .withArgs(changesWithTopics[i]._number, sinon.match.any)
+          .returns(promise);
+      }
+      model = new BulkActionsModel(getAppContext().restApiService);
+      model.sync(changesWithTopics);
+
+      element = (
+        await fixture(
+          wrapInProvider(
+            html`<gr-change-list-topic-flow></gr-change-list-topic-flow>`,
+            bulkActionsModelToken,
+            model
+          )
+        )
+      ).querySelector('gr-change-list-topic-flow')!;
+
+      // select changes
+      await selectChange(changesWithTopics[0]);
+      await selectChange(changesWithTopics[1]);
+      await waitUntilObserved(model.selectedChanges$, s => s.length === 2);
+      await element.updateComplete;
+
+      // open flow
+      queryAndAssert<GrButton>(element, 'gr-button#start-flow').click();
+      await element.updateComplete;
+      await waitEventLoop();
+    });
+
+    test('renders existing-topics flow', () => {
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <gr-button
+            id="start-flow"
+            flatten=""
+            down-arrow=""
+            aria-disabled="false"
+            role="button"
+            tabindex="0"
+            >Topic</gr-button
+          >
+          <iron-dropdown
+            aria-disabled="false"
+            vertical-align="auto"
+            horizontal-align="auto"
+          >
+            <div slot="dropdown-content">
+              <div class="chips">
+                <button
+                  role="listbox"
+                  aria-label="topic1 selection"
+                  class="chip"
+                >
+                  topic1
+                </button>
+                <button
+                  role="listbox"
+                  aria-label="topic2 selection"
+                  class="chip"
+                >
+                  topic2
+                </button>
+              </div>
+              <div class="footer">
+                <div class="loadingOrError" role="progressbar"></div>
+                <div class="buttons">
+                  <gr-button
+                    id="apply-to-all-button"
+                    flatten=""
+                    aria-disabled="true"
+                    disabled=""
+                    role="button"
+                    tabindex="-1"
+                    >Apply to all</gr-button
+                  >
+                  <gr-button
+                    id="remove-topics-button"
+                    flatten=""
+                    aria-disabled="true"
+                    disabled=""
+                    role="button"
+                    tabindex="-1"
+                    >Remove</gr-button
+                  >
+                </div>
+              </div>
+            </div>
+          </iron-dropdown>
+        `,
+        {
+          // iron-dropdown sizing seems to vary between local & CI
+          ignoreAttributes: [{tags: ['iron-dropdown'], attributes: ['style']}],
+        }
+      );
+    });
+
+    test('apply all button is disabled if all changes have the same topic', async () => {
+      assert.isTrue(
+        queryAndAssert<GrButton>(element, '#apply-to-all-button').disabled
+      );
+
+      queryAll<HTMLButtonElement>(element, 'button.chip')[0].click();
+      await element.updateComplete;
+
+      assert.isFalse(
+        queryAndAssert<GrButton>(element, '#apply-to-all-button').disabled
+      );
+
+      await deselectChange(changesWithTopics[1]);
+
+      const allChanges = model.getState().allChanges;
+      const change2 = {
+        ...createChange(),
+        _number: 2 as NumericChangeId,
+        subject: 'Subject 2',
+        topic: 'topic1' as TopicName, // same as changesWithTopics[0]
+      };
+      allChanges.set(2 as NumericChangeId, change2);
+      model.setState({
+        ...model.getState(),
+        allChanges,
+      });
+
+      await selectChange(change2);
+
+      assert.isTrue(
+        queryAndAssert<GrButton>(element, '#apply-to-all-button').disabled
+      );
+    });
+
+    test('remove single topic', async () => {
+      const alertStub = sinon.stub();
+      element.addEventListener(EventType.SHOW_ALERT, alertStub);
+      queryAll<HTMLButtonElement>(element, 'button.chip')[0].click();
+      await element.updateComplete;
+      queryAndAssert<GrButton>(element, '#remove-topics-button').click();
+      await element.updateComplete;
+
+      assert.equal(
+        queryAndAssert(element, '.loadingText').textContent,
+        'Removing topic...'
+      );
+
+      await resolvePromises();
+      await element.updateComplete;
+
+      // not called for second change which as a different topic
+      assert.isTrue(setChangeTopicStub.calledOnce);
+      assert.deepEqual(setChangeTopicStub.firstCall.args, [
+        changesWithTopics[0]._number,
+        '',
+      ]);
+
+      await waitUntilCalled(alertStub, 'alertStub');
+      assert.deepEqual(alertStub.lastCall.args[0].detail, {
+        message: 'topic1 removed from changes',
+        showDismiss: true,
+      });
+      assert.deepEqual(reportingStub.lastCall.args[1], {
+        type: 'removing-topic',
+        selectedChangeCount: 2,
+      });
+    });
+
+    test('remove multiple topics', async () => {
+      queryAll<HTMLButtonElement>(element, 'button.chip')[0].click();
+      queryAll<HTMLButtonElement>(element, 'button.chip')[1].click();
+      await element.updateComplete;
+      queryAndAssert<GrButton>(element, '#remove-topics-button').click();
+      await element.updateComplete;
+
+      assert.equal(
+        queryAndAssert(element, '.loadingText').textContent,
+        'Removing topics...'
+      );
+
+      await resolvePromises();
+      await element.updateComplete;
+
+      // not called for second change which as a different topic
+      assert.isTrue(setChangeTopicStub.calledTwice);
+      assert.deepEqual(setChangeTopicStub.firstCall.args, [
+        changesWithTopics[0]._number,
+        '',
+      ]);
+      assert.deepEqual(setChangeTopicStub.secondCall.args, [
+        changesWithTopics[1]._number,
+        '',
+      ]);
+    });
+
+    test('shows error when remove topic fails', async () => {
+      const alertStub = sinon.stub();
+      element.addEventListener(EventType.SHOW_ALERT, alertStub);
+      queryAll<HTMLButtonElement>(element, 'button.chip')[0].click();
+      await element.updateComplete;
+      queryAndAssert<GrButton>(element, '#remove-topics-button').click();
+      await element.updateComplete;
+
+      assert.equal(
+        queryAndAssert(element, '.loadingText').textContent,
+        'Removing topic...'
+      );
+
+      await rejectPromises();
+      await element.updateComplete;
+
+      await waitUntil(() => query(element, '.error') !== undefined);
+      assert.equal(
+        queryAndAssert(element, '.error').textContent,
+        'Failed to remove topic'
+      );
+      assert.equal(
+        queryAndAssert(element, 'gr-button#cancel-button').textContent,
+        'Cancel'
+      );
+      assert.isUndefined(query(element, '.loadingText'));
+    });
+
+    test('can only apply a single topic', async () => {
+      assert.isTrue(
+        queryAndAssert<GrButton>(element, '#apply-to-all-button').disabled
+      );
+
+      queryAll<HTMLButtonElement>(element, 'button.chip')[0].click();
+      await element.updateComplete;
+
+      assert.isFalse(
+        queryAndAssert<GrButton>(element, '#apply-to-all-button').disabled
+      );
+
+      queryAll<HTMLButtonElement>(element, 'button.chip')[1].click();
+      await element.updateComplete;
+
+      assert.isTrue(
+        queryAndAssert<GrButton>(element, '#apply-to-all-button').disabled
+      );
+    });
+
+    test('applies topic to all changes', async () => {
+      const alertStub = sinon.stub();
+      element.addEventListener(EventType.SHOW_ALERT, alertStub);
+
+      queryAll<HTMLButtonElement>(element, 'button.chip')[0].click();
+      await element.updateComplete;
+
+      queryAndAssert<GrButton>(element, '#apply-to-all-button').click();
+      await element.updateComplete;
+
+      assert.equal(
+        queryAndAssert(element, '.loadingText').textContent,
+        'Applying to all'
+      );
+
+      await resolvePromises();
+      await element.updateComplete;
+
+      assert.isTrue(setChangeTopicStub.calledTwice);
+      assert.deepEqual(setChangeTopicStub.firstCall.args, [
+        changesWithTopics[0]._number,
+        'topic1',
+      ]);
+      assert.deepEqual(setChangeTopicStub.secondCall.args, [
+        changesWithTopics[1]._number,
+        'topic1',
+      ]);
+
+      await waitUntilCalled(alertStub, 'alertStub');
+      assert.deepEqual(alertStub.lastCall.args[0].detail, {
+        message: 'topic1 applied to all changes',
+        showDismiss: true,
+      });
+      assert.deepEqual(reportingStub.lastCall.args[1], {
+        type: 'apply-topic-to-all',
+        selectedChangeCount: 2,
+      });
+    });
+  });
+
+  suite('change have no existing topics', () => {
+    const changesWithNoTopics: ChangeInfo[] = [
+      {
+        ...createChange(),
+        _number: 1 as NumericChangeId,
+        subject: 'Subject 1',
+      },
+      {
+        ...createChange(),
+        _number: 2 as NumericChangeId,
+        subject: 'Subject 2',
+      },
+    ];
+    let setChangeTopicPromises: MockPromise<string>[];
+    let setChangeTopicStub: sinon.SinonStub;
+
+    async function resolvePromises() {
+      setChangeTopicPromises[0].resolve('foo');
+      setChangeTopicPromises[1].resolve('foo');
+      await element.updateComplete;
+    }
+
+    async function rejectPromises() {
+      setChangeTopicPromises[0].reject(new Error('error'));
+      setChangeTopicPromises[1].reject(new Error('error'));
+      await element.updateComplete;
+    }
+
+    setup(async () => {
+      stubRestApi('getDetailedChangesWithActions').resolves(
+        changesWithNoTopics
+      );
+      setChangeTopicPromises = [];
+      setChangeTopicStub = stubRestApi('setChangeTopic');
+      for (let i = 0; i < changesWithNoTopics.length; i++) {
+        const promise = mockPromise<string>();
+        setChangeTopicPromises.push(promise);
+        setChangeTopicStub
+          .withArgs(changesWithNoTopics[i]._number, sinon.match.any)
+          .returns(promise);
+      }
+
+      model = new BulkActionsModel(getAppContext().restApiService);
+      model.sync(changesWithNoTopics);
+
+      element = (
+        await fixture(
+          wrapInProvider(
+            html`<gr-change-list-topic-flow></gr-change-list-topic-flow>`,
+            bulkActionsModelToken,
+            model
+          )
+        )
+      ).querySelector('gr-change-list-topic-flow')!;
+
+      // select changes
+      await selectChange(changesWithNoTopics[0]);
+      await selectChange(changesWithNoTopics[1]);
+      await waitUntilObserved(model.selectedChanges$, s => s.length === 2);
+      await element.updateComplete;
+
+      // open flow
+      queryAndAssert<GrButton>(element, 'gr-button#start-flow').click();
+      await element.updateComplete;
+      await waitEventLoop();
+    });
+
+    test('renders no-existing-topics flow', () => {
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <gr-button
+            id="start-flow"
+            flatten=""
+            down-arrow=""
+            aria-disabled="false"
+            role="button"
+            tabindex="0"
+            >Topic</gr-button
+          >
+          <iron-dropdown
+            aria-disabled="false"
+            vertical-align="auto"
+            horizontal-align="auto"
+          >
+            <div slot="dropdown-content">
+              <gr-autocomplete
+                placeholder="Type topic name to create or filter topics"
+                show-blue-focus-border=""
+              ></gr-autocomplete>
+              <div class="footer">
+                <div class="loadingOrError" role="progressbar"></div>
+                <div class="buttons">
+                  <gr-button
+                    id="set-topic-button"
+                    flatten=""
+                    aria-disabled="true"
+                    disabled=""
+                    role="button"
+                    tabindex="-1"
+                    >Set Topic</gr-button
+                  >
+                </div>
+              </div>
+            </div>
+          </iron-dropdown>
+        `,
+        {
+          // iron-dropdown sizing seems to vary between local & CI
+          ignoreAttributes: [{tags: ['iron-dropdown'], attributes: ['style']}],
+        }
+      );
+    });
+
+    test('create new topic', async () => {
+      const alertStub = sinon.stub();
+      element.addEventListener(EventType.SHOW_ALERT, alertStub);
+      const getTopicsStub = stubRestApi('getChangesWithSimilarTopic').resolves(
+        []
+      );
+      const autocomplete = queryAndAssert<GrAutocomplete>(
+        element,
+        'gr-autocomplete'
+      );
+      autocomplete.setFocus(true);
+      autocomplete.text = 'foo';
+      await element.updateComplete;
+      await waitUntilCalled(getTopicsStub, 'getTopicsStub');
+      assert.isFalse(
+        queryAndAssert<GrButton>(element, '#set-topic-button').disabled
+      );
+
+      queryAndAssert<GrButton>(element, '#set-topic-button').click();
+      await element.updateComplete;
+
+      assert.equal(
+        queryAndAssert(element, '.loadingText').textContent,
+        'Setting topic...'
+      );
+
+      await resolvePromises();
+      await element.updateComplete;
+
+      assert.isTrue(setChangeTopicStub.calledTwice);
+      assert.deepEqual(setChangeTopicStub.firstCall.args, [
+        changesWithNoTopics[0]._number,
+        'foo',
+      ]);
+      assert.deepEqual(setChangeTopicStub.secondCall.args, [
+        changesWithNoTopics[1]._number,
+        'foo',
+      ]);
+
+      await waitUntilCalled(alertStub, 'alertStub');
+      assert.deepEqual(alertStub.lastCall.args[0].detail, {
+        message: '2 Changes added to foo',
+        showDismiss: true,
+      });
+      assert.deepEqual(reportingStub.lastCall.args[1], {
+        type: 'add-topic',
+        selectedChangeCount: 2,
+      });
+    });
+
+    test('shows error when create topic fails', async () => {
+      const alertStub = sinon.stub();
+      element.addEventListener(EventType.SHOW_ALERT, alertStub);
+      const getTopicsStub = stubRestApi('getChangesWithSimilarTopic').resolves(
+        []
+      );
+      const autocomplete = queryAndAssert<GrAutocomplete>(
+        element,
+        'gr-autocomplete'
+      );
+      autocomplete.setFocus(true);
+      autocomplete.text = 'foo';
+      await element.updateComplete;
+      await waitUntilCalled(getTopicsStub, 'getTopicsStub');
+      assert.isFalse(
+        queryAndAssert<GrButton>(element, '#set-topic-button').disabled
+      );
+      queryAndAssert<GrButton>(element, '#set-topic-button').click();
+      await element.updateComplete;
+
+      assert.equal(
+        queryAndAssert(element, '.loadingText').textContent,
+        'Setting topic...'
+      );
+
+      await rejectPromises();
+      await element.updateComplete;
+      await waitUntil(() => query(element, '.error') !== undefined);
+
+      assert.equal(
+        queryAndAssert(element, '.error').textContent,
+        'Failed to set topic'
+      );
+      assert.equal(
+        queryAndAssert(element, 'gr-button#cancel-button').textContent,
+        'Cancel'
+      );
+      assert.isUndefined(query(element, '.loadingText'));
+    });
+
+    test('apply topic', async () => {
+      const getTopicsStub = stubRestApi('getChangesWithSimilarTopic').resolves([
+        {...createChange(), topic: 'foo' as TopicName},
+      ]);
+      const alertStub = sinon.stub();
+      element.addEventListener(EventType.SHOW_ALERT, alertStub);
+      const autocomplete = queryAndAssert<GrAutocomplete>(
+        element,
+        'gr-autocomplete'
+      );
+
+      autocomplete.setFocus(true);
+      autocomplete.text = 'foo';
+      await element.updateComplete;
+      await waitUntilCalled(getTopicsStub, 'getTopicsStub');
+
+      assert.isFalse(
+        queryAndAssert<GrButton>(element, '#set-topic-button').disabled
+      );
+      queryAndAssert<GrButton>(element, '#set-topic-button').click();
+      await element.updateComplete;
+
+      assert.equal(
+        queryAndAssert(element, '.loadingText').textContent,
+        'Setting topic...'
+      );
+
+      await resolvePromises();
+
+      assert.isTrue(setChangeTopicStub.calledTwice);
+      assert.deepEqual(setChangeTopicStub.firstCall.args, [
+        changesWithNoTopics[0]._number,
+        'foo',
+      ]);
+      assert.deepEqual(setChangeTopicStub.secondCall.args, [
+        changesWithNoTopics[1]._number,
+        'foo',
+      ]);
+
+      await waitUntilCalled(alertStub, 'alertStub');
+      assert.deepEqual(alertStub.lastCall.args[0].detail, {
+        message: '2 Changes added to foo',
+        showDismiss: true,
+      });
+      assert.deepEqual(reportingStub.lastCall.args[1], {
+        type: 'add-topic',
+        selectedChangeCount: 2,
+      });
+    });
+
+    test('shows error when setting topic fails', async () => {
+      const getTopicsStub = stubRestApi('getChangesWithSimilarTopic').resolves([
+        {...createChange(), topic: 'foo' as TopicName},
+      ]);
+      const alertStub = sinon.stub();
+      element.addEventListener(EventType.SHOW_ALERT, alertStub);
+      const autocomplete = queryAndAssert<GrAutocomplete>(
+        element,
+        'gr-autocomplete'
+      );
+
+      autocomplete.setFocus(true);
+      autocomplete.text = 'foo';
+      await element.updateComplete;
+      await waitUntilCalled(getTopicsStub, 'getTopicsStub');
+      assert.isFalse(
+        queryAndAssert<GrButton>(element, '#set-topic-button').disabled
+      );
+      queryAndAssert<GrButton>(element, '#set-topic-button').click();
+      await element.updateComplete;
+
+      assert.equal(
+        queryAndAssert(element, '.loadingText').textContent,
+        'Setting topic...'
+      );
+
+      await rejectPromises();
+      await element.updateComplete;
+
+      await waitUntil(() => query(element, '.error') !== undefined);
+      assert.equal(
+        queryAndAssert(element, '.error').textContent,
+        'Failed to set topic'
+      );
+      assert.equal(
+        queryAndAssert(element, 'gr-button#cancel-button').textContent,
+        'Cancel'
+      );
+      assert.isUndefined(query(element, '.loadingText'));
+    });
+  });
+});
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts
index 9743987..7abd7c0 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts
@@ -1,27 +1,12 @@
 /**
  * @license
- * Copyright (C) 2015 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2015 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../shared/gr-icons/gr-icons';
 import '../gr-change-list/gr-change-list';
 import '../gr-repo-header/gr-repo-header';
 import '../gr-user-header/gr-user-header';
 import {page} from '../../../utils/page-wrapper-utils';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation';
-import {AppElementParams} from '../../gr-app-types';
 import {
   AccountDetailInfo,
   AccountId,
@@ -31,25 +16,17 @@
   RepoName,
 } from '../../../types/common';
 import {ChangeStarToggleStarDetail} from '../../shared/gr-change-star/gr-change-star';
-import {ChangeListViewState} from '../../../types/types';
-import {fire, fireTitleChange} from '../../../utils/event-util';
+import {fireAlert, fireEvent, fireTitleChange} from '../../../utils/event-util';
 import {getAppContext} from '../../../services/app-context';
-import {GerritView} from '../../../services/router/router-model';
 import {sharedStyles} from '../../../styles/shared-styles';
-import {LitElement, PropertyValues, html, css} from 'lit';
-import {customElement, property, state, query} from 'lit/decorators';
-import {ValueChangedEvent} from '../../../types/events';
-
-const LOOKUP_QUERY_PATTERNS: RegExp[] = [
-  /^\s*i?[0-9a-f]{7,40}\s*$/i, // CHANGE_ID
-  /^\s*[1-9][0-9]*\s*$/g, // CHANGE_NUM
-  /[0-9a-f]{40}/, // COMMIT
-];
-
-const USER_QUERY_PATTERN = /^owner:\s?("[^"]+"|[^ ]+)$/;
-
-const REPO_QUERY_PATTERN =
-  /^project:\s?("[^"]+"|[^ ]+)(\sstatus\s?:(open|"open"))?$/;
+import {LitElement, PropertyValues, html, css, nothing} from 'lit';
+import {customElement, state, query} from 'lit/decorators.js';
+import {
+  createSearchUrl,
+  searchViewModelToken,
+} from '../../../models/views/search';
+import {resolve} from '../../../models/dependency';
+import {subscribe} from '../../lit/subscription-controller';
 
 const LIMIT_OPERATOR_PATTERN = /\blimit:(\d+)/i;
 
@@ -65,17 +42,14 @@
 
   @query('#nextArrow') protected nextArrow?: HTMLAnchorElement;
 
-  @property({type: Object})
-  params?: AppElementParams;
+  // private but used in test
+  @state() account?: AccountDetailInfo;
 
-  @property({type: Object})
-  account: AccountDetailInfo | null = null;
+  // private but used in test
+  @state() loggedIn = false;
 
-  @property({type: Object})
-  viewState: ChangeListViewState = {};
-
-  @property({type: Object})
-  preferences?: PreferencesInput;
+  // private but used in test
+  @state() preferences?: PreferencesInput;
 
   // private but used in test
   @state() changesPerPage?: number;
@@ -84,38 +58,83 @@
   @state() query = '';
 
   // private but used in test
-  @state() offset?: number;
+  @state() offset = 0;
 
   // private but used in test
-  @state() changes?: ChangeInfo[];
+  @state() changes: ChangeInfo[] = [];
 
   // private but used in test
   @state() loading = true;
 
   // private but used in test
-  @state() userId: AccountId | EmailAddress | null = null;
+  @state() userId?: AccountId | EmailAddress;
 
   // private but used in test
-  @state() repo: RepoName | null = null;
+  @state() repo?: RepoName;
 
   private readonly restApiService = getAppContext().restApiService;
 
   private reporting = getAppContext().reportingService;
 
+  private userModel = getAppContext().userModel;
+
+  private readonly getViewModel = resolve(this, searchViewModelToken);
+
   constructor() {
     super();
     this.addEventListener('next-page', () => this.handleNextPage());
     this.addEventListener('previous-page', () => this.handlePreviousPage());
-    this.addEventListener('reload', () => this.reload());
-  }
 
-  override connectedCallback() {
-    super.connectedCallback();
-    this.loadPreferences();
-  }
-
-  override disconnectedCallback() {
-    super.disconnectedCallback();
+    subscribe(
+      this,
+      () => this.getViewModel().query$,
+      x => (this.query = x)
+    );
+    subscribe(
+      this,
+      () => this.getViewModel().offsetNumber$,
+      x => (this.offset = x)
+    );
+    subscribe(
+      this,
+      () => this.getViewModel().loading$,
+      x => (this.loading = x)
+    );
+    subscribe(
+      this,
+      () => this.getViewModel().changes$,
+      x => (this.changes = x)
+    );
+    subscribe(
+      this,
+      () => this.getViewModel().userId$,
+      x => (this.userId = x)
+    );
+    subscribe(
+      this,
+      () => this.getViewModel().repo$,
+      x => (this.repo = x)
+    );
+    subscribe(
+      this,
+      () => this.userModel.account$,
+      x => (this.account = x)
+    );
+    subscribe(
+      this,
+      () => this.userModel.loggedIn$,
+      x => (this.loggedIn = x)
+    );
+    subscribe(
+      this,
+      () => this.userModel.preferenceChangesPerPage$,
+      x => (this.changesPerPage = x)
+    );
+    subscribe(
+      this,
+      () => this.userModel.preferences$,
+      x => (this.preferences = x)
+    );
   }
 
   static override get styles() {
@@ -142,15 +161,11 @@
           height: 3rem;
           justify-content: flex-end;
           margin-right: 20px;
-        }
-        nav,
-        iron-icon {
           color: var(--deemphasized-text-color);
         }
-        iron-icon {
-          height: 1.85rem;
+        gr-icon {
+          font-size: 1.85rem;
           margin-left: 16px;
-          width: 1.85rem;
         }
         .hide {
           display: none;
@@ -166,26 +181,21 @@
   }
 
   override render() {
-    const loggedIn = !!(this.account && Object.keys(this.account).length > 0);
     // In case of an internal reload we want the ChangeList section components
     // to remain in the DOM so that the Bulk Actions Model associated with them
     // is not recreated after the reload resulting in user selections being lost
     return html`
       <div class="loading" ?hidden=${!this.loading}>Loading...</div>
       <div ?hidden=${this.loading}>
-        ${this.renderRepoHeader()} ${this.renderUserHeader(loggedIn)}
+        ${this.renderRepoHeader()} ${this.renderUserHeader()}
         <gr-change-list
           .account=${this.account}
           .changes=${this.changes}
           .preferences=${this.preferences}
-          .selectedIndex=${this.viewState.selectedChangeIndex}
-          .showStar=${loggedIn}
-          @selected-index-changed=${(e: ValueChangedEvent<number>) => {
-            this.handleSelectedIndexChanged(e);
-          }}
           @toggle-star=${(e: CustomEvent<ChangeStarToggleStarDetail>) => {
             this.handleToggleStar(e);
           }}
+          .usp=${'search'}
         ></gr-change-list>
         ${this.renderChangeListViewNav()}
       </div>
@@ -193,25 +203,25 @@
   }
 
   private renderRepoHeader() {
-    if (!this.repo) return;
+    if (!this.repo) return nothing;
 
     return html` <gr-repo-header .repo=${this.repo}></gr-repo-header> `;
   }
 
-  private renderUserHeader(loggedIn: boolean) {
-    if (!this.userId) return;
+  private renderUserHeader() {
+    if (!this.userId) return nothing;
 
     return html`
       <gr-user-header
         .userId=${this.userId}
         showDashboardLink
-        .loggedIn=${loggedIn}
+        .loggedIn=${this.loggedIn}
       ></gr-user-header>
     `;
   }
 
   private renderChangeListViewNav() {
-    if (this.loading || !this.changes || !this.changes.length) return;
+    if (this.loading || !this.changes || !this.changes.length) return nothing;
 
     return html`
       <nav>
@@ -222,122 +232,31 @@
   }
 
   private renderPrevArrow() {
-    if (this.offset === 0) return;
+    if (this.offset === 0) return nothing;
 
     return html`
       <a id="prevArrow" href=${this.computeNavLink(-1)}>
-        <iron-icon icon="gr-icons:chevron-left" aria-label="Older"> </iron-icon>
+        <gr-icon icon="chevron_left" aria-label="Older"></gr-icon>
       </a>
     `;
   }
 
   private renderNextArrow() {
-    if (
-      !(
-        this.changes?.length &&
-        this.changes[this.changes.length - 1]._more_changes
-      )
-    )
-      return;
+    const changesCount = this.changes?.length ?? 0;
+    if (changesCount === 0) return nothing;
+    if (!this.changes?.[changesCount - 1]._more_changes) return nothing;
 
     return html`
       <a id="nextArrow" href=${this.computeNavLink(1)}>
-        <iron-icon icon="gr-icons:chevron-right" aria-label="Newer">
-        </iron-icon>
+        <gr-icon icon="chevron_right" aria-label="Newer"></gr-icon>
       </a>
     `;
   }
 
-  override willUpdate(changedProperties: PropertyValues) {
-    if (changedProperties.has('params')) {
-      this.paramsChanged();
+  override updated(changedProperties: PropertyValues) {
+    if (changedProperties.has('query')) {
+      fireTitleChange(this, this.query);
     }
-
-    if (changedProperties.has('changes')) {
-      this.changesChanged();
-    }
-  }
-
-  reload() {
-    if (this.loading) return;
-    this.loading = true;
-    this.getChanges().then(changes => {
-      this.changes = changes || [];
-      this.loading = false;
-    });
-  }
-
-  private paramsChanged() {
-    const value = this.params;
-    if (!value || value.view !== GerritView.SEARCH) return;
-
-    this.loading = true;
-    this.query = value.query;
-    const offset = Number(value.offset);
-    this.offset = isNaN(offset) ? 0 : offset;
-    if (
-      this.viewState.query !== this.query ||
-      this.viewState.offset !== this.offset
-    ) {
-      this.viewState.selectedChangeIndex = 0;
-      this.viewState.query = this.query;
-      this.viewState.offset = this.offset;
-      fire(this, 'view-state-change-list-view-changed', {
-        value: this.viewState,
-      });
-    }
-
-    // NOTE: This method may be called before attachment. Fire title-change
-    // in an async so that attachment to the DOM can take place first.
-    setTimeout(() => fireTitleChange(this, this.query));
-
-    this.restApiService
-      .getPreferences()
-      .then(prefs => {
-        if (!prefs) {
-          throw new Error('getPreferences returned undefined');
-        }
-        this.changesPerPage = prefs.changes_per_page;
-        return this.getChanges();
-      })
-      .then(changes => {
-        changes = changes || [];
-        if (this.query && changes.length === 1) {
-          for (const queryPattern of LOOKUP_QUERY_PATTERNS) {
-            if (this.query.match(queryPattern)) {
-              // "Back"/"Forward" buttons work correctly only with
-              // opt_redirect options
-              GerritNav.navigateToChange(changes[0], {
-                redirect: true,
-              });
-              return;
-            }
-          }
-        }
-        this.changes = changes;
-        this.loading = false;
-      });
-  }
-
-  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
-  getChanges() {
-    return this.restApiService.getChanges(
-      this.changesPerPage,
-      this.query,
-      this.offset
-    );
   }
 
   // private but used in test
@@ -355,7 +274,7 @@
     const offset = this.offset ?? 0;
     const limit = this.limitFor(this.query, this.changesPerPage);
     const newOffset = Math.max(0, offset + limit * direction);
-    return GerritNav.getUrlForSearchQuery(this.query, newOffset);
+    return createSearchUrl({query: this.query, offset: newOffset});
   }
 
   // private but used in test
@@ -370,53 +289,35 @@
     page.show(this.computeNavLink(-1));
   }
 
-  private changesChanged() {
-    this.userId = null;
-    this.repo = null;
-    const changes = this.changes;
-    if (!changes || !changes.length) {
-      return;
-    }
-    if (USER_QUERY_PATTERN.test(this.query)) {
-      const owner = changes[0].owner;
-      const userId = owner._account_id ? owner._account_id : owner.email;
-      if (userId) {
-        this.userId = userId;
-        return;
-      }
-    }
-    if (REPO_QUERY_PATTERN.test(this.query)) {
-      this.repo = changes[0].project;
-    }
-  }
-
   // private but used in test
   computePage() {
     if (this.offset === undefined || this.changesPerPage === undefined) return;
-    return this.offset / this.changesPerPage + 1;
-  }
-
-  private handleToggleStar(e: CustomEvent<ChangeStarToggleStarDetail>) {
-    if (e.detail.starred) {
-      this.reporting.reportInteraction('change-starred-from-change-list');
-    }
-    this.restApiService.saveChangeStarred(
-      e.detail.change._number,
-      e.detail.starred
+    // We use Math.ceil in case the offset is not divisible by changesPerPage.
+    // If we did not do this, you'd have page '1.2' and then when pressing left
+    // arrow 'Page 1'.  This way page '1.2' becomes page '2'.
+    return (
+      Math.ceil(this.offset / this.limitFor(this.query, this.changesPerPage)) +
+      1
     );
   }
 
-  private handleSelectedIndexChanged(e: ValueChangedEvent<number>) {
-    if (!this.viewState) return;
-    this.viewState.selectedChangeIndex = e.detail.value;
-    fire(this, 'view-state-change-list-view-changed', {value: this.viewState});
+  private async handleToggleStar(e: CustomEvent<ChangeStarToggleStarDetail>) {
+    if (e.detail.starred) {
+      this.reporting.reportInteraction('change-starred-from-change-list');
+    }
+    const msg = e.detail.starred
+      ? 'Starring change...'
+      : 'Unstarring change...';
+    fireAlert(this, msg);
+    await this.restApiService.saveChangeStarred(
+      e.detail.change._number,
+      e.detail.starred
+    );
+    fireEvent(this, 'hide-alert');
   }
 }
 
 declare global {
-  interface HTMLElementEventMap {
-    'view-state-change-list-view-changed': ValueChangedEvent<ChangeListViewState>;
-  }
   interface HTMLElementTagNameMap {
     'gr-change-list-view': GrChangeListView;
   }
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.ts
index 0633f46..f4bd8bd 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.ts
@@ -1,56 +1,26 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma.js';
+import '../../../test/common-test-setup';
 import './gr-change-list-view';
 import {GrChangeListView} from './gr-change-list-view';
 import {page} from '../../../utils/page-wrapper-utils';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation';
-import {
-  mockPromise,
-  query,
-  stubRestApi,
-  queryAndAssert,
-  stubFlags,
-} from '../../../test/test-utils';
-import {createChange} from '../../../test/test-data-generators.js';
-import {
-  ChangeInfo,
-  EmailAddress,
-  NumericChangeId,
-  RepoName,
-} from '../../../api/rest-api.js';
-import {tap} from '@polymer/iron-test-helpers/mock-interactions';
-import {waitUntil} from '@open-wc/testing-helpers';
-
-const basicFixture = fixtureFromElement('gr-change-list-view');
-
-const CHANGE_ID = 'IcA3dAB3edAB9f60B8dcdA6ef71A75980e4B7127';
-const COMMIT_HASH = '12345678';
+import {query, queryAndAssert} from '../../../test/test-utils';
+import {createChange} from '../../../test/test-data-generators';
+import {ChangeInfo} from '../../../api/rest-api';
+import {fixture, html, waitUntil, assert} from '@open-wc/testing';
+import {GrChangeList} from '../gr-change-list/gr-change-list';
+import {GrChangeListSection} from '../gr-change-list-section/gr-change-list-section';
+import {GrChangeListItem} from '../gr-change-list-item/gr-change-list-item';
 
 suite('gr-change-list-view tests', () => {
   let element: GrChangeListView;
 
   setup(async () => {
-    stubRestApi('getLoggedIn').returns(Promise.resolve(false));
-    stubRestApi('getChanges').returns(Promise.resolve([]));
-    stubRestApi('getAccountDetails').returns(Promise.resolve(undefined));
-    stubRestApi('getAccountStatus').returns(Promise.resolve(undefined));
-    element = basicFixture.instantiate();
+    element = await fixture(html`<gr-change-list-view></gr-change-list-view>`);
+    element.query = 'test-query';
     await element.updateComplete;
   });
 
@@ -58,41 +28,67 @@
     await element.updateComplete;
   });
 
+  test('render', async () => {
+    element.changes = Array(25)
+      .fill(0)
+      .map(_ => createChange());
+    element.changesPerPage = 10;
+    element.loading = false;
+    await element.updateComplete;
+
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div class="loading" hidden="">Loading...</div>
+        <div>
+          <gr-change-list> </gr-change-list>
+          <nav>Page 1</nav>
+        </div>
+      `
+    );
+  });
+
   suite('bulk actions', () => {
-    let getChangesStub: sinon.SinonStub;
     setup(async () => {
-      stubFlags('isEnabled').returns(true);
-      getChangesStub = sinon.stub(element, 'getChanges');
-      getChangesStub.returns(Promise.resolve([createChange()]));
       element.loading = false;
-      element.reload();
-      await waitUntil(() => element.loading === false);
-      element.requestUpdate();
+      element.changes = [createChange()];
       await element.updateComplete;
+      await element.updateComplete;
+      await waitUntil(() => element.loading === false);
     });
 
     test('checkboxes remain checked after soft reload', async () => {
-      let checkbox = queryAndAssert<HTMLInputElement>(
-        query(
-          query(query(element, 'gr-change-list'), 'gr-change-list-section'),
-          'gr-change-list-item'
-        ),
-        '.selection > input'
+      const changeListEl = queryAndAssert<GrChangeList>(
+        element,
+        'gr-change-list'
       );
-      tap(checkbox);
+      await changeListEl.updateComplete;
+      const changeListSectionEl = queryAndAssert<GrChangeListSection>(
+        changeListEl,
+        'gr-change-list-section'
+      );
+      await changeListSectionEl.updateComplete;
+      const changeListItemEl = queryAndAssert<GrChangeListItem>(
+        changeListSectionEl,
+        'gr-change-list-item'
+      );
+      await changeListItemEl.updateComplete;
+      let checkbox = queryAndAssert<HTMLInputElement>(
+        changeListItemEl,
+        '.selection > .selectionLabel > input'
+      );
+      checkbox.click();
       await waitUntil(() => checkbox.checked);
 
-      getChangesStub.restore();
-      getChangesStub.returns(Promise.resolve([[createChange()]]));
-
-      element.reload();
+      element.changes = [createChange()];
       await element.updateComplete;
+
       checkbox = queryAndAssert<HTMLInputElement>(
         query(
           query(query(element, 'gr-change-list'), 'gr-change-list-section'),
           'gr-change-list-item'
         ),
-        '.selection > input'
+        '.selection > .selectionLabel > input'
       );
       assert.isTrue(checkbox.checked);
     });
@@ -117,25 +113,19 @@
   });
 
   test('computeNavLink', () => {
-    const getUrlStub = sinon
-      .stub(GerritNav, 'getUrlForSearchQuery')
-      .returns('');
     element.query = 'status:open';
     element.offset = 0;
     element.changesPerPage = 5;
     let direction = 1;
 
-    element.computeNavLink(direction);
-    assert.equal(getUrlStub.lastCall.args[1], 5);
+    assert.equal(element.computeNavLink(direction), '/q/status:open,5');
 
     direction = -1;
-    element.computeNavLink(direction);
-    assert.equal(getUrlStub.lastCall.args[1], 0);
+    assert.equal(element.computeNavLink(direction), '/q/status:open');
 
     element.offset = 5;
     direction = 1;
-    element.computeNavLink(direction);
-    assert.equal(getUrlStub.lastCall.args[1], 10);
+    assert.equal(element.computeNavLink(direction), '/q/status:open,10');
   });
 
   test('prevArrow', async () => {
@@ -204,161 +194,4 @@
     element.handlePreviousPage();
     assert.isTrue(showStub.called);
   });
-
-  test('userId query', async () => {
-    assert.isNull(element.userId);
-    element.query = 'owner: foo@bar';
-    element.changes = [
-      {...createChange(), owner: {email: 'foo@bar' as EmailAddress}},
-    ];
-    await element.updateComplete;
-    assert.equal(element.userId, 'foo@bar' as EmailAddress);
-
-    element.query = 'foo bar baz';
-    element.changes = [
-      {...createChange(), owner: {email: 'foo@bar' as EmailAddress}},
-    ];
-    await element.updateComplete;
-    assert.isNull(element.userId);
-  });
-
-  test('userId query without email', async () => {
-    assert.isNull(element.userId);
-    element.query = 'owner: foo@bar';
-    element.changes = [{...createChange(), owner: {}}];
-    await element.updateComplete;
-    assert.isNull(element.userId);
-  });
-
-  test('repo query', async () => {
-    assert.isNull(element.repo);
-    element.query = 'project: test-repo';
-    element.changes = [
-      {
-        ...createChange(),
-        owner: {email: 'foo@bar' as EmailAddress},
-        project: 'test-repo' as RepoName,
-      },
-    ];
-    await element.updateComplete;
-    assert.equal(element.repo, 'test-repo' as RepoName);
-
-    element.query = 'foo bar baz';
-    element.changes = [
-      {...createChange(), owner: {email: 'foo@bar' as EmailAddress}},
-    ];
-    await element.updateComplete;
-    assert.isNull(element.repo);
-  });
-
-  test('repo query with open status', async () => {
-    assert.isNull(element.repo);
-    element.query = 'project:test-repo status:open';
-    element.changes = [
-      {
-        ...createChange(),
-        owner: {email: 'foo@bar' as EmailAddress},
-        project: 'test-repo' as RepoName,
-      },
-    ];
-    await element.updateComplete;
-    assert.equal(element.repo, 'test-repo' as RepoName);
-
-    element.query = 'foo bar baz';
-    element.changes = [
-      {...createChange(), owner: {email: 'foo@bar' as EmailAddress}},
-    ];
-    await element.updateComplete;
-    assert.isNull(element.repo);
-  });
-
-  suite('query based navigation', () => {
-    setup(() => {});
-
-    teardown(async () => {
-      await element.updateComplete;
-      sinon.restore();
-    });
-
-    test('Searching for a change ID redirects to change', async () => {
-      const change = {...createChange(), _number: 1 as NumericChangeId};
-      sinon.stub(element, 'getChanges').returns(Promise.resolve([change]));
-      const promise = mockPromise();
-      sinon.stub(GerritNav, 'navigateToChange').callsFake((url, opt) => {
-        assert.equal(url, change);
-        // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
-        assert.isTrue(opt!.redirect);
-        promise.resolve();
-      });
-
-      element.params = {
-        view: GerritNav.View.SEARCH,
-        query: CHANGE_ID,
-        offset: '',
-      };
-      await promise;
-    });
-
-    test('Searching for a change num redirects to change', async () => {
-      const change = {...createChange(), _number: 1 as NumericChangeId};
-      sinon.stub(element, 'getChanges').returns(Promise.resolve([change]));
-      const promise = mockPromise();
-      sinon.stub(GerritNav, 'navigateToChange').callsFake((url, opt) => {
-        assert.equal(url, change);
-        // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
-        assert.isTrue(opt!.redirect);
-        promise.resolve();
-      });
-
-      element.params = {view: GerritNav.View.SEARCH, query: '1', offset: ''};
-      await promise;
-    });
-
-    test('Commit hash redirects to change', async () => {
-      const change = {...createChange(), _number: 1 as NumericChangeId};
-      sinon.stub(element, 'getChanges').returns(Promise.resolve([change]));
-      const promise = mockPromise();
-      sinon.stub(GerritNav, 'navigateToChange').callsFake((url, opt) => {
-        assert.equal(url, change);
-        // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
-        assert.isTrue(opt!.redirect);
-        promise.resolve();
-      });
-
-      element.params = {
-        view: GerritNav.View.SEARCH,
-        query: COMMIT_HASH,
-        offset: '',
-      };
-      await promise;
-    });
-
-    test('Searching for an invalid change ID searches', async () => {
-      sinon.stub(element, 'getChanges').returns(Promise.resolve([]));
-      const stub = sinon.stub(GerritNav, 'navigateToChange');
-
-      element.params = {
-        view: GerritNav.View.SEARCH,
-        query: CHANGE_ID,
-        offset: '',
-      };
-      await element.updateComplete;
-
-      assert.isFalse(stub.called);
-    });
-
-    test('Change ID with multiple search results searches', async () => {
-      sinon.stub(element, 'getChanges').returns(Promise.resolve(undefined));
-      const stub = sinon.stub(GerritNav, 'navigateToChange');
-
-      element.params = {
-        view: GerritNav.View.SEARCH,
-        query: CHANGE_ID,
-        offset: '',
-      };
-      await element.updateComplete;
-
-      assert.isFalse(stub.called);
-    });
-  });
 });
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts
index 2c10c1e..945ff6e 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts
@@ -1,27 +1,15 @@
 /**
  * @license
- * Copyright (C) 2015 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2015 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
 import '../../shared/gr-cursor-manager/gr-cursor-manager';
 import '../gr-change-list-item/gr-change-list-item';
 import '../gr-change-list-section/gr-change-list-section';
 import {GrChangeListItem} from '../gr-change-list-item/gr-change-list-item';
 import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
 import {getAppContext} from '../../../services/app-context';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
 import {getPluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints';
 import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 import {GrCursorManager} from '../../shared/gr-cursor-manager/gr-cursor-manager';
@@ -32,35 +20,22 @@
   PreferencesInput,
 } from '../../../types/common';
 import {fire, fireEvent, fireReload} from '../../../utils/event-util';
-import {ScrollMode} from '../../../constants/constants';
+import {ColumnNames, ScrollMode} from '../../../constants/constants';
 import {getRequirements} from '../../../utils/label-util';
-import {addGlobalShortcut, Key} from '../../../utils/dom-util';
-import {unique} from '../../../utils/common-util';
+import {Key} from '../../../utils/dom-util';
+import {assertIsDefined, unique} from '../../../utils/common-util';
 import {changeListStyles} from '../../../styles/gr-change-list-styles';
 import {fontStyles} from '../../../styles/gr-font-styles';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, PropertyValues, html, css, nothing} from 'lit';
-import {customElement, property, state} from 'lit/decorators';
-import {ShortcutController} from '../../lit/shortcut-controller';
-import {Shortcut} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
+import {customElement, property, state} from 'lit/decorators.js';
+import {Shortcut, ShortcutController} from '../../lit/shortcut-controller';
 import {queryAll} from '../../../utils/common-util';
-import {ValueChangedEvent} from '../../../types/events';
-import {KnownExperimentId} from '../../../services/flags/flags';
 import {GrChangeListSection} from '../gr-change-list-section/gr-change-list-section';
-
-export const columnNames = [
-  'Subject',
-  // TODO(milutin) - remove once Submit Requirements are rolled out.
-  'Status',
-  'Owner',
-  'Reviewers',
-  'Comments',
-  'Repo',
-  'Branch',
-  'Updated',
-  'Size',
-  ' Status ', // spaces to differentiate from old 'Status'
-];
+import {Execution} from '../../../constants/reporting';
+import {ValueChangedEvent} from '../../../types/events';
+import {resolve} from '../../../models/dependency';
+import {createChangeUrl} from '../../../models/views/change';
 
 export interface ChangeListSection {
   countLabel?: string;
@@ -133,21 +108,20 @@
 
   @state() private dynamicHeaderEndpoints?: string[];
 
-  @property({type: Number, attribute: 'selected-index'})
-  selectedIndex?: number;
+  @property({type: Number}) selectedIndex = 0;
 
   @property({type: Boolean})
   showNumber?: boolean; // No default value to prevent flickering.
 
   @property({type: Boolean})
-  showStar = false;
-
-  @property({type: Boolean})
   showReviewedState = false;
 
   @property({type: Array})
   changeTableColumns?: string[];
 
+  @property({type: String})
+  usp?: string;
+
   @property({type: Array})
   visibleChangeTableColumns?: string[];
 
@@ -160,12 +134,19 @@
   // private but used in test
   @state() config?: ServerInfo;
 
+  // Private but used in test.
+  userModel = getAppContext().userModel;
+
   private readonly flagsService = getAppContext().flagsService;
 
   private readonly restApiService = getAppContext().restApiService;
 
+  private readonly reporting = getAppContext().reportingService;
+
   private readonly shortcuts = new ShortcutController(this);
 
+  private readonly getNavigation = resolve(this, navigationToken);
+
   private cursor = new GrCursorManager();
 
   constructor() {
@@ -187,7 +168,10 @@
     this.shortcuts.addAbstract(Shortcut.REFRESH_CHANGE_LIST, () =>
       this.refreshChangeList()
     );
-    addGlobalShortcut({key: Key.ENTER}, () => this.openChange());
+    this.shortcuts.addAbstract(Shortcut.TOGGLE_CHECKBOX, () =>
+      this.toggleCheckbox()
+    );
+    this.shortcuts.addGlobal({key: Key.ENTER}, () => this.openChange());
   }
 
   override connectedCallback() {
@@ -241,19 +225,34 @@
   override render() {
     if (!this.sections) return;
     const labelNames = this.computeLabelNames(this.sections);
+    const startIndices = this.calculateStartIndices(this.sections);
     return html`
       <table id="changeList">
         ${this.sections.map((changeSection, sectionIndex) =>
-          this.renderSection(changeSection, sectionIndex, labelNames)
+          this.renderSection(
+            changeSection,
+            sectionIndex,
+            labelNames,
+            startIndices[sectionIndex]
+          )
         )}
       </table>
     `;
   }
 
+  private calculateStartIndices(sections: ChangeListSection[]): number[] {
+    const startIndices: number[] = new Array(sections.length).fill(0);
+    for (let i = 1; i < sections.length; ++i) {
+      startIndices[i] = startIndices[i - 1] + sections[i - 1].results.length;
+    }
+    return startIndices;
+  }
+
   private renderSection(
     changeSection: ChangeListSection,
     sectionIndex: number,
-    labelNames: string[]
+    labelNames: string[],
+    startIndex: number
   ) {
     return html`
       <gr-change-list-section
@@ -268,9 +267,14 @@
           sectionIndex,
           this.sections
         )}
-        ?showStar=${this.showStar}
         .showNumber=${this.showNumber}
         .visibleChangeTableColumns=${this.visibleChangeTableColumns}
+        .usp=${this.usp}
+        .startIndex=${startIndex}
+        .triggerSelectionCallback=${(index: number) => {
+          this.selectedIndex = index;
+          this.cursor.setCursorAtIndex(this.selectedIndex);
+        }}
       >
         ${changeSection.emptyStateSlotName
           ? html`<slot
@@ -289,7 +293,7 @@
       changedProperties.has('config') ||
       changedProperties.has('sections')
     ) {
-      this.computePreferences();
+      this.computeVisibleChangeTableColumns();
     }
 
     if (changedProperties.has('changes')) {
@@ -301,15 +305,39 @@
     if (changedProperties.has('sections')) {
       this.sectionsChanged();
     }
+    if (changedProperties.has('selectedIndex')) {
+      fire(this, 'selected-index-changed', {
+        value: this.selectedIndex ?? 0,
+      });
+    }
   }
 
-  private computePreferences() {
+  private toggleCheckbox() {
+    assertIsDefined(this.selectedIndex, 'selectedIndex');
+    let selectedIndex = this.selectedIndex;
+    assertIsDefined(this.sections, 'sections');
+    const changeSections = queryAll<GrChangeListSection>(
+      this,
+      'gr-change-list-section'
+    );
+    for (let i = 0; i < this.sections.length; i++) {
+      if (selectedIndex >= this.sections[i].results.length) {
+        selectedIndex -= this.sections[i].results.length;
+        continue;
+      }
+      changeSections[i].toggleChange(selectedIndex);
+      return;
+    }
+    throw new Error('invalid selected index');
+  }
+
+  private computeVisibleChangeTableColumns() {
     if (!this.config) return;
 
-    this.changeTableColumns = columnNames;
+    this.changeTableColumns = Object.values(ColumnNames);
     this.showNumber = false;
     this.visibleChangeTableColumns = this.changeTableColumns.filter(col =>
-      this._isColumnEnabled(col, this.config)
+      this.isColumnEnabled(col, this.config)
     );
     if (this.account && this.preferences) {
       this.showNumber = !!this.preferences?.legacycid_in_change_table;
@@ -317,12 +345,19 @@
         this.preferences?.change_table &&
         this.preferences.change_table.length > 0
       ) {
-        const prefColumns = this.preferences.change_table.map(column =>
-          column === 'Project' ? 'Repo' : column
-        );
-        this.visibleChangeTableColumns = prefColumns.filter(col =>
-          this._isColumnEnabled(col, this.config)
-        );
+        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));
       }
     }
   }
@@ -330,55 +365,29 @@
   /**
    * Is the column disabled by a server config or experiment?
    */
-  _isColumnEnabled(column: string, config?: ServerInfo) {
-    if (!columnNames.includes(column)) return false;
+  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 !this.flagsService.isEnabled(
-        KnownExperimentId.SUBMIT_REQUIREMENTS_UI
-      );
-    }
-    if (column === ' Status ')
-      return this.flagsService.isEnabled(
-        KnownExperimentId.SUBMIT_REQUIREMENTS_UI
-      );
+    if (column === 'Status') return false;
+    if (column === ColumnNames.STATUS2) return true;
     return true;
   }
 
   // private but used in test
   computeLabelNames(sections: ChangeListSection[]) {
     if (!sections) return [];
-    let labels: string[] = [];
-    const nonExistingLabel = function (item: string) {
-      return !labels.includes(item);
-    };
-    for (const section of sections) {
-      if (!section.results) {
-        continue;
-      }
-      for (const change of section.results) {
-        if (!change.labels) {
-          continue;
-        }
-        const currentLabels = Object.keys(change.labels);
-        labels = labels.concat(currentLabels.filter(nonExistingLabel));
-      }
+    if (this.config?.submit_requirement_dashboard_columns?.length) {
+      return this.config?.submit_requirement_dashboard_columns;
     }
-
-    if (this.flagsService.isEnabled(KnownExperimentId.SUBMIT_REQUIREMENTS_UI)) {
-      if (this.config?.submit_requirement_dashboard_columns?.length) {
-        return this.config?.submit_requirement_dashboard_columns;
-      } else {
-        const changes = sections.map(section => section.results).flat();
-        labels = (changes ?? [])
-          .map(change => getRequirements(change))
-          .flat()
-          .map(requirement => requirement.name)
-          .filter(unique);
-      }
-    }
+    const changes = sections.map(section => section.results).flat();
+    const labels = (changes ?? [])
+      .map(change => getRequirements(change))
+      .flat()
+      .map(requirement => requirement.name)
+      .filter(unique);
     return labels.sort();
   }
 
@@ -391,7 +400,6 @@
     this.cursor.next();
     this.isCursorMoving = false;
     this.selectedIndex = this.cursor.index;
-    fire(this, 'selected-index-changed', {value: this.cursor.index});
   }
 
   private prevChange() {
@@ -399,12 +407,11 @@
     this.cursor.previous();
     this.isCursorMoving = false;
     this.selectedIndex = this.cursor.index;
-    fire(this, 'selected-index-changed', {value: this.cursor.index});
   }
 
   private async openChange() {
     const change = await this.changeForIndex(this.selectedIndex);
-    if (change) GerritNav.navigateToChange(change);
+    if (change) this.getNavigation().setUrl(createChangeUrl({change}));
   }
 
   private nextPage() {
@@ -472,10 +479,10 @@
 }
 
 declare global {
-  interface HTMLElementEventMap {
-    'selected-index-changed': ValueChangedEvent<number>;
-  }
   interface HTMLElementTagNameMap {
     'gr-change-list': GrChangeList;
   }
+  interface HTMLElementEventMap {
+    'selected-index-changed': ValueChangedEvent<number>;
+  }
 }
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.ts
index 365ba72..7511b57 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
@@ -1,23 +1,12 @@
 /**
  * @license
- * Copyright (C) 2015 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2015 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-change-list';
 import {GrChangeList, computeRelativeIndex} from './gr-change-list';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
 import {
   pressKey,
   query,
@@ -27,22 +16,30 @@
   waitUntil,
 } from '../../../test/test-utils';
 import {Key} from '../../../utils/dom-util';
-import {TimeFormat} from '../../../constants/constants';
+import {
+  ColumnNames,
+  createDefaultPreferences,
+  TimeFormat,
+} from '../../../constants/constants';
 import {AccountId, NumericChangeId} from '../../../types/common';
 import {
+  createAccountWithEmail,
   createChange,
   createServerInfo,
+  createSubmitRequirementResultInfo,
 } from '../../../test/test-data-generators';
 import {GrChangeListItem} from '../gr-change-list-item/gr-change-list-item';
 import {GrChangeListSection} from '../gr-change-list-section/gr-change-list-section';
-
-const basicFixture = fixtureFromElement('gr-change-list');
+import {fixture, assert} from '@open-wc/testing';
+import {html} from 'lit';
+import {testResolver} from '../../../test/common-test-setup';
+import {Timestamp} from '../../../api/rest-api';
 
 suite('gr-change-list basic tests', () => {
   let element: GrChangeList;
 
-  setup(() => {
-    element = basicFixture.instantiate();
+  setup(async () => {
+    element = await fixture(html`<gr-change-list></gr-change-list>`);
   });
 
   test('renders', async () => {
@@ -53,68 +50,91 @@
     };
     element.account = {_account_id: 1001 as AccountId};
     element.config = createServerInfo();
-    element.sections = [{results: new Array(1)}, {results: new Array(2)}];
+    element.sections = [
+      {
+        results: [{...createChange(), _number: 0 as NumericChangeId}],
+      },
+      {
+        results: [
+          {...createChange(), _number: 1 as NumericChangeId},
+          {...createChange(), _number: 2 as NumericChangeId},
+        ],
+      },
+    ];
     element.selectedIndex = 0;
-    element.changes = [
-      {...createChange(), _number: 0 as NumericChangeId},
-      {...createChange(), _number: 1 as NumericChangeId},
-      {...createChange(), _number: 2 as NumericChangeId},
+    await element.updateComplete;
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <gr-change-list-section> </gr-change-list-section>
+        <gr-change-list-section> </gr-change-list-section>
+        <table id="changeList"></table>
+      `
+    );
+  });
+
+  test('sections receive global startIndex', async () => {
+    element.selectedIndex = 0;
+    element.sections = [
+      {
+        results: [{...createChange(), _number: 0 as NumericChangeId}],
+      },
+      {
+        results: [
+          {...createChange(), _number: 1 as NumericChangeId},
+          {...createChange(), _number: 2 as NumericChangeId},
+        ],
+      },
+      {
+        results: [
+          {...createChange(), _number: 3 as NumericChangeId},
+          {...createChange(), _number: 4 as NumericChangeId},
+        ],
+      },
     ];
     await element.updateComplete;
-    expect(element).shadowDom.to.equal(/* HTML */ `
-      <gr-change-list-section> </gr-change-list-section>
-      <table id="changeList"></table>
-    `);
+
+    assert.deepEqual(
+      [...element.shadowRoot!.querySelectorAll('gr-change-list-section')].map(
+        section => section.startIndex
+      ),
+      [0, 1, 3]
+    );
   });
 
-  suite('test show change number not logged in', () => {
-    setup(async () => {
-      element = basicFixture.instantiate();
-      element.account = undefined;
-      element.preferences = undefined;
-      element.config = createServerInfo();
-      await element.updateComplete;
-    });
+  test('show change number disabled when not logged in', async () => {
+    element.account = undefined;
+    element.preferences = undefined;
+    element.config = createServerInfo();
+    await element.updateComplete;
 
-    test('show number disabled', () => {
-      assert.isFalse(element.showNumber);
-    });
+    assert.isFalse(element.showNumber);
   });
 
-  suite('test show change number preference enabled', () => {
-    setup(async () => {
-      element = basicFixture.instantiate();
-      element.preferences = {
-        legacycid_in_change_table: true,
-        time_format: TimeFormat.HHMM_12,
-        change_table: [],
-      };
-      element.account = {_account_id: 1001 as AccountId};
-      element.config = createServerInfo();
-      await element.updateComplete;
-    });
+  test('show legacy change num when legacycid preference enabled', async () => {
+    element.preferences = {
+      legacycid_in_change_table: true,
+      time_format: TimeFormat.HHMM_12,
+      change_table: [],
+    };
+    element.account = {_account_id: 1001 as AccountId};
+    element.config = createServerInfo();
+    await element.updateComplete;
 
-    test('show number enabled', () => {
-      assert.isTrue(element.showNumber);
-    });
+    assert.isTrue(element.showNumber);
   });
 
-  suite('test show change number preference disabled', () => {
-    setup(async () => {
-      element = basicFixture.instantiate();
-      // legacycid_in_change_table is not set when false.
-      element.preferences = {
-        time_format: TimeFormat.HHMM_12,
-        change_table: [],
-      };
-      element.account = {_account_id: 1001 as AccountId};
-      element.config = createServerInfo();
-      await element.updateComplete;
-    });
+  test('hide legacy change num if legacycid preference disabled', async () => {
+    // legacycid_in_change_table is not set when false.
+    element.preferences = {
+      time_format: TimeFormat.HHMM_12,
+      change_table: [],
+    };
+    element.account = {_account_id: 1001 as AccountId};
+    element.config = createServerInfo();
+    await element.updateComplete;
 
-    test('show number disabled', () => {
-      assert.isFalse(element.showNumber);
-    });
+    assert.isFalse(element.showNumber);
   });
 
   test('computeRelativeIndex', () => {
@@ -165,23 +185,36 @@
             {
               ...createChange(),
               _number: 0 as NumericChangeId,
-              labels: {Verified: {approved: {}}},
+              submit_requirements: [
+                {
+                  ...createSubmitRequirementResultInfo(),
+                  name: 'Verified',
+                },
+              ],
             },
             {
               ...createChange(),
               _number: 1 as NumericChangeId,
-              labels: {
-                Verified: {approved: {}},
-                'Code-Review': {approved: {}},
-              },
+              submit_requirements: [
+                {
+                  ...createSubmitRequirementResultInfo(),
+                  name: 'Verified',
+                },
+                {
+                  ...createSubmitRequirementResultInfo(),
+                  name: 'Code-Review',
+                },
+              ],
             },
             {
               ...createChange(),
               _number: 2 as NumericChangeId,
-              labels: {
-                Verified: {approved: {}},
-                'Library-Compliance': {approved: {}},
-              },
+              submit_requirements: [
+                {
+                  ...createSubmitRequirementResultInfo(),
+                  name: 'Library-Compliance',
+                },
+              ],
             },
           ],
         },
@@ -194,22 +227,7 @@
     sinon.stub(element, 'computeLabelNames');
     element.sections = [{results: new Array(1)}, {results: new Array(2)}];
     element.selectedIndex = 0;
-    element.preferences = {
-      legacycid_in_change_table: true,
-      time_format: TimeFormat.HHMM_12,
-      change_table: [
-        'Subject',
-        'Status',
-        'Owner',
-        'Reviewers',
-        'Comments',
-        'Repo',
-        'Branch',
-        'Updated',
-        'Size',
-        ' Status ',
-      ],
-    };
+    element.preferences = createDefaultPreferences();
     element.config = createServerInfo();
     element.changes = [
       {...createChange(), _number: 0 as NumericChangeId},
@@ -231,29 +249,24 @@
     );
     assert.equal(elementItems.length, 3);
 
-    assert.isTrue(elementItems[0].hasAttribute('selected'));
+    assert.isTrue(elementItems[0].selected);
     await element.updateComplete;
     pressKey(element, 'j');
     await element.updateComplete;
     await section.updateComplete;
 
     assert.equal(element.selectedIndex, 1);
-    assert.isTrue(elementItems[1].hasAttribute('selected'));
+    assert.isTrue(elementItems[1].selected);
     pressKey(element, 'j');
     await element.updateComplete;
     assert.equal(element.selectedIndex, 2);
-    assert.isTrue(elementItems[2].hasAttribute('selected'));
+    assert.isTrue(elementItems[2].selected);
 
-    const navStub = sinon.stub(GerritNav, 'navigateToChange');
+    const setUrlStub = sinon.stub(testResolver(navigationToken), 'setUrl');
     assert.equal(element.selectedIndex, 2);
     pressKey(element, Key.ENTER);
-    await waitUntil(() => navStub.callCount > 1);
-    await element.updateComplete;
-    assert.deepEqual(
-      navStub.lastCall.args[0],
-      {...createChange(), _number: 2 as NumericChangeId},
-      'Should navigate to /c/2/'
-    );
+    await waitUntil(() => setUrlStub.callCount >= 1);
+    assert.equal(setUrlStub.lastCall.firstArg, '/c/test-project/+/2');
 
     pressKey(element, 'k');
     await element.updateComplete;
@@ -261,15 +274,11 @@
 
     assert.equal(element.selectedIndex, 1);
 
-    const prevCount = navStub.callCount;
+    const prevCount = setUrlStub.callCount;
     pressKey(element, Key.ENTER);
 
-    await waitUntil(() => navStub.callCount > prevCount);
-    assert.deepEqual(
-      navStub.lastCall.args[0],
-      {...createChange(), _number: 1 as NumericChangeId},
-      'Should navigate to /c/1/'
-    );
+    await waitUntil(() => setUrlStub.callCount > prevCount);
+    assert.equal(setUrlStub.lastCall.firstArg, '/c/test-project/+/1');
 
     pressKey(element, 'k');
     pressKey(element, 'k');
@@ -277,6 +286,83 @@
     assert.equal(element.selectedIndex, 0);
   });
 
+  test('toggle checkbox keyboard shortcut', async () => {
+    element.userModel.setAccount({
+      ...createAccountWithEmail('abc@def.com'),
+      registered_on: '2015-03-12 18:32:08.000000000' as Timestamp,
+    });
+    await element.updateComplete;
+
+    const getCheckbox = (item: GrChangeListItem) =>
+      queryAndAssert<HTMLInputElement>(query(item, '.selection'), 'input');
+
+    sinon.stub(element, 'computeLabelNames');
+    element.sections = [{results: new Array(1)}, {results: new Array(2)}];
+    element.selectedIndex = 0;
+    element.preferences = createDefaultPreferences();
+    element.config = createServerInfo();
+    element.changes = [
+      {...createChange(), _number: 0 as NumericChangeId},
+      {...createChange(), _number: 1 as NumericChangeId},
+      {...createChange(), _number: 2 as NumericChangeId},
+    ];
+    // explicitly trigger sectionsChanged so that cursor stops are properly
+    // updated
+    await element.sectionsChanged();
+    await element.updateComplete;
+    const section = queryAndAssert<GrChangeListSection>(
+      element,
+      'gr-change-list-section'
+    );
+    await section.updateComplete;
+    const elementItems = queryAll<GrChangeListItem>(
+      section,
+      'gr-change-list-item'
+    );
+    assert.equal(elementItems.length, 3);
+
+    assert.isTrue(elementItems[0].selected);
+    await element.updateComplete;
+
+    pressKey(element, 'x');
+    await element.updateComplete;
+
+    assert.isTrue(getCheckbox(elementItems[0]).checked);
+
+    pressKey(element, 'x');
+    await element.updateComplete;
+    assert.isFalse(getCheckbox(elementItems[0]).checked);
+
+    pressKey(element, 'j');
+    await element.updateComplete;
+
+    assert.equal(element.selectedIndex, 1);
+    assert.isTrue(elementItems[1].selected);
+
+    pressKey(element, 'x');
+    await element.updateComplete;
+
+    assert.isTrue(getCheckbox(elementItems[1]).checked);
+
+    pressKey(element, 'x');
+    await element.updateComplete;
+    assert.isFalse(getCheckbox(elementItems[1]).checked);
+
+    pressKey(element, 'j');
+    await element.updateComplete;
+    assert.equal(element.selectedIndex, 2);
+    assert.isTrue(elementItems[2].selected);
+
+    pressKey(element, 'x');
+    await element.updateComplete;
+
+    assert.isTrue(getCheckbox(elementItems[2]).checked);
+
+    pressKey(element, 'x');
+    await element.updateComplete;
+    assert.isFalse(getCheckbox(elementItems[2]).checked);
+  });
+
   test('no changes', async () => {
     element.changes = [];
     await element.updateComplete;
@@ -315,7 +401,7 @@
 
     setup(async () => {
       stubFlags('isEnabled').returns(true);
-      element = basicFixture.instantiate();
+      element = await fixture(html`<gr-change-list></gr-change-list>`);
       element.sections = [{results: [{...createChange()}]}];
       element.account = {_account_id: 1001 as AccountId};
       element.preferences = {
@@ -347,7 +433,7 @@
 
     setup(async () => {
       stubFlags('isEnabled').returns(true);
-      element = basicFixture.instantiate();
+      element = await fixture(html`<gr-change-list></gr-change-list>`);
       element.sections = [{results: [{...createChange()}]}];
       element.account = {_account_id: 1001 as AccountId};
       element.preferences = {
@@ -363,7 +449,7 @@
           'Branch',
           'Updated',
           'Size',
-          ' Status ',
+          ColumnNames.STATUS2,
         ],
       };
       element.config = createServerInfo();
@@ -386,7 +472,7 @@
 
     setup(async () => {
       stubFlags('isEnabled').returns(true);
-      element = basicFixture.instantiate();
+      element = await fixture(html`<gr-change-list></gr-change-list>`);
       element.sections = [{results: [{...createChange()}]}];
       element.account = {_account_id: 1001 as AccountId};
       element.preferences = {
@@ -401,7 +487,7 @@
           'Branch',
           'Updated',
           'Size',
-          ' Status ',
+          ColumnNames.STATUS2,
         ],
       };
       element.config = createServerInfo();
@@ -419,15 +505,26 @@
         }
       }
     });
+
+    test('show default order not preferences order', async () => {
+      element.preferences = {
+        legacycid_in_change_table: true,
+        time_format: TimeFormat.HHMM_12,
+        change_table: ['Owner', 'Subject'],
+      };
+      element.config = createServerInfo();
+      await element.updateComplete;
+      assert.equal(element.visibleChangeTableColumns?.[0], 'Subject');
+      assert.equal(element.visibleChangeTableColumns?.[1], 'Owner');
+    });
   });
 
   test('obsolete column in preferences not visible', () => {
-    assert.isTrue(element._isColumnEnabled('Subject'));
-    assert.isFalse(element._isColumnEnabled('Assignee'));
+    assert.isTrue(element.isColumnEnabled('Subject'));
+    assert.isFalse(element.isColumnEnabled('Assignee'));
   });
 
-  test('showStar and showNumber', async () => {
-    element = basicFixture.instantiate();
+  test('loggedIn and showNumber', async () => {
     element.sections = [{results: [{...createChange()}], name: 'a'}];
     element.account = {_account_id: 1001 as AccountId};
     element.preferences = {
@@ -442,10 +539,11 @@
         'Branch',
         'Updated',
         'Size',
-        ' Status ',
+        ColumnNames.STATUS2,
       ],
     };
     element.config = createServerInfo();
+    element.userModel.setAccount(undefined);
     await element.updateComplete;
     const section = query<GrChangeListSection>(
       element,
@@ -459,7 +557,10 @@
     assert.isNotOk(query(query(section, 'gr-change-list-item'), '.star'));
     assert.isNotOk(query(query(section, 'gr-change-list-item'), '.number'));
 
-    element.showStar = true;
+    element.userModel.setAccount({
+      ...createAccountWithEmail('abc@def.com'),
+      registered_on: '2015-03-12 18:32:08.000000000' as Timestamp,
+    });
     await element.updateComplete;
     await section.updateComplete;
     assert.isOk(query(query(section, 'gr-change-list-item'), '.star'));
@@ -472,24 +573,38 @@
     assert.isOk(query(query(section, 'gr-change-list-item'), '.number'));
   });
 
-  suite('random column does not exist', () => {
-    let element: GrChangeList;
+  test('garbage columns in preference are not shown', async () => {
+    // This would only exist if somebody manually updated the config file.
+    element.account = {_account_id: 1001 as AccountId};
+    element.preferences = {
+      legacycid_in_change_table: true,
+      time_format: TimeFormat.HHMM_12,
+      change_table: ['Bad'],
+    };
+    await element.updateComplete;
 
-    /* This would only exist if somebody manually updated the config
-    file. */
-    setup(async () => {
-      element = basicFixture.instantiate();
-      element.account = {_account_id: 1001 as AccountId};
-      element.preferences = {
-        legacycid_in_change_table: true,
-        time_format: TimeFormat.HHMM_12,
-        change_table: ['Bad'],
-      };
-      await element.updateComplete;
-    });
+    assert.isNotOk(query<HTMLElement>(element, '.bad'));
+  });
 
-    test('bad column does not exist', () => {
-      assert.isNotOk(query<HTMLElement>(element, '.bad'));
-    });
+  test('Show new status with feature flag', async () => {
+    stubFlags('isEnabled').returns(true);
+    const element: GrChangeList = await fixture(
+      html`<gr-change-list></gr-change-list>`
+    );
+    element.sections = [{results: [{...createChange()}]}];
+    element.account = {_account_id: 1001 as AccountId};
+    element.preferences = {
+      change_table: [
+        'Status', // old status
+      ],
+    };
+    element.config = createServerInfo();
+    await element.updateComplete;
+    assert.isTrue(
+      element.visibleChangeTableColumns?.includes(ColumnNames.STATUS2),
+      'Show new status'
+    );
+    const section = queryAndAssert(element, 'gr-change-list-section');
+    queryAndAssert<HTMLElement>(section, '.status');
   });
 });
diff --git a/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help.ts b/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help.ts
index fd2a5d7..9c53fea 100644
--- a/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help.ts
@@ -1,26 +1,13 @@
 /**
  * @license
- * Copyright (C) 2018 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
 import '../../shared/gr-button/gr-button';
-import '../../shared/gr-icons/gr-icons';
 import {fireEvent} from '../../../utils/event-util';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, css, html} from 'lit';
-import {customElement} from 'lit/decorators';
+import {customElement} from 'lit/decorators.js';
 import {fontStyles} from '../../../styles/gr-font-styles';
 
 declare global {
@@ -53,10 +40,9 @@
           justify-content: center;
           width: 10em;
         }
-        #graphic iron-icon {
+        #graphic gr-icon {
           color: var(--gray-foreground);
-          height: 5em;
-          width: 5em;
+          font-size: 5em;
         }
         #graphic p {
           color: var(--deemphasized-text-color);
@@ -82,9 +68,10 @@
   }
 
   override render() {
-    return html` <div id="graphic">
+    return html`
+      <div id="graphic">
         <div id="circle">
-          <iron-icon id="icon" icon="gr-icons:zeroState"></iron-icon>
+          <gr-icon id="icon" icon="empty_dashboard"></gr-icon>
         </div>
         <p>No outgoing changes yet</p>
       </div>
@@ -96,7 +83,8 @@
           the step by step instructions.
         </p>
         <gr-button @click=${this._handleCreateTap}>Create Change</gr-button>
-      </div>`;
+      </div>
+    `;
   }
 
   /**
diff --git a/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help_test.ts b/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help_test.ts
index e170a74..d5ab511 100644
--- a/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help_test.ts
@@ -1,41 +1,53 @@
 /**
  * @license
- * Copyright (C) 2018 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-create-change-help';
 import {GrCreateChangeHelp} from './gr-create-change-help';
 import {mockPromise, queryAndAssert} from '../../../test/test-utils';
 import {GrButton} from '../../shared/gr-button/gr-button';
-import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
-
-const basicFixture = fixtureFromElement('gr-create-change-help');
+import {fixture, html, assert} from '@open-wc/testing';
 
 suite('gr-create-change-help tests', () => {
   let element: GrCreateChangeHelp;
 
   setup(async () => {
-    element = basicFixture.instantiate();
-    await flush();
+    element = await fixture(
+      html`<gr-create-change-help></gr-create-change-help>`
+    );
   });
 
   test('Create change tap', async () => {
     const promise = mockPromise();
     element.addEventListener('create-tap', () => promise.resolve());
-    MockInteractions.tap(queryAndAssert<GrButton>(element, 'gr-button'));
+    queryAndAssert<GrButton>(element, 'gr-button').click();
     await promise;
   });
+
+  test('render', () => {
+    assert.shadowDom.equal(
+      element,
+      /* prettier-ignore */ /* HTML */ `
+        <div id="graphic">
+          <div id="circle">
+            <gr-icon icon="empty_dashboard" id="icon"> </gr-icon>
+          </div>
+          <p>No outgoing changes yet</p>
+        </div>
+        <div id="help">
+          <h2 class="heading-3">Push your first change for code review</h2>
+          <p>
+            Pushing a change for review is easy, but a little different from other
+          git code review tools. Click on the \`Create Change' button and follow
+          the step by step instructions.
+          </p>
+          <gr-button aria-disabled="false" role="button" tabindex="0">
+            Create Change
+          </gr-button>
+        </div>
+      `
+    );
+  });
 });
diff --git a/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog.ts b/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog.ts
index c41ad70..567c508 100644
--- a/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog.ts
@@ -1,27 +1,15 @@
 /**
  * @license
- * Copyright (C) 2018 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
 import '../../shared/gr-dialog/gr-dialog';
 import '../../shared/gr-overlay/gr-overlay';
 import '../../shared/gr-shell-command/gr-shell-command';
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, css, html} from 'lit';
-import {customElement, property, query} from 'lit/decorators';
+import {customElement, property, query} from 'lit/decorators.js';
 
 enum Commands {
   CREATE = 'git commit',
diff --git a/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog_test.ts b/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog_test.ts
index ea367f7..96ec9eb 100644
--- a/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog_test.ts
@@ -1,35 +1,78 @@
 /**
  * @license
- * Copyright (C) 2018 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import {fixture, html, assert} from '@open-wc/testing';
+import '../../../test/common-test-setup';
 import './gr-create-commands-dialog';
 import {GrCreateCommandsDialog} from './gr-create-commands-dialog';
 
-const basicFixture = fixtureFromElement('gr-create-commands-dialog');
-
 suite('gr-create-commands-dialog tests', () => {
   let element: GrCreateCommandsDialog;
 
-  setup(() => {
-    element = basicFixture.instantiate();
+  setup(async () => {
+    element = await fixture(
+      html`<gr-create-commands-dialog></gr-create-commands-dialog>`
+    );
   });
 
   test('branch', () => {
     element.branch = 'master';
     assert.equal(element.branch, 'master');
   });
+
+  test('render', () => {
+    // prettier and shadowDom assert don't agree about wrapping in the <p> tags
+    assert.shadowDom.equal(
+      element,
+      /* prettier-ignore */ /* HTML */ `
+      <gr-overlay
+        aria-hidden="true"
+        id="commandsOverlay"
+        style="outline: none; display: none;"
+        tabindex="-1"
+        with-backdrop=""
+      >
+        <gr-dialog
+          cancel-label=""
+          confirm-label="Done"
+          confirm-on-enter=""
+          id="commandsDialog"
+          role="dialog"
+        >
+          <div class="header" slot="header">Create change commands</div>
+          <div class="main" slot="main">
+            <ol>
+              <li>
+                <p>Make the changes to the files on your machine</p>
+              </li>
+              <li>
+                <p>If you are making a new commit use</p>
+                <gr-shell-command> </gr-shell-command>
+                <p>Or to amend an existing commit use</p>
+                <gr-shell-command> </gr-shell-command>
+                <p>
+                  Please make sure you add a commit message as it becomes the
+                description for your change.
+                </p>
+              </li>
+              <li>
+                <p>Push the change for code review</p>
+                <gr-shell-command> </gr-shell-command>
+              </li>
+              <li>
+                <p>
+                  Close this dialog and you should be able to see your recently
+                created change in the 'Outgoing changes' section on the 'Your
+                changes' page.
+                </p>
+              </li>
+            </ol>
+          </div>
+        </gr-dialog>
+      </gr-overlay>
+    `
+    );
+  });
 });
diff --git a/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog.ts b/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog.ts
index c686d70..983a0d9 100644
--- a/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog.ts
@@ -1,20 +1,8 @@
 /**
  * @license
- * Copyright (C) 2018 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
 import '../../shared/gr-dialog/gr-dialog';
 import '../../shared/gr-overlay/gr-overlay';
 import '../../shared/gr-repo-branch-picker/gr-repo-branch-picker';
@@ -22,7 +10,7 @@
 import {RepoName, BranchName} from '../../../types/common';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, html} from 'lit';
-import {customElement, state, query} from 'lit/decorators';
+import {customElement, state, query} from 'lit/decorators.js';
 import {assertIsDefined} from '../../../utils/common-util';
 import {BindValueChangeEvent} from '../../../types/events';
 
diff --git a/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog_test.ts b/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog_test.ts
new file mode 100644
index 0000000..44b3183
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog_test.ts
@@ -0,0 +1,44 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import {fixture, html, assert} from '@open-wc/testing';
+import './gr-create-destination-dialog';
+import {GrCreateDestinationDialog} from './gr-create-destination-dialog';
+
+suite('gr-create-destination-dialog tests', () => {
+  let element: GrCreateDestinationDialog;
+
+  setup(async () => {
+    element = await fixture(
+      html`<gr-create-destination-dialog></gr-create-destination-dialog>`
+    );
+  });
+
+  test('render', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <gr-overlay
+          aria-hidden="true"
+          id="createOverlay"
+          style="outline: none; display: none;"
+          tabindex="-1"
+          with-backdrop=""
+        >
+          <gr-dialog confirm-label="View commands" disabled="" role="dialog">
+            <div class="header" slot="header">Create change</div>
+            <div class="main" slot="main">
+              <gr-repo-branch-picker> </gr-repo-branch-picker>
+              <p>
+                If you haven't done so, you will need to clone the repository.
+              </p>
+            </div>
+          </gr-dialog>
+        </gr-overlay>
+      `
+    );
+  });
+});
diff --git a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.ts b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.ts
index 6929ca2..f11fe1c4 100644
--- a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.ts
@@ -1,20 +1,8 @@
 /**
  * @license
- * Copyright (C) 2015 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2015 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
 import '../gr-change-list/gr-change-list';
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-dialog/gr-dialog';
@@ -23,12 +11,6 @@
 import '../gr-create-change-help/gr-create-change-help';
 import '../gr-create-destination-dialog/gr-create-destination-dialog';
 import '../gr-user-header/gr-user-header';
-import {
-  GerritNav,
-  OUTGOING,
-  UserDashboard,
-  YOUR_TURN,
-} from '../../core/gr-navigation/gr-navigation';
 import {getAppContext} from '../../../services/app-context';
 import {changeIsOpen} from '../../../utils/change-util';
 import {parseDate} from '../../../utils/date-util';
@@ -39,7 +21,6 @@
   PreferencesInput,
   RepoName,
 } from '../../../types/common';
-import {AppElementDashboardParams} from '../../gr-app-types';
 import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
 import {GrCreateCommandsDialog} from '../gr-create-commands-dialog/gr-create-commands-dialog';
 import {
@@ -48,17 +29,34 @@
 } from '../gr-create-destination-dialog/gr-create-destination-dialog';
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
 import {ChangeStarToggleStarDetail} from '../../shared/gr-change-star/gr-change-star';
-import {DashboardViewState} from '../../../types/types';
-import {firePageError, fireTitleChange} from '../../../utils/event-util';
-import {GerritView} from '../../../services/router/router-model';
+import {
+  fireAlert,
+  fireEvent,
+  firePageError,
+  fireTitleChange,
+} from '../../../utils/event-util';
 import {RELOAD_DASHBOARD_INTERVAL_MS} from '../../../constants/constants';
 import {ChangeListSection} from '../gr-change-list/gr-change-list';
 import {a11yStyles} from '../../../styles/gr-a11y-styles';
 import {sharedStyles} from '../../../styles/shared-styles';
-import {LitElement, PropertyValues, html, css} from 'lit';
-import {customElement, property, state, query} from 'lit/decorators';
-import {ValueChangedEvent} from '../../../types/events';
+import {LitElement, html, css, nothing} from 'lit';
+import {customElement, property, state, query} from 'lit/decorators.js';
 import {assertIsDefined} from '../../../utils/common-util';
+import {Shortcut} from '../../../services/shortcuts/shortcuts-config';
+import {ShortcutController} from '../../lit/shortcut-controller';
+import {
+  dashboardViewModelToken,
+  DashboardViewState,
+} from '../../../models/views/dashboard';
+import {createSearchUrl} from '../../../models/views/search';
+import {subscribe} from '../../lit/subscription-controller';
+import {resolve} from '../../../models/dependency';
+import {
+  getUserDashboard,
+  OUTGOING,
+  UserDashboard,
+  YOUR_TURN,
+} from '../../../utils/dashboard-util';
 
 const PROJECT_PLACEHOLDER_PATTERN = /\${project}/g;
 
@@ -85,17 +83,14 @@
   @query('#confirmDeleteOverlay') protected confirmDeleteOverlay?: GrOverlay;
 
   @property({type: Object})
-  account: AccountDetailInfo | null = null;
+  account?: AccountDetailInfo;
 
   @property({type: Object})
   preferences?: PreferencesInput;
 
-  @property({type: Object})
+  @state()
   viewState?: DashboardViewState;
 
-  @property({type: Object})
-  params?: AppElementDashboardParams;
-
   // private but used in test
   @state() results?: ChangeListSection[];
 
@@ -108,18 +103,35 @@
   // private but used in test
   @state() showNewUserHelp = false;
 
-  // private but used in test
-  @state() selectedChangeIndex?: number;
-
   private reporting = getAppContext().reportingService;
 
   private readonly restApiService = getAppContext().restApiService;
 
+  private readonly userModel = getAppContext().userModel;
+
+  private readonly getViewModel = resolve(this, dashboardViewModelToken);
+
   private lastVisibleTimestampMs = 0;
 
+  private readonly shortcuts = new ShortcutController(this);
+
   constructor() {
     super();
+    subscribe(
+      this,
+      () => this.userModel.account$,
+      x => (this.account = x)
+    );
+    subscribe(
+      this,
+      () => this.getViewModel().state$,
+      x => {
+        this.viewState = x;
+        this.reload();
+      }
+    );
     this.addEventListener('reload', () => this.reload());
+    this.shortcuts.addAbstract(Shortcut.UP_TO_DASHBOARD, () => this.reload());
   }
 
   private readonly visibilityChangeListener = () => {
@@ -193,6 +205,7 @@
   }
 
   override render() {
+    if (!this.viewState) return nothing;
     return html`
       ${this.renderBanner()} ${this.renderContent()}
       <gr-overlay id="confirmDeleteOverlay" with-backdrop>
@@ -258,14 +271,10 @@
         ${this.renderUserHeader()}
         <h1 class="assistive-tech-only">Dashboard</h1>
         <gr-change-list
-          ?showStar=${true}
           .account=${this.account}
           .preferences=${this.preferences}
-          .selectedIndex=${this.selectedChangeIndex}
           .sections=${this.results}
-          @selected-index-changed=${(e: ValueChangedEvent<number>) => {
-            this.handleSelectedIndexChanged(e);
-          }}
+          .usp=${'dashboard'}
           @toggle-star=${(e: CustomEvent<ChangeStarToggleStarDetail>) => {
             this.handleToggleStar(e);
           }}
@@ -283,17 +292,15 @@
 
   private renderUserHeader() {
     if (
-      !this.params ||
-      this.params.view !== GerritView.DASHBOARD ||
-      !!this.params.project ||
-      !this.params.user ||
-      this.params.user === 'self'
+      !!this.viewState?.project ||
+      !this.viewState?.user ||
+      this.viewState?.user === 'self'
     ) {
       return;
     }
 
     return html`
-      <gr-user-header .userId=${this.params?.user}></gr-user-header>
+      <gr-user-header .userId=${this.viewState?.user}></gr-user-header>
     `;
   }
 
@@ -309,16 +316,6 @@
     `;
   }
 
-  override updated(changedProperties: PropertyValues) {
-    if (changedProperties.has('params')) {
-      this.paramsChanged();
-    }
-
-    if (changedProperties.has('selectedChangeIndex')) {
-      this.selectedChangeIndexChanged();
-    }
-  }
-
   private loadPreferences() {
     return this.restApiService.getLoggedIn().then(loggedIn => {
       if (loggedIn) {
@@ -334,11 +331,12 @@
   // private but used in test
   getProjectDashboard(
     project: RepoName,
-    dashboard: DashboardId
+    dashboard?: DashboardId
   ): Promise<UserDashboard | undefined> {
     const errFn = (response?: Response | null) => {
       firePageError(response);
     };
+    assertIsDefined(dashboard, 'project dashboard must have id');
     return this.restApiService
       .getDashboard(project, dashboard, errFn)
       .then(response => {
@@ -369,53 +367,20 @@
     return 'Dashboard for ' + user;
   }
 
-  private isViewActive(params: AppElementDashboardParams) {
-    return params.view === GerritView.DASHBOARD;
-  }
-
-  private selectedChangeIndexChanged() {
-    if (
-      !this.params ||
-      !this.isViewActive(this.params) ||
-      this.selectedChangeIndex === undefined
-    )
-      return;
-    if (!this.viewState) throw new Error('view state undefined');
-    if (!this.params.user) throw new Error('user for dashboard is undefined');
-    this.viewState[this.params.user] = this.selectedChangeIndex;
-  }
-
-  // private but used in test
-  paramsChanged() {
-    if (
-      this.params &&
-      this.isViewActive(this.params) &&
-      this.params.user &&
-      this.viewState
-    )
-      this.selectedChangeIndex = this.viewState[this.params.user] || 0;
-    return this.reload();
-  }
-
   /**
    * Reloads the element.
    *
    * private but used in test
    */
   reload() {
-    if (!this.params || !this.isViewActive(this.params)) {
-      return Promise.resolve();
-    }
+    if (!this.viewState) return Promise.resolve();
     this.loading = true;
-    const {project, dashboard, title, user, sections} = this.params;
+    const {project, dashboard, title, user, sections} = this.viewState;
+
     const dashboardPromise: Promise<UserDashboard | undefined> = project
       ? this.getProjectDashboard(project, dashboard)
       : Promise.resolve(
-          GerritNav.getUserDashboard(
-            user,
-            sections,
-            title || this.computeTitle(user)
-          )
+          getUserDashboard(user, sections, title || this.computeTitle(user))
         );
     // Checking `this.account` to make sure that the user is logged in.
     // Otherwise sending a query for 'owner:self' will result in an error.
@@ -433,7 +398,7 @@
       })
       .catch(err => {
         fireTitleChange(this, title || this.computeTitle(user));
-        this.reporting.error(err);
+        this.reporting.error('Dashboard reload', err);
       })
       .finally(() => {
         this.loading = false;
@@ -510,7 +475,7 @@
    * And then we want to emphasize the changes where the waiting time is larger.
    */
   private maybeSortResults(name: string, results: ChangeInfo[]) {
-    const userId = this.account && this.account._account_id;
+    const userId = this.account?._account_id;
     const sortedResults = [...results];
     if (name === YOUR_TURN.name && userId) {
       sortedResults.sort((c1, c2) => {
@@ -543,11 +508,16 @@
   }
 
   // private but used in test
-  handleToggleStar(e: CustomEvent<ChangeStarToggleStarDetail>) {
-    this.restApiService.saveChangeStarred(
+  async handleToggleStar(e: CustomEvent<ChangeStarToggleStarDetail>) {
+    const msg = e.detail.starred
+      ? 'Starring change...'
+      : 'Unstarring change...';
+    fireAlert(this, msg);
+    await this.restApiService.saveChangeStarred(
       e.detail.change._number,
       e.detail.starred
     );
+    fireEvent(this, 'hide-alert');
     if (e.detail.starred) {
       this.reporting.reportInteraction('change-starred-from-dashboard');
     }
@@ -573,9 +543,7 @@
    */
   maybeShowDraftsBanner() {
     this.showDraftsBanner = false;
-    if (!(this.params?.user === 'self')) {
-      return;
-    }
+    if (!(this.viewState?.user === 'self')) return;
 
     if (!this.results) {
       throw new Error('this.results must be set. restAPI returned undefined');
@@ -584,16 +552,12 @@
     const draftSection = this.results.find(
       section => section.query === 'has:draft'
     );
-    if (!draftSection || !draftSection.results.length) {
-      return;
-    }
+    if (!draftSection || !draftSection.results.length) return;
 
     const closedChanges = draftSection.results.filter(
       change => !changeIsOpen(change)
     );
-    if (!closedChanges.length) {
-      return;
-    }
+    if (!closedChanges.length) return;
 
     this.showDraftsBanner = true;
   }
@@ -620,7 +584,7 @@
   }
 
   private computeDraftsLink() {
-    return GerritNav.getUrlForSearchQuery('has:draft -is:open');
+    return createSearchUrl({query: 'has:draft -is:open'});
   }
 
   private handleCreateChangeTap() {
@@ -635,10 +599,6 @@
     this.commandsDialog.branch = e.detail.branch;
     this.commandsDialog.open();
   }
-
-  private handleSelectedIndexChanged(e: ValueChangedEvent<number>) {
-    this.selectedChangeIndex = e.detail.value;
-  }
 }
 
 declare global {
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 e5aff61..e7aaa21 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
@@ -1,21 +1,9 @@
 /**
  * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-dashboard-view';
 import {GrDashboardView} from './gr-dashboard-view';
 import {GerritView} from '../../../services/router/router-model';
@@ -41,19 +29,18 @@
   RepoName,
   Timestamp,
 } from '../../../types/common';
-import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
 import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
 import {GrCreateChangeHelp} from '../gr-create-change-help/gr-create-change-help';
 import {PageErrorEvent} from '../../../types/events';
-import {fixture, html} from '@open-wc/testing-helpers';
+import {fixture, html, assert} from '@open-wc/testing';
 import {SinonStubbedMember} from 'sinon';
 import {RestApiService} from '../../../services/gr-rest-api/gr-rest-api';
+import {GrButton} from '../../shared/gr-button/gr-button';
 
 suite('gr-dashboard-view tests', () => {
   let element: GrDashboardView;
 
-  let paramsChangedPromise: Promise<any>;
   let getChangesStub: SinonStubbedMember<
     RestApiService['getChangesForMultipleQueries']
   >;
@@ -66,33 +53,84 @@
         registered_on: '2015-03-12 18:32:08.000000000' as Timestamp,
       })
     );
-    stubRestApi('getAccountStatus').returns(Promise.resolve(undefined));
 
     element = await fixture<GrDashboardView>(html`
       <gr-dashboard-view></gr-dashboard-view>
     `);
 
     await element.updateComplete;
-    let resolver: (value?: any) => void;
-    paramsChangedPromise = new Promise(resolve => {
-      resolver = resolve;
-    });
-    const paramsChanged = element.paramsChanged.bind(element);
-    sinon
-      .stub(element, 'paramsChanged')
-      .callsFake(() => paramsChanged().then(() => resolver()));
+  });
+
+  test('render', async () => {
+    element.viewState = {
+      view: GerritView.DASHBOARD,
+      user: 'self',
+      sections: [
+        {name: 'test1', query: 'test1', hideIfEmpty: true},
+        {name: 'test2', query: 'test2', hideIfEmpty: true},
+      ],
+    };
+    getChangesStub.returns(Promise.resolve([[createChange()]]));
+    await element.reload();
+    element.loading = false;
+    stubFlags('isEnabled').returns(true);
+    element.requestUpdate();
+    await element.updateComplete;
+
+    assert.shadowDom.equal(
+      element,
+      /* prettier-ignore */ /* HTML */ `
+        <div class="loading" hidden="">Loading...</div>
+        <div>
+          <h1 class="assistive-tech-only">Dashboard</h1>
+          <gr-change-list>
+            <div id="emptyOutgoing" slot="outgoing-slot">No changes</div>
+            <div id="emptyYourTurn" slot="your-turn-slot">
+              <span> No changes need your attention &nbsp🎉 </span>
+            </div>
+          </gr-change-list>
+        </div>
+        <gr-overlay
+          aria-hidden="true"
+          id="confirmDeleteOverlay"
+          style="outline: none; display: none;"
+          tabindex="-1"
+          with-backdrop=""
+        >
+          <gr-dialog
+            confirm-label="Delete"
+            id="confirmDeleteDialog"
+            role="dialog"
+          >
+            <div class="header" slot="header">Delete comments</div>
+            <div class="main" slot="main">
+              Are you sure you want to delete all your draft comments in closed
+            changes? This action cannot be undone.
+            </div>
+          </gr-dialog>
+        </gr-overlay>
+        <gr-create-destination-dialog id="destinationDialog">
+        </gr-create-destination-dialog>
+        <gr-create-commands-dialog id="commandsDialog">
+        </gr-create-commands-dialog>
+      `
+    );
   });
 
   suite('bulk actions', () => {
     setup(async () => {
-      const sections = [
-        {name: 'test1', query: 'test1', hideIfEmpty: true},
-        {name: 'test2', query: 'test2', hideIfEmpty: true},
-      ];
+      element.viewState = {
+        view: GerritView.DASHBOARD,
+        user: 'user',
+        sections: [
+          {name: 'test1', query: 'test1', hideIfEmpty: true},
+          {name: 'test2', query: 'test2', hideIfEmpty: true},
+        ],
+      };
       getChangesStub.returns(Promise.resolve([[createChange()]]));
-      await element.fetchDashboardChanges({sections}, false);
-      element.loading = false;
       stubFlags('isEnabled').returns(true);
+      await element.reload();
+      element.loading = false;
       element.requestUpdate();
       await element.updateComplete;
     });
@@ -103,19 +141,14 @@
           query(query(element, 'gr-change-list'), 'gr-change-list-section'),
           'gr-change-list-item'
         ),
-        '.selection > input'
+        '.selection > label > input'
       );
-      MockInteractions.tap(checkbox);
+      checkbox.click();
       await waitUntil(() => checkbox.checked);
 
       getChangesStub.restore();
       getChangesStub.returns(Promise.resolve([[createChange()]]));
 
-      element.params = {
-        view: GerritView.DASHBOARD,
-        user: 'notself',
-        dashboard: '' as DashboardId,
-      };
       await element.reload();
       await element.updateComplete;
       assert.isTrue(checkbox.checked);
@@ -123,9 +156,20 @@
   });
 
   suite('drafts banner functionality', () => {
+    setup(async () => {
+      element.viewState = {
+        view: GerritView.DASHBOARD,
+        user: 'self',
+        sections: [
+          {name: 'test1', query: 'test1', hideIfEmpty: true},
+          {name: 'test2', query: 'test2', hideIfEmpty: true},
+        ],
+      };
+    });
+
     suite('maybeShowDraftsBanner', () => {
       test('not dashboard/self', () => {
-        element.params = {
+        element.viewState = {
           view: GerritView.DASHBOARD,
           user: 'notself',
           dashboard: '' as DashboardId,
@@ -136,7 +180,7 @@
 
       test('no drafts at all', () => {
         element.results = [];
-        element.params = {
+        element.viewState = {
           view: GerritView.DASHBOARD,
           user: 'self',
           dashboard: '' as DashboardId,
@@ -150,7 +194,7 @@
         element.results = [
           {countLabel: '', name: '', query: 'has:draft', results: [openChange]},
         ];
-        element.params = {
+        element.viewState = {
           view: GerritView.DASHBOARD,
           user: 'self',
           dashboard: '' as DashboardId,
@@ -170,7 +214,7 @@
           },
         ];
         assert.isFalse(changeIsOpen(element.results[0].results[0]));
-        element.params = {
+        element.viewState = {
           view: GerritView.DASHBOARD,
           user: 'self',
           dashboard: '' as DashboardId,
@@ -198,7 +242,7 @@
       element.showDraftsBanner = true;
       await element.updateComplete;
 
-      MockInteractions.tap(queryAndAssert(element, '.banner .delete'));
+      queryAndAssert<GrButton>(element, '.banner .delete').click();
       assert.isTrue(handleOpenDeleteDialogStub.called);
     });
 
@@ -223,9 +267,10 @@
 
       // Open confirmation dialog and tap confirm button.
       await queryAndAssert<GrOverlay>(element, '#confirmDeleteOverlay').open();
-      MockInteractions.tap(
-        queryAndAssert<GrDialog>(element, '#confirmDeleteDialog').confirmButton!
-      );
+      queryAndAssert<GrDialog>(
+        element,
+        '#confirmDeleteDialog'
+      ).confirmButton!.click();
       await element.updateComplete;
       assert.isTrue(deleteStub.calledWithExactly('-is:open'));
       assert.isTrue(
@@ -273,27 +318,10 @@
     });
   });
 
-  suite('_isViewActive', () => {
-    test('nothing happens when user param is falsy', async () => {
-      element.params = undefined;
-      await element.updateComplete;
-      assert.equal(getChangesStub.callCount, 0);
-    });
-
-    test('content is refreshed when user param is updated', async () => {
-      element.params = {
-        view: GerritView.DASHBOARD,
-        user: 'self',
-        dashboard: '' as DashboardId,
-      };
-      await paramsChangedPromise;
-      assert.isTrue(getChangesStub.called);
-    });
-  });
-
   suite('selfOnly sections', () => {
     test('viewing self dashboard includes selfOnly sections', async () => {
-      element.params = {
+      element.account = undefined;
+      element.viewState = {
         view: GerritView.DASHBOARD,
         user: 'self',
         dashboard: '' as DashboardId,
@@ -302,13 +330,13 @@
           {name: '', query: '2', selfOnly: true},
         ],
       };
-      await paramsChangedPromise;
+      await element.reload();
       assert.isTrue(getChangesStub.calledWith(undefined, ['1', '2']));
     });
 
     test('viewing dashboard when logged in includes owner:self query', async () => {
       element.account = createAccountDetailWithId(1);
-      element.params = {
+      element.viewState = {
         view: GerritView.DASHBOARD,
         user: 'self',
         dashboard: '' as DashboardId,
@@ -317,14 +345,14 @@
           {name: '', query: '2', selfOnly: true},
         ],
       };
-      await paramsChangedPromise;
+      await element.reload();
       assert.isTrue(
         getChangesStub.calledWith(undefined, ['1', '2', 'owner:self limit:1'])
       );
     });
 
     test("viewing another user's dashboard omits selfOnly sections", async () => {
-      element.params = {
+      element.viewState = {
         view: GerritView.DASHBOARD,
         user: 'user',
         dashboard: '' as DashboardId,
@@ -333,13 +361,13 @@
           {name: '', query: '2', selfOnly: true},
         ],
       };
-      await paramsChangedPromise;
+      await element.reload();
       assert.isTrue(getChangesStub.calledWith(undefined, ['1']));
     });
   });
 
   test('suffixForDashboard is included in getChanges query', async () => {
-    element.params = {
+    element.viewState = {
       view: GerritView.DASHBOARD,
       dashboard: '' as DashboardId,
       sections: [
@@ -347,7 +375,7 @@
         {name: '', query: '2', suffixForDashboard: 'suffix'},
       ],
     };
-    await paramsChangedPromise;
+    await element.reload();
     assert.isTrue(getChangesStub.calledWith(undefined, ['1', '2 suffix']));
   });
 
@@ -440,7 +468,7 @@
     assert.isNotOk(element.results![1].emptyStateSlotName);
   });
 
-  test('toggling star will update change everywhere', () => {
+  test('toggling star will update change everywhere', async () => {
     // It is important that the same change is represented by multiple objects
     // and all are updated.
     const change = {...createChange(), id: '5' as ChangeInfoId, starred: false};
@@ -464,7 +492,7 @@
       },
     ];
 
-    element.handleToggleStar(
+    await element.handleToggleStar(
       new CustomEvent('toggle-star', {
         detail: {
           change,
@@ -479,6 +507,9 @@
   });
 
   test('showNewUserHelp', async () => {
+    element.viewState = {
+      view: GerritView.DASHBOARD,
+    };
     element.loading = false;
     element.showNewUserHelp = false;
     await element.updateComplete;
@@ -506,11 +537,11 @@
   });
 
   test('gr-user-header', async () => {
-    element.params = undefined;
+    element.viewState = undefined;
     await element.updateComplete;
     assert.isNotOk(query(element, 'gr-user-header'));
 
-    element.params = {
+    element.viewState = {
       view: GerritView.DASHBOARD,
       dashboard: '' as DashboardId,
       user: 'self',
@@ -519,7 +550,7 @@
     assert.isNotOk(query(element, 'gr-user-header'));
 
     element.loading = false;
-    element.params = {
+    element.viewState = {
       view: GerritView.DASHBOARD,
       dashboard: '' as DashboardId,
       user: 'user',
@@ -527,7 +558,7 @@
     await element.updateComplete;
     assert.isOk(query(element, 'gr-user-header'));
 
-    element.params = {
+    element.viewState = {
       view: GerritView.DASHBOARD,
       dashboard: '' as DashboardId,
       project: 'p' as RepoName,
@@ -552,16 +583,16 @@
       assert.strictEqual((e as PageErrorEvent).detail.response, response);
       promise.resolve();
     });
-    element.params = {
+    element.viewState = {
       view: GerritView.DASHBOARD,
       dashboard: 'dashboard' as DashboardId,
       project: 'project' as RepoName,
       user: '',
     };
-    await Promise.all([paramsChangedPromise, promise]);
+    await Promise.all([element.reload(), promise]);
   });
 
-  test('params change triggers dashboardDisplayed()', async () => {
+  test('viewState change triggers dashboardDisplayed()', async () => {
     stubRestApi('getDashboard').returns(
       Promise.resolve({
         id: '' as DashboardId,
@@ -577,42 +608,13 @@
     );
     getChangesStub.returns(Promise.resolve([]));
     const dashboardDisplayedStub = stubReporting('dashboardDisplayed');
-    element.params = {
+    element.viewState = {
       view: GerritView.DASHBOARD,
       dashboard: 'dashboard' as DashboardId,
       project: 'project' as RepoName,
       user: '',
     };
-    await paramsChangedPromise;
+    await element.reload();
     assert.isTrue(dashboardDisplayedStub.calledOnce);
   });
-
-  test('selectedChangeIndex is derived from the params', async () => {
-    stubRestApi('getDashboard').returns(
-      Promise.resolve({
-        id: '' as DashboardId,
-        project: 'project' as RepoName,
-        defining_project: '' as RepoName,
-        ref: '',
-        path: '',
-        url: '',
-        title: 'title',
-        foreach: 'foreach for ${project}',
-        sections: [],
-      })
-    );
-    element.viewState = {
-      101001: 23,
-    };
-    element.params = {
-      view: GerritView.DASHBOARD,
-      dashboard: 'dashboard' as DashboardId,
-      project: 'project' as RepoName,
-      user: '101001',
-    };
-    await element.updateComplete;
-    stubReporting('dashboardDisplayed');
-    await paramsChangedPromise;
-    assert.equal(element.selectedChangeIndex, 23);
-  });
 });
diff --git a/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header.ts b/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header.ts
index dec1656..e27274b 100644
--- a/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header.ts
@@ -1,21 +1,8 @@
 /**
  * @license
- * Copyright (C) 2018 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {RepoName} from '../../../types/common';
 import {WebLinkInfo} from '../../../types/diff';
 import {getAppContext} from '../../../services/app-context';
@@ -23,7 +10,8 @@
 import {fontStyles} from '../../../styles/gr-font-styles';
 import {dashboardHeaderStyles} from '../../../styles/dashboard-header-styles';
 import {LitElement, css, html, PropertyValues} from 'lit';
-import {customElement, property} from 'lit/decorators';
+import {customElement, property} from 'lit/decorators.js';
+import {createRepoUrl} from '../../../models/views/repo';
 
 @customElement('gr-repo-header')
 export class GrRepoHeader extends LitElement {
@@ -85,15 +73,15 @@
   }
 
   _repoChanged() {
-    const repoName = this.repo;
-    if (!repoName) {
+    const repo = this.repo;
+    if (!repo) {
       this._repoUrl = null;
       return;
     }
 
-    this._repoUrl = GerritNav.getUrlForRepo(repoName);
+    this._repoUrl = createRepoUrl({repo});
 
-    this.restApiService.getRepo(repoName).then(repo => {
+    this.restApiService.getRepo(repo).then(repo => {
       if (!repo?.web_links) return;
       this._webLinks = repo.web_links;
     });
diff --git a/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header_test.ts b/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header_test.ts
index 56ad8bc..9054474 100644
--- a/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header_test.ts
@@ -1,44 +1,52 @@
 /**
  * @license
- * Copyright (C) 2018 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.