Merge branch 'stable-3.6'

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

Release-Notes: skip
Change-Id: I958179943979ba529b01d863add9e9ba55242e86
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/config-labels.txt b/Documentation/config-labels.txt
index 3fa84b1..05e32ab 100644
--- a/Documentation/config-labels.txt
+++ b/Documentation/config-labels.txt
@@ -278,11 +278,10 @@
 [[label_copyAnyScore]]
 === `label.Label-Name.copyAnyScore`
 
-*DEPRECATED: use `is:ANY` predicate in
-link:config-labels.html#label_copyCondition[copyCondition] instead*
+*DEPRECATED:* Use the link:#is_any[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.
+Defaults to false.
 
 [[label_copyCondition]]
 === `label.Label-Name.copyCondition`
@@ -296,24 +295,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 +411,22 @@
 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'.
+Files that are renamed in the new patch set are counted as a deletion
+of the file at the old path and an addition of the file at the new
+path. This means, if there are renames, the list of files did change
+and this predicate doesn't match.
+
+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
@@ -370,125 +460,72 @@
 [[label_copyMinScore]]
 === `label.Label-Name.copyMinScore`
 
-*DEPRECATED: use `is:MIN` predicate in
-link:config-labels.html#label_copyCondition[copyCondition] instead*
+*DEPRECATED:* Use the link:#is_min[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.
+Defaults to false.
 
 [[label_copyMaxScore]]
 === `label.Label-Name.copyMaxScore`
 
-*DEPRECATED: use `is:MAX` predicate in
-link:config-labels.html#label_copyCondition[copyCondition] instead*
+*DEPRECATED:* Use the link:#is_max[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.
+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.
+*DEPRECATED:* Use the link:#has_unchanged_files[has:unchanged-files]
+predicate in link:config-labels.html#label_copyCondition[copyCondition]
+instead.
 
 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.
+*DEPRECATED:* Use the
+link:#merge_first_parent_update[changekind:MERGE_FIRST_PARENT_UPDATE]
+predicate in link:config-labels.html#label_copyCondition[copyCondition]
+instead.
 
 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.
+*DEPRECATED:* Use the link:#trivial_rebase[changekind:TRIVIAL_REBASE]
+predicate in link:config-labels.html#label_copyCondition[copyCondition]
+instead.
 
 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.
+*DEPRECATED:* Use the link:#no_code_change[changekind:NO_CODE_CHANGE]
+predicate in link:config-labels.html#label_copyCondition[copyCondition]
+instead.
 
 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.
+*DEPRECATED:* Use the link:#no_change[changekind:NO_CHANGE]
+predicate in link:config-labels.html#label_copyCondition[copyCondition]
+instead.
 
 Defaults to true.
 
 [[label_copyValue]]
 === `label.Label-Name.copyValue`
 
-*DEPRECATED: use `is:<value>` predicate in
-link:config-labels.html#label_copyCondition[copyCondition] instead*
+*DEPRECATED:* Use the link:#is_value[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.
+By default not set.
 
 [[label_canOverride]]
 === `label.Label-Name.canOverride`
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-plugins.txt b/Documentation/dev-plugins.txt
index 797abaf..ca72f8b 100644
--- a/Documentation/dev-plugins.txt
+++ b/Documentation/dev-plugins.txt
@@ -438,6 +438,17 @@
 +
 Publication of usage data
 
+* `com.google.gerrit.extensions.events.GitReferenceUpdatedListener`:
++
+A git reference was updated. A separate event for every ref updated in
+a BatchRefUpdate will be fired.
+
+* `com.google.gerrit.extensions.events.GitBatchRefUpdateListener`:
++
+One or more git references were updated. Alternative to GitReferenceUpdatedListener.
+A single event will inform about all refs updated by a BatchRefUpdate. Will also be
+fired, if only a single ref was updated.
+
 * `com.google.gerrit.extensions.events.GarbageCollectorListener`:
 +
 Garbage collection ran on a project
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/intro-project-owner.txt b/Documentation/intro-project-owner.txt
index 8a3b10e..26dbc37 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
diff --git a/Documentation/licenses.txt b/Documentation/licenses.txt
index e5966de..4b36e3e 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
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 9df4b04..70352dc 100644
--- a/Documentation/metrics.txt
+++ b/Documentation/metrics.txt
@@ -456,6 +456,15 @@
 === Group
 
 * `group/guess_relevant_groups_latency`: Latency for guessing relevant groups.
+* `group/handles_count`: Number of calls to GroupBackend.handles.
+* `group/get_count`: Number of calls to GroupBackend.get.
+* `group/suggest_count`: Number of calls to GroupBackend.suggest.
+* `group/contains_count`: Number of calls to GroupMemberships.contains.
+* `group/contains_any_of_count`: Number of calls to
+  GroupMemberships.containsAnyOf.
+* `group/intersection_count`: Number of calls to GroupMemberships.intersection.
+* `group/known_groups_count`: Number of calls to GroupMemberships.getKnownGroups.
+
 
 === Replication Plugin
 
diff --git a/Documentation/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..64845da 100644
--- a/Documentation/rest-api-accounts.txt
+++ b/Documentation/rest-api-accounts.txt
@@ -2658,7 +2658,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).
@@ -2729,7 +2729,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).
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index 8f766ea..da15775 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -6641,7 +6641,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]]
@@ -7745,30 +7746,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
@@ -8242,6 +8251,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
@@ -8306,16 +8322,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 86d7f58..505def0 100644
--- a/Documentation/rest-api-config.txt
+++ b/Documentation/rest-api-config.txt
@@ -1380,7 +1380,14 @@
   POST /config/server/index.changes HTTP/1.0
   Content-Type: application/json; charset=UTF-8
 
-  {changes: ["foo~101", "bar~202"]}
+  {
+    "changes": [
+      "foo~101",
+      "bar~202",
+      "303"
+    ],
+    "delete_missing": "true"
+  }
 ----
 
 .Response
@@ -1389,6 +1396,9 @@
   Content-Disposition: attachment
 ----
 
+When `delete_missing` is set to `true` changes to be reindexed which are missing in NoteDb
+will be deleted in the index.
+
 
 [[ids]]
 == IDs
@@ -1876,6 +1886,10 @@
 |Field Name         ||Description
 |`changes`   ||
 List of link:rest-api-changes.html#change-id[change-ids]
+|`delete_missing`  |optional|
+Delete changes which are missing in NoteDb from the index. This can be used
+to get rid of stale index entries. Possible values are `true` and `false`.
+By default set to `false`.
 |================================
 
 [[jvm-summary-info]]
diff --git a/Documentation/rest-api-projects.txt b/Documentation/rest-api-projects.txt
index 6f0f937..6fa584ac 100644
--- a/Documentation/rest-api-projects.txt
+++ b/Documentation/rest-api-projects.txt
@@ -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"
       }
     }
   }
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-search.txt b/Documentation/user-search.txt
index bae083b..f716cb0 100644
--- a/Documentation/user-search.txt
+++ b/Documentation/user-search.txt
@@ -712,17 +712,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/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/antlr3/com/google/gerrit/index/query/Query.g b/antlr3/com/google/gerrit/index/query/Query.g
index c8587df..7f868e2 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..5d8dd6f 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
@@ -32,6 +32,9 @@
 
   override def replaceOverride(in: String): String = {
     var next = replaceKeyWith("_project", URLEncoder.encode(getFullProjectName(projectName), "UTF-8"), in)
+    val authenticated = getProperty("authenticated", false).toBoolean
+    val value = "CONTEXT_PATH" + (if (authenticated) "/a" else "")
+    next = replaceKeyWith("context_path", value, next)
     super.replaceOverride(next)
   }
 
diff --git a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
index 903b709..fd208b2 100644
--- a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
@@ -175,6 +175,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;
@@ -966,7 +967,7 @@
             repo,
             "new subject",
             "new file",
-            "new content");
+            "new content " + UUID.randomUUID());
     return result;
   }
 
diff --git a/java/com/google/gerrit/acceptance/ExtensionRegistry.java b/java/com/google/gerrit/acceptance/ExtensionRegistry.java
index 0b3eff1..0acf3bc 100644
--- a/java/com/google/gerrit/acceptance/ExtensionRegistry.java
+++ b/java/com/google/gerrit/acceptance/ExtensionRegistry.java
@@ -23,6 +23,7 @@
 import com.google.gerrit.extensions.events.AccountIndexedListener;
 import com.google.gerrit.extensions.events.ChangeIndexedListener;
 import com.google.gerrit.extensions.events.CommentAddedListener;
+import com.google.gerrit.extensions.events.GitBatchRefUpdateListener;
 import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
 import com.google.gerrit.extensions.events.GroupIndexedListener;
 import com.google.gerrit.extensions.events.ProjectIndexedListener;
@@ -82,6 +83,7 @@
   private final DynamicSet<RefOperationValidationListener> refOperationValidationListeners;
   private final DynamicSet<CommentAddedListener> commentAddedListeners;
   private final DynamicSet<GitReferenceUpdatedListener> refUpdatedListeners;
+  private final DynamicSet<GitBatchRefUpdateListener> batchRefUpdateListeners;
   private final DynamicSet<FileHistoryWebLink> fileHistoryWebLinks;
   private final DynamicSet<PatchSetWebLink> patchSetWebLinks;
   private final DynamicSet<ResolveConflictsWebLink> resolveConflictsWebLinks;
@@ -125,6 +127,7 @@
       DynamicSet<RefOperationValidationListener> refOperationValidationListeners,
       DynamicSet<CommentAddedListener> commentAddedListeners,
       DynamicSet<GitReferenceUpdatedListener> refUpdatedListeners,
+      DynamicSet<GitBatchRefUpdateListener> batchRefUpdateListeners,
       DynamicSet<FileHistoryWebLink> fileHistoryWebLinks,
       DynamicSet<PatchSetWebLink> patchSetWebLinks,
       DynamicSet<ResolveConflictsWebLink> resolveConflictsWebLinks,
@@ -163,6 +166,7 @@
     this.refOperationValidationListeners = refOperationValidationListeners;
     this.commentAddedListeners = commentAddedListeners;
     this.refUpdatedListeners = refUpdatedListeners;
+    this.batchRefUpdateListeners = batchRefUpdateListeners;
     this.fileHistoryWebLinks = fileHistoryWebLinks;
     this.patchSetWebLinks = patchSetWebLinks;
     this.editWebLinks = editWebLinks;
@@ -277,6 +281,10 @@
       return add(refUpdatedListeners, refUpdatedListener);
     }
 
+    public Registration add(GitBatchRefUpdateListener batchRefUpdateListener) {
+      return add(batchRefUpdateListeners, batchRefUpdateListener);
+    }
+
     public Registration add(FileHistoryWebLink fileHistoryWebLink) {
       return add(fileHistoryWebLinks, fileHistoryWebLink);
     }
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/entities/PatchSetApprovals.java b/java/com/google/gerrit/entities/PatchSetApprovals.java
new file mode 100644
index 0000000..b204115
--- /dev/null
+++ b/java/com/google/gerrit/entities/PatchSetApprovals.java
@@ -0,0 +1,48 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.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()));
+  }
+
+  public static PatchSetApprovals create(
+      ImmutableListMultimap<PatchSet.Id, PatchSetApproval> approvalsByPatchSet) {
+    return new AutoValue_PatchSetApprovals(approvalsByPatchSet);
+  }
+}
diff --git a/java/com/google/gerrit/entities/SubmitRecord.java b/java/com/google/gerrit/entities/SubmitRecord.java
index 95ad9f8..4142b42 100644
--- a/java/com/google/gerrit/entities/SubmitRecord.java
+++ b/java/com/google/gerrit/entities/SubmitRecord.java
@@ -70,7 +70,7 @@
 
   // Name of the rule that created this submit record, formatted as '$pluginName~$ruleName'
   public String ruleName;
-  public Status status;
+  public SubmitRecord.Status status;
   public List<Label> labels;
   public List<LegacySubmitRequirement> requirements;
   public String errorMessage;
@@ -113,7 +113,7 @@
     }
 
     public String label;
-    public Status status;
+    public Label.Status status;
     public Account.Id appliedBy;
 
     /**
diff --git a/java/com/google/gerrit/entities/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/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/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..7ece6f6 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
   }
diff --git a/java/com/google/gerrit/extensions/common/SubmitRecordInfo.java b/java/com/google/gerrit/extensions/common/SubmitRecordInfo.java
index 09c9841..e591963 100644
--- a/java/com/google/gerrit/extensions/common/SubmitRecordInfo.java
+++ b/java/com/google/gerrit/extensions/common/SubmitRecordInfo.java
@@ -36,7 +36,7 @@
     }
 
     public String label;
-    public Status status;
+    public Label.Status status;
     public AccountInfo appliedBy;
   }
 
diff --git a/java/com/google/gerrit/extensions/common/SubmitRequirementExpressionInfo.java b/java/com/google/gerrit/extensions/common/SubmitRequirementExpressionInfo.java
index e9549c9..038b6f8 100644
--- a/java/com/google/gerrit/extensions/common/SubmitRequirementExpressionInfo.java
+++ b/java/com/google/gerrit/extensions/common/SubmitRequirementExpressionInfo.java
@@ -16,7 +16,10 @@
 
 import java.util.List;
 
-/** Result of evaluating a single submit requirement expression. */
+/**
+ * Result of evaluating a single submit requirement expression. This API entity is populated from
+ * {@link com.google.gerrit.entities.SubmitRequirementExpressionResult}.
+ */
 public class SubmitRequirementExpressionInfo {
 
   /** Submit requirement expression as a String. */
@@ -25,6 +28,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"].
@@ -42,4 +48,22 @@
    * during its evaluation.
    */
   public String errorMessage;
+
+  /**
+   * Values in this enum should match with values in {@link
+   * com.google.gerrit.entities.SubmitRequirementExpressionResult.Status}.
+   */
+  public enum Status {
+    /** Expression was evaluated and the result was true. */
+    PASS,
+
+    /** Expression was evaluated and the result was false. */
+    FAIL,
+
+    /** An error occurred while evaluating the expression. */
+    ERROR,
+
+    /** Expression was not evaluated. */
+    NOT_EVALUATED
+  }
 }
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/GitBatchRefUpdateListener.java b/java/com/google/gerrit/extensions/events/GitBatchRefUpdateListener.java
new file mode 100644
index 0000000..3d638c8
--- /dev/null
+++ b/java/com/google/gerrit/extensions/events/GitBatchRefUpdateListener.java
@@ -0,0 +1,50 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.events;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+import com.google.gerrit.extensions.common.AccountInfo;
+import java.util.Set;
+
+/** Notified when one or more references are modified. */
+@ExtensionPoint
+public interface GitBatchRefUpdateListener {
+  interface Event extends ProjectEvent {
+    Set<UpdatedRef> getUpdatedRefs();
+
+    Set<String> getRefNames();
+
+    /** The updater, could be null if it's the server. */
+    @Nullable
+    AccountInfo getUpdater();
+  }
+
+  interface UpdatedRef {
+    public String getRefName();
+
+    public String getOldObjectId();
+
+    public String getNewObjectId();
+
+    public boolean isCreate();
+
+    public boolean isDelete();
+
+    public boolean isNonFastForward();
+  }
+
+  void onGitBatchRefUpdate(Event event);
+}
diff --git a/java/com/google/gerrit/extensions/events/GitReferenceUpdatedListener.java b/java/com/google/gerrit/extensions/events/GitReferenceUpdatedListener.java
index bf922f8..0fec0f0 100644
--- a/java/com/google/gerrit/extensions/events/GitReferenceUpdatedListener.java
+++ b/java/com/google/gerrit/extensions/events/GitReferenceUpdatedListener.java
@@ -21,18 +21,7 @@
 /** Notified when one or more references are modified. */
 @ExtensionPoint
 public interface GitReferenceUpdatedListener {
-  interface Event extends ProjectEvent {
-    String getRefName();
-
-    String getOldObjectId();
-
-    String getNewObjectId();
-
-    boolean isCreate();
-
-    boolean isDelete();
-
-    boolean isNonFastForward();
+  interface Event extends ProjectEvent, GitBatchRefUpdateListener.UpdatedRef {
     /** The updater, could be null if it's the server. */
     @Nullable
     AccountInfo getUpdater();
diff --git a/java/com/google/gerrit/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/init/WebAppInitializer.java b/java/com/google/gerrit/httpd/init/WebAppInitializer.java
index 1535c87..0073ec2 100644
--- a/java/com/google/gerrit/httpd/init/WebAppInitializer.java
+++ b/java/com/google/gerrit/httpd/init/WebAppInitializer.java
@@ -210,7 +210,7 @@
           buf.append("\nResolve above errors before continuing.");
           buf.append("\nComplete stack trace follows:");
         }
-        logger.atSevere().withCause(first.getCause()).log(buf.toString());
+        logger.atSevere().withCause(first.getCause()).log("%s", buf);
         throw new CreationException(Collections.singleton(first));
       }
 
diff --git a/java/com/google/gerrit/index/Schema.java b/java/com/google/gerrit/index/Schema.java
index 91c3f70..4235821 100644
--- a/java/com/google/gerrit/index/Schema.java
+++ b/java/com/google/gerrit/index/Schema.java
@@ -36,7 +36,6 @@
 
   public static class Builder<T> {
     private final List<FieldDef<T, ?>> fields = new ArrayList<>();
-    private boolean useLegacyNumericFields;
 
     public Builder<T> add(Schema<T> schema) {
       this.fields.addAll(schema.getFields().values());
@@ -55,13 +54,8 @@
       return this;
     }
 
-    public Builder<T> legacyNumericFields(boolean useLegacyNumericFields) {
-      this.useLegacyNumericFields = useLegacyNumericFields;
-      return this;
-    }
-
     public Schema<T> build() {
-      return new Schema<>(useLegacyNumericFields, ImmutableList.copyOf(fields));
+      return new Schema<>(ImmutableList.copyOf(fields));
     }
   }
 
@@ -90,15 +84,14 @@
 
   private final ImmutableMap<String, FieldDef<T, ?>> fields;
   private final ImmutableMap<String, FieldDef<T, ?>> storedFields;
-  private final boolean useLegacyNumericFields;
 
   private int version;
 
-  public Schema(boolean useLegacyNumericFields, Iterable<FieldDef<T, ?>> fields) {
-    this(0, useLegacyNumericFields, fields);
+  public Schema(Iterable<FieldDef<T, ?>> fields) {
+    this(0, fields);
   }
 
-  public Schema(int version, boolean useLegacyNumericFields, Iterable<FieldDef<T, ?>> fields) {
+  public Schema(int version, Iterable<FieldDef<T, ?>> fields) {
     this.version = version;
     ImmutableMap.Builder<String, FieldDef<T, ?>> b = ImmutableMap.builder();
     ImmutableMap.Builder<String, FieldDef<T, ?>> sb = ImmutableMap.builder();
@@ -110,17 +103,12 @@
     }
     this.fields = b.build();
     this.storedFields = sb.build();
-    this.useLegacyNumericFields = useLegacyNumericFields;
   }
 
   public final int getVersion() {
     return version;
   }
 
-  public final boolean useLegacyNumericFields() {
-    return useLegacyNumericFields;
-  }
-
   /**
    * Get all fields in this schema.
    *
diff --git a/java/com/google/gerrit/index/SchemaUtil.java b/java/com/google/gerrit/index/SchemaUtil.java
index 9599d6a..96fe4fc 100644
--- a/java/com/google/gerrit/index/SchemaUtil.java
+++ b/java/com/google/gerrit/index/SchemaUtil.java
@@ -67,30 +67,23 @@
   }
 
   public static <V> Schema<V> schema(Collection<FieldDef<V, ?>> fields) {
-    return new Schema<>(true, ImmutableList.copyOf(fields));
+    return new Schema<>(ImmutableList.copyOf(fields));
   }
 
-  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(FieldDef<V, ?>... fields) {
+    return schema(ImmutableList.copyOf(fields));
   }
 
   @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());
   }
 
-  @SafeVarargs
-  public static <V> Schema<V> schema(FieldDef<V, ?>... fields) {
-    return new Schema<>(true, ImmutableList.copyOf(fields));
-  }
-
   public static Set<String> getPersonParts(PersonIdent person) {
     if (person == null) {
       return ImmutableSet.of();
diff --git a/java/com/google/gerrit/index/project/ProjectSchemaDefinitions.java b/java/com/google/gerrit/index/project/ProjectSchemaDefinitions.java
index 3cc5f9b..52955d3 100644
--- a/java/com/google/gerrit/index/project/ProjectSchemaDefinitions.java
+++ b/java/com/google/gerrit/index/project/ProjectSchemaDefinitions.java
@@ -38,7 +38,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/json/OptionalTypeAdapter.java b/java/com/google/gerrit/json/OptionalTypeAdapter.java
new file mode 100644
index 0000000..9bfa72d
--- /dev/null
+++ b/java/com/google/gerrit/json/OptionalTypeAdapter.java
@@ -0,0 +1,69 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.json;
+
+import com.google.gson.JsonDeserializationContext;
+import com.google.gson.JsonDeserializer;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonNull;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParseException;
+import com.google.gson.JsonSerializationContext;
+import com.google.gson.JsonSerializer;
+import com.google.inject.TypeLiteral;
+import java.lang.reflect.ParameterizedType;
+import java.lang.reflect.Type;
+import java.util.Optional;
+
+public class OptionalTypeAdapter
+    implements JsonSerializer<Optional<?>>, JsonDeserializer<Optional<?>> {
+
+  private static final String VALUE = "value";
+
+  @Override
+  public JsonElement serialize(Optional<?> src, Type typeOfSrc, JsonSerializationContext context) {
+    Optional<?> optional = src == null ? Optional.empty() : src;
+    JsonObject json = new JsonObject();
+    json.add(VALUE, optional.map(context::serialize).orElse(JsonNull.INSTANCE));
+    return json;
+  }
+
+  @Override
+  public Optional<?> deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context)
+      throws JsonParseException {
+    if (!json.getAsJsonObject().has(VALUE)) {
+      return Optional.empty();
+    }
+
+    JsonElement value = json.getAsJsonObject().get(VALUE);
+    if (value == null || value.isJsonNull()) {
+      return Optional.empty();
+    }
+
+    // handle the situation when one uses Optional without type parameter which is an equivalent of
+    // <?> type
+    ParameterizedType parameterizedType =
+        (ParameterizedType) new TypeLiteral<Optional<?>>() {}.getType();
+    if (typeOfT instanceof ParameterizedType) {
+      parameterizedType = (ParameterizedType) typeOfT;
+      if (parameterizedType.getActualTypeArguments().length != 1) {
+        throw new JsonParseException("Expected one parameter type in Optional.");
+      }
+    }
+
+    Type optionalOf = parameterizedType.getActualTypeArguments()[0];
+    return Optional.of(context.deserialize(value, optionalOf));
+  }
+}
diff --git a/java/com/google/gerrit/lucene/AbstractLuceneIndex.java b/java/com/google/gerrit/lucene/AbstractLuceneIndex.java
index 988d6fb..f8256bb 100644
--- a/java/com/google/gerrit/lucene/AbstractLuceneIndex.java
+++ b/java/com/google/gerrit/lucene/AbstractLuceneIndex.java
@@ -66,8 +66,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,7 +86,6 @@
 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();
 
@@ -348,13 +345,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,13 +376,9 @@
   }
 
   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));
     }
   }
 
diff --git a/java/com/google/gerrit/lucene/ChangeSubIndex.java b/java/com/google/gerrit/lucene/ChangeSubIndex.java
index 475dac4..661c0b0 100644
--- a/java/com/google/gerrit/lucene/ChangeSubIndex.java
+++ b/java/com/google/gerrit/lucene/ChangeSubIndex.java
@@ -15,8 +15,7 @@
 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;
@@ -120,12 +119,9 @@
   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) {
+    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..fe3fc15 100644
--- a/java/com/google/gerrit/lucene/LuceneAccountIndex.java
+++ b/java/com/google/gerrit/lucene/LuceneAccountIndex.java
@@ -72,7 +72,7 @@
   private static Term idTerm(boolean useLegacyNumericFields, Account.Id id) {
     FieldDef<AccountState, ?> idField = useLegacyNumericFields ? ID : ID_STR;
     if (useLegacyNumericFields) {
-      return QueryBuilder.intTerm(idField.getName(), id.get());
+      return QueryBuilder.intTerm(idField.getName());
     }
     return QueryBuilder.stringTerm(idField.getName(), Integer.toString(id.get()));
   }
@@ -137,7 +137,7 @@
   @Override
   public void replace(AccountState as) {
     try {
-      replace(idTerm(getSchema().useLegacyNumericFields(), as), toDocument(as)).get();
+      replace(idTerm(getSchema().hasField(ID), as), toDocument(as)).get();
     } catch (ExecutionException | InterruptedException e) {
       throw new StorageException(e);
     }
@@ -155,7 +155,7 @@
   @Override
   public void delete(Account.Id key) {
     try {
-      delete(idTerm(getSchema().useLegacyNumericFields(), key)).get();
+      delete(idTerm(getSchema().hasField(ID), key)).get();
     } catch (ExecutionException | InterruptedException e) {
       throw new StorageException(e);
     }
@@ -164,15 +164,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))),
         queryBuilder.toQuery(p),
         getSort());
   }
 
   private Sort getSort() {
-    String idSortField = getSchema().useLegacyNumericFields() ? ID_SORT_FIELD : ID2_SORT_FIELD;
+    String idSortField = getSchema().hasField(ID) ? 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 +180,10 @@
 
   @Override
   protected AccountState fromDocument(Document doc) {
-    FieldDef<AccountState, ?> idField = getSchema().useLegacyNumericFields() ? ID : ID_STR;
+    FieldDef<AccountState, ?> idField = getSchema().hasField(ID) ? ID : ID_STR;
     Account.Id id =
         Account.id(
-            getSchema().useLegacyNumericFields()
+            getSchema().hasField(ID)
                 ? 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 9ea9d2e..cf176ee 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;
@@ -100,30 +99,39 @@
 
   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);
+  /*
+    @FunctionalInterface
+    interface IdTerm {
+      Term get(String name, int id);
+    }
+
+    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(ChangeData cd) {
+    return idTerm(cd.getId());
   }
 
-  static Term idTerm(IdTerm idTerm, FieldDef<ChangeData, ?> idField, ChangeData cd) {
-    return idTerm(idTerm, idField, cd.getId());
-  }
-
-  static Term idTerm(IdTerm idTerm, FieldDef<ChangeData, ?> idField, Change.Id id) {
-    return idTerm.get(idField.getName(), id.get());
-  }
-
-  @FunctionalInterface
-  interface ChangeIdExtractor {
-    Change.Id extract(IndexableField f);
+  static Term idTerm(Change.Id id) {
+    return QueryBuilder.stringTerm(LEGACY_ID_STR.getName(), Integer.toString(id.get()));
   }
 
   private final ListeningExecutorService executor;
@@ -132,12 +140,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
@@ -205,20 +207,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
@@ -237,7 +225,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);
@@ -270,9 +258,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);
     }
@@ -309,7 +297,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 {
@@ -357,7 +345,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<List<Document>>() {
@@ -378,7 +366,7 @@
     public ResultSet<FieldBundle> readRaw() {
       List<Document> documents;
       try {
-        documents = doRead(IndexUtils.changeFields(opts, schema.useLegacyNumericFields()));
+        documents = doRead(IndexUtils.changeFields(opts));
       } catch (IOException e) {
         throw new StorageException(e);
       }
@@ -457,7 +445,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) {
@@ -499,9 +487,10 @@
     } 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()) {
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/proc/ThreadMXBeanSun.java b/java/com/google/gerrit/metrics/proc/ThreadMXBeanSun.java
index 9e43a05..f5555b5 100644
--- a/java/com/google/gerrit/metrics/proc/ThreadMXBeanSun.java
+++ b/java/com/google/gerrit/metrics/proc/ThreadMXBeanSun.java
@@ -35,8 +35,6 @@
 
   @Override
   public long getCurrentThreadAllocatedBytes() {
-    // TODO(ms): call getCurrentThreadAllocatedBytes as soon as this is available in the patched
-    // Java version used by bazel
-    return sys.getThreadAllocatedBytes(Thread.currentThread().getId());
+    return sys.getCurrentThreadAllocatedBytes();
   }
 }
diff --git a/java/com/google/gerrit/pgm/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..27021bd 100644
--- a/java/com/google/gerrit/pgm/init/InitLabels.java
+++ b/java/com/google/gerrit/pgm/init/InitLabels.java
@@ -26,7 +26,8 @@
 
 @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_ALL_SCORES_IF_NO_CHANGE = "copyAllScoresIfNoChange";
+  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 +63,17 @@
           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);
+
+      // override the default which is true and rely on the copy condition instead
+      cfg.setBoolean(
+          KEY_LABEL, LABEL_VERIFIED, KEY_COPY_ALL_SCORES_IF_NO_CHANGE, /* value= */ false);
+
+      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/BatchGitModule.java b/java/com/google/gerrit/pgm/util/BatchGitModule.java
index d39c2fd..fa585d3 100644
--- a/java/com/google/gerrit/pgm/util/BatchGitModule.java
+++ b/java/com/google/gerrit/pgm/util/BatchGitModule.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.pgm.util;
 
 import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.gerrit.extensions.events.GitBatchRefUpdateListener;
 import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.server.git.GitModule;
@@ -24,6 +25,7 @@
 public class BatchGitModule extends FactoryModule {
   @Override
   protected void configure() {
+    DynamicSet.setOf(binder(), GitBatchRefUpdateListener.class);
     DynamicSet.setOf(binder(), GitReferenceUpdatedListener.class);
     DynamicSet.setOf(binder(), CommitValidationListener.class);
     install(new GitModule());
diff --git a/java/com/google/gerrit/pgm/util/BatchProgramModule.java b/java/com/google/gerrit/pgm/util/BatchProgramModule.java
index 41ed991..d2e4b18 100644
--- a/java/com/google/gerrit/pgm/util/BatchProgramModule.java
+++ b/java/com/google/gerrit/pgm/util/BatchProgramModule.java
@@ -46,6 +46,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;
@@ -63,11 +64,9 @@
 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;
@@ -150,9 +149,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(IdentifiedUser.class);
-    factory(MergeUtil.Factory.class);
     factory(PatchSetInserter.Factory.class);
     factory(RebaseChangeOp.Factory.class);
 
diff --git a/java/com/google/gerrit/server/BUILD b/java/com/google/gerrit/server/BUILD
index 7a6187d..5365426 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",
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/account/AccountConfig.java b/java/com/google/gerrit/server/account/AccountConfig.java
index 28e881e..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/AccountsUpdate.java b/java/com/google/gerrit/server/account/AccountsUpdate.java
index 3ee6365..137fd59 100644
--- a/java/com/google/gerrit/server/account/AccountsUpdate.java
+++ b/java/com/google/gerrit/server/account/AccountsUpdate.java
@@ -319,17 +319,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);
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/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/approval/ApprovalCopier.java b/java/com/google/gerrit/server/approval/ApprovalCopier.java
index 31380f4..ac35b5a 100644
--- a/java/com/google/gerrit/server/approval/ApprovalCopier.java
+++ b/java/com/google/gerrit/server/approval/ApprovalCopier.java
@@ -15,11 +15,13 @@
 package com.google.gerrit.server.approval;
 
 import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.common.base.Preconditions.checkState;
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
 
+import com.google.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;
@@ -52,7 +54,6 @@
 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;
@@ -60,16 +61,59 @@
 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();
 
+  @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());
+    }
+
+    static Result create(
+        ImmutableSet<PatchSetApproval> copiedApprovals,
+        ImmutableSet<PatchSetApproval> outdatedApprovals) {
+      return new AutoValue_ApprovalCopier_Result(copiedApprovals, outdatedApprovals);
+    }
+  }
+
   private final DiffOperations diffOperations;
   private final ProjectCache projectCache;
   private final ChangeKindCache changeKindCache;
@@ -97,11 +141,17 @@
   }
 
   /**
-   * 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,14 +164,12 @@
           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,
+      Project.NameKey projectName,
       PatchSetApproval psa,
       PatchSet.Id psId,
       ChangeKind kind,
@@ -142,7 +190,7 @@
           n,
           psa.key().patchSetId().changeId().get(),
           psId.get(),
-          project.getName());
+          projectName);
       return true;
     } else if (type.isCopyMaxScore() && type.isMaxPositive(psa)) {
       logger.atFine().log(
@@ -153,7 +201,7 @@
           n,
           psa.key().patchSetId().changeId().get(),
           psId.get(),
-          project.getName());
+          projectName);
       return true;
     } else if (type.isCopyAnyScore()) {
       logger.atFine().log(
@@ -164,7 +212,7 @@
           n,
           psa.key().patchSetId().changeId().get(),
           psId.get(),
-          project.getName());
+          projectName);
       return true;
     } else if (type.getCopyValues().contains(psa.value())) {
       logger.atFine().log(
@@ -176,7 +224,7 @@
           psa.key().patchSetId().changeId().get(),
           psId.get(),
           psa.value(),
-          project.getName());
+          projectName);
       return true;
     } else if (type.isCopyAllScoresIfListOfFilesDidNotChange()
         && listOfFilesUnchangedPredicate.match(
@@ -192,7 +240,7 @@
           n,
           psa.key().patchSetId().changeId().get(),
           psId.get(),
-          project.getName());
+          projectName);
       return true;
     }
     switch (kind) {
@@ -208,7 +256,7 @@
               psa.key().patchSetId().changeId().get(),
               psId.get(),
               kind,
-              project.getName());
+              projectName);
           return true;
         }
         return false;
@@ -224,7 +272,7 @@
               psa.key().patchSetId().changeId().get(),
               psId.get(),
               kind,
-              project.getName());
+              projectName);
           return true;
         }
         return false;
@@ -240,7 +288,7 @@
               psa.key().patchSetId().changeId().get(),
               psId.get(),
               kind,
-              project.getName());
+              projectName);
           return true;
         }
         return false;
@@ -256,7 +304,7 @@
               psa.key().patchSetId().changeId().get(),
               psId.get(),
               kind,
-              project.getName());
+              projectName);
           return true;
         }
         if (type.isCopyAllScoresOnTrivialRebase()) {
@@ -270,7 +318,7 @@
               psa.key().patchSetId().changeId().get(),
               psId.get(),
               kind,
-              project.getName());
+              projectName);
           return true;
         }
         if (isMerge && type.isCopyAllScoresOnMergeFirstParentUpdate()) {
@@ -284,7 +332,7 @@
               psa.key().patchSetId().changeId().get(),
               psId.get(),
               kind,
-              project.getName());
+              projectName);
           return true;
         }
         if (type.isCopyAllScoresIfNoCodeChange()) {
@@ -298,7 +346,7 @@
               psa.key().patchSetId().changeId().get(),
               psId.get(),
               kind,
-              project.getName());
+              projectName);
           return true;
         }
         return false;
@@ -342,45 +390,39 @@
     }
   }
 
-  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());
-
+  private Result computeForPatchSet(
+      LabelTypes labelTypes, ChangeNotes notes, PatchSet patchSet, RevWalk rw, Config repoConfig) {
+    Project.NameKey projectName = 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));
 
     // 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();
+      return Result.empty();
     }
     Map.Entry<PatchSet.Id, PatchSet> priorPatchSet = notes.load().getPatchSets().lowerEntry(psId);
     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(patchSet.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(),
-            rw,
-            repoConfig,
-            priorPatchSet.getValue().commitId(),
-            patchSet.commitId());
-    boolean isMerge = isMerge(project.getNameKey(), rw, patchSet);
+            projectName, rw, repoConfig, priorPatchSet.getValue().commitId(), patchSet.commitId());
+    boolean isMerge = isMerge(projectName, rw, patchSet);
     logger.atFine().log(
         "change kind for patch set %d of change %d against prior patch set %s is %s",
         patchSet.id().get(),
@@ -391,21 +433,21 @@
     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())) {
-        continue;
-      }
+    for (PatchSetApproval psa : priorApprovals) {
       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);
+        baseVsCurrent = listModifiedFiles(projectName, patchSet, rw, repoConfig);
+        baseVsPrior = listModifiedFiles(projectName, priorPatchSet.getValue(), rw, repoConfig);
         priorVsCurrent =
             listModifiedFiles(
-                project, priorPatchSet.getValue().commitId(), patchSet.commitId(), rw, repoConfig);
+                projectName,
+                priorPatchSet.getValue().commitId(),
+                patchSet.commitId(),
+                rw,
+                repoConfig);
       }
       if (!type.isPresent()) {
         logger.atFine().log(
@@ -416,11 +458,12 @@
             psa.key().patchSetId().get(),
             psa.key().patchSetId().changeId().get(),
             psId.get(),
-            project.getName());
+            projectName);
+        outdatedApprovalsBuilder.add(psa);
         continue;
       }
       if (!canCopyBasedOnBooleanLabelConfigs(
-              project,
+              projectName,
               psa,
               patchSet.id(),
               changeKind,
@@ -431,11 +474,18 @@
               priorVsCurrent)
           && !canCopyBasedOnCopyCondition(
               notes, psa, patchSet, type.get(), changeKind, isMerge, rw, repoConfig)) {
+        outdatedApprovalsBuilder.add(psa);
         continue;
       }
-      resultByUser.put(psa.label(), psa.accountId(), psa.copyWithPatchSet(patchSet.id()));
+      if (!currentApprovalsByUser.contains(psa.label(), psa.accountId())) {
+        copiedApprovalsByUser.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) {
@@ -455,19 +505,12 @@
    * files between those two patch-sets .
    */
   private Map<String, ModifiedFile> listModifiedFiles(
-      ProjectState project, PatchSet ps, RevWalk revWalk, Config repoConfig) {
+      Project.NameKey projectName, PatchSet ps, RevWalk revWalk, Config repoConfig) {
     try {
       Integer parentNum =
-          listOfFilesUnchangedPredicate.isInitialCommit(project.getNameKey(), ps.commitId())
-              ? 0
-              : 1;
+          listOfFilesUnchangedPredicate.isInitialCommit(projectName, ps.commitId()) ? 0 : 1;
       return diffOperations.loadModifiedFilesAgainstParent(
-          project.getNameKey(),
-          ps.commitId(),
-          parentNum,
-          DiffOptions.DEFAULTS,
-          revWalk,
-          repoConfig);
+          projectName, ps.commitId(), parentNum, DiffOptions.DEFAULTS, revWalk, repoConfig);
     } catch (DiffNotAvailableException ex) {
       throw new StorageException(
           "failed to compute difference in files, so won't copy"
@@ -482,19 +525,14 @@
    * change.
    */
   private Map<String, ModifiedFile> listModifiedFiles(
-      ProjectState project,
+      Project.NameKey projectName,
       ObjectId sourceCommit,
       ObjectId targetCommit,
       RevWalk revWalk,
       Config repoConfig) {
     try {
       return diffOperations.loadModifiedFiles(
-          project.getNameKey(),
-          sourceCommit,
-          targetCommit,
-          DiffOptions.DEFAULTS,
-          revWalk,
-          repoConfig);
+          projectName, sourceCommit, targetCommit, DiffOptions.DEFAULTS, revWalk, repoConfig);
     } catch (DiffNotAvailableException ex) {
       throw new StorageException(
           "failed to compute difference in files, so won't copy"
diff --git a/java/com/google/gerrit/server/approval/ApprovalsUtil.java b/java/com/google/gerrit/server/approval/ApprovalsUtil.java
index fdcaf69..c040e0b 100644
--- a/java/com/google/gerrit/server/approval/ApprovalsUtil.java
+++ b/java/com/google/gerrit/server/approval/ApprovalsUtil.java
@@ -18,15 +18,20 @@
 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.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.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.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;
@@ -57,6 +62,7 @@
 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,7 +101,7 @@
     return Iterables.filter(psas, a -> Objects.equals(a.accountId(), accountId));
   }
 
-  private final ApprovalCopier approvalInference;
+  private final ApprovalCopier approvalCopier;
   private final PermissionBackend permissionBackend;
   private final ProjectCache projectCache;
   private final LabelNormalizer labelNormalizer;
@@ -103,11 +109,11 @@
   @VisibleForTesting
   @Inject
   public ApprovalsUtil(
-      ApprovalCopier approvalInference,
+      ApprovalCopier approvalCopier,
       PermissionBackend permissionBackend,
       ProjectCache projectCache,
       LabelNormalizer labelNormalizer) {
-    this.approvalInference = approvalInference;
+    this.approvalCopier = approvalCopier;
     this.permissionBackend = permissionBackend;
     this.projectCache = projectCache;
     this.labelNormalizer = labelNormalizer;
@@ -336,26 +342,83 @@
 
   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);
   }
 
   /**
@@ -368,7 +431,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/SubmitRequirementExpressionResultSerializer.java b/java/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementExpressionResultSerializer.java
index 436fe76..d7091ca 100644
--- a/java/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementExpressionResultSerializer.java
+++ b/java/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementExpressionResultSerializer.java
@@ -28,9 +28,15 @@
 public class SubmitRequirementExpressionResultSerializer {
   public static SubmitRequirementExpressionResult deserialize(
       SubmitRequirementExpressionResultProto proto) {
+    SubmitRequirementExpressionResult.Status status;
+    try {
+      status = SubmitRequirementExpressionResult.Status.valueOf(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())));
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..1cf31c1 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");
@@ -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/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..b09b3c7 100644
--- a/java/com/google/gerrit/server/change/PatchSetInserter.java
+++ b/java/com/google/gerrit/server/change/PatchSetInserter.java
@@ -21,11 +21,13 @@
 import static java.util.Objects.requireNonNull;
 
 import com.google.common.collect.ImmutableListMultimap;
-import com.google.common.flogger.FluentLogger;
+import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.PatchSetApproval;
 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;
@@ -39,8 +41,6 @@
 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 ImmutableSet<PatchSetApproval> outdatedApprovals;
+  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());
@@ -307,8 +318,11 @@
     }
 
     if (storeCopiedVotes) {
-      approvalsUtil.persistCopiedApprovals(
-          ctx.getNotes(), patchSet, ctx.getRevWalk(), ctx.getRepoView().getConfig(), update);
+      outdatedApprovals =
+          approvalsUtil
+              .copyApprovalsToNewPatchSet(
+                  ctx.getNotes(), patchSet, ctx.getRevWalk(), ctx.getRepoView().getConfig(), update)
+              .outdatedApprovals();
     }
 
     return true;
@@ -319,22 +333,18 @@
     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,
+              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/RemoveFromAttentionSetOp.java b/java/com/google/gerrit/server/change/RemoveFromAttentionSetOp.java
index 50ee9d4..9fb4fc4 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");
@@ -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/RevisionJson.java b/java/com/google/gerrit/server/change/RevisionJson.java
index 7d40f06..5469b51 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 86d81e7..a750d8e 100644
--- a/java/com/google/gerrit/server/config/GerritGlobalModule.java
+++ b/java/com/google/gerrit/server/config/GerritGlobalModule.java
@@ -45,6 +45,7 @@
 import com.google.gerrit.extensions.events.ChangeRevertedListener;
 import com.google.gerrit.extensions.events.CommentAddedListener;
 import com.google.gerrit.extensions.events.GarbageCollectorListener;
+import com.google.gerrit.extensions.events.GitBatchRefUpdateListener;
 import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
 import com.google.gerrit.extensions.events.GroupIndexedListener;
 import com.google.gerrit.extensions.events.HashtagsEditedListener;
@@ -120,6 +121,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;
@@ -133,7 +135,6 @@
 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;
@@ -296,7 +297,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);
@@ -352,6 +353,7 @@
     DynamicMap.mapOf(binder(), CapabilityDefinition.class);
     DynamicMap.mapOf(binder(), PluginProjectPermissionDefinition.class);
     DynamicSet.setOf(binder(), GitReferenceUpdatedListener.class);
+    DynamicSet.setOf(binder(), GitBatchRefUpdateListener.class);
     DynamicSet.setOf(binder(), AssigneeChangedListener.class);
     DynamicSet.setOf(binder(), ChangeAbandonedListener.class);
     DynamicSet.setOf(binder(), ChangeDeletedListener.class);
@@ -387,7 +389,7 @@
     DynamicSet.setOf(binder(), GarbageCollectorListener.class);
     DynamicSet.setOf(binder(), HeadUpdatedListener.class);
     DynamicSet.setOf(binder(), UsageDataPublishedListener.class);
-    DynamicSet.bind(binder(), GitReferenceUpdatedListener.class).to(ReindexAfterRefUpdate.class);
+    DynamicSet.bind(binder(), GitBatchRefUpdateListener.class).to(ReindexAfterRefUpdate.class);
     DynamicSet.bind(binder(), GitReferenceUpdatedListener.class)
         .to(ProjectConfigEntry.UpdateChecker.class);
     DynamicSet.setOf(binder(), EventListener.class);
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/experiments/ExperimentFeaturesConstants.java b/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java
index ffeb44b..9deea8a 100644
--- a/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java
+++ b/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java
@@ -34,6 +34,15 @@
   public static final String GERRIT_BACKEND_REQUEST_FEATURE_COMPUTE_FROM_ALL_USERS_REPOSITORY =
       "GerritBackendRequestFeature__compute_from_all_users_repository";
 
+  /**
+   * When set, the result status of submit requirement expressions might hold the value
+   * "NOT_EVALUATED". For example if the change is not applicable, the submit / override expression
+   * results will be set to this value. If not set, the submit / override expressions will be set to
+   * empty optionals.
+   */
+  public static final String GERRIT_BACKEND_REQUEST_FEATURE_SR_EXPRESSIONS_NOT_EVALUATED =
+      "GerritBackendRequestFeature__sr_expressions_not_evaluated";
+
   /** 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/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/GitReferenceUpdated.java b/java/com/google/gerrit/server/extensions/events/GitReferenceUpdated.java
index 6ed0a08..814390b 100644
--- a/java/com/google/gerrit/server/extensions/events/GitReferenceUpdated.java
+++ b/java/com/google/gerrit/server/extensions/events/GitReferenceUpdated.java
@@ -17,11 +17,15 @@
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.events.GitBatchRefUpdateListener;
 import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
+import java.util.HashSet;
+import java.util.Set;
+import java.util.stream.Collectors;
 import org.eclipse.jgit.lib.BatchRefUpdate;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.RefUpdate;
@@ -58,17 +62,23 @@
             Project.NameKey project, BatchRefUpdate batchRefUpdate, AccountState updater) {}
       };
 
-  private final PluginSetContext<GitReferenceUpdatedListener> listeners;
+  private final PluginSetContext<GitBatchRefUpdateListener> batchRefUpdateListeners;
+  private final PluginSetContext<GitReferenceUpdatedListener> refUpdatedListeners;
   private final EventUtil util;
 
   @Inject
-  GitReferenceUpdated(PluginSetContext<GitReferenceUpdatedListener> listeners, EventUtil util) {
-    this.listeners = listeners;
+  GitReferenceUpdated(
+      PluginSetContext<GitBatchRefUpdateListener> batchRefUpdateListeners,
+      PluginSetContext<GitReferenceUpdatedListener> refUpdatedListeners,
+      EventUtil util) {
+    this.batchRefUpdateListeners = batchRefUpdateListeners;
+    this.refUpdatedListeners = refUpdatedListeners;
     this.util = util;
   }
 
   private GitReferenceUpdated() {
-    this.listeners = null;
+    this.batchRefUpdateListeners = null;
+    this.refUpdatedListeners = null;
     this.util = null;
   }
 
@@ -79,20 +89,19 @@
       AccountState updater) {
     fire(
         project,
-        refUpdate.getName(),
-        refUpdate.getOldObjectId(),
-        refUpdate.getNewObjectId(),
-        type,
+        new UpdatedRef(
+            refUpdate.getName(), refUpdate.getOldObjectId(), refUpdate.getNewObjectId(), type),
         util.accountInfo(updater));
   }
 
   public void fire(Project.NameKey project, RefUpdate refUpdate, AccountState updater) {
     fire(
         project,
-        refUpdate.getName(),
-        refUpdate.getOldObjectId(),
-        refUpdate.getNewObjectId(),
-        ReceiveCommand.Type.UPDATE,
+        new UpdatedRef(
+            refUpdate.getName(),
+            refUpdate.getOldObjectId(),
+            refUpdate.getNewObjectId(),
+            ReceiveCommand.Type.UPDATE),
         util.accountInfo(updater));
   }
 
@@ -104,83 +113,80 @@
       AccountState updater) {
     fire(
         project,
-        ref,
-        oldObjectId,
-        newObjectId,
-        ReceiveCommand.Type.UPDATE,
+        new UpdatedRef(ref, oldObjectId, newObjectId, ReceiveCommand.Type.UPDATE),
         util.accountInfo(updater));
   }
 
   public void fire(Project.NameKey project, ReceiveCommand cmd, AccountState updater) {
     fire(
         project,
-        cmd.getRefName(),
-        cmd.getOldId(),
-        cmd.getNewId(),
-        cmd.getType(),
+        new UpdatedRef(cmd.getRefName(), cmd.getOldId(), cmd.getNewId(), cmd.getType()),
         util.accountInfo(updater));
   }
 
   public void fire(Project.NameKey project, BatchRefUpdate batchRefUpdate, AccountState updater) {
-    if (listeners.isEmpty()) {
+    if (batchRefUpdateListeners.isEmpty() && refUpdatedListeners.isEmpty()) {
       return;
     }
+    Set<GitBatchRefUpdateListener.UpdatedRef> updates = new HashSet<>();
     for (ReceiveCommand cmd : batchRefUpdate.getCommands()) {
       if (cmd.getResult() == ReceiveCommand.Result.OK) {
-        fire(
-            project,
-            cmd.getRefName(),
-            cmd.getOldId(),
-            cmd.getNewId(),
-            cmd.getType(),
-            util.accountInfo(updater));
+        updates.add(
+            new UpdatedRef(cmd.getRefName(), cmd.getOldId(), cmd.getNewId(), cmd.getType()));
       }
     }
+    fireBatchRefUpdateEvent(project, updates, util.accountInfo(updater));
+    fireRefUpdatedEvents(project, updates, util.accountInfo(updater));
   }
 
-  private void fire(
+  private void fire(Project.NameKey project, UpdatedRef updatedRef, AccountInfo updater) {
+    fireBatchRefUpdateEvent(project, Set.of(updatedRef), updater);
+    fireRefUpdatedEvent(project, updatedRef, updater);
+  }
+
+  private void fireBatchRefUpdateEvent(
       Project.NameKey project,
-      String ref,
-      ObjectId oldObjectId,
-      ObjectId newObjectId,
-      ReceiveCommand.Type type,
+      Set<GitBatchRefUpdateListener.UpdatedRef> updatedRefs,
       AccountInfo updater) {
-    if (listeners.isEmpty()) {
+    if (batchRefUpdateListeners.isEmpty()) {
       return;
     }
-    ObjectId o = oldObjectId != null ? oldObjectId : ObjectId.zeroId();
-    ObjectId n = newObjectId != null ? newObjectId : ObjectId.zeroId();
-    Event event = new Event(project, ref, o.name(), n.name(), type, updater);
-    listeners.runEach(l -> l.onGitReferenceUpdated(event));
+    GitBatchRefUpdateEvent event = new GitBatchRefUpdateEvent(project, updatedRefs, updater);
+    batchRefUpdateListeners.runEach(l -> l.onGitBatchRefUpdate(event));
   }
 
-  /** Event to be fired when a Git reference has been updated. */
-  public static class Event implements GitReferenceUpdatedListener.Event {
-    private final String projectName;
-    private final String ref;
-    private final String oldObjectId;
-    private final String newObjectId;
-    private final ReceiveCommand.Type type;
-    private final AccountInfo updater;
-
-    Event(
-        Project.NameKey project,
-        String ref,
-        String oldObjectId,
-        String newObjectId,
-        ReceiveCommand.Type type,
-        AccountInfo updater) {
-      this.projectName = project.get();
-      this.ref = ref;
-      this.oldObjectId = oldObjectId;
-      this.newObjectId = newObjectId;
-      this.type = type;
-      this.updater = updater;
+  private void fireRefUpdatedEvents(
+      Project.NameKey project,
+      Set<GitBatchRefUpdateListener.UpdatedRef> updatedRefs,
+      AccountInfo updater) {
+    for (GitBatchRefUpdateListener.UpdatedRef updatedRef : updatedRefs) {
+      fireRefUpdatedEvent(project, updatedRef, updater);
     }
+  }
 
-    @Override
-    public String getProjectName() {
-      return projectName;
+  private void fireRefUpdatedEvent(
+      Project.NameKey project,
+      GitBatchRefUpdateListener.UpdatedRef updatedRef,
+      AccountInfo updater) {
+    if (refUpdatedListeners.isEmpty()) {
+      return;
+    }
+    GitReferenceUpdatedEvent event = new GitReferenceUpdatedEvent(project, updatedRef, updater);
+    refUpdatedListeners.runEach(l -> l.onGitReferenceUpdated(event));
+  }
+
+  public static class UpdatedRef implements GitBatchRefUpdateListener.UpdatedRef {
+    private final String ref;
+    private final ObjectId oldObjectId;
+    private final ObjectId newObjectId;
+    private final ReceiveCommand.Type type;
+
+    public UpdatedRef(
+        String ref, ObjectId oldObjectId, ObjectId newObjectId, ReceiveCommand.Type type) {
+      this.ref = ref;
+      this.oldObjectId = oldObjectId != null ? oldObjectId : ObjectId.zeroId();
+      this.newObjectId = newObjectId != null ? newObjectId : ObjectId.zeroId();
+      this.type = type;
     }
 
     @Override
@@ -190,12 +196,12 @@
 
     @Override
     public String getOldObjectId() {
-      return oldObjectId;
+      return oldObjectId.name();
     }
 
     @Override
     public String getNewObjectId() {
-      return newObjectId;
+      return newObjectId.name();
     }
 
     @Override
@@ -214,15 +220,51 @@
     }
 
     @Override
+    public String toString() {
+      return String.format("{%s: %s -> %s}", ref, oldObjectId, newObjectId);
+    }
+  }
+
+  /** Event to be fired when a Git reference has been updated. */
+  public static class GitBatchRefUpdateEvent implements GitBatchRefUpdateListener.Event {
+    private final String projectName;
+    private final Set<GitBatchRefUpdateListener.UpdatedRef> updatedRefs;
+    private final AccountInfo updater;
+
+    public GitBatchRefUpdateEvent(
+        Project.NameKey project,
+        Set<GitBatchRefUpdateListener.UpdatedRef> updatedRefs,
+        AccountInfo updater) {
+      this.projectName = project.get();
+      this.updatedRefs = updatedRefs;
+      this.updater = updater;
+    }
+
+    @Override
+    public String getProjectName() {
+      return projectName;
+    }
+
+    @Override
+    public Set<GitBatchRefUpdateListener.UpdatedRef> getUpdatedRefs() {
+      return updatedRefs;
+    }
+
+    @Override
+    public Set<String> getRefNames() {
+      return updatedRefs.stream()
+          .map(GitBatchRefUpdateListener.UpdatedRef::getRefName)
+          .collect(Collectors.toSet());
+    }
+
+    @Override
     public AccountInfo getUpdater() {
       return updater;
     }
 
     @Override
     public String toString() {
-      return String.format(
-          "%s[%s,%s: %s -> %s]",
-          getClass().getSimpleName(), projectName, ref, oldObjectId, newObjectId);
+      return String.format("%s[%s,%s]", getClass().getSimpleName(), projectName, updatedRefs);
     }
 
     @Override
@@ -230,4 +272,65 @@
       return NotifyHandling.ALL;
     }
   }
+
+  public static class GitReferenceUpdatedEvent implements GitReferenceUpdatedListener.Event {
+
+    private final String projectName;
+    private final GitBatchRefUpdateListener.UpdatedRef updatedRef;
+    private final AccountInfo updater;
+
+    public GitReferenceUpdatedEvent(
+        Project.NameKey project,
+        GitBatchRefUpdateListener.UpdatedRef updatedRef,
+        AccountInfo updater) {
+      this.projectName = project.get();
+      this.updatedRef = updatedRef;
+      this.updater = updater;
+    }
+
+    @Override
+    public String getProjectName() {
+      return projectName;
+    }
+
+    @Override
+    public NotifyHandling getNotify() {
+      return NotifyHandling.ALL;
+    }
+
+    @Override
+    public String getRefName() {
+      return updatedRef.getRefName();
+    }
+
+    @Override
+    public String getOldObjectId() {
+      return updatedRef.getOldObjectId();
+    }
+
+    @Override
+    public String getNewObjectId() {
+      return updatedRef.getNewObjectId();
+    }
+
+    @Override
+    public boolean isCreate() {
+      return updatedRef.isCreate();
+    }
+
+    @Override
+    public boolean isDelete() {
+      return updatedRef.isDelete();
+    }
+
+    @Override
+    public boolean isNonFastForward() {
+      return updatedRef.isNonFastForward();
+    }
+
+    @Override
+    public AccountInfo getUpdater() {
+      return updater;
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/extensions/events/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/MultiProgressMonitor.java b/java/com/google/gerrit/server/git/MultiProgressMonitor.java
index 52a34d9..290e1e7 100644
--- a/java/com/google/gerrit/server/git/MultiProgressMonitor.java
+++ b/java/com/google/gerrit/server/git/MultiProgressMonitor.java
@@ -249,6 +249,7 @@
    * @param out stream for writing progress messages.
    * @param taskName name of the overall task.
    */
+  @SuppressWarnings("UnusedMethod")
   @AssistedInject
   private MultiProgressMonitor(
       CancellationMetrics cancellationMetrics,
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/ReceiveCommits.java b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
index 889dfd6..cab2960 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).ifPresent(this::addMessage);
   }
 
   private void insertChangesAndPatchSets(
@@ -3187,22 +3188,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));
@@ -3232,6 +3231,10 @@
     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..675709b 100644
--- a/java/com/google/gerrit/server/git/receive/ReplaceOp.java
+++ b/java/com/google/gerrit/server/git/receive/ReplaceOp.java
@@ -21,14 +21,16 @@
 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;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
 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 +48,25 @@
 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.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 ImmutableSet<PatchSetApproval> outdatedApprovals;
   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,23 @@
     }
     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);
+    outdatedApprovals =
+        approvalsUtil
+            .copyApprovalsToNewPatchSet(
+                ctx.getNotes(),
+                newPatchSet,
+                ctx.getRevWalk(),
+                ctx.getRepoView().getConfig(),
+                update)
+            .outdatedApprovals();
 
     mailMessage = insertChangeMessage(update, ctx, reviewMessage);
     if (mergedByPushOp == null) {
@@ -489,16 +499,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,
+            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 +534,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 +583,42 @@
     return rejectMessage;
   }
 
-  public ReceiveCommand getCommand() {
-    return cmd;
+  public Optional<String> getOutdatedApprovalsMessage() {
+    if (outdatedApprovals == null || outdatedApprovals.isEmpty()) {
+      return Optional.empty();
+    }
+
+    return Optional.of(
+        "The following approvals got outdated and were removed:\n"
+            + 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..c0e3471 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());
     }
 
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..fced578 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;
@@ -71,11 +68,9 @@
 
   /**
    * 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 +79,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()));
   }
 
   /**
diff --git a/java/com/google/gerrit/server/index/account/AccountSchemaDefinitions.java b/java/com/google/gerrit/server/index/account/AccountSchemaDefinitions.java
index 5de3ba4..2e75d46 100644
--- a/java/com/google/gerrit/server/index/account/AccountSchemaDefinitions.java
+++ b/java/com/google/gerrit/server/index/account/AccountSchemaDefinitions.java
@@ -22,30 +22,25 @@
 
 /** Definition of account index versions (schemata). See {@link SchemaDefinitions}. */
 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.EXTERNAL_ID_STATE,
           AccountField.FULL_NAME,
           AccountField.ID,
           AccountField.NAME_PART,
+          AccountField.NAME_PART_NO_SECONDARY_EMAIL,
+          AccountField.PREFERRED_EMAIL,
+          AccountField.PREFERRED_EMAIL_EXACT,
+          AccountField.REF_STATE,
           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);
-
   // Bump Lucene version requires reindexing
   @Deprecated static final Schema<AccountState> V9 = schema(V8);
 
@@ -55,14 +50,17 @@
   // 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)
           .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..81a4d1e 100644
--- a/java/com/google/gerrit/server/index/account/StalenessChecker.java
+++ b/java/com/google/gerrit/server/index/account/StalenessChecker.java
@@ -100,7 +100,7 @@
       return StalenessCheckResult.notStale();
     }
 
-    boolean useLegacyNumericFields = i.getSchema().useLegacyNumericFields();
+    boolean useLegacyNumericFields = i.getSchema().hasField(AccountField.ID);
     ImmutableSet<String> fields = useLegacyNumericFields ? FIELDS : FIELDS2;
     Optional<FieldBundle> result =
         i.getRaw(
diff --git a/java/com/google/gerrit/server/index/change/ChangeField.java b/java/com/google/gerrit/server/index/change/ChangeField.java
index c06347e..281bcb4 100644
--- a/java/com/google/gerrit/server/index/change/ChangeField.java
+++ b/java/com/google/gerrit/server/index/change/ChangeField.java
@@ -116,9 +116,6 @@
 
   // 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()));
 
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/ChangeIndexer.java b/java/com/google/gerrit/server/index/change/ChangeIndexer.java
index 8f68904..6849831 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.
diff --git a/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java b/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
index 0a06735..ec1506d 100644
--- a/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
+++ b/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
@@ -23,18 +23,25 @@
 /** Definition of change index versions (schemata). See {@link SchemaDefinitions}. */
 public class ChangeSchemaDefinitions extends SchemaDefinitions<ChangeData> {
   @Deprecated
-  static final Schema<ChangeData> V55 =
+  /** Added new field {@link ChangeField#IS_SUBMITTABLE} based on submit requirements. */
+  static final Schema<ChangeData> V74 =
       schema(
           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 +54,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 +89,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 +118,13 @@
       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.
+  static final Schema<ChangeData> V78 = schema(V77);
+
   /**
    * 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 49f6ff9..458f4a4 100644
--- a/java/com/google/gerrit/server/index/change/ReindexAfterRefUpdate.java
+++ b/java/com/google/gerrit/server/index/change/ReindexAfterRefUpdate.java
@@ -26,7 +26,7 @@
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
-import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
+import com.google.gerrit.extensions.events.GitBatchRefUpdateListener;
 import com.google.gerrit.server.change.MergeabilityComputationBehavior;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.GerritServerConfig;
@@ -53,7 +53,7 @@
  *
  * <p>Will reindex accounts when the account's NoteDb ref changes.
  */
-public class ReindexAfterRefUpdate implements GitReferenceUpdatedListener {
+public class ReindexAfterRefUpdate implements GitBatchRefUpdateListener {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   private final OneOffRequestContext requestContext;
@@ -86,48 +86,54 @@
   }
 
   @Override
-  public void onGitReferenceUpdated(Event event) {
-    if (allUsersName.get().equals(event.getProjectName())
-        && !RefNames.REFS_CONFIG.equals(event.getRefName())) {
-      Account.Id accountId = Account.Id.fromRef(event.getRefName());
-      if (accountId != null && !event.getRefName().startsWith(RefNames.REFS_STARRED_CHANGES)) {
-        indexer.get().index(accountId);
+  public void onGitBatchRefUpdate(GitBatchRefUpdateListener.Event event) {
+    if (allUsersName.get().equals(event.getProjectName())) {
+      for (UpdatedRef ref : event.getUpdatedRefs()) {
+        if (!RefNames.REFS_CONFIG.equals(ref.getRefName())) {
+          Account.Id accountId = Account.Id.fromRef(ref.getRefName());
+          if (accountId != null && !ref.getRefName().startsWith(RefNames.REFS_STARRED_CHANGES)) {
+            indexer.get().index(accountId);
+            break;
+          }
+        }
       }
       // The update is in All-Users and not on refs/meta/config. So it's not a change. Return early.
       return;
     }
 
-    if (!enabled
-        || event.getRefName().startsWith(RefNames.REFS_CHANGES)
-        || event.getRefName().startsWith(RefNames.REFS_DRAFT_COMMENTS)
-        || event.getRefName().startsWith(RefNames.REFS_USERS)) {
-      return;
-    }
-    Futures.addCallback(
-        executor.submit(new GetChanges(event)),
-        new FutureCallback<List<Change>>() {
-          @Override
-          public void onSuccess(List<Change> changes) {
-            for (Change c : changes) {
-              @SuppressWarnings("unused")
-              Future<?> possiblyIgnoredError =
-                  indexerFactory.create(executor, indexes).indexAsync(c.getProject(), c.getId());
+    for (UpdatedRef ref : event.getUpdatedRefs()) {
+      if (!enabled
+          || ref.getRefName().startsWith(RefNames.REFS_CHANGES)
+          || ref.getRefName().startsWith(RefNames.REFS_DRAFT_COMMENTS)
+          || ref.getRefName().startsWith(RefNames.REFS_USERS)) {
+        continue;
+      }
+      Futures.addCallback(
+          executor.submit(new GetChanges(event.getProjectName(), ref)),
+          new FutureCallback<List<Change>>() {
+            @Override
+            public void onSuccess(List<Change> changes) {
+              for (Change c : changes) {
+                @SuppressWarnings("unused")
+                Future<?> possiblyIgnoredError =
+                    indexerFactory.create(executor, indexes).indexAsync(c.getProject(), c.getId());
+              }
             }
-          }
 
-          @Override
-          public void onFailure(Throwable ignored) {
-            // Logged by {@link GetChanges#call()}.
-          }
-        },
-        directExecutor());
+            @Override
+            public void onFailure(Throwable ignored) {
+              // Logged by {@link GetChanges#call()}.
+            }
+          },
+          directExecutor());
+    }
   }
 
   private abstract class Task<V> implements Callable<V> {
-    protected Event event;
+    protected UpdatedRef updatedRef;
 
-    protected Task(Event event) {
-      this.event = event;
+    protected Task(UpdatedRef updatedRef) {
+      this.updatedRef = updatedRef;
     }
 
     @Override
@@ -135,7 +141,7 @@
       try (ManualRequestContext ctx = requestContext.open()) {
         return impl(ctx);
       } catch (Exception e) {
-        logger.atSevere().withCause(e).log("Failed to reindex changes after %s", event);
+        logger.atSevere().withCause(e).log("Failed to reindex changes after %s", updatedRef);
         throw e;
       }
     }
@@ -146,14 +152,17 @@
   }
 
   private class GetChanges extends Task<List<Change>> {
-    private GetChanges(Event event) {
-      super(event);
+    protected String projectName;
+
+    private GetChanges(String projectName, UpdatedRef updatedRef) {
+      super(updatedRef);
+      this.projectName = projectName;
     }
 
     @Override
     protected List<Change> impl(RequestContext ctx) {
-      String ref = event.getRefName();
-      Project.NameKey project = Project.nameKey(event.getProjectName());
+      String ref = updatedRef.getRefName();
+      Project.NameKey project = Project.nameKey(projectName);
       if (ref.equals(RefNames.REFS_CONFIG)) {
         return asChanges(queryProvider.get().byProjectOpen(project));
       }
@@ -163,9 +172,9 @@
     @Override
     public String toString() {
       return "Get changes to reindex caused by "
-          + event.getRefName()
+          + updatedRef.getRefName()
           + " update of project "
-          + event.getProjectName();
+          + projectName;
     }
 
     @Override
diff --git a/java/com/google/gerrit/server/index/group/GroupSchemaDefinitions.java b/java/com/google/gerrit/server/index/group/GroupSchemaDefinitions.java
index c4d8952..91dd285 100644
--- a/java/com/google/gerrit/server/index/group/GroupSchemaDefinitions.java
+++ b/java/com/google/gerrit/server/index/group/GroupSchemaDefinitions.java
@@ -23,23 +23,20 @@
 /** Definition of group index versions (schemata). See {@link SchemaDefinitions}. */
 public class GroupSchemaDefinitions extends SchemaDefinitions<InternalGroup> {
   @Deprecated
-  static final Schema<InternalGroup> V2 =
+  static final Schema<InternalGroup> V5 =
       schema(
+          GroupField.CREATED_ON,
           GroupField.DESCRIPTION,
           GroupField.ID,
           GroupField.IS_VISIBLE_TO_ALL,
+          GroupField.MEMBER,
           GroupField.NAME,
           GroupField.NAME_PART,
           GroupField.OWNER_UUID,
+          GroupField.REF_STATE,
+          GroupField.SUBGROUP,
           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);
-
   // Bump Lucene version requires reindexing
   @Deprecated static final Schema<InternalGroup> V6 = schema(V5);
 
@@ -48,7 +45,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/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/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..f5af783 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
diff --git a/java/com/google/gerrit/server/mail/send/ChangeEmail.java b/java/com/google/gerrit/server/mail/send/ChangeEmail.java
index 288ccf8..94b8c72 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;
@@ -83,6 +83,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;
@@ -559,7 +564,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..0718b5e 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;
   }
 
@@ -506,6 +532,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 +550,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 +623,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 +634,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/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..1b830d9 100644
--- a/java/com/google/gerrit/server/mail/send/ReplacePatchSetSender.java
+++ b/java/com/google/gerrit/server/mail/send/ReplacePatchSetSender.java
@@ -14,34 +14,88 @@
 
 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 +106,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();
@@ -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/SmtpEmailSender.java b/java/com/google/gerrit/server/mail/send/SmtpEmailSender.java
index 0a721cf..c06cc1e 100644
--- a/java/com/google/gerrit/server/mail/send/SmtpEmailSender.java
+++ b/java/com/google/gerrit/server/mail/send/SmtpEmailSender.java
@@ -385,7 +385,7 @@
     try (QuotedPrintableOutputStream qp = new QuotedPrintableOutputStream(s, false)) {
       qp.write(input.getBytes(UTF_8));
     }
-    return s.toString();
+    return s.toString(UTF_8);
   }
 
   private static void setMissingHeader(Map<String, EmailHeader> hdrs, String name, String value) {
diff --git a/java/com/google/gerrit/server/notedb/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/ChangeDraftUpdate.java b/java/com/google/gerrit/server/notedb/ChangeDraftUpdate.java
index 5d19205..73161d7 100644
--- a/java/com/google/gerrit/server/notedb/ChangeDraftUpdate.java
+++ b/java/com/google/gerrit/server/notedb/ChangeDraftUpdate.java
@@ -92,6 +92,7 @@
   private List<HumanComment> put = new ArrayList<>();
   private Map<Key, DeleteReason> delete = new HashMap<>();
 
+  @SuppressWarnings("UnusedMethod")
   @AssistedInject
   private ChangeDraftUpdate(
       @GerritPersonIdent PersonIdent serverIdent,
diff --git a/java/com/google/gerrit/server/notedb/ChangeNoteJson.java b/java/com/google/gerrit/server/notedb/ChangeNoteJson.java
index 37a38fe..de401ac 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNoteJson.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNoteJson.java
@@ -16,8 +16,11 @@
 
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.entities.EntitiesAdapterFactory;
+import com.google.gerrit.entities.SubmitRequirementExpressionResult;
+import com.google.gerrit.entities.SubmitRequirementExpressionResult.Status;
 import com.google.gerrit.json.EnumTypeAdapterFactory;
 import com.google.gerrit.json.OptionalSubmitRequirementExpressionResultAdapterFactory;
+import com.google.gerrit.json.OptionalTypeAdapter;
 import com.google.gson.Gson;
 import com.google.gson.GsonBuilder;
 import com.google.gson.JsonElement;
@@ -52,6 +55,7 @@
 
   static Gson newGson() {
     return new GsonBuilder()
+        .registerTypeAdapter(Optional.class, new OptionalTypeAdapter())
         .registerTypeAdapter(Timestamp.class, new CommentTimestampAdapter().nullSafe())
         .registerTypeAdapterFactory(new EnumTypeAdapterFactory())
         .registerTypeAdapterFactory(EntitiesAdapterFactory.create())
@@ -63,6 +67,9 @@
             new OptionalBooleanAdapter().nullSafe())
         .registerTypeAdapterFactory(new OptionalSubmitRequirementExpressionResultAdapterFactory())
         .registerTypeAdapter(ObjectId.class, new ObjectIdAdapter())
+        .registerTypeAdapter(
+            SubmitRequirementExpressionResult.Status.class,
+            new SubmitRequirementExpressionResultStatusAdapter())
         .setPrettyPrinting()
         .create();
   }
@@ -156,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..6f413f1 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. */
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotes.java b/java/com/google/gerrit/server/notedb/ChangeNotes.java
index ca636e8..cc4e9ce 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;
@@ -47,6 +46,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;
@@ -390,8 +390,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(
@@ -429,28 +428,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();
   }
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesParser.java b/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
index 1d8ec82..6700f25 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
@@ -1270,11 +1270,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() {
diff --git a/java/com/google/gerrit/server/notedb/ChangeUpdate.java b/java/com/google/gerrit/server/notedb/ChangeUpdate.java
index 8f352cb..85bb445 100644
--- a/java/com/google/gerrit/server/notedb/ChangeUpdate.java
+++ b/java/com/google/gerrit/server/notedb/ChangeUpdate.java
@@ -181,6 +181,7 @@
   private DeleteChangeMessageRewriter deleteChangeMessageRewriter;
   private List<SubmitRequirementResult> submitRequirementResults;
 
+  @SuppressWarnings("UnusedMethod")
   @AssistedInject
   private ChangeUpdate(
       @GerritPersonIdent PersonIdent serverIdent,
@@ -426,12 +427,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) {
@@ -913,11 +923,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.
diff --git a/java/com/google/gerrit/server/notedb/CommitRewriter.java b/java/com/google/gerrit/server/notedb/CommitRewriter.java
index 534da0d..da20475 100644
--- a/java/com/google/gerrit/server/notedb/CommitRewriter.java
+++ b/java/com/google/gerrit/server/notedb/CommitRewriter.java
@@ -23,6 +23,7 @@
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_TAG;
 import static com.google.gerrit.server.util.AccountTemplateUtil.ACCOUNT_TEMPLATE_PATTERN;
 import static com.google.gerrit.server.util.AccountTemplateUtil.ACCOUNT_TEMPLATE_REGEX;
+import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.auto.value.AutoValue;
 import com.google.common.base.Splitter;
@@ -355,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());
       }
@@ -577,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());
   }
 
@@ -1251,7 +1249,7 @@
       fmt.setContext(0);
       fmt.format(diff, oldBody, newBody);
       fmt.flush();
-      return out.toString();
+      return out.toString(UTF_8);
     }
   }
 
diff --git a/java/com/google/gerrit/server/notedb/RobotCommentUpdate.java b/java/com/google/gerrit/server/notedb/RobotCommentUpdate.java
index edf5bd3..7f067f5 100644
--- a/java/com/google/gerrit/server/notedb/RobotCommentUpdate.java
+++ b/java/com/google/gerrit/server/notedb/RobotCommentUpdate.java
@@ -69,6 +69,7 @@
 
   private List<RobotComment> put = new ArrayList<>();
 
+  @SuppressWarnings("UnusedMethod")
   @AssistedInject
   private RobotCommentUpdate(
       @GerritPersonIdent PersonIdent serverIdent,
@@ -81,6 +82,7 @@
     super(noteUtil, serverIdent, notes, null, accountId, realAccountId, authorIdent, when);
   }
 
+  @SuppressWarnings("UnusedMethod")
   @AssistedInject
   private RobotCommentUpdate(
       @GerritPersonIdent PersonIdent serverIdent,
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/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/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..17cc468
--- /dev/null
+++ b/java/com/google/gerrit/server/project/LabelConfigValidator.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.server.project;
+
+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.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 (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();
+
+  // 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(ProjectConfig.KEY_COPY_ANY_SCORE, "is:ANY")
+          .put(ProjectConfig.KEY_COPY_MIN_SCORE, "is:MIN")
+          .put(ProjectConfig.KEY_COPY_MAX_SCORE, "is:MAX")
+          .put(
+              ProjectConfig.KEY_COPY_ALL_SCORES_IF_NO_CHANGE,
+              "changekind:" + ChangeKind.NO_CHANGE.name())
+          .put(
+              ProjectConfig.KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE,
+              "changekind:" + ChangeKind.NO_CODE_CHANGE.name())
+          .put(
+              ProjectConfig.KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE,
+              "changekind:" + ChangeKind.MERGE_FIRST_PARENT_UPDATE.name())
+          .put(
+              ProjectConfig.KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE,
+              "changekind:" + ChangeKind.TRIVIAL_REBASE.name())
+          .put(
+              ProjectConfig.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,
+                      ProjectConfig.KEY_COPY_VALUE,
+                      ProjectConfig.LABEL,
+                      labelName,
+                      ProjectConfig.KEY_COPY_CONDITION),
+                  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 copyValuesChangedOrNewlySet(
+      Config newConfig, @Nullable Config oldConfig, String labelName) {
+    if (oldConfig == null) {
+      return newConfig
+          .getNames(ProjectConfig.LABEL, labelName)
+          .contains(ProjectConfig.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, ProjectConfig.KEY_COPY_VALUE));
+    ImmutableSet<String> newValues =
+        ImmutableSet.copyOf(
+            newConfig.getStringList(ProjectConfig.LABEL, labelName, ProjectConfig.KEY_COPY_VALUE));
+    return !newValues.isEmpty() && !Sets.difference(newValues, oldValues).isEmpty();
+  }
+}
diff --git a/java/com/google/gerrit/server/project/SubmitRequirementsEvaluatorImpl.java b/java/com/google/gerrit/server/project/SubmitRequirementsEvaluatorImpl.java
index c57fe27..b6926b2 100644
--- a/java/com/google/gerrit/server/project/SubmitRequirementsEvaluatorImpl.java
+++ b/java/com/google/gerrit/server/project/SubmitRequirementsEvaluatorImpl.java
@@ -25,6 +25,8 @@
 import com.google.gerrit.entities.SubmitRequirementResult;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.server.experiments.ExperimentFeatures;
+import com.google.gerrit.server.experiments.ExperimentFeaturesConstants;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.SubmitRequirementChangeQueryBuilder;
@@ -48,6 +50,7 @@
   private final PluginSetContext<SubmitRequirement> globalSubmitRequirements;
   private final SubmitRequirementsUtil submitRequirementsUtil;
   private final OneOffRequestContext requestContext;
+  private final ExperimentFeatures experimentFeatures;
 
   public static Module module() {
     return new AbstractModule() {
@@ -66,12 +69,14 @@
       ProjectCache projectCache,
       PluginSetContext<SubmitRequirement> globalSubmitRequirements,
       SubmitRequirementsUtil submitRequirementsUtil,
-      OneOffRequestContext requestContext) {
+      OneOffRequestContext requestContext,
+      ExperimentFeatures experimentFeatures) {
     this.queryBuilder = queryBuilder;
     this.projectCache = projectCache;
     this.globalSubmitRequirements = globalSubmitRequirements;
     this.submitRequirementsUtil = submitRequirementsUtil;
     this.requestContext = requestContext;
+    this.experimentFeatures = experimentFeatures;
   }
 
   @Override
@@ -105,8 +110,23 @@
           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<SubmitRequirementExpressionResult> overrideResult;
+      if (experimentFeatures.isFeatureEnabled(
+          ExperimentFeaturesConstants
+              .GERRIT_BACKEND_REQUEST_FEATURE_SR_EXPRESSIONS_NOT_EVALUATED)) {
+        submittabilityResult =
+            Optional.of(
+                SubmitRequirementExpressionResult.notEvaluated(sr.submittabilityExpression()));
+        overrideResult =
+            sr.overrideExpression().isPresent()
+                ? Optional.of(
+                    SubmitRequirementExpressionResult.notEvaluated(sr.overrideExpression().get()))
+                : Optional.empty();
+      } else {
+        submittabilityResult = Optional.empty();
+        overrideResult = Optional.empty();
+      }
       if (!sr.applicabilityExpression().isPresent()
           || SubmitRequirementResult.assertPass(applicabilityResult)) {
         submittabilityResult = Optional.of(evaluateExpression(sr.submittabilityExpression(), cd));
diff --git a/java/com/google/gerrit/server/query/account/AccountPredicates.java b/java/com/google/gerrit/server/query/account/AccountPredicates.java
index 8f94089..dd8d685 100644
--- a/java/com/google/gerrit/server/query/account/AccountPredicates.java
+++ b/java/com/google/gerrit/server/query/account/AccountPredicates.java
@@ -66,7 +66,7 @@
 
   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) ? AccountField.ID : AccountField.ID_STR,
         AccountQueryBuilder.FIELD_ACCOUNT,
         accountId.toString());
   }
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/change/ChangeData.java b/java/com/google/gerrit/server/query/change/ChangeData.java
index ad422bc..b99f746 100644
--- a/java/com/google/gerrit/server/query/change/ChangeData.java
+++ b/java/com/google/gerrit/server/query/change/ChangeData.java
@@ -76,7 +76,7 @@
 import com.google.gerrit.server.config.AllUsersName;
 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;
@@ -289,7 +289,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;
@@ -371,7 +371,7 @@
       ChangeNotes.Factory notesFactory,
       CommentsUtil commentsUtil,
       GitRepositoryManager repoManager,
-      MergeUtil.Factory mergeUtilFactory,
+      MergeUtilFactory mergeUtilFactory,
       MergeabilityCache mergeabilityCache,
       PatchListCache patchListCache,
       PatchSetUtil psUtil,
@@ -942,7 +942,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() {
diff --git a/java/com/google/gerrit/server/query/change/ChangePredicates.java b/java/com/google/gerrit/server/query/change/ChangePredicates.java
index ce17b31..c874db7 100644
--- a/java/com/google/gerrit/server/query/change/ChangePredicates.java
+++ b/java/com/google/gerrit/server/query/change/ChangePredicates.java
@@ -136,15 +136,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 ChangeIndexPredicate(
-        ChangeField.LEGACY_ID, ChangeQueryBuilder.FIELD_CHANGE, id.toString());
-  }
-
-  /**
-   * Returns a predicate that matches the change with the provided {@link
-   * com.google.gerrit.entities.Change.Id}.
-   */
   public static Predicate<ChangeData> idStr(Change.Id id) {
     return new ChangeIndexPredicate(
         ChangeField.LEGACY_ID_STR, ChangeQueryBuilder.FIELD_CHANGE, id.toString());
diff --git a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
index 28ffef7..9e9a960 100644
--- a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
@@ -559,9 +559,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));
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/restapi/account/DeleteDraftComments.java b/java/com/google/gerrit/server/restapi/account/DeleteDraftComments.java
index fbd99eb..fbb4fbd 100644
--- a/java/com/google/gerrit/server/restapi/account/DeleteDraftComments.java
+++ b/java/com/google/gerrit/server/restapi/account/DeleteDraftComments.java
@@ -70,7 +70,7 @@
 
   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;
@@ -83,7 +83,7 @@
   DeleteDraftComments(
       Provider<CurrentUser> userProvider,
       BatchUpdate.Factory batchUpdateFactory,
-      Provider<ChangeQueryBuilder> queryBuilderProvider,
+      ChangeQueryBuilder queryBuilder,
       Provider<InternalChangeQuery> queryProvider,
       ChangeData.Factory changeDataFactory,
       ChangeJson.Factory changeJsonFactory,
@@ -93,7 +93,7 @@
       ExperimentFeatures experimentFeatures) {
     this.userProvider = userProvider;
     this.batchUpdateFactory = batchUpdateFactory;
-    this.queryBuilderProvider = queryBuilderProvider;
+    this.queryBuilder = queryBuilder;
     this.queryProvider = queryProvider;
     this.changeDataFactory = changeDataFactory;
     this.changeJsonFactory = changeJsonFactory;
@@ -161,7 +161,7 @@
       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/change/ChangeRestApiModule.java b/java/com/google/gerrit/server/restapi/change/ChangeRestApiModule.java
index e09f2f4..e4b9ca7 100644
--- a/java/com/google/gerrit/server/restapi/change/ChangeRestApiModule.java
+++ b/java/com/google/gerrit/server/restapi/change/ChangeRestApiModule.java
@@ -210,9 +210,11 @@
     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(RebaseChangeOp.Factory.class);
     factory(ReviewerResource.Factory.class);
     factory(SetAssigneeOp.Factory.class);
diff --git a/java/com/google/gerrit/server/restapi/change/CherryPickChange.java b/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
index cb08c11..00c48dc 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..8c56967 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;
@@ -320,9 +320,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 +356,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..aeaae9f 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,8 @@
               firstNonNull(input.notify, NotifyHandling.ALL), input.notifyDetails));
       bu.addOp(
           change.getId(),
-          new Op(
-              projectCache
-                  .get(r.getChange().getProject())
-                  .orElseThrow(illegalState(r.getChange().getProject())),
-              r.getReviewerUser().state(),
-              rsrc.getLabel(),
-              input));
+          deleteVoteOpFactory.create(
+              r.getChange().getProject(), r.getReviewerUser().state(), rsrc.getLabel(), input));
       if (!input.ignoreAutomaticAttentionSetRules
           && !r.getReviewerUser().getAccountId().equals(currentUserProvider.get().getAccountId())) {
         bu.addOp(
@@ -164,109 +108,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..6a83940
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/DeleteVoteOp.java
@@ -0,0 +1,206 @@
+// 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);
+  }
+
+  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 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) {
+    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;
+  }
+
+  @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 (!ctx.getUser().isInternalUser()) {
+        // 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)).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/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/PostReview.java b/java/com/google/gerrit/server/restapi/change/PostReview.java
index 4605d7c..8b47e1e 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,15 @@
   public static final String ERROR_WIP_READY_MUTUALLY_EXCLUSIVE =
       "work_in_progress and ready are mutually exclusive";
 
-  public static final String START_REVIEW_MESSAGE = "This change is ready for review.";
-
   private final BatchUpdate.Factory updateFactory;
+  private final PostReviewOp.Factory postReviewOpFactory;
   private final ChangeResource.Factory changeResourceFactory;
   private final ChangeData.Factory changeDataFactory;
   private final AccountCache accountCache;
   private final ApprovalsUtil approvalsUtil;
-  private final ChangeMessagesUtil cmUtil;
   private final CommentsUtil commentsUtil;
-  private final PublishCommentUtil publishCommentUtil;
-  private final PatchSetUtil psUtil;
   private final PatchListCache patchListCache;
   private final AccountResolver accountResolver;
-  private final EmailReviewComments.Factory email;
-  private final CommentAdded commentAdded;
   private final ReviewerModifier reviewerModifier;
   private final Metrics metrics;
   private final ModifyReviewersEmail modifyReviewersEmail;
@@ -208,28 +170,22 @@
   private final WorkInProgressOp.Factory workInProgressOpFactory;
   private final ProjectCache projectCache;
   private final PermissionBackend permissionBackend;
-  private final PluginSetContext<CommentValidator> commentValidators;
-  private final PluginSetContext<OnPostReview> onPostReviews;
+
   private final ReplyAttentionSetUpdates replyAttentionSetUpdates;
   private final ReviewerAdded reviewerAdded;
   private final boolean strictLabels;
-  private final boolean publishPatchSetLevelComment;
 
   @Inject
   PostReview(
       BatchUpdate.Factory updateFactory,
+      PostReviewOp.Factory postReviewOpFactory,
       ChangeResource.Factory changeResourceFactory,
       ChangeData.Factory changeDataFactory,
       AccountCache accountCache,
       ApprovalsUtil approvalsUtil,
-      ChangeMessagesUtil cmUtil,
       CommentsUtil commentsUtil,
-      PublishCommentUtil publishCommentUtil,
-      PatchSetUtil psUtil,
       PatchListCache patchListCache,
       AccountResolver accountResolver,
-      EmailReviewComments.Factory email,
-      CommentAdded commentAdded,
       ReviewerModifier reviewerModifier,
       Metrics metrics,
       ModifyReviewersEmail modifyReviewersEmail,
@@ -238,23 +194,17 @@
       WorkInProgressOp.Factory workInProgressOpFactory,
       ProjectCache projectCache,
       PermissionBackend permissionBackend,
-      PluginSetContext<CommentValidator> commentValidators,
-      PluginSetContext<OnPostReview> onPostReviews,
       ReplyAttentionSetUpdates replyAttentionSetUpdates,
       ReviewerAdded reviewerAdded) {
     this.updateFactory = updateFactory;
+    this.postReviewOpFactory = postReviewOpFactory;
     this.changeResourceFactory = changeResourceFactory;
     this.changeDataFactory = changeDataFactory;
     this.accountCache = accountCache;
     this.commentsUtil = commentsUtil;
-    this.publishCommentUtil = publishCommentUtil;
-    this.psUtil = psUtil;
     this.patchListCache = patchListCache;
     this.approvalsUtil = approvalsUtil;
-    this.cmUtil = cmUtil;
     this.accountResolver = accountResolver;
-    this.email = email;
-    this.commentAdded = commentAdded;
     this.reviewerModifier = reviewerModifier;
     this.metrics = metrics;
     this.modifyReviewersEmail = modifyReviewersEmail;
@@ -262,13 +212,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 +300,13 @@
     }
     output.labels = input.labels;
 
+    // Notify based on ReviewInput, ignoring the notify settings from any ReviewerInputs.
+    NotifyResolver.Result notify = notifyResolver.resolve(input.notify, input.notifyDetails);
+
     try (BatchUpdate bu =
         updateFactory.create(revision.getChange().getProject(), revision.getUser(), ts)) {
+      bu.setNotify(notify);
+
       Account account = revision.getUser().asIdentifiedUser().getAccount();
       boolean ccOrReviewer = false;
       if (input.labels != null && !input.labels.isEmpty()) {
@@ -427,30 +378,27 @@
 
       // Add the review op.
       logger.atFine().log("posting review");
-      bu.addOp(
-          revision.getChange().getId(), new Op(projectState, revision.getPatchSet().id(), input));
-
-      // Notify based on ReviewInput, ignoring the notify settings from any ReviewerInputs.
-      NotifyResolver.Result notify = notifyResolver.resolve(input.notify, input.notifyDetails);
-      bu.setNotify(notify);
+      PostReviewOp postReviewOp =
+          postReviewOpFactory.create(projectState, revision.getPatchSet().id(), input);
+      bu.addOp(revision.getChange().getId(), postReviewOp);
 
       // Adjust the attention set based on the input
       replyAttentionSetUpdates.updateAttentionSet(
           bu, revision.getNotes(), input, revision.getUser());
       bu.execute();
-
-      // Re-read change to take into account results of the update.
-      ChangeData cd = changeDataFactory.create(revision.getProject(), revision.getChange().getId());
-      for (ReviewerModification reviewerResult : reviewerResults) {
-        reviewerResult.gatherResults(cd);
-      }
-
-      // Sending emails and events from ReviewersOps was suppressed so we can send a single batch
-      // email/event here.
-      batchEmailReviewers(revision.getUser(), revision.getChange(), reviewerResults, notify);
-      batchReviewerEvents(revision.getUser(), cd, revision.getPatchSet(), reviewerResults, ts);
     }
 
+    // Re-read change to take into account results of the update.
+    ChangeData cd = changeDataFactory.create(revision.getProject(), revision.getChange().getId());
+    for (ReviewerModification reviewerResult : reviewerResults) {
+      reviewerResult.gatherResults(cd);
+    }
+
+    // Sending emails and events from ReviewersOps was suppressed so we can send a single batch
+    // email/event here.
+    batchEmailReviewers(revision.getUser(), revision.getChange(), reviewerResults, notify);
+    batchReviewerEvents(revision.getUser(), cd, revision.getPatchSet(), reviewerResults, ts);
+
     return Response.ok(output);
   }
 
@@ -487,7 +435,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<>();
@@ -698,10 +648,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 +954,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/PostReviewOp.java b/java/com/google/gerrit/server/restapi/change/PostReviewOp.java
new file mode 100644
index 0000000..5ff0968
--- /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.
+      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()
+              || (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/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/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..f991e17 100644
--- a/java/com/google/gerrit/server/restapi/change/ReviewersUtil.java
+++ b/java/com/google/gerrit/server/restapi/change/ReviewersUtil.java
@@ -256,7 +256,7 @@
       logger.atFine().log("accounts index query: %s", pred);
       accountIndexRewriter.validateMaxTermsInQuery(pred);
       boolean useLegacyNumericFields =
-          accountIndexes.getSearchIndex().getSchema().useLegacyNumericFields();
+          accountIndexes.getSearchIndex().getSchema().hasField(AccountField.ID);
       FieldDef<AccountState, ?> idField =
           useLegacyNumericFields ? AccountField.ID : AccountField.ID_STR;
       ResultSet<FieldBundle> result =
diff --git a/java/com/google/gerrit/server/restapi/change/Revisions.java b/java/com/google/gerrit/server/restapi/change/Revisions.java
index 41fecaf..1d5064e 100644
--- a/java/com/google/gerrit/server/restapi/change/Revisions.java
+++ b/java/com/google/gerrit/server/restapi/change/Revisions.java
@@ -154,9 +154,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 +164,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/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/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/project/CreateTag.java b/java/com/google/gerrit/server/restapi/project/CreateTag.java
index 6980006..c5013f7 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateTag.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateTag.java
@@ -44,7 +44,7 @@
 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;
@@ -136,7 +136,7 @@
                   resource
                       .getUser()
                       .asIdentifiedUser()
-                      .newCommitterIdent(TimeUtil.now(), TimeZone.getDefault()));
+                      .newCommitterIdent(TimeUtil.now(), ZoneId.systemDefault()));
         }
 
         Ref result = tag.call();
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/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/rules/PrologRuleEvaluator.java b/java/com/google/gerrit/server/rules/PrologRuleEvaluator.java
index ddc3fca..cab5b45 100644
--- a/java/com/google/gerrit/server/rules/PrologRuleEvaluator.java
+++ b/java/com/google/gerrit/server/rules/PrologRuleEvaluator.java
@@ -98,6 +98,7 @@
   private final PrologOptions opts;
   private Term submitRule;
 
+  @SuppressWarnings("UnusedMethod")
   @AssistedInject
   private PrologRuleEvaluator(
       AccountCache accountCache,
diff --git a/java/com/google/gerrit/server/schema/AllProjectsInput.java b/java/com/google/gerrit/server/schema/AllProjectsInput.java
index c040347..60dbc6c 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,12 @@
                 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)
+        // override the default which is true and rely on the copy condition instead
+        .setCopyAllScoresIfNoChange(false)
+        .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..7d49b97
--- /dev/null
+++ b/java/com/google/gerrit/server/schema/MigrateLabelConfigToCopyCondition.java
@@ -0,0 +1,305 @@
+// 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.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>
+ */
+public class MigrateLabelConfigToCopyCondition {
+  public static final String COMMIT_MESSAGE = "Migrate label configs to copy conditions";
+
+  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, ProjectConfig.KEY_COPY_ANY_SCORE, () -> copyConditions.add("is:ANY"));
+    ifTrue(cfg, labelName, ProjectConfig.KEY_COPY_MIN_SCORE, () -> copyConditions.add("is:MIN"));
+    ifTrue(cfg, labelName, ProjectConfig.KEY_COPY_MAX_SCORE, () -> copyConditions.add("is:MAX"));
+    forEachSkipNullValues(
+        cfg,
+        labelName,
+        ProjectConfig.KEY_COPY_VALUE,
+        value -> copyConditions.add("is:" + quoteIfNegative(parseCopyValue(value))));
+    ifTrue(
+        cfg,
+        labelName,
+        ProjectConfig.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,
+          ProjectConfig.KEY_COPY_ALL_SCORES_IF_NO_CHANGE,
+          () -> copyConditions.add("changekind:" + ChangeKind.NO_CHANGE.name()));
+    }
+
+    ifTrue(
+        cfg,
+        labelName,
+        ProjectConfig.KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE,
+        () -> copyConditions.add("changekind:" + ChangeKind.NO_CODE_CHANGE.name()));
+    ifTrue(
+        cfg,
+        labelName,
+        ProjectConfig.KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE,
+        () -> copyConditions.add("changekind:" + ChangeKind.MERGE_FIRST_PARENT_UPDATE.name()));
+    ifTrue(
+        cfg,
+        labelName,
+        ProjectConfig.KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE,
+        () -> copyConditions.add("changekind:" + ChangeKind.TRIVIAL_REBASE.name()));
+    ifTrue(
+        cfg,
+        labelName,
+        ProjectConfig.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, ProjectConfig.KEY_COPY_ANY_SCORE);
+    cfg.unset(ProjectConfig.LABEL, labelName, ProjectConfig.KEY_COPY_MIN_SCORE);
+    cfg.unset(ProjectConfig.LABEL, labelName, ProjectConfig.KEY_COPY_MAX_SCORE);
+    cfg.unset(ProjectConfig.LABEL, labelName, ProjectConfig.KEY_COPY_VALUE);
+    cfg.unset(ProjectConfig.LABEL, labelName, ProjectConfig.KEY_COPY_ALL_SCORES_IF_NO_CHANGE);
+    cfg.unset(ProjectConfig.LABEL, labelName, ProjectConfig.KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE);
+    cfg.unset(
+        ProjectConfig.LABEL,
+        labelName,
+        ProjectConfig.KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE);
+    cfg.unset(ProjectConfig.LABEL, labelName, ProjectConfig.KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE);
+    cfg.unset(
+        ProjectConfig.LABEL,
+        labelName,
+        ProjectConfig.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/testing/AllProjectsCreatorTestUtil.java b/java/com/google/gerrit/server/schema/testing/AllProjectsCreatorTestUtil.java
index e7d8337..1516fd5 100644
--- a/java/com/google/gerrit/server/schema/testing/AllProjectsCreatorTestUtil.java
+++ b/java/com/google/gerrit/server/schema/testing/AllProjectsCreatorTestUtil.java
@@ -102,8 +102,8 @@
           "[label \"Code-Review\"]",
           "  function = MaxWithBlock",
           "  defaultValue = 0",
-          "  copyMinScore = true",
-          "  copyAllScoresOnTrivialRebase = true",
+          "  copyAllScoresIfNoChange = false",
+          "  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..1a49171 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(
@@ -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 83c6634..f638078 100644
--- a/java/com/google/gerrit/server/submit/SubmitStrategy.java
+++ b/java/com/google/gerrit/server/submit/SubmitStrategy.java
@@ -43,6 +43,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;
@@ -151,7 +152,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..3dfd9b4 100644
--- a/java/com/google/gerrit/server/update/BatchUpdate.java
+++ b/java/com/google/gerrit/server/update/BatchUpdate.java
@@ -69,6 +69,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 +77,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;
@@ -257,8 +257,8 @@
     }
 
     @Override
-    public TimeZone getTimeZone() {
-      return tz;
+    public ZoneId getZoneId() {
+      return zoneId;
     }
 
     @Override
@@ -354,6 +354,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 +383,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();
@@ -416,7 +422,7 @@
     this.project = project;
     this.user = user;
     this.when = when;
-    tz = serverIdent.getTimeZone();
+    zoneId = serverIdent.getZoneId();
   }
 
   @Override
@@ -660,7 +666,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);
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/BaseCommand.java b/java/com/google/gerrit/sshd/BaseCommand.java
index c847c91..c1c58c8 100644
--- a/java/com/google/gerrit/sshd/BaseCommand.java
+++ b/java/com/google/gerrit/sshd/BaseCommand.java
@@ -398,7 +398,7 @@
       isZeroLength = Arrays.stream(stackTrace).anyMatch(s -> s.toString().contains(zeroLength));
     }
     if (!isZeroLength) {
-      logger.atSevere().withCause(e).log(message.toString());
+      logger.atSevere().withCause(e).log("%s", message);
     }
   }
 
diff --git a/java/com/google/gerrit/sshd/SshDaemon.java b/java/com/google/gerrit/sshd/SshDaemon.java
index f9d0769..cc35a32 100644
--- a/java/com/google/gerrit/sshd/SshDaemon.java
+++ b/java/com/google/gerrit/sshd/SshDaemon.java
@@ -643,7 +643,7 @@
           msg.append(avail[i].getName());
         }
         msg.append(" is supported");
-        logger.atSevere().log(msg.toString());
+        logger.atSevere().log("%s", msg);
       } else if (add) {
         if (!def.contains(n)) {
           def.add(n);
diff --git a/java/com/google/gerrit/sshd/commands/Receive.java b/java/com/google/gerrit/sshd/commands/Receive.java
index 92666f3..3f2e2ad 100644
--- a/java/com/google/gerrit/sshd/commands/Receive.java
+++ b/java/com/google/gerrit/sshd/commands/Receive.java
@@ -110,7 +110,7 @@
         msg.append(currentUser.getAccountId());
         msg.append("): ");
         msg.append(badStream.getCause().getMessage());
-        logger.atInfo().log(msg.toString());
+        logger.atInfo().log("%s", msg);
         throw new UnloggedFailure(128, "error: " + badStream.getCause().getMessage());
       }
       StringBuilder msg = new StringBuilder();
diff --git a/java/com/google/gerrit/sshd/commands/ScpCommand.java b/java/com/google/gerrit/sshd/commands/ScpCommand.java
index 6912795..3be98fd 100644
--- a/java/com/google/gerrit/sshd/commands/ScpCommand.java
+++ b/java/com/google/gerrit/sshd/commands/ScpCommand.java
@@ -147,7 +147,7 @@
     for (; ; ) {
       int c = in.read();
       if (c == '\n') {
-        return baos.toString();
+        return baos.toString(UTF_8);
       } else if (c == -1) {
         throw new IOException("End of stream");
       } else {
diff --git a/java/com/google/gerrit/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/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..9f47925 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
@@ -158,7 +158,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;
@@ -2466,9 +2465,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 +2478,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);
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 e00a137..90ca047 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";
@@ -1461,4 +1488,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..a08f00a 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java
@@ -626,7 +626,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);
@@ -1172,15 +1172,104 @@
     gApi.changes().id(r.getChangeId()).current().review(input);
 
     // Make a new patchset, keeping the Code-Review +2 vote.
-    amendChange(r.getChangeId());
+    r = amendChange(r.getChangeId());
+
+    // Verify that the approval has been copied to patch set 2 (use the currentApprovals() method
+    // rather than the approvals() method since the latter one filters out copied approvals).
+    List<PatchSetApproval> approvalsPs2 = r.getChange().currentApprovals();
+    assertThat(approvalsPs2).hasSize(1);
+    assertThat(Iterables.getOnlyElement(approvalsPs2).copied()).isTrue();
 
     // Post without changing the vote.
     input = new ReviewInput().label(LabelId.CODE_REVIEW, 2);
     gApi.changes().id(r.getChangeId()).current().review(input);
 
-    // There is a vote both on patchset 1 and on patchset 2, although both votes are Code-Review +2.
+    // There is a vote both on patch set 1 and on patch set 2, although both votes are Code-Review
+    // +2. The approval on patch set 2 is no longer copied since it was reapplied.
     assertThat(r.getChange().approvals().get(PatchSet.id(r.getChange().getId(), 1))).hasSize(1);
-    assertThat(r.getChange().approvals().get(PatchSet.id(r.getChange().getId(), 2))).hasSize(1);
+    approvalsPs2 = r.getChange().currentApprovals();
+    assertThat(approvalsPs2).hasSize(1);
+    assertThat(Iterables.getOnlyElement(approvalsPs2).copied()).isFalse();
+  }
+
+  @Test
+  public void copiedVoteIsNotReapplied_onVoteOnOtherLabel() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:ANY"));
+
+    PushOneCommit.Result r = createChange();
+
+    // Add vote that will be copied.
+    ReviewInput input = new ReviewInput().label(LabelId.CODE_REVIEW, 2);
+    gApi.changes().id(r.getChangeId()).current().review(input);
+
+    // Create a new patchset, the Code-Review +2 vote is copied.
+    r = amendChange(r.getChangeId());
+
+    // Verify that the approval has been copied to patch set 2 (use the currentApprovals() method
+    // rather than the approvals() method since the latter one filters out copied approvals).
+    List<PatchSetApproval> approvalsPs2 = r.getChange().currentApprovals();
+    assertThat(approvalsPs2).hasSize(1);
+    assertThat(Iterables.getOnlyElement(approvalsPs2).copied()).isTrue();
+
+    // Vote on another label. This shouldn't touch the copied approval.
+    input = new ReviewInput().label(LabelId.VERIFIED, 1);
+    gApi.changes().id(r.getChangeId()).current().review(input);
+
+    // Patch set 2 has 2 approvals now, one copied approval for the Code-Review label and one
+    // non-copied
+    // approval for the Verified label.
+    approvalsPs2 = r.getChange().currentApprovals();
+    assertThat(approvalsPs2).hasSize(2);
+    assertThat(
+            Iterables.getOnlyElement(
+                    approvalsPs2.stream()
+                        .filter(psa -> LabelId.CODE_REVIEW.equals(psa.label()))
+                        .collect(toImmutableList()))
+                .copied())
+        .isTrue();
+    assertThat(
+            Iterables.getOnlyElement(
+                    approvalsPs2.stream()
+                        .filter(psa -> LabelId.VERIFIED.equals(psa.label()))
+                        .collect(toImmutableList()))
+                .copied())
+        .isFalse();
+  }
+
+  @Test
+  public void copiedVoteIsNotReapplied_onRebase() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:ANY"));
+
+    PushOneCommit.Result r = createChange();
+
+    // Create a sibling change
+    testRepo.reset("HEAD~1");
+    PushOneCommit.Result r2 = createChange();
+
+    // Add vote that will be copied.
+    approve(r2.getChangeId());
+
+    // Verify that that the approval exists and is not copied.
+    List<PatchSetApproval> approvalsPs2 = r2.getChange().currentApprovals();
+    assertThat(approvalsPs2).hasSize(1);
+    assertThat(Iterables.getOnlyElement(approvalsPs2).copied()).isFalse();
+
+    // Approve, verify and submit the first change.
+    approve(r.getChangeId());
+    gApi.changes()
+        .id(r.getChangeId())
+        .current()
+        .review(new ReviewInput().label(LabelId.VERIFIED, 1));
+    gApi.changes().id(r.getChangeId()).current().submit();
+
+    // Rebase the second change, the approval should be sticky.
+    gApi.changes().id(r2.getChangeId()).rebase();
+
+    // Verify that the approval has been copied to patch set 2 (use the currentApprovals() method
+    // rather than the approvals() method since the latter one filters out copied approvals).
+    approvalsPs2 = changeDataFactory.create(project, r2.getChange().getId()).currentApprovals();
+    assertThat(approvalsPs2).hasSize(1);
+    assertThat(Iterables.getOnlyElement(approvalsPs2).copied()).isTrue();
   }
 
   @Test
@@ -1208,7 +1297,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());
 
@@ -1258,7 +1347,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);
@@ -1304,7 +1393,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());
 
@@ -1364,7 +1453,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());
 
@@ -1416,7 +1505,8 @@
         Iterables.getOnlyElement(
             r.getChange()
                 .notes()
-                .getApprovalsWithCopied()
+                .getApprovals()
+                .all()
                 .get(r.getChange().change().currentPatchSetId()));
 
     assertThat(nonCopiedSecondPatchsetRemovedVote.patchSetId().get()).isEqualTo(2);
@@ -1453,7 +1543,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)
@@ -1536,8 +1626,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 +1648,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..b9b77fe 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementIT.java
@@ -34,6 +34,7 @@
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.UseTimezone;
 import com.google.gerrit.acceptance.VerifyNoPiiInChangeNotes;
+import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.change.IndexOperations;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate;
@@ -68,6 +69,7 @@
 import com.google.gerrit.extensions.common.SubmitRequirementResultInfo.Status;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.httpd.raw.IndexPreloadingUtil;
+import com.google.gerrit.server.experiments.ExperimentFeaturesConstants;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.testing.TestLabels;
@@ -1894,13 +1896,19 @@
         /* expression= */ null,
         /* passingAtoms= */ null,
         /* failingAtoms= */ null,
+        /* status= */ SubmitRequirementExpressionInfo.Status.PASS,
         /* fulfilled= */ true);
     assertThat(requirement.submittabilityExpressionResult).isNotNull();
   }
 
   @Test
-  public void submitRequirement_nonApplicable_submittabilityAndOverrideNotEvaluated()
-      throws Exception {
+  @GerritConfig(
+      name = "experiments.enabled",
+      value =
+          ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_SR_EXPRESSIONS_NOT_EVALUATED)
+  public void
+      submitRequirement_nonApplicable_submittabilityAndOverrideNotEvaluatedIfExperimentEnabled()
+          throws Exception {
     configSubmitRequirement(
         project,
         SubmitRequirement.builder()
@@ -1927,6 +1935,49 @@
         /* expression= */ null,
         /* passingAtoms= */ null,
         /* failingAtoms= */ null,
+        /* status= */ SubmitRequirementExpressionInfo.Status.FAIL,
+        /* fulfilled= */ false);
+    assertThat(requirement.submittabilityExpressionResult.status)
+        .isEqualTo(SubmitRequirementExpressionInfo.Status.NOT_EVALUATED);
+    assertThat(requirement.submittabilityExpressionResult.expression)
+        .isEqualTo(SubmitRequirementExpression.maxCodeReview().expressionString());
+    assertThat(requirement.overrideExpressionResult.status)
+        .isEqualTo(SubmitRequirementExpressionInfo.Status.NOT_EVALUATED);
+    assertThat(requirement.overrideExpressionResult.expression)
+        .isEqualTo("project:" + project.get());
+  }
+
+  @Test
+  public void
+      submitRequirement_nonApplicable_submittabilityAndOverrideAreEmptyIfExperimentNotEnabled()
+          throws Exception {
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setApplicabilityExpression(
+                SubmitRequirementExpression.of("branch:refs/heads/non-existent"))
+            .setSubmittabilityExpression(SubmitRequirementExpression.maxCodeReview())
+            .setOverrideExpression(SubmitRequirementExpression.of("project:" + project.get()))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    voteLabel(changeId, "Code-Review", 2);
+
+    ChangeInfo changeInfo = gApi.changes().id(changeId).get();
+    assertSubmitRequirementStatus(
+        changeInfo.submitRequirements, "Code-Review", Status.NOT_APPLICABLE, /* isLegacy= */ false);
+    SubmitRequirementResultInfo requirement =
+        changeInfo.submitRequirements.stream().collect(MoreCollectors.onlyElement());
+    assertSubmitRequirementExpression(
+        requirement.applicabilityExpressionResult,
+        /* expression= */ null,
+        /* passingAtoms= */ null,
+        /* failingAtoms= */ null,
+        /* status= */ SubmitRequirementExpressionInfo.Status.FAIL,
         /* fulfilled= */ false);
     assertThat(requirement.submittabilityExpressionResult).isNull();
     assertThat(requirement.overrideExpressionResult).isNull();
@@ -1962,12 +2013,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 +2055,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 +2864,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 +2877,7 @@
     } else {
       assertThat(result.failingAtoms).containsExactlyElementsIn(failingAtoms);
     }
+    assertThat(result.status).isEqualTo(status);
     assertThat(result.fulfilled).isEqualTo(fulfilled);
   }
 
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/project/ProjectConfigIT.java b/javatests/com/google/gerrit/acceptance/api/project/ProjectConfigIT.java
new file mode 100644
index 0000000..5531050
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/project/ProjectConfigIT.java
@@ -0,0 +1,691 @@
+// 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.RawInputUtil;
+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.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(ProjectConfig.KEY_COPY_ANY_SCORE, /* value= */ true, "is:ANY");
+    testRejectSettingLabelFlag(ProjectConfig.KEY_COPY_ANY_SCORE, /* value= */ false, "is:ANY");
+  }
+
+  @Test
+  public void rejectSettingCopyMinScore() throws Exception {
+    testRejectSettingLabelFlag(ProjectConfig.KEY_COPY_MIN_SCORE, /* value= */ true, "is:MIN");
+    testRejectSettingLabelFlag(ProjectConfig.KEY_COPY_MIN_SCORE, /* value= */ false, "is:MIN");
+  }
+
+  @Test
+  public void rejectSettingCopyMaxScore() throws Exception {
+    testRejectSettingLabelFlag(ProjectConfig.KEY_COPY_MAX_SCORE, /* value= */ true, "is:MAX");
+    testRejectSettingLabelFlag(ProjectConfig.KEY_COPY_MAX_SCORE, /* value= */ false, "is:MAX");
+  }
+
+  @Test
+  public void rejectSettingCopyAllScoresIfNoChange() throws Exception {
+    testRejectSettingLabelFlag(
+        ProjectConfig.KEY_COPY_ALL_SCORES_IF_NO_CHANGE, /* value= */ true, "changekind:NO_CHANGE");
+    testRejectSettingLabelFlag(
+        ProjectConfig.KEY_COPY_ALL_SCORES_IF_NO_CHANGE, /* value= */ false, "changekind:NO_CHANGE");
+  }
+
+  @Test
+  public void rejectSettingCopyAllScoresIfNoCodeChange() throws Exception {
+    testRejectSettingLabelFlag(
+        ProjectConfig.KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE,
+        /* value= */ true,
+        "changekind:NO_CODE_CHANGE");
+    testRejectSettingLabelFlag(
+        ProjectConfig.KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE,
+        /* value= */ false,
+        "changekind:NO_CODE_CHANGE");
+  }
+
+  @Test
+  public void rejectSettingCopyAllScoresOnMergeFirstParentUpdate() throws Exception {
+    testRejectSettingLabelFlag(
+        ProjectConfig.KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE,
+        /* value= */ true,
+        "changekind:MERGE_FIRST_PARENT_UPDATE");
+    testRejectSettingLabelFlag(
+        ProjectConfig.KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE,
+        /* value= */ false,
+        "changekind:MERGE_FIRST_PARENT_UPDATE");
+  }
+
+  @Test
+  public void rejectSettingCopyAllScoresOnTrivialRebase() throws Exception {
+    testRejectSettingLabelFlag(
+        ProjectConfig.KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE,
+        /* value= */ true,
+        "changekind:TRIVIAL_REBASE");
+    testRejectSettingLabelFlag(
+        ProjectConfig.KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE,
+        /* value= */ false,
+        "changekind:TRIVIAL_REBASE");
+  }
+
+  @Test
+  public void rejectSettingCopyAllScoresIfListOfFilesDidNotChange() throws Exception {
+    testRejectSettingLabelFlag(
+        ProjectConfig.KEY_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE,
+        /* value= */ true,
+        "has:unchanged-files");
+    testRejectSettingLabelFlag(
+        ProjectConfig.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",
+                ProjectConfig.KEY_COPY_VALUE, ProjectConfig.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()), ProjectConfig.KEY_COPY_VALUE));
+  }
+
+  @Test
+  public void rejectChangingCopyAnyScore() throws Exception {
+    testRejectChangingLabelFlag(ProjectConfig.KEY_COPY_ANY_SCORE, /* value= */ true, "is:ANY");
+    testRejectChangingLabelFlag(ProjectConfig.KEY_COPY_ANY_SCORE, /* value= */ false, "is:ANY");
+  }
+
+  @Test
+  public void rejectChangingCopyMinScore() throws Exception {
+    testRejectChangingLabelFlag(ProjectConfig.KEY_COPY_MIN_SCORE, /* value= */ true, "is:MIN");
+    testRejectChangingLabelFlag(ProjectConfig.KEY_COPY_MIN_SCORE, /* value= */ false, "is:MIN");
+  }
+
+  @Test
+  public void rejectChangingCopyMaxScore() throws Exception {
+    testRejectChangingLabelFlag(ProjectConfig.KEY_COPY_MAX_SCORE, /* value= */ true, "is:MAX");
+    testRejectChangingLabelFlag(ProjectConfig.KEY_COPY_MAX_SCORE, /* value= */ false, "is:MAX");
+  }
+
+  @Test
+  public void rejectChangingCopyAllScoresIfNoChange() throws Exception {
+    testRejectChangingLabelFlag(
+        ProjectConfig.KEY_COPY_ALL_SCORES_IF_NO_CHANGE, /* value= */ true, "changekind:NO_CHANGE");
+    testRejectChangingLabelFlag(
+        ProjectConfig.KEY_COPY_ALL_SCORES_IF_NO_CHANGE, /* value= */ false, "changekind:NO_CHANGE");
+  }
+
+  @Test
+  public void rejectChangingCopyAllScoresIfNoCodeChange() throws Exception {
+    testRejectChangingLabelFlag(
+        ProjectConfig.KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE,
+        /* value= */ true,
+        "changekind:NO_CODE_CHANGE");
+    testRejectChangingLabelFlag(
+        ProjectConfig.KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE,
+        /* value= */ false,
+        "changekind:NO_CODE_CHANGE");
+  }
+
+  @Test
+  public void rejectChangingCopyAllScoresOnMergeFirstParentUpdate() throws Exception {
+    testRejectChangingLabelFlag(
+        ProjectConfig.KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE,
+        /* value= */ true,
+        "changekind:MERGE_FIRST_PARENT_UPDATE");
+    testRejectChangingLabelFlag(
+        ProjectConfig.KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE,
+        /* value= */ false,
+        "changekind:MERGE_FIRST_PARENT_UPDATE");
+  }
+
+  @Test
+  public void rejectChangingCopyAllScoresOnTrivialRebase() throws Exception {
+    testRejectChangingLabelFlag(
+        ProjectConfig.KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE,
+        /* value= */ true,
+        "changekind:TRIVIAL_REBASE");
+    testRejectChangingLabelFlag(
+        ProjectConfig.KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE,
+        /* value= */ false,
+        "changekind:TRIVIAL_REBASE");
+  }
+
+  @Test
+  public void rejectChangingCopyAllScoresIfListOfFilesDidNotChange() throws Exception {
+    testRejectChangingLabelFlag(
+        ProjectConfig.KEY_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE,
+        /* value= */ true,
+        "has:unchanged-files");
+    testRejectChangingLabelFlag(
+        ProjectConfig.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",
+                  ProjectConfig.KEY_COPY_VALUE, ProjectConfig.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",
+                ProjectConfig.KEY_COPY_VALUE, ProjectConfig.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()), ProjectConfig.KEY_COPY_VALUE));
+  }
+
+  @Test
+  public void unsetCopyAnyScore() throws Exception {
+    testUnsetLabelFlag(ProjectConfig.KEY_COPY_ANY_SCORE, /* previousValue= */ true);
+    testUnsetLabelFlag(ProjectConfig.KEY_COPY_ANY_SCORE, /* previousValue= */ false);
+  }
+
+  @Test
+  public void unsetCopyMinScore() throws Exception {
+    testUnsetLabelFlag(ProjectConfig.KEY_COPY_MIN_SCORE, /* previousValue= */ true);
+    testUnsetLabelFlag(ProjectConfig.KEY_COPY_MIN_SCORE, /* previousValue= */ false);
+  }
+
+  @Test
+  public void unsetCopyMaxScore() throws Exception {
+    testUnsetLabelFlag(ProjectConfig.KEY_COPY_MAX_SCORE, /* previousValue= */ true);
+    testUnsetLabelFlag(ProjectConfig.KEY_COPY_MAX_SCORE, /* previousValue= */ false);
+  }
+
+  @Test
+  public void unsetCopyAllScoresIfNoChange() throws Exception {
+    testUnsetLabelFlag(ProjectConfig.KEY_COPY_ALL_SCORES_IF_NO_CHANGE, /* previousValue= */ true);
+    testUnsetLabelFlag(ProjectConfig.KEY_COPY_ALL_SCORES_IF_NO_CHANGE, /* previousValue= */ false);
+  }
+
+  @Test
+  public void unsetCopyAllScoresIfNoCodeChange() throws Exception {
+    testUnsetLabelFlag(
+        ProjectConfig.KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE, /* previousValue= */ true);
+    testUnsetLabelFlag(
+        ProjectConfig.KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE, /* previousValue= */ false);
+  }
+
+  @Test
+  public void unsetCopyAllScoresOnMergeFirstParentUpdate() throws Exception {
+    testUnsetLabelFlag(
+        ProjectConfig.KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE, /* previousValue= */ true);
+    testUnsetLabelFlag(
+        ProjectConfig.KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE, /* previousValue= */ false);
+  }
+
+  @Test
+  public void unsetCopyAllScoresOnTrivialRebase() throws Exception {
+    testUnsetLabelFlag(
+        ProjectConfig.KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE, /* previousValue= */ true);
+    testUnsetLabelFlag(
+        ProjectConfig.KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE, /* previousValue= */ false);
+  }
+
+  @Test
+  public void unsetCopyAllScoresIfListOfFilesDidNotChange() throws Exception {
+    testUnsetLabelFlag(
+        ProjectConfig.KEY_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE,
+        /* previousValue= */ true);
+    testUnsetLabelFlag(
+        ProjectConfig.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",
+                  ProjectConfig.KEY_COPY_VALUE, ProjectConfig.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(ProjectConfig.KEY_COPY_ANY_SCORE, /* value= */ true);
+    testKeepLabelFlagUnchanged(ProjectConfig.KEY_COPY_ANY_SCORE, /* value= */ false);
+  }
+
+  @Test
+  public void keepCopyMinScoreUnchanged() throws Exception {
+    testKeepLabelFlagUnchanged(ProjectConfig.KEY_COPY_MIN_SCORE, /* value= */ true);
+    testKeepLabelFlagUnchanged(ProjectConfig.KEY_COPY_MIN_SCORE, /* value= */ false);
+  }
+
+  @Test
+  public void keepCopyMaxScoreUnchanged() throws Exception {
+    testKeepLabelFlagUnchanged(ProjectConfig.KEY_COPY_MAX_SCORE, /* value= */ true);
+    testKeepLabelFlagUnchanged(ProjectConfig.KEY_COPY_MAX_SCORE, /* value= */ false);
+  }
+
+  @Test
+  public void keepCopyAllScoresIfNoChangeUnchanged() throws Exception {
+    testKeepLabelFlagUnchanged(ProjectConfig.KEY_COPY_ALL_SCORES_IF_NO_CHANGE, /* value= */ true);
+    testKeepLabelFlagUnchanged(ProjectConfig.KEY_COPY_ALL_SCORES_IF_NO_CHANGE, /* value= */ false);
+  }
+
+  @Test
+  public void keepCopyAllScoresIfNoCodeChangeUnchanged() throws Exception {
+    testKeepLabelFlagUnchanged(
+        ProjectConfig.KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE, /* value= */ true);
+    testKeepLabelFlagUnchanged(
+        ProjectConfig.KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE, /* value= */ false);
+  }
+
+  @Test
+  public void keepCopyAllScoresOnMergeFirstParentUpdateUnchanged() throws Exception {
+    testKeepLabelFlagUnchanged(
+        ProjectConfig.KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE, /* value= */ true);
+    testKeepLabelFlagUnchanged(
+        ProjectConfig.KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE, /* value= */ false);
+  }
+
+  @Test
+  public void keepCopyAllScoresOnTrivialRebaseUnchanged() throws Exception {
+    testKeepLabelFlagUnchanged(
+        ProjectConfig.KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE, /* value= */ true);
+    testKeepLabelFlagUnchanged(
+        ProjectConfig.KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE, /* value= */ false);
+  }
+
+  @Test
+  public void keepCopyAllScoresIfListOfFilesDidNotChangeUnchanged() throws Exception {
+    testKeepLabelFlagUnchanged(
+        ProjectConfig.KEY_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE, /* value= */ true);
+    testKeepLabelFlagUnchanged(
+        ProjectConfig.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",
+                  ProjectConfig.KEY_COPY_VALUE, ProjectConfig.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",
+                ProjectConfig.KEY_COPY_VALUE, ProjectConfig.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",
+                  ProjectConfig.KEY_COPY_VALUE, ProjectConfig.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",
+                ProjectConfig.KEY_COPY_VALUE, ProjectConfig.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",
+                ProjectConfig.KEY_COPY_MIN_SCORE, ProjectConfig.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()), ProjectConfig.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()), ProjectConfig.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/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..2c80333 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
@@ -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/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..dd4b1e4 100644
--- a/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
+++ b/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
@@ -1062,6 +1062,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 +1074,13 @@
             "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
+    // 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,6 +1096,7 @@
             "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.");
   }
@@ -1110,6 +1115,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 +1123,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 +1250,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 +1942,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();
     }
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..2c2e57d
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/pgm/MigrateLabelConfigToCopyConditionIT.java
@@ -0,0 +1,829 @@
+// 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(ProjectConfig.KEY_COPY_ANY_SCORE)
+          .add(ProjectConfig.KEY_COPY_MIN_SCORE)
+          .add(ProjectConfig.KEY_COPY_MAX_SCORE)
+          .add(ProjectConfig.KEY_COPY_VALUE)
+          .add(ProjectConfig.KEY_COPY_ALL_SCORES_IF_NO_CHANGE)
+          .add(ProjectConfig.KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE)
+          .add(ProjectConfig.KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE)
+          .add(ProjectConfig.KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE)
+          .add(ProjectConfig.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
+    updateProjectConfig(
+        cfg -> {
+          cfg.setBoolean(
+              ProjectConfig.LABEL,
+              LabelId.CODE_REVIEW,
+              ProjectConfig.KEY_COPY_ALL_SCORES_IF_NO_CHANGE,
+              /* value= */ false);
+          cfg.setBoolean(
+              ProjectConfig.LABEL,
+              LabelId.VERIFIED,
+              ProjectConfig.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, ProjectConfig.KEY_COPY_ALL_SCORES_IF_NO_CHANGE);
+    assertDeprecatedFieldsUnset(LabelId.VERIFIED, ProjectConfig.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(
+        ProjectConfig.KEY_COPY_ANY_SCORE,
+        copyCondition -> assertThat(copyCondition).isEqualTo("is:ANY"));
+  }
+
+  @Test
+  public void migrateCopyMinScore() throws Exception {
+    testFlagMigration(
+        ProjectConfig.KEY_COPY_MIN_SCORE,
+        copyCondition -> assertThat(copyCondition).isEqualTo("is:MIN"));
+  }
+
+  @Test
+  public void migrateCopyMaxScore() throws Exception {
+    testFlagMigration(
+        ProjectConfig.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(
+        ProjectConfig.KEY_COPY_ALL_SCORES_IF_NO_CHANGE,
+        copyCondition ->
+            assertThat(copyCondition).isEqualTo("changekind:" + ChangeKind.NO_CHANGE.toString()));
+  }
+
+  @Test
+  public void migrateCopyAllScoresIfNoCodeCange() throws Exception {
+    testFlagMigration(
+        ProjectConfig.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(
+        ProjectConfig.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(
+        ProjectConfig.KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE,
+        copyCondition ->
+            assertThat(copyCondition)
+                .isEqualTo("changekind:" + ChangeKind.TRIVIAL_REBASE.toString()));
+  }
+
+  @Test
+  public void migrateCopyAllScoresIfListOfFilesDidNotChange() throws Exception {
+    testFlagMigration(
+        ProjectConfig.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, ProjectConfig.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, ProjectConfig.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(ProjectConfig.KEY_COPY_ANY_SCORE);
+    setFlagOnCodeReviewLabel(ProjectConfig.KEY_COPY_MIN_SCORE);
+    setFlagOnCodeReviewLabel(ProjectConfig.KEY_COPY_MAX_SCORE);
+    setCopyValuesOnCodeReviewLabel(-2, -1, 1, 2);
+    setFlagOnCodeReviewLabel(ProjectConfig.KEY_COPY_ALL_SCORES_IF_NO_CHANGE);
+    setFlagOnCodeReviewLabel(ProjectConfig.KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE);
+    setFlagOnCodeReviewLabel(ProjectConfig.KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE);
+    setFlagOnCodeReviewLabel(ProjectConfig.KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE);
+    setFlagOnCodeReviewLabel(ProjectConfig.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(ProjectConfig.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(ProjectConfig.KEY_COPY_ANY_SCORE);
+    setFlagOnCodeReviewLabel(ProjectConfig.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(ProjectConfig.KEY_COPY_ANY_SCORE);
+    setFlagOnCodeReviewLabel(ProjectConfig.KEY_COPY_MIN_SCORE);
+    setFlagOnCodeReviewLabel(ProjectConfig.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(ProjectConfig.KEY_COPY_ANY_SCORE);
+    setFlagOnCodeReviewLabel(ProjectConfig.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(ProjectConfig.KEY_COPY_ANY_SCORE);
+    setFlagOnCodeReviewLabel(ProjectConfig.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(ProjectConfig.KEY_COPY_ANY_SCORE);
+    setFlagOnCodeReviewLabel(ProjectConfig.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(ProjectConfig.KEY_COPY_ANY_SCORE);
+    setFlagOnCodeReviewLabel(ProjectConfig.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(ProjectConfig.KEY_COPY_ANY_SCORE);
+    setFlagOnCodeReviewLabel(ProjectConfig.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(ProjectConfig.KEY_COPY_MIN_SCORE);
+    setFlagOnCodeReviewLabel(ProjectConfig.KEY_COPY_ALL_SCORES_IF_NO_CHANGE);
+
+    setFlagOnVerifiedLabel(ProjectConfig.KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE);
+    setFlagOnVerifiedLabel(ProjectConfig.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,
+              ProjectConfig.KEY_COPY_ANY_SCORE,
+              /* value= */ false);
+          cfg.setBoolean(
+              ProjectConfig.LABEL,
+              LabelId.CODE_REVIEW,
+              ProjectConfig.KEY_COPY_MIN_SCORE,
+              /* value= */ false);
+          cfg.setBoolean(
+              ProjectConfig.LABEL,
+              LabelId.CODE_REVIEW,
+              ProjectConfig.KEY_COPY_MAX_SCORE,
+              /* value= */ false);
+          cfg.setBoolean(
+              ProjectConfig.LABEL,
+              LabelId.CODE_REVIEW,
+              ProjectConfig.KEY_COPY_ALL_SCORES_IF_NO_CHANGE,
+              /* value= */ false);
+          cfg.setBoolean(
+              ProjectConfig.LABEL,
+              LabelId.CODE_REVIEW,
+              ProjectConfig.KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE,
+              /* value= */ false);
+          cfg.setBoolean(
+              ProjectConfig.LABEL,
+              LabelId.CODE_REVIEW,
+              ProjectConfig.KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE,
+              /* value= */ false);
+          cfg.setBoolean(
+              ProjectConfig.LABEL,
+              LabelId.CODE_REVIEW,
+              ProjectConfig.KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE,
+              /* value= */ false);
+          cfg.setBoolean(
+              ProjectConfig.LABEL,
+              LabelId.CODE_REVIEW,
+              ProjectConfig.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,
+                ProjectConfig.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(ProjectConfig.KEY_COPY_MIN_SCORE);
+    setFlagOnVerifiedLabel(ProjectConfig.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(ProjectConfig.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(ProjectConfig.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,
+                ProjectConfig.KEY_COPY_ALL_SCORES_IF_NO_CHANGE,
+                copyAllScoresIfNoChangeValue);
+            cfg.setBoolean(
+                ProjectConfig.LABEL,
+                LabelId.VERIFIED,
+                ProjectConfig.KEY_COPY_ALL_SCORES_IF_NO_CHANGE,
+                copyAllScoresIfNoChangeValue);
+          } else {
+            cfg.unset(
+                ProjectConfig.LABEL,
+                LabelId.CODE_REVIEW,
+                ProjectConfig.KEY_COPY_ALL_SCORES_IF_NO_CHANGE);
+            cfg.unset(
+                ProjectConfig.LABEL,
+                LabelId.VERIFIED,
+                ProjectConfig.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,
+                ProjectConfig.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/rest/change/AbstractSubmit.java b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
index de14d00..0e4f212 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
@@ -28,6 +28,7 @@
 import static com.google.gerrit.server.group.SystemGroupBackend.CHANGE_OWNER;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.Objects.requireNonNull;
 import static java.util.stream.Collectors.toList;
 import static org.eclipse.jgit.lib.Constants.EMPTY_TREE_ID;
@@ -1360,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 {
@@ -1446,7 +1444,7 @@
       fmt.setRepository(repo);
       fmt.format(oldTreeId, newTreeId);
       fmt.flush();
-      return out.toString();
+      return out.toString(UTF_8);
     }
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/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/GetArchiveIT.java b/javatests/com/google/gerrit/acceptance/rest/change/GetArchiveIT.java
index 15e6360..e2f4b5b 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/GetArchiveIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/GetArchiveIT.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
@@ -135,7 +136,7 @@
               bufferedOut.write(data, 0, count);
             }
             bufferedOut.flush();
-            archiveEntries.put(entry.getName(), out.toString());
+            archiveEntries.put(entry.getName(), out.toString(UTF_8));
           }
         }
       }
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/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/project/LabelAssert.java b/javatests/com/google/gerrit/acceptance/rest/project/LabelAssert.java
index 40e5d50..7f36692 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;
 
@@ -42,14 +43,19 @@
     assertThat(codeReviewLabel.branches).isNull();
     assertThat(codeReviewLabel.canOverride).isTrue();
     assertThat(codeReviewLabel.copyAnyScore).isNull();
-    assertThat(codeReviewLabel.copyMinScore).isTrue();
+    assertThat(codeReviewLabel.copyMinScore).isNull();
     assertThat(codeReviewLabel.copyMaxScore).isNull();
     assertThat(codeReviewLabel.copyAllScoresIfListOfFilesDidNotChange).isNull();
-    assertThat(codeReviewLabel.copyAllScoresIfNoChange).isTrue();
+    assertThat(codeReviewLabel.copyAllScoresIfNoChange).isNull();
     assertThat(codeReviewLabel.copyAllScoresIfNoCodeChange).isNull();
-    assertThat(codeReviewLabel.copyAllScoresOnTrivialRebase).isTrue();
+    assertThat(codeReviewLabel.copyAllScoresOnTrivialRebase).isNull();
     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/TagsIT.java b/javatests/com/google/gerrit/acceptance/rest/project/TagsIT.java
index 044da19..c8c00a1 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/TagsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/TagsIT.java
@@ -457,11 +457,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..acde8f6
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/change/ApprovalCopierIT.java
@@ -0,0 +1,415 @@
+// 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.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.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);
+
+    ChangeData changeData = changeDataFactory.create(project, r.getChange().getId());
+    try (Repository repo = repoManager.openRepository(project);
+        RevWalk revWalk = new RevWalk(repo)) {
+      ApprovalCopier.Result approvalCopierResult =
+          approvalCopier.forPatchSet(
+              changeData.notes(), changeData.currentPatchSet(), revWalk, repo.getConfig());
+      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();
+    ChangeData changeData = changeDataFactory.create(project, r.getChange().getId());
+    assertThat(changeData.currentPatchSet().id().get()).isEqualTo(2);
+    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 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();
+    ChangeData changeData = changeDataFactory.create(project, r.getChange().getId());
+    assertThat(changeData.currentPatchSet().id().get()).isEqualTo(2);
+    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())
+          .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();
+
+    ChangeData changeData = changeDataFactory.create(project, r.getChange().getId());
+    assertThat(changeData.currentPatchSet().id().get()).isEqualTo(2);
+    try (Repository repo = repoManager.openRepository(project);
+        RevWalk revWalk = new RevWalk(repo)) {
+      ApprovalCopier.Result approvalCopierResult =
+          approvalCopier.forPatchSet(
+              changeData.notes(), changeData.currentPatchSet(), revWalk, repo.getConfig());
+      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);
+
+    ChangeData changeData = changeDataFactory.create(project, r.getChange().getId());
+    assertThat(changeData.currentPatchSet().id().get()).isEqualTo(2);
+    try (Repository repo = repoManager.openRepository(project);
+        RevWalk revWalk = new RevWalk(repo)) {
+      ApprovalCopier.Result approvalCopierResult =
+          approvalCopier.forPatchSet(
+              changeData.notes(), changeData.currentPatchSet(), revWalk, repo.getConfig());
+      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);
+
+    ChangeData changeData = changeDataFactory.create(project, r.getChange().getId());
+    assertThat(changeData.currentPatchSet().id().get()).isEqualTo(2);
+    try (Repository repo = repoManager.openRepository(project);
+        RevWalk revWalk = new RevWalk(repo)) {
+      ApprovalCopier.Result approvalCopierResult =
+          approvalCopier.forPatchSet(
+              changeData.notes(), changeData.currentPatchSet(), revWalk, repo.getConfig());
+      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);
+
+    ChangeData changeData = changeDataFactory.create(project, r.getChange().getId());
+    assertThat(changeData.currentPatchSet().id().get()).isEqualTo(2);
+    try (Repository repo = repoManager.openRepository(project);
+        RevWalk revWalk = new RevWalk(repo)) {
+      ApprovalCopier.Result approvalCopierResult =
+          approvalCopier.forPatchSet(
+              changeData.notes(), changeData.currentPatchSet(), revWalk, repo.getConfig());
+      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();
+
+    ChangeData changeData = changeDataFactory.create(project, r.getChange().getId());
+    assertThat(changeData.currentPatchSet().id().get()).isEqualTo(2);
+    try (Repository repo = repoManager.openRepository(project);
+        RevWalk revWalk = new RevWalk(repo)) {
+      ApprovalCopier.Result approvalCopierResult =
+          approvalCopier.forPatchSet(
+              changeData.notes(), changeData.currentPatchSet(), revWalk, repo.getConfig());
+      assertThatList(approvalCopierResult.copiedApprovals()).isEmpty();
+      assertThatList(approvalCopierResult.outdatedApprovals())
+          .comparingElementsUsing(hasTestId())
+          .containsExactly(
+              PatchSetApprovalTestId.create(patchSet1Id, admin.id(), LabelId.CODE_REVIEW, -2));
+    }
+  }
+
+  @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);
+
+    ChangeData changeData = changeDataFactory.create(project, r.getChange().getId());
+    assertThat(changeData.currentPatchSet().id().get()).isEqualTo(2);
+    try (Repository repo = repoManager.openRepository(project);
+        RevWalk revWalk = new RevWalk(repo)) {
+      ImmutableSet<PatchSetApproval> copiedApprovals =
+          approvalCopier
+              .forPatchSet(
+                  changeData.notes(), changeData.currentPatchSet(), revWalk, repo.getConfig())
+              .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());
+  }
+
+  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/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/mail/ChangeNotificationsIT.java b/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
index 6013862..b94996c 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;
@@ -952,13 +954,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 +2006,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/project/SubmitRequirementsEvaluatorIT.java b/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java
index 731e0df..1468565 100644
--- a/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java
@@ -27,6 +27,7 @@
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.change.ChangeOperations;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
@@ -44,6 +45,7 @@
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.server.experiments.ExperimentFeaturesConstants;
 import com.google.gerrit.server.project.SubmitRequirementEvaluationException;
 import com.google.gerrit.server.project.SubmitRequirementsEvaluator;
 import com.google.gerrit.server.query.change.ChangeData;
@@ -273,6 +275,10 @@
   }
 
   @Test
+  @GerritConfig(
+      name = "experiments.enabled",
+      value =
+          ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_SR_EXPRESSIONS_NOT_EVALUATED)
   public void submittabilityAndOverrideNotEvaluated_whenApplicabilityIsFalse() throws Exception {
     SubmitRequirement sr =
         createSubmitRequirement(
@@ -283,6 +289,24 @@
     SubmitRequirementResult result = evaluator.evaluateRequirement(sr, changeData);
     assertThat(result.status()).isEqualTo(SubmitRequirementResult.Status.NOT_APPLICABLE);
     assertThat(result.applicabilityExpressionResult().get().status()).isEqualTo(Status.FAIL);
+    assertThat(result.submittabilityExpressionResult().get().status())
+        .isEqualTo(Status.NOT_EVALUATED);
+    assertThat(result.submittabilityExpressionResult().get().expression().expressionString())
+        .isEqualTo("message:\"Fix bug\"");
+    assertThat(result.overrideExpressionResult().isPresent()).isFalse();
+  }
+
+  @Test
+  public void submittabilityAndOverrideAreEmpty_whenApplicabilityIsFalse() throws Exception {
+    SubmitRequirement sr =
+        createSubmitRequirement(
+            /* applicabilityExpr= */ "project:non-existent-project",
+            /* submittabilityExpr= */ "message:\"Fix bug\"",
+            /* overrideExpr= */ "");
+
+    SubmitRequirementResult result = evaluator.evaluateRequirement(sr, changeData);
+    assertThat(result.status()).isEqualTo(SubmitRequirementResult.Status.NOT_APPLICABLE);
+    assertThat(result.applicabilityExpressionResult().get().status()).isEqualTo(Status.FAIL);
     assertThat(result.submittabilityExpressionResult().isPresent()).isFalse();
     assertThat(result.overrideExpressionResult().isPresent()).isFalse();
   }
diff --git a/javatests/com/google/gerrit/acceptance/ssh/SshCommandsIT.java b/javatests/com/google/gerrit/acceptance/ssh/SshCommandsIT.java
index ac8a200..ab84e70 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/SshCommandsIT.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/SshCommandsIT.java
@@ -143,7 +143,7 @@
     // option causes the usage info to be written to stderr. Instead, we assert on the
     // content of the stderr, which will always start with "gerrit command" when the --help
     // option is used.
-    logger.atFine().log(cmd);
+    logger.atFine().log("%s", cmd);
     adminSshSession.exec(String.format("%s --help", cmd));
     String response = adminSshSession.getError();
     assertWithMessage(String.format("command %s failed: %s", cmd, response))
diff --git a/javatests/com/google/gerrit/acceptance/ssh/StreamEventsIT.java b/javatests/com/google/gerrit/acceptance/ssh/StreamEventsIT.java
index e0e1880..13a9e0c 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/StreamEventsIT.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/StreamEventsIT.java
@@ -40,7 +40,6 @@
 public class StreamEventsIT extends AbstractDaemonTest {
   private static final Duration MAX_DURATION_FOR_RECEIVING_EVENTS = Duration.ofSeconds(2);
   private static final String TEST_REVIEW_COMMENT = "any comment";
-  private StringBuilder eventsOutput = new StringBuilder();
   private Reader streamEventsReader;
 
   @Before
@@ -56,7 +55,17 @@
   @Test
   public void commentOnChangeShowsUpInStreamEvents() throws Exception {
     reviewChange(new ReviewInput().message(TEST_REVIEW_COMMENT));
-    waitForEvent(() -> pollEventsContaining(TEST_REVIEW_COMMENT).size() == 1);
+    waitForEvent(() -> pollEventsContaining("comment-added", TEST_REVIEW_COMMENT).size() == 1);
+  }
+
+  @Test
+  public void batchRefsUpdatedShowSeparatelyInStreamEvents() throws Exception {
+    String refName = createChange().getChange().currentPatchSet().refName();
+    waitForEvent(
+        () ->
+            pollEventsContaining("ref-updated", refName.substring(0, refName.lastIndexOf('/')))
+                    .size()
+                == 2);
   }
 
   private void waitForEvent(Supplier<Boolean> waitCondition) throws InterruptedException {
@@ -68,16 +77,20 @@
     changeApi.current().review(reviewInput);
   }
 
-  private List<String> pollEventsContaining(String reviewComment) {
+  private List<String> pollEventsContaining(String eventType, String expectedContent) {
     try {
       char[] cbuf = new char[2048];
+      StringBuilder eventsOutput = new StringBuilder();
       while (streamEventsReader.ready()) {
         streamEventsReader.read(cbuf);
         eventsOutput.append(cbuf);
       }
       return StreamSupport.stream(
               Splitter.on('\n').trimResults().split(eventsOutput.toString()).spliterator(), false)
-          .filter(event -> event.contains(reviewComment))
+          .filter(
+              event ->
+                  event.contains(String.format("\"type\":\"%s\"", eventType))
+                      && event.contains(expectedContent))
           .collect(Collectors.toList());
     } catch (IOException e) {
       throw new IllegalStateException(e);
diff --git a/javatests/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImplTest.java b/javatests/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImplTest.java
index 0bd6554..6c629c9 100644
--- a/javatests/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImplTest.java
+++ b/javatests/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImplTest.java
@@ -24,6 +24,7 @@
 import static com.google.gerrit.truth.MapSubject.assertThatMap;
 import static com.google.gerrit.truth.OptionalSubject.assertThat;
 
+import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.truth.Correspondence;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
@@ -610,6 +611,25 @@
   }
 
   @Test
+  public void createdChangeHasSpecifiedTopic() throws Exception {
+    Change.Id changeId = changeOperations.newChange().topic("test-topic").create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    assertThat(change.topic).isEqualTo("test-topic");
+  }
+
+  @Test
+  public void createdChangeHasSpecifiedApprovals() throws Exception {
+    Change.Id changeId =
+        changeOperations.newChange().approvals(ImmutableMap.of("Code-Review", (short) 1)).create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    assertThat(change.labels).hasSize(1);
+    assertThat(change.labels.get("Code-Review").recommended._accountId)
+        .isEqualTo(change.owner._accountId);
+  }
+
+  @Test
   public void createdChangeHasSpecifiedCommitMessage() throws Exception {
     Change.Id changeId =
         changeOperations
diff --git a/javatests/com/google/gerrit/httpd/raw/ResourceServletTest.java b/javatests/com/google/gerrit/httpd/raw/ResourceServletTest.java
index dd594d6..36641fe 100644
--- a/javatests/com/google/gerrit/httpd/raw/ResourceServletTest.java
+++ b/javatests/com/google/gerrit/httpd/raw/ResourceServletTest.java
@@ -72,16 +72,6 @@
       this.fs = fs;
     }
 
-    private Servlet(
-        FileSystem fs,
-        Cache<Path, Resource> cache,
-        boolean refresh,
-        boolean cacheOnClient,
-        int cacheFileSizeLimitBytes) {
-      super(cache, refresh, cacheOnClient, cacheFileSizeLimitBytes);
-      this.fs = fs;
-    }
-
     @Override
     protected Path getResourcePath(String pathInfo) {
       return fs.getPath("/" + CharMatcher.is('/').trimLeadingFrom(pathInfo));
diff --git a/javatests/com/google/gerrit/index/query/QueryParserTest.java b/javatests/com/google/gerrit/index/query/QueryParserTest.java
index f478803..0574746 100644
--- a/javatests/com/google/gerrit/index/query/QueryParserTest.java
+++ b/javatests/com/google/gerrit/index/query/QueryParserTest.java
@@ -19,6 +19,8 @@
 import static com.google.gerrit.index.query.QueryParser.DEFAULT_FIELD;
 import static com.google.gerrit.index.query.QueryParser.EXACT_PHRASE;
 import static com.google.gerrit.index.query.QueryParser.FIELD_NAME;
+import static com.google.gerrit.index.query.QueryParser.NOT;
+import static com.google.gerrit.index.query.QueryParser.OR;
 import static com.google.gerrit.index.query.QueryParser.SINGLE_WORD;
 import static com.google.gerrit.index.query.QueryParser.parse;
 import static com.google.gerrit.index.query.testing.TreeSubject.assertThat;
@@ -242,6 +244,36 @@
     assertThat(r).child(0).hasText("A backslash \\ in phrase");
   }
 
+  @Test
+  public void upperCaseParsedAsOperators() throws Exception {
+    Tree r = parse("project:foo:bar AND file:baz");
+    assertThat(r).hasType(AND);
+    assertThat(r).hasChildCount(2);
+
+    r = parse("project:foo:bar OR file:baz");
+    assertThat(r).hasType(OR);
+    assertThat(r).hasChildCount(2);
+
+    r = parse("NOT project:foo:bar");
+    assertThat(r).hasType(NOT);
+    assertThat(r).hasChildCount(1);
+  }
+
+  @Test
+  public void lowerCaseParsedAsOperators() throws Exception {
+    Tree r = parse("project:foo:bar and file:baz");
+    assertThat(r).hasType(AND);
+    assertThat(r).hasChildCount(2);
+
+    r = parse("project:foo:bar or file:baz");
+    assertThat(r).hasType(OR);
+    assertThat(r).hasChildCount(2);
+
+    r = parse("not project:foo:bar");
+    assertThat(r).hasType(NOT);
+    assertThat(r).hasChildCount(1);
+  }
+
   private static void assertParseFails(String query) {
     assertThrows(QueryParseException.class, () -> parse(query));
   }
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/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/extensions/events/GitReferenceUpdatedTest.java b/javatests/com/google/gerrit/server/extensions/events/GitReferenceUpdatedTest.java
new file mode 100644
index 0000000..96919be
--- /dev/null
+++ b/javatests/com/google/gerrit/server/extensions/events/GitReferenceUpdatedTest.java
@@ -0,0 +1,111 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.extensions.events;
+
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.events.GitBatchRefUpdateListener;
+import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.plugincontext.PluginContext.PluginMetrics;
+import com.google.gerrit.server.plugincontext.PluginSetContext;
+import java.io.IOException;
+import org.eclipse.jgit.internal.storage.dfs.DfsRepositoryDescription;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.lib.BatchRefUpdate;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.transport.ReceiveCommand;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.Mockito;
+import org.mockito.junit.MockitoJUnitRunner;
+
+@RunWith(MockitoJUnitRunner.class)
+public class GitReferenceUpdatedTest {
+  private DynamicSet<GitReferenceUpdatedListener> refUpdatedListeners;
+  private DynamicSet<GitBatchRefUpdateListener> batchRefUpdateListeners;
+
+  @Mock GitReferenceUpdatedListener refUpdatedListener;
+  @Mock GitBatchRefUpdateListener batchRefUpdateListener;
+  @Mock EventUtil util;
+  @Mock AccountState updater;
+
+  @Before
+  public void setup() {
+    refUpdatedListeners = new DynamicSet<>();
+    refUpdatedListeners.add("gerrit", refUpdatedListener);
+    batchRefUpdateListeners = new DynamicSet<>();
+    batchRefUpdateListeners.add("gerrit", batchRefUpdateListener);
+  }
+
+  @Test
+  public void RefUpdateEventsAndRefsUpdateEventAreFired_BatchRefUpdate() {
+    BatchRefUpdate update = newBatchRefUpdate();
+    ReceiveCommand cmd1 =
+        new ReceiveCommand(
+            ObjectId.zeroId(),
+            ObjectId.fromString("0000000000000000000000000000000000000001"),
+            "refs/changes/01/1/1");
+    ReceiveCommand cmd2 =
+        new ReceiveCommand(
+            ObjectId.zeroId(),
+            ObjectId.fromString("0000000000000000000000000000000000000001"),
+            "refs/changes/01/1/meta");
+    cmd1.setResult(ReceiveCommand.Result.OK);
+    cmd2.setResult(ReceiveCommand.Result.OK);
+    update.addCommand(cmd1);
+    update.addCommand(cmd2);
+
+    GitReferenceUpdated event =
+        new GitReferenceUpdated(
+            new PluginSetContext<>(batchRefUpdateListeners, PluginMetrics.DISABLED_INSTANCE),
+            new PluginSetContext<>(refUpdatedListeners, PluginMetrics.DISABLED_INSTANCE),
+            util);
+    event.fire(Project.NameKey.parse("project"), update, updater);
+    Mockito.verify(batchRefUpdateListener, Mockito.times(1)).onGitBatchRefUpdate(Mockito.any());
+    Mockito.verify(refUpdatedListener, Mockito.times(2)).onGitReferenceUpdated(Mockito.any());
+  }
+
+  @Test
+  public void RefUpdateEventAndRefsUpdateEventAreFired_RefUpdate() throws Exception {
+    String ref = "refs/heads/master";
+    RefUpdate update = newRefUpdate(ref);
+
+    GitReferenceUpdated event =
+        new GitReferenceUpdated(
+            new PluginSetContext<>(batchRefUpdateListeners, PluginMetrics.DISABLED_INSTANCE),
+            new PluginSetContext<>(refUpdatedListeners, PluginMetrics.DISABLED_INSTANCE),
+            util);
+    event.fire(Project.NameKey.parse("project"), update, updater);
+    Mockito.verify(batchRefUpdateListener, Mockito.times(1)).onGitBatchRefUpdate(Mockito.any());
+    Mockito.verify(refUpdatedListener, Mockito.times(1)).onGitReferenceUpdated(Mockito.any());
+  }
+
+  private static BatchRefUpdate newBatchRefUpdate() {
+    try (Repository repo = new InMemoryRepository(new DfsRepositoryDescription("repo"))) {
+      return repo.getRefDatabase().newBatchUpdate();
+    }
+  }
+
+  private static RefUpdate newRefUpdate(String ref) throws IOException {
+    try (Repository repo = new InMemoryRepository(new DfsRepositoryDescription("repo"))) {
+      return repo.getRefDatabase().newUpdate(ref, false);
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/server/fixes/fixCalculator/FixCalculatorVariousTest.java b/javatests/com/google/gerrit/server/fixes/fixCalculator/FixCalculatorVariousTest.java
index fa5c47f..42a80c3 100644
--- a/javatests/com/google/gerrit/server/fixes/fixCalculator/FixCalculatorVariousTest.java
+++ b/javatests/com/google/gerrit/server/fixes/fixCalculator/FixCalculatorVariousTest.java
@@ -248,4 +248,30 @@
     assertThat(edit).internalEdits().element(0).isReplace(6, 12, 6, 9);
     assertThat(edit).internalEdits().element(1).isReplace(18, 10, 15, 7);
   }
+
+  @Test
+  public void overlappingChangesInMiddleOfLineRaisesException() throws Exception {
+    FixReplacement firstReplace =
+        new FixReplacement("path", new Range(2, 0, 3, 5), "First modification\n");
+    FixReplacement secondReplace =
+        new FixReplacement("path", new Range(3, 4, 4, 0), "Some other modified content\n");
+    assertThrows(
+        ResourceConflictException.class,
+        () ->
+            FixCalculator.calculateFix(
+                multilineContent, ImmutableList.of(firstReplace, secondReplace)));
+  }
+
+  @Test
+  public void overlappingChangesInBeginningOfLineRaisesException() throws Exception {
+    FixReplacement firstReplace =
+        new FixReplacement("path", new Range(2, 0, 3, 1), "First modification\n");
+    FixReplacement secondReplace =
+        new FixReplacement("path", new Range(3, 0, 4, 0), "Some other modified content\n");
+    assertThrows(
+        ResourceConflictException.class,
+        () ->
+            FixCalculator.calculateFix(
+                multilineContent, ImmutableList.of(firstReplace, secondReplace)));
+  }
 }
diff --git a/javatests/com/google/gerrit/server/git/DeleteZombieCommentsRefsTest.java b/javatests/com/google/gerrit/server/git/DeleteZombieCommentsRefsTest.java
index 29dbe58..5810df7 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;
@@ -204,14 +203,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/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/change/FakeChangeIndex.java b/javatests/com/google/gerrit/server/index/change/FakeChangeIndex.java
index fc56a3c..07abae9 100644
--- a/javatests/com/google/gerrit/server/index/change/FakeChangeIndex.java
+++ b/javatests/com/google/gerrit/server/index/change/FakeChangeIndex.java
@@ -28,11 +28,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 = new Schema<>(1, ImmutableList.of(ChangeField.STATUS));
 
   static final Schema<ChangeData> V2 =
-      new Schema<>(
-          2, false, ImmutableList.of(ChangeField.STATUS, ChangeField.PATH, ChangeField.UPDATED));
+      new Schema<>(2, ImmutableList.of(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..d7c779e 100644
--- a/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java
+++ b/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java
@@ -68,8 +68,7 @@
 import com.google.inject.Inject;
 import com.google.inject.Injector;
 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.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
@@ -85,7 +84,7 @@
 @Ignore
 @RunWith(ConfigSuite.class)
 public abstract class AbstractChangeNotesTest {
-  private static final TimeZone TZ = TimeZone.getTimeZone("America/Los_Angeles");
+  private static final ZoneId ZONE_ID = ZoneId.of("America/Los_Angeles");
 
   @ConfigSuite.Parameter public Config testConfig;
 
@@ -115,15 +114,11 @@
   protected Injector injector;
   private String systemTimeZone;
 
-  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
-  // Instants
-  @SuppressWarnings("JdkObsolete")
   @Before
   public void setUpTestEnvironment() 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);
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNoteJsonTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNoteJsonTest.java
new file mode 100644
index 0000000..24e28f3
--- /dev/null
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNoteJsonTest.java
@@ -0,0 +1,127 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth8.assertThat;
+
+import com.google.gson.Gson;
+import com.google.inject.TypeLiteral;
+import java.util.Optional;
+import org.junit.Test;
+
+public class ChangeNoteJsonTest {
+  private final Gson gson = new ChangeNoteJson().getGson();
+
+  static class Child {
+    Optional<String> optionalValue;
+  }
+
+  static class Parent {
+    Optional<Child> optionalChild;
+  }
+
+  @Test
+  public void shouldSerializeAndDeserializeEmptyOptional() {
+    // given
+    Optional<?> empty = Optional.empty();
+
+    // when
+    String json = gson.toJson(empty);
+
+    // then
+    assertThat(json).isEqualTo("{}");
+
+    // and when
+    Optional<?> result = gson.fromJson(json, Optional.class);
+
+    // and then
+    assertThat(result).isEmpty();
+  }
+
+  @Test
+  public void shouldSerializeAndDeserializeNonEmptyOptional() {
+    // given
+    String value = "foo";
+    Optional<String> nonEmpty = Optional.of(value);
+
+    // when
+    String json = gson.toJson(nonEmpty);
+
+    // then
+    assertThat(json).isEqualTo("{\n  \"value\": \"" + value + "\"\n}");
+
+    // and when
+    Optional<String> result = gson.fromJson(json, new TypeLiteral<Optional<String>>() {}.getType());
+
+    // and then
+    assertThat(result).isPresent();
+    assertThat(result.get()).isEqualTo(value);
+  }
+
+  @Test
+  public void shouldSerializeAndDeserializeNestedNonEmptyOptional() {
+    String value = "foo";
+    Child fooChild = new Child();
+    fooChild.optionalValue = Optional.of(value);
+    Parent parent = new Parent();
+    parent.optionalChild = Optional.of(fooChild);
+
+    String json = gson.toJson(parent);
+
+    assertThat(json)
+        .isEqualTo(
+            "{\n"
+                + "  \"optionalChild\": {\n"
+                + "    \"value\": {\n"
+                + "      \"optionalValue\": {\n"
+                + "        \"value\": \"foo\"\n"
+                + "      }\n"
+                + "    }\n"
+                + "  }\n"
+                + "}");
+
+    Parent result = gson.fromJson(json, new TypeLiteral<Parent>() {}.getType());
+
+    assertThat(result.optionalChild).isPresent();
+    assertThat(result.optionalChild.get().optionalValue).isPresent();
+    assertThat(result.optionalChild.get().optionalValue.get()).isEqualTo(value);
+  }
+
+  @Test
+  public void shouldSerializeAndDeserializeNestedEmptyOptional() {
+    Child fooChild = new Child();
+    fooChild.optionalValue = Optional.empty();
+    Parent parent = new Parent();
+    parent.optionalChild = Optional.of(fooChild);
+
+    String json = gson.toJson(parent);
+
+    assertThat(json)
+        .isEqualTo(
+            "{\n"
+                + "  \"optionalChild\": {\n"
+                + "    \"value\": {\n"
+                + "      \"optionalValue\": {}\n"
+                + "    }\n"
+                + "  }\n"
+                + "}");
+
+    Parent result = gson.fromJson(json, new TypeLiteral<Parent>() {}.getType());
+
+    assertThat(result.optionalChild).isPresent();
+    assertThat(result.optionalChild.get().optionalValue).isEmpty();
+  }
+}
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
index 09c8059..8def660 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());
@@ -2130,11 +2130,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 +3528,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 +3556,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/CommitMessageOutputTest.java b/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java
index 5e2e1f2..9d02067 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,9 +35,6 @@
 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);
@@ -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/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/query/account/AbstractQueryAccountsTest.java b/javatests/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java
index 16f7199..9a2499a 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);
@@ -649,18 +628,13 @@
                     IndexConfig.createDefault(), 0, 1, schema.getStoredFields().keySet()));
 
     assertThat(rawFields).isPresent();
-    if (schema.useLegacyNumericFields()) {
+    if (schema.hasField(AccountField.ID)) {
       assertThat(rawFields.get().getValue(AccountField.ID)).isEqualTo(userInfo._accountId);
     } else {
       assertThat(Integer.valueOf(rawFields.get().getValue(AccountField.ID_STR)))
           .isEqualTo(userInfo._accountId);
     }
 
-    // The field EXTERNAL_ID_STATE is only supported from schema version 6.
-    if (getSchemaVersion() < 6) {
-      return;
-    }
-
     List<AccountExternalIdInfo> externalIdInfos = gApi.accounts().self().getExternalIds();
     List<ByteArrayWrapper> blobs = new ArrayList<>();
     for (AccountExternalIdInfo info : externalIdInfos) {
@@ -876,12 +850,6 @@
     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 {
     try {
       assertQuery(query);
@@ -891,14 +859,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 55c7921..baa4802 100644
--- a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
+++ b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
@@ -91,7 +91,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;
@@ -176,7 +175,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;
@@ -354,6 +353,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);
@@ -638,7 +649,6 @@
 
   @Test
   public void byAuthorExact() throws Exception {
-    assume().that(getSchema().hasField(ChangeField.EXACT_AUTHOR)).isTrue();
     byAuthorOrCommitterExact("author:");
   }
 
@@ -649,7 +659,6 @@
 
   @Test
   public void byCommitterExact() throws Exception {
-    assume().that(getSchema().hasField(ChangeField.EXACT_COMMITTER)).isTrue();
     byAuthorOrCommitterExact("committer:");
   }
 
@@ -1595,11 +1604,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
@@ -1963,21 +1970,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);
@@ -2461,10 +2453,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");
@@ -2485,13 +2473,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");
@@ -4100,7 +4081,6 @@
 
     assertQuery(ChangeIndexPredicate.none());
 
-    ChangeQueryBuilder queryBuilder = queryBuilderProvider.get();
     for (Predicate<ChangeData> matchingOneChange :
         ImmutableList.of(
             // One index query, one post-filtering query.
@@ -4470,12 +4450,6 @@
     }
   }
 
-  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);
   }
@@ -4492,10 +4466,6 @@
     }
   }
 
-  protected int getSchemaVersion() {
-    return getSchema().getVersion();
-  }
-
   protected Schema<ChangeData> getSchema() {
     return indexes.getSearchIndex().getSchema();
   }
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/lib/auto/BUILD b/lib/auto/BUILD
index 18b9b91..e01d91b 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 = [
diff --git a/plugins/gitiles b/plugins/gitiles
index 44b5333..648b1df 160000
--- a/plugins/gitiles
+++ b/plugins/gitiles
@@ -1 +1 @@
-Subproject commit 44b5333d08d02c15334f81906134a5613d3f0a3b
+Subproject commit 648b1df92b887ed5011e3a2fbf18fd2b9e8622b3
diff --git a/plugins/replication b/plugins/replication
index 99c9794..bdc7e6b 160000
--- a/plugins/replication
+++ b/plugins/replication
@@ -1 +1 @@
-Subproject commit 99c9794a6d5d649bd60e1f0d8f59f79f929f7ba6
+Subproject commit bdc7e6b3255965d0e4415a978031222a683b38d8
diff --git a/polygerrit-ui/README.md b/polygerrit-ui/README.md
index cd88f52..732a82c 100644
--- a/polygerrit-ui/README.md
+++ b/polygerrit-ui/README.md
@@ -45,7 +45,9 @@
 bazel clean --expunge
 ```
 
-The minimum nodejs version supported is 8.x+
+The minimum nodejs version supported is 8.x+, the maximum is currently 16.x due
+to [an issue](https://github.com/karma-runner/karma/issues/3730) in our test
+runner, karma.
 
 ```sh
 # Debian experimental
@@ -53,7 +55,7 @@
 sudo apt-get install npm
 
 # OS X with Homebrew
-brew install node
+brew install node@16
 brew install npm
 ```
 
@@ -94,7 +96,7 @@
 
 ## Setup typescript support in the IDE
 
-Modern IDE should automatically handle typescript settings from the 
+Modern IDE should automatically handle typescript settings from the
 `pollygerrit-ui/app/tsconfig.json` files. IDE places compiled files in the
 `.ts-out/pg` directory at the root of gerrit workspace and you can configure IDE
 to exclude the whole .ts-out directory. To do it in the IntelliJ IDEA click on
@@ -238,34 +240,6 @@
 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 +260,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
 
@@ -352,7 +326,7 @@
    ```
    // Before:
    import ... from 'x/y/z.js`
- 
+
    // After
    import .. from 'x/y/z'
    ```
@@ -421,16 +395,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 +501,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).
diff --git a/polygerrit-ui/app/.eslintrc.js b/polygerrit-ui/app/.eslintrc.js
index 8eaff5c..82873a7 100644
--- a/polygerrit-ui/app/.eslintrc.js
+++ b/polygerrit-ui/app/.eslintrc.js
@@ -314,6 +314,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 +328,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 +350,7 @@
       ],
       rules: {
         '@typescript-eslint/no-explicit-any': 'off',
+        '@typescript-eslint/require-await': 'off',
       },
     },
     {
diff --git a/polygerrit-ui/app/api/diff.ts b/polygerrit-ui/app/api/diff.ts
index 08e2e66..430e770 100644
--- a/polygerrit-ui/app/api/diff.ts
+++ b/polygerrit-ui/app/api/diff.ts
@@ -38,10 +38,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 +173,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 +340,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/constants/constants.ts b/polygerrit-ui/app/constants/constants.ts
index 8cdd765..a0a6417 100644
--- a/polygerrit-ui/app/constants/constants.ts
+++ b/polygerrit-ui/app/constants/constants.ts
@@ -107,6 +107,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
diff --git a/polygerrit-ui/app/constants/reporting.ts b/polygerrit-ui/app/constants/reporting.ts
index 8818066..d459886 100644
--- a/polygerrit-ui/app/constants/reporting.ts
+++ b/polygerrit-ui/app/constants/reporting.ts
@@ -33,6 +33,7 @@
   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',
 }
 
 export enum Timing {
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 a4ef0c3..9b01735 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
@@ -84,16 +84,15 @@
   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() {
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..de61d2d 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
@@ -623,7 +623,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
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..fc2b789 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
@@ -127,12 +127,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() {
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..596e850 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
@@ -11,9 +11,10 @@
 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
@@ -56,16 +57,16 @@
 
   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$,
+      () => this.getBulkActionsModel().totalChangeCount$,
       totalChangeCount => (this.totalChangeCount = totalChangeCount)
     );
   }
@@ -110,9 +111,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..df68f5f 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
@@ -65,9 +65,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-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..eb5e6a8 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
@@ -35,11 +35,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-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..77c9382 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
@@ -24,6 +24,7 @@
 import {getAppContext} from '../../../services/app-context';
 import {fontStyles} from '../../../styles/gr-font-styles';
 import {queryAndAssert} from '../../../utils/common-util';
+import '@polymer/iron-icon/iron-icon';
 import {
   LabelNameToValuesMap,
   ReviewInput,
@@ -35,6 +36,8 @@
 import '../../shared/gr-dialog/gr-dialog';
 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';
 
 @customElement('gr-change-list-bulk-vote-flow')
 export class GrChangeListBulkVoteFlow extends LitElement {
@@ -42,6 +45,8 @@
 
   private readonly userModel = getAppContext().userModel;
 
+  private readonly reportingService = getAppContext().reportingService;
+
   @state() selectedChanges: ChangeInfo[] = [];
 
   @state() progressByChange: Map<NumericChangeId, ProgressStatus> = new Map();
@@ -54,6 +59,9 @@
     return [
       fontStyles,
       css`
+        gr-dialog {
+          width: 840px;
+        }
         .scoresTable {
           display: table;
         }
@@ -66,24 +74,42 @@
         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(--red-50);
+          margin-top: var(--spacing-l);
+        }
+        .error-container iron-icon {
+          padding: 10px var(--spacing-xl);
+          color: var(--red-700);
+          --iron-icon-height: 20px;
+          --iron-icon-width: 20px;
+        }
+        .error-container span {
+          position: relative;
+          top: 1px;
         }
       `,
     ];
   }
 
-  override connectedCallback() {
-    super.connectedCallback();
+  constructor() {
+    super();
     subscribe(
       this,
-      this.getBulkActionsModel().selectedChanges$,
+      () => this.getBulkActionsModel().selectedChanges$,
       selectedChanges => {
         this.selectedChanges = selectedChanges;
         this.resetFlow();
@@ -91,7 +117,7 @@
     );
     subscribe(
       this,
-      this.userModel.account$,
+      () => this.userModel.account$,
       account => (this.account = account)
     );
   }
@@ -114,10 +140,16 @@
         <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,
@@ -129,20 +161,40 @@
               'Trigger Votes',
               permittedLabels
             )}
+            ${this.renderErrors()}
           </div>
-          <!-- TODO: Add error handling status if something fails -->
         </gr-dialog>
       </gr-overlay>
     `;
   }
 
+  private renderErrors() {
+    if (getOverallStatus(this.progressByChange) !== ProgressStatus.FAILED) {
+      return nothing;
+    }
+    return html`
+      <div class="error-container">
+        <iron-icon icon="gr-icons:error"></iron-icon>
+        <span>
+          <!-- 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
   ) {
     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>
       ${labels
         .filter(
           label =>
@@ -170,6 +222,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 +242,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('bulk-action', {
+      type: 'vote',
+      selectedChangeCount: this.selectedChanges.length,
+    });
     const reviewInput: ReviewInput = {
       labels: this.getLabelValues(
         this.computeCommonPermittedLabels(this.computePermittedLabels())
@@ -202,18 +261,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,
+      });
     }
   }
 
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..e2cbaf0 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
@@ -18,6 +18,7 @@
   query,
   mockPromise,
   queryAll,
+  stubReporting,
 } from '../../../test/test-utils';
 import {ChangeInfo, NumericChangeId, LabelInfo} from '../../../api/rest-api';
 import {getAppContext} from '../../../services/app-context';
@@ -34,6 +35,7 @@
 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 +94,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 +111,7 @@
   setup(async () => {
     model = new BulkActionsModel(getAppContext().restApiService);
     getChangesStub = stubRestApi('getDetailedChangesWithActions');
-
+    reportingStub = stubReporting('reportInteraction');
     element = (
       await fixture(
         wrapInProvider(
@@ -118,6 +122,7 @@
       )
     ).querySelector('gr-change-list-bulk-vote-flow')!;
     await element.updateComplete;
+    dispatchEventStub = sinon.stub(element, 'dispatchEvent');
   });
 
   test('renders', async () => {
@@ -147,9 +152,12 @@
         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>
               <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,7 +165,7 @@
               </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>
@@ -166,6 +174,75 @@
       </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
+    );
+
+    expect(element).shadowDom.to.equal(/* HTML */ `<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>
+              <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">
+              <iron-icon icon="gr-icons:error"> </iron-icon>
+              <span> Failed to vote on 1 change </span>
+            </div>
+          </div>
+        </gr-dialog>
+      </gr-overlay> `);
+  });
+
   test('button state updates as changes are updated', async () => {
     const changes: ChangeInfo[] = [change1];
     getChangesStub.returns(Promise.resolve(changes));
@@ -255,6 +332,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 +360,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 +374,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 +399,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 +431,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-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..d7ff864
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-hashtag-flow/gr-change-list-hashtag-flow.ts
@@ -0,0 +1,379 @@
+/**
+ * @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';
+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-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';
+import {ValueChangedEvent} from '../../../types/events';
+import {classMap} from 'lit/directives/class-map';
+import {spinnerStyles} from '../../../styles/gr-spinner-styles';
+import {ProgressStatus} from '../../../constants/constants';
+import {allSettled} from '../../../utils/async-util';
+
+@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;
+
+  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 {
+          --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;
+        }
+        .chip:not(.selected) {
+          border: var(--spacing-xxs) solid var(--gray-300);
+        }
+        .chip.selected {
+          color: var(--blue-800);
+          background-color: var(--blue-50);
+          margin: var(--spacing-xxs);
+        }
+        .loadingOrError {
+          display: flex;
+          gap: var(--spacing-s);
+        }
+
+        /* The basics of .loadingSpin are defined in spinnerStyles. */
+        .loadingSpin {
+          vertical-align: top;
+          position: relative;
+          top: 3px;
+        }
+      `,
+    ];
+  }
+
+  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
+        @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">
+              ${when(
+                this.selectedChanges.some(change => change.hashtags?.length),
+                () => this.renderExistingHashtagsMode(),
+                () => this.renderNoExistingHashtagsMode()
+              )}
+            </div>
+          `
+        )}
+      </iron-dropdown>
+    `;
+  }
+
+  private renderExistingHashtagsMode() {
+    const hashtags = this.selectedChanges
+      .flatMap(change => change.hashtags ?? [])
+      .filter(notUndefined)
+      .filter(unique);
+    const removeDisabled =
+      this.selectedExistingHashtags.size === 0 ||
+      this.overallProgress === ProgressStatus.RUNNING;
+    const applyToAllDisabled = this.selectedExistingHashtags.size !== 1;
+    return html`
+      <div class="chips">
+        ${hashtags.map(name => this.renderExistingHashtagChip(name))}
+      </div>
+      <div class="footer">
+        <div class="loadingOrError">${this.renderLoadingOrError()}</div>
+        <div class="buttons">
+          <gr-button
+            id="apply-to-all-button"
+            flatten
+            ?disabled=${applyToAllDisabled}
+            @click=${this.applyHashtagToAll}
+            >Apply to all</gr-button
+          >
+          <gr-button
+            id="remove-hashtags-button"
+            flatten
+            ?disabled=${removeDisabled}
+            @click=${this.removeHashtags}
+            >Remove</gr-button
+          >
+        </div>
+      </div>
+    `;
+  }
+
+  private renderExistingHashtagChip(name: Hashtag) {
+    const chipClasses = {
+      chip: true,
+      selected: this.selectedExistingHashtags.has(name),
+    };
+    return html`
+      <span
+        role="button"
+        aria-label=${name as string}
+        class=${classMap(chipClasses)}
+        @click=${() => this.toggleExistingHashtagSelected(name)}
+      >
+        ${name}
+      </span>
+    `;
+  }
+
+  private renderLoadingOrError() {
+    if (this.overallProgress === ProgressStatus.RUNNING) {
+      return html`
+        <span class="loadingSpin"></span>
+        <span class="loadingText">${this.loadingText}</span>
+      `;
+    } else if (this.errorText !== undefined) {
+      return html`<div class="error">${this.errorText}</div>`;
+    }
+    return nothing;
+  }
+
+  private renderNoExistingHashtagsMode() {
+    const isCreateNewHashtagDisabled =
+      this.hashtagToAdd === '' ||
+      this.existingHashtagSuggestions.includes(this.hashtagToAdd) ||
+      this.overallProgress === ProgressStatus.RUNNING;
+    const isApplyHashtagDisabled =
+      this.hashtagToAdd === '' ||
+      !this.existingHashtagSuggestions.includes(this.hashtagToAdd) ||
+      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.getHashtagSuggestions.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.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">${this.renderLoadingOrError()}</div>
+        <div class="buttons">
+          <gr-button
+            id="create-new-hashtag-button"
+            flatten
+            @click=${() => this.addHashtag('Creating hashtag...')}
+            .disabled=${isCreateNewHashtagDisabled}
+            >Create new hashtag</gr-button
+          >
+          <gr-button
+            id="apply-hashtag-button"
+            flatten
+            @click=${() => this.addHashtag('Applying hashtag...')}
+            .disabled=${isApplyHashtagDisabled}
+            >Apply</gr-button
+          >
+        </div>
+      </div>
+    `;
+  }
+
+  private toggleDropdown() {
+    if (this.isDropdownOpen) {
+      this.closeDropdown();
+    } else {
+      this.reset();
+      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.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 removeHashtags() {
+    this.loadingText = `Removing hashtag${
+      this.selectedExistingHashtags.size > 1 ? 's' : ''
+    }...`;
+    this.trackPromises(
+      this.selectedChanges
+        .filter(
+          change =>
+            change.hashtags &&
+            change.hashtags.some(hashtag =>
+              this.selectedExistingHashtags.has(hashtag)
+            )
+        )
+        .map(change =>
+          this.restApiService.setChangeHashtag(change._number, {
+            remove: Array.from(this.selectedExistingHashtags.values()),
+          })
+        )
+    );
+  }
+
+  private applyHashtagToAll() {
+    this.loadingText = 'Applying hashtag to all';
+    this.trackPromises(
+      this.selectedChanges.map(change =>
+        this.restApiService.setChangeHashtag(change._number, {
+          add: Array.from(this.selectedExistingHashtags.values()),
+        })
+      )
+    );
+  }
+
+  private addHashtag(loadingText: string) {
+    this.loadingText = loadingText;
+    this.trackPromises(
+      this.selectedChanges.map(change =>
+        this.restApiService.setChangeHashtag(change._number, {
+          add: [this.hashtagToAdd],
+        })
+      )
+    );
+  }
+
+  private async trackPromises(promises: Promise<Hashtag[]>[]) {
+    this.overallProgress = ProgressStatus.RUNNING;
+    const results = await allSettled(promises);
+    if (results.every(result => result.status === 'fulfilled')) {
+      this.overallProgress = ProgressStatus.SUCCESSFUL;
+      this.closeDropdown();
+      // TODO: fire reload of dashboard
+    } else {
+      this.overallProgress = ProgressStatus.FAILED;
+      // TODO: when some are rejected, show error and Cancel button
+    }
+  }
+
+  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..6910ae2
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-hashtag-flow/gr-change-list-hashtag-flow_test.ts
@@ -0,0 +1,542 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {fixture, html} from '@open-wc/testing-helpers';
+import {IronDropdownElement} from '@polymer/iron-dropdown';
+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 {createChange} from '../../../test/test-data-generators';
+import {
+  MockPromise,
+  mockPromise,
+  queryAll,
+  queryAndAssert,
+  stubRestApi,
+  waitUntil,
+  waitUntilCalled,
+  waitUntilObserved,
+} from '../../../test/test-utils';
+import {ChangeInfo, NumericChangeId, Hashtag} from '../../../types/common';
+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;
+
+  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 () => {
+      expect(element).shadowDom.to.equal(/* HTML */ `
+        <gr-button
+          id="start-flow"
+          flatten=""
+          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('changes in existing hashtags', () => {
+    const changesWithHashtags: ChangeInfo[] = [
+      {
+        ...createChange(),
+        _number: 1 as NumericChangeId,
+        subject: 'Subject 1',
+        hashtags: ['hashtag1' as Hashtag],
+      },
+      {
+        ...createChange(),
+        _number: 2 as NumericChangeId,
+        subject: 'Subject 2',
+        hashtags: ['hashtag2' as Hashtag],
+      },
+    ];
+    let setChangeHashtagPromises: MockPromise<string>[];
+    let setChangeHashtagStub: sinon.SinonStub;
+
+    async function resolvePromises() {
+      setChangeHashtagPromises[0].resolve('foo');
+      setChangeHashtagPromises[1].resolve('foo');
+      await element.updateComplete;
+    }
+
+    setup(async () => {
+      stubRestApi('getDetailedChangesWithActions').resolves(
+        changesWithHashtags
+      );
+      setChangeHashtagPromises = [];
+      setChangeHashtagStub = stubRestApi('setChangeHashtag');
+      for (let i = 0; i < changesWithHashtags.length; i++) {
+        const promise = mockPromise<string>();
+        setChangeHashtagPromises.push(promise);
+        setChangeHashtagStub
+          .withArgs(changesWithHashtags[i]._number, sinon.match.any)
+          .returns(promise);
+      }
+      model = new BulkActionsModel(getAppContext().restApiService);
+      model.sync(changesWithHashtags);
+
+      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(changesWithHashtags[0]);
+      await selectChange(changesWithHashtags[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 flush();
+    });
+
+    test('renders existing-hashtags flow', () => {
+      expect(element).shadowDom.to.equal(
+        /* HTML */ `
+          <gr-button
+            id="start-flow"
+            flatten=""
+            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">
+                <span role="button" aria-label="hashtag1" class="chip"
+                  >hashtag1</span
+                >
+                <span role="button" aria-label="hashtag2" class="chip"
+                  >hashtag2</span
+                >
+              </div>
+              <div class="footer">
+                <div class="loadingOrError"></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-hashtags-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('remove single hashtag', async () => {
+      queryAll<HTMLSpanElement>(element, 'span.chip')[0].click();
+      await element.updateComplete;
+      queryAndAssert<GrButton>(element, '#remove-hashtags-button').click();
+      await element.updateComplete;
+
+      assert.equal(
+        queryAndAssert(element, '.loadingText').textContent,
+        'Removing hashtag...'
+      );
+
+      await resolvePromises();
+      await element.updateComplete;
+
+      // not called for second change which as a different hashtag
+      assert.isTrue(setChangeHashtagStub.calledOnce);
+      assert.deepEqual(setChangeHashtagStub.firstCall.args, [
+        changesWithHashtags[0]._number,
+        {remove: ['hashtag1']},
+      ]);
+    });
+
+    test('remove multiple hashtags', async () => {
+      queryAll<HTMLSpanElement>(element, 'span.chip')[0].click();
+      queryAll<HTMLSpanElement>(element, 'span.chip')[1].click();
+      await element.updateComplete;
+      queryAndAssert<GrButton>(element, '#remove-hashtags-button').click();
+      await element.updateComplete;
+
+      assert.equal(
+        queryAndAssert(element, '.loadingText').textContent,
+        'Removing hashtags...'
+      );
+
+      await resolvePromises();
+      await element.updateComplete;
+
+      // not called for second change which as a different hashtag
+      assert.isTrue(setChangeHashtagStub.calledTwice);
+      assert.deepEqual(setChangeHashtagStub.firstCall.args, [
+        changesWithHashtags[0]._number,
+        {remove: ['hashtag1', 'hashtag2']},
+      ]);
+      assert.deepEqual(setChangeHashtagStub.secondCall.args, [
+        changesWithHashtags[1]._number,
+        {remove: ['hashtag1', 'hashtag2']},
+      ]);
+    });
+
+    test('can only apply a single hashtag', async () => {
+      assert.isTrue(
+        queryAndAssert<GrButton>(element, '#apply-to-all-button').disabled
+      );
+
+      queryAll<HTMLSpanElement>(element, 'span.chip')[0].click();
+      await element.updateComplete;
+
+      assert.isFalse(
+        queryAndAssert<GrButton>(element, '#apply-to-all-button').disabled
+      );
+
+      queryAll<HTMLSpanElement>(element, 'span.chip')[1].click();
+      await element.updateComplete;
+
+      assert.isTrue(
+        queryAndAssert<GrButton>(element, '#apply-to-all-button').disabled
+      );
+    });
+
+    test('applies hashtag to all changes', async () => {
+      queryAll<HTMLSpanElement>(element, 'span.chip')[0].click();
+      await element.updateComplete;
+
+      queryAndAssert<GrButton>(element, '#apply-to-all-button').click();
+      await element.updateComplete;
+
+      assert.equal(
+        queryAndAssert(element, '.loadingText').textContent,
+        'Applying hashtag to all'
+      );
+
+      await resolvePromises();
+      await element.updateComplete;
+
+      assert.isTrue(setChangeHashtagStub.calledTwice);
+      assert.deepEqual(setChangeHashtagStub.firstCall.args, [
+        changesWithHashtags[0]._number,
+        {add: ['hashtag1']},
+      ]);
+      assert.deepEqual(setChangeHashtagStub.secondCall.args, [
+        changesWithHashtags[1]._number,
+        {add: ['hashtag1']},
+      ]);
+    });
+  });
+
+  suite('change have no existing hashtags', () => {
+    const changesWithNoHashtags: ChangeInfo[] = [
+      {
+        ...createChange(),
+        _number: 1 as NumericChangeId,
+        subject: 'Subject 1',
+      },
+      {
+        ...createChange(),
+        _number: 2 as NumericChangeId,
+        subject: 'Subject 2',
+      },
+    ];
+    let setChangeHashtagPromises: MockPromise<string>[];
+    let setChangeHashtagStub: sinon.SinonStub;
+
+    async function resolvePromises() {
+      setChangeHashtagPromises[0].resolve('foo');
+      setChangeHashtagPromises[1].resolve('foo');
+      await element.updateComplete;
+    }
+
+    setup(async () => {
+      stubRestApi('getDetailedChangesWithActions').resolves(
+        changesWithNoHashtags
+      );
+      setChangeHashtagPromises = [];
+      setChangeHashtagStub = stubRestApi('setChangeHashtag');
+      for (let i = 0; i < changesWithNoHashtags.length; i++) {
+        const promise = mockPromise<string>();
+        setChangeHashtagPromises.push(promise);
+        setChangeHashtagStub
+          .withArgs(changesWithNoHashtags[i]._number, sinon.match.any)
+          .returns(promise);
+      }
+
+      model = new BulkActionsModel(getAppContext().restApiService);
+      model.sync(changesWithNoHashtags);
+
+      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(changesWithNoHashtags[0]);
+      await selectChange(changesWithNoHashtags[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 flush();
+    });
+
+    test('renders no-existing-hashtags flow', () => {
+      expect(element).shadowDom.to.equal(
+        /* HTML */ `
+          <gr-button
+            id="start-flow"
+            flatten=""
+            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">
+              <gr-autocomplete
+                placeholder="Type hashtag name to create or filter hashtags"
+                show-blue-focus-border=""
+              ></gr-autocomplete>
+              <div class="footer">
+                <div class="loadingOrError"></div>
+                <div class="buttons">
+                  <gr-button
+                    id="create-new-hashtag-button"
+                    flatten=""
+                    aria-disabled="true"
+                    disabled=""
+                    role="button"
+                    tabindex="-1"
+                    >Create new hashtag</gr-button
+                  >
+                  <gr-button
+                    id="apply-hashtag-button"
+                    flatten=""
+                    aria-disabled="true"
+                    disabled=""
+                    role="button"
+                    tabindex="-1"
+                    >Apply</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 hashtag', async () => {
+      const getHashtagsStub = stubRestApi(
+        'getChangesWithSimilarHashtag'
+      ).resolves([]);
+      const autocomplete = queryAndAssert<GrAutocomplete>(
+        element,
+        'gr-autocomplete'
+      );
+      autocomplete.focus();
+      autocomplete.text = 'foo';
+      await element.updateComplete;
+      await waitUntilCalled(getHashtagsStub, 'getHashtagsStub');
+      assert.isTrue(
+        queryAndAssert<GrButton>(element, '#apply-hashtag-button').disabled
+      );
+
+      queryAndAssert<GrButton>(element, '#create-new-hashtag-button').click();
+      await element.updateComplete;
+
+      assert.equal(
+        queryAndAssert(element, '.loadingText').textContent,
+        'Creating hashtag...'
+      );
+
+      await resolvePromises();
+      await element.updateComplete;
+
+      assert.isTrue(setChangeHashtagStub.calledTwice);
+      assert.deepEqual(setChangeHashtagStub.firstCall.args, [
+        changesWithNoHashtags[0]._number,
+        {add: ['foo']},
+      ]);
+      assert.deepEqual(setChangeHashtagStub.secondCall.args, [
+        changesWithNoHashtags[1]._number,
+        {add: ['foo']},
+      ]);
+    });
+
+    test('apply hashtag', async () => {
+      const getHashtagsStub = stubRestApi(
+        'getChangesWithSimilarHashtag'
+      ).resolves([{...createChange(), hashtags: ['foo' as Hashtag]}]);
+      const autocomplete = queryAndAssert<GrAutocomplete>(
+        element,
+        'gr-autocomplete'
+      );
+
+      autocomplete.focus();
+      autocomplete.text = 'foo';
+      await element.updateComplete;
+      await waitUntilCalled(getHashtagsStub, 'getHashtagsStub');
+      assert.isTrue(
+        queryAndAssert<GrButton>(element, '#create-new-hashtag-button').disabled
+      );
+
+      queryAndAssert<GrButton>(element, '#apply-hashtag-button').click();
+      await element.updateComplete;
+
+      assert.equal(
+        queryAndAssert(element, '.loadingText').textContent,
+        'Applying hashtag...'
+      );
+
+      await resolvePromises();
+
+      assert.isTrue(setChangeHashtagStub.calledTwice);
+      assert.deepEqual(setChangeHashtagStub.firstCall.args, [
+        changesWithNoHashtags[0]._number,
+        {add: ['foo']},
+      ]);
+      assert.deepEqual(setChangeHashtagStub.secondCall.args, [
+        changesWithNoHashtags[1]._number,
+        {add: ['foo']},
+      ]);
+    });
+  });
+});
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..b4ebd3c 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
@@ -40,12 +40,9 @@
   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';
@@ -53,7 +50,7 @@
 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 {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';
@@ -125,6 +122,18 @@
 
   private readonly getBulkActionsModel = resolve(this, bulkActionsModelToken);
 
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.getBulkActionsModel().selectedChangeNums$,
+      selectedChangeNums => {
+        if (!this.change) return;
+        this.checked = selectedChangeNums.includes(this.change._number);
+      }
+    );
+  }
+
   override connectedCallback() {
     super.connectedCallback();
     getPluginLoader()
@@ -134,14 +143,6 @@
           'change-list-item-cell'
         );
       });
-    subscribe(
-      this,
-      this.getBulkActionsModel().selectedChangeNums$,
-      selectedChangeNums => {
-        if (!this.change) return;
-        this.checked = selectedChangeNums.includes(this.change._number);
-      }
-    );
   }
 
   static override get styles() {
@@ -244,22 +245,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);
@@ -346,7 +331,12 @@
   }
 
   private renderCellSubject(changeUrl: string) {
-    if (this.computeIsColumnHidden('Subject', this.visibleChangeTableColumns))
+    if (
+      this.computeIsColumnHidden(
+        ColumnNames.SUBJECT,
+        this.visibleChangeTableColumns
+      )
+    )
       return;
 
     return html`
@@ -367,7 +357,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 +382,12 @@
   }
 
   private renderCellOwner() {
-    if (this.computeIsColumnHidden('Owner', this.visibleChangeTableColumns))
+    if (
+      this.computeIsColumnHidden(
+        ColumnNames.OWNER,
+        this.visibleChangeTableColumns
+      )
+    )
       return;
 
     return html`
@@ -403,7 +403,12 @@
   }
 
   private renderCellReviewers() {
-    if (this.computeIsColumnHidden('Reviewers', this.visibleChangeTableColumns))
+    if (
+      this.computeIsColumnHidden(
+        ColumnNames.REVIEWERS,
+        this.visibleChangeTableColumns
+      )
+    )
       return;
 
     return html`
@@ -455,7 +460,12 @@
   }
 
   private renderCellRepo() {
-    if (this.computeIsColumnHidden('Repo', this.visibleChangeTableColumns))
+    if (
+      this.computeIsColumnHidden(
+        ColumnNames.REPO,
+        this.visibleChangeTableColumns
+      )
+    )
       return;
 
     return html`
@@ -475,7 +485,12 @@
   }
 
   private renderCellBranch() {
-    if (this.computeIsColumnHidden('Branch', this.visibleChangeTableColumns))
+    if (
+      this.computeIsColumnHidden(
+        ColumnNames.BRANCH,
+        this.visibleChangeTableColumns
+      )
+    )
       return;
 
     return html`
@@ -567,7 +582,12 @@
   }
 
   private renderCellRequirements() {
-    if (this.computeIsColumnHidden(' Status ', this.visibleChangeTableColumns))
+    if (
+      this.computeIsColumnHidden(
+        ColumnNames.STATUS2,
+        this.visibleChangeTableColumns
+      )
+    )
       return;
 
     return html`
@@ -579,32 +599,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) {
@@ -637,118 +638,6 @@
     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 '';
-    }
-  }
-
   private computeRepoUrl() {
     if (!this.change) return '';
     return GerritNav.getUrlForProjectChanges(
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..7216249 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
@@ -47,9 +47,8 @@
 } 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 './gr-change-list-item';
-import {GrChangeListItem, LabelCategory} from './gr-change-list-item';
+import {GrChangeListItem} from './gr-change-list-item';
 import {
   DIProviderElement,
   wrapInProvider,
@@ -60,6 +59,7 @@
 } 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';
 
 suite('gr-change-list-item tests', () => {
   const account = createAccountWithId();
@@ -92,226 +92,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')
@@ -388,20 +185,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));
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..34080a1 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
@@ -7,29 +7,37 @@
 import {customElement, query, state} from 'lit/decorators';
 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,
+  ChangeInfo,
+  NumericChangeId,
+  ServerInfo,
+} 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 {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} from '../../../utils/string-util';
+import {getDisplayName} from '../../../utils/display-name-util';
+import {
+  AccountInput,
+  AccountInputDetail,
+} from '../../shared/gr-account-list/gr-account-list';
+import '@polymer/iron-icon/iron-icon';
+import {getReplyByReason} from '../../../utils/attention-set-util';
+import {intersection} from '../../../utils/common-util';
+import {accountOrGroupKey} from '../../../utils/account-util';
 
 @customElement('gr-change-list-reviewer-flow')
 export class GrChangeListReviewerFlow extends LitElement {
@@ -38,8 +46,11 @@
   // contents are given to gr-account-lists to mutate
   @state() private updatedAccountsByReviewerState: Map<
     ReviewerState,
-    AccountInfo[]
-  > = new Map();
+    AccountInput[]
+  > = new Map([
+    [ReviewerState.REVIEWER, []],
+    [ReviewerState.CC, []],
+  ]);
 
   @state() private suggestionsProviderByReviewerState: Map<
     ReviewerState,
@@ -53,12 +64,22 @@
 
   @state() private isOverlayOpen = false;
 
+  @state() private serverConfig?: ServerInfo;
+
   @query('gr-overlay') private overlay!: GrOverlay;
 
+  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 {
@@ -73,17 +94,49 @@
         display: flex;
         flex-wrap: wrap;
       }
+      .warning {
+        display: flex;
+        align-items: center;
+        gap: var(--spacing-xl);
+        padding: var(--spacing-l);
+        padding-left: var(--spacing-xl);
+        background-color: var(--yellow-50);
+      }
+      .grid + .warning {
+        margin-top: var(--spacing-l);
+      }
+      .warning + .warning {
+        margin-top: var(--spacing-s);
+      }
+      iron-icon {
+        color: var(--orange-800);
+        --iron-icon-height: 18px;
+        --iron-icon-width: 18px;
+      }
     `;
   }
 
-  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)
     );
   }
 
@@ -112,16 +165,19 @@
         .confirmLabel=${this.getConfirmLabel(overallStatus)}
         .disabled=${overallStatus === 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()}
         </div>
       </gr-dialog>
     `;
@@ -139,6 +195,8 @@
     if (!updatedAccounts || !suggestionsProvider) {
       return;
     }
+    // @accounts-changed will notify us when an account is added or removed, so
+    // we need to re-render to update warning messages.
     return html`
       <gr-account-list
         id=${id}
@@ -146,11 +204,68 @@
         .removableValues=${[]}
         .suggestionsProvider=${suggestionsProvider}
         .placeholder=${placeholder}
+        @accounts-changed=${() => this.requestUpdate()}
+        @account-added=${(e: CustomEvent<AccountInputDetail>) =>
+          this.onAccountAdded(reviewerState, e)}
       >
       </gr-account-list>
     `;
   }
 
+  private renderAnyOverwriteWarnings() {
+    return html`
+      ${this.renderAnyOverwriteWarning(ReviewerState.REVIEWER)}
+      ${this.renderAnyOverwriteWarning(ReviewerState.CC)}
+    `;
+  }
+
+  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">
+        <iron-icon icon="gr-icons:warning"></iron-icon>
+        ${listForSentence(overwrittenNames)} ${pluralizedVerb} ${currentLabel}
+        on some selected changes and will be moved to ${updatedLabel} on all
+        changes.
+      </div>
+    `;
+  }
+
+  private getOverwrittenDisplayNames(
+    currentReviewerState: ReviewerState
+  ): string[] {
+    const updatedReviewerState =
+      currentReviewerState === ReviewerState.CC
+        ? ReviewerState.REVIEWER
+        : ReviewerState.CC;
+    const accountsInCurrentState = this.selectedChanges
+      .flatMap(change => change.reviewers[currentReviewerState] ?? [])
+      .filter(account => account?._account_id !== undefined);
+    return this.updatedAccountsByReviewerState
+      .get(updatedReviewerState)!
+      .filter(account =>
+        accountsInCurrentState.some(
+          otherAccount =>
+            accountOrGroupKey(otherAccount) === accountOrGroupKey(account)
+        )
+      )
+      .map(reviewer => getDisplayName(this.serverConfig, reviewer));
+  }
+
   private openOverlay() {
     this.resetFlow();
     this.isOverlayOpen = true;
@@ -169,7 +284,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,6 +299,27 @@
     this.requestUpdate();
   }
 
+  /* Removes accounts from one list when they are added to the other */
+  private onAccountAdded(
+    reviewerState: ReviewerState,
+    event: CustomEvent<AccountInputDetail>
+  ) {
+    const oppositeReviewerState =
+      reviewerState === ReviewerState.CC
+        ? ReviewerState.REVIEWER
+        : ReviewerState.CC;
+    const oppositeUpdatedAccounts = this.updatedAccountsByReviewerState.get(
+      oppositeReviewerState
+    )!;
+    const oppositeUpdatedAccountIndex = oppositeUpdatedAccounts.findIndex(
+      acc => accountOrGroupKey(acc) === accountOrGroupKey(event.detail.account)
+    );
+    if (oppositeUpdatedAccountIndex >= 0) {
+      oppositeUpdatedAccounts.splice(oppositeUpdatedAccountIndex, 1);
+      this.requestUpdate();
+    }
+  }
+
   private onConfirm(overallStatus: ProgressStatus) {
     switch (overallStatus) {
       case ProgressStatus.NOT_STARTED:
@@ -198,7 +334,11 @@
     }
   }
 
-  private saveReviewers() {
+  private async saveReviewers() {
+    this.reportingService.reportInteraction('bulk-action', {
+      type: 'add-reviewer',
+      selectedChangeCount: this.selectedChanges.length,
+    });
     this.progressByChangeNum = new Map(
       this.selectedChanges.map(change => [
         change._number,
@@ -206,22 +346,34 @@
       ])
     );
     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,
+      });
     }
   }
 
@@ -243,25 +395,19 @@
     const reviewersPerChange = this.selectedChanges.map(
       change => change.reviewers[reviewerState] ?? []
     );
-    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);
   }
 
   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.map(change => change._number)
     );
-    suggestionsProvider.init();
     return suggestionsProvider;
   }
 }
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..4a142e4 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
@@ -4,26 +4,31 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import {fixture, html} from '@open-wc/testing-helpers';
-import {AccountInfo, ReviewerState} from '../../../api/rest-api';
+import {SinonStubbedMember} from 'sinon';
+import {AccountInfo, GroupInfo, ReviewerState} from '../../../api/rest-api';
 import {
   BulkActionsModel,
   bulkActionsModelToken,
 } from '../../../models/bulk-actions/bulk-actions-model';
 import {wrapInProvider} from '../../../models/di-provider-element';
 import {getAppContext} from '../../../services/app-context';
+import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
 import '../../../test/common-test-setup-karma';
 import {
   createAccountWithIdNameAndEmail,
   createChange,
+  createGroupInfo,
 } from '../../../test/test-data-generators';
 import {
   MockPromise,
   mockPromise,
   queryAndAssert,
+  stubReporting,
   stubRestApi,
   waitUntilObserved,
 } from '../../../test/test-utils';
 import {ChangeInfo, NumericChangeId} from '../../../types/common';
+import {ValueChangedEvent} from '../../../types/events';
 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';
@@ -39,6 +44,7 @@
   createAccountWithIdNameAndEmail(4),
   createAccountWithIdNameAndEmail(5),
 ];
+const groups: GroupInfo[] = [createGroupInfo('groupId')];
 const changes: ChangeInfo[] = [
   {
     ...createChange(),
@@ -60,6 +66,7 @@
 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 +78,7 @@
 
   setup(async () => {
     stubRestApi('getDetailedChangesWithActions').resolves(changes);
+    reportingStub = stubReporting('reportInteraction');
     model = new BulkActionsModel(getAppContext().restApiService);
     model.sync(changes);
 
@@ -181,12 +189,14 @@
           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 slot="header">Add reviewer / CC</div>
+            <div slot="main">
+              <div 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>
             </div>
           </gr-dialog>
         </gr-overlay>
@@ -217,12 +227,17 @@
         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();
       dialog.confirmButton!.click();
       await element.updateComplete;
 
+      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 +245,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,12 +268,77 @@
         {
           reviewers: [
             {reviewer: accounts[2]._account_id, state: ReviewerState.REVIEWER},
+            {reviewer: groups[0].id, state: ReviewerState.REVIEWER},
             {reviewer: accounts[5]._account_id, state: ReviewerState.CC},
           ],
+          ignore_automatic_attention_set_rules: true,
+          // only the reviewer is added to the attention set, not the cc
+          add_to_attention_set: [
+            {
+              reason: '<GERRIT_ACCOUNT_1> replied on the change',
+              user: accounts[2]._account_id,
+            },
+            {
+              reason: '<GERRIT_ACCOUNT_1> replied on the change',
+              user: groups[0].id,
+            },
+          ],
         },
       ]);
     });
 
+    test('removes from reviewer list when added to cc', async () => {
+      const ccList = queryAndAssert<GrAccountList>(
+        dialog,
+        'gr-account-list#cc-list'
+      );
+      const reviewerList = queryAndAssert<GrAccountList>(
+        dialog,
+        'gr-account-list#reviewer-list'
+      );
+      assert.sameOrderedMembers(reviewerList.accounts, [accounts[0]]);
+
+      ccList.handleAdd(
+        new CustomEvent('add', {
+          detail: {
+            value: {
+              account: accounts[0],
+              count: 1,
+            },
+          },
+        }) as unknown as ValueChangedEvent<string>
+      );
+      await flush();
+
+      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 flush();
+
+      assert.isEmpty(ccList.accounts);
+    });
+
     test('confirm button text updates', async () => {
       assert.equal(dialog.confirmLabel, 'Add');
 
@@ -259,5 +352,82 @@
 
       assert.equal(dialog.confirmLabel, 'Close');
     });
+
+    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 flush();
+
+      // prettier and shadowDom string don't agree on long text in divs
+      expect(element).shadowDom.to.equal(
+        /* prettier-ignore */
+        /* 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">
+            <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>
+                  <span>CC</span>
+                  <gr-account-list id="cc-list"></gr-account-list>
+                </div>
+                <div class="warning">
+                  <iron-icon icon="gr-icons:warning"></iron-icon>
+                  User-1 is a reviewer
+        on some selected changes and will be moved to CC on all
+        changes.
+                </div>
+                <div class="warning">
+                  <iron-icon icon="gr-icons:warning"></iron-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']}],
+        }
+      );
+    });
   });
 });
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..330a93b 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
@@ -111,6 +111,20 @@
           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;
+        }
       `,
     ];
   }
@@ -118,13 +132,9 @@
   constructor() {
     super();
     provide(this, bulkActionsModelToken, () => this.bulkActionsModel);
-  }
-
-  override connectedCallback() {
-    super.connectedCallback();
     subscribe(
       this,
-      this.bulkActionsModel.selectedChangeNums$,
+      () => this.bulkActionsModel.selectedChangeNums$,
       selectedChanges =>
         (this.showBulkActionsHeader = selectedChanges.length > 0)
     );
@@ -187,7 +197,6 @@
       <tbody>
         <tr class="groupHeader">
           <td aria-hidden="true" class="leftPadding"></td>
-          ${this.renderSelectionHeader()}
           <td aria-hidden="true" class="star" ?hidden=${!this.showStar}></td>
           <td class="cell" colspan=${colSpan}>
             <h2 class="heading-3">
@@ -234,7 +243,18 @@
 
   private renderSelectionHeader() {
     if (!this.flagsService.isEnabled(KnownExperimentId.BULK_ACTIONS)) return;
-    return html`<td aria-hidden="true" class="selection"></td>`;
+    // TODO: Currently the action bar replaces this checkbox and has it's own
+    // deselect checkbox. Instead, this checkbox should do both select/deselect
+    // and always be visible.
+    return html`
+      <td aria-hidden="true" class="selection">
+        <input
+          class="selection-checkbox"
+          type="checkbox"
+          @click=${() => this.bulkActionsModel.selectAll()}
+        />
+      </td>
+    `;
   }
 
   private renderHeaderCell(item: string) {
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..b1a33a9 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
@@ -24,8 +24,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 {ChangeListSection} from '../gr-change-list/gr-change-list';
 import {fixture, html} from '@open-wc/testing-helpers';
+import {ColumnNames} from '../../../constants/constants';
 
 suite('gr-change-list section', () => {
   let element: GrChangeListSection;
@@ -52,7 +53,7 @@
       html`<gr-change-list-section
         .account=${createAccountDetailWithId(1)}
         .config=${createServerInfo()}
-        .visibleChangeTableColumns=${columnNames}
+        .visibleChangeTableColumns=${Object.values(ColumnNames)}
         .changeSection=${changeSection}
       ></gr-change-list-section> `
     );
@@ -156,6 +157,48 @@
       );
       assert.isTrue(element.showBulkActionsHeader);
     });
+
+    test('select all checkbox checks all when none are selected', async () => {
+      element.changeSection = {
+        name: 'test',
+        query: 'test',
+        results: [
+          {
+            ...createChange(),
+            _number: 1 as NumericChangeId,
+            id: '1' as ChangeInfoId,
+          },
+          {
+            ...createChange(),
+            _number: 2 as NumericChangeId,
+            id: '2' as ChangeInfoId,
+          },
+        ],
+        emptyStateSlotName: 'test',
+      };
+      await element.updateComplete;
+      let rows = queryAll(element, 'gr-change-list-item');
+      assert.lengthOf(rows, 2);
+      assert.isFalse(
+        queryAndAssert<HTMLInputElement>(rows[0], 'input').checked
+      );
+      assert.isFalse(
+        queryAndAssert<HTMLInputElement>(rows[1], 'input').checked
+      );
+
+      const checkbox = queryAndAssert<HTMLInputElement>(element, 'input');
+      checkbox.click();
+      await waitUntilObserved(
+        element.bulkActionsModel.selectedChangeNums$,
+        s => s.length === 2
+      );
+      await element.updateComplete;
+
+      rows = queryAll(element, 'gr-change-list-item');
+      assert.lengthOf(rows, 2);
+      assert.isTrue(queryAndAssert<HTMLInputElement>(rows[0], 'input').checked);
+      assert.isTrue(queryAndAssert<HTMLInputElement>(rows[1], 'input').checked);
+    });
   });
 
   test('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..ffdc9f4
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-topic-flow/gr-change-list-topic-flow.ts
@@ -0,0 +1,371 @@
+/**
+ * @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';
+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-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';
+import {ValueChangedEvent} from '../../../types/events';
+import {classMap} from 'lit/directives/class-map';
+import {spinnerStyles} from '../../../styles/gr-spinner-styles';
+import {ProgressStatus} from '../../../constants/constants';
+import {allSettled} from '../../../utils/async-util';
+
+@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;
+
+  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 {
+          --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;
+        }
+        .chip:not(.selected) {
+          border: var(--spacing-xxs) solid var(--gray-300);
+        }
+        .chip.selected {
+          color: var(--blue-800);
+          background-color: var(--blue-50);
+          margin: var(--spacing-xxs);
+        }
+        .loadingOrError {
+          display: flex;
+          gap: var(--spacing-s);
+        }
+
+        /* The basics of .loadingSpin are defined in spinnerStyles. */
+        .loadingSpin {
+          vertical-align: top;
+          position: relative;
+          top: 3px;
+        }
+      `,
+    ];
+  }
+
+  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
+        @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 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">${this.renderLoadingOrError()}</div>
+        <div class="buttons">
+          <gr-button
+            id="apply-to-all-button"
+            flatten
+            ?disabled=${this.selectedExistingTopics.size !== 1}
+            @click=${this.applyTopicToAll}
+            >Apply to all</gr-button
+          >
+          <gr-button
+            id="remove-topics-button"
+            flatten
+            ?disabled=${removeDisabled}
+            @click=${this.removeTopics}
+            >Remove</gr-button
+          >
+        </div>
+      </div>
+    `;
+  }
+
+  private renderExistingTopicChip(name: TopicName) {
+    const chipClasses = {
+      chip: true,
+      selected: this.selectedExistingTopics.has(name),
+    };
+    return html`
+      <span
+        role="button"
+        aria-label=${name as string}
+        class=${classMap(chipClasses)}
+        @click=${() => this.toggleExistingTopicSelected(name)}
+      >
+        ${name}
+      </span>
+    `;
+  }
+
+  private renderLoadingOrError() {
+    if (this.overallProgress === ProgressStatus.RUNNING) {
+      return html`
+        <span class="loadingSpin"></span>
+        <span class="loadingText">${this.loadingText}</span>
+      `;
+    } else if (this.errorText !== undefined) {
+      return html`<div class="error">${this.errorText}</div>`;
+    }
+    return nothing;
+  }
+
+  private renderNoExistingTopicsMode() {
+    const isCreateNewTopicDisabled =
+      this.topicToAdd === '' ||
+      this.existingTopicSuggestions.includes(this.topicToAdd) ||
+      this.overallProgress === ProgressStatus.RUNNING;
+    const isApplyTopicDisabled =
+      this.topicToAdd === '' ||
+      !this.existingTopicSuggestions.includes(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">${this.renderLoadingOrError()}</div>
+        <div class="buttons">
+          <gr-button
+            id="create-new-topic-button"
+            flatten
+            @click=${() => this.addTopic('Creating topic...')}
+            .disabled=${isCreateNewTopicDisabled}
+            >Create new topic</gr-button
+          >
+          <gr-button
+            id="apply-topic-button"
+            flatten
+            @click=${() => this.addTopic('Applying topic...')}
+            .disabled=${isApplyTopicDisabled}
+            >Apply</gr-button
+          >
+        </div>
+      </div>
+    `;
+  }
+
+  private toggleDropdown() {
+    if (this.isDropdownOpen) {
+      this.closeDropdown();
+    } else {
+      this.reset();
+      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.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.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, ''))
+    );
+  }
+
+  private applyTopicToAll() {
+    this.loadingText = 'Applying to all';
+    this.trackPromises(
+      this.selectedChanges.map(change =>
+        this.restApiService.setChangeTopic(
+          change._number,
+          Array.from(this.selectedExistingTopics.values())[0]
+        )
+      )
+    );
+  }
+
+  private addTopic(loadingText: string) {
+    this.loadingText = loadingText;
+    this.trackPromises(
+      this.selectedChanges.map(change =>
+        this.restApiService.setChangeTopic(change._number, this.topicToAdd)
+      )
+    );
+  }
+
+  private async trackPromises(promises: Promise<string>[]) {
+    this.overallProgress = ProgressStatus.RUNNING;
+    const results = await allSettled(promises);
+    if (results.every(result => result.status === 'fulfilled')) {
+      this.overallProgress = ProgressStatus.SUCCESSFUL;
+      this.dropdown?.close();
+      this.isDropdownOpen = false;
+      // TODO: fire reload of dashboard
+    } else {
+      this.overallProgress = ProgressStatus.FAILED;
+      // TODO: when some are rejected, show error and Cancel button
+    }
+  }
+
+  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..fd78479
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-topic-flow/gr-change-list-topic-flow_test.ts
@@ -0,0 +1,540 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {fixture, html} from '@open-wc/testing-helpers';
+import {IronDropdownElement} from '@polymer/iron-dropdown';
+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 {createChange} from '../../../test/test-data-generators';
+import {
+  MockPromise,
+  mockPromise,
+  queryAll,
+  queryAndAssert,
+  stubRestApi,
+  waitUntil,
+  waitUntilCalled,
+  waitUntilObserved,
+} from '../../../test/test-utils';
+import {ChangeInfo, NumericChangeId, TopicName} from '../../../types/common';
+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;
+
+  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-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 () => {
+      expect(element).shadowDom.to.equal(/* HTML */ `
+        <gr-button
+          id="start-flow"
+          flatten=""
+          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;
+    }
+
+    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 flush();
+    });
+
+    test('renders existing-topics flow', () => {
+      expect(element).shadowDom.to.equal(
+        /* HTML */ `
+          <gr-button
+            id="start-flow"
+            flatten=""
+            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">
+                <span role="button" aria-label="topic1" class="chip"
+                  >topic1</span
+                >
+                <span role="button" aria-label="topic2" class="chip"
+                  >topic2</span
+                >
+              </div>
+              <div class="footer">
+                <div class="loadingOrError"></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('remove single topic', async () => {
+      queryAll<HTMLSpanElement>(element, 'span.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,
+        '',
+      ]);
+    });
+
+    test('remove multiple topics', async () => {
+      queryAll<HTMLSpanElement>(element, 'span.chip')[0].click();
+      queryAll<HTMLSpanElement>(element, 'span.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('can only apply a single topic', async () => {
+      assert.isTrue(
+        queryAndAssert<GrButton>(element, '#apply-to-all-button').disabled
+      );
+
+      queryAll<HTMLSpanElement>(element, 'span.chip')[0].click();
+      await element.updateComplete;
+
+      assert.isFalse(
+        queryAndAssert<GrButton>(element, '#apply-to-all-button').disabled
+      );
+
+      queryAll<HTMLSpanElement>(element, 'span.chip')[1].click();
+      await element.updateComplete;
+
+      assert.isTrue(
+        queryAndAssert<GrButton>(element, '#apply-to-all-button').disabled
+      );
+    });
+
+    test('applies topic to all changes', async () => {
+      queryAll<HTMLSpanElement>(element, 'span.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',
+      ]);
+    });
+  });
+
+  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;
+    }
+
+    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 flush();
+    });
+
+    test('renders no-existing-topics flow', () => {
+      expect(element).shadowDom.to.equal(
+        /* HTML */ `
+          <gr-button
+            id="start-flow"
+            flatten=""
+            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"></div>
+                <div class="buttons">
+                  <gr-button
+                    id="create-new-topic-button"
+                    flatten=""
+                    aria-disabled="true"
+                    disabled=""
+                    role="button"
+                    tabindex="-1"
+                    >Create new topic</gr-button
+                  >
+                  <gr-button
+                    id="apply-topic-button"
+                    flatten=""
+                    aria-disabled="true"
+                    disabled=""
+                    role="button"
+                    tabindex="-1"
+                    >Apply</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 getTopicsStub = stubRestApi('getChangesWithSimilarTopic').resolves(
+        []
+      );
+      const autocomplete = queryAndAssert<GrAutocomplete>(
+        element,
+        'gr-autocomplete'
+      );
+      autocomplete.focus();
+      autocomplete.text = 'foo';
+      await element.updateComplete;
+      await waitUntilCalled(getTopicsStub, 'getTopicsStub');
+      assert.isTrue(
+        queryAndAssert<GrButton>(element, '#apply-topic-button').disabled
+      );
+
+      queryAndAssert<GrButton>(element, '#create-new-topic-button').click();
+      await element.updateComplete;
+
+      assert.equal(
+        queryAndAssert(element, '.loadingText').textContent,
+        'Creating 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',
+      ]);
+    });
+
+    test('apply topic', async () => {
+      const getTopicsStub = stubRestApi('getChangesWithSimilarTopic').resolves([
+        {...createChange(), topic: 'foo' as TopicName},
+      ]);
+      const autocomplete = queryAndAssert<GrAutocomplete>(
+        element,
+        'gr-autocomplete'
+      );
+
+      autocomplete.focus();
+      autocomplete.text = 'foo';
+      await element.updateComplete;
+      await waitUntilCalled(getTopicsStub, 'getTopicsStub');
+      assert.isTrue(
+        queryAndAssert<GrButton>(element, '#create-new-topic-button').disabled
+      );
+
+      queryAndAssert<GrButton>(element, '#apply-topic-button').click();
+      await element.updateComplete;
+
+      assert.equal(
+        queryAndAssert(element, '.loadingText').textContent,
+        'Applying 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',
+      ]);
+    });
+  });
+});
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..2f5965b 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
@@ -32,7 +32,7 @@
   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';
@@ -45,22 +45,8 @@
 import {Shortcut} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
 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';
 
 export interface ChangeListSection {
   countLabel?: string;
@@ -164,6 +150,8 @@
 
   private readonly restApiService = getAppContext().restApiService;
 
+  private readonly reporting = getAppContext().reportingService;
+
   private readonly shortcuts = new ShortcutController(this);
 
   private cursor = new GrCursorManager();
@@ -289,7 +277,7 @@
       changedProperties.has('config') ||
       changedProperties.has('sections')
     ) {
-      this.computePreferences();
+      this.computeVisibleChangeTableColumns();
     }
 
     if (changedProperties.has('changes')) {
@@ -303,13 +291,13 @@
     }
   }
 
-  private computePreferences() {
+  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 +305,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 +325,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();
   }
 
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..1e81123 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
@@ -27,11 +27,12 @@
   waitUntil,
 } from '../../../test/test-utils';
 import {Key} from '../../../utils/dom-util';
-import {TimeFormat} from '../../../constants/constants';
+import {ColumnNames, TimeFormat} from '../../../constants/constants';
 import {AccountId, NumericChangeId} from '../../../types/common';
 import {
   createChange,
   createServerInfo,
+  createSubmitRequirementResultInfo,
 } from '../../../test/test-data-generators';
 import {GrChangeListItem} from '../gr-change-list-item/gr-change-list-item';
 import {GrChangeListSection} from '../gr-change-list-section/gr-change-list-section';
@@ -165,23 +166,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',
+                },
+              ],
             },
           ],
         },
@@ -207,7 +221,7 @@
         'Branch',
         'Updated',
         'Size',
-        ' Status ',
+        ColumnNames.STATUS2,
       ],
     };
     element.config = createServerInfo();
@@ -363,7 +377,7 @@
           'Branch',
           'Updated',
           'Size',
-          ' Status ',
+          ColumnNames.STATUS2,
         ],
       };
       element.config = createServerInfo();
@@ -401,7 +415,7 @@
           'Branch',
           'Updated',
           'Size',
-          ' Status ',
+          ColumnNames.STATUS2,
         ],
       };
       element.config = createServerInfo();
@@ -419,11 +433,23 @@
         }
       }
     });
+
+    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 () => {
@@ -442,7 +468,7 @@
         'Branch',
         'Updated',
         'Size',
-        ' Status ',
+        ColumnNames.STATUS2,
       ],
     };
     element.config = createServerInfo();
@@ -492,4 +518,24 @@
       assert.isNotOk(query<HTMLElement>(element, '.bad'));
     });
   });
+
+  test('Show new status with feature flag', async () => {
+    stubFlags('isEnabled').returns(true);
+    element = basicFixture.instantiate();
+    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/gr-change-metadata/gr-change-metadata.ts b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts
index 9af14f5..9df22f1 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts
@@ -29,7 +29,6 @@
 import '../../shared/gr-linked-chip/gr-linked-chip';
 import '../../shared/gr-tooltip-content/gr-tooltip-content';
 import '../gr-submit-requirements/gr-submit-requirements';
-import '../gr-change-requirements/gr-change-requirements';
 import '../gr-commit-info/gr-commit-info';
 import '../gr-reviewer-list/gr-reviewer-list';
 import '../../shared/gr-account-list/gr-account-list';
@@ -83,11 +82,7 @@
 } from '../../shared/gr-autocomplete/gr-autocomplete';
 import {getRevertCreatedChangeIds} from '../../../utils/message-util';
 import {Interaction} from '../../../constants/reporting';
-import {
-  getApprovalInfo,
-  getCodeReviewLabel,
-  showNewSubmitRequirements,
-} from '../../../utils/label-util';
+import {getApprovalInfo, getCodeReviewLabel} from '../../../utils/label-util';
 import {LitElement, css, html, nothing, PropertyValues} from 'lit';
 import {customElement, property, query, state} from 'lit/decorators';
 import {sharedStyles} from '../../../styles/shared-styles';
@@ -173,15 +168,16 @@
 
   @state() private queryTopic?: AutocompleteQuery;
 
+  @state() private queryHashtag?: AutocompleteQuery;
+
   private restApiService = getAppContext().restApiService;
 
   private readonly reporting = getAppContext().reportingService;
 
-  private readonly flagsService = getAppContext().flagsService;
-
   constructor() {
     super();
     this.queryTopic = (input: string) => this.getTopicSuggestions(input);
+    this.queryHashtag = (input: string) => this.getHashtagSuggestions(input);
   }
 
   static override styles = [
@@ -192,7 +188,6 @@
       :host {
         display: table;
       }
-      gr-change-requirements,
       gr-submit-requirements {
         --requirements-horizontal-padding: var(--metadata-horizontal-padding);
       }
@@ -689,6 +684,8 @@
               .readOnly=${this.hashtagReadOnly}
               @changed=${this.handleHashtagChanged}
               showAsEditPencil
+              autocomplete
+              .query=${this.queryHashtag}
             ></gr-editable-label>
           `
         )}
@@ -697,23 +694,13 @@
   }
 
   private renderSubmitRequirements() {
-    if (this.showNewSubmitRequirements()) {
-      return html`<div class="separatedSection">
-        <gr-submit-requirements
-          .change=${this.change}
-          .account=${this.account}
-          .mutable=${this.mutable}
-        ></gr-submit-requirements>
-      </div>`;
-    } else {
-      return html` <div class="oldSeparatedSection">
-        <gr-change-requirements
-          .change=${this.change}
-          .account=${this.account}
-          .mutable=${this.mutable}
-        ></gr-change-requirements>
-      </div>`;
-    }
+    return html`<div class="separatedSection">
+      <gr-submit-requirements
+        .change=${this.change}
+        .account=${this.account}
+        .mutable=${this.mutable}
+      ></gr-submit-requirements>
+    </div>`;
   }
 
   private renderWeblinks() {
@@ -1192,8 +1179,20 @@
       );
   }
 
-  private showNewSubmitRequirements() {
-    return showNewSubmitRequirements(this.flagsService, this.change);
+  private getHashtagSuggestions(
+    input: string
+  ): Promise<AutocompleteSuggestion[]> {
+    return this.restApiService
+      .getChangesWithSimilarHashtag(input)
+      .then(response =>
+        (response ?? [])
+          .flatMap(change => change.hashtags ?? [])
+          .filter(notUndefined)
+          .filter(unique)
+          .map(hashtag => {
+            return {name: hashtag, value: hashtag};
+          })
+      );
   }
 
   private computeVoteForRole(role: ChangeRole) {
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.ts b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.ts
index 2b48697..a383cb1 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.ts
@@ -199,8 +199,8 @@
         <span class="title"> Hashtags </span>
         <span class="value"> </span>
       </section>
-      <div class="oldSeparatedSection">
-      <gr-change-requirements></gr-change-requirements>
+      <div class="separatedSection">
+      <gr-submit-requirements></gr-submit-requirements>
       </div>
       <gr-endpoint-decorator name="change-metadata-item">
         <gr-endpoint-param name="labels"> </gr-endpoint-param>
diff --git a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.ts b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.ts
deleted file mode 100644
index 821e1ce..0000000
--- a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.ts
+++ /dev/null
@@ -1,208 +0,0 @@
-/**
- * @license
- * Copyright (C) 2018 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../../styles/shared-styles';
-import '../../../styles/gr-font-styles';
-import '../../shared/gr-button/gr-button';
-import '../../shared/gr-icons/gr-icons';
-import '../../shared/gr-label-info/gr-label-info';
-import '../../shared/gr-limited-text/gr-limited-text';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-change-requirements_html';
-import {customElement, property, observe} from '@polymer/decorators';
-import {
-  ChangeInfo,
-  AccountInfo,
-  QuickLabelInfo,
-  Requirement,
-  RequirementType,
-  LabelNameToInfoMap,
-  LabelInfo,
-} from '../../../types/common';
-import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
-import {getAppContext} from '../../../services/app-context';
-import {labelCompare} from '../../../utils/label-util';
-import {Interaction} from '../../../constants/reporting';
-
-interface ChangeRequirement extends Requirement {
-  satisfied: boolean;
-  style: string;
-}
-
-interface ChangeWIP {
-  type: RequirementType;
-  fallback_text: string;
-  tooltip: string;
-}
-
-export interface Label {
-  labelName: string;
-  labelInfo: LabelInfo;
-  icon: string;
-  style: string;
-}
-
-@customElement('gr-change-requirements')
-export class GrChangeRequirements extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
-  @property({type: Object})
-  change?: ChangeInfo;
-
-  @property({type: Object})
-  account?: AccountInfo;
-
-  @property({type: Boolean})
-  mutable?: boolean;
-
-  @property({type: Array, computed: '_computeRequirements(change)'})
-  _requirements?: Array<ChangeRequirement | ChangeWIP>;
-
-  @property({type: Array})
-  _requiredLabels: Label[] = [];
-
-  @property({type: Array})
-  _optionalLabels: Label[] = [];
-
-  @property({type: Boolean, computed: '_computeShowWip(change)'})
-  _showWip?: boolean;
-
-  @property({type: Boolean})
-  _showOptionalLabels = true;
-
-  private readonly reporting = getAppContext().reportingService;
-
-  _computeShowWip(change: ChangeInfo) {
-    return change.work_in_progress;
-  }
-
-  _computeRequirements(change: ChangeInfo) {
-    const _requirements: Array<ChangeRequirement | ChangeWIP> = [];
-
-    if (change.requirements) {
-      for (const requirement of change.requirements) {
-        const satisfied = requirement.status === 'OK';
-        const style = this._computeRequirementClass(satisfied);
-        _requirements.push({...requirement, satisfied, style});
-      }
-    }
-    if (change.work_in_progress) {
-      _requirements.push({
-        type: 'wip' as RequirementType,
-        fallback_text: 'Work-in-progress',
-        tooltip: "Change must not be in 'Work in Progress' state.",
-      });
-    }
-
-    return _requirements;
-  }
-
-  _computeRequirementClass(requirementStatus: boolean) {
-    return requirementStatus ? 'approved' : '';
-  }
-
-  _computeRequirementIcon(requirementStatus: boolean) {
-    return requirementStatus ? 'gr-icons:check' : 'gr-icons:schedule';
-  }
-
-  @observe('change.labels.*')
-  _computeLabels(
-    labelsRecord: PolymerDeepPropertyChange<
-      LabelNameToInfoMap,
-      LabelNameToInfoMap
-    >
-  ) {
-    const labels = labelsRecord.base || {};
-    const allLabels: Label[] = [];
-
-    for (const label of Object.keys(labels).sort(labelCompare)) {
-      allLabels.push({
-        labelName: label,
-        icon: this._computeLabelIcon(labels[label]),
-        style: this._computeLabelClass(labels[label]),
-        labelInfo: labels[label],
-      });
-    }
-    this._optionalLabels = allLabels.filter(label => label.labelInfo.optional);
-    this._requiredLabels = allLabels.filter(label => !label.labelInfo.optional);
-  }
-
-  /**
-   * @return The icon name, or undefined if no icon should
-   * be used.
-   */
-  _computeLabelIcon(labelInfo: QuickLabelInfo) {
-    if (labelInfo.approved) {
-      return 'gr-icons:check';
-    }
-    if (labelInfo.rejected) {
-      return 'gr-icons:close';
-    }
-    return 'gr-icons:schedule';
-  }
-
-  _computeLabelClass(labelInfo: QuickLabelInfo) {
-    if (labelInfo.approved) {
-      return 'approved';
-    }
-    if (labelInfo.rejected) {
-      return 'rejected';
-    }
-    return '';
-  }
-
-  _computeShowOptional(
-    optionalFieldsRecord: PolymerDeepPropertyChange<Label[], Label[]>
-  ) {
-    return optionalFieldsRecord.base.length ? '' : 'hidden';
-  }
-
-  _computeLabelValue(value: number) {
-    return `${value > 0 ? '+' : ''}${value}`;
-  }
-
-  _computeSectionClass(show: boolean) {
-    return show ? '' : 'hidden';
-  }
-
-  _handleShowHide() {
-    this._showOptionalLabels = !this._showOptionalLabels;
-    this.reporting.reportInteraction(Interaction.TOGGLE_SHOW_ALL_BUTTON, {
-      sectionName: 'optional labels',
-      toState: this._showOptionalLabels ? 'Show all' : 'Show less',
-    });
-  }
-
-  _computeSubmitRequirementEndpoint(item: ChangeRequirement | ChangeWIP) {
-    return `submit-requirement-item-${item.type}`;
-  }
-
-  _computeShowAllLabelText(_showOptionalLabels: boolean) {
-    if (_showOptionalLabels) {
-      return 'Show less';
-    } else {
-      return 'Show all';
-    }
-  }
-}
-
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-change-requirements': GrChangeRequirements;
-  }
-}
diff --git a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_html.ts b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_html.ts
deleted file mode 100644
index 8161592..0000000
--- a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_html.ts
+++ /dev/null
@@ -1,214 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="gr-font-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="shared-styles">
-    :host {
-      display: table;
-      width: 100%;
-    }
-    .status {
-      color: var(--warning-foreground);
-      display: inline-block;
-      text-align: center;
-      vertical-align: top;
-      font-family: var(--monospace-font-family);
-      font-size: var(--font-size-mono);
-      line-height: var(--line-height-mono);
-    }
-    .approved.status {
-      color: var(--positive-green-text-color);
-    }
-    .rejected.status {
-      color: var(--negative-red-text-color);
-    }
-    iron-icon {
-      color: inherit;
-    }
-    .status iron-icon {
-      vertical-align: top;
-    }
-    gr-endpoint-decorator.submit-requirement-endpoints,
-    section {
-      display: table-row;
-    }
-    .show-hide {
-      float: right;
-    }
-    .title {
-      min-width: 10em;
-      padding: var(--spacing-s) var(--spacing-m) 0
-        var(--requirements-horizontal-padding);
-    }
-    .value {
-      padding: var(--spacing-s) 0 0 0;
-    }
-    .title,
-    .value {
-      display: table-cell;
-      vertical-align: top;
-    }
-    .hidden {
-      display: none;
-    }
-    .showHide {
-      cursor: pointer;
-    }
-    .showHide .title {
-      padding-bottom: var(--spacing-m);
-      padding-top: var(--spacing-l);
-    }
-    .showHide .value {
-      padding-top: 0;
-      vertical-align: middle;
-    }
-    .showHide iron-icon {
-      color: var(--deemphasized-text-color);
-      float: right;
-    }
-    .show-all-button {
-      float: right;
-    }
-    .show-all-button iron-icon {
-      color: inherit;
-      --iron-icon-height: 18px;
-      --iron-icon-width: 18px;
-    }
-    .spacer {
-      height: var(--spacing-m);
-    }
-    gr-endpoint-param {
-      display: none;
-    }
-    .metadata-title {
-      font-weight: var(--font-weight-bold);
-      color: var(--deemphasized-text-color);
-      padding-left: var(--metadata-horizontal-padding);
-    }
-    .title .metadata-title {
-      padding-left: 0;
-    }
-  </style>
-  <h3 class="metadata-title heading-3">Submit requirements</h3>
-  <template is="dom-repeat" items="[[_requirements]]">
-    <gr-endpoint-decorator
-      class="submit-requirement-endpoints"
-      name$="[[_computeSubmitRequirementEndpoint(item)]]"
-    >
-      <gr-endpoint-param name="change" value="[[change]]"></gr-endpoint-param>
-      <gr-endpoint-param name="requirement" value="[[item]]">
-      </gr-endpoint-param>
-      <div class="title requirement">
-        <span class$="status [[item.style]]">
-          <iron-icon
-            class="icon"
-            icon="[[_computeRequirementIcon(item.satisfied)]]"
-          ></iron-icon>
-        </span>
-        <gr-limited-text
-          class="name"
-          limit="25"
-          tooltip="[[item.tooltip]]"
-          text="[[item.fallback_text]]"
-        ></gr-limited-text>
-      </div>
-      <div class="value">
-        <gr-endpoint-slot name="value"></gr-endpoint-slot>
-      </div>
-    </gr-endpoint-decorator>
-  </template>
-  <template is="dom-repeat" items="[[_requiredLabels]]">
-    <section>
-      <div class="title">
-        <span class$="status [[item.style]]">
-          <iron-icon class="icon" icon="[[item.icon]]"></iron-icon>
-        </span>
-        <gr-limited-text
-          class="name"
-          limit="25"
-          text="[[item.labelName]]"
-        ></gr-limited-text>
-      </div>
-      <div class="value">
-        <gr-label-info
-          change="{{change}}"
-          account="[[account]]"
-          mutable="[[mutable]]"
-          label="[[item.labelName]]"
-          label-info="[[item.labelInfo]]"
-        ></gr-label-info>
-      </div>
-    </section>
-  </template>
-  <section class="spacer"></section>
-  <section
-    class$="spacer [[_computeShowOptional(_optionalLabels.*)]]"
-  ></section>
-  <section class$="showHide [[_computeShowOptional(_optionalLabels.*)]]">
-    <div class="title">
-      <h3 class="metadata-title">Other labels</h3>
-    </div>
-    <div class="value">
-      <gr-button link="" class="show-all-button" on-click="_handleShowHide"
-        >[[_computeShowAllLabelText(_showOptionalLabels)]]
-        <iron-icon
-          icon="gr-icons:expand-more"
-          hidden$="[[_showOptionalLabels]]"
-        ></iron-icon
-        ><iron-icon
-          icon="gr-icons:expand-less"
-          hidden$="[[!_showOptionalLabels]]"
-        ></iron-icon>
-      </gr-button>
-    </div>
-  </section>
-  <template is="dom-repeat" items="[[_optionalLabels]]">
-    <section class$="optional [[_computeSectionClass(_showOptionalLabels)]]">
-      <div class="title">
-        <span class$="status [[item.style]]">
-          <template is="dom-if" if="[[item.icon]]">
-            <iron-icon class="icon" icon="[[item.icon]]"></iron-icon>
-          </template>
-          <template is="dom-if" if="[[!item.icon]]">
-            <span>[[_computeLabelValue(item.labelInfo.value)]]</span>
-          </template>
-        </span>
-        <gr-limited-text
-          class="name"
-          limit="25"
-          text="[[item.labelName]]"
-        ></gr-limited-text>
-      </div>
-      <div class="value">
-        <gr-label-info
-          change="{{change}}"
-          account="[[account]]"
-          mutable="[[mutable]]"
-          label="[[item.labelName]]"
-          label-info="[[item.labelInfo]]"
-        ></gr-label-info>
-      </div>
-    </section>
-  </template>
-  <section
-    class$="spacer [[_computeShowOptional(_optionalLabels.*)]] [[_computeSectionClass(_showOptionalLabels)]]"
-  ></section>
-`;
diff --git a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_test.js b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_test.js
deleted file mode 100644
index 90f9d29..0000000
--- a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_test.js
+++ /dev/null
@@ -1,222 +0,0 @@
-/**
- * @license
- * Copyright (C) 2018 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import './gr-change-requirements.js';
-import {isHidden} from '../../../test/test-utils.js';
-
-const basicFixture = fixtureFromElement('gr-change-requirements');
-
-suite('gr-change-metadata tests', () => {
-  let element;
-
-  setup(() => {
-    element = basicFixture.instantiate();
-  });
-
-  test('requirements computed fields', () => {
-    assert.isTrue(element._computeShowWip({work_in_progress: true}));
-    assert.isFalse(element._computeShowWip({work_in_progress: false}));
-
-    assert.equal(element._computeRequirementClass(true), 'approved');
-    assert.equal(element._computeRequirementClass(false), '');
-
-    assert.equal(element._computeRequirementIcon(true), 'gr-icons:check');
-    assert.equal(element._computeRequirementIcon(false),
-        'gr-icons:schedule');
-  });
-
-  test('label computed fields', () => {
-    assert.equal(element._computeLabelIcon({approved: []}), 'gr-icons:check');
-    assert.equal(element._computeLabelIcon({rejected: []}), 'gr-icons:close');
-    assert.equal(element._computeLabelIcon({}), 'gr-icons:schedule');
-
-    assert.equal(element._computeLabelClass({approved: []}), 'approved');
-    assert.equal(element._computeLabelClass({rejected: []}), 'rejected');
-    assert.equal(element._computeLabelClass({}), '');
-    assert.equal(element._computeLabelClass({value: 0}), '');
-
-    assert.equal(element._computeLabelValue(1), '+1');
-    assert.equal(element._computeLabelValue(-1), '-1');
-    assert.equal(element._computeLabelValue(0), '0');
-  });
-
-  test('_computeLabels', () => {
-    assert.equal(element._optionalLabels.length, 0);
-    assert.equal(element._requiredLabels.length, 0);
-    element._computeLabels({base: {
-      test: {
-        all: [{_account_id: 1, name: 'bojack', value: 1}],
-        default_value: 0,
-        values: [],
-        value: 1,
-      },
-      opt_test: {
-        all: [{_account_id: 1, name: 'bojack', value: 1}],
-        default_value: 0,
-        values: [],
-        optional: true,
-      },
-    }});
-    assert.equal(element._optionalLabels.length, 1);
-    assert.equal(element._requiredLabels.length, 1);
-
-    assert.equal(element._optionalLabels[0].labelName, 'opt_test');
-    assert.equal(element._optionalLabels[0].icon, 'gr-icons:schedule');
-    assert.equal(element._optionalLabels[0].style, '');
-    assert.ok(element._optionalLabels[0].labelInfo);
-  });
-
-  test('optional show/hide', () => {
-    element._optionalLabels = [{label: 'test'}];
-    flush();
-
-    assert.ok(element.shadowRoot
-        .querySelector('section.optional'));
-    MockInteractions.tap(element.shadowRoot
-        .querySelector('.show-all-button'));
-    flush();
-
-    assert.isFalse(element._showOptionalLabels);
-    assert.isTrue(isHidden(element.shadowRoot
-        .querySelector('section.optional')));
-  });
-
-  test('properly converts satisfied labels', () => {
-    element.change = {
-      status: 'NEW',
-      labels: {
-        Verified: {
-          approved: [],
-        },
-      },
-      requirements: [],
-    };
-    flush();
-
-    assert.ok(element.shadowRoot
-        .querySelector('.approved'));
-    assert.ok(element.shadowRoot
-        .querySelector('.name'));
-    assert.equal(element.shadowRoot
-        .querySelector('.name').text, 'Verified');
-  });
-
-  test('properly converts unsatisfied labels', () => {
-    element.change = {
-      status: 'NEW',
-      labels: {
-        Verified: {
-          approved: false,
-        },
-      },
-    };
-    flush();
-
-    const name = element.shadowRoot
-        .querySelector('.name');
-    assert.ok(name);
-    assert.isFalse(name.hasAttribute('hidden'));
-    assert.equal(name.text, 'Verified');
-  });
-
-  test('properly displays Work In Progress', () => {
-    element.change = {
-      status: 'NEW',
-      labels: {},
-      requirements: [],
-      work_in_progress: true,
-    };
-    flush();
-
-    const changeIsWip = element.shadowRoot
-        .querySelector('.title');
-    assert.ok(changeIsWip);
-  });
-
-  test('properly displays a satisfied requirement', () => {
-    element.change = {
-      status: 'NEW',
-      labels: {},
-      requirements: [{
-        fallback_text: 'Resolve all comments',
-        status: 'OK',
-      }],
-    };
-    flush();
-
-    const requirement = element.shadowRoot
-        .querySelector('.requirement');
-    assert.ok(requirement);
-    assert.isFalse(requirement.hasAttribute('hidden'));
-    assert.ok(requirement.querySelector('.approved'));
-    assert.equal(requirement.querySelector('.name').text,
-        'Resolve all comments');
-  });
-
-  test('satisfied class is applied with OK', () => {
-    element.change = {
-      status: 'NEW',
-      labels: {},
-      requirements: [{
-        fallback_text: 'Resolve all comments',
-        status: 'OK',
-      }],
-    };
-    flush();
-
-    const requirement = element.shadowRoot
-        .querySelector('.requirement');
-    assert.ok(requirement);
-    assert.ok(requirement.querySelector('.approved'));
-  });
-
-  test('satisfied class is not applied with NOT_READY', () => {
-    element.change = {
-      status: 'NEW',
-      labels: {},
-      requirements: [{
-        fallback_text: 'Resolve all comments',
-        status: 'NOT_READY',
-      }],
-    };
-    flush();
-
-    const requirement = element.shadowRoot
-        .querySelector('.requirement');
-    assert.ok(requirement);
-    assert.strictEqual(requirement.querySelector('.approved'), null);
-  });
-
-  test('satisfied class is not applied with RULE_ERROR', () => {
-    element.change = {
-      status: 'NEW',
-      labels: {},
-      requirements: [{
-        fallback_text: 'Resolve all comments',
-        status: 'RULE_ERROR',
-      }],
-    };
-    flush();
-
-    const requirement = element.shadowRoot
-        .querySelector('.requirement');
-    assert.ok(requirement);
-    assert.strictEqual(requirement.querySelector('.approved'), null);
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts b/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts
index f5893ac..6ec815c 100644
--- a/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts
@@ -430,54 +430,58 @@
 
   private readonly reporting = getAppContext().reportingService;
 
-  override connectedCallback() {
-    super.connectedCallback();
+  constructor() {
+    super();
     subscribe(
       this,
-      this.getChecksModel().allRunsLatestPatchsetLatestAttempt$,
+      () => this.getChecksModel().allRunsLatestPatchsetLatestAttempt$,
       x => (this.runs = x)
     );
     subscribe(
       this,
-      this.getChecksModel().aPluginHasRegistered$,
+      () => this.getChecksModel().aPluginHasRegistered$,
       x => (this.showChecksSummary = x)
     );
     subscribe(
       this,
-      this.getChecksModel().someProvidersAreLoadingFirstTime$,
+      () => this.getChecksModel().someProvidersAreLoadingFirstTime$,
       x => (this.someProvidersAreLoading = x)
     );
     subscribe(
       this,
-      this.getChecksModel().errorMessagesLatest$,
+      () => this.getChecksModel().errorMessagesLatest$,
       x => (this.errorMessages = x)
     );
     subscribe(
       this,
-      this.getChecksModel().loginCallbackLatest$,
+      () => this.getChecksModel().loginCallbackLatest$,
       x => (this.loginCallback = x)
     );
     subscribe(
       this,
-      this.getChecksModel().topLevelActionsLatest$,
+      () => this.getChecksModel().topLevelActionsLatest$,
       x => (this.actions = x)
     );
     subscribe(
       this,
-      this.getChecksModel().topLevelMessagesLatest$,
+      () => this.getChecksModel().topLevelMessagesLatest$,
       x => (this.messages = x)
     );
     subscribe(
       this,
-      this.getCommentsModel().changeComments$,
+      () => this.getCommentsModel().changeComments$,
       x => (this.changeComments = x)
     );
     subscribe(
       this,
-      this.getCommentsModel().threads$,
+      () => this.getCommentsModel().threads$,
       x => (this.commentThreads = x)
     );
-    subscribe(this, this.userModel.account$, x => (this.selfAccount = x));
+    subscribe(
+      this,
+      () => this.userModel.account$,
+      x => (this.selfAccount = x)
+    );
   }
 
   static override get styles() {
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
index ca2e4da..3db1012 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
@@ -633,6 +633,12 @@
 
   private connected$ = new BehaviorSubject(false);
 
+  /**
+   * For `connectedCallback()` to distinguish between connecting to the DOM for
+   * the first time or if just re-connecting.
+   */
+  private isFirstConnection = true;
+
   /** Simply reflects the router-model value. */
   // visible for testing
   routerPatchNum?: PatchSetNum;
@@ -649,12 +655,29 @@
       'fullscreen-overlay-opened',
       () => this._handleHideBackgroundContent()
     );
-
     this.addEventListener('fullscreen-overlay-closed', () =>
       this._handleShowBackgroundContent()
     );
-
     this.addEventListener('open-reply-dialog', () => this._openReplyDialog());
+    this.addEventListener('change-message-deleted', () => fireReload(this));
+    this.addEventListener('editable-content-save', e =>
+      this._handleCommitMessageSave(e)
+    );
+    this.addEventListener('editable-content-cancel', () =>
+      this._handleCommitMessageCancel()
+    );
+    this.addEventListener('open-fix-preview', e => this._onOpenFixPreview(e));
+    this.addEventListener('close-fix-preview', e => this._onCloseFixPreview(e));
+
+    this.addEventListener(EventType.SHOW_PRIMARY_TAB, e =>
+      this._setActivePrimaryTab(e)
+    );
+    this.addEventListener('reload', e => {
+      this.loadData(
+        /* isLocationChange= */ false,
+        /* clearPatchset= */ e.detail && e.detail.clearPatchset
+      );
+    });
   }
 
   private setupSubscriptions() {
@@ -699,24 +722,23 @@
 
   override connectedCallback() {
     super.connectedCallback();
+    this.firstConnectedCallback();
     this.connected$.next(true);
-    this.setupSubscriptions();
-    this._throttledToggleChangeStar = throttleWrap<KeyboardEvent>(_ =>
-      this._handleToggleChangeStar()
-    );
-    this._getServerConfig().then(config => {
-      this._serverConfig = config;
-      this._replyDisabled = false;
-    });
 
-    this._getLoggedIn().then(loggedIn => {
-      this._loggedIn = loggedIn;
-      if (loggedIn) {
-        this.restApiService.getAccount().then(acct => {
-          this._account = acct;
-        });
-      }
-    });
+    // Make sure to reverse everything below this line in disconnectedCallback().
+    // Or consider using either firstConnectedCallback() or constructor().
+    this.setupSubscriptions();
+    document.addEventListener('visibilitychange', this.handleVisibilityChange);
+    document.addEventListener('scroll', this.handleScroll);
+  }
+
+  /**
+   * For initialization that should only happen once, not again when
+   * re-connecting to the DOM later.
+   */
+  private firstConnectedCallback() {
+    if (!this.isFirstConnection) return;
+    this.isFirstConnection = false;
 
     getPluginLoader()
       .awaitPluginsLoaded()
@@ -734,26 +756,21 @@
       })
       .then(() => this._initActiveTabs(this.params));
 
-    this.addEventListener('change-message-deleted', () => fireReload(this));
-    this.addEventListener('editable-content-save', e =>
-      this._handleCommitMessageSave(e)
+    this._throttledToggleChangeStar = throttleWrap<KeyboardEvent>(_ =>
+      this._handleToggleChangeStar()
     );
-    this.addEventListener('editable-content-cancel', () =>
-      this._handleCommitMessageCancel()
-    );
-    this.addEventListener('open-fix-preview', e => this._onOpenFixPreview(e));
-    this.addEventListener('close-fix-preview', e => this._onCloseFixPreview(e));
-    document.addEventListener('visibilitychange', this.handleVisibilityChange);
-    document.addEventListener('scroll', this.handleScroll);
+    this._getServerConfig().then(config => {
+      this._serverConfig = config;
+      this._replyDisabled = false;
+    });
 
-    this.addEventListener(EventType.SHOW_PRIMARY_TAB, e =>
-      this._setActivePrimaryTab(e)
-    );
-    this.addEventListener('reload', e => {
-      this.loadData(
-        /* isLocationChange= */ false,
-        /* clearPatchset= */ e.detail && e.detail.clearPatchset
-      );
+    this._getLoggedIn().then(loggedIn => {
+      this._loggedIn = loggedIn;
+      if (loggedIn) {
+        this.restApiService.getAccount().then(acct => {
+          this._account = acct;
+        });
+      }
     });
   }
 
@@ -1815,7 +1832,7 @@
   _openReplyDialog(focusTarget?: FocusTarget, quote?: string) {
     if (!this._change) return;
     const overlay = this.$.replyOverlay;
-    overlay.open().finally(async () => {
+    overlay.open().finally(() => {
       // the following code should be executed no matter open succeed or not
       const dialog = query<GrReplyDialog>(this, '#replyDialog');
       assertIsDefined(dialog, 'reply dialog');
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.ts
index 8ea2bf5..8ead6a0 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.ts
@@ -20,7 +20,7 @@
 import {LitElement, html, css, nothing} from 'lit';
 import {customElement, state} from 'lit/decorators';
 import {ChangeInfo, CommitId} from '../../../types/common';
-import {fireAlert} from '../../../utils/event-util';
+import {fire, fireAlert} from '../../../utils/event-util';
 import {getAppContext} from '../../../services/app-context';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {BindValueChangeEvent} from '../../../types/events';
@@ -40,20 +40,23 @@
   message?: string;
 }
 
+export interface CancelRevertEventDetail {
+  revertType: RevertType;
+}
+
+declare global {
+  interface HTMLElementEventMap {
+    /** Fired when the confirm button is pressed. */
+    // prettier-ignore
+    'confirm': CustomEvent<ConfirmRevertEventDetail>;
+    /** Fired when the cancel button is pressed. */
+    // prettier-ignore
+    'cancel': CustomEvent<CancelRevertEventDetail>;
+  }
+}
+
 @customElement('gr-confirm-revert-dialog')
 export class GrConfirmRevertDialog extends LitElement {
-  /**
-   * Fired when the confirm button is pressed.
-   *
-   * @event confirm
-   */
-
-  /**
-   * Fired when the cancel button is pressed.
-   *
-   * @event cancel
-   */
-
   /* The revert message updated by the user
       The default value is set by the dialog */
   @state()
@@ -311,25 +314,16 @@
       revertType: this.revertType,
       message: this.message,
     };
-    this.dispatchEvent(
-      new CustomEvent('confirm', {
-        detail,
-        composed: true,
-        bubbles: false,
-      })
-    );
+    fire(this, 'confirm', detail);
   }
 
   private handleCancelTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
-    this.dispatchEvent(
-      new CustomEvent('cancel', {
-        detail: {revertType: this.revertType},
-        composed: true,
-        bubbles: false,
-      })
-    );
+    const detail: ConfirmRevertEventDetail = {
+      revertType: this.revertType,
+    };
+    fire(this, 'cancel', detail);
   }
 }
 
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.ts
index de9395f..b8d4761 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.ts
@@ -93,12 +93,16 @@
     ];
   }
 
-  override connectedCallback() {
-    super.connectedCallback();
-    subscribe(this, this.getChangeModel().change$, x => (this.change = x));
+  constructor() {
+    super();
     subscribe(
       this,
-      this.getCommentsModel().threads$,
+      () => this.getChangeModel().change$,
+      x => (this.change = x)
+    );
+    subscribe(
+      this,
+      () => this.getCommentsModel().threads$,
       x => (this.unresolvedThreads = x.filter(isUnresolved))
     );
   }
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.ts b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.ts
index 8d82e41..66df866 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.ts
@@ -17,11 +17,9 @@
 import '../../../test/common-test-setup-karma';
 import '../../shared/gr-date-formatter/gr-date-formatter';
 import './gr-file-list';
-import {createCommentApiMockWithTemplateElement} from '../../../test/mocks/comment-api';
 import {FilesExpandedState} from '../gr-file-list-constants';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {runA11yAudit} from '../../../test/a11y-test-utils';
-import {html} from '@polymer/polymer/lib/utils/html-tag';
 import {
   listenOnce,
   mockPromise,
@@ -61,12 +59,7 @@
 import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
 import {GrEditFileControls} from '../../edit/gr-edit-file-controls/gr-edit-file-controls';
 
-const commentApiMock = createCommentApiMockWithTemplateElement(
-  'gr-file-list-comment-api-mock',
-  html` <gr-file-list id="fileList"></gr-file-list> `
-);
-
-const basicFixture = fixtureFromElement(commentApiMock.is);
+const basicFixture = fixtureFromElement('gr-file-list');
 
 suite('gr-diff a11y test', () => {
   test('audit', async () => {
@@ -85,7 +78,6 @@
 
 suite('gr-file-list tests', () => {
   let element: GrFileList;
-  let commentApiWrapper: any;
 
   let saveStub: sinon.SinonStub;
 
@@ -103,8 +95,7 @@
 
       // Element must be wrapped in an element with direct access to the
       // comment API.
-      commentApiWrapper = basicFixture.instantiate();
-      element = commentApiWrapper.$.fileList;
+      element = basicFixture.instantiate();
 
       element._loading = false;
       element.diffPrefs = {} as DiffPreferencesInfo;
@@ -1976,8 +1967,7 @@
 
       // Element must be wrapped in an element with direct access to the
       // comment API.
-      commentApiWrapper = basicFixture.instantiate();
-      element = commentApiWrapper.$.fileList;
+      element = basicFixture.instantiate();
       element.diffPrefs = {} as DiffPreferencesInfo;
       element.change = {
         ...createParsedChange(),
diff --git a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.ts b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.ts
index e2852f6..6bd3f62 100644
--- a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.ts
+++ b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.ts
@@ -27,9 +27,6 @@
   DetailedLabelInfo,
 } from '../../../types/common';
 import {assertIsDefined, hasOwnProperty} from '../../../utils/common-util';
-import {getAppContext} from '../../../services/app-context';
-import {KnownExperimentId} from '../../../services/flags/flags';
-import {classMap} from 'lit/directives/class-map';
 import {Label} from '../../../utils/label-util';
 import {LabelNameToValuesMap} from '../../../api/rest-api';
 
@@ -68,12 +65,6 @@
   @state()
   private selectedValueText = 'No value selected';
 
-  private readonly flagsService = getAppContext().flagsService;
-
-  private readonly isSubmitRequirementsUiEnabled = this.flagsService.isEnabled(
-    KnownExperimentId.SUBMIT_REQUIREMENTS_UI
-  );
-
   static override get styles() {
     return [
       sharedStyles,
@@ -86,9 +77,7 @@
         }
         /* We want the :hover highlight to extend to the border of the dialog. */
         .labelNameCell {
-          padding-left: var(--spacing-xl);
-        }
-        .labelNameCell.newSubmitRequirements {
+          padding-left: var(--label-score-padding-left, 0);
           width: 160px;
         }
         .selectedValueCell {
@@ -100,9 +89,6 @@
           white-space: nowrap;
         }
         .selectedValueCell {
-          width: 75%;
-        }
-        .selectedValueCell.newSubmitRequirements {
           width: 52%;
         }
         .labelMessage {
@@ -175,13 +161,7 @@
 
   override render() {
     return html`
-      <span
-        class=${classMap({
-          labelNameCell: true,
-          newSubmitRequirements: this.isSubmitRequirementsUiEnabled,
-        })}
-        id="labelName"
-        aria-hidden="true"
+      <span class="labelNameCell" id="labelName" aria-hidden="true"
         >${this.label?.name ?? ''}</span
       >
       ${this.renderButtonsCell()} ${this.renderSelectedValue()}
@@ -257,12 +237,7 @@
 
   private renderSelectedValue() {
     return html`
-      <div
-        class=${classMap({
-          selectedValueCell: true,
-          newSubmitRequirements: this.isSubmitRequirementsUiEnabled,
-        })}
-      >
+      <div class="selectedValueCell">
         <span id="selectedValueLabel">${this.selectedValueText}</span>
       </div>
     `;
diff --git a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.ts b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.ts
index dd2a83e..8e757da 100644
--- a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.ts
+++ b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.ts
@@ -24,10 +24,8 @@
   LabelNameToValueMap,
 } from '../../../types/common';
 import {GrLabelScoreRow} from '../gr-label-score-row/gr-label-score-row';
-import {getAppContext} from '../../../services/app-context';
 import {
   getTriggerVotes,
-  showNewSubmitRequirements,
   computeLabels,
   Label,
   computeOrderedLabelValues,
@@ -48,8 +46,6 @@
   @property({type: Object})
   account?: AccountInfo;
 
-  private readonly flagsService = getAppContext().flagsService;
-
   static override get styles() {
     return [
       fontStyles,
@@ -57,8 +53,6 @@
         .scoresTable {
           display: table;
           width: 100%;
-        }
-        .scoresTable.newSubmitRequirements {
           table-layout: fixed;
         }
         .mergedMessage,
@@ -70,7 +64,7 @@
         .permissionMessage {
           width: 100%;
           color: var(--deemphasized-text-color);
-          padding-left: var(--spacing-xl);
+          padding-left: var(--label-score-padding-left, 0);
         }
         gr-label-score-row:hover {
           background-color: var(--hover-background-color);
@@ -78,12 +72,12 @@
         gr-label-score-row {
           display: table-row;
         }
-        .heading-3 {
-          padding-left: var(--spacing-xl);
-          margin-bottom: var(--spacing-m);
+        .heading-4 {
+          padding-left: var(--label-score-padding-left, 0);
+          margin-bottom: var(--spacing-s);
           margin-top: var(--spacing-l);
         }
-        .heading-3:first-of-type {
+        .heading-4:first-of-type {
           margin-top: 0;
         }
       `,
@@ -91,19 +85,6 @@
   }
 
   override render() {
-    if (showNewSubmitRequirements(this.flagsService, this.change)) {
-      return this.renderNewSubmitRequirements();
-    } else {
-      return this.renderOldSubmitRequirements();
-    }
-  }
-
-  private renderOldSubmitRequirements() {
-    const labels = computeLabels(this.account, this.change);
-    return html`${this.renderLabels(labels)}${this.renderErrorMessages()}`;
-  }
-
-  private renderNewSubmitRequirements() {
     return html`${this.renderSubmitReqsLabels()}${this.renderTriggerVotes()}
     ${this.renderErrorMessages()}`;
   }
@@ -119,10 +100,10 @@
         label => !this.permittedLabels || this.permittedLabels[label.name]
       ).length === 0
     ) {
-      return html`<h3 class="heading-3">Submit requirements votes</h3>
+      return html`<h3 class="heading-4">Submit requirements votes</h3>
         <div class="permissionMessage">You don't have permission to vote</div>`;
     }
-    return html`<h3 class="heading-3">Submit requirements votes</h3>
+    return html`<h3 class="heading-4">Submit requirements votes</h3>
       ${this.renderLabels(labels)}`;
   }
 
@@ -137,21 +118,15 @@
         label => !this.permittedLabels || this.permittedLabels[label.name]
       ).length === 0
     ) {
-      return html`<h3 class="heading-3">Trigger Votes</h3>
+      return html`<h3 class="heading-4">Trigger Votes</h3>
         <div class="permissionMessage">You don't have permission to vote</div>`;
     }
-    return html`<h3 class="heading-3">Trigger Votes</h3>
+    return html`<h3 class="heading-4">Trigger Votes</h3>
       ${this.renderLabels(labels)}`;
   }
 
   private renderLabels(labels: Label[]) {
-    const newSubReqs = showNewSubmitRequirements(
-      this.flagsService,
-      this.change
-    );
-    return html`<div
-      class="scoresTable ${newSubReqs ? 'newSubmitRequirements' : ''}"
-    >
+    return html`<div class="scoresTable">
       ${labels
         .filter(
           label =>
diff --git a/polygerrit-ui/app/elements/change/gr-message-scores/gr-message-scores.ts b/polygerrit-ui/app/elements/change/gr-message-scores/gr-message-scores.ts
index 4bf9d10..f204d76 100644
--- a/polygerrit-ui/app/elements/change/gr-message-scores/gr-message-scores.ts
+++ b/polygerrit-ui/app/elements/change/gr-message-scores/gr-message-scores.ts
@@ -25,8 +25,6 @@
 } from '../../../utils/comment-util';
 import {hasOwnProperty} from '../../../utils/common-util';
 import {getTriggerVotes} from '../../../utils/label-util';
-import {getAppContext} from '../../../services/app-context';
-import {KnownExperimentId} from '../../../services/flags/flags';
 
 const VOTE_RESET_TEXT = '0 (vote reset)';
 
@@ -102,8 +100,6 @@
     `;
   }
 
-  private readonly flagsService = getAppContext().flagsService;
-
   override render() {
     const scores = this._getScores(this.message, this.labelExtremes);
     const triggerVotes = getTriggerVotes(this.change);
@@ -112,7 +108,6 @@
 
   private renderScore(score: Score, triggerVotes: string[]) {
     if (
-      this.flagsService.isEnabled(KnownExperimentId.SUBMIT_REQUIREMENTS_UI) &&
       score.label &&
       triggerVotes.includes(score.label) &&
       !score.value?.includes(VOTE_RESET_TEXT)
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.ts b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.ts
index b9cb616..ac487f8 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.ts
@@ -17,10 +17,8 @@
 
 import '../../../test/common-test-setup-karma';
 import './gr-messages-list';
-import {createCommentApiMockWithTemplateElement} from '../../../test/mocks/comment-api';
 import {CombinedMessage, GrMessagesList, TEST_ONLY} from './gr-messages-list';
 import {MessageTag} from '../../../constants/constants';
-import {html} from '@polymer/polymer/lib/utils/html-tag';
 import {
   query,
   queryAll,
@@ -43,16 +41,7 @@
 import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
 import {assertIsDefined} from '../../../utils/common-util';
 
-createCommentApiMockWithTemplateElement(
-  'gr-messages-list-comment-mock-api',
-  html` <gr-messages-list id="messagesList"></gr-messages-list> `
-);
-
-const basicFixture = fixtureFromTemplate(html`
-  <gr-messages-list-comment-mock-api>
-    <gr-messages-list></gr-messages-list>
-  </gr-messages-list-comment-mock-api>
-`);
+const basicFixture = fixtureFromElement('gr-messages-list');
 
 const author = {
   _account_id: 42 as AccountId,
@@ -99,8 +88,6 @@
   let element: GrMessagesList;
   let messages: ChangeMessageInfo[];
 
-  let commentApiWrapper: any;
-
   const getMessages = function () {
     return queryAll<GrMessage>(element, 'gr-message');
   };
@@ -156,13 +143,7 @@
       stubRestApi('getDiffDrafts').returns(Promise.resolve({}));
 
       messages = generateRandomMessages(3);
-      // Element must be wrapped in an element with direct access to the
-      // comment API.
-      commentApiWrapper = basicFixture.instantiate();
-      element = queryAndAssert<GrMessagesList>(
-        commentApiWrapper,
-        '#messagesList'
-      );
+      element = basicFixture.instantiate();
       await element.getCommentsModel().reloadComments(0 as NumericChangeId);
       element.messages = messages;
       await flush();
@@ -507,8 +488,6 @@
     let element: GrMessagesList;
     let messages: ChangeMessageInfo[];
 
-    let commentApiWrapper: any;
-
     setup(() => {
       stubRestApi('getLoggedIn').returns(Promise.resolve(false));
       stubRestApi('getDiffComments').returns(Promise.resolve({}));
@@ -529,13 +508,7 @@
         }),
       ];
 
-      // Element must be wrapped in an element with direct access to the
-      // comment API.
-      commentApiWrapper = basicFixture.instantiate();
-      element = queryAndAssert<GrMessagesList>(
-        commentApiWrapper,
-        '#messagesList'
-      );
+      element = basicFixture.instantiate();
       element.messages = messages;
       flush();
     });
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
index f4493bc..6a9bd92 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
@@ -27,10 +27,7 @@
 import '../gr-label-scores/gr-label-scores';
 import '../gr-thread-list/gr-thread-list';
 import '../../../styles/shared-styles';
-import {
-  GrReviewerSuggestionsProvider,
-  SUGGESTIONS_PROVIDERS_USERS_TYPES,
-} from '../../../scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider';
+import {GrReviewerSuggestionsProvider} from '../../../scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider';
 import {getAppContext} from '../../../services/app-context';
 import {
   ChangeStatus,
@@ -118,6 +115,7 @@
 import {classMap} from 'lit/directives/class-map';
 import {BindValueChangeEvent, ValueChangedEvent} from '../../../types/events';
 import {customElement, property, state, query} from 'lit/decorators';
+import {subscribe} from '../../lit/subscription-controller';
 
 const STORAGE_DEBOUNCE_INTERVAL_MS = 400;
 
@@ -263,7 +261,7 @@
   account?: AccountInfo;
 
   @state()
-  ccs: (AccountInfoInput | GroupInfoInput)[] = [];
+  ccs: AccountInput[] = [];
 
   @state()
   attentionCcsCount = 0;
@@ -347,6 +345,8 @@
 
   storeTask?: DelayedTask;
 
+  private isLoggedIn = false;
+
   /** Called in disconnectedCallback. */
   private cleanups: (() => void)[] = [];
 
@@ -357,6 +357,7 @@
         background-color: var(--dialog-background-color);
         display: block;
         max-height: 90vh;
+        --label-score-padding-left: var(--spacing-xl);
       }
       :host([disabled]) {
         pointer-events: none;
@@ -592,6 +593,60 @@
     `,
   ];
 
+  constructor() {
+    super();
+    this.filterReviewerSuggestion =
+      this.filterReviewerSuggestionGenerator(false);
+    this.filterCCSuggestion = this.filterReviewerSuggestionGenerator(true);
+    this.jsAPI.addElement(TargetElement.REPLY_DIALOG, this);
+    subscribe(
+      this,
+      () => getAppContext().userModel.loggedIn$,
+      isLoggedIn => (this.isLoggedIn = isLoggedIn)
+    );
+  }
+
+  override connectedCallback() {
+    super.connectedCallback();
+    (
+      IronA11yAnnouncer as unknown as FixIronA11yAnnouncer
+    ).requestAvailability();
+    this.restApiService.getAccount().then(account => {
+      if (account) this.account = account;
+    });
+
+    this.cleanups.push(
+      addShortcut(this, {key: Key.ENTER, modifiers: [Modifier.CTRL_KEY]}, _ =>
+        this.submit()
+      )
+    );
+    this.cleanups.push(
+      addShortcut(this, {key: Key.ENTER, modifiers: [Modifier.META_KEY]}, _ =>
+        this.submit()
+      )
+    );
+    this.cleanups.push(addShortcut(this, {key: Key.ESC}, _ => this.cancel()));
+    this.addEventListener('comment-editing-changed', e => {
+      this.commentEditing = (e as CustomEvent).detail;
+    });
+
+    // Plugins on reply-reviewers endpoint can take advantage of these
+    // events to add / remove reviewers
+
+    this.addEventListener('add-reviewer', e => {
+      // Only support account type, see more from:
+      // elements/shared/gr-account-list/gr-account-list.js#addAccountItem
+      this.reviewersList?.addAccountItem({
+        account: (e as CustomEvent).detail.reviewer,
+        count: 1,
+      });
+    });
+
+    this.addEventListener('remove-reviewer', e => {
+      this.reviewersList?.removeAccount((e as CustomEvent).detail.reviewer);
+    });
+  }
+
   override willUpdate(changedProperties: PropertyValues) {
     if (changedProperties.has('draft')) {
       this.draftChanged(changedProperties.get('draft') as string);
@@ -639,55 +694,6 @@
     }
   }
 
-  constructor() {
-    super();
-    this.filterReviewerSuggestion =
-      this.filterReviewerSuggestionGenerator(false);
-    this.filterCCSuggestion = this.filterReviewerSuggestionGenerator(true);
-    this.jsAPI.addElement(TargetElement.REPLY_DIALOG, this);
-  }
-
-  override connectedCallback() {
-    super.connectedCallback();
-    (
-      IronA11yAnnouncer as unknown as FixIronA11yAnnouncer
-    ).requestAvailability();
-    this.restApiService.getAccount().then(account => {
-      if (account) this.account = account;
-    });
-
-    this.cleanups.push(
-      addShortcut(this, {key: Key.ENTER, modifiers: [Modifier.CTRL_KEY]}, _ =>
-        this.submit()
-      )
-    );
-    this.cleanups.push(
-      addShortcut(this, {key: Key.ENTER, modifiers: [Modifier.META_KEY]}, _ =>
-        this.submit()
-      )
-    );
-    this.cleanups.push(addShortcut(this, {key: Key.ESC}, _ => this.cancel()));
-    this.addEventListener('comment-editing-changed', e => {
-      this.commentEditing = (e as CustomEvent).detail;
-    });
-
-    // Plugins on reply-reviewers endpoint can take advantage of these
-    // events to add / remove reviewers
-
-    this.addEventListener('add-reviewer', e => {
-      // Only support account type, see more from:
-      // elements/shared/gr-account-list/gr-account-list.js#addAccountItem
-      this.reviewersList?.addAccountItem({
-        account: (e as CustomEvent).detail.reviewer,
-        count: 1,
-      });
-    });
-
-    this.addEventListener('remove-reviewer', e => {
-      this.reviewersList?.removeAccount((e as CustomEvent).detail.reviewer);
-    });
-  }
-
   override disconnectedCallback() {
     this.storeTask?.cancel();
     for (const cleanup of this.cleanups) cleanup();
@@ -1481,7 +1487,7 @@
     const jsonPromise = this.restApiService.getResponseObject(response.clone());
     return jsonPromise.then((parsed: ParsedJSON) => {
       const result = parsed as ReviewResult;
-      // Only perform custom error handling for 400s and a parseable
+      // Only perform custom error handling for 400s and a parsable
       // ReviewResult response.
       if (response.status === 400 && result && result.reviewers) {
         const errors: string[] = [];
@@ -1521,8 +1527,8 @@
     if (!this.change?.owner || !this.change?.reviewers) return;
     this.owner = this.change.owner;
 
-    const reviewers = [];
-    const ccs = [];
+    const reviewers: AccountInput[] = [];
+    const ccs: AccountInput[] = [];
 
     if (this.change.reviewers) {
       for (const key of Object.keys(this.change.reviewers)) {
@@ -2078,23 +2084,25 @@
 
   getReviewerSuggestionsProvider(change?: ChangeInfo) {
     if (!change) return;
-    const provider = GrReviewerSuggestionsProvider.create(
+    const provider = new GrReviewerSuggestionsProvider(
       this.restApiService,
-      change._number,
-      SUGGESTIONS_PROVIDERS_USERS_TYPES.REVIEWER
+      ReviewerState.REVIEWER,
+      this.serverConfig,
+      this.isLoggedIn,
+      change._number
     );
-    provider.init();
     return provider;
   }
 
   getCcSuggestionsProvider(change?: ChangeInfo) {
     if (!change) return;
-    const provider = GrReviewerSuggestionsProvider.create(
+    const provider = new GrReviewerSuggestionsProvider(
       this.restApiService,
-      change._number,
-      SUGGESTIONS_PROVIDERS_USERS_TYPES.CC
+      ReviewerState.CC,
+      this.serverConfig,
+      this.isLoggedIn,
+      change._number
     );
-    provider.init();
     return provider;
   }
 
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.ts b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.ts
index 6740977..d5ce654 100644
--- a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.ts
@@ -29,12 +29,7 @@
   LabelInfo,
 } from '../../../types/common';
 import {hasOwnProperty} from '../../../utils/common-util';
-import {getAppContext} from '../../../services/app-context';
-import {
-  getApprovalInfo,
-  getCodeReviewLabel,
-  showNewSubmitRequirements,
-} from '../../../utils/label-util';
+import {getApprovalInfo, getCodeReviewLabel} from '../../../utils/label-util';
 import {sortReviewers} from '../../../utils/attention-set-util';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {css} from 'lit';
@@ -68,8 +63,6 @@
 
   @state() showAllReviewers = false;
 
-  private readonly flagsService = getAppContext().flagsService;
-
   static override get styles() {
     return [
       sharedStyles,
@@ -166,14 +159,12 @@
         .vote=${this.computeVote(reviewer)}
         .label=${this.computeCodeReviewLabel()}
       >
-        ${showNewSubmitRequirements(this.flagsService, this.change)
-          ? html`<gr-vote-chip
-              slot="vote-chip"
-              .vote=${this.computeVote(reviewer)}
-              .label=${this.computeCodeReviewLabel()}
-              circle-shape
-            ></gr-vote-chip>`
-          : nothing}
+        <gr-vote-chip
+          slot="vote-chip"
+          .vote=${this.computeVote(reviewer)}
+          .label=${this.computeCodeReviewLabel()}
+          circle-shape
+        ></gr-vote-chip>
       </gr-account-chip>
     `;
   }
diff --git a/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements.ts b/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements.ts
index 6e60a22..08e3ab4 100644
--- a/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements.ts
+++ b/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements.ts
@@ -46,12 +46,14 @@
 import {subscribe} from '../../lit/subscription-controller';
 import {CheckRun} from '../../../models/checks/checks-model';
 import {getResultsOf, hasResultsOf} from '../../../models/checks/checks-util';
-import {Category} from '../../../api/checks';
+import {Category, RunStatus} from '../../../api/checks';
 import {fireShowPrimaryTab} from '../../../utils/event-util';
 import {PrimaryTab} from '../../../constants/constants';
 import {submitRequirementsStyles} from '../../../styles/gr-submit-requirements-styles';
 import {resolve} from '../../../models/dependency';
 import {checksModelToken} from '../../../models/checks/checks-model';
+import {join} from 'lit/directives/join';
+import {map} from 'lit/directives/map';
 
 /**
  * @attr {Boolean} suppress-title - hide titles, currently for hovercard view
@@ -119,9 +121,15 @@
         td {
           padding: var(--spacing-s);
           white-space: nowrap;
+          vertical-align: top;
         }
         .votes-cell {
           display: flex;
+          flex-flow: wrap;
+        }
+        .votes-cell .separator {
+          width: 100%;
+          margin-top: var(--spacing-s);
         }
         gr-vote-chip {
           margin-right: var(--spacing-s);
@@ -136,11 +144,11 @@
 
   private readonly getChecksModel = resolve(this, checksModelToken);
 
-  override connectedCallback(): void {
-    super.connectedCallback();
+  constructor() {
+    super();
     subscribe(
       this,
-      this.getChecksModel().allRunsLatestPatchsetLatestAttempt$,
+      () => this.getChecksModel().allRunsLatestPatchsetLatestAttempt$,
       x => (this.runs = x)
     );
   }
@@ -192,16 +200,11 @@
         <td class="name">
           <gr-limited-text
             class="name"
-            limit="25"
             .text=${requirement.name}
           ></gr-limited-text>
         </td>
         <td>
-          ${this.renderEndpoint(
-            requirement,
-            html`${this.renderVotesAndChecksChips(requirement)}
-            ${this.renderOverrideLabels(requirement)}`
-          )}
+          ${this.renderEndpoint(requirement, this.renderVoteCell(requirement))}
         </td>
       </tr>
     `;
@@ -254,36 +257,44 @@
     ></iron-icon>`;
   }
 
-  renderVotesAndChecksChips(requirement: SubmitRequirementResultInfo) {
+  renderVoteCell(requirement: SubmitRequirementResultInfo) {
     if (requirement.status === SubmitRequirementStatus.ERROR) {
       return html`<span class="error">Error</span>`;
     }
+
     const requirementLabels = extractAssociatedLabels(requirement);
     const allLabels = this.change?.labels ?? {};
     const associatedLabels = Object.keys(allLabels).filter(label =>
       requirementLabels.includes(label)
     );
 
-    const everyAssociatedLabelsIsWithoutVotes = associatedLabels.every(
-      label => !hasVotes(allLabels[label])
-    );
-
-    const checksChips = this.renderChecks(requirement);
-
     const requirementWithoutLabelToVoteOn = associatedLabels.length === 0;
     if (requirementWithoutLabelToVoteOn) {
       const status = capitalizeFirstLetter(requirement.status.toLowerCase());
-      return checksChips || html`${status}`;
+      return this.renderChecks(requirement) || html`${status}`;
     }
 
+    const everyAssociatedLabelsIsWithoutVotes = associatedLabels.every(
+      label => !hasVotes(allLabels[label])
+    );
     if (everyAssociatedLabelsIsWithoutVotes) {
-      return checksChips || html`No votes`;
+      return this.renderChecks(requirement) || html`No votes`;
     }
 
-    return html`${associatedLabels.map(label =>
-      this.renderLabelVote(label, allLabels)
+    const associatedLabelsWithVotes = associatedLabels.filter(label =>
+      hasVotes(allLabels[label])
+    );
+
+    return html`${join(
+      map(
+        associatedLabelsWithVotes,
+        label =>
+          html`${this.renderLabelVote(label, allLabels)}
+          ${this.renderOverrideLabels(requirement, label)}`
+      ),
+      html`<span class="separator"></span>`
     )}
-    ${checksChips}`;
+    ${this.renderChecks(requirement)}`;
   }
 
   renderLabelVote(label: string, labels: LabelNameToInfoMap) {
@@ -309,50 +320,87 @@
     }
   }
 
-  renderChecks(requirement: SubmitRequirementResultInfo) {
-    const requirementLabels = extractAssociatedLabels(requirement);
-    const requirementRuns = this.runs
-      .filter(run => hasResultsOf(run, Category.ERROR))
-      .filter(
-        run => run.labelName && requirementLabels.includes(run.labelName)
-      );
-    const runsCount = requirementRuns.reduce(
-      (sum, run) => sum + getResultsOf(run, Category.ERROR).length,
-      0
-    );
-    if (runsCount === 0) return;
-    const links = [];
-    if (requirementRuns.length === 1 && requirementRuns[0].statusLink) {
-      links.push(requirementRuns[0].statusLink);
-    }
-    return html`<gr-checks-chip
-      .text=${`${runsCount}`}
-      .links=${links}
-      .statusOrCategory=${Category.ERROR}
-      @click=${() => {
-        fireShowPrimaryTab(this, PrimaryTab.CHECKS, false, {
-          checksTab: {
-            statusOrCategory: Category.ERROR,
-          },
-        });
-      }}
-    ></gr-checks-chip>`;
-  }
-
-  renderOverrideLabels(requirement: SubmitRequirementResultInfo) {
+  renderOverrideLabels(
+    requirement: SubmitRequirementResultInfo,
+    forLabel: string
+  ) {
     if (requirement.status !== SubmitRequirementStatus.OVERRIDDEN) return;
     const requirementLabels = extractAssociatedLabels(
       requirement,
       'onlyOverride'
-    ).filter(label => {
-      const allLabels = this.change?.labels ?? {};
-      return allLabels[label] && hasVotes(allLabels[label]);
-    });
+    )
+      .filter(label => label === forLabel)
+      .filter(label => {
+        const allLabels = this.change?.labels ?? {};
+        return allLabels[label] && hasVotes(allLabels[label]);
+      });
     return requirementLabels.map(
       label => html`<span class="overrideLabel">${label}</span>`
     );
   }
 
+  renderChecks(requirement: SubmitRequirementResultInfo) {
+    const requirementLabels = extractAssociatedLabels(requirement);
+    const errorRuns = this.runs
+      .filter(run => hasResultsOf(run, Category.ERROR))
+      .filter(
+        run => run.labelName && requirementLabels.includes(run.labelName)
+      );
+    const errorRunsCount = errorRuns.reduce(
+      (sum, run) => sum + getResultsOf(run, Category.ERROR).length,
+      0
+    );
+    if (errorRunsCount > 0) {
+      return this.renderChecksCategoryChip(
+        errorRuns,
+        errorRunsCount,
+        Category.ERROR
+      );
+    }
+    const runningRuns = this.runs
+      .filter(r => r.isLatestAttempt)
+      .filter(
+        r => r.status === RunStatus.RUNNING || r.status === RunStatus.SCHEDULED
+      )
+      .filter(
+        run => run.labelName && requirementLabels.includes(run.labelName)
+      );
+
+    const runningRunsCount = runningRuns.length;
+    if (runningRunsCount > 0) {
+      return this.renderChecksCategoryChip(
+        runningRuns,
+        runningRunsCount,
+        RunStatus.RUNNING
+      );
+    }
+    return;
+  }
+
+  renderChecksCategoryChip(
+    runs: CheckRun[],
+    runsCount: Number,
+    category: Category | RunStatus
+  ) {
+    if (runsCount === 0) return;
+    const links = [];
+    if (runs.length === 1 && runs[0].statusLink) {
+      links.push(runs[0].statusLink);
+    }
+    return html`<gr-checks-chip
+      .text=${`${runsCount}`}
+      .links=${links}
+      .statusOrCategory=${category}
+      @click=${() => {
+        fireShowPrimaryTab(this, PrimaryTab.CHECKS, false, {
+          checksTab: {
+            statusOrCategory: category,
+          },
+        });
+      }}
+    ></gr-checks-chip>`;
+  }
+
   renderTriggerVotes() {
     const labels = this.change?.labels ?? {};
     const triggerVotes = getTriggerVotes(this.change).filter(label =>
diff --git a/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements_test.ts b/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements_test.ts
index 392fac91..09e601c 100644
--- a/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements_test.ts
@@ -28,9 +28,15 @@
   createSubmitRequirementExpressionInfo,
   createSubmitRequirementResultInfo,
   createNonApplicableSubmitRequirementResultInfo,
+  createRunResult,
+  createCheckResult,
 } from '../../../test/test-data-generators';
-import {SubmitRequirementResultInfo} from '../../../api/rest-api';
+import {
+  SubmitRequirementResultInfo,
+  SubmitRequirementStatus,
+} from '../../../api/rest-api';
 import {ParsedChangeInfo} from '../../../types/types';
+import {RunStatus} from '../../../api/checks';
 
 suite('gr-submit-requirements tests', () => {
   let element: GrSubmitRequirements;
@@ -93,7 +99,7 @@
               </iron-icon>
             </td>
             <td class="name">
-              <gr-limited-text class="name" limit="25"></gr-limited-text>
+              <gr-limited-text class="name"></gr-limited-text>
             </td>
             <td>
               <gr-endpoint-decorator
@@ -153,6 +159,117 @@
         <div class="votes-cell">Satisfied</div>
       `);
     });
+
+    test('checks', async () => {
+      element.runs = [
+        {
+          ...createRunResult(),
+          labelName: 'Verified',
+          results: [createCheckResult()],
+        },
+      ];
+      await element.updateComplete;
+      const votesCell = element.shadowRoot?.querySelectorAll('.votes-cell');
+      expect(votesCell?.[0]).dom.equal(/* HTML */ `
+        <div class="votes-cell">
+          <gr-vote-chip></gr-vote-chip>
+          <gr-checks-chip></gr-checks-chip>
+        </div>
+      `);
+    });
+
+    test('running checks', async () => {
+      element.runs = [
+        {
+          ...createRunResult(),
+          status: RunStatus.RUNNING,
+          labelName: 'Verified',
+          results: [createCheckResult()],
+        },
+      ];
+      await element.updateComplete;
+      const votesCell = element.shadowRoot?.querySelectorAll('.votes-cell');
+      expect(votesCell?.[0]).dom.equal(/* HTML */ `
+        <div class="votes-cell">
+          <gr-vote-chip></gr-vote-chip>
+          <gr-checks-chip></gr-checks-chip>
+        </div>
+      `);
+    });
+
+    test('with override label', async () => {
+      const modifiedChange = {...change};
+      modifiedChange.labels = {
+        Override: {
+          ...createDetailedLabelInfo(),
+          all: [
+            {
+              ...createApproval(),
+              value: 2,
+            },
+          ],
+        },
+      };
+      modifiedChange.submit_requirements = [
+        {
+          ...createSubmitRequirementResultInfo(),
+          status: SubmitRequirementStatus.OVERRIDDEN,
+          override_expression_result: createSubmitRequirementExpressionInfo(
+            'label:Override=MAX -label:Override=MIN'
+          ),
+        },
+      ];
+      element.change = modifiedChange;
+      await element.updateComplete;
+      const votesCell = element.shadowRoot?.querySelectorAll('.votes-cell');
+      expect(votesCell?.[0]).dom.equal(/* HTML */ `<div class="votes-cell">
+        <gr-vote-chip> </gr-vote-chip>
+        <span class="overrideLabel"> Override </span>
+      </div>`);
+    });
+
+    test('with override with 2 labels', async () => {
+      const modifiedChange = {...change};
+      modifiedChange.labels = {
+        Override: {
+          ...createDetailedLabelInfo(),
+          all: [
+            {
+              ...createApproval(),
+              value: 2,
+            },
+          ],
+        },
+        Override2: {
+          ...createDetailedLabelInfo(),
+          all: [
+            {
+              ...createApproval(),
+              value: 2,
+            },
+          ],
+        },
+      };
+      modifiedChange.submit_requirements = [
+        {
+          ...createSubmitRequirementResultInfo(),
+          status: SubmitRequirementStatus.OVERRIDDEN,
+          override_expression_result: createSubmitRequirementExpressionInfo(
+            'label:Override=MAX label:Override2=MAX'
+          ),
+        },
+      ];
+      element.change = modifiedChange;
+      await element.updateComplete;
+      const votesCell = element.shadowRoot?.querySelectorAll('.votes-cell');
+      expect(votesCell?.[0]).dom.equal(/* HTML */ `<div class="votes-cell">
+        <gr-vote-chip> </gr-vote-chip>
+        <span class="overrideLabel"> Override </span>
+        <span class="separator"></span>
+        <gr-vote-chip> </gr-vote-chip>
+        <span class="overrideLabel"> Override2 </span>
+      </div>`);
+    });
   });
 
   test('calculateEndpointName()', () => {
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts
index bf85d11..0ba2e89 100644
--- a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts
@@ -205,15 +205,23 @@
 
   private readonly userModel = getAppContext().userModel;
 
-  override connectedCallback(): void {
-    super.connectedCallback();
+  constructor() {
+    super();
     subscribe(
       this,
-      this.getChangeModel().changeNum$,
+      () => this.getChangeModel().changeNum$,
       x => (this.changeNum = x)
     );
-    subscribe(this, this.getChangeModel().change$, x => (this.change = x));
-    subscribe(this, this.userModel.account$, x => (this.account = x));
+    subscribe(
+      this,
+      () => this.getChangeModel().change$,
+      x => (this.change = x)
+    );
+    subscribe(
+      this,
+      () => this.userModel.account$,
+      x => (this.account = x)
+    );
   }
 
   override willUpdate(changed: PropertyValues) {
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-results.ts b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
index 9ea29b0..b9beb1d 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-results.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
@@ -114,9 +114,13 @@
 
   private readonly reporting = getAppContext().reportingService;
 
-  override connectedCallback() {
-    super.connectedCallback();
-    subscribe(this, this.getChangeModel().labels$, x => (this.labels = x));
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.getChangeModel().labels$,
+      x => (this.labels = x)
+    );
   }
 
   static override get styles() {
@@ -608,11 +612,11 @@
     ];
   }
 
-  override connectedCallback() {
-    super.connectedCallback();
+  constructor() {
+    super();
     subscribe(
       this,
-      this.getConfigModel().repoConfig$,
+      () => this.getConfigModel().repoConfig$,
       x => (this.repoConfig = x)
     );
   }
@@ -787,31 +791,31 @@
 
   private readonly reporting = getAppContext().reportingService;
 
-  override connectedCallback() {
-    super.connectedCallback();
+  constructor() {
+    super();
     subscribe(
       this,
-      this.getChecksModel().topLevelActionsSelected$,
+      () => this.getChecksModel().topLevelActionsSelected$,
       x => (this.actions = x)
     );
     subscribe(
       this,
-      this.getChecksModel().topLevelLinksSelected$,
+      () => this.getChecksModel().topLevelLinksSelected$,
       x => (this.links = x)
     );
     subscribe(
       this,
-      this.getChecksModel().checksSelectedPatchsetNumber$,
+      () => this.getChecksModel().checksSelectedPatchsetNumber$,
       x => (this.checksPatchsetNumber = x)
     );
     subscribe(
       this,
-      this.getChangeModel().latestPatchNum$,
+      () => this.getChangeModel().latestPatchNum$,
       x => (this.latestPatchsetNumber = x)
     );
     subscribe(
       this,
-      this.getChecksModel().someProvidersAreLoadingSelected$,
+      () => this.getChecksModel().someProvidersAreLoadingSelected$,
       x => (this.someProvidersAreLoading = x)
     );
   }
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-runs.ts b/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
index b18a5ff..414be21 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
@@ -429,21 +429,21 @@
 
   private readonly reporting = getAppContext().reportingService;
 
-  override connectedCallback(): void {
-    super.connectedCallback();
+  constructor() {
+    super();
     subscribe(
       this,
-      this.getChecksModel().allRunsSelectedPatchset$,
+      () => this.getChecksModel().allRunsSelectedPatchset$,
       x => (this.runs = x)
     );
     subscribe(
       this,
-      this.getChecksModel().errorMessagesLatest$,
+      () => this.getChecksModel().errorMessagesLatest$,
       x => (this.errorMessages = x)
     );
     subscribe(
       this,
-      this.getChecksModel().loginCallbackLatest$,
+      () => this.getChecksModel().loginCallbackLatest$,
       x => (this.loginCallback = x)
     );
   }
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-tab.ts b/polygerrit-ui/app/elements/checks/gr-checks-tab.ts
index d808d11..bb34dec 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-tab.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-tab.ts
@@ -73,31 +73,31 @@
 
   private readonly reporting = getAppContext().reportingService;
 
-  override connectedCallback(): void {
-    super.connectedCallback();
+  constructor() {
+    super();
     subscribe(
       this,
-      this.getChecksModel().allRunsSelectedPatchset$,
+      () => this.getChecksModel().allRunsSelectedPatchset$,
       x => (this.runs = x)
     );
     subscribe(
       this,
-      this.getChecksModel().allResultsSelected$,
+      () => this.getChecksModel().allResultsSelected$,
       x => (this.results = x)
     );
     subscribe(
       this,
-      this.getChecksModel().checksSelectedPatchsetNumber$,
+      () => this.getChecksModel().checksSelectedPatchsetNumber$,
       x => (this.checksPatchsetNumber = x)
     );
     subscribe(
       this,
-      this.getChangeModel().latestPatchNum$,
+      () => this.getChangeModel().latestPatchNum$,
       x => (this.latestPatchsetNumber = x)
     );
     subscribe(
       this,
-      this.getChangeModel().changeNum$,
+      () => this.getChangeModel().changeNum$,
       x => (this.changeNum = x)
     );
   }
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
index f4e5835..eb6d071 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
@@ -277,8 +277,6 @@
 
   private readonly getChecksModel = resolve(this, checksModelToken);
 
-  private readonly flagService = getAppContext().flagsService;
-
   private readonly reporting = getAppContext().reportingService;
 
   private readonly flags = getAppContext().flagsService;
@@ -494,9 +492,6 @@
       this.checksChanged([]);
     }
 
-    const experiment = KnownExperimentId.CHECK_RESULTS_IN_DIFFS;
-    if (!this.flagService.isEnabled(experiment)) return;
-
     const path = this.path;
     const patchNum = this.patchRange?.patchNum;
     if (!path || !patchNum || patchNum === EditPatchSetNum) return;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js
index 6f8b1a4..f2b99c5 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js
@@ -21,11 +21,12 @@
 import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
 import {createDefaultDiffPrefs, Side} from '../../../constants/constants.js';
 import {createChange, createComment, createCommentThread} from '../../../test/test-data-generators.js';
-import {addListenerForTest, mockPromise, stubRestApi} from '../../../test/test-utils.js';
+import {addListenerForTest, mockPromise, stubRestApi, waitUntil} from '../../../test/test-utils.js';
 import {EditPatchSetNum, ParentPatchSetNum} from '../../../types/common.js';
 import {CoverageType} from '../../../types/types.js';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 import {GrDiffBuilderImage} from '../../../embed/diff/gr-diff-builder/gr-diff-builder-image.js';
+import {waitForEventOnce} from '../../../utils/event-util.js';
 
 const basicFixture = fixtureFromElement('gr-diff-host');
 
@@ -79,8 +80,7 @@
       // Multiple cascading microtasks are scheduled.
       await flush();
       notifySyntaxProcessed();
-      // Multiple cascading microtasks are scheduled.
-      await flush();
+      await waitUntil(() => element.reporting.timeEnd.callCount === 4);
       const calls = element.reporting.timeEnd.getCalls();
       assert.equal(calls.length, 4);
       assert.equal(calls[0].args[0], 'Diff Load Render');
@@ -109,8 +109,7 @@
       await flush();
       assert.isFalse(reloadComplete);
       notifySyntaxProcessed();
-      // Assert after the notification task is processed.
-      await flush();
+      await waitUntil(() => reloadComplete);
       assert.isTrue(reloadComplete);
     });
   });
@@ -307,64 +306,41 @@
         },
       }));
 
-      const promise = mockPromise();
-      const rendered = () => {
-        // Recognizes that it should be an image diff.
-        assert.isTrue(element.isImageDiff);
-        assert.instanceOf(
-            element.$.diff.$.diffBuilder._builder, GrDiffBuilderImage);
-
-        // Left image rendered with the parent commit's version of the file.
-        const leftImage =
-            element.$.diff.$.diffTable.querySelector('td.left img');
-        const leftLabel =
-            element.$.diff.$.diffTable.querySelector('td.left label');
-        const leftLabelContent = leftLabel.querySelector('.label');
-        const leftLabelName = leftLabel.querySelector('.name');
-
-        const rightImage =
-            element.$.diff.$.diffTable.querySelector('td.right img');
-        const rightLabel = element.$.diff.$.diffTable.querySelector(
-            'td.right label');
-        const rightLabelContent = rightLabel.querySelector('.label');
-        const rightLabelName = rightLabel.querySelector('.name');
-
-        assert.isNotOk(rightLabelName);
-        assert.isNotOk(leftLabelName);
-
-        let leftLoaded = false;
-        let rightLoaded = false;
-
-        leftImage.addEventListener('load', () => {
-          assert.isOk(leftImage);
-          assert.equal(leftImage.getAttribute('src'),
-              'data:image/bmp;base64,' + mockFile1.body);
-          assert.equal(leftLabelContent.textContent, '1×1 image/bmp');
-          leftLoaded = true;
-          if (rightLoaded) {
-            element.removeEventListener('render', rendered);
-            promise.resolve();
-          }
-        });
-
-        rightImage.addEventListener('load', () => {
-          assert.isOk(rightImage);
-          assert.equal(rightImage.getAttribute('src'),
-              'data:image/bmp;base64,' + mockFile2.body);
-          assert.equal(rightLabelContent.textContent, '1×1 image/bmp');
-
-          rightLoaded = true;
-          if (leftLoaded) {
-            element.removeEventListener('render', rendered);
-            promise.resolve();
-          }
-        });
-      };
-
-      element.addEventListener('render', rendered);
       element.prefs = createDefaultDiffPrefs();
       element.reload();
-      await promise;
+      await waitForEventOnce(element, 'render');
+
+      // Recognizes that it should be an image diff.
+      assert.isTrue(element.isImageDiff);
+      assert.instanceOf(
+          element.$.diff.diffBuilder.builder, GrDiffBuilderImage);
+
+      // Left image rendered with the parent commit's version of the file.
+      const leftImage =
+          element.$.diff.$.diffTable.querySelector('td.left img');
+      const leftLabel =
+          element.$.diff.$.diffTable.querySelector('td.left label');
+      const leftLabelContent = leftLabel.querySelector('.label');
+      const leftLabelName = leftLabel.querySelector('.name');
+
+      const rightImage =
+          element.$.diff.$.diffTable.querySelector('td.right img');
+      const rightLabel = element.$.diff.$.diffTable.querySelector(
+          'td.right label');
+      const rightLabelContent = rightLabel.querySelector('.label');
+      const rightLabelName = rightLabel.querySelector('.name');
+
+      assert.isOk(leftImage);
+      assert.equal(leftImage.getAttribute('src'),
+          'data:image/bmp;base64,' + mockFile1.body);
+      assert.isTrue(leftLabelContent.textContent.includes('image/bmp'));
+      assert.isNotOk(leftLabelName);
+
+      assert.isOk(rightImage);
+      assert.equal(rightImage.getAttribute('src'),
+          'data:image/bmp;base64,' + mockFile2.body);
+      assert.isTrue(rightLabelContent.textContent.includes('image/bmp'));
+      assert.isNotOk(rightLabelName);
     });
 
     test('renders image diffs with a different file name', async () => {
@@ -398,66 +374,44 @@
         },
       }));
 
-      const promise = mockPromise();
-      const rendered = () => {
-        // Recognizes that it should be an image diff.
-        assert.isTrue(element.isImageDiff);
-        assert.instanceOf(
-            element.$.diff.$.diffBuilder._builder, GrDiffBuilderImage);
-
-        // Left image rendered with the parent commit's version of the file.
-        const leftImage =
-            element.$.diff.$.diffTable.querySelector('td.left img');
-        const leftLabel =
-            element.$.diff.$.diffTable.querySelector('td.left label');
-        const leftLabelContent = leftLabel.querySelector('.label');
-        const leftLabelName = leftLabel.querySelector('.name');
-
-        const rightImage =
-            element.$.diff.$.diffTable.querySelector('td.right img');
-        const rightLabel = element.$.diff.$.diffTable.querySelector(
-            'td.right label');
-        const rightLabelContent = rightLabel.querySelector('.label');
-        const rightLabelName = rightLabel.querySelector('.name');
-
-        assert.isOk(rightLabelName);
-        assert.isOk(leftLabelName);
-        assert.equal(leftLabelName.textContent, mockDiff.meta_a.name);
-        assert.equal(rightLabelName.textContent, mockDiff.meta_b.name);
-
-        let leftLoaded = false;
-        let rightLoaded = false;
-
-        leftImage.addEventListener('load', () => {
-          assert.isOk(leftImage);
-          assert.equal(leftImage.getAttribute('src'),
-              'data:image/bmp;base64,' + mockFile1.body);
-          assert.equal(leftLabelContent.textContent, '1×1 image/bmp');
-          leftLoaded = true;
-          if (rightLoaded) {
-            element.removeEventListener('render', rendered);
-            promise.resolve();
-          }
-        });
-
-        rightImage.addEventListener('load', () => {
-          assert.isOk(rightImage);
-          assert.equal(rightImage.getAttribute('src'),
-              'data:image/bmp;base64,' + mockFile2.body);
-          assert.equal(rightLabelContent.textContent, '1×1 image/bmp');
-
-          rightLoaded = true;
-          if (leftLoaded) {
-            element.removeEventListener('render', rendered);
-            promise.resolve();
-          }
-        });
-      };
-
-      element.addEventListener('render', rendered);
       element.prefs = createDefaultDiffPrefs();
       element.reload();
-      await promise;
+      await waitForEventOnce(element, 'render');
+
+      // Recognizes that it should be an image diff.
+      assert.isTrue(element.isImageDiff);
+      assert.instanceOf(
+          element.$.diff.diffBuilder.builder, GrDiffBuilderImage);
+
+      // Left image rendered with the parent commit's version of the file.
+      const leftImage =
+          element.$.diff.$.diffTable.querySelector('td.left img');
+      const leftLabel =
+          element.$.diff.$.diffTable.querySelector('td.left label');
+      const leftLabelContent = leftLabel.querySelector('.label');
+      const leftLabelName = leftLabel.querySelector('.name');
+
+      const rightImage =
+          element.$.diff.$.diffTable.querySelector('td.right img');
+      const rightLabel = element.$.diff.$.diffTable.querySelector(
+          'td.right label');
+      const rightLabelContent = rightLabel.querySelector('.label');
+      const rightLabelName = rightLabel.querySelector('.name');
+
+      assert.isOk(rightLabelName);
+      assert.isOk(leftLabelName);
+      assert.equal(leftLabelName.textContent, mockDiff.meta_a.name);
+      assert.equal(rightLabelName.textContent, mockDiff.meta_b.name);
+
+      assert.isOk(leftImage);
+      assert.equal(leftImage.getAttribute('src'),
+          'data:image/bmp;base64,' + mockFile1.body);
+      assert.isTrue(leftLabelContent.textContent.includes('image/bmp'));
+
+      assert.isOk(rightImage);
+      assert.equal(rightImage.getAttribute('src'),
+          'data:image/bmp;base64,' + mockFile2.body);
+      assert.isTrue(rightLabelContent.textContent.includes('image/bmp'));
     });
 
     test('renders added image', async () => {
@@ -491,7 +445,7 @@
         // Recognizes that it should be an image diff.
         assert.isTrue(element.isImageDiff);
         assert.instanceOf(
-            element.$.diff.$.diffBuilder._builder, GrDiffBuilderImage);
+            element.$.diff.diffBuilder.builder, GrDiffBuilderImage);
 
         const leftImage =
             element.$.diff.$.diffTable.querySelector('td.left img');
@@ -539,7 +493,7 @@
         // Recognizes that it should be an image diff.
         assert.isTrue(element.isImageDiff);
         assert.instanceOf(
-            element.$.diff.$.diffBuilder._builder, GrDiffBuilderImage);
+            element.$.diff.diffBuilder.builder, GrDiffBuilderImage);
 
         const leftImage =
             element.$.diff.$.diffTable.querySelector('td.left img');
@@ -589,7 +543,7 @@
         // Recognizes that it should be an image diff.
         assert.isTrue(element.isImageDiff);
         assert.instanceOf(
-            element.$.diff.$.diffBuilder._builder, GrDiffBuilderImage);
+            element.$.diff.diffBuilder.builder, GrDiffBuilderImage);
         const leftImage =
             element.$.diff.$.diffTable.querySelector('td.left img');
         assert.isNotOk(leftImage);
@@ -669,7 +623,7 @@
 
     test('clearBlame', () => {
       element._blame = [];
-      const setBlameSpy = sinon.spy(element.$.diff.$.diffBuilder, 'setBlame');
+      const setBlameSpy = sinon.spy(element.$.diff.diffBuilder, 'setBlame');
       element.clearBlame();
       assert.isNull(element._blame);
       assert.isTrue(setBlameSpy.calledWithExactly(null));
diff --git a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.ts b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.ts
index 39fc048..896a9b2 100644
--- a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.ts
+++ b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.ts
@@ -131,11 +131,11 @@
 
   private readonly getCommentsModel = resolve(this, commentsModelToken);
 
-  override connectedCallback() {
-    super.connectedCallback();
+  constructor() {
+    super();
     subscribe(
       this,
-      this.getCommentsModel().changeComments$,
+      () => this.getCommentsModel().changeComments$,
       x => (this.changeComments = x)
     );
   }
diff --git a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.ts b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.ts
index dc501c8..2e48771 100644
--- a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.ts
+++ b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.ts
@@ -20,7 +20,6 @@
 import '../../shared/revision-info/revision-info';
 import './gr-patch-range-select';
 import {GrPatchRangeSelect} from './gr-patch-range-select';
-import '../../../test/mocks/comment-api';
 import {RevisionInfo as RevisionInfoClass} from '../../shared/revision-info/revision-info';
 import {ChangeComments} from '../gr-comment-api/gr-comment-api';
 import {stubRestApi} from '../../../test/test-utils';
diff --git a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.ts b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.ts
index dc8e7a6..48d50c7 100644
--- a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.ts
+++ b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.ts
@@ -122,13 +122,17 @@
     this.addEventListener('content-change', e => {
       this.handleContentChange(e as CustomEvent<{value: string}>);
     });
+    subscribe(
+      this,
+      () => this.userModel.editPreferences$,
+      editPreferences => {
+        this.editPrefs = editPreferences;
+      }
+    );
   }
 
   override connectedCallback() {
     super.connectedCallback();
-    subscribe(this, this.userModel.editPreferences$, editPreferences => {
-      this.editPrefs = editPreferences;
-    });
     this.cleanups.push(
       addShortcut(this, {key: 's', modifiers: [Modifier.CTRL_KEY]}, () =>
         this.handleSaveShortcut()
diff --git a/polygerrit-ui/app/elements/gr-app-element.ts b/polygerrit-ui/app/elements/gr-app-element.ts
index a78e59f..16e959a 100644
--- a/polygerrit-ui/app/elements/gr-app-element.ts
+++ b/polygerrit-ui/app/elements/gr-app-element.ts
@@ -367,7 +367,7 @@
         id="mainHeader"
         .searchQuery=${(this.params as AppElementSearchParam)?.query}
         @mobile-search=${this.mobileSearchToggle}
-        @show-keyboard-shortcuts=${this.handleShowKeyboardShortcuts}
+        @show-keyboard-shortcuts=${this.showKeyboardShortcuts}
         .mobileSearchHidden=${!this.mobileSearch}
         .loginUrl=${this.loginUrl}
         ?aria-hidden=${this.footerHeaderAriaHidden}
@@ -640,8 +640,8 @@
       await this.updateComplete;
       assertIsDefined(this.registrationOverlay, 'registrationOverlay');
       assertIsDefined(this.registrationDialog, 'registrationDialog');
-      this.registrationOverlay.open();
-      this.registrationDialog.loadData().then(() => {
+      await this.registrationOverlay.open();
+      await this.registrationDialog.loadData().then(() => {
         this.registrationOverlay!.refit();
       });
     }
@@ -751,26 +751,18 @@
     }
   }
 
-  private async handleShowKeyboardShortcuts() {
+  private async showKeyboardShortcuts() {
     this.loadKeyboardShortcutsDialog = true;
     await this.updateComplete;
     assertIsDefined(this.keyboardShortcuts, 'keyboardShortcuts');
-    this.keyboardShortcuts.open();
-  }
 
-  private async showKeyboardShortcuts() {
-    // same shortcut should close the dialog if pressed again
-    // when dialog is open
-    this.loadKeyboardShortcutsDialog = true;
-    await this.updateComplete;
-    if (!this.keyboardShortcuts) return;
     if (this.keyboardShortcuts.opened) {
       this.keyboardShortcuts.cancel();
       return;
     }
-    this.keyboardShortcuts.open();
     this.footerHeaderAriaHidden = true;
     this.mainAriaHidden = true;
+    await this.keyboardShortcuts.open();
   }
 
   private handleKeyboardShortcutDialogClose() {
diff --git a/polygerrit-ui/app/elements/lit/subscription-controller.ts b/polygerrit-ui/app/elements/lit/subscription-controller.ts
index b37a978..fdd24cf 100644
--- a/polygerrit-ui/app/elements/lit/subscription-controller.ts
+++ b/polygerrit-ui/app/elements/lit/subscription-controller.ts
@@ -5,50 +5,46 @@
  */
 import {ReactiveController, ReactiveControllerHost} from 'lit';
 import {Observable, Subscription} from 'rxjs';
+import {Provider} from '../../models/dependency';
 
-const SUBSCRIPTION_SYMBOL = Symbol('subscriptions');
-
-// Checks whether a subscription can be added. Returns true if it can be added,
-// return false if it's already present.
-// Subscriptions are stored on the host so they have the same life-time as the
-// host.
-function checkSubscription<T>(
-  host: ReactiveControllerHost,
-  obs$: Observable<T>,
-  setProp: (t: T) => void
-): boolean {
-  // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  const hostSubscriptions = ((host as any)[SUBSCRIPTION_SYMBOL] ||= new Map());
-  if (!hostSubscriptions.has(obs$)) hostSubscriptions.set(obs$, new Set());
-  const obsSubscriptions = hostSubscriptions.get(obs$);
-  if (obsSubscriptions.has(setProp)) return false;
-  obsSubscriptions.add(setProp);
-  return true;
+export class SubscriptionError extends Error {
+  constructor(message: string) {
+    super(message);
+  }
 }
 
 /**
  * Enables components to simply hook up a property with an Observable like so:
  *
- * subscribe(this, obs$, x => (this.prop = x));
+ * subscribe(this, () => obs$, x => (this.prop = x));
  */
 export function subscribe<T>(
-  host: ReactiveControllerHost,
-  obs$: Observable<T>,
-  setProp: (t: T) => void
+  host: ReactiveControllerHost & HTMLElement,
+  provider: Provider<Observable<T>>,
+  callback: (t: T) => void
 ) {
-  if (!checkSubscription(host, obs$, setProp)) return;
-  host.addController(new SubscriptionController(obs$, setProp));
+  if (host.isConnected)
+    throw new Error(
+      'Subscriptions should happen before a component is connected'
+    );
+  const controller = new SubscriptionController(provider, callback);
+  host.addController(controller);
 }
+
 export class SubscriptionController<T> implements ReactiveController {
   private sub?: Subscription;
 
   constructor(
-    private readonly obs$: Observable<T>,
-    private readonly setProp: (t: T) => void
+    private readonly provider: Provider<Observable<T>>,
+    private readonly callback: (t: T) => void
   ) {}
 
   hostConnected() {
-    this.sub = this.obs$.subscribe(this.setProp);
+    this.sub = this.provider().subscribe(v => this.update(v));
+  }
+
+  update(value: T) {
+    this.callback(value);
   }
 
   hostDisconnected() {
diff --git a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.ts b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.ts
index 64548ac..eb59134 100644
--- a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.ts
@@ -130,8 +130,7 @@
     }
     const expectProperties = this.getEndpointParams().map(paramEl => {
       const helper = plugin.attributeHelper(paramEl);
-      // TODO: this should be replaced by accessing the property directly
-      const paramName = paramEl.getAttribute('name');
+      const paramName = paramEl.name;
       if (!paramName) {
         this.reporting.error(
           new Error(
diff --git a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.ts b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.ts
index 34e376b..a09cdbc 100644
--- a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.ts
+++ b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.ts
@@ -17,8 +17,6 @@
 import '../../shared/gr-button/gr-button';
 import {ServerInfo} from '../../../types/common';
 import {getAppContext} from '../../../services/app-context';
-import {columnNames} from '../../change-list/gr-change-list/gr-change-list';
-import {KnownExperimentId} from '../../../services/flags/flags';
 import {LitElement, css, html} from 'lit';
 import {customElement, property} from 'lit/decorators';
 import {sharedStyles} from '../../../styles/shared-styles';
@@ -26,6 +24,7 @@
 import {PropertyValues} from 'lit';
 import {fire} from '../../../utils/event-util';
 import {ValueChangedEvent} from '../../../types/events';
+import {ColumnNames} from '../../../constants/constants';
 
 @customElement('gr-change-table-editor')
 export class GrChangeTableEditor extends LitElement {
@@ -119,7 +118,7 @@
   }
 
   private configChanged() {
-    this.defaultColumns = columnNames.filter(column =>
+    this.defaultColumns = Object.values(ColumnNames).filter(column =>
       this.isColumnEnabled(column)
     );
     if (!this.displayedColumns) return;
@@ -134,16 +133,9 @@
    */
   isColumnEnabled(column: string) {
     if (!this.serverConfig?.change) return true;
-    if (column === 'Comments')
+    if (column === ColumnNames.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 === ColumnNames.STATUS) return false;
     return true;
   }
 
diff --git a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.ts b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.ts
index c37c3f9..42ef8f4 100644
--- a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.ts
+++ b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.ts
@@ -20,6 +20,7 @@
 import {queryAndAssert} from '../../../test/test-utils';
 import {createServerInfo} from '../../../test/test-data-generators';
 import {fixture, html} from '@open-wc/testing-helpers';
+import {ColumnNames} from '../../../constants/constants';
 
 suite('gr-change-table-editor tests', () => {
   let element: GrChangeTableEditor;
@@ -37,8 +38,8 @@
       'Reviewers',
       'Comments',
       'Repo',
-      'Branch',
-      'Updated',
+      ColumnNames.BRANCH,
+      ColumnNames.UPDATED,
     ];
 
     element.displayedColumns = columns;
@@ -70,12 +71,6 @@
             </td>
           </tr>
           <tr>
-            <td><label for="Status"> Status </label></td>
-            <td class="checkboxContainer">
-              <input checked="" id="Status" name="Status" type="checkbox" />
-            </td>
-          </tr>
-          <tr>
             <td><label for="Owner"> Owner </label></td>
             <td class="checkboxContainer">
               <input checked="" id="Owner" name="Owner" type="checkbox" />
@@ -116,6 +111,12 @@
               <input id="Size" name="Size" type="checkbox" />
             </td>
           </tr>
+          <tr>
+            <td><label for=" Status "> Status </label></td>
+            <td class="checkboxContainer">
+              <input id=" Status " name=" Status " type="checkbox" />
+            </td>
+          </tr>
         </tbody>
       </table>
     </div>`);
@@ -150,7 +151,13 @@
   });
 
   test('show item', async () => {
-    element.displayedColumns = ['Status', 'Owner', 'Repo', 'Branch', 'Updated'];
+    element.displayedColumns = [
+      ColumnNames.STATUS,
+      ColumnNames.OWNER,
+      ColumnNames.REPO,
+      ColumnNames.BRANCH,
+      ColumnNames.UPDATED,
+    ];
     // trigger computation of enabled displayed columns
     element.serverConfig = createServerInfo();
     await element.updateComplete;
diff --git a/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences.ts b/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences.ts
index 0f7e065..94ccc16 100644
--- a/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences.ts
+++ b/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences.ts
@@ -60,12 +60,16 @@
 
   private readonly userModel = getAppContext().userModel;
 
-  override connectedCallback() {
-    super.connectedCallback();
-    subscribe(this, this.userModel.editPreferences$, editPreferences => {
-      this.originalEditPrefs = editPreferences;
-      this.editPrefs = {...editPreferences};
-    });
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.userModel.editPreferences$,
+      editPreferences => {
+        this.originalEditPrefs = editPreferences;
+        this.editPrefs = {...editPreferences};
+      }
+    );
   }
 
   static override get styles() {
diff --git a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.ts b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.ts
index 845b30c..46c2956 100644
--- a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.ts
+++ b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.ts
@@ -35,12 +35,16 @@
 
   private readonly userModel = getAppContext().userModel;
 
-  override connectedCallback() {
-    super.connectedCallback();
-    subscribe(this, this.userModel.preferences$, prefs => {
-      this.originalPrefs = prefs;
-      this.menuItems = [...prefs.my];
-    });
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.userModel.preferences$,
+      prefs => {
+        this.originalPrefs = prefs;
+        this.menuItems = [...prefs.my];
+      }
+    );
   }
 
   static override styles = [
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.ts b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.ts
index 94c21f6..c4bafac 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.ts
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.ts
@@ -48,6 +48,7 @@
 import {getAppContext} from '../../../services/app-context';
 import {GerritView} from '../../../services/router/router-model';
 import {
+  ColumnNames,
   DateFormat,
   DefaultBase,
   DiffViewMode,
@@ -55,10 +56,9 @@
   EmailStrategy,
   TimeFormat,
 } from '../../../constants/constants';
-import {columnNames} from '../../change-list/gr-change-list/gr-change-list';
 import {windowLocationReload} from '../../../utils/dom-util';
 import {BindValueChangeEvent, ValueChangedEvent} from '../../../types/events';
-import {LitElement, css, html} from 'lit';
+import {LitElement, css, html, nothing} from 'lit';
 import {customElement, property, query, state} from 'lit/decorators';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {paperStyles} from '../../../styles/gr-paper-styles';
@@ -228,7 +228,7 @@
         this.copyPrefs(CopyPrefsDirection.PrefsToLocalPrefs);
         this.localChangeTableColumns =
           prefs.change_table.length === 0
-            ? columnNames
+            ? Object.values(ColumnNames)
             : prefs.change_table.map(column =>
                 column === 'Project' ? 'Repo' : column
               );
@@ -408,279 +408,16 @@
             Preferences
           </h2>
           <fieldset id="preferences">
-            <section>
-              <label class="title" for="changesPerPageSelect"
-                >Changes per page</label
-              >
-              <span class="value">
-                <gr-select
-                  .bindValue=${this.convertToString(
-                    this.localPrefs.changes_per_page
-                  )}
-                  @change=${() => {
-                    this.localPrefs.changes_per_page = Number(
-                      this.changesPerPageSelect.value
-                    ) as 10 | 25 | 50 | 100;
-                    this.prefsChanged = true;
-                  }}
-                >
-                  <select id="changesPerPageSelect">
-                    <option value="10">10 rows per page</option>
-                    <option value="25">25 rows per page</option>
-                    <option value="50">50 rows per page</option>
-                    <option value="100">100 rows per page</option>
-                  </select>
-                </gr-select>
-              </span>
-            </section>
-            <section>
-              <label class="title" for="dateTimeFormatSelect"
-                >Date/time format</label
-              >
-              <span class="value">
-                <gr-select
-                  .bindValue=${this.convertToString(
-                    this.localPrefs.date_format
-                  )}
-                  @change=${() => {
-                    this.localPrefs.date_format = this.dateTimeFormatSelect
-                      .value as DateFormat;
-                    this.prefsChanged = true;
-                  }}
-                >
-                  <select id="dateTimeFormatSelect">
-                    <option value="STD">Jun 3 ; Jun 3, 2016</option>
-                    <option value="US">06/03 ; 06/03/16</option>
-                    <option value="ISO">06-03 ; 2016-06-03</option>
-                    <option value="EURO">3. Jun ; 03.06.2016</option>
-                    <option value="UK">03/06 ; 03/06/2016</option>
-                  </select>
-                </gr-select>
-                <gr-select
-                  .bindValue=${this.convertToString(
-                    this.localPrefs.time_format
-                  )}
-                  aria-label="Time Format"
-                  @change=${() => {
-                    this.localPrefs.time_format = this.timeFormatSelect
-                      .value as TimeFormat;
-                    this.prefsChanged = true;
-                  }}
-                >
-                  <select id="timeFormatSelect">
-                    <option value="HHMM_12">4:10 PM</option>
-                    <option value="HHMM_24">16:10</option>
-                  </select>
-                </gr-select>
-              </span>
-            </section>
-            <section>
-              <label class="title" for="emailNotificationsSelect"
-                >Email notifications</label
-              >
-              <span class="value">
-                <gr-select
-                  .bindValue=${this.convertToString(
-                    this.localPrefs.email_strategy
-                  )}
-                  @change=${() => {
-                    this.localPrefs.email_strategy = this
-                      .emailNotificationsSelect.value as EmailStrategy;
-                    this.prefsChanged = true;
-                  }}
-                >
-                  <select id="emailNotificationsSelect">
-                    <option value="CC_ON_OWN_COMMENTS">Every comment</option>
-                    <option value="ENABLED">
-                      Only comments left by others
-                    </option>
-                    <option value="ATTENTION_SET_ONLY">
-                      Only when I am in the attention set
-                    </option>
-                    <option value="DISABLED">None</option>
-                  </select>
-                </gr-select>
-              </span>
-            </section>
-            <section
-              ?hidden=${!this.convertToString(this.localPrefs.email_format)}
-            >
-              <label class="title" for="emailFormatSelect">Email format</label>
-              <span class="value">
-                <gr-select
-                  .bindValue=${this.convertToString(
-                    this.localPrefs.email_format
-                  )}
-                  @change=${() => {
-                    this.localPrefs.email_format = this.emailFormatSelect
-                      .value as EmailFormat;
-                    this.prefsChanged = true;
-                  }}
-                >
-                  <select id="emailFormatSelect">
-                    <option value="HTML_PLAINTEXT">HTML and plaintext</option>
-                    <option value="PLAINTEXT">Plaintext only</option>
-                  </select>
-                </gr-select>
-              </span>
-            </section>
-            <section ?hidden=${!this.localPrefs.default_base_for_merges}>
-              <span class="title">Default Base For Merges</span>
-              <span class="value">
-                <gr-select
-                  .bindValue=${this.convertToString(
-                    this.localPrefs.default_base_for_merges
-                  )}
-                  @change=${() => {
-                    this.localPrefs.default_base_for_merges = this
-                      .defaultBaseForMergesSelect.value as DefaultBase;
-                    this.prefsChanged = true;
-                  }}
-                >
-                  <select id="defaultBaseForMergesSelect">
-                    <option value="AUTO_MERGE">Auto Merge</option>
-                    <option value="FIRST_PARENT">First Parent</option>
-                  </select>
-                </gr-select>
-              </span>
-            </section>
-            <section>
-              <label class="title" for="relativeDateInChangeTable"
-                >Show Relative Dates In Changes Table</label
-              >
-              <span class="value">
-                <input
-                  id="relativeDateInChangeTable"
-                  type="checkbox"
-                  ?checked=${this.localPrefs.relative_date_in_change_table}
-                  @change=${() => {
-                    this.localPrefs.relative_date_in_change_table =
-                      this.relativeDateInChangeTable.checked;
-                    this.prefsChanged = true;
-                  }}
-                />
-              </span>
-            </section>
-            <section>
-              <span class="title">Diff view</span>
-              <span class="value">
-                <gr-select
-                  .bindValue=${this.convertToString(this.localPrefs.diff_view)}
-                  @change=${() => {
-                    this.localPrefs.diff_view = this.diffViewSelect
-                      .value as DiffViewMode;
-                    this.prefsChanged = true;
-                  }}
-                >
-                  <select id="diffViewSelect">
-                    <option value="SIDE_BY_SIDE">Side by side</option>
-                    <option value="UNIFIED_DIFF">Unified diff</option>
-                  </select>
-                </gr-select>
-              </span>
-            </section>
-            <section>
-              <label for="showSizeBarsInFileList" class="title"
-                >Show size bars in file list</label
-              >
-              <span class="value">
-                <input
-                  id="showSizeBarsInFileList"
-                  type="checkbox"
-                  ?checked=${this.localPrefs.size_bar_in_change_table}
-                  @change=${() => {
-                    this.localPrefs.size_bar_in_change_table =
-                      this.showSizeBarsInFileList.checked;
-                    this.prefsChanged = true;
-                  }}
-                />
-              </span>
-            </section>
-            <section>
-              <label for="publishCommentsOnPush" class="title"
-                >Publish comments on push</label
-              >
-              <span class="value">
-                <input
-                  id="publishCommentsOnPush"
-                  type="checkbox"
-                  ?checked=${this.localPrefs.publish_comments_on_push}
-                  @change=${() => {
-                    this.localPrefs.publish_comments_on_push =
-                      this.publishCommentsOnPush.checked;
-                    this.prefsChanged = true;
-                  }}
-                />
-              </span>
-            </section>
-            <section>
-              <label for="workInProgressByDefault" class="title"
-                >Set new changes to "work in progress" by default</label
-              >
-              <span class="value">
-                <input
-                  id="workInProgressByDefault"
-                  type="checkbox"
-                  ?checked=${this.localPrefs.work_in_progress_by_default}
-                  @change=${() => {
-                    this.localPrefs.work_in_progress_by_default =
-                      this.workInProgressByDefault.checked;
-                    this.prefsChanged = true;
-                  }}
-                />
-              </span>
-            </section>
-            <section>
-              <label for="disableKeyboardShortcuts" class="title"
-                >Disable all keyboard shortcuts</label
-              >
-              <span class="value">
-                <input
-                  id="disableKeyboardShortcuts"
-                  type="checkbox"
-                  ?checked=${this.localPrefs.disable_keyboard_shortcuts}
-                  @change=${() => {
-                    this.localPrefs.disable_keyboard_shortcuts =
-                      this.disableKeyboardShortcuts.checked;
-                    this.prefsChanged = true;
-                  }}
-                />
-              </span>
-            </section>
-            <section>
-              <label for="disableTokenHighlighting" class="title"
-                >Disable token highlighting on hover</label
-              >
-              <span class="value">
-                <input
-                  id="disableTokenHighlighting"
-                  type="checkbox"
-                  ?checked=${this.localPrefs.disable_token_highlighting}
-                  @change=${() => {
-                    this.localPrefs.disable_token_highlighting =
-                      this.disableTokenHighlighting.checked;
-                    this.prefsChanged = true;
-                  }}
-                />
-              </span>
-            </section>
-            <section>
-              <label for="insertSignedOff" class="title">
-                Insert Signed-off-by Footer For Inline Edit Changes
-              </label>
-              <span class="value">
-                <input
-                  id="insertSignedOff"
-                  type="checkbox"
-                  ?checked=${this.localPrefs.signed_off_by}
-                  @change=${() => {
-                    this.localPrefs.signed_off_by =
-                      this.insertSignedOff.checked;
-                    this.prefsChanged = true;
-                  }}
-                />
-              </span>
-            </section>
+            ${this.renderChangesPerPages()} ${this.renderDateTimeFormat()}
+            ${this.renderEmailNotification()} ${this.renderEmailFormat()}
+            ${this.renderDefaultBaseForMerges()}
+            ${this.renderRelativeDateInChangeTable()} ${this.renderDiffView()}
+            ${this.renderShowSizeBarsInFileList()}
+            ${this.renderPublishCommentsOnPush()}
+            ${this.renderWorkInProgressByDefault()}
+            ${this.renderDisableKeyboardShortcuts()}
+            ${this.renderDisableTokenHighlighting()}
+            ${this.renderInsertSignedOff()}
             <gr-button
               id="savePrefs"
               @click=${this.handleSavePreferences}
@@ -990,6 +727,327 @@
     super.disconnectedCallback();
   }
 
+  private renderChangesPerPages() {
+    return html`
+      <section>
+        <label class="title" for="changesPerPageSelect">Changes per page</label>
+        <span class="value">
+          <gr-select
+            .bindValue=${this.convertToString(this.localPrefs.changes_per_page)}
+            @change=${() => {
+              this.localPrefs.changes_per_page = Number(
+                this.changesPerPageSelect.value
+              ) as 10 | 25 | 50 | 100;
+              this.prefsChanged = true;
+            }}
+          >
+            <select id="changesPerPageSelect">
+              <option value="10">10 rows per page</option>
+              <option value="25">25 rows per page</option>
+              <option value="50">50 rows per page</option>
+              <option value="100">100 rows per page</option>
+            </select>
+          </gr-select>
+        </span>
+      </section>
+    `;
+  }
+
+  private renderDateTimeFormat() {
+    return html`
+      <section>
+        <label class="title" for="dateTimeFormatSelect">Date/time format</label>
+        <span class="value">
+          <gr-select
+            .bindValue=${this.convertToString(this.localPrefs.date_format)}
+            @change=${() => {
+              this.localPrefs.date_format = this.dateTimeFormatSelect
+                .value as DateFormat;
+              this.prefsChanged = true;
+            }}
+          >
+            <select id="dateTimeFormatSelect">
+              <option value="STD">Jun 3 ; Jun 3, 2016</option>
+              <option value="US">06/03 ; 06/03/16</option>
+              <option value="ISO">06-03 ; 2016-06-03</option>
+              <option value="EURO">3. Jun ; 03.06.2016</option>
+              <option value="UK">03/06 ; 03/06/2016</option>
+            </select>
+          </gr-select>
+          <gr-select
+            .bindValue=${this.convertToString(this.localPrefs.time_format)}
+            aria-label="Time Format"
+            @change=${() => {
+              this.localPrefs.time_format = this.timeFormatSelect
+                .value as TimeFormat;
+              this.prefsChanged = true;
+            }}
+          >
+            <select id="timeFormatSelect">
+              <option value="HHMM_12">4:10 PM</option>
+              <option value="HHMM_24">16:10</option>
+            </select>
+          </gr-select>
+        </span>
+      </section>
+    `;
+  }
+
+  private renderEmailNotification() {
+    return html`
+      <section>
+        <label class="title" for="emailNotificationsSelect"
+          >Email notifications</label
+        >
+        <span class="value">
+          <gr-select
+            .bindValue=${this.convertToString(this.localPrefs.email_strategy)}
+            @change=${() => {
+              this.localPrefs.email_strategy = this.emailNotificationsSelect
+                .value as EmailStrategy;
+              this.prefsChanged = true;
+            }}
+          >
+            <select id="emailNotificationsSelect">
+              <option value="CC_ON_OWN_COMMENTS">Every comment</option>
+              <option value="ENABLED">Only comments left by others</option>
+              <option value="ATTENTION_SET_ONLY">
+                Only when I am in the attention set
+              </option>
+              <option value="DISABLED">None</option>
+            </select>
+          </gr-select>
+        </span>
+      </section>
+    `;
+  }
+
+  private renderEmailFormat() {
+    if (!this.localPrefs.email_format) return nothing;
+    return html`
+      <section>
+        <label class="title" for="emailFormatSelect">Email format</label>
+        <span class="value">
+          <gr-select
+            .bindValue=${this.convertToString(this.localPrefs.email_format)}
+            @change=${() => {
+              this.localPrefs.email_format = this.emailFormatSelect
+                .value as EmailFormat;
+              this.prefsChanged = true;
+            }}
+          >
+            <select id="emailFormatSelect">
+              <option value="HTML_PLAINTEXT">HTML and plaintext</option>
+              <option value="PLAINTEXT">Plaintext only</option>
+            </select>
+          </gr-select>
+        </span>
+      </section>
+    `;
+  }
+
+  private renderDefaultBaseForMerges() {
+    if (!this.localPrefs.default_base_for_merges) return nothing;
+    return html`
+      <section>
+        <span class="title">Default Base For Merges</span>
+        <span class="value">
+          <gr-select
+            .bindValue=${this.convertToString(
+              this.localPrefs.default_base_for_merges
+            )}
+            @change=${() => {
+              this.localPrefs.default_base_for_merges = this
+                .defaultBaseForMergesSelect.value as DefaultBase;
+              this.prefsChanged = true;
+            }}
+          >
+            <select id="defaultBaseForMergesSelect">
+              <option value="AUTO_MERGE">Auto Merge</option>
+              <option value="FIRST_PARENT">First Parent</option>
+            </select>
+          </gr-select>
+        </span>
+      </section>
+    `;
+  }
+
+  private renderRelativeDateInChangeTable() {
+    return html`
+      <section>
+        <label class="title" for="relativeDateInChangeTable"
+          >Show Relative Dates In Changes Table</label
+        >
+        <span class="value">
+          <input
+            id="relativeDateInChangeTable"
+            type="checkbox"
+            ?checked=${this.localPrefs.relative_date_in_change_table}
+            @change=${() => {
+              this.localPrefs.relative_date_in_change_table =
+                this.relativeDateInChangeTable.checked;
+              this.prefsChanged = true;
+            }}
+          />
+        </span>
+      </section>
+    `;
+  }
+
+  private renderDiffView() {
+    return html`
+      <section>
+        <span class="title">Diff view</span>
+        <span class="value">
+          <gr-select
+            .bindValue=${this.convertToString(this.localPrefs.diff_view)}
+            @change=${() => {
+              this.localPrefs.diff_view = this.diffViewSelect
+                .value as DiffViewMode;
+              this.prefsChanged = true;
+            }}
+          >
+            <select id="diffViewSelect">
+              <option value="SIDE_BY_SIDE">Side by side</option>
+              <option value="UNIFIED_DIFF">Unified diff</option>
+            </select>
+          </gr-select>
+        </span>
+      </section>
+    `;
+  }
+
+  private renderShowSizeBarsInFileList() {
+    return html`
+      <section>
+        <label for="showSizeBarsInFileList" class="title"
+          >Show size bars in file list</label
+        >
+        <span class="value">
+          <input
+            id="showSizeBarsInFileList"
+            type="checkbox"
+            ?checked=${this.localPrefs.size_bar_in_change_table}
+            @change=${() => {
+              this.localPrefs.size_bar_in_change_table =
+                this.showSizeBarsInFileList.checked;
+              this.prefsChanged = true;
+            }}
+          />
+        </span>
+      </section>
+    `;
+  }
+
+  private renderPublishCommentsOnPush() {
+    return html`
+      <section>
+        <label for="publishCommentsOnPush" class="title"
+          >Publish comments on push</label
+        >
+        <span class="value">
+          <input
+            id="publishCommentsOnPush"
+            type="checkbox"
+            ?checked=${this.localPrefs.publish_comments_on_push}
+            @change=${() => {
+              this.localPrefs.publish_comments_on_push =
+                this.publishCommentsOnPush.checked;
+              this.prefsChanged = true;
+            }}
+          />
+        </span>
+      </section>
+    `;
+  }
+
+  private renderWorkInProgressByDefault() {
+    return html`
+      <section>
+        <label for="workInProgressByDefault" class="title"
+          >Set new changes to "work in progress" by default</label
+        >
+        <span class="value">
+          <input
+            id="workInProgressByDefault"
+            type="checkbox"
+            ?checked=${this.localPrefs.work_in_progress_by_default}
+            @change=${() => {
+              this.localPrefs.work_in_progress_by_default =
+                this.workInProgressByDefault.checked;
+              this.prefsChanged = true;
+            }}
+          />
+        </span>
+      </section>
+    `;
+  }
+
+  private renderDisableKeyboardShortcuts() {
+    return html`
+      <section>
+        <label for="disableKeyboardShortcuts" class="title"
+          >Disable all keyboard shortcuts</label
+        >
+        <span class="value">
+          <input
+            id="disableKeyboardShortcuts"
+            type="checkbox"
+            ?checked=${this.localPrefs.disable_keyboard_shortcuts}
+            @change=${() => {
+              this.localPrefs.disable_keyboard_shortcuts =
+                this.disableKeyboardShortcuts.checked;
+              this.prefsChanged = true;
+            }}
+          />
+        </span>
+      </section>
+    `;
+  }
+
+  private renderDisableTokenHighlighting() {
+    return html`
+      <section>
+        <label for="disableTokenHighlighting" class="title"
+          >Disable token highlighting on hover</label
+        >
+        <span class="value">
+          <input
+            id="disableTokenHighlighting"
+            type="checkbox"
+            ?checked=${this.localPrefs.disable_token_highlighting}
+            @change=${() => {
+              this.localPrefs.disable_token_highlighting =
+                this.disableTokenHighlighting.checked;
+              this.prefsChanged = true;
+            }}
+          />
+        </span>
+      </section>
+    `;
+  }
+
+  private renderInsertSignedOff() {
+    return html`
+      <section>
+        <label for="insertSignedOff" class="title">
+          Insert Signed-off-by Footer For Inline Edit Changes
+        </label>
+        <span class="value">
+          <input
+            id="insertSignedOff"
+            type="checkbox"
+            ?checked=${this.localPrefs.signed_off_by}
+            @change=${() => {
+              this.localPrefs.signed_off_by = this.insertSignedOff.checked;
+              this.prefsChanged = true;
+            }}
+          />
+        </span>
+      </section>
+    `;
+  }
+
   private readonly handleLocationChange = () => {
     // Handle anchor tag after dom attached
     const urlHash = window.location.hash;
diff --git a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.ts b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.ts
index 689a9fb..7f20a1a 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.ts
@@ -27,7 +27,6 @@
 import {LitElement, css, html} from 'lit';
 import {customElement, property} from 'lit/decorators';
 import {ClassInfo, classMap} from 'lit/directives/class-map';
-import {KnownExperimentId} from '../../../services/flags/flags';
 import {getLabelStatus, hasVoted, LabelStatus} from '../../../utils/label-util';
 
 @customElement('gr-account-chip')
@@ -94,8 +93,6 @@
 
   private readonly restApiService = getAppContext().restApiService;
 
-  private readonly flagsService = getAppContext().flagsService;
-
   static override get styles() {
     return [
       css`
@@ -252,12 +249,7 @@
   }
 
   private computeVoteClasses(): ClassInfo {
-    if (
-      !this.flagsService.isEnabled(KnownExperimentId.SUBMIT_REQUIREMENTS_UI) ||
-      !this.label ||
-      !this.account ||
-      !hasVoted(this.label, this.account)
-    ) {
+    if (!this.label || !this.account || !hasVoted(this.label, this.account)) {
       return {};
     }
     const status = getLabelStatus(this.label, this.vote?.value);
diff --git a/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry.ts b/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry.ts
index 6060826..c642db1 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry.ts
@@ -24,6 +24,7 @@
 import {LitElement, PropertyValues, html, css} from 'lit';
 import {customElement, property, query, state} from 'lit/decorators';
 import {BindValueChangeEvent} from '../../../types/events';
+import {SuggestedReviewerInfo} from '../../../types/common';
 
 /**
  * gr-account-entry is an element for entering account
@@ -57,7 +58,8 @@
   placeholder = '';
 
   @property({type: Object})
-  querySuggestions: AutocompleteQuery = () => Promise.resolve([]);
+  querySuggestions: AutocompleteQuery<SuggestedReviewerInfo> = () =>
+    Promise.resolve([]);
 
   @state() private inputText = '';
 
diff --git a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.ts b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.ts
index 1143b4e..3fecd63 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.ts
@@ -25,6 +25,7 @@
   EmailAddress,
   SuggestedReviewerGroupInfo,
   SuggestedReviewerAccountInfo,
+  SuggestedReviewerInfo,
 } from '../../../types/common';
 import {ReviewerSuggestionsProvider} from '../../../scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider';
 import {GrAccountEntry} from '../gr-account-entry/gr-account-entry';
@@ -166,13 +167,10 @@
   @property({type: Array})
   removableValues?: AccountInput[];
 
-  @property({type: Number})
-  maxCount = 0;
-
   /**
    * Returns suggestion items
    */
-  @state() private querySuggestions: AutocompleteQuery;
+  @state() private querySuggestions: AutocompleteQuery<SuggestedReviewerInfo>;
 
   private readonly reporting = getAppContext().reportingService;
 
@@ -202,7 +200,7 @@
       .group {
         --account-label-suffix: ' (group)';
       }
-      .pending-add {
+      .pendingAdd {
         font-style: italic;
       }
       .list {
@@ -233,8 +231,7 @@
       </div>
       <gr-account-entry
         borderless=""
-        ?hidden=${(this.maxCount && this.maxCount <= this.accounts.length) ||
-        this.readonly}
+        ?hidden=${this.readonly}
         id="entry"
         .placeholder=${this.placeholder}
         @add=${this.handleAdd}
@@ -265,7 +262,9 @@
     return this.entry?.focusStart;
   }
 
-  getSuggestions(input: string): Promise<AutocompleteSuggestion[]> {
+  getSuggestions(
+    input: string
+  ): Promise<AutocompleteSuggestion<SuggestedReviewerInfo>[]> {
     const provider = this.suggestionsProvider;
     if (!provider) return Promise.resolve([]);
     return provider.getSuggestions(input).then(suggestions => {
diff --git a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_test.ts b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_test.ts
index 981ad32..26566a3 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_test.ts
@@ -28,12 +28,16 @@
   GroupBaseInfo,
   GroupId,
   GroupName,
+  SuggestedReviewerInfo,
   Suggestion,
 } from '../../../types/common';
 import {queryAll, queryAndAssert, waitUntil} from '../../../test/test-utils';
 import {ReviewerSuggestionsProvider} from '../../../scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider';
 import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
-import {GrAutocomplete} from '../gr-autocomplete/gr-autocomplete';
+import {
+  AutocompleteSuggestion,
+  GrAutocomplete,
+} from '../gr-autocomplete/gr-autocomplete';
 import {GrAccountEntry} from '../gr-account-entry/gr-account-entry';
 
 const basicFixture = fixtureFromElement('gr-account-list');
@@ -45,7 +49,9 @@
     return Promise.resolve([]);
   }
 
-  makeSuggestionItem(_: Suggestion) {
+  makeSuggestionItem(
+    _: Suggestion
+  ): AutocompleteSuggestion<SuggestedReviewerInfo> {
     return {
       name: 'test',
       value: {
@@ -53,7 +59,7 @@
           _account_id: 1 as AccountId,
         } as AccountInfo,
         count: 1,
-      } as unknown as string,
+      },
     };
   }
 }
@@ -234,7 +240,7 @@
           value: {
             account: suggestion as AccountInfo,
             count: 1,
-          } as unknown as string,
+          },
         };
       });
 
@@ -259,7 +265,7 @@
             value: {
               account: originalSuggestions[0] as AccountInfo,
               count: 1,
-            } as unknown as string,
+            },
           },
         ]);
       });
@@ -394,16 +400,6 @@
     assert.equal(element.accounts.length, 1);
   });
 
-  test('max-count', async () => {
-    element.maxCount = 1;
-    const acct = makeAccount();
-    handleAdd({account: acct, count: 1});
-    await element.updateComplete;
-    assert.isTrue(
-      queryAndAssert<GrAccountEntry>(element, '#entry').hasAttribute('hidden')
-    );
-  });
-
   test('enter text calls suggestions provider', async () => {
     const suggestions: Suggestion[] = [
       {
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.ts b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.ts
index 04fdd0f..efcb751 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.ts
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.ts
@@ -283,7 +283,7 @@
       changedProperties.has('suggestions') ||
       changedProperties.has('focused')
     ) {
-      this.maybeOpenDropdown();
+      this.updateDropdownVisibility();
     }
     if (changedProperties.has('text')) {
       fire(this, 'text-changed', {value: this.text});
@@ -475,10 +475,10 @@
   setFocus(focused: boolean) {
     if (focused === this.focused) return;
     this.focused = focused;
-    this.maybeOpenDropdown();
+    this.updateDropdownVisibility();
   }
 
-  maybeOpenDropdown() {
+  updateDropdownVisibility() {
     if (this.suggestions.length > 0 && this.focused) {
       this.suggestionsDropdown?.open();
       return;
@@ -620,7 +620,7 @@
 
     // Value and text are mirrors of each other in multi mode.
     if (this.multi) {
-      this.setText(this.value);
+      await this.setText(this.value);
     } else {
       if (!this.clearOnCommit && this.selected) {
         const dataSet = this.selected.dataset;
@@ -628,7 +628,7 @@
         if (dataSet) {
           const index = Number(dataSet['index']!);
           if (isNaN(index)) return;
-          this.setText(this.suggestions[index]?.name || '');
+          await this.setText(this.suggestions[index]?.name || '');
         }
       } else {
         this.clear();
diff --git a/polygerrit-ui/app/elements/shared/gr-button/gr-button.ts b/polygerrit-ui/app/elements/shared/gr-button/gr-button.ts
index 1282666..3181445 100644
--- a/polygerrit-ui/app/elements/shared/gr-button/gr-button.ts
+++ b/polygerrit-ui/app/elements/shared/gr-button/gr-button.ts
@@ -22,7 +22,6 @@
 import {addShortcut, getEventPath, Key} from '../../../utils/dom-util';
 import {getAppContext} from '../../../services/app-context';
 import {classMap} from 'lit/directives/class-map';
-import {KnownExperimentId} from '../../../services/flags/flags';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -161,6 +160,10 @@
           cursor: default;
         }
 
+        :host([disabled][flatten]) {
+          --background-color: transparent;
+        }
+
         /* Styles for link buttons specifically */
         :host([link]) {
           --background-color: transparent;
@@ -202,12 +205,6 @@
     ];
   }
 
-  private readonly flagsService = getAppContext().flagsService;
-
-  private readonly isSubmitRequirementsUiEnabled = this.flagsService.isEnabled(
-    KnownExperimentId.SUBMIT_REQUIREMENTS_UI
-  );
-
   override render() {
     return html`<paper-button
       ?raised=${!this.link && !this.flatten}
@@ -216,8 +213,7 @@
       tabindex="-1"
       part="paper-button"
       class=${classMap({
-        voteChip: this.voteChip && !this.isSubmitRequirementsUiEnabled,
-        newVoteChip: this.voteChip && this.isSubmitRequirementsUiEnabled,
+        newVoteChip: this.voteChip,
       })}
     >
       ${this.loading ? html`<span class="loadingSpin"></span>` : ''}
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
index 471ebd6..f5f9584 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
@@ -264,35 +264,49 @@
     super();
     this.shortcuts.addGlobal({key: 'e'}, () => this.handleExpandShortcut());
     this.shortcuts.addGlobal({key: 'E'}, () => this.handleCollapseShortcut());
-  }
-
-  override connectedCallback(): void {
-    super.connectedCallback();
     subscribe(
       this,
-      this.getChangeModel().changeNum$,
+      () => this.getChangeModel().changeNum$,
       x => (this.changeNum = x)
     );
-    subscribe(this, this.userModel.account$, x => (this.account = x));
-    subscribe(this, this.getChangeModel().repo$, x => (this.repoName = x));
-    subscribe(this, this.userModel.diffPreferences$, x =>
-      this.syntaxLayer.setEnabled(!!x.syntax_highlighting)
+    subscribe(
+      this,
+      () => this.userModel.account$,
+      x => (this.account = x)
     );
-    subscribe(this, this.userModel.preferences$, prefs => {
-      const layers: DiffLayer[] = [this.syntaxLayer];
-      if (!prefs.disable_token_highlighting) {
-        layers.push(new TokenHighlightLayer(this));
+    subscribe(
+      this,
+      () => this.getChangeModel().repo$,
+      x => (this.repoName = x)
+    );
+    subscribe(
+      this,
+      () => this.userModel.diffPreferences$,
+      x => this.syntaxLayer.setEnabled(!!x.syntax_highlighting)
+    );
+    subscribe(
+      this,
+      () => this.userModel.preferences$,
+      prefs => {
+        const layers: DiffLayer[] = [this.syntaxLayer];
+        if (!prefs.disable_token_highlighting) {
+          layers.push(new TokenHighlightLayer(this));
+        }
+        this.layers = layers;
       }
-      this.layers = layers;
-    });
-    subscribe(this, this.userModel.diffPreferences$, prefs => {
-      this.prefs = {
-        ...prefs,
-        // set line_wrapping to true so that the context can take all the
-        // remaining space after comment card has rendered
-        line_wrapping: true,
-      };
-    });
+    );
+    subscribe(
+      this,
+      () => this.userModel.diffPreferences$,
+      prefs => {
+        this.prefs = {
+          ...prefs,
+          // set line_wrapping to true so that the context can take all the
+          // remaining space after comment card has rendered
+          line_wrapping: true,
+        };
+      }
+    );
   }
 
   static override get styles() {
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
index c460ad7..3e4a555 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
@@ -248,27 +248,36 @@
         });
       }
     }
-  }
-
-  override connectedCallback() {
-    super.connectedCallback();
     subscribe(
       this,
-      this.configModel().repoCommentLinks$,
+      () => this.configModel().repoCommentLinks$,
       x => (this.commentLinks = x)
     );
-    subscribe(this, this.userModel.account$, x => (this.account = x));
-    subscribe(this, this.userModel.isAdmin$, x => (this.isAdmin = x));
-
-    subscribe(this, this.getChangeModel().repo$, x => (this.repoName = x));
     subscribe(
       this,
-      this.getChangeModel().changeNum$,
+      () => this.userModel.account$,
+      x => (this.account = x)
+    );
+    subscribe(
+      this,
+      () => this.userModel.isAdmin$,
+      x => (this.isAdmin = x)
+    );
+
+    subscribe(
+      this,
+      () => this.getChangeModel().repo$,
+      x => (this.repoName = x)
+    );
+    subscribe(
+      this,
+      () => this.getChangeModel().changeNum$,
       x => (this.changeNum = x)
     );
     subscribe(
       this,
-      this.autoSaveTrigger$.pipe(debounceTime(AUTO_SAVE_DEBOUNCE_DELAY_MS)),
+      () =>
+        this.autoSaveTrigger$.pipe(debounceTime(AUTO_SAVE_DEBOUNCE_DELAY_MS)),
       () => {
         this.autoSave();
       }
diff --git a/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog.ts b/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog.ts
index 4df53da..538be7c 100644
--- a/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog.ts
+++ b/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog.ts
@@ -20,6 +20,7 @@
 import {css, html, LitElement, PropertyValues} from 'lit';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {fontStyles} from '../../../styles/gr-font-styles';
+import {when} from 'lit/directives/when';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -51,6 +52,12 @@
   @property({type: String, attribute: 'cancel-label'})
   cancelLabel = 'Cancel';
 
+  @property({type: Boolean, attribute: 'loading'})
+  loading = false;
+
+  @property({type: String, attribute: 'loading-label'})
+  loadingLabel = 'Loading...';
+
   // TODO: Add consistent naming after Lit conversion of the codebase
   @property({type: Boolean})
   disabled = false;
@@ -105,15 +112,24 @@
         footer {
           display: flex;
           flex-shrink: 0;
-          justify-content: flex-end;
           padding-top: var(--spacing-xl);
         }
+        .flex-space {
+          flex-grow: 1;
+        }
         gr-button {
           margin-left: var(--spacing-l);
         }
         .hidden {
           display: none;
         }
+        .loadingSpin {
+          width: 18px;
+          height: 18px;
+        }
+        .loadingLabel {
+          color: var(--gray-700);
+        }
       `,
     ];
   }
@@ -134,6 +150,14 @@
           </div>
         </main>
         <footer>
+          ${when(
+            this.loading,
+            () => html`
+              <span class="loadingSpin"></span>
+              <span class="loadingLabel"> ${this.loadingLabel} </span>
+            `
+          )}
+          <div class="flex-space"></div>
           <gr-button
             id="cancel"
             class=${this.cancelLabel.length ? '' : 'hidden'}
diff --git a/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog_test.ts b/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog_test.ts
index 171fc6c..e4f33bc 100644
--- a/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog_test.ts
@@ -20,17 +20,93 @@
 import './gr-dialog';
 import {GrDialog} from './gr-dialog';
 import {isHidden, queryAndAssert} from '../../../test/test-utils';
-
-const basicFixture = fixtureFromElement('gr-dialog');
+import {fixture, html} from '@open-wc/testing-helpers';
 
 suite('gr-dialog tests', () => {
   let element: GrDialog;
 
   setup(async () => {
-    element = basicFixture.instantiate();
+    element = await fixture<GrDialog>(html` <gr-dialog></gr-dialog> `);
     await element.updateComplete;
   });
 
+  test('renders', async () => {
+    expect(element).shadowDom.to.equal(/* HTML */ `<div class="container">
+      <header class="heading-3">
+        <slot name="header"> </slot>
+      </header>
+      <main>
+        <div class="overflow-container">
+          <slot name="main"> </slot>
+        </div>
+      </main>
+      <footer>
+        <div class="flex-space"></div>
+        <gr-button
+          aria-disabled="false"
+          id="cancel"
+          link=""
+          role="button"
+          tabindex="0"
+        >
+          Cancel
+        </gr-button>
+        <gr-button
+          aria-disabled="false"
+          id="confirm"
+          link=""
+          primary=""
+          role="button"
+          tabindex="0"
+          title=""
+        >
+          Confirm
+        </gr-button>
+      </footer>
+    </div> `);
+  });
+
+  test('renders with loading state', async () => {
+    element.loading = true;
+    element.loadingLabel = 'Loading!!';
+    await element.updateComplete;
+    expect(element).shadowDom.to.equal(/* HTML */ `<div class="container">
+      <header class="heading-3">
+        <slot name="header"> </slot>
+      </header>
+      <main>
+        <div class="overflow-container">
+          <slot name="main"> </slot>
+        </div>
+      </main>
+      <footer>
+        <span class="loadingSpin"> </span>
+        <span class="loadingLabel"> Loading!! </span>
+        <div class="flex-space"></div>
+        <gr-button
+          aria-disabled="false"
+          id="cancel"
+          link=""
+          role="button"
+          tabindex="0"
+        >
+          Cancel
+        </gr-button>
+        <gr-button
+          aria-disabled="false"
+          id="confirm"
+          link=""
+          primary=""
+          role="button"
+          tabindex="0"
+          title=""
+        >
+          Confirm
+        </gr-button>
+      </footer>
+    </div> `);
+  });
+
   test('events', () => {
     const confirm = sinon.stub();
     const cancel = sinon.stub();
diff --git a/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences.ts b/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences.ts
index 44b6fa2..7d52a3c 100644
--- a/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences.ts
+++ b/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences.ts
@@ -65,13 +65,17 @@
 
   private readonly userModel = getAppContext().userModel;
 
-  override connectedCallback() {
-    super.connectedCallback();
-    subscribe(this, this.userModel.diffPreferences$, diffPreferences => {
-      if (!diffPreferences) return;
-      this.originalDiffPrefs = diffPreferences;
-      this.diffPrefs = {...diffPreferences};
-    });
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.userModel.diffPreferences$,
+      diffPreferences => {
+        if (!diffPreferences) return;
+        this.originalDiffPrefs = diffPreferences;
+        this.diffPrefs = {...diffPreferences};
+      }
+    );
   }
 
   static override get styles() {
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts
index 5d0f52a..bdd2b52 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts
+++ b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts
@@ -171,8 +171,8 @@
           box-shadow: none;
           border: 1px solid var(--border-color);
         }
-        .show-all-container .show-all-button {
-          margin-right: auto;
+        .flex-space {
+          flex-grow: 1;
         }
         .show-all-container iron-icon {
           color: inherit;
@@ -267,6 +267,7 @@
               )}
               ${this.commitCollapsed ? 'Show all' : 'Show less'}
             </gr-button>
+            <div class="flex-space"></div>
           `
         )}
         ${when(
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.ts b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.ts
index 9b30591..de00651 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.ts
@@ -50,6 +50,7 @@
           <iron-icon icon="gr-icons:expand-more"> </iron-icon>
           Show all
         </gr-button>
+        <div class="flex-space"></div>
         <gr-button
           aria-disabled="false"
           class="edit-commit-message"
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.ts b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.ts
index 4493e8d..ce5ef4f 100644
--- a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.ts
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.ts
@@ -292,7 +292,7 @@
             class="attentionIcon"
             icon="gr-icons:attention"
           ></iron-icon>
-          <span> ${this.computePronoun()} turn to take this action. </span>
+          <span> ${this.computePronoun()} turn to take action. </span>
           <a
             href="https://gerrit-review.googlesource.com/Documentation/user-attention-set.html"
             target="_blank"
diff --git a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.ts b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.ts
index b1d3914..ee3bec7 100644
--- a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.ts
+++ b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.ts
@@ -18,11 +18,9 @@
 import '../../../styles/gr-voting-styles';
 import '../../../styles/shared-styles';
 import '../gr-vote-chip/gr-vote-chip';
-import '../gr-account-label/gr-account-label';
 import '../gr-account-chip/gr-account-chip';
 import '../gr-button/gr-button';
 import '../gr-icons/gr-icons';
-import '../gr-label/gr-label';
 import '../gr-tooltip-content/gr-tooltip-content';
 import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
 import {
@@ -30,9 +28,7 @@
   LabelInfo,
   ApprovalInfo,
   AccountId,
-  isQuickLabelInfo,
   isDetailedLabelInfo,
-  LabelNameToInfoMap,
 } from '../../../types/common';
 import {LitElement, css, html} from 'lit';
 import {customElement, property} from 'lit/decorators';
@@ -40,10 +36,8 @@
 import {
   canVote,
   getApprovalInfo,
-  getVotingRangeOrDefault,
   hasNeutralStatus,
   hasVoted,
-  showNewSubmitRequirements,
   valueString,
 } from '../../../utils/label-util';
 import {getAppContext} from '../../../services/app-context';
@@ -61,19 +55,6 @@
   }
 }
 
-enum LabelClassName {
-  NEGATIVE = 'negative',
-  POSITIVE = 'positive',
-  MIN = 'min',
-  MAX = 'max',
-}
-
-interface FormattedLabel {
-  className?: LabelClassName;
-  account: ApprovalInfo | AccountInfo;
-  value: string;
-}
-
 @customElement('gr-label-info')
 export class GrLabelInfo extends LitElement {
   @property({type: Object})
@@ -107,8 +88,6 @@
 
   private readonly reporting = getAppContext().reportingService;
 
-  private readonly flagsService = getAppContext().flagsService;
-
   // TODO(TS): not used, remove later
   _xhrPromise?: Promise<void>;
 
@@ -118,9 +97,6 @@
       fontStyles,
       votingStyles,
       css`
-        .placeholder {
-          color: var(--deemphasized-text-color);
-        }
         .hidden {
           display: none;
         }
@@ -132,33 +108,6 @@
           margin-right: var(--spacing-s);
           padding: 1px;
         }
-        .max {
-          background-color: var(--vote-color-approved);
-        }
-        .min {
-          background-color: var(--vote-color-rejected);
-        }
-        .positive {
-          background-color: var(--vote-color-recommended);
-          border-radius: 12px;
-          border: 1px solid var(--vote-outline-recommended);
-          color: var(--chip-color);
-        }
-        .negative {
-          background-color: var(--vote-color-disliked);
-          border-radius: 12px;
-          border: 1px solid var(--vote-outline-disliked);
-          color: var(--chip-color);
-        }
-        .hidden {
-          display: none;
-        }
-        td {
-          vertical-align: top;
-        }
-        tr {
-          min-height: var(--line-height-normal);
-        }
         gr-tooltip-content {
           display: block;
         }
@@ -173,17 +122,10 @@
         gr-button[disabled] iron-icon {
           color: var(--border-color);
         }
-        gr-account-label {
-          --account-max-length: 100px;
-          margin-right: var(--spacing-xs);
-        }
         iron-icon {
           height: calc(var(--line-height-normal) - 2px);
           width: calc(var(--line-height-normal) - 2px);
         }
-        .labelValueContainer:not(:first-of-type) td {
-          padding-top: var(--spacing-s);
-        }
         .reviewer-row {
           padding-top: var(--spacing-s);
         }
@@ -208,14 +150,6 @@
   }
 
   override render() {
-    if (showNewSubmitRequirements(this.flagsService, this.change)) {
-      return this.renderNewSubmitRequirements();
-    } else {
-      return this.renderOldSubmitRequirements();
-    }
-  }
-
-  private renderNewSubmitRequirements() {
     const labelInfo = this.labelInfo;
     if (!labelInfo) return;
     const reviewers = (this.change?.reviewers['REVIEWER'] ?? [])
@@ -238,23 +172,6 @@
     </div>`;
   }
 
-  private renderOldSubmitRequirements() {
-    const labelInfo = this.labelInfo;
-    return html` <p
-        class="placeholder ${this.computeShowPlaceholder(
-          labelInfo,
-          this.change?.labels
-        )}"
-      >
-        No votes
-      </p>
-      <table>
-        ${this.mapLabelInfo(labelInfo, this.account, this.change?.labels).map(
-          mappedLabel => this.renderLabel(mappedLabel)
-        )}
-      </table>`;
-  }
-
   renderReviewerVote(reviewer: AccountInfo) {
     const labelInfo = this.labelInfo;
     if (!labelInfo) return;
@@ -285,30 +202,6 @@
     </div>`;
   }
 
-  renderLabel(mappedLabel: FormattedLabel) {
-    const {labelInfo, change} = this;
-    return html` <tr class="labelValueContainer">
-      <td>
-        <gr-tooltip-content
-          has-tooltip
-          title=${this._computeValueTooltip(labelInfo, mappedLabel.value)}
-        >
-          <gr-label class="${mappedLabel.className} voteChip font-small">
-            ${mappedLabel.value}
-          </gr-label>
-        </gr-tooltip-content>
-      </td>
-      <td>
-        <gr-account-label
-          clickable
-          .account=${mappedLabel.account}
-          .change=${change}
-        ></gr-account-label>
-      </td>
-      <td>${this.renderRemoveVote(mappedLabel.account)}</td>
-    </tr>`;
-  }
-
   private renderVoteAbility(reviewer: AccountInfo) {
     if (this.labelInfo && isDetailedLabelInfo(this.labelInfo)) {
       const approvalInfo = getApprovalInfo(this.labelInfo, reviewer);
@@ -341,83 +234,6 @@
   }
 
   /**
-   * This method also listens on change.labels.*,
-   * to trigger computation when a label is removed from the change.
-   *
-   * The third parameter is just for *triggering* computation.
-   */
-  private mapLabelInfo(
-    labelInfo?: LabelInfo,
-    account?: AccountInfo,
-    _?: LabelNameToInfoMap
-  ): FormattedLabel[] {
-    const result: FormattedLabel[] = [];
-    if (!labelInfo) {
-      return result;
-    }
-    if (!isDetailedLabelInfo(labelInfo)) {
-      if (
-        isQuickLabelInfo(labelInfo) &&
-        (labelInfo.rejected || labelInfo.approved)
-      ) {
-        const ok = labelInfo.approved || !labelInfo.rejected;
-        return [
-          {
-            value: ok ? '👍️' : '👎️',
-            className: ok ? LabelClassName.POSITIVE : LabelClassName.NEGATIVE,
-            // executed only if approved or rejected is not undefined
-            account: ok ? labelInfo.approved! : labelInfo.rejected!,
-          },
-        ];
-      }
-      return result;
-    }
-
-    // Sort votes by positivity.
-    // TODO(TS): maybe mark value as required if always present
-    const votes = (labelInfo.all || []).sort(
-      (a, b) => (a.value || 0) - (b.value || 0)
-    );
-    const votingRange = getVotingRangeOrDefault(labelInfo);
-    for (const label of votes) {
-      if (
-        label.value &&
-        (!isQuickLabelInfo(labelInfo) ||
-          label.value !== labelInfo.default_value)
-      ) {
-        let labelClassName;
-        let labelValPrefix = '';
-        if (label.value > 0) {
-          labelValPrefix = '+';
-          if (label.value === votingRange.max) {
-            labelClassName = LabelClassName.MAX;
-          } else {
-            labelClassName = LabelClassName.POSITIVE;
-          }
-        } else if (label.value < 0) {
-          if (label.value === votingRange.min) {
-            labelClassName = LabelClassName.MIN;
-          } else {
-            labelClassName = LabelClassName.NEGATIVE;
-          }
-        }
-        const formattedLabel: FormattedLabel = {
-          value: `${labelValPrefix}${label.value}`,
-          className: labelClassName,
-          account: label,
-        };
-        if (label._account_id === account?._account_id) {
-          // Put self-votes at the top.
-          result.unshift(formattedLabel);
-        } else {
-          result.push(formattedLabel);
-        }
-      }
-    }
-    return result;
-  }
-
-  /**
    * A user is able to delete a vote iff the mutable property is true and the
    * reviewer that left the vote exists in the list of removable_reviewers
    * received from the backend.
@@ -488,39 +304,4 @@
     }
     return labelInfo.values[score];
   }
-
-  /**
-   * This method also listens change.labels.* in
-   * order to trigger computation when a label is removed from the change.
-   *
-   * The second parameter is just for *triggering* computation.
-   */
-  private computeShowPlaceholder(
-    labelInfo?: LabelInfo,
-    _?: LabelNameToInfoMap
-  ) {
-    if (!labelInfo) {
-      return '';
-    }
-    if (
-      !isDetailedLabelInfo(labelInfo) &&
-      isQuickLabelInfo(labelInfo) &&
-      (labelInfo.rejected || labelInfo.approved)
-    ) {
-      return 'hidden';
-    }
-
-    if (isDetailedLabelInfo(labelInfo) && labelInfo.all) {
-      for (const label of labelInfo.all) {
-        if (
-          label.value &&
-          (!isQuickLabelInfo(labelInfo) ||
-            label.value !== labelInfo.default_value)
-        ) {
-          return 'hidden';
-        }
-      }
-    }
-    return '';
-  }
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_test.ts b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_test.ts
index 0ac49a7..f1336b4 100644
--- a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_test.ts
@@ -20,20 +20,18 @@
 import {
   isHidden,
   mockPromise,
-  queryAll,
   queryAndAssert,
   stubRestApi,
 } from '../../../test/test-utils';
 import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
 import {GrLabelInfo} from './gr-label-info';
 import {GrButton} from '../gr-button/gr-button';
-import {GrLabel} from '../gr-label/gr-label';
 import {
   createAccountWithIdNameAndEmail,
+  createDetailedLabelInfo,
   createParsedChange,
 } from '../../../test/test-data-generators';
-import {LabelInfo} from '../../../types/common';
-import {GrAccountLabel} from '../gr-account-label/gr-account-label';
+import {ApprovalInfo, LabelInfo} from '../../../types/common';
 
 const basicFixture = fixtureFromElement('gr-label-info');
 
@@ -41,12 +39,51 @@
   let element: GrLabelInfo;
   const account = createAccountWithIdNameAndEmail(5);
 
-  setup(() => {
+  setup(async () => {
     element = basicFixture.instantiate();
 
     // Needed to trigger computed bindings.
     element.account = {};
-    element.change = {...createParsedChange(), labels: {}};
+    element.change = {
+      ...createParsedChange(),
+      labels: {},
+      reviewers: {
+        REVIEWER: [account],
+        CC: [],
+      },
+    };
+    const approval: ApprovalInfo = {
+      value: 2,
+      _account_id: account._account_id,
+    };
+    element.labelInfo = {
+      ...createDetailedLabelInfo(),
+      all: [approval],
+    };
+    await element.updateComplete;
+  });
+
+  test('renders', () => {
+    expect(element).shadowDom.to.equal(/* HTML */ `<div>
+      <div class="reviewer-row">
+        <gr-account-chip>
+          <gr-vote-chip circle-shape="" slot="vote-chip"> </gr-vote-chip>
+        </gr-account-chip>
+        <gr-tooltip-content has-tooltip="" title="Remove vote">
+          <gr-button
+            aria-disabled="false"
+            aria-label="Remove vote"
+            class="deleteBtn hidden"
+            data-account-id="5"
+            link=""
+            role="button"
+            tabindex="0"
+          >
+            <iron-icon icon="gr-icons:delete"> </iron-icon>
+          </gr-button>
+        </gr-tooltip-content>
+      </div>
+    </div>`);
   });
 
   suite('remove reviewer votes', () => {
@@ -62,6 +99,10 @@
       element.change = {
         ...createParsedChange(),
         labels: {'Code-Review': label},
+        reviewers: {
+          REVIEWER: [account],
+          CC: [],
+        },
       };
       element.labelInfo = label;
       element.label = 'Code-Review';
@@ -108,101 +149,6 @@
     });
   });
 
-  suite('label color and order', () => {
-    test('valueless label rejected', async () => {
-      element.labelInfo = {rejected: {name: 'someone'}};
-      await element.updateComplete;
-      const labels = queryAll<GrLabel>(element, 'gr-label');
-      assert.isTrue(labels[0].classList.contains('negative'));
-    });
-
-    test('valueless label approved', async () => {
-      element.labelInfo = {approved: {name: 'someone'}};
-      await element.updateComplete;
-      const labels = queryAll<GrLabel>(element, 'gr-label');
-      assert.isTrue(labels[0].classList.contains('positive'));
-    });
-
-    test('-2 to +2', async () => {
-      element.labelInfo = {
-        all: [
-          {value: 2, name: 'user 2'},
-          {value: 1, name: 'user 1'},
-          {value: -1, name: 'user 3'},
-          {value: -2, name: 'user 4'},
-        ],
-        values: {
-          '-2': 'Awful',
-          '-1': "Don't submit as-is",
-          ' 0': 'No score',
-          '+1': 'Looks good to me',
-          '+2': 'Ready to submit',
-        },
-      };
-      await element.updateComplete;
-      const labels = queryAll<GrLabel>(element, 'gr-label');
-      assert.isTrue(labels[0].classList.contains('max'));
-      assert.isTrue(labels[1].classList.contains('positive'));
-      assert.isTrue(labels[2].classList.contains('negative'));
-      assert.isTrue(labels[3].classList.contains('min'));
-    });
-
-    test('-1 to +1', async () => {
-      element.labelInfo = {
-        all: [
-          {value: 1, name: 'user 1'},
-          {value: -1, name: 'user 2'},
-        ],
-        values: {
-          '-1': "Don't submit as-is",
-          ' 0': 'No score',
-          '+1': 'Looks good to me',
-        },
-      };
-      await element.updateComplete;
-      const labels = queryAll<GrLabel>(element, 'gr-label');
-      assert.isTrue(labels[0].classList.contains('max'));
-      assert.isTrue(labels[1].classList.contains('min'));
-    });
-
-    test('0 to +2', async () => {
-      element.labelInfo = {
-        all: [
-          {value: 1, name: 'user 2'},
-          {value: 2, name: 'user '},
-        ],
-        values: {
-          ' 0': "Don't submit as-is",
-          '+1': 'No score',
-          '+2': 'Looks good to me',
-        },
-      };
-      await element.updateComplete;
-      const labels = queryAll<GrLabel>(element, 'gr-label');
-      assert.isTrue(labels[0].classList.contains('max'));
-      assert.isTrue(labels[1].classList.contains('positive'));
-    });
-
-    test('self votes at top', async () => {
-      const otherAccount = createAccountWithIdNameAndEmail(8);
-      element.account = account;
-      element.labelInfo = {
-        all: [
-          {...otherAccount, value: 1},
-          {...account, value: -1},
-        ],
-        values: {
-          '-1': "Don't submit as-is",
-          ' 0': 'No score',
-          '+1': 'Looks good to me',
-        },
-      };
-      await element.updateComplete;
-      const chips = queryAll<GrAccountLabel>(element, 'gr-account-label');
-      assert.equal(chips[0].account!._account_id, element.account._account_id);
-    });
-  });
-
   test('_computeValueTooltip', () => {
     // Existing label.
     let labelInfo: LabelInfo = {values: {0: 'Baz'}};
@@ -218,49 +164,4 @@
     score = '0';
     assert.equal(element._computeValueTooltip(labelInfo, score), '');
   });
-
-  test('placeholder', async () => {
-    const values = {
-      '0': 'No score',
-      '+1': 'good',
-      '+2': 'excellent',
-      '-1': 'bad',
-      '-2': 'terrible',
-    };
-    element.labelInfo = {};
-    await element.updateComplete;
-    assert.isFalse(
-      isHidden(queryAndAssert<HTMLParagraphElement>(element, '.placeholder'))
-    );
-    element.labelInfo = {all: [], values};
-    await element.updateComplete;
-    assert.isFalse(
-      isHidden(queryAndAssert<HTMLParagraphElement>(element, '.placeholder'))
-    );
-    element.labelInfo = {all: [{value: 1}], values};
-    await element.updateComplete;
-    assert.isTrue(
-      isHidden(queryAndAssert<HTMLParagraphElement>(element, '.placeholder'))
-    );
-    element.labelInfo = {rejected: account};
-    await element.updateComplete;
-    assert.isTrue(
-      isHidden(queryAndAssert<HTMLParagraphElement>(element, '.placeholder'))
-    );
-    element.labelInfo = {rejected: account, all: [{value: 1}], values};
-    await element.updateComplete;
-    assert.isTrue(
-      isHidden(queryAndAssert<HTMLParagraphElement>(element, '.placeholder'))
-    );
-    element.labelInfo = {approved: account};
-    await element.updateComplete;
-    assert.isTrue(
-      isHidden(queryAndAssert<HTMLParagraphElement>(element, '.placeholder'))
-    );
-    element.labelInfo = {approved: account, all: [{value: 1}], values};
-    await element.updateComplete;
-    assert.isTrue(
-      isHidden(queryAndAssert<HTMLParagraphElement>(element, '.placeholder'))
-    );
-  });
 });
diff --git a/polygerrit-ui/app/elements/shared/gr-label/gr-label.ts b/polygerrit-ui/app/elements/shared/gr-label/gr-label.ts
deleted file mode 100644
index 842b35e..0000000
--- a/polygerrit-ui/app/elements/shared/gr-label/gr-label.ts
+++ /dev/null
@@ -1,42 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-/**
- * @fileoverview Consider removing this element as
- * its functionality seems to be duplicated with gr-tooltip and only
- * used in gr-label-info.
- */
-
-import {html, LitElement} from 'lit';
-import {customElement} from 'lit/decorators';
-
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-label': GrLabel;
-  }
-}
-
-@customElement('gr-label')
-export class GrLabel extends LitElement {
-  static override get styles() {
-    return [];
-  }
-
-  override render() {
-    return html` <slot></slot> `;
-  }
-}
diff --git a/polygerrit-ui/app/elements/shared/gr-label/gr-label_html.ts b/polygerrit-ui/app/elements/shared/gr-label/gr-label_html.ts
deleted file mode 100644
index 94196df..0000000
--- a/polygerrit-ui/app/elements/shared/gr-label/gr-label_html.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html` <slot></slot> `;
diff --git a/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text.ts b/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text.ts
index 9bb112e..6b5fc39 100644
--- a/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text.ts
+++ b/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text.ts
@@ -15,7 +15,7 @@
  * limitations under the License.
  */
 import {customElement, property} from 'lit/decorators';
-import {html, LitElement} from 'lit';
+import {css, html, LitElement} from 'lit';
 import '../gr-tooltip-content/gr-tooltip-content';
 
 declare global {
@@ -38,13 +38,19 @@
 
   /** The maximum length for the text to display before truncating. */
   @property({type: Number})
-  limit = 0;
+  limit = 25;
 
   @property({type: String})
   tooltip?: string;
 
   static override get styles() {
-    return [];
+    return [
+      css`
+        :host {
+          white-space: nowrap;
+        }
+      `,
+    ];
   }
 
   override render() {
diff --git a/polygerrit-ui/app/elements/shared/gr-vote-chip/gr-vote-chip.ts b/polygerrit-ui/app/elements/shared/gr-vote-chip/gr-vote-chip.ts
index 146a01e..5859731 100644
--- a/polygerrit-ui/app/elements/shared/gr-vote-chip/gr-vote-chip.ts
+++ b/polygerrit-ui/app/elements/shared/gr-vote-chip/gr-vote-chip.ts
@@ -23,8 +23,6 @@
   isQuickLabelInfo,
   LabelInfo,
 } from '../../../api/rest-api';
-import {getAppContext} from '../../../services/app-context';
-import {KnownExperimentId} from '../../../services/flags/flags';
 import {
   classForLabelStatus,
   getLabelStatus,
@@ -61,8 +59,6 @@
   @property({type: Boolean, attribute: 'tooltip-with-who-voted'})
   tooltipWithWhoVoted = false;
 
-  private readonly flagsService = getAppContext().flagsService;
-
   static override get styles() {
     return [
       css`
@@ -131,9 +127,6 @@
   }
 
   override render() {
-    if (!this.flagsService.isEnabled(KnownExperimentId.SUBMIT_REQUIREMENTS_UI))
-      return;
-
     const renderValue = this.renderValue();
     if (!renderValue) return;
 
diff --git a/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls.ts b/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls.ts
index 77a5dfb..47f5f81 100644
--- a/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls.ts
+++ b/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls.ts
@@ -200,8 +200,8 @@
     }
   `;
 
-  override connectedCallback() {
-    super.connectedCallback();
+  constructor() {
+    super();
     this.setupButtonHoverHandler();
   }
 
@@ -220,16 +220,17 @@
   private setupButtonHoverHandler() {
     subscribe(
       this,
-      this.expandButtonsHover.pipe(
-        switchMap(e => {
-          if (e.eventType === 'leave') {
-            // cancel any previous delay
-            // for mouse enter
-            return EMPTY;
-          }
-          return of(e).pipe(delay(500));
-        })
-      ),
+      () =>
+        this.expandButtonsHover.pipe(
+          switchMap(e => {
+            if (e.eventType === 'leave') {
+              // cancel any previous delay
+              // for mouse enter
+              return EMPTY;
+            }
+            return of(e).pipe(delay(500));
+          })
+        ),
       ({buttonType, linesToExpand}) => {
         fire(this, 'diff-context-button-hovered', {
           buttonType,
@@ -461,7 +462,7 @@
     numLines: number,
     referenceLine: number
   ) {
-    assertIsDefined(this.diff, 'diff');
+    if (!this.diff?.meta_b) return;
     const syntaxTree = this.diff.meta_b.syntax_tree;
     const outlineSyntaxPath = findBlockTreePathForLine(
       referenceLine,
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element.ts
index e426e66..d2704ac 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element.ts
@@ -1,32 +1,18 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '../gr-diff-processor/gr-diff-processor';
 import '../../../elements/shared/gr-hovercard/gr-hovercard';
 import './gr-diff-builder-side-by-side';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-diff-builder-element_html';
 import {GrAnnotation} from '../gr-diff-highlight/gr-annotation';
 import {DiffBuilder, DiffContextExpandedEventDetail} from './gr-diff-builder';
 import {GrDiffBuilderSideBySide} from './gr-diff-builder-side-by-side';
 import {GrDiffBuilderImage} from './gr-diff-builder-image';
 import {GrDiffBuilderUnified} from './gr-diff-builder-unified';
 import {GrDiffBuilderBinary} from './gr-diff-builder-binary';
-import {CancelablePromise, util} from '../../../scripts/util';
-import {customElement, property, observe} from '@polymer/decorators';
+import {CancelablePromise, makeCancelable} from '../../../scripts/util';
 import {BlameInfo, ImageInfo} from '../../../types/common';
 import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
 import {CoverageRange, DiffLayer} from '../../../types/types';
@@ -50,14 +36,28 @@
 } from '../gr-diff/gr-diff-group';
 import {getLineNumber, getSideByLineEl} from '../gr-diff/gr-diff-utils';
 import {fireAlert, fireEvent} from '../../../utils/event-util';
-import {afterNextRender} from '@polymer/polymer/lib/utils/render-status';
+import {assertIsDefined} from '../../../utils/common-util';
+import {untilRendered} from '../../../utils/dom-util';
 
 const TRAILING_WHITESPACE_PATTERN = /\s+$/;
-
-// https://gerrit.googlesource.com/gerrit/+/234616a8627334686769f1de989d286039f4d6a5/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js#740
 const COMMIT_MSG_PATH = '/COMMIT_MSG';
 const COMMIT_MSG_LINE_LENGTH = 72;
 
+declare global {
+  interface HTMLElementEventMap {
+    /**
+     * Fired when the diff begins rendering - both for full renders and for
+     * partial rerenders.
+     */
+    'render-start': CustomEvent<{}>;
+    /**
+     * Fired when the diff finishes rendering text content - both for full
+     * renders and for partial rerenders.
+     */
+    'render-content': CustomEvent<{}>;
+  }
+}
+
 export function getLineNumberCellWidth(prefs: DiffPreferencesInfo) {
   return prefs.font_size * 4;
 }
@@ -87,109 +87,59 @@
   }
 }
 
-@customElement('gr-diff-builder')
-export class GrDiffBuilderElement
-  extends PolymerElement
-  implements GroupConsumer
-{
-  static get template() {
-    return htmlTemplate;
-  }
-
-  /**
-   * Fired when the diff begins rendering.
-   *
-   * @event render-start
-   */
-
-  /**
-   * Fired whenever a new chunk of lines has been rendered synchronously.
-   *
-   * @event render-progress
-   */
-
-  /**
-   * Fired when the diff finishes rendering text content.
-   *
-   * @event render-content
-   */
-
-  @property({type: Object})
+// TODO: Rename the class and the file and remove "element". This is not an
+// element anymore.
+export class GrDiffBuilderElement implements GroupConsumer {
   diff?: DiffInfo;
 
-  @property({type: String})
+  diffElement?: HTMLTableElement;
+
   viewMode?: string;
 
-  @property({type: Boolean})
   isImageDiff?: boolean;
 
-  @property({type: Object})
   baseImage: ImageInfo | null = null;
 
-  @property({type: Object})
   revisionImage: ImageInfo | null = null;
 
-  @property({type: Number})
-  parentIndex?: number;
-
-  @property({type: String})
   path?: string;
 
-  @property({type: Object})
   prefs: DiffPreferencesInfo = createDefaultDiffPrefs();
 
-  @property({type: Object})
   renderPrefs?: RenderPreferences;
 
-  @property({type: Object})
-  _builder?: DiffBuilder;
-
-  /**
-   * The gr-diff-processor adds (and only adds!) to this array. It does so by
-   * using `this.push()` and Polymer's two-way data binding.
-   * Below (@observe('_groups.splices')) we are observing the groups that the
-   * processor adds, and pass them on to the builder for rendering. Henceforth
-   * the builder groups are the source of truth, because when
-   * expanding/collapsing groups only the builder is updated. This field and the
-   * corresponsing one in the processor are not updated.
-   */
-  @property({type: Array})
-  _groups: GrDiffGroup[] = [];
+  useNewImageDiffUi = false;
 
   /**
    * Layers passed in from the outside.
+   *
+   * See `layersInternal` for where these layers will end up together with the
+   * internal layers.
    */
-  @property({type: Array})
   layers: DiffLayer[] = [];
 
+  // visible for testing
+  builder?: DiffBuilder;
+
   /**
-   * All layers, both from the outside and the default ones.
+   * All layers, both from the outside and the default ones. See `layers` for
+   * the property that can be set from the outside.
    */
-  @property({type: Array})
-  _layers: DiffLayer[] = [];
+  // visible for testing
+  layersInternal: DiffLayer[] = [];
 
-  @property({type: Boolean})
-  _showTabs?: boolean;
+  // visible for testing
+  showTabs?: boolean;
 
-  @property({type: Boolean})
-  _showTrailingWhitespace?: boolean;
-
-  @property({type: Array})
-  commentRanges: CommentRangeLayer[] = [];
-
-  @property({type: Array, observer: 'coverageObserver'})
-  coverageRanges: CoverageRange[] = [];
-
-  @property({type: Boolean})
-  useNewImageDiffUi = false;
+  // visible for testing
+  showTrailingWhitespace?: boolean;
 
   /**
    * The promise last returned from `render()` while the asynchronous
    * rendering is running - `null` otherwise. Provides a `cancel()`
    * method that rejects it with `{isCancelled: true}`.
    */
-  @property({type: Object})
-  _cancelableRenderPromise: CancelablePromise<unknown> | null = null;
+  private cancelableRenderPromise: CancelablePromise<unknown> | null = null;
 
   private coverageLayerLeft = new GrCoverageLayer(Side.LEFT);
 
@@ -197,115 +147,124 @@
 
   private rangeLayer = new GrRangedCommentLayer();
 
-  private processor = new GrDiffProcessor();
+  // visible for testing
+  processor = new GrDiffProcessor();
+
+  /**
+   * Groups are mostly just passed on to the diff builder (this.builder). But
+   * we also keep track of them here for being able to fire a `render-content`
+   * event when .element of each group has rendered.
+   *
+   * TODO: Refactor DiffBuilderElement and DiffBuilders with a cleaner
+   * separation of responsibilities.
+   */
+  private groups: GrDiffGroup[] = [];
 
   constructor() {
-    super();
-    afterNextRender(this, () => {
-      this.addEventListener(
-        'diff-context-expanded',
-        (e: CustomEvent<DiffContextExpandedEventDetail>) => {
-          // Don't stop propagation. The host may listen for reporting or
-          // resizing.
-          this.replaceGroup(e.detail.contextGroup, e.detail.groups);
-        }
-      );
-    });
     this.processor.consumer = this;
   }
 
-  override disconnectedCallback() {
-    this.processor.cancel();
-    if (this._builder) {
-      this._builder.clear();
-    }
-    super.disconnectedCallback();
+  updateCommentRanges(ranges: CommentRangeLayer[]) {
+    this.rangeLayer.updateRanges(ranges);
   }
 
-  get diffElement(): HTMLTableElement {
-    // Not searching in shadowRoot, because the diff table is slotted!
-    return this.querySelector('#diffTable') as HTMLTableElement;
+  updateCoverageRanges(rs: CoverageRange[]) {
+    this.coverageLayerLeft.setRanges(rs.filter(r => r?.side === Side.LEFT));
+    this.coverageLayerRight.setRanges(rs.filter(r => r?.side === Side.RIGHT));
   }
 
-  @observe('commentRanges.*')
-  rangeObserver() {
-    this.rangeLayer.updateRanges(this.commentRanges);
-  }
-
-  coverageObserver(coverageRanges: CoverageRange[]) {
-    const leftRanges = coverageRanges.filter(
-      range => range && range.side === Side.LEFT
-    );
-    this.coverageLayerLeft.setRanges(leftRanges);
-
-    const rightRanges = coverageRanges.filter(
-      range => range && range.side === Side.RIGHT
-    );
-    this.coverageLayerRight.setRanges(rightRanges);
-  }
-
-  render(keyLocations: KeyLocations) {
+  render(keyLocations: KeyLocations): void {
     // Setting up annotation layers must happen after plugins are
     // installed, and |render| satisfies the requirement, however,
     // |attached| doesn't because in the diff view page, the element is
     // attached before plugins are installed.
-    this._setupAnnotationLayers();
+    this.setupAnnotationLayers();
 
-    this._showTabs = this.prefs.show_tabs;
-    this._showTrailingWhitespace = this.prefs.show_whitespace_errors;
+    this.showTabs = this.prefs.show_tabs;
+    this.showTrailingWhitespace = this.prefs.show_whitespace_errors;
 
     // Stop the processor if it's running.
     this.cancel();
 
-    if (this._builder) {
-      this._builder.clear();
-    }
-    if (!this.diff) {
-      throw Error('Cannot render a diff without DiffInfo.');
-    }
-    this._builder = this._getDiffBuilder();
+    this.builder?.clear();
+    assertIsDefined(this.diff, 'diff');
+    assertIsDefined(this.diffElement, 'diff table');
+    this.builder = this.getDiffBuilder();
 
     this.processor.context = this.prefs.context;
     this.processor.keyLocations = keyLocations;
 
-    this._clearDiffContent();
-    this._builder.addColumns(
+    this.diffElement.addEventListener(
+      'diff-context-expanded',
+      this.onDiffContextExpanded
+    );
+
+    this.clearDiffContent();
+    this.builder.addColumns(
       this.diffElement,
       getLineNumberCellWidth(this.prefs)
     );
 
     const isBinary = !!(this.isImageDiff || this.diff.binary);
 
-    fireEvent(this, 'render-start');
-    this._cancelableRenderPromise = util.makeCancelable(
-      this.processor.process(this.diff.content, isBinary).then(() => {
-        if (this.isImageDiff) {
-          (this._builder as GrDiffBuilderImage).renderDiff();
-        }
-        fireEvent(this, 'render-content');
-      })
-    );
-    return (
-      this._cancelableRenderPromise
-        .finally(() => {
-          this._cancelableRenderPromise = null;
+    this.fireDiffEvent('render-start');
+    this.cancelableRenderPromise = makeCancelable(
+      this.processor
+        .process(this.diff.content, isBinary)
+        .then(() => {
+          if (this.isImageDiff) {
+            (this.builder as GrDiffBuilderImage).renderDiff();
+          }
+          return this.untilGroupsRendered();
         })
-        // Mocca testing does not like uncaught rejections, so we catch
+        .then(() => {
+          this.fireDiffEvent('render-content');
+        })
+        // Mocha testing does not like uncaught rejections, so we catch
         // the cancels which are expected and should not throw errors in
         // tests.
         .catch(e => {
           if (!e.isCanceled) return Promise.reject(e);
           return;
         })
+        .finally(() => {
+          this.cancelableRenderPromise = null;
+        })
     );
   }
 
-  _setupAnnotationLayers() {
+  // visible for testing
+  async untilGroupsRendered(groups: readonly GrDiffGroup[] = this.groups) {
+    for (const g of groups) {
+      // The LOST or FILE lines may be hidden and thus never resolve an
+      // untilRendered() promise.
+      const lineNumber = g.lines?.[0]?.beforeNumber;
+      if (g.skip || lineNumber === 'LOST' || lineNumber === 'FILE') continue;
+      assertIsDefined(g.element);
+      await untilRendered(g.element);
+    }
+  }
+
+  private onDiffContextExpanded = (
+    e: CustomEvent<DiffContextExpandedEventDetail>
+  ) => {
+    // Don't stop propagation. The host may listen for reporting or
+    // resizing.
+    this.replaceGroup(e.detail.contextGroup, e.detail.groups);
+  };
+
+  private fireDiffEvent<K extends keyof HTMLElementEventMap>(type: K) {
+    assertIsDefined(this.diffElement, 'diff table');
+    fireEvent(this.diffElement, type);
+  }
+
+  // visible for testing
+  setupAnnotationLayers() {
     const layers: DiffLayer[] = [
-      this._createTrailingWhitespaceLayer(),
-      this._createIntralineLayer(),
-      this._createTabIndicatorLayer(),
-      this._createSpecialCharacterIndicatorLayer(),
+      this.createTrailingWhitespaceLayer(),
+      this.createIntralineLayer(),
+      this.createTabIndicatorLayer(),
+      this.createSpecialCharacterIndicatorLayer(),
       this.rangeLayer,
       this.coverageLayerLeft,
       this.coverageLayerRight,
@@ -314,15 +273,15 @@
     if (this.layers) {
       layers.push(...this.layers);
     }
-    this._layers = layers;
+    this.layersInternal = layers;
   }
 
   getContentTdByLine(lineNumber: LineNumber, side?: Side, root?: Element) {
-    if (!this._builder) return null;
-    return this._builder.getContentTdByLine(lineNumber, side, root);
+    if (!this.builder) return null;
+    return this.builder.getContentTdByLine(lineNumber, side, root);
   }
 
-  _getDiffRowByChild(child: Element) {
+  private getDiffRowByChild(child: Element) {
     while (!child.classList.contains('diff-row') && child.parentElement) {
       child = child.parentElement;
     }
@@ -336,23 +295,23 @@
     const side = getSideByLineEl(lineEl);
     // Performance optimization because we already have an element in the
     // correct row
-    const row = this._getDiffRowByChild(lineEl);
+    const row = this.getDiffRowByChild(lineEl);
     return this.getContentTdByLine(line, side, row);
   }
 
   getLineElByNumber(lineNumber: LineNumber, side?: Side) {
-    if (!this._builder) return null;
-    return this._builder.getLineElByNumber(lineNumber, side);
+    if (!this.builder) return null;
+    return this.builder.getLineElByNumber(lineNumber, side);
   }
 
   getLineNumberRows() {
-    if (!this._builder) return [];
-    return this._builder.getLineNumberRows();
+    if (!this.builder) return [];
+    return this.builder.getLineNumberRows();
   }
 
   getLineNumEls(side: Side) {
-    if (!this._builder) return [];
-    return this._builder.getLineNumEls(side);
+    if (!this.builder) return [];
+    return this.builder.getLineNumEls(side);
   }
 
   /**
@@ -364,8 +323,8 @@
    * @param side The side the line number refer to.
    */
   unhideLine(lineNum: number, side: Side) {
-    if (!this._builder) return;
-    const group = this._builder.findGroup(side, lineNum);
+    if (!this.builder) return;
+    const group = this.builder.findGroup(side, lineNum);
     // Cannot unhide a line that is not part of the diff.
     if (!group) return;
     // If it's already visible, great!
@@ -393,8 +352,7 @@
         lineRange.end_line - lineRange.start_line + 1
       )
     );
-    this._builder.replaceGroup(group, newGroups);
-    setTimeout(() => fireEvent(this, 'render-content'), 1);
+    this.replaceGroup(group, newGroups);
   }
 
   /**
@@ -409,37 +367,47 @@
     contextGroup: GrDiffGroup,
     newGroups: readonly GrDiffGroup[]
   ) {
-    if (!this._builder) return;
-    this._builder.replaceGroup(contextGroup, newGroups);
-    setTimeout(() => fireEvent(this, 'render-content'), 1);
+    if (!this.builder) return;
+    this.fireDiffEvent('render-start');
+    this.builder.replaceGroup(contextGroup, newGroups);
+    this.groups = this.groups.filter(g => g !== contextGroup);
+    this.groups.push(...newGroups);
+    this.untilGroupsRendered(newGroups).then(() => {
+      this.fireDiffEvent('render-content');
+    });
   }
 
   cancel() {
     this.processor.cancel();
-    if (this._cancelableRenderPromise) {
-      this._cancelableRenderPromise.cancel();
-      this._cancelableRenderPromise = null;
-    }
+    this.builder?.clear();
+    this.cancelableRenderPromise?.cancel();
+    this.cancelableRenderPromise = null;
+    this.diffElement?.removeEventListener(
+      'diff-context-expanded',
+      this.onDiffContextExpanded
+    );
   }
 
-  _handlePreferenceError(pref: string): never {
+  // visible for testing
+  handlePreferenceError(pref: string): never {
     const message =
       `The value of the '${pref}' user preference is ` +
       'invalid. Fix in diff preferences';
-    fireAlert(this, message);
+    assertIsDefined(this.diffElement, 'diff table');
+    fireAlert(this.diffElement, message);
     throw Error(`Invalid preference value: ${pref}`);
   }
 
-  _getDiffBuilder(): DiffBuilder {
-    if (!this.diff) {
-      throw Error('Cannot render a diff without DiffInfo.');
-    }
+  // visible for testing
+  getDiffBuilder(): DiffBuilder {
+    assertIsDefined(this.diff, 'diff');
+    assertIsDefined(this.diffElement, 'diff table');
     if (isNaN(this.prefs.tab_size) || this.prefs.tab_size <= 0) {
-      this._handlePreferenceError('tab size');
+      this.handlePreferenceError('tab size');
     }
 
     if (isNaN(this.prefs.line_length) || this.prefs.line_length <= 0) {
-      this._handlePreferenceError('diff width');
+      this.handlePreferenceError('diff width');
     }
 
     const localPrefs = {...this.prefs};
@@ -468,7 +436,7 @@
         this.diff,
         localPrefs,
         this.diffElement,
-        this._layers,
+        this.layersInternal,
         this.renderPrefs
       );
     } else if (this.viewMode === DiffViewMode.UNIFIED) {
@@ -476,7 +444,7 @@
         this.diff,
         localPrefs,
         this.diffElement,
-        this._layers,
+        this.layersInternal,
         this.renderPrefs
       );
     }
@@ -486,7 +454,8 @@
     return builder;
   }
 
-  _clearDiffContent() {
+  private clearDiffContent() {
+    assertIsDefined(this.diffElement, 'diff table');
     this.diffElement.innerHTML = '';
   }
 
@@ -495,20 +464,22 @@
    * server into chunks.
    */
   clearGroups() {
-    if (!this._builder) return;
-    this._builder.clearGroups();
+    if (!this.builder) return;
+    this.groups = [];
+    this.builder.clearGroups();
   }
 
   /**
    * Called when the processor is done converting a chunk of the diff.
    */
   addGroup(group: GrDiffGroup) {
-    if (!this._builder) return;
-    this._builder.addGroups([group]);
-    fireEvent(this, 'render-progress');
+    if (!this.builder) return;
+    this.builder.addGroups([group]);
+    this.groups.push(group);
   }
 
-  _createIntralineLayer(): DiffLayer {
+  // visible for testing
+  createIntralineLayer(): DiffLayer {
     return {
       // Take a DIV.contentText element and a line object with intraline
       // differences to highlight and apply them to the element as
@@ -540,8 +511,9 @@
     };
   }
 
-  _createTabIndicatorLayer(): DiffLayer {
-    const show = () => this._showTabs;
+  // visible for testing
+  createTabIndicatorLayer(): DiffLayer {
+    const show = () => this.showTabs;
     return {
       annotate(contentEl: HTMLElement, _: HTMLElement, line: GrDiffLine) {
         // If visible tabs are disabled, do nothing.
@@ -555,7 +527,7 @@
     };
   }
 
-  _createSpecialCharacterIndicatorLayer(): DiffLayer {
+  private createSpecialCharacterIndicatorLayer(): DiffLayer {
     return {
       annotate(contentEl: HTMLElement, _: HTMLElement, line: GrDiffLine) {
         // Find and annotate the locations of soft hyphen (\u00AD)
@@ -571,8 +543,9 @@
     };
   }
 
-  _createTrailingWhitespaceLayer(): DiffLayer {
-    const show = () => this._showTrailingWhitespace;
+  // visible for testing
+  createTrailingWhitespaceLayer(): DiffLayer {
+    const show = () => this.showTrailingWhitespace;
 
     return {
       annotate(contentEl: HTMLElement, _: HTMLElement, line: GrDiffLine) {
@@ -600,18 +573,12 @@
   }
 
   setBlame(blame: BlameInfo[] | null) {
-    if (!this._builder) return;
-    this._builder.setBlame(blame ?? []);
+    if (!this.builder) return;
+    this.builder.setBlame(blame ?? []);
   }
 
   updateRenderPrefs(renderPrefs: RenderPreferences) {
-    this._builder?.updateRenderPrefs(renderPrefs);
+    this.builder?.updateRenderPrefs(renderPrefs);
     this.processor.updateRenderPrefs(renderPrefs);
   }
 }
-
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-diff-builder': GrDiffBuilderElement;
-  }
-}
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element_html.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element_html.ts
deleted file mode 100644
index bd0e034..0000000
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element_html.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <div class="contentWrapper">
-    <slot></slot>
-  </div>
-`;
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element_test.js b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element_test.js
deleted file mode 100644
index 0ad21b0..0000000
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element_test.js
+++ /dev/null
@@ -1,1084 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../../test/common-test-setup-karma.js';
-import {createDiff} from '../../../test/test-data-generators.js';
-import './gr-diff-builder-element.js';
-import {stubBaseUrl} from '../../../test/test-utils.js';
-import {GrAnnotation} from '../gr-diff-highlight/gr-annotation.js';
-import {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line.js';
-import {GrDiffBuilderSideBySide} from './gr-diff-builder-side-by-side.js';
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-import {DiffViewMode, Side} from '../../../api/diff.js';
-import {stubRestApi} from '../../../test/test-utils.js';
-import {afterNextRender} from '@polymer/polymer/lib/utils/render-status';
-import {GrDiffBuilderLegacy} from './gr-diff-builder-legacy.js';
-
-const basicFixture = fixtureFromTemplate(html`
-    <gr-diff-builder>
-      <table id="diffTable"></table>
-    </gr-diff-builder>
-`);
-
-const divWithTextFixture = fixtureFromTemplate(html`
-<div>Lorem ipsum dolor sit amet, suspendisse inceptos vehicula</div>
-`);
-
-const mockDiffFixture = fixtureFromTemplate(html`
-<gr-diff-builder view-mode="SIDE_BY_SIDE">
-      <table id="diffTable"></table>
-    </gr-diff-builder>
-`);
-
-// GrDiffBuilderElement forces these prefs to be set - tests that do not care
-// about these values can just set these defaults.
-const DEFAULT_PREFS = {
-  line_length: 10,
-  show_tabs: true,
-  tab_size: 4,
-};
-
-suite('gr-diff-builder tests', () => {
-  let prefs;
-  let element;
-  let builder;
-
-  const LINE_BREAK_HTML = '<span class="style-scope gr-diff br"></span>';
-  const WBR_HTML = '<wbr class="style-scope gr-diff">';
-
-  setup(() => {
-    element = basicFixture.instantiate();
-    stubRestApi('getLoggedIn').returns(Promise.resolve(false));
-    stubRestApi('getProjectConfig').returns(Promise.resolve({}));
-    stubBaseUrl('/r');
-    prefs = {...DEFAULT_PREFS};
-    builder = new GrDiffBuilderLegacy({content: []}, prefs);
-  });
-
-  test('line_length applied with <wbr> if line_wrapping is true', () => {
-    builder._prefs = {line_wrapping: true, tab_size: 4, line_length: 50};
-    const text = 'a'.repeat(51);
-
-    const line = {text, highlights: []};
-    const expected = 'a'.repeat(50) + WBR_HTML + 'a';
-    const result = builder.createTextEl(undefined, line).firstChild.innerHTML;
-    assert.equal(result, expected);
-  });
-
-  test('line_length applied with line break if line_wrapping is false', () => {
-    builder._prefs = {line_wrapping: false, tab_size: 4, line_length: 50};
-    const text = 'a'.repeat(51);
-
-    const line = {text, highlights: []};
-    const expected = 'a'.repeat(50) + LINE_BREAK_HTML + 'a';
-    const result = builder.createTextEl(undefined, line).firstChild.innerHTML;
-    assert.equal(result, expected);
-  });
-
-  [DiffViewMode.UNIFIED, DiffViewMode.SIDE_BY_SIDE]
-      .forEach(mode => {
-        test(`line_length used for regular files under ${mode}`, () => {
-          element.path = '/a.txt';
-          element.viewMode = mode;
-          element.diff = {};
-          element.prefs = {tab_size: 4, line_length: 50};
-          builder = element._getDiffBuilder();
-          assert.equal(builder._prefs.line_length, 50);
-        });
-
-        test(`line_length ignored for commit msg under ${mode}`, () => {
-          element.path = '/COMMIT_MSG';
-          element.viewMode = mode;
-          element.diff = {};
-          element.prefs = {tab_size: 4, line_length: 50};
-          builder = element._getDiffBuilder();
-          assert.equal(builder._prefs.line_length, 72);
-        });
-      });
-
-  test('createTextEl linewrap with tabs', () => {
-    const text = '\t'.repeat(7) + '!';
-    const line = {text, highlights: []};
-    const el = builder.createTextEl(undefined, line);
-    assert.equal(el.innerText, text);
-    // With line length 10 and tab size 2, there should be a line break
-    // after every two tabs.
-    const newlineEl = el.querySelector('.contentText > .br');
-    assert.isOk(newlineEl);
-    assert.equal(
-        el.querySelector('.contentText .tab:nth-child(2)').nextSibling,
-        newlineEl);
-  });
-
-  test('_handlePreferenceError throws with invalid preference', () => {
-    element.prefs = {tab_size: 0};
-    assert.throws(() => element._getDiffBuilder());
-  });
-
-  test('_handlePreferenceError triggers alert and javascript error', () => {
-    const errorStub = sinon.stub();
-    element.addEventListener('show-alert', errorStub);
-    assert.throws(() => element._handlePreferenceError('tab size'));
-    assert.equal(errorStub.lastCall.args[0].detail.message,
-        `The value of the 'tab size' user preference is invalid. ` +
-      `Fix in diff preferences`);
-  });
-
-  suite('intraline differences', () => {
-    let el;
-    let str;
-    let annotateElementSpy;
-    let layer;
-    const lineNumberEl = document.createElement('td');
-
-    function slice(str, start, end) {
-      return Array.from(str).slice(start, end)
-          .join('');
-    }
-
-    setup(() => {
-      el = divWithTextFixture.instantiate();
-      str = el.textContent;
-      annotateElementSpy = sinon.spy(GrAnnotation, 'annotateElement');
-      layer = document.createElement('gr-diff-builder')
-          ._createIntralineLayer();
-    });
-
-    test('annotate no highlights', () => {
-      const line = {
-        text: str,
-        highlights: [],
-      };
-
-      layer.annotate(el, lineNumberEl, line);
-
-      // The content is unchanged.
-      assert.isFalse(annotateElementSpy.called);
-      assert.equal(el.childNodes.length, 1);
-      assert.instanceOf(el.childNodes[0], Text);
-      assert.equal(str, el.childNodes[0].textContent);
-    });
-
-    test('annotate with highlights', () => {
-      const line = {
-        text: str,
-        highlights: [
-          {startIndex: 6, endIndex: 12},
-          {startIndex: 18, endIndex: 22},
-        ],
-      };
-      const str0 = slice(str, 0, 6);
-      const str1 = slice(str, 6, 12);
-      const str2 = slice(str, 12, 18);
-      const str3 = slice(str, 18, 22);
-      const str4 = slice(str, 22);
-
-      layer.annotate(el, lineNumberEl, line);
-
-      assert.isTrue(annotateElementSpy.called);
-      assert.equal(el.childNodes.length, 5);
-
-      assert.instanceOf(el.childNodes[0], Text);
-      assert.equal(el.childNodes[0].textContent, str0);
-
-      assert.notInstanceOf(el.childNodes[1], Text);
-      assert.equal(el.childNodes[1].textContent, str1);
-
-      assert.instanceOf(el.childNodes[2], Text);
-      assert.equal(el.childNodes[2].textContent, str2);
-
-      assert.notInstanceOf(el.childNodes[3], Text);
-      assert.equal(el.childNodes[3].textContent, str3);
-
-      assert.instanceOf(el.childNodes[4], Text);
-      assert.equal(el.childNodes[4].textContent, str4);
-    });
-
-    test('annotate without endIndex', () => {
-      const line = {
-        text: str,
-        highlights: [
-          {startIndex: 28},
-        ],
-      };
-
-      const str0 = slice(str, 0, 28);
-      const str1 = slice(str, 28);
-
-      layer.annotate(el, lineNumberEl, line);
-
-      assert.isTrue(annotateElementSpy.called);
-      assert.equal(el.childNodes.length, 2);
-
-      assert.instanceOf(el.childNodes[0], Text);
-      assert.equal(el.childNodes[0].textContent, str0);
-
-      assert.notInstanceOf(el.childNodes[1], Text);
-      assert.equal(el.childNodes[1].textContent, str1);
-    });
-
-    test('annotate ignores empty highlights', () => {
-      const line = {
-        text: str,
-        highlights: [
-          {startIndex: 28, endIndex: 28},
-        ],
-      };
-
-      layer.annotate(el, lineNumberEl, line);
-
-      assert.isFalse(annotateElementSpy.called);
-      assert.equal(el.childNodes.length, 1);
-    });
-
-    test('annotate handles unicode', () => {
-      // Put some unicode into the string:
-      str = str.replace(/\s/g, '💢');
-      el.textContent = str;
-      const line = {
-        text: str,
-        highlights: [
-          {startIndex: 6, endIndex: 12},
-        ],
-      };
-
-      const str0 = slice(str, 0, 6);
-      const str1 = slice(str, 6, 12);
-      const str2 = slice(str, 12);
-
-      layer.annotate(el, lineNumberEl, line);
-
-      assert.isTrue(annotateElementSpy.called);
-      assert.equal(el.childNodes.length, 3);
-
-      assert.instanceOf(el.childNodes[0], Text);
-      assert.equal(el.childNodes[0].textContent, str0);
-
-      assert.notInstanceOf(el.childNodes[1], Text);
-      assert.equal(el.childNodes[1].textContent, str1);
-
-      assert.instanceOf(el.childNodes[2], Text);
-      assert.equal(el.childNodes[2].textContent, str2);
-    });
-
-    test('annotate handles unicode w/o endIndex', () => {
-      // Put some unicode into the string:
-      str = str.replace(/\s/g, '💢');
-      el.textContent = str;
-
-      const line = {
-        text: str,
-        highlights: [
-          {startIndex: 6},
-        ],
-      };
-
-      const str0 = slice(str, 0, 6);
-      const str1 = slice(str, 6);
-
-      layer.annotate(el, lineNumberEl, line);
-
-      assert.isTrue(annotateElementSpy.called);
-      assert.equal(el.childNodes.length, 2);
-
-      assert.instanceOf(el.childNodes[0], Text);
-      assert.equal(el.childNodes[0].textContent, str0);
-
-      assert.notInstanceOf(el.childNodes[1], Text);
-      assert.equal(el.childNodes[1].textContent, str1);
-    });
-  });
-
-  suite('tab indicators', () => {
-    let element;
-    let layer;
-    const lineNumberEl = document.createElement('td');
-
-    setup(() => {
-      element = basicFixture.instantiate();
-      element._showTabs = true;
-      layer = element._createTabIndicatorLayer();
-    });
-
-    test('does nothing with empty line', () => {
-      const line = {text: ''};
-      const el = document.createElement('div');
-      const annotateElementStub =
-          sinon.stub(GrAnnotation, 'annotateElement');
-
-      layer.annotate(el, lineNumberEl, line);
-
-      assert.isFalse(annotateElementStub.called);
-    });
-
-    test('does nothing with no tabs', () => {
-      const str = 'lorem ipsum no tabs';
-      const line = {text: str};
-      const el = document.createElement('div');
-      el.textContent = str;
-      const annotateElementStub =
-          sinon.stub(GrAnnotation, 'annotateElement');
-
-      layer.annotate(el, lineNumberEl, line);
-
-      assert.isFalse(annotateElementStub.called);
-    });
-
-    test('annotates tab at beginning', () => {
-      const str = '\tlorem upsum';
-      const line = {text: str};
-      const el = document.createElement('div');
-      el.textContent = str;
-      const annotateElementStub =
-          sinon.stub(GrAnnotation, 'annotateElement');
-
-      layer.annotate(el, lineNumberEl, line);
-
-      assert.equal(annotateElementStub.callCount, 1);
-      const args = annotateElementStub.getCalls()[0].args;
-      assert.equal(args[0], el);
-      assert.equal(args[1], 0, 'offset of tab indicator');
-      assert.equal(args[2], 1, 'length of tab indicator');
-      assert.include(args[3], 'tab-indicator');
-    });
-
-    test('does not annotate when disabled', () => {
-      element._showTabs = false;
-
-      const str = '\tlorem upsum';
-      const line = {text: str};
-      const el = document.createElement('div');
-      el.textContent = str;
-      const annotateElementStub =
-          sinon.stub(GrAnnotation, 'annotateElement');
-
-      layer.annotate(el, lineNumberEl, line);
-
-      assert.isFalse(annotateElementStub.called);
-    });
-
-    test('annotates multiple in beginning', () => {
-      const str = '\t\tlorem upsum';
-      const line = {text: str};
-      const el = document.createElement('div');
-      el.textContent = str;
-      const annotateElementStub =
-          sinon.stub(GrAnnotation, 'annotateElement');
-
-      layer.annotate(el, lineNumberEl, line);
-
-      assert.equal(annotateElementStub.callCount, 2);
-
-      let args = annotateElementStub.getCalls()[0].args;
-      assert.equal(args[0], el);
-      assert.equal(args[1], 0, 'offset of tab indicator');
-      assert.equal(args[2], 1, 'length of tab indicator');
-      assert.include(args[3], 'tab-indicator');
-
-      args = annotateElementStub.getCalls()[1].args;
-      assert.equal(args[0], el);
-      assert.equal(args[1], 1, 'offset of tab indicator');
-      assert.equal(args[2], 1, 'length of tab indicator');
-      assert.include(args[3], 'tab-indicator');
-    });
-
-    test('annotates intermediate tabs', () => {
-      const str = 'lorem\tupsum';
-      const line = {text: str};
-      const el = document.createElement('div');
-      el.textContent = str;
-      const annotateElementStub =
-          sinon.stub(GrAnnotation, 'annotateElement');
-
-      layer.annotate(el, lineNumberEl, line);
-
-      assert.equal(annotateElementStub.callCount, 1);
-      const args = annotateElementStub.getCalls()[0].args;
-      assert.equal(args[0], el);
-      assert.equal(args[1], 5, 'offset of tab indicator');
-      assert.equal(args[2], 1, 'length of tab indicator');
-      assert.include(args[3], 'tab-indicator');
-    });
-  });
-
-  suite('layers', () => {
-    let element;
-    let initialLayersCount;
-    let withLayerCount;
-    setup(() => {
-      const layers = [];
-      element = basicFixture.instantiate();
-      element.layers = layers;
-      element._showTrailingWhitespace = true;
-      element._setupAnnotationLayers();
-      initialLayersCount = element._layers.length;
-    });
-
-    test('no layers', () => {
-      element._setupAnnotationLayers();
-      assert.equal(element._layers.length, initialLayersCount);
-    });
-
-    suite('with layers', () => {
-      const layers = [{}, {}];
-      setup(() => {
-        element = basicFixture.instantiate();
-        element.layers = layers;
-        element._showTrailingWhitespace = true;
-        element._setupAnnotationLayers();
-        withLayerCount = element._layers.length;
-      });
-      test('with layers', () => {
-        element._setupAnnotationLayers();
-        assert.equal(element._layers.length, withLayerCount);
-        assert.equal(initialLayersCount + layers.length,
-            withLayerCount);
-      });
-    });
-  });
-
-  suite('trailing whitespace', () => {
-    let element;
-    let layer;
-    const lineNumberEl = document.createElement('td');
-
-    setup(() => {
-      element = basicFixture.instantiate();
-      element._showTrailingWhitespace = true;
-      layer = element._createTrailingWhitespaceLayer();
-    });
-
-    test('does nothing with empty line', () => {
-      const line = {text: ''};
-      const el = document.createElement('div');
-      const annotateElementStub =
-          sinon.stub(GrAnnotation, 'annotateElement');
-      layer.annotate(el, lineNumberEl, line);
-      assert.isFalse(annotateElementStub.called);
-    });
-
-    test('does nothing with no trailing whitespace', () => {
-      const str = 'lorem ipsum blah blah';
-      const line = {text: str};
-      const el = document.createElement('div');
-      el.textContent = str;
-      const annotateElementStub =
-          sinon.stub(GrAnnotation, 'annotateElement');
-      layer.annotate(el, lineNumberEl, line);
-      assert.isFalse(annotateElementStub.called);
-    });
-
-    test('annotates trailing spaces', () => {
-      const str = 'lorem ipsum   ';
-      const line = {text: str};
-      const el = document.createElement('div');
-      el.textContent = str;
-      const annotateElementStub =
-          sinon.stub(GrAnnotation, 'annotateElement');
-      layer.annotate(el, lineNumberEl, line);
-      assert.isTrue(annotateElementStub.called);
-      assert.equal(annotateElementStub.lastCall.args[1], 11);
-      assert.equal(annotateElementStub.lastCall.args[2], 3);
-    });
-
-    test('annotates trailing tabs', () => {
-      const str = 'lorem ipsum\t\t\t';
-      const line = {text: str};
-      const el = document.createElement('div');
-      el.textContent = str;
-      const annotateElementStub =
-          sinon.stub(GrAnnotation, 'annotateElement');
-      layer.annotate(el, lineNumberEl, line);
-      assert.isTrue(annotateElementStub.called);
-      assert.equal(annotateElementStub.lastCall.args[1], 11);
-      assert.equal(annotateElementStub.lastCall.args[2], 3);
-    });
-
-    test('annotates mixed trailing whitespace', () => {
-      const str = 'lorem ipsum\t \t';
-      const line = {text: str};
-      const el = document.createElement('div');
-      el.textContent = str;
-      const annotateElementStub =
-          sinon.stub(GrAnnotation, 'annotateElement');
-      layer.annotate(el, lineNumberEl, line);
-      assert.isTrue(annotateElementStub.called);
-      assert.equal(annotateElementStub.lastCall.args[1], 11);
-      assert.equal(annotateElementStub.lastCall.args[2], 3);
-    });
-
-    test('unicode preceding trailing whitespace', () => {
-      const str = '💢\t';
-      const line = {text: str};
-      const el = document.createElement('div');
-      el.textContent = str;
-      const annotateElementStub =
-          sinon.stub(GrAnnotation, 'annotateElement');
-      layer.annotate(el, lineNumberEl, line);
-      assert.isTrue(annotateElementStub.called);
-      assert.equal(annotateElementStub.lastCall.args[1], 1);
-      assert.equal(annotateElementStub.lastCall.args[2], 1);
-    });
-
-    test('does not annotate when disabled', () => {
-      element._showTrailingWhitespace = false;
-      const str = 'lorem upsum\t \t ';
-      const line = {text: str};
-      const el = document.createElement('div');
-      el.textContent = str;
-      const annotateElementStub =
-          sinon.stub(GrAnnotation, 'annotateElement');
-      layer.annotate(el, lineNumberEl, line);
-      assert.isFalse(annotateElementStub.called);
-    });
-  });
-
-  suite('rendering text, images and binary files', () => {
-    let processStub;
-    let keyLocations;
-    let content;
-
-    setup(() => {
-      element = basicFixture.instantiate();
-      element.viewMode = 'SIDE_BY_SIDE';
-      processStub = sinon.stub(element.processor, 'process')
-          .returns(Promise.resolve());
-      keyLocations = {left: {}, right: {}};
-      element.prefs = {
-        ...DEFAULT_PREFS,
-        context: -1,
-        syntax_highlighting: true,
-      };
-      content = [{
-        a: ['all work and no play make andybons a dull boy'],
-        b: ['elgoog elgoog elgoog'],
-      }, {
-        ab: [
-          'Non eram nescius, Brute, cum, quae summis ingeniis ',
-          'exquisitaque doctrina philosophi Graeco sermone tractavissent',
-        ],
-      }];
-    });
-
-    test('text', () => {
-      element.diff = {content};
-      return element.render(keyLocations).then(() => {
-        assert.isTrue(processStub.calledOnce);
-        assert.isFalse(processStub.lastCall.args[1]);
-      });
-    });
-
-    test('image', () => {
-      element.diff = {content, binary: true};
-      element.isImageDiff = true;
-      return element.render(keyLocations).then(() => {
-        assert.isTrue(processStub.calledOnce);
-        assert.isTrue(processStub.lastCall.args[1]);
-      });
-    });
-
-    test('binary', () => {
-      element.diff = {content, binary: true};
-      return element.render(keyLocations).then(() => {
-        assert.isTrue(processStub.calledOnce);
-        assert.isTrue(processStub.lastCall.args[1]);
-      });
-    });
-  });
-
-  suite('rendering', () => {
-    let content;
-    let outputEl;
-    let keyLocations;
-
-    setup(async () => {
-      const prefs = {...DEFAULT_PREFS};
-      content = [
-        {
-          a: ['all work and no play make andybons a dull boy'],
-          b: ['elgoog elgoog elgoog'],
-        },
-        {
-          ab: [
-            'Non eram nescius, Brute, cum, quae summis ingeniis ',
-            'exquisitaque doctrina philosophi Graeco sermone tractavissent',
-          ],
-        },
-      ];
-      element = basicFixture.instantiate();
-      sinon.stub(element, 'dispatchEvent');
-      outputEl = element.querySelector('#diffTable');
-      keyLocations = {left: {}, right: {}};
-      sinon.stub(element, '_getDiffBuilder').callsFake(() => {
-        const builder = new GrDiffBuilderSideBySide({content}, prefs, outputEl);
-        sinon.stub(builder, 'addColumns');
-        builder.buildSectionElement = function(group) {
-          const section = document.createElement('stub');
-          section.textContent = group.lines
-              .reduce((acc, line) => acc + line.text, '');
-          return section;
-        };
-        return builder;
-      });
-      element.diff = {content};
-      element.prefs = prefs;
-      await element.render(keyLocations);
-    });
-
-    test('addColumns is called', () => {
-      assert.isTrue(element._builder.addColumns.called);
-    });
-
-    test('getGroupsByLineRange one line', () => {
-      const section = outputEl.querySelector('stub:nth-of-type(3)');
-      const groups = element._builder.getGroupsByLineRange(1, 1, 'left');
-      assert.equal(groups.length, 1);
-      assert.strictEqual(groups[0].element, section);
-    });
-
-    test('getGroupsByLineRange over diff', () => {
-      const section = [
-        outputEl.querySelector('stub:nth-of-type(3)'),
-        outputEl.querySelector('stub:nth-of-type(4)'),
-      ];
-      const groups = element._builder.getGroupsByLineRange(1, 2, 'left');
-      assert.equal(groups.length, 2);
-      assert.strictEqual(groups[0].element, section[0]);
-      assert.strictEqual(groups[1].element, section[1]);
-    });
-
-    test('render-start and render-content are fired', async () => {
-      const firedEventTypes = element.dispatchEvent.getCalls()
-          .map(c => c.args[0].type);
-      assert.include(firedEventTypes, 'render-start');
-      assert.include(firedEventTypes, 'render-content');
-    });
-
-    test('cancel cancels the processor', () => {
-      const processorCancelStub = sinon.stub(element.processor, 'cancel');
-      element.cancel();
-      assert.isTrue(processorCancelStub.called);
-    });
-  });
-
-  suite('context hiding and expanding', () => {
-    setup(async () => {
-      element = basicFixture.instantiate();
-      sinon.stub(element, 'dispatchEvent');
-      const afterNextRenderPromise = new Promise((resolve, reject) => {
-        afterNextRender(element, resolve);
-      });
-      element.diff = {
-        content: [
-          {ab: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map(i => `unchanged ${i}`)},
-          {a: ['before'], b: ['after']},
-          {ab: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map(i => `unchanged ${10 + i}`)},
-        ],
-      };
-      element.viewMode = DiffViewMode.SIDE_BY_SIDE;
-
-      const keyLocations = {left: {}, right: {}};
-      element.prefs = {
-        ...DEFAULT_PREFS,
-        context: 1,
-      };
-      await element.render(keyLocations);
-      // Make sure all listeners are installed.
-      await afterNextRenderPromise;
-    });
-
-    test('hides lines behind two context controls', () => {
-      const contextControls = element.querySelectorAll('gr-context-controls');
-      assert.equal(contextControls.length, 2);
-
-      const diffRows = element.querySelectorAll('.diff-row');
-      // The first two are LOST and FILE line
-      assert.equal(diffRows.length, 2 + 1 + 1 + 1);
-      assert.include(diffRows[2].textContent, 'unchanged 10');
-      assert.include(diffRows[3].textContent, 'before');
-      assert.include(diffRows[3].textContent, 'after');
-      assert.include(diffRows[4].textContent, 'unchanged 11');
-    });
-
-    test('clicking +x common lines expands those lines', () => {
-      const contextControls = element.querySelectorAll('gr-context-controls');
-      const topExpandCommonButton = contextControls[0].shadowRoot
-          .querySelectorAll('.showContext')[0];
-      assert.include(topExpandCommonButton.textContent, '+9 common lines');
-      topExpandCommonButton.click();
-      const diffRows = element.querySelectorAll('.diff-row');
-      // The first two are LOST and FILE line
-      assert.equal(diffRows.length, 2 + 10 + 1 + 1);
-      assert.include(diffRows[2].textContent, 'unchanged 1');
-      assert.include(diffRows[3].textContent, 'unchanged 2');
-      assert.include(diffRows[4].textContent, 'unchanged 3');
-      assert.include(diffRows[5].textContent, 'unchanged 4');
-      assert.include(diffRows[6].textContent, 'unchanged 5');
-      assert.include(diffRows[7].textContent, 'unchanged 6');
-      assert.include(diffRows[8].textContent, 'unchanged 7');
-      assert.include(diffRows[9].textContent, 'unchanged 8');
-      assert.include(diffRows[10].textContent, 'unchanged 9');
-      assert.include(diffRows[11].textContent, 'unchanged 10');
-      assert.include(diffRows[12].textContent, 'before');
-      assert.include(diffRows[12].textContent, 'after');
-      assert.include(diffRows[13].textContent, 'unchanged 11');
-    });
-
-    test('unhideLine shows the line with context', async () => {
-      const clock = sinon.useFakeTimers();
-      element.dispatchEvent.reset();
-      element.unhideLine(4, Side.LEFT);
-
-      const diffRows = element.querySelectorAll('.diff-row');
-      // The first two are LOST and FILE line
-      // Lines 3-5 (Line 4 plus 1 context in each direction) will be expanded
-      // Because context expanders do not hide <3 lines, lines 1-2 will also
-      // be shown.
-      // Lines 6-9 continue to be hidden
-      assert.equal(diffRows.length, 2 + 5 + 1 + 1 + 1);
-      assert.include(diffRows[2].textContent, 'unchanged 1');
-      assert.include(diffRows[3].textContent, 'unchanged 2');
-      assert.include(diffRows[4].textContent, 'unchanged 3');
-      assert.include(diffRows[5].textContent, 'unchanged 4');
-      assert.include(diffRows[6].textContent, 'unchanged 5');
-      assert.include(diffRows[7].textContent, 'unchanged 10');
-      assert.include(diffRows[8].textContent, 'before');
-      assert.include(diffRows[8].textContent, 'after');
-      assert.include(diffRows[9].textContent, 'unchanged 11');
-
-      clock.tick(1);
-      await flush();
-      const firedEventTypes = element.dispatchEvent.getCalls()
-          .map(c => c.args[0].type);
-      assert.include(firedEventTypes, 'render-content');
-    });
-  });
-
-  suite('mock-diff', () => {
-    let element;
-    let builder;
-    let diff;
-    let keyLocations;
-
-    setup(async () => {
-      element = mockDiffFixture.instantiate();
-      diff = createDiff();
-      element.diff = diff;
-
-      keyLocations = {left: {}, right: {}};
-
-      element.prefs = {
-        line_length: 80,
-        show_tabs: true,
-        tab_size: 4,
-      };
-      await element.render(keyLocations);
-      builder = element._builder;
-    });
-
-    test('aria-labels on added line numbers', () => {
-      const deltaLineNumberButton = element.diffElement.querySelectorAll(
-          '.lineNumButton.right')[5];
-
-      assert.isOk(deltaLineNumberButton);
-      assert.equal(deltaLineNumberButton.getAttribute('aria-label'), '5 added');
-    });
-
-    test('aria-labels on removed line numbers', () => {
-      const deltaLineNumberButton = element.diffElement.querySelectorAll(
-          '.lineNumButton.left')[10];
-
-      assert.isOk(deltaLineNumberButton);
-      assert.equal(
-          deltaLineNumberButton.getAttribute('aria-label'), '10 removed');
-    });
-
-    test('getContentByLine', () => {
-      let actual;
-
-      actual = builder.getContentByLine(2, 'left');
-      assert.equal(actual.textContent, diff.content[0].ab[1]);
-
-      actual = builder.getContentByLine(2, 'right');
-      assert.equal(actual.textContent, diff.content[0].ab[1]);
-
-      actual = builder.getContentByLine(5, 'left');
-      assert.equal(actual.textContent, diff.content[2].ab[0]);
-
-      actual = builder.getContentByLine(5, 'right');
-      assert.equal(actual.textContent, diff.content[1].b[0]);
-    });
-
-    test('getContentTdByLineEl works both with button and td', () => {
-      const diffRow = element.diffElement.querySelectorAll('tr.diff-row')[2];
-
-      const lineNumTdLeft = diffRow.querySelector('td.lineNum.left');
-      const lineNumButtonLeft = lineNumTdLeft.querySelector('button');
-      const contentTdLeft = diffRow.querySelectorAll('.content')[0];
-
-      const lineNumTdRight = diffRow.querySelector('td.lineNum.right');
-      const lineNumButtonRight = lineNumTdRight.querySelector('button');
-      const contentTdRight = diffRow.querySelectorAll('.content')[1];
-
-      assert.equal(element.getContentTdByLineEl(lineNumTdLeft), contentTdLeft);
-      assert.equal(
-          element.getContentTdByLineEl(lineNumButtonLeft), contentTdLeft);
-      assert.equal(
-          element.getContentTdByLineEl(lineNumTdRight), contentTdRight);
-      assert.equal(
-          element.getContentTdByLineEl(lineNumButtonRight), contentTdRight);
-    });
-
-    test('findLinesByRange', () => {
-      const lines = [];
-      const elems = [];
-      const start = 6;
-      const end = 10;
-      const count = end - start + 1;
-
-      builder.findLinesByRange(start, end, 'right', lines, elems);
-
-      assert.equal(lines.length, count);
-      assert.equal(elems.length, count);
-
-      for (let i = 0; i < 5; i++) {
-        assert.instanceOf(lines[i], GrDiffLine);
-        assert.equal(lines[i].afterNumber, start + i);
-        assert.instanceOf(elems[i], HTMLElement);
-        assert.equal(lines[i].text, elems[i].textContent);
-      }
-    });
-
-    test('renderContentByRange', () => {
-      const spy = sinon.spy(builder, 'createTextEl');
-      const start = 9;
-      const end = 14;
-      const count = end - start + 1;
-
-      builder.renderContentByRange(start, end, 'left');
-
-      assert.equal(spy.callCount, count);
-      spy.getCalls().forEach((call, i) => {
-        assert.equal(call.args[1].beforeNumber, start + i);
-      });
-    });
-
-    test('renderContentByRange non-existent elements', () => {
-      const spy = sinon.spy(builder, 'createTextEl');
-
-      sinon.stub(builder, 'getLineNumberEl').returns(
-          document.createElement('div')
-      );
-      sinon.stub(builder, 'findLinesByRange').callsFake(
-          (s, e, d, lines, elements) => {
-            // Add a line and a corresponding element.
-            lines.push(new GrDiffLine(GrDiffLineType.BOTH));
-            const tr = document.createElement('tr');
-            const td = document.createElement('td');
-            const el = document.createElement('div');
-            tr.appendChild(td);
-            td.appendChild(el);
-            elements.push(el);
-
-            // Add 2 lines without corresponding elements.
-            lines.push(new GrDiffLine(GrDiffLineType.BOTH));
-            lines.push(new GrDiffLine(GrDiffLineType.BOTH));
-          });
-
-      builder.renderContentByRange(1, 10, 'left');
-      // Should be called only once because only one line had a corresponding
-      // element.
-      assert.equal(spy.callCount, 1);
-    });
-
-    test('getLineNumberEl side-by-side left', () => {
-      const contentEl = builder.getContentByLine(5, 'left',
-          element.$.diffTable);
-      const lineNumberEl = builder.getLineNumberEl(contentEl, 'left');
-      assert.isTrue(lineNumberEl.classList.contains('lineNum'));
-      assert.isTrue(lineNumberEl.classList.contains('left'));
-    });
-
-    test('getLineNumberEl side-by-side right', () => {
-      const contentEl = builder.getContentByLine(5, 'right',
-          element.$.diffTable);
-      const lineNumberEl = builder.getLineNumberEl(contentEl, 'right');
-      assert.isTrue(lineNumberEl.classList.contains('lineNum'));
-      assert.isTrue(lineNumberEl.classList.contains('right'));
-    });
-
-    test('getLineNumberEl unified left', async () => {
-      // Re-render as unified:
-      element.viewMode = 'UNIFIED_DIFF';
-      await element.render(keyLocations);
-      builder = element._builder;
-
-      const contentEl = builder.getContentByLine(5, 'left',
-          element.$.diffTable);
-      const lineNumberEl = builder.getLineNumberEl(contentEl, 'left');
-      assert.isTrue(lineNumberEl.classList.contains('lineNum'));
-      assert.isTrue(lineNumberEl.classList.contains('left'));
-    });
-
-    test('getLineNumberEl unified right', async () => {
-      // Re-render as unified:
-      element.viewMode = 'UNIFIED_DIFF';
-      await element.render(keyLocations);
-      builder = element._builder;
-
-      const contentEl = builder.getContentByLine(5, 'right',
-          element.$.diffTable);
-      const lineNumberEl = builder.getLineNumberEl(contentEl, 'right');
-      assert.isTrue(lineNumberEl.classList.contains('lineNum'));
-      assert.isTrue(lineNumberEl.classList.contains('right'));
-    });
-
-    test('getNextContentOnSide side-by-side left', () => {
-      const startElem = builder.getContentByLine(5, 'left',
-          element.$.diffTable);
-      const expectedStartString = diff.content[2].ab[0];
-      const expectedNextString = diff.content[2].ab[1];
-      assert.equal(startElem.textContent, expectedStartString);
-
-      const nextElem = builder.getNextContentOnSide(startElem,
-          'left');
-      assert.equal(nextElem.textContent, expectedNextString);
-    });
-
-    test('getNextContentOnSide side-by-side right', () => {
-      const startElem = builder.getContentByLine(5, 'right',
-          element.$.diffTable);
-      const expectedStartString = diff.content[1].b[0];
-      const expectedNextString = diff.content[1].b[1];
-      assert.equal(startElem.textContent, expectedStartString);
-
-      const nextElem = builder.getNextContentOnSide(startElem,
-          'right');
-      assert.equal(nextElem.textContent, expectedNextString);
-    });
-
-    test('getNextContentOnSide unified left', async () => {
-      // Re-render as unified:
-      element.viewMode = 'UNIFIED_DIFF';
-      await element.render(keyLocations);
-      builder = element._builder;
-
-      const startElem = builder.getContentByLine(5, 'left',
-          element.$.diffTable);
-      const expectedStartString = diff.content[2].ab[0];
-      const expectedNextString = diff.content[2].ab[1];
-      assert.equal(startElem.textContent, expectedStartString);
-
-      const nextElem = builder.getNextContentOnSide(startElem,
-          'left');
-      assert.equal(nextElem.textContent, expectedNextString);
-    });
-
-    test('getNextContentOnSide unified right', async () => {
-      // Re-render as unified:
-      element.viewMode = 'UNIFIED_DIFF';
-      await element.render(keyLocations);
-      builder = element._builder;
-
-      const startElem = builder.getContentByLine(5, 'right',
-          element.$.diffTable);
-      const expectedStartString = diff.content[1].b[0];
-      const expectedNextString = diff.content[1].b[1];
-      assert.equal(startElem.textContent, expectedStartString);
-
-      const nextElem = builder.getNextContentOnSide(startElem,
-          'right');
-      assert.equal(nextElem.textContent, expectedNextString);
-    });
-  });
-
-  suite('blame', () => {
-    let mockBlame;
-
-    setup(() => {
-      mockBlame = [
-        {id: 'commit 1', ranges: [{start: 1, end: 2}, {start: 10, end: 16}]},
-        {id: 'commit 2', ranges: [{start: 4, end: 10}, {start: 17, end: 32}]},
-      ];
-    });
-
-    test('setBlame attempts to render each blamed line', () => {
-      const getBlameStub = sinon.stub(builder, 'getBlameTdByLine')
-          .returns(null);
-      builder.setBlame(mockBlame);
-      assert.equal(getBlameStub.callCount, 32);
-    });
-
-    test('getBlameCommitForBaseLine', () => {
-      sinon.stub(builder, 'getBlameTdByLine').returns(undefined);
-      builder.setBlame(mockBlame);
-      assert.isOk(builder.getBlameCommitForBaseLine(1));
-      assert.equal(builder.getBlameCommitForBaseLine(1).id, 'commit 1');
-
-      assert.isOk(builder.getBlameCommitForBaseLine(11));
-      assert.equal(builder.getBlameCommitForBaseLine(11).id, 'commit 1');
-
-      assert.isOk(builder.getBlameCommitForBaseLine(32));
-      assert.equal(builder.getBlameCommitForBaseLine(32).id, 'commit 2');
-
-      assert.isUndefined(builder.getBlameCommitForBaseLine(33));
-    });
-
-    test('getBlameCommitForBaseLine w/o blame returns null', () => {
-      assert.isUndefined(builder.getBlameCommitForBaseLine(1));
-      assert.isUndefined(builder.getBlameCommitForBaseLine(11));
-      assert.isUndefined(builder.getBlameCommitForBaseLine(31));
-    });
-
-    test('createBlameCell', () => {
-      const mockBlameInfo = {
-        time: 1576155200,
-        id: 1234567890,
-        author: 'Clark Kent',
-        commit_msg: 'Testing Commit',
-        ranges: [1],
-      };
-      const getBlameStub = sinon.stub(builder, 'getBlameCommitForBaseLine')
-          .returns(mockBlameInfo);
-      const line = new GrDiffLine(GrDiffLineType.BOTH);
-      line.beforeNumber = 3;
-      line.afterNumber = 5;
-
-      const result = builder.createBlameCell(line.beforeNumber);
-
-      assert.isTrue(getBlameStub.calledWithExactly(3));
-      assert.equal(result.getAttribute('data-line-number'), '3');
-      expect(result).dom.to.equal(/* HTML */`
-        <span class="gr-diff style-scope">
-          <a class="blameDate gr-diff style-scope" href="/r/q/1234567890">
-            12/12/2019
-          </a>
-          <span class="blameAuthor gr-diff style-scope">Clark</span>
-          <gr-hovercard class="gr-diff style-scope">
-            <span class="blameHoverCard gr-diff style-scope">
-              Commit 1234567890<br>
-              Author: Clark Kent<br>
-              Date: 12/12/2019<br>
-              <br>
-              Testing Commit
-            </span>
-          </gr-hovercard>
-        </span>
-      `);
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element_test.ts
new file mode 100644
index 0000000..d37afc0
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element_test.ts
@@ -0,0 +1,1127 @@
+/**
+ * @license
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup-karma';
+import {
+  createConfig,
+  createDiff,
+  createEmptyDiff,
+} from '../../../test/test-data-generators';
+import './gr-diff-builder-element';
+import {queryAndAssert, stubBaseUrl, waitUntil} from '../../../test/test-utils';
+import {GrAnnotation} from '../gr-diff-highlight/gr-annotation';
+import {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line';
+import {GrDiffBuilderSideBySide} from './gr-diff-builder-side-by-side';
+import {
+  DiffContent,
+  DiffInfo,
+  DiffLayer,
+  DiffPreferencesInfo,
+  DiffViewMode,
+  Side,
+} from '../../../api/diff';
+import {stubRestApi} from '../../../test/test-utils';
+import {GrDiffBuilderLegacy} from './gr-diff-builder-legacy';
+import {waitForEventOnce} from '../../../utils/event-util';
+import {GrDiffBuilderElement} from './gr-diff-builder-element';
+import {createDefaultDiffPrefs} from '../../../constants/constants';
+import {KeyLocations} from '../gr-diff-processor/gr-diff-processor';
+import {BlameInfo} from '../../../types/common';
+import {fixture, html} from '@open-wc/testing-helpers';
+
+const DEFAULT_PREFS = createDefaultDiffPrefs();
+
+suite('gr-diff-builder tests', () => {
+  let element: GrDiffBuilderElement;
+  let builder: GrDiffBuilderLegacy;
+  let diffTable: HTMLTableElement;
+
+  const LINE_BREAK_HTML = '<span class="style-scope gr-diff br"></span>';
+  const WBR_HTML = '<wbr class="style-scope gr-diff">';
+
+  const setBuilderPrefs = (prefs: Partial<DiffPreferencesInfo>) => {
+    builder = new GrDiffBuilderSideBySide(
+      createEmptyDiff(),
+      {...createDefaultDiffPrefs(), ...prefs},
+      diffTable
+    );
+  };
+
+  const line = (text: string) => {
+    const line = new GrDiffLine(GrDiffLineType.BOTH);
+    line.text = text;
+    return line;
+  };
+
+  setup(async () => {
+    diffTable = await fixture(html`<table id="diffTable"></table>`);
+    element = new GrDiffBuilderElement();
+    element.diffElement = diffTable;
+    stubRestApi('getLoggedIn').returns(Promise.resolve(false));
+    stubRestApi('getProjectConfig').returns(Promise.resolve(createConfig()));
+    stubBaseUrl('/r');
+    setBuilderPrefs({});
+  });
+
+  test('line_length applied with <wbr> if line_wrapping is true', () => {
+    setBuilderPrefs({line_wrapping: true, tab_size: 4, line_length: 50});
+    const text = 'a'.repeat(51);
+    const expected = 'a'.repeat(50) + WBR_HTML + 'a';
+    const result = builder.createTextEl(null, line(text)).firstElementChild
+      ?.innerHTML;
+    assert.equal(result, expected);
+  });
+
+  test('line_length applied with line break if line_wrapping is false', () => {
+    setBuilderPrefs({line_wrapping: false, tab_size: 4, line_length: 50});
+    const text = 'a'.repeat(51);
+    const expected = 'a'.repeat(50) + LINE_BREAK_HTML + 'a';
+    const result = builder.createTextEl(null, line(text)).firstElementChild
+      ?.innerHTML;
+    assert.equal(result, expected);
+  });
+
+  [DiffViewMode.UNIFIED, DiffViewMode.SIDE_BY_SIDE].forEach(mode => {
+    test(`line_length used for regular files under ${mode}`, () => {
+      element.path = '/a.txt';
+      element.viewMode = mode;
+      element.diff = createEmptyDiff();
+      element.prefs = {
+        ...createDefaultDiffPrefs(),
+        tab_size: 4,
+        line_length: 50,
+      };
+      builder = element.getDiffBuilder() as GrDiffBuilderLegacy;
+      assert.equal(builder._prefs.line_length, 50);
+    });
+
+    test(`line_length ignored for commit msg under ${mode}`, () => {
+      element.path = '/COMMIT_MSG';
+      element.viewMode = mode;
+      element.diff = createEmptyDiff();
+      element.prefs = {
+        ...createDefaultDiffPrefs(),
+        tab_size: 4,
+        line_length: 50,
+      };
+      builder = element.getDiffBuilder() as GrDiffBuilderLegacy;
+      assert.equal(builder._prefs.line_length, 72);
+    });
+  });
+
+  test('createTextEl linewrap with tabs', () => {
+    setBuilderPrefs({tab_size: 4, line_length: 10});
+    const text = '\t'.repeat(7) + '!';
+    const el = builder.createTextEl(null, line(text));
+    assert.equal(el.innerText, text);
+    // With line length 10 and tab size 4, there should be a line break
+    // after every two tabs.
+    const newlineEl = el.querySelector('.contentText > .br');
+    assert.isOk(newlineEl);
+    assert.equal(
+      el.querySelector('.contentText .tab:nth-child(2)')?.nextSibling,
+      newlineEl
+    );
+  });
+
+  test('_handlePreferenceError throws with invalid preference', () => {
+    element.prefs = {...createDefaultDiffPrefs(), tab_size: 0};
+    assert.throws(() => element.getDiffBuilder());
+  });
+
+  test('_handlePreferenceError triggers alert and javascript error', () => {
+    const errorStub = sinon.stub();
+    diffTable.addEventListener('show-alert', errorStub);
+    assert.throws(() => element.handlePreferenceError('tab size'));
+    assert.equal(
+      errorStub.lastCall.args[0].detail.message,
+      "The value of the 'tab size' user preference is invalid. " +
+        'Fix in diff preferences'
+    );
+  });
+
+  suite('intraline differences', () => {
+    let el: HTMLElement;
+    let str: string;
+    let annotateElementSpy: sinon.SinonSpy;
+    let layer: DiffLayer;
+    const lineNumberEl = document.createElement('td');
+
+    function slice(str: string, start: number, end?: number) {
+      return Array.from(str).slice(start, end).join('');
+    }
+
+    setup(async () => {
+      el = await fixture(html`
+        <div>Lorem ipsum dolor sit amet, suspendisse inceptos vehicula</div>
+      `);
+      str = el.textContent ?? '';
+      annotateElementSpy = sinon.spy(GrAnnotation, 'annotateElement');
+      layer = element.createIntralineLayer();
+    });
+
+    test('annotate no highlights', () => {
+      layer.annotate(el, lineNumberEl, line(str), Side.LEFT);
+
+      // The content is unchanged.
+      assert.isFalse(annotateElementSpy.called);
+      assert.equal(el.childNodes.length, 1);
+      assert.instanceOf(el.childNodes[0], Text);
+      assert.equal(str, el.childNodes[0].textContent);
+    });
+
+    test('annotate with highlights', () => {
+      const l = line(str);
+      l.highlights = [
+        {contentIndex: 0, startIndex: 6, endIndex: 12},
+        {contentIndex: 0, startIndex: 18, endIndex: 22},
+      ];
+      const str0 = slice(str, 0, 6);
+      const str1 = slice(str, 6, 12);
+      const str2 = slice(str, 12, 18);
+      const str3 = slice(str, 18, 22);
+      const str4 = slice(str, 22);
+
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+
+      assert.isTrue(annotateElementSpy.called);
+      assert.equal(el.childNodes.length, 5);
+
+      assert.instanceOf(el.childNodes[0], Text);
+      assert.equal(el.childNodes[0].textContent, str0);
+
+      assert.notInstanceOf(el.childNodes[1], Text);
+      assert.equal(el.childNodes[1].textContent, str1);
+
+      assert.instanceOf(el.childNodes[2], Text);
+      assert.equal(el.childNodes[2].textContent, str2);
+
+      assert.notInstanceOf(el.childNodes[3], Text);
+      assert.equal(el.childNodes[3].textContent, str3);
+
+      assert.instanceOf(el.childNodes[4], Text);
+      assert.equal(el.childNodes[4].textContent, str4);
+    });
+
+    test('annotate without endIndex', () => {
+      const l = line(str);
+      l.highlights = [{contentIndex: 0, startIndex: 28}];
+
+      const str0 = slice(str, 0, 28);
+      const str1 = slice(str, 28);
+
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+
+      assert.isTrue(annotateElementSpy.called);
+      assert.equal(el.childNodes.length, 2);
+
+      assert.instanceOf(el.childNodes[0], Text);
+      assert.equal(el.childNodes[0].textContent, str0);
+
+      assert.notInstanceOf(el.childNodes[1], Text);
+      assert.equal(el.childNodes[1].textContent, str1);
+    });
+
+    test('annotate ignores empty highlights', () => {
+      const l = line(str);
+      l.highlights = [{contentIndex: 0, startIndex: 28, endIndex: 28}];
+
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+
+      assert.isFalse(annotateElementSpy.called);
+      assert.equal(el.childNodes.length, 1);
+    });
+
+    test('annotate handles unicode', () => {
+      // Put some unicode into the string:
+      str = str.replace(/\s/g, '💢');
+      el.textContent = str;
+      const l = line(str);
+      l.highlights = [{contentIndex: 0, startIndex: 6, endIndex: 12}];
+
+      const str0 = slice(str, 0, 6);
+      const str1 = slice(str, 6, 12);
+      const str2 = slice(str, 12);
+
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+
+      assert.isTrue(annotateElementSpy.called);
+      assert.equal(el.childNodes.length, 3);
+
+      assert.instanceOf(el.childNodes[0], Text);
+      assert.equal(el.childNodes[0].textContent, str0);
+
+      assert.notInstanceOf(el.childNodes[1], Text);
+      assert.equal(el.childNodes[1].textContent, str1);
+
+      assert.instanceOf(el.childNodes[2], Text);
+      assert.equal(el.childNodes[2].textContent, str2);
+    });
+
+    test('annotate handles unicode w/o endIndex', () => {
+      // Put some unicode into the string:
+      str = str.replace(/\s/g, '💢');
+      el.textContent = str;
+
+      const l = line(str);
+      l.highlights = [{contentIndex: 0, startIndex: 6}];
+
+      const str0 = slice(str, 0, 6);
+      const str1 = slice(str, 6);
+
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+
+      assert.isTrue(annotateElementSpy.called);
+      assert.equal(el.childNodes.length, 2);
+
+      assert.instanceOf(el.childNodes[0], Text);
+      assert.equal(el.childNodes[0].textContent, str0);
+
+      assert.notInstanceOf(el.childNodes[1], Text);
+      assert.equal(el.childNodes[1].textContent, str1);
+    });
+  });
+
+  suite('tab indicators', () => {
+    let layer: DiffLayer;
+    const lineNumberEl = document.createElement('td');
+
+    setup(() => {
+      element.showTabs = true;
+      layer = element.createTabIndicatorLayer();
+    });
+
+    test('does nothing with empty line', () => {
+      const l = line('');
+      const el = document.createElement('div');
+      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+
+      assert.isFalse(annotateElementStub.called);
+    });
+
+    test('does nothing with no tabs', () => {
+      const str = 'lorem ipsum no tabs';
+      const l = line(str);
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+
+      assert.isFalse(annotateElementStub.called);
+    });
+
+    test('annotates tab at beginning', () => {
+      const str = '\tlorem upsum';
+      const l = line(str);
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+
+      assert.equal(annotateElementStub.callCount, 1);
+      const args = annotateElementStub.getCalls()[0].args;
+      assert.equal(args[0], el);
+      assert.equal(args[1], 0, 'offset of tab indicator');
+      assert.equal(args[2], 1, 'length of tab indicator');
+      assert.include(args[3], 'tab-indicator');
+    });
+
+    test('does not annotate when disabled', () => {
+      element.showTabs = false;
+
+      const str = '\tlorem upsum';
+      const l = line(str);
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+
+      assert.isFalse(annotateElementStub.called);
+    });
+
+    test('annotates multiple in beginning', () => {
+      const str = '\t\tlorem upsum';
+      const l = line(str);
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+
+      assert.equal(annotateElementStub.callCount, 2);
+
+      let args = annotateElementStub.getCalls()[0].args;
+      assert.equal(args[0], el);
+      assert.equal(args[1], 0, 'offset of tab indicator');
+      assert.equal(args[2], 1, 'length of tab indicator');
+      assert.include(args[3], 'tab-indicator');
+
+      args = annotateElementStub.getCalls()[1].args;
+      assert.equal(args[0], el);
+      assert.equal(args[1], 1, 'offset of tab indicator');
+      assert.equal(args[2], 1, 'length of tab indicator');
+      assert.include(args[3], 'tab-indicator');
+    });
+
+    test('annotates intermediate tabs', () => {
+      const str = 'lorem\tupsum';
+      const l = line(str);
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+
+      assert.equal(annotateElementStub.callCount, 1);
+      const args = annotateElementStub.getCalls()[0].args;
+      assert.equal(args[0], el);
+      assert.equal(args[1], 5, 'offset of tab indicator');
+      assert.equal(args[2], 1, 'length of tab indicator');
+      assert.include(args[3], 'tab-indicator');
+    });
+  });
+
+  suite('layers', () => {
+    let initialLayersCount = 0;
+    let withLayerCount = 0;
+    setup(() => {
+      const layers: DiffLayer[] = [];
+      element.layers = layers;
+      element.showTrailingWhitespace = true;
+      element.setupAnnotationLayers();
+      initialLayersCount = element.layersInternal.length;
+    });
+
+    test('no layers', () => {
+      element.setupAnnotationLayers();
+      assert.equal(element.layersInternal.length, initialLayersCount);
+    });
+
+    suite('with layers', () => {
+      const layers: DiffLayer[] = [{annotate: () => {}}, {annotate: () => {}}];
+      setup(() => {
+        element.layers = layers;
+        element.showTrailingWhitespace = true;
+        element.setupAnnotationLayers();
+        withLayerCount = element.layersInternal.length;
+      });
+      test('with layers', () => {
+        element.setupAnnotationLayers();
+        assert.equal(element.layersInternal.length, withLayerCount);
+        assert.equal(initialLayersCount + layers.length, withLayerCount);
+      });
+    });
+  });
+
+  suite('trailing whitespace', () => {
+    let layer: DiffLayer;
+    const lineNumberEl = document.createElement('td');
+
+    setup(() => {
+      element.showTrailingWhitespace = true;
+      layer = element.createTrailingWhitespaceLayer();
+    });
+
+    test('does nothing with empty line', () => {
+      const l = line('');
+      const el = document.createElement('div');
+      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+      assert.isFalse(annotateElementStub.called);
+    });
+
+    test('does nothing with no trailing whitespace', () => {
+      const str = 'lorem ipsum blah blah';
+      const l = line(str);
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+      assert.isFalse(annotateElementStub.called);
+    });
+
+    test('annotates trailing spaces', () => {
+      const str = 'lorem ipsum   ';
+      const l = line(str);
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+      assert.isTrue(annotateElementStub.called);
+      assert.equal(annotateElementStub.lastCall.args[1], 11);
+      assert.equal(annotateElementStub.lastCall.args[2], 3);
+    });
+
+    test('annotates trailing tabs', () => {
+      const str = 'lorem ipsum\t\t\t';
+      const l = line(str);
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+      assert.isTrue(annotateElementStub.called);
+      assert.equal(annotateElementStub.lastCall.args[1], 11);
+      assert.equal(annotateElementStub.lastCall.args[2], 3);
+    });
+
+    test('annotates mixed trailing whitespace', () => {
+      const str = 'lorem ipsum\t \t';
+      const l = line(str);
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+      assert.isTrue(annotateElementStub.called);
+      assert.equal(annotateElementStub.lastCall.args[1], 11);
+      assert.equal(annotateElementStub.lastCall.args[2], 3);
+    });
+
+    test('unicode preceding trailing whitespace', () => {
+      const str = '💢\t';
+      const l = line(str);
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+      assert.isTrue(annotateElementStub.called);
+      assert.equal(annotateElementStub.lastCall.args[1], 1);
+      assert.equal(annotateElementStub.lastCall.args[2], 1);
+    });
+
+    test('does not annotate when disabled', () => {
+      element.showTrailingWhitespace = false;
+      const str = 'lorem upsum\t \t ';
+      const l = line(str);
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+      assert.isFalse(annotateElementStub.called);
+    });
+  });
+
+  suite('rendering text, images and binary files', () => {
+    let processStub: sinon.SinonStub;
+    let keyLocations: KeyLocations;
+    let content: DiffContent[] = [];
+
+    setup(() => {
+      element.viewMode = 'SIDE_BY_SIDE';
+      processStub = sinon
+        .stub(element.processor, 'process')
+        .returns(Promise.resolve());
+      keyLocations = {left: {}, right: {}};
+      element.prefs = {
+        ...DEFAULT_PREFS,
+        context: -1,
+        syntax_highlighting: true,
+      };
+      content = [
+        {
+          a: ['all work and no play make andybons a dull boy'],
+          b: ['elgoog elgoog elgoog'],
+        },
+        {
+          ab: [
+            'Non eram nescius, Brute, cum, quae summis ingeniis ',
+            'exquisitaque doctrina philosophi Graeco sermone tractavissent',
+          ],
+        },
+      ];
+    });
+
+    test('text', async () => {
+      element.diff = {...createEmptyDiff(), content};
+      element.render(keyLocations);
+      await waitForEventOnce(diffTable, 'render-content');
+      assert.isTrue(processStub.calledOnce);
+      assert.isFalse(processStub.lastCall.args[1]);
+    });
+
+    test('image', async () => {
+      element.diff = {...createEmptyDiff(), content, binary: true};
+      element.isImageDiff = true;
+      element.render(keyLocations);
+      await waitForEventOnce(diffTable, 'render-content');
+      assert.isTrue(processStub.calledOnce);
+      assert.isTrue(processStub.lastCall.args[1]);
+    });
+
+    test('binary', async () => {
+      element.diff = {...createEmptyDiff(), content, binary: true};
+      element.render(keyLocations);
+      await waitForEventOnce(diffTable, 'render-content');
+      assert.isTrue(processStub.calledOnce);
+      assert.isTrue(processStub.lastCall.args[1]);
+    });
+  });
+
+  suite('rendering', () => {
+    let content: DiffContent[];
+    let outputEl: HTMLTableElement;
+    let keyLocations: KeyLocations;
+    let addColumnsStub: sinon.SinonStub;
+    let dispatchStub: sinon.SinonStub;
+    let builder: GrDiffBuilderSideBySide;
+
+    setup(() => {
+      const prefs = {...DEFAULT_PREFS};
+      content = [
+        {
+          a: ['all work and no play make andybons a dull boy'],
+          b: ['elgoog elgoog elgoog'],
+        },
+        {
+          ab: [
+            'Non eram nescius, Brute, cum, quae summis ingeniis ',
+            'exquisitaque doctrina philosophi Graeco sermone tractavissent',
+          ],
+        },
+      ];
+      dispatchStub = sinon.stub(diffTable, 'dispatchEvent');
+      outputEl = element.diffElement!;
+      keyLocations = {left: {}, right: {}};
+      sinon.stub(element, 'getDiffBuilder').callsFake(() => {
+        builder = new GrDiffBuilderSideBySide(
+          {...createEmptyDiff(), content},
+          prefs,
+          outputEl
+        );
+        addColumnsStub = sinon.stub(builder, 'addColumns');
+        builder.buildSectionElement = function (group) {
+          const section = document.createElement('stub');
+          section.style.display = 'block';
+          section.textContent = group.lines.reduce(
+            (acc, line) => acc + line.text,
+            ''
+          );
+          return section;
+        };
+        return builder;
+      });
+      element.diff = {...createEmptyDiff(), content};
+      element.prefs = prefs;
+      element.render(keyLocations);
+    });
+
+    test('addColumns is called', () => {
+      assert.isTrue(addColumnsStub.called);
+    });
+
+    test('getGroupsByLineRange one line', () => {
+      const section = outputEl.querySelector<HTMLElement>(
+        'stub:nth-of-type(3)'
+      );
+      const groups = builder.getGroupsByLineRange(1, 1, Side.LEFT);
+      assert.equal(groups.length, 1);
+      assert.strictEqual(groups[0].element, section);
+    });
+
+    test('getGroupsByLineRange over diff', () => {
+      const section = [
+        outputEl.querySelector<HTMLElement>('stub:nth-of-type(3)'),
+        outputEl.querySelector<HTMLElement>('stub:nth-of-type(4)'),
+      ];
+      const groups = builder.getGroupsByLineRange(1, 2, Side.LEFT);
+      assert.equal(groups.length, 2);
+      assert.strictEqual(groups[0].element, section[0]);
+      assert.strictEqual(groups[1].element, section[1]);
+    });
+
+    test('render-start and render-content are fired', async () => {
+      await waitUntil(() => dispatchStub.callCount >= 1);
+      let firedEventTypes = dispatchStub.getCalls().map(c => c.args[0].type);
+      assert.include(firedEventTypes, 'render-start');
+
+      await waitUntil(() => dispatchStub.callCount >= 2);
+      firedEventTypes = dispatchStub.getCalls().map(c => c.args[0].type);
+      assert.include(firedEventTypes, 'render-content');
+    });
+
+    test('cancel cancels the processor', () => {
+      const processorCancelStub = sinon.stub(element.processor, 'cancel');
+      element.cancel();
+      assert.isTrue(processorCancelStub.called);
+    });
+  });
+
+  suite('context hiding and expanding', () => {
+    let dispatchStub: sinon.SinonStub;
+
+    setup(async () => {
+      dispatchStub = sinon.stub(diffTable, 'dispatchEvent');
+      element.diff = {
+        ...createEmptyDiff(),
+        content: [
+          {ab: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map(i => `unchanged ${i}`)},
+          {a: ['before'], b: ['after']},
+          {ab: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map(i => `unchanged ${10 + i}`)},
+        ],
+      };
+      element.viewMode = DiffViewMode.SIDE_BY_SIDE;
+
+      const keyLocations: KeyLocations = {left: {}, right: {}};
+      element.prefs = {
+        ...DEFAULT_PREFS,
+        context: 1,
+      };
+      element.render(keyLocations);
+      // Make sure all listeners are installed.
+      await element.untilGroupsRendered();
+    });
+
+    test('hides lines behind two context controls', () => {
+      const contextControls = diffTable.querySelectorAll('gr-context-controls');
+      assert.equal(contextControls.length, 2);
+
+      const diffRows = diffTable.querySelectorAll('.diff-row');
+      // The first two are LOST and FILE line
+      assert.equal(diffRows.length, 2 + 1 + 1 + 1);
+      assert.include(diffRows[2].textContent, 'unchanged 10');
+      assert.include(diffRows[3].textContent, 'before');
+      assert.include(diffRows[3].textContent, 'after');
+      assert.include(diffRows[4].textContent, 'unchanged 11');
+    });
+
+    test('clicking +x common lines expands those lines', () => {
+      const contextControls = diffTable.querySelectorAll('gr-context-controls');
+      const topExpandCommonButton =
+        contextControls[0].shadowRoot?.querySelectorAll<HTMLElement>(
+          '.showContext'
+        )[0];
+      assert.isOk(topExpandCommonButton);
+      assert.include(topExpandCommonButton!.textContent, '+9 common lines');
+      topExpandCommonButton!.click();
+      const diffRows = diffTable.querySelectorAll('.diff-row');
+      // The first two are LOST and FILE line
+      assert.equal(diffRows.length, 2 + 10 + 1 + 1);
+      assert.include(diffRows[2].textContent, 'unchanged 1');
+      assert.include(diffRows[3].textContent, 'unchanged 2');
+      assert.include(diffRows[4].textContent, 'unchanged 3');
+      assert.include(diffRows[5].textContent, 'unchanged 4');
+      assert.include(diffRows[6].textContent, 'unchanged 5');
+      assert.include(diffRows[7].textContent, 'unchanged 6');
+      assert.include(diffRows[8].textContent, 'unchanged 7');
+      assert.include(diffRows[9].textContent, 'unchanged 8');
+      assert.include(diffRows[10].textContent, 'unchanged 9');
+      assert.include(diffRows[11].textContent, 'unchanged 10');
+      assert.include(diffRows[12].textContent, 'before');
+      assert.include(diffRows[12].textContent, 'after');
+      assert.include(diffRows[13].textContent, 'unchanged 11');
+    });
+
+    test('unhideLine shows the line with context', async () => {
+      dispatchStub.reset();
+      element.unhideLine(4, Side.LEFT);
+
+      const diffRows = diffTable.querySelectorAll('.diff-row');
+      // The first two are LOST and FILE line
+      // Lines 3-5 (Line 4 plus 1 context in each direction) will be expanded
+      // Because context expanders do not hide <3 lines, lines 1-2 will also
+      // be shown.
+      // Lines 6-9 continue to be hidden
+      assert.equal(diffRows.length, 2 + 5 + 1 + 1 + 1);
+      assert.include(diffRows[2].textContent, 'unchanged 1');
+      assert.include(diffRows[3].textContent, 'unchanged 2');
+      assert.include(diffRows[4].textContent, 'unchanged 3');
+      assert.include(diffRows[5].textContent, 'unchanged 4');
+      assert.include(diffRows[6].textContent, 'unchanged 5');
+      assert.include(diffRows[7].textContent, 'unchanged 10');
+      assert.include(diffRows[8].textContent, 'before');
+      assert.include(diffRows[8].textContent, 'after');
+      assert.include(diffRows[9].textContent, 'unchanged 11');
+
+      await element.untilGroupsRendered();
+      const firedEventTypes = dispatchStub.getCalls().map(c => c.args[0].type);
+      assert.include(firedEventTypes, 'render-content');
+    });
+  });
+
+  suite('mock-diff', () => {
+    let builder: GrDiffBuilderSideBySide;
+    let diff: DiffInfo;
+    let keyLocations: KeyLocations;
+
+    setup(() => {
+      element.viewMode = DiffViewMode.SIDE_BY_SIDE;
+      diff = createDiff();
+      element.diff = diff;
+
+      keyLocations = {left: {}, right: {}};
+
+      element.prefs = {
+        ...createDefaultDiffPrefs(),
+        line_length: 80,
+        show_tabs: true,
+        tab_size: 4,
+      };
+      element.render(keyLocations);
+      builder = element.builder as GrDiffBuilderSideBySide;
+    });
+
+    test('aria-labels on added line numbers', () => {
+      const deltaLineNumberButton = diffTable.querySelectorAll(
+        '.lineNumButton.right'
+      )[5];
+
+      assert.isOk(deltaLineNumberButton);
+      assert.equal(deltaLineNumberButton.getAttribute('aria-label'), '5 added');
+    });
+
+    test('aria-labels on removed line numbers', () => {
+      const deltaLineNumberButton = diffTable.querySelectorAll(
+        '.lineNumButton.left'
+      )[10];
+
+      assert.isOk(deltaLineNumberButton);
+      assert.equal(
+        deltaLineNumberButton.getAttribute('aria-label'),
+        '10 removed'
+      );
+    });
+
+    test('getContentByLine', () => {
+      let actual: HTMLElement | null;
+
+      actual = builder.getContentByLine(2, Side.LEFT);
+      assert.equal(actual?.textContent, diff.content[0].ab?.[1]);
+
+      actual = builder.getContentByLine(2, Side.RIGHT);
+      assert.equal(actual?.textContent, diff.content[0].ab?.[1]);
+
+      actual = builder.getContentByLine(5, Side.LEFT);
+      assert.equal(actual?.textContent, diff.content[2].ab?.[0]);
+
+      actual = builder.getContentByLine(5, Side.RIGHT);
+      assert.equal(actual?.textContent, diff.content[1].b?.[0]);
+    });
+
+    test('getContentTdByLineEl works both with button and td', () => {
+      const diffRow = diffTable.querySelectorAll('tr.diff-row')[2];
+
+      const lineNumTdLeft = queryAndAssert(diffRow, 'td.lineNum.left');
+      const lineNumButtonLeft = queryAndAssert(lineNumTdLeft, 'button');
+      const contentTdLeft = diffRow.querySelectorAll('.content')[0];
+
+      const lineNumTdRight = queryAndAssert(diffRow, 'td.lineNum.right');
+      const lineNumButtonRight = queryAndAssert(lineNumTdRight, 'button');
+      const contentTdRight = diffRow.querySelectorAll('.content')[1];
+
+      assert.equal(element.getContentTdByLineEl(lineNumTdLeft), contentTdLeft);
+      assert.equal(
+        element.getContentTdByLineEl(lineNumButtonLeft),
+        contentTdLeft
+      );
+      assert.equal(
+        element.getContentTdByLineEl(lineNumTdRight),
+        contentTdRight
+      );
+      assert.equal(
+        element.getContentTdByLineEl(lineNumButtonRight),
+        contentTdRight
+      );
+    });
+
+    test('findLinesByRange', () => {
+      const lines: GrDiffLine[] = [];
+      const elems: HTMLElement[] = [];
+      const start = 6;
+      const end = 10;
+      const count = end - start + 1;
+
+      builder.findLinesByRange(start, end, Side.RIGHT, lines, elems);
+
+      assert.equal(lines.length, count);
+      assert.equal(elems.length, count);
+
+      for (let i = 0; i < 5; i++) {
+        assert.instanceOf(lines[i], GrDiffLine);
+        assert.equal(lines[i].afterNumber, start + i);
+        assert.instanceOf(elems[i], HTMLElement);
+        assert.equal(lines[i].text, elems[i].textContent);
+      }
+    });
+
+    test('renderContentByRange', () => {
+      const spy = sinon.spy(builder, 'createTextEl');
+      const start = 9;
+      const end = 14;
+      const count = end - start + 1;
+
+      builder.renderContentByRange(start, end, Side.LEFT);
+
+      assert.equal(spy.callCount, count);
+      spy.getCalls().forEach((call, i: number) => {
+        assert.equal(call.args[1].beforeNumber, start + i);
+      });
+    });
+
+    test('renderContentByRange non-existent elements', () => {
+      const spy = sinon.spy(builder, 'createTextEl');
+
+      sinon
+        .stub(builder, 'getLineNumberEl')
+        .returns(document.createElement('div'));
+      sinon
+        .stub(builder, 'findLinesByRange')
+        .callsFake((_1, _2, _3, lines, elements) => {
+          // Add a line and a corresponding element.
+          lines?.push(new GrDiffLine(GrDiffLineType.BOTH));
+          const tr = document.createElement('tr');
+          const td = document.createElement('td');
+          const el = document.createElement('div');
+          tr.appendChild(td);
+          td.appendChild(el);
+          elements?.push(el);
+
+          // Add 2 lines without corresponding elements.
+          lines?.push(new GrDiffLine(GrDiffLineType.BOTH));
+          lines?.push(new GrDiffLine(GrDiffLineType.BOTH));
+        });
+
+      builder.renderContentByRange(1, 10, Side.LEFT);
+      // Should be called only once because only one line had a corresponding
+      // element.
+      assert.equal(spy.callCount, 1);
+    });
+
+    test('getLineNumberEl side-by-side left', () => {
+      const contentEl = builder.getContentByLine(
+        5,
+        Side.LEFT,
+        element.diffElement as HTMLTableElement
+      );
+      assert.isOk(contentEl);
+      const lineNumberEl = builder.getLineNumberEl(contentEl!, Side.LEFT);
+      assert.isOk(lineNumberEl);
+      assert.isTrue(lineNumberEl!.classList.contains('lineNum'));
+      assert.isTrue(lineNumberEl!.classList.contains(Side.LEFT));
+    });
+
+    test('getLineNumberEl side-by-side right', () => {
+      const contentEl = builder.getContentByLine(
+        5,
+        Side.RIGHT,
+        element.diffElement as HTMLTableElement
+      );
+      assert.isOk(contentEl);
+      const lineNumberEl = builder.getLineNumberEl(contentEl!, Side.RIGHT);
+      assert.isOk(lineNumberEl);
+      assert.isTrue(lineNumberEl!.classList.contains('lineNum'));
+      assert.isTrue(lineNumberEl!.classList.contains(Side.RIGHT));
+    });
+
+    test('getLineNumberEl unified left', async () => {
+      // Re-render as unified:
+      element.viewMode = 'UNIFIED_DIFF';
+      element.render(keyLocations);
+      builder = element.builder as GrDiffBuilderSideBySide;
+
+      const contentEl = builder.getContentByLine(
+        5,
+        Side.LEFT,
+        element.diffElement as HTMLTableElement
+      );
+      assert.isOk(contentEl);
+      const lineNumberEl = builder.getLineNumberEl(contentEl!, Side.LEFT);
+      assert.isOk(lineNumberEl);
+      assert.isTrue(lineNumberEl!.classList.contains('lineNum'));
+      assert.isTrue(lineNumberEl!.classList.contains(Side.LEFT));
+    });
+
+    test('getLineNumberEl unified right', async () => {
+      // Re-render as unified:
+      element.viewMode = 'UNIFIED_DIFF';
+      element.render(keyLocations);
+      builder = element.builder as GrDiffBuilderSideBySide;
+
+      const contentEl = builder.getContentByLine(
+        5,
+        Side.RIGHT,
+        element.diffElement as HTMLTableElement
+      );
+      assert.isOk(contentEl);
+      const lineNumberEl = builder.getLineNumberEl(contentEl!, Side.RIGHT);
+      assert.isOk(lineNumberEl);
+      assert.isTrue(lineNumberEl!.classList.contains('lineNum'));
+      assert.isTrue(lineNumberEl!.classList.contains(Side.RIGHT));
+    });
+
+    test('getNextContentOnSide side-by-side left', () => {
+      const startElem = builder.getContentByLine(
+        5,
+        Side.LEFT,
+        element.diffElement as HTMLTableElement
+      );
+      assert.isOk(startElem);
+      const expectedStartString = diff.content[2].ab?.[0];
+      const expectedNextString = diff.content[2].ab?.[1];
+      assert.equal(startElem!.textContent, expectedStartString);
+
+      const nextElem = builder.getNextContentOnSide(startElem!, Side.LEFT);
+      assert.isOk(nextElem);
+      assert.equal(nextElem!.textContent, expectedNextString);
+    });
+
+    test('getNextContentOnSide side-by-side right', () => {
+      const startElem = builder.getContentByLine(
+        5,
+        Side.RIGHT,
+        element.diffElement as HTMLTableElement
+      );
+      const expectedStartString = diff.content[1].b?.[0];
+      const expectedNextString = diff.content[1].b?.[1];
+      assert.isOk(startElem);
+      assert.equal(startElem!.textContent, expectedStartString);
+
+      const nextElem = builder.getNextContentOnSide(startElem!, Side.RIGHT);
+      assert.isOk(nextElem);
+      assert.equal(nextElem!.textContent, expectedNextString);
+    });
+
+    test('getNextContentOnSide unified left', async () => {
+      // Re-render as unified:
+      element.viewMode = 'UNIFIED_DIFF';
+      element.render(keyLocations);
+      builder = element.builder as GrDiffBuilderSideBySide;
+
+      const startElem = builder.getContentByLine(
+        5,
+        Side.LEFT,
+        element.diffElement as HTMLTableElement
+      );
+      const expectedStartString = diff.content[2].ab?.[0];
+      const expectedNextString = diff.content[2].ab?.[1];
+      assert.isOk(startElem);
+      assert.equal(startElem!.textContent, expectedStartString);
+
+      const nextElem = builder.getNextContentOnSide(startElem!, Side.LEFT);
+      assert.isOk(nextElem);
+      assert.equal(nextElem!.textContent, expectedNextString);
+    });
+
+    test('getNextContentOnSide unified right', async () => {
+      // Re-render as unified:
+      element.viewMode = 'UNIFIED_DIFF';
+      element.render(keyLocations);
+      builder = element.builder as GrDiffBuilderSideBySide;
+
+      const startElem = builder.getContentByLine(
+        5,
+        Side.RIGHT,
+        element.diffElement as HTMLTableElement
+      );
+      const expectedStartString = diff.content[1].b?.[0];
+      const expectedNextString = diff.content[1].b?.[1];
+      assert.isOk(startElem);
+      assert.equal(startElem!.textContent, expectedStartString);
+
+      const nextElem = builder.getNextContentOnSide(startElem!, Side.RIGHT);
+      assert.isOk(nextElem);
+      assert.equal(nextElem!.textContent, expectedNextString);
+    });
+  });
+
+  suite('blame', () => {
+    let mockBlame: BlameInfo[];
+
+    setup(() => {
+      mockBlame = [
+        {
+          author: 'test-author',
+          time: 314,
+          commit_msg: 'test-commit-message',
+          id: 'commit 1',
+          ranges: [
+            {start: 1, end: 2},
+            {start: 10, end: 16},
+          ],
+        },
+        {
+          author: 'test-author',
+          time: 314,
+          commit_msg: 'test-commit-message',
+          id: 'commit 2',
+          ranges: [
+            {start: 4, end: 10},
+            {start: 17, end: 32},
+          ],
+        },
+      ];
+    });
+
+    test('setBlame attempts to render each blamed line', () => {
+      const getBlameStub = sinon
+        .stub(builder, 'getBlameTdByLine')
+        .returns(undefined);
+      builder.setBlame(mockBlame);
+      assert.equal(getBlameStub.callCount, 32);
+    });
+
+    test('getBlameCommitForBaseLine', () => {
+      sinon.stub(builder, 'getBlameTdByLine').returns(undefined);
+      builder.setBlame(mockBlame);
+      assert.isOk(builder.getBlameCommitForBaseLine(1));
+      assert.equal(builder.getBlameCommitForBaseLine(1)?.id, 'commit 1');
+
+      assert.isOk(builder.getBlameCommitForBaseLine(11));
+      assert.equal(builder.getBlameCommitForBaseLine(11)?.id, 'commit 1');
+
+      assert.isOk(builder.getBlameCommitForBaseLine(32));
+      assert.equal(builder.getBlameCommitForBaseLine(32)?.id, 'commit 2');
+
+      assert.isUndefined(builder.getBlameCommitForBaseLine(33));
+    });
+
+    test('getBlameCommitForBaseLine w/o blame returns null', () => {
+      assert.isUndefined(builder.getBlameCommitForBaseLine(1));
+      assert.isUndefined(builder.getBlameCommitForBaseLine(11));
+      assert.isUndefined(builder.getBlameCommitForBaseLine(31));
+    });
+
+    test('createBlameCell', () => {
+      const mockBlameInfo = {
+        time: 1576155200,
+        id: '1234567890',
+        author: 'Clark Kent',
+        commit_msg: 'Testing Commit',
+        ranges: [{start: 4, end: 10}],
+      };
+      const getBlameStub = sinon
+        .stub(builder, 'getBlameCommitForBaseLine')
+        .returns(mockBlameInfo);
+      const line = new GrDiffLine(GrDiffLineType.BOTH);
+      line.beforeNumber = 3;
+      line.afterNumber = 5;
+
+      const result = builder.createBlameCell(line.beforeNumber);
+
+      assert.isTrue(getBlameStub.calledWithExactly(3));
+      assert.equal(result.getAttribute('data-line-number'), '3');
+      expect(result).dom.to.equal(/* HTML */ `
+        <span class="gr-diff style-scope">
+          <a class="blameDate gr-diff style-scope" href="/r/q/1234567890">
+            12/12/2019
+          </a>
+          <span class="blameAuthor gr-diff style-scope">Clark</span>
+          <gr-hovercard class="gr-diff style-scope">
+            <span class="blameHoverCard gr-diff style-scope">
+              Commit 1234567890<br />
+              Author: Clark Kent<br />
+              Date: 12/12/2019<br />
+              <br />
+              Testing Commit
+            </span>
+          </gr-hovercard>
+        </span>
+      `);
+    });
+  });
+});
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-legacy.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-legacy.ts
index ceadc94..c04d156 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-legacy.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-legacy.ts
@@ -162,10 +162,8 @@
    *
    * TODO(brohlfs): Consolidate this with getLineEl... methods in html file.
    */
-  private getLineNumberEl(
-    content: HTMLElement,
-    side: Side
-  ): HTMLElement | null {
+  // visible for testing
+  getLineNumberEl(content: HTMLElement, side: Side): HTMLElement | null {
     let row: HTMLElement | null = content;
     while (row && !row.classList.contains('diff-row')) row = row.parentElement;
     return row ? (row.querySelector('.lineNum.' + side) as HTMLElement) : null;
@@ -349,7 +347,8 @@
     });
   }
 
-  protected createTextEl(
+  // visible for testing
+  createTextEl(
     lineNumberEl: HTMLElement | null,
     line: GrDiffLine,
     side?: Side
@@ -491,7 +490,8 @@
    * Create a blame cell for the given base line. Blame information will be
    * included in the cell if available.
    */
-  protected createBlameCell(lineNumber: LineNumber): HTMLTableCellElement {
+  // visible for testing
+  createBlameCell(lineNumber: LineNumber): HTMLTableCellElement {
     const blameTd = createElementDiff('td', 'blame') as HTMLTableCellElement;
     blameTd.setAttribute('data-line-number', lineNumber.toString());
     if (!lineNumber) return blameTd;
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-side-by-side.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-side-by-side.ts
index a711215..f2690bc 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-side-by-side.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-side-by-side.ts
@@ -44,7 +44,8 @@
     };
   }
 
-  protected override buildSectionElement(group: GrDiffGroup) {
+  // visible for testing
+  override buildSectionElement(group: GrDiffGroup) {
     const sectionEl = createElementDiff('tbody', 'section');
     sectionEl.classList.add(group.type);
     if (group.isTotal()) {
@@ -147,7 +148,8 @@
     return td;
   }
 
-  protected override getNextContentOnSide(
+  // visible for testing
+  override getNextContentOnSide(
     content: HTMLElement,
     side: Side
   ): HTMLElement | null {
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-unified.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-unified.ts
index 4145485..0c9d1d9 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-unified.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-unified.ts
@@ -43,7 +43,8 @@
     };
   }
 
-  protected override buildSectionElement(group: GrDiffGroup): HTMLElement {
+  // visible for testing
+  override buildSectionElement(group: GrDiffGroup): HTMLElement {
     const sectionEl = createElementDiff('tbody', 'section');
     sectionEl.classList.add(group.type);
     if (group.isTotal()) {
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-unified_test.js b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-unified_test.js
deleted file mode 100644
index 5f3fb72..0000000
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-unified_test.js
+++ /dev/null
@@ -1,242 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import '../gr-diff/gr-diff-group.js';
-import './gr-diff-builder.js';
-import './gr-diff-builder-unified.js';
-import {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line.js';
-import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group.js';
-import {GrDiffBuilderUnified} from './gr-diff-builder-unified.js';
-
-suite('GrDiffBuilderUnified tests', () => {
-  let prefs;
-  let outputEl;
-  let diffBuilder;
-
-  setup(()=> {
-    prefs = {
-      line_length: 10,
-      show_tabs: true,
-      tab_size: 4,
-    };
-    outputEl = document.createElement('div');
-    diffBuilder = new GrDiffBuilderUnified({}, prefs, outputEl, []);
-  });
-
-  suite('buildSectionElement for BOTH group', () => {
-    let lines;
-    let group;
-
-    setup(() => {
-      lines = [
-        new GrDiffLine(GrDiffLineType.BOTH, 1, 2),
-        new GrDiffLine(GrDiffLineType.BOTH, 2, 3),
-        new GrDiffLine(GrDiffLineType.BOTH, 3, 4),
-      ];
-      lines[0].text = 'def hello_world():';
-      lines[1].text = '  print "Hello World";';
-      lines[2].text = '  return True';
-
-      group = new GrDiffGroup({type: GrDiffGroupType.BOTH, lines});
-    });
-
-    test('creates the section', () => {
-      const sectionEl = diffBuilder.buildSectionElement(group);
-      assert.isTrue(sectionEl.classList.contains('section'));
-      assert.isTrue(sectionEl.classList.contains('both'));
-    });
-
-    test('creates each unchanged row once', () => {
-      const sectionEl = diffBuilder.buildSectionElement(group);
-      const rowEls = sectionEl.querySelectorAll('.diff-row');
-
-      assert.equal(rowEls.length, 3);
-
-      assert.equal(
-          rowEls[0].querySelector('.lineNum.left').textContent,
-          lines[0].beforeNumber);
-      assert.equal(
-          rowEls[0].querySelector('.lineNum.right').textContent,
-          lines[0].afterNumber);
-      assert.equal(
-          rowEls[0].querySelector('.content').textContent, lines[0].text);
-
-      assert.equal(
-          rowEls[1].querySelector('.lineNum.left').textContent,
-          lines[1].beforeNumber);
-      assert.equal(
-          rowEls[1].querySelector('.lineNum.right').textContent,
-          lines[1].afterNumber);
-      assert.equal(
-          rowEls[1].querySelector('.content').textContent, lines[1].text);
-
-      assert.equal(
-          rowEls[2].querySelector('.lineNum.left').textContent,
-          lines[2].beforeNumber);
-      assert.equal(
-          rowEls[2].querySelector('.lineNum.right').textContent,
-          lines[2].afterNumber);
-      assert.equal(
-          rowEls[2].querySelector('.content').textContent, lines[2].text);
-    });
-  });
-
-  suite('buildSectionElement for moved chunks', () => {
-    test('creates a moved out group', () => {
-      const lines = [
-        new GrDiffLine(GrDiffLineType.REMOVE, 15),
-        new GrDiffLine(GrDiffLineType.REMOVE, 16),
-      ];
-      lines[0].text = 'def hello_world():';
-      lines[1].text = '  print "Hello World"';
-      const group = new GrDiffGroup({type: GrDiffGroupType.DELTA, lines});
-      group.moveDetails = {changed: false};
-
-      const sectionEl = diffBuilder.buildSectionElement(group);
-
-      const rowEls = sectionEl.querySelectorAll('tr');
-      const moveControlsRow = rowEls[0];
-      const cells = moveControlsRow.querySelectorAll('td');
-      assert.isTrue(sectionEl.classList.contains('dueToMove'));
-      assert.equal(rowEls.length, 3);
-      assert.isTrue(moveControlsRow.classList.contains('movedOut'));
-      assert.equal(cells.length, 3);
-      assert.isTrue(cells[2].classList.contains('moveHeader'));
-      assert.equal(cells[2].textContent, 'Moved out');
-    });
-
-    test('creates a moved in group', () => {
-      const lines = [
-        new GrDiffLine(GrDiffLineType.ADD, 37),
-        new GrDiffLine(GrDiffLineType.ADD, 38),
-      ];
-      lines[0].text = 'def hello_world():';
-      lines[1].text = '  print "Hello World"';
-      const group = new GrDiffGroup({type: GrDiffGroupType.DELTA, lines});
-      group.moveDetails = {changed: false};
-
-      const sectionEl = diffBuilder.buildSectionElement(group);
-
-      const rowEls = sectionEl.querySelectorAll('tr');
-      const moveControlsRow = rowEls[0];
-      const cells = moveControlsRow.querySelectorAll('td');
-      assert.isTrue(sectionEl.classList.contains('dueToMove'));
-      assert.equal(rowEls.length, 3);
-      assert.isTrue(moveControlsRow.classList.contains('movedIn'));
-      assert.equal(cells.length, 3);
-      assert.isTrue(cells[2].classList.contains('moveHeader'));
-      assert.equal(cells[2].textContent, 'Moved in');
-    });
-  });
-
-  suite('buildSectionElement for DELTA group', () => {
-    let lines;
-    let group;
-
-    setup(() => {
-      lines = [
-        new GrDiffLine(GrDiffLineType.REMOVE, 1),
-        new GrDiffLine(GrDiffLineType.REMOVE, 2),
-        new GrDiffLine(GrDiffLineType.ADD, 2),
-        new GrDiffLine(GrDiffLineType.ADD, 3),
-      ];
-      lines[0].text = 'def hello_world():';
-      lines[1].text = '  print "Hello World"';
-      lines[2].text = 'def hello_universe()';
-      lines[3].text = '  print "Hello Universe"';
-
-      group = new GrDiffGroup({type: GrDiffGroupType.DELTA, lines});
-    });
-
-    test('creates the section', () => {
-      const sectionEl = diffBuilder.buildSectionElement(group);
-      assert.isTrue(sectionEl.classList.contains('section'));
-      assert.isTrue(sectionEl.classList.contains('delta'));
-    });
-
-    test('creates the section with class if ignoredWhitespaceOnly', () => {
-      group.ignoredWhitespaceOnly = true;
-      const sectionEl = diffBuilder.buildSectionElement(group);
-      assert.isTrue(sectionEl.classList.contains('ignoredWhitespaceOnly'));
-    });
-
-    test('creates the section with class if dueToRebase', () => {
-      group.dueToRebase = true;
-      const sectionEl = diffBuilder.buildSectionElement(group);
-      assert.isTrue(sectionEl.classList.contains('dueToRebase'));
-    });
-
-    test('creates first the removed and then the added rows', () => {
-      const sectionEl = diffBuilder.buildSectionElement(group);
-      const rowEls = sectionEl.querySelectorAll('.diff-row');
-
-      assert.equal(rowEls.length, 4);
-
-      assert.equal(
-          rowEls[0].querySelector('.lineNum.left').textContent,
-          lines[0].beforeNumber);
-      assert.isNotOk(rowEls[0].querySelector('.lineNum.right'));
-      assert.equal(
-          rowEls[0].querySelector('.content').textContent, lines[0].text);
-
-      assert.equal(
-          rowEls[1].querySelector('.lineNum.left').textContent,
-          lines[1].beforeNumber);
-      assert.isNotOk(rowEls[1].querySelector('.lineNum.right'));
-      assert.equal(
-          rowEls[1].querySelector('.content').textContent, lines[1].text);
-
-      assert.isNotOk(rowEls[2].querySelector('.lineNum.left'));
-      assert.equal(
-          rowEls[2].querySelector('.lineNum.right').textContent,
-          lines[2].afterNumber);
-      assert.equal(
-          rowEls[2].querySelector('.content').textContent, lines[2].text);
-
-      assert.isNotOk(rowEls[3].querySelector('.lineNum.left'));
-      assert.equal(
-          rowEls[3].querySelector('.lineNum.right').textContent,
-          lines[3].afterNumber);
-      assert.equal(
-          rowEls[3].querySelector('.content').textContent, lines[3].text);
-    });
-
-    test('creates only the added rows if only ignored whitespace', () => {
-      group.ignoredWhitespaceOnly = true;
-      const sectionEl = diffBuilder.buildSectionElement(group);
-      const rowEls = sectionEl.querySelectorAll('.diff-row');
-
-      assert.equal(rowEls.length, 2);
-
-      assert.isNotOk(rowEls[0].querySelector('.lineNum.left'));
-      assert.equal(
-          rowEls[0].querySelector('.lineNum.right').textContent,
-          lines[2].afterNumber);
-      assert.equal(
-          rowEls[0].querySelector('.content').textContent, lines[2].text);
-
-      assert.isNotOk(rowEls[1].querySelector('.lineNum.left'));
-      assert.equal(
-          rowEls[1].querySelector('.lineNum.right').textContent,
-          lines[3].afterNumber);
-      assert.equal(
-          rowEls[1].querySelector('.content').textContent, lines[3].text);
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-unified_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-unified_test.ts
new file mode 100644
index 0000000..7a9d06d
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-unified_test.ts
@@ -0,0 +1,282 @@
+/**
+ * @license
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup-karma';
+import '../gr-diff/gr-diff-group';
+import './gr-diff-builder';
+import './gr-diff-builder-unified';
+import {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line';
+import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group';
+import {GrDiffBuilderUnified} from './gr-diff-builder-unified';
+import {DiffPreferencesInfo} from '../../../api/diff';
+import {createDefaultDiffPrefs} from '../../../constants/constants';
+import {createDiff} from '../../../test/test-data-generators';
+import {queryAndAssert} from '../../../utils/common-util';
+
+suite('GrDiffBuilderUnified tests', () => {
+  let prefs: DiffPreferencesInfo;
+  let outputEl: HTMLElement;
+  let diffBuilder: GrDiffBuilderUnified;
+
+  setup(() => {
+    prefs = {
+      ...createDefaultDiffPrefs(),
+      line_length: 10,
+      show_tabs: true,
+      tab_size: 4,
+    };
+    outputEl = document.createElement('div');
+    diffBuilder = new GrDiffBuilderUnified(createDiff(), prefs, outputEl, []);
+  });
+
+  suite('buildSectionElement for BOTH group', () => {
+    let lines: GrDiffLine[];
+    let group: GrDiffGroup;
+
+    setup(() => {
+      lines = [
+        new GrDiffLine(GrDiffLineType.BOTH, 1, 2),
+        new GrDiffLine(GrDiffLineType.BOTH, 2, 3),
+        new GrDiffLine(GrDiffLineType.BOTH, 3, 4),
+      ];
+      lines[0].text = 'def hello_world():';
+      lines[1].text = '  print "Hello World";';
+      lines[2].text = '  return True';
+
+      group = new GrDiffGroup({type: GrDiffGroupType.BOTH, lines});
+    });
+
+    test('creates the section', () => {
+      const sectionEl = diffBuilder.buildSectionElement(group);
+      assert.isTrue(sectionEl.classList.contains('section'));
+      assert.isTrue(sectionEl.classList.contains('both'));
+    });
+
+    test('creates each unchanged row once', () => {
+      const sectionEl = diffBuilder.buildSectionElement(group);
+      const rowEls = sectionEl.querySelectorAll('.diff-row');
+
+      assert.equal(rowEls.length, 3);
+
+      assert.equal(
+        queryAndAssert(rowEls[0], '.lineNum.left').textContent,
+        lines[0].beforeNumber.toString()
+      );
+      assert.equal(
+        queryAndAssert(rowEls[0], '.lineNum.right').textContent,
+        lines[0].afterNumber.toString()
+      );
+      assert.equal(
+        queryAndAssert(rowEls[0], '.content').textContent,
+        lines[0].text
+      );
+
+      assert.equal(
+        queryAndAssert(rowEls[1], '.lineNum.left').textContent,
+        lines[1].beforeNumber.toString()
+      );
+      assert.equal(
+        queryAndAssert(rowEls[1], '.lineNum.right').textContent,
+        lines[1].afterNumber.toString()
+      );
+      assert.equal(
+        queryAndAssert(rowEls[1], '.content').textContent,
+        lines[1].text
+      );
+
+      assert.equal(
+        queryAndAssert(rowEls[2], '.lineNum.left').textContent,
+        lines[2].beforeNumber.toString()
+      );
+      assert.equal(
+        queryAndAssert(rowEls[2], '.lineNum.right').textContent,
+        lines[2].afterNumber.toString()
+      );
+      assert.equal(
+        queryAndAssert(rowEls[2], '.content').textContent,
+        lines[2].text
+      );
+    });
+  });
+
+  suite('buildSectionElement for moved chunks', () => {
+    test('creates a moved out group', () => {
+      const lines = [
+        new GrDiffLine(GrDiffLineType.REMOVE, 15),
+        new GrDiffLine(GrDiffLineType.REMOVE, 16),
+      ];
+      lines[0].text = 'def hello_world():';
+      lines[1].text = '  print "Hello World"';
+      const group = new GrDiffGroup({
+        type: GrDiffGroupType.DELTA,
+        lines,
+        moveDetails: {changed: false},
+      });
+
+      const sectionEl = diffBuilder.buildSectionElement(group);
+
+      const rowEls = sectionEl.querySelectorAll('tr');
+      const moveControlsRow = rowEls[0];
+      const cells = moveControlsRow.querySelectorAll('td');
+      assert.isTrue(sectionEl.classList.contains('dueToMove'));
+      assert.equal(rowEls.length, 3);
+      assert.isTrue(moveControlsRow.classList.contains('movedOut'));
+      assert.equal(cells.length, 3);
+      assert.isTrue(cells[2].classList.contains('moveHeader'));
+      assert.equal(cells[2].textContent, 'Moved out');
+    });
+
+    test('creates a moved in group', () => {
+      const lines = [
+        new GrDiffLine(GrDiffLineType.ADD, 37),
+        new GrDiffLine(GrDiffLineType.ADD, 38),
+      ];
+      lines[0].text = 'def hello_world():';
+      lines[1].text = '  print "Hello World"';
+      const group = new GrDiffGroup({
+        type: GrDiffGroupType.DELTA,
+        lines,
+        moveDetails: {changed: false},
+      });
+
+      const sectionEl = diffBuilder.buildSectionElement(group);
+
+      const rowEls = sectionEl.querySelectorAll('tr');
+      const moveControlsRow = rowEls[0];
+      const cells = moveControlsRow.querySelectorAll('td');
+      assert.isTrue(sectionEl.classList.contains('dueToMove'));
+      assert.equal(rowEls.length, 3);
+      assert.isTrue(moveControlsRow.classList.contains('movedIn'));
+      assert.equal(cells.length, 3);
+      assert.isTrue(cells[2].classList.contains('moveHeader'));
+      assert.equal(cells[2].textContent, 'Moved in');
+    });
+  });
+
+  suite('buildSectionElement for DELTA group', () => {
+    let lines: GrDiffLine[];
+    let group: GrDiffGroup;
+
+    setup(() => {
+      lines = [
+        new GrDiffLine(GrDiffLineType.REMOVE, 1),
+        new GrDiffLine(GrDiffLineType.REMOVE, 2),
+        new GrDiffLine(GrDiffLineType.ADD, 2),
+        new GrDiffLine(GrDiffLineType.ADD, 3),
+      ];
+      lines[0].text = 'def hello_world():';
+      lines[1].text = '  print "Hello World"';
+      lines[2].text = 'def hello_universe()';
+      lines[3].text = '  print "Hello Universe"';
+    });
+
+    test('creates the section', () => {
+      group = new GrDiffGroup({type: GrDiffGroupType.DELTA, lines});
+      const sectionEl = diffBuilder.buildSectionElement(group);
+      assert.isTrue(sectionEl.classList.contains('section'));
+      assert.isTrue(sectionEl.classList.contains('delta'));
+    });
+
+    test('creates the section with class if ignoredWhitespaceOnly', () => {
+      group = new GrDiffGroup({
+        type: GrDiffGroupType.DELTA,
+        lines,
+        ignoredWhitespaceOnly: true,
+      });
+      const sectionEl = diffBuilder.buildSectionElement(group);
+      assert.isTrue(sectionEl.classList.contains('ignoredWhitespaceOnly'));
+    });
+
+    test('creates the section with class if dueToRebase', () => {
+      group = new GrDiffGroup({
+        type: GrDiffGroupType.DELTA,
+        lines,
+        dueToRebase: true,
+      });
+      const sectionEl = diffBuilder.buildSectionElement(group);
+      assert.isTrue(sectionEl.classList.contains('dueToRebase'));
+    });
+
+    test('creates first the removed and then the added rows', () => {
+      group = new GrDiffGroup({type: GrDiffGroupType.DELTA, lines});
+      const sectionEl = diffBuilder.buildSectionElement(group);
+      const rowEls = sectionEl.querySelectorAll('.diff-row');
+
+      assert.equal(rowEls.length, 4);
+
+      assert.equal(
+        queryAndAssert(rowEls[0], '.lineNum.left').textContent,
+        lines[0].beforeNumber.toString()
+      );
+      assert.isNotOk(rowEls[0].querySelector('.lineNum.right'));
+      assert.equal(
+        queryAndAssert(rowEls[0], '.content').textContent,
+        lines[0].text
+      );
+
+      assert.equal(
+        queryAndAssert(rowEls[1], '.lineNum.left').textContent,
+        lines[1].beforeNumber.toString()
+      );
+      assert.isNotOk(rowEls[1].querySelector('.lineNum.right'));
+      assert.equal(
+        queryAndAssert(rowEls[1], '.content').textContent,
+        lines[1].text
+      );
+
+      assert.isNotOk(rowEls[2].querySelector('.lineNum.left'));
+      assert.equal(
+        queryAndAssert(rowEls[2], '.lineNum.right').textContent,
+        lines[2].afterNumber.toString()
+      );
+      assert.equal(
+        queryAndAssert(rowEls[2], '.content').textContent,
+        lines[2].text
+      );
+
+      assert.isNotOk(rowEls[3].querySelector('.lineNum.left'));
+      assert.equal(
+        queryAndAssert(rowEls[3], '.lineNum.right').textContent,
+        lines[3].afterNumber.toString()
+      );
+      assert.equal(
+        queryAndAssert(rowEls[3], '.content').textContent,
+        lines[3].text
+      );
+    });
+
+    test('creates only the added rows if only ignored whitespace', () => {
+      group = new GrDiffGroup({
+        type: GrDiffGroupType.DELTA,
+        lines,
+        ignoredWhitespaceOnly: true,
+      });
+      const sectionEl = diffBuilder.buildSectionElement(group);
+      const rowEls = sectionEl.querySelectorAll('.diff-row');
+
+      assert.equal(rowEls.length, 2);
+
+      assert.isNotOk(rowEls[0].querySelector('.lineNum.left'));
+      assert.equal(
+        queryAndAssert(rowEls[0], '.lineNum.right').textContent,
+        lines[2].afterNumber.toString()
+      );
+      assert.equal(
+        queryAndAssert(rowEls[0], '.content').textContent,
+        lines[2].text
+      );
+
+      assert.isNotOk(rowEls[1].querySelector('.lineNum.left'));
+      assert.equal(
+        queryAndAssert(rowEls[1], '.lineNum.right').textContent,
+        lines[3].afterNumber.toString()
+      );
+      assert.equal(
+        queryAndAssert(rowEls[1], '.content').textContent,
+        lines[3].text
+      );
+    });
+  });
+});
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder.ts
index add7ffa..4b664e2 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder.ts
@@ -89,7 +89,8 @@
 
   protected readonly numLinesLeft: number;
 
-  protected readonly _prefs: DiffPreferencesInfo;
+  // visible for testing
+  readonly _prefs: DiffPreferencesInfo;
 
   protected readonly renderPrefs?: RenderPreferences;
 
@@ -194,7 +195,8 @@
     group.element = element;
   }
 
-  private getGroupsByLineRange(
+  // visible for testing
+  getGroupsByLineRange(
     startLine: LineNumber,
     endLine: LineNumber,
     side: Side
@@ -257,7 +259,8 @@
    *        TODO: Change `null` to `undefined` in paramete type. Also: Do we
    *        really need to support null/undefined? Also change to camelCase.
    */
-  protected findLinesByRange(
+  // visible for testing
+  findLinesByRange(
     start: LineNumber,
     end: LineNumber,
     side: Side,
@@ -352,9 +355,8 @@
    *
    * @return The commit information.
    */
-  protected getBlameCommitForBaseLine(
-    lineNum: LineNumber
-  ): BlameInfo | undefined {
+  // visible for testing
+  getBlameCommitForBaseLine(lineNum: LineNumber): BlameInfo | undefined {
     for (const blameCommit of this.blameInfo) {
       for (const range of blameCommit.ranges) {
         if (range.start <= lineNum && range.end >= lineNum) {
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor.ts b/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor.ts
index dfe8a15..e00353a 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor.ts
@@ -109,7 +109,8 @@
    */
   initialLineNumber: number | null = null;
 
-  private cursorManager = new GrCursorManager();
+  // visible for testing
+  cursorManager = new GrCursorManager();
 
   private targetSubscription?: Subscription;
 
@@ -335,10 +336,6 @@
     this.preventAutoScrollOnManualScroll = true;
   };
 
-  private _boundHandleDiffRenderProgress = () => {
-    this._updateStops();
-  };
-
   private _boundHandleDiffRenderContent = () => {
     this._updateStops();
     // When done rendering, turn focus on move and automatic scrolling back on
@@ -550,10 +547,6 @@
     );
     diff.removeEventListener('render-start', this._boundHandleDiffRenderStart);
     diff.removeEventListener(
-      'render-progress',
-      this._boundHandleDiffRenderProgress
-    );
-    diff.removeEventListener(
       'render-content',
       this._boundHandleDiffRenderContent
     );
@@ -569,10 +562,6 @@
       this.boundHandleDiffLoadingChanged
     );
     diff.addEventListener('render-start', this._boundHandleDiffRenderStart);
-    diff.addEventListener(
-      'render-progress',
-      this._boundHandleDiffRenderProgress
-    );
     diff.addEventListener('render-content', this._boundHandleDiffRenderContent);
     diff.addEventListener('line-selected', this._boundHandleDiffLineSelected);
   }
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor_test.js b/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor_test.js
deleted file mode 100644
index c64f484..0000000
--- a/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor_test.js
+++ /dev/null
@@ -1,696 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import '../gr-diff/gr-diff.js';
-import './gr-diff-cursor.js';
-import {fixture, html} from '@open-wc/testing-helpers';
-import {listenOnce, mockPromise} from '../../../test/test-utils.js';
-import {createDiff} from '../../../test/test-data-generators.js';
-import {createDefaultDiffPrefs} from '../../../constants/constants.js';
-import {GrDiffCursor} from './gr-diff-cursor.js';
-
-suite('gr-diff-cursor tests', () => {
-  let cursor;
-  let diffElement;
-  let diff;
-
-  setup(async () => {
-    diffElement = await fixture(html`<gr-diff></gr-diff>`);
-    cursor = new GrDiffCursor();
-
-    // Register the diff with the cursor.
-    cursor.replaceDiffs([diffElement]);
-
-    diffElement.loggedIn = false;
-    diffElement.comments = {
-      left: [],
-      right: [],
-      meta: {},
-    };
-    diffElement.path = 'some/path.ts';
-    const promise = mockPromise();
-    const setupDone = () => {
-      cursor._updateStops();
-      cursor.moveToFirstChunk();
-      diffElement.removeEventListener('render', setupDone);
-      promise.resolve();
-    };
-    diffElement.addEventListener('render', setupDone);
-
-    diff = createDiff();
-    diffElement.prefs = createDefaultDiffPrefs();
-    diffElement.diff = diff;
-    await promise;
-  });
-
-  test('diff cursor functionality (side-by-side)', () => {
-    // The cursor has been initialized to the first delta.
-    assert.isOk(cursor.diffRow);
-
-    const firstDeltaRow = diffElement.shadowRoot
-        .querySelector('.section.delta .diff-row');
-    assert.equal(cursor.diffRow, firstDeltaRow);
-
-    cursor.moveDown();
-
-    assert.notEqual(cursor.diffRow, firstDeltaRow);
-    assert.equal(cursor.diffRow, firstDeltaRow.nextSibling);
-
-    cursor.moveUp();
-
-    assert.notEqual(cursor.diffRow, firstDeltaRow.nextSibling);
-    assert.equal(cursor.diffRow, firstDeltaRow);
-  });
-
-  test('moveToFirstChunk', async () => {
-    const diff = {
-      meta_a: {
-        name: 'lorem-ipsum.txt',
-        content_type: 'text/plain',
-        lines: 3,
-      },
-      meta_b: {
-        name: 'lorem-ipsum.txt',
-        content_type: 'text/plain',
-        lines: 3,
-      },
-      intraline_status: 'OK',
-      change_type: 'MODIFIED',
-      diff_header: [
-        'diff --git a/lorem-ipsum.txt b/lorem-ipsum.txt',
-        'index b2adcf4..554ae49 100644',
-        '--- a/lorem-ipsum.txt',
-        '+++ b/lorem-ipsum.txt',
-      ],
-      content: [
-        {b: ['new line 1']},
-        {ab: ['unchanged line']},
-        {a: ['old line 2']},
-        {ab: ['more unchanged lines']},
-      ],
-    };
-
-    diffElement.diff = diff;
-    // The file comment button, if present, is a cursor stop. Ensure
-    // moveToFirstChunk() works correctly even if the button is not shown.
-    diffElement.prefs.show_file_comment_button = false;
-    await flush();
-    cursor._updateStops();
-
-    const chunks = Array.from(diffElement.root.querySelectorAll(
-        '.section.delta'));
-    assert.equal(chunks.length, 2);
-
-    // Verify it works on fresh diff.
-    cursor.moveToFirstChunk();
-    assert.equal(chunks.indexOf(cursor.diffRow.parentElement), 0);
-    assert.equal(cursor.side, 'right');
-
-    // Verify it works from other cursor positions.
-    cursor.moveToNextChunk();
-    assert.equal(chunks.indexOf(cursor.diffRow.parentElement), 1);
-    assert.equal(cursor.side, 'left');
-    cursor.moveToFirstChunk();
-    assert.equal(chunks.indexOf(cursor.diffRow.parentElement), 0);
-    assert.equal(cursor.side, 'right');
-  });
-
-  test('moveToLastChunk', async () => {
-    const diff = {
-      meta_a: {
-        name: 'lorem-ipsum.txt',
-        content_type: 'text/plain',
-        lines: 3,
-      },
-      meta_b: {
-        name: 'lorem-ipsum.txt',
-        content_type: 'text/plain',
-        lines: 3,
-      },
-      intraline_status: 'OK',
-      change_type: 'MODIFIED',
-      diff_header: [
-        'diff --git a/lorem-ipsum.txt b/lorem-ipsum.txt',
-        'index b2adcf4..554ae49 100644',
-        '--- a/lorem-ipsum.txt',
-        '+++ b/lorem-ipsum.txt',
-      ],
-      content: [
-        {ab: ['unchanged line']},
-        {a: ['old line 2']},
-        {ab: ['more unchanged lines']},
-        {b: ['new line 3']},
-      ],
-    };
-
-    diffElement.diff = diff;
-    await flush();
-    cursor._updateStops();
-
-    const chunks = Array.from(diffElement.root.querySelectorAll(
-        '.section.delta'));
-    assert.equal(chunks.length, 2);
-
-    // Verify it works on fresh diff.
-    cursor.moveToLastChunk();
-    assert.equal(chunks.indexOf(cursor.diffRow.parentElement), 1);
-    assert.equal(cursor.side, 'right');
-
-    // Verify it works from other cursor positions.
-    cursor.moveToPreviousChunk();
-    assert.equal(chunks.indexOf(cursor.diffRow.parentElement), 0);
-    assert.equal(cursor.side, 'left');
-    cursor.moveToLastChunk();
-    assert.equal(chunks.indexOf(cursor.diffRow.parentElement), 1);
-    assert.equal(cursor.side, 'right');
-  });
-
-  test('cursor scroll behavior', () => {
-    assert.equal(cursor.cursorManager.scrollMode, 'keep-visible');
-
-    diffElement.dispatchEvent(new Event('render-start'));
-    assert.isTrue(cursor.cursorManager.focusOnMove);
-
-    window.dispatchEvent(new Event('scroll'));
-    assert.equal(cursor.cursorManager.scrollMode, 'never');
-    assert.isFalse(cursor.cursorManager.focusOnMove);
-
-    diffElement.dispatchEvent(new Event('render-content'));
-    assert.isTrue(cursor.cursorManager.focusOnMove);
-
-    cursor.reInitCursor();
-    assert.equal(cursor.cursorManager.scrollMode, 'keep-visible');
-  });
-
-  test('moves to selected line', () => {
-    const moveToNumStub = sinon.stub(cursor, 'moveToLineNumber');
-
-    diffElement.dispatchEvent(
-        new CustomEvent('line-selected', {
-          detail: {number: '123', side: 'right', path: 'some/file'},
-        }));
-
-    assert.isTrue(moveToNumStub.called);
-    assert.equal(moveToNumStub.lastCall.args[0], '123');
-    assert.equal(moveToNumStub.lastCall.args[1], 'right');
-    assert.equal(moveToNumStub.lastCall.args[2], 'some/file');
-  });
-
-  suite('unified diff', () => {
-    setup(async () => {
-      const promise = mockPromise();
-      // We must allow the diff to re-render after setting the viewMode.
-      const renderHandler = function() {
-        diffElement.removeEventListener('render', renderHandler);
-        cursor.reInitCursor();
-        promise.resolve();
-      };
-      diffElement.addEventListener('render', renderHandler);
-      diffElement.viewMode = 'UNIFIED_DIFF';
-      await promise;
-    });
-
-    test('diff cursor functionality (unified)', () => {
-      // The cursor has been initialized to the first delta.
-      assert.isOk(cursor.diffRow);
-
-      let firstDeltaRow = diffElement.shadowRoot
-          .querySelector('.section.delta .diff-row');
-      assert.equal(cursor.diffRow, firstDeltaRow);
-
-      firstDeltaRow = diffElement.shadowRoot
-          .querySelector('.section.delta .diff-row');
-      assert.equal(cursor.diffRow, firstDeltaRow);
-
-      cursor.moveDown();
-
-      assert.notEqual(cursor.diffRow, firstDeltaRow);
-      assert.equal(cursor.diffRow, firstDeltaRow.nextSibling);
-
-      cursor.moveUp();
-
-      assert.notEqual(cursor.diffRow, firstDeltaRow.nextSibling);
-      assert.equal(cursor.diffRow, firstDeltaRow);
-    });
-  });
-
-  test('cursor side functionality', () => {
-    // The side only applies to side-by-side mode, which should be the default
-    // mode.
-    assert.equal(diffElement.viewMode, 'SIDE_BY_SIDE');
-
-    const firstDeltaSection = diffElement.shadowRoot
-        .querySelector('.section.delta');
-    const firstDeltaRow = firstDeltaSection.querySelector('.diff-row');
-
-    // Because the first delta in this diff is on the right, it should be set
-    // to the right side.
-    assert.equal(cursor.side, 'right');
-    assert.equal(cursor.diffRow, firstDeltaRow);
-    const firstIndex = cursor.cursorManager.index;
-
-    // Move the side to the left. Because this delta only has a right side, we
-    // should be moved up to the previous line where there is content on the
-    // right. The previous row is part of the previous section.
-    cursor.moveLeft();
-
-    assert.equal(cursor.side, 'left');
-    assert.notEqual(cursor.diffRow, firstDeltaRow);
-    assert.equal(cursor.cursorManager.index, firstIndex - 1);
-    assert.equal(cursor.diffRow.parentElement,
-        firstDeltaSection.previousSibling);
-
-    // If we move down, we should skip everything in the first delta because
-    // we are on the left side and the first delta has no content on the left.
-    cursor.moveDown();
-
-    assert.equal(cursor.side, 'left');
-    assert.notEqual(cursor.diffRow, firstDeltaRow);
-    assert.isTrue(cursor.cursorManager.index > firstIndex);
-    assert.equal(cursor.diffRow.parentElement,
-        firstDeltaSection.nextSibling);
-  });
-
-  test('chunk skip functionality', () => {
-    const chunks = diffElement.root.querySelectorAll(
-        '.section.delta');
-    const indexOfChunk = function(chunk) {
-      return Array.prototype.indexOf.call(chunks, chunk);
-    };
-
-    // We should be initialized to the first chunk. Since this chunk only has
-    // content on the right side, our side should be right.
-    let currentIndex = indexOfChunk(cursor.diffRow.parentElement);
-    assert.equal(currentIndex, 0);
-    assert.equal(cursor.side, 'right');
-
-    // Move to the next chunk.
-    cursor.moveToNextChunk();
-
-    // Since this chunk only has content on the left side. we should have been
-    // automatically moved over.
-    const previousIndex = currentIndex;
-    currentIndex = indexOfChunk(cursor.diffRow.parentElement);
-    assert.equal(currentIndex, previousIndex + 1);
-    assert.equal(cursor.side, 'left');
-  });
-
-  suite('moved chunks without line range)', () => {
-    setup(async () => {
-      const promise = mockPromise();
-      const renderHandler = function() {
-        diffElement.removeEventListener('render', renderHandler);
-        cursor.reInitCursor();
-        promise.resolve();
-      };
-      diffElement.addEventListener('render', renderHandler);
-      diffElement.diff = {...diff, content: [
-        {
-          ab: [
-            'Lorem ipsum dolor sit amet, suspendisse inceptos vehicula, ',
-          ],
-        },
-        {
-          b: [
-            'Nullam neque, ligula ac, id blandit.',
-            'Sagittis tincidunt torquent, tempor nunc amet.',
-            'At rhoncus id.',
-          ],
-          move_details: {changed: false},
-        },
-        {
-          ab: [
-            'Sem nascetur, erat ut, non in.',
-          ],
-        },
-        {
-          a: [
-            'Nullam neque, ligula ac, id blandit.',
-            'Sagittis tincidunt torquent, tempor nunc amet.',
-            'At rhoncus id.',
-          ],
-          move_details: {changed: false},
-        },
-        {
-          ab: [
-            'Arcu eget, rhoncus amet cursus, ipsum elementum.',
-          ],
-        },
-      ]};
-      await promise;
-    });
-
-    test('renders moveControls with simple descriptions', () => {
-      const [movedIn, movedOut] = diffElement.root
-          .querySelectorAll('.dueToMove .moveControls');
-      assert.equal(movedIn.textContent, 'Moved in');
-      assert.equal(movedOut.textContent, 'Moved out');
-    });
-  });
-
-  suite('moved chunks (moveDetails)', () => {
-    setup(async () => {
-      const promise = mockPromise();
-      const renderHandler = function() {
-        diffElement.removeEventListener('render', renderHandler);
-        cursor.reInitCursor();
-        promise.resolve();
-      };
-      diffElement.addEventListener('render', renderHandler);
-      diffElement.diff = {...diff, content: [
-        {
-          ab: [
-            'Lorem ipsum dolor sit amet, suspendisse inceptos vehicula, ',
-          ],
-        },
-        {
-          b: [
-            'Nullam neque, ligula ac, id blandit.',
-            'Sagittis tincidunt torquent, tempor nunc amet.',
-            'At rhoncus id.',
-          ],
-          move_details: {changed: false, range: {start: 4, end: 6}},
-        },
-        {
-          ab: [
-            'Sem nascetur, erat ut, non in.',
-          ],
-        },
-        {
-          a: [
-            'Nullam neque, ligula ac, id blandit.',
-            'Sagittis tincidunt torquent, tempor nunc amet.',
-            'At rhoncus id.',
-          ],
-          move_details: {changed: false, range: {start: 2, end: 4}},
-        },
-        {
-          ab: [
-            'Arcu eget, rhoncus amet cursus, ipsum elementum.',
-          ],
-        },
-      ]};
-      await promise;
-    });
-
-    test('renders moveControls with simple descriptions', () => {
-      const [movedIn, movedOut] = diffElement.root
-          .querySelectorAll('.dueToMove .moveControls');
-      assert.equal(movedIn.textContent, 'Moved from lines 4 - 6');
-      assert.equal(movedOut.textContent, 'Moved to lines 2 - 4');
-    });
-
-    test('startLineAnchor of movedIn chunk fires events', async () => {
-      const [movedIn] = diffElement.root
-          .querySelectorAll('.dueToMove .moveControls');
-      const [startLineAnchor] = movedIn.querySelectorAll('a');
-
-      const promise = mockPromise();
-      const onMovedLinkClicked = e => {
-        assert.deepEqual(e.detail, {lineNum: 4, side: 'left'});
-        promise.resolve();
-      };
-      assert.equal(startLineAnchor.textContent, '4');
-      startLineAnchor
-          .addEventListener('moved-link-clicked', onMovedLinkClicked);
-      MockInteractions.click(startLineAnchor);
-      await promise;
-    });
-
-    test('endLineAnchor of movedOut fires events', async () => {
-      const [, movedOut] = diffElement.root
-          .querySelectorAll('.dueToMove .moveControls');
-      const [, endLineAnchor] = movedOut.querySelectorAll('a');
-
-      const promise = mockPromise();
-      const onMovedLinkClicked = e => {
-        assert.deepEqual(e.detail, {lineNum: 4, side: 'right'});
-        promise.resolve();
-      };
-      assert.equal(endLineAnchor.textContent, '4');
-      endLineAnchor.addEventListener('moved-link-clicked', onMovedLinkClicked);
-      MockInteractions.click(endLineAnchor);
-      await promise;
-    });
-  });
-
-  test('initialLineNumber not provided', async () => {
-    let scrollBehaviorDuringMove;
-    const moveToNumStub = sinon.stub(cursor, 'moveToLineNumber');
-    const moveToChunkStub = sinon.stub(cursor, 'moveToFirstChunk')
-        .callsFake(() => {
-          scrollBehaviorDuringMove = cursor.cursorManager.scrollMode;
-        });
-
-    const promise = mockPromise();
-    function renderHandler() {
-      diffElement.removeEventListener('render', renderHandler);
-      cursor.reInitCursor();
-      assert.isFalse(moveToNumStub.called);
-      assert.isTrue(moveToChunkStub.called);
-      assert.equal(scrollBehaviorDuringMove, 'never');
-      assert.equal(cursor.cursorManager.scrollMode, 'keep-visible');
-      promise.resolve();
-    }
-    diffElement.addEventListener('render', renderHandler);
-    diffElement._diffChanged(createDiff());
-    await promise;
-  });
-
-  test('initialLineNumber provided', async () => {
-    let scrollBehaviorDuringMove;
-    const moveToNumStub = sinon.stub(cursor, 'moveToLineNumber')
-        .callsFake(() => {
-          scrollBehaviorDuringMove = cursor.cursorManager.scrollMode;
-        });
-    const moveToChunkStub = sinon.stub(cursor, 'moveToFirstChunk');
-    const promise = mockPromise();
-    function renderHandler() {
-      diffElement.removeEventListener('render', renderHandler);
-      cursor.reInitCursor();
-      assert.isFalse(moveToChunkStub.called);
-      assert.isTrue(moveToNumStub.called);
-      assert.equal(moveToNumStub.lastCall.args[0], 10);
-      assert.equal(moveToNumStub.lastCall.args[1], 'right');
-      assert.equal(scrollBehaviorDuringMove, 'keep-visible');
-      assert.equal(cursor.cursorManager.scrollMode, 'keep-visible');
-      promise.resolve();
-    }
-    diffElement.addEventListener('render', renderHandler);
-    cursor.initialLineNumber = 10;
-    cursor.side = 'right';
-
-    diffElement._diffChanged(createDiff());
-    await promise;
-  });
-
-  test('getTargetDiffElement', () => {
-    cursor.initialLineNumber = 1;
-    assert.isTrue(!!cursor.diffRow);
-    assert.equal(
-        cursor.getTargetDiffElement(),
-        diffElement
-    );
-  });
-
-  suite('createCommentInPlace', () => {
-    setup(() => {
-      diffElement.loggedIn = true;
-    });
-
-    test('adds new draft for selected line on the left', async () => {
-      cursor.moveToLineNumber(2, 'left');
-      const promise = mockPromise();
-      diffElement.addEventListener('create-comment', e => {
-        const {lineNum, range, side} = e.detail;
-        assert.equal(lineNum, 2);
-        assert.equal(range, undefined);
-        assert.equal(side, 'left');
-        promise.resolve();
-      });
-      cursor.createCommentInPlace();
-      await promise;
-    });
-
-    test('adds draft for selected line on the right', async () => {
-      cursor.moveToLineNumber(4, 'right');
-      const promise = mockPromise();
-      diffElement.addEventListener('create-comment', e => {
-        const {lineNum, range, side} = e.detail;
-        assert.equal(lineNum, 4);
-        assert.equal(range, undefined);
-        assert.equal(side, 'right');
-        promise.resolve();
-      });
-      cursor.createCommentInPlace();
-      await promise;
-    });
-
-    test('creates comment for range if selected', async () => {
-      const someRange = {
-        start_line: 2,
-        start_character: 3,
-        end_line: 6,
-        end_character: 1,
-      };
-      diffElement.highlights.selectedRange = {
-        side: 'right',
-        range: someRange,
-      };
-      const promise = mockPromise();
-      diffElement.addEventListener('create-comment', e => {
-        const {lineNum, range, side} = e.detail;
-        assert.equal(lineNum, 6);
-        assert.equal(range, someRange);
-        assert.equal(side, 'right');
-        promise.resolve();
-      });
-      cursor.createCommentInPlace();
-      await promise;
-    });
-
-    test('ignores call if nothing is selected', () => {
-      const createRangeCommentStub = sinon.stub(diffElement,
-          'createRangeComment');
-      const addDraftAtLineStub = sinon.stub(diffElement, 'addDraftAtLine');
-      cursor.diffRow = undefined;
-      cursor.createCommentInPlace();
-      assert.isFalse(createRangeCommentStub.called);
-      assert.isFalse(addDraftAtLineStub.called);
-    });
-  });
-
-  test('getAddress', () => {
-    // It should initialize to the first chunk: line 5 of the revision.
-    assert.deepEqual(cursor.getAddress(),
-        {leftSide: false, number: 5});
-
-    // Revision line 4 is up.
-    cursor.moveUp();
-    assert.deepEqual(cursor.getAddress(),
-        {leftSide: false, number: 4});
-
-    // Base line 4 is left.
-    cursor.moveLeft();
-    assert.deepEqual(cursor.getAddress(), {leftSide: true, number: 4});
-
-    // Moving to the next chunk takes it back to the start.
-    cursor.moveToNextChunk();
-    assert.deepEqual(cursor.getAddress(),
-        {leftSide: false, number: 5});
-
-    // The following chunk is a removal starting on line 10 of the base.
-    cursor.moveToNextChunk();
-    assert.deepEqual(cursor.getAddress(),
-        {leftSide: true, number: 10});
-
-    // Should be null if there is no selection.
-    cursor.cursorManager.unsetCursor();
-    assert.isNotOk(cursor.getAddress());
-  });
-
-  test('_findRowByNumberAndFile', () => {
-    // Get the first ab row after the first chunk.
-    const row = diffElement.root.querySelectorAll('tr')[9];
-
-    // It should be line 8 on the right, but line 5 on the left.
-    assert.equal(cursor._findRowByNumberAndFile(8, 'right'), row);
-    assert.equal(cursor._findRowByNumberAndFile(5, 'left'), row);
-  });
-
-  test('expand context updates stops', async () => {
-    sinon.spy(cursor, '_updateStops');
-    MockInteractions.tap(diffElement.shadowRoot
-        .querySelector('gr-context-controls').shadowRoot
-        .querySelector('.showContext'));
-    await flush();
-    assert.isTrue(cursor._updateStops.called);
-  });
-
-  test('updates stops when loading changes', () => {
-    sinon.spy(cursor, '_updateStops');
-    diffElement.dispatchEvent(new Event('loading-changed'));
-    assert.isTrue(cursor._updateStops.called);
-  });
-
-  suite('multi diff', () => {
-    let diffElements;
-
-    setup(async () => {
-      diffElements = [
-        await fixture(html`<gr-diff></gr-diff>`),
-        await fixture(html`<gr-diff></gr-diff>`),
-        await fixture(html`<gr-diff></gr-diff>`),
-      ];
-      cursor = new GrDiffCursor();
-
-      // Register the diff with the cursor.
-      cursor.replaceDiffs(diffElements);
-
-      for (const el of diffElements) {
-        el.prefs = createDefaultDiffPrefs();
-      }
-    });
-
-    function getTargetDiffIndex() {
-      // Mocha has a bug where when `assert.equals` fails, it will try to
-      // JSON.stringify the operands, which fails when they are cyclic structures
-      // like GrDiffElement. The failure is difficult to attribute to a specific
-      // assertion because of the async nature assertion errors are handled and
-      // can cause the test simply timing out, causing a lot of debugging headache.
-      // Working with indices circumvents the problem.
-      return diffElements.indexOf(cursor.getTargetDiffElement());
-    }
-
-    test('do not skip loading diffs', async () => {
-      const diffRenderedPromises =
-          diffElements.map(diffEl => listenOnce(diffEl, 'render'));
-
-      diffElements[0].diff = createDiff();
-      diffElements[2].diff = createDiff();
-      await Promise.all([diffRenderedPromises[0], diffRenderedPromises[2]]);
-
-      const lastLine = diffElements[0].diff.meta_b.lines;
-
-      // Goto second last line of the first diff
-      cursor.moveToLineNumber(lastLine - 1, 'right');
-      assert.equal(
-          cursor.getTargetLineElement().textContent, lastLine - 1);
-
-      // Can move down until we reach the loading file
-      cursor.moveDown();
-      assert.equal(getTargetDiffIndex(), 0);
-      assert.equal(cursor.getTargetLineElement().textContent, lastLine);
-
-      // Cannot move down while still loading the diff we would switch to
-      cursor.moveDown();
-      assert.equal(getTargetDiffIndex(), 0);
-      assert.equal(cursor.getTargetLineElement().textContent, lastLine);
-
-      // Diff 1 finishing to load
-      diffElements[1].diff = createDiff();
-      await diffRenderedPromises[1];
-
-      // Now we can go down
-      cursor.moveDown();
-      assert.equal(getTargetDiffIndex(), 1);
-      assert.equal(cursor.getTargetLineElement().textContent, 'File');
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor_test.ts
new file mode 100644
index 0000000..ac9b407
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor_test.ts
@@ -0,0 +1,693 @@
+/**
+ * @license
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup-karma';
+import '../gr-diff/gr-diff';
+import './gr-diff-cursor';
+import {fixture, html} from '@open-wc/testing-helpers';
+import {mockPromise, queryAll, queryAndAssert} from '../../../test/test-utils';
+import {createDiff} from '../../../test/test-data-generators';
+import {createDefaultDiffPrefs} from '../../../constants/constants';
+import {GrDiffCursor} from './gr-diff-cursor';
+import {waitForEventOnce} from '../../../utils/event-util';
+import {DiffInfo, DiffViewMode, Side} from '../../../api/diff';
+import {GrDiff} from '../gr-diff/gr-diff';
+import {assertIsDefined} from '../../../utils/common-util';
+
+suite('gr-diff-cursor tests', () => {
+  let cursor: GrDiffCursor;
+  let diffElement: GrDiff;
+  let diff: DiffInfo;
+
+  setup(async () => {
+    diffElement = await fixture(html`<gr-diff></gr-diff>`);
+    cursor = new GrDiffCursor();
+
+    // Register the diff with the cursor.
+    cursor.replaceDiffs([diffElement]);
+
+    diffElement.loggedIn = false;
+    diffElement.path = 'some/path.ts';
+    const promise = mockPromise();
+    const setupDone = () => {
+      cursor._updateStops();
+      cursor.moveToFirstChunk();
+      diffElement.removeEventListener('render', setupDone);
+      promise.resolve();
+    };
+    diffElement.addEventListener('render', setupDone);
+
+    diff = createDiff();
+    diffElement.prefs = createDefaultDiffPrefs();
+    diffElement.diff = diff;
+    await promise;
+  });
+
+  test('diff cursor functionality (side-by-side)', () => {
+    // The cursor has been initialized to the first delta.
+    assert.isOk(cursor.diffRow);
+
+    const firstDeltaRow = queryAndAssert<HTMLElement>(
+      diffElement,
+      '.section.delta .diff-row'
+    );
+    assert.equal(cursor.diffRow, firstDeltaRow);
+
+    cursor.moveDown();
+
+    assert.isOk(firstDeltaRow.nextElementSibling);
+    assert.notEqual(cursor.diffRow, firstDeltaRow);
+    assert.equal(
+      cursor.diffRow,
+      firstDeltaRow.nextElementSibling as HTMLElement
+    );
+
+    cursor.moveUp();
+
+    assert.isOk(firstDeltaRow.nextElementSibling);
+    assert.notEqual(
+      cursor.diffRow,
+      firstDeltaRow.nextElementSibling as HTMLElement
+    );
+    assert.equal(cursor.diffRow, firstDeltaRow);
+  });
+
+  test('moveToFirstChunk', async () => {
+    const diff: DiffInfo = {
+      meta_a: {
+        name: 'lorem-ipsum.txt',
+        content_type: 'text/plain',
+        lines: 3,
+      },
+      meta_b: {
+        name: 'lorem-ipsum.txt',
+        content_type: 'text/plain',
+        lines: 3,
+      },
+      intraline_status: 'OK',
+      change_type: 'MODIFIED',
+      diff_header: [
+        'diff --git a/lorem-ipsum.txt b/lorem-ipsum.txt',
+        'index b2adcf4..554ae49 100644',
+        '--- a/lorem-ipsum.txt',
+        '+++ b/lorem-ipsum.txt',
+      ],
+      content: [
+        {b: ['new line 1']},
+        {ab: ['unchanged line']},
+        {a: ['old line 2']},
+        {ab: ['more unchanged lines']},
+      ],
+    };
+
+    diffElement.diff = diff;
+    // The file comment button, if present, is a cursor stop. Ensure
+    // moveToFirstChunk() works correctly even if the button is not shown.
+    diffElement.prefs!.show_file_comment_button = false;
+    await waitForEventOnce(diffElement, 'render');
+
+    cursor._updateStops();
+
+    const chunks = [
+      ...queryAll(diffElement, '.section.delta'),
+    ] as HTMLElement[];
+    assert.equal(chunks.length, 2);
+
+    // Verify it works on fresh diff.
+    cursor.moveToFirstChunk();
+    assert.ok(cursor.diffRow);
+    assert.equal(chunks.indexOf(cursor.diffRow!.parentElement!), 0);
+    assert.equal(cursor.side, Side.RIGHT);
+
+    // Verify it works from other cursor positions.
+    cursor.moveToNextChunk();
+    assert.ok(cursor.diffRow);
+    assert.equal(chunks.indexOf(cursor.diffRow!.parentElement!), 1);
+    assert.equal(cursor.side, Side.LEFT);
+    cursor.moveToFirstChunk();
+    assert.ok(cursor.diffRow);
+    assert.equal(chunks.indexOf(cursor.diffRow!.parentElement!), 0);
+    assert.equal(cursor.side, Side.RIGHT);
+  });
+
+  test('moveToLastChunk', async () => {
+    const diff: DiffInfo = {
+      meta_a: {
+        name: 'lorem-ipsum.txt',
+        content_type: 'text/plain',
+        lines: 3,
+      },
+      meta_b: {
+        name: 'lorem-ipsum.txt',
+        content_type: 'text/plain',
+        lines: 3,
+      },
+      intraline_status: 'OK',
+      change_type: 'MODIFIED',
+      diff_header: [
+        'diff --git a/lorem-ipsum.txt b/lorem-ipsum.txt',
+        'index b2adcf4..554ae49 100644',
+        '--- a/lorem-ipsum.txt',
+        '+++ b/lorem-ipsum.txt',
+      ],
+      content: [
+        {ab: ['unchanged line']},
+        {a: ['old line 2']},
+        {ab: ['more unchanged lines']},
+        {b: ['new line 3']},
+      ],
+    };
+
+    diffElement.diff = diff;
+    await waitForEventOnce(diffElement, 'render');
+    cursor._updateStops();
+
+    const chunks = [...queryAll(diffElement, '.section.delta')];
+    assert.equal(chunks.length, 2);
+
+    // Verify it works on fresh diff.
+    cursor.moveToLastChunk();
+    assert.equal(chunks.indexOf(cursor.diffRow!.parentElement!), 1);
+    assert.equal(cursor.side, Side.RIGHT);
+
+    // Verify it works from other cursor positions.
+    cursor.moveToPreviousChunk();
+    assert.equal(chunks.indexOf(cursor.diffRow!.parentElement!), 0);
+    assert.equal(cursor.side, Side.LEFT);
+    cursor.moveToLastChunk();
+    assert.equal(chunks.indexOf(cursor.diffRow!.parentElement!), 1);
+    assert.equal(cursor.side, Side.RIGHT);
+  });
+
+  test('cursor scroll behavior', () => {
+    assert.equal(cursor.cursorManager.scrollMode, 'keep-visible');
+
+    diffElement.dispatchEvent(new Event('render-start'));
+    assert.isTrue(cursor.cursorManager.focusOnMove);
+
+    window.dispatchEvent(new Event('scroll'));
+    assert.equal(cursor.cursorManager.scrollMode, 'never');
+    assert.isFalse(cursor.cursorManager.focusOnMove);
+
+    diffElement.dispatchEvent(new Event('render-content'));
+    assert.isTrue(cursor.cursorManager.focusOnMove);
+
+    cursor.reInitCursor();
+    assert.equal(cursor.cursorManager.scrollMode, 'keep-visible');
+  });
+
+  test('moves to selected line', () => {
+    const moveToNumStub = sinon.stub(cursor, 'moveToLineNumber');
+
+    diffElement.dispatchEvent(
+      new CustomEvent('line-selected', {
+        detail: {number: '123', side: Side.RIGHT, path: 'some/file'},
+      })
+    );
+
+    assert.isTrue(moveToNumStub.called);
+    assert.equal(moveToNumStub.lastCall.args[0], 123);
+    assert.equal(moveToNumStub.lastCall.args[1], Side.RIGHT);
+    assert.equal(moveToNumStub.lastCall.args[2], 'some/file');
+  });
+
+  suite('unified diff', () => {
+    setup(async () => {
+      diffElement.viewMode = DiffViewMode.UNIFIED;
+      await waitForEventOnce(diffElement, 'render');
+      cursor.reInitCursor();
+    });
+
+    test('diff cursor functionality (unified)', () => {
+      // The cursor has been initialized to the first delta.
+      assert.isOk(cursor.diffRow);
+
+      const firstDeltaRow = queryAndAssert<HTMLElement>(
+        diffElement,
+        '.section.delta .diff-row'
+      );
+      assert.equal(cursor.diffRow, firstDeltaRow);
+
+      cursor.moveDown();
+
+      assert.notEqual(cursor.diffRow, firstDeltaRow);
+      assert.equal(
+        cursor.diffRow,
+        firstDeltaRow.nextElementSibling as HTMLElement
+      );
+
+      cursor.moveUp();
+
+      assert.notEqual(
+        cursor.diffRow,
+        firstDeltaRow.nextElementSibling as HTMLElement
+      );
+      assert.equal(cursor.diffRow, firstDeltaRow);
+    });
+  });
+
+  test('cursor side functionality', () => {
+    // The side only applies to side-by-side mode, which should be the default
+    // mode.
+    assert.equal(diffElement.viewMode, 'SIDE_BY_SIDE');
+
+    const firstDeltaSection = queryAndAssert<HTMLElement>(
+      diffElement,
+      '.section.delta'
+    );
+    const firstDeltaRow = queryAndAssert<HTMLElement>(
+      firstDeltaSection,
+      '.diff-row'
+    );
+
+    // Because the first delta in this diff is on the right, it should be set
+    // to the right side.
+    assert.equal(cursor.side, Side.RIGHT);
+    assert.equal(cursor.diffRow, firstDeltaRow);
+    const firstIndex = cursor.cursorManager.index;
+
+    // Move the side to the left. Because this delta only has a right side, we
+    // should be moved up to the previous line where there is content on the
+    // right. The previous row is part of the previous section.
+    cursor.moveLeft();
+
+    assert.equal(cursor.side, Side.LEFT);
+    assert.notEqual(cursor.diffRow, firstDeltaRow);
+    assert.equal(cursor.cursorManager.index, firstIndex - 1);
+    assert.equal(
+      cursor.diffRow!.parentElement,
+      firstDeltaSection.previousSibling
+    );
+
+    // If we move down, we should skip everything in the first delta because
+    // we are on the left side and the first delta has no content on the left.
+    cursor.moveDown();
+
+    assert.equal(cursor.side, Side.LEFT);
+    assert.notEqual(cursor.diffRow, firstDeltaRow);
+    assert.isTrue(cursor.cursorManager.index > firstIndex);
+    assert.equal(cursor.diffRow!.parentElement, firstDeltaSection.nextSibling);
+  });
+
+  test('chunk skip functionality', () => {
+    const chunks = [...queryAll(diffElement, '.section.delta')];
+    const indexOfChunk = function (chunk: HTMLElement) {
+      return Array.prototype.indexOf.call(chunks, chunk);
+    };
+
+    // We should be initialized to the first chunk. Since this chunk only has
+    // content on the right side, our side should be right.
+    let currentIndex = indexOfChunk(cursor.diffRow!.parentElement!);
+    assert.equal(currentIndex, 0);
+    assert.equal(cursor.side, Side.RIGHT);
+
+    // Move to the next chunk.
+    cursor.moveToNextChunk();
+
+    // Since this chunk only has content on the left side. we should have been
+    // automatically moved over.
+    const previousIndex = currentIndex;
+    currentIndex = indexOfChunk(cursor.diffRow!.parentElement!);
+    assert.equal(currentIndex, previousIndex + 1);
+    assert.equal(cursor.side, Side.LEFT);
+  });
+
+  suite('moved chunks without line range)', () => {
+    setup(async () => {
+      const promise = mockPromise();
+      const renderHandler = function () {
+        diffElement.removeEventListener('render', renderHandler);
+        cursor.reInitCursor();
+        promise.resolve();
+      };
+      diffElement.addEventListener('render', renderHandler);
+      diffElement.diff = {
+        ...diff,
+        content: [
+          {
+            ab: ['Lorem ipsum dolor sit amet, suspendisse inceptos vehicula, '],
+          },
+          {
+            b: [
+              'Nullam neque, ligula ac, id blandit.',
+              'Sagittis tincidunt torquent, tempor nunc amet.',
+              'At rhoncus id.',
+            ],
+            move_details: {changed: false},
+          },
+          {
+            ab: ['Sem nascetur, erat ut, non in.'],
+          },
+          {
+            a: [
+              'Nullam neque, ligula ac, id blandit.',
+              'Sagittis tincidunt torquent, tempor nunc amet.',
+              'At rhoncus id.',
+            ],
+            move_details: {changed: false},
+          },
+          {
+            ab: ['Arcu eget, rhoncus amet cursus, ipsum elementum.'],
+          },
+        ],
+      };
+      await promise;
+    });
+
+    test('renders moveControls with simple descriptions', () => {
+      const [movedIn, movedOut] = [
+        ...queryAll(diffElement, '.dueToMove .moveControls'),
+      ];
+      assert.equal(movedIn.textContent, 'Moved in');
+      assert.equal(movedOut.textContent, 'Moved out');
+    });
+  });
+
+  suite('moved chunks (moveDetails)', () => {
+    setup(async () => {
+      const promise = mockPromise();
+      const renderHandler = function () {
+        diffElement.removeEventListener('render', renderHandler);
+        cursor.reInitCursor();
+        promise.resolve();
+      };
+      diffElement.addEventListener('render', renderHandler);
+      diffElement.diff = {
+        ...diff,
+        content: [
+          {
+            ab: ['Lorem ipsum dolor sit amet, suspendisse inceptos vehicula, '],
+          },
+          {
+            b: [
+              'Nullam neque, ligula ac, id blandit.',
+              'Sagittis tincidunt torquent, tempor nunc amet.',
+              'At rhoncus id.',
+            ],
+            move_details: {changed: false, range: {start: 4, end: 6}},
+          },
+          {
+            ab: ['Sem nascetur, erat ut, non in.'],
+          },
+          {
+            a: [
+              'Nullam neque, ligula ac, id blandit.',
+              'Sagittis tincidunt torquent, tempor nunc amet.',
+              'At rhoncus id.',
+            ],
+            move_details: {changed: false, range: {start: 2, end: 4}},
+          },
+          {
+            ab: ['Arcu eget, rhoncus amet cursus, ipsum elementum.'],
+          },
+        ],
+      };
+      await promise;
+    });
+
+    test('renders moveControls with simple descriptions', () => {
+      const [movedIn, movedOut] = [
+        ...queryAll(diffElement, '.dueToMove .moveControls'),
+      ];
+      assert.equal(movedIn.textContent, 'Moved from lines 4 - 6');
+      assert.equal(movedOut.textContent, 'Moved to lines 2 - 4');
+    });
+
+    test('startLineAnchor of movedIn chunk fires events', async () => {
+      const [movedIn] = [...queryAll(diffElement, '.dueToMove .moveControls')];
+      const [startLineAnchor] = movedIn.querySelectorAll('a');
+
+      const promise = mockPromise();
+      const onMovedLinkClicked = (e: CustomEvent) => {
+        assert.deepEqual(e.detail, {lineNum: 4, side: Side.LEFT});
+        promise.resolve();
+      };
+      assert.equal(startLineAnchor.textContent, '4');
+      startLineAnchor.addEventListener(
+        'moved-link-clicked',
+        onMovedLinkClicked
+      );
+      startLineAnchor.click();
+      await promise;
+    });
+
+    test('endLineAnchor of movedOut fires events', async () => {
+      const [, movedOut] = [
+        ...queryAll(diffElement, '.dueToMove .moveControls'),
+      ];
+      const [, endLineAnchor] = movedOut.querySelectorAll('a');
+
+      const promise = mockPromise();
+      const onMovedLinkClicked = (e: CustomEvent) => {
+        assert.deepEqual(e.detail, {lineNum: 4, side: Side.RIGHT});
+        promise.resolve();
+      };
+      assert.equal(endLineAnchor.textContent, '4');
+      endLineAnchor.addEventListener('moved-link-clicked', onMovedLinkClicked);
+      endLineAnchor.click();
+      await promise;
+    });
+  });
+
+  test('initialLineNumber not provided', async () => {
+    let scrollBehaviorDuringMove;
+    const moveToNumStub = sinon.stub(cursor, 'moveToLineNumber');
+    const moveToChunkStub = sinon
+      .stub(cursor, 'moveToFirstChunk')
+      .callsFake(() => {
+        scrollBehaviorDuringMove = cursor.cursorManager.scrollMode;
+      });
+
+    diffElement._diffChanged(createDiff());
+    await waitForEventOnce(diffElement, 'render');
+    cursor.reInitCursor();
+    assert.isFalse(moveToNumStub.called);
+    assert.isTrue(moveToChunkStub.called);
+    assert.equal(scrollBehaviorDuringMove, 'never');
+    assert.equal(cursor.cursorManager.scrollMode, 'keep-visible');
+  });
+
+  test('initialLineNumber provided', async () => {
+    let scrollBehaviorDuringMove;
+    const moveToNumStub = sinon
+      .stub(cursor, 'moveToLineNumber')
+      .callsFake(() => {
+        scrollBehaviorDuringMove = cursor.cursorManager.scrollMode;
+      });
+    const moveToChunkStub = sinon.stub(cursor, 'moveToFirstChunk');
+    cursor.initialLineNumber = 10;
+    cursor.side = Side.RIGHT;
+
+    diffElement._diffChanged(createDiff());
+    await waitForEventOnce(diffElement, 'render');
+    cursor.reInitCursor();
+    assert.isFalse(moveToChunkStub.called);
+    assert.isTrue(moveToNumStub.called);
+    assert.equal(moveToNumStub.lastCall.args[0], 10);
+    assert.equal(moveToNumStub.lastCall.args[1], Side.RIGHT);
+    assert.equal(scrollBehaviorDuringMove, 'keep-visible');
+    assert.equal(cursor.cursorManager.scrollMode, 'keep-visible');
+  });
+
+  test('getTargetDiffElement', () => {
+    cursor.initialLineNumber = 1;
+    assert.isTrue(!!cursor.diffRow);
+    assert.equal(cursor.getTargetDiffElement(), diffElement);
+  });
+
+  suite('createCommentInPlace', () => {
+    setup(() => {
+      diffElement.loggedIn = true;
+    });
+
+    test('adds new draft for selected line on the left', async () => {
+      cursor.moveToLineNumber(2, Side.LEFT);
+      const promise = mockPromise();
+      diffElement.addEventListener('create-comment', e => {
+        const {lineNum, range, side} = e.detail;
+        assert.equal(lineNum, 2);
+        assert.equal(range, undefined);
+        assert.equal(side, Side.LEFT);
+        promise.resolve();
+      });
+      cursor.createCommentInPlace();
+      await promise;
+    });
+
+    test('adds draft for selected line on the right', async () => {
+      cursor.moveToLineNumber(4, Side.RIGHT);
+      const promise = mockPromise();
+      diffElement.addEventListener('create-comment', e => {
+        const {lineNum, range, side} = e.detail;
+        assert.equal(lineNum, 4);
+        assert.equal(range, undefined);
+        assert.equal(side, Side.RIGHT);
+        promise.resolve();
+      });
+      cursor.createCommentInPlace();
+      await promise;
+    });
+
+    test('creates comment for range if selected', async () => {
+      const someRange = {
+        start_line: 2,
+        start_character: 3,
+        end_line: 6,
+        end_character: 1,
+      };
+      diffElement.highlights.selectedRange = {
+        side: Side.RIGHT,
+        range: someRange,
+      };
+      const promise = mockPromise();
+      diffElement.addEventListener('create-comment', e => {
+        const {lineNum, range, side} = e.detail;
+        assert.equal(lineNum, 6);
+        assert.equal(range, someRange);
+        assert.equal(side, Side.RIGHT);
+        promise.resolve();
+      });
+      cursor.createCommentInPlace();
+      await promise;
+    });
+
+    test('ignores call if nothing is selected', () => {
+      const createRangeCommentStub = sinon.stub(
+        diffElement,
+        'createRangeComment'
+      );
+      const addDraftAtLineStub = sinon.stub(diffElement, 'addDraftAtLine');
+      cursor.diffRow = undefined;
+      cursor.createCommentInPlace();
+      assert.isFalse(createRangeCommentStub.called);
+      assert.isFalse(addDraftAtLineStub.called);
+    });
+  });
+
+  test('getAddress', () => {
+    // It should initialize to the first chunk: line 5 of the revision.
+    assert.deepEqual(cursor.getAddress(), {leftSide: false, number: 5});
+
+    // Revision line 4 is up.
+    cursor.moveUp();
+    assert.deepEqual(cursor.getAddress(), {leftSide: false, number: 4});
+
+    // Base line 4 is left.
+    cursor.moveLeft();
+    assert.deepEqual(cursor.getAddress(), {leftSide: true, number: 4});
+
+    // Moving to the next chunk takes it back to the start.
+    cursor.moveToNextChunk();
+    assert.deepEqual(cursor.getAddress(), {leftSide: false, number: 5});
+
+    // The following chunk is a removal starting on line 10 of the base.
+    cursor.moveToNextChunk();
+    assert.deepEqual(cursor.getAddress(), {leftSide: true, number: 10});
+
+    // Should be null if there is no selection.
+    cursor.cursorManager.unsetCursor();
+    assert.isNotOk(cursor.getAddress());
+  });
+
+  test('_findRowByNumberAndFile', () => {
+    // Get the first ab row after the first chunk.
+    const rows = [...queryAll<HTMLTableRowElement>(diffElement, 'tr')];
+    const row = rows[9];
+    assert.ok(row);
+
+    // It should be line 8 on the right, but line 5 on the left.
+    assert.equal(cursor._findRowByNumberAndFile(8, Side.RIGHT), row);
+    assert.equal(cursor._findRowByNumberAndFile(5, Side.LEFT), row);
+  });
+
+  test('expand context updates stops', async () => {
+    const spy = sinon.spy(cursor, '_updateStops');
+    const controls = queryAndAssert(diffElement, 'gr-context-controls');
+    const showContext = queryAndAssert<HTMLElement>(controls, '.showContext');
+    showContext.click();
+    await waitForEventOnce(diffElement, 'render');
+    assert.isTrue(spy.called);
+  });
+
+  test('updates stops when loading changes', () => {
+    const spy = sinon.spy(cursor, '_updateStops');
+    diffElement.dispatchEvent(new Event('loading-changed'));
+    assert.isTrue(spy.called);
+  });
+
+  suite('multi diff', () => {
+    let diffElements: GrDiff[];
+
+    setup(async () => {
+      diffElements = [
+        await fixture(html`<gr-diff></gr-diff>`),
+        await fixture(html`<gr-diff></gr-diff>`),
+        await fixture(html`<gr-diff></gr-diff>`),
+      ];
+      cursor = new GrDiffCursor();
+
+      // Register the diff with the cursor.
+      cursor.replaceDiffs(diffElements);
+
+      for (const el of diffElements) {
+        el.prefs = createDefaultDiffPrefs();
+      }
+    });
+
+    function getTargetDiffIndex() {
+      // Mocha has a bug where when `assert.equals` fails, it will try to
+      // JSON.stringify the operands, which fails when they are cyclic structures
+      // like GrDiffElement. The failure is difficult to attribute to a specific
+      // assertion because of the async nature assertion errors are handled and
+      // can cause the test simply timing out, causing a lot of debugging headache.
+      // Working with indices circumvents the problem.
+      const target = cursor.getTargetDiffElement();
+      assertIsDefined(target);
+      return diffElements.indexOf(target);
+    }
+
+    test('do not skip loading diffs', async () => {
+      diffElements[0].diff = createDiff();
+      diffElements[2].diff = createDiff();
+      await waitForEventOnce(diffElements[0], 'render');
+      await waitForEventOnce(diffElements[2], 'render');
+
+      const lastLine = diffElements[0].diff.meta_b?.lines;
+      assertIsDefined(lastLine);
+
+      // Goto second last line of the first diff
+      cursor.moveToLineNumber(lastLine - 1, Side.RIGHT);
+      assert.equal(
+        cursor.getTargetLineElement()!.textContent,
+        `${lastLine - 1}`
+      );
+
+      // Can move down until we reach the loading file
+      cursor.moveDown();
+      assert.equal(getTargetDiffIndex(), 0);
+      assert.equal(
+        cursor.getTargetLineElement()!.textContent,
+        lastLine.toString()
+      );
+
+      // Cannot move down while still loading the diff we would switch to
+      cursor.moveDown();
+      assert.equal(getTargetDiffIndex(), 0);
+      assert.equal(
+        cursor.getTargetLineElement()!.textContent,
+        lastLine.toString()
+      );
+
+      // Diff 1 finishing to load
+      diffElements[1].diff = createDiff();
+      await waitForEventOnce(diffElements[1], 'render');
+
+      // Now we can go down
+      cursor.moveDown();
+      assert.equal(getTargetDiffIndex(), 1);
+      assert.equal(cursor.getTargetLineElement()!.textContent, 'File');
+    });
+  });
+});
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor.ts b/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor.ts
index 4a1e5be..616a36f 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor.ts
@@ -15,7 +15,7 @@
   GrDiffGroupType,
   hideInContextControl,
 } from '../gr-diff/gr-diff-group';
-import {CancelablePromise, util} from '../../../scripts/util';
+import {CancelablePromise, makeCancelable} from '../../../scripts/util';
 import {DiffContent} from '../../../types/diff';
 import {Side} from '../../../constants/constants';
 import {debounce, DelayedTask} from '../../../utils/async-util';
@@ -138,7 +138,7 @@
       return Promise.resolve();
     }
 
-    this.processPromise = util.makeCancelable(
+    this.processPromise = makeCancelable(
       new Promise(resolve => {
         const state = {
           lineNums: {left: 0, right: 0},
@@ -614,6 +614,7 @@
     const result = [];
     let lastChunkEndOffset = 0;
     for (const {offset, keyLocation} of chunkEnds) {
+      if (lastChunkEndOffset === offset) continue;
       result.push({
         lines: lines.slice(lastChunkEndOffset, offset),
         keyLocation,
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group.ts
index b8262e0..e015d49 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group.ts
@@ -17,6 +17,7 @@
 import {BLANK_LINE, GrDiffLine, GrDiffLineType} from './gr-diff-line';
 import {LineRange, Side} from '../../../api/diff';
 import {LineNumber} from './gr-diff-line';
+import {assertIsDefined, check} from '../../../utils/common-util';
 
 export enum GrDiffGroupType {
   /** Unchanged context. */
@@ -275,7 +276,9 @@
             },
           };
         } else {
-          for (const line of options.lines ?? []) {
+          assertIsDefined(options.lines);
+          check(options.lines.length > 0, 'diff group must have lines');
+          for (const line of options.lines) {
             this.addLine(line);
           }
         }
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group_test.js b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group_test.ts
similarity index 60%
rename from polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group_test.js
rename to polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group_test.ts
index 321086c..bbf03ae 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group_test.js
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group_test.ts
@@ -1,32 +1,25 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma.js';
-import {GrDiffLine, GrDiffLineType, BLANK_LINE} from './gr-diff-line.js';
-import {GrDiffGroup, GrDiffGroupType, hideInContextControl} from './gr-diff-group.js';
+import '../../../test/common-test-setup-karma';
+import {GrDiffLine, GrDiffLineType, BLANK_LINE} from './gr-diff-line';
+import {
+  GrDiffGroup,
+  GrDiffGroupType,
+  hideInContextControl,
+} from './gr-diff-group';
 
 suite('gr-diff-group tests', () => {
   test('delta line pairs', () => {
     const l1 = new GrDiffLine(GrDiffLineType.ADD, 0, 128);
     const l2 = new GrDiffLine(GrDiffLineType.ADD, 0, 129);
     const l3 = new GrDiffLine(GrDiffLineType.REMOVE, 64, 0);
-    let group = new GrDiffGroup({type: GrDiffGroupType.DELTA, lines: [
-      l1, l2, l3,
-    ]});
+    let group = new GrDiffGroup({
+      type: GrDiffGroupType.DELTA,
+      lines: [l1, l2, l3],
+    });
     assert.deepEqual(group.lines, [l1, l2, l3]);
     assert.deepEqual(group.adds, [l1, l2]);
     assert.deepEqual(group.removes, [l3]);
@@ -53,13 +46,25 @@
     ]);
   });
 
+  test('group must have lines', () => {
+    try {
+      new GrDiffGroup({type: GrDiffGroupType.BOTH});
+    } catch (e) {
+      // expected
+      return;
+    }
+    assert.fail('a standard diff group cannot be empty');
+  });
+
   test('group/header line pairs', () => {
     const l1 = new GrDiffLine(GrDiffLineType.BOTH, 64, 128);
     const l2 = new GrDiffLine(GrDiffLineType.BOTH, 65, 129);
     const l3 = new GrDiffLine(GrDiffLineType.BOTH, 66, 130);
 
     const group = new GrDiffGroup({
-      type: GrDiffGroupType.BOTH, lines: [l1, l2, l3]});
+      type: GrDiffGroupType.BOTH,
+      lines: [l1, l2, l3],
+    });
 
     assert.deepEqual(group.lines, [l1, l2, l3]);
     assert.deepEqual(group.adds, []);
@@ -83,34 +88,44 @@
     const l2 = new GrDiffLine(GrDiffLineType.REMOVE);
     const l3 = new GrDiffLine(GrDiffLineType.BOTH);
 
-    assert.throws(() =>
-      new GrDiffGroup({type: GrDiffGroupType.BOTH, lines: [l1, l2, l3]}));
+    assert.throws(
+      () => new GrDiffGroup({type: GrDiffGroupType.BOTH, lines: [l1, l2, l3]})
+    );
   });
 
   suite('hideInContextControl', () => {
-    let groups;
+    let groups: GrDiffGroup[];
     setup(() => {
       groups = [
-        new GrDiffGroup({type: GrDiffGroupType.BOTH, lines: [
-          new GrDiffLine(GrDiffLineType.BOTH, 5, 7),
-          new GrDiffLine(GrDiffLineType.BOTH, 6, 8),
-          new GrDiffLine(GrDiffLineType.BOTH, 7, 9),
-        ]}),
-        new GrDiffGroup({type: GrDiffGroupType.DELTA, lines: [
-          new GrDiffLine(GrDiffLineType.REMOVE, 8),
-          new GrDiffLine(GrDiffLineType.ADD, 0, 10),
-          new GrDiffLine(GrDiffLineType.REMOVE, 9),
-          new GrDiffLine(GrDiffLineType.ADD, 0, 11),
-          new GrDiffLine(GrDiffLineType.REMOVE, 10),
-          new GrDiffLine(GrDiffLineType.ADD, 0, 12),
-          new GrDiffLine(GrDiffLineType.REMOVE, 11),
-          new GrDiffLine(GrDiffLineType.ADD, 0, 13),
-        ]}),
-        new GrDiffGroup({type: GrDiffGroupType.BOTH, lines: [
-          new GrDiffLine(GrDiffLineType.BOTH, 12, 14),
-          new GrDiffLine(GrDiffLineType.BOTH, 13, 15),
-          new GrDiffLine(GrDiffLineType.BOTH, 14, 16),
-        ]}),
+        new GrDiffGroup({
+          type: GrDiffGroupType.BOTH,
+          lines: [
+            new GrDiffLine(GrDiffLineType.BOTH, 5, 7),
+            new GrDiffLine(GrDiffLineType.BOTH, 6, 8),
+            new GrDiffLine(GrDiffLineType.BOTH, 7, 9),
+          ],
+        }),
+        new GrDiffGroup({
+          type: GrDiffGroupType.DELTA,
+          lines: [
+            new GrDiffLine(GrDiffLineType.REMOVE, 8),
+            new GrDiffLine(GrDiffLineType.ADD, 0, 10),
+            new GrDiffLine(GrDiffLineType.REMOVE, 9),
+            new GrDiffLine(GrDiffLineType.ADD, 0, 11),
+            new GrDiffLine(GrDiffLineType.REMOVE, 10),
+            new GrDiffLine(GrDiffLineType.ADD, 0, 12),
+            new GrDiffLine(GrDiffLineType.REMOVE, 11),
+            new GrDiffLine(GrDiffLineType.ADD, 0, 13),
+          ],
+        }),
+        new GrDiffGroup({
+          type: GrDiffGroupType.BOTH,
+          lines: [
+            new GrDiffLine(GrDiffLineType.BOTH, 12, 14),
+            new GrDiffLine(GrDiffLineType.BOTH, 13, 15),
+            new GrDiffLine(GrDiffLineType.BOTH, 14, 16),
+          ],
+        }),
       ];
     });
 
@@ -140,21 +155,25 @@
       assert.equal(collapsedGroups[2].contextGroups.length, 2);
 
       assert.equal(
-          collapsedGroups[2].contextGroups[0].type,
-          GrDiffGroupType.DELTA);
+        collapsedGroups[2].contextGroups[0].type,
+        GrDiffGroupType.DELTA
+      );
       assert.deepEqual(
-          collapsedGroups[2].contextGroups[0].adds,
-          groups[1].adds.slice(1));
+        collapsedGroups[2].contextGroups[0].adds,
+        groups[1].adds.slice(1)
+      );
       assert.deepEqual(
-          collapsedGroups[2].contextGroups[0].removes,
-          groups[1].removes.slice(1));
+        collapsedGroups[2].contextGroups[0].removes,
+        groups[1].removes.slice(1)
+      );
 
       assert.equal(
-          collapsedGroups[2].contextGroups[1].type,
-          GrDiffGroupType.BOTH);
-      assert.deepEqual(
-          collapsedGroups[2].contextGroups[1].lines,
-          [groups[2].lines[0]]);
+        collapsedGroups[2].contextGroups[1].type,
+        GrDiffGroupType.BOTH
+      );
+      assert.deepEqual(collapsedGroups[2].contextGroups[1].lines, [
+        groups[2].lines[0],
+      ]);
 
       assert.equal(collapsedGroups[3].type, GrDiffGroupType.BOTH);
       assert.deepEqual(collapsedGroups[3].lines, groups[2].lines.slice(1));
@@ -166,19 +185,26 @@
           type: GrDiffGroupType.BOTH,
           skip: 60,
           offsetLeft: 8,
-          offsetRight: 10});
+          offsetRight: 10,
+        });
         groups = [
-          new GrDiffGroup({type: GrDiffGroupType.BOTH, lines: [
-            new GrDiffLine(GrDiffLineType.BOTH, 5, 7),
-            new GrDiffLine(GrDiffLineType.BOTH, 6, 8),
-            new GrDiffLine(GrDiffLineType.BOTH, 7, 9),
-          ]}),
+          new GrDiffGroup({
+            type: GrDiffGroupType.BOTH,
+            lines: [
+              new GrDiffLine(GrDiffLineType.BOTH, 5, 7),
+              new GrDiffLine(GrDiffLineType.BOTH, 6, 8),
+              new GrDiffLine(GrDiffLineType.BOTH, 7, 9),
+            ],
+          }),
           skipGroup,
-          new GrDiffGroup({type: GrDiffGroupType.BOTH, lines: [
-            new GrDiffLine(GrDiffLineType.BOTH, 68, 70),
-            new GrDiffLine(GrDiffLineType.BOTH, 69, 71),
-            new GrDiffLine(GrDiffLineType.BOTH, 70, 72),
-          ]}),
+          new GrDiffGroup({
+            type: GrDiffGroupType.BOTH,
+            lines: [
+              new GrDiffLine(GrDiffLineType.BOTH, 68, 70),
+              new GrDiffLine(GrDiffLineType.BOTH, 69, 71),
+              new GrDiffLine(GrDiffLineType.BOTH, 70, 72),
+            ],
+          }),
         ];
       });
 
@@ -189,13 +215,11 @@
     });
 
     test('groups unchanged if the hidden range is empty', () => {
-      assert.deepEqual(
-          hideInContextControl(groups, 0, 0), groups);
+      assert.deepEqual(hideInContextControl(groups, 0, 0), groups);
     });
 
     test('groups unchanged if there is only 1 line to hide', () => {
-      assert.deepEqual(
-          hideInContextControl(groups, 3, 4), groups);
+      assert.deepEqual(hideInContextControl(groups, 3, 4), groups);
     });
   });
 
@@ -206,7 +230,7 @@
         lines.push(new GrDiffLine(GrDiffLineType.ADD));
       }
       const group = new GrDiffGroup({type: GrDiffGroupType.DELTA, lines});
-      assert.isTrue(group.isTotal(group));
+      assert.isTrue(group.isTotal());
     });
 
     test('is total for remove', () => {
@@ -215,12 +239,7 @@
         lines.push(new GrDiffLine(GrDiffLineType.REMOVE));
       }
       const group = new GrDiffGroup({type: GrDiffGroupType.DELTA, lines});
-      assert.isTrue(group.isTotal(group));
-    });
-
-    test('not total for empty', () => {
-      const group = new GrDiffGroup({type: GrDiffGroupType.BOTH});
-      assert.isFalse(group.isTotal(group));
+      assert.isTrue(group.isTotal());
     });
 
     test('not total for non-delta', () => {
@@ -229,8 +248,7 @@
         lines.push(new GrDiffLine(GrDiffLineType.BOTH));
       }
       const group = new GrDiffGroup({type: GrDiffGroupType.DELTA, lines});
-      assert.isFalse(group.isTotal(group));
+      assert.isFalse(group.isTotal());
     });
   });
 });
-
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts
index 34c2a33..27952d3 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts
@@ -43,11 +43,7 @@
 import {getHiddenScroll} from '../../../scripts/hiddenscroll';
 import {customElement, observe, property} from '@polymer/decorators';
 import {BlameInfo, CommentRange, ImageInfo} from '../../../types/common';
-import {
-  DiffInfo,
-  DiffPreferencesInfo,
-  DiffPreferencesInfoKey,
-} from '../../../types/diff';
+import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
 import {GrDiffHighlight} from '../gr-diff-highlight/gr-diff-highlight';
 import {
   GrDiffBuilderElement,
@@ -81,6 +77,7 @@
 import {assertIsDefined} from '../../../utils/common-util';
 import {debounce, DelayedTask} from '../../../utils/async-util';
 import {GrDiffSelection} from '../gr-diff-selection/gr-diff-selection';
+import {deepEqual} from '../../../utils/deep-util';
 
 const NO_NEWLINE_LEFT = 'No newline at end of left file.';
 const NO_NEWLINE_RIGHT = 'No newline at end of right file.';
@@ -100,7 +97,6 @@
 
 export interface GrDiff {
   $: {
-    diffBuilder: GrDiffBuilderElement;
     diffTable: HTMLTableElement;
   };
 }
@@ -179,7 +175,7 @@
   @property({type: Object})
   highlightRange?: CommentRange;
 
-  @property({type: Array})
+  @property({type: Array, observer: '_coverageRangesObserver'})
   coverageRanges: CoverageRange[] = [];
 
   @property({type: Boolean, observer: '_lineWrappingObserver'})
@@ -248,9 +244,6 @@
   @property({type: Object, observer: '_blameChanged'})
   blame: BlameInfo[] | null = null;
 
-  @property({type: Number})
-  parentIndex?: number;
-
   @property({type: Boolean})
   showNewlineWarningLeft = false;
 
@@ -292,11 +285,16 @@
   @property({type: Boolean})
   isAttached = false;
 
-  private renderDiffTableTask?: DelayedTask;
+  // visible for testing
+  renderDiffTableTask?: DelayedTask;
 
   private diffSelection = new GrDiffSelection();
 
-  private highlights = new GrDiffHighlight();
+  // visible for testing
+  highlights = new GrDiffHighlight();
+
+  // visible for testing
+  diffBuilder = new GrDiffBuilderElement();
 
   constructor() {
     super();
@@ -321,11 +319,12 @@
     this._unobserveNodes();
     this.diffSelection.cleanup();
     this.highlights.cleanup();
+    this.diffBuilder.cancel();
     super.disconnectedCallback();
   }
 
   getLineNumEls(side: Side): HTMLElement[] {
-    return this.$.diffBuilder.getLineNumEls(side);
+    return this.diffBuilder.getLineNumEls(side);
   }
 
   showNoChangeMessage(
@@ -426,19 +425,25 @@
           cr.side === removedCommentRange.side &&
           rangesEqual(cr.range, removedCommentRange.range)
       );
-      this.splice('_commentRanges', i, 1);
+      this._commentRanges.splice(i, 1);
     }
 
-    if (addedCommentRanges && addedCommentRanges.length) {
-      this.push('_commentRanges', ...addedCommentRanges);
+    if (addedCommentRanges?.length) {
+      this._commentRanges.push(...addedCommentRanges);
     }
     if (this.highlightRange) {
-      this.push('_commentRanges', {
+      this._commentRanges.push({
         side: Side.RIGHT,
         range: this.highlightRange,
         rootId: '',
       });
     }
+
+    this.diffBuilder.updateCommentRanges(this._commentRanges);
+  }
+
+  _coverageRangesObserver() {
+    this.diffBuilder.updateCoverageRanges(this.coverageRanges);
   }
 
   /**
@@ -483,7 +488,7 @@
 
   /** Cancel any remaining diff builder rendering work. */
   cancel() {
-    this.$.diffBuilder.cancel();
+    this.diffBuilder.cancel();
     this.renderDiffTableTask?.cancel();
   }
 
@@ -492,7 +497,7 @@
 
     // Get rendered stops.
     const stops: Array<HTMLElement | AbortStop> =
-      this.$.diffBuilder.getLineNumberRows();
+      this.diffBuilder.getLineNumberRows();
 
     // If we are still loading this diff, abort after the rendered stops to
     // avoid skipping over to e.g. the next file.
@@ -512,7 +517,7 @@
 
   _blameChanged(newValue?: BlameInfo[] | null) {
     if (newValue === undefined) return;
-    this.$.diffBuilder.setBlame(newValue);
+    this.diffBuilder.setBlame(newValue);
     if (newValue) {
       this.classList.add('showBlame');
     } else {
@@ -534,7 +539,7 @@
     return classes.join(' ');
   }
 
-  _handleTap(e: CustomEvent) {
+  _handleTap(e: Event) {
     const el = (dom(e) as EventApi).localTarget as Element;
 
     if (
@@ -603,7 +608,7 @@
 
   _createCommentForSelection(side: Side, range: CommentRange) {
     const lineNum = range.end_line;
-    const lineEl = this.$.diffBuilder.getLineElByNumber(lineNum, side);
+    const lineEl = this.diffBuilder.getLineElByNumber(lineNum, side);
     if (lineEl) {
       this._createComment(lineEl, lineNum, side, range);
     }
@@ -621,7 +626,7 @@
     side?: Side,
     range?: CommentRange
   ) {
-    const contentEl = this.$.diffBuilder.getContentTdByLineEl(lineEl);
+    const contentEl = this.diffBuilder.getContentTdByLineEl(lineEl);
     if (!contentEl) throw new Error('content el not found for line el');
     side = side ?? this._getCommentSideByLineAndContent(lineEl, contentEl);
     assertIsDefined(this.path, 'path');
@@ -663,28 +668,11 @@
   }
 
   _prefsObserver(newPrefs: DiffPreferencesInfo, oldPrefs: DiffPreferencesInfo) {
-    if (!this._prefsEqual(newPrefs, oldPrefs)) {
+    if (!deepEqual(newPrefs, oldPrefs)) {
       this._prefsChanged(newPrefs);
     }
   }
 
-  _prefsEqual(prefs1: DiffPreferencesInfo, prefs2: DiffPreferencesInfo) {
-    if (prefs1 === prefs2) {
-      return true;
-    }
-    if (!prefs1 || !prefs2) {
-      return false;
-    }
-    // Scan the preference objects one level deep to see if they differ.
-    const keys1 = Object.keys(prefs1) as DiffPreferencesInfoKey[];
-    const keys2 = Object.keys(prefs2) as DiffPreferencesInfoKey[];
-    return (
-      keys1.length === keys2.length &&
-      keys1.every(key => prefs1[key] === prefs2[key]) &&
-      keys2.every(key => prefs1[key] === prefs2[key])
-    );
-  }
-
   _pathObserver() {
     // Call _prefsChanged(), because line-limit style value depends on path.
     this._prefsChanged(this.prefs);
@@ -699,7 +687,7 @@
     if (!this.lineOfInterest) return;
     const lineNum = this.lineOfInterest.lineNum;
     if (typeof lineNum !== 'number') return;
-    this.$.diffBuilder.unhideLine(lineNum, this.lineOfInterest.side);
+    this.diffBuilder.unhideLine(lineNum, this.lineOfInterest.side);
   }
 
   _cleanup() {
@@ -808,7 +796,7 @@
     if (this.prefs) {
       this._updatePreferenceStyles(this.prefs, renderPrefs);
     }
-    this.$.diffBuilder.updateRenderPrefs(renderPrefs);
+    this.diffBuilder.updateRenderPrefs(renderPrefs);
   }
 
   _diffChanged(newValue?: DiffInfo) {
@@ -820,7 +808,7 @@
     }
     if (this.diff) {
       this.diffSelection.init(this.diff, this.$.diffTable);
-      this.highlights.init(this.$.diffTable, this.$.diffBuilder);
+      this.highlights.init(this.$.diffTable, this.diffBuilder);
     }
   }
 
@@ -866,11 +854,24 @@
     this._showWarning = false;
 
     const keyLocations = this._computeKeyLocations();
-    this.$.diffBuilder.prefs = this._getBypassPrefs(this.prefs);
-    this.$.diffBuilder.renderPrefs = this.renderPrefs;
-    this.$.diffBuilder.render(keyLocations).then(() => {
-      fireEvent(this, 'render');
-    });
+
+    // TODO: Setting tons of public properties like this is obviously a code
+    // smell. We are planning to introduce a diff model for managing all this
+    // data. Then diff builder will only need access to that model.
+    this.diffBuilder.prefs = this._getBypassPrefs(this.prefs);
+    this.diffBuilder.renderPrefs = this.renderPrefs;
+    this.diffBuilder.diff = this.diff;
+    this.diffBuilder.path = this.path;
+    this.diffBuilder.viewMode = this.viewMode;
+    this.diffBuilder.layers = this.layers ?? [];
+    this.diffBuilder.isImageDiff = this.isImageDiff;
+    this.diffBuilder.baseImage = this.baseImage ?? null;
+    this.diffBuilder.revisionImage = this.revisionImage ?? null;
+    this.diffBuilder.useNewImageDiffUi = this.useNewImageDiffUi;
+    this.diffBuilder.diffElement = this.$.diffTable;
+    this.diffBuilder.updateCommentRanges(this._commentRanges);
+    this.diffBuilder.updateCoverageRanges(this.coverageRanges);
+    this.diffBuilder.render(keyLocations);
   }
 
   _handleRenderContent() {
@@ -879,6 +880,9 @@
     );
     this._setLoading(false);
     this._unobserveIncrementalNodes();
+    // We are just converting 'render-content' into 'render' here. Maybe we
+    // should retire the 'render' event in favor of 'render-content'?
+    fireEvent(this, 'render');
     this._incrementalNodeObserver = (
       dom(this) as PolymerDomWrapper
     ).observeNodes(info => {
@@ -894,10 +898,7 @@
         const commentSide = getSide(threadEl);
         const range = getRange(threadEl);
         if (!commentSide) continue;
-        const lineEl = this.$.diffBuilder.getLineElByNumber(
-          lineNum,
-          commentSide
-        );
+        const lineEl = this.diffBuilder.getLineElByNumber(lineNum, commentSide);
         // When the line the comment refers to does not exist, log an error
         // but don't crash. This can happen e.g. if the API does not fully
         // validate e.g. (robot) comments
@@ -910,7 +911,7 @@
           );
           continue;
         }
-        const contentEl = this.$.diffBuilder.getContentTdByLineEl(lineEl);
+        const contentEl = this.diffBuilder.getContentTdByLineEl(lineEl);
         if (!contentEl) continue;
         if (lineNum === 'LOST' && !contentEl.hasChildNodes()) {
           contentEl.appendChild(this._portedCommentsWithoutRangeMessage());
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_html.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_html.ts
index 6d36b89..40d4e7f 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_html.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_html.ts
@@ -698,36 +698,22 @@
     class$="[[_computeContainerClass(loggedIn, viewMode, displayLine)]]"
     on-click="_handleTap"
   >
-    <gr-diff-builder
-      id="diffBuilder"
-      comment-ranges="[[_commentRanges]]"
-      coverage-ranges="[[coverageRanges]]"
-      diff="[[diff]]"
-      path="[[path]]"
-      view-mode="[[viewMode]]"
-      is-image-diff="[[isImageDiff]]"
-      base-image="[[baseImage]]"
-      layers="[[layers]]"
-      revision-image="[[revisionImage]]"
-      use-new-image-diff-ui="[[useNewImageDiffUi]]"
-    >
-      <table
-        id="diffTable"
-        class$="[[_diffTableClass]]"
-        role="presentation"
-        contenteditable$="[[isContentEditable]]"
-      ></table>
+    <table
+      id="diffTable"
+      class$="[[_diffTableClass]]"
+      role="presentation"
+      contenteditable$="[[isContentEditable]]"
+    ></table>
 
-      <template
-        is="dom-if"
-        if="[[showNoChangeMessage(_loading, prefs, _diffLength, diff)]]"
-      >
-        <div class="whitespace-change-only-message">
-          This file only contains whitespace changes. Modify the whitespace
-          setting to see the changes.
-        </div>
-      </template>
-    </gr-diff-builder>
+    <template
+      is="dom-if"
+      if="[[showNoChangeMessage(_loading, prefs, _diffLength, diff)]]"
+    >
+      <div class="whitespace-change-only-message">
+        This file only contains whitespace changes. Modify the whitespace
+        setting to see the changes.
+      </div>
+    </template>
   </div>
   <div class$="[[_computeNewlineWarningClass(_newlineWarning, _loading)]]">
     [[_newlineWarning]]
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_test.js b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_test.js
deleted file mode 100644
index c8d8a2f..0000000
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_test.js
+++ /dev/null
@@ -1,1239 +0,0 @@
-/**
- * @license
- * Copyright (C) 2015 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../../test/common-test-setup-karma.js';
-import {createDiff} from '../../../test/test-data-generators.js';
-import './gr-diff.js';
-import {GrDiffBuilderImage} from '../gr-diff-builder/gr-diff-builder-image.js';
-import {getComputedStyleValue} from '../../../utils/dom-util.js';
-import {_setHiddenScroll} from '../../../scripts/hiddenscroll.js';
-import {runA11yAudit} from '../../../test/a11y-test-utils.js';
-import '@polymer/paper-button/paper-button.js';
-import {Side} from '../../../api/diff.js';
-import {mockPromise, stubRestApi} from '../../../test/test-utils.js';
-import {AbortStop} from '../../../api/core.js';
-
-const basicFixture = fixtureFromElement('gr-diff');
-
-suite('gr-diff a11y test', () => {
-  test('audit', async () => {
-    await runA11yAudit(basicFixture);
-  });
-});
-
-suite('gr-diff tests', () => {
-  let element;
-
-  const MINIMAL_PREFS = {tab_size: 2, line_length: 80, font_size: 12};
-
-  setup(() => {
-
-  });
-
-  suite('selectionchange event handling', () => {
-    const emulateSelection = function() {
-      document.dispatchEvent(new CustomEvent('selectionchange'));
-    };
-
-    setup(() => {
-      element = basicFixture.instantiate();
-      sinon.stub(element.highlights, 'handleSelectionChange');
-    });
-
-    test('enabled if logged in', async () => {
-      element.loggedIn = true;
-      emulateSelection();
-      await flush();
-      assert.isTrue(element.highlights.handleSelectionChange.called);
-    });
-
-    test('ignored if logged out', async () => {
-      element.loggedIn = false;
-      emulateSelection();
-      await flush();
-      assert.isFalse(element.highlights.handleSelectionChange.called);
-    });
-  });
-
-  test('cancel', () => {
-    element = basicFixture.instantiate();
-    const cancelStub = sinon.stub(element.$.diffBuilder, 'cancel');
-    element.cancel();
-    assert.isTrue(cancelStub.calledOnce);
-  });
-
-  test('line limit with line_wrapping', () => {
-    element = basicFixture.instantiate();
-    element.prefs = {...MINIMAL_PREFS, line_wrapping: true};
-    flush();
-    assert.equal(getComputedStyleValue('--line-limit-marker', element), '80ch');
-  });
-
-  test('line limit without line_wrapping', () => {
-    element = basicFixture.instantiate();
-    element.prefs = {...MINIMAL_PREFS, line_wrapping: false};
-    flush();
-    assert.equal(getComputedStyleValue('--line-limit-marker', element), '-1px');
-  });
-  suite('FULL_RESPONSIVE mode', () => {
-    setup(() => {
-      element = basicFixture.instantiate();
-      element.prefs = {...MINIMAL_PREFS};
-      element.renderPrefs = {responsive_mode: 'FULL_RESPONSIVE'};
-    });
-
-    test('line limit is based on line_length', () => {
-      element.prefs = {...element.prefs, line_length: 100};
-      flush();
-      assert.equal(getComputedStyleValue('--line-limit-marker', element),
-          '100ch');
-    });
-
-    test('content-width should not be defined', () => {
-      flush();
-      assert.equal(getComputedStyleValue('--content-width', element), 'none');
-    });
-  });
-
-  suite('SHRINK_ONLY mode', () => {
-    setup(() => {
-      element = basicFixture.instantiate();
-      element.prefs = {...MINIMAL_PREFS};
-      element.renderPrefs = {responsive_mode: 'SHRINK_ONLY'};
-    });
-
-    test('content-width should not be defined', () => {
-      flush();
-      assert.equal(getComputedStyleValue('--content-width', element), 'none');
-    });
-
-    test('max-width considers two content columns in side-by-side', () => {
-      element.viewMode = 'SIDE_BY_SIDE';
-      flush();
-      assert.equal(getComputedStyleValue('--diff-max-width', element),
-          'calc(2 * 80ch + 2 * 48px + 0ch + 1px + 2px)');
-    });
-
-    test('max-width considers one content column in unified', () => {
-      element.viewMode = 'UNIFIED_DIFF';
-      flush();
-      assert.equal(getComputedStyleValue('--diff-max-width', element),
-          'calc(1 * 80ch + 2 * 48px + 0ch + 1px + 2px)');
-    });
-
-    test('max-width considers font-size', () => {
-      element.prefs = {...element.prefs, font_size: 13};
-      flush();
-      // Each line number column: 4 * 13 = 52px
-      assert.equal(getComputedStyleValue('--diff-max-width', element),
-          'calc(2 * 80ch + 2 * 52px + 0ch + 1px + 2px)');
-    });
-
-    test('sign cols are considered if show_sign_col is true', () => {
-      element.renderPrefs = {...element.renderPrefs, show_sign_col: true};
-      flush();
-      assert.equal(getComputedStyleValue('--diff-max-width', element),
-          'calc(2 * 80ch + 2 * 48px + 2ch + 1px + 2px)');
-    });
-  });
-
-  suite('not logged in', () => {
-    setup(() => {
-      const getLoggedInPromise = Promise.resolve(false);
-      stubRestApi('getLoggedIn').returns(getLoggedInPromise);
-      element = basicFixture.instantiate();
-      return getLoggedInPromise;
-    });
-
-    test('toggleLeftDiff', () => {
-      element.toggleLeftDiff();
-      assert.isTrue(element.classList.contains('no-left'));
-      element.toggleLeftDiff();
-      assert.isFalse(element.classList.contains('no-left'));
-    });
-
-    test('view does not start with displayLine classList', () => {
-      assert.isFalse(
-          element.shadowRoot
-              .querySelector('.diffContainer')
-              .classList
-              .contains('displayLine'));
-    });
-
-    test('displayLine class added called when displayLine is true', () => {
-      const spy = sinon.spy(element, '_computeContainerClass');
-      element.displayLine = true;
-      assert.isTrue(spy.called);
-      assert.isTrue(
-          element.shadowRoot
-              .querySelector('.diffContainer')
-              .classList
-              .contains('displayLine'));
-    });
-
-    test('thread groups', () => {
-      const contentEl = document.createElement('div');
-
-      element.changeNum = 123;
-      element.patchRange = {basePatchNum: 1, patchNum: 2};
-      element.path = 'file.txt';
-      element.$.diffBuilder.diff = createDiff();
-      element.$.diffBuilder.prefs = {...MINIMAL_PREFS};
-      element.$.diffBuilder._builder = element.$.diffBuilder._getDiffBuilder();
-
-      // No thread groups.
-      assert.equal(contentEl.querySelectorAll('.thread-group').length, 0);
-
-      // A thread group gets created.
-      const threadGroupEl = element._getOrCreateThreadGroup(contentEl);
-      assert.isOk(threadGroupEl);
-
-      // The new thread group can be fetched.
-      assert.equal(contentEl.querySelectorAll('.thread-group').length, 1);
-    });
-
-    suite('image diffs', () => {
-      let mockFile1;
-      let mockFile2;
-      setup(() => {
-        mockFile1 = {
-          body: 'Qk06AAAAAAAAADYAAAAoAAAAAQAAAP////8BACAAAAAAAAAAAAATCwAAE' +
-          'wsAAAAAAAAAAAAAAAAA/w==',
-          type: 'image/bmp',
-        };
-        mockFile2 = {
-          body: 'Qk06AAAAAAAAADYAAAAoAAAAAQAAAP////8BACAAAAAAAAAAAAATCwAAE' +
-          'wsAAAAAAAAAAAAA/////w==',
-          type: 'image/bmp',
-        };
-
-        element.isImageDiff = true;
-        element.prefs = {
-          context: 10,
-          cursor_blink_rate: 0,
-          font_size: 12,
-          ignore_whitespace: 'IGNORE_NONE',
-          line_length: 100,
-          line_wrapping: false,
-          show_line_endings: true,
-          show_tabs: true,
-          show_whitespace_errors: true,
-          syntax_highlighting: true,
-          tab_size: 8,
-          theme: 'DEFAULT',
-        };
-      });
-
-      test('renders image diffs with same file name', async () => {
-        const leftRendered = mockPromise();
-        const rightRendered = mockPromise();
-        const rendered = () => {
-          // Recognizes that it should be an image diff.
-          assert.isTrue(element.isImageDiff);
-          assert.instanceOf(
-              element.$.diffBuilder._builder, GrDiffBuilderImage);
-
-          // Left image rendered with the parent commit's version of the file.
-          const leftImage = element.$.diffTable.querySelector('td.left img');
-          const leftLabel =
-              element.$.diffTable.querySelector('td.left label');
-          const leftLabelContent = leftLabel.querySelector('.label');
-          const leftLabelName = leftLabel.querySelector('.name');
-
-          const rightImage =
-              element.$.diffTable.querySelector('td.right img');
-          const rightLabel = element.$.diffTable.querySelector(
-              'td.right label');
-          const rightLabelContent = rightLabel.querySelector('.label');
-          const rightLabelName = rightLabel.querySelector('.name');
-
-          assert.isNotOk(rightLabelName);
-          assert.isNotOk(leftLabelName);
-
-          leftImage.addEventListener('load', () => {
-            assert.isOk(leftImage);
-            assert.equal(leftImage.getAttribute('src'),
-                'data:image/bmp;base64,' + mockFile1.body);
-            assert.equal(leftLabelContent.textContent, '1\u00d71 image/bmp');// \u00d7 - '×'
-            leftRendered.resolve();
-          });
-
-          rightImage.addEventListener('load', () => {
-            assert.isOk(rightImage);
-            assert.equal(rightImage.getAttribute('src'),
-                'data:image/bmp;base64,' + mockFile2.body);
-            assert.equal(rightLabelContent.textContent, '1\u00d71 image/bmp');// \u00d7 - '×'
-
-            rightRendered.resolve();
-          });
-        };
-
-        element.addEventListener('render', rendered);
-
-        element.baseImage = mockFile1;
-        element.revisionImage = mockFile2;
-        element.diff = {
-          meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
-          meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg',
-            lines: 560},
-          intraline_status: 'OK',
-          change_type: 'MODIFIED',
-          diff_header: [
-            'diff --git a/carrot.jpg b/carrot.jpg',
-            'index 2adc47d..f9c2f2c 100644',
-            '--- a/carrot.jpg',
-            '+++ b/carrot.jpg',
-            'Binary files differ',
-          ],
-          content: [{skip: 66}],
-          binary: true,
-        };
-        await Promise.all([leftRendered, rightRendered]);
-        element.removeEventListener('render', rendered);
-      });
-
-      test('renders image diffs with a different file name', async () => {
-        const mockDiff = {
-          meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
-          meta_b: {name: 'carrot2.jpg', content_type: 'image/jpeg',
-            lines: 560},
-          intraline_status: 'OK',
-          change_type: 'MODIFIED',
-          diff_header: [
-            'diff --git a/carrot.jpg b/carrot2.jpg',
-            'index 2adc47d..f9c2f2c 100644',
-            '--- a/carrot.jpg',
-            '+++ b/carrot2.jpg',
-            'Binary files differ',
-          ],
-          content: [{skip: 66}],
-          binary: true,
-        };
-        const leftRendered = mockPromise();
-        const rightRendered = mockPromise();
-        const rendered = () => {
-          // Recognizes that it should be an image diff.
-          assert.isTrue(element.isImageDiff);
-          assert.instanceOf(
-              element.$.diffBuilder._builder, GrDiffBuilderImage);
-
-          // Left image rendered with the parent commit's version of the file.
-          const leftImage = element.$.diffTable.querySelector('td.left img');
-          const leftLabel =
-              element.$.diffTable.querySelector('td.left label');
-          const leftLabelContent = leftLabel.querySelector('.label');
-          const leftLabelName = leftLabel.querySelector('.name');
-
-          const rightImage =
-              element.$.diffTable.querySelector('td.right img');
-          const rightLabel = element.$.diffTable.querySelector(
-              'td.right label');
-          const rightLabelContent = rightLabel.querySelector('.label');
-          const rightLabelName = rightLabel.querySelector('.name');
-
-          assert.isOk(rightLabelName);
-          assert.isOk(leftLabelName);
-          assert.equal(leftLabelName.textContent, mockDiff.meta_a.name);
-          assert.equal(rightLabelName.textContent, mockDiff.meta_b.name);
-
-          leftImage.addEventListener('load', () => {
-            assert.isOk(leftImage);
-            assert.equal(leftImage.getAttribute('src'),
-                'data:image/bmp;base64,' + mockFile1.body);
-            assert.equal(leftLabelContent.textContent, '1\u00d71 image/bmp');// \u00d7 - '×'
-            leftRendered.resolve();
-          });
-
-          rightImage.addEventListener('load', () => {
-            assert.isOk(rightImage);
-            assert.equal(rightImage.getAttribute('src'),
-                'data:image/bmp;base64,' + mockFile2.body);
-            assert.equal(rightLabelContent.textContent, '1\u00d71 image/bmp');// \u00d7 - '×'
-
-            rightRendered.resolve();
-          });
-        };
-
-        element.addEventListener('render', rendered);
-
-        element.baseImage = mockFile1;
-        element.baseImage._name = mockDiff.meta_a.name;
-        element.revisionImage = mockFile2;
-        element.revisionImage._name = mockDiff.meta_b.name;
-        element.diff = mockDiff;
-        await Promise.all([leftRendered, rightRendered]);
-        element.removeEventListener('render', rendered);
-      });
-
-      test('renders added image', async () => {
-        const mockDiff = {
-          meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg',
-            lines: 560},
-          intraline_status: 'OK',
-          change_type: 'ADDED',
-          diff_header: [
-            'diff --git a/carrot.jpg b/carrot.jpg',
-            'index 0000000..f9c2f2c 100644',
-            '--- /dev/null',
-            '+++ b/carrot.jpg',
-            'Binary files differ',
-          ],
-          content: [{skip: 66}],
-          binary: true,
-        };
-
-        const promise = mockPromise();
-        function rendered() { promise.resolve(); }
-        element.addEventListener('render', rendered);
-
-        element.revisionImage = mockFile2;
-        element.diff = mockDiff;
-        await promise;
-        element.removeEventListener('render', rendered);
-        // Recognizes that it should be an image diff.
-        assert.isTrue(element.isImageDiff);
-        assert.instanceOf(
-            element.$.diffBuilder._builder, GrDiffBuilderImage);
-
-        const leftImage = element.$.diffTable.querySelector('td.left img');
-        const rightImage = element.$.diffTable.querySelector('td.right img');
-
-        assert.isNotOk(leftImage);
-        assert.isOk(rightImage);
-      });
-
-      test('renders removed image', async () => {
-        const mockDiff = {
-          meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg',
-            lines: 560},
-          intraline_status: 'OK',
-          change_type: 'DELETED',
-          diff_header: [
-            'diff --git a/carrot.jpg b/carrot.jpg',
-            'index f9c2f2c..0000000 100644',
-            '--- a/carrot.jpg',
-            '+++ /dev/null',
-            'Binary files differ',
-          ],
-          content: [{skip: 66}],
-          binary: true,
-        };
-        const promise = mockPromise();
-        function rendered() { promise.resolve(); }
-        element.addEventListener('render', rendered);
-
-        element.baseImage = mockFile1;
-        element.diff = mockDiff;
-        await promise;
-        element.removeEventListener('render', rendered);
-        // Recognizes that it should be an image diff.
-        assert.isTrue(element.isImageDiff);
-        assert.instanceOf(
-            element.$.diffBuilder._builder, GrDiffBuilderImage);
-
-        const leftImage = element.$.diffTable.querySelector('td.left img');
-        const rightImage = element.$.diffTable.querySelector('td.right img');
-
-        assert.isOk(leftImage);
-        assert.isNotOk(rightImage);
-      });
-
-      test('does not render disallowed image type', async () => {
-        const mockDiff = {
-          meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg-evil',
-            lines: 560},
-          intraline_status: 'OK',
-          change_type: 'DELETED',
-          diff_header: [
-            'diff --git a/carrot.jpg b/carrot.jpg',
-            'index f9c2f2c..0000000 100644',
-            '--- a/carrot.jpg',
-            '+++ /dev/null',
-            'Binary files differ',
-          ],
-          content: [{skip: 66}],
-          binary: true,
-        };
-        mockFile1.type = 'image/jpeg-evil';
-
-        const promise = mockPromise();
-        function rendered() { promise.resolve(); }
-        element.addEventListener('render', rendered);
-
-        element.baseImage = mockFile1;
-        element.diff = mockDiff;
-        await promise;
-        element.removeEventListener('render', rendered);
-        // Recognizes that it should be an image diff.
-        assert.isTrue(element.isImageDiff);
-        assert.instanceOf(
-            element.$.diffBuilder._builder, GrDiffBuilderImage);
-        const leftImage = element.$.diffTable.querySelector('td.left img');
-        assert.isNotOk(leftImage);
-      });
-    });
-
-    test('_handleTap lineNum', async () => {
-      const addDraftStub = sinon.stub(element, 'addDraftAtLine');
-      const el = document.createElement('div');
-      el.className = 'lineNum';
-      const promise = mockPromise();
-      el.addEventListener('click', e => {
-        element._handleTap(e);
-        assert.isTrue(addDraftStub.called);
-        assert.equal(addDraftStub.lastCall.args[0], el);
-        promise.resolve();
-      });
-      el.click();
-      await promise;
-    });
-
-    test('_handleTap content', async () => {
-      const content = document.createElement('div');
-      const lineEl = document.createElement('div');
-      lineEl.className = 'lineNum';
-      const row = document.createElement('div');
-      row.appendChild(lineEl);
-      row.appendChild(content);
-
-      const selectStub = sinon.stub(element, '_selectLine');
-
-      content.className = 'content';
-      const promise = mockPromise();
-      content.addEventListener('click', e => {
-        element._handleTap(e);
-        assert.isTrue(selectStub.called);
-        assert.equal(selectStub.lastCall.args[0], lineEl);
-        promise.resolve();
-      });
-      content.click();
-      await promise;
-    });
-
-    suite('getCursorStops', () => {
-      function setupDiff() {
-        element.diff = createDiff();
-        element.prefs = {
-          context: 10,
-          tab_size: 8,
-          font_size: 12,
-          line_length: 100,
-          cursor_blink_rate: 0,
-          line_wrapping: false,
-
-          show_line_endings: true,
-          show_tabs: true,
-          show_whitespace_errors: true,
-          syntax_highlighting: true,
-          theme: 'DEFAULT',
-          ignore_whitespace: 'IGNORE_NONE',
-        };
-
-        element._renderDiffTable();
-
-        flush();
-      }
-
-      test('returns [] when hidden and noAutoRender', () => {
-        element.noAutoRender = true;
-        setupDiff();
-        element._setLoading(false);
-        flush();
-        element.hidden = true;
-        assert.equal(element.getCursorStops().length, 0);
-      });
-
-      test('returns one stop per line and one for the file row', () => {
-        setupDiff();
-        element._setLoading(false);
-        flush();
-        const ROWS = 48;
-        const FILE_ROW = 1;
-        assert.equal(element.getCursorStops().length, ROWS + FILE_ROW);
-      });
-
-      test('returns an additional AbortStop when still loading', () => {
-        setupDiff();
-        element._setLoading(true);
-        flush();
-        const ROWS = 48;
-        const FILE_ROW = 1;
-        const actual = element.getCursorStops();
-        assert.equal(actual.length, ROWS + FILE_ROW + 1);
-        assert.isTrue(actual[actual.length -1] instanceof AbortStop);
-      });
-    });
-
-    test('adds .hiddenscroll', () => {
-      _setHiddenScroll(true);
-      element.displayLine = true;
-      assert.include(element.shadowRoot
-          .querySelector('.diffContainer').className, 'hiddenscroll');
-    });
-  });
-
-  suite('logged in', () => {
-    let fakeLineEl;
-    setup(() => {
-      element = basicFixture.instantiate();
-      element.loggedIn = true;
-
-      fakeLineEl = {
-        getAttribute: sinon.stub().returns(42),
-        classList: {
-          contains: sinon.stub().returns(true),
-        },
-      };
-    });
-
-    test('addDraftAtLine', () => {
-      sinon.stub(element, '_selectLine');
-      sinon.stub(element, '_createComment');
-      element.addDraftAtLine(fakeLineEl);
-      assert.isTrue(element._createComment
-          .calledWithExactly(fakeLineEl, 42));
-    });
-
-    test('adds long range comment hint', async () => {
-      const range = {
-        start_line: 1,
-        end_line: 12,
-        start_character: 0,
-        end_character: 0,
-      };
-      const threadEl = document.createElement('div');
-      threadEl.className = 'comment-thread';
-      threadEl.setAttribute('diff-side', 'right');
-      threadEl.setAttribute('line-num', 1);
-      threadEl.setAttribute('range', JSON.stringify(range));
-      threadEl.setAttribute('slot', 'right-1');
-      const content = [{
-        a: [],
-        b: [],
-      }, {
-        ab: Array(13).fill('text'),
-      }];
-      setupSampleDiff({content});
-
-      element.appendChild(threadEl);
-      await flush();
-
-      assert.deepEqual(
-          element.querySelector('gr-ranged-comment-hint').range, range);
-    });
-
-    test('no duplicate range hint for same thread', async () => {
-      const range = {
-        start_line: 1,
-        end_line: 12,
-        start_character: 0,
-        end_character: 0,
-      };
-      const threadEl = document.createElement('div');
-      threadEl.className = 'comment-thread';
-      threadEl.setAttribute('diff-side', 'right');
-      threadEl.setAttribute('line-num', 1);
-      threadEl.setAttribute('range', JSON.stringify(range));
-      threadEl.setAttribute('slot', 'right-1');
-      const firstHint = document.createElement('gr-ranged-comment-hint');
-      firstHint.range = range;
-      firstHint.setAttribute('threadElRootId', threadEl.rootId);
-      firstHint.setAttribute('slot', 'right-1');
-      const content = [{
-        a: [],
-        b: [],
-      }, {
-        ab: Array(13).fill('text'),
-      }];
-      setupSampleDiff({content});
-
-      element.appendChild(firstHint);
-      await flush();
-      element._handleRenderContent();
-      await flush();
-      element.appendChild(threadEl);
-      await flush();
-
-      assert.equal(
-          element.querySelectorAll('gr-ranged-comment-hint').length, 1);
-    });
-
-    test('removes long range comment hint when comment is discarded',
-        async () => {
-          const range = {
-            start_line: 1,
-            end_line: 7,
-            start_character: 0,
-            end_character: 0,
-          };
-          const threadEl = document.createElement('div');
-          threadEl.className = 'comment-thread';
-          threadEl.setAttribute('diff-side', 'right');
-          threadEl.setAttribute('line-num', 1);
-          threadEl.setAttribute('range', JSON.stringify(range));
-          threadEl.setAttribute('slot', 'right-1');
-          const content = [{
-            a: [],
-            b: [],
-          }, {
-            ab: Array(8).fill('text'),
-          }];
-          setupSampleDiff({content});
-          element.appendChild(threadEl);
-          await flush();
-
-          threadEl.remove();
-          await flush();
-
-          assert.isEmpty(element.querySelectorAll('gr-ranged-comment-hint'));
-        });
-
-    suite('change in preferences', () => {
-      setup(() => {
-        element.diff = {
-          meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
-          meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg',
-            lines: 560},
-          diff_header: [],
-          intraline_status: 'OK',
-          change_type: 'MODIFIED',
-          content: [{skip: 66}],
-        };
-        element.renderDiffTableTask.flush();
-      });
-
-      test('change in preferences re-renders diff', () => {
-        sinon.stub(element, '_renderDiffTable');
-        element.prefs = {
-          ...MINIMAL_PREFS, time_format: 'HHMM_12'};
-        element.renderDiffTableTask.flush();
-        assert.isTrue(element._renderDiffTable.called);
-      });
-
-      test('adding/removing property in preferences re-renders diff', () => {
-        const stub = sinon.stub(element, '_renderDiffTable');
-        const newPrefs1 = {...MINIMAL_PREFS,
-          line_wrapping: true};
-        element.prefs = newPrefs1;
-        element.renderDiffTableTask.flush();
-        assert.isTrue(element._renderDiffTable.called);
-        stub.reset();
-
-        const newPrefs2 = {...newPrefs1};
-        delete newPrefs2.line_wrapping;
-        element.prefs = newPrefs2;
-        element.renderDiffTableTask.flush();
-        assert.isTrue(element._renderDiffTable.called);
-      });
-
-      test('change in preferences does not re-renders diff with ' +
-          'noRenderOnPrefsChange', () => {
-        sinon.stub(element, '_renderDiffTable');
-        element.noRenderOnPrefsChange = true;
-        element.prefs = {
-          ...MINIMAL_PREFS, time_format: 'HHMM_12'};
-        element.renderDiffTableTask.flush();
-        assert.isFalse(element._renderDiffTable.called);
-      });
-    });
-  });
-
-  suite('diff header', () => {
-    setup(() => {
-      element = basicFixture.instantiate();
-      element.diff = {
-        meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
-        meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg',
-          lines: 560},
-        diff_header: [],
-        intraline_status: 'OK',
-        change_type: 'MODIFIED',
-        content: [{skip: 66}],
-      };
-    });
-
-    test('hidden', () => {
-      assert.equal(element._diffHeaderItems.length, 0);
-      element.push('diff.diff_header', 'diff --git a/test.jpg b/test.jpg');
-      assert.equal(element._diffHeaderItems.length, 0);
-      element.push('diff.diff_header', 'index 2adc47d..f9c2f2c 100644');
-      assert.equal(element._diffHeaderItems.length, 0);
-      element.push('diff.diff_header', '--- a/test.jpg');
-      assert.equal(element._diffHeaderItems.length, 0);
-      element.push('diff.diff_header', '+++ b/test.jpg');
-      assert.equal(element._diffHeaderItems.length, 0);
-      element.push('diff.diff_header', 'test');
-      assert.equal(element._diffHeaderItems.length, 1);
-      flush();
-
-      assert.equal(element.$.diffHeader.textContent.trim(), 'test');
-    });
-
-    test('binary files', () => {
-      element.diff.binary = true;
-      assert.equal(element._diffHeaderItems.length, 0);
-      element.push('diff.diff_header', 'diff --git a/test.jpg b/test.jpg');
-      assert.equal(element._diffHeaderItems.length, 0);
-      element.push('diff.diff_header', 'test');
-      assert.equal(element._diffHeaderItems.length, 1);
-      element.push('diff.diff_header', 'Binary files differ');
-      assert.equal(element._diffHeaderItems.length, 1);
-    });
-  });
-
-  suite('safety and bypass', () => {
-    let renderStub;
-
-    setup(() => {
-      element = basicFixture.instantiate();
-      renderStub = sinon.stub(element.$.diffBuilder, 'render').callsFake(
-          () => {
-            element.$.diffBuilder.dispatchEvent(
-                new CustomEvent('render', {bubbles: true, composed: true}));
-            return Promise.resolve({});
-          });
-      sinon.stub(element, 'getDiffLength').returns(10000);
-      element.diff = createDiff();
-      element.noRenderOnPrefsChange = true;
-    });
-
-    test('large render w/ context = 10', async () => {
-      element.prefs = {...MINIMAL_PREFS, context: 10};
-      const promise = mockPromise();
-      function rendered() {
-        assert.isTrue(renderStub.called);
-        assert.isFalse(element._showWarning);
-        promise.resolve();
-        element.removeEventListener('render', rendered);
-      }
-      element.addEventListener('render', rendered);
-      element._renderDiffTable();
-      await promise;
-    });
-
-    test('large render w/ whole file and bypass', async () => {
-      element.prefs = {...MINIMAL_PREFS, context: -1};
-      element._safetyBypass = 10;
-      const promise = mockPromise();
-      function rendered() {
-        assert.isTrue(renderStub.called);
-        assert.isFalse(element._showWarning);
-        promise.resolve();
-        element.removeEventListener('render', rendered);
-      }
-      element.addEventListener('render', rendered);
-      element._renderDiffTable();
-      await promise;
-    });
-
-    test('large render w/ whole file and no bypass', async () => {
-      element.prefs = {...MINIMAL_PREFS, context: -1};
-      const promise = mockPromise();
-      function rendered() {
-        assert.isFalse(renderStub.called);
-        assert.isTrue(element._showWarning);
-        promise.resolve();
-        element.removeEventListener('render', rendered);
-      }
-      element.addEventListener('render', rendered);
-      element._renderDiffTable();
-      await promise;
-    });
-
-    test('toggles expand context using bypass', async () => {
-      element.prefs = {...MINIMAL_PREFS, context: 3};
-
-      element.toggleAllContext();
-      element._renderDiffTable();
-      await flush();
-
-      assert.equal(element.prefs.context, 3);
-      assert.equal(element._safetyBypass, -1);
-      assert.equal(element.$.diffBuilder.prefs.context, -1);
-    });
-
-    test('toggles collapse context from bypass', async () => {
-      element.prefs = {...MINIMAL_PREFS, context: 3};
-      element._safetyBypass = -1;
-
-      element.toggleAllContext();
-      element._renderDiffTable();
-      await flush();
-
-      assert.equal(element.prefs.context, 3);
-      assert.isNull(element._safetyBypass);
-      assert.equal(element.$.diffBuilder.prefs.context, 3);
-    });
-
-    test('toggles collapse context from pref using default', async () => {
-      element.prefs = {...MINIMAL_PREFS, context: -1};
-
-      element.toggleAllContext();
-      element._renderDiffTable();
-      await flush();
-
-      assert.equal(element.prefs.context, -1);
-      assert.equal(element._safetyBypass, 10);
-      assert.equal(element.$.diffBuilder.prefs.context, 10);
-    });
-  });
-
-  suite('blame', () => {
-    setup(() => {
-      element = basicFixture.instantiate();
-    });
-
-    test('unsetting', () => {
-      element.blame = [];
-      const setBlameSpy = sinon.spy(element.$.diffBuilder, 'setBlame');
-      element.classList.add('showBlame');
-      element.blame = null;
-      assert.isTrue(setBlameSpy.calledWithExactly(null));
-      assert.isFalse(element.classList.contains('showBlame'));
-    });
-
-    test('setting', () => {
-      element.blame = [{id: 'commit id', ranges: [{start: 1, end: 2}]}];
-      assert.isTrue(element.classList.contains('showBlame'));
-    });
-  });
-
-  suite('trailing newline warnings', () => {
-    const NO_NEWLINE_LEFT = 'No newline at end of left file.';
-    const NO_NEWLINE_RIGHT = 'No newline at end of right file.';
-
-    const getWarning = element =>
-      element.shadowRoot.querySelector('.newlineWarning').textContent;
-
-    setup(() => {
-      element = basicFixture.instantiate();
-      element.showNewlineWarningLeft = false;
-      element.showNewlineWarningRight = false;
-    });
-
-    test('shows combined warning if both sides set to warn', () => {
-      element.showNewlineWarningLeft = true;
-      element.showNewlineWarningRight = true;
-      assert.include(
-          getWarning(element),
-          NO_NEWLINE_LEFT + ' \u2014 ' + NO_NEWLINE_RIGHT);// \u2014 - '—'
-    });
-
-    suite('showNewlineWarningLeft', () => {
-      test('show warning if true', () => {
-        element.showNewlineWarningLeft = true;
-        assert.include(getWarning(element), NO_NEWLINE_LEFT);
-      });
-
-      test('hide warning if false', () => {
-        element.showNewlineWarningLeft = false;
-        assert.notInclude(getWarning(element), NO_NEWLINE_LEFT);
-      });
-
-      test('hide warning if undefined', () => {
-        element.showNewlineWarningLeft = undefined;
-        assert.notInclude(getWarning(element), NO_NEWLINE_LEFT);
-      });
-    });
-
-    suite('showNewlineWarningRight', () => {
-      test('show warning if true', () => {
-        element.showNewlineWarningRight = true;
-        assert.include(getWarning(element), NO_NEWLINE_RIGHT);
-      });
-
-      test('hide warning if false', () => {
-        element.showNewlineWarningRight = false;
-        assert.notInclude(getWarning(element), NO_NEWLINE_RIGHT);
-      });
-
-      test('hide warning if undefined', () => {
-        element.showNewlineWarningRight = undefined;
-        assert.notInclude(getWarning(element), NO_NEWLINE_RIGHT);
-      });
-    });
-
-    test('_computeNewlineWarningClass', () => {
-      const hidden = 'newlineWarning hidden';
-      const shown = 'newlineWarning';
-      assert.equal(element._computeNewlineWarningClass(null, true), hidden);
-      assert.equal(element._computeNewlineWarningClass('foo', true), hidden);
-      assert.equal(element._computeNewlineWarningClass(null, false), hidden);
-      assert.equal(element._computeNewlineWarningClass('foo', false), shown);
-    });
-
-    test('_prefsEqual', () => {
-      element = basicFixture.instantiate();
-      assert.isTrue(element._prefsEqual(null, null));
-      assert.isTrue(element._prefsEqual({}, {}));
-      assert.isTrue(element._prefsEqual({x: 1}, {x: 1}));
-      assert.isTrue(
-          element._prefsEqual({x: 1, abc: 'def'}, {x: 1, abc: 'def'}));
-      const somePref = {abc: 'def', p: true};
-      assert.isTrue(element._prefsEqual(somePref, somePref));
-
-      assert.isFalse(element._prefsEqual({}, null));
-      assert.isFalse(element._prefsEqual(null, {}));
-      assert.isFalse(element._prefsEqual({x: 1}, {x: 2}));
-      assert.isFalse(element._prefsEqual({x: 1, y: 'abc'}, {x: 1, y: 'abcd'}));
-      assert.isFalse(element._prefsEqual({x: 1, y: 'abc'}, {x: 1}));
-      assert.isFalse(element._prefsEqual({x: 1}, {x: 1, y: 'abc'}));
-    });
-  });
-
-  suite('key locations', () => {
-    let renderStub;
-
-    setup(() => {
-      element = basicFixture.instantiate();
-      element.prefs = {};
-      renderStub = sinon.stub(element.$.diffBuilder, 'render')
-          .returns(new Promise(() => {}));
-    });
-
-    test('lineOfInterest is a key location', () => {
-      element.lineOfInterest = {lineNum: 789, side: Side.LEFT};
-      element._renderDiffTable();
-      assert.isTrue(renderStub.called);
-      assert.deepEqual(renderStub.lastCall.args[0], {
-        left: {789: true},
-        right: {},
-      });
-    });
-
-    test('line comments are key locations', () => {
-      const threadEl = document.createElement('div');
-      threadEl.className = 'comment-thread';
-      threadEl.setAttribute('diff-side', 'right');
-      threadEl.setAttribute('line-num', 3);
-      element.appendChild(threadEl);
-      flush();
-
-      element._renderDiffTable();
-      assert.isTrue(renderStub.called);
-      assert.deepEqual(renderStub.lastCall.args[0], {
-        left: {},
-        right: {3: true},
-      });
-    });
-
-    test('file comments are key locations', () => {
-      const threadEl = document.createElement('div');
-      threadEl.className = 'comment-thread';
-      threadEl.setAttribute('diff-side', 'left');
-      element.appendChild(threadEl);
-      flush();
-
-      element._renderDiffTable();
-      assert.isTrue(renderStub.called);
-      assert.deepEqual(renderStub.lastCall.args[0], {
-        left: {FILE: true},
-        right: {},
-      });
-    });
-  });
-  const setupSampleDiff = function(params) {
-    const {ignore_whitespace, content} = params;
-    // binary can't be undefined, use false if not set
-    const binary = params.binary || false;
-    element = basicFixture.instantiate();
-    element.prefs = {
-      ignore_whitespace: ignore_whitespace || 'IGNORE_ALL',
-      context: 10,
-      cursor_blink_rate: 0,
-      font_size: 12,
-
-      line_length: 100,
-      line_wrapping: false,
-      show_line_endings: true,
-      show_tabs: true,
-      show_whitespace_errors: true,
-      syntax_highlighting: true,
-      tab_size: 8,
-      theme: 'DEFAULT',
-    };
-    element.diff = {
-      intraline_status: 'OK',
-      change_type: 'MODIFIED',
-      diff_header: [
-        'diff --git a/carrot.js b/carrot.js',
-        'index 2adc47d..f9c2f2c 100644',
-        '--- a/carrot.js',
-        '+++ b/carrot.jjs',
-        'file differ',
-      ],
-      content,
-      binary,
-    };
-    element._renderDiffTable();
-    flush();
-  };
-
-  test('clear diff table content as soon as diff changes', () => {
-    const content = [{
-      a: ['all work and no play make andybons a dull boy'],
-    }, {
-      b: [
-        'Non eram nescius, Brute, cum, quae summis ingeniis ',
-      ],
-    }];
-    function assertDiffTableWithContent() {
-      assert.isTrue(element.$.diffTable.innerText.includes(content[0].a));
-    }
-    setupSampleDiff({content});
-    assertDiffTableWithContent();
-    element.diff = {...element.diff};
-    // immediately cleaned up
-    assert.equal(element.$.diffTable.innerHTML, '');
-    element._renderDiffTable();
-    flush();
-    // rendered again
-    assertDiffTableWithContent();
-  });
-
-  suite('selection test', () => {
-    test('user-select set correctly on side-by-side view', () => {
-      const content = [{
-        a: ['all work and no play make andybons a dull boy'],
-        b: ['elgoog elgoog elgoog'],
-      }, {
-        ab: [
-          'Non eram nescius, Brute, cum, quae summis ingeniis ',
-          'exquisitaque doctrina philosophi Graeco sermone tractavissent',
-        ],
-      }];
-      setupSampleDiff({content});
-      flush();
-      const diffLine = element.shadowRoot.querySelectorAll('.contentText')[2];
-      assert.equal(getComputedStyle(diffLine).userSelect, 'none');
-      // click to mark it as selected
-      MockInteractions.tap(diffLine);
-      assert.equal(getComputedStyle(diffLine).userSelect, 'text');
-    });
-
-    test('user-select set correctly on unified view', () => {
-      const content = [{
-        a: ['all work and no play make andybons a dull boy'],
-        b: ['elgoog elgoog elgoog'],
-      }, {
-        ab: [
-          'Non eram nescius, Brute, cum, quae summis ingeniis ',
-          'exquisitaque doctrina philosophi Graeco sermone tractavissent',
-        ],
-      }];
-      setupSampleDiff({content});
-      element.viewMode = 'UNIFIED_DIFF';
-      flush();
-      const diffLine = element.shadowRoot.querySelectorAll('.contentText')[2];
-      assert.equal(getComputedStyle(diffLine).userSelect, 'none');
-      MockInteractions.tap(diffLine);
-      assert.equal(getComputedStyle(diffLine).userSelect, 'text');
-    });
-  });
-
-  suite('whitespace changes only message', () => {
-    test('show the message if ignore_whitespace is criteria matches', () => {
-      setupSampleDiff({content: [{skip: 100}]});
-      assert.isTrue(element.showNoChangeMessage(
-          /* loading= */ false,
-          element.prefs,
-          element._diffLength,
-          element.diff
-      ));
-    });
-
-    test('do not show the message for binary files', () => {
-      setupSampleDiff({content: [{skip: 100}], binary: true});
-      assert.isFalse(element.showNoChangeMessage(
-          /* loading= */ false,
-          element.prefs,
-          element._diffLength,
-          element.diff
-      ));
-    });
-
-    test('do not show the message if still loading', () => {
-      setupSampleDiff({content: [{skip: 100}]});
-      assert.isFalse(element.showNoChangeMessage(
-          /* loading= */ true,
-          element.prefs,
-          element._diffLength,
-          element.diff
-      ));
-    });
-
-    test('do not show the message if contains valid changes', () => {
-      const content = [{
-        a: ['all work and no play make andybons a dull boy'],
-        b: ['elgoog elgoog elgoog'],
-      }, {
-        ab: [
-          'Non eram nescius, Brute, cum, quae summis ingeniis ',
-          'exquisitaque doctrina philosophi Graeco sermone tractavissent',
-        ],
-      }];
-      setupSampleDiff({content});
-      assert.equal(element._diffLength, 3);
-      assert.isFalse(element.showNoChangeMessage(
-          /* loading= */ false,
-          element.prefs,
-          element._diffLength,
-          element.diff
-      ));
-    });
-
-    test('do not show message if ignore whitespace is disabled', () => {
-      const content = [{
-        a: ['all work and no play make andybons a dull boy'],
-        b: ['elgoog elgoog elgoog'],
-      }, {
-        ab: [
-          'Non eram nescius, Brute, cum, quae summis ingeniis ',
-          'exquisitaque doctrina philosophi Graeco sermone tractavissent',
-        ],
-      }];
-      setupSampleDiff({ignore_whitespace: 'IGNORE_NONE', content});
-      assert.isFalse(element.showNoChangeMessage(
-          /* loading= */ false,
-          element.prefs,
-          element._diffLength,
-          element.diff
-      ));
-    });
-  });
-
-  test('getDiffLength', () => {
-    const diff = createDiff();
-    assert.equal(element.getDiffLength(diff), 52);
-  });
-
-  test('_prefsEqual', () => {
-    element = basicFixture.instantiate();
-    assert.isTrue(element._prefsEqual(null, null));
-    assert.isTrue(element._prefsEqual({}, {}));
-    assert.isTrue(element._prefsEqual({x: 1}, {x: 1}));
-    assert.isTrue(element._prefsEqual({x: 1, abc: 'def'}, {x: 1, abc: 'def'}));
-    const somePref = {abc: 'def', p: true};
-    assert.isTrue(element._prefsEqual(somePref, somePref));
-
-    assert.isFalse(element._prefsEqual({}, null));
-    assert.isFalse(element._prefsEqual(null, {}));
-    assert.isFalse(element._prefsEqual({x: 1}, {x: 2}));
-    assert.isFalse(element._prefsEqual({x: 1, y: 'abc'}, {x: 1, y: 'abcd'}));
-    assert.isFalse(element._prefsEqual({x: 1, y: 'abc'}, {x: 1}));
-    assert.isFalse(element._prefsEqual({x: 1}, {x: 1, y: 'abc'}));
-  });
-});
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_test.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_test.ts
new file mode 100644
index 0000000..1422223
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_test.ts
@@ -0,0 +1,1240 @@
+/**
+ * @license
+ * Copyright 2015 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup-karma';
+import {createDiff} from '../../../test/test-data-generators';
+import './gr-diff';
+import {GrDiffBuilderImage} from '../gr-diff-builder/gr-diff-builder-image';
+import {getComputedStyleValue} from '../../../utils/dom-util';
+import {_setHiddenScroll} from '../../../scripts/hiddenscroll';
+import {runA11yAudit} from '../../../test/a11y-test-utils';
+import '@polymer/paper-button/paper-button';
+import {
+  DiffContent,
+  DiffInfo,
+  DiffPreferencesInfo,
+  DiffViewMode,
+  IgnoreWhitespaceType,
+  Side,
+} from '../../../api/diff';
+import {
+  mockPromise,
+  mouseDown,
+  query,
+  queryAll,
+  queryAndAssert,
+  stubRestApi,
+} from '../../../test/test-utils';
+import {AbortStop} from '../../../api/core';
+import {waitForEventOnce} from '../../../utils/event-util';
+import {GrDiff} from './gr-diff';
+import {ImageInfo} from '../../../types/common';
+import {GrRangedCommentHint} from '../gr-ranged-comment-hint/gr-ranged-comment-hint';
+
+const basicFixture = fixtureFromElement('gr-diff');
+
+suite('gr-diff a11y test', () => {
+  test('audit', async () => {
+    await runA11yAudit(basicFixture);
+  });
+});
+
+suite('gr-diff tests', () => {
+  let element: GrDiff;
+
+  const MINIMAL_PREFS: DiffPreferencesInfo = {
+    tab_size: 2,
+    line_length: 80,
+    font_size: 12,
+    context: 3,
+    ignore_whitespace: 'IGNORE_NONE',
+  };
+
+  setup(() => {});
+
+  suite('selectionchange event handling', () => {
+    let handleSelectionChangeStub: sinon.SinonSpy;
+
+    const emulateSelection = function () {
+      document.dispatchEvent(new CustomEvent('selectionchange'));
+    };
+
+    setup(() => {
+      element = basicFixture.instantiate();
+      handleSelectionChangeStub = sinon.spy(
+        element.highlights,
+        'handleSelectionChange'
+      );
+    });
+
+    test('enabled if logged in', async () => {
+      element.loggedIn = true;
+      emulateSelection();
+      await flush();
+      assert.isTrue(handleSelectionChangeStub.called);
+    });
+
+    test('ignored if logged out', async () => {
+      element.loggedIn = false;
+      emulateSelection();
+      await flush();
+      assert.isFalse(handleSelectionChangeStub.called);
+    });
+  });
+
+  test('cancel', () => {
+    element = basicFixture.instantiate();
+    const cancelStub = sinon.stub(element.diffBuilder, 'cancel');
+    element.cancel();
+    assert.isTrue(cancelStub.calledOnce);
+  });
+
+  test('line limit with line_wrapping', () => {
+    element = basicFixture.instantiate();
+    element.prefs = {...MINIMAL_PREFS, line_wrapping: true};
+    flush();
+    assert.equal(getComputedStyleValue('--line-limit-marker', element), '80ch');
+  });
+
+  test('line limit without line_wrapping', () => {
+    element = basicFixture.instantiate();
+    element.prefs = {...MINIMAL_PREFS, line_wrapping: false};
+    flush();
+    assert.equal(getComputedStyleValue('--line-limit-marker', element), '-1px');
+  });
+  suite('FULL_RESPONSIVE mode', () => {
+    setup(() => {
+      element = basicFixture.instantiate();
+      element.prefs = {...MINIMAL_PREFS};
+      element.renderPrefs = {responsive_mode: 'FULL_RESPONSIVE'};
+    });
+
+    test('line limit is based on line_length', () => {
+      element.prefs = {...element.prefs!, line_length: 100};
+      flush();
+      assert.equal(
+        getComputedStyleValue('--line-limit-marker', element),
+        '100ch'
+      );
+    });
+
+    test('content-width should not be defined', () => {
+      flush();
+      assert.equal(getComputedStyleValue('--content-width', element), 'none');
+    });
+  });
+
+  suite('SHRINK_ONLY mode', () => {
+    setup(() => {
+      element = basicFixture.instantiate();
+      element.prefs = {...MINIMAL_PREFS};
+      element.renderPrefs = {responsive_mode: 'SHRINK_ONLY'};
+    });
+
+    test('content-width should not be defined', () => {
+      flush();
+      assert.equal(getComputedStyleValue('--content-width', element), 'none');
+    });
+
+    test('max-width considers two content columns in side-by-side', () => {
+      element.viewMode = DiffViewMode.SIDE_BY_SIDE;
+      flush();
+      assert.equal(
+        getComputedStyleValue('--diff-max-width', element),
+        'calc(2 * 80ch + 2 * 48px + 0ch + 1px + 2px)'
+      );
+    });
+
+    test('max-width considers one content column in unified', () => {
+      element.viewMode = DiffViewMode.UNIFIED;
+      flush();
+      assert.equal(
+        getComputedStyleValue('--diff-max-width', element),
+        'calc(1 * 80ch + 2 * 48px + 0ch + 1px + 2px)'
+      );
+    });
+
+    test('max-width considers font-size', () => {
+      element.prefs = {...element.prefs!, font_size: 13};
+      flush();
+      // Each line number column: 4 * 13 = 52px
+      assert.equal(
+        getComputedStyleValue('--diff-max-width', element),
+        'calc(2 * 80ch + 2 * 52px + 0ch + 1px + 2px)'
+      );
+    });
+
+    test('sign cols are considered if show_sign_col is true', () => {
+      element.renderPrefs = {...element.renderPrefs, show_sign_col: true};
+      flush();
+      assert.equal(
+        getComputedStyleValue('--diff-max-width', element),
+        'calc(2 * 80ch + 2 * 48px + 2ch + 1px + 2px)'
+      );
+    });
+  });
+
+  suite('not logged in', () => {
+    setup(() => {
+      const getLoggedInPromise = Promise.resolve(false);
+      stubRestApi('getLoggedIn').returns(getLoggedInPromise);
+      element = basicFixture.instantiate();
+      return getLoggedInPromise;
+    });
+
+    test('toggleLeftDiff', () => {
+      element.toggleLeftDiff();
+      assert.isTrue(element.classList.contains('no-left'));
+      element.toggleLeftDiff();
+      assert.isFalse(element.classList.contains('no-left'));
+    });
+
+    test('view does not start with displayLine classList', () => {
+      const container = queryAndAssert(element, '.diffContainer');
+      assert.isFalse(container.classList.contains('displayLine'));
+    });
+
+    test('displayLine class added called when displayLine is true', () => {
+      const spy = sinon.spy(element, '_computeContainerClass');
+      element.displayLine = true;
+      const container = queryAndAssert(element, '.diffContainer');
+      assert.isTrue(spy.called);
+      assert.isTrue(container.classList.contains('displayLine'));
+    });
+
+    test('thread groups', () => {
+      const contentEl = document.createElement('div');
+
+      element.path = 'file.txt';
+
+      // No thread groups.
+      assert.equal(contentEl.querySelectorAll('.thread-group').length, 0);
+
+      // A thread group gets created.
+      const threadGroupEl = element._getOrCreateThreadGroup(
+        contentEl,
+        Side.LEFT
+      );
+      assert.isOk(threadGroupEl);
+
+      // The new thread group can be fetched.
+      assert.equal(contentEl.querySelectorAll('.thread-group').length, 1);
+    });
+
+    suite('image diffs', () => {
+      let mockFile1: ImageInfo;
+      let mockFile2: ImageInfo;
+      setup(() => {
+        mockFile1 = {
+          body:
+            'Qk06AAAAAAAAADYAAAAoAAAAAQAAAP////8BACAAAAAAAAAAAAATCwAAE' +
+            'wsAAAAAAAAAAAAAAAAA/w==',
+          type: 'image/bmp',
+        };
+        mockFile2 = {
+          body:
+            'Qk06AAAAAAAAADYAAAAoAAAAAQAAAP////8BACAAAAAAAAAAAAATCwAAE' +
+            'wsAAAAAAAAAAAAA/////w==',
+          type: 'image/bmp',
+        };
+
+        element.isImageDiff = true;
+        element.prefs = {
+          context: 10,
+          cursor_blink_rate: 0,
+          font_size: 12,
+          ignore_whitespace: 'IGNORE_NONE',
+          line_length: 100,
+          line_wrapping: false,
+          show_line_endings: true,
+          show_tabs: true,
+          show_whitespace_errors: true,
+          syntax_highlighting: true,
+          tab_size: 8,
+        };
+      });
+
+      test('renders image diffs with same file name', async () => {
+        element.baseImage = mockFile1;
+        element.revisionImage = mockFile2;
+        element.diff = {
+          meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
+          meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 560},
+          intraline_status: 'OK',
+          change_type: 'MODIFIED',
+          diff_header: [
+            'diff --git a/carrot.jpg b/carrot.jpg',
+            'index 2adc47d..f9c2f2c 100644',
+            '--- a/carrot.jpg',
+            '+++ b/carrot.jpg',
+            'Binary files differ',
+          ],
+          content: [{skip: 66}],
+          binary: true,
+        };
+        await waitForEventOnce(element, 'render');
+
+        // Recognizes that it should be an image diff.
+        assert.isTrue(element.isImageDiff);
+        assert.instanceOf(element.diffBuilder.builder, GrDiffBuilderImage);
+
+        // Left image rendered with the parent commit's version of the file.
+        const diffTable = element.$.diffTable;
+        const leftImage = queryAndAssert(diffTable, 'td.left img');
+        const leftLabel = queryAndAssert(diffTable, 'td.left label');
+        const leftLabelContent = queryAndAssert(leftLabel, '.label');
+        const leftLabelName = query(leftLabel, '.name');
+
+        const rightImage = queryAndAssert(diffTable, 'td.right img');
+        const rightLabel = queryAndAssert(diffTable, 'td.right label');
+        const rightLabelContent = queryAndAssert(rightLabel, '.label');
+        const rightLabelName = query(rightLabel, '.name');
+
+        assert.isNotOk(rightLabelName);
+        assert.isNotOk(leftLabelName);
+
+        assert.equal(
+          leftImage.getAttribute('src'),
+          'data:image/bmp;base64,' + mockFile1.body
+        );
+        assert.isTrue(leftLabelContent.textContent?.includes('image/bmp'));
+
+        assert.equal(
+          rightImage.getAttribute('src'),
+          'data:image/bmp;base64,' + mockFile2.body
+        );
+        assert.isTrue(rightLabelContent.textContent?.includes('image/bmp'));
+      });
+
+      test('renders image diffs with a different file name', async () => {
+        const mockDiff: DiffInfo = {
+          meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
+          meta_b: {name: 'carrot2.jpg', content_type: 'image/jpeg', lines: 560},
+          intraline_status: 'OK',
+          change_type: 'MODIFIED',
+          diff_header: [
+            'diff --git a/carrot.jpg b/carrot2.jpg',
+            'index 2adc47d..f9c2f2c 100644',
+            '--- a/carrot.jpg',
+            '+++ b/carrot2.jpg',
+            'Binary files differ',
+          ],
+          content: [{skip: 66}],
+          binary: true,
+        };
+
+        element.baseImage = mockFile1;
+        element.baseImage._name = mockDiff.meta_a!.name;
+        element.revisionImage = mockFile2;
+        element.revisionImage._name = mockDiff.meta_b!.name;
+        element.diff = mockDiff;
+        await waitForEventOnce(element, 'render');
+
+        // Recognizes that it should be an image diff.
+        assert.isTrue(element.isImageDiff);
+        assert.instanceOf(element.diffBuilder.builder, GrDiffBuilderImage);
+
+        // Left image rendered with the parent commit's version of the file.
+        const diffTable = element.$.diffTable;
+        const leftImage = queryAndAssert(diffTable, 'td.left img');
+        const leftLabel = queryAndAssert(diffTable, 'td.left label');
+        const leftLabelContent = queryAndAssert(leftLabel, '.label');
+        const leftLabelName = queryAndAssert(leftLabel, '.name');
+
+        const rightImage = queryAndAssert(diffTable, 'td.right img');
+        const rightLabel = queryAndAssert(diffTable, 'td.right label');
+        const rightLabelContent = queryAndAssert(rightLabel, '.label');
+        const rightLabelName = queryAndAssert(rightLabel, '.name');
+
+        assert.isOk(rightLabelName);
+        assert.isOk(leftLabelName);
+        assert.equal(leftLabelName.textContent, mockDiff.meta_a?.name);
+        assert.equal(rightLabelName.textContent, mockDiff.meta_b?.name);
+
+        assert.isOk(leftImage);
+        assert.equal(
+          leftImage.getAttribute('src'),
+          'data:image/bmp;base64,' + mockFile1.body
+        );
+        assert.isTrue(leftLabelContent.textContent?.includes('image/bmp'));
+
+        assert.isOk(rightImage);
+        assert.equal(
+          rightImage.getAttribute('src'),
+          'data:image/bmp;base64,' + mockFile2.body
+        );
+        assert.isTrue(rightLabelContent.textContent?.includes('image/bmp'));
+      });
+
+      test('renders added image', async () => {
+        const mockDiff: DiffInfo = {
+          meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 560},
+          intraline_status: 'OK',
+          change_type: 'ADDED',
+          diff_header: [
+            'diff --git a/carrot.jpg b/carrot.jpg',
+            'index 0000000..f9c2f2c 100644',
+            '--- /dev/null',
+            '+++ b/carrot.jpg',
+            'Binary files differ',
+          ],
+          content: [{skip: 66}],
+          binary: true,
+        };
+
+        const promise = mockPromise();
+        function rendered() {
+          promise.resolve();
+        }
+        element.addEventListener('render', rendered);
+
+        element.revisionImage = mockFile2;
+        element.diff = mockDiff;
+        await promise;
+        element.removeEventListener('render', rendered);
+        // Recognizes that it should be an image diff.
+        assert.isTrue(element.isImageDiff);
+        assert.instanceOf(element.diffBuilder.builder, GrDiffBuilderImage);
+
+        const diffTable = element.$.diffTable;
+        const leftImage = query(diffTable, 'td.left img');
+        assert.isNotOk(leftImage);
+        queryAndAssert(diffTable, 'td.right img');
+      });
+
+      test('renders removed image', async () => {
+        const mockDiff: DiffInfo = {
+          meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 560},
+          intraline_status: 'OK',
+          change_type: 'DELETED',
+          diff_header: [
+            'diff --git a/carrot.jpg b/carrot.jpg',
+            'index f9c2f2c..0000000 100644',
+            '--- a/carrot.jpg',
+            '+++ /dev/null',
+            'Binary files differ',
+          ],
+          content: [{skip: 66}],
+          binary: true,
+        };
+        const promise = mockPromise();
+        function rendered() {
+          promise.resolve();
+        }
+        element.addEventListener('render', rendered);
+
+        element.baseImage = mockFile1;
+        element.diff = mockDiff;
+        await promise;
+        element.removeEventListener('render', rendered);
+        // Recognizes that it should be an image diff.
+        assert.isTrue(element.isImageDiff);
+        assert.instanceOf(element.diffBuilder.builder, GrDiffBuilderImage);
+
+        const diffTable = element.$.diffTable;
+        queryAndAssert(diffTable, 'td.left img');
+        const rightImage = query(diffTable, 'td.right img');
+        assert.isNotOk(rightImage);
+      });
+
+      test('does not render disallowed image type', async () => {
+        const mockDiff: DiffInfo = {
+          meta_a: {
+            name: 'carrot.jpg',
+            content_type: 'image/jpeg-evil',
+            lines: 560,
+          },
+          intraline_status: 'OK',
+          change_type: 'DELETED',
+          diff_header: [
+            'diff --git a/carrot.jpg b/carrot.jpg',
+            'index f9c2f2c..0000000 100644',
+            '--- a/carrot.jpg',
+            '+++ /dev/null',
+            'Binary files differ',
+          ],
+          content: [{skip: 66}],
+          binary: true,
+        };
+        mockFile1.type = 'image/jpeg-evil';
+
+        const promise = mockPromise();
+        function rendered() {
+          promise.resolve();
+        }
+        element.addEventListener('render', rendered);
+
+        element.baseImage = mockFile1;
+        element.diff = mockDiff;
+        await promise;
+        element.removeEventListener('render', rendered);
+        // Recognizes that it should be an image diff.
+        assert.isTrue(element.isImageDiff);
+        assert.instanceOf(element.diffBuilder.builder, GrDiffBuilderImage);
+        const diffTable = element.$.diffTable;
+        const leftImage = query(diffTable, 'td.left img');
+        assert.isNotOk(leftImage);
+      });
+    });
+
+    test('_handleTap lineNum', async () => {
+      const addDraftStub = sinon.stub(element, 'addDraftAtLine');
+      const el = document.createElement('div');
+      el.className = 'lineNum';
+      const promise = mockPromise();
+      el.addEventListener('click', e => {
+        element._handleTap(e);
+        assert.isTrue(addDraftStub.called);
+        assert.equal(addDraftStub.lastCall.args[0], el);
+        promise.resolve();
+      });
+      el.click();
+      await promise;
+    });
+
+    test('_handleTap content', async () => {
+      const content = document.createElement('div');
+      const lineEl = document.createElement('div');
+      lineEl.className = 'lineNum';
+      const row = document.createElement('div');
+      row.appendChild(lineEl);
+      row.appendChild(content);
+
+      const selectStub = sinon.stub(element, '_selectLine');
+
+      content.className = 'content';
+      const promise = mockPromise();
+      content.addEventListener('click', e => {
+        element._handleTap(e);
+        assert.isTrue(selectStub.called);
+        assert.equal(selectStub.lastCall.args[0], lineEl);
+        promise.resolve();
+      });
+      content.click();
+      await promise;
+    });
+
+    suite('getCursorStops', () => {
+      function setupDiff() {
+        element.diff = createDiff();
+        element.prefs = {
+          context: 10,
+          tab_size: 8,
+          font_size: 12,
+          line_length: 100,
+          cursor_blink_rate: 0,
+          line_wrapping: false,
+
+          show_line_endings: true,
+          show_tabs: true,
+          show_whitespace_errors: true,
+          syntax_highlighting: true,
+          ignore_whitespace: 'IGNORE_NONE',
+        };
+
+        element._renderDiffTable();
+
+        flush();
+      }
+
+      test('returns [] when hidden and noAutoRender', () => {
+        element.noAutoRender = true;
+        setupDiff();
+        element._setLoading(false);
+        flush();
+        element.hidden = true;
+        assert.equal(element.getCursorStops().length, 0);
+      });
+
+      test('returns one stop per line and one for the file row', () => {
+        setupDiff();
+        element._setLoading(false);
+        flush();
+        const ROWS = 48;
+        const FILE_ROW = 1;
+        assert.equal(element.getCursorStops().length, ROWS + FILE_ROW);
+      });
+
+      test('returns an additional AbortStop when still loading', () => {
+        setupDiff();
+        element._setLoading(true);
+        flush();
+        const ROWS = 48;
+        const FILE_ROW = 1;
+        const actual = element.getCursorStops();
+        assert.equal(actual.length, ROWS + FILE_ROW + 1);
+        assert.isTrue(actual[actual.length - 1] instanceof AbortStop);
+      });
+    });
+
+    test('adds .hiddenscroll', () => {
+      _setHiddenScroll(true);
+      element.displayLine = true;
+      const container = queryAndAssert(element, '.diffContainer');
+      assert.include(container.className, 'hiddenscroll');
+    });
+  });
+
+  suite('logged in', () => {
+    let fakeLineEl: HTMLElement;
+    setup(() => {
+      element = basicFixture.instantiate();
+      element.loggedIn = true;
+
+      fakeLineEl = {
+        getAttribute: sinon.stub().returns(42),
+        classList: {
+          contains: sinon.stub().returns(true),
+        },
+      } as unknown as HTMLElement;
+    });
+
+    test('addDraftAtLine', () => {
+      sinon.stub(element, '_selectLine');
+      const createCommentStub = sinon.stub(element, '_createComment');
+      element.addDraftAtLine(fakeLineEl);
+      assert.isTrue(createCommentStub.calledWithExactly(fakeLineEl, 42));
+    });
+
+    test('adds long range comment hint', async () => {
+      const range = {
+        start_line: 1,
+        end_line: 12,
+        start_character: 0,
+        end_character: 0,
+      };
+      const threadEl = document.createElement('div');
+      threadEl.className = 'comment-thread';
+      threadEl.setAttribute('diff-side', 'right');
+      threadEl.setAttribute('line-num', '1');
+      threadEl.setAttribute('range', JSON.stringify(range));
+      threadEl.setAttribute('slot', 'right-1');
+      const content = [
+        {
+          a: ['asdf'],
+        },
+        {
+          ab: Array(13).fill('text'),
+        },
+      ];
+      setupSampleDiff({content});
+      await waitForEventOnce(element, 'render');
+
+      element.appendChild(threadEl);
+      await waitForEventOnce(element, 'render');
+
+      const hint = queryAndAssert<GrRangedCommentHint>(
+        element,
+        'gr-ranged-comment-hint'
+      );
+      assert.deepEqual(hint.range, range);
+    });
+
+    test('no duplicate range hint for same thread', async () => {
+      const range = {
+        start_line: 1,
+        end_line: 12,
+        start_character: 0,
+        end_character: 0,
+      };
+      const threadEl = document.createElement('div');
+      threadEl.className = 'comment-thread';
+      threadEl.setAttribute('diff-side', 'right');
+      threadEl.setAttribute('line-num', '1');
+      threadEl.setAttribute('range', JSON.stringify(range));
+      threadEl.setAttribute('slot', 'right-1');
+      const firstHint = document.createElement('gr-ranged-comment-hint');
+      firstHint.range = range;
+      firstHint.setAttribute('slot', 'right-1');
+      const content = [
+        {
+          a: ['asdf'],
+        },
+        {
+          ab: Array(13).fill('text'),
+        },
+      ];
+      setupSampleDiff({content});
+      await waitForEventOnce(element, 'render');
+
+      element.appendChild(firstHint);
+      element.appendChild(threadEl);
+      await waitForEventOnce(element, 'render');
+
+      assert.equal(
+        element.querySelectorAll('gr-ranged-comment-hint').length,
+        1
+      );
+    });
+
+    test('removes long range comment hint when comment is discarded', async () => {
+      const range = {
+        start_line: 1,
+        end_line: 7,
+        start_character: 0,
+        end_character: 0,
+      };
+      const threadEl = document.createElement('div');
+      threadEl.className = 'comment-thread';
+      threadEl.setAttribute('diff-side', 'right');
+      threadEl.setAttribute('line-num', '1');
+      threadEl.setAttribute('range', JSON.stringify(range));
+      threadEl.setAttribute('slot', 'right-1');
+      const content = [
+        {
+          a: [],
+          b: [],
+        },
+        {
+          ab: Array(8).fill('text'),
+        },
+      ];
+      setupSampleDiff({content});
+      element.appendChild(threadEl);
+      await flush();
+
+      threadEl.remove();
+      await flush();
+
+      assert.isEmpty(element.querySelectorAll('gr-ranged-comment-hint'));
+    });
+
+    suite('change in preferences', () => {
+      setup(() => {
+        element.diff = {
+          meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
+          meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 560},
+          diff_header: [],
+          intraline_status: 'OK',
+          change_type: 'MODIFIED',
+          content: [{skip: 66}],
+        };
+        element.renderDiffTableTask?.flush();
+      });
+
+      test('change in preferences re-renders diff', () => {
+        const stub = sinon.stub(element, '_renderDiffTable');
+        element.prefs = {
+          ...MINIMAL_PREFS,
+        };
+        element.renderDiffTableTask?.flush();
+        assert.isTrue(stub.called);
+      });
+
+      test('adding/removing property in preferences re-renders diff', () => {
+        const stub = sinon.stub(element, '_renderDiffTable');
+        const newPrefs1: DiffPreferencesInfo = {
+          ...MINIMAL_PREFS,
+          line_wrapping: true,
+        };
+        element.prefs = newPrefs1;
+        element.renderDiffTableTask?.flush();
+        assert.isTrue(stub.called);
+        stub.reset();
+
+        const newPrefs2 = {...newPrefs1};
+        delete newPrefs2.line_wrapping;
+        element.prefs = newPrefs2;
+        element.renderDiffTableTask?.flush();
+        assert.isTrue(stub.called);
+      });
+
+      test(
+        'change in preferences does not re-renders diff with ' +
+          'noRenderOnPrefsChange',
+        () => {
+          const stub = sinon.stub(element, '_renderDiffTable');
+          element.noRenderOnPrefsChange = true;
+          element.prefs = {
+            ...MINIMAL_PREFS,
+            context: 12,
+          };
+          element.renderDiffTableTask?.flush();
+          assert.isFalse(stub.called);
+        }
+      );
+    });
+  });
+
+  suite('diff header', () => {
+    setup(() => {
+      element = basicFixture.instantiate();
+      element.diff = {
+        meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
+        meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 560},
+        diff_header: [],
+        intraline_status: 'OK',
+        change_type: 'MODIFIED',
+        content: [{skip: 66}],
+      };
+    });
+
+    test('hidden', () => {
+      assert.equal(element._diffHeaderItems.length, 0);
+      element.push('diff.diff_header', 'diff --git a/test.jpg b/test.jpg');
+      assert.equal(element._diffHeaderItems.length, 0);
+      element.push('diff.diff_header', 'index 2adc47d..f9c2f2c 100644');
+      assert.equal(element._diffHeaderItems.length, 0);
+      element.push('diff.diff_header', '--- a/test.jpg');
+      assert.equal(element._diffHeaderItems.length, 0);
+      element.push('diff.diff_header', '+++ b/test.jpg');
+      assert.equal(element._diffHeaderItems.length, 0);
+      element.push('diff.diff_header', 'test');
+      assert.equal(element._diffHeaderItems.length, 1);
+      flush();
+
+      const header = queryAndAssert(element, '#diffHeader');
+      assert.equal(header.textContent?.trim(), 'test');
+    });
+
+    test('binary files', () => {
+      element.diff!.binary = true;
+      assert.equal(element._diffHeaderItems.length, 0);
+      element.push('diff.diff_header', 'diff --git a/test.jpg b/test.jpg');
+      assert.equal(element._diffHeaderItems.length, 0);
+      element.push('diff.diff_header', 'test');
+      assert.equal(element._diffHeaderItems.length, 1);
+      element.push('diff.diff_header', 'Binary files differ');
+      assert.equal(element._diffHeaderItems.length, 1);
+    });
+  });
+
+  suite('safety and bypass', () => {
+    let renderStub: sinon.SinonStub;
+
+    setup(() => {
+      element = basicFixture.instantiate();
+      renderStub = sinon.stub(element.diffBuilder, 'render').callsFake(() => {
+        const diffTable = element.$.diffTable;
+        diffTable.dispatchEvent(
+          new CustomEvent('render', {bubbles: true, composed: true})
+        );
+        return Promise.resolve({});
+      });
+      sinon.stub(element, 'getDiffLength').returns(10000);
+      element.diff = createDiff();
+      element.noRenderOnPrefsChange = true;
+    });
+
+    test('large render w/ context = 10', async () => {
+      element.prefs = {...MINIMAL_PREFS, context: 10};
+      const promise = mockPromise();
+      function rendered() {
+        assert.isTrue(renderStub.called);
+        assert.isFalse(element._showWarning);
+        promise.resolve();
+        element.removeEventListener('render', rendered);
+      }
+      element.addEventListener('render', rendered);
+      element._renderDiffTable();
+      await promise;
+    });
+
+    test('large render w/ whole file and bypass', async () => {
+      element.prefs = {...MINIMAL_PREFS, context: -1};
+      element._safetyBypass = 10;
+      const promise = mockPromise();
+      function rendered() {
+        assert.isTrue(renderStub.called);
+        assert.isFalse(element._showWarning);
+        promise.resolve();
+        element.removeEventListener('render', rendered);
+      }
+      element.addEventListener('render', rendered);
+      element._renderDiffTable();
+      await promise;
+    });
+
+    test('large render w/ whole file and no bypass', async () => {
+      element.prefs = {...MINIMAL_PREFS, context: -1};
+      const promise = mockPromise();
+      function rendered() {
+        assert.isFalse(renderStub.called);
+        assert.isTrue(element._showWarning);
+        promise.resolve();
+        element.removeEventListener('render', rendered);
+      }
+      element.addEventListener('render', rendered);
+      element._renderDiffTable();
+      await promise;
+    });
+
+    test('toggles expand context using bypass', async () => {
+      element.prefs = {...MINIMAL_PREFS, context: 3};
+
+      element.toggleAllContext();
+      element._renderDiffTable();
+      await flush();
+
+      assert.equal(element.prefs.context, 3);
+      assert.equal(element._safetyBypass, -1);
+      assert.equal(element.diffBuilder.prefs.context, -1);
+    });
+
+    test('toggles collapse context from bypass', async () => {
+      element.prefs = {...MINIMAL_PREFS, context: 3};
+      element._safetyBypass = -1;
+
+      element.toggleAllContext();
+      element._renderDiffTable();
+      await flush();
+
+      assert.equal(element.prefs.context, 3);
+      assert.isNull(element._safetyBypass);
+      assert.equal(element.diffBuilder.prefs.context, 3);
+    });
+
+    test('toggles collapse context from pref using default', async () => {
+      element.prefs = {...MINIMAL_PREFS, context: -1};
+
+      element.toggleAllContext();
+      element._renderDiffTable();
+      await flush();
+
+      assert.equal(element.prefs.context, -1);
+      assert.equal(element._safetyBypass, 10);
+      assert.equal(element.diffBuilder.prefs.context, 10);
+    });
+  });
+
+  suite('blame', () => {
+    setup(() => {
+      element = basicFixture.instantiate();
+    });
+
+    test('unsetting', () => {
+      element.blame = [];
+      const setBlameSpy = sinon.spy(element.diffBuilder, 'setBlame');
+      element.classList.add('showBlame');
+      element.blame = null;
+      assert.isTrue(setBlameSpy.calledWithExactly(null));
+      assert.isFalse(element.classList.contains('showBlame'));
+    });
+
+    test('setting', () => {
+      element.blame = [
+        {
+          author: 'test-author',
+          time: 12345,
+          commit_msg: '',
+          id: 'commit id',
+          ranges: [{start: 1, end: 2}],
+        },
+      ];
+      assert.isTrue(element.classList.contains('showBlame'));
+    });
+  });
+
+  suite('trailing newline warnings', () => {
+    const NO_NEWLINE_LEFT = 'No newline at end of left file.';
+    const NO_NEWLINE_RIGHT = 'No newline at end of right file.';
+
+    const getWarning = (element: GrDiff) => {
+      const warningElement = queryAndAssert(element, '.newlineWarning');
+      return warningElement.textContent;
+    };
+
+    setup(() => {
+      element = basicFixture.instantiate();
+      element.showNewlineWarningLeft = false;
+      element.showNewlineWarningRight = false;
+    });
+
+    test('shows combined warning if both sides set to warn', () => {
+      element.showNewlineWarningLeft = true;
+      element.showNewlineWarningRight = true;
+      assert.include(
+        getWarning(element),
+        NO_NEWLINE_LEFT + ' \u2014 ' + NO_NEWLINE_RIGHT
+      ); // \u2014 - '—'
+    });
+
+    suite('showNewlineWarningLeft', () => {
+      test('show warning if true', () => {
+        element.showNewlineWarningLeft = true;
+        assert.include(getWarning(element), NO_NEWLINE_LEFT);
+      });
+
+      test('hide warning if false', () => {
+        element.showNewlineWarningLeft = false;
+        assert.notInclude(getWarning(element), NO_NEWLINE_LEFT);
+      });
+    });
+
+    suite('showNewlineWarningRight', () => {
+      test('show warning if true', () => {
+        element.showNewlineWarningRight = true;
+        assert.include(getWarning(element), NO_NEWLINE_RIGHT);
+      });
+
+      test('hide warning if false', () => {
+        element.showNewlineWarningRight = false;
+        assert.notInclude(getWarning(element), NO_NEWLINE_RIGHT);
+      });
+    });
+
+    test('_computeNewlineWarningClass', () => {
+      const hidden = 'newlineWarning hidden';
+      const shown = 'newlineWarning';
+      assert.equal(element._computeNewlineWarningClass(false, true), hidden);
+      assert.equal(element._computeNewlineWarningClass(true, true), hidden);
+      assert.equal(element._computeNewlineWarningClass(false, false), hidden);
+      assert.equal(element._computeNewlineWarningClass(true, false), shown);
+    });
+  });
+
+  suite('key locations', () => {
+    let renderStub: sinon.SinonStub;
+
+    setup(() => {
+      element = basicFixture.instantiate();
+      element.prefs = {...MINIMAL_PREFS};
+      renderStub = sinon.stub(element.diffBuilder, 'render');
+    });
+
+    test('lineOfInterest is a key location', () => {
+      element.lineOfInterest = {lineNum: 789, side: Side.LEFT};
+      element._renderDiffTable();
+      assert.isTrue(renderStub.called);
+      assert.deepEqual(renderStub.lastCall.args[0], {
+        left: {789: true},
+        right: {},
+      });
+    });
+
+    test('line comments are key locations', () => {
+      const threadEl = document.createElement('div');
+      threadEl.className = 'comment-thread';
+      threadEl.setAttribute('diff-side', 'right');
+      threadEl.setAttribute('line-num', '3');
+      element.appendChild(threadEl);
+      flush();
+
+      element._renderDiffTable();
+      assert.isTrue(renderStub.called);
+      assert.deepEqual(renderStub.lastCall.args[0], {
+        left: {},
+        right: {3: true},
+      });
+    });
+
+    test('file comments are key locations', () => {
+      const threadEl = document.createElement('div');
+      threadEl.className = 'comment-thread';
+      threadEl.setAttribute('diff-side', 'left');
+      element.appendChild(threadEl);
+      flush();
+
+      element._renderDiffTable();
+      assert.isTrue(renderStub.called);
+      assert.deepEqual(renderStub.lastCall.args[0], {
+        left: {FILE: true},
+        right: {},
+      });
+    });
+  });
+  const setupSampleDiff = function (params: {
+    content: DiffContent[];
+    ignore_whitespace?: IgnoreWhitespaceType;
+    binary?: boolean;
+  }) {
+    const {ignore_whitespace, content} = params;
+    // binary can't be undefined, use false if not set
+    const binary = params.binary || false;
+    element = basicFixture.instantiate();
+    element.prefs = {
+      ignore_whitespace: ignore_whitespace || 'IGNORE_ALL',
+      context: 10,
+      cursor_blink_rate: 0,
+      font_size: 12,
+
+      line_length: 100,
+      line_wrapping: false,
+      show_line_endings: true,
+      show_tabs: true,
+      show_whitespace_errors: true,
+      syntax_highlighting: true,
+      tab_size: 8,
+    };
+    element.diff = {
+      intraline_status: 'OK',
+      change_type: 'MODIFIED',
+      diff_header: [
+        'diff --git a/carrot.js b/carrot.js',
+        'index 2adc47d..f9c2f2c 100644',
+        '--- a/carrot.js',
+        '+++ b/carrot.jjs',
+        'file differ',
+      ],
+      content,
+      binary,
+    };
+    element._renderDiffTable();
+    flush();
+  };
+
+  test('clear diff table content as soon as diff changes', () => {
+    const content = [
+      {
+        a: ['all work and no play make andybons a dull boy'],
+      },
+      {
+        b: ['Non eram nescius, Brute, cum, quae summis ingeniis '],
+      },
+    ];
+    function assertDiffTableWithContent() {
+      const diffTable = element.$.diffTable;
+      assert.isTrue(diffTable.innerText.includes(content[0].a?.[0] ?? ''));
+    }
+    setupSampleDiff({content});
+    assertDiffTableWithContent();
+    element.diff = {...element.diff!};
+    // immediately cleaned up
+    const diffTable = element.$.diffTable;
+    assert.equal(diffTable.innerHTML, '');
+    element._renderDiffTable();
+    flush();
+    // rendered again
+    assertDiffTableWithContent();
+  });
+
+  suite('selection test', () => {
+    test('user-select set correctly on side-by-side view', () => {
+      const content = [
+        {
+          a: ['all work and no play make andybons a dull boy'],
+          b: ['elgoog elgoog elgoog'],
+        },
+        {
+          ab: [
+            'Non eram nescius, Brute, cum, quae summis ingeniis ',
+            'exquisitaque doctrina philosophi Graeco sermone tractavissent',
+          ],
+        },
+      ];
+      setupSampleDiff({content});
+      flush();
+
+      const diffLine = queryAll<HTMLElement>(element, '.contentText')[2];
+      assert.equal(getComputedStyle(diffLine).userSelect, 'none');
+      mouseDown(diffLine);
+      assert.equal(getComputedStyle(diffLine).userSelect, 'text');
+    });
+
+    test('user-select set correctly on unified view', () => {
+      const content = [
+        {
+          a: ['all work and no play make andybons a dull boy'],
+          b: ['elgoog elgoog elgoog'],
+        },
+        {
+          ab: [
+            'Non eram nescius, Brute, cum, quae summis ingeniis ',
+            'exquisitaque doctrina philosophi Graeco sermone tractavissent',
+          ],
+        },
+      ];
+      setupSampleDiff({content});
+      element.viewMode = DiffViewMode.UNIFIED;
+      flush();
+      const diffLine = queryAll<HTMLElement>(element, '.contentText')[2];
+      assert.equal(getComputedStyle(diffLine).userSelect, 'none');
+      mouseDown(diffLine);
+      assert.equal(getComputedStyle(diffLine).userSelect, 'text');
+    });
+  });
+
+  suite('whitespace changes only message', () => {
+    test('show the message if ignore_whitespace is criteria matches', () => {
+      setupSampleDiff({content: [{skip: 100}]});
+      assert.isTrue(
+        element.showNoChangeMessage(
+          /* loading= */ false,
+          element.prefs,
+          element._diffLength,
+          element.diff
+        )
+      );
+    });
+
+    test('do not show the message for binary files', () => {
+      setupSampleDiff({content: [{skip: 100}], binary: true});
+      assert.isFalse(
+        element.showNoChangeMessage(
+          /* loading= */ false,
+          element.prefs,
+          element._diffLength,
+          element.diff
+        )
+      );
+    });
+
+    test('do not show the message if still loading', () => {
+      setupSampleDiff({content: [{skip: 100}]});
+      assert.isFalse(
+        element.showNoChangeMessage(
+          /* loading= */ true,
+          element.prefs,
+          element._diffLength,
+          element.diff
+        )
+      );
+    });
+
+    test('do not show the message if contains valid changes', () => {
+      const content = [
+        {
+          a: ['all work and no play make andybons a dull boy'],
+          b: ['elgoog elgoog elgoog'],
+        },
+        {
+          ab: [
+            'Non eram nescius, Brute, cum, quae summis ingeniis ',
+            'exquisitaque doctrina philosophi Graeco sermone tractavissent',
+          ],
+        },
+      ];
+      setupSampleDiff({content});
+      assert.equal(element._diffLength, 3);
+      assert.isFalse(
+        element.showNoChangeMessage(
+          /* loading= */ false,
+          element.prefs,
+          element._diffLength,
+          element.diff
+        )
+      );
+    });
+
+    test('do not show message if ignore whitespace is disabled', () => {
+      const content = [
+        {
+          a: ['all work and no play make andybons a dull boy'],
+          b: ['elgoog elgoog elgoog'],
+        },
+        {
+          ab: [
+            'Non eram nescius, Brute, cum, quae summis ingeniis ',
+            'exquisitaque doctrina philosophi Graeco sermone tractavissent',
+          ],
+        },
+      ];
+      setupSampleDiff({ignore_whitespace: 'IGNORE_NONE', content});
+      assert.isFalse(
+        element.showNoChangeMessage(
+          /* loading= */ false,
+          element.prefs,
+          element._diffLength,
+          element.diff
+        )
+      );
+    });
+  });
+
+  test('getDiffLength', () => {
+    const diff = createDiff();
+    assert.equal(element.getDiffLength(diff), 52);
+  });
+});
diff --git a/polygerrit-ui/app/embed/diff/gr-syntax-layer/gr-syntax-layer-worker.ts b/polygerrit-ui/app/embed/diff/gr-syntax-layer/gr-syntax-layer-worker.ts
index 9938d34..deb075e 100644
--- a/polygerrit-ui/app/embed/diff/gr-syntax-layer/gr-syntax-layer-worker.ts
+++ b/polygerrit-ui/app/embed/diff/gr-syntax-layer/gr-syntax-layer-worker.ts
@@ -10,7 +10,7 @@
 import {Side} from '../../../constants/constants';
 import {getAppContext} from '../../../services/app-context';
 import {SyntaxLayerLine} from '../../../types/syntax-worker-api';
-import {CancelablePromise, util} from '../../../scripts/util';
+import {CancelablePromise, makeCancelable} from '../../../scripts/util';
 
 const LANGUAGE_MAP = new Map<string, string>([
   ['application/dart', 'dart'],
@@ -287,7 +287,7 @@
     code?: string
   ): CancelablePromise<SyntaxLayerLine[]> {
     const hlPromise = this.highlightService.highlight(language, code);
-    return util.makeCancelable(hlPromise);
+    return makeCancelable(hlPromise);
   }
 
   notify() {
diff --git a/polygerrit-ui/app/embed/gr-diff-app-context-init_test.js b/polygerrit-ui/app/embed/gr-diff-app-context-init_test.js
deleted file mode 100644
index bb46484..0000000
--- a/polygerrit-ui/app/embed/gr-diff-app-context-init_test.js
+++ /dev/null
@@ -1,32 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../test/common-test-setup-karma.js';
-import {createDiffAppContext} from './gr-diff-app-context-init.js';
-
-suite('gr diff app context initializer tests', () => {
-  test('all services initialized and are singletons', () => {
-    const appContext = createDiffAppContext();
-    Object.keys(appContext).forEach(serviceName => {
-      const service = appContext[serviceName];
-      assert.isNotNull(service);
-      const service2 = appContext[serviceName];
-      assert.strictEqual(service, service2);
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/embed/gr-diff-app-context-init_test.ts b/polygerrit-ui/app/embed/gr-diff-app-context-init_test.ts
new file mode 100644
index 0000000..84fd859
--- /dev/null
+++ b/polygerrit-ui/app/embed/gr-diff-app-context-init_test.ts
@@ -0,0 +1,22 @@
+/**
+ * @license
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {AppContext} from '../services/app-context';
+import '../test/common-test-setup-karma';
+import {createDiffAppContext} from './gr-diff-app-context-init';
+
+suite('gr diff app context initializer tests', () => {
+  test('all services initialized and are singletons', () => {
+    const appContext: AppContext = createDiffAppContext();
+    for (const serviceName of Object.keys(appContext) as Array<
+      keyof AppContext
+    >) {
+      const service = appContext[serviceName];
+      assert.isNotNull(service);
+      const service2 = appContext[serviceName];
+      assert.strictEqual(service, service2);
+    }
+  });
+});
diff --git a/polygerrit-ui/app/mixins/hovercard-mixin/hovercard-mixin.ts b/polygerrit-ui/app/mixins/hovercard-mixin/hovercard-mixin.ts
index 473a3ca..79a3c46 100644
--- a/polygerrit-ui/app/mixins/hovercard-mixin/hovercard-mixin.ts
+++ b/polygerrit-ui/app/mixins/hovercard-mixin/hovercard-mixin.ts
@@ -35,6 +35,11 @@
   getFocusableElements,
   getFocusableElementsReverse,
 } from '../../utils/focusable';
+import {getAppContext} from '../../services/app-context';
+import {
+  ReportingService,
+  Timer,
+} from '../../services/gr-reporting/gr-reporting';
 
 interface ReloadEventDetail {
   clearPatchset?: boolean;
@@ -147,6 +152,10 @@
 
     openedByKeyboard = false;
 
+    reporting: ReportingService = getAppContext().reportingService;
+
+    reportingTimer?: Timer;
+
     private targetCleanups: Array<() => void> = [];
 
     /** Called in disconnectedCallback. */
@@ -426,6 +435,10 @@
         this.container.removeChild(this);
       }
       document.removeEventListener('click', this.documentClickListener);
+      this.reportingTimer?.end({
+        targetId: this._target?.id,
+        tagName: this.tagName,
+      });
     };
 
     /**
@@ -520,6 +533,7 @@
         this.focus();
       }
       document.addEventListener('click', this.documentClickListener);
+      this.reportingTimer = this.reporting.getTimer('Show Hovercard');
     };
 
     updatePosition() {
diff --git a/polygerrit-ui/app/models/bulk-actions/bulk-actions-model.ts b/polygerrit-ui/app/models/bulk-actions/bulk-actions-model.ts
index b0984c1..a34f880 100644
--- a/polygerrit-ui/app/models/bulk-actions/bulk-actions-model.ts
+++ b/polygerrit-ui/app/models/bulk-actions/bulk-actions-model.ts
@@ -9,14 +9,21 @@
   NumericChangeId,
   ChangeStatus,
   ReviewerState,
+  AccountId,
   AccountInfo,
+  GroupInfo,
 } from '../../api/rest-api';
 import {Model} from '../model';
 import {Finalizable} from '../../services/registry';
 import {RestApiService} from '../../services/gr-rest-api/gr-rest-api';
 import {define} from '../dependency';
 import {select} from '../../utils/observable-util';
-import {ReviewInput, ReviewerInput} from '../../types/common';
+import {
+  ReviewInput,
+  ReviewerInput,
+  AttentionSetInput,
+} from '../../types/common';
+import {accountOrGroupKey} from '../../utils/account-util';
 
 export const bulkActionsModelToken =
   define<BulkActionsModel>('bulk-actions-model');
@@ -105,6 +112,14 @@
     this.setState({...this.subject$.getValue(), selectedChangeNums: []});
   }
 
+  selectAll() {
+    const current = this.subject$.getValue();
+    this.setState({
+      ...current,
+      selectedChangeNums: Array.from(current.allChanges.keys()),
+    });
+  }
+
   abandonChanges(
     reason?: string,
     // errorFn is needed to avoid showing an error dialog
@@ -146,14 +161,15 @@
   }
 
   addReviewers(
-    changedReviewers: Map<ReviewerState, AccountInfo[]>
+    changedReviewers: Map<ReviewerState, (AccountInfo | GroupInfo)[]>,
+    reason: string
   ): Promise<Response>[] {
     const current = this.subject$.getValue();
     const changes = current.selectedChangeNums.map(
       changeNum => current.allChanges.get(changeNum)!
     );
     return changes.map(change => {
-      const reviewersNewToChange = [
+      const reviewersNewToChange: ReviewerInput[] = [
         ReviewerState.REVIEWER,
         ReviewerState.CC,
       ].flatMap(state =>
@@ -162,8 +178,20 @@
       if (reviewersNewToChange.length === 0) {
         return Promise.resolve(new Response());
       }
+      const attentionSetUpdates: AttentionSetInput[] = reviewersNewToChange
+        .filter(reviewerInput => reviewerInput.state === ReviewerState.REVIEWER)
+        .map(reviewerInput => {
+          return {
+            // TODO: Once Groups are supported, filter them out and only add
+            // Accounts to the attention set, just like gr-reply-dialog.
+            user: reviewerInput.reviewer as AccountId,
+            reason,
+          };
+        });
       const reviewInput: ReviewInput = {
         reviewers: reviewersNewToChange,
+        ignore_automatic_attention_set_rules: true,
+        add_to_attention_set: attentionSetUpdates,
       };
       return this.restApiService.saveChangeReview(
         change._number,
@@ -234,14 +262,14 @@
   private getNewReviewersToChange(
     change: ChangeInfo,
     state: ReviewerState,
-    changedReviewers: Map<ReviewerState, AccountInfo[]>
+    changedReviewers: Map<ReviewerState, (AccountInfo | GroupInfo)[]>
   ): ReviewerInput[] {
     return (
       changedReviewers
         .get(state)
         ?.filter(account => !change.reviewers[state]?.includes(account))
         .map(account => {
-          return {state, reviewer: account._account_id!};
+          return {state, reviewer: accountOrGroupKey(account)};
         }) ?? []
     );
   }
diff --git a/polygerrit-ui/app/models/bulk-actions/bulk-actions-model_test.ts b/polygerrit-ui/app/models/bulk-actions/bulk-actions-model_test.ts
index b08455c..84d5c4e 100644
--- a/polygerrit-ui/app/models/bulk-actions/bulk-actions-model_test.ts
+++ b/polygerrit-ui/app/models/bulk-actions/bulk-actions-model_test.ts
@@ -7,6 +7,7 @@
 import {
   createAccountWithIdNameAndEmail,
   createChange,
+  createGroupInfo,
   createRevisions,
 } from '../../test/test-data-generators';
 import {
@@ -18,6 +19,7 @@
   AccountInfo,
   ReviewerState,
   AccountId,
+  GroupInfo,
 } from '../../api/rest-api';
 import {BulkActionsModel, LoadingState} from './bulk-actions-model';
 import {getAppContext} from '../../services/app-context';
@@ -126,6 +128,37 @@
     assert.equal(totalChangeCount, 2);
   });
 
+  test('selects all changes', async () => {
+    const c1 = createChange();
+    c1._number = 1 as NumericChangeId;
+    const c2 = createChange();
+    c2._number = 2 as NumericChangeId;
+    bulkActionsModel.sync([c1, c2]);
+    let selectedChangeNums = await waitUntilObserved(
+      bulkActionsModel!.selectedChangeNums$,
+      s => s.length === 0
+    );
+    let totalChangeCount = await waitUntilObserved(
+      bulkActionsModel.totalChangeCount$,
+      totalChangeCount => totalChangeCount === 2
+    );
+    assert.isEmpty(selectedChangeNums);
+    assert.equal(totalChangeCount, 2);
+
+    bulkActionsModel.selectAll();
+    selectedChangeNums = await waitUntilObserved(
+      bulkActionsModel!.selectedChangeNums$,
+      s => s.length === 2
+    );
+    totalChangeCount = await waitUntilObserved(
+      bulkActionsModel.totalChangeCount$,
+      totalChangeCount => totalChangeCount === 2
+    );
+
+    assert.sameMembers(selectedChangeNums, [c1._number, c2._number]);
+    assert.equal(totalChangeCount, 2);
+  });
+
   suite('abandon changes', () => {
     let detailedActionsStub: SinonStubbedMember<
       RestApiService['getDetailedChangesWithActions']
@@ -169,6 +202,7 @@
       createAccountWithIdNameAndEmail(0),
       createAccountWithIdNameAndEmail(1),
     ];
+    const groups: GroupInfo[] = [createGroupInfo('groupId')];
     const changes: ChangeInfo[] = [
       {
         ...createChange(),
@@ -203,21 +237,49 @@
     test('adds reviewers/cc only to changes that need it', async () => {
       bulkActionsModel.addReviewers(
         new Map([
-          [ReviewerState.REVIEWER, [accounts[0]]],
+          [ReviewerState.REVIEWER, [accounts[0], groups[0]]],
           [ReviewerState.CC, [accounts[1]]],
-        ])
+        ]),
+        '<GERRIT_ACCOUNT_12345> replied on the change'
       );
 
-      // changes[0] is not updated since it already has the reviewer & CC
-      assert.isTrue(saveChangeReviewStub.calledOnce);
+      assert.isTrue(saveChangeReviewStub.calledTwice);
+      // changes[0] only adds the group since it already has the other
+      // reviewer/CCs
       assert.sameDeepOrderedMembers(saveChangeReviewStub.firstCall.args, [
+        changes[0]._number,
+        'current',
+        {
+          reviewers: [{reviewer: groups[0].id, state: ReviewerState.REVIEWER}],
+          ignore_automatic_attention_set_rules: true,
+          add_to_attention_set: [
+            {
+              reason: '<GERRIT_ACCOUNT_12345> replied on the change',
+              user: groups[0].id,
+            },
+          ],
+        },
+      ]);
+      assert.sameDeepOrderedMembers(saveChangeReviewStub.secondCall.args, [
         changes[1]._number,
         'current',
         {
           reviewers: [
             {reviewer: accounts[0]._account_id, state: ReviewerState.REVIEWER},
+            {reviewer: groups[0].id, state: ReviewerState.REVIEWER},
             {reviewer: accounts[1]._account_id, state: ReviewerState.CC},
           ],
+          ignore_automatic_attention_set_rules: true,
+          add_to_attention_set: [
+            {
+              reason: '<GERRIT_ACCOUNT_12345> replied on the change',
+              user: accounts[0]._account_id,
+            },
+            {
+              reason: '<GERRIT_ACCOUNT_12345> replied on the change',
+              user: groups[0].id,
+            },
+          ],
         },
       ]);
     });
diff --git a/polygerrit-ui/app/models/dependency.ts b/polygerrit-ui/app/models/dependency.ts
index e7ac242c..9f7398d 100644
--- a/polygerrit-ui/app/models/dependency.ts
+++ b/polygerrit-ui/app/models/dependency.ts
@@ -102,7 +102,7 @@
  * Type Safety
  * ---
  *
- * Dependency injection is guaranteed npmtype-safe by construction due to the
+ * Dependency injection is guaranteed type-safe by construction due to the
  * typing of the token used to tie together dependency providers and dependency
  * consumers.
  *
@@ -133,16 +133,38 @@
  */
 export type Provider<T> = () => T;
 
+// Symbols to cache the providers and resolvers to avoid duplicate registration.
+const PROVIDERS_SYMBOL = Symbol('providers');
+const RESOLVERS_SYMBOL = Symbol('resolvers');
+
+interface Registrations {
+  [PROVIDERS_SYMBOL]?: Map<
+    DependencyToken<unknown>,
+    DependencyProvider<unknown>
+  >;
+  [RESOLVERS_SYMBOL]?: Map<DependencyToken<unknown>, Provider<unknown>>;
+}
 /**
  * A producer of a dependency expresses this as a need that results in a promise
  * for the given dependency.
  */
 export function provide<T>(
-  host: ReactiveControllerHost & HTMLElement,
+  host: ReactiveControllerHost & HTMLElement & Registrations,
   dependency: DependencyToken<T>,
   provider: Provider<T>
 ) {
-  host.addController(new DependencyProvider<T>(host, dependency, provider));
+  const hostProviders = (host[PROVIDERS_SYMBOL] ||= new Map<
+    DependencyToken<unknown>,
+    DependencyProvider<unknown>
+  >());
+  const oldController = hostProviders.get(dependency);
+  if (oldController) {
+    host.removeController(oldController);
+    oldController.hostDisconnected();
+  }
+  const controller = new DependencyProvider<T>(host, dependency, provider);
+  hostProviders.set(dependency, controller);
+  host.addController(controller);
 }
 
 /**
@@ -151,12 +173,21 @@
  * the injected value.
  */
 export function resolve<T>(
-  host: ReactiveControllerHost & HTMLElement,
+  host: ReactiveControllerHost & HTMLElement & Registrations,
   dependency: DependencyToken<T>
 ): Provider<T> {
-  const controller = new DependencySubscriber(host, dependency);
-  host.addController(controller);
-  return () => controller.get();
+  const hostResolvers = (host[RESOLVERS_SYMBOL] ||= new Map<
+    DependencyToken<unknown>,
+    Provider<unknown>
+  >());
+  let resolver = hostResolvers.get(dependency);
+  if (!resolver) {
+    const controller = new DependencySubscriber(host, dependency);
+    host.addController(controller);
+    resolver = () => controller.get();
+    hostResolvers.set(dependency, resolver);
+  }
+  return resolver as Provider<T>;
 }
 
 /**
@@ -249,7 +280,7 @@
 }
 
 /**
- * A resolved dependency is valid within the econnectd lifetime of a component,
+ * A resolved dependency is valid within the connected lifetime of a component,
  * namely between connectedCallback and disconnectedCallback.
  */
 interface ResolvedDependency<T> {
diff --git a/polygerrit-ui/app/models/di-provider-element_test.ts b/polygerrit-ui/app/models/di-provider-element_test.ts
index 83feac7..36d73e5 100644
--- a/polygerrit-ui/app/models/di-provider-element_test.ts
+++ b/polygerrit-ui/app/models/di-provider-element_test.ts
@@ -26,9 +26,13 @@
   @state()
   private injectedValue = '';
 
-  override connectedCallback() {
-    super.connectedCallback();
-    subscribe(this, this.getModel(), value => (this.injectedValue = value));
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.getModel(),
+      value => (this.injectedValue = value)
+    );
   }
 
   override render() {
diff --git a/polygerrit-ui/app/scripts/gr-email-suggestions-provider/gr-email-suggestions-provider.ts b/polygerrit-ui/app/scripts/gr-email-suggestions-provider/gr-email-suggestions-provider.ts
deleted file mode 100644
index 5818003..0000000
--- a/polygerrit-ui/app/scripts/gr-email-suggestions-provider/gr-email-suggestions-provider.ts
+++ /dev/null
@@ -1,39 +0,0 @@
-/**
- * @license
- * Copyright (C) 2019 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {getAccountDisplayName} from '../../utils/display-name-util';
-import {RestApiService} from '../../services/gr-rest-api/gr-rest-api';
-import {AccountInfo} from '../../types/common';
-
-export class GrEmailSuggestionsProvider {
-  constructor(private restAPI: RestApiService) {}
-
-  getSuggestions(input: string) {
-    return this.restAPI.getSuggestedAccounts(`${input}`).then(accounts => {
-      if (!accounts) {
-        return [];
-      }
-      return accounts;
-    });
-  }
-
-  makeSuggestionItem(account: AccountInfo) {
-    return {
-      name: getAccountDisplayName(undefined, account),
-      value: {account, count: 1},
-    };
-  }
-}
diff --git a/polygerrit-ui/app/scripts/gr-email-suggestions-provider/gr-email-suggestions-provider_test.ts b/polygerrit-ui/app/scripts/gr-email-suggestions-provider/gr-email-suggestions-provider_test.ts
deleted file mode 100644
index 465ba3f..0000000
--- a/polygerrit-ui/app/scripts/gr-email-suggestions-provider/gr-email-suggestions-provider_test.ts
+++ /dev/null
@@ -1,67 +0,0 @@
-/**
- * @license
- * Copyright (C) 2019 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../test/common-test-setup-karma';
-import {GrEmailSuggestionsProvider} from './gr-email-suggestions-provider';
-import {getAppContext} from '../../services/app-context';
-import {stubRestApi} from '../../test/test-utils';
-import {AccountId, EmailAddress} from '../../types/common';
-
-suite('GrEmailSuggestionsProvider tests', () => {
-  let provider: GrEmailSuggestionsProvider;
-  const account1 = {
-    name: 'Some name',
-    email: 'some@example.com' as EmailAddress,
-  };
-  const account2 = {
-    email: 'other@example.com' as EmailAddress,
-    _account_id: 3 as AccountId,
-  };
-
-  setup(() => {
-    provider = new GrEmailSuggestionsProvider(getAppContext().restApiService);
-  });
-
-  test('getSuggestions', async () => {
-    const getSuggestedAccountsStub = stubRestApi(
-      'getSuggestedAccounts'
-    ).returns(Promise.resolve([account1, account2]));
-
-    const res = await provider.getSuggestions('Some input');
-    assert.deepEqual(res, [account1, account2]);
-    assert.isTrue(getSuggestedAccountsStub.calledOnce);
-    assert.equal(getSuggestedAccountsStub.lastCall.args[0], 'Some input');
-  });
-
-  test('makeSuggestionItem', () => {
-    assert.deepEqual(provider.makeSuggestionItem(account1), {
-      name: 'Some name <some@example.com>',
-      value: {
-        account: account1,
-        count: 1,
-      },
-    });
-
-    assert.deepEqual(provider.makeSuggestionItem(account2), {
-      name: 'other@example.com <other@example.com>',
-      value: {
-        account: account2,
-        count: 1,
-      },
-    });
-  });
-});
diff --git a/polygerrit-ui/app/scripts/gr-group-suggestions-provider/gr-group-suggestions-provider.ts b/polygerrit-ui/app/scripts/gr-group-suggestions-provider/gr-group-suggestions-provider.ts
deleted file mode 100644
index ff113fb..0000000
--- a/polygerrit-ui/app/scripts/gr-group-suggestions-provider/gr-group-suggestions-provider.ts
+++ /dev/null
@@ -1,42 +0,0 @@
-/**
- * @license
- * Copyright (C) 2019 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import {RestApiService} from '../../services/gr-rest-api/gr-rest-api';
-import {GroupBaseInfo} from '../../types/common';
-
-export class GrGroupSuggestionsProvider {
-  constructor(private restAPI: RestApiService) {}
-
-  getSuggestions(input: string) {
-    return this.restAPI.getSuggestedGroups(`${input}`).then(groups => {
-      if (!groups) {
-        return [];
-      }
-      const keys = Object.keys(groups);
-      return keys.map(key => {
-        return {...groups[key], name: key};
-      });
-    });
-  }
-
-  makeSuggestionItem(suggestion: GroupBaseInfo) {
-    return {
-      name: suggestion.name,
-      value: {group: {name: suggestion.name, id: suggestion.id}},
-    };
-  }
-}
diff --git a/polygerrit-ui/app/scripts/gr-group-suggestions-provider/gr-group-suggestions-provider_test.ts b/polygerrit-ui/app/scripts/gr-group-suggestions-provider/gr-group-suggestions-provider_test.ts
deleted file mode 100644
index 41441f3..0000000
--- a/polygerrit-ui/app/scripts/gr-group-suggestions-provider/gr-group-suggestions-provider_test.ts
+++ /dev/null
@@ -1,75 +0,0 @@
-/**
- * @license
- * Copyright (C) 2019 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../test/common-test-setup-karma';
-import {GrGroupSuggestionsProvider} from './gr-group-suggestions-provider';
-import {getAppContext} from '../../services/app-context';
-import {stubRestApi} from '../../test/test-utils';
-import {GroupId, GroupName} from '../../types/common';
-
-suite('GrGroupSuggestionsProvider tests', () => {
-  let provider: GrGroupSuggestionsProvider;
-  const group1 = {
-    name: 'Some name' as GroupName,
-    id: '1' as GroupId,
-  };
-  const group2 = {
-    name: 'Other name' as GroupName,
-    id: '3' as GroupId,
-    url: 'abcd',
-  };
-
-  setup(() => {
-    provider = new GrGroupSuggestionsProvider(getAppContext().restApiService);
-  });
-
-  test('getSuggestions', async () => {
-    const getSuggestedAccountsStub = stubRestApi('getSuggestedGroups').returns(
-      Promise.resolve({
-        'Some name': {id: '1' as GroupId},
-        'Other name': {id: '3' as GroupId, url: 'abcd'},
-      })
-    );
-
-    const res = await provider.getSuggestions('Some input');
-    assert.deepEqual(res, [group1, group2]);
-    assert.isTrue(getSuggestedAccountsStub.calledOnce);
-    assert.equal(getSuggestedAccountsStub.lastCall.args[0], 'Some input');
-  });
-
-  test('makeSuggestionItem', () => {
-    assert.deepEqual(provider.makeSuggestionItem(group1), {
-      name: 'Some name' as GroupName,
-      value: {
-        group: {
-          name: 'Some name' as GroupName,
-          id: '1' as GroupId,
-        },
-      },
-    });
-
-    assert.deepEqual(provider.makeSuggestionItem(group2), {
-      name: 'Other name' as GroupName,
-      value: {
-        group: {
-          name: 'Other name' as GroupName,
-          id: '3' as GroupId,
-        },
-      },
-    });
-  });
-});
diff --git a/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.ts b/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.ts
index 78cff25..5cb57aa 100644
--- a/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.ts
+++ b/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.ts
@@ -25,105 +25,63 @@
   isReviewerGroupSuggestion,
   NumericChangeId,
   ServerInfo,
+  SuggestedReviewerInfo,
   Suggestion,
 } from '../../types/common';
-import {assertNever} from '../../utils/common-util';
+import {assertNever, intersection} from '../../utils/common-util';
 import {AutocompleteSuggestion} from '../../elements/shared/gr-autocomplete/gr-autocomplete';
-
-// TODO(TS): enum name doesn't follow typescript style guid rules
-// Rename it
-export enum SUGGESTIONS_PROVIDERS_USERS_TYPES {
-  REVIEWER = 'reviewers',
-  CC = 'ccs',
-  ANY = 'any',
-}
-
-export function isAccountSuggestions(s: Suggestion): s is AccountInfo {
-  return (s as AccountInfo)._account_id !== undefined;
-}
-
-type ApiCallCallback = (input: string) => Promise<Suggestion[] | void>;
+import {allSettled, isFulfilled} from '../../utils/async-util';
+import {notUndefined} from '../../types/types';
+import {accountKey} from '../../utils/account-util';
+import {ReviewerState} from '../../api/rest-api';
 
 export interface ReviewerSuggestionsProvider {
-  init(): void;
   getSuggestions(input: string): Promise<Suggestion[]>;
-  makeSuggestionItem(suggestion: Suggestion): AutocompleteSuggestion;
+  makeSuggestionItem(
+    suggestion: Suggestion
+  ): AutocompleteSuggestion<SuggestedReviewerInfo>;
 }
 
 export class GrReviewerSuggestionsProvider
   implements ReviewerSuggestionsProvider
 {
-  static create(
-    restApi: RestApiService,
-    changeNumber: NumericChangeId,
-    userType: SUGGESTIONS_PROVIDERS_USERS_TYPES
+  private changeNumbers: NumericChangeId[];
+
+  constructor(
+    private restApi: RestApiService,
+    private type: ReviewerState.REVIEWER | ReviewerState.CC,
+    private config: ServerInfo | undefined,
+    private loggedIn: boolean,
+    ...changeNumbers: NumericChangeId[]
   ) {
-    switch (userType) {
-      case SUGGESTIONS_PROVIDERS_USERS_TYPES.REVIEWER:
-        return new GrReviewerSuggestionsProvider(restApi, input =>
-          restApi.getChangeSuggestedReviewers(changeNumber, input)
-        );
-      case SUGGESTIONS_PROVIDERS_USERS_TYPES.CC:
-        return new GrReviewerSuggestionsProvider(restApi, input =>
-          restApi.getChangeSuggestedCCs(changeNumber, input)
-        );
-      case SUGGESTIONS_PROVIDERS_USERS_TYPES.ANY:
-        return new GrReviewerSuggestionsProvider(restApi, input =>
-          restApi.getSuggestedAccounts(`cansee:${changeNumber} ${input}`)
-        );
-      default:
-        throw new Error(`Unknown users type: ${userType}`);
-    }
+    this.changeNumbers = changeNumbers;
   }
 
-  private initPromise?: Promise<void>;
+  async getSuggestions(input: string): Promise<Suggestion[]> {
+    if (!this.loggedIn) return [];
 
-  private config?: ServerInfo;
-
-  private loggedIn = false;
-
-  private initialized = false;
-
-  private constructor(
-    private readonly _restAPI: RestApiService,
-    private readonly _apiCall: ApiCallCallback
-  ) {}
-
-  init() {
-    if (this.initPromise) {
-      return this.initPromise;
-    }
-    const getConfigPromise = this._restAPI.getConfig().then(cfg => {
-      this.config = cfg;
-    });
-    const getLoggedInPromise = this._restAPI.getLoggedIn().then(loggedIn => {
-      this.loggedIn = loggedIn;
-    });
-    this.initPromise = Promise.all([getConfigPromise, getLoggedInPromise]).then(
-      () => {
-        this.initialized = true;
-      }
+    const allResults = await allSettled(
+      this.changeNumbers.map(changeNumber =>
+        this.getSuggestionsForChange(changeNumber, input)
+      )
     );
-    return this.initPromise;
+    const allSuggestions = allResults
+      .filter(isFulfilled)
+      .map(result => result.value)
+      .filter(notUndefined);
+    return intersection(allSuggestions, (s1, s2) =>
+      this.areSameSuggestions(s1, s2)
+    );
   }
 
-  getSuggestions(input: string): Promise<Suggestion[]> {
-    if (!this.initialized || !this.loggedIn) {
-      return Promise.resolve([]);
-    }
-
-    return this._apiCall(input).then(reviewers => reviewers || []);
-  }
-
-  // this can be retyped to AutocompleteSuggestion<SuggestedReviewerInfo> but
-  // this would need to change generics of gr-autocomplete.
-  makeSuggestionItem(suggestion: Suggestion): AutocompleteSuggestion {
+  makeSuggestionItem(
+    suggestion: Suggestion
+  ): AutocompleteSuggestion<SuggestedReviewerInfo> {
     if (isReviewerAccountSuggestion(suggestion)) {
       // Reviewer is an account suggestion from getChangeSuggestedReviewers.
       return {
         name: getAccountDisplayName(this.config, suggestion.account),
-        // TODO(TS) this is temporary hack to avoid cascade of ts issues
-        value: suggestion as unknown as string,
+        value: suggestion,
       };
     }
 
@@ -131,19 +89,41 @@
       // Reviewer is a group suggestion from getChangeSuggestedReviewers.
       return {
         name: getGroupDisplayName(suggestion.group),
-        // TODO(TS) this is temporary hack to avoid cascade of ts issues
-        value: suggestion as unknown as string,
+        value: suggestion,
       };
     }
 
-    if (isAccountSuggestions(suggestion)) {
+    if (this.isAccountSuggestion(suggestion)) {
       // Reviewer is an account suggestion from getSuggestedAccounts.
       return {
         name: getAccountDisplayName(this.config, suggestion),
-        // TODO(TS) this is temporary hack to avoid cascade of ts issues
-        value: {account: suggestion, count: 1} as unknown as string,
+        value: {account: suggestion, count: 1},
       };
     }
     assertNever(suggestion, 'Received an incorrect suggestion');
   }
+
+  private getSuggestionsForChange(
+    changeNumber: NumericChangeId,
+    input: string
+  ): Promise<SuggestedReviewerInfo[] | undefined> {
+    return this.type === ReviewerState.REVIEWER
+      ? this.restApi.getChangeSuggestedReviewers(changeNumber, input)
+      : this.restApi.getChangeSuggestedCCs(changeNumber, input);
+  }
+
+  private areSameSuggestions(a: Suggestion, b: Suggestion): boolean {
+    if (isReviewerAccountSuggestion(a) && isReviewerAccountSuggestion(b)) {
+      return accountKey(a.account) === accountKey(b.account);
+    } else if (isReviewerGroupSuggestion(a) && isReviewerGroupSuggestion(b)) {
+      return a.group.id === b.group.id;
+    } else if (this.isAccountSuggestion(a) && this.isAccountSuggestion(b)) {
+      return accountKey(a) === accountKey(b);
+    }
+    return false;
+  }
+
+  private isAccountSuggestion(s: Suggestion): s is AccountInfo {
+    return (s as AccountInfo)._account_id !== undefined;
+  }
 }
diff --git a/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider_test.js b/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider_test.js
deleted file mode 100644
index 1916822..0000000
--- a/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider_test.js
+++ /dev/null
@@ -1,225 +0,0 @@
-/**
- * @license
- * Copyright (C) 2019 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../test/common-test-setup-karma.js';
-import {GrReviewerSuggestionsProvider, SUGGESTIONS_PROVIDERS_USERS_TYPES} from './gr-reviewer-suggestions-provider.js';
-import {getAppContext} from '../../services/app-context.js';
-import {stubRestApi} from '../../test/test-utils.js';
-
-suite('GrReviewerSuggestionsProvider tests', () => {
-  let _nextAccountId = 0;
-  const makeAccount = function(opt_status) {
-    const accountId = ++_nextAccountId;
-    return {
-      _account_id: accountId,
-      name: 'name ' + accountId,
-      email: 'email ' + accountId,
-      status: opt_status,
-    };
-  };
-  let _nextAccountId2 = 0;
-  const makeAccount2 = function(opt_status) {
-    const accountId2 = ++_nextAccountId2;
-    return {
-      _account_id: accountId2,
-      name: 'name ' + accountId2,
-      status: opt_status,
-    };
-  };
-
-  let owner;
-  let existingReviewer1;
-  let existingReviewer2;
-  let suggestion1;
-  let suggestion2;
-  let suggestion3;
-  let provider;
-
-  let redundantSuggestion1;
-  let redundantSuggestion2;
-  let redundantSuggestion3;
-  let change;
-
-  setup(async () => {
-    owner = makeAccount();
-    existingReviewer1 = makeAccount();
-    existingReviewer2 = makeAccount();
-    suggestion1 = {account: makeAccount()};
-    suggestion2 = {account: makeAccount()};
-    suggestion3 = {
-      group: {
-        id: 'suggested group id',
-        name: 'suggested group',
-      },
-    };
-
-    stubRestApi('getConfig').returns(Promise.resolve({}));
-
-    change = {
-      _number: 42,
-      owner,
-      reviewers: {
-        CC: [existingReviewer1],
-        REVIEWER: [existingReviewer2],
-      },
-    };
-
-    await flush();
-  });
-
-  suite('allowAnyUser set to false', () => {
-    setup(async () => {
-      provider = GrReviewerSuggestionsProvider.create(
-          getAppContext().restApiService, change._number,
-          SUGGESTIONS_PROVIDERS_USERS_TYPES.REVIEWER);
-      await provider.init();
-    });
-    suite('stubbed values for _getReviewerSuggestions', () => {
-      let getChangeSuggestedReviewersStub;
-      setup(() => {
-        getChangeSuggestedReviewersStub =
-            stubRestApi('getChangeSuggestedReviewers').callsFake(() => {
-              redundantSuggestion1 = {account: existingReviewer1};
-              redundantSuggestion2 = {account: existingReviewer2};
-              redundantSuggestion3 = {account: owner};
-              return Promise.resolve([
-                redundantSuggestion1, redundantSuggestion2,
-                redundantSuggestion3, suggestion1, suggestion2, suggestion3]);
-            });
-      });
-
-      test('makeSuggestionItem formats account or group accordingly', () => {
-        let account = makeAccount();
-        const account3 = makeAccount2();
-        let suggestion = provider.makeSuggestionItem({account});
-        assert.deepEqual(suggestion, {
-          name: account.name + ' <' + account.email + '>',
-          value: {account},
-        });
-
-        const group = {name: 'test'};
-        suggestion = provider.makeSuggestionItem({group});
-        assert.deepEqual(suggestion, {
-          name: group.name + ' (group)',
-          value: {group},
-        });
-
-        suggestion = provider.makeSuggestionItem(account);
-        assert.deepEqual(suggestion, {
-          name: account.name + ' <' + account.email + '>',
-          value: {account, count: 1},
-        });
-
-        suggestion = provider.makeSuggestionItem({account: {}});
-        assert.deepEqual(suggestion, {
-          name: 'Anonymous',
-          value: {account: {}},
-        });
-
-        provider.config = {
-          user: {
-            anonymous_coward_name: 'Anonymous Coward Name',
-          },
-        };
-
-        suggestion = provider.makeSuggestionItem({account: {}});
-        assert.deepEqual(suggestion, {
-          name: 'Anonymous Coward Name',
-          value: {account: {}},
-        });
-
-        account = makeAccount('OOO');
-
-        suggestion = provider.makeSuggestionItem({account});
-        assert.deepEqual(suggestion, {
-          name: account.name + ' <' + account.email + '> (OOO)',
-          value: {account},
-        });
-
-        suggestion = provider.makeSuggestionItem(account);
-        assert.deepEqual(suggestion, {
-          name: account.name + ' <' + account.email + '> (OOO)',
-          value: {account, count: 1},
-        });
-
-        account3.email = undefined;
-
-        suggestion = provider.makeSuggestionItem(account3);
-        assert.deepEqual(suggestion, {
-          name: account3.name,
-          value: {account: account3, count: 1},
-        });
-      });
-
-      test('getSuggestions', async () => {
-        const reviewers = await provider.getSuggestions();
-
-        // Default is no filtering.
-        assert.equal(reviewers.length, 6);
-        assert.deepEqual(reviewers,
-            [redundantSuggestion1, redundantSuggestion2,
-              redundantSuggestion3, suggestion1,
-              suggestion2, suggestion3]);
-      });
-
-      test('getSuggestions short circuits when logged out', () => {
-        provider.loggedIn = false;
-        return provider.getSuggestions('').then(() => {
-          assert.isFalse(getChangeSuggestedReviewersStub.called);
-          provider.loggedIn = true;
-          return provider.getSuggestions('').then(() => {
-            assert.isTrue(getChangeSuggestedReviewersStub.called);
-          });
-        });
-      });
-    });
-
-    test('getChangeSuggestedReviewers is used', async () => {
-      const suggestReviewerStub = stubRestApi('getChangeSuggestedReviewers')
-          .returns(Promise.resolve([]));
-      const suggestAccountStub = stubRestApi('getSuggestedAccounts')
-          .returns(Promise.resolve([]));
-
-      await provider.getSuggestions('');
-      assert.isTrue(suggestReviewerStub.calledOnce);
-      assert.isTrue(suggestReviewerStub.calledWith(42, ''));
-      assert.isFalse(suggestAccountStub.called);
-    });
-  });
-
-  suite('allowAnyUser set to true', () => {
-    setup(async () => {
-      provider = GrReviewerSuggestionsProvider.create(
-          getAppContext().restApiService, change._number,
-          SUGGESTIONS_PROVIDERS_USERS_TYPES.ANY);
-      await provider.init();
-    });
-
-    test('getSuggestedAccounts is used', async () => {
-      const suggestReviewerStub = stubRestApi('getChangeSuggestedReviewers')
-          .returns(Promise.resolve([]));
-      const suggestAccountStub = stubRestApi('getSuggestedAccounts')
-          .returns(Promise.resolve([]));
-
-      await provider.getSuggestions('');
-      assert.isFalse(suggestReviewerStub.called);
-      assert.isTrue(suggestAccountStub.calledOnce);
-      assert.isTrue(suggestAccountStub.calledWith('cansee:42 '));
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider_test.ts b/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider_test.ts
new file mode 100644
index 0000000..3dc30dd
--- /dev/null
+++ b/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider_test.ts
@@ -0,0 +1,212 @@
+/**
+ * @license
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../test/common-test-setup-karma';
+import {GrReviewerSuggestionsProvider} from './gr-reviewer-suggestions-provider';
+import {getAppContext} from '../../services/app-context';
+import {stubRestApi} from '../../test/test-utils';
+import {
+  ChangeInfo,
+  GroupId,
+  GroupName,
+  NumericChangeId,
+  ReviewerState,
+} from '../../api/rest-api';
+import {
+  SuggestedReviewerAccountInfo,
+  SuggestedReviewerGroupInfo,
+} from '../../types/common';
+import {
+  createAccountDetailWithIdNameAndEmail,
+  createChange,
+  createServerInfo,
+} from '../../test/test-data-generators';
+
+suite('GrReviewerSuggestionsProvider tests', () => {
+  const suggestion1: SuggestedReviewerAccountInfo = {
+    account: createAccountDetailWithIdNameAndEmail(3),
+    count: 1,
+  };
+  const suggestion2: SuggestedReviewerAccountInfo = {
+    account: createAccountDetailWithIdNameAndEmail(4),
+    count: 1,
+  };
+  const suggestion3: SuggestedReviewerGroupInfo = {
+    group: {
+      id: 'suggested group id' as GroupId,
+      name: 'suggested group' as GroupName,
+    },
+    count: 4,
+  };
+  const change: ChangeInfo = createChange();
+  let getChangeSuggestedReviewersStub: sinon.SinonStub;
+  let getChangeSuggestedCCsStub: sinon.SinonStub;
+  let provider: GrReviewerSuggestionsProvider;
+
+  setup(() => {
+    getChangeSuggestedReviewersStub = stubRestApi(
+      'getChangeSuggestedReviewers'
+    ).resolves([suggestion1, suggestion2, suggestion3]);
+    getChangeSuggestedCCsStub = stubRestApi('getChangeSuggestedCCs').resolves([
+      suggestion1,
+      suggestion2,
+      suggestion3,
+    ]);
+    provider = new GrReviewerSuggestionsProvider(
+      getAppContext().restApiService,
+      ReviewerState.REVIEWER,
+      createServerInfo(),
+      true,
+      change._number
+    );
+  });
+
+  test('getSuggestions', async () => {
+    const reviewers = await provider.getSuggestions('');
+
+    assert.sameDeepMembers(reviewers, [suggestion1, suggestion2, suggestion3]);
+  });
+
+  test('getSuggestions short circuits when logged out', async () => {
+    await provider.getSuggestions('');
+    assert.isTrue(getChangeSuggestedReviewersStub.calledOnce);
+
+    // not logged in
+    provider = new GrReviewerSuggestionsProvider(
+      getAppContext().restApiService,
+      ReviewerState.REVIEWER,
+      createServerInfo(),
+      false,
+      change._number
+    );
+
+    await provider.getSuggestions('');
+
+    // no additional call is made
+    assert.isTrue(getChangeSuggestedReviewersStub.calledOnce);
+  });
+
+  test('only returns REVIEWER suggestions shared by all changes', async () => {
+    getChangeSuggestedReviewersStub
+      .onSecondCall()
+      .resolves([suggestion2, suggestion3]);
+    provider = new GrReviewerSuggestionsProvider(
+      getAppContext().restApiService,
+      ReviewerState.REVIEWER,
+      createServerInfo(),
+      true,
+      ...[change._number, 43 as NumericChangeId]
+    );
+
+    // suggestion1 is excluded because it is not returned for the second
+    // change.
+    assert.sameDeepMembers(await provider.getSuggestions('s'), [
+      suggestion2,
+      suggestion3,
+    ]);
+  });
+
+  test('only returns CC suggestions shared by all changes', async () => {
+    getChangeSuggestedCCsStub
+      .onSecondCall()
+      .resolves([suggestion2, suggestion3]);
+    provider = new GrReviewerSuggestionsProvider(
+      getAppContext().restApiService,
+      ReviewerState.CC,
+      createServerInfo(),
+      true,
+      ...[change._number, 43 as NumericChangeId]
+    );
+
+    // suggestion1 is excluded because it is not returned for the second
+    // change.
+    assert.sameDeepMembers(await provider.getSuggestions('s'), [
+      suggestion2,
+      suggestion3,
+    ]);
+  });
+
+  test('makeSuggestionItem formats account or group accordingly', () => {
+    let account = createAccountDetailWithIdNameAndEmail(1);
+    const account3 = createAccountDetailWithIdNameAndEmail(2);
+    let suggestion = provider.makeSuggestionItem({account, count: 1});
+    assert.deepEqual(suggestion, {
+      name: `${account.name} <${account.email}>`,
+      value: {account, count: 1},
+    });
+
+    const group = {name: 'test' as GroupName, id: '5' as GroupId};
+    suggestion = provider.makeSuggestionItem({group, count: 1});
+    assert.deepEqual(suggestion, {
+      name: `${group.name} (group)`,
+      value: {group, count: 1},
+    });
+
+    suggestion = provider.makeSuggestionItem(account);
+    assert.deepEqual(suggestion, {
+      name: `${account.name} <${account.email}>`,
+      value: {account, count: 1},
+    });
+
+    suggestion = provider.makeSuggestionItem({account: {}, count: 1});
+    assert.deepEqual(suggestion, {
+      name: 'Name of user not set',
+      value: {account: {}, count: 1},
+    });
+
+    provider = new GrReviewerSuggestionsProvider(
+      getAppContext().restApiService,
+      ReviewerState.REVIEWER,
+      {
+        ...createServerInfo(),
+        user: {
+          anonymous_coward_name: 'Anonymous Coward Name',
+        },
+      },
+      true,
+      change._number
+    );
+
+    suggestion = provider.makeSuggestionItem({account: {}, count: 1});
+    assert.deepEqual(suggestion, {
+      name: 'Anonymous Coward Name',
+      value: {account: {}, count: 1},
+    });
+
+    account = {...createAccountDetailWithIdNameAndEmail(3), status: 'OOO'};
+
+    suggestion = provider.makeSuggestionItem({account, count: 1});
+    assert.deepEqual(suggestion, {
+      name: `${account.name} <${account.email}> (OOO)`,
+      value: {account, count: 1},
+    });
+
+    suggestion = provider.makeSuggestionItem(account);
+    assert.deepEqual(suggestion, {
+      name: `${account.name} <${account.email}> (OOO)`,
+      value: {account, count: 1},
+    });
+
+    account3.email = undefined;
+
+    suggestion = provider.makeSuggestionItem(account3);
+    assert.deepEqual(suggestion, {
+      name: account3.name,
+      value: {account: account3, count: 1},
+    });
+  });
+});
diff --git a/polygerrit-ui/app/scripts/util.ts b/polygerrit-ui/app/scripts/util.ts
index bf7120f..59ee020 100644
--- a/polygerrit-ui/app/scripts/util.ts
+++ b/polygerrit-ui/app/scripts/util.ts
@@ -19,59 +19,40 @@
   cancel(): void;
 }
 
-// TODO (dmfilippov): Each function must be exported separately. According to
-// the code style guide, a namespacing is not allowed.
-export const util = {
-  getCookie(name: string) {
-    const key = name + '=';
-    const cookies = document.cookie.split(';');
-    for (let i = 0; i < cookies.length; i++) {
-      let c = cookies[i];
-      while (c.charAt(0) === ' ') {
-        c = c.substring(1);
-      }
-      if (c.startsWith(key)) {
-        return c.substring(key.length, c.length);
-      }
+/**
+ * Make the promise cancelable.
+ *
+ * Returns a promise with a `cancel()` method wrapped around `promise`.
+ * Calling `cancel()` will reject the returned promise with
+ * {isCancelled: true} synchronously. If the inner promise for a cancelled
+ * promise resolves or rejects this is ignored.
+ */
+export function makeCancelable<T>(promise: Promise<T>) {
+  // True if the promise is either resolved or reject (possibly cancelled)
+  let isDone = false;
+
+  let rejectPromise: (reason?: unknown) => void;
+
+  const wrappedPromise: CancelablePromise<T> = new Promise(
+    (resolve, reject) => {
+      rejectPromise = reject;
+      promise.then(
+        val => {
+          if (!isDone) resolve(val);
+          isDone = true;
+        },
+        error => {
+          if (!isDone) reject(error);
+          isDone = true;
+        }
+      );
     }
-    return '';
-  },
+  ) as CancelablePromise<T>;
 
-  /**
-   * Make the promise cancelable.
-   *
-   * Returns a promise with a `cancel()` method wrapped around `promise`.
-   * Calling `cancel()` will reject the returned promise with
-   * {isCancelled: true} synchronously. If the inner promise for a cancelled
-   * promise resolves or rejects this is ignored.
-   */
-  makeCancelable<T>(promise: Promise<T>) {
-    // True if the promise is either resolved or reject (possibly cancelled)
-    let isDone = false;
-
-    let rejectPromise: (reason?: unknown) => void;
-
-    const wrappedPromise: CancelablePromise<T> = new Promise(
-      (resolve, reject) => {
-        rejectPromise = reject;
-        promise.then(
-          val => {
-            if (!isDone) resolve(val);
-            isDone = true;
-          },
-          error => {
-            if (!isDone) reject(error);
-            isDone = true;
-          }
-        );
-      }
-    ) as CancelablePromise<T>;
-
-    wrappedPromise.cancel = () => {
-      if (isDone) return;
-      rejectPromise({isCanceled: true});
-      isDone = true;
-    };
-    return wrappedPromise;
-  },
-};
+  wrappedPromise.cancel = () => {
+    if (isDone) return;
+    rejectPromise({isCanceled: true});
+    isDone = true;
+  };
+  return wrappedPromise;
+}
diff --git a/polygerrit-ui/app/services/flags/flags.ts b/polygerrit-ui/app/services/flags/flags.ts
index 3ddff60..44d63d4b 100644
--- a/polygerrit-ui/app/services/flags/flags.ts
+++ b/polygerrit-ui/app/services/flags/flags.ts
@@ -28,8 +28,6 @@
 export enum KnownExperimentId {
   NEW_IMAGE_DIFF_UI = 'UiFeature__new_image_diff_ui',
   CHECKS_DEVELOPER = 'UiFeature__checks_developer',
-  SUBMIT_REQUIREMENTS_UI = 'UiFeature__submit_requirements_ui',
   BULK_ACTIONS = 'UiFeature__bulk_actions_dashboard',
-  CHECK_RESULTS_IN_DIFFS = 'UiFeature__check_results_in_diffs',
   DIFF_RENDERING_LIT = 'UiFeature__diff_rendering_lit',
 }
diff --git a/polygerrit-ui/app/services/gr-auth/gr-auth_impl.ts b/polygerrit-ui/app/services/gr-auth/gr-auth_impl.ts
index 5f77e8a..028b2af 100644
--- a/polygerrit-ui/app/services/gr-auth/gr-auth_impl.ts
+++ b/polygerrit-ui/app/services/gr-auth/gr-auth_impl.ts
@@ -62,7 +62,7 @@
 
   static CREDS_EXPIRED_MSG = 'Credentials expired.';
 
-  private authCheckPromise?: Promise<Response>;
+  private authCheckPromise?: Promise<boolean>;
 
   private _last_auth_check_time: number = Date.now();
 
@@ -100,37 +100,37 @@
       Date.now() - this._last_auth_check_time > MAX_AUTH_CHECK_WAIT_TIME_MS
     ) {
       // Refetch after last check expired
-      this.authCheckPromise = fetch(`${this.baseUrl}/auth-check`);
+      this.authCheckPromise = fetch(`${this.baseUrl}/auth-check`)
+        .then(res => {
+          // Make a call that requires loading the body of the request. This makes it so that the browser
+          // can close the request even though callers of this method might only ever read headers.
+          // See https://stackoverflow.com/questions/45816743/how-to-solve-this-caution-request-is-not-finished-yet-in-chrome
+          try {
+            res.clone().text();
+          } catch {
+            // Ignore error
+          }
+
+          // auth-check will return 204 if authed
+          // treat the rest as unauthed
+          if (res.status === 204) {
+            this._setStatus(Auth.STATUS.AUTHED);
+            return true;
+          } else {
+            this._setStatus(Auth.STATUS.NOT_AUTHED);
+            return false;
+          }
+        })
+        .catch(() => {
+          this._setStatus(AuthStatus.ERROR);
+          // Reset authCheckPromise to avoid caching the failed promise
+          this.authCheckPromise = undefined;
+          return false;
+        });
       this._last_auth_check_time = Date.now();
     }
 
-    return this.authCheckPromise
-      .then(res => {
-        // Make a call that requires loading the body of the request. This makes it so that the browser
-        // can close the request even though callers of this method might only ever read headers.
-        // See https://stackoverflow.com/questions/45816743/how-to-solve-this-caution-request-is-not-finished-yet-in-chrome
-        try {
-          res.clone().text();
-        } catch {
-          // Ignore error
-        }
-
-        // auth-check will return 204 if authed
-        // treat the rest as unauthed
-        if (res.status === 204) {
-          this._setStatus(Auth.STATUS.AUTHED);
-          return true;
-        } else {
-          this._setStatus(Auth.STATUS.NOT_AUTHED);
-          return false;
-        }
-      })
-      .catch(() => {
-        this._setStatus(AuthStatus.ERROR);
-        // Reset authCheckPromise to avoid caching the failed promise
-        this.authCheckPromise = undefined;
-        return false;
-      });
+    return this.authCheckPromise;
   }
 
   clearCache() {
diff --git a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl.ts b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl.ts
index 8e6a147..3193833 100644
--- a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl.ts
+++ b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl.ts
@@ -1817,7 +1817,7 @@
   }
 
   getChangesWithSimilarTopic(topic: string): Promise<ChangeInfo[] | undefined> {
-    const query = [`intopic:"${topic}"`].join(' ');
+    const query = `intopic:"${topic}"`;
     return this._restApiHelper.fetchJSON({
       url: '/changes/',
       params: {q: query},
@@ -1825,6 +1825,17 @@
     }) as Promise<ChangeInfo[] | undefined>;
   }
 
+  getChangesWithSimilarHashtag(
+    hashtag: string
+  ): Promise<ChangeInfo[] | undefined> {
+    const query = `inhashtag:"${hashtag}"`;
+    return this._restApiHelper.fetchJSON({
+      url: '/changes/',
+      params: {q: query},
+      anonymizedUrl: '/changes/inhashtag:*',
+    }) as Promise<ChangeInfo[] | undefined>;
+  }
+
   getReviewedFiles(
     changeNum: NumericChangeId,
     patchNum: PatchSetNum
@@ -2691,20 +2702,22 @@
 
     return Promise.all([promiseA, promiseB]).then(results => {
       // Sometimes the server doesn't send back the content type.
-      const baseImage: Base64ImageFile | null = results[0]
-        ? {
-            ...results[0],
-            _expectedType: diff.meta_a.content_type,
-            _name: diff.meta_a.name,
-          }
-        : null;
-      const revisionImage: Base64ImageFile | null = results[1]
-        ? {
-            ...results[1],
-            _expectedType: diff.meta_b.content_type,
-            _name: diff.meta_b.name,
-          }
-        : null;
+      const baseImage: Base64ImageFile | null =
+        results[0] && diff.meta_a
+          ? {
+              ...results[0],
+              _expectedType: diff.meta_a.content_type,
+              _name: diff.meta_a.name,
+            }
+          : null;
+      const revisionImage: Base64ImageFile | null =
+        results[1] && diff.meta_b
+          ? {
+              ...results[1],
+              _expectedType: diff.meta_b.content_type,
+              _name: diff.meta_b.name,
+            }
+          : null;
       const imagesForDiff: ImagesForDiff = {baseImage, revisionImage};
       return imagesForDiff;
     });
diff --git a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts
index e727216..0ea561f 100644
--- a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts
+++ b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts
@@ -634,6 +634,9 @@
     }
   ): Promise<ChangeInfo[] | undefined>;
   getChangesWithSimilarTopic(topic: string): Promise<ChangeInfo[] | undefined>;
+  getChangesWithSimilarHashtag(
+    hashtag: string
+  ): Promise<ChangeInfo[] | undefined>;
 
   hasPendingDiffDrafts(): number;
   awaitPendingDiffDrafts(): Promise<void>;
diff --git a/polygerrit-ui/app/services/registry.ts b/polygerrit-ui/app/services/registry.ts
index e7de1ef..74b6997 100644
--- a/polygerrit-ui/app/services/registry.ts
+++ b/polygerrit-ui/app/services/registry.ts
@@ -73,7 +73,7 @@
             initializing = true;
             initialized.set(name, factory(context));
           } catch (e) {
-            console.error(`Failed to initialize ${name}`, e);
+            console.error(`Failed to initialize ${String(name)}`, e);
           } finally {
             initializing = false;
           }
diff --git a/polygerrit-ui/app/styles/gr-font-styles.ts b/polygerrit-ui/app/styles/gr-font-styles.ts
index 422a7c5..670b576 100644
--- a/polygerrit-ui/app/styles/gr-font-styles.ts
+++ b/polygerrit-ui/app/styles/gr-font-styles.ts
@@ -45,6 +45,12 @@
     font-weight: var(--font-weight-h3);
     line-height: var(--line-height-h3);
   }
+  .heading-4 {
+    font-family: var(--header-font-family);
+    font-size: var(--font-size-normal);
+    font-weight: var(--font-weight-h4);
+    line-height: var(--line-height-normal);
+  }
   strong {
     font-weight: var(--font-weight-bold);
   }
diff --git a/polygerrit-ui/app/styles/themes/app-theme.ts b/polygerrit-ui/app/styles/themes/app-theme.ts
index 0aae217..dc17ee6 100644
--- a/polygerrit-ui/app/styles/themes/app-theme.ts
+++ b/polygerrit-ui/app/styles/themes/app-theme.ts
@@ -46,6 +46,7 @@
     --blue-50: #e8f0fe;
     --blue-tonal: #314972;
     --orange-900: #b06000;
+    --orange-800: #c26401;
     --orange-700: #d56e0c;
     --orange-700-04: #d56e0c0a;
     --orange-700-10: #d56e0c1a;
@@ -338,7 +339,8 @@
     --font-weight-bold: 500;
     --font-weight-h1: 400;
     --font-weight-h2: 400;
-    --font-weight-h3: var(--font-weight-bold, 500);
+    --font-weight-h3: 400;
+    --font-weight-h4: 600;
     --context-control-button-font: var(--font-weight-normal)
       var(--font-size-normal) var(--font-family);
     --code-hint-font-weight: 500;
diff --git a/polygerrit-ui/app/test/mocks/comment-api.js b/polygerrit-ui/app/test/mocks/comment-api.js
deleted file mode 100644
index fc4599d..0000000
--- a/polygerrit-ui/app/test/mocks/comment-api.js
+++ /dev/null
@@ -1,47 +0,0 @@
-/**
- * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import {PolymerElement} from '@polymer/polymer/polymer-element.js';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
-
-/**
- * This is an "abstract" class for tests. The descendant must define a template
- * for this element and a tagName - see createCommentApiMockWithTemplateElement below
- */
-class CommentApiMock extends LegacyElementMixin(PolymerElement) {
-  static get properties() {
-    return {
-      _changeComments: Object,
-    };
-  }
-}
-
-/**
- * Creates a new element which is descendant of CommentApiMock with specified
- * template. Additionally, the method registers a tagName for this element.
- *
- * Each tagName must be a unique accross all tests.
- */
-export function createCommentApiMockWithTemplateElement(tagName, template) {
-  const elementClass = class extends CommentApiMock {
-    static get is() { return tagName; }
-
-    static get template() { return template; }
-  };
-  customElements.define(tagName, elementClass);
-  return elementClass;
-}
diff --git a/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts b/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
index d91b438..ae8545a 100644
--- a/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
+++ b/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
@@ -280,6 +280,9 @@
   getChangesWithSimilarTopic(): Promise<ChangeInfo[] | undefined> {
     return Promise.resolve([]);
   },
+  getChangesWithSimilarHashtag(): Promise<ChangeInfo[] | undefined> {
+    return Promise.resolve([]);
+  },
   getConfig(): Promise<ServerInfo | undefined> {
     return Promise.resolve(createServerInfo());
   },
diff --git a/polygerrit-ui/app/test/test-data-generators.ts b/polygerrit-ui/app/test/test-data-generators.ts
index 2fffc9a..8e8fe42 100644
--- a/polygerrit-ui/app/test/test-data-generators.ts
+++ b/polygerrit-ui/app/test/test-data-generators.ts
@@ -121,7 +121,7 @@
   SubmitRequirementResultInfo,
   SubmitRequirementStatus,
 } from '../api/rest-api';
-import {RunResult} from '../models/checks/checks-model';
+import {CheckResult, RunResult} from '../models/checks/checks-model';
 import {Category, RunStatus} from '../api/checks';
 import {DiffInfo} from '../api/diff';
 
@@ -469,6 +469,24 @@
   };
 }
 
+export function createEmptyDiff(): DiffInfo {
+  return {
+    meta_a: {
+      name: 'empty-left.txt',
+      content_type: 'text/plain',
+      lines: 1,
+    },
+    meta_b: {
+      name: 'empty-right.txt',
+      content_type: 'text/plain',
+      lines: 1,
+    },
+    intraline_status: 'OK',
+    change_type: 'MODIFIED',
+    content: [],
+  };
+}
+
 export function createDiff(): DiffInfo {
   return {
     meta_a: {
@@ -942,6 +960,14 @@
   };
 }
 
+export function createCheckResult(): CheckResult {
+  return {
+    category: Category.ERROR,
+    summary: 'error',
+    internalResultId: 'test-internal-result-id',
+  };
+}
+
 export function createDetailedLabelInfo(): DetailedLabelInfo {
   return {
     values: {
diff --git a/polygerrit-ui/app/types/diff.ts b/polygerrit-ui/app/types/diff.ts
index 562d47f..7ad656d 100644
--- a/polygerrit-ui/app/types/diff.ts
+++ b/polygerrit-ui/app/types/diff.ts
@@ -48,9 +48,9 @@
 
 export interface DiffInfo extends DiffInfoApi {
   /** Meta information about the file on side A as a DiffFileMetaInfo entity. */
-  meta_a: DiffFileMetaInfo;
+  meta_a?: DiffFileMetaInfo;
   /** Meta information about the file on side B as a DiffFileMetaInfo entity. */
-  meta_b: DiffFileMetaInfo;
+  meta_b?: DiffFileMetaInfo;
 
   /**
    * Links to the file diff in external sites as a list of DiffWebLinkInfo
diff --git a/polygerrit-ui/app/utils/account-util.ts b/polygerrit-ui/app/utils/account-util.ts
index b7cc77b..b6018ba 100644
--- a/polygerrit-ui/app/utils/account-util.ts
+++ b/polygerrit-ui/app/utils/account-util.ts
@@ -35,7 +35,7 @@
 export const ACCOUNT_TEMPLATE_REGEX = '<GERRIT_ACCOUNT_(\\d+)>';
 
 export function accountKey(account: AccountInfo): AccountId | EmailAddress {
-  if (account._account_id) return account._account_id;
+  if (account._account_id !== undefined) return account._account_id;
   if (account.email) return account.email;
   throw new Error('Account has neither _account_id nor email.');
 }
diff --git a/polygerrit-ui/app/utils/async-util.ts b/polygerrit-ui/app/utils/async-util.ts
index 3f51532..981bcae 100644
--- a/polygerrit-ui/app/utils/async-util.ts
+++ b/polygerrit-ui/app/utils/async-util.ts
@@ -146,3 +146,27 @@
 }
 
 export const isFalse = (b: boolean) => b === false;
+
+export type PromiseResult<T> =
+  | {status: 'fulfilled'; value: T}
+  | {status: 'rejected'; reason: string};
+export function isFulfilled<T>(
+  promiseResult?: PromiseResult<T>
+): promiseResult is PromiseResult<T> & {status: 'fulfilled'} {
+  return promiseResult?.status === 'fulfilled';
+}
+
+// An equivalent to Promise.allSettled from ES2020.
+// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/allSettled
+// TODO: Migrate our tooling to ES2020 and remove this method.
+export function allSettled<T>(
+  promises: Promise<T>[]
+): Promise<PromiseResult<T>[]> {
+  return Promise.all(
+    promises.map(promise =>
+      promise
+        .then(value => ({status: 'fulfilled', value} as const))
+        .catch(reason => ({status: 'rejected', reason} as const))
+    )
+  );
+}
diff --git a/polygerrit-ui/app/utils/common-util.ts b/polygerrit-ui/app/utils/common-util.ts
index 9e3bc74..95b753c 100644
--- a/polygerrit-ui/app/utils/common-util.ts
+++ b/polygerrit-ui/app/utils/common-util.ts
@@ -156,3 +156,26 @@
 export function unique<T>(item: T, index: number, array: T[]) {
   return array.indexOf(item) === index;
 }
+
+/**
+ * Returns the elements that are present in every sub-array. If a compareBy
+ * predicate is passed in, it will be used instead of strict equality. A new
+ * array is always returned even if there is already just a single array.
+ */
+export function intersection<T>(
+  arrays: T[][],
+  compareBy: (t: T, u: T) => boolean = (t, u) => t === u
+): T[] {
+  // Array.prototype.reduce needs either an initialValue or a non-empty array.
+  // Since there is no good initialValue for intersecting (∅ ∩ X = ∅), the
+  // empty array must be checked separately.
+  if (arrays.length === 0) {
+    return [];
+  }
+  if (arrays.length === 1) {
+    return [...arrays[0]];
+  }
+  return arrays.reduce((result, array) =>
+    result.filter(t => array.find(u => compareBy(t, u)))
+  );
+}
diff --git a/polygerrit-ui/app/utils/common-util_test.ts b/polygerrit-ui/app/utils/common-util_test.ts
index 4156729..0adfaa6 100644
--- a/polygerrit-ui/app/utils/common-util_test.ts
+++ b/polygerrit-ui/app/utils/common-util_test.ts
@@ -16,7 +16,12 @@
  */
 
 import '../test/common-test-setup-karma';
-import {hasOwnProperty, areSetsEqual, containsAll} from './common-util';
+import {
+  hasOwnProperty,
+  areSetsEqual,
+  containsAll,
+  intersection,
+} from './common-util';
 
 suite('common-util tests', () => {
   suite('hasOwnProperty', () => {
@@ -68,4 +73,31 @@
     assert.isFalse(containsAll(new Set([1, 2, 3, 4]), new Set([5])));
     assert.isFalse(containsAll(new Set([1, 2, 3, 4]), new Set([1, 2, 3, 5])));
   });
+
+  test('intersections', () => {
+    const arrayWithValues = [1, 2, 3];
+    assert.sameDeepMembers(intersection([]), []);
+    assert.sameDeepMembers(intersection([arrayWithValues]), arrayWithValues);
+    // a new array is returned even if a single array is provided.
+    assert.notStrictEqual(intersection([arrayWithValues]), arrayWithValues);
+    assert.sameDeepMembers(
+      intersection([
+        [1, 2, 3],
+        [2, 3, 4],
+        [5, 3, 2],
+      ]),
+      [2, 3]
+    );
+
+    const foo1 = {value: 5};
+    const foo2 = {value: 5};
+
+    // these foo's will fail strict equality with each other, but a comparator
+    // can make them intersect.
+    assert.sameDeepMembers(intersection([[foo1], [foo2]]), []);
+    assert.sameDeepMembers(
+      intersection([[foo1], [foo2]], (a, b) => a.value === b.value),
+      [foo1]
+    );
+  });
 });
diff --git a/polygerrit-ui/app/utils/date-util.ts b/polygerrit-ui/app/utils/date-util.ts
index a780af5..6d37ef6 100644
--- a/polygerrit-ui/app/utils/date-util.ts
+++ b/polygerrit-ui/app/utils/date-util.ts
@@ -44,7 +44,10 @@
 export function durationString(from: Date, to: Date, noAgo = false) {
   const ago = noAgo ? '' : ' ago';
   const secondsAgo = Math.floor((to.valueOf() - from.valueOf()) / 1000);
-  if (secondsAgo <= 59) return 'just now';
+  if (secondsAgo <= 59) {
+    if (noAgo) return `${secondsAgo} seconds`;
+    return 'just now';
+  }
   if (secondsAgo <= 119) return `1 minute${ago}`;
   const minutesAgo = Math.floor(secondsAgo / 60);
   if (minutesAgo <= 59) return `${minutesAgo} minutes${ago}`;
diff --git a/polygerrit-ui/app/utils/dom-util.ts b/polygerrit-ui/app/utils/dom-util.ts
index 16e0586..be6aa06 100644
--- a/polygerrit-ui/app/utils/dom-util.ts
+++ b/polygerrit-ui/app/utils/dom-util.ts
@@ -491,8 +491,18 @@
   return false;
 }
 
+/** Returns a promise that waits for the element's height to become > 0. */
+export function untilRendered(el: HTMLElement) {
+  return new Promise(resolve => {
+    whenRendered(el, resolve);
+  });
+}
+
 /** Executes the given callback when the element's height is > 0. */
-export function whenRendered(el: HTMLElement, callback: () => void) {
+export function whenRendered(
+  el: HTMLElement,
+  callback: (value?: unknown) => void
+) {
   if (el.clientHeight > 0) {
     callback();
     return;
diff --git a/polygerrit-ui/app/utils/event-util.ts b/polygerrit-ui/app/utils/event-util.ts
index 418adbd..e624cef 100644
--- a/polygerrit-ui/app/utils/event-util.ts
+++ b/polygerrit-ui/app/utils/event-util.ts
@@ -32,7 +32,7 @@
   );
 }
 
-type HTMLElementEventDetailType<K extends keyof HTMLElementEventMap> =
+export type HTMLElementEventDetailType<K extends keyof HTMLElementEventMap> =
   HTMLElementEventMap[K] extends CustomEvent<infer DT>
     ? unknown extends DT
       ? never
diff --git a/polygerrit-ui/app/utils/label-util.ts b/polygerrit-ui/app/utils/label-util.ts
index f5703f4..2b6f700 100644
--- a/polygerrit-ui/app/utils/label-util.ts
+++ b/polygerrit-ui/app/utils/label-util.ts
@@ -21,7 +21,6 @@
   SubmitRequirementStatus,
   LabelNameToValuesMap,
 } from '../api/rest-api';
-import {FlagsService, KnownExperimentId} from '../services/flags/flags';
 import {
   AccountInfo,
   ApprovalInfo,
@@ -421,16 +420,3 @@
     label => !labelAssociatedWithSubmitReqs.includes(label)
   );
 }
-
-export function showNewSubmitRequirements(
-  flagsService: FlagsService,
-  change?: ParsedChangeInfo | ChangeInfo
-) {
-  const isSubmitRequirementsUiEnabled = flagsService.isEnabled(
-    KnownExperimentId.SUBMIT_REQUIREMENTS_UI
-  );
-  if (!isSubmitRequirementsUiEnabled) return false;
-  if ((getRequirements(change) ?? []).length === 0) return false;
-
-  return true;
-}
diff --git a/polygerrit-ui/app/utils/string-util.ts b/polygerrit-ui/app/utils/string-util.ts
index 0a0928e..ae255ab 100644
--- a/polygerrit-ui/app/utils/string-util.ts
+++ b/polygerrit-ui/app/utils/string-util.ts
@@ -51,3 +51,21 @@
 export function capitalizeFirstLetter(str: string) {
   return str.charAt(0).toUpperCase() + str.slice(1);
 }
+
+/**
+ * Converts the items into a sentence-friendly format. Examples:
+ * listForSentence(["Foo", "Bar", "Baz"])
+ * => 'Foo, Bar, and Baz'
+ * listForSentence(["Foo", "Bar"])
+ * => 'Foo and Bar'
+ * listForSentence(["Foo"])
+ * => 'Foo'
+ */
+export function listForSentence(items: string[]): string {
+  if (items.length < 2) return items.join('');
+  if (items.length === 2) return items.join(' and ');
+
+  const firstItems = items.slice(0, items.length - 1);
+  const lastItem = items[items.length - 1];
+  return `${firstItems.join(', ')}, and ${lastItem}`;
+}
diff --git a/polygerrit-ui/app/utils/string-util_test.ts b/polygerrit-ui/app/utils/string-util_test.ts
index 8de6ac2..118b7e5 100644
--- a/polygerrit-ui/app/utils/string-util_test.ts
+++ b/polygerrit-ui/app/utils/string-util_test.ts
@@ -16,7 +16,7 @@
  */
 
 import '../test/common-test-setup-karma';
-import {pluralize, ordinal} from './string-util';
+import {pluralize, ordinal, listForSentence} from './string-util';
 
 suite('formatter util tests', () => {
   test('pluralize', () => {
@@ -39,4 +39,11 @@
     assert.equal(ordinal(44413), '44413th');
     assert.equal(ordinal(44451), '44451st');
   });
+
+  test('listForSentence', () => {
+    assert.equal(listForSentence(['Foo', 'Bar', 'Baz']), 'Foo, Bar, and Baz');
+    assert.equal(listForSentence(['Foo', 'Bar']), 'Foo and Bar');
+    assert.equal(listForSentence(['Foo']), 'Foo');
+    assert.equal(listForSentence([]), '');
+  });
 });
diff --git a/resources/com/google/gerrit/pgm/init/gerrit.sh b/resources/com/google/gerrit/pgm/init/gerrit.sh
index e7fda5a..1399b15 100755
--- a/resources/com/google/gerrit/pgm/init/gerrit.sh
+++ b/resources/com/google/gerrit/pgm/init/gerrit.sh
@@ -51,7 +51,7 @@
 
 usage() {
     me=`basename "$0"`
-    echo >&2 "Usage: $me {start|stop|restart|check|status|run|supervise|threads} [-d site]"
+    echo >&2 "Usage: $me {start|stop|restart|check|status|run|supervise|threads} [-d site] [--debug [--debug_port|--debug_address ...] [--suspend]]"
     exit 1
 }
 
@@ -148,6 +148,27 @@
     GERRIT_SITE=${1##--site-path=}
     shift
     ;;
+  --debug)
+    JVM_DEBUG=true
+    shift
+    ;;
+  --suspend)
+    JVM_DEBUG_SUSPEND=true
+    shift
+    ;;
+  --debug-port=*)
+    DEBUG_ADDRESS=${1##--debug-port=}
+    shift
+    ;;
+  --debug-address=*)
+    DEBUG_ADDRESS=${1##--debug-address=}
+    shift
+    ;;
+  --debug-port|--debug-address)
+    shift
+    DEBUG_ADDRESS=$1
+    shift
+    ;;
 
   *)
     usage
@@ -317,6 +338,20 @@
   JAVA_OPTIONS="$JAVA_OPTIONS -Xmx$GERRIT_MEMORY"
 fi
 
+if test -n "$JVM_DEBUG" ; then
+  if test -z "$DEBUG_ADDRESS" ; then
+    DEBUG_ADDRESS=8000
+  fi
+  echo "Put JVM in debug mode, debugger listens to: $DEBUG_ADDRESS"
+  if test -n "$JVM_DEBUG_SUSPEND" ; then
+    SUSPEND=y
+    echo "JVM will await for a debugger to attach"
+  else
+    SUSPEND=n
+  fi
+  JAVA_OPTIONS="$JAVA_OPTIONS -agentlib:jdwp=transport=dt_socket,server=y,suspend=$SUSPEND,address=$DEBUG_ADDRESS"
+fi
+
 GERRIT_FDS=`get_config --int core.packedGitOpenFiles`
 test -z "$GERRIT_FDS" && GERRIT_FDS=128
 FDS_MULTIPLIER=2
diff --git a/resources/com/google/gerrit/server/mail/Comment.soy b/resources/com/google/gerrit/server/mail/Comment.soy
index 4b66401..98ab4b2 100644
--- a/resources/com/google/gerrit/server/mail/Comment.soy
+++ b/resources/com/google/gerrit/server/mail/Comment.soy
@@ -26,8 +26,34 @@
   {@param email: ?}
   {@param fromName: ?}
   {@param commentFiles: ?}
+  {@param unsatisfiedSubmitRequirements: ?}
+  {@param oldSubmitRequirements: ?}
+  {@param newSubmitRequirements: ?}
   {$fromName} has posted comments on this change.
   {if $email.changeUrl} ( {$email.changeUrl} ){/if}{\n}
+  {if $unsatisfiedSubmitRequirements}
+    {\n}
+    The change is no longer submittable:{sp}
+    {if length($unsatisfiedSubmitRequirements) > 0}
+      {for $unsatisfiedSubmitRequirement, $index in $unsatisfiedSubmitRequirements}
+        {if $index > 0}
+          {if $index == length($unsatisfiedSubmitRequirements) - 1}
+            {sp}and{sp}
+          {else}
+            ,{sp}
+          {/if}
+        {/if}
+        {$unsatisfiedSubmitRequirement}
+      {/for}
+      {sp}
+      {if length($unsatisfiedSubmitRequirements) == 1}
+        is
+      {else}
+        are
+      {/if}
+      {sp}unsatisfied now.{\n}
+    {/if}
+  {/if}
   {\n}
   Change subject: {$change.subject}{\n}
   ......................................................................{\n}
diff --git a/resources/com/google/gerrit/server/mail/CommentHtml.soy b/resources/com/google/gerrit/server/mail/CommentHtml.soy
index a120cea..320122e 100644
--- a/resources/com/google/gerrit/server/mail/CommentHtml.soy
+++ b/resources/com/google/gerrit/server/mail/CommentHtml.soy
@@ -25,6 +25,9 @@
   {@param labels: ?}
   {@param patchSet: ?}
   {@param patchSetCommentBlocks: ?}
+  {@param unsatisfiedSubmitRequirements: ?}
+  {@param oldSubmitRequirements: ?}
+  {@param newSubmitRequirements: ?}
   {let $commentHeaderStyle kind="css"}
     margin-bottom: 4px;
   {/let}
@@ -99,6 +102,31 @@
     </p>
   {/if}
 
+  {if $unsatisfiedSubmitRequirements}
+    <p>
+      The change is no longer submittable:{sp}
+      {if length($unsatisfiedSubmitRequirements) > 0}
+        {for $unsatisfiedSubmitRequirement, $index in $unsatisfiedSubmitRequirements}
+          {if $index > 0}
+            {if $index == length($unsatisfiedSubmitRequirements) - 1}
+              {sp}and{sp}
+            {else}
+              ,{sp}
+            {/if}
+          {/if}
+          {$unsatisfiedSubmitRequirement}
+        {/for}
+        {sp}
+        {if length($unsatisfiedSubmitRequirements) == 1}
+          is
+        {else}
+          are
+        {/if}
+        {sp}unsatisfied now.
+      {/if}
+    </p>
+  {/if}
+
   {if $email.changeUrl}
     <p>
       {call mailTemplate.ViewChangeButton data="all" /}
diff --git a/resources/com/google/gerrit/server/mail/ReplacePatchSet.soy b/resources/com/google/gerrit/server/mail/ReplacePatchSet.soy
index 2647572..6ae8625 100644
--- a/resources/com/google/gerrit/server/mail/ReplacePatchSet.soy
+++ b/resources/com/google/gerrit/server/mail/ReplacePatchSet.soy
@@ -27,6 +27,9 @@
   {@param fromName: ?}
   {@param patchSet: ?}
   {@param projectName: ?}
+  {@param unsatisfiedSubmitRequirements: ?}
+  {@param oldSubmitRequirements: ?}
+  {@param newSubmitRequirements: ?}
   {if $email.reviewerNames and $fromEmail == $change.ownerEmail}
     Hello{sp}
     {for $reviewerName in $email.reviewerNames}
@@ -50,6 +53,40 @@
     {/if}.
     {if $email.changeUrl} ( {$email.changeUrl} ){/if}
   {/if}{\n}
+  {if $email.outdatedApprovals and length($email.outdatedApprovals) > 0}
+    {\n}
+    The following approvals got outdated and were removed:{\n}
+    {for $outdatedApproval, $index in $email.outdatedApprovals}
+      {if $index > 0}
+        ,{sp}
+      {/if}
+      {$outdatedApproval}
+    {/for}{\n}
+  {/if}
+  {if $unsatisfiedSubmitRequirements}
+    {\n}
+    The change is no longer submittable:{sp}
+    {if length($unsatisfiedSubmitRequirements) > 0}
+      {for $unsatisfiedSubmitRequirement, $index in $unsatisfiedSubmitRequirements}
+        {if $index > 0}
+          {if $index == length($unsatisfiedSubmitRequirements) - 1}
+            {sp}and{sp}
+          {else}
+            ,{sp}
+          {/if}
+        {/if}
+        {$unsatisfiedSubmitRequirement}
+      {/for}
+      {sp}
+      {if length($unsatisfiedSubmitRequirements) == 1}
+        is
+      {else}
+        are
+      {/if}
+      {sp}unsatisfied now.{\n}
+    {/if}
+  {/if}
+  {\n}
   {\n}
   Change subject: {$change.subject}{\n}
   ......................................................................{\n}
diff --git a/resources/com/google/gerrit/server/mail/ReplacePatchSetHtml.soy b/resources/com/google/gerrit/server/mail/ReplacePatchSetHtml.soy
index 4916a4a..1d99591 100644
--- a/resources/com/google/gerrit/server/mail/ReplacePatchSetHtml.soy
+++ b/resources/com/google/gerrit/server/mail/ReplacePatchSetHtml.soy
@@ -25,6 +25,9 @@
   {@param fromEmail: ?}
   {@param patchSet: ?}
   {@param projectName: ?}
+  {@param unsatisfiedSubmitRequirements: ?}
+  {@param oldSubmitRequirements: ?}
+  {@param newSubmitRequirements: ?}
   <p>
     {$fromName} <strong>uploaded patch set #{$patchSet.patchSetId}</strong>{sp}
     to{sp}
@@ -41,6 +44,43 @@
     </p>
   {/if}
 
+  {if $email.outdatedApprovals and length($email.outdatedApprovals) > 0}
+    <p>
+      The following approvals got outdated and were removed:{\n}
+      {for $outdatedApproval, $index in $email.outdatedApprovals}
+        {if $index > 0}
+          ,{sp}
+        {/if}
+        {$outdatedApproval}
+      {/for}
+    </p>
+  {/if}
+
+  {if $unsatisfiedSubmitRequirements}
+    <p>
+      The change is no longer submittable:{sp}
+      {if length($unsatisfiedSubmitRequirements) > 0}
+        {for $unsatisfiedSubmitRequirement, $index in $unsatisfiedSubmitRequirements}
+          {if $index > 0}
+            {if $index == length($unsatisfiedSubmitRequirements) - 1}
+              {sp}and{sp}
+            {else}
+              ,{sp}
+            {/if}
+          {/if}
+          {$unsatisfiedSubmitRequirement}
+        {/for}
+        {sp}
+        {if length($unsatisfiedSubmitRequirements) == 1}
+          is
+        {else}
+          are
+        {/if}
+        {sp}unsatisfied now.
+      {/if}
+    </p>
+  {/if}
+
   {call mailTemplate.Pre}
     {param content: $email.changeDetail /}
   {/call}
diff --git a/tools/deps.bzl b/tools/deps.bzl
index c4cbc40..3138d15 100644
--- a/tools/deps.bzl
+++ b/tools/deps.bzl
@@ -9,6 +9,8 @@
 MAIL_VERS = "1.6.0"
 MIME4J_VERS = "0.8.1"
 OW2_VERS = "9.2"
+AUTO_COMMON_VERSION = "1.2.1"
+AUTO_FACTORY_VERSION = "1.0.1"
 AUTO_VALUE_VERSION = "1.7.4"
 AUTO_VALUE_GSON_VERSION = "1.3.1"
 PROLOG_VERS = "1.4.4"
@@ -411,6 +413,24 @@
     )
 
     maven_jar(
+        name = "auto-common",
+        artifact = "com.google.auto:auto-common:" + AUTO_COMMON_VERSION,
+        sha1 = "f6da26895f759010f5f170c8044e84c1b17ef83e",
+    )
+
+    maven_jar(
+        name = "auto-factory",
+        artifact = "com.google.auto.factory:auto-factory:" + AUTO_FACTORY_VERSION,
+        sha1 = "f81ece06b6525085da217cd900116f44caafe877",
+    )
+
+    maven_jar(
+        name = "auto-service-annotations",
+        artifact = "com.google.auto.service:auto-service-annotations:" + AUTO_FACTORY_VERSION,
+        sha1 = "ac86dacc0eb9285ea9d42eee6aad8629ca3a7432",
+    )
+
+    maven_jar(
         name = "auto-value",
         artifact = "com.google.auto.value:auto-value:" + AUTO_VALUE_VERSION,
         sha1 = "6b126cb218af768339e4d6e95a9b0ae41f74e73d",
diff --git a/tools/eclipse/BUILD b/tools/eclipse/BUILD
index cd9f132..c1d8095 100644
--- a/tools/eclipse/BUILD
+++ b/tools/eclipse/BUILD
@@ -44,12 +44,17 @@
     name = "autovalue_classpath_collect",
     deps = [
         "//lib/auto:auto-value",
+        "@auto-common//jar",
+        "@auto-factory//jar",
+        "@auto-service-annotations//jar",
         "@auto-value-annotations//jar",
         "@auto-value-gson-extension//jar",
         "@auto-value-gson-factory//jar",
         "@auto-value-gson-runtime//jar",
         "@autotransient//jar",
         "@gson//jar",
+        "@guava//jar",
         "@javapoet//jar",
+        "@javax_inject//jar",
     ],
 )
diff --git a/tools/js/template_checker.bzl b/tools/js/template_checker.bzl
index da77234..6c645f4 100644
--- a/tools/js/template_checker.bzl
+++ b/tools/js/template_checker.bzl
@@ -123,9 +123,7 @@
     )
 
     # Pack all transformed files. Later files can be materialized in the
-    # WORKSPACE/polygerrit-ui/app/tmpl_out dir. The following command do it
-    # automatically
-    # npm run polytest:dev
+    # WORKSPACE/polygerrit-ui/app/tmpl_out dir.
     pkg_tar(
         name = name + "_tar",
         srcs = generated_dev_files,
diff --git a/tools/nongoogle.bzl b/tools/nongoogle.bzl
index d8f7020..37d8b9c 100644
--- a/tools/nongoogle.bzl
+++ b/tools/nongoogle.bzl
@@ -243,36 +243,36 @@
         sha1 = "64cba89cf87c1d84cb8c81d06f0b9c482f10b4dc",
     )
 
-    LUCENE_VERS = "6.6.5"
+    LUCENE_VERS = "7.7.3"
 
     maven_jar(
         name = "lucene-core",
         artifact = "org.apache.lucene:lucene-core:" + LUCENE_VERS,
-        sha1 = "2983f80b1037e098209657b0ca9176827892d0c0",
+        sha1 = "5faa5ae56f7599019fce6184accc6c968b7519e7",
     )
 
     maven_jar(
         name = "lucene-analyzers-common",
         artifact = "org.apache.lucene:lucene-analyzers-common:" + LUCENE_VERS,
-        sha1 = "6094f91071d90570b7f5f8ce481d5de7d2d2e9d5",
+        sha1 = "0a76cbf5e21bbbb0c2d6288b042450236248214e",
     )
 
     maven_jar(
         name = "backward-codecs",
         artifact = "org.apache.lucene:lucene-backward-codecs:" + LUCENE_VERS,
-        sha1 = "460a19e8d1aa7d31e9614cf528a6cb508c9e823d",
+        sha1 = "40207d0dd023a0e2868a23dd87d72f1a3cdbb893",
     )
 
     maven_jar(
         name = "lucene-misc",
         artifact = "org.apache.lucene:lucene-misc:" + LUCENE_VERS,
-        sha1 = "ce3a1b7b6a92b9af30791356a4bd46d1cea6cc1e",
+        sha1 = "3aca078edf983059722fe61a81b7b7bd5ecdb222",
     )
 
     maven_jar(
         name = "lucene-queryparser",
         artifact = "org.apache.lucene:lucene-queryparser:" + LUCENE_VERS,
-        sha1 = "2db9ca0086a4b8e0b9bc9f08a9b420303168e37c",
+        sha1 = "685fc6166d29eb3e3441ae066873bb442aa02df1",
     )
 
     # JGit's transitive dependencies