Merge branch 'stable-3.7' into stable-3.8

* stable-3.7:
  Add metrics for git/upload-pack/*bitmap* misses
  Add timer for git/upload-pack/phase_searching_for_*
  Fix: CommentTimestampAdapter can't parse some old comments
  Add timer for git/upload-pack/phase_negotiating
  H2CacheImpl: Lower logging level for no pruning

Release-Notes: skip
Change-Id: I13c3d2502d347409682f4c355a73847dbb410cde
diff --git a/.bazelrc b/.bazelrc
index 407b005..cf5403d 100644
--- a/.bazelrc
+++ b/.bazelrc
@@ -37,6 +37,6 @@
 build --announce_rc
 
 test --build_tests_only
-test --test_output=all
+test --test_output=errors
 
 import %workspace%/tools/remote-bazelrc
diff --git a/.gitignore b/.gitignore
index 53bc9f6..0bbcaba 100644
--- a/.gitignore
+++ b/.gitignore
@@ -31,6 +31,8 @@
 /infer-out
 /local.properties
 /node_modules/
+/polygerrit-ui/node_modules/
+/polygerrit-ui/app/node_modules/
 /package-lock.json
 /plugins/*
 /polygerrit-ui/coverage/
diff --git a/Documentation/access-control.txt b/Documentation/access-control.txt
index 3da69df..cf89982 100644
--- a/Documentation/access-control.txt
+++ b/Documentation/access-control.txt
@@ -782,7 +782,7 @@
 === Rebase
 
 This category permits users to rebase changes via the web UI by pushing
-the `Rebase Change` button.
+the `REBASE` button.
 
 The change owner and submitters can always rebase changes in the web UI
 (even without having the `Rebase` access right assigned).
@@ -800,6 +800,22 @@
 Users without this access right who are able to upload changes can
 still do the revert locally and upload the revert commit as a new change.
 
+[[category_remove_label]]
+=== Remove Label (Remove Vote)
+
+For every configured label `My-Name` in the project, there is a
+corresponding permission `removeLabel-My-Name` with a range corresponding to
+the defined values. For these values, the users are permitted to remove
+other users' votes from a change.
+
+Change owners can always remove zero or positive votes (even without
+having the `Remove Vote` access right assigned).
+
+Project owners and site administrators can always remove any vote (even
+without having the `Remove Vote` access right assigned).
+
+Users without this access right can still remove their own votes.
+
 [[category_remove_reviewer]]
 === Remove Reviewer
 
@@ -890,6 +906,9 @@
 the Work In Progress bit of the change (even without having the
 `Toggle Work In Progress state` access right assigned).
 
+Must be assigned on the target branch ref (i.e. on 'refs/heads/*', not on
+'refs/for/*').
+
 [[category_delete_own_changes]]
 === Delete Own Changes
 
@@ -932,15 +951,6 @@
 can always edit or remove hashtags (even without having the `Edit Hashtags`
 access right assigned).
 
-[[category_edit_assigned_to]]
-=== Edit Assignee
-
-This category permits users to set who is assigned to a change that is
-uploaded for review.
-
-The change owner, ref owners, and the user currently assigned to a change
-can always change the assignee.
-
 [[example_roles]]
 == Examples of typical roles in a project
 
@@ -1143,7 +1153,7 @@
 use 'BLOCK' rules to enforce site-wide restrictions.
 
 For example, if a user in the 'Foo Users' group tries to push to
-'refs/heads/mater' with the permissions below, that user will be blocked
+'refs/heads/master' with the permissions below, that user will be blocked
 
 [options="header"]
 |=========================================================================
@@ -1351,10 +1361,11 @@
 [[capability_createProject]]
 === Create Project
 
-Allow project creation.  This capability allows the granted group to
-either link:cmd-create-project.html[create new git projects via ssh]
-or via the web UI.
+Allow project creation.
 
+This capability allows the granted group to create projects via the web UI, via
+link:rest-api-projects.html#create-project][REST] and via
+link:cmd-create-project.html[SSH].
 
 [[capability_emailReviewers]]
 === Email Reviewers
@@ -1406,8 +1417,8 @@
 
 Allow to link:cmd-set-account.html[modify accounts over the ssh prompt].
 This capability allows the granted group members to modify any user account
-setting. In addition this capability is required to view secondary emails
-of other accounts.
+setting. In addition this capability allows to view secondary emails of other
+accounts.
 
 [[capability_priority]]
 === Priority
@@ -1509,7 +1520,8 @@
 
 This capability allows to view all accounts but not all account data.
 E.g. secondary emails of all accounts can only be viewed with the
-link:#capability_modifyAccount[Modify Account] capability.
+link:#capability_viewSecondaryEmails[View Secondary Emails] capability
+or the link:#capability_modifyAccount[Modify Account] capability.
 
 
 [[capability_viewCaches]]
@@ -1542,6 +1554,15 @@
 link:cmd-show-queue.html[look at the Gerrit task queue via ssh].
 
 
+[[capability_viewSecondaryEmails]]
+=== View Secondary Emails
+
+Allows viewing secondary emails of other accounts.
+
+Users with the link:#capability_modifyAccount[Modify Account] capability have
+this capbility implicitly.
+
+
 [[reference]]
 == Permission evaluation reference
 
diff --git a/Documentation/cmd-create-project.txt b/Documentation/cmd-create-project.txt
index 358324d..87f3851 100644
--- a/Documentation/cmd-create-project.txt
+++ b/Documentation/cmd-create-project.txt
@@ -108,6 +108,7 @@
 	Action used by Gerrit to submit an approved change to its
 	destination branch.  Supported options are:
 +
+* INHERIT: inherits the submit-type from the parent project.
 * FAST_FORWARD_ONLY: produces a strictly linear history.
 * MERGE_IF_NECESSARY: create a merge commit when required.
 * REBASE_IF_NECESSARY: rebase the commit when required.
@@ -116,7 +117,7 @@
 * CHERRY_PICK: always cherry-pick the commit.
 
 +
-Defaults to MERGE_IF_NECESSARY unless
+Defaults to INHERIT unless
 link:config-gerrit.html#repository.name.defaultSubmitType[
 repository.<name>.defaultSubmitType] is set to a different value.
 For more details see link:config-project-config.html#submit-type[
diff --git a/Documentation/cmd-stream-events.txt b/Documentation/cmd-stream-events.txt
index 5fd0bfc..2456662 100644
--- a/Documentation/cmd-stream-events.txt
+++ b/Documentation/cmd-stream-events.txt
@@ -58,21 +58,6 @@
 
 [[events]]
 == EVENTS
-=== Assignee Changed
-
-Sent when the assignee of a change has been modified.
-
-type:: "assignee-changed"
-
-change:: link:json.html#change[change attribute]
-
-changer:: link:json.html#account[account attribute]
-
-oldAssignee:: Assignee before it was changed.
-
-eventCreatedOn:: Time in seconds since the UNIX epoch when this event was
-created.
-
 === Change Abandoned
 
 Sent when a change has been abandoned.
diff --git a/Documentation/concept-changes.txt b/Documentation/concept-changes.txt
index 6e76a8a..323b32a 100644
--- a/Documentation/concept-changes.txt
+++ b/Documentation/concept-changes.txt
@@ -39,10 +39,6 @@
 `git push` command, or the user that triggered the patch set creation through
 an action in the UI).
 
-|Assignee
-|The contributor responsible for the change. Often used when a change has
-mulitple reviewers to identify the individual responsible for final approval.
-
 |Reviewers
 |A list of one or more contributors responsible for reviewing the change.
 
diff --git a/Documentation/config-accounts.txt b/Documentation/config-accounts.txt
index 716fa2f..f46c821 100644
--- a/Documentation/config-accounts.txt
+++ b/Documentation/config-accounts.txt
@@ -380,6 +380,14 @@
 log in to Gerrit, then Gerrit will update the external ID record with
 the new email address.
 
+=== Transition from LDAP to Google OAuth
+
+When authentication is changed from LDAP to Google Oauth gerrit will automatically
+adjust the external IDs in the `refs/meta/external-ids` branch. Gerrit will re-use
+the same account ID that was used by the LDAP account. Transition to other OAuth
+mechanisms will fail and require manual changes to the `refs/meta/external-ids` branch.
+The LDAP e-mail and Google OAuth e-mail must be the same.
+
 [[starred-changes]]
 == Starred Changes
 
diff --git a/Documentation/config-gerrit.txt b/Documentation/config-gerrit.txt
index 6a25259..23455b2 100644
--- a/Documentation/config-gerrit.txt
+++ b/Documentation/config-gerrit.txt
@@ -562,7 +562,8 @@
 +
 To enable the actual usage of contributor agreement the project
 specific config option in the `project.config` must be set:
-link:config-project-config.html[receive.requireContributorAgreement].
+link:config-project-config.html#receive.requireContributorAgreement[
+receive.requireContributorAgreement].
 
 [[auth.trustContainerAuth]]auth.trustContainerAuth::
 +
@@ -1429,20 +1430,6 @@
 +
 The default is false.
 
-[[change.enableAttentionSet]]change.enableAttentionSet::
-+
-If set to true, then all UI features for using and interacting with the
-attention set are enabled.
-+
-The default is true.
-
-[[change.enableAssignee]]change.enableAssignee::
-+
-If set to true, then all UI features for using and interacting with the
-assignee are enabled.
-+
-The default is false.
-
 [[change.maxComments]]change.maxComments::
 +
 Maximum number of comments (regular plus robot) allowed per change. Additional
@@ -1551,6 +1538,13 @@
 +
 By default true.
 
+[[change.enableRobotComments]]change.enableRobotComments::
++
+Are robot comments enabled in the Gerrit UI? This setting allows phasing out
+robot comments.
++
+By default true.
+
 [[change.robotCommentSizeLimit]]change.robotCommentSizeLimit::
 +
 Maximum allowed size in characters of a robot comment. Robot comments which
@@ -1756,9 +1750,7 @@
 will match typical Gerrit Change-Id values and create a hyperlink
 to changes which reference it.  The second configuration 'bugzilla'
 will hyperlink terms such as 'bug 42' to an external bug tracker,
-supplying the argument record number '42' for display.  The third
-configuration 'tracker' uses raw HTML to more precisely control
-how the replacement is displayed to the user.
+supplying the argument record number '42' for display.
 
 commentlinks supports link:#reloadConfig[configuration reloads]. Though a
 link:cmd-flush-caches.html[flush-caches] of "projects" is needed for the
@@ -1775,16 +1767,10 @@
   prefix = $1
   suffix = $4
   text = $2$3
-
-[commentlink "tracker"]
-  match = ([Bb]ug:\\s+)(\\d+)
-  html = $1<a href=\"http://trak.example.com/$2\">$2</a>
 ----
 
 Comment links can also be specified in `project.config` and sections in
-children override those in parents. The only restriction is that to
-avoid injecting arbitrary user-supplied HTML in the page, comment links
-defined in `project.config` may only supply `link`, not `html`.
+children override those in parents.
 
 [[commentlink.name.match]]commentlink.<name>.match::
 +
@@ -1818,38 +1804,23 @@
 +
 The URL to direct the user to whenever the regular expression is
 matched.  Groups in the match expression may be accessed as `$'n'`.
-+
-The link property is used only when the html property is not present.
 
 [[commentlink.name.prefix]]commentlink.<name>.prefix::
 +
 The text inserted before the link. Groups in the match expression may be
 accessed as `$'n'`.
-+
-The link property is used only when the html property is not present.
 
 [[commentlink.name.suffix]]commentlink.<name>.suffix::
 +
 The text inserted after the link. Groups in the match expression may be
 accessed as `$'n'`.
-+
-The link property is used only when the html property is not present.
 
 [[commentlink.name.text]]commentlink.<name>.text::
 +
 The text content of the link. Groups in the match expression may be
 accessed as `$'n'`.
 +
-The link property is used only when the html property is not present.
-
-[[commentlink.name.html]]commentlink.<name>.html::
-+
-HTML to replace the entire matched string with.  If present,
-this property overrides the link property above.  Groups in the
-match expression may be accessed as `$'n'`.
-+
-The configuration file eats double quotes, so escaping them as
-`\"` is necessary to protect them from the parser.
+If not specified defaults to `$&` (the matched text).
 
 [[commentlink.name.enabled]]commentlink.<name>.enabled::
 +
@@ -1857,11 +1828,6 @@
 section in a parent or the site-wide config that is disabled by
 specifying `enabled = true`.
 +
-Disabling sections in `gerrit.config` can be used by site administrators
-to create a library of comment links with `html` set that are not
-user-supplied and thus can be verified to be XSS-free, but are only
-enabled for a subset of projects.
-+
 By default, true.
 +
 Note that the names and contents of disabled sections are visible even
@@ -2116,6 +2082,16 @@
 +
 Default is 1 hour.
 
+[[core.usePerRequestRefCache]]core.usePerRequestRefCache::
++
+Use a per request (currently per request thread) ref cache. The ref
+cache uses JGit's SnapshottingRefDirectory to ensure that packed
+refs are checked and potentially read at least once per request
+(lazily) if needed. This helps reduce the overhead of checking if
+the packed-refs file is outdated.
++
+Default is true.
+
 [[dashboard]]
 === Section dashboard
 
@@ -2815,6 +2791,39 @@
 when this parameter is removed and the system group uses the default
 name again.
 
+[[groups.relevantGroup]]groups.relevantGroup::
++
+UUID of an external group that should always be considered as relevant
+when checking whether an account is visible.
++
+This setting is only relevant for external group backends and only if
+the link:#accounts.visibility[account visibility] is set to
+`SAME_GROUP` or `VISIBLE_GROUP`.
++
+If the link:#accounts.visibility[account visibility] is set to
+`SAME_GROUP` or `VISIBLE_GROUP` users should see all accounts that are
+a member of a group that contains themselves or that is visible to
+them. Checking this would require getting all groups of the current
+user and all groups of the accounts for which the visibility is being
+checked, but since getting all groups that a user is a member of is
+expensive for external group backends Gerrit doesn't query these groups
+but instead guesses the relevant groups. Guessing relevant groups
+limits the inspected groups to all groups that are mentioned in the
+ACLs of the projects that are currently cached (i.e. all groups that
+are listed in the link:config-project-config.html#file-groups[groups]
+files of the cached projects). This is not very reliable since it
+depends on which groups are mentioned in the ACLs and which projects
+are currently cached. To make this more reliable this configuration
+parameter allows to configure external groups that should always be
+considered as relevant.
++
+As said this setting is only relevant for external group backends. In
+Gerrit core this is only the LDAP backend, but it may apply to further
+group backends that are added by plugins.
++
+This parameter may be added multiple times to specify multiple relevant
+groups.
+
 [[has-operand-alias]]
 === Section has operand alias
 
@@ -4363,6 +4372,14 @@
 +
 Default is 5 seconds. Negative values will be converted to 0.
 
+[[plugins.transitionalPushOptions]]plugins.transitionalPushOptions::
++
+Additional push options which should be accepted by gerrit as valid
+options even if they are not registered by any plugin(e.g. "myplugin~foo").
++
+This config can be used when gerrit migrates from a deprecated plugin to the new one. The new plugin
+can (temporary) accept push options of the old plugin without registering such options.
+
 [[receive]]
 === Section receive
 
diff --git a/Documentation/config-labels.txt b/Documentation/config-labels.txt
index e4eee10..ce63295 100644
--- a/Documentation/config-labels.txt
+++ b/Documentation/config-labels.txt
@@ -200,9 +200,9 @@
 properties in the child project's configuration; all properties from
 the parent definition must be redefined in the child.
 
-To remove a label in a child project, add an empty label with the same
-name as in the parent. This will override the parent label with
-a label containing the defaults (`function = MaxWithBlock`,
+To remove a label in a child project, add an empty label with a single "0"
+value, with the same name as in the parent. This will override the parent label
+with a label containing the defaults (`function = NoBlock`,
 `defaultValue = 0` and no further allowed values)
 
 [[label_layout]]
diff --git a/Documentation/config-mail.txt b/Documentation/config-mail.txt
index 8bd5dc7..4f11ca8 100644
--- a/Documentation/config-mail.txt
+++ b/Documentation/config-mail.txt
@@ -139,12 +139,6 @@
 change being reverted.  It is a `ChangeEmail`: see `ChangeSubject.soy` and
 ChangeFooter.
 
-=== SetAssignee.soy and SetAssigneeHtml.soy
-
-The SetAssignee templates will determine the contents of the email related to a
-user being assigned to a change. It is a `ChangeEmail`: see `ChangeSubject.soy`
-and ChangeFooter.
-
 
 == Mail Variables and Methods
 
diff --git a/Documentation/config-project-config.txt b/Documentation/config-project-config.txt
index 781b458..25fe9f3 100644
--- a/Documentation/config-project-config.txt
+++ b/Documentation/config-project-config.txt
@@ -458,11 +458,6 @@
 NOTE: Rebasing merge commits is not supported. If a change with a merge commit
 is submitted the link:#merge_if_necessary[merge if necessary] submit action is
 applied.
-+
-When rebasing the patchset, Gerrit automatically appends onto the end of the
-commit message a short summary of the change's approvals, and a URL link back
-to the change in the web UI (see link:#submit-footers[below]). If a fast-forward
-is done no footers are added.
 
 [[rebase_always]]
 * 'rebase always':
@@ -737,6 +732,15 @@
 the parent project. If the property is not set in any parent project, the
 default value is `FALSE`.
 
+[[reviewer.skipAddingAuthorAndCommitterAsReviewers]]reviewer.skipAddingAuthorAndCommitterAsReviewers::
++
+Whether to skip adding the Git commit author and committer as reviewers for
+a new change.
++
+Default is `INHERIT`, which means that this property is inherited from
+the parent project. If the property is not set in any parent project, the
+default value is `FALSE`.
+
 [[file-groups]]
 == The file +groups+
 
diff --git a/Documentation/config-submit-requirements.txt b/Documentation/config-submit-requirements.txt
index 8298be3..fb12ff3 100644
--- a/Documentation/config-submit-requirements.txt
+++ b/Documentation/config-submit-requirements.txt
@@ -160,6 +160,22 @@
 link:http://www.brics.dk/automaton/[dk.brics.automaton library,role=external,window=_blank]
 is used for the evaluation of such patterns.
 
+[[operator_committeremail]]
+committeremail:'EMAIL_PATTERN'::
++
+An operator that returns true if the change committer's email address matches a
+specific regular expression pattern. The
+link:http://www.brics.dk/automaton/[dk.brics.automaton library,role=external,window=_blank]
+is used for the evaluation of such patterns.
+
+[[operator_uploaderemail]]
+uploaderemail:'EMAIL_PATTERN'::
++
+An operator that returns true if the change uploader's primary email address
+matches a specific regular expression pattern. The
+link:http://www.brics.dk/automaton/[dk.brics.automaton library,role=external,window=_blank]
+is used for the evaluation of such patterns.
+
 [[operator_distinctvoters]]
 distinctvoters:'[Label1,Label2,...,LabelN],value=MAX,count>1'::
 +
@@ -187,11 +203,57 @@
 redefine a submit requirement in a child project and make the submit requirement
 always non-applicable.
 
+[[operator_has_submodule_update]]
+has:submodule-update::
++
+An operator that returns true if the diff of the latest patchset against the
+default parent has a submodule modified file, that is, a ".gitmodules" or a
+git link file.
++
+The optional `base` parameter can also be supplied for merge commits like
+`has:submodule-update,base=1`, or `has:submodule-update,base=2`. In these cases,
+the operator returns true if the diff of the latest patchset against parent
+number identified by `base` has a submodule modified file. Note that the
+operator will return false if the base parameter is greater than the number of
+parents for the latest patchset for the change.
+
 [[operator_file]]
 file:"'<filePattern>',withDiffContaining='<contentPattern>'"::
 +
 An operator that returns true if the latest patchset contained a modified file
 matching `<filePattern>` with a modified region matching `<contentPattern>`.
++
+Both `<filePattern>` and `<contentPattern>` support regular expressions if they
+start with the '^' character. Regular expressions are matched with the
+`java.util.regex` engine. When using regular expressions, special characters
+should be double escaped because the config is parsed twice when the server
+reads the `project.config` file and when the submit-requirement expressions
+are parsed as a predicate tree. For example, to match against modified files
+that end with ".cc" or ".cpp" the following `applicableIf` expression can be
+used:
++
+----
+  applicableIf = file:\"^.*\\\\.(cc|cpp)$\"
+----
++
+Below is another example that uses both `<filePattern>` and `<contentPattern>`:
++
+----
+  applicableIf = file:\"'^.*\\\\.(cc|cpp)$',withDiffContaining='^.*th[rR]ee$'\"
+----
++
+If no regular expression is used, the text is matched by checking that the file
+name contains the file pattern, or the edits of the file diff contain the edit
+pattern.
+
+[[operator_label]]
+label:labelName=+1,user=non_contributor::
++
+Submit requirements support an additional `user=non_contributor` argument for
+labels that returns true if the change has a label vote matching the specified
+value and the vote is applied from a gerrit account that's not the uploader,
+author or committer of the latest patchset. See the documentation for the labels
+operator in the link:user-search.html[user search] page.
 
 [[unsupported_operators]]
 === Unsupported Operators
@@ -268,6 +330,34 @@
   canOverrideInChildProjects = true
 ----
 
+Branch configuration supports regular expressions as well, e.g. to exempt 'refs/heads/release/*' pattern,
+when migrating from the label Submit-Rule:
+
+----
+[label "Verified"]
+  branch = refs/heads/release/*
+----
+
+The following SR can be configured:
+
+----
+[submit-requirement "Verified"]
+  submittableIf = label:Verified=MAX AND -label:Verified=MIN
+  applicableIf = branch:^refs/heads/release/.*
+----
+
+[[require-footer-example]]
+=== Require a footer Example
+
+It's possible to use a submit requirement to require a footer to be present in
+the commit message.
+
+----
+[submit-requirement "Bug-Footer"]
+  description = Changes must include a 'Bug' footer
+  applicableIf = -branch:refs/meta/config AND -hasfooter:\"Bug\"
+  submittableIf = hasfooter:\"Bug\"
+----
 
 [[test-submit-requirements]]
 == Testing Submit Requirements
diff --git a/Documentation/config-validation.txt b/Documentation/config-validation.txt
index 56c9ecd..b0149fe 100644
--- a/Documentation/config-validation.txt
+++ b/Documentation/config-validation.txt
@@ -100,13 +100,6 @@
 E.g. a plugin could use this to enforce a certain name scheme for
 group names.
 
-[[assignee-validation]]
-== Assignee validation
-
-
-Plugins implementing the `AssigneeValidationListener` interface can perform
-validation of assignees before they are assigned to a change.
-
 [[hashtag-validation]]
 == Hashtag validation
 
diff --git a/Documentation/dev-bazel.txt b/Documentation/dev-bazel.txt
index a2bd6fc..1c3fd78 100644
--- a/Documentation/dev-bazel.txt
+++ b/Documentation/dev-bazel.txt
@@ -365,7 +365,7 @@
 ----
 
 Now attach with a debugger to the port `5005`. For example use "Remote Java Application" launch
-configuration in Eclipe and specify the port `5005`.
+configuration in Eclipse and specify the port `5005`.
 
 [[logging]]
 === Controlling logging level
@@ -564,7 +564,7 @@
 ----
 
 Update the `polygerrit-ui/app/node_modules_licenses/licenses.ts` file. You should add licenses
-for the package itself and for all transitive depndencies. If you forgot to add a license, the
+for the package itself and for all transitive dependencies. If you forgot to add a license, the
 `Documentation:check_licenses` test will fail.
 
 After the update, commit all changes to the repository (including `yarn.lock`).
diff --git a/Documentation/dev-crafting-changes.txt b/Documentation/dev-crafting-changes.txt
index ac0780d..6150c20 100644
--- a/Documentation/dev-crafting-changes.txt
+++ b/Documentation/dev-crafting-changes.txt
@@ -28,7 +28,7 @@
   might take a while until you could benefit from it. In that case,
   implement the feature on master and, if you really need it on an
   earlier `stable-*` branch, cherry-pick the change and build
-  Gerrit on your own environent.
+  Gerrit in your own environment.
 * Bug-fixes should generally at least cover the oldest affected and
   still supported version. If you're affected and run an even older
   version, you're welcome to upload to that older version, even if
diff --git a/Documentation/dev-design.txt b/Documentation/dev-design.txt
index 5636dfd..176b53f 100644
--- a/Documentation/dev-design.txt
+++ b/Documentation/dev-design.txt
@@ -98,7 +98,7 @@
 User authentication is handled by identity realms. Gerrit supports the
 following types of authentication:
 
-* OpenId (see link:http://openid.net/developers/specs/[OpenID Specifications,role=external,window=_blank])
+* OpenID (see link:http://openid.net/developers/specs/[OpenID Specifications,role=external,window=_blank])
 * OAuth2
 * LDAP
 * Google accounts (on googlesource.com)
@@ -373,7 +373,7 @@
 and one for SSH).
 
 The git wire protocol does a client/server negotiation to avoid
-sending too much data. This negotation occupies a CPU, so the number
+sending too much data. This negotiation occupies a CPU, so the number
 of concurrent push/fetch operations should be capped by the number of
 CPUs.
 
diff --git a/Documentation/dev-eclipse.txt b/Documentation/dev-eclipse.txt
index afd2825..f4238d1 100644
--- a/Documentation/dev-eclipse.txt
+++ b/Documentation/dev-eclipse.txt
@@ -61,9 +61,10 @@
 startup --output_user_root=/Users/johndoe/.cache/bazel
 ----
 
-==== Increase the treshold for the cleanup of temporary files
-The default treshold for the cleanup can be overriden by creating a configuration file under
-`/etc/periodic.conf` and setting a larger value for the `daily_clean_tmps_days`.
+==== Increase the threshold for the cleanup of temporary files
+The default threshold for the cleanup can be overridden by creating a configuration
+file under `/etc/periodic.conf` and setting a larger value for the
+`daily_clean_tmps_days`.
 
 An example `/etc/periodic.conf` file:
 
diff --git a/Documentation/dev-intellij.txt b/Documentation/dev-intellij.txt
index ab2082f..be4196c 100644
--- a/Documentation/dev-intellij.txt
+++ b/Documentation/dev-intellij.txt
@@ -31,13 +31,13 @@
 === Installation of IntelliJ IDEA
 
 Please refer to the
-link:https://www.jetbrains.com/help/idea/installation-guide.html[installation guide provided by Jetbrains,role=external,window=_blank]
+link:https://www.jetbrains.com/help/idea/installation-guide.html[installation guide provided by JetBrains,role=external,window=_blank]
 to install it on your platform. Make sure to install a version compatible with
 the Bazel plugin as mentioned above.
 
 == Installation of the Bazel plugin
 
-The plugin is usually installed using the Jetbrains plugin repository as shown
+The plugin is usually installed using the JetBrains plugin repository as shown
 in the steps below, but it's also possible to
 link:https://github.com/bazelbuild/intellij[build it from source].
 
diff --git a/Documentation/dev-plugins.txt b/Documentation/dev-plugins.txt
index 11f85dd..3c4e9ea 100644
--- a/Documentation/dev-plugins.txt
+++ b/Documentation/dev-plugins.txt
@@ -533,6 +533,24 @@
 Certain operations in Gerrit can be validated by plugins by
 implementing the corresponding link:config-validation.html[listeners].
 
+[[taskListeners]]
+== WorkQueue.TaskListeners
+
+It is possible for plugins to listen to
+`com.google.gerrit.server.git.WorkQueue$Task`s directly before they run, and
+directly after they complete. This may be used to delay task executions based
+on custom criteria by blocking, likely on a lock or semaphore, inside
+onStart(), and a lock/semaphore release in onStop(). Plugins may listen to
+tasks by implementing a `com.google.gerrit.server.git.WorkQueue$TaskListener`
+and registering the new listener like this:
+
+[source,java]
+----
+bind(TaskListener.class)
+    .annotatedWith(Exports.named("MyListener"))
+    .to(MyListener.class);
+----
+
 [[change-message-modifier]]
 == Change Message Modifier
 
@@ -945,7 +963,7 @@
 When calling command options not provided by your plugin, there is always
 a risk that the options may not exist, perhaps because the options being
 called are to be provided by another plugin, and said plugin is not
-currently installed. To protect againt this situation, it is possible to
+currently installed. To protect against this situation, it is possible to
 define an option as being dependent on other options using the
 @RequiresOptions() annotation. If the required options are not all not
 currently present, then the dependent option will not be available or
@@ -981,7 +999,7 @@
 @RequiresOptions("--format")
 @Option(
   name = "--special",
-  usage = "ouptut results using json",
+  usage = "output results using json",
   handler = JsonOutputOptionHandler.class
 )
 boolean json;
@@ -2191,7 +2209,6 @@
 ----
 import com.google.gerrit.extensions.annotations.Listen;
 import com.google.gerrit.extensions.webui.PatchSetWebLink;;
-import com.google.gerrit.extensions.webui.WebLinkTarget;
 
 @Listen
 public class MyWeblinkPlugin implements PatchSetWebLink {
@@ -2204,8 +2221,7 @@
    String commitMessage, String branchName) {
     return new WebLinkInfo(name,
         imageUrl,
-        String.format(placeHolderUrlProjectCommit, project, commit),
-        WebLinkTarget.BLANK);
+        String.format(placeHolderUrlProjectCommit, project, commit));
   }
 }
 ----
@@ -2725,7 +2741,7 @@
 Plugins are expected to support rules inheritance themselves, providing ways
 to configure it and handling the logic behind it.
 Please note that no inheritance is sometimes better than badly handled
-inheritance: mis-communication and strange behaviors caused by inheritance
+inheritance: miscommunication and strange behaviors caused by inheritance
 may and will confuse the users. Each plugins is responsible for handling the
 project hierarchy and taking wise actions. Gerrit does not enforce it.
 
diff --git a/Documentation/dev-readme.txt b/Documentation/dev-readme.txt
index 6ff064c..70f41af 100644
--- a/Documentation/dev-readme.txt
+++ b/Documentation/dev-readme.txt
@@ -178,7 +178,7 @@
 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
+NOTE: When launching the daemon 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:
diff --git a/Documentation/dev-release.txt b/Documentation/dev-release.txt
index 85337c2..85371100 100644
--- a/Documentation/dev-release.txt
+++ b/Documentation/dev-release.txt
@@ -368,6 +368,22 @@
 
 Submit any previously uploaded notes change on the homepage project.
 
+[[update-supported-releases]]
+=== Update list of supported releases
+
+If you created a new stable release update the list of supported releases
+in the link:https://www.gerritcodereview.com/support.html[support page].
+
+Gerrit releases are also listed on the
+link:https://endoflife.date/gerrit[endoflife website].
+Push a PR to
+link:https://github.com/endoflife-date/endoflife.date.git[endoflife.date repository]
+to update supported releases in `products/gerrit.md`. New release tags
+should be updated automatically by the site's automation job which uses
+Dependabot to
+link:https://github.com/endoflife-date/endoflife.date/wiki/Automation[auto-create PRs]
+for new release tags.
+
 [[announce]]
 ==== Announce on Mailing List
 
diff --git a/Documentation/images/browser-notification-example.png b/Documentation/images/browser-notification-example.png
new file mode 100644
index 0000000..2b60054
--- /dev/null
+++ b/Documentation/images/browser-notification-example.png
Binary files differ
diff --git a/Documentation/images/browser-notification-preference.png b/Documentation/images/browser-notification-preference.png
new file mode 100644
index 0000000..57d5fd6
--- /dev/null
+++ b/Documentation/images/browser-notification-preference.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-apply-fix.png b/Documentation/images/user-review-ui-apply-fix.png
new file mode 100644
index 0000000..d838d48
--- /dev/null
+++ b/Documentation/images/user-review-ui-apply-fix.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-metadata.png b/Documentation/images/user-review-ui-change-metadata.png
new file mode 100644
index 0000000..23abc07
--- /dev/null
+++ b/Documentation/images/user-review-ui-change-metadata.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-annotated.png b/Documentation/images/user-review-ui-change-screen-annotated.png
index 5c3f80a..4e12c96 100644
--- a/Documentation/images/user-review-ui-change-screen-annotated.png
+++ b/Documentation/images/user-review-ui-change-screen-annotated.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-change-info-labels.png b/Documentation/images/user-review-ui-change-screen-change-info-labels.png
deleted file mode 100644
index 61e2b25..0000000
--- a/Documentation/images/user-review-ui-change-screen-change-info-labels.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-comments-tab.png b/Documentation/images/user-review-ui-change-screen-comments-tab.png
new file mode 100644
index 0000000..d522f60
--- /dev/null
+++ b/Documentation/images/user-review-ui-change-screen-comments-tab.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-file-list.png b/Documentation/images/user-review-ui-change-screen-file-list.png
index 721b229..b0c2af3 100644
--- a/Documentation/images/user-review-ui-change-screen-file-list.png
+++ b/Documentation/images/user-review-ui-change-screen-file-list.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-keyboard-shortcuts.png b/Documentation/images/user-review-ui-change-screen-keyboard-shortcuts.png
index 9ef8f27..224de2d 100644
--- a/Documentation/images/user-review-ui-change-screen-keyboard-shortcuts.png
+++ b/Documentation/images/user-review-ui-change-screen-keyboard-shortcuts.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-reply.png b/Documentation/images/user-review-ui-change-screen-reply.png
index 1c50fc5..201db13 100644
--- a/Documentation/images/user-review-ui-change-screen-reply.png
+++ b/Documentation/images/user-review-ui-change-screen-reply.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-topleft.png b/Documentation/images/user-review-ui-change-screen-topleft.png
index a1f7813..b3bf8e7f 100644
--- a/Documentation/images/user-review-ui-change-screen-topleft.png
+++ b/Documentation/images/user-review-ui-change-screen-topleft.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen.png b/Documentation/images/user-review-ui-change-screen.png
index ff2570b..98a5d6d 100644
--- a/Documentation/images/user-review-ui-change-screen.png
+++ b/Documentation/images/user-review-ui-change-screen.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-copy-links.png b/Documentation/images/user-review-ui-copy-links.png
new file mode 100644
index 0000000..f8fa114
--- /dev/null
+++ b/Documentation/images/user-review-ui-copy-links.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-side-by-side-diff-screen-inline-comments.png b/Documentation/images/user-review-ui-side-by-side-diff-screen-inline-comments.png
index 047034c..98cf7af 100644
--- a/Documentation/images/user-review-ui-side-by-side-diff-screen-inline-comments.png
+++ b/Documentation/images/user-review-ui-side-by-side-diff-screen-inline-comments.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-side-by-side-diff-screen.png b/Documentation/images/user-review-ui-side-by-side-diff-screen.png
index 74d02e3..ebdd177 100644
--- a/Documentation/images/user-review-ui-side-by-side-diff-screen.png
+++ b/Documentation/images/user-review-ui-side-by-side-diff-screen.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-submit-requirements.png b/Documentation/images/user-review-ui-submit-requirements.png
new file mode 100644
index 0000000..e4b88c1
--- /dev/null
+++ b/Documentation/images/user-review-ui-submit-requirements.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-suggest-fix.png b/Documentation/images/user-review-ui-suggest-fix.png
new file mode 100644
index 0000000..e08fb26
--- /dev/null
+++ b/Documentation/images/user-review-ui-suggest-fix.png
Binary files differ
diff --git a/Documentation/images/user-suggest-edits-preview.png b/Documentation/images/user-suggest-edits-preview.png
new file mode 100644
index 0000000..0a6af91
--- /dev/null
+++ b/Documentation/images/user-suggest-edits-preview.png
Binary files differ
diff --git a/Documentation/images/user-suggest-edits-reviewer-comment.png b/Documentation/images/user-suggest-edits-reviewer-comment.png
new file mode 100644
index 0000000..76fbac1
--- /dev/null
+++ b/Documentation/images/user-suggest-edits-reviewer-comment.png
Binary files differ
diff --git a/Documentation/images/user-suggest-edits-reviewer-preview.png b/Documentation/images/user-suggest-edits-reviewer-preview.png
new file mode 100644
index 0000000..5b3015e
--- /dev/null
+++ b/Documentation/images/user-suggest-edits-reviewer-preview.png
Binary files differ
diff --git a/Documentation/images/user-suggest-edits-reviewer-suggest-fix.png b/Documentation/images/user-suggest-edits-reviewer-suggest-fix.png
new file mode 100644
index 0000000..2ac28bd
--- /dev/null
+++ b/Documentation/images/user-suggest-edits-reviewer-suggest-fix.png
Binary files differ
diff --git a/Documentation/images/user-suggest-edits-suggestion.png b/Documentation/images/user-suggest-edits-suggestion.png
new file mode 100644
index 0000000..a9c6d9c
--- /dev/null
+++ b/Documentation/images/user-suggest-edits-suggestion.png
Binary files differ
diff --git a/Documentation/index.txt b/Documentation/index.txt
index e0ece47..5d4a29b 100644
--- a/Documentation/index.txt
+++ b/Documentation/index.txt
@@ -15,18 +15,21 @@
 == Contributor Guides
 . link:dev-community.html[Gerrit Community]
 . link:dev-community.html#how-to-contribute[How to Contribute]
+.. link:dev-readme.html[Developer Setup]
 
 == User Guides
 . link:intro-user.html[User Guide]
 . link:intro-project-owner.html[Project Owner Guide]
 . link:https://source.android.com/source/developing[Default Android Workflow,role=external,window=_blank] (external)
 
-== Tutorials
+== Features and Workflows
 . Web
-.. link:user-review-ui.html[Reviewing Changes]
+.. link:user-review-ui.html[Review UI Overview]
 .. link:user-search.html[Searching Changes]
 .. link:user-inline-edit.html[Manipulating Changes in Browser]
 .. link:user-notify.html[Subscribing to Email Notifications]
+.. link:user-attention-set.html[Attention Set]
+.. link:user-suggest-edits.html[User Suggest Edits]
 . SSH
 .. link:user-upload.html#ssh[SSH connection details]
 .. link:cmd-index.html[Command Line Tools]
@@ -49,9 +52,6 @@
 . Multi-project management
 .. link:user-submodules.html[Submodules]
 .. link:https://source.android.com/source/using-repo.html[Repo,role=external,window=_blank] (external)
-. Prolog rules
-.. link:prolog-cookbook.html[Prolog Cookbook]
-.. link:prolog-change-facts.html[Prolog Facts for Gerrit Changes]
 . link:intro-project-owner.html#project-deletion[Project deletion]
 
 == Customization and Integration
diff --git a/Documentation/intro-user.txt b/Documentation/intro-user.txt
index 0f78e1f..4642247 100644
--- a/Documentation/intro-user.txt
+++ b/Documentation/intro-user.txt
@@ -1014,7 +1014,7 @@
 inline comment ("Yeah, I see why, let me try again.").
 
 [[security-fixes]]
--- Security Fixes
+== Security Fixes
 
 If a security vulnerability is discovered you normally want to have an
 embargo about it until fixed releases have been made available. This
diff --git a/Documentation/js_licenses.txt b/Documentation/js_licenses.txt
index ae3064c..c032c36 100644
--- a/Documentation/js_licenses.txt
+++ b/Documentation/js_licenses.txt
@@ -603,39 +603,6 @@
 ----
 
 
-[[codemirror-minified]]
-codemirror-minified
-
-* codemirror-minified
-
-[[codemirror-minified_license]]
-----
-The MIT License (MIT)
-
-Copyright (c) 2016 Marijn Haverbeke <marijnh@gmail.com> and others
-Copyright (c) 2016 Michael Zhou <zhoumotongxue008@gmail.com>
-
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in all
-copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-SOFTWARE.
-
-----
-
-
 [[font-roboto-local-fonts-roboto]]
 font-roboto-local-fonts-roboto
 
@@ -1241,38 +1208,6 @@
 ----
 
 
-[[isarray]]
-isarray
-
-* isarray
-
-[[isarray_license]]
-----
-(MIT)
-
-Copyright (c) 2013 Julian Gruber <julian@juliangruber.com>;
-
-Permission is hereby granted, free of charge, to any person obtaining a copy of
-this software and associated documentation files (the "Software"), to deal in
-the Software without restriction, including without limitation the rights to
-use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
-of the Software, and to permit persons to whom the Software is furnished to do
-so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in all
-copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-SOFTWARE.
-
-----
-
-
 [[marked]]
 marked
 
@@ -1330,7 +1265,7 @@
 [[page]]
 page
 
-* page
+* polygerrit-gr-page
 
 [[page_license]]
 ----
@@ -1360,38 +1295,6 @@
 ----
 
 
-[[path-to-regexp]]
-path-to-regexp
-
-* path-to-regexp
-
-[[path-to-regexp_license]]
-----
-The MIT License (MIT)
-
-Copyright (c) 2014 Blake Embrey (hello@blakeembrey.com)
-
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in
-all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-THE SOFTWARE.
-
-----
-
-
 [[resemblejs]]
 resemblejs
 
diff --git a/Documentation/licenses.txt b/Documentation/licenses.txt
index 1706f13..2d5ab1d 100644
--- a/Documentation/licenses.txt
+++ b/Documentation/licenses.txt
@@ -3507,39 +3507,6 @@
 ----
 
 
-[[codemirror-minified]]
-codemirror-minified
-
-* codemirror-minified
-
-[[codemirror-minified_license]]
-----
-The MIT License (MIT)
-
-Copyright (c) 2016 Marijn Haverbeke <marijnh@gmail.com> and others
-Copyright (c) 2016 Michael Zhou <zhoumotongxue008@gmail.com>
-
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in all
-copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-SOFTWARE.
-
-----
-
-
 [[font-roboto-local-fonts-roboto]]
 font-roboto-local-fonts-roboto
 
@@ -4145,38 +4112,6 @@
 ----
 
 
-[[isarray]]
-isarray
-
-* isarray
-
-[[isarray_license]]
-----
-(MIT)
-
-Copyright (c) 2013 Julian Gruber <julian@juliangruber.com>;
-
-Permission is hereby granted, free of charge, to any person obtaining a copy of
-this software and associated documentation files (the "Software"), to deal in
-the Software without restriction, including without limitation the rights to
-use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
-of the Software, and to permit persons to whom the Software is furnished to do
-so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in all
-copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-SOFTWARE.
-
-----
-
-
 [[marked]]
 marked
 
@@ -4234,7 +4169,7 @@
 [[page]]
 page
 
-* page
+* polygerrit-gr-page
 
 [[page_license]]
 ----
@@ -4264,38 +4199,6 @@
 ----
 
 
-[[path-to-regexp]]
-path-to-regexp
-
-* path-to-regexp
-
-[[path-to-regexp_license]]
-----
-The MIT License (MIT)
-
-Copyright (c) 2014 Blake Embrey (hello@blakeembrey.com)
-
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in
-all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-THE SOFTWARE.
-
-----
-
-
 [[resemblejs]]
 resemblejs
 
diff --git a/Documentation/metrics.txt b/Documentation/metrics.txt
index 13a6354..8b21ca2 100644
--- a/Documentation/metrics.txt
+++ b/Documentation/metrics.txt
@@ -200,6 +200,19 @@
 
 === Change
 
+* `change/count_rebases`: Total number of rebases
+** `on_behalf_of_uploader`:
+   Whether the rebase was done on behalf of the uploader.
+   If the uploader does a rebase with '`on_behalf_of_uploader = true`', the flag
+   is ignored and a normal rebase is done, hence such rebases are recorded as
+   '`on_behalf_of_uploader` = false`'.
+** `rebase_chain`:
+   Whether a chain was rebased.
+** `allow_conflicts`:
+   Whether the rebase was done with allowing conflicts.
+* `change/submitted_with_rebaser_approval`: Number of rebased changes that were
+  submitted with a Code-Review approval of the rebaser that would not have been
+  submittable if the rebase was not done on behalf of the uploader.
 * `change/submit_rule_evaluation`: Latency for evaluating submit rules on a
   change.
 * `change/submit_type_evaluation`: Latency for evaluating the submit type on a
diff --git a/Documentation/pg-plugin-checks-api.txt b/Documentation/pg-plugin-checks-api.txt
index 9864f18..968adcc 100644
--- a/Documentation/pg-plugin-checks-api.txt
+++ b/Documentation/pg-plugin-checks-api.txt
@@ -10,6 +10,10 @@
 when a change page is loaded. Such a call would return a list of `Runs` and each
 run can contain a list of `Results`.
 
+`Results` messages will render as markdown. It follows the
+[CommonMark](https://commonmark.org/help/) spec except inline images and direct
+HTML are not rendered and kept as plaintext.
+
 The details of the ChecksApi are documented in the
 link:https://gerrit.googlesource.com/gerrit/+/master/polygerrit-ui/app/api/checks.ts[source code].
 Note that this link points to the `master` branch and might thus reflect a
diff --git a/Documentation/pg-plugin-dev.txt b/Documentation/pg-plugin-dev.txt
index 61944b6..7c93cc0 100644
--- a/Documentation/pg-plugin-dev.txt
+++ b/Documentation/pg-plugin-dev.txt
@@ -125,20 +125,29 @@
 See link:https://gerrit.googlesource.com/gerrit/+/master/polygerrit-ui/app/styles/themes/[app-theme.ts]
 for the list of available variables.
 
-Just add code like this to your JavaScript plugin:
+You can just create `<style>` elements yourself and add them to the
+`document.head`, but for your convenience the Plugin API provides a simple
+`styleApi().insertCSSRule()` method for doing just that. Typically you would
+define a CSS rule for `html`, which is always applied, or for a specific theme
+such as `html.lightTheme`. 
 
 ``` js
 Gerrit.install(plugin => {
-  const styleEl = document.createElement('style');
-  styleEl.innerHTML = `
-      html {
-        --header-background-color: #c3d9ff;
-      }
-      html.darkTheme {
-        --header-background-color: #c3d9ff90;
-      }
-  `;
-  document.head.appendChild(styleEl);
+  plugin.styleApi().insertCSSRule(`
+    html {
+      --header-text-color: black;
+    }
+  `);
+  plugin.styleApi().insertCSSRule(`
+    html.lightTheme {
+      --header-background-color: red;
+    }
+  `);
+  plugin.styleApi().insertCSSRule(`
+    html.darkTheme {
+      --header-background-color: blue;
+    }
+  `);
 });
 ```
 
@@ -181,12 +190,6 @@
 
 See link:pg-plugin-endpoints.html[endpoints].
 
-=== registerStyleModule
-`plugin.registerStyleModule(endpointName, moduleName)`
-
-This API is deprecated and will be removed either in version 3.6 or 3.7,
-see link:#low-level-style[above] for an alternative.
-
 === on
 Register a JavaScript callback to be invoked when events occur within
 the web interface. Signature
diff --git a/Documentation/pg-plugin-endpoints.txt b/Documentation/pg-plugin-endpoints.txt
index 8fe0833..0429f91 100644
--- a/Documentation/pg-plugin-endpoints.txt
+++ b/Documentation/pg-plugin-endpoints.txt
@@ -151,6 +151,10 @@
 === settings-screen
 This endpoint is situated at the end of the body of the settings screen.
 
+=== profile
+This endpoint is situated at the top of the Profile section of the settings
+screen below the section description text.
+
 === reply-text
 This endpoint wraps the textarea in the reply dialog.
 
diff --git a/Documentation/prolog-cookbook.txt b/Documentation/prolog-cookbook.txt
index c083f28..6c77109 100644
--- a/Documentation/prolog-cookbook.txt
+++ b/Documentation/prolog-cookbook.txt
@@ -1,5 +1,12 @@
 :linkattrs:
-= Gerrit Code Review - Prolog Submit Rules Cookbook
+= Gerrit Code Review - Prolog Submit Rules Cookbook (Deprecated)
+
+[WARNING]
+Prolog rules are no longer supported in Gerrit. Existing usages of prolog rules
+can be modified or deleted, but uploading new "rules.pl" files are rejected.
+Please use link:config-submit-requirements.html[submit requirements] instead.
+Note that the link:#SubmitType[Submit Type] being deprecated in this
+documentation page currently has no substitution in submit requirements.
 
 [[SubmitRule]]
 == Submit Rule
diff --git a/Documentation/rest-api-accounts.txt b/Documentation/rest-api-accounts.txt
index 7e06e4a..7646777 100644
--- a/Documentation/rest-api-accounts.txt
+++ b/Documentation/rest-api-accounts.txt
@@ -2316,6 +2316,9 @@
 link:access-control.html#capability_viewPlugins[View Plugins] capability.
 |`viewQueue`         |not set if `false`|Whether the user has the
 link:access-control.html#capability_viewQueue[View Queue] capability.
+|`viewSecondaryEmails`|not set if `false`|Whether the user has the
+link:access-control.html#capability_viewSecondaryEmails[View Secondary
+Emails] capability.
 |=================================
 
 [[contributor-agreement-info]]
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index b9de6ab..92d4030 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -103,15 +103,17 @@
       "id": "demo~master~Idaf5e098d70898b7119f6f4af5a6c13343d64b57",
       "project": "demo",
       "branch": "master",
-      "attention_set": [
-        {
+      "attention_set": {
+        "1000096": {
           "account": {
-            "name": "John Doe"
+            "_account_id": 1000096,
+            "name": "John Doe",
+            "email": "john.doe@example.com"
           },
-         "last_update": "2012-07-17 07:19:27.766000000",
-         "reason": "reviewer or cc replied"
+          "last_update": "2012-07-17 07:19:27.766000000",
+          "reason": "reviewer or cc replied"
         }
-      ]
+      },
       "change_id": "Idaf5e098d70898b7119f6f4af5a6c13343d64b57",
       "subject": "One change",
       "status": "NEW",
@@ -552,15 +554,17 @@
     "id": "myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940",
     "project": "myProject",
     "branch": "master",
-    "attention_set": [
-      {
+    "attention_set": {
+      "1000096": {
         "account": {
-          "name": "John Doe"
+          "_account_id": 1000096,
+          "name": "John Doe",
+          "email": "john.doe@example.com"
         },
-       "last_update": "2013-02-21 11:16:36.775000000",
-       "reason": "reviewer or cc replied"
+        "last_update": "2013-02-21 11:16:36.775000000",
+        "reason": "reviewer or cc replied"
       }
-    ]
+    },
     "change_id": "I8473b95934b5732ac55d26311a706c9c2bde9940",
     "subject": "Implementing Feature X",
     "status": "NEW",
@@ -619,15 +623,17 @@
   )]}'
   {
     "added": {
-      "attention_set": [
-        {
+      "attention_set": {
+        "1000096": {
           "account": {
-            "name": "John Doe"
+            "_account_id": 1000096,
+            "name": "John Doe",
+            "email": "john.doe@example.com"
           },
-         "last_update": "2013-02-21 11:16:36.775000000",
-         "reason": "reviewer or cc replied"
+          "last_update": "2013-02-21 11:16:36.775000000",
+          "reason": "reviewer or cc replied"
         }
-      ]
+      },
       "updated": "2013-02-21 11:16:36.775000000",
       "topic": "new-topic"
     },
@@ -658,15 +664,17 @@
       "id": "myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940",
       "project": "myProject",
       "branch": "master",
-      "attention_set": [
-        {
+      "attention_set": {
+        "1000096": {
           "account": {
-            "name": "John Doe"
+            "_account_id": 1000096,
+            "name": "John Doe",
+            "email": "john.doe@example.com"
           },
-         "last_update": "2013-02-21 11:16:36.775000000",
-         "reason": "reviewer or cc replied"
+          "last_update": "2013-02-21 11:16:36.775000000",
+          "reason": "reviewer or cc replied"
         }
-      ],
+      },
       "change_id": "I8473b95934b5732ac55d26311a706c9c2bde9940",
       "subject": "Implementing Feature X",
       "status": "NEW",
@@ -726,18 +734,18 @@
     "id": "myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940",
     "project": "myProject",
     "branch": "master",
-    "attention_set": [
-      {
+    "attention_set": {
+      "1000096": {
         "account": {
           "_account_id": 1000096,
           "name": "John Doe",
           "email": "john.doe@example.com",
           "username": "jdoe"
         },
-       "last_update": "2013-02-21 11:16:36.775000000",
-       "reason": "reviewer or cc replied"
+        "last_update": "2013-02-21 11:16:36.775000000",
+        "reason": "reviewer or cc replied"
       }
-    ]
+    },
     "change_id": "I8473b95934b5732ac55d26311a706c9c2bde9940",
     "subject": "Implementing Feature X",
     "status": "NEW",
@@ -823,6 +831,26 @@
         "+2"
       ]
     },
+    "removable_labels": {
+      "Code-Review": {
+        "-1": [
+          {
+            "_account_id": 1000096,
+            "name": "John Doe",
+            "email": "john.doe@example.com",
+            "username": "jdoe"
+          }
+        ],
+        "+1": [
+          {
+            "_account_id": 1000097,
+            "name": "Jane Roe",
+            "email": "jane.roe@example.com",
+            "username": "jroe"
+          }
+        ]
+      }
+    },
     "removable_reviewers": [
       {
         "_account_id": 1000096,
@@ -1104,154 +1132,6 @@
   HTTP/1.1 204 No Content
 ----
 
-[[get-assignee]]
-=== Get Assignee
---
-'GET /changes/link:#change-id[\{change-id\}]/assignee'
---
-
-Retrieves the account of the user assigned to a change.
-
-.Request
-----
-  GET /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/assignee HTTP/1.0
-----
-
-As a response an link:rest-api-accounts.html#account-info[AccountInfo] entity
-describing the assigned account is returned.
-
-.Response
-----
-  HTTP/1.1 200 OK
-  Content-Disposition: attachment
-  Content-Type: application/json; charset=UTF-8
-
-  )]}'
-  {
-    "_account_id": 1000096,
-    "name": "John Doe",
-    "email": "john.doe@example.com",
-    "username": "jdoe"
-  }
-----
-
-If the change has no assignee the response is "`204 No Content`".
-
-[[get-past-assignees]]
-=== Get Past Assignees
---
-'GET /changes/link:#change-id[\{change-id\}]/past_assignees'
---
-
-Returns a list of every user ever assigned to a change, in the order in which
-they were first assigned.
-
-.Request
-----
-  GET /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/past_assignees HTTP/1.0
-----
-
-As a response a list of link:rest-api-accounts.html#account-info[AccountInfo]
-entities is returned.
-
-.Response
-----
-  HTTP/1.1 200 OK
-  Content-Disposition: attachment
-  Content-Type: application/json; charset=UTF-8
-
-  )]}'
-  [
-    {
-      "_account_id": 1000051,
-      "name": "Jane Doe",
-      "email": "jane.doe@example.com",
-      "username": "janed"
-    },
-    {
-      "_account_id": 1000096,
-      "name": "John Doe",
-      "email": "john.doe@example.com",
-      "username": "jdoe"
-    }
-  ]
-
-----
-
-
-[[set-assignee]]
-=== Set Assignee
---
-'PUT /changes/link:#change-id[\{change-id\}]/assignee'
---
-
-Sets the assignee of a change.
-
-The new assignee must be provided in the request body inside a
-link:#assignee-input[AssigneeInput] entity.
-
-.Request
-----
-  PUT /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/assignee HTTP/1.0
-  Content-Type: application/json; charset=UTF-8
-
-  {
-    "assignee": "jdoe"
-  }
-----
-
-As a response an link:rest-api-accounts.html#account-info[AccountInfo] entity
-describing the assigned account is returned.
-
-.Response
-----
-  HTTP/1.1 200 OK
-  Content-Disposition: attachment
-  Content-Type: application/json; charset=UTF-8
-
-  )]}'
-  {
-    "_account_id": 1000096,
-    "name": "John Doe",
-    "email": "john.doe@example.com",
-    "username": "jdoe"
-  }
-----
-
-[[delete-assignee]]
-=== Delete Assignee
---
-'DELETE /changes/link:#change-id[\{change-id\}]/assignee'
---
-
-Deletes the assignee of a change.
-
-
-.Request
-----
-  DELETE /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/assignee HTTP/1.0
-----
-
-As a response an link:rest-api-accounts.html#account-info[AccountInfo] entity
-describing the account of the deleted assignee is returned.
-
-.Response
-----
-  HTTP/1.1 200 OK
-  Content-Disposition: attachment
-  Content-Type: application/json; charset=UTF-8
-
-  )]}'
-  {
-    "_account_id": 1000096,
-    "name": "John Doe",
-    "email": "john.doe@example.com",
-    "username": "jdoe"
-  }
-----
-
-If the change had no assignee the response is "`204 No Content`".
-
 [[get-pure-revert]]
 === Get Pure Revert
 --
@@ -1512,6 +1392,250 @@
   The change could not be rebased due to a path conflict during merge.
 ----
 
+Rebasing a change is allowed for the change owner, users with the
+link:access-control.html#category_rebase[Rebase] permission and users
+with the link:access-control.html#category_submit[Submit] permission.
+
+In addition, the rebaser or the original uploader, if rebasing is done
+on behalf of the uploader (see `rebase_on_behalf_of_uploader` option in
+link:#rebase-input[RebaseInput]), needs to have all permissions that
+are required to create the new patch set:
+
+* the link:access-control.html#category_push[Push] permission
+* the link:access-control.html#category_add_patch_set[Add Patch Set]
+  permission (only if the user is not the change owner)
+* the link:access-control.html#category_forge_author[Forge Author]
+  permission (only if the commit author is forged)
+* the link:access-control.html#category_forge_server[Forge Server]
+  permission (only if the commit author is the server identity)
+
+The same permissions were required for the upload of the original patch
+set. This means if the rebase is done on behalf of the uploader these
+permission checks should just pass, unless the uploader lost
+permissions after the upload of the original patch set. In this case
+rebasing on behalf of the uploader is not possible and a normal rebase
+(on behalf of the rebaser) must be done, which means that the rebaser
+becomes the uploader and takes over the change. If self approvals are
+disallowed, this means that the rebaser can no longer approve the
+change (as approvals of the uploader are ignored).
+
+[[rebase-chain]]
+=== Rebase Chain
+--
+'POST /changes/link:#change-id[\{change-id\}]/rebase:chain'
+--
+
+Rebases an ancestry chain of changes.
+
+The operated change is treated as the chain tip. All unsubmitted ancestors are rebased.
+
+Requires a linear ancestry relation (single parenting throughout the chain).
+
+Optionally, the parent revision (of the oldest ancestor to be rebased) can be changed to another
+change, revision or branch through the link:#rebase-input[RebaseInput] entity.
+
+If the chain is outdated, i.e., there's a change that depends on an old revision of its parent, the
+result is the same as individually rebasing all outdated changes on top of their parent's latest
+revision before running the rebase chain action.
+
+.Request
+----
+  POST /changes/myProject~master~I08a021fb07b83fe845140a2c11508b3bdd93b48f/rebase:chain HTTP/1.0
+  Content-Type: application/json;charset=UTF-8
+
+  {
+    "base" : "1234",
+  }
+----
+
+As response a link:#rebase-chain-info[RebaseChainInfo] entity is returned that
+describes the rebased changes. Information about the current patch sets
+are included.
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "rebased_changes": [
+      {
+        "id": "myProject~master~I0e534de9d7f0d6f35b71f7d726acf835b2110c66",
+        "project": "myProject",
+        "branch": "master",
+        "hashtags": [
+
+        ],
+        "change_id": "I0e534de9d7f0d6f35b71f7d726acf835b2110c66",
+        "subject": "456",
+        "status": "NEW",
+        "created": "2022-11-21 20: 51: 31.000000000",
+        "updated": "2022-11-21 20: 56: 49.000000000",
+        "submit_type": "MERGE_IF_NECESSARY",
+        "insertions": 0,
+        "deletions": 0,
+        "total_comment_count": 0,
+        "unresolved_comment_count": 0,
+        "has_review_started": true,
+        "meta_rev_id": "a2a6692213f546e1045ecf4647439fac8d6d8faa",
+        "_number": 21,
+        "owner": {
+          "_account_id": 1000000
+        },
+        "current_revision": "c3b2ba222d42a56e05c90f88d4509a124620517d",
+        "revisions": {
+          "c3b2ba222d42a56e05c90f88d4509a124620517d": {
+            "kind": "NO_CHANGE",
+            "_number": 2,
+            "created": "2022-11-21 20: 56: 49.000000000",
+            "uploader": {
+              "_account_id": 1000000
+            },
+            "ref": "refs/changes/21/21/2",
+            "fetch": {
+
+            },
+            "commit": {
+              "parents": [
+                {
+                  "commit": "7803f427dd7c4a2441466e4d740a1850dcee1af4",
+                  "subject": "123"
+                }
+              ],
+              "author": {
+                "name": "Nitzan Gur-Furman",
+                "email": "nitzan@google.com",
+                "date": "2022-11-21 20: 49: 39.000000000",
+                "tz": 60
+              },
+              "committer": {
+                "name": "Administrator",
+                "email": "admin@example.com",
+                "date": "2022-11-21 20: 56: 49.000000000",
+                "tz": 60
+              },
+              "subject": "456",
+              "message": "456\n"
+            },
+            "description": "Rebase"
+          }
+        },
+        "requirements": [
+
+        ],
+        "submit_records": [
+          {
+            "rule_name": "gerrit~DefaultSubmitRule",
+            "status": "NOT_READY",
+            "labels": [
+              {
+                "label": "Code-Review",
+                "status": "NEED"
+              }
+            ]
+          }
+        ]
+      },
+      {
+        "id": "myProject~master~I08a021fb07b83fe845140a2c11508b3bdd93b48f",
+        "project": "myProject",
+        "branch": "master",
+        "hashtags": [
+
+        ],
+        "change_id": "I08a021fb07b83fe845140a2c11508b3bdd93b48f",
+        "subject": "789",
+        "status": "NEW",
+        "created": "2022-11-21 20: 51: 31.000000000",
+        "updated": "2022-11-21 20: 56: 49.000000000",
+        "submit_type": "MERGE_IF_NECESSARY",
+        "insertions": 0,
+        "deletions": 0,
+        "total_comment_count": 0,
+        "unresolved_comment_count": 0,
+        "has_review_started": true,
+        "meta_rev_id": "3bfb843fea471f96e16b9199c3a30fff0285bc45",
+        "_number": 22,
+        "owner": {
+          "_account_id": 1000000
+        },
+        "current_revision": "77eb17a9501a5c21963bc6af56085e60f281acbb",
+        "revisions": {
+          "77eb17a9501a5c21963bc6af56085e60f281acbb": {
+            "kind": "NO_CHANGE",
+            "_number": 2,
+            "created": "2022-11-21 20: 56: 49.000000000",
+            "uploader": {
+              "_account_id": 1000000
+            },
+            "ref": "refs/changes/22/22/2",
+            "fetch": {
+
+            },
+            "commit": {
+              "parents": [
+                {
+                  "commit": "c3b2ba222d42a56e05c90f88d4509a124620517d",
+                  "subject": "456"
+                }
+              ],
+              "author": {
+                "name": "Nitzan Gur-Furman",
+                "email": "nitzan@google.com",
+                "date": "2022-11-21 20: 51: 07.000000000",
+                "tz": 60
+              },
+              "committer": {
+                "name": "Administrator",
+                "email": "admin@example.com",
+                "date": "2022-11-21 20: 56: 49.000000000",
+                "tz": 60
+              },
+              "subject": "789",
+              "message": "789\n"
+            },
+            "description": "Rebase"
+          }
+        },
+        "requirements": [
+
+        ],
+        "submit_records": [
+          {
+            "rule_name": "gerrit~DefaultSubmitRule",
+            "status": "NOT_READY",
+            "labels": [
+              {
+                "label": "Code-Review",
+                "status": "NEED"
+              }
+            ]
+          }
+        ]
+      }
+    ],
+  }
+----
+
+If the change cannot be rebased, e.g. due to conflicts, the response is
+"`409 Conflict`" and the error message is contained in the response
+body.
+
+.Response
+----
+  HTTP/1.1 409 Conflict
+  Content-Disposition: attachment
+  Content-Type: text/plain; charset=UTF-8
+
+  Change I0e534de9d7f0d6f35b71f7d726acf835b2110c66 could not be rebased due to a conflict during
+  merge.
+
+  merge conflict(s):
+  a.txt
+----
+
 [[move-change]]
 === Move Change
 --
@@ -2131,6 +2255,66 @@
   HTTP/1.1 204 No Content
 ----
 
+[[apply-patch]]
+=== Create patch-set from patch
+--
+'POST /changes/link:#change-id[\{change-id\}]/patch:apply'
+--
+
+Creates a new patch set on a destination change from the provided patch.
+
+The patch must be provided in the request body, inside a
+link:#applypatchpatchset-input[ApplyPatchPatchSetInput] entity.
+
+If a base commit is given, the patch is applied on top of it. Otherwise, the
+patch is applied on top of the target change's original parent.
+
+Applying the patch will fail if the destination change is closed, or in case of any conflicts.
+
+.Request
+----
+  POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/patch:apply HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "patch": {
+      "patch": "new file mode 100644\n--- /dev/null\n+++ b/a_new_file.txt\n@@ -0,0 +1,2 @@ \
++Patch compatible `git diff` output \
++For example: `link:#get-patch[<gerrit patch>] | base64 -d | sed -z 's/\n/\\n/g'`"
+    }
+  }
+----
+
+As response a link:#change-info[ChangeInfo] entity is returned that
+describes the destination change after applying the patch.
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "id": "myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9941",
+    "project": "myProject",
+    "branch": "release-branch",
+    "change_id": "I8473b95934b5732ac55d26311a706c9c2bde9941",
+    "subject": "Original change subject",
+    "status": "NEW",
+    "created": "2013-02-01 09:59:32.126000000",
+    "updated": "2013-02-21 11:16:36.775000000",
+    "mergeable": true,
+    "insertions": 12,
+    "deletions": 11,
+    "_number": 3965,
+    "owner": {
+      "name": "John Doe"
+    },
+    "current_revision": "184ebe53805e102605d11f6b143486d15c23a09c"
+  }
+----
+
 [[get-included-in]]
 === Get Included In
 --
@@ -2862,7 +3046,7 @@
 'GET /changes/link:#change-id[\{change-id\}]/edit
 --
 
-Retrieves a change edit details.
+Retrieves the details of the change edit done by the caller to the given change.
 
 .Request
 ----
@@ -2934,12 +3118,19 @@
   Content-Type: application/json; charset=UTF-8
 
   {
-    "binary_content": "data:text/plain;base64,SGVsbG8sIFdvcmxkIQ=="
+    "binary_content": "data:text/plain;base64,SGVsbG8sIFdvcmxkIQ==",
+    "file_mode": 100755
   }
 ----
 
 Note that it must be base-64 encoded data uri.
 
+The "file_mode" field is optional, and if provided must be in octal format. The field
+indicates whether the file is executable or not and has a value of either 100755
+(executable) or 100644 (not executable). If it's unset, this indicates no change
+has been made. New files default to not being executable if this parameter is not
+provided
+
 When change edit doesn't exist for this change yet it is created. When file
 content isn't provided, it is wiped out for that file. As response
 "`204 No Content`" is returned.
@@ -3642,6 +3833,10 @@
 If another user removed a user's vote, the user with the deleted vote will be
 added to the attention set.
 
+The request returns:
+ * '204 No Content' if the vote is deleted successfully;
+ * '404 Not Found' when the vote to be deleted is zero or not present.
+
 .Request
 ----
   DELETE /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/reviewers/John%20Doe/votes/Code-Review HTTP/1.0
@@ -6469,7 +6664,7 @@
 * a commit ID ("674ac754f91e64a0efb8087e59a176484bd534d1")
 * an abbreviated commit ID that uniquely identifies one revision of the
   change ("674ac754"), at least 4 digits are required
-* a legacy numeric patch number ("1" for first patch set of the change)
+* a numeric patch number ("1" for first patch set of the change)
 * "0" or the literal `edit` for a change edit
 
 [[json-entities]]
@@ -6520,8 +6715,94 @@
 If true the action is permitted at this time and the caller is
 likely allowed to execute it. This may change if state is updated
 at the server or permissions are modified. Not present if false.
+|`enabled_options`      |optional|
+Optional list of enabled options. +
+See the list of suppported options link:#action-options[below].
 |====================================
 
+[[action-options]]
+==== Action Options
+
+Options that are returned via the `enabled_options` field of
+link:#action-info[ActionInfo].
+
+[options="header",cols="1,^1,5"]
+|===================================
+|REST view     |Option  |Description
+|`rebase`      |`rebase`|
+Present if the user can rebase the change. +
+This is the case for the change owner and users with the
+link:access-control.html#category_submit[Submit] or
+link:access-control.html#category_rebase[Rebase] permission if they
+have the link:access-control.html#category_push[Push] permission.
+|`rebase`      |`rebase_on_behalf_of_uploader`|
+Present if the user can rebase the change on behalf of the uploader. +
+This is the case for the change owner and users with the
+link:access-control.html#category_submit[Submit] or
+link:access-control.html#category_rebase[Rebase] permission.
+|`rebase:chain`|`rebase`|
+Present if the user can rebase the chain. +
+This is the case if the calling user can rebase each change in the
+chain.
+Rebasing a change is allowed for the change owner and users with the
+link:access-control.html#category_submit[Submit] or
+link:access-control.html#category_rebase[Rebase] permission if they
+have the link:access-control.html#category_push[Push] permission.
+|`rebase:chain`|`rebase_on_behalf_of_uploader`|
+Present if the user can rebase the chain on behalf of the uploader. +
+This is the case if the calling user can rebase each change in the
+chain on behalf of the uploader.
+Rebasing a change on behalf of the uploader is allowed for the change
+owner and users with the link:access-control.html#category_submit[Submit]
+or link:access-control.html#category_rebase[Rebase] permission.
+|===================================
+
+For all other REST views no options are returned.
+
+[[applypatch-input]]
+=== ApplyPatchInput
+The `ApplyPatchInput` entity contains information about a patch to apply.
+
+A new commit will be created from the patch, and saved as a new patch set.
+
+[options="header",cols="1,^1,5"]
+|=================================
+|Field Name          ||Description
+|`patch`             |required|
+The patch to be applied. Must be compatible with `git diff` output.
+For example, link:#get-patch[Get Patch] output.
+The patch must be provided as UTF-8 text, either directly or base64-encoded.
+|=================================
+
+[[applypatchpatchset-input]]
+=== ApplyPatchPatchSetInput
+The `ApplyPatchPatchSetInput` entity contains information for creating a new patch set from a
+given patch.
+
+[options="header",cols="1,^1,5"]
+|=================================
+|Field Name           ||Description
+|`patch`              |required|
+The details of the patch to be applied as a link:#applypatch-input[ApplyPatchInput] entity.
+|`commit_message`     |optional|
+The commit message for the new patch set. If not specified, the latest patch-set message will be
+used.
+|`base`               |optional|
+40-hex digit SHA-1 of the commit which will be the parent commit of the newly created patch set.
+If set, it must be a merged commit or a change revision on the destination branch.
+Otherwise, the target change's branch tip will be used.
+|`author`             |optional|
+The author of the commit to create. Must be an
+link:rest-api-accounts.html#account-input[AccountInput] entity with at least
+the `name` and `email` fields set.
+The caller needs "Forge Author" permission when using this field, unless specifies their own details.
+This field does not affect the owner of the change, which will continue to use the identity of the
+caller.
+|`response_format_options`     |optional|
+List of link:#query-options[query options] to format the response.
+|=================================
+
+
 [[approval-info]]
 === ApprovalInfo
 The `ApprovalInfo` entity contains information about an approval from a
@@ -6555,18 +6836,6 @@
 If true, this vote was made after the change was submitted.
 |===========================
 
-[[assignee-input]]
-=== AssigneeInput
-The `AssigneeInput` entity contains the identity of the user to be set as assignee.
-
-[options="header",cols="1,^1,5"]
-|===========================
-|Field Name    ||Description
-|`assignee`     ||
-The link:rest-api-accounts.html#account-id[ID] of one account that
-should be added as assignee.
-|===========================
-
 [[attention-set-info]]
 === AttentionSetInfo
 The `AttentionSetInfo` entity contains details of users that are in
@@ -6675,9 +6944,6 @@
 accounts that were in the attention set but were removed. The
 link:#attention-set-info[AttentionSetInfo] is the latest and most recent removal
  of the account from the attention set.
-|`assignee`           |optional|
-The assignee of the change as an link:rest-api-accounts.html#account-info[
-AccountInfo] entity.
 |`hashtags`           |optional|
 List of hashtags that are set on the change.
 |`change_id`          ||The Change-Id of the change.
@@ -6756,6 +7022,13 @@
 A map of the permitted labels that maps a label name to the list of
 values that are allowed for that label. +
 Only set if link:#detailed-labels[detailed labels] are requested.
+|`removable_labels`   |optional|
+A map of the removable labels that maps a label name to the map of
+values and reviewers (
+link:rest-api-accounts.html#account-info[AccountInfo] entities)
+that are allowed to be removed from the change. +
+Only set if link:#labels[labels] or
+link:#detailed-labels[detailed labels] are requested.
 |`removable_reviewers`|optional|
 The reviewers that can be removed by the calling user as a list of
 link:rest-api-accounts.html#account-info[AccountInfo] entities. +
@@ -6874,11 +7147,16 @@
 Whether the new change should be set to work in progress.
 |`base_change`        |optional|
 A link:#change-id[\{change-id\}] that identifies the base change for a create
-change operation. Mutually exclusive with `base_commit`.
+change operation. +
+Mutually exclusive with `base_commit`. +
+If neither `base_commit` nor `base_change` are set, the target branch tip will
+be used as the parent commit.
 |`base_commit`        |optional|
 A 40-digit hex SHA-1 of the commit which will be the parent commit of the newly
-created change. If set, it must be a merged commit on the destination branch.
-Mutually exclusive with `base_change`.
+created change. If set, it must be a merged commit on the destination branch. +
+Mutually exclusive with `base_change`. +
+If neither `base_commit` nor `base_change` are set, the target branch tip will
+be used as the parent commit.
 |`new_branch`         |optional, default to `false`|
 Allow creating a new branch when set to `true`. Using this option is
 only possible for non-merge commits (if the `merge` field is not set).
@@ -6895,10 +7173,12 @@
 If set, the target branch (see  `branch` field) must exist (it is not
 possible to create it automatically by setting the `new_branch` field
 to `true`.
+|`patch`              |optional|
+The detail of a patch to be applied as an link:#applypatch-input[ApplyPatchInput] entity.
 |`author`             |optional|
-An link:rest-api-accounts.html#account-input[AccountInput] entity
-that will set the author of the commit to create. The author must be
-specified as name/email combination.
+The author of the commit to create. Must be an
+link:rest-api-accounts.html#account-input[AccountInput] entity with at least
+the `name` and `email` fields set.
 The caller needs "Forge Author" permission when using this field.
 This field does not affect the owner of the change, which will
 continue to use the identity of the caller.
@@ -6911,6 +7191,8 @@
 Additional information about whom to notify about the change creation
 as a map of link:user-notify.html#recipient-types[recipient type] to
 link:#notify-info[NotifyInfo] entity.
+|`response_format_options`     |optional|
+List of link:#query-options[query options] to format the response.
 |==================================
 
 [[change-message-info]]
@@ -6929,7 +7211,7 @@
 |`real_author`         |optional|
 Real author of the message as an
 link:rest-api-accounts.html#account-info[AccountInfo] entity. +
-Set if the message was posted on behalf of another user.
+Only set if the message was posted on behalf of another user.
 |`date`            ||
 The link:rest-api.html#timestamp[timestamp] this message was posted.
 |`message`            ||
@@ -7386,18 +7668,19 @@
 
 [[diff-web-link-info]]
 === DiffWebLinkInfo
-The `DiffWebLinkInfo` entity describes a link on a diff screen to an
-external site.
+The `DiffWebLinkInfo` entity extends link:#web-link-info[WebLinkInfo] and
+describes a link on a diff screen to an external site.
 
-[options="header",cols="1,6"]
+[options="header",cols="1,^1,5"]
 |=======================
-|Field Name|Description
-|`name`     |The link name.
-|`url`      |The link URL.
-|`image_url`|URL to the icon of the link.
-|show_on_side_by_side_diff_view|
+|Field Name||Description
+|`name`     ||See link:#web-link-info[WebLinkInfo]
+|`tooltip`  |optional|See link:#web-link-info[WebLinkInfo]
+|`url`      ||See link:#web-link-info[WebLinkInfo]
+|`image_url`|optional|See link:#web-link-info[WebLinkInfo]
+|show_on_side_by_side_diff_view||
 Whether the web link should be shown on the side-by-side diff screen.
-|show_on_unified_diff_view|
+|show_on_unified_diff_view||
 Whether the web link should be shown on the unified diff screen.
 |=======================
 
@@ -7488,8 +7771,13 @@
 differ by one from details provided in <<diff-info,DiffInfo>>.
 |`size_delta`    ||
 Number of bytes by which the file size increased/decreased.
-|`size`          ||
-File size in bytes.
+|`size`          || File size in bytes.
+|`old_mode`        |optional|File mode in octal (e.g. 100644) at the old commit.
+The first three digits indicate the file type and the last three digits contain
+the file permission bits. For added files, this field will not be present.
+|`new_mode`        |optional|File mode in octal (e.g. 100644) at the new commit.
+The first three digits indicate the file type and the last three digits contain
+the file permission bits. For deleted files, this field will not be present.
 |=============================
 
 [[fix-input]]
@@ -7731,11 +8019,11 @@
 The detail of the source commit for merge as a link:#merge-input[MergeInput]
 entity.
 |`author`             |optional|
-An link:rest-api-accounts.html#account-input[AccountInput] entity
-that will set the author of the commit to create. The author must be
-specified as name/email combination.
+The author of the commit to create. Must be an
+link:rest-api-accounts.html#account-input[AccountInput] entity with at least
+the `name` and `email` fields set.
 The caller needs "Forge Author" permission when using this field.
-This field does not affect the owner of the change, which will
+This field does not affect the owner or the committer of the change, which will
 continue to use the identity of the caller.
 |==================================
 
@@ -7865,22 +8153,32 @@
 The `RebaseInput` entity contains information for changing parent when rebasing.
 
 [options="header",cols="1,^1,5"]
-|===========================
-|Field Name          ||Description
-|`base`              |optional|
+|====================================
+|Field Name             ||Description
+|`base`                 |optional|
 The new parent revision. This can be a ref or a SHA-1 to a concrete patchset. +
 Alternatively, a change number can be specified, in which case the current
 patch set is inferred. +
 Empty string is used for rebasing directly on top of the target branch,
 which effectively breaks dependency towards a parent change.
-|`allow_conflicts`   |optional, defaults to false|
+|`allow_conflicts`      |optional, defaults to false|
 If `true`, the rebase also succeeds if there are conflicts. +
 If there are conflicts the file contents of the rebased patch set contain
 git conflict markers to indicate the conflicts. +
 Callers can find out whether there were conflicts by checking the
 `contains_git_conflicts` field in the returned link:#change-info[ChangeInfo]. +
-If there are conflicts the change is marked as work-in-progress.
-|`validation_options`|optional|
+If there are conflicts the change is marked as work-in-progress. +
+Cannot be combined with the `on_behalf_of_uploader` option.
+|`on_behalf_of_uploader`|optional, defaults to false|
+If `true`, the rebase is done on behalf of the uploader. +
+This means the uploader of the current patch set will also be the uploader of
+the rebased patch set. The calling user will be recorded as the real user. +
+Rebasing on behalf of the uploader is only supported for trivial rebases.
+This means this option cannot be combined with the `allow_conflicts` option. +
+In addition, rebasing on behalf of the uploader is only supported for the
+current patch set of a change. +
+If the caller is the uploader this flag is ignored and a normal rebase is done.
+|`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.
@@ -7888,6 +8186,22 @@
 listeners that are implemented in plugins may. Please refer to the
 documentation of the installed plugins to learn whether they support validation
 options. Unknown validation options are silently ignored.
+|====================================
+
+[[rebase-chain-info]]
+=== RebaseChainInfo
+
+The `RebaseChainInfo` entity contains information about a chain of changes
+that were rebased.
+
+[options="header",cols="1,^1,5"]
+|===========================
+|Field Name                ||Description
+|`rebased_changes`         ||List of the unsubmitted ancestors, as link:#change-info[ChangeInfo]
+entities. Includes both rebased changes, and previously up-to-date ancestors. The list is ordered by
+ancestry, where the oldest ancestor is the first.
+|`contains_git_conflicts`  ||Whether any of the rebased changes has conflicts
+due to rebasing.
 |===========================
 
 [[related-change-and-commit-info]]
@@ -7982,7 +8296,9 @@
 Topic can't contain quotation marks.
 |`work_in_progress`  |optional|
 When present, change is marked as Work In Progress. The `notify` input is
-used if it's present, otherwise it will be overridden to `OWNER`. +
+used if it's present, otherwise it will be overridden to `NONE`. +
+Notifications for the reverted change will only sent once the result change is
+no longer WIP. +
 If not set, the default is false.
 |`validation_options`|optional|
 Map with key-value pairs that are forwarded as options to the commit validation
@@ -8251,6 +8567,10 @@
 |`uploader`    ||
 The uploader of the patch set as an
 link:rest-api-accounts.html#account-info[AccountInfo] entity.
+|`real_uploader`|optional|
+The real uploader of the patch set as an
+link:rest-api-accounts.html#account-info[AccountInfo] entity. +
+Only set if the upload was done on behalf of another user.
 |`ref`         ||The Git reference for the patch set.
 |`fetch`       ||
 Information about how to fetch this patch set. The fetch information is
@@ -8380,7 +8700,13 @@
 Notify handling that defines to whom email notifications should be sent after
 the change is submitted. +
 Allowed values are `NONE`, `OWNER`, `OWNER_REVIEWERS` and `ALL`. +
-If not set, the default is `ALL`.
+If not set, the default is `ALL`.+
+Ignored if a post approval diff is present (i.e. if the change is submitted
+with copied approvals), because in this case everyone should be informed
+about the non-reviewed diff which has been applied after the change has been
+approved so that they can take action if the post approval diff looks
+unexpected. In other words if a post approval diff is present `ALL` is
+enforced.
 |`notify_details`|optional|
 Additional information about whom to notify about the update as a map
 of link:user-notify.html#recipient-types[recipient type] to
@@ -8635,15 +8961,23 @@
 
 [[web-link-info]]
 === WebLinkInfo
-The `WebLinkInfo` entity describes a link to an external site.
+The `WebLinkInfo` entity describes a link to an external site. Depending on the
+context and the provided data the UI may decide to show the link as a text link,
+a linkified icon, or both.
+
+If the `tooltip` is not provided, then the UI may fall back to showing something
+like "Open in External Tool".
+
+Weblinks will always be opened in a new tab.
 
 [options="header",cols="1,^1,5"]
 |========================
 |Field Name ||Description
-|`name`     ||The link name.
+|`name`     ||The text to be linkified.
+|`tooltip`  |optional|Tooltip to show when hovering over the link. Using "Open
+in $NAME_OF_EXTERNAL_TOOL" is a good option here.
 |`url`      ||The link URL.
 |`image_url`|optional|URL to the icon of the link.
-|`target`   |optional|The target window in which the web link should be opened.
 |========================
 
 [[work-in-progress-input]]
diff --git a/Documentation/rest-api-config.txt b/Documentation/rest-api-config.txt
index 45de1b1..fe9b13c 100644
--- a/Documentation/rest-api-config.txt
+++ b/Documentation/rest-api-config.txt
@@ -1575,10 +1575,8 @@
 configuration parameter] that controls whether the mergeability bit in
 link:rest-api-changes.html#change-info[ChangeInfo] will never be set and if the
 bit is indexed.
-|`enable_attention_set` |defaults to `false`|
-Returns true if attention set UI features are enabled.
-|`enable_assignee` |defaults to `true`|
-Returns true if assignee related UI features are enabled.
+|`enable_robot_comments`|not set if `false`|
+link:config-gerrit.html#change.enableRobotComments[Are robot comments enabled?].
 |`conflicts_predicate_enabled`|not set if `false`|
 link:config-gerrit.html#change.conflictsPredicateEnabled[Are conflicts enabled?].
 |=============================
diff --git a/Documentation/rest-api-projects.txt b/Documentation/rest-api-projects.txt
index 578fe75..675c054 100644
--- a/Documentation/rest-api-projects.txt
+++ b/Documentation/rest-api-projects.txt
@@ -3817,13 +3817,13 @@
 |`enabled`  |optional|Whether the commentlink is enabled, as documented
 in link:config-gerrit.html#commentlink.name.enabled[
 commentlink.name.enabled]. If not set the commentlink is enabled.
+|==================================================
 
 [[commentlink-input]]
 === CommentLinkInput
 The `CommentLinkInput` entity describes the input for a
 link:config-gerrit.html#commentlink[commentlink].
 
-|==================================================
 [options="header",cols="1,^2,4"]
 |==================================================
 |Field Name |        |Description
@@ -3909,17 +3909,19 @@
 Map with the comment link configurations of the project. The name of
 the comment link configuration is mapped to a link:#commentlink-info[
 CommentlinkInfo] entity.
-|`plugin_config`                           |optional|
+|`plugin_config`                                    |optional|
 Plugin configuration as map which maps the plugin name to a map of
 parameter names to link:#config-parameter-info[ConfigParameterInfo]
 entities. Only filled for users who have read access to `refs/meta/config`.
-|`actions`                                 |optional|
+|`actions`                                          |optional|
 Actions the caller might be able to perform on this project. The
 information is a map of view names to
-|`reject_empty_commit`                     |optional|
+|`reject_empty_commit`                              |optional|
 link:#inherited-boolean-info[InheritedBooleanInfo] that tells whether
 empty commits should be rejected when a change is merged.
 link:rest-api-changes.html#action-info[ActionInfo] entities.
+|`skip_adding_author_and_committer_as_reviewers`    |optional|
+Whether to skip adding the Git commit author and committer as reviewers for a new change.
 |=======================================================
 
 [[config-input]]
diff --git a/Documentation/rest-api.txt b/Documentation/rest-api.txt
index 469bee5..348af76 100644
--- a/Documentation/rest-api.txt
+++ b/Documentation/rest-api.txt
@@ -76,6 +76,16 @@
 headers. If the named resource already exists the server will respond
 with HTTP 412 Precondition Failed.
 
+[[backwards-compatibility]]
+=== Backwards Compatibility
+
+The REST API is regularly extended (e.g. addition of new REST endpoints or new fields in existing
+JSON entities). Callers of the REST API must be able to deal with this (e.g. ignore unknown fields
+in the REST responses). Incompatible changes (e.g. removal of REST endpoints, altering/removal of
+existing fields in JSON entities) are avoided if possible, but can happen in rare cases. If they
+happen, they are announced in the link:https://www.gerritcodereview.com/releases-readme.html[release
+notes].
+
 [[output]]
 === Output Format
 JSON responses are encoded using UTF-8 and use content type
diff --git a/Documentation/user-attention-set.txt b/Documentation/user-attention-set.txt
index 5dc1416..4fe5aae 100644
--- a/Documentation/user-attention-set.txt
+++ b/Documentation/user-attention-set.txt
@@ -131,6 +131,27 @@
 Gerrit-Attention: Marian Harbach <mharbach@google.com>
 ----
 
+=== Browser notifications
+
+You'll automatically get notifications when you are in the attention set. You
+must enable desktop notifications on your browser to see them.
+
+image::images/browser-notification-example.png["browser notification example", align="center"]
+
+You can turn off automatic notifications in user preferences. They are enabled
+by default.
+
+image::images/browser-notification-preference.png["user preference for browser notifications", align="center"]
+
+The notifications work only when Gerrit is open in one of the browser tabs.
+The latency to get the notification is up to 5 minutes.
+
+If you are not getting notifications:
+ - Check your user preferences - Allow browser notification setting
+ - Make sure notifications are turned on for the Gerrit site in the browser
+ - Make sure browser notifications are turned on in your operating system
+ - Your host can have browser notifications disabled for some user groups
+
 === Bold Changes / Mark Reviewed
 
 Before the attention set feature, changes were bolded in the dashboard when
@@ -140,13 +161,7 @@
 
 === For Gerrit Admins
 
-The Attention Set has been available since the 3.3 release (late 2020). It
-is enabled by default, but you can disable it by setting
-link:config-gerrit.html#change.enableAttentionSet[enableAttentionSet] to false.
-
-As part of Gerrit 3.3 upgrade, the user group "Non-Interactive Users" is
-renamed "Service Users". For a new installation, the group is automatically
-created upon initialization.
+The Attention Set has been available since the 3.3 release (late 2020).
 
 === Important note for all host owners, project owners, and bot owners
 
diff --git a/Documentation/user-inline-edit.txt b/Documentation/user-inline-edit.txt
index 4a9d18f..7ed87b6 100644
--- a/Documentation/user-inline-edit.txt
+++ b/Documentation/user-inline-edit.txt
@@ -15,6 +15,9 @@
 [[create-change]]
 == Creating a Change
 
+[[create_in_web_interface]]
+=== In the web interface
+
 To create a change in the Gerrit web interface:
 
 . From the link:http://gerrit-review.googlesource.com[Gerrit Code Review,role=external,window=_blank]
@@ -64,6 +67,22 @@
 
 . Add the files you want to be reviewed.
 
+[[create_from_url]]
+=== From URL
+
+Gerrit supports creating a new change and opening a specific file for edit
+in that change from an "Edit URL":
+```
+^\/admin\/repos\/edit\/repo\/(.+)\/branch\/(.+)\/file\/(.+)$
+```
+This enables other tools to provide a direct link to edit their configuration
+files in Gerrit.
+
+Ex:
+```
+https://gerrit.mycompany.com/admin/repos/edit/repo/my/repo/branch/refs/heads/master/file/Jenkinsfile # Jenkins build file
+https://gerrit.mycompany.com/admin/repos/edit/repo/my/repo/branch/refs/heads/master/file/catalog-info.yaml # Backstage catalog-info
+```
 
 [[add-files]]
 == Adding a File to a Change
@@ -173,7 +192,7 @@
 [[search-for-changes]]
 == Searching for Changes with Pending Edits
 
-To find changes with pending edits:
+To find changes with pending edits created by you:
 
 *  From the Gerrit dashboard, select Your > Changes. All your changes are
 listed, according to Work in progress, Outgoing reviews, Incoming reviews,
@@ -183,6 +202,12 @@
 link:user-search.html[Searching Changes]. For example, to find only
 those changes that contain edits, see link:user-search.html#has[has:edit].
 
+[NOTE]
+Though edits created by others are not accessible from the Gerrit UI, edits
+are not considered to be private data, and are stored in non-encrypted special
+branches under the target repository. As such, they can be accessed by users who
+have access to the repository.
+
 
 [[change-edit-actions]]
 == Modifying Changes
diff --git a/Documentation/user-notify.txt b/Documentation/user-notify.txt
index de5ea57..f420fe7 100644
--- a/Documentation/user-notify.txt
+++ b/Documentation/user-notify.txt
@@ -181,7 +181,6 @@
 * newpatchset
 * restore
 * revert
-* setassignee
 
 [[Gerrit-Change-Id]]Gerrit-Change-Id::
 
diff --git a/Documentation/user-review-ui.txt b/Documentation/user-review-ui.txt
index 6f5f7297..39929e1 100644
--- a/Documentation/user-review-ui.txt
+++ b/Documentation/user-review-ui.txt
@@ -1,21 +1,15 @@
 :linkattrs:
-= Review UI
+= Review UI Overview
 
 Reviewing changes is an important task and the Gerrit Web UI provides
 many functionalities to make the review process comfortable and
 efficient.
 
-The UI has three different main views,
-
-** The dashboard, which shows all changes that are relevant to you
-** The change screen, which shows the change with all its metadata
-** The diff view, which shows changes to a single file
-
 [[change-screen]]
 == Change Screen
 
-The change screen shows the details of a single change and provides
-various actions on it.
+The change screen is the main view for a change. It shows the details of a
+single change and allows various actions on it.
 
 image::images/user-review-ui-change-screen.png[width=800, link="images/user-review-ui-change-screen.png"]
 
@@ -28,44 +22,81 @@
 
 Top left, you find the status of the change, and a permalink.
 
-image::images/user-review-ui-change-screen-topleft.png[width=400, link="images/user-review-ui-change-screen-topleft.png"]
+image::images/user-review-ui-change-screen-topleft.png[width=600, link="images/user-review-ui-change-screen-topleft.png"]
 
 [[change-status]]
 The change status shows the state of the change:
 
-- [[active]]`Active`:
+- `Active`:
 +
 The change is under active review.
 
-- [[merge-conflict]]`Merge Conflict`:
+- `Merge Conflict`:
 +
-The change can't be merged due to conflicts.
+The change can't be merged into the destination branch due to conflicts.
 
-- [[ready-to-submit]]`Ready to Submit`:
+- `Ready to Submit`:
 +
-The change has all necessary approvals and may be submitted.
+The change has all necessary approvals and fulfils all other submit
+requirements. It can be submitted.
 
-- [[merged]]`Merged`:
+- `Merged`:
 +
 The change was successfully merged into the destination branch.
 
-- [[abandoned]]`Abandoned`:
+- `Abandoned`:
 +
-The change was abandoned.
+The change was abandoned. It is not intended to be updated, reviewed or
+submitted anymore.
+
+- `Private`:
++
+The change is marked as link:intro-user.html#private-changes[Private]. And has
+reduced visibility.
+
+- `Revert Created|Revert Submitted`:
++
+The change has a corresponding revert change. Revert changes can be created
+through UI (see <<actions, Actions section>>).
+
+- `WIP`:
++
+The change was marked as "Work in Progress". For example to indicate to
+reviewers that they shouldn't review the change yet.
 
 [[star]]
 === Star Change
 
-Clicking the star icon marks the change as a favorite: it turns on
+Clicking the star icon bookmarks the change: it turns on
 email notifications for this change, and the change is added to the
 list under `Your` > `Starred Changes`. They can be queried by the
 link:user-search.html#is[is:starred] search operator.
 
+[[quick-links]]
+=== Links Menu
+
+Links menu contains various change related strings for quick copying. Such as:
+Change Number, URL, Title+Url, etc. The lines in this menu can also be accessed
+via shortcuts for convenience.
+
+image::images/user-review-ui-copy-links.png[width=600, link="images/user-review-ui-copy-links.png"]
+
 [[change-info]]
 === Change metadata
 
-The change metadata block contains detailed information about the change
-and offers actions on the change.
+The change metadata block contains detailed information about the change.
+
+image::images/user-review-ui-change-metadata.png[width=600, link="images/user-review-ui-change-metadata.png"]
+
+- [[owner]]Owner/Uploader/Author/Committer:
++
+Owner is the person who created the change
++
+Uploader is the person who uploaded the latest patchset (the patchset that will
+be merged if the change is submitted)
++
+Author/Committer are concepts from Git and are retrieved from the commit when
+it's sent for review.
 
 - [[reviewers]]Reviewers:
 +
@@ -74,16 +105,36 @@
 For each reviewer there is a tooltip that shows on which labels the
 reviewer is allowed to vote.
 +
-New reviewers can be added by clicking on the pencil icon. Typing
-into the pop-up text field activates auto completion of user and group
-names.
+New reviewers can be added through reply dialog that is opened by clicking on
+the pencil icon or on "Reply" button. Typing into the reviewer text field
+activates auto completion of user and group names.
 +
+
+- [[cc-list]]CC:
++
+Accounts in CC receive notifications for the updates on the change, but don't
+need to vote/review. If the CC'ed user votes they are moved to reviewers.
++
+
+- [[attention-set]]link:user-attention-set.html[Attention set]:
++
+Users in attention set are marked by "chevron" symbol (see screenshot above).
+The mark indicates that there are actions their attention is required on the
+change: Something updated/changed since last review, their vote is required,
+etc.
++
+Changes for which you are currently in attention set can be found using
+`attention:<User>` in search and show up in a separate category of personal
+dashboard.
++
+Clicking on the mark removes the user from attention set.
+
+
 [[remove-reviewer]]
-Reviewers can be removed from the change by clicking on the `x` icon
-in the reviewer's chip token. Removing a reviewer also removes the
-current votes of the reviewer. The removal of votes is recorded as a
-message on the change.
-+
+Reviewers can be removed from the change by selecting the appropriate option on
+the chip's hovercard. Removing a reviewer also removes current votes of the
+reviewer. The removal of votes is recorded in the change log.
+
 Removing reviewers is protected by permissions:
 
 ** Users can always remove themselves.
@@ -92,10 +143,7 @@
    Remove Reviewer] access right, the branch owner, the project owner
    and Gerrit administrators may remove anyone.
 
-+
-image::images/user-review-ui-change-screen-info-reviewers.png[width=600, link="images/user-review-ui-change-screen-reviewers.png"]
-
-- [[project-branch-topic]]Project / Branch / Topic:
+- [[repo-branch-topic]]Project (Repo) / Branch / Topic:
 +
 The name of the project for which the change was done is displayed as a
 link to the link:user-dashboards.html#project-default-dashboard[default
@@ -112,15 +160,55 @@
 access right. To be able to set a topic on a closed change, the
 `Edit Topic Name` must be assigned with the `force` flag.
 
+- [[parent]]Parent:
++
+Parent commit of the latest uploaded patchset. Or if the change has been merged
+the parent of the commit it was merged as into the destination branch.
+
+- [[merged-as]]Merged As:
++
+The SHA of the commit corresponding to the merged change on the destination
+branch.
+
+- [[revert-created-as]]Revert (Created|Submitted) As:
++
+Points to the revert change, if one was created.
+
+- [[cherry-pick-of]]Cherry-pick of:
++
+If the change was created as cherry-pick of some other change to a different
+branch, points to the original change.
+
 - [[submit-strategy]]Submit Strategy:
 +
 The link:project-setup.html#submit_type[submit strategy] that will be
 used to submit the change. The submit strategy is only displayed for
 open changes.
 
-- [[actions]]Actions:
+- [[hastags]]Hashtags:
 +
-Actions buttons are at the top, and in the overflow menu.
+Arbitrary string hashtags, that can be used to categorize changes and later use
+hashtags for search queries.
+
+[[submit-requirements]]
+=== Submit Requirements
+
+image::images/user-review-ui-submit-requirements.png[width=600, link="images/user-review-ui-copy-links.png"]
+
+Submit Requirements describe various conditions that must be fulfilled before
+the change can be submitted. Hovering over the requirement will show the
+description of the requirement, as well as additional information, such as:
+corresponding expression that is being evaluated, who can vote on the related
+labels etc.
+
+Approving votes are colored green; negative votes are colored red.
+
+For more detail on Submit Requirements see
+link:config-submit-requirements.html[Submit Requirement Configuration] page.
+
+[[actions]]
+=== Actions
+Actions buttons are at the top right and in the overflow menu.
 Depending on the change state and the permissions of the user, different
 actions are available on the change:
 
@@ -220,13 +308,7 @@
 +
 image::images/user-review-ui-change-screen-change-info-actions.png[width=400, link="images/user-review-ui-change-screen-change-info-actions.png"]
 
-- [[labels]]Labels & Votes:
-+
-Approving votes are colored green; negative votes are colored red.
-+
-image::images/user-review-ui-change-screen-change-info-labels.png[width=400, link="images/user-review-ui-change-screen-change-info-labels.png"]
-
-[[files]]
+[[files-tab]]
 === File List
 
 The file list shows the files that are modified in the currently viewed
@@ -251,17 +333,40 @@
 The list of commits that are being integrated into the destination
 branch by submitting the merge commit.
 
+Every file is accompanied by a number of extra information, such as status
+(modified, added, deleted, etc.), number of changed lines, type (executable,
+link, plain), comments and others. Hovering over most icons and columns reveals
+additional information.
+
+Each file can be expanded to view the contents of the file and diff. For more
+information see <<diff-view, Diff View>> section.
+
+[[comments-tab]]
+=== Comments Tab
+
+Instead of the file list, a comments tab can be selected. Comments tab presents
+comments along with related file/diff snippets. It also offers some filtering
+opportunities at the top (ex. only unresolved, only comments from user X, etc.)
+
+image::images/user-review-ui-change-screen-comments-tab.png[width=800, link="images/user-review-ui-change-screen-comments-tab.png"]
+
+[[checks-tab]]
+=== Checks Tab
+Checks tab contains results of different "Check Runs" installed by plugins. For
+more information see link:pg-plugin-checks-api.html[Checks API] page.
 
 [[patch-sets]]
 === Patch Sets
 
-The change screen only presents one patch set at a time. Which patch
-set is currently viewed can be seen from the `Patch Sets` drop-down
-panel in the change header.
+The change screen only presents one pair of patch sets (`Patchset A` and
+`Patchset B`) at a time. `A` is always an earlier upload than `B` and serves as
+a base for diffing when viewing changes in the files. Which patch
+sets is currently viewed can be seen from the `Patch Sets` drop-down
+panel in the change header. If patchset 'A' is not selected a parent commit of
+patchset 'B' is used by default.
 
 image::images/user-review-ui-change-screen-patch-sets.png[width=300, link="images/user-review-ui-change-screen-patch-sets.png"]
 
-
 [[download]]
 === Download
 
@@ -278,7 +383,8 @@
 
 Each command has a copy-to-clipboard icon that allows the command to be
 copied into the clipboard. This makes it easy to paste and execute the
-command on a Git command line.
+command on a Git command line. Additionally each line can copied to clipboard
+using number (1..9) of the appropriate line as a keyboard shortcut.
 
 If several download schemes are configured on the server (e.g. SSH and
 HTTP) there is a drop-down list to switch between the download schemes.
@@ -306,22 +412,20 @@
 
 image::images/user-review-ui-change-screen-included-in.png[width=800, link="images/user-review-ui-change-screen-included-in.png"]
 
-
-
 [[related-changes]]
 === Related Changes
 
 If there are changes that are related to the currently viewed change
 they are displayed in the third column of the change screen.
 
-There are several lists of related changes and a tab control is used to
-display each list of related changes in its own tab.
+There are several lists of related changes that are displayed in separate
+sectionsunder each other.
 
-The following tabs may be displayed:
+The following sections may be displayed:
 
-- [[related-changes-tab]]`Related Changes`:
+- [[related-changes-section]]`Related Changes`:
 +
-This tab page shows changes on which the current change depends
+This section shows changes on which the current change depends
 (ancestors) and open changes that depend on the current change
 (descendants). For merge commits it also shows the closed changes that
 will be merged into the destination branch by submitting the merge
@@ -341,10 +445,10 @@
 +
 ** [[not-current]]Not current:
 +
-The selected patch set of the change is outdated; it is not the current
-patch set of the change.
+The patch set of the related change which is related to the current change is
+outdated; it is not the current patch set of the change.
 +
-It means that the
+For ancestor it means that the
 currently viewed patch set depends on a outdated patch set of the
 ancestor change. This is because a new patch set for the ancestor
 change was uploaded in the meantime and as result the currently viewed
@@ -364,20 +468,24 @@
 note that following the link to an indirect descendant change may
 result in a completely different related changes listing.
 
-** [[closed-ancestor]]Closed ancestor:
+** [[merged-related-change]]Merged
 +
-Indicates a closed ancestor, e.g. the commit was directly pushed into
-the repository bypassing code review, or the ancestor change was
-reviewed and submitted on another branch. The latter may indicate that
-the user has accidentally pushed the commit to the wrong branch, e.g.
-the commit was done on `branch-a`, but was then pushed to
-`refs/for/branch-b`.
+The change has been  merged.
++
+If the relationship to submitted change falls under conditions described in
+<<not-current, Not current>> the status is orange. Such changes can appear as
+both ancestors and descendants of the change.
+
+** [[submittable-related-change]]Submittable
++
+All the submit requirements are fulfilled for the related change and it can be
+submitted when all of its ancestors are submitted.
 
 ** [[closed-ancestor-abandoned]]Abandoned:
 +
 Indicates an abandoned change.
 
-- [[conflicts-with]]`Conflicts With`:
+- [[conflicts-with]]`Merge Conflicts`:
 +
 This section shows changes that conflict with the current change.
 Non-mergeable changes are filtered out; only conflicting changes that
@@ -393,10 +501,9 @@
 currently viewed change, when clicking the submit button. It includes
 ancestors of the current patch set.
 +
-This may include changes and its ancestors with the same topic if
-`change.submitWholeTopic` is enabled. Only open changes with the
-same topic are included in the list.
-+
+If `change.submitWholeTopic` is enabled this section also includes changes with
+the same topic. The list recursively includes all changes that can be reached by
+ancestor and topic relationships. Only open changes are included in the result.
 
 - [[cherry-picks]]`Cherry-Picks`:
 +
@@ -411,12 +518,18 @@
 
 If there are no related changes for a tab, the tab is not displayed.
 
+- [[same-topic]]`Same Topic`:
++
+This section shows changes which are part of the same topic. If
+`change.submitWholeTopic` is enabled, then this section is omitted and changes
+are included as part of <<submitted-together, `Submitted Together`>>
+
 [[reply]]
 === Reply
 
 The `Reply...` button in the change header allows to reply to the
 currently viewed patch set; one can add a summary comment, publish
-inline draft comments, and vote on the labels.
+inline draft comments, vote on the labels and adjust attention set.
 
 image::images/user-review-ui-change-screen-reply.png[width=800, link="images/user-review-ui-change-screen-reply.png"]
 
@@ -424,10 +537,8 @@
 
 [[summary-comment]]
 A text box allows to type a summary comment for the currently viewed
-patch set. Some basic markdown-like syntax is supported which renders
-indented lines preformatted, lines starting with "- " or "* " as list
-items, and lines starting with "> " as block quotes (also see replying to
-link:#reply-to-message[messages] and link:#reply-inline-comment[inline comments]).
+patch set. Markdown syntax is supported same as in other
+<<comments-markdown, Comments>>.
 
 [[vote]]
 If the current patch set is viewed, buttons are displayed for
@@ -439,7 +550,7 @@
 are links to navigate to the inline comments which can be used if a
 comment needs to be edited.
 
-The `Post` button publishes the comments and the votes.
+The `SEND` button publishes the comments and the votes.
 
 [[quick-approve]]
 If a user can approve a label that is still required, a quick approve
@@ -460,12 +571,12 @@
 
 image::images/user-review-ui-change-screen-quick-approve.png[width=800, link="images/user-review-ui-change-screen-quick-approve.png"]
 
-[[history]]
-=== History
+[[change-log]]
+=== Change Log
 
 The history of the change can be seen in the lower part of the screen.
 
-The history contains messages for all kinds of change updates, e.g. a
+The log contains messages for all kinds of change updates, e.g. a
 message is added when a new patch set is uploaded or when a review was
 done.
 
@@ -491,12 +602,12 @@
 
 image::images/user-review-ui-change-screen-plugin-extensions.png[width=300, link="images/user-review-ui-change-screen-plugin-extensions.png"]
 
-[[side-by-side]]
+[[diff-view]]
 == Side-by-Side Diff Screen
 
-The side-by-side diff screen shows a single patch; the old file version
-is displayed on the left side of the screen; the new file version is
-displayed on the right side of the screen.
+The side-by-side diff screen shows a single patch (or difference between two
+patchsets); the old file version is displayed on the left side of the screen;
+the new file version is displayed on the right side of the screen.
 
 This screen allows to review a patch and to comment on it.
 
@@ -557,6 +668,10 @@
 Code blocks with comments may overlap. This means it is possible to
 attach several comments to the same code.
 
+[[comments-markdown]]
+The comments support markdown. It follows the CommonMark spec, except inline
+images and direct HTML are not rendered and kept as plaintext.
+
 [[line-links]]
 The lines of the patch file are linkable: simply append
 '#<linenumber>' to the URL, or click on the line-number. This not only
@@ -565,15 +680,14 @@
 [[reply-inline-comment]]
 Clicking on the `Reply` button opens an editor to type the reply.
 
-Quoting is supported, but only by manually copying & pasting the old
-comment that should be quoted and prefixing every line by "> ". Please
-note that for a correct rendering it is important to leave a blank line
-between a quoted block and the reply to it.
+Previous comment can be quoted using "Quote" button. A new draft would be open
+on the same comment thread with the text of the previoused comment quoted using
+markdown syntax.
 
 image::images/user-review-ui-side-by-side-diff-screen-inline-comments.png[width=800, link="images/user-review-ui-side-by-side-diff-screen-inline-comments.png"]
 
 Comments are first saved as drafts, and you can revisit the drafts as
-you read through code review. Finally, they should be published by
+you read through code review. Finally, they will be published by
 clicking the "Reply".
 
 [[done]]
@@ -610,6 +724,21 @@
 make it visible to other users it must be published from the change
 screen by link:#reply[replying] to the change.
 
+[[suggest-fix]]
+=== Suggest fix (WIP)
+Comments can contain suggested fixes.
+
+Clicking "Suggest Fix" will insert a special code-block in the text of the
+comment. The contents of this code block will replace the lines the comment is
+attached to (what gets highlighted when hovering over comment).
+
+image::images/user-review-ui-suggest-fix.png[width=400, link="images/user-review-ui-suggest-fix.png"]
+
+The author of the change can then preview and apply the change. This will created
+a new patchset with changes applied.
+
+image::images/user-review-ui-apply-fix.png[width=800, link="images/user-review-ui-apply-fix.png"]
+
 [[file-level-comments]]
 === File Level Comments
 
diff --git a/Documentation/user-search-accounts.txt b/Documentation/user-search-accounts.txt
index 6bcd18e..d5318c9 100644
--- a/Documentation/user-search-accounts.txt
+++ b/Documentation/user-search-accounts.txt
@@ -26,7 +26,10 @@
 [[cansee]]
 cansee:'CHANGE'::
 +
-Matches accounts that can see the change 'CHANGE'.
+Matches accounts that can see the change 'CHANGE'. If the change is private,
+this operator will match with the owner/reviewers/ccs of the change if the
+caller is in owner/reviewers/ccs of the change. Otherwise, the request will fail
+with 404 `Bad Request` with "change not found" message.
 
 [[email]]
 email:'EMAIL'::
diff --git a/Documentation/user-search.txt b/Documentation/user-search.txt
index d09717de..67b8d75 100644
--- a/Documentation/user-search.txt
+++ b/Documentation/user-search.txt
@@ -43,6 +43,17 @@
 For more predictable results, use explicit search operators as described
 in the following section.
 
+[IMPORTANT]
+--
+The change search API is backed by a secondary index and might sometimes return
+stale results if the re-indexing operation failed for a change update.
+
+Please also note that changes are not re-indexed if the project configuration
+is updated with newly added or modified
+link:config-submit-requirements.html[submit requirements].
+--
+
+
 [[search-operators]]
 == Search Operators
 
@@ -74,11 +85,6 @@
 means 'everything older than 2 days' while `-age:2d` means
 'everything with an age of at most 2 days'.
 
-[[assignee]]
-assignee:'USER'::
-+
-Changes assigned to the given user.
-
 [[attention]]
 attention:'USER'::
 +
@@ -345,6 +351,18 @@
 regular expressions is limited to the first 32766 bytes of the
 commit message due to limitations in Lucene.
 
+[[subject]]
+subject:'SUBJECT'::
++
+Changes that have a commit message where the first line (aka the subject)
+matches 'SUBJECT'. The matching is done by full text search over the subject.
+
+[[prefixsubject]]
+prefixsubject:'PREFIX'::
++
+Changes that have a commit message where the first line (aka the subject)
+has the prefix 'PREFIX'.
+
 [[comment]]
 comment:'TEXT'::
 +
@@ -359,6 +377,10 @@
 The link:http://www.brics.dk/automaton/[dk.brics.automaton library,role=external,window=_blank]
 is used for the evaluation of such patterns.
 +
+Note that the Gerrit host may not support regular expression search.
+You will then see an error dialog when using expressions starting with
+`^`.
++
 The `^` required at the beginning of the regular expression not only
 denotes a regular expression, but it also has the usual meaning of
 anchoring the match to the start of the string.  To match all Java
@@ -460,20 +482,12 @@
 
 
 [[is]]
-is:assigned::
-+
-True if the change has an assignee.
-
 [[is-starred]]
 is:starred::
 +
 Same as 'has:star', true if the change has been starred by the
 current user with the default label.
 
-is:unassigned::
-+
-True if the change does not have an assignee.
-
 is:attention::
 +
 True if the change has attention by the current user.
@@ -618,7 +632,7 @@
 +
 Changes containing a top-level or inline comment by 'USER'. The special
 case of `commentby:self` will find changes where the caller has
-commented.
+commented. Note that setting a vote is also considered as a comment.
 
 [[from]]
 from:'USER'::
@@ -633,7 +647,7 @@
 last update (comment or patch set) from the change owner.
 
 [[author]]
-author:'AUTHOR'::
+author:'AUTHOR', a:'AUTHOR'::
 +
 Changes where 'AUTHOR' is the author of the current patch set. 'AUTHOR' may be
 the author's exact email address, or part of the name or email address. The
diff --git a/Documentation/user-suggest-edits.txt b/Documentation/user-suggest-edits.txt
new file mode 100644
index 0000000..99f17a4
--- /dev/null
+++ b/Documentation/user-suggest-edits.txt
@@ -0,0 +1,37 @@
+= Gerrit Code Review - User suggested edits (Experiment)
+
+Easy and fast way for reviewers to suggest code changes that can be easily applied
+by change owner.
+
+== Reviewer workflow
+
+** Select line or multiple lines of diff and start comment
+
+image::images/user-suggest-edits-reviewer-comment.png["Comment example", align="center", width=400]
+
+** Click on suggest fix - that copies whole selected line/lines
+
+image::images/user-suggest-edits-reviewer-suggest-fix.png["Comment example", align="center", width=400]
+
+** Modify lines in the suggestion block. Optionally add more details as normal comment text before or after
+the suggestion block.
+
+image::images/user-suggest-edits-suggestion.png["Suggestion example", align="center", width=400]
+
+** Optionally you can preview suggested edit by clicking on Preview fix when you stop editing comment
+
+image::images/user-suggest-edits-reviewer-preview.png["Suggestion Draft example", align="center", width=400]
+
+image::images/user-suggest-edits-preview.png["Suggestion Preview", align="center", width=400]
+
+== Author workflow
+
+You can apply one or more suggested fixes. When suggested fix is applied - it creates
+a change edit that you can modify. link:user-inline-edit.html#editing-change[More about editing mode.]
+
+GERRIT
+------
+Part of link:index.html[Gerrit Code Review]
+
+SEARCHBOX
+---------
diff --git a/Documentation/user-upload.txt b/Documentation/user-upload.txt
index 8c51207..c6fce2a5 100644
--- a/Documentation/user-upload.txt
+++ b/Documentation/user-upload.txt
@@ -1,13 +1,15 @@
 :linkattrs:
 = Gerrit Code Review - Uploading Changes
 
-Gerrit supports three methods of uploading changes:
+Gerrit supports five methods of uploading changes:
 
 * Use `repo upload`, to create changes for review
 * Use `git push`, to create changes for review
+* link:user-inline-edit.html#create_in_web_interface[Create a change for review from the web interface]
+* link:user-inline-edit.html#create_from_url[Create a change for review by using an "Edit URL"]
 * Use `git push`, and bypass code review
 
-All three methods rely on authentication, which must first be configured
+All five methods rely on authentication, which must first be configured
 by the uploading user.
 
 Gerrit supports two protocols for uploading changes; SSH and HTTP/HTTPS. These
diff --git a/README.md b/README.md
index 4df9271..c8f0b70 100644
--- a/README.md
+++ b/README.md
@@ -3,7 +3,7 @@
 [Gerrit](https://www.gerritcodereview.com) is a code review and project
 management tool for Git based projects.
 
-[![Build Status](https://gerrit-ci.gerritforge.com/job/Gerrit-bazel-java11-master/badge/icon)](https://gerrit-ci.gerritforge.com/job/Gerrit-bazel-java11-master/)
+[![Build Status](https://gerrit-ci.gerritforge.com/view/Gerrit/job/Gerrit-bazel-master/badge/icon)](https://gerrit-ci.gerritforge.com/view/Gerrit/job/Gerrit-bazel-master/)
 ![Maven Central](https://img.shields.io/maven-central/v/com.google.gerrit/gerrit-war)
 
 ## Objective
diff --git a/WORKSPACE b/WORKSPACE
index cf399dc..047da6a 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -65,8 +65,8 @@
 
 http_archive(
     name = "build_bazel_rules_nodejs",
-    sha256 = "0fad45a9bda7dc1990c47b002fd64f55041ea751fafc00cd34efb96107675778",
-    urls = ["https://github.com/bazelbuild/rules_nodejs/releases/download/5.5.0/rules_nodejs-5.5.0.tar.gz"],
+    sha256 = "c29944ba9b0b430aadcaf3bf2570fece6fc5ebfb76df145c6cdad40d65c20811",
+    urls = ["https://github.com/bazelbuild/rules_nodejs/releases/download/5.7.0/rules_nodejs-5.7.0.tar.gz"],
 )
 
 load("@build_bazel_rules_nodejs//:repositories.bzl", "build_bazel_rules_nodejs_dependencies")
@@ -136,8 +136,8 @@
 load("@build_bazel_rules_nodejs//:index.bzl", "node_repositories", "yarn_install")
 
 node_repositories(
-    node_version = "16.15.0",
-    yarn_version = "1.22.18",
+    node_version = "17.9.1",
+    yarn_version = "1.22.19",
 )
 
 yarn_install(
diff --git a/contrib/git-gc-preserve b/contrib/git-gc-preserve
new file mode 100755
index 0000000..33c8f5b
--- /dev/null
+++ b/contrib/git-gc-preserve
@@ -0,0 +1,149 @@
+#!/bin/bash
+# Copyright (C) 2022 The Android Open Source Project
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+usage() { # exit code
+  cat <<-EOF
+NAME
+    git-gc-preserve - Run git gc and preserve old packs to avoid races for JGit
+
+SYNOPSIS
+    git gc-preserve
+
+DESCRIPTION
+    Runs git gc and can preserve old packs to avoid races with concurrently
+    executed commands in JGit.
+
+    This command uses custom git config options to configure if preserved packs
+    from the last run of git gc should be pruned and if packs should be preserved.
+
+    This is similar to the implementation in JGit [1] which is used by
+    JGit to avoid errors [2] in such situations.
+
+    The command prevents concurrent runs of the command on the same repository
+    by acquiring an exclusive file lock on the file
+      "\$repopath/gc-preserve.pid"
+    If it cannot acquire the lock it fails immediately with exit code 3.
+
+    Failure Exit Codes
+        1: General failure
+        2: Couldn't determine repository path. If the current working directory
+           is outside of the working tree of the git repository use git option
+           --git-dir to pass the root path of the repository.
+           E.g.
+              $ git --git-dir ~/git/foo gc-preserve
+        3: Another process already runs $0 on the same repository
+
+    [1] https://git.eclipse.org/r/c/jgit/jgit/+/87969
+    [2] https://git.eclipse.org/r/c/jgit/jgit/+/122288
+
+CONFIGURATION
+    "pack.prunepreserved": if set to "true" preserved packs from the last gc run
+      are pruned before current packs are preserved.
+
+    "pack.preserveoldpacks": if set to "true" current packs will be hard linked
+      to objects/pack/preserved before git gc is executed. JGit will
+      fallback to the preserved packs in this directory in case it comes
+      across missing objects which might be caused by a concurrent run of
+      git gc.
+EOF
+  exit "$1"
+}
+
+# acquire file lock, unlock when the script exits
+lock() { # repo
+  readonly LOCKFILE="$1/gc-preserve.pid"
+  test -f "$LOCKFILE" || touch "$LOCKFILE"
+  exec 9> "$LOCKFILE"
+  if flock -nx 9; then
+    echo -n "$$ $USERNAME@$HOSTNAME" >&9
+    trap unlock EXIT
+  else
+    echo "$0 is already running"
+    exit 3
+  fi
+}
+
+unlock() {
+  # only delete if the file descriptor 9 is open
+  if { : >&9 ; } &> /dev/null; then
+    rm -f "$LOCKFILE"
+  fi
+  # close the file handle to release file lock
+  exec 9>&-
+}
+
+# prune preserved packs if pack.prunepreserved == true
+prune_preserved() { # repo
+  configured=$(git --git-dir="$1" config --get pack.prunepreserved)
+  if [ "$configured" != "true" ]; then
+    return 0
+  fi
+  local preserved=$1/objects/pack/preserved
+  if [ -d "$preserved" ]; then
+    printf "Pruning old preserved packs: "
+    count=$(find "$preserved" -name "*.old-pack" | wc -l)
+    rm -rf "$preserved"
+    echo "$count, done."
+  fi
+}
+
+# preserve packs if pack.preserveoldpacks == true
+preserve_packs() { # repo
+  configured=$(git --git-dir="$1" config --get pack.preserveoldpacks)
+  if [ "$configured" != "true" ]; then
+    return 0
+  fi
+  local packdir=$1/objects/pack
+  pushd "$packdir" >/dev/null || exit 1
+  mkdir -p preserved
+  printf "Preserving packs: "
+  count=0
+  for file in pack-*{.pack,.idx} ; do
+    ln -f "$file" preserved/"$(get_preserved_packfile_name "$file")"
+    if [[ "$file" == pack-*.pack ]]; then
+      ((count++))
+    fi
+  done
+  echo "$count, done."
+  popd >/dev/null || exit 1
+}
+
+# pack-0...2.pack to pack-0...2.old-pack
+# pack-0...2.idx to pack-0...2.old-idx
+get_preserved_packfile_name() { # packfile > preserved_packfile
+  local old=${1/%\.pack/.old-pack}
+  old=${old/%\.idx/.old-idx}
+  echo "$old"
+}
+
+# main
+
+while [ $# -gt 0 ] ; do
+    case "$1" in
+        -u|-h)  usage 0 ;;
+    esac
+    shift
+done
+args=$(git rev-parse --sq-quote "$@")
+
+repopath=$(git rev-parse --git-dir)
+if [ -z "$repopath" ]; then
+  usage 2
+fi
+
+lock "$repopath"
+prune_preserved "$repopath"
+preserve_packs "$repopath"
+git gc ${args:+"$args"} || { echo "git gc failed"; exit "$?"; }
diff --git a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
index 01c4942..f4e7cce 100644
--- a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
@@ -32,6 +32,8 @@
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
 import static com.google.gerrit.server.project.testing.TestLabels.label;
 import static com.google.gerrit.server.project.testing.TestLabels.value;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static com.google.gerrit.testing.TestActionRefUpdateContext.testRefAction;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.Objects.requireNonNull;
 import static java.util.stream.Collectors.toList;
@@ -91,6 +93,7 @@
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.client.ProjectWatchInfo;
+import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
@@ -100,6 +103,7 @@
 import com.google.gerrit.extensions.common.EditInfo;
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.json.OutputFormat;
 import com.google.gerrit.server.GerritPersonIdent;
@@ -1100,6 +1104,19 @@
     }
   }
 
+  protected void setSkipAddingAuthorAndCommitterAsReviewers(InheritableBoolean value)
+      throws Exception {
+    try (MetaDataUpdate md = metaDataUpdateFactory.create(project)) {
+      ProjectConfig config = projectConfigFactory.read(md);
+      config.updateProject(
+          p ->
+              p.setBooleanConfig(
+                  BooleanProjectConfig.SKIP_ADDING_AUTHOR_AND_COMMITTER_AS_REVIEWERS, value));
+      config.commit(md);
+      projectCache.evictAndReindex(config.getProject());
+    }
+  }
+
   protected void blockAnonymousRead() throws Exception {
     String allRefs = RefNames.REFS + "*";
     projectOperations
@@ -1123,6 +1140,42 @@
     gApi.changes().id(id).current().review(ReviewInput.recommend());
   }
 
+  protected void assertThatAccountIsNotVisible(TestAccount... testAccounts) {
+    for (TestAccount testAccount : testAccounts) {
+      assertThrows(
+          ResourceNotFoundException.class, () -> gApi.accounts().id(testAccount.id().get()).get());
+    }
+  }
+
+  protected void assertReviewers(String changeId, TestAccount... expectedReviewers)
+      throws RestApiException {
+    Map<ReviewerState, Collection<AccountInfo>> reviewerMap =
+        gApi.changes().id(changeId).get().reviewers;
+    assertThat(reviewerMap).containsKey(ReviewerState.REVIEWER);
+    List<Integer> reviewers =
+        reviewerMap.get(ReviewerState.REVIEWER).stream().map(a -> a._accountId).collect(toList());
+    assertThat(reviewers)
+        .containsExactlyElementsIn(
+            Arrays.stream(expectedReviewers).map(a -> a.id().get()).collect(toList()));
+  }
+
+  protected void assertCcs(String changeId, TestAccount... expectedCcs) throws RestApiException {
+    Map<ReviewerState, Collection<AccountInfo>> reviewerMap =
+        gApi.changes().id(changeId).get().reviewers;
+    assertThat(reviewerMap).containsKey(ReviewerState.CC);
+    List<Integer> ccs =
+        reviewerMap.get(ReviewerState.CC).stream().map(a -> a._accountId).collect(toList());
+    assertThat(ccs)
+        .containsExactlyElementsIn(
+            Arrays.stream(expectedCcs).map(a -> a.id().get()).collect(toList()));
+  }
+
+  protected void assertNoCcs(String changeId) throws RestApiException {
+    Map<ReviewerState, Collection<AccountInfo>> reviewerMap =
+        gApi.changes().id(changeId).get().reviewers;
+    assertThat(reviewerMap).doesNotContainKey(ReviewerState.CC);
+  }
+
   protected void assertSubmittedTogether(String chId, String... expected) throws Exception {
     assertSubmittedTogether(chId, ImmutableSet.of(), expected);
   }
@@ -1237,34 +1290,61 @@
     assertThat(refValues.keySet()).containsAnyIn(trees.keySet());
   }
 
-  protected void assertDiffForNewFile(
-      DiffInfo diff, RevCommit commit, String path, String expectedContentSideB) throws Exception {
-    List<String> expectedLines = ImmutableList.copyOf(expectedContentSideB.split("\n", -1));
+  protected void assertDiffForFullyModifiedFile(
+      DiffInfo diff,
+      String commitName,
+      String path,
+      String expectedContentSideA,
+      String expectedContentSideB)
+      throws Exception {
+    assertDiffForFile(diff, commitName, path);
 
-    assertThat(diff.binary).isNull();
+    ImmutableList<String> expectedOldLines =
+        ImmutableList.copyOf(expectedContentSideA.split("\n", -1));
+    ImmutableList<String> expectedNewLines =
+        ImmutableList.copyOf(expectedContentSideB.split("\n", -1));
+
+    assertThat(diff.changeType).isEqualTo(ChangeType.MODIFIED);
+
+    assertThat(diff.metaA).isNotNull();
+    assertThat(diff.metaB).isNotNull();
+
+    assertThat(diff.metaA.name).isEqualTo(path);
+    assertThat(diff.metaA.lines).isEqualTo(expectedOldLines.size());
+    assertThat(diff.metaB.name).isEqualTo(path);
+    assertThat(diff.metaB.lines).isEqualTo(expectedNewLines.size());
+
+    DiffInfo.ContentEntry contentEntry = diff.content.get(0);
+    assertThat(contentEntry.a).containsExactlyElementsIn(expectedOldLines).inOrder();
+    assertThat(contentEntry.b).containsExactlyElementsIn(expectedNewLines).inOrder();
+    assertThat(contentEntry.ab).isNull();
+    assertThat(contentEntry.common).isNull();
+    assertThat(contentEntry.editA).isNull();
+    assertThat(contentEntry.editB).isNull();
+    assertThat(contentEntry.skip).isNull();
+  }
+
+  protected void assertDiffForNewFile(
+      DiffInfo diff, @Nullable RevCommit commit, String path, String expectedContentSideB)
+      throws Exception {
+    assertDiffForNewFile(diff, commit.name(), path, expectedContentSideB);
+  }
+
+  protected void assertDiffForNewFile(
+      DiffInfo diff, String commitName, String path, String expectedContentSideB) throws Exception {
+    assertDiffForFile(diff, commitName, path);
+
+    ImmutableList<String> expectedLines =
+        ImmutableList.copyOf(expectedContentSideB.split("\n", -1));
+
     assertThat(diff.changeType).isEqualTo(ChangeType.ADDED);
-    assertThat(diff.diffHeader).isNotNull();
-    assertThat(diff.intralineStatus).isNull();
-    assertThat(diff.webLinks).isNull();
-    assertThat(diff.editWebLinks).isNull();
 
     assertThat(diff.metaA).isNull();
     assertThat(diff.metaB).isNotNull();
-    assertThat(diff.metaB.commitId).isEqualTo(commit.name());
 
-    String expectedContentType = "text/plain";
-    if (COMMIT_MSG.equals(path)) {
-      expectedContentType = FileContentUtil.TEXT_X_GERRIT_COMMIT_MESSAGE;
-    } else if (MERGE_LIST.equals(path)) {
-      expectedContentType = FileContentUtil.TEXT_X_GERRIT_MERGE_LIST;
-    }
-    assertThat(diff.metaB.contentType).isEqualTo(expectedContentType);
-
-    assertThat(diff.metaB.lines).isEqualTo(expectedLines.size());
     assertThat(diff.metaB.name).isEqualTo(path);
-    assertThat(diff.metaB.webLinks).isNull();
+    assertThat(diff.metaB.lines).isEqualTo(expectedLines.size());
 
-    assertThat(diff.content).hasSize(1);
     DiffInfo.ContentEntry contentEntry = diff.content.get(0);
     assertThat(contentEntry.b).containsExactlyElementsIn(expectedLines).inOrder();
     assertThat(contentEntry.a).isNull();
@@ -1275,6 +1355,57 @@
     assertThat(contentEntry.skip).isNull();
   }
 
+  protected void assertDiffForDeletedFile(DiffInfo diff, String path, String expectedContentSideA)
+      throws Exception {
+    assertDiffHeaders(diff);
+
+    ImmutableList<String> expectedOriginalLines =
+        ImmutableList.copyOf(expectedContentSideA.split("\n", -1));
+
+    assertThat(diff.changeType).isEqualTo(ChangeType.DELETED);
+
+    assertThat(diff.metaA).isNotNull();
+    assertThat(diff.metaB).isNull();
+
+    assertThat(diff.metaA.name).isEqualTo(path);
+    assertThat(diff.metaA.lines).isEqualTo(expectedOriginalLines.size());
+
+    DiffInfo.ContentEntry contentEntry = diff.content.get(0);
+    assertThat(contentEntry.a).containsExactlyElementsIn(expectedOriginalLines).inOrder();
+    assertThat(contentEntry.b).isNull();
+    assertThat(contentEntry.ab).isNull();
+    assertThat(contentEntry.common).isNull();
+    assertThat(contentEntry.editA).isNull();
+    assertThat(contentEntry.editB).isNull();
+    assertThat(contentEntry.skip).isNull();
+  }
+
+  private void assertDiffForFile(DiffInfo diff, String commitName, String path) throws Exception {
+    assertDiffHeaders(diff);
+
+    assertThat(diff.metaB.commitId).isEqualTo(commitName);
+
+    String expectedContentType = "text/plain";
+    if (COMMIT_MSG.equals(path)) {
+      expectedContentType = FileContentUtil.TEXT_X_GERRIT_COMMIT_MESSAGE;
+    } else if (MERGE_LIST.equals(path)) {
+      expectedContentType = FileContentUtil.TEXT_X_GERRIT_MERGE_LIST;
+    }
+
+    assertThat(diff.metaB.contentType).isEqualTo(expectedContentType);
+
+    assertThat(diff.metaB.name).isEqualTo(path);
+    assertThat(diff.metaB.webLinks).isNull();
+  }
+
+  private void assertDiffHeaders(DiffInfo diff) throws Exception {
+    assertThat(diff.binary).isNull();
+    assertThat(diff.diffHeader).isNotNull();
+    assertThat(diff.intralineStatus).isNull();
+    assertThat(diff.webLinks).isNull();
+    assertThat(diff.editWebLinks).isNull();
+  }
+
   protected void assertPermitted(ChangeInfo info, String label, Integer... expected) {
     assertThat(info.permittedLabels).isNotNull();
     Collection<String> strs = info.permittedLabels.get(label);
@@ -1286,6 +1417,17 @@
     }
   }
 
+  protected void assertOnlyRemovableLabel(
+      ChangeInfo info, String labelId, String labelValue, TestAccount reviewer) {
+    assertThat(info.removableLabels).hasSize(1);
+    assertThat(info.removableLabels).containsKey(labelId);
+    assertThat(info.removableLabels.get(labelId)).hasSize(1);
+    assertThat(info.removableLabels.get(labelId)).containsKey(labelValue);
+    assertThat(info.removableLabels.get(labelId).get(labelValue)).hasSize(1);
+    assertThat(info.removableLabels.get(labelId).get(labelValue).get(0).email)
+        .isEqualTo(reviewer.email());
+  }
+
   protected void assertPermissions(
       Project.NameKey project,
       GroupReference groupReference,
@@ -1589,11 +1731,14 @@
     }
 
     public void save() throws Exception {
-      metaDataUpdate.setAuthor(identifiedUserFactory.create(admin.id()));
-      projectConfig.commit(metaDataUpdate);
-      metaDataUpdate.close();
-      metaDataUpdate = null;
-      projectCache.evictAndReindex(projectConfig.getProject());
+      testRefAction(
+          () -> {
+            metaDataUpdate.setAuthor(identifiedUserFactory.create(admin.id()));
+            projectConfig.commit(metaDataUpdate);
+            metaDataUpdate.close();
+            metaDataUpdate = null;
+            projectCache.evictAndReindex(projectConfig.getProject());
+          });
     }
 
     @Override
@@ -1636,6 +1781,14 @@
         .collect(ImmutableMap.toImmutableMap(branch -> branch.ref, branch -> branch));
   }
 
+  protected void createRefLogFileIfMissing(Repository repo, String ref) throws IOException {
+    File log = new File(repo.getDirectory(), "logs/" + ref);
+    if (!log.exists()) {
+      log.getParentFile().mkdirs();
+      assertThat(log.createNewFile()).isTrue();
+    }
+  }
+
   protected AutoCloseable installPlugin(String pluginName, Class<? extends Module> sysModuleClass)
       throws Exception {
     return installPlugin(pluginName, sysModuleClass, null, null);
diff --git a/java/com/google/gerrit/acceptance/AbstractNotificationTest.java b/java/com/google/gerrit/acceptance/AbstractNotificationTest.java
index da033c1..8a9e56a 100644
--- a/java/com/google/gerrit/acceptance/AbstractNotificationTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractNotificationTest.java
@@ -343,7 +343,6 @@
     public final TestAccount reviewer;
     public final TestAccount ccer;
     public final TestAccount starrer;
-    public final TestAccount assignee;
     public final TestAccount watchingProjectOwner;
     private final Map<NotifyType, TestAccount> watchers = new HashMap<>();
     private final Map<String, TestAccount> accountsByEmail = new HashMap<>();
@@ -369,7 +368,6 @@
           reviewer = reindexAndCopy(existing.reviewer);
           ccer = reindexAndCopy(existing.ccer);
           starrer = reindexAndCopy(existing.starrer);
-          assignee = reindexAndCopy(existing.assignee);
           watchingProjectOwner = reindexAndCopy(existing.watchingProjectOwner);
           watchers.putAll(existing.watchers);
           return;
@@ -381,7 +379,6 @@
         uploader = testAccount("uploader");
         ccer = testAccount("ccer");
         starrer = testAccount("starrer");
-        assignee = testAccount("assignee");
 
         watchingProjectOwner = testAccount("watchingProjectOwner", "Administrators");
         requestScopeOperations.setApiUser(watchingProjectOwner.id());
diff --git a/java/com/google/gerrit/acceptance/AbstractPluginFieldsTest.java b/java/com/google/gerrit/acceptance/AbstractPluginFieldsTest.java
index 91fbf9e..fe845c0 100644
--- a/java/com/google/gerrit/acceptance/AbstractPluginFieldsTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractPluginFieldsTest.java
@@ -303,6 +303,7 @@
     return pluginInfoFromChangeInfo(changeInfos.get(0));
   }
 
+  @Nullable
   protected static List<PluginDefinedInfo> pluginInfoFromChangeInfo(ChangeInfo changeInfo) {
     List<PluginDefinedInfo> pluginInfo = changeInfo.plugins;
     if (pluginInfo == null) {
@@ -331,6 +332,7 @@
    * @param plugins list of {@code MyInfo} objects, each as a raw map returned from Gson.
    * @return decoded list of {@code MyInfo}s.
    */
+  @Nullable
   protected static List<PluginDefinedInfo> decodeRawPluginsList(
       Gson gson, @Nullable Object plugins) {
     if (plugins == null) {
diff --git a/java/com/google/gerrit/acceptance/AccountCreator.java b/java/com/google/gerrit/acceptance/AccountCreator.java
index c67991d..ff5bc00 100644
--- a/java/com/google/gerrit/acceptance/AccountCreator.java
+++ b/java/com/google/gerrit/acceptance/AccountCreator.java
@@ -141,6 +141,10 @@
     return create(username, null, username, null, (String[]) null);
   }
 
+  public TestAccount createValid(String username) throws Exception {
+    return create(username, username + "@example.com", username, username);
+  }
+
   public TestAccount admin() throws Exception {
     return create("admin", "admin@example.com", "Administrator", "Adminny", "Administrators");
   }
diff --git a/java/com/google/gerrit/acceptance/BUILD b/java/com/google/gerrit/acceptance/BUILD
index 2cf279f..8b2160c 100644
--- a/java/com/google/gerrit/acceptance/BUILD
+++ b/java/com/google/gerrit/acceptance/BUILD
@@ -76,6 +76,8 @@
     "//java/com/google/gerrit/gpg/testing:gpg-test-util",
     "//java/com/google/gerrit/git/testing",
     "//java/com/google/gerrit/index/testing",
+    "//java/com/google/gerrit/testing:test-ref-update-context",
+    "//lib/errorprone:annotations",
 ]
 
 PGM_DEPLOY_ENV = [
diff --git a/java/com/google/gerrit/acceptance/DisabledAccountIndex.java b/java/com/google/gerrit/acceptance/DisabledAccountIndex.java
index 7bd0c73..7660948 100644
--- a/java/com/google/gerrit/acceptance/DisabledAccountIndex.java
+++ b/java/com/google/gerrit/acceptance/DisabledAccountIndex.java
@@ -55,6 +55,11 @@
   }
 
   @Override
+  public void deleteByValue(AccountState value) {
+    throw new UnsupportedOperationException("AccountIndex is disabled");
+  }
+
+  @Override
   public void delete(Account.Id key) {
     throw new UnsupportedOperationException("AccountIndex is disabled");
   }
diff --git a/java/com/google/gerrit/acceptance/DisabledChangeIndex.java b/java/com/google/gerrit/acceptance/DisabledChangeIndex.java
index 7671ad4..c028a8e 100644
--- a/java/com/google/gerrit/acceptance/DisabledChangeIndex.java
+++ b/java/com/google/gerrit/acceptance/DisabledChangeIndex.java
@@ -62,6 +62,11 @@
   }
 
   @Override
+  public void deleteByValue(ChangeData value) {
+    throw new UnsupportedOperationException("ChangeIndex is disabled");
+  }
+
+  @Override
   public void delete(Change.Id key) {
     throw new UnsupportedOperationException("ChangeIndex is disabled");
   }
diff --git a/java/com/google/gerrit/acceptance/DisabledProjectIndex.java b/java/com/google/gerrit/acceptance/DisabledProjectIndex.java
index 2e3dd90..f2aad4a 100644
--- a/java/com/google/gerrit/acceptance/DisabledProjectIndex.java
+++ b/java/com/google/gerrit/acceptance/DisabledProjectIndex.java
@@ -60,6 +60,11 @@
   }
 
   @Override
+  public void deleteByValue(ProjectData value) {
+    throw new UnsupportedOperationException("ProjectIndex is disabled");
+  }
+
+  @Override
   public void delete(Project.NameKey key) {
     throw new UnsupportedOperationException("ProjectIndex is disabled");
   }
diff --git a/java/com/google/gerrit/acceptance/GerritServer.java b/java/com/google/gerrit/acceptance/GerritServer.java
index a9ad3f2..1199bf9 100644
--- a/java/com/google/gerrit/acceptance/GerritServer.java
+++ b/java/com/google/gerrit/acceptance/GerritServer.java
@@ -70,6 +70,7 @@
 import com.google.gerrit.server.util.ReplicaUtil;
 import com.google.gerrit.server.util.SocketUtil;
 import com.google.gerrit.server.util.SystemLog;
+import com.google.gerrit.testing.FakeAccountPatchReviewStore.FakeAccountPatchReviewStoreModule;
 import com.google.gerrit.testing.FakeEmailSender.FakeEmailSenderModule;
 import com.google.gerrit.testing.InMemoryRepositoryManager;
 import com.google.gerrit.testing.SshMode;
@@ -431,6 +432,7 @@
               }
             },
             site);
+    daemon.setAccountPatchReviewStoreModuleForTesting(new FakeAccountPatchReviewStoreModule());
     daemon.setEmailModuleForTesting(new FakeEmailSenderModule());
     daemon.setAuditEventModuleForTesting(
         MoreObjects.firstNonNull(testAuditModule, new FakeGroupAuditServiceModule()));
diff --git a/java/com/google/gerrit/acceptance/HttpResponse.java b/java/com/google/gerrit/acceptance/HttpResponse.java
index 88079a4..76c0f04 100644
--- a/java/com/google/gerrit/acceptance/HttpResponse.java
+++ b/java/com/google/gerrit/acceptance/HttpResponse.java
@@ -19,6 +19,7 @@
 import static java.util.Objects.requireNonNull;
 
 import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.Nullable;
 import java.io.IOException;
 import java.io.InputStreamReader;
 import java.io.Reader;
@@ -59,6 +60,7 @@
     return getHeader("X-FYI-Content-Type");
   }
 
+  @Nullable
   public String getHeader(String name) {
     Header hdr = response.getFirstHeader(name);
     return hdr != null ? hdr.getValue() : null;
diff --git a/java/com/google/gerrit/acceptance/ProjectResetter.java b/java/com/google/gerrit/acceptance/ProjectResetter.java
index 46f7496..c8ab1a9 100644
--- a/java/com/google/gerrit/acceptance/ProjectResetter.java
+++ b/java/com/google/gerrit/acceptance/ProjectResetter.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.gerrit.entities.RefNames.REFS_USERS;
+import static com.google.gerrit.testing.TestActionRefUpdateContext.testRefAction;
 import static java.util.stream.Collectors.toSet;
 
 import com.google.common.collect.ImmutableList;
@@ -202,10 +203,12 @@
     keptRefsByProject = MultimapBuilder.hashKeys().arrayListValues().build();
     restoredRefsByProject = MultimapBuilder.hashKeys().arrayListValues().build();
     deletedRefsByProject = MultimapBuilder.hashKeys().arrayListValues().build();
-
-    restoreRefs();
-    deleteNewlyCreatedRefs();
-    evictCachesAndReindex();
+    testRefAction(
+        () -> {
+          restoreRefs();
+          deleteNewlyCreatedRefs();
+          evictCachesAndReindex();
+        });
   }
 
   /** Read the states of all matching refs. */
diff --git a/java/com/google/gerrit/acceptance/PushOneCommit.java b/java/com/google/gerrit/acceptance/PushOneCommit.java
index 99db40a..9f38fcb 100644
--- a/java/com/google/gerrit/acceptance/PushOneCommit.java
+++ b/java/com/google/gerrit/acceptance/PushOneCommit.java
@@ -24,6 +24,7 @@
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Sets;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.common.UsedAt.Project;
@@ -40,6 +41,7 @@
 import com.google.inject.assistedinject.AssistedInject;
 import java.util.Arrays;
 import java.util.List;
+import java.util.Locale;
 import java.util.Map;
 import java.util.concurrent.atomic.AtomicInteger;
 import org.eclipse.jgit.api.TagCommand;
@@ -280,6 +282,12 @@
     return this;
   }
 
+  @CanIgnoreReturnValue
+  public PushOneCommit setTopLevelTreeId(ObjectId treeId) throws Exception {
+    commitBuilder.setTopLevelTree(treeId);
+    return this;
+  }
+
   public PushOneCommit setParent(RevCommit parent) throws Exception {
     commitBuilder.noParents();
     commitBuilder.parent(parent);
@@ -291,6 +299,19 @@
     return this;
   }
 
+  public PushOneCommit addFile(String path, String content, int fileMode) throws Exception {
+    RevBlob blobId = testRepo.blob(content);
+    commitBuilder.edit(
+        new PathEdit(path) {
+          @Override
+          public void apply(DirCacheEntry ent) {
+            ent.setFileMode(FileMode.fromBits(fileMode));
+            ent.setObjectId(blobId);
+          }
+        });
+    return this;
+  }
+
   public PushOneCommit addSymlink(String path, String target) throws Exception {
     RevBlob blobId = testRepo.blob(target);
     commitBuilder.edit(
@@ -470,12 +491,14 @@
     public void assertMessage(String expectedMessage) {
       RemoteRefUpdate refUpdate = result.getRemoteUpdate(ref);
       assertThat(refUpdate).isNotNull();
-      assertThat(message(refUpdate).toLowerCase()).contains(expectedMessage.toLowerCase());
+      assertThat(message(refUpdate).toLowerCase(Locale.US))
+          .contains(expectedMessage.toLowerCase(Locale.US));
     }
 
     public void assertNotMessage(String message) {
       RemoteRefUpdate refUpdate = result.getRemoteUpdate(ref);
-      assertThat(message(refUpdate).toLowerCase()).doesNotContain(message.toLowerCase());
+      assertThat(message(refUpdate).toLowerCase(Locale.US))
+          .doesNotContain(message.toLowerCase(Locale.US));
     }
 
     public String getMessage() {
diff --git a/java/com/google/gerrit/acceptance/TestMetricMaker.java b/java/com/google/gerrit/acceptance/TestMetricMaker.java
index 881f389..85233f2 100644
--- a/java/com/google/gerrit/acceptance/TestMetricMaker.java
+++ b/java/com/google/gerrit/acceptance/TestMetricMaker.java
@@ -14,13 +14,19 @@
 
 package com.google.gerrit.acceptance;
 
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.metrics.Counter0;
+import com.google.gerrit.metrics.Counter1;
+import com.google.gerrit.metrics.Counter2;
+import com.google.gerrit.metrics.Counter3;
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.DisabledMetricMaker;
 import com.google.gerrit.metrics.Field;
 import com.google.gerrit.metrics.Timer1;
 import com.google.inject.Singleton;
+import java.util.Arrays;
 import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.TimeUnit;
 import org.apache.commons.lang3.mutable.MutableLong;
@@ -28,11 +34,9 @@
 /**
  * {@link com.google.gerrit.metrics.MetricMaker} to be bound in tests.
  *
- * <p>Records how often {@link Counter0} and {@link Timer1} metrics are invoked. Metrics for other
- * types are not recorded.
+ * <p>Records how often counter metrics are invoked. Metrics of other types are not recorded.
  *
- * <p>Allows test to check how much a {@link Counter0} and {@link Timer1} metric is increased by an
- * operation.
+ * <p>Allows test to check how much a counter metrics is increased by an operation.
  *
  * <p>Example:
  *
@@ -53,15 +57,15 @@
  */
 @Singleton
 public class TestMetricMaker extends DisabledMetricMaker {
-  private final ConcurrentHashMap<String, MutableLong> counts = new ConcurrentHashMap<>();
-  private final ConcurrentHashMap<String, MutableLong> timers = new ConcurrentHashMap<>();
+  private final ConcurrentHashMap<CounterKey, MutableLong> counts = new ConcurrentHashMap<>();
+  private final ConcurrentHashMap<CounterKey, MutableLong> timers = new ConcurrentHashMap<>();
 
-  public long getCount(String counter0Name) {
-    return getCounterValue(counter0Name).longValue();
+  public long getCount(String counterName, Object... fieldValues) {
+    return getCounterValue(CounterKey.create(counterName, fieldValues)).longValue();
   }
 
   public long getTimer(String timerName) {
-    return getTimerValue(timerName).longValue();
+    return getTimerValue(CounterKey.create(timerName)).longValue();
   }
 
   public void reset() {
@@ -69,11 +73,11 @@
     timers.clear();
   }
 
-  private MutableLong getCounterValue(String counter0Name) {
-    return counts.computeIfAbsent(counter0Name, name -> new MutableLong(0));
+  private MutableLong getCounterValue(CounterKey counterKey) {
+    return counts.computeIfAbsent(counterKey, name -> new MutableLong(0));
   }
 
-  private MutableLong getTimerValue(String timerName) {
+  private MutableLong getTimerValue(CounterKey timerName) {
     return counts.computeIfAbsent(timerName, name -> new MutableLong(0));
   }
 
@@ -82,7 +86,7 @@
     return new Counter0() {
       @Override
       public void incrementBy(long value) {
-        getCounterValue(name).add(value);
+        getCounterValue(CounterKey.create(name)).add(value);
       }
 
       @Override
@@ -96,11 +100,64 @@
     return new Timer1<>(name, field1) {
       @Override
       protected void doRecord(F1 field1, long value, TimeUnit unit) {
-        getTimerValue(name).add(value);
+        getTimerValue(CounterKey.create(name)).add(value);
       }
 
       @Override
       public void remove() {}
     };
   }
+
+  @Override
+  public <F1> Counter1<F1> newCounter(String name, Description desc, Field<F1> field1) {
+    return new Counter1<>() {
+      @Override
+      public void incrementBy(F1 field1, long value) {
+        getCounterValue(CounterKey.create(name, field1)).add(value);
+      }
+
+      @Override
+      public void remove() {}
+    };
+  }
+
+  @Override
+  public <F1, F2> Counter2<F1, F2> newCounter(
+      String name, Description desc, Field<F1> field1, Field<F2> field2) {
+    return new Counter2<>() {
+      @Override
+      public void incrementBy(F1 field1, F2 field2, long value) {
+        getCounterValue(CounterKey.create(name, field1, field2)).add(value);
+      }
+
+      @Override
+      public void remove() {}
+    };
+  }
+
+  @Override
+  public <F1, F2, F3> Counter3<F1, F2, F3> newCounter(
+      String name, Description desc, Field<F1> field1, Field<F2> field2, Field<F3> field3) {
+    return new Counter3<>() {
+      @Override
+      public void incrementBy(F1 field1, F2 field2, F3 field3, long value) {
+        getCounterValue(CounterKey.create(name, field1, field2, field3)).add(value);
+      }
+
+      @Override
+      public void remove() {}
+    };
+  }
+
+  @AutoValue
+  abstract static class CounterKey {
+    abstract String name();
+
+    abstract ImmutableList<Object> fieldValues();
+
+    static CounterKey create(String name, Object... fieldValues) {
+      return new AutoValue_TestMetricMaker_CounterKey(
+          name, ImmutableList.copyOf(Arrays.asList(fieldValues)));
+    }
+  }
 }
diff --git a/java/com/google/gerrit/acceptance/config/BUILD b/java/com/google/gerrit/acceptance/config/BUILD
index a8ccc1f..0da68b0 100644
--- a/java/com/google/gerrit/acceptance/config/BUILD
+++ b/java/com/google/gerrit/acceptance/config/BUILD
@@ -7,6 +7,7 @@
     srcs = glob(["*.java"]),
     visibility = ["//visibility:public"],
     deps = [
+        "//java/com/google/gerrit/common:annotations",
         "//lib:guava",
         "//lib:jgit",
         "//lib/auto:auto-value",
diff --git a/java/com/google/gerrit/acceptance/config/ConfigAnnotationParser.java b/java/com/google/gerrit/acceptance/config/ConfigAnnotationParser.java
index 32dfa83..fc6be03 100644
--- a/java/com/google/gerrit/acceptance/config/ConfigAnnotationParser.java
+++ b/java/com/google/gerrit/acceptance/config/ConfigAnnotationParser.java
@@ -18,6 +18,7 @@
 import com.google.common.base.Splitter;
 import com.google.common.base.Strings;
 import com.google.common.collect.Lists;
+import com.google.gerrit.common.Nullable;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.HashMap;
@@ -27,6 +28,7 @@
 public class ConfigAnnotationParser {
   private static Splitter splitter = Splitter.on(".").trimResults();
 
+  @Nullable
   public static Config parse(Config base, GerritConfigs annotation) {
     if (annotation == null) {
       return null;
@@ -55,6 +57,7 @@
     System.setProperty(annotation.name(), annotation.value());
   }
 
+  @Nullable
   public static Map<String, Config> parse(GlobalPluginConfigs annotation) {
     if (annotation == null || annotation.value().length < 1) {
       return null;
@@ -77,6 +80,7 @@
     return result;
   }
 
+  @Nullable
   public static Map<String, Config> parse(GlobalPluginConfig annotation) {
     if (annotation == null) {
       return null;
diff --git a/java/com/google/gerrit/acceptance/testsuite/account/TestSshKeys.java b/java/com/google/gerrit/acceptance/testsuite/account/TestSshKeys.java
index 277d219..e510ba3 100644
--- a/java/com/google/gerrit/acceptance/testsuite/account/TestSshKeys.java
+++ b/java/com/google/gerrit/acceptance/testsuite/account/TestSshKeys.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance.testsuite.account;
 
 import static com.google.common.base.Preconditions.checkState;
+import static com.google.gerrit.testing.TestActionRefUpdateContext.testRefAction;
 import static java.nio.charset.StandardCharsets.US_ASCII;
 
 import com.google.gerrit.acceptance.SshEnabled;
@@ -88,7 +89,8 @@
   private KeyPair createKeyPair(Account.Id accountId, String username, @Nullable String email)
       throws Exception {
     KeyPair keyPair = SshSessionFactory.genSshKey();
-    authorizedKeys.addKey(accountId, publicKey(keyPair, email));
+    testRefAction(() -> authorizedKeys.addKey(accountId, publicKey(keyPair, email)));
+
     sshKeyCache.evict(username);
     return keyPair;
   }
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImpl.java b/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImpl.java
index c1029be..5efcfc6 100644
--- a/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImpl.java
+++ b/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImpl.java
@@ -16,10 +16,12 @@
 
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.gerrit.testing.TestActionRefUpdateContext.openTestRefUpdateContext;
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Streams;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
@@ -43,6 +45,7 @@
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.update.context.RefUpdateContext;
 import com.google.gerrit.server.util.CommitMessageUtil;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
@@ -131,29 +134,33 @@
   }
 
   private Change.Id createChange(TestChangeCreation changeCreation) throws Exception {
-    Change.Id changeId = Change.id(seq.nextChangeId());
-    Project.NameKey project = getTargetProject(changeCreation);
+    try (RefUpdateContext ctx = openTestRefUpdateContext()) {
+      Change.Id changeId = Change.id(seq.nextChangeId());
+      Project.NameKey project = getTargetProject(changeCreation);
 
-    try (Repository repository = repositoryManager.openRepository(project);
-        ObjectInserter objectInserter = repository.newObjectInserter();
-        RevWalk revWalk = new RevWalk(objectInserter.newReader())) {
-      Instant now = TimeUtil.now();
-      IdentifiedUser changeOwner = getChangeOwner(changeCreation);
-      PersonIdent authorAndCommitter = changeOwner.newCommitterIdent(now, serverIdent.getZoneId());
-      ObjectId commitId =
-          createCommit(repository, revWalk, objectInserter, changeCreation, authorAndCommitter);
+      try (Repository repository = repositoryManager.openRepository(project);
+          ObjectInserter objectInserter = repository.newObjectInserter();
+          RevWalk revWalk = new RevWalk(objectInserter.newReader())) {
+        Instant now = TimeUtil.now();
+        IdentifiedUser changeOwner = getChangeOwner(changeCreation);
+        PersonIdent author = getAuthorIdent(now, changeCreation);
+        PersonIdent committer = getCommitterIdent(now, changeCreation);
+        ObjectId commitId =
+            createCommit(repository, revWalk, objectInserter, changeCreation, author, committer);
 
-      String refName = RefNames.fullName(changeCreation.branch());
-      ChangeInserter inserter = getChangeInserter(changeId, refName, commitId);
-      changeCreation.topic().ifPresent(t -> inserter.setTopic(t));
-      inserter.setApprovals(changeCreation.approvals());
+        String refName = RefNames.fullName(changeCreation.branch());
+        ChangeInserter inserter = getChangeInserter(changeId, refName, commitId);
+        inserter.setGroups(getGroups(changeCreation));
+        changeCreation.topic().ifPresent(t -> inserter.setTopic(t));
+        inserter.setApprovals(changeCreation.approvals());
 
-      try (BatchUpdate batchUpdate = batchUpdateFactory.create(project, changeOwner, now)) {
-        batchUpdate.setRepository(repository, revWalk, objectInserter);
-        batchUpdate.insertChange(inserter);
-        batchUpdate.execute();
+        try (BatchUpdate batchUpdate = batchUpdateFactory.create(project, changeOwner, now)) {
+          batchUpdate.setRepository(repository, revWalk, objectInserter);
+          batchUpdate.insertChange(inserter);
+          batchUpdate.execute();
+        }
+        return changeId;
       }
-      return changeId;
     }
   }
 
@@ -189,6 +196,30 @@
     return getArbitraryUser();
   }
 
+  private PersonIdent getAuthorIdent(Instant when, TestChangeCreation changeCreation)
+      throws IOException, ConfigInvalidException {
+    if (changeCreation.authorIdent().isPresent()) {
+      return new PersonIdent(changeCreation.authorIdent().get(), when);
+    }
+
+    return (changeCreation.author().isPresent()
+            ? userFactory.create(changeCreation.author().get())
+            : getChangeOwner(changeCreation))
+        .newCommitterIdent(when, serverIdent.getZoneId());
+  }
+
+  private PersonIdent getCommitterIdent(Instant when, TestChangeCreation changeCreation)
+      throws IOException, ConfigInvalidException {
+    if (changeCreation.committerIdent().isPresent()) {
+      return new PersonIdent(changeCreation.committerIdent().get(), when);
+    }
+
+    return (changeCreation.committer().isPresent()
+            ? userFactory.create(changeCreation.committer().get())
+            : getChangeOwner(changeCreation))
+        .newCommitterIdent(when, serverIdent.getZoneId());
+  }
+
   private IdentifiedUser getArbitraryUser() throws ConfigInvalidException, IOException {
     ImmutableSet<Account.Id> foundAccounts = resolver.resolveIgnoreVisibility("").asIdSet();
     checkState(
@@ -202,20 +233,84 @@
       RevWalk revWalk,
       ObjectInserter objectInserter,
       TestChangeCreation changeCreation,
-      PersonIdent authorAndCommitter)
+      PersonIdent author,
+      PersonIdent committer)
       throws IOException, BadRequestException {
     ImmutableList<ObjectId> parentCommits = getParentCommits(repository, revWalk, changeCreation);
     TreeCreator treeCreator =
         getTreeCreator(objectInserter, parentCommits, changeCreation.mergeStrategy());
     ObjectId tree = createNewTree(repository, treeCreator, changeCreation.treeModifications());
     String commitMessage = correctCommitMessage(changeCreation.commitMessage());
-    return createCommit(
-        objectInserter, tree, parentCommits, authorAndCommitter, authorAndCommitter, commitMessage);
+    return createCommit(objectInserter, tree, parentCommits, author, committer, commitMessage);
+  }
+
+  private ImmutableList<String> getGroups(TestChangeCreation changeCreation) {
+    return changeCreation
+        .parents()
+        .map(parents -> getGroups(parents))
+        .orElseGet(() -> ImmutableList.of());
+  }
+
+  private ImmutableList<String> getGroups(ImmutableList<TestCommitIdentifier> parents) {
+    return parents.stream()
+        .map(parent -> getGroups(parent))
+        .flatMap(groups -> groups.stream())
+        .collect(toImmutableList());
+  }
+
+  private ImmutableList<String> getGroups(TestCommitIdentifier parentCommit) {
+    switch (parentCommit.getKind()) {
+      case BRANCH:
+        return ImmutableList.of();
+      case CHANGE_ID:
+        return getGroupsFromChange(parentCommit.changeId());
+      case COMMIT_SHA_1:
+        return ImmutableList.of();
+      case PATCHSET_ID:
+        return getGroupsFromPatchset(parentCommit.patchsetId());
+      default:
+        throw new IllegalStateException(
+            String.format("No parent behavior implemented for %s.", parentCommit.getKind()));
+    }
+  }
+
+  private ImmutableList<String> getGroupsFromChange(Change.Id changeId) {
+    Optional<ChangeNotes> changeNotes = changeFinder.findOne(changeId);
+
+    if (changeNotes.isPresent() && changeNotes.get().getChange().isClosed()) {
+      return ImmutableList.of();
+    }
+
+    return changeNotes
+        .map(ChangeNotes::getCurrentPatchSet)
+        .map(PatchSet::groups)
+        .orElseThrow(
+            () ->
+                new IllegalStateException(
+                    String.format(
+                        "Change %s not found and hence can't be used as parent.", changeId)));
+  }
+
+  private ImmutableList<String> getGroupsFromPatchset(PatchSet.Id patchsetId) {
+    Optional<ChangeNotes> changeNotes = changeFinder.findOne(patchsetId.changeId());
+
+    if (changeNotes.isPresent() && changeNotes.get().getChange().isClosed()) {
+      return ImmutableList.of();
+    }
+
+    return changeNotes
+        .map(ChangeNotes::getPatchSets)
+        .map(patchsets -> patchsets.get(patchsetId))
+        .map(PatchSet::groups)
+        .orElseThrow(
+            () ->
+                new IllegalStateException(
+                    String.format(
+                        "Patchset %s not found and hence can't be used as parent.", patchsetId)));
   }
 
   private ImmutableList<ObjectId> getParentCommits(
       Repository repository, RevWalk revWalk, TestChangeCreation changeCreation) {
-
     return changeCreation
         .parents()
         .map(parents -> resolveParents(repository, revWalk, parents))
@@ -426,37 +521,82 @@
 
     private PatchSet.Id createPatchset(TestPatchsetCreation patchsetCreation)
         throws IOException, RestApiException, UpdateException {
-      ChangeNotes changeNotes = getChangeNotes();
-      Project.NameKey project = changeNotes.getProjectName();
-      try (Repository repository = repositoryManager.openRepository(project);
-          ObjectInserter objectInserter = repository.newObjectInserter();
-          RevWalk revWalk = new RevWalk(objectInserter.newReader())) {
-        Instant now = TimeUtil.now();
-        ObjectId newPatchsetCommit =
-            createPatchsetCommit(
-                repository, revWalk, objectInserter, changeNotes, patchsetCreation, now);
+      try (RefUpdateContext ctx = openTestRefUpdateContext()) {
+        ChangeNotes changeNotes = getChangeNotes();
+        Project.NameKey project = changeNotes.getProjectName();
+        try (Repository repository = repositoryManager.openRepository(project);
+            ObjectInserter objectInserter = repository.newObjectInserter();
+            RevWalk revWalk = new RevWalk(objectInserter.newReader())) {
+          Instant now = TimeUtil.now();
+          PersonIdent authorIdent = getAuthorIdent(now, patchsetCreation);
+          PersonIdent committerIdent = getCommitterIdent(now, patchsetCreation);
+          ObjectId newPatchsetCommit =
+              createPatchsetCommit(
+                  repository,
+                  revWalk,
+                  objectInserter,
+                  changeNotes,
+                  patchsetCreation,
+                  authorIdent,
+                  committerIdent,
+                  now);
 
-        PatchSet.Id patchsetId =
-            ChangeUtil.nextPatchSetId(repository, changeNotes.getCurrentPatchSet().id());
-        PatchSetInserter patchSetInserter =
-            getPatchSetInserter(changeNotes, newPatchsetCommit, patchsetId);
+          PatchSet.Id patchsetId =
+              ChangeUtil.nextPatchSetId(repository, changeNotes.getCurrentPatchSet().id());
+          PatchSetInserter patchSetInserter =
+              getPatchSetInserter(changeNotes, newPatchsetCommit, patchsetId);
 
-        IdentifiedUser changeOwner = userFactory.create(changeNotes.getChange().getOwner());
-        try (BatchUpdate batchUpdate = batchUpdateFactory.create(project, changeOwner, now)) {
-          batchUpdate.setRepository(repository, revWalk, objectInserter);
-          batchUpdate.addOp(changeId, patchSetInserter);
-          batchUpdate.execute();
+          Account.Id uploaderId =
+              patchsetCreation.uploader().orElse(changeNotes.getChange().getOwner());
+          IdentifiedUser uploader = userFactory.create(uploaderId);
+          try (BatchUpdate batchUpdate = batchUpdateFactory.create(project, uploader, now)) {
+            batchUpdate.setRepository(repository, revWalk, objectInserter);
+            batchUpdate.addOp(changeId, patchSetInserter);
+            batchUpdate.execute();
+          }
+          return patchsetId;
         }
-        return patchsetId;
       }
     }
 
+    @Nullable
+    private PersonIdent getAuthorIdent(Instant when, TestPatchsetCreation patchsetCreation) {
+      if (patchsetCreation.authorIdent().isPresent()) {
+        return new PersonIdent(patchsetCreation.authorIdent().get(), when);
+      }
+
+      if (patchsetCreation.author().isPresent()) {
+        return userFactory
+            .create(patchsetCreation.author().get())
+            .newCommitterIdent(when, serverIdent.getZoneId());
+      }
+
+      return null;
+    }
+
+    @Nullable
+    private PersonIdent getCommitterIdent(Instant when, TestPatchsetCreation patchsetCreation) {
+      if (patchsetCreation.committerIdent().isPresent()) {
+        return new PersonIdent(patchsetCreation.committerIdent().get(), when);
+      }
+
+      if (patchsetCreation.committer().isPresent()) {
+        return userFactory
+            .create(patchsetCreation.committer().get())
+            .newCommitterIdent(when, serverIdent.getZoneId());
+      }
+
+      return null;
+    }
+
     private ObjectId createPatchsetCommit(
         Repository repository,
         RevWalk revWalk,
         ObjectInserter objectInserter,
         ChangeNotes changeNotes,
         TestPatchsetCreation patchsetCreation,
+        @Nullable PersonIdent author,
+        @Nullable PersonIdent committer,
         Instant now)
         throws IOException, BadRequestException {
       ObjectId oldPatchsetCommitId = changeNotes.getCurrentPatchSet().commitId();
@@ -472,9 +612,13 @@
               changeNotes.getChange().getKey().get(),
               patchsetCreation.commitMessage().orElseGet(oldPatchsetCommit::getFullMessage));
 
-      PersonIdent author = getAuthor(oldPatchsetCommit);
-      PersonIdent committer = getCommitter(oldPatchsetCommit, now);
-      return createCommit(objectInserter, tree, parentCommitIds, author, committer, commitMessage);
+      return createCommit(
+          objectInserter,
+          tree,
+          parentCommitIds,
+          Optional.ofNullable(author).orElse(getAuthor(oldPatchsetCommit)),
+          Optional.ofNullable(committer).orElse(getCommitter(oldPatchsetCommit, now)),
+          commitMessage);
     }
 
     private String correctCommitMessage(String oldChangeId, String desiredCommitMessage)
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/FileContentBuilder.java b/java/com/google/gerrit/acceptance/testsuite/change/FileContentBuilder.java
index d0ccd5b..1971c57 100644
--- a/java/com/google/gerrit/acceptance/testsuite/change/FileContentBuilder.java
+++ b/java/com/google/gerrit/acceptance/testsuite/change/FileContentBuilder.java
@@ -28,13 +28,18 @@
 public class FileContentBuilder<T> {
   private final T builder;
   private final String filePath;
+  private final int newGitFileMode;
   private final Consumer<TreeModification> modificationToBuilderAdder;
 
   FileContentBuilder(
-      T builder, String filePath, Consumer<TreeModification> modificationToBuilderAdder) {
+      T builder,
+      String filePath,
+      int newGitFileMode,
+      Consumer<TreeModification> modificationToBuilderAdder) {
     checkNotNull(Strings.emptyToNull(filePath), "File path must not be null or empty.");
     this.builder = builder;
     this.filePath = filePath;
+    this.newGitFileMode = newGitFileMode;
     this.modificationToBuilderAdder = modificationToBuilderAdder;
   }
 
@@ -44,7 +49,7 @@
         Strings.emptyToNull(content),
         "Empty file content is not supported. Adjust test API if necessary.");
     modificationToBuilderAdder.accept(
-        new ChangeFileContentModification(filePath, RawInputUtil.create(content)));
+        new ChangeFileContentModification(filePath, RawInputUtil.create(content), newGitFileMode));
     return builder;
   }
 
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/PerPatchsetOperationsImpl.java b/java/com/google/gerrit/acceptance/testsuite/change/PerPatchsetOperationsImpl.java
index 9b393ef..3bd355b 100644
--- a/java/com/google/gerrit/acceptance/testsuite/change/PerPatchsetOperationsImpl.java
+++ b/java/com/google/gerrit/acceptance/testsuite/change/PerPatchsetOperationsImpl.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.acceptance.testsuite.change;
 
+import static com.google.gerrit.testing.TestActionRefUpdateContext.openTestRefUpdateContext;
+
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Comment.Status;
 import com.google.gerrit.entities.HumanComment;
@@ -34,6 +36,7 @@
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.update.context.RefUpdateContext;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
@@ -101,21 +104,23 @@
 
   private String createComment(TestCommentCreation commentCreation)
       throws IOException, RestApiException, UpdateException {
+
     Project.NameKey project = changeNotes.getProjectName();
+    try (RefUpdateContext ctx = openTestRefUpdateContext()) {
+      try (Repository repository = repositoryManager.openRepository(project);
+          ObjectInserter objectInserter = repository.newObjectInserter();
+          RevWalk revWalk = new RevWalk(objectInserter.newReader())) {
+        Instant now = TimeUtil.now();
 
-    try (Repository repository = repositoryManager.openRepository(project);
-        ObjectInserter objectInserter = repository.newObjectInserter();
-        RevWalk revWalk = new RevWalk(objectInserter.newReader())) {
-      Instant now = TimeUtil.now();
-
-      IdentifiedUser author = getAuthor(commentCreation);
-      CommentAdditionOp commentAdditionOp = new CommentAdditionOp(commentCreation);
-      try (BatchUpdate batchUpdate = batchUpdateFactory.create(project, author, now)) {
-        batchUpdate.setRepository(repository, revWalk, objectInserter);
-        batchUpdate.addOp(changeNotes.getChangeId(), commentAdditionOp);
-        batchUpdate.execute();
+        IdentifiedUser author = getAuthor(commentCreation);
+        CommentAdditionOp commentAdditionOp = new CommentAdditionOp(commentCreation);
+        try (BatchUpdate batchUpdate = batchUpdateFactory.create(project, author, now)) {
+          batchUpdate.setRepository(repository, revWalk, objectInserter);
+          batchUpdate.addOp(changeNotes.getChangeId(), commentAdditionOp);
+          batchUpdate.execute();
+        }
+        return commentAdditionOp.createdCommentUuid;
       }
-      return commentAdditionOp.createdCommentUuid;
     }
   }
 
@@ -197,21 +202,22 @@
   private String createRobotComment(TestRobotCommentCreation robotCommentCreation)
       throws IOException, RestApiException, UpdateException {
     Project.NameKey project = changeNotes.getProjectName();
+    try (RefUpdateContext ctx = openTestRefUpdateContext()) {
+      try (Repository repository = repositoryManager.openRepository(project);
+          ObjectInserter objectInserter = repository.newObjectInserter();
+          RevWalk revWalk = new RevWalk(objectInserter.newReader())) {
+        Instant now = TimeUtil.now();
 
-    try (Repository repository = repositoryManager.openRepository(project);
-        ObjectInserter objectInserter = repository.newObjectInserter();
-        RevWalk revWalk = new RevWalk(objectInserter.newReader())) {
-      Instant now = TimeUtil.now();
-
-      IdentifiedUser author = getAuthor(robotCommentCreation);
-      RobotCommentAdditionOp robotCommentAdditionOp =
-          new RobotCommentAdditionOp(robotCommentCreation);
-      try (BatchUpdate batchUpdate = batchUpdateFactory.create(project, author, now)) {
-        batchUpdate.setRepository(repository, revWalk, objectInserter);
-        batchUpdate.addOp(changeNotes.getChangeId(), robotCommentAdditionOp);
-        batchUpdate.execute();
+        IdentifiedUser author = getAuthor(robotCommentCreation);
+        RobotCommentAdditionOp robotCommentAdditionOp =
+            new RobotCommentAdditionOp(robotCommentCreation);
+        try (BatchUpdate batchUpdate = batchUpdateFactory.create(project, author, now)) {
+          batchUpdate.setRepository(repository, revWalk, objectInserter);
+          batchUpdate.addOp(changeNotes.getChangeId(), robotCommentAdditionOp);
+          batchUpdate.execute();
+        }
+        return robotCommentAdditionOp.createdRobotCommentUuid;
       }
-      return robotCommentAdditionOp.createdRobotCommentUuid;
     }
   }
 
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/TestChangeCreation.java b/java/com/google/gerrit/acceptance/testsuite/change/TestChangeCreation.java
index a064d02..a0746e2 100644
--- a/java/com/google/gerrit/acceptance/testsuite/change/TestChangeCreation.java
+++ b/java/com/google/gerrit/acceptance/testsuite/change/TestChangeCreation.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.acceptance.testsuite.change;
 
+import static com.google.common.base.Preconditions.checkState;
+
 import com.google.auto.value.AutoValue;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
@@ -24,6 +26,7 @@
 import com.google.gerrit.server.edit.tree.TreeModification;
 import java.util.Optional;
 import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.merge.MergeStrategy;
 
 /** Initial attributes of the change. If not provided, arbitrary values will be used. */
@@ -35,6 +38,14 @@
 
   public abstract Optional<Account.Id> owner();
 
+  public abstract Optional<Account.Id> author();
+
+  public abstract Optional<PersonIdent> authorIdent();
+
+  public abstract Optional<Account.Id> committer();
+
+  public abstract Optional<PersonIdent> committerIdent();
+
   public abstract Optional<String> topic();
 
   public abstract ImmutableMap<String, Short> approvals();
@@ -69,9 +80,65 @@
      */
     public abstract Builder branch(String branch);
 
-    /** The change owner. Must be an existing user account. */
+    /**
+     * The change owner.
+     *
+     * <p>Must be an existing user account.
+     */
     public abstract Builder owner(Account.Id owner);
 
+    /**
+     * The author of the commit for which the change is created.
+     *
+     * <p>Must be an existing user account.
+     *
+     * <p>Cannot be set together with {@link #authorIdent()} is set.
+     *
+     * <p>If neither {@link #author()} nor {@link #authorIdent()} is set the {@link
+     * TestChangeCreation#owner()} is used as the author.
+     */
+    public abstract Builder author(Account.Id author);
+
+    /**
+     * The author ident of the commit for which the change is created.
+     *
+     * <p>Cannot be set together with {@link #author()} is set.
+     *
+     * <p>If neither {@link #author()} nor {@link #authorIdent()} is set the {@link
+     * TestChangeCreation#owner()} is used as the author.
+     */
+    public abstract Builder authorIdent(PersonIdent authorIdent);
+
+    public abstract Optional<Account.Id> author();
+
+    public abstract Optional<PersonIdent> authorIdent();
+
+    /**
+     * The committer of the commit for which the change is created.
+     *
+     * <p>Must be an existing user account.
+     *
+     * <p>Cannot be set together with {@link #committerIdent()} is set.
+     *
+     * <p>If neither {@link #committer()} nor {@link #committerIdent()} is set the {@link
+     * TestChangeCreation#owner()} is used as the committer.
+     */
+    public abstract Builder committer(Account.Id committer);
+
+    /**
+     * The committer ident of the commit for which the change is created.
+     *
+     * <p>Cannot be set together with {@link #committer()} is set.
+     *
+     * <p>If neither {@link #committer()} nor {@link #committerIdent()} is set the {@link
+     * TestChangeCreation#owner()} is used as the committer.
+     */
+    public abstract Builder committerIdent(PersonIdent committerIdent);
+
+    public abstract Optional<Account.Id> committer();
+
+    public abstract Optional<PersonIdent> committerIdent();
+
     /** The topic to add this change to. */
     public abstract Builder topic(String topic);
 
@@ -89,7 +156,18 @@
 
     /** Modified file of the change. The file content is specified via the returned builder. */
     public FileContentBuilder<Builder> file(String filePath) {
-      return new FileContentBuilder<>(this, filePath, treeModificationsBuilder()::add);
+      return new FileContentBuilder<>(this, filePath, 0, treeModificationsBuilder()::add);
+    }
+
+    /**
+     * Modified file of the change. The file content is specified via the returned builder. The
+     * second parameter indicates the git file mode for the modified file if it has been changed.
+     *
+     * @see org.eclipse.jgit.lib.FileMode
+     */
+    public FileContentBuilder<Builder> file(String filePath, int newGitFileMode) {
+      return new FileContentBuilder<>(
+          this, filePath, newGitFileMode, treeModificationsBuilder()::add);
     }
 
     abstract ImmutableList.Builder<TreeModification> treeModificationsBuilder();
@@ -145,13 +223,23 @@
 
     abstract TestChangeCreation autoBuild();
 
+    public TestChangeCreation build() {
+      checkState(
+          author().isEmpty() || authorIdent().isEmpty(),
+          "author and authorIdent cannot be set together");
+      checkState(
+          committer().isEmpty() || committerIdent().isEmpty(),
+          "committer and committerIdent cannot be set together");
+      return autoBuild();
+    }
+
     /**
      * Creates the change.
      *
      * @return the {@code Change.Id} of the created change
      */
     public Change.Id create() {
-      TestChangeCreation changeUpdate = autoBuild();
+      TestChangeCreation changeUpdate = build();
       return changeUpdate.changeCreator().applyAndThrowSilently(changeUpdate);
     }
   }
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/TestPatchsetCreation.java b/java/com/google/gerrit/acceptance/testsuite/change/TestPatchsetCreation.java
index fe9d909..f8ca977 100644
--- a/java/com/google/gerrit/acceptance/testsuite/change/TestPatchsetCreation.java
+++ b/java/com/google/gerrit/acceptance/testsuite/change/TestPatchsetCreation.java
@@ -14,17 +14,31 @@
 
 package com.google.gerrit.acceptance.testsuite.change;
 
+import static com.google.common.base.Preconditions.checkState;
+
 import com.google.auto.value.AutoValue;
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.testsuite.ThrowingFunction;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.server.edit.tree.TreeModification;
 import java.util.Optional;
+import org.eclipse.jgit.lib.PersonIdent;
 
 /** Initial attributes of the patchset. If not provided, arbitrary values will be used. */
 @AutoValue
 public abstract class TestPatchsetCreation {
 
+  public abstract Optional<Account.Id> uploader();
+
+  public abstract Optional<Account.Id> author();
+
+  public abstract Optional<PersonIdent> authorIdent();
+
+  public abstract Optional<Account.Id> committer();
+
+  public abstract Optional<PersonIdent> committerIdent();
+
   public abstract Optional<String> commitMessage();
 
   public abstract ImmutableList<TreeModification> treeModifications();
@@ -40,12 +54,78 @@
 
   @AutoValue.Builder
   public abstract static class Builder {
+    /**
+     * The uploader for the new patch set.
+     *
+     * <p>Must be an existing user account.
+     *
+     * <p>If not set the new patch set is uploaded by the change owner.
+     */
+    public abstract Builder uploader(Account.Id uploader);
+
+    /**
+     * The author of the commit for which the change is created.
+     *
+     * <p>Must be an existing user account.
+     *
+     * <p>Cannot be set together with {@link #authorIdent()} is set.
+     *
+     * <p>If neither {@link #author()} nor {@link #authorIdent()} is set the {@link
+     * TestChangeCreation#owner()} is used as the author.
+     */
+    public abstract Builder author(Account.Id author);
+
+    /**
+     * The author ident of the commit for which the change is created.
+     *
+     * <p>Cannot be set together with {@link #author()} is set.
+     *
+     * <p>If neither {@link #author()} nor {@link #authorIdent()} is set the {@link
+     * TestChangeCreation#owner()} is used as the author.
+     */
+    public abstract Builder authorIdent(PersonIdent authorIdent);
+
+    public abstract Optional<Account.Id> author();
+
+    public abstract Optional<PersonIdent> authorIdent();
+
+    /**
+     * The committer of the commit for which the change is created.
+     *
+     * <p>Must be an existing user account.
+     *
+     * <p>Cannot be set together with {@link #committerIdent()} is set.
+     *
+     * <p>If neither {@link #committer()} nor {@link #committerIdent()} is set the {@link
+     * TestChangeCreation#owner()} is used as the committer.
+     */
+    public abstract Builder committer(Account.Id committer);
+
+    /**
+     * The committer ident of the commit for which the change is created.
+     *
+     * <p>Cannot be set together with {@link #committer()} is set.
+     *
+     * <p>If neither {@link #committer()} nor {@link #committerIdent()} is set the {@link
+     * TestChangeCreation#owner()} is used as the committer.
+     */
+    public abstract Builder committerIdent(PersonIdent committerIdent);
+
+    public abstract Optional<Account.Id> committer();
+
+    public abstract Optional<PersonIdent> committerIdent();
 
     public abstract Builder commitMessage(String commitMessage);
 
     /** Modified file of the patchset. The file content is specified via the returned builder. */
     public FileContentBuilder<Builder> file(String filePath) {
-      return new FileContentBuilder<>(this, filePath, treeModificationsBuilder()::add);
+      return new FileContentBuilder<>(this, filePath, 0, treeModificationsBuilder()::add);
+    }
+
+    /** Modified file of the patchset. The file content is specified via the returned builder. */
+    public FileContentBuilder<Builder> file(String filePath, int newGitFileMode) {
+      return new FileContentBuilder<>(
+          this, filePath, newGitFileMode, treeModificationsBuilder()::add);
     }
 
     abstract ImmutableList.Builder<TreeModification> treeModificationsBuilder();
@@ -86,13 +166,23 @@
 
     abstract TestPatchsetCreation autoBuild();
 
+    public TestPatchsetCreation build() {
+      checkState(
+          author().isEmpty() || authorIdent().isEmpty(),
+          "author and authorIdent cannot be set together");
+      checkState(
+          committer().isEmpty() || committerIdent().isEmpty(),
+          "committer and committerIdent cannot be set together");
+      return autoBuild();
+    }
+
     /**
      * Creates the patchset.
      *
      * @return the {@code PatchSet.Id} of the created patchset
      */
     public PatchSet.Id create() {
-      TestPatchsetCreation patchsetCreation = autoBuild();
+      TestPatchsetCreation patchsetCreation = build();
       return patchsetCreation.patchsetCreator().applyAndThrowSilently(patchsetCreation);
     }
   }
diff --git a/java/com/google/gerrit/acceptance/testsuite/group/BUILD b/java/com/google/gerrit/acceptance/testsuite/group/BUILD
index d4f1175..2052105 100644
--- a/java/com/google/gerrit/acceptance/testsuite/group/BUILD
+++ b/java/com/google/gerrit/acceptance/testsuite/group/BUILD
@@ -14,6 +14,7 @@
         "//java/com/google/gerrit/exceptions",
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/testing:test-ref-update-context",
         "//lib:guava",
         "//lib:jgit",
         "//lib:jgit-junit",
diff --git a/java/com/google/gerrit/acceptance/testsuite/group/TestGroupCreation.java b/java/com/google/gerrit/acceptance/testsuite/group/TestGroupCreation.java
index 8bb7b23..99899cf 100644
--- a/java/com/google/gerrit/acceptance/testsuite/group/TestGroupCreation.java
+++ b/java/com/google/gerrit/acceptance/testsuite/group/TestGroupCreation.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.acceptance.testsuite.group;
 
+import static com.google.gerrit.testing.TestActionRefUpdateContext.testRefAction;
+
 import com.google.auto.value.AutoValue;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Sets;
@@ -106,7 +108,7 @@
      */
     public AccountGroup.UUID create() {
       TestGroupCreation groupCreation = autoBuild();
-      return groupCreation.groupCreator().applyAndThrowSilently(groupCreation);
+      return testRefAction(() -> groupCreation.groupCreator().applyAndThrowSilently(groupCreation));
     }
   }
 }
diff --git a/java/com/google/gerrit/acceptance/testsuite/project/BUILD b/java/com/google/gerrit/acceptance/testsuite/project/BUILD
index 850a133..4ac2705 100644
--- a/java/com/google/gerrit/acceptance/testsuite/project/BUILD
+++ b/java/com/google/gerrit/acceptance/testsuite/project/BUILD
@@ -4,14 +4,17 @@
 
 java_library(
     name = "project",
+    testonly = True,
     srcs = glob(["*.java"]),
     visibility = ["//visibility:public"],
     deps = [
         "//java/com/google/gerrit/acceptance:function",
+        "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/common:server",
         "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/testing:test-ref-update-context",
         "//lib:guava",
         "//lib:jgit",
         "//lib:jgit-junit",
diff --git a/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImpl.java b/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImpl.java
index deeb843..bd3d656 100644
--- a/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImpl.java
+++ b/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImpl.java
@@ -17,6 +17,7 @@
 import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.gerrit.entities.RefNames.REFS_CONFIG;
 import static com.google.gerrit.server.project.ProjectConfig.PROJECT_CONFIG;
+import static com.google.gerrit.testing.TestActionRefUpdateContext.openTestRefUpdateContext;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.Objects.requireNonNull;
 
@@ -26,6 +27,7 @@
 import com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.TestCapability;
 import com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.TestLabelPermission;
 import com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.TestPermission;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.AccessSection;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.GroupReference;
@@ -40,6 +42,7 @@
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.ProjectCreator;
+import com.google.gerrit.server.update.context.RefUpdateContext;
 import com.google.inject.Inject;
 import java.io.IOException;
 import java.util.ArrayList;
@@ -135,19 +138,21 @@
 
     private void updateProject(TestProjectUpdate projectUpdate)
         throws IOException, ConfigInvalidException {
-      try (MetaDataUpdate metaDataUpdate = metaDataUpdateFactory.create(nameKey)) {
-        ProjectConfig projectConfig = projectConfigFactory.read(metaDataUpdate);
-        if (projectUpdate.removeAllAccessSections()) {
-          projectConfig.getAccessSections().forEach(as -> projectConfig.remove(as));
+      try (RefUpdateContext ctx = openTestRefUpdateContext()) {
+        try (MetaDataUpdate metaDataUpdate = metaDataUpdateFactory.create(nameKey)) {
+          ProjectConfig projectConfig = projectConfigFactory.read(metaDataUpdate);
+          if (projectUpdate.removeAllAccessSections()) {
+            projectConfig.getAccessSections().forEach(as -> projectConfig.remove(as));
+          }
+          removePermissions(projectConfig, projectUpdate.removedPermissions());
+          addCapabilities(projectConfig, projectUpdate.addedCapabilities());
+          addPermissions(projectConfig, projectUpdate.addedPermissions());
+          addLabelPermissions(projectConfig, projectUpdate.addedLabelPermissions());
+          setExclusiveGroupPermissions(projectConfig, projectUpdate.exclusiveGroupPermissions());
+          projectConfig.commit(metaDataUpdate);
         }
-        removePermissions(projectConfig, projectUpdate.removedPermissions());
-        addCapabilities(projectConfig, projectUpdate.addedCapabilities());
-        addPermissions(projectConfig, projectUpdate.addedPermissions());
-        addLabelPermissions(projectConfig, projectUpdate.addedLabelPermissions());
-        setExclusiveGroupPermissions(projectConfig, projectUpdate.exclusiveGroupPermissions());
-        projectConfig.commit(metaDataUpdate);
+        projectCache.evictAndReindex(nameKey);
       }
-      projectCache.evictAndReindex(nameKey);
     }
 
     private void removePermissions(
@@ -196,8 +201,13 @@
         PermissionRule.Builder rule = newRule(projectConfig, p.group());
         rule.setAction(p.action());
         rule.setRange(p.min(), p.max());
-        String permissionName =
-            p.impersonation() ? Permission.forLabelAs(p.name()) : Permission.forLabel(p.name());
+        String permissionName;
+        if (p.isAddPermission()) {
+          permissionName =
+              p.impersonation() ? Permission.forLabelAs(p.name()) : Permission.forLabel(p.name());
+        } else {
+          permissionName = Permission.forRemoveLabel(p.name());
+        }
         projectConfig.upsertAccessSection(
             p.ref(), as -> as.upsertPermission(permissionName).add(rule));
       }
@@ -213,6 +223,7 @@
                   as -> as.upsertPermission(key.name()).setExclusiveGroup(exclusive)));
     }
 
+    @Nullable
     private RevCommit headOrNull(String branch) {
       branch = RefNames.fullName(branch);
 
diff --git a/java/com/google/gerrit/acceptance/testsuite/project/TestProjectUpdate.java b/java/com/google/gerrit/acceptance/testsuite/project/TestProjectUpdate.java
index 9a9a21a..5634c78 100644
--- a/java/com/google/gerrit/acceptance/testsuite/project/TestProjectUpdate.java
+++ b/java/com/google/gerrit/acceptance/testsuite/project/TestProjectUpdate.java
@@ -162,12 +162,34 @@
 
   /** Starts a builder for allowing a label permission. */
   public static TestLabelPermission.Builder allowLabel(String name) {
-    return TestLabelPermission.builder().name(name).action(PermissionRule.Action.ALLOW);
+    return TestLabelPermission.builder()
+        .name(name)
+        .isAddPermission(true)
+        .action(PermissionRule.Action.ALLOW);
   }
 
   /** Starts a builder for denying a label permission. */
   public static TestLabelPermission.Builder blockLabel(String name) {
-    return TestLabelPermission.builder().name(name).action(PermissionRule.Action.BLOCK);
+    return TestLabelPermission.builder()
+        .name(name)
+        .isAddPermission(true)
+        .action(PermissionRule.Action.BLOCK);
+  }
+
+  /** Starts a builder for allowing a remove-label permission. */
+  public static TestLabelPermission.Builder allowLabelRemoval(String name) {
+    return TestLabelPermission.builder()
+        .name(name)
+        .isAddPermission(false)
+        .action(PermissionRule.Action.ALLOW);
+  }
+
+  /** Starts a builder for denying a remove-label permission. */
+  public static TestLabelPermission.Builder blockLabelRemoval(String name) {
+    return TestLabelPermission.builder()
+        .name(name)
+        .isAddPermission(false)
+        .action(PermissionRule.Action.BLOCK);
   }
 
   /** Records a label permission to be updated. */
@@ -191,6 +213,8 @@
 
     abstract boolean impersonation();
 
+    abstract boolean isAddPermission();
+
     /** Builder for {@link TestLabelPermission}. */
     @AutoValue.Builder
     public abstract static class Builder {
@@ -208,6 +232,8 @@
 
       abstract Builder max(int max);
 
+      abstract Builder isAddPermission(boolean isAddPermission);
+
       /** Sets the minimum and maximum values for the permission. */
       public Builder range(int min, int max) {
         checkArgument(min != 0 || max != 0, "empty range");
@@ -243,6 +269,12 @@
     return TestPermissionKey.builder().name(Permission.forLabel(name));
   }
 
+  /** Starts a builder for describing a label removal permission key for deletion. */
+  public static TestPermissionKey.Builder labelRemovalPermissionKey(String name) {
+    checkLabelName(name);
+    return TestPermissionKey.builder().name(Permission.forRemoveLabel(name));
+  }
+
   /** Starts a builder for describing a capability key for deletion. */
   public static TestPermissionKey.Builder capabilityKey(String name) {
     return TestPermissionKey.builder().name(name).section(GLOBAL_CAPABILITIES);
diff --git a/java/com/google/gerrit/auth/ldap/FakeLdapGroupBackend.java b/java/com/google/gerrit/auth/ldap/FakeLdapGroupBackend.java
index 8fb4d35..c40baba 100644
--- a/java/com/google/gerrit/auth/ldap/FakeLdapGroupBackend.java
+++ b/java/com/google/gerrit/auth/ldap/FakeLdapGroupBackend.java
@@ -39,6 +39,7 @@
     return uuid.get().startsWith(LDAP_UUID);
   }
 
+  @Nullable
   @Override
   public GroupDescription.Basic get(AccountGroup.UUID uuid) {
     if (!handles(uuid)) {
diff --git a/java/com/google/gerrit/auth/ldap/Helper.java b/java/com/google/gerrit/auth/ldap/Helper.java
index a939c72..c11d045 100644
--- a/java/com/google/gerrit/auth/ldap/Helper.java
+++ b/java/com/google/gerrit/auth/ldap/Helper.java
@@ -18,6 +18,7 @@
 import com.google.common.cache.Cache;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.ParameterizedString;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.metrics.Description;
@@ -224,6 +225,7 @@
     return ctx;
   }
 
+  @Nullable
   private DirContext kerberosOpen(Properties env)
       throws IOException, LoginException, NamingException {
     LoginContext ctx = new LoginContext("KerberosLogin");
diff --git a/java/com/google/gerrit/auth/ldap/LdapGroupBackend.java b/java/com/google/gerrit/auth/ldap/LdapGroupBackend.java
index c3870f4..bb6480a 100644
--- a/java/com/google/gerrit/auth/ldap/LdapGroupBackend.java
+++ b/java/com/google/gerrit/auth/ldap/LdapGroupBackend.java
@@ -117,6 +117,7 @@
     return isLdapUUID(uuid);
   }
 
+  @Nullable
   @Override
   public GroupDescription.Basic get(AccountGroup.UUID uuid) {
     if (!handles(uuid)) {
diff --git a/java/com/google/gerrit/auth/ldap/LdapQuery.java b/java/com/google/gerrit/auth/ldap/LdapQuery.java
index 409c9f5..71dc141 100644
--- a/java/com/google/gerrit/auth/ldap/LdapQuery.java
+++ b/java/com/google/gerrit/auth/ldap/LdapQuery.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.auth.ldap;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.ParameterizedString;
 import com.google.gerrit.metrics.Timer0;
 import java.util.ArrayList;
@@ -114,6 +115,7 @@
       return get("dn");
     }
 
+    @Nullable
     String get(String attName) throws NamingException {
       final Attribute att = getAll(attName);
       return att != null && 0 < att.size() ? String.valueOf(att.get(0)) : null;
diff --git a/java/com/google/gerrit/auth/ldap/LdapRealm.java b/java/com/google/gerrit/auth/ldap/LdapRealm.java
index 7699799..7dc2b1b 100644
--- a/java/com/google/gerrit/auth/ldap/LdapRealm.java
+++ b/java/com/google/gerrit/auth/ldap/LdapRealm.java
@@ -20,6 +20,7 @@
 import com.google.common.cache.CacheLoader;
 import com.google.common.cache.LoadingCache;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.ParameterizedString;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
@@ -162,6 +163,7 @@
     return vlist;
   }
 
+  @Nullable
   static String optdef(Config c, String n, String d) {
     final String[] v = c.getStringList("ldap", null, n);
     if (v == null || v.length == 0) {
@@ -184,6 +186,7 @@
     return v;
   }
 
+  @Nullable
   static ParameterizedString paramString(Config c, String n, String d) {
     String expression = optdef(c, n, d);
     if (expression == null) {
@@ -209,6 +212,7 @@
     return !readOnlyAccountFields.contains(field);
   }
 
+  @Nullable
   static String apply(ParameterizedString p, LdapQuery.Result m) throws NamingException {
     if (p == null) {
       return null;
@@ -306,6 +310,7 @@
     usernameCache.put(who.getLocalUser(), Optional.of(account.id()));
   }
 
+  @Nullable
   @Override
   public Account.Id lookup(String accountName) {
     if (Strings.isNullOrEmpty(accountName)) {
diff --git a/java/com/google/gerrit/auth/oauth/OAuthTokenCache.java b/java/com/google/gerrit/auth/oauth/OAuthTokenCache.java
index b0c1f51..ab53cde 100644
--- a/java/com/google/gerrit/auth/oauth/OAuthTokenCache.java
+++ b/java/com/google/gerrit/auth/oauth/OAuthTokenCache.java
@@ -20,6 +20,7 @@
 import com.google.common.base.Converter;
 import com.google.common.base.Strings;
 import com.google.common.cache.Cache;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.auth.oauth.OAuthToken;
 import com.google.gerrit.extensions.auth.oauth.OAuthTokenEncrypter;
@@ -109,6 +110,7 @@
     this.encrypter = encrypter;
   }
 
+  @Nullable
   public OAuthToken get(Account.Id id) {
     OAuthToken accessToken = cache.getIfPresent(id);
     if (accessToken == null) {
diff --git a/java/com/google/gerrit/common/PageLinks.java b/java/com/google/gerrit/common/PageLinks.java
index 38de5b1..836eb32 100644
--- a/java/com/google/gerrit/common/PageLinks.java
+++ b/java/com/google/gerrit/common/PageLinks.java
@@ -106,10 +106,6 @@
     return toChangeQuery(op("owner", fullname) + " " + status(status));
   }
 
-  public static String toAssigneeQuery(String fullname) {
-    return toChangeQuery(op("assignee", fullname));
-  }
-
   public static String toCustomDashboard(String params) {
     return "/dashboard/?" + params;
   }
diff --git a/java/com/google/gerrit/common/RawInputUtil.java b/java/com/google/gerrit/common/RawInputUtil.java
index 4a676e6..23e4a23 100644
--- a/java/com/google/gerrit/common/RawInputUtil.java
+++ b/java/com/google/gerrit/common/RawInputUtil.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.common;
 
-import static com.google.common.base.Preconditions.checkArgument;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.Objects.requireNonNull;
 
@@ -31,7 +30,6 @@
 
   public static RawInput create(byte[] bytes, String contentType) {
     requireNonNull(bytes);
-    checkArgument(bytes.length > 0);
     return new RawInput() {
       @Override
       public InputStream getInputStream() throws IOException {
diff --git a/java/com/google/gerrit/common/UsedAt.java b/java/com/google/gerrit/common/UsedAt.java
index 6a482fb..46b43c6 100644
--- a/java/com/google/gerrit/common/UsedAt.java
+++ b/java/com/google/gerrit/common/UsedAt.java
@@ -46,7 +46,8 @@
     PLUGIN_SERVICEUSER,
     PLUGIN_PULL_REPLICATION,
     PLUGIN_WEBSESSION_FLATFILE,
-    MODULE_GIT_REFS_FILTER
+    MODULE_GIT_REFS_FILTER,
+    MODULE_VIRTUALHOST
   }
 
   /** Reference to the project that uses the method annotated with this annotation. */
diff --git a/java/com/google/gerrit/common/data/GlobalCapability.java b/java/com/google/gerrit/common/data/GlobalCapability.java
index 253266d..23151c2 100644
--- a/java/com/google/gerrit/common/data/GlobalCapability.java
+++ b/java/com/google/gerrit/common/data/GlobalCapability.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.common.data;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.PermissionRange;
 import java.util.ArrayList;
@@ -21,6 +22,7 @@
 import java.util.Collection;
 import java.util.Collections;
 import java.util.List;
+import java.util.Locale;
 
 /**
  * Server wide capabilities. Represented as {@link Permission} objects.
@@ -127,6 +129,9 @@
   /** Can view all pending tasks in the queue (not just the filtered set). */
   public static final String VIEW_QUEUE = "viewQueue";
 
+  /** Can view secondary emails of other accounts. */
+  public static final String VIEW_SECONDARY_EMAILS = "viewSecondaryEmails";
+
   private static final List<String> NAMES_ALL;
   private static final List<String> NAMES_LC;
   private static final String[] RANGE_NAMES = {
@@ -158,10 +163,11 @@
     NAMES_ALL.add(VIEW_CONNECTIONS);
     NAMES_ALL.add(VIEW_PLUGINS);
     NAMES_ALL.add(VIEW_QUEUE);
+    NAMES_ALL.add(VIEW_SECONDARY_EMAILS);
 
     NAMES_LC = new ArrayList<>(NAMES_ALL.size());
     for (String name : NAMES_ALL) {
-      NAMES_LC.add(name.toLowerCase());
+      NAMES_LC.add(name.toLowerCase(Locale.US));
     }
   }
 
@@ -172,7 +178,7 @@
 
   /** Returns true if the name is recognized as a capability name. */
   public static boolean isGlobalCapability(String varName) {
-    return NAMES_LC.contains(varName.toLowerCase());
+    return NAMES_LC.contains(varName.toLowerCase(Locale.US));
   }
 
   /** Returns true if the capability should have a range attached. */
@@ -190,6 +196,7 @@
   }
 
   /** Returns the valid range for the capability if it has one, otherwise null. */
+  @Nullable
   public static PermissionRange.WithDefaults getRange(String varName) {
     if (QUERY_LIMIT.equalsIgnoreCase(varName)) {
       return new PermissionRange.WithDefaults(
diff --git a/java/com/google/gerrit/common/data/ParameterizedString.java b/java/com/google/gerrit/common/data/ParameterizedString.java
index 84bb535..c8c2b2b 100644
--- a/java/com/google/gerrit/common/data/ParameterizedString.java
+++ b/java/com/google/gerrit/common/data/ParameterizedString.java
@@ -19,6 +19,7 @@
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
+import java.util.Locale;
 import java.util.Map;
 
 /** Performs replacements on strings such as <code>Hello ${user}</code>. */
@@ -213,7 +214,7 @@
         new Function() {
           @Override
           String apply(String a) {
-            return a.toLowerCase();
+            return a.toLowerCase(Locale.US);
           }
         });
     m.put(
@@ -221,7 +222,7 @@
         new Function() {
           @Override
           String apply(String a) {
-            return a.toUpperCase();
+            return a.toUpperCase(Locale.US);
           }
         });
     m.put(
diff --git a/java/com/google/gerrit/entities/Account.java b/java/com/google/gerrit/entities/Account.java
index 85dbdeb..699acc0 100644
--- a/java/com/google/gerrit/entities/Account.java
+++ b/java/com/google/gerrit/entities/Account.java
@@ -62,6 +62,7 @@
       return Optional.ofNullable(Ints.tryParse(str)).map(Account::id);
     }
 
+    @Nullable
     public static Id fromRef(String name) {
       if (name == null) {
         return null;
@@ -82,11 +83,13 @@
      * @param name a ref name with the following syntax: {@code "34/1234..."}. We assume that the
      *     caller has trimmed any prefix.
      */
+    @Nullable
     public static Id fromRefPart(String name) {
       Integer id = RefNames.parseShardedRefPart(name);
       return id != null ? Account.id(id) : null;
     }
 
+    @Nullable
     public static Id parseAfterShardedRefPart(String name) {
       Integer id = RefNames.parseAfterShardedRefPart(name);
       return id != null ? Account.id(id) : null;
@@ -102,6 +105,7 @@
      * @param name ref name
      * @return account ID, or null if not numeric.
      */
+    @Nullable
     public static Id fromRefSuffix(String name) {
       Integer id = RefNames.parseRefSuffix(name);
       return id != null ? Account.id(id) : null;
diff --git a/java/com/google/gerrit/entities/AccountGroup.java b/java/com/google/gerrit/entities/AccountGroup.java
index 001a544..b5c97da 100644
--- a/java/com/google/gerrit/entities/AccountGroup.java
+++ b/java/com/google/gerrit/entities/AccountGroup.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.entities;
 
 import com.google.auto.value.AutoValue;
+import com.google.gerrit.common.Nullable;
 
 public final class AccountGroup {
   public static NameKey nameKey(String n) {
@@ -65,6 +66,7 @@
     }
 
     /** Parse an {@link AccountGroup.UUID} out of a ref-name. */
+    @Nullable
     public static UUID fromRef(String ref) {
       if (ref == null) {
         return null;
@@ -81,6 +83,7 @@
      * @param refPart a ref name with the following syntax: {@code "12/1234..."}. We assume that the
      *     caller has trimmed any prefix.
      */
+    @Nullable
     public static UUID fromRefPart(String refPart) {
       String uuid = RefNames.parseShardedUuidFromRefPart(refPart);
       return uuid != null ? AccountGroup.uuid(uuid) : null;
diff --git a/java/com/google/gerrit/entities/Address.java b/java/com/google/gerrit/entities/Address.java
index 5d63476..eb1da46 100644
--- a/java/com/google/gerrit/entities/Address.java
+++ b/java/com/google/gerrit/entities/Address.java
@@ -46,6 +46,7 @@
     throw new IllegalArgumentException("Invalid email address: " + in);
   }
 
+  @Nullable
   public static Address tryParse(String in) {
     try {
       return parse(in);
diff --git a/java/com/google/gerrit/entities/BooleanProjectConfig.java b/java/com/google/gerrit/entities/BooleanProjectConfig.java
index 5201f6d..09f63d4 100644
--- a/java/com/google/gerrit/entities/BooleanProjectConfig.java
+++ b/java/com/google/gerrit/entities/BooleanProjectConfig.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.entities;
 
+import com.google.gerrit.common.Nullable;
+
 /**
  * Contains all inheritable boolean project configs and maps internal representations to API
  * objects.
@@ -41,7 +43,9 @@
   ENABLE_REVIEWER_BY_EMAIL("reviewer", "enableByEmail"),
   MATCH_AUTHOR_TO_COMMITTER_DATE("submit", "matchAuthorToCommitterDate"),
   REJECT_EMPTY_COMMIT("submit", "rejectEmptyCommit"),
-  WORK_IN_PROGRESS_BY_DEFAULT("change", "workInProgressByDefault");
+  WORK_IN_PROGRESS_BY_DEFAULT("change", "workInProgressByDefault"),
+  SKIP_ADDING_AUTHOR_AND_COMMITTER_AS_REVIEWERS(
+      "reviewer", "skipAddingAuthorAndCommitterAsReviewers");
 
   // Git config
   private final String section;
@@ -56,6 +60,7 @@
     return section;
   }
 
+  @Nullable
   public String getSubSection() {
     return null;
   }
diff --git a/java/com/google/gerrit/entities/Change.java b/java/com/google/gerrit/entities/Change.java
index 66e1a96..56fb748 100644
--- a/java/com/google/gerrit/entities/Change.java
+++ b/java/com/google/gerrit/entities/Change.java
@@ -117,6 +117,7 @@
       return id != null ? Optional.of(Change.id(id)) : Optional.empty();
     }
 
+    @Nullable
     public static Id fromRef(String ref) {
       if (RefNames.isRefsEdit(ref)) {
         return fromEditRefPart(ref);
@@ -134,6 +135,7 @@
       return null;
     }
 
+    @Nullable
     public static Id fromAllUsersRef(String ref) {
       if (ref == null) {
         return null;
@@ -169,6 +171,7 @@
       return true;
     }
 
+    @Nullable
     public static Id fromEditRefPart(String ref) {
       int startChangeId = ref.indexOf(RefNames.EDIT_PREFIX) + RefNames.EDIT_PREFIX.length();
       int endChangeId = nextNonDigit(ref, startChangeId);
@@ -179,6 +182,7 @@
       return null;
     }
 
+    @Nullable
     public static Id fromRefPart(String ref) {
       Integer id = RefNames.parseShardedRefPart(ref);
       return id != null ? Change.id(id) : null;
@@ -404,6 +408,7 @@
       return changeStatus;
     }
 
+    @Nullable
     public static Status forCode(char c) {
       for (Status s : Status.values()) {
         if (s.code == c) {
@@ -414,6 +419,7 @@
       return null;
     }
 
+    @Nullable
     public static Status forChangeStatus(ChangeStatus cs) {
       for (Status s : Status.values()) {
         if (s.changeStatus == cs) {
@@ -471,9 +477,6 @@
    */
   @Nullable private String submissionId;
 
-  /** Allows assigning a change to a user. */
-  @Nullable private Account.Id assignee;
-
   /** Whether the change is private. */
   private boolean isPrivate;
 
@@ -503,7 +506,6 @@
   }
 
   public Change(Change other) {
-    assignee = other.assignee;
     changeId = other.changeId;
     changeKey = other.changeKey;
     createdOn = other.createdOn;
@@ -542,14 +544,6 @@
     changeKey = k;
   }
 
-  public Account.Id getAssignee() {
-    return assignee;
-  }
-
-  public void setAssignee(Account.Id a) {
-    assignee = a;
-  }
-
   public Instant getCreatedOn() {
     return createdOn;
   }
@@ -599,6 +593,7 @@
   }
 
   /** Get the id of the most current {@link PatchSet} in this change. */
+  @Nullable
   public PatchSet.Id currentPatchSetId() {
     if (currentPatchSetId > 0) {
       return PatchSet.id(changeId, currentPatchSetId);
diff --git a/java/com/google/gerrit/entities/Comment.java b/java/com/google/gerrit/entities/Comment.java
index 65a1559..e1e143c 100644
--- a/java/com/google/gerrit/entities/Comment.java
+++ b/java/com/google/gerrit/entities/Comment.java
@@ -49,6 +49,7 @@
       return code;
     }
 
+    @Nullable
     public static Status forCode(char c) {
       for (Status s : Status.values()) {
         if (s.code == c) {
@@ -263,7 +264,7 @@
 
   public void setLineNbrAndRange(
       Integer lineNbr, com.google.gerrit.extensions.client.Comment.Range range) {
-    this.lineNbr = lineNbr != null ? lineNbr : range != null ? range.endLine : 0;
+    this.lineNbr = range != null ? range.endLine : lineNbr != null ? lineNbr : 0;
     if (range != null) {
       this.range = new Comment.Range(range);
     }
diff --git a/java/com/google/gerrit/entities/EmailHeader.java b/java/com/google/gerrit/entities/EmailHeader.java
index e43b6a3..d3710c4 100644
--- a/java/com/google/gerrit/entities/EmailHeader.java
+++ b/java/com/google/gerrit/entities/EmailHeader.java
@@ -115,8 +115,8 @@
         byte[] buf = new String(Character.toChars(cp)).getBytes(UTF_8);
         for (byte b : buf) {
           r.append('=');
-          r.append(Integer.toHexString((b >>> 4) & 0x0f).toUpperCase());
-          r.append(Integer.toHexString(b & 0x0f).toUpperCase());
+          r.append(Integer.toHexString((b >>> 4) & 0x0f).toUpperCase(Locale.US));
+          r.append(Integer.toHexString(b & 0x0f).toUpperCase(Locale.US));
         }
 
       } else {
diff --git a/java/com/google/gerrit/entities/KeyUtil.java b/java/com/google/gerrit/entities/KeyUtil.java
index 0f14cd9..4aec7ac 100644
--- a/java/com/google/gerrit/entities/KeyUtil.java
+++ b/java/com/google/gerrit/entities/KeyUtil.java
@@ -66,7 +66,13 @@
     return r.toString();
   }
 
-  public static String decode(final String key) {
+  public static String decode(String key) {
+    // URLs use percentage encoding which replaces unsafe ASCII characters with a '%' followed by
+    // two hexadecimal digits. If there is '%' that is not followed by two hexadecimal digits
+    // the code below fails with an IllegalArgumentException. To prevent this replace any '%'
+    // that is not followed by two hexadecimal digits by "%25", which is the URL encoding for '%'.
+    key = key.replaceAll("%(?![0-9a-fA-F]{2})", "%25");
+
     if (key.indexOf('%') < 0) {
       return key.replace('+', ' ');
     }
diff --git a/java/com/google/gerrit/entities/LabelFunction.java b/java/com/google/gerrit/entities/LabelFunction.java
index f361741..d49ab0f 100644
--- a/java/com/google/gerrit/entities/LabelFunction.java
+++ b/java/com/google/gerrit/entities/LabelFunction.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.entities;
 
+import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.SubmitRecord.Label;
 import java.util.Collections;
@@ -48,6 +49,16 @@
     ALL = Collections.unmodifiableMap(all);
   }
 
+  public static final Map<String, LabelFunction> ALL_NON_DEPRECATED;
+
+  static {
+    Map<String, LabelFunction> allNonDeprecated = new LinkedHashMap<>();
+    for (LabelFunction f : ImmutableSet.of(NO_BLOCK, NO_OP, PATCH_SET_LOCK)) {
+      allNonDeprecated.put(f.getFunctionName(), f);
+    }
+    ALL_NON_DEPRECATED = Collections.unmodifiableMap(allNonDeprecated);
+  }
+
   public static Optional<LabelFunction> parse(@Nullable String str) {
     return Optional.ofNullable(ALL.get(str));
   }
diff --git a/java/com/google/gerrit/entities/LabelType.java b/java/com/google/gerrit/entities/LabelType.java
index 0349a73..7a3266ecc 100644
--- a/java/com/google/gerrit/entities/LabelType.java
+++ b/java/com/google/gerrit/entities/LabelType.java
@@ -132,6 +132,7 @@
     return psa.labelId().get().equalsIgnoreCase(getName());
   }
 
+  @Nullable
   public LabelValue getMin() {
     if (getValues().isEmpty()) {
       return null;
@@ -139,6 +140,7 @@
     return getValues().get(0);
   }
 
+  @Nullable
   public LabelValue getMax() {
     if (getValues().isEmpty()) {
       return null;
diff --git a/java/com/google/gerrit/entities/LabelTypes.java b/java/com/google/gerrit/entities/LabelTypes.java
index a2f2e0b..fa7b741 100644
--- a/java/com/google/gerrit/entities/LabelTypes.java
+++ b/java/com/google/gerrit/entities/LabelTypes.java
@@ -19,6 +19,7 @@
 import java.util.Comparator;
 import java.util.HashMap;
 import java.util.List;
+import java.util.Locale;
 import java.util.Map;
 import java.util.Optional;
 
@@ -38,11 +39,11 @@
   }
 
   public Optional<LabelType> byLabel(LabelId labelId) {
-    return Optional.ofNullable(byLabel().get(labelId.get().toLowerCase()));
+    return Optional.ofNullable(byLabel().get(labelId.get().toLowerCase(Locale.US)));
   }
 
   public Optional<LabelType> byLabel(String labelName) {
-    return Optional.ofNullable(byLabel().get(labelName.toLowerCase()));
+    return Optional.ofNullable(byLabel().get(labelName.toLowerCase(Locale.US)));
   }
 
   private Map<String, LabelType> byLabel() {
@@ -52,7 +53,7 @@
           Map<String, LabelType> l = new HashMap<>();
           if (labelTypes != null) {
             for (LabelType t : labelTypes) {
-              l.put(t.getName().toLowerCase(), t);
+              l.put(t.getName().toLowerCase(Locale.US), t);
             }
           }
           byLabel = l;
diff --git a/java/com/google/gerrit/entities/Patch.java b/java/com/google/gerrit/entities/Patch.java
index 2d28046..bef6580 100644
--- a/java/com/google/gerrit/entities/Patch.java
+++ b/java/com/google/gerrit/entities/Patch.java
@@ -19,6 +19,7 @@
 import com.google.auto.value.AutoValue;
 import com.google.common.base.Splitter;
 import com.google.common.primitives.Ints;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.UsedAt;
 import java.util.List;
 
@@ -112,6 +113,7 @@
     }
 
     @UsedAt(UsedAt.Project.COLLABNET)
+    @Nullable
     public static ChangeType forCode(char c) {
       for (ChangeType s : ChangeType.values()) {
         if (s.code == c) {
@@ -168,33 +170,40 @@
    */
   public enum FileMode implements CodedEnum {
     /** Mode indicating an entry is a tree (aka directory). */
-    TREE('T'),
+    TREE('T', 0040000),
 
     /** Mode indicating an entry is a symbolic link. */
-    SYMLINK('S'),
+    SYMLINK('S', 0120000),
 
     /** Mode indicating an entry is a non-executable file. */
-    REGULAR_FILE('R'),
+    REGULAR_FILE('R', 0100644),
 
     /** Mode indicating an entry is an executable file. */
-    EXECUTABLE_FILE('E'),
+    EXECUTABLE_FILE('E', 0100755),
 
     /** Mode indicating an entry is a submodule commit in another repository. */
-    GITLINK('G'),
+    GITLINK('G', 0160000),
 
     /** Mode indicating an entry is missing during parallel walks. */
-    MISSING('M');
+    MISSING('M', 0000000);
 
     private final char code;
 
-    FileMode(char c) {
+    private final int mode;
+
+    FileMode(char c, int m) {
       code = c;
+      mode = m;
     }
 
     @Override
     public char getCode() {
       return code;
     }
+
+    public int getMode() {
+      return mode;
+    }
   }
 
   private Patch() {}
diff --git a/java/com/google/gerrit/entities/PatchSet.java b/java/com/google/gerrit/entities/PatchSet.java
index 6c52368..8784437 100644
--- a/java/com/google/gerrit/entities/PatchSet.java
+++ b/java/com/google/gerrit/entities/PatchSet.java
@@ -23,6 +23,7 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.primitives.Ints;
 import com.google.errorprone.annotations.InlineMe;
+import com.google.gerrit.common.Nullable;
 import java.time.Instant;
 import java.util.List;
 import java.util.Optional;
@@ -66,7 +67,7 @@
   }
 
   @AutoValue
-  public abstract static class Id {
+  public abstract static class Id implements Comparable<Id> {
     /** Parse a PatchSet.Id out of a string representation. */
     public static Id parse(String str) {
       List<String> parts = Splitter.on(',').splitToList(str);
@@ -83,6 +84,7 @@
     }
 
     /** Parse a PatchSet.Id from a {@link #refName()} result. */
+    @Nullable
     public static Id fromRef(String ref) {
       int cs = Change.Id.startIndex(ref);
       if (cs < 0) {
@@ -145,6 +147,11 @@
     public final String toString() {
       return getCommaSeparatedChangeAndPatchSetId();
     }
+
+    @Override
+    public int compareTo(Id other) {
+      return Ints.compare(get(), other.get());
+    }
   }
 
   public static Builder builder() {
@@ -163,6 +170,8 @@
 
     public abstract Builder uploader(Account.Id uploader);
 
+    public abstract Builder realUploader(Account.Id realUploader);
+
     public abstract Builder createdOn(Instant createdOn);
 
     public abstract Builder groups(Iterable<String> groups);
@@ -197,6 +206,9 @@
   /**
    * Account that uploaded the patch set.
    *
+   * <p>If the upload was done on behalf of another user, the impersonated user on whom's behalf the
+   * patch set was uploaded.
+   *
    * <p>If this is a deserialized instance that was originally serialized by an older version of
    * Gerrit, and the old data erroneously did not include an {@code uploader}, then this method will
    * return an account ID of 0.
@@ -204,6 +216,15 @@
   public abstract Account.Id uploader();
 
   /**
+   * The real account that uploaded the patch set.
+   *
+   * <p>If this is a deserialized instance that was originally serialized by an older version of
+   * Gerrit, and the old data did not include an {@code realUploader}, then this method will return
+   * the {@code uploader}.
+   */
+  public abstract Account.Id realUploader();
+
+  /**
    * When this patch set was first introduced onto the change.
    *
    * <p>If this is a deserialized instance that was originally serialized by an older version of
diff --git a/java/com/google/gerrit/entities/Permission.java b/java/com/google/gerrit/entities/Permission.java
index 95164bd..2a34579 100644
--- a/java/com/google/gerrit/entities/Permission.java
+++ b/java/com/google/gerrit/entities/Permission.java
@@ -22,6 +22,7 @@
 import java.util.ArrayList;
 import java.util.Iterator;
 import java.util.List;
+import java.util.Locale;
 import java.util.function.Consumer;
 
 /** A single permission within an {@link AccessSection} of a project. */
@@ -35,7 +36,6 @@
   public static final String DELETE = "delete";
   public static final String DELETE_CHANGES = "deleteChanges";
   public static final String DELETE_OWN_CHANGES = "deleteOwnChanges";
-  public static final String EDIT_ASSIGNEE = "editAssignee";
   public static final String EDIT_HASHTAGS = "editHashtags";
   public static final String EDIT_TOPIC_NAME = "editTopicName";
   public static final String FORGE_AUTHOR = "forgeAuthor";
@@ -43,6 +43,7 @@
   public static final String FORGE_SERVER = "forgeServerAsCommitter";
   public static final String LABEL = "label-";
   public static final String LABEL_AS = "labelAs-";
+  public static final String REMOVE_LABEL = "removeLabel-";
   public static final String OWNER = "owner";
   public static final String PUSH = "push";
   public static final String PUSH_MERGE = "pushMerge";
@@ -60,48 +61,53 @@
   private static final List<String> NAMES_LC;
   private static final int LABEL_INDEX;
   private static final int LABEL_AS_INDEX;
+  private static final int REMOVE_LABEL_INDEX;
 
   static {
     NAMES_LC = new ArrayList<>();
-    NAMES_LC.add(ABANDON.toLowerCase());
-    NAMES_LC.add(ADD_PATCH_SET.toLowerCase());
-    NAMES_LC.add(CREATE.toLowerCase());
-    NAMES_LC.add(CREATE_SIGNED_TAG.toLowerCase());
-    NAMES_LC.add(CREATE_TAG.toLowerCase());
-    NAMES_LC.add(DELETE.toLowerCase());
-    NAMES_LC.add(DELETE_CHANGES.toLowerCase());
-    NAMES_LC.add(DELETE_OWN_CHANGES.toLowerCase());
-    NAMES_LC.add(EDIT_ASSIGNEE.toLowerCase());
-    NAMES_LC.add(EDIT_HASHTAGS.toLowerCase());
-    NAMES_LC.add(EDIT_TOPIC_NAME.toLowerCase());
-    NAMES_LC.add(FORGE_AUTHOR.toLowerCase());
-    NAMES_LC.add(FORGE_COMMITTER.toLowerCase());
-    NAMES_LC.add(FORGE_SERVER.toLowerCase());
-    NAMES_LC.add(LABEL.toLowerCase());
-    NAMES_LC.add(LABEL_AS.toLowerCase());
-    NAMES_LC.add(OWNER.toLowerCase());
-    NAMES_LC.add(PUSH.toLowerCase());
-    NAMES_LC.add(PUSH_MERGE.toLowerCase());
-    NAMES_LC.add(READ.toLowerCase());
-    NAMES_LC.add(REBASE.toLowerCase());
-    NAMES_LC.add(REMOVE_REVIEWER.toLowerCase());
-    NAMES_LC.add(REVERT.toLowerCase());
-    NAMES_LC.add(SUBMIT.toLowerCase());
-    NAMES_LC.add(SUBMIT_AS.toLowerCase());
-    NAMES_LC.add(TOGGLE_WORK_IN_PROGRESS_STATE.toLowerCase());
-    NAMES_LC.add(VIEW_PRIVATE_CHANGES.toLowerCase());
+    NAMES_LC.add(ABANDON.toLowerCase(Locale.US));
+    NAMES_LC.add(ADD_PATCH_SET.toLowerCase(Locale.US));
+    NAMES_LC.add(CREATE.toLowerCase(Locale.US));
+    NAMES_LC.add(CREATE_SIGNED_TAG.toLowerCase(Locale.US));
+    NAMES_LC.add(CREATE_TAG.toLowerCase(Locale.US));
+    NAMES_LC.add(DELETE.toLowerCase(Locale.US));
+    NAMES_LC.add(DELETE_CHANGES.toLowerCase(Locale.US));
+    NAMES_LC.add(DELETE_OWN_CHANGES.toLowerCase(Locale.US));
+    NAMES_LC.add(EDIT_HASHTAGS.toLowerCase(Locale.US));
+    NAMES_LC.add(EDIT_TOPIC_NAME.toLowerCase(Locale.US));
+    NAMES_LC.add(FORGE_AUTHOR.toLowerCase(Locale.US));
+    NAMES_LC.add(FORGE_COMMITTER.toLowerCase(Locale.US));
+    NAMES_LC.add(FORGE_SERVER.toLowerCase(Locale.US));
+    NAMES_LC.add(LABEL.toLowerCase(Locale.US));
+    NAMES_LC.add(LABEL_AS.toLowerCase(Locale.US));
+    NAMES_LC.add(REMOVE_LABEL.toLowerCase(Locale.US));
+    NAMES_LC.add(OWNER.toLowerCase(Locale.US));
+    NAMES_LC.add(PUSH.toLowerCase(Locale.US));
+    NAMES_LC.add(PUSH_MERGE.toLowerCase(Locale.US));
+    NAMES_LC.add(READ.toLowerCase(Locale.US));
+    NAMES_LC.add(REBASE.toLowerCase(Locale.US));
+    NAMES_LC.add(REMOVE_REVIEWER.toLowerCase(Locale.US));
+    NAMES_LC.add(REVERT.toLowerCase(Locale.US));
+    NAMES_LC.add(SUBMIT.toLowerCase(Locale.US));
+    NAMES_LC.add(SUBMIT_AS.toLowerCase(Locale.US));
+    NAMES_LC.add(TOGGLE_WORK_IN_PROGRESS_STATE.toLowerCase(Locale.US));
+    NAMES_LC.add(VIEW_PRIVATE_CHANGES.toLowerCase(Locale.US));
 
     LABEL_INDEX = NAMES_LC.indexOf(Permission.LABEL);
-    LABEL_AS_INDEX = NAMES_LC.indexOf(Permission.LABEL_AS.toLowerCase());
+    LABEL_AS_INDEX = NAMES_LC.indexOf(Permission.LABEL_AS.toLowerCase(Locale.US));
+    REMOVE_LABEL_INDEX = NAMES_LC.indexOf(Permission.REMOVE_LABEL.toLowerCase(Locale.US));
   }
 
   /** Returns true if the name is recognized as a permission name. */
   public static boolean isPermission(String varName) {
-    return isLabel(varName) || isLabelAs(varName) || NAMES_LC.contains(varName.toLowerCase());
+    return isLabel(varName)
+        || isLabelAs(varName)
+        || isRemoveLabel(varName)
+        || NAMES_LC.contains(varName.toLowerCase(Locale.US));
   }
 
   public static boolean hasRange(String varName) {
-    return isLabel(varName) || isLabelAs(varName);
+    return isLabel(varName) || isLabelAs(varName) || isRemoveLabel(varName);
   }
 
   /** Returns true if the permission name is actually for a review label. */
@@ -114,6 +120,11 @@
     return var.startsWith(LABEL_AS) && LABEL_AS.length() < var.length();
   }
 
+  /** Returns true if the permission is for impersonated review labels. */
+  public static boolean isRemoveLabel(String var) {
+    return var.startsWith(REMOVE_LABEL) && REMOVE_LABEL.length() < var.length();
+  }
+
   /** Returns permission name for the given review label. */
   public static String forLabel(String labelName) {
     return LABEL + labelName;
@@ -124,11 +135,19 @@
     return LABEL_AS + labelName;
   }
 
+  /** Returns permission name to remove a label for another user. */
+  public static String forRemoveLabel(String labelName) {
+    return REMOVE_LABEL + labelName;
+  }
+
+  @Nullable
   public static String extractLabel(String varName) {
     if (isLabel(varName)) {
       return varName.substring(LABEL.length());
     } else if (isLabelAs(varName)) {
       return varName.substring(LABEL_AS.length());
+    } else if (isRemoveLabel(varName)) {
+      return varName.substring(REMOVE_LABEL.length());
     }
     return null;
   }
@@ -204,9 +223,11 @@
       return LABEL_INDEX;
     } else if (isLabelAs(a.getName())) {
       return LABEL_AS_INDEX;
+    } else if (isRemoveLabel(a.getName())) {
+      return REMOVE_LABEL_INDEX;
     }
 
-    int index = NAMES_LC.indexOf(a.getName().toLowerCase());
+    int index = NAMES_LC.indexOf(a.getName().toLowerCase(Locale.US));
     return 0 <= index ? index : NAMES_LC.size();
   }
 
@@ -277,7 +298,10 @@
 
     public Permission build() {
       setRules(
-          rulesBuilders.stream().map(PermissionRule.Builder::build).collect(toImmutableList()));
+          rulesBuilders.stream()
+              .map(PermissionRule.Builder::build)
+              .distinct()
+              .collect(toImmutableList()));
       return autoBuild();
     }
 
diff --git a/java/com/google/gerrit/entities/PermissionRule.java b/java/com/google/gerrit/entities/PermissionRule.java
index 9a2d31e..1665c1c 100644
--- a/java/com/google/gerrit/entities/PermissionRule.java
+++ b/java/com/google/gerrit/entities/PermissionRule.java
@@ -202,6 +202,9 @@
         int dotdot = range.indexOf("..");
         int min = parseInt(range.substring(0, dotdot));
         int max = parseInt(range.substring(dotdot + 2));
+        if (min > max) {
+          throw new IllegalArgumentException("Invalid range in rule: " + orig);
+        }
         rule.setRange(min, max);
       } else {
         throw new IllegalArgumentException("Invalid range in rule: " + orig);
diff --git a/java/com/google/gerrit/entities/Project.java b/java/com/google/gerrit/entities/Project.java
index 617b827..72ca6a9 100644
--- a/java/com/google/gerrit/entities/Project.java
+++ b/java/com/google/gerrit/entities/Project.java
@@ -61,7 +61,7 @@
 
     /** Parse a Project.NameKey out of a string representation. */
     public static NameKey parse(String str) {
-      return nameKey(KeyUtil.decode(str));
+      return nameKey(ProjectUtil.sanitizeProjectName(KeyUtil.decode(str)));
     }
 
     private final String name;
@@ -166,6 +166,7 @@
    * @return name key of the parent project, {@code null} if this project is the All-Projects
    *     project
    */
+  @Nullable
   public Project.NameKey getParent(Project.NameKey allProjectsName) {
     if (getParent() != null) {
       return getParent();
@@ -178,6 +179,7 @@
     return allProjectsName;
   }
 
+  @Nullable
   public String getParentName() {
     return getParent() != null ? getParent().get() : null;
   }
diff --git a/java/com/google/gerrit/entities/ProjectUtil.java b/java/com/google/gerrit/entities/ProjectUtil.java
new file mode 100644
index 0000000..98ce67a
--- /dev/null
+++ b/java/com/google/gerrit/entities/ProjectUtil.java
@@ -0,0 +1,41 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.entities;
+
+public class ProjectUtil {
+  public static String sanitizeProjectName(String name) {
+    name = stripGitSuffix(name);
+    name = stripTrailingSlash(name);
+    return name;
+  }
+
+  public static String stripGitSuffix(String name) {
+    if (name.endsWith(".git")) {
+      // Be nice and drop the trailing ".git" suffix, which we never keep
+      // in our database, but clients might mistakenly provide anyway.
+      //
+      name = name.substring(0, name.length() - 4);
+      name = stripTrailingSlash(name);
+    }
+    return name;
+  }
+
+  private static String stripTrailingSlash(String name) {
+    while (name.endsWith("/")) {
+      name = name.substring(0, name.length() - 1);
+    }
+    return name;
+  }
+}
diff --git a/java/com/google/gerrit/entities/RefNames.java b/java/com/google/gerrit/entities/RefNames.java
index b9c1b3c..e79c530 100644
--- a/java/com/google/gerrit/entities/RefNames.java
+++ b/java/com/google/gerrit/entities/RefNames.java
@@ -16,6 +16,7 @@
 
 import com.google.common.base.Splitter;
 import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.UsedAt;
 import java.util.List;
 
@@ -184,6 +185,21 @@
     return ref.startsWith(REFS_CHANGES);
   }
 
+  /** True if the provided ref is in {@code refs/sequences/*}. */
+  public static boolean isSequenceRef(String ref) {
+    return ref.startsWith(REFS_SEQUENCES);
+  }
+
+  /** True if the provided ref is in {@code refs/tags/*}. */
+  public static boolean isTagRef(String ref) {
+    return ref.startsWith(REFS_TAGS);
+  }
+
+  /** True if the provided ref is {@link #REFS_EXTERNAL_IDS}. */
+  public static boolean isExternalIdRef(String ref) {
+    return REFS_EXTERNAL_IDS.equals(ref);
+  }
+
   public static String refsGroups(AccountGroup.UUID groupUuid) {
     return REFS_GROUPS + shardUuid(groupUuid.get());
   }
@@ -222,6 +238,7 @@
     return REFS_CACHE_AUTOMERGE + hash.substring(0, 2) + '/' + hash.substring(2);
   }
 
+  @Nullable
   public static String shard(int id) {
     if (id < 0) {
       return null;
@@ -328,6 +345,21 @@
     return REFS_CONFIG.equals(ref);
   }
 
+  /** Whether the ref is the version branch, i.e. {@code refs/meta/version}. */
+  public static boolean isVersionRef(String ref) {
+    return REFS_VERSION.equals(ref);
+  }
+
+  /** Whether the ref is an auto-merge ref. */
+  public static boolean isAutoMergeRef(String ref) {
+    return ref.startsWith(REFS_CACHE_AUTOMERGE);
+  }
+
+  /** Whether the ref is an reject commit ref, i.e. {@code refs/meta/reject-commits} */
+  public static boolean isRejectCommitsRef(String ref) {
+    return REFS_REJECT_COMMITS.equals(ref);
+  }
+
   /**
    * Whether the ref is managed by Gerrit. Covers all Gerrit-internal refs like refs/cache-automerge
    * and refs/meta as well as refs/changes. Does not cover user-created refs like branches or custom
@@ -343,6 +375,7 @@
     return GERRIT_REFS.stream().anyMatch(internalRef -> ref.startsWith(internalRef));
   }
 
+  @Nullable
   static Integer parseShardedRefPart(String name) {
     if (name == null) {
       return null;
@@ -386,6 +419,7 @@
   }
 
   @UsedAt(UsedAt.Project.PLUGINS_ALL)
+  @Nullable
   public static String parseShardedUuidFromRefPart(String name) {
     if (name == null) {
       return null;
@@ -420,6 +454,7 @@
    * @return the rest of the name, {@code null} if the ref name part doesn't start with a valid
    *     sharded ID
    */
+  @Nullable
   static String skipShardedRefPart(String name) {
     if (name == null) {
       return null;
@@ -473,6 +508,7 @@
    *     ref name part doesn't start with a valid sharded ID or if no valid ID follows the sharded
    *     ref part
    */
+  @Nullable
   static Integer parseAfterShardedRefPart(String name) {
     String rest = skipShardedRefPart(name);
     if (rest == null || !rest.startsWith("/")) {
@@ -493,6 +529,7 @@
     return Integer.parseInt(rest.substring(0, ie));
   }
 
+  @Nullable
   public static Integer parseRefSuffix(String name) {
     if (name == null) {
       return null;
diff --git a/java/com/google/gerrit/entities/StoredCommentLinkInfo.java b/java/com/google/gerrit/entities/StoredCommentLinkInfo.java
index 75bc034..5ee76da 100644
--- a/java/com/google/gerrit/entities/StoredCommentLinkInfo.java
+++ b/java/com/google/gerrit/entities/StoredCommentLinkInfo.java
@@ -31,7 +31,7 @@
   public abstract String getMatch();
 
   /**
-   * The link to replace the match with. This can only be set if html is {@code null}.
+   * The link to replace the match with.
    *
    * <p>The constructed link is using {@link #getLink()} {@link #getPrefix()} {@link #getSuffix()}
    * and {@link #getText()}, and has the shape of
@@ -41,31 +41,18 @@
   @Nullable
   public abstract String getLink();
 
-  /**
-   * The text before the link tag that the match is replaced with. This can only be set if link is
-   * not {@code null}.
-   */
+  /** The optional text before the link tag that the match is replaced with. */
   @Nullable
   public abstract String getPrefix();
 
-  /**
-   * The text after the link tag that the match is replaced with. This can only be set if link is
-   * not {@code null}.
-   */
+  /** The optional text after the link tag that the match is replaced with. */
   @Nullable
   public abstract String getSuffix();
 
-  /**
-   * The content of the link tag that the match is replaced with. This can only be set if link is
-   * not {@code null}.
-   */
+  /** The content of the link tag that the match is replaced with. If not set full match is used. */
   @Nullable
   public abstract String getText();
 
-  /** The html to replace the match with. This can only be set if link is {@code null}. */
-  @Nullable
-  public abstract String getHtml();
-
   /** Weather this comment link is active. {@code null} means true. */
   @Nullable
   public abstract Boolean getEnabled();
@@ -103,7 +90,6 @@
         .setPrefix(src.prefix)
         .setSuffix(src.suffix)
         .setText(src.text)
-        .setHtml(src.html)
         .setEnabled(enabled)
         .setOverrideOnly(false)
         .build();
@@ -118,7 +104,6 @@
     info.prefix = getPrefix();
     info.suffix = getSuffix();
     info.text = getText();
-    info.html = getHtml();
     info.enabled = getEnabled();
     return info;
   }
@@ -137,25 +122,21 @@
 
     public abstract Builder setText(@Nullable String value);
 
-    public abstract Builder setHtml(@Nullable String value);
-
     public abstract Builder setEnabled(@Nullable Boolean value);
 
     public abstract Builder setOverrideOnly(boolean value);
 
     public StoredCommentLinkInfo build() {
       checkArgument(getName() != null, "invalid commentlink.name");
-      setLink(Strings.emptyToNull(getLink()));
       setPrefix(Strings.emptyToNull(getPrefix()));
       setSuffix(Strings.emptyToNull(getSuffix()));
       setText(Strings.emptyToNull(getText()));
-      setHtml(Strings.emptyToNull(getHtml()));
       if (!getOverrideOnly()) {
         checkArgument(
             !Strings.isNullOrEmpty(getMatch()), "invalid commentlink.%s.match", getName());
         checkArgument(
-            (getLink() != null && getHtml() == null) || (getLink() == null && getHtml() != null),
-            "commentlink.%s must have either link or html",
+            !Strings.isNullOrEmpty(getLink()),
+            "commentlink.%s must have link specified",
             getName());
       }
       return autoBuild();
@@ -175,8 +156,6 @@
 
     protected abstract String getText();
 
-    protected abstract String getHtml();
-
     protected abstract boolean getOverrideOnly();
   }
 }
diff --git a/java/com/google/gerrit/entities/SubmitRequirementExpressionResult.java b/java/com/google/gerrit/entities/SubmitRequirementExpressionResult.java
index c24227d..fbb2fd7 100644
--- a/java/com/google/gerrit/entities/SubmitRequirementExpressionResult.java
+++ b/java/com/google/gerrit/entities/SubmitRequirementExpressionResult.java
@@ -197,8 +197,6 @@
 
     @AutoValue.Builder
     public abstract static class Builder {
-      public abstract Builder childPredicateResults(ImmutableList<PredicateResult> value);
-
       protected abstract ImmutableList.Builder<PredicateResult> childPredicateResultsBuilder();
 
       public abstract Builder predicateString(String value);
diff --git a/java/com/google/gerrit/entities/converter/ChangeProtoConverter.java b/java/com/google/gerrit/entities/converter/ChangeProtoConverter.java
index 4903364..3b772d0 100644
--- a/java/com/google/gerrit/entities/converter/ChangeProtoConverter.java
+++ b/java/com/google/gerrit/entities/converter/ChangeProtoConverter.java
@@ -71,10 +71,6 @@
     if (submissionId != null) {
       builder.setSubmissionId(submissionId);
     }
-    Account.Id assignee = change.getAssignee();
-    if (assignee != null) {
-      builder.setAssignee(accountIdConverter.toProto(assignee));
-    }
     Change.Id revertOf = change.getRevertOf();
     if (revertOf != null) {
       builder.setRevertOf(changeIdConverter.toProto(revertOf));
@@ -114,9 +110,6 @@
     if (proto.hasSubmissionId()) {
       change.setSubmissionId(proto.getSubmissionId());
     }
-    if (proto.hasAssignee()) {
-      change.setAssignee(accountIdConverter.fromProto(proto.getAssignee()));
-    }
     change.setPrivate(proto.getIsPrivate());
     change.setWorkInProgress(proto.getWorkInProgress());
     change.setReviewStarted(proto.getReviewStarted());
diff --git a/java/com/google/gerrit/entities/converter/PatchSetProtoConverter.java b/java/com/google/gerrit/entities/converter/PatchSetProtoConverter.java
index 210972d..b32f09a 100644
--- a/java/com/google/gerrit/entities/converter/PatchSetProtoConverter.java
+++ b/java/com/google/gerrit/entities/converter/PatchSetProtoConverter.java
@@ -42,6 +42,7 @@
             .setId(patchSetIdConverter.toProto(patchSet.id()))
             .setCommitId(objectIdConverter.toProto(patchSet.commitId()))
             .setUploaderAccountId(accountIdConverter.toProto(patchSet.uploader()))
+            .setRealUploaderAccountId(accountIdConverter.toProto(patchSet.realUploader()))
             .setCreatedOn(patchSet.createdOn().toEpochMilli());
     List<String> groups = patchSet.groups();
     if (!groups.isEmpty()) {
@@ -75,15 +76,20 @@
     // Callers that encounter one of these sentinels will likely fail, for example by failing to
     // look up the zeroId. They would have also failed back when the fields were nullable, for
     // example with NPE; the current behavior just fails slightly differently.
+    Account.Id uploader =
+        proto.hasUploaderAccountId()
+            ? accountIdConverter.fromProto(proto.getUploaderAccountId())
+            : Account.id(0);
     builder
         .commitId(
             proto.hasCommitId()
                 ? objectIdConverter.fromProto(proto.getCommitId())
                 : ObjectId.zeroId())
-        .uploader(
-            proto.hasUploaderAccountId()
-                ? accountIdConverter.fromProto(proto.getUploaderAccountId())
-                : Account.id(0))
+        .uploader(uploader)
+        .realUploader(
+            proto.hasRealUploaderAccountId()
+                ? accountIdConverter.fromProto(proto.getRealUploaderAccountId())
+                : uploader)
         .createdOn(
             proto.hasCreatedOn() ? Instant.ofEpochMilli(proto.getCreatedOn()) : Instant.EPOCH);
 
diff --git a/java/com/google/gerrit/extensions/api/changes/AssigneeInput.java b/java/com/google/gerrit/extensions/api/changes/ApplyPatchInput.java
similarity index 66%
rename from java/com/google/gerrit/extensions/api/changes/AssigneeInput.java
rename to java/com/google/gerrit/extensions/api/changes/ApplyPatchInput.java
index e17e1c9..493329c 100644
--- a/java/com/google/gerrit/extensions/api/changes/AssigneeInput.java
+++ b/java/com/google/gerrit/extensions/api/changes/ApplyPatchInput.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2016 The Android Open Source Project
+// Copyright (C) 2022 The Android Open Source Project
 //
 // Licensed under the Apache License, Version 2.0 (the "License");
 // you may not use this file except in compliance with the License.
@@ -14,8 +14,12 @@
 
 package com.google.gerrit.extensions.api.changes;
 
-import com.google.gerrit.extensions.restapi.DefaultInput;
-
-public class AssigneeInput {
-  @DefaultInput public String assignee;
+/** Information about a patch to apply. */
+public class ApplyPatchInput {
+  /**
+   * Required. The patch to be applied.
+   *
+   * <p>Must be compatible with `git diff` output. For example, Gerrit API `Get Patch` output.
+   */
+  public String patch;
 }
diff --git a/java/com/google/gerrit/extensions/api/changes/ApplyPatchPatchSetInput.java b/java/com/google/gerrit/extensions/api/changes/ApplyPatchPatchSetInput.java
new file mode 100644
index 0000000..cf114df
--- /dev/null
+++ b/java/com/google/gerrit/extensions/api/changes/ApplyPatchPatchSetInput.java
@@ -0,0 +1,47 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.api.changes;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.api.accounts.AccountInput;
+import com.google.gerrit.extensions.client.ListChangesOption;
+import java.util.List;
+
+/** Information for creating a new patch set from a given patch. */
+public class ApplyPatchPatchSetInput {
+
+  /** The patch to be applied. */
+  public ApplyPatchInput patch;
+
+  /**
+   * The commit message for the new patch set. If not specified, a predefined message will be used.
+   */
+  @Nullable public String commitMessage;
+
+  /**
+   * 40-hex digit SHA-1 of the commit which will be the parent commit of the newly created patch
+   * set. If set, it must be a merged commit or a change revision on the destination branch.
+   * Otherwise, the target change's branch tip will be used.
+   */
+  @Nullable public String base;
+
+  /**
+   * The author of the new patch set. Must include both {@link AccountInput#name} and {@link
+   * AccountInput#email} fields.
+   */
+  @Nullable public AccountInput author;
+
+  @Nullable public List<ListChangesOption> responseFormatOptions;
+}
diff --git a/java/com/google/gerrit/extensions/api/changes/ChangeApi.java b/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
index 018a6cf..ef61b68 100644
--- a/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
+++ b/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
@@ -27,12 +27,14 @@
 import com.google.gerrit.extensions.common.CommitMessageInput;
 import com.google.gerrit.extensions.common.MergePatchSetInput;
 import com.google.gerrit.extensions.common.PureRevertInfo;
+import com.google.gerrit.extensions.common.RebaseChainInfo;
 import com.google.gerrit.extensions.common.RevertSubmissionInfo;
 import com.google.gerrit.extensions.common.RobotCommentInfo;
 import com.google.gerrit.extensions.common.SubmitRequirementInput;
 import com.google.gerrit.extensions.common.SubmitRequirementResultInfo;
 import com.google.gerrit.extensions.common.SuggestedReviewerInfo;
 import com.google.gerrit.extensions.restapi.NotImplementedException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import java.util.Arrays;
 import java.util.Collection;
@@ -152,6 +154,8 @@
   /** Create a merge patch set for the change. */
   ChangeInfo createMergePatchSet(MergePatchSetInput in) throws RestApiException;
 
+  ChangeInfo applyPatch(ApplyPatchPatchSetInput in) throws RestApiException;
+
   default List<ChangeInfo> submittedTogether() throws RestApiException {
     SubmittedTogetherInfo info =
         submittedTogether(
@@ -176,6 +180,24 @@
   /** Rebase the current revision of a change. */
   void rebase(RebaseInput in) throws RestApiException;
 
+  /**
+   * Rebase the current revisions of a change's chain using default options.
+   *
+   * @return a {@code RebaseChainInfo} contains the {@code ChangeInfo} data for the rebased the
+   *     chain
+   */
+  default Response<RebaseChainInfo> rebaseChain() throws RestApiException {
+    return rebaseChain(new RebaseInput());
+  }
+
+  /**
+   * Rebase the current revisions of a change's chain.
+   *
+   * @return a {@code RebaseChainInfo} contains the {@code ChangeInfo} data for the rebased the
+   *     chain
+   */
+  Response<RebaseChainInfo> rebaseChain(RebaseInput in) throws RestApiException;
+
   /** Deletes a change. */
   void delete() throws RestApiException;
 
@@ -325,22 +347,6 @@
   /** Adds a user to the attention set. */
   AccountInfo addToAttentionSet(AttentionSetInput input) throws RestApiException;
 
-  /** Set the assignee of a change. */
-  AccountInfo setAssignee(AssigneeInput input) throws RestApiException;
-
-  /** Get the assignee of a change. */
-  AccountInfo getAssignee() throws RestApiException;
-
-  /** Get all past assignees. */
-  List<AccountInfo> getPastAssignees() throws RestApiException;
-
-  /**
-   * Delete the assignee of a change.
-   *
-   * @return the assignee that was deleted, or null if there was no assignee.
-   */
-  AccountInfo deleteAssignee() throws RestApiException;
-
   /**
    * Get all published comments on a change.
    *
@@ -632,6 +638,11 @@
     }
 
     @Override
+    public Response<RebaseChainInfo> rebaseChain(RebaseInput in) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public void delete() throws RestApiException {
       throw new NotImplementedException();
     }
@@ -719,26 +730,6 @@
     }
 
     @Override
-    public AccountInfo setAssignee(AssigneeInput input) throws RestApiException {
-      throw new NotImplementedException();
-    }
-
-    @Override
-    public AccountInfo getAssignee() throws RestApiException {
-      throw new NotImplementedException();
-    }
-
-    @Override
-    public List<AccountInfo> getPastAssignees() throws RestApiException {
-      throw new NotImplementedException();
-    }
-
-    @Override
-    public AccountInfo deleteAssignee() throws RestApiException {
-      throw new NotImplementedException();
-    }
-
-    @Override
     @Deprecated
     public Map<String, List<CommentInfo>> comments() throws RestApiException {
       throw new NotImplementedException();
@@ -824,6 +815,11 @@
     }
 
     @Override
+    public ChangeInfo applyPatch(ApplyPatchPatchSetInput in) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public PureRevertInfo pureRevert() throws RestApiException {
       throw new NotImplementedException();
     }
diff --git a/java/com/google/gerrit/extensions/api/changes/FileContentInput.java b/java/com/google/gerrit/extensions/api/changes/FileContentInput.java
index 0cfe908..6349595 100644
--- a/java/com/google/gerrit/extensions/api/changes/FileContentInput.java
+++ b/java/com/google/gerrit/extensions/api/changes/FileContentInput.java
@@ -21,4 +21,5 @@
 public class FileContentInput {
   @DefaultInput public RawInput content;
   public String binary_content;
+  public Integer fileMode;
 }
diff --git a/java/com/google/gerrit/extensions/api/changes/RebaseInput.java b/java/com/google/gerrit/extensions/api/changes/RebaseInput.java
index e9b05cc..a85bc73 100644
--- a/java/com/google/gerrit/extensions/api/changes/RebaseInput.java
+++ b/java/com/google/gerrit/extensions/api/changes/RebaseInput.java
@@ -27,5 +27,21 @@
    */
   public boolean allowConflicts;
 
+  /**
+   * Whether the rebase should be done on behalf of the uploader.
+   *
+   * <p>This means the uploader of the current patch set will also be the uploader of the rebased
+   * patch set. The calling user will be recorded as the real user.
+   *
+   * <p>Rebasing on behalf of the uploader is only supported for trivial rebases. This means this
+   * option cannot be combined with the {@link #allowConflicts} option.
+   *
+   * <p>In addition, rebasing on behalf of the uploader is only supported for the current patch set
+   * of a change and not when rebasing a chain.
+   *
+   * <p>Using this option is not supported when rebasing a chain via the Rebase Chain REST endpoint.
+   */
+  public boolean onBehalfOfUploader;
+
   public Map<String, String> validationOptions;
 }
diff --git a/java/com/google/gerrit/extensions/api/changes/ReviewInput.java b/java/com/google/gerrit/extensions/api/changes/ReviewInput.java
index 11999ab..8bfe468 100644
--- a/java/com/google/gerrit/extensions/api/changes/ReviewInput.java
+++ b/java/com/google/gerrit/extensions/api/changes/ReviewInput.java
@@ -14,8 +14,10 @@
 
 package com.google.gerrit.extensions.api.changes;
 
+import static com.google.gerrit.extensions.client.ReviewerState.CC;
 import static com.google.gerrit.extensions.client.ReviewerState.REVIEWER;
 
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.extensions.client.Comment;
 import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.common.FixSuggestionInfo;
@@ -117,11 +119,13 @@
     public List<FixSuggestionInfo> fixSuggestions;
   }
 
+  @CanIgnoreReturnValue
   public ReviewInput message(String msg) {
     message = msg != null && !msg.isEmpty() ? msg : null;
     return this;
   }
 
+  @CanIgnoreReturnValue
   public ReviewInput patchSetLevelComment(String message) {
     Objects.requireNonNull(message);
     CommentInput comment = new CommentInput();
@@ -131,6 +135,7 @@
     return this;
   }
 
+  @CanIgnoreReturnValue
   public ReviewInput label(String name, short value) {
     if (name == null || name.isEmpty()) {
       throw new IllegalArgumentException();
@@ -142,6 +147,7 @@
     return this;
   }
 
+  @CanIgnoreReturnValue
   public ReviewInput label(String name, int value) {
     if (value < Short.MIN_VALUE || value > Short.MAX_VALUE) {
       throw new IllegalArgumentException();
@@ -149,14 +155,22 @@
     return label(name, (short) value);
   }
 
+  @CanIgnoreReturnValue
   public ReviewInput label(String name) {
     return label(name, (short) 1);
   }
 
+  @CanIgnoreReturnValue
   public ReviewInput reviewer(String reviewer) {
-    return reviewer(reviewer, REVIEWER, false);
+    return reviewer(reviewer, REVIEWER, /* confirmed= */ false);
   }
 
+  @CanIgnoreReturnValue
+  public ReviewInput cc(String cc) {
+    return reviewer(cc, CC, /* confirmed= */ false);
+  }
+
+  @CanIgnoreReturnValue
   public ReviewInput reviewer(String reviewer, ReviewerState state, boolean confirmed) {
     ReviewerInput input = new ReviewerInput();
     input.reviewer = reviewer;
@@ -169,6 +183,7 @@
     return this;
   }
 
+  @CanIgnoreReturnValue
   public ReviewInput addUserToAttentionSet(String user, String reason) {
     AttentionSetInput input = new AttentionSetInput();
     input.user = user;
@@ -180,6 +195,7 @@
     return this;
   }
 
+  @CanIgnoreReturnValue
   public ReviewInput removeUserFromAttentionSet(String user, String reason) {
     AttentionSetInput input = new AttentionSetInput();
     input.user = user;
@@ -191,17 +207,20 @@
     return this;
   }
 
+  @CanIgnoreReturnValue
   public ReviewInput blockAutomaticAttentionSetRules() {
     ignoreAutomaticAttentionSetRules = true;
     return this;
   }
 
+  @CanIgnoreReturnValue
   public ReviewInput setWorkInProgress(boolean workInProgress) {
     this.workInProgress = workInProgress;
     ready = !workInProgress;
     return this;
   }
 
+  @CanIgnoreReturnValue
   public ReviewInput setReady(boolean ready) {
     this.ready = ready;
     workInProgress = !ready;
diff --git a/java/com/google/gerrit/extensions/api/projects/CommentLinkInfo.java b/java/com/google/gerrit/extensions/api/projects/CommentLinkInfo.java
index b45fcee..5c47ac3 100644
--- a/java/com/google/gerrit/extensions/api/projects/CommentLinkInfo.java
+++ b/java/com/google/gerrit/extensions/api/projects/CommentLinkInfo.java
@@ -24,7 +24,6 @@
   public String prefix;
   public String suffix;
   public String text;
-  public String html;
   public Boolean enabled; // null means true
 
   public transient String name;
@@ -41,7 +40,6 @@
           && Objects.equals(this.prefix, that.prefix)
           && Objects.equals(this.suffix, that.suffix)
           && Objects.equals(this.text, that.text)
-          && Objects.equals(this.html, that.html)
           && Objects.equals(this.enabled, that.enabled);
     }
     return false;
@@ -49,7 +47,7 @@
 
   @Override
   public int hashCode() {
-    return Objects.hash(match, link, html, enabled);
+    return Objects.hash(match, link, prefix, suffix, text, enabled);
   }
 
   @Override
@@ -61,7 +59,6 @@
         .add("prefix", prefix)
         .add("suffix", suffix)
         .add("text", text)
-        .add("html", html)
         .add("enabled", enabled)
         .toString();
   }
diff --git a/java/com/google/gerrit/extensions/api/projects/ConfigInfo.java b/java/com/google/gerrit/extensions/api/projects/ConfigInfo.java
index 3ba1277..1a51c15 100644
--- a/java/com/google/gerrit/extensions/api/projects/ConfigInfo.java
+++ b/java/com/google/gerrit/extensions/api/projects/ConfigInfo.java
@@ -40,6 +40,7 @@
   public InheritedBooleanInfo enableReviewerByEmail;
   public InheritedBooleanInfo matchAuthorToCommitterDate;
   public InheritedBooleanInfo rejectEmptyCommit;
+  public InheritedBooleanInfo skipAddingAuthorAndCommitterAsReviewers;
 
   public MaxObjectSizeLimitInfo maxObjectSizeLimit;
   @Deprecated // Equivalent to defaultSubmitType.value
diff --git a/java/com/google/gerrit/extensions/api/projects/ConfigInput.java b/java/com/google/gerrit/extensions/api/projects/ConfigInput.java
index 8005fc5..906fc4c 100644
--- a/java/com/google/gerrit/extensions/api/projects/ConfigInput.java
+++ b/java/com/google/gerrit/extensions/api/projects/ConfigInput.java
@@ -34,6 +34,7 @@
   public InheritableBoolean enableReviewerByEmail;
   public InheritableBoolean matchAuthorToCommitterDate;
   public InheritableBoolean rejectEmptyCommit;
+  public InheritableBoolean skipAddingAuthorAndCommitterAsReviewers;
   public String maxObjectSizeLimit;
   public SubmitType submitType;
   public ProjectState state;
diff --git a/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java b/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
index 1ee2cd8..020351b 100644
--- a/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
+++ b/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
@@ -134,7 +134,6 @@
   public DateFormat dateFormat;
   public TimeFormat timeFormat;
   public Boolean expandInlineDiffs;
-  public Boolean highlightAssigneeInChangeTable;
   public Boolean relativeDateInChangeTable;
   public DiffView diffView;
   public Boolean sizeBarInChangeTable;
@@ -195,7 +194,6 @@
     p.dateFormat = DateFormat.STD;
     p.timeFormat = TimeFormat.HHMM_12;
     p.expandInlineDiffs = false;
-    p.highlightAssigneeInChangeTable = true;
     p.relativeDateInChangeTable = false;
     p.diffView = DiffView.SIDE_BY_SIDE;
     p.sizeBarInChangeTable = true;
diff --git a/java/com/google/gerrit/extensions/client/GerritTopMenu.java b/java/com/google/gerrit/extensions/client/GerritTopMenu.java
index b7e1a5a..b9a395e 100644
--- a/java/com/google/gerrit/extensions/client/GerritTopMenu.java
+++ b/java/com/google/gerrit/extensions/client/GerritTopMenu.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.extensions.client;
 
+import java.util.Locale;
+
 public enum GerritTopMenu {
   ALL,
   MY,
@@ -25,6 +27,6 @@
   public final String menuName;
 
   GerritTopMenu() {
-    menuName = name().substring(0, 1) + name().substring(1).toLowerCase();
+    menuName = name().substring(0, 1) + name().substring(1).toLowerCase(Locale.US);
   }
 }
diff --git a/java/com/google/gerrit/extensions/client/Side.java b/java/com/google/gerrit/extensions/client/Side.java
index e077df2..a87b37a 100644
--- a/java/com/google/gerrit/extensions/client/Side.java
+++ b/java/com/google/gerrit/extensions/client/Side.java
@@ -14,10 +14,13 @@
 
 package com.google.gerrit.extensions.client;
 
+import com.google.gerrit.common.Nullable;
+
 public enum Side {
   PARENT,
   REVISION;
 
+  @Nullable
   public static Side fromShort(short s) {
     if (s <= 0) {
       return PARENT;
diff --git a/java/com/google/gerrit/extensions/common/ActionInfo.java b/java/com/google/gerrit/extensions/common/ActionInfo.java
index 2144ed5..f148444 100644
--- a/java/com/google/gerrit/extensions/common/ActionInfo.java
+++ b/java/com/google/gerrit/extensions/common/ActionInfo.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.extensions.common;
 
 import com.google.gerrit.extensions.webui.UiAction;
+import java.util.List;
 import java.util.Objects;
 
 /**
@@ -50,11 +51,30 @@
    */
   public Boolean enabled;
 
+  /**
+   * Optional list of enabled options.
+   *
+   * <p>For the {@code rebase} REST view the following options are supported:
+   *
+   * <ul>
+   *   <li>{@code rebase}: Present if the user can rebase the change. This is the case for the
+   *       change owner and users with the {@code Submit} or {@code Rebase} permission if they have
+   *       the {@code Push} permission.
+   *   <li>{@code rebase_on_behalf_of}: Present if the user can rebase the change on behalf of the
+   *       uploader. This is the case for the change owner and users with the {@code Submit} or
+   *       {@code Rebase} permission.
+   * </ul>
+   *
+   * <p>For all other REST views no options are returned.
+   */
+  public List<String> enabledOptions;
+
   public ActionInfo(UiAction.Description d) {
     method = d.getMethod();
     label = d.getLabel();
     title = d.getTitle();
     enabled = d.isEnabled() ? true : null;
+    enabledOptions = d.getEnabledOptions();
   }
 
   @Override
@@ -64,14 +84,15 @@
       return Objects.equals(method, actionInfo.method)
           && Objects.equals(label, actionInfo.label)
           && Objects.equals(title, actionInfo.title)
-          && Objects.equals(enabled, actionInfo.enabled);
+          && Objects.equals(enabled, actionInfo.enabled)
+          && Objects.equals(enabledOptions, actionInfo.enabledOptions);
     }
     return false;
   }
 
   @Override
   public int hashCode() {
-    return Objects.hash(method, label, title, enabled);
+    return Objects.hash(method, label, title, enabled, enabledOptions);
   }
 
   protected ActionInfo() {}
diff --git a/java/com/google/gerrit/extensions/common/ChangeConfigInfo.java b/java/com/google/gerrit/extensions/common/ChangeConfigInfo.java
index 3bcd150..80bf130 100644
--- a/java/com/google/gerrit/extensions/common/ChangeConfigInfo.java
+++ b/java/com/google/gerrit/extensions/common/ChangeConfigInfo.java
@@ -17,12 +17,10 @@
 /** API response containing values from the {@code change} section of {@code gerrit.config}. */
 public class ChangeConfigInfo {
   public Boolean allowBlame;
-  public Boolean showAssigneeInChangesTable;
   public Boolean disablePrivateChanges;
   public int updateDelay;
   public Boolean submitWholeTopic;
   public String mergeabilityComputationBehavior;
-  public Boolean enableAttentionSet;
-  public Boolean enableAssignee;
+  public Boolean enableRobotComments;
   public Boolean conflictsPredicateEnabled;
 }
diff --git a/java/com/google/gerrit/extensions/common/ChangeInfo.java b/java/com/google/gerrit/extensions/common/ChangeInfo.java
index 40ae2ec..dc9bc32 100644
--- a/java/com/google/gerrit/extensions/common/ChangeInfo.java
+++ b/java/com/google/gerrit/extensions/common/ChangeInfo.java
@@ -49,7 +49,6 @@
 
   public Map<Integer, AttentionSetInfo> removedFromAttentionSet;
 
-  public AccountInfo assignee;
   public Collection<String> hashtags;
   public String changeId;
   public String subject;
@@ -105,6 +104,7 @@
   public Map<String, ActionInfo> actions;
   public Map<String, LabelInfo> labels;
   public Map<String, Collection<String>> permittedLabels;
+  public Map<String, Map<String, List<AccountInfo>>> removableLabels;
   public Collection<AccountInfo> removableReviewers;
   public Map<ReviewerState, Collection<AccountInfo>> reviewers;
   public Map<ReviewerState, Collection<AccountInfo>> pendingReviewers;
@@ -174,4 +174,14 @@
     submitted = Timestamp.from(when);
     submitter = who;
   }
+
+  public RevisionInfo getCurrentRevision() {
+    RevisionInfo currentRevisionInfo = revisions.get(currentRevision);
+    if (currentRevisionInfo.commit != null) {
+      // If all revisions are requested the commit.commit field is not populated because the commit
+      // SHA1 is already present as the key in the revisions map.
+      currentRevisionInfo.commit.commit = currentRevision;
+    }
+    return currentRevisionInfo;
+  }
 }
diff --git a/java/com/google/gerrit/extensions/common/ChangeInfoDiffer.java b/java/com/google/gerrit/extensions/common/ChangeInfoDiffer.java
index ad112d3..51c35dc 100644
--- a/java/com/google/gerrit/extensions/common/ChangeInfoDiffer.java
+++ b/java/com/google/gerrit/extensions/common/ChangeInfoDiffer.java
@@ -21,6 +21,7 @@
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.common.Nullable;
 import java.lang.reflect.Constructor;
 import java.lang.reflect.Field;
 import java.sql.Timestamp;
@@ -147,16 +148,19 @@
   }
 
   /** Returns {@code null} if nothing has been added to {@code oldCollection} */
+  @Nullable
   private static ImmutableList<?> getAddedForCollection(
-      Collection<?> oldCollection, Collection<?> newCollection) {
-    ImmutableList<?> notInOldCollection = getAdditions(oldCollection, newCollection);
+      @Nullable Collection<?> oldCollection, Collection<?> newCollection) {
+    ImmutableList<?> notInOldCollection = getAdditionsForCollection(oldCollection, newCollection);
     return notInOldCollection.isEmpty() ? null : notInOldCollection;
   }
 
-  private static ImmutableList<Object> getAdditions(
-      Collection<?> oldCollection, Collection<?> newCollection) {
-    if (oldCollection == null)
-      return newCollection != null ? ImmutableList.copyOf(newCollection) : null;
+  @Nullable
+  private static ImmutableList<Object> getAdditionsForCollection(
+      @Nullable Collection<?> oldCollection, Collection<?> newCollection) {
+    if (oldCollection == null) {
+      return ImmutableList.copyOf(newCollection);
+    }
 
     Map<Object, List<Object>> duplicatesMap = newCollection.stream().collect(groupingBy(v -> v));
     oldCollection.forEach(
@@ -169,7 +173,19 @@
   }
 
   /** Returns {@code null} if nothing has been added to {@code oldMap} */
-  private static ImmutableMap<Object, Object> getAddedForMap(Map<?, ?> oldMap, Map<?, ?> newMap) {
+  @Nullable
+  private static ImmutableMap<Object, Object> getAddedForMap(
+      @Nullable Map<?, ?> oldMap, Map<?, ?> newMap) {
+    ImmutableMap<Object, Object> notInOldMap = getAdditionsForMap(oldMap, newMap);
+    return notInOldMap.isEmpty() ? null : notInOldMap;
+  }
+
+  @Nullable
+  private static ImmutableMap<Object, Object> getAdditionsForMap(
+      @Nullable Map<?, ?> oldMap, Map<?, ?> newMap) {
+    if (oldMap == null) {
+      return ImmutableMap.copyOf(newMap);
+    }
     ImmutableMap.Builder<Object, Object> additionsBuilder = ImmutableMap.builder();
     for (Map.Entry<?, ?> entry : newMap.entrySet()) {
       Object added = getAdded(oldMap.get(entry.getKey()), entry.getValue());
@@ -177,8 +193,7 @@
         additionsBuilder.put(entry.getKey(), added);
       }
     }
-    ImmutableMap<Object, Object> additions = additionsBuilder.build();
-    return additions.isEmpty() ? null : additions;
+    return additionsBuilder.build();
   }
 
   private static Object get(Field field, Object obj) {
diff --git a/java/com/google/gerrit/extensions/common/ChangeInput.java b/java/com/google/gerrit/extensions/common/ChangeInput.java
index ea12ef1..6f9cff7 100644
--- a/java/com/google/gerrit/extensions/common/ChangeInput.java
+++ b/java/com/google/gerrit/extensions/common/ChangeInput.java
@@ -14,11 +14,15 @@
 
 package com.google.gerrit.extensions.common;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.api.accounts.AccountInput;
+import com.google.gerrit.extensions.api.changes.ApplyPatchInput;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.NotifyInfo;
 import com.google.gerrit.extensions.api.changes.RecipientType;
 import com.google.gerrit.extensions.client.ChangeStatus;
+import com.google.gerrit.extensions.client.ListChangesOption;
+import java.util.List;
 import java.util.Map;
 
 public class ChangeInput {
@@ -35,9 +39,12 @@
   public Boolean newBranch;
   public Map<String, String> validationOptions;
   public MergeInput merge;
+  public ApplyPatchInput patch;
 
   public AccountInput author;
 
+  @Nullable public List<ListChangesOption> responseFormatOptions;
+
   public ChangeInput() {}
 
   /**
diff --git a/java/com/google/gerrit/extensions/common/DiffWebLinkInfo.java b/java/com/google/gerrit/extensions/common/DiffWebLinkInfo.java
index 9bcf2cf..05029be 100644
--- a/java/com/google/gerrit/extensions/common/DiffWebLinkInfo.java
+++ b/java/com/google/gerrit/extensions/common/DiffWebLinkInfo.java
@@ -18,29 +18,26 @@
   public Boolean showOnSideBySideDiffView;
   public Boolean showOnUnifiedDiffView;
 
-  public static DiffWebLinkInfo forSideBySideDiffView(
-      String name, String imageUrl, String url, String target) {
-    return new DiffWebLinkInfo(name, imageUrl, url, target, true, false);
+  public static DiffWebLinkInfo forSideBySideDiffView(String name, String imageUrl, String url) {
+    return new DiffWebLinkInfo(name, imageUrl, url, true, false);
   }
 
-  public static DiffWebLinkInfo forUnifiedDiffView(
-      String name, String imageUrl, String url, String target) {
-    return new DiffWebLinkInfo(name, imageUrl, url, target, false, true);
+  public static DiffWebLinkInfo forUnifiedDiffView(String name, String imageUrl, String url) {
+    return new DiffWebLinkInfo(name, imageUrl, url, false, true);
   }
 
   public static DiffWebLinkInfo forSideBySideAndUnifiedDiffView(
-      String name, String imageUrl, String url, String target) {
-    return new DiffWebLinkInfo(name, imageUrl, url, target, true, true);
+      String name, String imageUrl, String url) {
+    return new DiffWebLinkInfo(name, imageUrl, url, true, true);
   }
 
   private DiffWebLinkInfo(
       String name,
       String imageUrl,
       String url,
-      String target,
       boolean showOnSideBySideDiffView,
       boolean showOnUnifiedDiffView) {
-    super(name, imageUrl, url, target);
+    super(name, imageUrl, url);
     this.showOnSideBySideDiffView = showOnSideBySideDiffView ? true : null;
     this.showOnUnifiedDiffView = showOnUnifiedDiffView ? true : null;
   }
diff --git a/java/com/google/gerrit/extensions/common/FileInfo.java b/java/com/google/gerrit/extensions/common/FileInfo.java
index c732663..9526fbb 100644
--- a/java/com/google/gerrit/extensions/common/FileInfo.java
+++ b/java/com/google/gerrit/extensions/common/FileInfo.java
@@ -18,6 +18,8 @@
 
 public class FileInfo {
   public Character status;
+  public Integer oldMode;
+  public Integer newMode;
   public Boolean binary;
   public String oldPath;
   public Integer linesInserted;
diff --git a/java/com/google/gerrit/extensions/common/RebaseChainInfo.java b/java/com/google/gerrit/extensions/common/RebaseChainInfo.java
new file mode 100644
index 0000000..b327007
--- /dev/null
+++ b/java/com/google/gerrit/extensions/common/RebaseChainInfo.java
@@ -0,0 +1,27 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.common;
+
+import java.util.List;
+
+public class RebaseChainInfo {
+  public List<ChangeInfo> rebasedChanges;
+  /**
+   * Whether any of the changes contain conflicts.
+   *
+   * <p>If {@code true}, some of the rebased changes are marked with conflicts.
+   */
+  public Boolean containsGitConflicts;
+}
diff --git a/java/com/google/gerrit/extensions/common/RevisionInfo.java b/java/com/google/gerrit/extensions/common/RevisionInfo.java
index 7c52c8c..941dffe 100644
--- a/java/com/google/gerrit/extensions/common/RevisionInfo.java
+++ b/java/com/google/gerrit/extensions/common/RevisionInfo.java
@@ -32,6 +32,7 @@
   public Timestamp created;
 
   public AccountInfo uploader;
+  public AccountInfo realUploader;
   public String ref;
   public Map<String, FetchInfo> fetch;
   public CommitInfo commit;
@@ -72,6 +73,7 @@
           && _number == revisionInfo._number
           && Objects.equals(created, revisionInfo.created)
           && Objects.equals(uploader, revisionInfo.uploader)
+          && Objects.equals(realUploader, revisionInfo.realUploader)
           && Objects.equals(ref, revisionInfo.ref)
           && Objects.equals(fetch, revisionInfo.fetch)
           && Objects.equals(commit, revisionInfo.commit)
@@ -92,6 +94,7 @@
         _number,
         created,
         uploader,
+        realUploader,
         ref,
         fetch,
         commit,
diff --git a/java/com/google/gerrit/extensions/common/WebLinkInfo.java b/java/com/google/gerrit/extensions/common/WebLinkInfo.java
index ba12be0..fbd8d2f 100644
--- a/java/com/google/gerrit/extensions/common/WebLinkInfo.java
+++ b/java/com/google/gerrit/extensions/common/WebLinkInfo.java
@@ -14,24 +14,18 @@
 
 package com.google.gerrit.extensions.common;
 
-import com.google.gerrit.extensions.webui.WebLink.Target;
 import java.util.Objects;
 
 public class WebLinkInfo {
   public String name;
+  public String tooltip;
   public String imageUrl;
   public String url;
-  public String target;
 
-  public WebLinkInfo(String name, String imageUrl, String url, String target) {
+  public WebLinkInfo(String name, String imageUrl, String url) {
     this.name = name;
     this.imageUrl = imageUrl;
     this.url = url;
-    this.target = target;
-  }
-
-  public WebLinkInfo(String name, String imageUrl, String url) {
-    this(name, imageUrl, url, Target.SELF);
   }
 
   @Override
@@ -41,14 +35,14 @@
     }
     WebLinkInfo i = (WebLinkInfo) o;
     return Objects.equals(name, i.name)
+        && Objects.equals(tooltip, i.tooltip)
         && Objects.equals(imageUrl, i.imageUrl)
-        && Objects.equals(url, i.url)
-        && Objects.equals(target, i.target);
+        && Objects.equals(url, i.url);
   }
 
   @Override
   public int hashCode() {
-    return Objects.hash(name, imageUrl, url, target);
+    return Objects.hash(name, tooltip, imageUrl, url);
   }
 
   @Override
@@ -56,12 +50,12 @@
     return getClass().getSimpleName()
         + "{name="
         + name
+        + ", tooltip="
+        + tooltip
         + ", imageUrl="
         + imageUrl
         + ", url="
         + url
-        + ", target"
-        + target
         + "}";
   }
 
diff --git a/java/com/google/gerrit/extensions/common/testing/FileInfoSubject.java b/java/com/google/gerrit/extensions/common/testing/FileInfoSubject.java
index d011d5d..180a94691 100644
--- a/java/com/google/gerrit/extensions/common/testing/FileInfoSubject.java
+++ b/java/com/google/gerrit/extensions/common/testing/FileInfoSubject.java
@@ -45,6 +45,16 @@
     return check("linesDeleted").that(fileInfo.linesDeleted);
   }
 
+  public IntegerSubject oldMode() {
+    isNotNull();
+    return check("oldMode").that(fileInfo.oldMode);
+  }
+
+  public IntegerSubject newMode() {
+    isNotNull();
+    return check("newMode").that(fileInfo.newMode);
+  }
+
   public ComparableSubject<Character> status() {
     isNotNull();
     return check("status").that(fileInfo.status);
diff --git a/java/com/google/gerrit/extensions/events/AssigneeChangedListener.java b/java/com/google/gerrit/extensions/events/AssigneeChangedListener.java
deleted file mode 100644
index 7fc0f03..0000000
--- a/java/com/google/gerrit/extensions/events/AssigneeChangedListener.java
+++ /dev/null
@@ -1,30 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.extensions.events;
-
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.extensions.annotations.ExtensionPoint;
-import com.google.gerrit.extensions.common.AccountInfo;
-
-/** Notified whenever a change assignee is changed. */
-@ExtensionPoint
-public interface AssigneeChangedListener {
-  interface Event extends ChangeEvent {
-    @Nullable
-    AccountInfo getOldAssignee();
-  }
-
-  void onAssigneeChanged(Event event);
-}
diff --git a/java/com/google/gerrit/extensions/registration/DynamicItemProvider.java b/java/com/google/gerrit/extensions/registration/DynamicItemProvider.java
index d8dd1f9..7ed7077 100644
--- a/java/com/google/gerrit/extensions/registration/DynamicItemProvider.java
+++ b/java/com/google/gerrit/extensions/registration/DynamicItemProvider.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.extensions.registration;
 
+import com.google.gerrit.common.Nullable;
 import com.google.inject.Binding;
 import com.google.inject.Inject;
 import com.google.inject.Injector;
@@ -39,6 +40,7 @@
     return new DynamicItem<>(key, find(injector, type), PluginName.GERRIT);
   }
 
+  @Nullable
   private static <T> Provider<T> find(Injector src, TypeLiteral<T> type) {
     List<Binding<T>> bindings = src.findBindingsByType(type);
     if (bindings != null && bindings.size() == 1) {
diff --git a/java/com/google/gerrit/extensions/registration/DynamicSet.java b/java/com/google/gerrit/extensions/registration/DynamicSet.java
index a0b2c6a..6dc8c6a 100644
--- a/java/com/google/gerrit/extensions/registration/DynamicSet.java
+++ b/java/com/google/gerrit/extensions/registration/DynamicSet.java
@@ -20,6 +20,7 @@
 
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.ImmutableSortedSet;
+import com.google.gerrit.common.Nullable;
 import com.google.inject.Binder;
 import com.google.inject.Key;
 import com.google.inject.Provider;
@@ -313,6 +314,7 @@
       return key;
     }
 
+    @Nullable
     @Override
     public ReloadableHandle replace(Key<T> newKey, Provider<T> newItem) {
       Extension<T> n = new Extension<>(item.getPluginName(), newItem);
diff --git a/java/com/google/gerrit/extensions/registration/PrivateInternals_DynamicMapImpl.java b/java/com/google/gerrit/extensions/registration/PrivateInternals_DynamicMapImpl.java
index fb520b4..67fc068 100644
--- a/java/com/google/gerrit/extensions/registration/PrivateInternals_DynamicMapImpl.java
+++ b/java/com/google/gerrit/extensions/registration/PrivateInternals_DynamicMapImpl.java
@@ -16,6 +16,7 @@
 
 import static java.util.Objects.requireNonNull;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.annotations.Export;
 import com.google.inject.Key;
 import com.google.inject.Provider;
@@ -79,6 +80,7 @@
       return key;
     }
 
+    @Nullable
     @Override
     public ReloadableHandle replace(Key<T> newKey, Provider<T> newItem) {
       if (items.replace(np, item, newItem)) {
diff --git a/java/com/google/gerrit/extensions/restapi/IdString.java b/java/com/google/gerrit/extensions/restapi/IdString.java
index b2538fa..a69919f 100644
--- a/java/com/google/gerrit/extensions/restapi/IdString.java
+++ b/java/com/google/gerrit/extensions/restapi/IdString.java
@@ -38,7 +38,16 @@
 
   /** Returns the decoded value of the string. */
   public String get() {
-    return Url.decode(urlEncoded);
+    String data = urlEncoded;
+
+    // URLs use percentage encoding which replaces unsafe ASCII characters with a '%' followed by
+    // two hexadecimal digits. If there is '%' that is not followed by two hexadecimal digits
+    // Url.decode(String) fails with an IllegalArgumentException. To prevent this replace any '%'
+    // that is not followed by two hexadecimal digits by "%25", which is the URL encoding for '%',
+    // before calling Url.decode(String).
+    data = data.replaceAll("%(?![0-9a-fA-F]{2})", "%25");
+
+    return Url.decode(data);
   }
 
   /** Returns true if the string is the empty string. */
diff --git a/java/com/google/gerrit/extensions/restapi/Url.java b/java/com/google/gerrit/extensions/restapi/Url.java
index 9c69376..09def84 100644
--- a/java/com/google/gerrit/extensions/restapi/Url.java
+++ b/java/com/google/gerrit/extensions/restapi/Url.java
@@ -16,6 +16,7 @@
 
 import static java.nio.charset.StandardCharsets.UTF_8;
 
+import com.google.gerrit.common.Nullable;
 import java.io.UnsupportedEncodingException;
 import java.net.URLDecoder;
 import java.net.URLEncoder;
@@ -40,6 +41,7 @@
    * @param component a string containing text to encode.
    * @return a string with all invalid URL characters escaped.
    */
+  @Nullable
   public static String encode(String component) {
     if (component != null) {
       try {
@@ -52,6 +54,7 @@
   }
 
   /** Decode a URL encoded string, e.g. from {@code "%2F"} to {@code "/"}. */
+  @Nullable
   public static String decode(String str) {
     if (str != null) {
       try {
diff --git a/java/com/google/gerrit/extensions/webui/UiAction.java b/java/com/google/gerrit/extensions/webui/UiAction.java
index 2f21bf3..9da0642 100644
--- a/java/com/google/gerrit/extensions/webui/UiAction.java
+++ b/java/com/google/gerrit/extensions/webui/UiAction.java
@@ -14,10 +14,13 @@
 
 package com.google.gerrit.extensions.webui;
 
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.conditions.BooleanCondition;
 import com.google.gerrit.extensions.restapi.RestResource;
 import com.google.gerrit.extensions.restapi.RestView;
+import java.util.ArrayList;
+import java.util.List;
 
 public interface UiAction<R extends RestResource> extends RestView<R> {
   /**
@@ -40,6 +43,7 @@
     private String title;
     private BooleanCondition visible = BooleanCondition.TRUE;
     private BooleanCondition enabled = BooleanCondition.TRUE;
+    private List<String> enabledOptions = new ArrayList<>();
 
     public String getMethod() {
       return method;
@@ -122,5 +126,22 @@
       this.enabled = enabled;
       return this;
     }
+
+    @Nullable
+    public ImmutableList<String> getEnabledOptions() {
+      if (enabledOptions.isEmpty()) {
+        return null;
+      }
+      return ImmutableList.copyOf(enabledOptions);
+    }
+
+    public Description setOption(String optionName, boolean enabled) {
+      if (enabled) {
+        enabledOptions.add(optionName);
+      } else {
+        enabledOptions.remove(optionName);
+      }
+      return this;
+    }
   }
 }
diff --git a/java/com/google/gerrit/extensions/webui/WebLink.java b/java/com/google/gerrit/extensions/webui/WebLink.java
index 7cbeff2..36ae50c 100644
--- a/java/com/google/gerrit/extensions/webui/WebLink.java
+++ b/java/com/google/gerrit/extensions/webui/WebLink.java
@@ -15,16 +15,4 @@
 package com.google.gerrit.extensions.webui;
 
 /** Marks that the implementor has a method that provides a weblinkInfo */
-public interface WebLink {
-  /** Class that holds target defaults for WebLink anchors. */
-  class Target {
-    /** Opens the link in a new window or tab */
-    public static final String BLANK = "_blank";
-    /** Opens the link in the frame it was clicked. */
-    public static final String SELF = "_self";
-    /** Opens link in parent frame. */
-    public static final String PARENT = "_parent";
-    /** Opens link in the full body of the window. */
-    public static final String TOP = "_top";
-  }
-}
+public interface WebLink {}
diff --git a/java/com/google/gerrit/git/GitUpdateFailureException.java b/java/com/google/gerrit/git/GitUpdateFailureException.java
index 7fcb828..339339c 100644
--- a/java/com/google/gerrit/git/GitUpdateFailureException.java
+++ b/java/com/google/gerrit/git/GitUpdateFailureException.java
@@ -46,6 +46,11 @@
             .collect(toImmutableList());
   }
 
+  protected GitUpdateFailureException(String message, Throwable cause) {
+    super(message, cause);
+    this.failures = ImmutableList.of();
+  }
+
   /** Returns the names of the refs for which the update failed. */
   public ImmutableList<String> getFailedRefs() {
     return failures.stream().map(GitUpdateFailure::ref).collect(toImmutableList());
diff --git a/java/com/google/gerrit/git/LockFailureException.java b/java/com/google/gerrit/git/LockFailureException.java
index 371488d..2908db2 100644
--- a/java/com/google/gerrit/git/LockFailureException.java
+++ b/java/com/google/gerrit/git/LockFailureException.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.git;
 
+import org.eclipse.jgit.api.errors.ConcurrentRefUpdateException;
 import org.eclipse.jgit.lib.BatchRefUpdate;
 import org.eclipse.jgit.lib.RefUpdate;
 
@@ -21,6 +22,9 @@
 public class LockFailureException extends GitUpdateFailureException {
   private static final long serialVersionUID = 1L;
 
+  private static final String REF_UPDATE_RETURN_CODE_WAS_LOCK_FAILURE =
+      "RefUpdate return code was: LOCK_FAILURE";
+
   public LockFailureException(String message, RefUpdate refUpdate) {
     super(message, refUpdate);
   }
@@ -28,4 +32,15 @@
   public LockFailureException(String message, BatchRefUpdate batchRefUpdate) {
     super(message, batchRefUpdate);
   }
+
+  protected LockFailureException(String message, Throwable cause) {
+    super(message, cause);
+  }
+
+  public static void throwIfLockFailure(ConcurrentRefUpdateException e)
+      throws LockFailureException {
+    if (e.getMessage().contains(REF_UPDATE_RETURN_CODE_WAS_LOCK_FAILURE)) {
+      throw new LockFailureException(e.getMessage(), e);
+    }
+  }
 }
diff --git a/java/com/google/gerrit/gpg/BUILD b/java/com/google/gerrit/gpg/BUILD
index 0ee5212..b2173c4 100644
--- a/java/com/google/gerrit/gpg/BUILD
+++ b/java/com/google/gerrit/gpg/BUILD
@@ -5,6 +5,7 @@
     srcs = glob(["**/*.java"]),
     visibility = ["//visibility:public"],
     deps = [
+        "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/exceptions",
         "//java/com/google/gerrit/extensions:api",
diff --git a/java/com/google/gerrit/gpg/GerritPublicKeyChecker.java b/java/com/google/gerrit/gpg/GerritPublicKeyChecker.java
index 71dff97..fff4045 100644
--- a/java/com/google/gerrit/gpg/GerritPublicKeyChecker.java
+++ b/java/com/google/gerrit/gpg/GerritPublicKeyChecker.java
@@ -37,6 +37,7 @@
 import java.util.HashSet;
 import java.util.Iterator;
 import java.util.List;
+import java.util.Locale;
 import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
@@ -82,7 +83,7 @@
       if (strs.length != 0) {
         Map<Long, Fingerprint> fps = Maps.newHashMapWithExpectedSize(strs.length);
         for (String str : strs) {
-          str = CharMatcher.whitespace().removeFrom(str).toUpperCase();
+          str = CharMatcher.whitespace().removeFrom(str).toUpperCase(Locale.US);
           Fingerprint fp = new Fingerprint(BaseEncoding.base16().decode(str));
           fps.put(fp.getId(), fp);
         }
diff --git a/java/com/google/gerrit/gpg/PublicKeyChecker.java b/java/com/google/gerrit/gpg/PublicKeyChecker.java
index 0a96212..5347398 100644
--- a/java/com/google/gerrit/gpg/PublicKeyChecker.java
+++ b/java/com/google/gerrit/gpg/PublicKeyChecker.java
@@ -30,6 +30,7 @@
 import static org.bouncycastle.openpgp.PGPSignature.KEY_REVOCATION;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.common.GpgKeyInfo.Status;
 import java.io.IOException;
 import java.time.Instant;
@@ -229,6 +230,7 @@
         || PushCertificateChecker.getCreationTime(revocation).isBefore(now);
   }
 
+  @Nullable
   private PGPSignature scanRevocations(
       PGPPublicKey key,
       Instant now,
@@ -264,6 +266,7 @@
     return null;
   }
 
+  @Nullable
   private RevocationKey getRevocationKey(PGPPublicKey key, PGPSignature sig) throws PGPException {
     if (sig.getKeyID() != key.getKeyID()) {
       return null;
@@ -320,6 +323,7 @@
     }
   }
 
+  @Nullable
   private static RevocationReason getRevocationReason(PGPSignature sig) {
     if (sig.getSignatureType() != KEY_REVOCATION) {
       throw new IllegalArgumentException(
@@ -425,6 +429,7 @@
     return CheckResult.create(OK, problems);
   }
 
+  @Nullable
   private static PGPPublicKey getSigner(
       PublicKeyStore store,
       PGPSignature sig,
@@ -455,6 +460,7 @@
     }
   }
 
+  @Nullable
   private String checkTrustSubpacket(PGPSignature sig, int depth) {
     SignatureSubpacket trustSub =
         sig.getHashedSubPackets().getSubpacket(SignatureSubpacketTags.TRUST_SIG);
diff --git a/java/com/google/gerrit/gpg/PublicKeyStore.java b/java/com/google/gerrit/gpg/PublicKeyStore.java
index 2cce480..def35d6 100644
--- a/java/com/google/gerrit/gpg/PublicKeyStore.java
+++ b/java/com/google/gerrit/gpg/PublicKeyStore.java
@@ -15,14 +15,17 @@
 package com.google.gerrit.gpg;
 
 import static com.google.common.base.Preconditions.checkState;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.GPG_KEYS_MODIFICATION;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static org.eclipse.jgit.lib.Constants.EMPTY_TREE_ID;
 import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
 
 import com.google.common.base.Preconditions;
 import com.google.common.io.ByteStreams;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.git.LockFailureException;
 import com.google.gerrit.git.ObjectIds;
+import com.google.gerrit.server.update.context.RefUpdateContext;
 import java.io.ByteArrayInputStream;
 import java.io.ByteArrayOutputStream;
 import java.io.IOException;
@@ -92,6 +95,7 @@
    *     null} if none was found.
    * @throws PGPException if an error occurred verifying the signature.
    */
+  @Nullable
   public static PGPPublicKey getSigner(
       Iterable<PGPPublicKeyRing> keyRings, PGPSignature sig, byte[] data) throws PGPException {
     for (PGPPublicKeyRing kr : keyRings) {
@@ -126,6 +130,7 @@
    *     {@code null} if none was found.
    * @throws PGPException if an error occurred verifying the certification.
    */
+  @Nullable
   public static PGPPublicKey getSigner(
       Iterable<PGPPublicKeyRing> keyRings, PGPSignature sig, String userId, PGPPublicKey key)
       throws PGPException {
@@ -210,6 +215,7 @@
    * @throws PGPException if an error occurred parsing the key data.
    * @throws IOException if an error occurred reading the repository data.
    */
+  @Nullable
   public PGPPublicKeyRing get(byte[] fingerprint) throws PGPException, IOException {
     List<PGPPublicKeyRing> keyRings = get(Fingerprint.getId(fingerprint), fingerprint);
     return !keyRings.isEmpty() ? keyRings.get(0) : null;
@@ -272,7 +278,7 @@
   }
 
   public void rebuildSubkeyMasterKeyMap()
-      throws MissingObjectException, IncorrectObjectTypeException, IOException, PGPException {
+      throws MissingObjectException, IncorrectObjectTypeException, IOException {
     if (reader == null) {
       load();
     }
@@ -373,35 +379,36 @@
       newTip = ins.insert(cb);
       ins.flush();
     }
-
-    RefUpdate ru = repo.updateRef(PublicKeyStore.REFS_GPG_KEYS);
-    ru.setExpectedOldObjectId(tip);
-    ru.setNewObjectId(newTip);
-    ru.setRefLogIdent(cb.getCommitter());
-    ru.setRefLogMessage("Store public keys", true);
-    RefUpdate.Result result = ru.update();
-    reset();
-    switch (result) {
-      case FAST_FORWARD:
-      case NEW:
-      case NO_CHANGE:
-        toAdd.clear();
-        toRemove.clear();
-        break;
-      case LOCK_FAILURE:
-        throw new LockFailureException("Failed to store public keys", ru);
-      case FORCED:
-      case IO_FAILURE:
-      case NOT_ATTEMPTED:
-      case REJECTED:
-      case REJECTED_CURRENT_BRANCH:
-      case RENAMED:
-      case REJECTED_MISSING_OBJECT:
-      case REJECTED_OTHER_REASON:
-      default:
-        break;
+    try (RefUpdateContext ctx = RefUpdateContext.open(GPG_KEYS_MODIFICATION)) {
+      RefUpdate ru = repo.updateRef(PublicKeyStore.REFS_GPG_KEYS);
+      ru.setExpectedOldObjectId(tip);
+      ru.setNewObjectId(newTip);
+      ru.setRefLogIdent(cb.getCommitter());
+      ru.setRefLogMessage("Store public keys", true);
+      RefUpdate.Result result = ru.update();
+      reset();
+      switch (result) {
+        case FAST_FORWARD:
+        case NEW:
+        case NO_CHANGE:
+          toAdd.clear();
+          toRemove.clear();
+          break;
+        case LOCK_FAILURE:
+          throw new LockFailureException("Failed to store public keys", ru);
+        case FORCED:
+        case IO_FAILURE:
+        case NOT_ATTEMPTED:
+        case REJECTED:
+        case REJECTED_CURRENT_BRANCH:
+        case RENAMED:
+        case REJECTED_MISSING_OBJECT:
+        case REJECTED_OTHER_REASON:
+        default:
+          break;
+      }
+      return result;
     }
-    return result;
   }
 
   private void saveToNotes(ObjectInserter ins, PGPPublicKeyRing keyRing)
diff --git a/java/com/google/gerrit/gpg/PushCertificateChecker.java b/java/com/google/gerrit/gpg/PushCertificateChecker.java
index 17ca5a4..b9ff50b 100644
--- a/java/com/google/gerrit/gpg/PushCertificateChecker.java
+++ b/java/com/google/gerrit/gpg/PushCertificateChecker.java
@@ -22,6 +22,7 @@
 
 import com.google.common.base.Joiner;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.common.GpgKeyInfo.Status;
 import java.io.ByteArrayInputStream;
 import java.io.IOException;
@@ -176,6 +177,7 @@
     return CheckResult.ok();
   }
 
+  @Nullable
   private PGPSignature readSignature(PushCertificate cert) throws IOException {
     ArmoredInputStream in =
         new ArmoredInputStream(new ByteArrayInputStream(Constants.encode(cert.getSignature())));
diff --git a/java/com/google/gerrit/gpg/server/GpgKeys.java b/java/com/google/gerrit/gpg/server/GpgKeys.java
index b3a2f53..00a0f57 100644
--- a/java/com/google/gerrit/gpg/server/GpgKeys.java
+++ b/java/com/google/gerrit/gpg/server/GpgKeys.java
@@ -48,6 +48,7 @@
 import java.util.Arrays;
 import java.util.HashMap;
 import java.util.Iterator;
+import java.util.Locale;
 import java.util.Map;
 import org.bouncycastle.bcpg.ArmoredOutputStream;
 import org.bouncycastle.openpgp.PGPException;
@@ -106,7 +107,7 @@
 
   static ExternalId findGpgKey(String str, Iterable<ExternalId> existingExtIds)
       throws ResourceNotFoundException {
-    str = CharMatcher.whitespace().removeFrom(str).toUpperCase();
+    str = CharMatcher.whitespace().removeFrom(str).toUpperCase(Locale.US);
     if ((str.length() != 8 && str.length() != 40)
         || !CharMatcher.anyOf("0123456789ABCDEF").matchesAllOf(str)) {
       throw new ResourceNotFoundException(str);
diff --git a/java/com/google/gerrit/gpg/server/PostGpgKeys.java b/java/com/google/gerrit/gpg/server/PostGpgKeys.java
index 3341806..d51ee6a 100644
--- a/java/com/google/gerrit/gpg/server/PostGpgKeys.java
+++ b/java/com/google/gerrit/gpg/server/PostGpgKeys.java
@@ -29,6 +29,7 @@
 import com.google.common.collect.Maps;
 import com.google.common.flogger.FluentLogger;
 import com.google.common.io.BaseEncoding;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.exceptions.EmailException;
 import com.google.gerrit.exceptions.StorageException;
@@ -299,6 +300,7 @@
     return externalIdKeyFactory.create(SCHEME_GPGKEY, BaseEncoding.base16().encode(fp));
   }
 
+  @Nullable
   private Account getAccountByExternalId(ExternalId.Key extIdKey) {
     List<AccountState> accountStates = accountQueryProvider.get().byExternalId(extIdKey);
 
diff --git a/java/com/google/gerrit/httpd/BUILD b/java/com/google/gerrit/httpd/BUILD
index 1284829..0142031 100644
--- a/java/com/google/gerrit/httpd/BUILD
+++ b/java/com/google/gerrit/httpd/BUILD
@@ -38,9 +38,11 @@
         "//lib/auto:auto-value",
         "//lib/auto:auto-value-annotations",
         "//lib/commons:lang3",
+        "//lib/errorprone:annotations",
         "//lib/flogger:api",
         "//lib/guice",
         "//lib/guice:guice-assistedinject",
         "//lib/guice:guice-servlet",
+        "//lib/jsoup",
     ],
 )
diff --git a/java/com/google/gerrit/httpd/CacheBasedWebSession.java b/java/com/google/gerrit/httpd/CacheBasedWebSession.java
index 5b62f96..9625039 100644
--- a/java/com/google/gerrit/httpd/CacheBasedWebSession.java
+++ b/java/com/google/gerrit/httpd/CacheBasedWebSession.java
@@ -116,6 +116,7 @@
     }
   }
 
+  @Nullable
   private static String readCookie(HttpServletRequest request) {
     Cookie[] all = request.getCookies();
     if (all != null) {
@@ -219,6 +220,7 @@
     }
   }
 
+  @Nullable
   @Override
   public String getSessionId() {
     return val != null ? val.getSessionId() : null;
diff --git a/java/com/google/gerrit/httpd/GitOverHttpServlet.java b/java/com/google/gerrit/httpd/GitOverHttpServlet.java
index e1ead59..e513a72 100644
--- a/java/com/google/gerrit/httpd/GitOverHttpServlet.java
+++ b/java/com/google/gerrit/httpd/GitOverHttpServlet.java
@@ -22,6 +22,7 @@
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Lists;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.Capable;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.registration.DynamicSet;
@@ -666,6 +667,7 @@
     public void destroy() {}
   }
 
+  @Nullable
   private static String getSessionIdOrNull(Provider<WebSession> sessionProvider) {
     WebSession session = sessionProvider.get();
     if (session.isSignedIn()) {
diff --git a/java/com/google/gerrit/httpd/HtmlDomUtil.java b/java/com/google/gerrit/httpd/HtmlDomUtil.java
index 57f2664..16e0938 100644
--- a/java/com/google/gerrit/httpd/HtmlDomUtil.java
+++ b/java/com/google/gerrit/httpd/HtmlDomUtil.java
@@ -16,7 +16,9 @@
 
 import static java.nio.charset.StandardCharsets.UTF_8;
 
+import com.google.common.flogger.FluentLogger;
 import com.google.common.io.ByteStreams;
+import com.google.gerrit.common.Nullable;
 import java.io.ByteArrayOutputStream;
 import java.io.IOException;
 import java.io.InputStream;
@@ -25,6 +27,8 @@
 import java.nio.file.Files;
 import java.nio.file.NoSuchFileException;
 import java.nio.file.Path;
+import java.util.Optional;
+import java.util.concurrent.TimeUnit;
 import java.util.zip.GZIPOutputStream;
 import javax.xml.parsers.DocumentBuilder;
 import javax.xml.parsers.DocumentBuilderFactory;
@@ -39,6 +43,7 @@
 import javax.xml.xpath.XPathExpression;
 import javax.xml.xpath.XPathExpressionException;
 import javax.xml.xpath.XPathFactory;
+import org.jsoup.parser.Parser;
 import org.w3c.dom.Document;
 import org.w3c.dom.Element;
 import org.w3c.dom.Node;
@@ -47,6 +52,8 @@
 
 /** Utility functions to deal with HTML using W3C DOM operations. */
 public class HtmlDomUtil {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   /** Standard character encoding we prefer (UTF-8). */
   public static final Charset ENC = UTF_8;
 
@@ -89,6 +96,7 @@
   }
 
   /** Find an element by its "id" attribute; null if no element is found. */
+  @Nullable
   public static Element find(Node parent, String name) {
     NodeList list = parent.getChildNodes();
     for (int i = 0; i < list.getLength(); i++) {
@@ -139,6 +147,7 @@
   }
 
   /** Parse an XHTML file from our CLASSPATH and return the instance. */
+  @Nullable
   public static Document parseFile(Class<?> context, String name) throws IOException {
     try (InputStream in = context.getResourceAsStream(name)) {
       if (in == null) {
@@ -168,6 +177,7 @@
   }
 
   /** Read a Read a UTF-8 text file from our CLASSPATH and return it. */
+  @Nullable
   public static String readFile(Class<?> context, String name) throws IOException {
     try (InputStream in = context.getResourceAsStream(name)) {
       if (in == null) {
@@ -180,6 +190,7 @@
   }
 
   /** Parse an XHTML file from the local drive and return the instance. */
+  @Nullable
   public static Document parseFile(Path path) throws IOException {
     try (InputStream in = Files.newInputStream(path)) {
       Document doc = newBuilder().parse(in);
@@ -193,6 +204,7 @@
   }
 
   /** Read a UTF-8 text file from the local drive. */
+  @Nullable
   public static String readFile(Path parentDir, String name) throws IOException {
     if (parentDir == null) {
       return null;
@@ -215,4 +227,27 @@
     factory.setCoalescing(true);
     return factory.newDocumentBuilder();
   }
+
+  /**
+   * Attaches nonce to all script elements in html.
+   *
+   * <p>The returned html is not guaranteed to have the same formatting as the input.
+   *
+   * @return Updated html or {#link Optional.empty()} if parsing failed.
+   */
+  public static Optional<String> attachNonce(String html, String nonce) {
+    Parser parser = Parser.htmlParser();
+    org.jsoup.nodes.Document document = parser.parseInput(html, "");
+    if (!parser.getErrors().isEmpty()) {
+      logger.atSevere().atMostEvery(5, TimeUnit.MINUTES).log(
+          "Html couldn't be parsed to attach nonce. Errors: %s", parser.getErrors());
+      return Optional.empty();
+    }
+    document.getElementsByTag("script").attr("nonce", nonce);
+    return Optional.of(
+        document
+            .outputSettings(
+                new org.jsoup.nodes.Document.OutputSettings().prettyPrint(false).indentAmount(0))
+            .outerHtml());
+  }
 }
diff --git a/java/com/google/gerrit/httpd/HttpCanonicalWebUrlProvider.java b/java/com/google/gerrit/httpd/HttpCanonicalWebUrlProvider.java
index 6943faa..85dc200 100644
--- a/java/com/google/gerrit/httpd/HttpCanonicalWebUrlProvider.java
+++ b/java/com/google/gerrit/httpd/HttpCanonicalWebUrlProvider.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.httpd;
 
+import com.google.gerrit.common.UsedAt;
+import com.google.gerrit.common.UsedAt.Project;
 import com.google.gerrit.server.config.CanonicalWebUrlProvider;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.inject.Inject;
@@ -30,7 +32,8 @@
   private Provider<HttpServletRequest> requestProvider;
 
   @Inject
-  HttpCanonicalWebUrlProvider(@GerritServerConfig Config config) {
+  @UsedAt(Project.MODULE_VIRTUALHOST)
+  protected HttpCanonicalWebUrlProvider(@GerritServerConfig Config config) {
     super(config);
   }
 
diff --git a/java/com/google/gerrit/httpd/ProjectOAuthFilter.java b/java/com/google/gerrit/httpd/ProjectOAuthFilter.java
index de6ae50..5a99cab 100644
--- a/java/com/google/gerrit/httpd/ProjectOAuthFilter.java
+++ b/java/com/google/gerrit/httpd/ProjectOAuthFilter.java
@@ -23,6 +23,7 @@
 import com.google.common.collect.Iterables;
 import com.google.common.flogger.FluentLogger;
 import com.google.common.io.BaseEncoding;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.auth.oauth.OAuthLoginProvider;
 import com.google.gerrit.extensions.registration.DynamicItem;
@@ -226,6 +227,7 @@
     }
   }
 
+  @Nullable
   private AuthInfo extractAuthInfo(String hdr, String encoding)
       throws UnsupportedEncodingException {
     byte[] decoded = BaseEncoding.base64().decode(hdr.substring(BASIC.length()));
@@ -241,6 +243,7 @@
         defaultAuthProvider);
   }
 
+  @Nullable
   private AuthInfo extractAuthInfo(Cookie cookie) throws UnsupportedEncodingException {
     String username =
         URLDecoder.decode(cookie.getName().substring(GIT_COOKIE_PREFIX.length()), UTF_8.name());
@@ -272,6 +275,7 @@
     return MoreObjects.firstNonNull(req.getCharacterEncoding(), UTF_8.name());
   }
 
+  @Nullable
   private static Cookie findGitCookie(HttpServletRequest req) {
     Cookie[] cookies = req.getCookies();
     if (cookies != null) {
diff --git a/java/com/google/gerrit/httpd/RemoteUserUtil.java b/java/com/google/gerrit/httpd/RemoteUserUtil.java
index 84954dc..6f3e9c4 100644
--- a/java/com/google/gerrit/httpd/RemoteUserUtil.java
+++ b/java/com/google/gerrit/httpd/RemoteUserUtil.java
@@ -19,6 +19,7 @@
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.common.io.BaseEncoding;
+import com.google.gerrit.common.Nullable;
 import javax.servlet.http.HttpServletRequest;
 
 public class RemoteUserUtil {
@@ -62,6 +63,7 @@
    * @param auth header value which is used for extracting.
    * @return username if available or null.
    */
+  @Nullable
   public static String extractUsername(String auth) {
     auth = emptyToNull(auth);
 
diff --git a/java/com/google/gerrit/httpd/UrlModule.java b/java/com/google/gerrit/httpd/UrlModule.java
index 029efba..69adf82 100644
--- a/java/com/google/gerrit/httpd/UrlModule.java
+++ b/java/com/google/gerrit/httpd/UrlModule.java
@@ -92,11 +92,17 @@
     // which is bound in HttpPluginModule. We cannot bind it here again although
     // this means that plugins can't add REST views on PLUGIN_KIND.
     serveRegex("^/(?:a/)?access/(.*)$").with(AccessRestApiServlet.class);
+    serveRegex("^/(?:a/)?access$").with(AccessRestApiServlet.class);
     serveRegex("^/(?:a/)?accounts/(.*)$").with(AccountsRestApiServlet.class);
+    serveRegex("^/(?:a/)?accounts$").with(AccountsRestApiServlet.class);
     serveRegex("^/(?:a/)?changes/(.*)$").with(ChangesRestApiServlet.class);
+    serveRegex("^/(?:a/)?changes$").with(ChangesRestApiServlet.class);
     serveRegex("^/(?:a/)?config/(.*)$").with(ConfigRestApiServlet.class);
+    serveRegex("^/(?:a/)?config$").with(ConfigRestApiServlet.class);
     serveRegex("^/(?:a/)?groups/(.*)?$").with(GroupsRestApiServlet.class);
+    serveRegex("^/(?:a/)?groups$").with(GroupsRestApiServlet.class);
     serveRegex("^/(?:a/)?projects/(.*)?$").with(ProjectsRestApiServlet.class);
+    serveRegex("^/(?:a/)?projects$").with(ProjectsRestApiServlet.class);
 
     serveRegex("^/Documentation$").with(redirectDocumentation());
     serveRegex("^/Documentation/$").with(redirectDocumentation());
diff --git a/java/com/google/gerrit/httpd/WebSessionManager.java b/java/com/google/gerrit/httpd/WebSessionManager.java
index 87bf3a6..1137b65 100644
--- a/java/com/google/gerrit/httpd/WebSessionManager.java
+++ b/java/com/google/gerrit/httpd/WebSessionManager.java
@@ -30,6 +30,7 @@
 
 import com.google.common.cache.Cache;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.account.externalids.ExternalIdKeyFactory;
@@ -149,6 +150,7 @@
     return -1;
   }
 
+  @Nullable
   Val get(Key key) {
     Val val = self.getIfPresent(key.token);
     if (val != null && val.expiresAt <= nowMs()) {
diff --git a/java/com/google/gerrit/httpd/auth/become/BecomeAnyAccountLoginServlet.java b/java/com/google/gerrit/httpd/auth/become/BecomeAnyAccountLoginServlet.java
index 2f760f0..bc8a01a 100644
--- a/java/com/google/gerrit/httpd/auth/become/BecomeAnyAccountLoginServlet.java
+++ b/java/com/google/gerrit/httpd/auth/become/BecomeAnyAccountLoginServlet.java
@@ -17,6 +17,7 @@
 import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
 import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_UUID;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.PageLinks;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.registration.DynamicItem;
@@ -185,6 +186,7 @@
     return account.map(a -> new AuthResult(a.account().id(), null, false));
   }
 
+  @Nullable
   private AuthResult auth(Account.Id account) {
     if (account != null) {
       return new AuthResult(account, null, false);
@@ -192,6 +194,7 @@
     return null;
   }
 
+  @Nullable
   private AuthResult byUserName(String userName) {
     List<AccountState> accountStates = queryProvider.get().byExternalId(SCHEME_USERNAME, userName);
     if (accountStates.isEmpty()) {
@@ -223,6 +226,7 @@
     }
   }
 
+  @Nullable
   private AuthResult create() throws IOException {
     try {
       return accountManager.authenticate(
diff --git a/java/com/google/gerrit/httpd/auth/container/HttpAuthFilter.java b/java/com/google/gerrit/httpd/auth/container/HttpAuthFilter.java
index 5a5de0a..f0a8b89 100644
--- a/java/com/google/gerrit/httpd/auth/container/HttpAuthFilter.java
+++ b/java/com/google/gerrit/httpd/auth/container/HttpAuthFilter.java
@@ -21,6 +21,7 @@
 import static java.nio.charset.StandardCharsets.ISO_8859_1;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.httpd.HtmlDomUtil;
 import com.google.gerrit.httpd.RemoteUserUtil;
@@ -143,6 +144,7 @@
         : remoteUser;
   }
 
+  @Nullable
   String getRemoteDisplayname(HttpServletRequest req) {
     if (displaynameHeader != null) {
       String raw = emptyToNull(req.getHeader(displaynameHeader));
@@ -153,6 +155,7 @@
     return null;
   }
 
+  @Nullable
   String getRemoteEmail(HttpServletRequest req) {
     if (emailHeader != null) {
       return emptyToNull(req.getHeader(emailHeader));
@@ -160,6 +163,7 @@
     return null;
   }
 
+  @Nullable
   String getRemoteExternalIdToken(HttpServletRequest req) {
     if (externalIdHeader != null) {
       return emptyToNull(req.getHeader(externalIdHeader));
diff --git a/java/com/google/gerrit/httpd/auth/openid/LoginForm.java b/java/com/google/gerrit/httpd/auth/openid/LoginForm.java
index 0b6008c..e7057ad 100644
--- a/java/com/google/gerrit/httpd/auth/openid/LoginForm.java
+++ b/java/com/google/gerrit/httpd/auth/openid/LoginForm.java
@@ -334,6 +334,7 @@
     form.appendChild(div);
   }
 
+  @Nullable
   private OAuthServiceProvider lookupOAuthServiceProvider(String providerId) {
     if (providerId.startsWith("http://")) {
       providerId = providerId.substring("http://".length());
@@ -350,6 +351,7 @@
     return null;
   }
 
+  @Nullable
   private static String getLastId(HttpServletRequest req) {
     Cookie[] cookies = req.getCookies();
     if (cookies != null) {
diff --git a/java/com/google/gerrit/httpd/auth/openid/OpenIdServiceImpl.java b/java/com/google/gerrit/httpd/auth/openid/OpenIdServiceImpl.java
index fcd16ae..0c71d68 100644
--- a/java/com/google/gerrit/httpd/auth/openid/OpenIdServiceImpl.java
+++ b/java/com/google/gerrit/httpd/auth/openid/OpenIdServiceImpl.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.httpd.auth.openid;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.PageLinks;
 import com.google.gerrit.common.auth.openid.OpenIdUrls;
 import com.google.gerrit.entities.Account;
@@ -518,6 +519,7 @@
     rsp.sendRedirect(rdr.toString());
   }
 
+  @Nullable
   private State init(
       HttpServletRequest req,
       final String openidIdentifier,
diff --git a/java/com/google/gerrit/httpd/auth/restapi/BUILD b/java/com/google/gerrit/httpd/auth/restapi/BUILD
index d499768..9ab51c5 100644
--- a/java/com/google/gerrit/httpd/auth/restapi/BUILD
+++ b/java/com/google/gerrit/httpd/auth/restapi/BUILD
@@ -6,6 +6,7 @@
     visibility = ["//visibility:public"],
     deps = [
         "//java/com/google/gerrit/auth",
+        "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/server",
         "//lib/flogger:api",
diff --git a/java/com/google/gerrit/httpd/auth/restapi/GetOAuthToken.java b/java/com/google/gerrit/httpd/auth/restapi/GetOAuthToken.java
index 3594c7c..2eee415 100644
--- a/java/com/google/gerrit/httpd/auth/restapi/GetOAuthToken.java
+++ b/java/com/google/gerrit/httpd/auth/restapi/GetOAuthToken.java
@@ -16,6 +16,7 @@
 
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.auth.oauth.OAuthTokenCache;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.auth.oauth.OAuthToken;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
@@ -70,6 +71,7 @@
     return Response.ok(accessTokenInfo);
   }
 
+  @Nullable
   private static String getHostName(String canonicalWebUrl) {
     if (canonicalWebUrl == null) {
       logger.atSevere().log(
diff --git a/java/com/google/gerrit/httpd/gitweb/GitwebServlet.java b/java/com/google/gerrit/httpd/gitweb/GitwebServlet.java
index 4c31253..d6718ca 100644
--- a/java/com/google/gerrit/httpd/gitweb/GitwebServlet.java
+++ b/java/com/google/gerrit/httpd/gitweb/GitwebServlet.java
@@ -79,6 +79,7 @@
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
+import java.util.Locale;
 import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
@@ -111,6 +112,7 @@
   private final Provider<CurrentUser> userProvider;
   private final EnvList _env;
 
+  @SuppressWarnings("CheckReturnValue")
   @Inject
   GitwebServlet(
       GitRepositoryManager repoManager,
@@ -158,7 +160,7 @@
 
     if (!_env.envMap.containsKey("SystemRoot")) {
       String os = System.getProperty("os.name");
-      if (os != null && os.toLowerCase().contains("windows")) {
+      if (os != null && os.toLowerCase(Locale.US).contains("windows")) {
         String sysroot = System.getenv("SystemRoot");
         if (sysroot == null || sysroot.isEmpty()) {
           sysroot = "C:\\WINDOWS";
@@ -575,7 +577,7 @@
 
     for (String name : getHeaderNames(req)) {
       final String value = req.getHeader(name);
-      env.set("HTTP_" + name.toUpperCase().replace('-', '_'), value);
+      env.set("HTTP_" + name.toUpperCase(Locale.US).replace('-', '_'), value);
     }
 
     Project.NameKey nameKey = projectState.getNameKey();
diff --git a/java/com/google/gerrit/httpd/init/WebAppInitializer.java b/java/com/google/gerrit/httpd/init/WebAppInitializer.java
index dcabac0..e3cc0a5 100644
--- a/java/com/google/gerrit/httpd/init/WebAppInitializer.java
+++ b/java/com/google/gerrit/httpd/init/WebAppInitializer.java
@@ -46,6 +46,7 @@
 import com.google.gerrit.lucene.LuceneIndexModule;
 import com.google.gerrit.metrics.dropwizard.DropWizardMetricMaker;
 import com.google.gerrit.pgm.util.LogFileCompressor.LogFileCompressorModule;
+import com.google.gerrit.server.DefaultRefLogIdentityProvider;
 import com.google.gerrit.server.LibModuleLoader;
 import com.google.gerrit.server.LibModuleType;
 import com.google.gerrit.server.ModuleOverloader;
@@ -55,6 +56,7 @@
 import com.google.gerrit.server.account.externalids.ExternalIdCaseSensitivityMigrator;
 import com.google.gerrit.server.api.GerritApiModule;
 import com.google.gerrit.server.api.PluginApiModule;
+import com.google.gerrit.server.api.projects.ProjectQueryBuilderModule;
 import com.google.gerrit.server.audit.AuditModule;
 import com.google.gerrit.server.cache.h2.H2CacheModule;
 import com.google.gerrit.server.cache.mem.DefaultMemoryCacheModule;
@@ -308,6 +310,8 @@
     modules.add(new MimeUtil2Module());
     modules.add(cfgInjector.getInstance(GerritGlobalModule.class));
     modules.add(new GerritApiModule());
+    modules.add(new ProjectQueryBuilderModule());
+    modules.add(new DefaultRefLogIdentityProvider.Module());
     modules.add(new PluginApiModule());
     modules.add(new ChangesByProjectCache.Module(ChangesByProjectCache.UseIndex.TRUE, config));
     modules.add(new InternalAccountDirectoryModule());
diff --git a/java/com/google/gerrit/httpd/plugins/HttpPluginServlet.java b/java/com/google/gerrit/httpd/plugins/HttpPluginServlet.java
index a03aa36..9b8f4c6 100644
--- a/java/com/google/gerrit/httpd/plugins/HttpPluginServlet.java
+++ b/java/com/google/gerrit/httpd/plugins/HttpPluginServlet.java
@@ -35,6 +35,7 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.common.io.ByteStreams;
 import com.google.common.net.HttpHeaders;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.httpd.resources.Resource;
 import com.google.gerrit.httpd.resources.ResourceKey;
 import com.google.gerrit.httpd.resources.SmallResource;
@@ -174,6 +175,7 @@
     plugins.put(name, holder);
   }
 
+  @Nullable
   private GuiceFilter load(Plugin plugin) {
     if (plugin.getHttpInjector() != null) {
       final String name = plugin.getName();
@@ -327,6 +329,7 @@
     }
   }
 
+  @Nullable
   private static Pattern makeAllowOrigin(Config cfg) {
     String[] allow = cfg.getStringList("site", null, "allowOriginRegex");
     if (allow.length > 0) {
@@ -720,6 +723,7 @@
       this.docPrefix = getPrefix(plugin, "Gerrit-HttpDocumentationPrefix", "Documentation/");
     }
 
+    @Nullable
     private static String getPrefix(Plugin plugin, String attr, String def) {
       Path path = plugin.getSrcFile();
       PluginContentScanner scanner = plugin.getContentScanner();
diff --git a/java/com/google/gerrit/httpd/plugins/LfsPluginServlet.java b/java/com/google/gerrit/httpd/plugins/LfsPluginServlet.java
index fc0ec39..ed29629 100644
--- a/java/com/google/gerrit/httpd/plugins/LfsPluginServlet.java
+++ b/java/com/google/gerrit/httpd/plugins/LfsPluginServlet.java
@@ -19,6 +19,7 @@
 import static javax.servlet.http.HttpServletResponse.SC_NOT_IMPLEMENTED;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.httpd.resources.Resource;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.plugins.Plugin;
@@ -119,6 +120,7 @@
     filter.set(guiceFilter);
   }
 
+  @Nullable
   private GuiceFilter load(Plugin plugin) {
     if (plugin.getHttpInjector() != null) {
       final String name = plugin.getName();
diff --git a/java/com/google/gerrit/httpd/raw/DirectoryDocServlet.java b/java/com/google/gerrit/httpd/raw/DirectoryDocServlet.java
index c13286e..3f59084 100644
--- a/java/com/google/gerrit/httpd/raw/DirectoryDocServlet.java
+++ b/java/com/google/gerrit/httpd/raw/DirectoryDocServlet.java
@@ -15,15 +15,17 @@
 package com.google.gerrit.httpd.raw;
 
 import com.google.common.cache.Cache;
+import com.google.gerrit.server.experiments.ExperimentFeatures;
 import java.nio.file.Path;
 
-class DirectoryDocServlet extends ResourceServlet {
+class DirectoryDocServlet extends DocServlet {
   private static final long serialVersionUID = 1L;
 
   private final Path doc;
 
-  DirectoryDocServlet(Cache<Path, Resource> cache, Path unpackedWar) {
-    super(cache, true);
+  DirectoryDocServlet(
+      Cache<Path, Resource> cache, Path unpackedWar, ExperimentFeatures experimentFeatures) {
+    super(cache, true, experimentFeatures);
     this.doc = unpackedWar.resolve("Documentation");
   }
 
diff --git a/java/com/google/gerrit/httpd/raw/DocServlet.java b/java/com/google/gerrit/httpd/raw/DocServlet.java
new file mode 100644
index 0000000..d5027ba
--- /dev/null
+++ b/java/com/google/gerrit/httpd/raw/DocServlet.java
@@ -0,0 +1,65 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.httpd.raw;
+
+import static com.google.gerrit.server.experiments.ExperimentFeaturesConstants.GERRIT_BACKEND_FEATURE_ATTACH_NONCE_TO_DOCUMENTATION;
+
+import com.google.common.cache.Cache;
+import com.google.gerrit.httpd.HtmlDomUtil;
+import com.google.gerrit.server.experiments.ExperimentFeatures;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Path;
+import java.util.Optional;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+abstract class DocServlet extends ResourceServlet {
+  private static final long serialVersionUID = 1L;
+
+  private final ExperimentFeatures experimentFeatures;
+
+  DocServlet(Cache<Path, Resource> cache, boolean refresh, ExperimentFeatures experimentFeatures) {
+    super(cache, refresh);
+    this.experimentFeatures = experimentFeatures;
+  }
+
+  @Override
+  protected boolean shouldProcessResourceBeforeServe(
+      HttpServletRequest req, HttpServletResponse rsp, Path p) {
+    String nonce = (String) req.getAttribute("nonce");
+    if (!experimentFeatures.isFeatureEnabled(GERRIT_BACKEND_FEATURE_ATTACH_NONCE_TO_DOCUMENTATION)
+        || nonce == null) {
+      return false;
+    }
+    return ResourceServlet.contentType(p.toString()).equals("text/html");
+  }
+
+  @Override
+  protected Resource processResourceBeforeServe(
+      HttpServletRequest req, HttpServletResponse rsp, Resource resource) {
+    // ResourceServlet doesn't set character encoding for a resource. Gerrit will
+    // default to setting charset to utf-8, if none provided. So we guess UTF_8 here.
+    Optional<String> updatedHtml =
+        HtmlDomUtil.attachNonce(
+            new String(resource.raw, StandardCharsets.UTF_8), (String) req.getAttribute("nonce"));
+    if (updatedHtml.isEmpty()) {
+      return resource;
+    }
+    return new Resource(
+        resource.lastModified,
+        resource.contentType,
+        updatedHtml.get().getBytes(StandardCharsets.UTF_8));
+  }
+}
diff --git a/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java b/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java
index 72bfe40..fb28d30 100644
--- a/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java
+++ b/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java
@@ -101,6 +101,7 @@
             IndexPreloadingUtil.computeChangeRequestsPath(requestedPath, page).get());
         data.put("changeNum", IndexPreloadingUtil.computeChangeNum(requestedPath, page).get());
         break;
+      case PROFILE:
       case DASHBOARD:
         // Dashboard is preloaded queries are added later when we check user is authenticated.
       case PAGE_WITHOUT_PRELOADING:
@@ -121,7 +122,7 @@
       data.put("userIsAuthenticated", true);
       if (page == RequestedPage.DASHBOARD) {
         data.put("defaultDashboardHex", ListOption.toHex(IndexPreloadingUtil.DASHBOARD_OPTIONS));
-        data.put("dashboardQuery", IndexPreloadingUtil.computeDashboardQueryList(serverApi));
+        data.put("dashboardQuery", IndexPreloadingUtil.computeDashboardQueryList());
       }
     } catch (AuthException e) {
       logger.atFine().log("Can't inline account-related data because user is unauthenticated");
diff --git a/java/com/google/gerrit/httpd/raw/IndexPreloadingUtil.java b/java/com/google/gerrit/httpd/raw/IndexPreloadingUtil.java
index f7c3681..36fa61b 100644
--- a/java/com/google/gerrit/httpd/raw/IndexPreloadingUtil.java
+++ b/java/com/google/gerrit/httpd/raw/IndexPreloadingUtil.java
@@ -22,9 +22,7 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.common.UsedAt.Project;
-import com.google.gerrit.extensions.api.config.Server;
 import com.google.gerrit.extensions.client.ListChangesOption;
-import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.Url;
 import java.net.URI;
 import java.net.URISyntaxException;
@@ -42,6 +40,7 @@
     CHANGE,
     DIFF,
     DASHBOARD,
+    PROFILE,
     PAGE_WITHOUT_PRELOADING,
   }
 
@@ -52,31 +51,28 @@
   public static final Pattern DIFF_URL_PATTERN =
       Pattern.compile(CHANGE_CANONICAL_PATH + BASE_PATCH_NUM_PATH_PART + "(/(.+))" + "/?$");
   public static final Pattern DASHBOARD_PATTERN = Pattern.compile("/dashboard/self$");
+  public static final Pattern PROFILE_PATTERN = Pattern.compile("/profile/self$");
   public static final String ROOT_PATH = "/";
 
   // These queries should be kept in sync with PolyGerrit:
   // polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts
   public static final String DASHBOARD_HAS_UNPUBLISHED_DRAFTS_QUERY = "has:draft limit:10";
   public static final String YOUR_TURN = "attention:${user} limit:25";
-  public static final String DASHBOARD_ASSIGNED_QUERY =
-      "assignee:${user} (-is:wip OR " + "owner:self OR assignee:self) is:open limit:25";
   public static final String DASHBOARD_WORK_IN_PROGRESS_QUERY =
       "is:open owner:${user} is:wip limit:25";
   public static final String DASHBOARD_OUTGOING_QUERY = "is:open owner:${user} -is:wip limit:25";
   public static final String DASHBOARD_INCOMING_QUERY =
-      "is:open -owner:${user} -is:wip (reviewer:${user} OR assignee:${user}) limit:25";
+      "is:open -owner:${user} -is:wip reviewer:${user} limit:25";
   public static final String CC_QUERY = "is:open -is:wip cc:${user} limit:10";
   public static final String DASHBOARD_RECENTLY_CLOSED_QUERY =
       "is:closed (-is:wip OR owner:self) "
-          + "(owner:${user} OR reviewer:${user} OR assignee:${user} "
-          + "OR cc:${user}) -age:4w limit:10";
+          + "(owner:${user} OR reviewer:${user} OR cc:${user}) "
+          + "-age:4w limit:10";
   public static final String NEW_USER = "owner:${user} limit:1";
 
   public static final String SELF_DASHBOARD_HAS_UNPUBLISHED_DRAFTS_QUERY =
       DASHBOARD_HAS_UNPUBLISHED_DRAFTS_QUERY.replaceAll("\\$\\{user}", "self");
   public static final String SELF_YOUR_TURN = YOUR_TURN.replaceAll("\\$\\{user}", "self");
-  public static final String SELF_DASHBOARD_ASSIGNED_QUERY =
-      DASHBOARD_ASSIGNED_QUERY.replaceAll("\\$\\{user}", "self");
   public static final ImmutableList<String> SELF_DASHBOARD_QUERIES =
       Stream.of(
               DASHBOARD_WORK_IN_PROGRESS_QUERY,
@@ -107,6 +103,7 @@
           ListChangesOption.SKIP_DIFFSTAT,
           ListChangesOption.SUBMIT_REQUIREMENTS);
 
+  @Nullable
   public static String getPath(@Nullable String requestedURL) throws URISyntaxException {
     if (requestedURL == null) {
       return null;
@@ -136,6 +133,11 @@
       return RequestedPage.DASHBOARD;
     }
 
+    Matcher profileMatcher = IndexPreloadingUtil.PROFILE_PATTERN.matcher(requestedPath);
+    if (profileMatcher.matches()) {
+      return RequestedPage.PROFILE;
+    }
+
     if (ROOT_PATH.equals(requestedPath)) {
       return RequestedPage.DASHBOARD;
     }
@@ -154,6 +156,7 @@
         matcher = DIFF_URL_PATTERN.matcher(requestedURL);
         break;
       case DASHBOARD:
+      case PROFILE:
       case PAGE_WITHOUT_PRELOADING:
       default:
         return Optional.empty();
@@ -178,6 +181,7 @@
         matcher = DIFF_URL_PATTERN.matcher(requestedURL);
         break;
       case DASHBOARD:
+      case PROFILE:
       case PAGE_WITHOUT_PRELOADING:
       default:
         return Optional.empty();
@@ -192,34 +196,14 @@
     return Optional.empty();
   }
 
-  public static List<String> computeDashboardQueryList(Server serverApi) throws RestApiException {
+  public static List<String> computeDashboardQueryList() {
     List<String> queryList = new ArrayList<>();
     queryList.add(SELF_DASHBOARD_HAS_UNPUBLISHED_DRAFTS_QUERY);
-    if (isEnabledAttentionSet(serverApi)) {
-      queryList.add(SELF_YOUR_TURN);
-    }
-    if (isEnabledAssignee(serverApi)) {
-      queryList.add(SELF_DASHBOARD_ASSIGNED_QUERY);
-    }
-
+    queryList.add(SELF_YOUR_TURN);
     queryList.addAll(SELF_DASHBOARD_QUERIES);
 
     return queryList;
   }
 
-  private static boolean isEnabledAttentionSet(Server serverApi) throws RestApiException {
-    return serverApi.getInfo() != null
-        && serverApi.getInfo().change != null
-        && serverApi.getInfo().change.enableAttentionSet != null
-        && serverApi.getInfo().change.enableAttentionSet;
-  }
-
-  private static boolean isEnabledAssignee(Server serverApi) throws RestApiException {
-    return serverApi.getInfo() != null
-        && serverApi.getInfo().change != null
-        && serverApi.getInfo().change.enableAssignee != null
-        && serverApi.getInfo().change.enableAssignee;
-  }
-
   private IndexPreloadingUtil() {}
 }
diff --git a/java/com/google/gerrit/httpd/raw/ResourceServlet.java b/java/com/google/gerrit/httpd/raw/ResourceServlet.java
index 8be4abc..871ec78 100644
--- a/java/com/google/gerrit/httpd/raw/ResourceServlet.java
+++ b/java/com/google/gerrit/httpd/raw/ResourceServlet.java
@@ -126,6 +126,34 @@
    */
   protected abstract Path getResourcePath(String pathInfo) throws IOException;
 
+  /**
+   * Indicates that resource requires some processing before being served.
+   *
+   * <p>If true, the caching headers in response are set to not cache. Additionally, streaming
+   * option is disabled.
+   *
+   * @param req the HTTP servlet request
+   * @param rsp the HTTP servlet response
+   * @param p URL path
+   * @return true if the {@link #processResourceBeforeServe(HttpServletRequest, HttpServletResponse,
+   *     Resource)} should be called.
+   */
+  protected boolean shouldProcessResourceBeforeServe(
+      HttpServletRequest req, HttpServletResponse rsp, Path p) {
+    return false;
+  }
+
+  /**
+   * Edits the resource before adding it to the response.
+   *
+   * @param req the HTTP servlet request
+   * @param rsp the HTTP servlet response
+   */
+  protected Resource processResourceBeforeServe(
+      HttpServletRequest req, HttpServletResponse rsp, Resource resource) {
+    return resource;
+  }
+
   protected FileTime getLastModifiedTime(Path p) throws IOException {
     return Files.getLastModifiedTime(p);
   }
@@ -148,10 +176,11 @@
       return;
     }
 
+    boolean requiresPostProcess = shouldProcessResourceBeforeServe(req, rsp, p);
     Resource r = cache.getIfPresent(p);
     try {
       if (r == null) {
-        if (maybeStream(p, req, rsp)) {
+        if (!requiresPostProcess && maybeStream(p, req, rsp)) {
           return; // Bypass cache for large resource.
         }
         r = cache.get(p, newLoader(p));
@@ -176,11 +205,16 @@
       CacheHeaders.setNotCacheable(rsp);
       rsp.setStatus(SC_NOT_FOUND);
       return;
-    } else if (cacheOnClient && r.etag.equals(req.getHeader(IF_NONE_MATCH))) {
+    } else if (!requiresPostProcess
+        && cacheOnClient
+        && r.etag.equals(req.getHeader(IF_NONE_MATCH))) {
       rsp.setStatus(SC_NOT_MODIFIED);
       return;
     }
 
+    if (requiresPostProcess) {
+      r = processResourceBeforeServe(req, rsp, r);
+    }
     byte[] tosend = r.raw;
     if (!r.contentType.equals(JS) && RequestUtil.acceptsGzipEncoding(req)) {
       byte[] gz = HtmlDomUtil.compress(tosend);
@@ -190,7 +224,7 @@
       }
     }
 
-    if (cacheOnClient) {
+    if (!requiresPostProcess && cacheOnClient) {
       rsp.setHeader(ETAG, r.etag);
     } else {
       CacheHeaders.setNotCacheable(rsp);
diff --git a/java/com/google/gerrit/httpd/raw/StaticModule.java b/java/com/google/gerrit/httpd/raw/StaticModule.java
index 15dcf42..8319d9d 100644
--- a/java/com/google/gerrit/httpd/raw/StaticModule.java
+++ b/java/com/google/gerrit/httpd/raw/StaticModule.java
@@ -77,9 +77,9 @@
           "/x/*",
           "/admin/*",
           "/dashboard/*",
+          "/profile/*",
           "/groups/self",
           "/settings/*",
-          "/topic/*",
           "/Documentation/q/*");
 
   /**
@@ -144,12 +144,13 @@
   @Provides
   @Singleton
   @Named(DOC_SERVLET)
-  HttpServlet getDocServlet(@Named(CACHE) Cache<Path, Resource> cache) {
+  HttpServlet getDocServlet(
+      @Named(CACHE) Cache<Path, Resource> cache, ExperimentFeatures experimentFeatures) {
     Paths p = getPaths();
     if (p.warFs != null) {
-      return new WarDocServlet(cache, p.warFs);
+      return new WarDocServlet(cache, p.warFs, experimentFeatures);
     } else if (p.unpackedWar != null && !p.isDev()) {
-      return new DirectoryDocServlet(cache, p.unpackedWar);
+      return new DirectoryDocServlet(cache, p.unpackedWar, experimentFeatures);
     } else {
       return new HttpServlet() {
         private static final long serialVersionUID = 1L;
@@ -305,6 +306,7 @@
       sourceRoot = getSourceRootOrNull();
     }
 
+    @Nullable
     private static Path getSourceRootOrNull() {
       try {
         return GerritLauncher.resolveInSourceRoot(".");
@@ -313,6 +315,7 @@
       }
     }
 
+    @Nullable
     private FileSystem getDistributionArchive(File war) throws IOException {
       if (war == null) {
         return null;
@@ -320,6 +323,7 @@
       return GerritLauncher.getZipFileSystem(war.toPath());
     }
 
+    @Nullable
     private File getLauncherLoadedFrom() {
       File war;
       try {
@@ -441,6 +445,7 @@
       super(req);
     }
 
+    @Nullable
     @Override
     public String getPathInfo() {
       String uri = getRequestURI();
diff --git a/java/com/google/gerrit/httpd/raw/WarDocServlet.java b/java/com/google/gerrit/httpd/raw/WarDocServlet.java
index 27520e3..718d46d 100644
--- a/java/com/google/gerrit/httpd/raw/WarDocServlet.java
+++ b/java/com/google/gerrit/httpd/raw/WarDocServlet.java
@@ -15,20 +15,22 @@
 package com.google.gerrit.httpd.raw;
 
 import com.google.common.cache.Cache;
+import com.google.gerrit.server.experiments.ExperimentFeatures;
 import com.google.gerrit.server.util.time.TimeUtil;
 import java.nio.file.FileSystem;
 import java.nio.file.Path;
 import java.nio.file.attribute.FileTime;
 
-class WarDocServlet extends ResourceServlet {
+class WarDocServlet extends DocServlet {
   private static final long serialVersionUID = 1L;
 
   private static final FileTime NOW = FileTime.fromMillis(TimeUtil.nowMs());
 
   private final FileSystem warFs;
 
-  WarDocServlet(Cache<Path, Resource> cache, FileSystem warFs) {
-    super(cache, false);
+  WarDocServlet(
+      Cache<Path, Resource> cache, FileSystem warFs, ExperimentFeatures experimentFeatures) {
+    super(cache, false, experimentFeatures);
     this.warFs = warFs;
   }
 
diff --git a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
index 543e794..44e7854 100644
--- a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
+++ b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
@@ -30,7 +30,6 @@
 import static com.google.common.net.HttpHeaders.CONTENT_TYPE;
 import static com.google.common.net.HttpHeaders.ORIGIN;
 import static com.google.common.net.HttpHeaders.VARY;
-import static com.google.gerrit.server.experiments.ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_REMOVE_REVISION_ETAG;
 import static java.math.RoundingMode.CEILING;
 import static java.nio.charset.StandardCharsets.ISO_8859_1;
 import static java.nio.charset.StandardCharsets.UTF_8;
@@ -120,9 +119,7 @@
 import com.google.gerrit.server.cancellation.RequestStateContext;
 import com.google.gerrit.server.cancellation.RequestStateProvider;
 import com.google.gerrit.server.change.ChangeFinder;
-import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.experiments.ExperimentFeatures;
 import com.google.gerrit.server.group.GroupAuditService;
 import com.google.gerrit.server.logging.Metadata;
 import com.google.gerrit.server.logging.PerformanceLogContext;
@@ -270,7 +267,6 @@
     final PluginSetContext<ExceptionHook> exceptionHooks;
     final Injector injector;
     final DynamicMap<DynamicOptions.DynamicBean> dynamicBeans;
-    final ExperimentFeatures experimentFeatures;
     final DeadlineChecker.Factory deadlineCheckerFactory;
     final CancellationMetrics cancellationMetrics;
 
@@ -291,7 +287,6 @@
         PluginSetContext<ExceptionHook> exceptionHooks,
         Injector injector,
         DynamicMap<DynamicOptions.DynamicBean> dynamicBeans,
-        ExperimentFeatures experimentFeatures,
         DeadlineChecker.Factory deadlineCheckerFactory,
         CancellationMetrics cancellationMetrics) {
       this.currentUser = currentUser;
@@ -310,11 +305,11 @@
       allowOrigin = makeAllowOrigin(config);
       this.injector = injector;
       this.dynamicBeans = dynamicBeans;
-      this.experimentFeatures = experimentFeatures;
       this.deadlineCheckerFactory = deadlineCheckerFactory;
       this.cancellationMetrics = cancellationMetrics;
     }
 
+    @Nullable
     private static Pattern makeAllowOrigin(Config cfg) {
       String[] allow = cfg.getStringList("site", null, "allowOriginRegex");
       if (allow.length > 0) {
@@ -854,17 +849,13 @@
     }
   }
 
+  @Nullable
   private String getEtagWithRetry(
       HttpServletRequest req, TraceContext traceContext, RestResource.HasETag rsrc) {
     try (TraceTimer ignored =
         TraceContext.newTimer(
             "RestApiServlet#getEtagWithRetry:resource",
             Metadata.builder().restViewName(rsrc.getClass().getSimpleName()).build())) {
-      if (rsrc instanceof RevisionResource
-          && globals.experimentFeatures.isFeatureEnabled(
-              GERRIT_BACKEND_REQUEST_FEATURE_REMOVE_REVISION_ETAG)) {
-        return null;
-      }
       return invokeRestEndpointWithRetry(
           req,
           traceContext,
@@ -1277,6 +1268,7 @@
     return ((ParameterizedType) supertype).getActualTypeArguments()[2];
   }
 
+  @Nullable
   private Object parseRequest(HttpServletRequest req, Type type)
       throws IOException, BadRequestException, SecurityException, IllegalArgumentException,
           NoSuchMethodException, IllegalAccessException, InstantiationException,
@@ -1394,22 +1386,21 @@
     throw new BadRequestException("Expected JSON object");
   }
 
-  @SuppressWarnings("unchecked")
   private static Object createInstance(Type type)
       throws NoSuchMethodException, InstantiationException, IllegalAccessException,
           InvocationTargetException {
     if (type instanceof Class) {
-      Class<Object> clazz = (Class<Object>) type;
-      Constructor<Object> c = clazz.getDeclaredConstructor();
+      Class<?> clazz = (Class<?>) type;
+      Constructor<?> c = clazz.getDeclaredConstructor();
       c.setAccessible(true);
       return c.newInstance();
     }
     if (type instanceof ParameterizedType) {
       Type rawType = ((ParameterizedType) type).getRawType();
-      if (rawType instanceof Class && List.class.isAssignableFrom((Class<Object>) rawType)) {
+      if (rawType instanceof Class && List.class.isAssignableFrom((Class<?>) rawType)) {
         return new ArrayList<>();
       }
-      if (rawType instanceof Class && Map.class.isAssignableFrom((Class<Object>) rawType)) {
+      if (rawType instanceof Class && Map.class.isAssignableFrom((Class<?>) rawType)) {
         return new HashMap<>();
       }
     }
diff --git a/java/com/google/gerrit/httpd/template/SiteHeaderFooter.java b/java/com/google/gerrit/httpd/template/SiteHeaderFooter.java
index 655f4ca..2065a31 100644
--- a/java/com/google/gerrit/httpd/template/SiteHeaderFooter.java
+++ b/java/com/google/gerrit/httpd/template/SiteHeaderFooter.java
@@ -18,6 +18,7 @@
 
 import com.google.common.base.Strings;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.httpd.HtmlDomUtil;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SitePaths;
@@ -125,6 +126,7 @@
       return cssFile.isStale() || headerFile.isStale() || footerFile.isStale();
     }
 
+    @Nullable
     private static Element readXml(FileInfo src) throws IOException {
       Document d = HtmlDomUtil.parseFile(src.path);
       return d != null ? d.getDocumentElement() : null;
diff --git a/java/com/google/gerrit/index/FieldDef.java b/java/com/google/gerrit/index/FieldDef.java
deleted file mode 100644
index 1c2074b..0000000
--- a/java/com/google/gerrit/index/FieldDef.java
+++ /dev/null
@@ -1,215 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.index;
-
-import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.common.base.Preconditions.checkState;
-import static java.util.Objects.requireNonNull;
-
-import com.google.common.base.CharMatcher;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.index.SchemaFieldDefs.Getter;
-import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
-import com.google.gerrit.index.SchemaFieldDefs.Setter;
-import java.io.IOException;
-import java.sql.Timestamp;
-import java.util.Optional;
-
-/**
- * Definition of a field stored in the secondary index.
- *
- * <p>{@link FieldDef}-s must not be changed once introduced to the codebase. Instead, a new
- * FieldDef must be added and the old one removed from the schema (in two upgrade steps, see {@code
- * com.google.gerrit.index.IndexUpgradeValidator}).
- *
- * <p>Note that {@link FieldDef} does not override {@link Object#equals(Object)}. It relies on
- * instances being singletons so that the default (i.e. reference) comparison works.
- *
- * @param <I> input type from which documents are created and search results are returned.
- * @param <T> type that should be extracted from the input object when converting to an index
- *     document.
- */
-public final class FieldDef<I, T> implements SchemaField<I, T> {
-  public static FieldDef.Builder<String> exact(String name) {
-    return new FieldDef.Builder<>(FieldType.EXACT, name);
-  }
-
-  public static FieldDef.Builder<String> fullText(String name) {
-    return new FieldDef.Builder<>(FieldType.FULL_TEXT, name);
-  }
-
-  public static FieldDef.Builder<Integer> intRange(String name) {
-    return new FieldDef.Builder<>(FieldType.INTEGER_RANGE, name).stored();
-  }
-
-  public static FieldDef.Builder<Integer> integer(String name) {
-    return new FieldDef.Builder<>(FieldType.INTEGER, name);
-  }
-
-  public static FieldDef.Builder<String> prefix(String name) {
-    return new FieldDef.Builder<>(FieldType.PREFIX, name);
-  }
-
-  public static FieldDef.Builder<byte[]> storedOnly(String name) {
-    return new FieldDef.Builder<>(FieldType.STORED_ONLY, name).stored();
-  }
-
-  public static FieldDef.Builder<Timestamp> timestamp(String name) {
-    return new FieldDef.Builder<>(FieldType.TIMESTAMP, name);
-  }
-
-  public static class Builder<T> {
-    private final FieldType<T> type;
-    private final String name;
-    private boolean stored;
-
-    public Builder(FieldType<T> type, String name) {
-      this.type = requireNonNull(type);
-      this.name = requireNonNull(name);
-    }
-
-    public Builder<T> stored() {
-      this.stored = true;
-      return this;
-    }
-
-    public <I> FieldDef<I, T> build(Getter<I, T> getter) {
-      return new FieldDef<>(name, type, stored, false, getter, null);
-    }
-
-    public <I> FieldDef<I, T> build(Getter<I, T> getter, Setter<I, T> setter) {
-      return new FieldDef<>(name, type, stored, false, getter, setter);
-    }
-
-    public <I> FieldDef<I, Iterable<T>> buildRepeatable(Getter<I, Iterable<T>> getter) {
-      return new FieldDef<>(name, type, stored, true, getter, null);
-    }
-
-    public <I> FieldDef<I, Iterable<T>> buildRepeatable(
-        Getter<I, Iterable<T>> getter, Setter<I, Iterable<T>> setter) {
-      return new FieldDef<>(name, type, stored, true, getter, setter);
-    }
-  }
-
-  private final String name;
-  private final FieldType<?> type;
-  /** Allow reading the actual data from the index. */
-  private final boolean stored;
-
-  private final boolean repeatable;
-  private final Getter<I, T> getter;
-  private final Optional<Setter<I, T>> setter;
-
-  private FieldDef(
-      String name,
-      FieldType<?> type,
-      boolean stored,
-      boolean repeatable,
-      Getter<I, T> getter,
-      @Nullable Setter<I, T> setter) {
-    checkArgument(
-        !(repeatable && type == FieldType.INTEGER_RANGE),
-        "Range queries against repeated fields are unsupported");
-    this.name = checkName(name);
-    this.type = requireNonNull(type);
-    this.stored = stored;
-    this.repeatable = repeatable;
-    this.getter = requireNonNull(getter);
-    this.setter = Optional.ofNullable(setter);
-  }
-
-  private static String checkName(String name) {
-    CharMatcher m = CharMatcher.anyOf("abcdefghijklmnopqrstuvwxyz0123456789_");
-    checkArgument(name != null && m.matchesAllOf(name), "illegal field name: %s", name);
-    return name;
-  }
-
-  /** Returns name of the field. */
-  @Override
-  public String getName() {
-    return name;
-  }
-
-  /** Returns type of the field; for repeatable fields, the inner type, not the iterable type. */
-  @Override
-  public FieldType<?> getType() {
-    return type;
-  }
-
-  /** Returns whether the field should be stored in the index. */
-  @Override
-  public boolean isStored() {
-    return stored;
-  }
-
-  /**
-   * Get the field contents from the input object.
-   *
-   * @param input input object.
-   * @return the field value(s) to index.
-   */
-  @Override
-  @Nullable
-  public T get(I input) {
-    try {
-      return getter.get(input);
-    } catch (IOException e) {
-      throw new StorageException(e);
-    }
-  }
-
-  /**
-   * Set the field contents back to an object. Used to reconstruct fields from indexed values. No-op
-   * if the field can't be reconstructed.
-   *
-   * @param object input object.
-   * @param doc indexed document
-   * @return {@code true} if the field was set, {@code false} otherwise
-   */
-  @SuppressWarnings("unchecked")
-  @Override
-  public boolean setIfPossible(I object, StoredValue doc) {
-    if (!setter.isPresent()) {
-      return false;
-    }
-
-    if (FieldType.STRING_TYPES.stream().anyMatch(t -> t.getName().equals(getType().getName()))) {
-      setter.get().set(object, (T) (isRepeatable() ? doc.asStrings() : doc.asString()));
-      return true;
-    } else if (FieldType.INTEGER_TYPES.stream()
-        .anyMatch(t -> t.getName().equals(getType().getName()))) {
-      setter.get().set(object, (T) (isRepeatable() ? doc.asIntegers() : doc.asInteger()));
-      return true;
-    } else if (FieldType.LONG.getName().equals(getType().getName())) {
-      setter.get().set(object, (T) (isRepeatable() ? doc.asLongs() : doc.asLong()));
-      return true;
-    } else if (FieldType.STORED_ONLY.getName().equals(getType().getName())) {
-      setter.get().set(object, (T) (isRepeatable() ? doc.asByteArrays() : doc.asByteArray()));
-      return true;
-    } else if (FieldType.TIMESTAMP.getName().equals(getType().getName())) {
-      checkState(!isRepeatable(), "can't repeat timestamp values");
-      setter.get().set(object, (T) doc.asTimestamp());
-      return true;
-    }
-    return false;
-  }
-
-  /** Returns whether the field is repeatable. */
-  @Override
-  public boolean isRepeatable() {
-    return repeatable;
-  }
-}
diff --git a/java/com/google/gerrit/index/Index.java b/java/com/google/gerrit/index/Index.java
index cc3117d..870d827 100644
--- a/java/com/google/gerrit/index/Index.java
+++ b/java/com/google/gerrit/index/Index.java
@@ -60,6 +60,9 @@
    */
   void replace(V obj);
 
+  /** Delete a document from the index by value */
+  void deleteByValue(V value);
+
   /**
    * Delete a document from the index by key.
    *
@@ -153,4 +156,14 @@
   default boolean isEnabled() {
     return true;
   }
+
+  /**
+   * Rewriter that should be invoked on queries to this index.
+   *
+   * <p>The default implementation does not do anything. Should be overridden by implementation, if
+   * needed.
+   */
+  default IndexRewriter<V> getIndexRewriter() {
+    return (in, opts) -> in;
+  }
 }
diff --git a/java/com/google/gerrit/index/IndexType.java b/java/com/google/gerrit/index/IndexType.java
index 75f8351..d8c8f6a 100644
--- a/java/com/google/gerrit/index/IndexType.java
+++ b/java/com/google/gerrit/index/IndexType.java
@@ -19,6 +19,7 @@
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.common.Nullable;
+import java.util.Locale;
 import java.util.Optional;
 
 /**
@@ -51,7 +52,7 @@
     if (Strings.isNullOrEmpty(value)) {
       return Optional.empty();
     }
-    value = value.toUpperCase().replace("-", "_");
+    value = value.toUpperCase(Locale.US).replace("-", "_");
     IndexType type = new IndexType(value);
     if (!Strings.isNullOrEmpty(System.getenv(ENV_VAR))) {
       checkArgument(
@@ -67,7 +68,7 @@
   }
 
   public IndexType(@Nullable String type) {
-    this.type = type == null ? getDefault() : type.toLowerCase();
+    this.type = type == null ? getDefault() : type.toLowerCase(Locale.US);
   }
 
   public static String getDefault() {
diff --git a/java/com/google/gerrit/index/IndexedField.java b/java/com/google/gerrit/index/IndexedField.java
index e44f562..94943d6 100644
--- a/java/com/google/gerrit/index/IndexedField.java
+++ b/java/com/google/gerrit/index/IndexedField.java
@@ -36,6 +36,7 @@
 import java.lang.reflect.Type;
 import java.sql.Timestamp;
 import java.util.HashMap;
+import java.util.Locale;
 import java.util.Map;
 import java.util.Optional;
 import java.util.stream.StreamSupport;
@@ -118,7 +119,7 @@
   /**
    * Defines how {@link IndexedField} can be searched and how the index tokens are generated.
    *
-   * <p>Multiple {@link SearchSpec} can be defined on single {@link IndexedField}.
+   * <p>Multiple {@link SearchSpec} can be defined on a single {@link IndexedField}.
    *
    * <p>Depending on the implementation, indexes can choose to store {@link IndexedField} and {@link
    * SearchSpec} separately. The searches are issues to {@link SearchSpec}.
@@ -249,6 +250,8 @@
 
   public SearchSpec integerRange(String name) {
     checkState(fieldType().equals(INTEGER_TYPE));
+    // we currently store all integer range fields, this may change in the future
+    checkState(stored());
     return addSearchSpec(name, SearchOption.RANGE);
   }
 
@@ -349,7 +352,8 @@
 
     private static String checkName(String name) {
       String allowedCharacters = "abcdefghijklmnopqrstuvwxyz0123456789_";
-      CharMatcher m = CharMatcher.anyOf(allowedCharacters + allowedCharacters.toUpperCase());
+      CharMatcher m =
+          CharMatcher.anyOf(allowedCharacters + allowedCharacters.toUpperCase(Locale.US));
       checkArgument(name != null && m.matchesAllOf(name), "illegal field name: %s", name);
       return name;
     }
@@ -357,13 +361,22 @@
 
   private Map<String, SearchSpec> searchSpecs = new HashMap<>();
 
-  /** The name to store this field under. */
+  /**
+   * The name to store this field under.
+   *
+   * <p>The name should use the UpperCamelCase format, see {@link Builder#checkName}.
+   */
   public abstract String name();
 
   /** Optional description of the field data. */
   public abstract Optional<String> description();
 
-  /** True if this field is mandatory. Default is false. */
+  /**
+   * True if this field is mandatory. Default is false.
+   *
+   * <p>This property is not enforced by the common indexing logic. It is up to the index
+   * implementations to enforce that the field is required.
+   */
   public abstract boolean required();
 
   /** Allow reading the actual data from the index. Default is false. */
@@ -375,6 +388,11 @@
   /**
    * Optional size constrain on the field. The size is not constrained if this property is {@link
    * Optional#empty()}
+   *
+   * <p>This property is not enforced by the common indexing logic. It is up to the index
+   * implementations to enforce the size.
+   *
+   * <p>If the field is {@link #repeatable()}, the constraint applies to each element separately.
    */
   public abstract Optional<Integer> size();
 
diff --git a/java/com/google/gerrit/index/Schema.java b/java/com/google/gerrit/index/Schema.java
index dda9ce5..974bb74 100644
--- a/java/com/google/gerrit/index/Schema.java
+++ b/java/com/google/gerrit/index/Schema.java
@@ -63,22 +63,6 @@
     }
 
     @SafeVarargs
-    public final Builder<T> add(FieldDef<T, ?>... fields) {
-      return add(ImmutableList.copyOf(fields));
-    }
-
-    public final Builder<T> add(ImmutableList<FieldDef<T, ?>> fields) {
-      this.searchFields.addAll(fields);
-      return this;
-    }
-
-    @SafeVarargs
-    public final Builder<T> remove(FieldDef<T, ?>... fields) {
-      this.searchFields.removeAll(Arrays.asList(fields));
-      return this;
-    }
-
-    @SafeVarargs
     public final Builder<T> addSearchSpecs(IndexedField<T, ?>.SearchSpec... searchSpecs) {
       return addSearchSpecs(ImmutableList.copyOf(searchSpecs));
     }
@@ -202,14 +186,17 @@
    * @return all fields in this schema indexed by name.
    */
   public final ImmutableMap<String, SchemaField<T, ?>> getSchemaFields() {
-    return ImmutableMap.copyOf(schemaFields);
+    return schemaFields;
   }
 
   public final ImmutableMap<String, IndexedField<T, ?>> getIndexFields() {
     return indexedFields;
   }
 
-  /** Returns all fields in this schema where {@link FieldDef#isStored()} is true. */
+  /**
+   * Returns names of {@link SchemaField} fields in this schema where {@link SchemaField#isStored()}
+   * is true.
+   */
   public final ImmutableSet<String> getStoredFields() {
     return storedFields;
   }
diff --git a/java/com/google/gerrit/index/SchemaFieldDefs.java b/java/com/google/gerrit/index/SchemaFieldDefs.java
index e0b5dd2..36173c7 100644
--- a/java/com/google/gerrit/index/SchemaFieldDefs.java
+++ b/java/com/google/gerrit/index/SchemaFieldDefs.java
@@ -24,8 +24,8 @@
    * Definition of a field stored in the secondary index.
    *
    * <p>{@link SchemaField}-s must not be changed once introduced to the codebase. Instead, a new
-   * FieldDef must be added and the old one removed from the schema (in two upgrade steps, see
-   * {@code com.google.gerrit.index.IndexUpgradeValidator}).
+   * {@link SchemaField} must be added and the old one removed from the schema (in two upgrade
+   * steps, see {@code com.google.gerrit.index.IndexUpgradeValidator}).
    *
    * @param <I> input type from which documents are created and search results are returned.
    * @param <T> type that should be extracted from the input object when converting to an index
@@ -100,4 +100,12 @@
   public interface Setter<I, T> {
     void set(I object, T value);
   }
+
+  public static boolean isProtoField(SchemaField<?, ?> schemaField) {
+    if (!(schemaField instanceof IndexedField<?, ?>.SearchSpec)) {
+      return false;
+    }
+    IndexedField<?, ?> indexedField = ((IndexedField<?, ?>.SearchSpec) schemaField).getField();
+    return indexedField.isProtoType() || indexedField.isProtoIterableType();
+  }
 }
diff --git a/java/com/google/gerrit/index/SchemaUtil.java b/java/com/google/gerrit/index/SchemaUtil.java
index 8f47cf5..b358c63 100644
--- a/java/com/google/gerrit/index/SchemaUtil.java
+++ b/java/com/google/gerrit/index/SchemaUtil.java
@@ -70,33 +70,16 @@
     return ImmutableSortedMap.copyOf(schemas);
   }
 
-  @SafeVarargs
-  public static <V> Schema<V> schema(FieldDef<V, ?>... fields) {
-    return new Schema.Builder<V>().version(0).add(fields).build();
-  }
-
-  @SafeVarargs
-  public static <V> Schema<V> schema(int version, FieldDef<V, ?>... fields) {
-    return new Schema.Builder<V>().version(version).add(fields).build();
-  }
-
-  public static <V> Schema<V> schema(int version, ImmutableList<FieldDef<V, ?>> fields) {
-    return new Schema.Builder<V>().version(version).add(fields).build();
-  }
-
-  @SafeVarargs
-  public static <V> Schema<V> schema(Schema<V> schema, FieldDef<V, ?>... moreFields) {
-    return new Schema.Builder<V>().add(schema).add(moreFields).build();
+  public static <V> Schema<V> schema(int version) {
+    return new Schema.Builder<V>().version(version).build();
   }
 
   public static <V> Schema<V> schema(
       int version,
-      ImmutableList<FieldDef<V, ?>> fieldDefs,
       ImmutableList<IndexedField<V, ?>> indexedFields,
       ImmutableList<IndexedField<V, ?>.SearchSpec> searchSpecs) {
     return new Schema.Builder<V>()
         .version(version)
-        .add(fieldDefs)
         .addIndexedFields(indexedFields)
         .addSearchSpecs(searchSpecs)
         .build();
@@ -104,22 +87,23 @@
 
   public static <V> Schema<V> schema(
       Schema<V> schema,
-      ImmutableList<FieldDef<V, ?>> fieldDefs,
       ImmutableList<IndexedField<V, ?>> indexedFields,
       ImmutableList<IndexedField<V, ?>.SearchSpec> searchSpecs) {
     return new Schema.Builder<V>()
         .add(schema)
-        .add(fieldDefs)
         .addIndexedFields(indexedFields)
         .addSearchSpecs(searchSpecs)
         .build();
   }
 
+  public static <V> Schema<V> schema(Schema<V> schema) {
+    return new Schema.Builder<V>().add(schema).build();
+  }
+
   public static <V> Schema<V> schema(
-      ImmutableList<FieldDef<V, ?>> fieldDefs,
       ImmutableList<IndexedField<V, ?>> indexFields,
       ImmutableList<IndexedField<V, ?>.SearchSpec> searchSpecs) {
-    return schema(/* version= */ 0, fieldDefs, indexFields, searchSpecs);
+    return schema(/* version= */ 0, indexFields, searchSpecs);
   }
 
   public static Set<String> getPersonParts(PersonIdent person) {
diff --git a/java/com/google/gerrit/index/project/ProjectField.java b/java/com/google/gerrit/index/project/ProjectField.java
index 3114b4c..ff55546 100644
--- a/java/com/google/gerrit/index/project/ProjectField.java
+++ b/java/com/google/gerrit/index/project/ProjectField.java
@@ -15,14 +15,10 @@
 package com.google.gerrit.index.project;
 
 import static com.google.common.collect.ImmutableList.toImmutableList;
-import static com.google.gerrit.index.FieldDef.exact;
-import static com.google.gerrit.index.FieldDef.fullText;
-import static com.google.gerrit.index.FieldDef.prefix;
-import static com.google.gerrit.index.FieldDef.storedOnly;
 
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
-import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.IndexedField;
 import com.google.gerrit.index.RefState;
 import com.google.gerrit.index.SchemaUtil;
 
@@ -38,23 +34,53 @@
         .toByteArray(project.getNameKey());
   }
 
-  public static final FieldDef<ProjectData, String> NAME =
-      exact("name").stored().build(p -> p.getProject().getName());
+  public static final IndexedField<ProjectData, String> NAME_FIELD =
+      IndexedField.<ProjectData>stringBuilder("RepoName")
+          .required()
+          .size(200)
+          .stored()
+          .build(p -> p.getProject().getName());
 
-  public static final FieldDef<ProjectData, String> DESCRIPTION =
-      fullText("description").stored().build(p -> p.getProject().getDescription());
+  public static final IndexedField<ProjectData, String>.SearchSpec NAME_SPEC =
+      NAME_FIELD.exact("name");
 
-  public static final FieldDef<ProjectData, String> PARENT_NAME =
-      exact("parent_name").build(p -> p.getProject().getParentName());
+  public static final IndexedField<ProjectData, String> DESCRIPTION_FIELD =
+      IndexedField.<ProjectData>stringBuilder("Description")
+          .stored()
+          .build(p -> p.getProject().getDescription());
 
-  public static final FieldDef<ProjectData, Iterable<String>> NAME_PART =
-      prefix("name_part").buildRepeatable(p -> SchemaUtil.getNameParts(p.getProject().getName()));
+  public static final IndexedField<ProjectData, String>.SearchSpec DESCRIPTION_SPEC =
+      DESCRIPTION_FIELD.fullText("description");
 
-  public static final FieldDef<ProjectData, String> STATE =
-      exact("state").stored().build(p -> p.getProject().getState().name());
+  public static final IndexedField<ProjectData, String> PARENT_NAME_FIELD =
+      IndexedField.<ProjectData>stringBuilder("ParentName")
+          .build(p -> p.getProject().getParentName());
 
-  public static final FieldDef<ProjectData, Iterable<String>> ANCESTOR_NAME =
-      exact("ancestor_name").buildRepeatable(ProjectData::getParentNames);
+  public static final IndexedField<ProjectData, String>.SearchSpec PARENT_NAME_SPEC =
+      PARENT_NAME_FIELD.exact("parent_name");
+
+  public static final IndexedField<ProjectData, Iterable<String>> NAME_PART_FIELD =
+      IndexedField.<ProjectData>iterableStringBuilder("NamePart")
+          .size(200)
+          .build(p -> SchemaUtil.getNameParts(p.getProject().getName()));
+
+  public static final IndexedField<ProjectData, Iterable<String>>.SearchSpec NAME_PART_SPEC =
+      NAME_PART_FIELD.prefix("name_part");
+
+  public static final IndexedField<ProjectData, String> STATE_FIELD =
+      IndexedField.<ProjectData>stringBuilder("State")
+          .stored()
+          .build(p -> p.getProject().getState().name());
+
+  public static final IndexedField<ProjectData, String>.SearchSpec STATE_SPEC =
+      STATE_FIELD.exact("state");
+
+  public static final IndexedField<ProjectData, Iterable<String>> ANCESTOR_NAME_FIELD =
+      IndexedField.<ProjectData>iterableStringBuilder("AncestorName")
+          .build(ProjectData::getParentNames);
+
+  public static final IndexedField<ProjectData, Iterable<String>>.SearchSpec ANCESTOR_NAME_SPEC =
+      ANCESTOR_NAME_FIELD.exact("ancestor_name");
 
   /**
    * All values of all refs that were used in the course of indexing this document. This covers
@@ -62,12 +88,17 @@
    *
    * <p>Emitted as UTF-8 encoded strings of the form {@code project:ref/name:[hex sha]}.
    */
-  public static final FieldDef<ProjectData, Iterable<byte[]>> REF_STATE =
-      storedOnly("ref_state")
-          .buildRepeatable(
+  public static final IndexedField<ProjectData, Iterable<byte[]>> REF_STATE_FIELD =
+      IndexedField.<ProjectData>iterableByteArrayBuilder("RefState")
+          .stored()
+          .required()
+          .build(
               projectData ->
                   projectData.tree().stream()
                       .filter(p -> p.getProject().getConfigRefState() != null)
                       .map(p -> toRefState(p.getProject()))
                       .collect(toImmutableList()));
+
+  public static final IndexedField<ProjectData, Iterable<byte[]>>.SearchSpec REF_STATE_SPEC =
+      REF_STATE_FIELD.storedOnly("ref_state");
 }
diff --git a/java/com/google/gerrit/index/project/ProjectIndex.java b/java/com/google/gerrit/index/project/ProjectIndex.java
index b2ddaff..0aa7393 100644
--- a/java/com/google/gerrit/index/project/ProjectIndex.java
+++ b/java/com/google/gerrit/index/project/ProjectIndex.java
@@ -18,6 +18,7 @@
 import com.google.gerrit.index.Index;
 import com.google.gerrit.index.IndexDefinition;
 import com.google.gerrit.index.query.Predicate;
+import java.util.function.Function;
 
 /**
  * Index for Gerrit projects (repositories). This class is mainly used for typing the generic parent
@@ -30,6 +31,8 @@
 
   @Override
   default Predicate<ProjectData> keyPredicate(Project.NameKey nameKey) {
-    return new ProjectPredicate(ProjectField.NAME, nameKey.get());
+    return new ProjectPredicate(ProjectField.NAME_SPEC, nameKey.get());
   }
+
+  Function<ProjectData, Project.NameKey> ENTITY_TO_KEY = (p) -> p.getProject().getNameKey();
 }
diff --git a/java/com/google/gerrit/index/project/ProjectPredicate.java b/java/com/google/gerrit/index/project/ProjectPredicate.java
index 11875ef..0eaf2b6 100644
--- a/java/com/google/gerrit/index/project/ProjectPredicate.java
+++ b/java/com/google/gerrit/index/project/ProjectPredicate.java
@@ -14,12 +14,12 @@
 
 package com.google.gerrit.index.project;
 
-import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
 import com.google.gerrit.index.query.IndexPredicate;
 
 /** Predicate that is mapped to a field in the project index. */
 public class ProjectPredicate extends IndexPredicate<ProjectData> {
-  public ProjectPredicate(FieldDef<ProjectData, ?> def, String value) {
+  public ProjectPredicate(SchemaField<ProjectData, ?> def, String value) {
     super(def, value);
   }
 }
diff --git a/java/com/google/gerrit/index/project/ProjectSchemaDefinitions.java b/java/com/google/gerrit/index/project/ProjectSchemaDefinitions.java
index 0619566..3ac594e 100644
--- a/java/com/google/gerrit/index/project/ProjectSchemaDefinitions.java
+++ b/java/com/google/gerrit/index/project/ProjectSchemaDefinitions.java
@@ -16,6 +16,8 @@
 
 import static com.google.gerrit.index.SchemaUtil.schema;
 
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.index.IndexedField;
 import com.google.gerrit.index.Schema;
 import com.google.gerrit.index.SchemaDefinitions;
 
@@ -31,14 +33,27 @@
   static final Schema<ProjectData> V1 =
       schema(
           /* version= */ 1,
-          ProjectField.NAME,
-          ProjectField.DESCRIPTION,
-          ProjectField.PARENT_NAME,
-          ProjectField.NAME_PART,
-          ProjectField.ANCESTOR_NAME);
+          ImmutableList.of(
+              ProjectField.NAME_FIELD,
+              ProjectField.DESCRIPTION_FIELD,
+              ProjectField.PARENT_NAME_FIELD,
+              ProjectField.NAME_PART_FIELD,
+              ProjectField.ANCESTOR_NAME_FIELD),
+          ImmutableList.<IndexedField<ProjectData, ?>.SearchSpec>of(
+              ProjectField.NAME_SPEC,
+              ProjectField.DESCRIPTION_SPEC,
+              ProjectField.PARENT_NAME_SPEC,
+              ProjectField.NAME_PART_SPEC,
+              ProjectField.ANCESTOR_NAME_SPEC));
 
   @Deprecated
-  static final Schema<ProjectData> V2 = schema(V1, ProjectField.STATE, ProjectField.REF_STATE);
+  static final Schema<ProjectData> V2 =
+      schema(
+          V1,
+          ImmutableList.<IndexedField<ProjectData, ?>>of(
+              ProjectField.STATE_FIELD, ProjectField.REF_STATE_FIELD),
+          ImmutableList.<IndexedField<ProjectData, ?>.SearchSpec>of(
+              ProjectField.STATE_SPEC, ProjectField.REF_STATE_SPEC));
 
   // Bump Lucene version requires reindexing
   @Deprecated static final Schema<ProjectData> V3 = schema(V2);
diff --git a/java/com/google/gerrit/index/query/AndPredicate.java b/java/com/google/gerrit/index/query/AndPredicate.java
index 23ae312..fda961d 100644
--- a/java/com/google/gerrit/index/query/AndPredicate.java
+++ b/java/com/google/gerrit/index/query/AndPredicate.java
@@ -134,7 +134,7 @@
       cmp = a.estimateCost() - b.estimateCost();
     }
 
-    if (cmp == 0 && a instanceof DataSource && b instanceof DataSource) {
+    if (cmp == 0 && a instanceof DataSource) {
       DataSource<?> as = (DataSource<?>) a;
       DataSource<?> bs = (DataSource<?>) b;
       cmp = as.getCardinality() - bs.getCardinality();
diff --git a/java/com/google/gerrit/index/query/AndSource.java b/java/com/google/gerrit/index/query/AndSource.java
index f4c1464..3adf881 100644
--- a/java/com/google/gerrit/index/query/AndSource.java
+++ b/java/com/google/gerrit/index/query/AndSource.java
@@ -92,7 +92,7 @@
 
   @SuppressWarnings("unchecked")
   private PaginatingSource<T> toPaginatingSource(Predicate<T> pred) {
-    return new PaginatingSource<T>((DataSource<T>) pred, start, indexConfig) {
+    return new PaginatingSource<>((DataSource<T>) pred, start, indexConfig) {
       @Override
       protected boolean match(T object) {
         return AndSource.this.match(object);
diff --git a/java/com/google/gerrit/index/query/FieldBundle.java b/java/com/google/gerrit/index/query/FieldBundle.java
index 60881df..551de92 100644
--- a/java/com/google/gerrit/index/query/FieldBundle.java
+++ b/java/com/google/gerrit/index/query/FieldBundle.java
@@ -16,9 +16,12 @@
 
 import static com.google.common.base.Preconditions.checkArgument;
 
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.ListMultimap;
+import com.google.gerrit.index.IndexedField;
+import com.google.gerrit.index.IndexedField.SearchSpec;
 import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
 
 /** FieldBundle is an abstraction that allows retrieval of raw values from different sources. */
@@ -27,8 +30,22 @@
   // Map String => List{Integer, Long, Timestamp, String, byte[]}
   private ImmutableListMultimap<String, Object> fields;
 
-  public FieldBundle(ListMultimap<String, Object> fields) {
+  /**
+   * Depending on the index implementation 1) either {@link IndexedField} are stored once and
+   * referenced by {@link com.google.gerrit.index.IndexedField.SearchSpec} on the queries, 2) or
+   * each {@link com.google.gerrit.index.IndexedField.SearchSpec} is stored individually.
+   *
+   * <p>In case #1 {@link #storesIndexedFields} is set to {@code true} and the {@link #fields}
+   * contain a map from {@link IndexedField#name()} to a stored value.
+   *
+   * <p>In case #2 {@link #storesIndexedFields} is set to {@code false} and the {@link #fields}
+   * contain a map from {@link SearchSpec#name()} to a stored value.
+   */
+  private final boolean storesIndexedFields;
+
+  public FieldBundle(ListMultimap<String, Object> fields, boolean storesIndexedFields) {
     this.fields = ImmutableListMultimap.copyOf(fields);
+    this.storesIndexedFields = storesIndexedFields;
   }
 
   /**
@@ -46,13 +63,17 @@
   @SuppressWarnings("unchecked")
   public <T> T getValue(SchemaField<?, T> schemaField) {
     checkArgument(schemaField.isStored(), "Field must be stored");
+    String storedFieldName =
+        storesIndexedFields && schemaField instanceof IndexedField<?, ?>.SearchSpec
+            ? ((IndexedField<?, ?>.SearchSpec) schemaField).getField().name()
+            : schemaField.getName();
     checkArgument(
-        fields.containsKey(schemaField.getName()) || schemaField.isRepeatable(),
+        fields.containsKey(storedFieldName) || schemaField.isRepeatable(),
         "Field %s is not in result set %s",
-        schemaField.getName(),
+        storedFieldName,
         fields.keySet());
 
-    Iterable<Object> result = fields.get(schemaField.getName());
+    ImmutableList<Object> result = fields.get(storedFieldName);
     if (schemaField.isRepeatable()) {
       return (T) result;
     }
diff --git a/java/com/google/gerrit/index/query/IndexPredicate.java b/java/com/google/gerrit/index/query/IndexPredicate.java
index de81c47..0bde640 100644
--- a/java/com/google/gerrit/index/query/IndexPredicate.java
+++ b/java/com/google/gerrit/index/query/IndexPredicate.java
@@ -23,6 +23,7 @@
 import com.google.common.primitives.Longs;
 import com.google.gerrit.index.FieldType;
 import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
+import java.util.Locale;
 import java.util.Objects;
 import java.util.Set;
 import java.util.stream.StreamSupport;
@@ -117,7 +118,8 @@
   }
 
   private static ImmutableSet<String> tokenizeString(String value) {
-    return StreamSupport.stream(FULL_TEXT_SPLITTER.split(value.toLowerCase()).spliterator(), false)
+    return StreamSupport.stream(
+            FULL_TEXT_SPLITTER.split(value.toLowerCase(Locale.US)).spliterator(), false)
         .filter(s -> !s.trim().isEmpty())
         .collect(toImmutableSet());
   }
diff --git a/java/com/google/gerrit/index/query/IntegerRangePredicate.java b/java/com/google/gerrit/index/query/IntegerRangePredicate.java
index 850c4a5..278d2af 100644
--- a/java/com/google/gerrit/index/query/IntegerRangePredicate.java
+++ b/java/com/google/gerrit/index/query/IntegerRangePredicate.java
@@ -14,13 +14,13 @@
 
 package com.google.gerrit.index.query;
 
-import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
 import com.google.gerrit.index.query.RangeUtil.Range;
 
 public abstract class IntegerRangePredicate<T> extends IndexPredicate<T> {
   private final Range range;
 
-  protected IntegerRangePredicate(FieldDef<T, Integer> type, String value)
+  protected IntegerRangePredicate(SchemaField<T, Integer> type, String value)
       throws QueryParseException {
     super(type, value);
     range = RangeUtil.getRange(value, Integer.MIN_VALUE, Integer.MAX_VALUE);
diff --git a/java/com/google/gerrit/index/query/InternalQuery.java b/java/com/google/gerrit/index/query/InternalQuery.java
index 5c003bc..b6418a9 100644
--- a/java/com/google/gerrit/index/query/InternalQuery.java
+++ b/java/com/google/gerrit/index/query/InternalQuery.java
@@ -20,12 +20,13 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Lists;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.Index;
 import com.google.gerrit.index.IndexCollection;
 import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.index.Schema;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
 import java.util.Arrays;
 import java.util.List;
 import java.util.function.Supplier;
@@ -76,10 +77,10 @@
   }
 
   @SafeVarargs
-  public final Q setRequestedFields(FieldDef<T, ?>... fields) {
+  public final Q setRequestedFields(SchemaField<T, ?>... fields) {
     checkArgument(fields.length > 0, "requested field list is empty");
     queryProcessor.setRequestedFields(
-        Arrays.stream(fields).map(FieldDef::getName).collect(toSet()));
+        Arrays.stream(fields).map(SchemaField::getName).collect(toSet()));
     return self();
   }
 
@@ -118,6 +119,7 @@
     }
   }
 
+  @Nullable
   protected final Schema<T> schema() {
     Index<?, T> index = indexes != null ? indexes.getSearchIndex() : null;
     return index != null ? index.getSchema() : null;
diff --git a/java/com/google/gerrit/index/query/LimitPredicate.java b/java/com/google/gerrit/index/query/LimitPredicate.java
index 23e0f6d..9196811 100644
--- a/java/com/google/gerrit/index/query/LimitPredicate.java
+++ b/java/com/google/gerrit/index/query/LimitPredicate.java
@@ -14,8 +14,11 @@
 
 package com.google.gerrit.index.query;
 
+import com.google.gerrit.common.Nullable;
+
 public class LimitPredicate<T> extends IntPredicate<T> implements Matchable<T> {
   @SuppressWarnings("unchecked")
+  @Nullable
   public static Integer getLimit(String fieldName, Predicate<?> p) {
     IntPredicate<?> ip = QueryBuilder.find(p, IntPredicate.class, fieldName);
     return ip != null ? ip.intValue() : null;
diff --git a/java/com/google/gerrit/index/query/PaginatingSource.java b/java/com/google/gerrit/index/query/PaginatingSource.java
index 03d749a..98a0ed3 100644
--- a/java/com/google/gerrit/index/query/PaginatingSource.java
+++ b/java/com/google/gerrit/index/query/PaginatingSource.java
@@ -113,7 +113,7 @@
 
   @Override
   public ResultSet<FieldBundle> readRaw() {
-    // TOOD(hiesel): Implement
+    // TODO(hiesel): Implement
     throw new UnsupportedOperationException("not implemented");
   }
 
@@ -122,6 +122,12 @@
         .transformAndConcat(this::transformBuffer);
   }
 
+  /**
+   * Checks whether the given object matches.
+   *
+   * @param object the object to be matched
+   * @return whether the given object matches
+   */
   protected boolean match(T object) {
     return true;
   }
diff --git a/java/com/google/gerrit/index/query/QueryProcessor.java b/java/com/google/gerrit/index/query/QueryProcessor.java
index c27b7c4..1f8266a 100644
--- a/java/com/google/gerrit/index/query/QueryProcessor.java
+++ b/java/com/google/gerrit/index/query/QueryProcessor.java
@@ -273,7 +273,9 @@
                 Ints.saturatedCast((long) limit + 1),
                 getRequestedFields());
         logger.atFine().log("Query options: %s", opts);
-        Predicate<T> pred = rewriter.rewrite(q, opts);
+        // Apply index-specific rewrite first
+        Predicate<T> pred = indexes.getSearchIndex().getIndexRewriter().rewrite(q, opts);
+        pred = rewriter.rewrite(pred, opts);
         if (enforceVisibility) {
           pred = enforceVisibility(pred);
         }
@@ -287,7 +289,7 @@
         @SuppressWarnings("unchecked")
         DataSource<T> s = (DataSource<T>) pred;
         if (initialPageSize < limit && !(pred instanceof AndSource)) {
-          s = new PaginatingSource<T>(s, start, indexConfig);
+          s = new PaginatingSource<>(s, start, indexConfig);
         }
         sources.add(s);
       }
diff --git a/java/com/google/gerrit/index/query/RegexPredicate.java b/java/com/google/gerrit/index/query/RegexPredicate.java
index 60a2a9e..4c76770 100644
--- a/java/com/google/gerrit/index/query/RegexPredicate.java
+++ b/java/com/google/gerrit/index/query/RegexPredicate.java
@@ -14,14 +14,14 @@
 
 package com.google.gerrit.index.query;
 
-import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
 
 public abstract class RegexPredicate<I> extends IndexPredicate<I> {
-  protected RegexPredicate(FieldDef<I, ?> def, String value) {
+  protected RegexPredicate(SchemaField<I, ?> def, String value) {
     super(def, value);
   }
 
-  protected RegexPredicate(FieldDef<I, ?> def, String name, String value) {
+  protected RegexPredicate(SchemaField<I, ?> def, String name, String value) {
     super(def, name, value);
   }
 }
diff --git a/java/com/google/gerrit/index/query/TimestampRangePredicate.java b/java/com/google/gerrit/index/query/TimestampRangePredicate.java
index 29d6f22..1fd81a6 100644
--- a/java/com/google/gerrit/index/query/TimestampRangePredicate.java
+++ b/java/com/google/gerrit/index/query/TimestampRangePredicate.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.index.query;
 
-import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
 import com.google.gerrit.json.JavaSqlTimestampHelper;
 import java.sql.Timestamp;
 import java.time.Instant;
@@ -30,7 +30,7 @@
     }
   }
 
-  protected TimestampRangePredicate(FieldDef<I, Timestamp> def, String name, String value) {
+  protected TimestampRangePredicate(SchemaField<I, Timestamp> def, String name, String value) {
     super(def, name, value);
   }
 
diff --git a/java/com/google/gerrit/index/testing/AbstractFakeIndex.java b/java/com/google/gerrit/index/testing/AbstractFakeIndex.java
index c790fe4..e4c6745 100644
--- a/java/com/google/gerrit/index/testing/AbstractFakeIndex.java
+++ b/java/com/google/gerrit/index/testing/AbstractFakeIndex.java
@@ -29,6 +29,7 @@
 import com.google.gerrit.index.Index;
 import com.google.gerrit.index.QueryOptions;
 import com.google.gerrit.index.Schema;
+import com.google.gerrit.index.SchemaFieldDefs;
 import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
 import com.google.gerrit.index.project.ProjectData;
 import com.google.gerrit.index.project.ProjectIndex;
@@ -166,6 +167,7 @@
       @Override
       public ResultSet<V> read() {
         return new ListResultSet<>(results) {
+          @Nullable
           @Override
           public Object searchAfter() {
             @Nullable V last = Iterables.getLast(results, null);
@@ -190,7 +192,7 @@
               fields.put(field.getName(), field.get(result));
             }
           }
-          fieldBundles.add(new FieldBundle(fields.build()));
+          fieldBundles.add(new FieldBundle(fields.build(), /* storesIndexedFields= */ false));
           searchAfter = keyFor(result);
         }
         ImmutableList<FieldBundle> resultSet = fieldBundles.build();
@@ -228,7 +230,7 @@
    * <p>This index is special in that ChangeData is a mutable object. Therefore we can't just hold
    * onto the object that the caller wanted us to index. We also can't just create a new ChangeData
    * from scratch because there are tests that assert that certain computations (e.g. diffs) are
-   * only done once. So we do what the prod indices do: We read and write fields using FieldDef.
+   * only done once. So we do what the prod indices do: We read and write fields using SchemaField.
    */
   public static class FakeChangeIndex
       extends AbstractFakeIndex<Change.Id, ChangeData, Map<String, Object>> implements ChangeIndex {
@@ -266,7 +268,7 @@
     protected Map<String, Object> docFor(ChangeData value) {
       ImmutableMap.Builder<String, Object> doc = ImmutableMap.builder();
       for (SchemaField<ChangeData, ?> field : getSchema().getSchemaFields().values()) {
-        if (ChangeField.MERGEABLE.getName().equals(field.getName()) && skipMergable) {
+        if (ChangeField.MERGEABLE_SPEC.getName().equals(field.getName()) && skipMergable) {
           continue;
         }
         Object docifiedValue = field.get(value);
@@ -281,16 +283,23 @@
     protected ChangeData valueFor(Map<String, Object> doc) {
       ChangeData cd =
           changeDataFactory.create(
-              Project.nameKey((String) doc.get(ChangeField.PROJECT.getName())),
-              Change.id(Integer.valueOf((String) doc.get(ChangeField.LEGACY_ID_STR.getName()))));
+              Project.nameKey((String) doc.get(ChangeField.PROJECT_SPEC.getName())),
+              Change.id(
+                  Integer.valueOf((String) doc.get(ChangeField.NUMERIC_ID_STR_SPEC.getName()))));
       for (SchemaField<ChangeData, ?> field : getSchema().getSchemaFields().values()) {
-        field.setIfPossible(cd, new FakeStoredValue(doc.get(field.getName())));
+        boolean isProtoField = SchemaFieldDefs.isProtoField(field);
+        field.setIfPossible(cd, new FakeStoredValue(doc.get(field.getName()), isProtoField));
       }
       return cd;
     }
 
     @Override
     public void insert(ChangeData obj) {}
+
+    @Override
+    public void deleteByValue(ChangeData value) {
+      delete(ChangeIndex.ENTITY_TO_KEY.apply(value));
+    }
   }
 
   /** Fake implementation of {@link AccountIndex} where all filtering happens in-memory. */
@@ -323,6 +332,11 @@
 
     @Override
     public void insert(AccountState obj) {}
+
+    @Override
+    public void deleteByValue(AccountState value) {
+      delete(AccountIndex.ENTITY_TO_KEY.apply(value));
+    }
   }
 
   /** Fake implementation of {@link GroupIndex} where all filtering happens in-memory. */
@@ -356,6 +370,11 @@
 
     @Override
     public void insert(InternalGroup obj) {}
+
+    @Override
+    public void deleteByValue(InternalGroup value) {
+      delete(GroupIndex.ENTITY_TO_KEY.apply(value));
+    }
   }
 
   /** Fake implementation of {@link ProjectIndex} where all filtering happens in-memory. */
@@ -388,5 +407,10 @@
 
     @Override
     public void insert(ProjectData obj) {}
+
+    @Override
+    public void deleteByValue(ProjectData value) {
+      delete(ProjectIndex.ENTITY_TO_KEY.apply(value));
+    }
   }
 }
diff --git a/java/com/google/gerrit/index/testing/BUILD b/java/com/google/gerrit/index/testing/BUILD
index 9af2598..44bf70d 100644
--- a/java/com/google/gerrit/index/testing/BUILD
+++ b/java/com/google/gerrit/index/testing/BUILD
@@ -10,13 +10,9 @@
     deps = [
         "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/entities",
-        "//java/com/google/gerrit/exceptions",
         "//java/com/google/gerrit/index",
-        "//java/com/google/gerrit/index:query_exception",
         "//java/com/google/gerrit/index/project",
-        "//java/com/google/gerrit/proto",
         "//java/com/google/gerrit/server",
-        "//java/com/google/gerrit/server/logging",
         "//lib:guava",
         "//lib:jgit",
         "//lib:protobuf",
diff --git a/java/com/google/gerrit/index/testing/TestIndexedFields.java b/java/com/google/gerrit/index/testing/TestIndexedFields.java
index f80b8a1..51440fb 100644
--- a/java/com/google/gerrit/index/testing/TestIndexedFields.java
+++ b/java/com/google/gerrit/index/testing/TestIndexedFields.java
@@ -164,6 +164,12 @@
   public static final IndexedField<TestIndexedData, String>.SearchSpec STRING_FIELD_SPEC =
       STRING_FIELD.fullText("string_test");
 
+  public static final IndexedField<TestIndexedData, String>.SearchSpec PREFIX_STRING_FIELD_SPEC =
+      STRING_FIELD.prefix("prefix_string_test");
+
+  public static final IndexedField<TestIndexedData, String>.SearchSpec EXACT_STRING_FIELD_SPEC =
+      STRING_FIELD.exact("exact_string_test");
+
   public static final IndexedField<TestIndexedData, Iterable<byte[]>> ITERABLE_STORED_BYTE_FIELD =
       IndexedField.<TestIndexedData>iterableByteArrayBuilder("IterableByteTestField")
           .stored()
@@ -182,7 +188,10 @@
 
   public static final IndexedField<TestIndexedData, Entities.Change> STORED_PROTO_FIELD =
       IndexedField.<TestIndexedData, Entities.Change>builder(
-              "TestChange", new TypeToken<Entities.Change>() {})
+              "TestChange",
+              new TypeToken<Entities.Change>() {
+                private static final long serialVersionUID = 1L;
+              })
           .stored()
           .build(getter(), setter(), ChangeProtoConverter.INSTANCE);
 
@@ -192,7 +201,10 @@
   public static final IndexedField<TestIndexedData, Iterable<Entities.Change>>
       ITERABLE_STORED_PROTO_FIELD =
           IndexedField.<TestIndexedData, Iterable<Entities.Change>>builder(
-                  "IterableTestChange", new TypeToken<Iterable<Entities.Change>>() {})
+                  "IterableTestChange",
+                  new TypeToken<Iterable<Entities.Change>>() {
+                    private static final long serialVersionUID = 1L;
+                  })
               .stored()
               .build(getter(), setter(), ChangeProtoConverter.INSTANCE);
 
diff --git a/java/com/google/gerrit/json/EnumTypeAdapterFactory.java b/java/com/google/gerrit/json/EnumTypeAdapterFactory.java
index 9c32aa8..b6cb5f9 100644
--- a/java/com/google/gerrit/json/EnumTypeAdapterFactory.java
+++ b/java/com/google/gerrit/json/EnumTypeAdapterFactory.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.json;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gson.Gson;
 import com.google.gson.JsonSyntaxException;
 import com.google.gson.TypeAdapter;
@@ -34,6 +35,7 @@
 public class EnumTypeAdapterFactory implements TypeAdapterFactory {
 
   @SuppressWarnings({"rawtypes", "unchecked"})
+  @Nullable
   @Override
   public <T> TypeAdapter<T> create(Gson gson, TypeToken<T> typeToken) {
     TypeAdapter<T> defaultEnumAdapter = TypeAdapters.ENUM_FACTORY.create(gson, typeToken);
diff --git a/java/com/google/gerrit/json/OptionalSubmitRequirementExpressionResultAdapterFactory.java b/java/com/google/gerrit/json/OptionalSubmitRequirementExpressionResultAdapterFactory.java
index d35b8fb..2557515 100644
--- a/java/com/google/gerrit/json/OptionalSubmitRequirementExpressionResultAdapterFactory.java
+++ b/java/com/google/gerrit/json/OptionalSubmitRequirementExpressionResultAdapterFactory.java
@@ -45,6 +45,7 @@
       TypeToken.get(SubmitRequirementExpressionResult.class);
 
   @SuppressWarnings({"unchecked"})
+  @Nullable
   @Override
   public <T> TypeAdapter<T> create(Gson gson, TypeToken<T> typeToken) {
     if (typeToken.equals(OPTIONAL_SR_EXPRESSION_RESULT_TOKEN)) {
diff --git a/java/com/google/gerrit/json/SqlTimestampDeserializer.java b/java/com/google/gerrit/json/SqlTimestampDeserializer.java
index e1cf382..9aeda2b 100644
--- a/java/com/google/gerrit/json/SqlTimestampDeserializer.java
+++ b/java/com/google/gerrit/json/SqlTimestampDeserializer.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.json;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gson.JsonDeserializationContext;
 import com.google.gson.JsonDeserializer;
 import com.google.gson.JsonElement;
@@ -30,6 +31,7 @@
 class SqlTimestampDeserializer implements JsonDeserializer<Timestamp>, JsonSerializer<Timestamp> {
   private static final TimeZone UTC = TimeZone.getTimeZone("UTC");
 
+  @Nullable
   @Override
   public Timestamp deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context)
       throws JsonParseException {
diff --git a/java/com/google/gerrit/launcher/GerritLauncher.java b/java/com/google/gerrit/launcher/GerritLauncher.java
index 550597b..07a071a 100644
--- a/java/com/google/gerrit/launcher/GerritLauncher.java
+++ b/java/com/google/gerrit/launcher/GerritLauncher.java
@@ -44,6 +44,7 @@
 import java.util.HashMap;
 import java.util.Iterator;
 import java.util.List;
+import java.util.Locale;
 import java.util.Map;
 import java.util.NavigableMap;
 import java.util.Properties;
@@ -222,7 +223,7 @@
         String cn = programClassName(name);
         clazz = Class.forName(PKG + "." + cn, true, loader);
       } catch (ClassNotFoundException cnfe) {
-        if (name.equals(name.toLowerCase())) {
+        if (name.equals(name.toLowerCase(Locale.US))) {
           clazz = Class.forName(PKG + "." + name, true, loader);
         } else {
           throw cnfe;
@@ -266,7 +267,7 @@
   }
 
   private static String programClassName(String cn) {
-    if (cn.equals(cn.toLowerCase())) {
+    if (cn.equals(cn.toLowerCase(Locale.US))) {
       StringBuilder buf = new StringBuilder();
       buf.append(Character.toUpperCase(cn.charAt(0)));
       for (int i = 1; i < cn.length(); i++) {
@@ -560,6 +561,7 @@
     return myHome;
   }
 
+  @SuppressWarnings("ReturnMissingNullable")
   private static File tmproot() {
     File tmp;
     String gerritTemp = System.getenv("GERRIT_TMP");
@@ -599,6 +601,7 @@
     }
   }
 
+  @SuppressWarnings("ReturnMissingNullable")
   private static File locateHomeDirectory() {
     // Try to find the user's home directory. If we can't find it
     // return null so the JVM's default temporary directory is used
diff --git a/java/com/google/gerrit/lucene/AbstractLuceneIndex.java b/java/com/google/gerrit/lucene/AbstractLuceneIndex.java
index 229674b..2fd1c45 100644
--- a/java/com/google/gerrit/lucene/AbstractLuceneIndex.java
+++ b/java/com/google/gerrit/lucene/AbstractLuceneIndex.java
@@ -40,16 +40,19 @@
 import com.google.gerrit.index.QueryOptions;
 import com.google.gerrit.index.Schema;
 import com.google.gerrit.index.Schema.Values;
+import com.google.gerrit.index.SchemaFieldDefs;
 import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
 import com.google.gerrit.index.query.DataSource;
 import com.google.gerrit.index.query.FieldBundle;
 import com.google.gerrit.index.query.ListResultSet;
 import com.google.gerrit.index.query.ResultSet;
+import com.google.gerrit.proto.Protos;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.index.IndexUtils;
 import com.google.gerrit.server.index.options.AutoFlush;
 import com.google.gerrit.server.logging.LoggingContextAwareExecutorService;
 import com.google.gerrit.server.logging.LoggingContextAwareScheduledExecutorService;
+import com.google.protobuf.MessageLite;
 import java.io.IOException;
 import java.sql.Timestamp;
 import java.util.Set;
@@ -106,6 +109,7 @@
   private final Set<NrtFuture> notDoneNrtFutures;
   private final AutoFlush autoFlush;
   private ScheduledExecutorService autoCommitExecutor;
+  private final Function<V, K> valueToKeyFunction;
 
   @SuppressWarnings("ThreadPriorityCheck")
   AbstractLuceneIndex(
@@ -117,7 +121,8 @@
       String subIndex,
       GerritIndexWriterConfig writerConfig,
       SearcherFactory searcherFactory,
-      AutoFlush autoFlush)
+      AutoFlush autoFlush,
+      Function<V, K> valueToKeyFunction)
       throws IOException {
     this.schema = schema;
     this.sitePaths = sitePaths;
@@ -125,6 +130,7 @@
     this.name = name;
     this.skipFields = skipFields;
     this.autoFlush = autoFlush;
+    this.valueToKeyFunction = valueToKeyFunction;
     String index = Joiner.on('_').skipNulls().join(name, subIndex);
     long commitPeriod = writerConfig.getCommitWithinMs();
 
@@ -298,6 +304,11 @@
   }
 
   @Override
+  public void deleteByValue(V value) {
+    delete(valueToKeyFunction.apply(value));
+  }
+
+  @Override
   public void deleteAll() {
     try {
       writer.deleteAll();
@@ -368,8 +379,12 @@
         doc.add(new TextField(name, (String) value, store));
       }
     } else if (type == FieldType.STORED_ONLY) {
+      boolean isProtoField = SchemaFieldDefs.isProtoField(values.getField());
       for (Object value : values.getValues()) {
-        doc.add(new StoredField(name, (byte[]) value));
+        // Lucene stores protos as bytes
+        doc.add(
+            new StoredField(
+                name, isProtoField ? Protos.toByteArray((MessageLite) value) : (byte[]) value));
       }
     } else {
       throw FieldType.badFieldType(type);
@@ -402,7 +417,7 @@
         throw FieldType.badFieldType(type);
       }
     }
-    return new FieldBundle(rawFields);
+    return new FieldBundle(rawFields, /* storesIndexedFields= */ false);
   }
 
   private static Field.Store store(SchemaField<?, ?> f) {
@@ -550,7 +565,7 @@
           }
         }
         ScoreDoc searchAfter = scoreDoc;
-        return new ListResultSet<T>(b.build()) {
+        return new ListResultSet<>(b.build()) {
           @Override
           public Object searchAfter() {
             return searchAfter;
diff --git a/java/com/google/gerrit/lucene/ChangeSubIndex.java b/java/com/google/gerrit/lucene/ChangeSubIndex.java
index ce50473..024b102 100644
--- a/java/com/google/gerrit/lucene/ChangeSubIndex.java
+++ b/java/com/google/gerrit/lucene/ChangeSubIndex.java
@@ -85,7 +85,8 @@
         subIndex,
         writerConfig,
         searcherFactory,
-        autoFlush);
+        autoFlush,
+        ChangeIndex.ENTITY_TO_KEY);
   }
 
   @Override
@@ -119,13 +120,13 @@
   void add(Document doc, Values<ChangeData> values) {
     // Add separate DocValues fields for those fields needed for sorting.
     SchemaField<ChangeData, ?> f = values.getField();
-    if (f == ChangeField.LEGACY_ID_STR) {
+    if (f == ChangeField.NUMERIC_ID_STR_SPEC) {
       String v = (String) getOnlyElement(values.getValues());
       doc.add(new NumericDocValuesField(ID_STR_SORT_FIELD, Integer.valueOf(v)));
-    } else if (f == ChangeField.UPDATED) {
+    } else if (f == ChangeField.UPDATED_SPEC) {
       long t = ((Timestamp) getOnlyElement(values.getValues())).getTime();
       doc.add(new NumericDocValuesField(UPDATED_SORT_FIELD, t));
-    } else if (f == ChangeField.MERGED_ON) {
+    } else if (f == ChangeField.MERGED_ON_SPEC) {
       long t = ((Timestamp) getOnlyElement(values.getValues())).getTime();
       doc.add(new NumericDocValuesField(MERGED_ON_SORT_FIELD, t));
     }
diff --git a/java/com/google/gerrit/lucene/LuceneAccountIndex.java b/java/com/google/gerrit/lucene/LuceneAccountIndex.java
index 2e1771f..9c0baa8 100644
--- a/java/com/google/gerrit/lucene/LuceneAccountIndex.java
+++ b/java/com/google/gerrit/lucene/LuceneAccountIndex.java
@@ -108,7 +108,8 @@
         null,
         new GerritIndexWriterConfig(cfg, ACCOUNTS),
         new SearcherFactory(),
-        autoFlush);
+        autoFlush,
+        AccountIndex.ENTITY_TO_KEY);
     this.accountCache = accountCache;
 
     indexWriterConfig = new GerritIndexWriterConfig(cfg, ACCOUNTS);
diff --git a/java/com/google/gerrit/lucene/LuceneChangeIndex.java b/java/com/google/gerrit/lucene/LuceneChangeIndex.java
index eaae40f..4c05f70 100644
--- a/java/com/google/gerrit/lucene/LuceneChangeIndex.java
+++ b/java/com/google/gerrit/lucene/LuceneChangeIndex.java
@@ -17,8 +17,8 @@
 import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.gerrit.lucene.AbstractLuceneIndex.sortFieldName;
 import static com.google.gerrit.server.git.QueueProvider.QueueType.INTERACTIVE;
-import static com.google.gerrit.server.index.change.ChangeField.LEGACY_ID_STR;
-import static com.google.gerrit.server.index.change.ChangeField.PROJECT;
+import static com.google.gerrit.server.index.change.ChangeField.NUMERIC_ID_STR_SPEC;
+import static com.google.gerrit.server.index.change.ChangeField.PROJECT_SPEC;
 import static com.google.gerrit.server.index.change.ChangeIndexRewriter.CLOSED_STATUSES;
 import static com.google.gerrit.server.index.change.ChangeIndexRewriter.OPEN_STATUSES;
 import static java.util.Objects.requireNonNull;
@@ -33,6 +33,7 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.common.util.concurrent.Futures;
 import com.google.common.util.concurrent.ListeningExecutorService;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.converter.ChangeProtoConverter;
@@ -102,21 +103,21 @@
 public class LuceneChangeIndex implements ChangeIndex {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  static final String UPDATED_SORT_FIELD = sortFieldName(ChangeField.UPDATED);
-  static final String MERGED_ON_SORT_FIELD = sortFieldName(ChangeField.MERGED_ON);
-  static final String ID_STR_SORT_FIELD = sortFieldName(ChangeField.LEGACY_ID_STR);
+  static final String UPDATED_SORT_FIELD = sortFieldName(ChangeField.UPDATED_SPEC);
+  static final String MERGED_ON_SORT_FIELD = sortFieldName(ChangeField.MERGED_ON_SPEC);
+  static final String ID_STR_SORT_FIELD = sortFieldName(ChangeField.NUMERIC_ID_STR_SPEC);
 
   private static final String CHANGES = "changes";
   private static final String CHANGES_OPEN = "open";
   private static final String CHANGES_CLOSED = "closed";
-  private static final String CHANGE_FIELD = ChangeField.CHANGE.getName();
+  private static final String CHANGE_FIELD = ChangeField.CHANGE_SPEC.getName();
 
   static Term idTerm(ChangeData cd) {
     return idTerm(cd.getVirtualId());
   }
 
   static Term idTerm(Change.Id id) {
-    return QueryBuilder.stringTerm(LEGACY_ID_STR.getName(), Integer.toString(id.get()));
+    return QueryBuilder.stringTerm(NUMERIC_ID_STR_SPEC.getName(), Integer.toString(id.get()));
   }
 
   private final ListeningExecutorService executor;
@@ -142,7 +143,7 @@
     this.skipFields =
         MergeabilityComputationBehavior.fromConfig(cfg).includeInIndex()
             ? ImmutableSet.of()
-            : ImmutableSet.of(ChangeField.MERGEABLE.getName());
+            : ImmutableSet.of(ChangeField.MERGEABLE_SPEC.getName());
 
     GerritIndexWriterConfig openConfig = new GerritIndexWriterConfig(cfg, "changes_open");
     GerritIndexWriterConfig closedConfig = new GerritIndexWriterConfig(cfg, "changes_closed");
@@ -242,6 +243,11 @@
   }
 
   @Override
+  public void deleteByValue(ChangeData value) {
+    delete(ChangeIndex.ENTITY_TO_KEY.apply(value));
+  }
+
+  @Override
   public void delete(Change.Id changeId) {
     Term idTerm = LuceneChangeIndex.idTerm(changeId);
     try {
@@ -454,11 +460,13 @@
      * @param subIndex change sub-index
      * @return the score doc that can be used to page result sets
      */
+    @Nullable
     private ScoreDoc getSearchAfter(ChangeSubIndex subIndex) {
       if (isSearchAfterPagination
           && opts.searchAfter() != null
-          && opts.searchAfter() instanceof Map) {
-        return ((Map<ChangeSubIndex, ScoreDoc>) opts.searchAfter()).get(subIndex);
+          && opts.searchAfter() instanceof Map
+          && ((Map<?, ?>) opts.searchAfter()).get(subIndex) instanceof ScoreDoc) {
+        return (ScoreDoc) ((Map<?, ?>) opts.searchAfter()).get(subIndex);
       }
       return null;
     }
@@ -498,7 +506,7 @@
         ImmutableList.Builder<ChangeData> result =
             ImmutableList.builderWithExpectedSize(docs.size());
         for (Document doc : docs) {
-          result.add(toChangeData(fields(doc, fields), fields, LEGACY_ID_STR.getName()));
+          result.add(toChangeData(fields(doc, fields), fields, NUMERIC_ID_STR_SPEC.getName()));
         }
         return result.build();
       } catch (InterruptedException e) {
@@ -547,7 +555,7 @@
 
       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();
+      IndexableField project = doc.get(PROJECT_SPEC.getName()).iterator().next();
       cd = changeDataFactory.create(Project.nameKey(project.stringValue()), id);
     }
 
diff --git a/java/com/google/gerrit/lucene/LuceneGroupIndex.java b/java/com/google/gerrit/lucene/LuceneGroupIndex.java
index d475ab7..6301421 100644
--- a/java/com/google/gerrit/lucene/LuceneGroupIndex.java
+++ b/java/com/google/gerrit/lucene/LuceneGroupIndex.java
@@ -18,6 +18,7 @@
 import static com.google.gerrit.server.index.group.GroupField.UUID_FIELD_SPEC;
 
 import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.exceptions.StorageException;
@@ -97,7 +98,8 @@
         null,
         new GerritIndexWriterConfig(cfg, GROUPS),
         new SearcherFactory(),
-        autoFlush);
+        autoFlush,
+        GroupIndex.ENTITY_TO_KEY);
     this.groupCache = groupCache;
 
     indexWriterConfig = new GerritIndexWriterConfig(cfg, GROUPS);
@@ -151,6 +153,7 @@
         new Sort(new SortField(UUID_SORT_FIELD, SortField.Type.STRING, false)));
   }
 
+  @Nullable
   @Override
   protected InternalGroup fromDocument(Document doc) {
     AccountGroup.UUID uuid =
diff --git a/java/com/google/gerrit/lucene/LuceneProjectIndex.java b/java/com/google/gerrit/lucene/LuceneProjectIndex.java
index 96b22db..911d91f 100644
--- a/java/com/google/gerrit/lucene/LuceneProjectIndex.java
+++ b/java/com/google/gerrit/lucene/LuceneProjectIndex.java
@@ -15,9 +15,10 @@
 package com.google.gerrit.lucene;
 
 import static com.google.common.collect.Iterables.getOnlyElement;
-import static com.google.gerrit.index.project.ProjectField.NAME;
+import static com.google.gerrit.index.project.ProjectField.NAME_SPEC;
 
 import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.index.QueryOptions;
@@ -57,14 +58,14 @@
     implements ProjectIndex {
   private static final String PROJECTS = "projects";
 
-  private static final String NAME_SORT_FIELD = sortFieldName(NAME);
+  private static final String NAME_SORT_FIELD = sortFieldName(NAME_SPEC);
 
   private static Term idTerm(ProjectData projectState) {
     return idTerm(projectState.getProject().getNameKey());
   }
 
   private static Term idTerm(Project.NameKey nameKey) {
-    return QueryBuilder.stringTerm(NAME.getName(), nameKey.get());
+    return QueryBuilder.stringTerm(NAME_SPEC.getName(), nameKey.get());
   }
 
   private final GerritIndexWriterConfig indexWriterConfig;
@@ -97,7 +98,8 @@
         null,
         new GerritIndexWriterConfig(cfg, PROJECTS),
         new SearcherFactory(),
-        autoFlush);
+        autoFlush,
+        ProjectIndex.ENTITY_TO_KEY);
     this.projectCache = projectCache;
 
     indexWriterConfig = new GerritIndexWriterConfig(cfg, PROJECTS);
@@ -108,7 +110,7 @@
   void add(Document doc, Values<ProjectData> values) {
     // Add separate DocValues field for the field that is needed for sorting.
     SchemaField<ProjectData, ?> f = values.getField();
-    if (f == NAME) {
+    if (f == NAME_SPEC) {
       String value = (String) getOnlyElement(values.getValues());
       doc.add(new SortedDocValuesField(NAME_SORT_FIELD, new BytesRef(value)));
     }
@@ -151,9 +153,10 @@
         new Sort(new SortField(NAME_SORT_FIELD, SortField.Type.STRING, false)));
   }
 
+  @Nullable
   @Override
   protected ProjectData fromDocument(Document doc) {
-    Project.NameKey nameKey = Project.nameKey(doc.getField(NAME.getName()).stringValue());
+    Project.NameKey nameKey = Project.nameKey(doc.getField(NAME_SPEC.getName()).stringValue());
     return projectCache.get().get(nameKey).map(ProjectState::toProjectData).orElse(null);
   }
 }
diff --git a/java/com/google/gerrit/lucene/LuceneStoredValue.java b/java/com/google/gerrit/lucene/LuceneStoredValue.java
index 1f8c039..58ae3e0 100644
--- a/java/com/google/gerrit/lucene/LuceneStoredValue.java
+++ b/java/com/google/gerrit/lucene/LuceneStoredValue.java
@@ -38,6 +38,7 @@
     this.field = field;
   }
 
+  @Nullable
   @Override
   public String asString() {
     return Iterables.getFirst(asStrings(), null);
@@ -48,6 +49,7 @@
     return field.stream().map(f -> f.stringValue()).collect(toImmutableList());
   }
 
+  @Nullable
   @Override
   public Integer asInteger() {
     return Iterables.getFirst(asIntegers(), null);
@@ -58,6 +60,7 @@
     return field.stream().map(f -> f.numericValue().intValue()).collect(toImmutableList());
   }
 
+  @Nullable
   @Override
   public Long asLong() {
     return Iterables.getFirst(asLongs(), null);
@@ -68,11 +71,13 @@
     return field.stream().map(f -> f.numericValue().longValue()).collect(toImmutableList());
   }
 
+  @Nullable
   @Override
   public Timestamp asTimestamp() {
     return asLong() == null ? null : new Timestamp(asLong());
   }
 
+  @Nullable
   @Override
   public byte[] asByteArray() {
     return Iterables.getFirst(asByteArrays(), null);
diff --git a/java/com/google/gerrit/lucene/WrappableSearcherManager.java b/java/com/google/gerrit/lucene/WrappableSearcherManager.java
index c164b29..56cb220 100644
--- a/java/com/google/gerrit/lucene/WrappableSearcherManager.java
+++ b/java/com/google/gerrit/lucene/WrappableSearcherManager.java
@@ -17,6 +17,7 @@
  * limitations under the License.
  */
 
+import com.google.gerrit.common.Nullable;
 import java.io.IOException;
 import org.apache.lucene.index.DirectoryReader;
 import org.apache.lucene.index.FilterDirectoryReader;
@@ -132,6 +133,7 @@
     reference.getIndexReader().decRef();
   }
 
+  @Nullable
   @Override
   protected IndexSearcher refreshIfNeeded(IndexSearcher referenceToRefresh) throws IOException {
     final IndexReader r = referenceToRefresh.getIndexReader();
diff --git a/java/com/google/gerrit/mail/MailHeader.java b/java/com/google/gerrit/mail/MailHeader.java
index 2700f81..6933140 100644
--- a/java/com/google/gerrit/mail/MailHeader.java
+++ b/java/com/google/gerrit/mail/MailHeader.java
@@ -17,7 +17,6 @@
 /** Variables used by emails to hold data */
 public enum MailHeader {
   // Gerrit metadata holders
-  ASSIGNEE("Gerrit-Assignee"),
   ATTENTION("Gerrit-Attention"),
   BRANCH("Gerrit-Branch"),
   CC("Gerrit-CC"),
diff --git a/java/com/google/gerrit/mail/RawMailParser.java b/java/com/google/gerrit/mail/RawMailParser.java
index 929e9f9..79d1cb8f 100644
--- a/java/com/google/gerrit/mail/RawMailParser.java
+++ b/java/com/google/gerrit/mail/RawMailParser.java
@@ -26,6 +26,7 @@
 import java.io.IOException;
 import java.io.InputStreamReader;
 import java.time.Instant;
+import java.util.Locale;
 import org.apache.james.mime4j.MimeException;
 import org.apache.james.mime4j.dom.Entity;
 import org.apache.james.mime4j.dom.Message;
@@ -90,7 +91,7 @@
 
     // Add additional headers
     mimeMessage.getHeader().getFields().stream()
-        .filter(f -> !MAIN_HEADERS.contains(f.getName().toLowerCase()))
+        .filter(f -> !MAIN_HEADERS.contains(f.getName().toLowerCase(Locale.US)))
         .forEach(f -> messageBuilder.addAdditionalHeader(f.getName() + ": " + f.getBody()));
 
     // Add text and html body parts
diff --git a/java/com/google/gerrit/metrics/BUILD b/java/com/google/gerrit/metrics/BUILD
index 5f4e0c0..0f80a0c 100644
--- a/java/com/google/gerrit/metrics/BUILD
+++ b/java/com/google/gerrit/metrics/BUILD
@@ -5,6 +5,7 @@
     srcs = glob(["**/*.java"]),
     visibility = ["//visibility:public"],
     deps = [
+        "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/common:server",
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/lifecycle",
diff --git a/java/com/google/gerrit/metrics/dropwizard/MetricJson.java b/java/com/google/gerrit/metrics/dropwizard/MetricJson.java
index d59a1d9..27e9377 100644
--- a/java/com/google/gerrit/metrics/dropwizard/MetricJson.java
+++ b/java/com/google/gerrit/metrics/dropwizard/MetricJson.java
@@ -23,6 +23,7 @@
 import com.codahale.metrics.Timer;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.Field;
 import java.util.ArrayList;
@@ -144,6 +145,7 @@
     }
   }
 
+  @Nullable
   private static Boolean toBool(ImmutableMap<String, String> atts, String key) {
     return Description.TRUE_VALUE.equals(atts.get(key)) ? true : null;
   }
diff --git a/java/com/google/gerrit/metrics/proc/OperatingSystemMXBeanFactory.java b/java/com/google/gerrit/metrics/proc/OperatingSystemMXBeanFactory.java
index ef0ced6..84f2320 100644
--- a/java/com/google/gerrit/metrics/proc/OperatingSystemMXBeanFactory.java
+++ b/java/com/google/gerrit/metrics/proc/OperatingSystemMXBeanFactory.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.metrics.proc;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.sun.management.UnixOperatingSystemMXBean;
 import java.lang.management.ManagementFactory;
 import java.lang.management.OperatingSystemMXBean;
@@ -25,6 +26,7 @@
 
   private OperatingSystemMXBeanFactory() {}
 
+  @Nullable
   static OperatingSystemMXBeanInterface create() {
     OperatingSystemMXBean sys = ManagementFactory.getOperatingSystemMXBean();
     if (sys instanceof UnixOperatingSystemMXBean) {
diff --git a/java/com/google/gerrit/pgm/Daemon.java b/java/com/google/gerrit/pgm/Daemon.java
index 74a3dce..72c465d 100644
--- a/java/com/google/gerrit/pgm/Daemon.java
+++ b/java/com/google/gerrit/pgm/Daemon.java
@@ -55,6 +55,7 @@
 import com.google.gerrit.pgm.util.LogFileCompressor.LogFileCompressorModule;
 import com.google.gerrit.pgm.util.RuntimeShutdown;
 import com.google.gerrit.pgm.util.SiteProgram;
+import com.google.gerrit.server.DefaultRefLogIdentityProvider;
 import com.google.gerrit.server.LibModuleLoader;
 import com.google.gerrit.server.LibModuleType;
 import com.google.gerrit.server.ModuleOverloader;
@@ -64,6 +65,7 @@
 import com.google.gerrit.server.account.externalids.ExternalIdCaseSensitivityMigrator;
 import com.google.gerrit.server.api.GerritApiModule;
 import com.google.gerrit.server.api.PluginApiModule;
+import com.google.gerrit.server.api.projects.ProjectQueryBuilderModule;
 import com.google.gerrit.server.audit.AuditModule;
 import com.google.gerrit.server.cache.h2.H2CacheModule;
 import com.google.gerrit.server.cache.mem.DefaultMemoryCacheModule;
@@ -212,6 +214,7 @@
   private Path runFile;
   private boolean inMemoryTest;
   private AbstractModule indexModule;
+  private Module accountPatchReviewStoreModule;
   private Module emailModule;
   private List<Module> testSysModules = new ArrayList<>();
   private List<Module> testSshModules = new ArrayList<>();
@@ -334,6 +337,11 @@
   }
 
   @VisibleForTesting
+  public void setAccountPatchReviewStoreModuleForTesting(Module module) {
+    accountPatchReviewStoreModule = module;
+  }
+
+  @VisibleForTesting
   public void setEmailModuleForTesting(Module module) {
     emailModule = module;
   }
@@ -444,12 +452,18 @@
     modules.add(new WorkQueueModule());
     modules.add(new StreamEventsApiListenerModule());
     modules.add(new EventBrokerModule());
-    modules.add(new JdbcAccountPatchReviewStoreModule(config));
+    if (accountPatchReviewStoreModule != null) {
+      modules.add(accountPatchReviewStoreModule);
+    } else {
+      modules.add(new JdbcAccountPatchReviewStoreModule(config));
+    }
     modules.add(new SysExecutorModule());
     modules.add(new DiffExecutorModule());
     modules.add(new MimeUtil2Module());
     modules.add(cfgInjector.getInstance(GerritGlobalModule.class));
     modules.add(new GerritApiModule());
+    modules.add(new ProjectQueryBuilderModule());
+    modules.add(new DefaultRefLogIdentityProvider.Module());
     modules.add(new PluginApiModule());
 
     modules.add(
diff --git a/java/com/google/gerrit/pgm/Reindex.java b/java/com/google/gerrit/pgm/Reindex.java
index ecfca0d..b4344d7 100644
--- a/java/com/google/gerrit/pgm/Reindex.java
+++ b/java/com/google/gerrit/pgm/Reindex.java
@@ -36,6 +36,7 @@
 import com.google.gerrit.server.cache.CacheInfo;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.git.WorkQueue.WorkQueueModule;
 import com.google.gerrit.server.index.IndexModule;
 import com.google.gerrit.server.index.change.ChangeSchemaDefinitions;
 import com.google.gerrit.server.index.options.AutoFlush;
@@ -175,6 +176,8 @@
     }
     boolean replica = ReplicaUtil.isReplica(globalConfig);
     List<Module> modules = new ArrayList<>();
+    modules.add(new WorkQueueModule());
+
     Module indexModule;
     IndexType indexType = IndexModule.getIndexType(dbInjector);
     if (indexType.isLucene()) {
diff --git a/java/com/google/gerrit/pgm/SwitchSecureStore.java b/java/com/google/gerrit/pgm/SwitchSecureStore.java
index 824a9a7..6dec2d8 100644
--- a/java/com/google/gerrit/pgm/SwitchSecureStore.java
+++ b/java/com/google/gerrit/pgm/SwitchSecureStore.java
@@ -19,6 +19,7 @@
 import com.google.common.collect.Iterables;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.IoUtil;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.SiteLibraryLoaderUtil;
 import com.google.gerrit.pgm.util.SiteProgram;
 import com.google.gerrit.server.config.SitePaths;
@@ -185,6 +186,7 @@
     }
   }
 
+  @Nullable
   private Path findJarWithSecureStore(SitePaths sitePaths, String secureStoreClass)
       throws IOException {
     List<Path> jars = SiteLibraryLoaderUtil.listJars(sitePaths.lib_dir);
diff --git a/java/com/google/gerrit/pgm/init/BUILD b/java/com/google/gerrit/pgm/init/BUILD
index 5849711..0c0d937 100644
--- a/java/com/google/gerrit/pgm/init/BUILD
+++ b/java/com/google/gerrit/pgm/init/BUILD
@@ -23,7 +23,6 @@
         "//java/com/google/gerrit/server/schema",
         "//java/com/google/gerrit/server/util/time",
         "//lib:guava",
-        "//lib:h2",
         "//lib:jgit",
         "//lib/commons:validator",
         "//lib/flogger:api",
diff --git a/java/com/google/gerrit/pgm/init/BaseInit.java b/java/com/google/gerrit/pgm/init/BaseInit.java
index 4592cbb..b59b924 100644
--- a/java/com/google/gerrit/pgm/init/BaseInit.java
+++ b/java/com/google/gerrit/pgm/init/BaseInit.java
@@ -22,6 +22,7 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Die;
 import com.google.gerrit.common.IoUtil;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.index.IndexType;
 import com.google.gerrit.metrics.DisabledMetricMaker;
@@ -175,6 +176,7 @@
    */
   protected void afterInit(SiteRun run) throws Exception {}
 
+  @Nullable
   protected List<String> getInstallPlugins() {
     try {
       if (pluginsToInstall != null && pluginsToInstall.isEmpty()) {
@@ -304,6 +306,7 @@
     return ConsoleUI.getInstance(false);
   }
 
+  @Nullable
   private SecureStoreInitData discoverSecureStoreClass() {
     String secureStore = getSecureStoreLib();
     if (Strings.isNullOrEmpty(secureStore)) {
diff --git a/java/com/google/gerrit/pgm/init/Browser.java b/java/com/google/gerrit/pgm/init/Browser.java
index 2e49e13..228a528 100644
--- a/java/com/google/gerrit/pgm/init/Browser.java
+++ b/java/com/google/gerrit/pgm/init/Browser.java
@@ -24,7 +24,7 @@
 import java.net.URISyntaxException;
 import org.eclipse.jgit.lib.Config;
 
-/** Opens the user's web browser to the web UI. */
+/** Points the user to a web browser URL. */
 public class Browser {
   private final Config cfg;
 
@@ -57,7 +57,7 @@
       return;
     }
     waitForServer(uri);
-    openBrowser(uri, link);
+    printBrowserUrl(uri, link);
   }
 
   private void waitForServer(URI uri) throws IOException {
@@ -97,17 +97,9 @@
     return url;
   }
 
-  private void openBrowser(URI uri, String link) {
+  private void printBrowserUrl(URI uri, String link) {
     String url = resolveUrl(uri, link);
-    System.err.format("Opening %s ...", url);
+    System.err.format("Please open the following URL in the browser: %s", url);
     System.err.flush();
-    try {
-      org.h2.tools.Server.openBrowser(url);
-      System.err.println("OK");
-    } catch (Exception e) {
-      System.err.println("FAILED");
-      System.err.println("Open Gerrit with a JavaScript capable browser:");
-      System.err.println("  " + url);
-    }
   }
 }
diff --git a/java/com/google/gerrit/pgm/init/InitAdminUser.java b/java/com/google/gerrit/pgm/init/InitAdminUser.java
index 4e854b5..3dce974 100644
--- a/java/com/google/gerrit/pgm/init/InitAdminUser.java
+++ b/java/com/google/gerrit/pgm/init/InitAdminUser.java
@@ -17,6 +17,7 @@
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.common.base.Strings;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.GroupReference;
 import com.google.gerrit.entities.InternalGroup;
@@ -182,6 +183,7 @@
     return email;
   }
 
+  @Nullable
   private AccountSshKey readSshKey(Account.Id id) throws IOException {
     String defaultPublicSshKeyFile = "";
     Path defaultPublicSshKeyPath = Paths.get(System.getProperty("user.home"), ".ssh", "id_rsa.pub");
diff --git a/java/com/google/gerrit/pgm/init/InitPluginStepsLoader.java b/java/com/google/gerrit/pgm/init/InitPluginStepsLoader.java
index a7f9c5d..16c4ce7 100644
--- a/java/com/google/gerrit/pgm/init/InitPluginStepsLoader.java
+++ b/java/com/google/gerrit/pgm/init/InitPluginStepsLoader.java
@@ -16,6 +16,7 @@
 
 import com.google.common.base.MoreObjects;
 import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.annotations.PluginName;
 import com.google.gerrit.pgm.init.api.ConsoleUI;
 import com.google.gerrit.pgm.init.api.InitStep;
@@ -62,6 +63,7 @@
     return pluginsInitSteps;
   }
 
+  @Nullable
   private InitStep loadInitStep(Path jar) {
     try {
       URLClassLoader pluginLoader =
diff --git a/java/com/google/gerrit/pgm/init/SitePathInitializer.java b/java/com/google/gerrit/pgm/init/SitePathInitializer.java
index 236d185..a057e66 100644
--- a/java/com/google/gerrit/pgm/init/SitePathInitializer.java
+++ b/java/com/google/gerrit/pgm/init/SitePathInitializer.java
@@ -144,8 +144,6 @@
     extractMailExample("RestoredHtml.soy");
     extractMailExample("Reverted.soy");
     extractMailExample("RevertedHtml.soy");
-    extractMailExample("SetAssignee.soy");
-    extractMailExample("SetAssigneeHtml.soy");
 
     if (!ui.isBatch()) {
       System.err.println();
diff --git a/java/com/google/gerrit/pgm/init/api/ConsoleUI.java b/java/com/google/gerrit/pgm/init/api/ConsoleUI.java
index dffdde7..865f7d7 100644
--- a/java/com/google/gerrit/pgm/init/api/ConsoleUI.java
+++ b/java/com/google/gerrit/pgm/init/api/ConsoleUI.java
@@ -17,8 +17,10 @@
 import com.google.errorprone.annotations.FormatMethod;
 import com.google.errorprone.annotations.FormatString;
 import com.google.gerrit.common.Die;
+import com.google.gerrit.common.Nullable;
 import java.io.Console;
 import java.util.EnumSet;
+import java.util.Locale;
 import java.util.Set;
 
 /** Console based interaction with the invoking user. */
@@ -164,21 +166,22 @@
         String def, Set<String> allowedValues, @FormatString String fmt, Object... args) {
       for (; ; ) {
         String r = readString(def, fmt, args);
-        if (allowedValues.contains(r.toLowerCase())) {
-          return r.toLowerCase();
+        if (allowedValues.contains(r.toLowerCase(Locale.US))) {
+          return r.toLowerCase(Locale.US);
         }
         if (!"?".equals(r)) {
           console.printf("error: '%s' is not a valid choice\n", r);
         }
         console.printf("       Supported options are:\n");
         for (String v : allowedValues) {
-          console.printf("         %s\n", v.toLowerCase());
+          console.printf("         %s\n", v.toLowerCase(Locale.US));
         }
       }
     }
 
     @Override
     @FormatMethod
+    @Nullable
     public String password(String fmt, Object... args) {
       final String prompt = String.format(fmt, args);
       for (; ; ) {
@@ -208,7 +211,8 @@
         T def, A options, String fmt, Object... args) {
       final String prompt = String.format(fmt, args);
       for (; ; ) {
-        String r = console.readLine("%-30s [%s/?]: ", prompt, def.toString().toLowerCase());
+        String r =
+            console.readLine("%-30s [%s/?]: ", prompt, def.toString().toLowerCase(Locale.US));
         if (r == null) {
           throw abort();
         }
@@ -226,7 +230,7 @@
         }
         console.printf("       Supported options are:\n");
         for (T e : options) {
-          console.printf("         %s\n", e.toString().toLowerCase());
+          console.printf("         %s\n", e.toString().toLowerCase(Locale.US));
         }
       }
     }
diff --git a/java/com/google/gerrit/pgm/init/api/InitUtil.java b/java/com/google/gerrit/pgm/init/api/InitUtil.java
index d038de7..7688728 100644
--- a/java/com/google/gerrit/pgm/init/api/InitUtil.java
+++ b/java/com/google/gerrit/pgm/init/api/InitUtil.java
@@ -18,6 +18,7 @@
 
 import com.google.common.io.ByteStreams;
 import com.google.gerrit.common.Die;
+import com.google.gerrit.common.Nullable;
 import java.io.ByteArrayInputStream;
 import java.io.File;
 import java.io.FileNotFoundException;
@@ -127,6 +128,7 @@
     }
   }
 
+  @Nullable
   private static InputStream open(Class<?> sibling, String name) {
     final InputStream in = sibling.getResourceAsStream(name);
     if (in == null) {
diff --git a/java/com/google/gerrit/pgm/init/api/Section.java b/java/com/google/gerrit/pgm/init/api/Section.java
index b5d35f4..5cc4b5d 100644
--- a/java/com/google/gerrit/pgm/init/api/Section.java
+++ b/java/com/google/gerrit/pgm/init/api/Section.java
@@ -166,6 +166,7 @@
     return nv;
   }
 
+  @Nullable
   public String password(String username, String password) {
     final String ov = getSecure(password);
 
diff --git a/java/com/google/gerrit/pgm/rules/PrologCompiler.java b/java/com/google/gerrit/pgm/rules/PrologCompiler.java
index 0a41db5..e68e203 100644
--- a/java/com/google/gerrit/pgm/rules/PrologCompiler.java
+++ b/java/com/google/gerrit/pgm/rules/PrologCompiler.java
@@ -14,6 +14,9 @@
 
 package com.google.gerrit.pgm.rules;
 
+import static com.google.gerrit.server.project.ProjectConfig.RULES_PL_FILE;
+
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.Version;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.server.config.GerritServerConfig;
@@ -83,7 +86,7 @@
       return Status.NO_RULES;
     }
 
-    ObjectId rulesId = git.resolve(metaConfig.name() + ":rules.pl");
+    ObjectId rulesId = git.resolve(metaConfig.name() + ":" + RULES_PL_FILE);
     if (rulesId == null) {
       return Status.NO_RULES;
     }
@@ -182,6 +185,7 @@
     }
   }
 
+  @Nullable
   private String getMyClasspath() {
     StringBuilder cp = new StringBuilder();
     appendClasspath(cp, getClass().getClassLoader());
diff --git a/java/com/google/gerrit/pgm/util/AbstractProgram.java b/java/com/google/gerrit/pgm/util/AbstractProgram.java
index 96b042a..00cba31 100644
--- a/java/com/google/gerrit/pgm/util/AbstractProgram.java
+++ b/java/com/google/gerrit/pgm/util/AbstractProgram.java
@@ -18,6 +18,7 @@
 import com.google.gerrit.util.cli.CmdLineParser;
 import com.google.gerrit.util.cli.OptionHandlers;
 import java.io.StringWriter;
+import java.util.Locale;
 import org.kohsuke.args4j.CmdLineException;
 import org.kohsuke.args4j.Option;
 
@@ -35,7 +36,7 @@
     if (0 < dot) {
       n = n.substring(dot + 1);
     }
-    return n.toLowerCase();
+    return n.toLowerCase(Locale.US);
   }
 
   public final int main(String[] argv) throws Exception {
diff --git a/java/com/google/gerrit/pgm/util/BatchProgramModule.java b/java/com/google/gerrit/pgm/util/BatchProgramModule.java
index f58387e3..1e41cbc 100644
--- a/java/com/google/gerrit/pgm/util/BatchProgramModule.java
+++ b/java/com/google/gerrit/pgm/util/BatchProgramModule.java
@@ -28,6 +28,7 @@
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.DefaultRefLogIdentityProvider;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.InternalUser;
 import com.google.gerrit.server.LibModuleLoader;
@@ -84,18 +85,19 @@
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.project.SubmitRequirementsEvaluatorImpl;
 import com.google.gerrit.server.project.SubmitRuleEvaluator;
-import com.google.gerrit.server.query.FileEditsPredicate;
 import com.google.gerrit.server.query.approval.ApprovalModule;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeIsVisibleToPredicate;
 import com.google.gerrit.server.query.change.ChangeQueryBuilder;
 import com.google.gerrit.server.query.change.ConflictsCacheImpl;
-import com.google.gerrit.server.query.change.DistinctVotersPredicate;
 import com.google.gerrit.server.restapi.group.GroupModule;
 import com.google.gerrit.server.rules.DefaultSubmitRule.DefaultSubmitRuleModule;
 import com.google.gerrit.server.rules.IgnoreSelfApprovalRule.IgnoreSelfApprovalRuleModule;
 import com.google.gerrit.server.rules.PrologModule;
 import com.google.gerrit.server.rules.SubmitRule;
+import com.google.gerrit.server.submitrequirement.predicate.DistinctVotersPredicate;
+import com.google.gerrit.server.submitrequirement.predicate.FileEditsPredicate;
+import com.google.gerrit.server.submitrequirement.predicate.HasSubmoduleUpdatePredicate;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.inject.Injector;
 import com.google.inject.Key;
@@ -127,6 +129,7 @@
     modules.add(PatchListCacheImpl.module());
     modules.add(new DefaultUrlFormatterModule());
     modules.add(DiffOperationsImpl.module());
+    modules.add(new DefaultRefLogIdentityProvider.Module());
 
     // There is the concept of LifecycleModule, in Gerrit's own extension to Guice, which has these:
     //  listener().to(SomeClassImplementingLifecycleListener.class);
@@ -197,6 +200,7 @@
     factory(ChangeData.AssistedFactory.class);
     factory(ChangeIsVisibleToPredicate.Factory.class);
     factory(DistinctVotersPredicate.Factory.class);
+    factory(HasSubmoduleUpdatePredicate.Factory.class);
     factory(ProjectState.Factory.class);
 
     DynamicMap.mapOf(binder(), ChangeQueryBuilder.ChangeOperatorFactory.class);
diff --git a/java/com/google/gerrit/prettify/BUILD b/java/com/google/gerrit/prettify/BUILD
index 0a15fda..a5c8b77 100644
--- a/java/com/google/gerrit/prettify/BUILD
+++ b/java/com/google/gerrit/prettify/BUILD
@@ -5,6 +5,7 @@
     srcs = glob(["common/**/*.java"]),
     visibility = ["//visibility:public"],
     deps = [
+        "//java/com/google/gerrit/common:annotations",
         "//lib:guava",
         "//lib:jgit",
         "//lib/auto:auto-value",
diff --git a/java/com/google/gerrit/prettify/common/SparseFileContent.java b/java/com/google/gerrit/prettify/common/SparseFileContent.java
index 1249b65..f40222a 100644
--- a/java/com/google/gerrit/prettify/common/SparseFileContent.java
+++ b/java/com/google/gerrit/prettify/common/SparseFileContent.java
@@ -17,6 +17,7 @@
 import com.google.auto.value.AutoValue;
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.Nullable;
 
 /**
  * A class to store subset of a file's lines in a memory efficient way. Internally, it stores lines
@@ -134,6 +135,7 @@
       return getSize();
     }
 
+    @Nullable
     private String getLine(int idx) {
       // Most requests are sequential in nature, fetching the next
       // line from the current range, or the next range.
diff --git a/java/com/google/gerrit/server/AssigneeStatusUpdate.java b/java/com/google/gerrit/server/AssigneeStatusUpdate.java
deleted file mode 100644
index 812aad1..0000000
--- a/java/com/google/gerrit/server/AssigneeStatusUpdate.java
+++ /dev/null
@@ -1,35 +0,0 @@
-// Copyright (C) 2019 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server;
-
-import com.google.auto.value.AutoValue;
-import com.google.gerrit.entities.Account;
-import java.time.Instant;
-import java.util.Optional;
-
-/** Change to an assignee's status. */
-@AutoValue
-public abstract class AssigneeStatusUpdate {
-  public static AssigneeStatusUpdate create(
-      Instant ts, Account.Id updatedBy, Optional<Account.Id> currentAssignee) {
-    return new AutoValue_AssigneeStatusUpdate(ts, updatedBy, currentAssignee);
-  }
-
-  public abstract Instant date();
-
-  public abstract Account.Id updatedBy();
-
-  public abstract Optional<Account.Id> currentAssignee();
-}
diff --git a/java/com/google/gerrit/server/ProjectUtil.java b/java/com/google/gerrit/server/BranchUtil.java
similarity index 71%
rename from java/com/google/gerrit/server/ProjectUtil.java
rename to java/com/google/gerrit/server/BranchUtil.java
index fa056b3..78f693d 100644
--- a/java/com/google/gerrit/server/ProjectUtil.java
+++ b/java/com/google/gerrit/server/BranchUtil.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2012 The Android Open Source Project
+// Copyright (C) 2023 The Android Open Source Project
 //
 // Licensed under the Apache License, Version 2.0 (the "License");
 // you may not use this file except in compliance with the License.
@@ -21,8 +21,7 @@
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.Repository;
 
-public class ProjectUtil {
-
+public class BranchUtil {
   /**
    * Checks whether the specified branch exists.
    *
@@ -45,28 +44,4 @@
       return exists;
     }
   }
-
-  public static String sanitizeProjectName(String name) {
-    name = stripGitSuffix(name);
-    name = stripTrailingSlash(name);
-    return name;
-  }
-
-  public static String stripGitSuffix(String name) {
-    if (name.endsWith(".git")) {
-      // Be nice and drop the trailing ".git" suffix, which we never keep
-      // in our database, but clients might mistakenly provide anyway.
-      //
-      name = name.substring(0, name.length() - 4);
-      name = stripTrailingSlash(name);
-    }
-    return name;
-  }
-
-  private static String stripTrailingSlash(String name) {
-    while (name.endsWith("/")) {
-      name = name.substring(0, name.length() - 1);
-    }
-    return name;
-  }
 }
diff --git a/java/com/google/gerrit/server/ChangeMessagesUtil.java b/java/com/google/gerrit/server/ChangeMessagesUtil.java
index 81cff6e..400da58 100644
--- a/java/com/google/gerrit/server/ChangeMessagesUtil.java
+++ b/java/com/google/gerrit/server/ChangeMessagesUtil.java
@@ -40,8 +40,6 @@
   public static final String TAG_ABANDON = AUTOGENERATED_BY_GERRIT_TAG_PREFIX + "abandon";
   public static final String TAG_CHERRY_PICK_CHANGE =
       AUTOGENERATED_BY_GERRIT_TAG_PREFIX + "cherryPickChange";
-  public static final String TAG_DELETE_ASSIGNEE =
-      AUTOGENERATED_BY_GERRIT_TAG_PREFIX + "deleteAssignee";
   public static final String TAG_DELETE_REVIEWER =
       AUTOGENERATED_BY_GERRIT_TAG_PREFIX + "deleteReviewer";
   public static final String TAG_DELETE_VOTE = AUTOGENERATED_BY_GERRIT_TAG_PREFIX + "deleteVote";
@@ -49,7 +47,6 @@
   public static final String TAG_MOVE = AUTOGENERATED_BY_GERRIT_TAG_PREFIX + "move";
   public static final String TAG_RESTORE = AUTOGENERATED_BY_GERRIT_TAG_PREFIX + "restore";
   public static final String TAG_REVERT = AUTOGENERATED_BY_GERRIT_TAG_PREFIX + "revert";
-  public static final String TAG_SET_ASSIGNEE = AUTOGENERATED_BY_GERRIT_TAG_PREFIX + "setAssignee";
   public static final String TAG_UPDATE_ATTENTION_SET =
       AUTOGENERATED_BY_GERRIT_TAG_PREFIX + "updateAttentionSet";
   public static final String TAG_SET_DESCRIPTION =
@@ -150,7 +147,7 @@
     cmi.tag = message.getTag();
     cmi._revisionNumber = patchNum != null ? patchNum.get() : null;
     Account.Id realAuthor = message.getRealAuthor();
-    if (realAuthor != null) {
+    if (realAuthor != null && !realAuthor.equals(message.getAuthor())) {
       cmi.realAuthor = accountLoader.get(realAuthor);
     }
     cmi.accountsInMessage =
diff --git a/java/com/google/gerrit/server/ChangeUtil.java b/java/com/google/gerrit/server/ChangeUtil.java
index d9edf42..2265055 100644
--- a/java/com/google/gerrit/server/ChangeUtil.java
+++ b/java/com/google/gerrit/server/ChangeUtil.java
@@ -31,6 +31,7 @@
 import java.security.SecureRandom;
 import java.util.Collection;
 import java.util.List;
+import java.util.Locale;
 import java.util.Optional;
 import java.util.Random;
 import java.util.Set;
@@ -124,7 +125,10 @@
    * @throws BadRequestException if the new commit message is null or empty
    */
   public static void ensureChangeIdIsCorrect(
-      boolean requireChangeId, String currentChangeId, String newCommitMessage)
+      boolean requireChangeId,
+      String currentChangeId,
+      String newCommitMessage,
+      UrlFormatter urlFormatter)
       throws ResourceConflictException, BadRequestException {
     RevCommit revCommit =
         RevCommit.parse(
@@ -133,7 +137,7 @@
     // Check that the commit message without footers is not empty
     CommitMessageUtil.checkAndSanitizeCommitMessage(revCommit.getShortMessage());
 
-    List<String> changeIdFooters = revCommit.getFooterLines(FooterConstants.CHANGE_ID);
+    List<String> changeIdFooters = getChangeIdsFromFooter(revCommit, urlFormatter);
     if (requireChangeId && changeIdFooters.isEmpty()) {
       throw new ResourceConflictException("missing Change-Id footer");
     }
@@ -146,7 +150,7 @@
   }
 
   public static String status(Change c) {
-    return c != null ? c.getStatus().name().toLowerCase() : "deleted";
+    return c != null ? c.getStatus().name().toLowerCase(Locale.US) : "deleted";
   }
 
   private static final Pattern LINK_CHANGE_ID_PATTERN = Pattern.compile("I[0-9a-f]{40}");
diff --git a/java/com/google/gerrit/server/CommentsUtil.java b/java/com/google/gerrit/server/CommentsUtil.java
index 8198ce4..285657e 100644
--- a/java/com/google/gerrit/server/CommentsUtil.java
+++ b/java/com/google/gerrit/server/CommentsUtil.java
@@ -104,6 +104,7 @@
     return PatchSet.id(changeId, comment.key.patchSetId);
   }
 
+  @Nullable
   public static String extractMessageId(@Nullable String tag) {
     if (tag == null || !tag.startsWith("mailMessageId=")) {
       return null;
diff --git a/java/com/google/gerrit/server/DefaultRefLogIdentityProvider.java b/java/com/google/gerrit/server/DefaultRefLogIdentityProvider.java
new file mode 100644
index 0000000..10b4ec5
--- /dev/null
+++ b/java/com/google/gerrit/server/DefaultRefLogIdentityProvider.java
@@ -0,0 +1,97 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.server.config.AnonymousCowardName;
+import com.google.gerrit.server.config.EnablePeerIPInReflogRecord;
+import com.google.inject.AbstractModule;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.net.InetAddress;
+import java.net.InetSocketAddress;
+import java.net.SocketAddress;
+import java.time.Instant;
+import java.time.ZoneId;
+import org.eclipse.jgit.lib.PersonIdent;
+
+@Singleton
+public class DefaultRefLogIdentityProvider implements RefLogIdentityProvider {
+  public static class Module extends AbstractModule {
+    @Override
+    protected void configure() {
+      bind(RefLogIdentityProvider.class).to(DefaultRefLogIdentityProvider.class);
+    }
+  }
+
+  private final String anonymousCowardName;
+  private final Boolean enablePeerIPInReflogRecord;
+
+  @Inject
+  DefaultRefLogIdentityProvider(
+      @AnonymousCowardName String anonymousCowardName,
+      @EnablePeerIPInReflogRecord Boolean enablePeerIPInReflogRecord) {
+    this.anonymousCowardName = anonymousCowardName;
+    this.enablePeerIPInReflogRecord = enablePeerIPInReflogRecord;
+  }
+
+  @Override
+  public PersonIdent newRefLogIdent(IdentifiedUser user, Instant when, ZoneId zoneId) {
+    Account account = user.getAccount();
+
+    String name = account.fullName();
+    if (name == null || name.isEmpty()) {
+      name = account.preferredEmail();
+    }
+    if (name == null || name.isEmpty()) {
+      name = anonymousCowardName;
+    }
+
+    String email;
+    if (enablePeerIPInReflogRecord) {
+      email = constructMailAddress(user, guessHost(user));
+    } else {
+      email =
+          Strings.isNullOrEmpty(account.preferredEmail())
+              ? constructMailAddress(user, getDefaultDomain())
+              : account.preferredEmail();
+    }
+
+    return new PersonIdent(name, email, when, zoneId);
+  }
+
+  private String constructMailAddress(IdentifiedUser user, String host) {
+    return user.getUserName().orElse("")
+        + "|account-"
+        + user.getAccountId().toString()
+        + "@"
+        + host;
+  }
+
+  private String guessHost(IdentifiedUser user) {
+    String host = null;
+    SocketAddress remotePeer = user.getRemotePeer();
+    if (remotePeer instanceof InetSocketAddress) {
+      InetSocketAddress sa = (InetSocketAddress) remotePeer;
+      InetAddress in = sa.getAddress();
+      host = in != null ? in.getHostAddress() : sa.getHostName();
+    }
+    if (Strings.isNullOrEmpty(host)) {
+      return getDefaultDomain();
+    }
+    return host;
+  }
+}
diff --git a/java/com/google/gerrit/server/IdentifiedUser.java b/java/com/google/gerrit/server/IdentifiedUser.java
index 65a81f7..36d7888 100644
--- a/java/com/google/gerrit/server/IdentifiedUser.java
+++ b/java/com/google/gerrit/server/IdentifiedUser.java
@@ -19,7 +19,6 @@
 import static com.google.common.flogger.LazyArgs.lazy;
 
 import com.google.common.annotations.VisibleForTesting;
-import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
@@ -44,8 +43,6 @@
 import com.google.inject.ProvisionException;
 import com.google.inject.Singleton;
 import com.google.inject.util.Providers;
-import java.net.InetAddress;
-import java.net.InetSocketAddress;
 import java.net.MalformedURLException;
 import java.net.SocketAddress;
 import java.net.URL;
@@ -66,6 +63,7 @@
     private final AuthConfig authConfig;
     private final Realm realm;
     private final String anonymousCowardName;
+    private final RefLogIdentityProvider refLogIdentityProvider;
     private final Provider<String> canonicalUrl;
     private final AccountCache accountCache;
     private final GroupBackend groupBackend;
@@ -76,6 +74,7 @@
         AuthConfig authConfig,
         Realm realm,
         @AnonymousCowardName String anonymousCowardName,
+        RefLogIdentityProvider refLogIdentityProvider,
         @CanonicalWebUrl Provider<String> canonicalUrl,
         @EnablePeerIPInReflogRecord Boolean enablePeerIPInReflogRecord,
         AccountCache accountCache,
@@ -83,6 +82,7 @@
       this.authConfig = authConfig;
       this.realm = realm;
       this.anonymousCowardName = anonymousCowardName;
+      this.refLogIdentityProvider = refLogIdentityProvider;
       this.canonicalUrl = canonicalUrl;
       this.accountCache = accountCache;
       this.groupBackend = groupBackend;
@@ -94,36 +94,37 @@
           authConfig,
           realm,
           anonymousCowardName,
+          refLogIdentityProvider,
           canonicalUrl,
           accountCache,
           groupBackend,
           enablePeerIPInReflogRecord,
           Providers.of(null),
           state,
-          null);
+          /* realUser= */ null);
     }
 
     public IdentifiedUser create(Account.Id id) {
-      return create(null, id);
+      return create(/* remotePeer= */ null, id);
     }
 
     @VisibleForTesting
     @UsedAt(UsedAt.Project.GOOGLE)
     public IdentifiedUser forTest(Account.Id id, PropertyMap properties) {
-      return runAs(null, id, null, properties);
+      return runAs(/* remotePeer= */ null, id, /* caller= */ null, properties);
     }
 
-    public IdentifiedUser create(SocketAddress remotePeer, Account.Id id) {
-      return runAs(remotePeer, id, null);
+    public IdentifiedUser create(@Nullable SocketAddress remotePeer, Account.Id id) {
+      return runAs(remotePeer, id, /* caller= */ null);
     }
 
     public IdentifiedUser runAs(
-        SocketAddress remotePeer, Account.Id id, @Nullable CurrentUser caller) {
+        @Nullable SocketAddress remotePeer, Account.Id id, @Nullable CurrentUser caller) {
       return runAs(remotePeer, id, caller, PropertyMap.EMPTY);
     }
 
     private IdentifiedUser runAs(
-        SocketAddress remotePeer,
+        @Nullable SocketAddress remotePeer,
         Account.Id id,
         @Nullable CurrentUser caller,
         PropertyMap properties) {
@@ -131,6 +132,7 @@
           authConfig,
           realm,
           anonymousCowardName,
+          refLogIdentityProvider,
           canonicalUrl,
           accountCache,
           groupBackend,
@@ -153,6 +155,7 @@
     private final AuthConfig authConfig;
     private final Realm realm;
     private final String anonymousCowardName;
+    private final RefLogIdentityProvider refLogIdentityProvider;
     private final Provider<String> canonicalUrl;
     private final AccountCache accountCache;
     private final GroupBackend groupBackend;
@@ -164,6 +167,7 @@
         AuthConfig authConfig,
         Realm realm,
         @AnonymousCowardName String anonymousCowardName,
+        RefLogIdentityProvider refLogIdentityProvider,
         @CanonicalWebUrl Provider<String> canonicalUrl,
         AccountCache accountCache,
         GroupBackend groupBackend,
@@ -172,6 +176,7 @@
       this.authConfig = authConfig;
       this.realm = realm;
       this.anonymousCowardName = anonymousCowardName;
+      this.refLogIdentityProvider = refLogIdentityProvider;
       this.canonicalUrl = canonicalUrl;
       this.accountCache = accountCache;
       this.groupBackend = groupBackend;
@@ -188,6 +193,7 @@
           authConfig,
           realm,
           anonymousCowardName,
+          refLogIdentityProvider,
           canonicalUrl,
           accountCache,
           groupBackend,
@@ -203,6 +209,7 @@
           authConfig,
           realm,
           anonymousCowardName,
+          refLogIdentityProvider,
           canonicalUrl,
           accountCache,
           groupBackend,
@@ -224,6 +231,7 @@
   private final Realm realm;
   private final GroupBackend groupBackend;
   private final String anonymousCowardName;
+  private final RefLogIdentityProvider refLogIdentityProvider;
   private final Boolean enablePeerIPInReflogRecord;
   private final Set<String> validEmails = Sets.newTreeSet(String.CASE_INSENSITIVE_ORDER);
   private final CurrentUser realUser; // Must be final since cached properties depend on it.
@@ -235,22 +243,25 @@
   private boolean loadedAllEmails;
   private Set<String> invalidEmails;
   private GroupMembership effectiveGroups;
+  private PersonIdent refLogIdent;
 
   private IdentifiedUser(
       AuthConfig authConfig,
       Realm realm,
       String anonymousCowardName,
+      RefLogIdentityProvider refLogIdentityProvider,
       Provider<String> canonicalUrl,
       AccountCache accountCache,
       GroupBackend groupBackend,
       Boolean enablePeerIPInReflogRecord,
-      @Nullable Provider<SocketAddress> remotePeerProvider,
+      Provider<SocketAddress> remotePeerProvider,
       AccountState state,
       @Nullable CurrentUser realUser) {
     this(
         authConfig,
         realm,
         anonymousCowardName,
+        refLogIdentityProvider,
         canonicalUrl,
         accountCache,
         groupBackend,
@@ -266,11 +277,12 @@
       AuthConfig authConfig,
       Realm realm,
       String anonymousCowardName,
+      RefLogIdentityProvider refLogIdentityProvider,
       Provider<String> canonicalUrl,
       AccountCache accountCache,
       GroupBackend groupBackend,
       Boolean enablePeerIPInReflogRecord,
-      @Nullable Provider<SocketAddress> remotePeerProvider,
+      Provider<SocketAddress> remotePeerProvider,
       Account.Id id,
       @Nullable CurrentUser realUser,
       PropertyMap properties) {
@@ -281,6 +293,7 @@
     this.authConfig = authConfig;
     this.realm = realm;
     this.anonymousCowardName = anonymousCowardName;
+    this.refLogIdentityProvider = refLogIdentityProvider;
     this.enablePeerIPInReflogRecord = enablePeerIPInReflogRecord;
     this.remotePeerProvider = remotePeerProvider;
     this.accountId = id;
@@ -426,36 +439,27 @@
     return getAccountId();
   }
 
+  @Nullable
+  public SocketAddress getRemotePeer() {
+    try {
+      return remotePeerProvider.get();
+    } catch (OutOfScopeException | ProvisionException e) {
+      return null;
+    }
+  }
+
   public PersonIdent newRefLogIdent() {
-    return newRefLogIdent(Instant.now(), ZoneId.systemDefault());
+    return refLogIdentityProvider.newRefLogIdent(this);
   }
 
   public PersonIdent newRefLogIdent(Instant when, ZoneId zoneId) {
-    final Account ua = getAccount();
-
-    String name = ua.fullName();
-    if (name == null || name.isEmpty()) {
-      name = ua.preferredEmail();
+    if (refLogIdent != null) {
+      refLogIdent =
+          new PersonIdent(refLogIdent.getName(), refLogIdent.getEmailAddress(), when, zoneId);
+      return refLogIdent;
     }
-    if (name == null || name.isEmpty()) {
-      name = anonymousCowardName;
-    }
-
-    String user;
-    if (enablePeerIPInReflogRecord) {
-      user = constructMailAddress(ua, guessHost());
-    } else {
-      user =
-          Strings.isNullOrEmpty(ua.preferredEmail())
-              ? constructMailAddress(ua, "unknown")
-              : ua.preferredEmail();
-    }
-
-    return new PersonIdent(name, user, when, zoneId);
-  }
-
-  private String constructMailAddress(Account ua, String host) {
-    return getUserName().orElse("") + "|account-" + ua.id().toString() + "@" + host;
+    refLogIdent = refLogIdentityProvider.newRefLogIdent(this, when, zoneId);
+    return refLogIdent;
   }
 
   public PersonIdent newCommitterIdent(PersonIdent ident) {
@@ -533,6 +537,7 @@
         authConfig,
         realm,
         anonymousCowardName,
+        refLogIdentityProvider,
         Providers.of(canonicalUrl.get()),
         accountCache,
         groupBackend,
@@ -546,23 +551,4 @@
   public boolean hasSameAccountId(CurrentUser other) {
     return getAccountId().get() == other.getAccountId().get();
   }
-
-  private String guessHost() {
-    String host = null;
-    SocketAddress remotePeer = null;
-    try {
-      remotePeer = remotePeerProvider.get();
-    } catch (OutOfScopeException | ProvisionException e) {
-      // Leave null.
-    }
-    if (remotePeer instanceof InetSocketAddress) {
-      InetSocketAddress sa = (InetSocketAddress) remotePeer;
-      InetAddress in = sa.getAddress();
-      host = in != null ? in.getHostAddress() : sa.getHostName();
-    }
-    if (Strings.isNullOrEmpty(host)) {
-      return "unknown";
-    }
-    return host;
-  }
 }
diff --git a/java/com/google/gerrit/server/PatchSetUtil.java b/java/com/google/gerrit/server/PatchSetUtil.java
index 3d449b7..68d2314 100644
--- a/java/com/google/gerrit/server/PatchSetUtil.java
+++ b/java/com/google/gerrit/server/PatchSetUtil.java
@@ -35,11 +35,14 @@
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.update.RepoContext;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.List;
+import java.util.Map;
+import java.util.Objects;
 import java.util.Optional;
 import java.util.Set;
 import org.eclipse.jgit.lib.ObjectId;
@@ -105,6 +108,7 @@
         .id(psId)
         .commitId(commit)
         .uploader(update.getAccountId())
+        .realUploader(update.getRealAccountId())
         .createdOn(update.getWhen())
         .groups(groups)
         .pushCertificate(Optional.ofNullable(pushCertificate))
@@ -169,4 +173,55 @@
       return src;
     }
   }
+
+  /**
+   * Gets the commit ID for the latest patch-set of a given change.
+   *
+   * <p>This also takes into account the patch sets that are added in the provided {@link
+   * RepoContext}.
+   *
+   * @param ctx to look for pending updates in.
+   * @param notesFactory to fetch existing patch sets with.
+   * @param changeId to get the latest commit for.
+   * @return the latest commit ID.
+   * @throws IOException if no committed nor pending commits found for the change.
+   */
+  public static RevCommit getCurrentRevCommitIncludingPending(
+      RepoContext ctx, ChangeNotes.Factory notesFactory, Change.Id changeId) throws IOException {
+    Map<String, ObjectId> refUpdates = ctx.getRepoView().getRefs(changeId.toRefPrefix());
+    refUpdates.remove("meta");
+    if (!refUpdates.isEmpty()) {
+      Optional<PatchSet.Id> latestPendingPatchSet =
+          refUpdates.keySet().stream()
+              .map(r -> PatchSet.Id.fromRef(changeId.toRefPrefix() + r))
+              .filter(Objects::nonNull)
+              .max(PatchSet.Id::compareTo);
+      if (latestPendingPatchSet.isPresent()) {
+        return ctx.getRevWalk().parseCommit(refUpdates.get(latestPendingPatchSet.get().getId()));
+      }
+    }
+    return getCurrentCommittedRevCommit(ctx.getProject(), ctx.getRevWalk(), notesFactory, changeId);
+  }
+
+  /**
+   * Gets the commit ID for the latest committed patch-set of a given change.
+   *
+   * <p>This DOES NOT take into account the patch sets that are added in the provided {@link
+   * RepoContext}.
+   *
+   * @param project name.
+   * @param notesFactory to fetch existing patch sets with.
+   * @param changeId to get the latest commit for.
+   * @return the latest commit ID.
+   * @throws IOException if no committed commits found for the change.
+   */
+  public static RevCommit getCurrentCommittedRevCommit(
+      Project.NameKey project,
+      RevWalk revWalk,
+      ChangeNotes.Factory notesFactory,
+      Change.Id changeId)
+      throws IOException {
+    ChangeNotes notes = notesFactory.createChecked(project, changeId);
+    return revWalk.parseCommit(notes.getCurrentPatchSet().commitId());
+  }
 }
diff --git a/java/com/google/gerrit/server/PublishCommentsOp.java b/java/com/google/gerrit/server/PublishCommentsOp.java
index 827c078..830928a 100644
--- a/java/com/google/gerrit/server/PublishCommentsOp.java
+++ b/java/com/google/gerrit/server/PublishCommentsOp.java
@@ -23,7 +23,6 @@
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.server.change.EmailReviewComments;
-import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.extensions.events.CommentAdded;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
@@ -112,19 +111,16 @@
     }
     ChangeNotes changeNotes = changeNotesFactory.createChecked(projectNameKey, psId.changeId());
     PatchSet ps = psUtil.get(changeNotes, psId);
-    NotifyResolver.Result notify = ctx.getNotify(changeNotes.getChangeId());
-    if (notify.shouldNotify()) {
-      email
-          .create(
-              ctx,
-              ps,
-              preUpdateMetaId,
-              mailMessage,
-              comments,
-              /* patchSetComment= */ null,
-              /* labels= */ ImmutableList.of())
-          .sendAsync();
-    }
+    email
+        .create(
+            ctx,
+            ps,
+            preUpdateMetaId,
+            mailMessage,
+            comments,
+            /* patchSetComment= */ null,
+            /* labels= */ ImmutableList.of())
+        .sendAsync();
     commentAdded.fire(
         ctx.getChangeData(changeNotes),
         ps,
diff --git a/java/com/google/gerrit/server/RefLogIdentityProvider.java b/java/com/google/gerrit/server/RefLogIdentityProvider.java
new file mode 100644
index 0000000..613b2bb
--- /dev/null
+++ b/java/com/google/gerrit/server/RefLogIdentityProvider.java
@@ -0,0 +1,110 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server;
+
+import static com.google.common.base.Preconditions.checkState;
+import static java.util.stream.Collectors.joining;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.Account;
+import java.time.Instant;
+import java.time.ZoneId;
+import org.eclipse.jgit.lib.PersonIdent;
+
+/**
+ * Extension point that allows to control which identity should be recorded in the reflog for ref
+ * updates done by a user or done on behalf of a user.
+ */
+public interface RefLogIdentityProvider {
+  /**
+   * Creates a {@link PersonIdent} for the given user that should be used as the user identity in
+   * the reflog for ref updates done by this user or done on behalf of this user.
+   *
+   * <p>The returned {@link PersonIdent} is created with the current timestamp and the system
+   * default timezone.
+   *
+   * @param user the user for which a reflog identity should be created
+   */
+  default PersonIdent newRefLogIdent(IdentifiedUser user) {
+    return newRefLogIdent(user, Instant.now(), ZoneId.systemDefault());
+  }
+
+  /**
+   * Creates a {@link PersonIdent} for the given user that should be used as the user identity in
+   * the reflog for ref updates done by this user or done on behalf of this user.
+   *
+   * @param user the user for which a reflog identity should be created
+   * @param when the timestamp that should be used to create the {@link PersonIdent}
+   * @param zoneId the zone ID identifying the timezone that should be used to create the {@link
+   *     PersonIdent}
+   */
+  PersonIdent newRefLogIdent(IdentifiedUser user, Instant when, ZoneId zoneId);
+
+  /**
+   * Creates a {@link PersonIdent} for the given users that should be used as the user identity in
+   * the reflog for ref updates done by these users or done on behalf of these users.
+   *
+   * <p>Usually ref updates are done by a single user or on behalf of a single user, but with {@link
+   * com.google.gerrit.server.update.BatchUpdate} it's possible that updates of different users are
+   * batched together into a single ref update.
+   *
+   * <p>If a single user is provided or all provided users reference the same account a reflog
+   * identity for that user/account is created and returned.
+   *
+   * <p>If multiple users (that reference different accounts) are provided a shared reflog identity
+   * is created and returned. The shared reflog identity lists all involved accounts. How the shared
+   * reflog identity looks like doesn't matter much, as long as it's not the reflog identity of a
+   * real user (e.g. if impersonated updates of multiple users are batched together it must not be
+   * the reflog identity of the real user).
+   *
+   * @param users the users for which a reflog identity should be created
+   * @param when the timestamp that should be used to create the {@link PersonIdent}
+   * @param zoneId the zone ID identifying the timezone that should be used to create the {@link
+   *     PersonIdent}
+   */
+  default PersonIdent newRefLogIdent(
+      ImmutableList<IdentifiedUser> users, Instant when, ZoneId zoneId) {
+    checkState(!users.isEmpty(), "expected at least one user");
+
+    // If it's a single user create a reflog ident for that user.
+    // Use IdentifiedUser.newReflogIdent(Instant, ZoneId) rather than invoking
+    // #newRefLogIdent(IdentifiedUser, Instant ZoneId) directly, so that we can benefit from the
+    // reflog ident caching in IdentifiedUser.
+    if (users.size() == 1 || users.stream().allMatch(user -> user.hasSameAccountId(users.get(0)))) {
+      return users.get(0).newRefLogIdent(when, zoneId);
+    }
+
+    // Multiple users (for different accounts) have been provided. Create a shared relog identity
+    // that lists all involved accounts.
+    String accounts =
+        users.stream()
+            .map(IdentifiedUser::getAccountId)
+            .map(Account.Id::get)
+            .distinct()
+            .sorted()
+            .map(id -> "account-" + id)
+            .collect(joining("|"));
+    return new PersonIdent(
+        accounts, String.format("%s@%s", accounts, getDefaultDomain()), when, zoneId);
+  }
+
+  /**
+   * Returns the default domain for constructing email addresses if guessing the correct host is not
+   * possible.
+   */
+  default String getDefaultDomain() {
+    return "unknown";
+  }
+}
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/StarredChangesUtil.java
index c845415..cf04029 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/StarredChangesUtil.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server;
 
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.Objects.requireNonNull;
 import static java.util.stream.Collectors.joining;
@@ -23,7 +24,6 @@
 import com.google.common.base.CharMatcher;
 import com.google.common.base.Joiner;
 import com.google.common.base.Splitter;
-import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.ImmutableSortedSet;
@@ -32,7 +32,6 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.git.GitUpdateFailureException;
@@ -40,13 +39,10 @@
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.logging.Metadata;
 import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.logging.TraceContext.TraceTimer;
-import com.google.gerrit.server.project.NoSuchChangeException;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.query.change.InternalChangeQuery;
+import com.google.gerrit.server.update.context.RefUpdateContext;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -55,6 +51,7 @@
 import java.util.Collections;
 import java.util.List;
 import java.util.NavigableSet;
+import java.util.Objects;
 import java.util.Set;
 import java.util.TreeSet;
 import java.util.stream.Collectors;
@@ -81,6 +78,7 @@
   public abstract static class StarField {
     private static final String SEPARATOR = ":";
 
+    @Nullable
     public static StarField parse(String s) {
       int p = s.indexOf(SEPARATOR);
       if (p >= 0) {
@@ -167,20 +165,17 @@
   private final GitReferenceUpdated gitRefUpdated;
   private final AllUsersName allUsers;
   private final Provider<PersonIdent> serverIdent;
-  private final Provider<InternalChangeQuery> queryProvider;
 
   @Inject
   StarredChangesUtil(
       GitRepositoryManager repoManager,
       GitReferenceUpdated gitRefUpdated,
       AllUsersName allUsers,
-      @GerritPersonIdent Provider<PersonIdent> serverIdent,
-      Provider<InternalChangeQuery> queryProvider) {
+      @GerritPersonIdent Provider<PersonIdent> serverIdent) {
     this.repoManager = repoManager;
     this.gitRefUpdated = gitRefUpdated;
     this.allUsers = allUsers;
     this.serverIdent = serverIdent;
-    this.queryProvider = queryProvider;
   }
 
   public NavigableSet<String> getLabels(Account.Id accountId, Change.Id changeId) {
@@ -195,7 +190,7 @@
     }
   }
 
-  public void star(Account.Id accountId, Project.NameKey project, Change.Id changeId, Operation op)
+  public void star(Account.Id accountId, Change.Id changeId, Operation op)
       throws IllegalLabelException {
     try (Repository repo = repoManager.openRepository(allUsers)) {
       String refName = RefNames.refsStarredChanges(changeId, accountId);
@@ -262,7 +257,7 @@
       batchUpdate.setAllowNonFastForwards(true);
       batchUpdate.setRefLogIdent(serverIdent.get());
       batchUpdate.setRefLogMessage("Unstar change " + changeId.get(), true);
-      for (Account.Id accountId : byChangeFromIndex(changeId).keySet()) {
+      for (Account.Id accountId : getStars(repo, changeId)) {
         String refName = RefNames.refsStarredChanges(changeId, accountId);
         Ref ref = repo.getRefDatabase().exactRef(refName);
         if (ref != null) {
@@ -288,12 +283,7 @@
   public ImmutableMap<Account.Id, StarRef> byChange(Change.Id changeId) {
     try (Repository repo = repoManager.openRepository(allUsers)) {
       ImmutableMap.Builder<Account.Id, StarRef> builder = ImmutableMap.builder();
-      for (String refPart : getRefNames(repo, RefNames.refsStarredChangesPrefix(changeId))) {
-        Integer id = Ints.tryParse(refPart);
-        if (id == null) {
-          continue;
-        }
-        Account.Id accountId = Account.id(id);
+      for (Account.Id accountId : getStars(repo, changeId)) {
         builder.put(accountId, readLabels(repo, RefNames.refsStarredChanges(changeId, accountId)));
       }
       return builder.build();
@@ -303,7 +293,7 @@
     }
   }
 
-  public ImmutableSet<Change.Id> byAccountId(Account.Id accountId, String label) {
+  public ImmutableSet<Change.Id> byAccountId(Account.Id accountId) {
     try (Repository repo = repoManager.openRepository(allUsers)) {
       ImmutableSet.Builder<Change.Id> builder = ImmutableSet.builder();
       for (Ref ref : repo.getRefDatabase().getRefsByPrefix(RefNames.REFS_STARRED_CHANGES)) {
@@ -314,7 +304,7 @@
         }
         // Skip all refs that don't contain the required label.
         StarRef starRef = readLabels(repo, ref.getName());
-        if (!starRef.labels().contains(label)) {
+        if (!starRef.labels().contains(DEFAULT_LABEL)) {
           continue;
         }
 
@@ -332,22 +322,15 @@
     }
   }
 
-  public ImmutableListMultimap<Account.Id, String> byChangeFromIndex(Change.Id changeId) {
-    List<ChangeData> changeData =
-        queryProvider
-            .get()
-            .setRequestedFields(ChangeField.ID, ChangeField.STAR)
-            .byLegacyChangeId(changeId);
-    if (changeData.size() != 1) {
-      throw new NoSuchChangeException(changeId);
-    }
-    return changeData.get(0).stars();
-  }
-
-  private static Set<String> getRefNames(Repository repo, String prefix) throws IOException {
-    RefDatabase refDb = repo.getRefDatabase();
+  private static Set<Account.Id> getStars(Repository allUsers, Change.Id changeId)
+      throws IOException {
+    String prefix = RefNames.refsStarredChangesPrefix(changeId);
+    RefDatabase refDb = allUsers.getRefDatabase();
     return refDb.getRefsByPrefix(prefix).stream()
         .map(r -> r.getName().substring(prefix.length()))
+        .map(refPart -> Ints.tryParse(refPart))
+        .filter(Objects::nonNull)
+        .map(id -> Account.id(id))
         .collect(toSet());
   }
 
@@ -432,27 +415,29 @@
       u.setNewObjectId(writeLabels(repo, labels));
       u.setRefLogIdent(serverIdent.get());
       u.setRefLogMessage("Update star labels", true);
-      RefUpdate.Result result = u.update(rw);
-      switch (result) {
-        case NEW:
-        case FORCED:
-        case NO_CHANGE:
-        case FAST_FORWARD:
-          gitRefUpdated.fire(allUsers, u, null);
-          return;
-        case LOCK_FAILURE:
-          throw new LockFailureException(
-              String.format("Update star labels on ref %s failed", refName), u);
-        case IO_FAILURE:
-        case NOT_ATTEMPTED:
-        case REJECTED:
-        case REJECTED_CURRENT_BRANCH:
-        case RENAMED:
-        case REJECTED_MISSING_OBJECT:
-        case REJECTED_OTHER_REASON:
-        default:
-          throw new StorageException(
-              String.format("Update star labels on ref %s failed: %s", refName, result.name()));
+      try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+        RefUpdate.Result result = u.update(rw);
+        switch (result) {
+          case NEW:
+          case FORCED:
+          case NO_CHANGE:
+          case FAST_FORWARD:
+            gitRefUpdated.fire(allUsers, u, null);
+            return;
+          case LOCK_FAILURE:
+            throw new LockFailureException(
+                String.format("Update star labels on ref %s failed", refName), u);
+          case IO_FAILURE:
+          case NOT_ATTEMPTED:
+          case REJECTED:
+          case REJECTED_CURRENT_BRANCH:
+          case RENAMED:
+          case REJECTED_MISSING_OBJECT:
+          case REJECTED_OTHER_REASON:
+          default:
+            throw new StorageException(
+                String.format("Update star labels on ref %s failed: %s", refName, result.name()));
+        }
       }
     }
   }
@@ -471,26 +456,28 @@
       u.setExpectedOldObjectId(oldObjectId);
       u.setRefLogIdent(serverIdent.get());
       u.setRefLogMessage("Unstar change", true);
-      RefUpdate.Result result = u.delete();
-      switch (result) {
-        case FORCED:
-          gitRefUpdated.fire(allUsers, u, null);
-          return;
-        case LOCK_FAILURE:
-          throw new LockFailureException(String.format("Delete star ref %s failed", refName), u);
-        case NEW:
-        case NO_CHANGE:
-        case FAST_FORWARD:
-        case IO_FAILURE:
-        case NOT_ATTEMPTED:
-        case REJECTED:
-        case REJECTED_CURRENT_BRANCH:
-        case RENAMED:
-        case REJECTED_MISSING_OBJECT:
-        case REJECTED_OTHER_REASON:
-        default:
-          throw new StorageException(
-              String.format("Delete star ref %s failed: %s", refName, result.name()));
+      try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+        RefUpdate.Result result = u.delete();
+        switch (result) {
+          case FORCED:
+            gitRefUpdated.fire(allUsers, u, null);
+            return;
+          case LOCK_FAILURE:
+            throw new LockFailureException(String.format("Delete star ref %s failed", refName), u);
+          case NEW:
+          case NO_CHANGE:
+          case FAST_FORWARD:
+          case IO_FAILURE:
+          case NOT_ATTEMPTED:
+          case REJECTED:
+          case REJECTED_CURRENT_BRANCH:
+          case RENAMED:
+          case REJECTED_MISSING_OBJECT:
+          case REJECTED_OTHER_REASON:
+          default:
+            throw new StorageException(
+                String.format("Delete star ref %s failed: %s", refName, result.name()));
+        }
       }
     }
   }
diff --git a/java/com/google/gerrit/server/account/AccountControl.java b/java/com/google/gerrit/server/account/AccountControl.java
index c80059b..ca63565 100644
--- a/java/com/google/gerrit/server/account/AccountControl.java
+++ b/java/com/google/gerrit/server/account/AccountControl.java
@@ -82,12 +82,12 @@
      * accounts.
      */
     @UsedAt(UsedAt.Project.PLUGIN_CODE_OWNERS)
-    public AccountControl get(IdentifiedUser identifiedUser) {
+    public AccountControl get(CurrentUser user) {
       return new AccountControl(
           permissionBackend,
           projectCache,
           groupControlFactory,
-          identifiedUser,
+          user,
           userFactory,
           accountVisibility);
     }
diff --git a/java/com/google/gerrit/server/account/AccountDelta.java b/java/com/google/gerrit/server/account/AccountDelta.java
index fac3233..8f285b5 100644
--- a/java/com/google/gerrit/server/account/AccountDelta.java
+++ b/java/com/google/gerrit/server/account/AccountDelta.java
@@ -161,6 +161,11 @@
    */
   public abstract Optional<EditPreferencesInfo> getEditPreferences();
 
+  public boolean hasExternalIdUpdates() {
+    return !this.getCreatedExternalIds().isEmpty()
+        || !this.getDeletedExternalIds().isEmpty()
+        || !this.getUpdatedExternalIds().isEmpty();
+  }
   /**
    * Class to build an {@link AccountDelta}.
    *
diff --git a/java/com/google/gerrit/server/account/AccountLimits.java b/java/com/google/gerrit/server/account/AccountLimits.java
index 5549d28..d97563a 100644
--- a/java/com/google/gerrit/server/account/AccountLimits.java
+++ b/java/com/google/gerrit/server/account/AccountLimits.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.account;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.entities.PermissionRange;
 import com.google.gerrit.entities.PermissionRule;
@@ -105,6 +106,7 @@
   }
 
   /** The range of permitted values associated with a label permission. */
+  @Nullable
   public PermissionRange getRange(String permission) {
     if (GlobalCapability.hasRange(permission)) {
       return toRange(permission, getRules(permission));
diff --git a/java/com/google/gerrit/server/account/AccountManager.java b/java/com/google/gerrit/server/account/AccountManager.java
index 891a467..edec52c 100644
--- a/java/com/google/gerrit/server/account/AccountManager.java
+++ b/java/com/google/gerrit/server/account/AccountManager.java
@@ -16,6 +16,8 @@
 
 import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_GERRIT;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_GOOGLE_OAUTH;
 import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
 
 import com.google.common.annotations.VisibleForTesting;
@@ -146,10 +148,7 @@
     try {
       Optional<ExternalId> optionalExtId = externalIds.get(who.getExternalIdKey());
       if (!optionalExtId.isPresent()) {
-        logger.atFine().log(
-            "External ID for account %s not found. A new account will be automatically created.",
-            who.getUserName());
-        return create(who);
+        return createOrLinkAccount(who);
       }
 
       ExternalId extId = optionalExtId.get();
@@ -180,6 +179,46 @@
     }
   }
 
+  /**
+   * Determines if a new account should be created or if we should link to an existing account.
+   *
+   * @param who identity of the user, with any details we received about them.
+   * @return the result of authenticating the user.
+   * @throws AccountException the account does not exist, and cannot be created, or exists, but
+   *     cannot be located, is unable to be activated or deactivated, or is inactive, or cannot be
+   *     added to the admin group (only for the first account).
+   */
+  private AuthResult createOrLinkAccount(AuthRequest who)
+      throws AccountException, IOException, ConfigInvalidException {
+    // TODO: in case of extension of further migration paths this code should
+    // probably be refactored out by creating an AccountMigrator extension point.
+    if (who.getExternalIdKey().isScheme(SCHEME_GOOGLE_OAUTH)) {
+      Optional<ExternalId> existingLDAPExtID = findLdapExternalId(who);
+      if (existingLDAPExtID.isPresent()) {
+        return migrateLdapAccountToOauth(who, existingLDAPExtID.get());
+      }
+    }
+    logger.atFine().log(
+        "External ID for account %s not found. A new account will be automatically created.",
+        who.getEmailAddress());
+    return create(who);
+  }
+
+  private AuthResult migrateLdapAccountToOauth(AuthRequest who, ExternalId ldapExternalId)
+      throws AccountException, IOException, ConfigInvalidException {
+    Account.Id extAccId = ldapExternalId.accountId();
+    AuthResult res = link(extAccId, who);
+    accountsUpdateProvider
+        .get()
+        .update(
+            "remove existing LDAP externalId with matching e-mail",
+            extAccId,
+            u -> {
+              u.deleteExternalId(ldapExternalId);
+            });
+    return res;
+  }
+
   private void deactivateAccountIfItExists(AuthRequest authRequest) {
     if (!shouldUpdateActiveStatus(authRequest)) {
       return;
@@ -277,6 +316,17 @@
     }
   }
 
+  private Optional<ExternalId> findLdapExternalId(AuthRequest who) throws IOException {
+    String email = who.getEmailAddress();
+    if (email == null || email.isEmpty()) {
+      return Optional.empty();
+    }
+
+    Optional<ExternalId> ldapExternalId =
+        externalIds.byEmail(email).stream().filter(a -> a.isScheme(SCHEME_GERRIT)).findFirst();
+    return ldapExternalId;
+  }
+
   private AuthResult create(AuthRequest who)
       throws AccountException, IOException, ConfigInvalidException {
     Account.Id newId = Account.id(sequences.nextAccountId());
diff --git a/java/com/google/gerrit/server/account/AccountResolver.java b/java/com/google/gerrit/server/account/AccountResolver.java
index 8824d56..2020d2f 100644
--- a/java/com/google/gerrit/server/account/AccountResolver.java
+++ b/java/com/google/gerrit/server/account/AccountResolver.java
@@ -25,6 +25,7 @@
 import com.google.common.base.Suppliers;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
 import com.google.common.collect.Streams;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
@@ -43,6 +44,7 @@
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.List;
+import java.util.Objects;
 import java.util.Optional;
 import java.util.Set;
 import java.util.TreeSet;
@@ -132,12 +134,18 @@
     private final String input;
     private final ImmutableList<AccountState> list;
     private final ImmutableList<AccountState> filteredInactive;
+    private final CurrentUser searchedAsUser;
 
     @VisibleForTesting
-    Result(String input, List<AccountState> list, List<AccountState> filteredInactive) {
+    Result(
+        String input,
+        List<AccountState> list,
+        List<AccountState> filteredInactive,
+        CurrentUser searchedAsUser) {
       this.input = requireNonNull(input);
       this.list = canonicalize(list);
       this.filteredInactive = canonicalize(filteredInactive);
+      this.searchedAsUser = requireNonNull(searchedAsUser);
     }
 
     private ImmutableList<AccountState> canonicalize(List<AccountState> list) {
@@ -180,13 +188,21 @@
       }
     }
 
-    public IdentifiedUser asUniqueUser() throws UnresolvableAccountException {
+    private void ensureSelfIsUniqueIdentifiedUser() throws UnresolvableAccountException {
       ensureUnique();
+      if (!searchedAsUser.isIdentifiedUser()) {
+        throw new UnresolvableAccountException(this);
+      }
+    }
+
+    public IdentifiedUser asUniqueUser() throws UnresolvableAccountException {
       if (isSelf()) {
+        ensureSelfIsUniqueIdentifiedUser();
         // In the special case of "self", use the exact IdentifiedUser from the request context, to
         // preserve the peer address and any other per-request state.
-        return self.get().asIdentifiedUser();
+        return searchedAsUser.asIdentifiedUser();
       }
+      ensureUnique();
       return userFactory.create(asUnique());
     }
 
@@ -194,11 +210,10 @@
         throws UnresolvableAccountException {
       ensureUnique();
       if (isSelf()) {
-        // TODO(dborowitz): This preserves old behavior, but it seems wrong to discard the caller.
-        return self.get().asIdentifiedUser();
+        return searchedAsUser.asIdentifiedUser();
       }
       return userFactory.runAs(
-          null, list.get(0).account().id(), requireNonNull(caller).getRealUser());
+          /* remotePeer= */ null, list.get(0).account().id(), requireNonNull(caller).getRealUser());
     }
 
     @VisibleForTesting
@@ -217,20 +232,57 @@
       return true;
     }
 
-    default boolean callerMayAssumeCandidatesAreVisible() {
+    /**
+     * Searches can be done on behalf of either the current user or another provided user. The
+     * results of some searchers, such as BySelf, are affected by the context user.
+     */
+    default boolean requiresContextUser() {
       return false;
     }
 
     Optional<I> tryParse(String input) throws IOException;
 
-    Stream<AccountState> search(I input) throws IOException, ConfigInvalidException;
+    /**
+     * This method should be implemented for every searcher which doesn't require a context user.
+     *
+     * @param input to search for
+     * @return stream of the matching accounts
+     * @throws IOException by some subclasses
+     * @throws ConfigInvalidException by some subclasses
+     */
+    default Stream<AccountState> search(I input) throws IOException, ConfigInvalidException {
+      throw new IllegalStateException("search(I) default implementation should never be called.");
+    }
+
+    /**
+     * This method should be implemented for every searcher which requires a context user.
+     *
+     * @param input to search for
+     * @param asUser the context user for the search
+     * @return stream of the matching accounts
+     * @throws IOException by some subclasses
+     * @throws ConfigInvalidException by some subclasses
+     */
+    default Stream<AccountState> search(I input, CurrentUser asUser)
+        throws IOException, ConfigInvalidException {
+      if (!requiresContextUser()) {
+        return search(input);
+      }
+      throw new IllegalStateException(
+          "The searcher requires a context user, but doesn't implement search(input, asUser).");
+    }
 
     boolean shortCircuitIfNoResults();
 
-    default Optional<Stream<AccountState>> trySearch(String input)
+    default Optional<Stream<AccountState>> trySearch(String input, CurrentUser asUser)
         throws IOException, ConfigInvalidException {
       Optional<I> parsed = tryParse(input);
-      return parsed.isPresent() ? Optional.of(search(parsed.get())) : Optional.empty();
+      if (parsed.isEmpty()) {
+        return Optional.empty();
+      }
+      return requiresContextUser()
+          ? Optional.of(search(parsed.get(), asUser))
+          : Optional.of(search(parsed.get()));
     }
   }
 
@@ -251,14 +303,14 @@
     }
   }
 
-  private class BySelf extends StringSearcher {
+  private static class BySelf extends StringSearcher {
     @Override
     public boolean callerShouldFilterOutInactiveCandidates() {
       return false;
     }
 
     @Override
-    public boolean callerMayAssumeCandidatesAreVisible() {
+    public boolean requiresContextUser() {
       return true;
     }
 
@@ -268,12 +320,11 @@
     }
 
     @Override
-    public Stream<AccountState> search(String input) {
-      CurrentUser user = self.get();
-      if (!user.isIdentifiedUser()) {
+    public Stream<AccountState> search(String input, CurrentUser asUser) {
+      if (!asUser.isIdentifiedUser()) {
         return Stream.empty();
       }
-      return Stream.of(user.asIdentifiedUser().state());
+      return Stream.of(asUser.asIdentifiedUser().state());
     }
 
     @Override
@@ -372,13 +423,70 @@
 
   private class ByEmail extends StringSearcher {
     @Override
+    public boolean requiresContextUser() {
+      return true;
+    }
+
+    @Override
     protected boolean matches(String input) {
       return input.contains("@");
     }
 
     @Override
-    public Stream<AccountState> search(String input) throws IOException {
-      return toAccountStates(emails.getAccountFor(input));
+    public Stream<AccountState> search(String input, CurrentUser asUser) throws IOException {
+      boolean canViewSecondaryEmails = false;
+      try {
+        if (permissionBackend.user(asUser).test(GlobalPermission.VIEW_SECONDARY_EMAILS)) {
+          canViewSecondaryEmails = true;
+        }
+      } catch (PermissionBackendException e) {
+        // remains false
+      }
+
+      if (canViewSecondaryEmails) {
+        return toAccountStates(emails.getAccountFor(input));
+      }
+
+      // User cannot see secondary emails, hence search by preferred email only.
+      List<AccountState> accountStates = accountQueryProvider.get().byPreferredEmail(input);
+
+      if (accountStates.size() == 1) {
+        return Stream.of(Iterables.getOnlyElement(accountStates));
+      }
+
+      if (accountStates.size() > 1) {
+        // An email can only belong to a single account. If multiple accounts are found it means
+        // there is an inconsistency, i.e. some of the found accounts have a preferred email set
+        // that they do not own via an external ID. Hence in this case we return only the one
+        // account that actually owns the email via an external ID.
+        for (AccountState accountState : accountStates) {
+          if (accountState.externalIds().stream()
+              .map(ExternalId::email)
+              .filter(Objects::nonNull)
+              .anyMatch(email -> email.equals(input))) {
+            return Stream.of(accountState);
+          }
+        }
+
+        // None of the matched accounts owns the email, return all matches to be consistent with
+        // the behavior of Emails.getAccountFor(String) that is used above if the user can see
+        // secondary emails.
+        return accountStates.stream();
+      }
+
+      // No match by preferred email. Since users can always see their own secondary emails, check
+      // if the input matches a secondary email of the user and if yes, return the account of the
+      // user.
+      if (asUser.isIdentifiedUser()
+          && asUser.asIdentifiedUser().state().externalIds().stream()
+              .map(ExternalId::email)
+              .filter(Objects::nonNull)
+              .anyMatch(email -> email.equals(input))) {
+        return Stream.of(asUser.asIdentifiedUser().state());
+      }
+
+      // No match.
+      return Stream.empty();
     }
 
     @Override
@@ -399,34 +507,9 @@
     }
   }
 
-  private class ByFullName implements Searcher<AccountState> {
-    @Override
-    public boolean callerMayAssumeCandidatesAreVisible() {
-      return true; // Rely on enforceVisibility from the index.
-    }
-
-    @Override
-    public Optional<AccountState> tryParse(String input) {
-      List<AccountState> results =
-          accountQueryProvider.get().enforceVisibility(true).byFullName(input);
-      return results.size() == 1 ? Optional.of(results.get(0)) : Optional.empty();
-    }
-
-    @Override
-    public Stream<AccountState> search(AccountState input) {
-      return Stream.of(input);
-    }
-
-    @Override
-    public boolean shortCircuitIfNoResults() {
-      return false;
-    }
-  }
-
-  private class ByDefaultSearch extends StringSearcher {
-    @Override
-    public boolean callerMayAssumeCandidatesAreVisible() {
-      return true; // Rely on enforceVisibility from the index.
+  private class ByFullName extends StringSearcher {
+    ByFullName() {
+      super();
     }
 
     @Override
@@ -436,20 +519,45 @@
 
     @Override
     public Stream<AccountState> search(String input) {
+      return accountQueryProvider.get().byFullName(input).stream();
+    }
+
+    @Override
+    public boolean shortCircuitIfNoResults() {
+      return false;
+    }
+  }
+
+  private class ByDefaultSearch extends StringSearcher {
+    ByDefaultSearch() {
+      super();
+    }
+
+    @Override
+    public boolean requiresContextUser() {
+      return true;
+    }
+
+    @Override
+    protected boolean matches(String input) {
+      return true;
+    }
+
+    @Override
+    public Stream<AccountState> search(String input, CurrentUser asUser) {
       // At this point we have no clue. Just perform a whole bunch of suggestions and pray we come
       // up with a reasonable result list.
       // TODO(dborowitz): This doesn't match the documentation; consider whether it's possible to be
       // more strict here.
-      boolean canSeeSecondaryEmails = false;
+      boolean canViewSecondaryEmails = false;
       try {
-        if (permissionBackend.user(self.get()).test(GlobalPermission.MODIFY_ACCOUNT)) {
-          canSeeSecondaryEmails = true;
+        if (permissionBackend.user(asUser).test(GlobalPermission.VIEW_SECONDARY_EMAILS)) {
+          canViewSecondaryEmails = true;
         }
       } catch (PermissionBackendException e) {
         // remains false
       }
-      return accountQueryProvider.get().enforceVisibility(true)
-          .byDefault(input, canSeeSecondaryEmails).stream();
+      return accountQueryProvider.get().byDefault(input, canViewSecondaryEmails).stream();
     }
 
     @Override
@@ -538,12 +646,59 @@
    * @throws IOException if an error occurs.
    */
   public Result resolve(String input) throws ConfigInvalidException, IOException {
-    return searchImpl(input, searchers, this::canSeePredicate, AccountResolver::isActive);
+    return searchImpl(
+        input, searchers, self.get(), this::currentUserCanSeePredicate, AccountResolver::isActive);
   }
 
   public Result resolve(String input, Predicate<AccountState> accountActivityPredicate)
       throws ConfigInvalidException, IOException {
-    return searchImpl(input, searchers, this::canSeePredicate, accountActivityPredicate);
+    return searchImpl(
+        input, searchers, self.get(), this::currentUserCanSeePredicate, accountActivityPredicate);
+  }
+
+  /**
+   * Resolves all accounts matching the input string, visible to the provided user.
+   *
+   * <p>The following input formats are recognized:
+   *
+   * <ul>
+   *   <li>The strings {@code "self"} and {@code "me"}, if the provided user is an {@link
+   *       IdentifiedUser}. In this case, may return exactly one inactive account.
+   *   <li>A bare account ID ({@code "18419"}). In this case, may return exactly one inactive
+   *       account. This case short-circuits if the input matches.
+   *   <li>An account ID in parentheses following a full name ({@code "Full Name (18419)"}). This
+   *       case short-circuits if the input matches.
+   *   <li>A username ({@code "username"}).
+   *   <li>A full name and email address ({@code "Full Name <email@example>"}). This case
+   *       short-circuits if the input matches.
+   *   <li>An email address ({@code "email@example"}. This case short-circuits if the input matches.
+   *   <li>An account name recognized by the configured {@link Realm#lookup(String)} Realm}.
+   *   <li>A full name ({@code "Full Name"}).
+   *   <li>As a fallback, a {@link
+   *       com.google.gerrit.server.query.account.AccountPredicates#defaultPredicate(Schema,
+   *       boolean, String) default search} against the account index.
+   * </ul>
+   *
+   * @param asUser user to resolve the users by.
+   * @param input input string.
+   * @return a result describing matching accounts. Never null even if the result set is empty.
+   * @throws ConfigInvalidException if an error occurs.
+   * @throws IOException if an error occurs.
+   */
+  public Result resolveAsUser(CurrentUser asUser, String input)
+      throws ConfigInvalidException, IOException {
+    return resolveAsUser(asUser, input, AccountResolver::isActive);
+  }
+
+  public Result resolveAsUser(
+      CurrentUser asUser, String input, Predicate<AccountState> accountActivityPredicate)
+      throws ConfigInvalidException, IOException {
+    return searchImpl(
+        input,
+        searchers,
+        asUser,
+        new ProvidedUserCanSeePredicate(asUser),
+        accountActivityPredicate);
   }
 
   /**
@@ -556,17 +711,35 @@
    * instead will be stored as a link to the corresponding Gerrit Account.
    */
   public Result resolveIncludeInactive(String input) throws ConfigInvalidException, IOException {
-    return searchImpl(input, searchers, this::canSeePredicate, AccountResolver::allVisible);
+    return searchImpl(
+        input,
+        searchers,
+        self.get(),
+        this::currentUserCanSeePredicate,
+        AccountResolver::allVisible);
+  }
+
+  public Result resolveIncludeInactiveIgnoreVisibility(String input)
+      throws ConfigInvalidException, IOException {
+    return searchImpl(
+        input, searchers, self.get(), this::allVisiblePredicate, AccountResolver::allVisible);
   }
 
   public Result resolveIgnoreVisibility(String input) throws ConfigInvalidException, IOException {
-    return searchImpl(input, searchers, this::allVisiblePredicate, AccountResolver::isActive);
+    return searchImpl(
+        input, searchers, self.get(), this::allVisiblePredicate, AccountResolver::isActive);
   }
 
-  public Result resolveIgnoreVisibility(
-      String input, Predicate<AccountState> accountActivityPredicate)
+  public Result resolveAsUserIgnoreVisibility(CurrentUser asUser, String input)
       throws ConfigInvalidException, IOException {
-    return searchImpl(input, searchers, this::allVisiblePredicate, accountActivityPredicate);
+    return resolveAsUserIgnoreVisibility(asUser, input, AccountResolver::isActive);
+  }
+
+  public Result resolveAsUserIgnoreVisibility(
+      CurrentUser asUser, String input, Predicate<AccountState> accountActivityPredicate)
+      throws ConfigInvalidException, IOException {
+    return searchImpl(
+        input, searchers, asUser, this::allVisiblePredicate, accountActivityPredicate);
   }
 
   /**
@@ -595,7 +768,11 @@
   @Deprecated
   public Result resolveByNameOrEmail(String input) throws ConfigInvalidException, IOException {
     return searchImpl(
-        input, nameOrEmailSearchers, this::canSeePredicate, AccountResolver::isActive);
+        input,
+        nameOrEmailSearchers,
+        self.get(),
+        this::currentUserCanSeePredicate,
+        AccountResolver::isActive);
   }
 
   /**
@@ -614,16 +791,26 @@
     return searchImpl(
         input,
         ImmutableList.of(new ByNameAndEmail(), new ByEmail(), new ByFullName(), new ByUsername()),
-        this::canSeePredicate,
+        self.get(),
+        this::currentUserCanSeePredicate,
         AccountResolver::isActive);
   }
 
-  private Predicate<AccountState> canSeePredicate() {
-    return this::canSee;
+  private Predicate<AccountState> currentUserCanSeePredicate() {
+    return accountControlFactory.get()::canSee;
   }
 
-  private boolean canSee(AccountState accountState) {
-    return accountControlFactory.get().canSee(accountState);
+  private class ProvidedUserCanSeePredicate implements Supplier<Predicate<AccountState>> {
+    CurrentUser asUser;
+
+    ProvidedUserCanSeePredicate(CurrentUser asUser) {
+      this.asUser = asUser;
+    }
+
+    @Override
+    public Predicate<AccountState> get() {
+      return accountControlFactory.get(asUser)::canSee;
+    }
   }
 
   private Predicate<AccountState> allVisiblePredicate() {
@@ -643,22 +830,24 @@
   Result searchImpl(
       String input,
       ImmutableList<Searcher<?>> searchers,
+      CurrentUser asUser,
       Supplier<Predicate<AccountState>> visibilitySupplier,
       Predicate<AccountState> accountActivityPredicate)
       throws ConfigInvalidException, IOException {
+    requireNonNull(asUser);
     visibilitySupplier = Suppliers.memoize(visibilitySupplier::get);
     List<AccountState> inactive = new ArrayList<>();
 
     for (Searcher<?> searcher : searchers) {
-      Optional<Stream<AccountState>> maybeResults = searcher.trySearch(input);
+      Optional<Stream<AccountState>> maybeResults = searcher.trySearch(input, asUser);
       if (!maybeResults.isPresent()) {
         continue;
       }
       Stream<AccountState> results = maybeResults.get();
 
-      if (!searcher.callerMayAssumeCandidatesAreVisible()) {
-        results = results.filter(visibilitySupplier.get());
-      }
+      // Filter out non-visible results, except if it's the BySelf searcher. Since users can always
+      // see themselves checking the visibility is not needed for the BySelf searcher.
+      results = searcher instanceof BySelf ? results : results.filter(visibilitySupplier.get());
 
       List<AccountState> list;
       if (searcher.callerShouldFilterOutInactiveCandidates()) {
@@ -672,22 +861,25 @@
       }
 
       if (!list.isEmpty()) {
-        return createResult(input, list);
+        return createResult(input, list, asUser);
       }
       if (searcher.shortCircuitIfNoResults()) {
         // For a short-circuiting searcher, return results even if empty.
-        return !inactive.isEmpty() ? emptyResult(input, inactive) : createResult(input, list);
+        return !inactive.isEmpty()
+            ? emptyResult(input, inactive, asUser)
+            : createResult(input, list, asUser);
       }
     }
-    return emptyResult(input, inactive);
+    return emptyResult(input, inactive, asUser);
   }
 
-  private Result createResult(String input, List<AccountState> list) {
-    return new Result(input, list, ImmutableList.of());
+  private Result createResult(String input, List<AccountState> list, CurrentUser searchedAsUser) {
+    return new Result(input, list, ImmutableList.of(), searchedAsUser);
   }
 
-  private Result emptyResult(String input, List<AccountState> inactive) {
-    return new Result(input, ImmutableList.of(), inactive);
+  private Result emptyResult(
+      String input, List<AccountState> inactive, CurrentUser searchedAsUser) {
+    return new Result(input, ImmutableList.of(), inactive, searchedAsUser);
   }
 
   private Stream<AccountState> toAccountStates(Set<Account.Id> ids) {
diff --git a/java/com/google/gerrit/server/account/AccountsUpdate.java b/java/com/google/gerrit/server/account/AccountsUpdate.java
index 8d5fea4..b706bca 100644
--- a/java/com/google/gerrit/server/account/AccountsUpdate.java
+++ b/java/com/google/gerrit/server/account/AccountsUpdate.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkState;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.ACCOUNTS_UPDATE;
 import static java.util.Objects.requireNonNull;
 import static java.util.stream.Collectors.toList;
 import static java.util.stream.Collectors.toSet;
@@ -25,6 +26,7 @@
 import com.google.common.base.Throwables;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.exceptions.DuplicateKeyException;
 import com.google.gerrit.exceptions.StorageException;
@@ -45,6 +47,7 @@
 import com.google.gerrit.server.notedb.Sequences;
 import com.google.gerrit.server.update.RetryHelper;
 import com.google.gerrit.server.update.RetryableAction.Action;
+import com.google.gerrit.server.update.context.RefUpdateContext;
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
@@ -199,10 +202,9 @@
   private final Runnable beforeCommit;
 
   /** Single instance that accumulates updates from the batch. */
-  private ExternalIdNotes externalIdNotes;
+  @Nullable private ExternalIdNotes externalIdNotes;
 
   @AssistedInject
-  @SuppressWarnings("BindingAnnotationWithoutInject")
   AccountsUpdate(
       GitRepositoryManager repoManager,
       GitReferenceUpdated gitRefUpdated,
@@ -228,7 +230,6 @@
   }
 
   @AssistedInject
-  @SuppressWarnings("BindingAnnotationWithoutInject")
   AccountsUpdate(
       GitRepositoryManager repoManager,
       GitReferenceUpdated gitRefUpdated,
@@ -402,13 +403,17 @@
               delta.getDeletedExternalIds()),
           updateArguments.accountId);
 
-      if (externalIdNotes == null) {
-        externalIdNotes =
-            extIdNotesLoader.load(
-                repo, accountConfig.getExternalIdsRev().orElse(ObjectId.zeroId()));
+      if (delta.hasExternalIdUpdates()) {
+        // Only load the externalIds if they are going to be updated
+        // This makes e.g. preferences updates faster.
+        if (externalIdNotes == null) {
+          externalIdNotes =
+              extIdNotesLoader.load(
+                  repo, accountConfig.getExternalIdsRev().orElse(ObjectId.zeroId()));
+        }
+        externalIdNotes.replace(delta.getDeletedExternalIds(), delta.getCreatedExternalIds());
+        externalIdNotes.upsert(delta.getUpdatedExternalIds());
       }
-      externalIdNotes.replace(delta.getDeletedExternalIds(), delta.getCreatedExternalIds());
-      externalIdNotes.upsert(delta.getUpdatedExternalIds());
 
       CachedPreferences cachedDefaultPreferences =
           CachedPreferences.fromConfig(VersionedDefaultPreferences.get(repo, allUsersName));
@@ -445,28 +450,32 @@
 
   private ImmutableList<Optional<AccountState>> execute(List<ExecutableUpdate> executableUpdates)
       throws IOException, ConfigInvalidException {
-    List<Optional<AccountState>> accountState = new ArrayList<>();
-    List<UpdatedAccount> updatedAccounts = new ArrayList<>();
-    executeWithRetry(
-        () -> {
-          // Reset state for retry.
-          externalIdNotes = null;
-          accountState.clear();
-          updatedAccounts.clear();
+    try (RefUpdateContext ctx = RefUpdateContext.open(ACCOUNTS_UPDATE)) {
+      List<Optional<AccountState>> accountState = new ArrayList<>();
+      List<UpdatedAccount> updatedAccounts = new ArrayList<>();
+      executeWithRetry(
+          () -> {
 
-          try (Repository allUsersRepo = repoManager.openRepository(allUsersName)) {
-            for (ExecutableUpdate executableUpdate : executableUpdates) {
-              updatedAccounts.add(executableUpdate.execute(allUsersRepo));
+            // Reset state for retry.
+            externalIdNotes = null;
+            accountState.clear();
+            updatedAccounts.clear();
+            try (Repository allUsersRepo = repoManager.openRepository(allUsersName)) {
+              for (ExecutableUpdate executableUpdate : executableUpdates) {
+                updatedAccounts.add(executableUpdate.execute(allUsersRepo));
+              }
+              commit(
+                  allUsersRepo,
+                  updatedAccounts.stream().filter(Objects::nonNull).collect(toList()));
+              for (UpdatedAccount ua : updatedAccounts) {
+                accountState.add(ua == null ? Optional.empty() : ua.getAccountState());
+              }
             }
-            commit(
-                allUsersRepo, updatedAccounts.stream().filter(Objects::nonNull).collect(toList()));
-            for (UpdatedAccount ua : updatedAccounts) {
-              accountState.add(ua == null ? Optional.empty() : ua.getAccountState());
-            }
-          }
-          return null;
-        });
-    return ImmutableList.copyOf(accountState);
+            return null;
+          });
+
+      return ImmutableList.copyOf(accountState);
+    }
   }
 
   private void executeWithRetry(Action<Void> action) throws IOException, ConfigInvalidException {
@@ -505,17 +514,22 @@
     beforeCommit.run();
 
     BatchRefUpdate batchRefUpdate = allUsersRepo.getRefDatabase().newBatchUpdate();
-
-    String externalIdUpdateMessage =
-        updatedAccounts.size() == 1
-            ? Iterables.getOnlyElement(updatedAccounts).message
-            : "Batch update for " + updatedAccounts.size() + " accounts";
-    ObjectId oldExternalIdsRevision = externalIdNotes.getRevision();
-    // These update the same ref, so they need to be stacked on top of one another using the same
-    // ExternalIdNotes instance.
-    RevCommit revCommit =
-        commitExternalIdUpdates(externalIdUpdateMessage, allUsersRepo, batchRefUpdate);
-    boolean externalIdsUpdated = !Objects.equals(revCommit.getId(), oldExternalIdsRevision);
+    //  External ids may be not updated if:
+    //  * externalIdNotes is not loaded  (there were no externalId updates in the delta)
+    //  * new revCommit is identical to the previous externalId tip
+    boolean externalIdsUpdated = false;
+    if (externalIdNotes != null) {
+      String externalIdUpdateMessage =
+          updatedAccounts.size() == 1
+              ? Iterables.getOnlyElement(updatedAccounts).message
+              : "Batch update for " + updatedAccounts.size() + " accounts";
+      ObjectId oldExternalIdsRevision = externalIdNotes.getRevision();
+      // These update the same ref, so they need to be stacked on top of one another using the same
+      // ExternalIdNotes instance.
+      RevCommit revCommit =
+          commitExternalIdUpdates(externalIdUpdateMessage, allUsersRepo, batchRefUpdate);
+      externalIdsUpdated = !Objects.equals(revCommit.getId(), oldExternalIdsRevision);
+    }
     for (UpdatedAccount updatedAccount : updatedAccounts) {
 
       // These updates are all for different refs (because batches never update the same account
@@ -540,8 +554,10 @@
     RefUpdateUtil.executeChecked(batchRefUpdate, allUsersRepo);
 
     Set<Account.Id> accountsToSkipForReindex = getUpdatedAccountIds(batchRefUpdate);
-    extIdNotesLoader.updateExternalIdCacheAndMaybeReindexAccounts(
-        externalIdNotes, accountsToSkipForReindex);
+    if (externalIdsUpdated) {
+      extIdNotesLoader.updateExternalIdCacheAndMaybeReindexAccounts(
+          externalIdNotes, accountsToSkipForReindex);
+    }
 
     gitRefUpdated.fire(
         allUsersName, batchRefUpdate, currentUser.map(IdentifiedUser::state).orElse(null));
diff --git a/java/com/google/gerrit/server/account/AuthRequest.java b/java/com/google/gerrit/server/account/AuthRequest.java
index cceda70..cf1e552 100644
--- a/java/com/google/gerrit/server/account/AuthRequest.java
+++ b/java/com/google/gerrit/server/account/AuthRequest.java
@@ -16,6 +16,7 @@
 
 import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_EXTERNAL;
 import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_GERRIT;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_GOOGLE_OAUTH;
 import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_MAILTO;
 
 import com.google.common.base.Strings;
@@ -66,6 +67,14 @@
       return r;
     }
 
+    public AuthRequest createForOAuthUser(String userName) {
+      AuthRequest r =
+          new AuthRequest(
+              externalIdKeyFactory.create(SCHEME_GOOGLE_OAUTH, userName), externalIdKeyFactory);
+      r.setUserName(userName);
+      return r;
+    }
+
     /**
      * Create a request for an email address registration.
      *
@@ -102,6 +111,7 @@
     return externalId;
   }
 
+  @Nullable
   public String getLocalUser() {
     if (externalId.isScheme(SCHEME_GERRIT)) {
       return externalId.id();
diff --git a/java/com/google/gerrit/server/account/CreateGroupArgs.java b/java/com/google/gerrit/server/account/CreateGroupArgs.java
index ba58c3f..9d9fe9d 100644
--- a/java/com/google/gerrit/server/account/CreateGroupArgs.java
+++ b/java/com/google/gerrit/server/account/CreateGroupArgs.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.account;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
 import java.util.Collection;
@@ -30,6 +31,7 @@
     return groupName;
   }
 
+  @Nullable
   public String getGroupName() {
     return groupName != null ? groupName.get() : null;
   }
diff --git a/java/com/google/gerrit/server/account/DefaultRealm.java b/java/com/google/gerrit/server/account/DefaultRealm.java
index 329825f..cfffceb 100644
--- a/java/com/google/gerrit/server/account/DefaultRealm.java
+++ b/java/com/google/gerrit/server/account/DefaultRealm.java
@@ -16,6 +16,7 @@
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Strings;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.client.AccountFieldName;
@@ -79,6 +80,7 @@
   @Override
   public void onCreateAccount(AuthRequest who, Account account) {}
 
+  @Nullable
   @Override
   public Account.Id lookup(String accountName) throws IOException {
     if (emailExpander.canExpand(accountName)) {
diff --git a/java/com/google/gerrit/server/account/DestinationList.java b/java/com/google/gerrit/server/account/DestinationList.java
index 15c1e25..084a3ac 100644
--- a/java/com/google/gerrit/server/account/DestinationList.java
+++ b/java/com/google/gerrit/server/account/DestinationList.java
@@ -18,6 +18,7 @@
 import com.google.common.collect.MultimapBuilder;
 import com.google.common.collect.SetMultimap;
 import com.google.common.collect.Sets;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.git.ValidationError;
@@ -39,6 +40,7 @@
     destinations.replaceValues(label, toSet(parse(text, DIR_NAME + label, TRIM, null, errors)));
   }
 
+  @Nullable
   String asText(String label) {
     Set<BranchNameKey> dests = destinations.get(label);
     if (dests == null) {
diff --git a/java/com/google/gerrit/server/account/Emails.java b/java/com/google/gerrit/server/account/Emails.java
index 8c3f033..13385d0 100644
--- a/java/com/google/gerrit/server/account/Emails.java
+++ b/java/com/google/gerrit/server/account/Emails.java
@@ -57,12 +57,11 @@
    * are needed it is more efficient to use {@link #getAccountsFor(String...)} as this method reads
    * the SHA1 of the refs/meta/external-ids branch only once (and not once per email).
    *
-   * <p>In addition accounts are included that have the given email as preferred email even if they
-   * have no external ID for the preferred email. Having accounts with a preferred email that does
-   * not exist as external ID is an inconsistency, but existing functionality relies on still
-   * getting those accounts, which is why they are included. Accounts by preferred email are fetched
-   * from the account index as a fallback for email addresses that could not be resolved using
-   * {@link ExternalIds}.
+   * <p>If there is no account that owns the email via an external ID all accounts that have the
+   * email set as a preferred email are returned. Having accounts with a preferred email that does
+   * not exist as external ID is an inconsistency, but existing functionality relies on getting
+   * those accounts, which is why they are returned as a fall-back by fetching them from the account
+   * index.
    *
    * @see #getAccountsFor(String...)
    */
diff --git a/java/com/google/gerrit/server/account/GroupCache.java b/java/com/google/gerrit/server/account/GroupCache.java
index 1e28d7d..46c730c 100644
--- a/java/com/google/gerrit/server/account/GroupCache.java
+++ b/java/com/google/gerrit/server/account/GroupCache.java
@@ -14,11 +14,15 @@
 
 package com.google.gerrit.server.account;
 
+import com.google.gerrit.common.UsedAt;
+import com.google.gerrit.common.UsedAt.Project;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.InternalGroup;
+import com.google.gerrit.exceptions.StorageException;
 import java.util.Collection;
 import java.util.Map;
 import java.util.Optional;
+import org.eclipse.jgit.lib.ObjectId;
 
 /** Tracks group objects in memory for efficient access. */
 public interface GroupCache {
@@ -62,6 +66,22 @@
   Map<AccountGroup.UUID, InternalGroup> get(Collection<AccountGroup.UUID> groupUuids);
 
   /**
+   * Returns an {@code InternalGroup} instance for the given {@code AccountGroup.UUID} at the given
+   * {@code metaId} of {@link com.google.gerrit.entities.RefNames#refsGroups} ref.
+   *
+   * <p>The caller is responsible to ensure the presence of {@code metaId} and the corresponding
+   * meta ref.
+   *
+   * @param groupUuid the UUID of the internal group
+   * @param metaId the sha1 of commit in {@link com.google.gerrit.entities.RefNames#refsGroups} ref.
+   * @return the internal group at specific sha1 {@code metaId}
+   * @throws StorageException if no internal group with this UUID exists on this server at the
+   *     specific sha1, or if an error occurred during lookup.
+   */
+  @UsedAt(Project.GOOGLE)
+  InternalGroup getFromMetaId(AccountGroup.UUID groupUuid, ObjectId metaId) throws StorageException;
+
+  /**
    * Removes the association of the given ID with a group.
    *
    * <p>The next call to {@link #get(AccountGroup.Id)} won't provide a cached value.
diff --git a/java/com/google/gerrit/server/account/GroupCacheImpl.java b/java/com/google/gerrit/server/account/GroupCacheImpl.java
index 36f725f..fac2fd5 100644
--- a/java/com/google/gerrit/server/account/GroupCacheImpl.java
+++ b/java/com/google/gerrit/server/account/GroupCacheImpl.java
@@ -23,9 +23,11 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.proto.Protos;
 import com.google.gerrit.server.cache.CacheModule;
 import com.google.gerrit.server.cache.proto.Cache;
@@ -121,15 +123,19 @@
   private final LoadingCache<AccountGroup.Id, Optional<InternalGroup>> byId;
   private final LoadingCache<String, Optional<InternalGroup>> byName;
   private final LoadingCache<String, Optional<InternalGroup>> byUUID;
+  private final LoadingCache<Cache.GroupKeyProto, InternalGroup> persistedByUuidCache;
 
   @Inject
   GroupCacheImpl(
       @Named(BYID_NAME) LoadingCache<AccountGroup.Id, Optional<InternalGroup>> byId,
       @Named(BYNAME_NAME) LoadingCache<String, Optional<InternalGroup>> byName,
-      @Named(BYUUID_NAME) LoadingCache<String, Optional<InternalGroup>> byUUID) {
+      @Named(BYUUID_NAME) LoadingCache<String, Optional<InternalGroup>> byUUID,
+      @Named(BYUUID_NAME_PERSISTED)
+          LoadingCache<Cache.GroupKeyProto, InternalGroup> persistedByUuidCache) {
     this.byId = byId;
     this.byName = byName;
     this.byUUID = byUUID;
+    this.persistedByUuidCache = persistedByUuidCache;
   }
 
   @Override
@@ -184,6 +190,21 @@
   }
 
   @Override
+  public InternalGroup getFromMetaId(AccountGroup.UUID groupUuid, ObjectId metaId)
+      throws StorageException {
+    Cache.GroupKeyProto key =
+        Cache.GroupKeyProto.newBuilder()
+            .setUuid(groupUuid.get())
+            .setRevision(ObjectIdConverter.create().toByteString(metaId))
+            .build();
+    try {
+      return persistedByUuidCache.get(key);
+    } catch (ExecutionException e) {
+      throw new StorageException(e);
+    }
+  }
+
+  @Override
   public void evict(AccountGroup.Id groupId) {
     if (groupId != null) {
       logger.atFine().log("Evict group %s by ID", groupId.get());
@@ -351,6 +372,7 @@
       return Protos.toByteArray(InternalGroupSerializer.serialize(value));
     }
 
+    @Nullable
     @Override
     public InternalGroup deserialize(byte[] in) {
       if (Strings.fromByteArray(in).isEmpty()) {
diff --git a/java/com/google/gerrit/server/account/GroupIncludeCache.java b/java/com/google/gerrit/server/account/GroupIncludeCache.java
index d92d9fc..266f858 100644
--- a/java/com/google/gerrit/server/account/GroupIncludeCache.java
+++ b/java/com/google/gerrit/server/account/GroupIncludeCache.java
@@ -16,7 +16,9 @@
 
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.AccountGroup.UUID;
 import java.util.Collection;
+import java.util.Set;
 
 /** Tracks group inclusions in memory for efficient access. */
 public interface GroupIncludeCache {
@@ -37,6 +39,14 @@
    */
   Collection<AccountGroup.UUID> parentGroupsOf(AccountGroup.UUID groupId);
 
+  /**
+   * Returns the parent groups of the provided subgroups.
+   *
+   * @param groupId the UUID of the subgroup
+   * @return the UUIDs of all direct parent groups
+   */
+  Collection<AccountGroup.UUID> parentGroupsOf(Set<UUID> groupId);
+
   /** Returns set of any UUIDs that are not internal groups. */
   Collection<AccountGroup.UUID> allExternalMembers();
 
diff --git a/java/com/google/gerrit/server/account/GroupIncludeCacheImpl.java b/java/com/google/gerrit/server/account/GroupIncludeCacheImpl.java
index f203240..fc6087b 100644
--- a/java/com/google/gerrit/server/account/GroupIncludeCacheImpl.java
+++ b/java/com/google/gerrit/server/account/GroupIncludeCacheImpl.java
@@ -22,6 +22,8 @@
 import com.google.common.cache.LoadingCache;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Maps;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
@@ -45,6 +47,9 @@
 import com.google.inject.name.Named;
 import java.util.Collection;
 import java.util.Collections;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
 import java.util.concurrent.ExecutionException;
 
 /** Tracks group inclusions in memory for efficient access. */
@@ -70,7 +75,7 @@
         cache(
                 PARENT_GROUPS_NAME,
                 AccountGroup.UUID.class,
-                new TypeLiteral<ImmutableList<AccountGroup.UUID>>() {})
+                new TypeLiteral<ImmutableSet<AccountGroup.UUID>>() {})
             .loader(ParentGroupsLoader.class);
 
         /**
@@ -101,7 +106,7 @@
   }
 
   private final LoadingCache<Account.Id, ImmutableSet<AccountGroup.UUID>> groupsWithMember;
-  private final LoadingCache<AccountGroup.UUID, ImmutableList<AccountGroup.UUID>> parentGroups;
+  private final LoadingCache<AccountGroup.UUID, ImmutableSet<AccountGroup.UUID>> parentGroups;
   private final LoadingCache<String, ImmutableList<AccountGroup.UUID>> external;
 
   @Inject
@@ -109,7 +114,7 @@
       @Named(GROUPS_WITH_MEMBER_NAME)
           LoadingCache<Account.Id, ImmutableSet<AccountGroup.UUID>> groupsWithMember,
       @Named(PARENT_GROUPS_NAME)
-          LoadingCache<AccountGroup.UUID, ImmutableList<AccountGroup.UUID>> parentGroups,
+          LoadingCache<AccountGroup.UUID, ImmutableSet<AccountGroup.UUID>> parentGroups,
       @Named(EXTERNAL_NAME) LoadingCache<String, ImmutableList<AccountGroup.UUID>> external) {
     this.groupsWithMember = groupsWithMember;
     this.parentGroups = parentGroups;
@@ -137,6 +142,18 @@
   }
 
   @Override
+  public Collection<AccountGroup.UUID> parentGroupsOf(Set<AccountGroup.UUID> groupIds) {
+    try {
+      Set<AccountGroup.UUID> parents = new HashSet<>();
+      parentGroups.getAll(groupIds).values().forEach(p -> parents.addAll(p));
+      return parents;
+    } catch (ExecutionException e) {
+      logger.atWarning().withCause(e).log("Cannot load included groups");
+      return Collections.emptySet();
+    }
+  }
+
+  @Override
   public void evictGroupsWithMember(Account.Id memberId) {
     if (memberId != null) {
       logger.atFine().log("Evict groups with member %d", memberId.get());
@@ -194,7 +211,10 @@
   }
 
   static class ParentGroupsLoader
-      extends CacheLoader<AccountGroup.UUID, ImmutableList<AccountGroup.UUID>> {
+      extends CacheLoader<AccountGroup.UUID, ImmutableSet<AccountGroup.UUID>> {
+    // Be conservative with batching: We don't want to exhaust the number of
+    // results per page and maximum terms per query. Both are usually 1000+.
+    private static final int MAX_BATCH_SIZE = 100;
     private final Provider<InternalGroupQuery> groupQueryProvider;
 
     @Inject
@@ -203,13 +223,26 @@
     }
 
     @Override
-    public ImmutableList<AccountGroup.UUID> load(AccountGroup.UUID key) {
+    public ImmutableSet<AccountGroup.UUID> load(AccountGroup.UUID key) {
       try (TraceTimer timer =
           TraceContext.newTimer(
               "Loading parent groups", Metadata.builder().groupUuid(key.get()).build())) {
-        return groupQueryProvider.get().bySubgroup(key).stream()
-            .map(InternalGroup::getGroupUUID)
-            .collect(toImmutableList());
+        return loadAll(ImmutableList.of(key)).get(key);
+      }
+    }
+
+    @Override
+    public Map<AccountGroup.UUID, ImmutableSet<AccountGroup.UUID>> loadAll(
+        Iterable<? extends AccountGroup.UUID> keys) {
+      int numKeys = Iterables.size(keys);
+      Map<AccountGroup.UUID, ImmutableSet<AccountGroup.UUID>> result =
+          Maps.newHashMapWithExpectedSize(numKeys);
+      try (TraceTimer timer = TraceContext.newTimer("Loading " + numKeys + " parent groups")) {
+        Iterables.partition(keys, MAX_BATCH_SIZE)
+            .forEach(
+                keyPartition ->
+                    result.putAll(groupQueryProvider.get().bySubgroups(ImmutableSet.copyOf(keys))));
+        return result;
       }
     }
   }
diff --git a/java/com/google/gerrit/server/account/IncludingGroupMembership.java b/java/com/google/gerrit/server/account/IncludingGroupMembership.java
index 8cec8bf..e1edf10 100644
--- a/java/com/google/gerrit/server/account/IncludingGroupMembership.java
+++ b/java/com/google/gerrit/server/account/IncludingGroupMembership.java
@@ -16,7 +16,6 @@
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.Lists;
 import com.google.common.collect.Sets;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.InternalGroup;
@@ -25,7 +24,6 @@
 import com.google.inject.assistedinject.Assisted;
 import java.util.Collection;
 import java.util.HashSet;
-import java.util.List;
 import java.util.Map;
 import java.util.Set;
 import java.util.concurrent.ConcurrentHashMap;
@@ -135,7 +133,7 @@
     Set<AccountGroup.UUID> r = Sets.newHashSet(direct);
     r.remove(null);
 
-    List<AccountGroup.UUID> q = Lists.newArrayList(r);
+    Set<AccountGroup.UUID> q = Sets.newHashSet(r);
     for (AccountGroup.UUID g : membership.intersection(includeCache.allExternalMembers())) {
       if (g != null && r.add(g)) {
         q.add(g);
@@ -143,9 +141,10 @@
     }
 
     while (!q.isEmpty()) {
-      AccountGroup.UUID id = q.remove(q.size() - 1);
-      for (AccountGroup.UUID g : includeCache.parentGroupsOf(id)) {
-        if (g != null && r.add(g)) {
+      Collection<AccountGroup.UUID> parents = includeCache.parentGroupsOf(q);
+      q.clear();
+      for (AccountGroup.UUID g : parents) {
+        if (r.add(g)) {
           q.add(g);
           memberOf.put(g, true);
         }
diff --git a/java/com/google/gerrit/server/account/InternalAccountDirectory.java b/java/com/google/gerrit/server/account/InternalAccountDirectory.java
index b895834..64b8ec0 100644
--- a/java/com/google/gerrit/server/account/InternalAccountDirectory.java
+++ b/java/com/google/gerrit/server/account/InternalAccountDirectory.java
@@ -96,12 +96,12 @@
       return;
     }
 
-    boolean canModifyAccount = false;
+    boolean canViewSecondaryEmails = false;
     Account.Id currentUserId = null;
     if (self.get().isIdentifiedUser()) {
       currentUserId = self.get().getAccountId();
-      if (permissionBackend.currentUser().test(GlobalPermission.MODIFY_ACCOUNT)) {
-        canModifyAccount = true;
+      if (permissionBackend.currentUser().test(GlobalPermission.VIEW_SECONDARY_EMAILS)) {
+        canViewSecondaryEmails = true;
       }
     }
 
@@ -115,7 +115,7 @@
       if (state != null) {
         if (!options.contains(FillOptions.SECONDARY_EMAILS)
             || Objects.equals(currentUserId, state.account().id())
-            || canModifyAccount) {
+            || canViewSecondaryEmails) {
           fill(info, accountStates.get(id), options);
         } else {
           // user is not allowed to see secondary emails
diff --git a/java/com/google/gerrit/server/account/InternalGroupBackend.java b/java/com/google/gerrit/server/account/InternalGroupBackend.java
index 91fe701..01254a0 100644
--- a/java/com/google/gerrit/server/account/InternalGroupBackend.java
+++ b/java/com/google/gerrit/server/account/InternalGroupBackend.java
@@ -17,6 +17,7 @@
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.entities.GroupReference;
@@ -60,6 +61,7 @@
     return ObjectId.isId(uuid.get()); // [0-9a-f]{40};
   }
 
+  @Nullable
   @Override
   public GroupDescription.Internal get(AccountGroup.UUID uuid) {
     if (!handles(uuid)) {
diff --git a/java/com/google/gerrit/server/account/ProjectWatches.java b/java/com/google/gerrit/server/account/ProjectWatches.java
index 42137c1..86132d3 100644
--- a/java/com/google/gerrit/server/account/ProjectWatches.java
+++ b/java/com/google/gerrit/server/account/ProjectWatches.java
@@ -201,6 +201,7 @@
 
   @AutoValue
   public abstract static class NotifyValue {
+    @Nullable
     public static NotifyValue parse(
         Account.Id accountId,
         String project,
diff --git a/java/com/google/gerrit/server/account/UniversalGroupBackend.java b/java/com/google/gerrit/server/account/UniversalGroupBackend.java
index 1587bc5..476ca79 100644
--- a/java/com/google/gerrit/server/account/UniversalGroupBackend.java
+++ b/java/com/google/gerrit/server/account/UniversalGroupBackend.java
@@ -130,6 +130,7 @@
     return true;
   }
 
+  @Nullable
   @Override
   public GroupDescription.Basic get(AccountGroup.UUID uuid) {
     if (uuid == null) {
diff --git a/java/com/google/gerrit/server/account/VersionedAuthorizedKeys.java b/java/com/google/gerrit/server/account/VersionedAuthorizedKeys.java
index 555a2c1..1fce3d5 100644
--- a/java/com/google/gerrit/server/account/VersionedAuthorizedKeys.java
+++ b/java/com/google/gerrit/server/account/VersionedAuthorizedKeys.java
@@ -20,6 +20,7 @@
 
 import com.google.common.base.Strings;
 import com.google.common.collect.Ordering;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.exceptions.InvalidSshKeyException;
@@ -194,6 +195,7 @@
    * @return the SSH key, <code>null</code> if there is no SSH key with this sequence number, or if
    *     the SSH key with this sequence number has been deleted
    */
+  @Nullable
   private AccountSshKey getKey(int seq) {
     checkLoaded();
     return keys.get(seq - 1).orElse(null);
diff --git a/java/com/google/gerrit/server/account/externalids/AllExternalIds.java b/java/com/google/gerrit/server/account/externalids/AllExternalIds.java
index e718bcb..14aa368 100644
--- a/java/com/google/gerrit/server/account/externalids/AllExternalIds.java
+++ b/java/com/google/gerrit/server/account/externalids/AllExternalIds.java
@@ -45,6 +45,13 @@
     return new AutoValue_AllExternalIds(byKey.build(), byAccount.build(), byEmail.build());
   }
 
+  static AllExternalIds create(
+      ImmutableMap<ExternalId.Key, ExternalId> byKey,
+      ImmutableSetMultimap<Account.Id, ExternalId> byAccount,
+      ImmutableSetMultimap<String, ExternalId> byEmail) {
+    return new AutoValue_AllExternalIds(byKey, byAccount, byEmail);
+  }
+
   public abstract ImmutableMap<ExternalId.Key, ExternalId> byKey();
 
   public abstract ImmutableSetMultimap<Account.Id, ExternalId> byAccount();
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalId.java b/java/com/google/gerrit/server/account/externalids/ExternalId.java
index 1616198..9196db8 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalId.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalId.java
@@ -150,6 +150,9 @@
   /** Scheme for xri resources. OpenID in particular makes use of these external IDs. */
   public static final String SCHEME_XRI = "xri";
 
+  /** Scheme for Google OAuth external IDs. */
+  public static final String SCHEME_GOOGLE_OAUTH = "google-oauth";
+
   @AutoValue
   public abstract static class Key implements Serializable {
     private static final long serialVersionUID = 1L;
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdCacheLoader.java b/java/com/google/gerrit/server/account/externalids/ExternalIdCacheLoader.java
index 7984d7e..bf281a5 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdCacheLoader.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdCacheLoader.java
@@ -272,7 +272,7 @@
         }
       }
     }
-    return new AutoValue_AllExternalIds(
+    return AllExternalIds.create(
         ImmutableMap.<ExternalId.Key, ExternalId>builder().putAll(byKeyMutableMap).build(),
         byAccount.build(),
         byEmail.build());
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdNotes.java b/java/com/google/gerrit/server/account/externalids/ExternalIdNotes.java
index b0618ba..48c403c 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdNotes.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdNotes.java
@@ -1020,6 +1020,7 @@
    * @return the external ID that was removed, {@code null} if no external ID with the specified key
    *     exists
    */
+  @Nullable
   private ExternalId remove(
       RevWalk rw, NoteMap noteMap, ExternalId.Key extIdKey, Account.Id expectedAccountId)
       throws IOException, ConfigInvalidException {
diff --git a/java/com/google/gerrit/server/account/externalids/testing/BUILD b/java/com/google/gerrit/server/account/externalids/testing/BUILD
index 0e469e3..e2de6da 100644
--- a/java/com/google/gerrit/server/account/externalids/testing/BUILD
+++ b/java/com/google/gerrit/server/account/externalids/testing/BUILD
@@ -8,6 +8,7 @@
     deps = [
         "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/testing:test-ref-update-context",
         "//lib:jgit",
     ],
 )
diff --git a/java/com/google/gerrit/server/account/externalids/testing/ExternalIdTestUtil.java b/java/com/google/gerrit/server/account/externalids/testing/ExternalIdTestUtil.java
index a42afc3..7878ee2 100644
--- a/java/com/google/gerrit/server/account/externalids/testing/ExternalIdTestUtil.java
+++ b/java/com/google/gerrit/server/account/externalids/testing/ExternalIdTestUtil.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.account.externalids.testing;
 
+import static com.google.gerrit.testing.TestActionRefUpdateContext.testRefAction;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
 import static org.eclipse.jgit.lib.Constants.OBJ_TREE;
@@ -143,7 +144,7 @@
       RefUpdate u = repo.updateRef(RefNames.REFS_EXTERNAL_IDS);
       u.setExpectedOldObjectId(rev);
       u.setNewObjectId(commitId);
-      RefUpdate.Result res = u.update();
+      RefUpdate.Result res = testRefAction(() -> u.update());
       switch (res) {
         case NEW:
         case FAST_FORWARD:
diff --git a/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java b/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java
index b23782f..828f868 100644
--- a/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java
+++ b/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java
@@ -17,6 +17,7 @@
 import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
 import static javax.servlet.http.HttpServletResponse.SC_OK;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.RawInputUtil;
 import com.google.gerrit.extensions.api.accounts.AccountApi;
 import com.google.gerrit.extensions.api.accounts.AgreementInput;
@@ -575,6 +576,7 @@
     }
   }
 
+  @Nullable
   @Override
   public String generateHttpPassword() throws RestApiException {
     HttpPasswordInput input = new HttpPasswordInput();
@@ -589,6 +591,7 @@
     }
   }
 
+  @Nullable
   @Override
   public String setHttpPassword(String password) throws RestApiException {
     HttpPasswordInput input = new HttpPasswordInput();
diff --git a/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java b/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
index 1713171..4fba660 100644
--- a/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
+++ b/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
@@ -20,7 +20,7 @@
 import com.google.common.collect.ListMultimap;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.api.changes.AbandonInput;
-import com.google.gerrit.extensions.api.changes.AssigneeInput;
+import com.google.gerrit.extensions.api.changes.ApplyPatchPatchSetInput;
 import com.google.gerrit.extensions.api.changes.AttentionSetApi;
 import com.google.gerrit.extensions.api.changes.AttentionSetInput;
 import com.google.gerrit.extensions.api.changes.ChangeApi;
@@ -53,6 +53,7 @@
 import com.google.gerrit.extensions.common.InputWithMessage;
 import com.google.gerrit.extensions.common.MergePatchSetInput;
 import com.google.gerrit.extensions.common.PureRevertInfo;
+import com.google.gerrit.extensions.common.RebaseChainInfo;
 import com.google.gerrit.extensions.common.RevertSubmissionInfo;
 import com.google.gerrit.extensions.common.RobotCommentInfo;
 import com.google.gerrit.extensions.common.SubmitRequirementInput;
@@ -69,20 +70,18 @@
 import com.google.gerrit.server.change.WorkInProgressOp;
 import com.google.gerrit.server.restapi.change.Abandon;
 import com.google.gerrit.server.restapi.change.AddToAttentionSet;
+import com.google.gerrit.server.restapi.change.ApplyPatch;
 import com.google.gerrit.server.restapi.change.AttentionSet;
 import com.google.gerrit.server.restapi.change.ChangeIncludedIn;
 import com.google.gerrit.server.restapi.change.ChangeMessages;
 import com.google.gerrit.server.restapi.change.Check;
 import com.google.gerrit.server.restapi.change.CheckSubmitRequirement;
 import com.google.gerrit.server.restapi.change.CreateMergePatchSet;
-import com.google.gerrit.server.restapi.change.DeleteAssignee;
 import com.google.gerrit.server.restapi.change.DeleteChange;
 import com.google.gerrit.server.restapi.change.DeletePrivate;
-import com.google.gerrit.server.restapi.change.GetAssignee;
 import com.google.gerrit.server.restapi.change.GetChange;
 import com.google.gerrit.server.restapi.change.GetHashtags;
 import com.google.gerrit.server.restapi.change.GetMetaDiff;
-import com.google.gerrit.server.restapi.change.GetPastAssignees;
 import com.google.gerrit.server.restapi.change.GetPureRevert;
 import com.google.gerrit.server.restapi.change.GetTopic;
 import com.google.gerrit.server.restapi.change.Index;
@@ -94,10 +93,10 @@
 import com.google.gerrit.server.restapi.change.PostHashtags;
 import com.google.gerrit.server.restapi.change.PostPrivate;
 import com.google.gerrit.server.restapi.change.PostReviewers;
-import com.google.gerrit.server.restapi.change.PutAssignee;
 import com.google.gerrit.server.restapi.change.PutMessage;
 import com.google.gerrit.server.restapi.change.PutTopic;
 import com.google.gerrit.server.restapi.change.Rebase;
+import com.google.gerrit.server.restapi.change.RebaseChain;
 import com.google.gerrit.server.restapi.change.Restore;
 import com.google.gerrit.server.restapi.change.Revert;
 import com.google.gerrit.server.restapi.change.RevertSubmission;
@@ -139,8 +138,10 @@
   private final RevertSubmission revertSubmission;
   private final Restore restore;
   private final CreateMergePatchSet updateByMerge;
+  private final ApplyPatch applyPatch;
   private final Provider<SubmittedTogether> submittedTogether;
   private final Rebase.CurrentRevision rebase;
+  private final RebaseChain rebaseChain;
   private final DeleteChange deleteChange;
   private final GetTopic getTopic;
   private final PutTopic putTopic;
@@ -153,10 +154,6 @@
   private final AttentionSet attentionSet;
   private final AttentionSetApiImpl.Factory attentionSetApi;
   private final AddToAttentionSet addToAttentionSet;
-  private final PutAssignee putAssignee;
-  private final GetAssignee getAssignee;
-  private final GetPastAssignees getPastAssignees;
-  private final DeleteAssignee deleteAssignee;
   private final Provider<ListChangeComments> listCommentsProvider;
   private final ListChangeRobotComments listChangeRobotComments;
   private final Provider<ListChangeDrafts> listDraftsProvider;
@@ -191,8 +188,10 @@
       RevertSubmission revertSubmission,
       Restore restore,
       CreateMergePatchSet updateByMerge,
+      ApplyPatch applyPatch,
       Provider<SubmittedTogether> submittedTogether,
       Rebase.CurrentRevision rebase,
+      RebaseChain rebaseChain,
       DeleteChange deleteChange,
       GetTopic getTopic,
       PutTopic putTopic,
@@ -205,10 +204,6 @@
       AttentionSet attentionSet,
       AttentionSetApiImpl.Factory attentionSetApi,
       AddToAttentionSet addToAttentionSet,
-      PutAssignee putAssignee,
-      GetAssignee getAssignee,
-      GetPastAssignees getPastAssignees,
-      DeleteAssignee deleteAssignee,
       Provider<ListChangeComments> listCommentsProvider,
       ListChangeRobotComments listChangeRobotComments,
       Provider<ListChangeDrafts> listDraftsProvider,
@@ -241,8 +236,10 @@
     this.abandon = abandon;
     this.restore = restore;
     this.updateByMerge = updateByMerge;
+    this.applyPatch = applyPatch;
     this.submittedTogether = submittedTogether;
     this.rebase = rebase;
+    this.rebaseChain = rebaseChain;
     this.deleteChange = deleteChange;
     this.getTopic = getTopic;
     this.putTopic = putTopic;
@@ -255,10 +252,6 @@
     this.attentionSet = attentionSet;
     this.attentionSetApi = attentionSetApi;
     this.addToAttentionSet = addToAttentionSet;
-    this.putAssignee = putAssignee;
-    this.getAssignee = getAssignee;
-    this.getPastAssignees = getPastAssignees;
-    this.deleteAssignee = deleteAssignee;
     this.listCommentsProvider = listCommentsProvider;
     this.listChangeRobotComments = listChangeRobotComments;
     this.listDraftsProvider = listDraftsProvider;
@@ -389,6 +382,15 @@
   }
 
   @Override
+  public ChangeInfo applyPatch(ApplyPatchPatchSetInput in) throws RestApiException {
+    try {
+      return applyPatch.apply(change, in).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot apply patch", e);
+    }
+  }
+
+  @Override
   public SubmittedTogetherInfo submittedTogether(
       EnumSet<ListChangesOption> listOptions, EnumSet<SubmittedTogetherOption> submitOptions)
       throws RestApiException {
@@ -413,6 +415,15 @@
   }
 
   @Override
+  public Response<RebaseChainInfo> rebaseChain(RebaseInput in) throws RestApiException {
+    try {
+      return rebaseChain.apply(change, in);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot rebase chain", e);
+    }
+  }
+
+  @Override
   public void delete() throws RestApiException {
     try {
       deleteChange.apply(change, null);
@@ -575,44 +586,6 @@
   }
 
   @Override
-  public AccountInfo setAssignee(AssigneeInput input) throws RestApiException {
-    try {
-      return putAssignee.apply(change, input).value();
-    } catch (Exception e) {
-      throw asRestApiException("Cannot set assignee", e);
-    }
-  }
-
-  @Override
-  public AccountInfo getAssignee() throws RestApiException {
-    try {
-      Response<AccountInfo> r = getAssignee.apply(change);
-      return r.isNone() ? null : r.value();
-    } catch (Exception e) {
-      throw asRestApiException("Cannot get assignee", e);
-    }
-  }
-
-  @Override
-  public List<AccountInfo> getPastAssignees() throws RestApiException {
-    try {
-      return getPastAssignees.apply(change).value();
-    } catch (Exception e) {
-      throw asRestApiException("Cannot get past assignees", e);
-    }
-  }
-
-  @Override
-  public AccountInfo deleteAssignee() throws RestApiException {
-    try {
-      Response<AccountInfo> r = deleteAssignee.apply(change, null);
-      return r.isNone() ? null : r.value();
-    } catch (Exception e) {
-      throw asRestApiException("Cannot delete assignee", e);
-    }
-  }
-
-  @Override
   public CommentsRequest commentsRequest() {
     return new CommentsRequest() {
       @Override
diff --git a/java/com/google/gerrit/server/api/changes/ChangesImpl.java b/java/com/google/gerrit/server/api/changes/ChangesImpl.java
index 0596524..6b107f1 100644
--- a/java/com/google/gerrit/server/api/changes/ChangesImpl.java
+++ b/java/com/google/gerrit/server/api/changes/ChangesImpl.java
@@ -21,6 +21,7 @@
 import com.google.common.base.Joiner;
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.changes.ChangeApi;
 import com.google.gerrit.extensions.api.changes.Changes;
 import com.google.gerrit.extensions.client.ListChangesOption;
@@ -41,6 +42,7 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.util.List;
+import org.eclipse.jgit.lib.ObjectId;
 
 @Singleton
 class ChangesImpl implements Changes {
@@ -101,7 +103,11 @@
   public ChangeApi create(ChangeInput in) throws RestApiException {
     try {
       ChangeInfo out = createChange.apply(TopLevelResource.INSTANCE, in).value();
-      return api.create(changes.parse(Change.id(out._number)));
+      return api.create(
+          changes.parse(
+              Project.nameKey(out.project),
+              Change.id(out._number),
+              ObjectId.fromString(out.metaRevId)));
     } catch (Exception e) {
       throw asRestApiException("Cannot create change", e);
     }
diff --git a/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java b/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java
index 3a892bc..ad42ae6 100644
--- a/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java
+++ b/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java
@@ -16,6 +16,8 @@
 
 import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
 import static com.google.gerrit.server.restapi.project.DashboardsCollection.DEFAULT_DASHBOARD_NAME;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.BRANCH_MODIFICATION;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.HEAD_MODIFICATION;
 import static java.util.stream.Collectors.toList;
 
 import com.google.gerrit.extensions.api.access.ProjectAccessInfo;
@@ -89,6 +91,7 @@
 import com.google.gerrit.server.restapi.project.SetAccess;
 import com.google.gerrit.server.restapi.project.SetHead;
 import com.google.gerrit.server.restapi.project.SetParent;
+import com.google.gerrit.server.update.context.RefUpdateContext;
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
@@ -595,7 +598,9 @@
   @Override
   public void deleteBranches(DeleteBranchesInput in) throws RestApiException {
     try {
-      deleteBranches.apply(checkExists(), in);
+      try (RefUpdateContext ctx = RefUpdateContext.open(BRANCH_MODIFICATION)) {
+        deleteBranches.apply(checkExists(), in);
+      }
     } catch (Exception e) {
       throw asRestApiException("Cannot delete branches", e);
     }
@@ -686,7 +691,9 @@
     HeadInput input = new HeadInput();
     input.ref = head;
     try {
-      setHead.apply(checkExists(), input);
+      try (RefUpdateContext ctx = RefUpdateContext.open(HEAD_MODIFICATION)) {
+        setHead.apply(checkExists(), input);
+      }
     } catch (Exception e) {
       throw asRestApiException("Cannot set HEAD", e);
     }
diff --git a/java/com/google/gerrit/server/api/projects/ProjectQueryBuilderModule.java b/java/com/google/gerrit/server/api/projects/ProjectQueryBuilderModule.java
new file mode 100644
index 0000000..8ed1175
--- /dev/null
+++ b/java/com/google/gerrit/server/api/projects/ProjectQueryBuilderModule.java
@@ -0,0 +1,26 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.api.projects;
+
+import com.google.gerrit.server.query.project.ProjectQueryBuilder;
+import com.google.gerrit.server.query.project.ProjectQueryBuilderImpl;
+import com.google.inject.AbstractModule;
+
+public class ProjectQueryBuilderModule extends AbstractModule {
+  @Override
+  protected void configure() {
+    bind(ProjectQueryBuilder.class).to(ProjectQueryBuilderImpl.class);
+  }
+}
diff --git a/java/com/google/gerrit/server/api/projects/TagApiImpl.java b/java/com/google/gerrit/server/api/projects/TagApiImpl.java
index 005486a..f9bd048 100644
--- a/java/com/google/gerrit/server/api/projects/TagApiImpl.java
+++ b/java/com/google/gerrit/server/api/projects/TagApiImpl.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.api.projects;
 
 import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.TAG_MODIFICATION;
 
 import com.google.gerrit.extensions.api.projects.TagApi;
 import com.google.gerrit.extensions.api.projects.TagInfo;
@@ -29,6 +30,7 @@
 import com.google.gerrit.server.restapi.project.DeleteTag;
 import com.google.gerrit.server.restapi.project.ListTags;
 import com.google.gerrit.server.restapi.project.TagsCollection;
+import com.google.gerrit.server.update.context.RefUpdateContext;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
@@ -83,7 +85,9 @@
   @Override
   public void delete() throws RestApiException {
     try {
-      deleteTag.apply(resource(), new Input());
+      try (RefUpdateContext ctx = RefUpdateContext.open(TAG_MODIFICATION)) {
+        deleteTag.apply(resource(), new Input());
+      }
     } catch (Exception e) {
       throw asRestApiException("Cannot delete tag", e);
     }
diff --git a/java/com/google/gerrit/server/approval/ApprovalCopier.java b/java/com/google/gerrit/server/approval/ApprovalCopier.java
index 059445e..a1889da 100644
--- a/java/com/google/gerrit/server/approval/ApprovalCopier.java
+++ b/java/com/google/gerrit/server/approval/ApprovalCopier.java
@@ -32,6 +32,7 @@
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.client.ChangeKind;
+import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.change.ChangeKindCache;
@@ -45,6 +46,7 @@
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.query.approval.ApprovalContext;
 import com.google.gerrit.server.query.approval.ApprovalQueryBuilder;
+import com.google.gerrit.server.util.LabelVote;
 import com.google.gerrit.server.util.ManualRequestContext;
 import com.google.gerrit.server.util.OneOffRequestContext;
 import com.google.inject.Inject;
@@ -85,7 +87,7 @@
      *   <li>the approval is not overridden by a current approval on the patch set
      * </ul>
      */
-    public abstract ImmutableSet<PatchSetApproval> copiedApprovals();
+    public abstract ImmutableSet<PatchSetApprovalData> copiedApprovals();
 
     /**
      * Approvals on the previous patch set that have not been copied to the patch set.
@@ -96,7 +98,7 @@
      * <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();
+    public abstract ImmutableSet<PatchSetApprovalData> outdatedApprovals();
 
     static Result empty() {
       return create(
@@ -105,10 +107,68 @@
 
     @VisibleForTesting
     public static Result create(
-        ImmutableSet<PatchSetApproval> copiedApprovals,
-        ImmutableSet<PatchSetApproval> outdatedApprovals) {
+        ImmutableSet<PatchSetApprovalData> copiedApprovals,
+        ImmutableSet<PatchSetApprovalData> outdatedApprovals) {
       return new AutoValue_ApprovalCopier_Result(copiedApprovals, outdatedApprovals);
     }
+
+    /**
+     * A {@link PatchSetApproval} with information about which atoms of the copy condition are
+     * passing/failing.
+     */
+    @AutoValue
+    public abstract static class PatchSetApprovalData {
+      /** The approval. */
+      public abstract PatchSetApproval patchSetApproval();
+
+      /**
+       * Lists the leaf predicates of the copy condition that are fulfilled.
+       *
+       * <p>Example: The expression
+       *
+       * <pre>
+       * changekind:TRIVIAL_REBASE OR is:MIN
+       * </pre>
+       *
+       * has two leaf predicates:
+       *
+       * <ul>
+       *   <li>changekind:TRIVIAL_REBASE
+       *   <li>is:MIN
+       * </ul>
+       *
+       * This method will return the leaf predicates that are fulfilled, for example if only the
+       * first predicate is fulfilled, the returned list will be equal to
+       * ["changekind:TRIVIAL_REBASE"].
+       *
+       * <p>Empty if the label type is missing, if there is no copy condition or if the copy
+       * condition is not parseable.
+       */
+      public abstract ImmutableSet<String> passingAtoms();
+
+      /**
+       * Lists the leaf predicates of the copy condition that are not fulfilled. See {@link
+       * #passingAtoms()} for more details.
+       *
+       * <p>Empty if the label type is missing, if there is no copy condition or if the copy
+       * condition is not parseable.
+       */
+      public abstract ImmutableSet<String> failingAtoms();
+
+      @VisibleForTesting
+      public static PatchSetApprovalData create(
+          PatchSetApproval approval,
+          ImmutableSet<String> passingAtoms,
+          ImmutableSet<String> failingAtoms) {
+        return new AutoValue_ApprovalCopier_Result_PatchSetApprovalData(
+            approval, passingAtoms, failingAtoms);
+      }
+
+      private static PatchSetApprovalData createForMissingLabelType(PatchSetApproval approval) {
+        return new AutoValue_ApprovalCopier_Result_PatchSetApprovalData(
+            approval, ImmutableSet.of(), ImmutableSet.of());
+      }
+    }
   }
 
   private final GitRepositoryManager repoManager;
@@ -227,17 +287,18 @@
                 followUpPatchSet.commitId());
         boolean isMerge = isMerge(changeNotes.getProjectName(), revWalk, followUpPatchSet);
 
-        if (canCopy(
-            changeNotes,
-            priorPatchSet.id(),
-            followUpPatchSet,
-            approverId,
-            labelType.get(),
-            approvalValue,
-            changeKind,
-            isMerge,
-            revWalk,
-            repo.getConfig())) {
+        if (computeCopyResult(
+                changeNotes,
+                priorPatchSet.id(),
+                followUpPatchSet,
+                approverId,
+                labelType.get(),
+                approvalValue,
+                changeKind,
+                isMerge,
+                revWalk,
+                repo.getConfig())
+            .canCopy()) {
           targetPatchSetsBuilder.add(followUpPatchSetId);
         } else {
           // The approval is not copyable to this follow-up patch set.
@@ -251,7 +312,14 @@
     return targetPatchSetsBuilder.build();
   }
 
-  private boolean canCopy(
+  /**
+   * Checks whether a given approval can be copied from the given source patch set to the given
+   * target patch set.
+   *
+   * <p>The returned result also informs about which atoms of the copy condition are
+   * passing/failing.
+   */
+  private ApprovalCopyResult computeCopyResult(
       ChangeNotes changeNotes,
       PatchSet.Id sourcePatchSetId,
       PatchSet targetPatchSet,
@@ -263,7 +331,7 @@
       RevWalk revWalk,
       Config repoConfig) {
     if (!labelType.getCopyCondition().isPresent()) {
-      return false;
+      return ApprovalCopyResult.createForMissingCopyCondition();
     }
     ApprovalContext ctx =
         ApprovalContext.create(
@@ -283,15 +351,33 @@
       // request (e.g. a group used in this query might not be visible to the person sending this
       // request).
       try (ManualRequestContext ignored = requestContext.open()) {
-        return approvalQueryBuilder
-            .parse(labelType.getCopyCondition().get())
-            .asMatchable()
-            .match(ctx);
+        Predicate<ApprovalContext> copyConditionPredicate =
+            approvalQueryBuilder.parse(labelType.getCopyCondition().get());
+        boolean canCopy = copyConditionPredicate.asMatchable().match(ctx);
+        ImmutableSet.Builder<String> passingAtomsBuilder = ImmutableSet.builder();
+        ImmutableSet.Builder<String> failingAtomsBuilder = ImmutableSet.builder();
+        evaluateAtoms(copyConditionPredicate, ctx, passingAtomsBuilder, failingAtomsBuilder);
+        ImmutableSet<String> passingAtoms = passingAtomsBuilder.build();
+        ImmutableSet<String> failingAtoms = failingAtomsBuilder.build();
+        logger.atFine().log(
+            "%s copy %s of account %d on change %d from patch set %d to patch set %d"
+                + " (copyCondition = %s, passingAtoms = %s, failingAtoms = %s, changeKind = %s)",
+            canCopy ? "Can" : "Cannot",
+            LabelVote.create(labelType.getName(), approvalValue).format(),
+            approverId.get(),
+            changeNotes.getChangeId().get(),
+            sourcePatchSetId.get(),
+            targetPatchSet.id().get(),
+            labelType.getCopyCondition().get(),
+            passingAtoms,
+            failingAtoms,
+            changeKind.name());
+        return ApprovalCopyResult.create(canCopy, passingAtoms, failingAtoms);
       }
     } catch (QueryParseException e) {
       logger.atWarning().withCause(e).log(
           "Unable to copy label because config is invalid. This should have been caught before.");
-      return false;
+      return ApprovalCopyResult.createForNonParseableCopyCondition();
     }
   }
 
@@ -321,8 +407,10 @@
     nonCopiedApprovalsForGivenPatchSet.forEach(
         psa -> currentApprovalsByUser.put(psa.label(), psa.accountId(), psa));
 
-    Table<String, Account.Id, PatchSetApproval> copiedApprovalsByUser = HashBasedTable.create();
-    ImmutableSet.Builder<PatchSetApproval> outdatedApprovalsBuilder = ImmutableSet.builder();
+    Table<String, Account.Id, Result.PatchSetApprovalData> copiedApprovalsByUser =
+        HashBasedTable.create();
+    ImmutableSet.Builder<Result.PatchSetApprovalData> outdatedApprovalsBuilder =
+        ImmutableSet.builder();
 
     ImmutableList<PatchSetApproval> priorApprovals =
         notes.load().getApprovals().all().get(priorPatchSet.getKey());
@@ -362,35 +450,55 @@
             priorPsa.key().patchSetId().changeId().get(),
             targetPsId.get(),
             projectName);
-        outdatedApprovalsBuilder.add(priorPsa);
+        outdatedApprovalsBuilder.add(
+            Result.PatchSetApprovalData.createForMissingLabelType(priorPsa));
         continue;
       }
-      if (canCopy(
-          notes,
-          priorPsa.patchSetId(),
-          targetPatchSet,
-          priorPsa.accountId(),
-          labelType.get(),
-          priorPsa.value(),
-          changeKind,
-          isMerge,
-          rw,
-          repoConfig)) {
+      ApprovalCopyResult approvalCopyResult =
+          computeCopyResult(
+              notes,
+              priorPsa.patchSetId(),
+              targetPatchSet,
+              priorPsa.accountId(),
+              labelType.get(),
+              priorPsa.value(),
+              changeKind,
+              isMerge,
+              rw,
+              repoConfig);
+      if (approvalCopyResult.canCopy()) {
         if (!currentApprovalsByUser.contains(priorPsa.label(), priorPsa.accountId())) {
+          PatchSetApproval copiedApproval = priorPsa.copyWithPatchSet(targetPatchSet.id());
+
+          // Normalize the copied approval.
+          Optional<PatchSetApproval> copiedApprovalNormalized =
+              labelNormalizer.normalize(notes, copiedApproval);
+          logger.atFine().log(
+              "Copied approval %s has been normalized to %s",
+              copiedApproval,
+              copiedApprovalNormalized.map(PatchSetApproval::toString).orElse("n/a"));
+          if (!copiedApprovalNormalized.isPresent()) {
+            continue;
+          }
+
           copiedApprovalsByUser.put(
               priorPsa.label(),
               priorPsa.accountId(),
-              priorPsa.copyWithPatchSet(targetPatchSet.id()));
+              Result.PatchSetApprovalData.create(
+                  copiedApprovalNormalized.get(),
+                  approvalCopyResult.passingAtoms(),
+                  approvalCopyResult.failingAtoms()));
         }
       } else {
-        outdatedApprovalsBuilder.add(priorPsa);
+        outdatedApprovalsBuilder.add(
+            Result.PatchSetApprovalData.create(
+                priorPsa, approvalCopyResult.passingAtoms(), approvalCopyResult.failingAtoms()));
         continue;
       }
     }
 
-    ImmutableSet<PatchSetApproval> copiedApprovals =
-        labelNormalizer.normalize(notes, copiedApprovalsByUser.values()).getNormalized();
-    return Result.create(copiedApprovals, outdatedApprovalsBuilder.build());
+    return Result.create(
+        ImmutableSet.copyOf(copiedApprovalsByUser.values()), outdatedApprovalsBuilder.build());
   }
 
   private boolean isMerge(Project.NameKey project, RevWalk rw, PatchSet patchSet) {
@@ -404,4 +512,72 @@
           e);
     }
   }
+
+  /**
+   * Evaluates a predicate of the copy condition and adds its passing and failing atoms to the given
+   * builders.
+   *
+   * @param predicate a predicate of the copy condition that should be evaluated
+   * @param approvalContext the approval context against which the predicate should be evaluated
+   * @param passingAtoms a builder to which passing atoms should be added
+   * @param failingAtoms a builder to which failing atoms should be added
+   */
+  private static void evaluateAtoms(
+      Predicate<ApprovalContext> predicate,
+      ApprovalContext approvalContext,
+      ImmutableSet.Builder<String> passingAtoms,
+      ImmutableSet.Builder<String> failingAtoms) {
+    if (predicate.isLeaf()) {
+      boolean isPassing = predicate.asMatchable().match(approvalContext);
+      (isPassing ? passingAtoms : failingAtoms).add(predicate.getPredicateString());
+      return;
+    }
+    predicate
+        .getChildren()
+        .forEach(
+            childPredicate ->
+                evaluateAtoms(childPredicate, approvalContext, passingAtoms, failingAtoms));
+  }
+
+  /** Result for checking if an approval can be copied to the next patch set. */
+  @AutoValue
+  abstract static class ApprovalCopyResult {
+    /** Whether the approval can be copied to the next patch set. */
+    abstract boolean canCopy();
+
+    /**
+     * Lists the leaf predicates of the copy condition that are fulfilled. See {@link
+     * Result.PatchSetApprovalData#passingAtoms()} for more details.
+     *
+     * <p>Empty if there is no copy condition or if the copy condition is not parseable.
+     */
+    abstract ImmutableSet<String> passingAtoms();
+
+    /**
+     * Lists the leaf predicates of the copy condition that are not fulfilled. See {@link
+     * Result.PatchSetApprovalData#passingAtoms()} for more details.
+     *
+     * <p>Empty if there is no copy condition or if the copy condition is not parseable.
+     */
+    abstract ImmutableSet<String> failingAtoms();
+
+    private static ApprovalCopyResult create(
+        boolean canCopy, ImmutableSet<String> passingAtoms, ImmutableSet<String> failingAtoms) {
+      return new AutoValue_ApprovalCopier_ApprovalCopyResult(canCopy, passingAtoms, failingAtoms);
+    }
+
+    private static ApprovalCopyResult createForMissingCopyCondition() {
+      return new AutoValue_ApprovalCopier_ApprovalCopyResult(
+          /* canCopy= */ false,
+          /* passingAtoms= */ ImmutableSet.of(),
+          /* failingAtoms= */ ImmutableSet.of());
+    }
+
+    private static ApprovalCopyResult createForNonParseableCopyCondition() {
+      return new AutoValue_ApprovalCopier_ApprovalCopyResult(
+          /* canCopy= */ false,
+          /* passingAtoms= */ ImmutableSet.of(),
+          /* failingAtoms= */ ImmutableSet.of());
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/approval/ApprovalsUtil.java b/java/com/google/gerrit/server/approval/ApprovalsUtil.java
index 620f712..8fae13a 100644
--- a/java/com/google/gerrit/server/approval/ApprovalsUtil.java
+++ b/java/com/google/gerrit/server/approval/ApprovalsUtil.java
@@ -15,8 +15,10 @@
 package com.google.gerrit.server.approval;
 
 import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkState;
 import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.common.collect.ImmutableListMultimap.toImmutableListMultimap;
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.CC;
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
@@ -25,6 +27,7 @@
 import static java.util.stream.Collectors.joining;
 
 import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Function;
 import com.google.common.collect.ArrayListMultimap;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableListMultimap;
@@ -36,6 +39,7 @@
 import com.google.common.collect.Multimaps;
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.entities.Change;
@@ -84,6 +88,7 @@
 import java.util.Objects;
 import java.util.Optional;
 import java.util.Set;
+import java.util.StringTokenizer;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.revwalk.RevWalk;
 
@@ -403,12 +408,17 @@
       ChangeUpdate changeUpdate) {
     ApprovalCopier.Result approvalCopierResult =
         approvalCopier.forPatchSet(notes, patchSet, revWalk, repoConfig);
-    approvalCopierResult.copiedApprovals().forEach(a -> changeUpdate.putCopiedApproval(a));
+    approvalCopierResult
+        .copiedApprovals()
+        .forEach(approvalData -> changeUpdate.putCopiedApproval(approvalData.patchSetApproval()));
 
     if (!notes.getChange().isWorkInProgress()) {
       // The attention set should not be updated when the change is work-in-progress.
       addAttentionSetUpdatesForOutdatedApprovals(
-          changeUpdate, approvalCopierResult.outdatedApprovals());
+          changeUpdate,
+          approvalCopierResult.outdatedApprovals().stream()
+              .map(ApprovalCopier.Result.PatchSetApprovalData::patchSetApproval)
+              .collect(toImmutableSet()));
     }
 
     return approvalCopierResult;
@@ -515,31 +525,40 @@
    *       "is:FOO")}
    * </ul>
    *
-   * @param approvals the approvals that should be formatted
+   * @param approvalDatas the approvals that should be formatted, with approval meta data
    * @param labelTypes the label types
    * @return bullet list with the formatted approvals
    */
   private String formatApprovalListWithCopyCondition(
-      ImmutableSet<PatchSetApproval> approvals, LabelTypes labelTypes) {
+      ImmutableSet<ApprovalCopier.Result.PatchSetApprovalData> approvalDatas,
+      LabelTypes labelTypes) {
     StringBuilder message = new StringBuilder();
 
     // sort approvals by label vote so that we list them in a deterministic order
-    ImmutableList<PatchSetApproval> approvalsSortedByLabelVote =
-        approvals.stream()
-            .sorted(comparing(psa -> LabelVote.create(psa.label(), psa.value()).format()))
+    ImmutableList<ApprovalCopier.Result.PatchSetApprovalData> approvalsSortedByLabelVote =
+        approvalDatas.stream()
+            .sorted(
+                comparing(
+                    approvalData ->
+                        LabelVote.create(
+                                approvalData.patchSetApproval().label(),
+                                approvalData.patchSetApproval().value())
+                            .format()))
             .collect(toImmutableList());
 
-    ImmutableListMultimap<String, PatchSetApproval> approvalsByLabel =
-        Multimaps.index(approvalsSortedByLabelVote, PatchSetApproval::label);
+    ImmutableListMultimap<String, ApprovalCopier.Result.PatchSetApprovalData> approvalsByLabel =
+        Multimaps.index(
+            approvalsSortedByLabelVote, approvalData -> approvalData.patchSetApproval().label());
 
-    for (Map.Entry<String, Collection<PatchSetApproval>> approvalsByLabelEntry :
-        approvalsByLabel.asMap().entrySet()) {
+    for (Map.Entry<String, Collection<ApprovalCopier.Result.PatchSetApprovalData>>
+        approvalsByLabelEntry : approvalsByLabel.asMap().entrySet()) {
       String label = approvalsByLabelEntry.getKey();
-      Collection<PatchSetApproval> approvalsForSameLabel = approvalsByLabelEntry.getValue();
+      Collection<ApprovalCopier.Result.PatchSetApprovalData> approvalsForSameLabel =
+          approvalsByLabelEntry.getValue();
 
-      message.append("* ");
       if (!labelTypes.byLabel(label).isPresent()) {
         message
+            .append("* ")
             .append(formatApprovalsAsLabelVotesList(approvalsForSameLabel))
             .append(" (label type is missing)\n");
         continue;
@@ -547,22 +566,65 @@
 
       LabelType labelType = labelTypes.byLabel(label).get();
       if (!labelType.getCopyCondition().isPresent()) {
-        message.append(formatApprovalsAsLabelVotesList(approvalsForSameLabel)).append("\n");
+        message
+            .append("* ")
+            .append(formatApprovalsAsLabelVotesList(approvalsForSameLabel))
+            .append("\n");
         continue;
       }
 
-      message
-          .append(
-              formatApprovalsWithCopyCondition(
-                  approvalsForSameLabel, labelType.getCopyCondition().get()))
-          .append("\n");
+      // Group the approvals that have the same label by the passing atoms. If approvals have the
+      // same label, but have different passing atoms, we need to list them in separate lines
+      // (because in each line we will highlight different passing atoms that matched). Approvals
+      // with the same label and the same passing atoms are formatted as a single line.
+      ImmutableListMultimap<ImmutableSet<String>, ApprovalCopier.Result.PatchSetApprovalData>
+          approvalsForSameLabelByPassingAndFailingAtoms =
+              Multimaps.index(
+                  approvalsForSameLabel, ApprovalCopier.Result.PatchSetApprovalData::passingAtoms);
+
+      // Approvals with the same label that have the same passing atoms should have the same failing
+      // atoms (since the label is the same they have the same copy condition).
+      approvalsForSameLabelByPassingAndFailingAtoms
+          .asMap()
+          .values()
+          .forEach(
+              approvalsForSameLabelAndSamePassingAtoms ->
+                  checkThatPropertyIsTheSameForAllApprovals(
+                      approvalsForSameLabelAndSamePassingAtoms,
+                      "failing atoms",
+                      approvalData -> approvalData.failingAtoms()));
+
+      // The order in which we add lines for approvals with the same label but different passing
+      // atoms needs to be deterministic for tests. Just sort them by the string representation of
+      // the passing atoms.
+      for (Collection<ApprovalCopier.Result.PatchSetApprovalData>
+          approvalsForSameLabelWithSamePassingAndFailingAtoms :
+              approvalsForSameLabelByPassingAndFailingAtoms.asMap().entrySet().stream()
+                  .sorted(
+                      comparing(
+                          (Map.Entry<
+                                      ImmutableSet<String>,
+                                      Collection<ApprovalCopier.Result.PatchSetApprovalData>>
+                                  e) -> e.getKey().toString()))
+                  .map(Map.Entry::getValue)
+                  .collect(toImmutableList())) {
+        message
+            .append("* ")
+            .append(
+                formatApprovalsWithCopyCondition(
+                    approvalsForSameLabelWithSamePassingAndFailingAtoms,
+                    labelType.getCopyCondition().get()))
+            .append("\n");
+      }
     }
 
     return message.toString();
   }
 
   /**
-   * Formats the given approvals of the same label with the given copy condition.
+   * Formats the given approvals with the given copy condition.
+   *
+   * <p>The given approvals must have the same label and the same passing and failing atoms.
    *
    * <p>E.g.: {Code-Review+1, Code-Review+2 (copy condition: "is:MIN")}
    *
@@ -582,12 +644,29 @@
    *       "is:FOO")}
    * </ul>
    *
-   * @param approvalsForSameLabel the approvals that should be formatted, must be for the same label
+   * @param approvalsWithSameLabelAndSamePassingAndFailingAtoms the approvals that should be
+   *     formatted, must be for the same label
    * @param copyCondition the copy condition of the label
    * @return the formatted approvals
    */
   private String formatApprovalsWithCopyCondition(
-      Collection<PatchSetApproval> approvalsForSameLabel, String copyCondition) {
+      Collection<ApprovalCopier.Result.PatchSetApprovalData>
+          approvalsWithSameLabelAndSamePassingAndFailingAtoms,
+      String copyCondition) {
+    // Check that all given approvals have the same label and the same passing and failing atoms.
+    checkThatPropertyIsTheSameForAllApprovals(
+        approvalsWithSameLabelAndSamePassingAndFailingAtoms,
+        "label",
+        approvalData -> approvalData.patchSetApproval().label());
+    checkThatPropertyIsTheSameForAllApprovals(
+        approvalsWithSameLabelAndSamePassingAndFailingAtoms,
+        "passing atoms",
+        approvalData -> approvalData.passingAtoms());
+    checkThatPropertyIsTheSameForAllApprovals(
+        approvalsWithSameLabelAndSamePassingAndFailingAtoms,
+        "failing atoms",
+        approvalData -> approvalData.failingAtoms());
+
     StringBuilder message = new StringBuilder();
 
     boolean containsUserInPredicate;
@@ -595,7 +674,8 @@
       containsUserInPredicate = containsUserInPredicate(copyCondition);
     } catch (QueryParseException e) {
       logger.atWarning().withCause(e).log("Non-parsable query condition");
-      message.append(formatApprovalsAsLabelVotesList(approvalsForSameLabel));
+      message.append(
+          formatApprovalsAsLabelVotesList(approvalsWithSameLabelAndSamePassingAndFailingAtoms));
       message.append(String.format(" (non-parseable copy condition: \"%s\")", copyCondition));
       return message.toString();
     }
@@ -622,26 +702,35 @@
 
       // sort the approvals by their approvers name-email so that the approvers always appear in a
       // deterministic order
-      ImmutableList<PatchSetApproval> approvalsSortedByLabelVoteAndApprover =
-          approvalsForSameLabel.stream()
-              .sorted(
-                  comparing(
-                          (PatchSetApproval psa) ->
-                              LabelVote.create(psa.label(), psa.value()).format())
-                      .thenComparing(
-                          psa ->
-                              accountCache
-                                  .getEvenIfMissing(psa.accountId())
-                                  .account()
-                                  .getNameEmail(anonymousCowardName)))
-              .collect(toImmutableList());
+      ImmutableList<ApprovalCopier.Result.PatchSetApprovalData>
+          approvalsSortedByLabelVoteAndApprover =
+              approvalsWithSameLabelAndSamePassingAndFailingAtoms.stream()
+                  .sorted(
+                      comparing(
+                              (ApprovalCopier.Result.PatchSetApprovalData approvalData) ->
+                                  LabelVote.create(
+                                          approvalData.patchSetApproval().label(),
+                                          approvalData.patchSetApproval().value())
+                                      .format())
+                          .thenComparing(
+                              approvalData ->
+                                  accountCache
+                                      .getEvenIfMissing(approvalData.patchSetApproval().accountId())
+                                      .account()
+                                      .getNameEmail(anonymousCowardName)))
+                  .collect(toImmutableList());
 
       ImmutableListMultimap<LabelVote, Account.Id> approversByLabelVote =
           Multimaps.index(
                   approvalsSortedByLabelVoteAndApprover,
-                  psa -> LabelVote.create(psa.label(), psa.value()))
+                  approvalData ->
+                      LabelVote.create(
+                          approvalData.patchSetApproval().label(),
+                          approvalData.patchSetApproval().value()))
               .entries().stream()
-              .collect(toImmutableListMultimap(e -> e.getKey(), e -> e.getValue().accountId()));
+              .collect(
+                  toImmutableListMultimap(
+                      e -> e.getKey(), e -> e.getValue().patchSetApproval().accountId()));
       message.append(
           approversByLabelVote.asMap().entrySet().stream()
               .map(
@@ -651,12 +740,64 @@
               .collect(joining(", ")));
     } else {
       // copy condition doesn't contain a UserInPredicate
-      message.append(formatApprovalsAsLabelVotesList(approvalsForSameLabel));
+      message.append(
+          formatApprovalsAsLabelVotesList(approvalsWithSameLabelAndSamePassingAndFailingAtoms));
     }
-    message.append(String.format(" (copy condition: \"%s\")", copyCondition));
+    ImmutableSet<String> passingAtoms =
+        !approvalsWithSameLabelAndSamePassingAndFailingAtoms.isEmpty()
+            ? approvalsWithSameLabelAndSamePassingAndFailingAtoms.iterator().next().passingAtoms()
+            : ImmutableSet.of();
+    message.append(
+        String.format(
+            " (copy condition: \"%s\")",
+            formatCopyConditionAsMarkdown(copyCondition, passingAtoms)));
     return message.toString();
   }
 
+  /** Checks that all given approvals have the same value for a given property. */
+  private void checkThatPropertyIsTheSameForAllApprovals(
+      Collection<ApprovalCopier.Result.PatchSetApprovalData> approvals,
+      String propertyName,
+      Function<ApprovalCopier.Result.PatchSetApprovalData, ?> propertyExtractor) {
+    if (approvals.isEmpty()) {
+      return;
+    }
+
+    Object propertyOfFirstEntry = propertyExtractor.apply(approvals.iterator().next());
+    approvals.forEach(
+        approvalData ->
+            checkState(
+                propertyExtractor.apply(approvalData).equals(propertyOfFirstEntry),
+                "property %s of approval %s does not match, expected value: %s",
+                propertyName,
+                approvalData,
+                propertyOfFirstEntry));
+  }
+
+  /**
+   * Formats the given copy condition as a Markdown string.
+   *
+   * <p>Passing atoms are formatted as bold.
+   *
+   * @param copyCondition the copy condition that should be formatted
+   * @param passingAtoms atoms of the copy conditions which are passing/matching
+   * @return the formatted copy condition as a Markdown string
+   */
+  private String formatCopyConditionAsMarkdown(
+      String copyCondition, ImmutableSet<String> passingAtoms) {
+    StringBuilder formattedCopyCondition = new StringBuilder();
+    StringTokenizer tokenizer = new StringTokenizer(copyCondition, " ()", /* returnDelims= */ true);
+    while (tokenizer.hasMoreTokens()) {
+      String token = tokenizer.nextToken();
+      if (passingAtoms.contains(token)) {
+        formattedCopyCondition.append("**" + token.replace("*", "\\*") + "**");
+      } else {
+        formattedCopyCondition.append(token);
+      }
+    }
+    return formattedCopyCondition.toString();
+  }
+
   private boolean containsUserInPredicate(String copyCondition) throws QueryParseException {
     // Use a request context to run checks as an internal user with expanded visibility. This is
     // so that the output of the copy condition does not depend on who is running the current
@@ -679,8 +820,9 @@
    * @return the given approvals as a comma-separated list of label votes
    */
   private String formatApprovalsAsLabelVotesList(
-      Collection<PatchSetApproval> sortedApprovalsForSameLabel) {
+      Collection<ApprovalCopier.Result.PatchSetApprovalData> sortedApprovalsForSameLabel) {
     return sortedApprovalsForSameLabel.stream()
+        .map(ApprovalCopier.Result.PatchSetApprovalData::patchSetApproval)
         .map(psa -> LabelVote.create(psa.label(), psa.value()))
         .distinct()
         .map(LabelVote::format)
@@ -729,6 +871,7 @@
     return filterApprovals(byPatchSet(notes, psId), accountId);
   }
 
+  @Nullable
   public PatchSetApproval getSubmitter(ChangeNotes notes, PatchSet.Id c) {
     if (c == null) {
       return null;
@@ -741,6 +884,7 @@
     }
   }
 
+  @Nullable
   public static PatchSetApproval getSubmitter(PatchSet.Id c, Iterable<PatchSetApproval> approvals) {
     if (c == null) {
       return null;
diff --git a/java/com/google/gerrit/server/approval/testing/TestPatchSetApprovalUuidGenerator.java b/java/com/google/gerrit/server/approval/testing/TestPatchSetApprovalUuidGenerator.java
index 89727c7..676640d 100644
--- a/java/com/google/gerrit/server/approval/testing/TestPatchSetApprovalUuidGenerator.java
+++ b/java/com/google/gerrit/server/approval/testing/TestPatchSetApprovalUuidGenerator.java
@@ -20,6 +20,7 @@
 import com.google.gerrit.entities.PatchSetApproval.UUID;
 import com.google.gerrit.server.approval.PatchSetApprovalUuidGenerator;
 import java.time.Instant;
+import java.util.Locale;
 import javax.inject.Singleton;
 
 /**
@@ -44,6 +45,6 @@
                 value,
                 invocationCount)
             .replace("-", "_")
-            .toLowerCase());
+            .toLowerCase(Locale.US));
   }
 }
diff --git a/java/com/google/gerrit/server/args4j/ObjectIdHandler.java b/java/com/google/gerrit/server/args4j/ObjectIdHandler.java
index aa8a958..3cad7ce 100644
--- a/java/com/google/gerrit/server/args4j/ObjectIdHandler.java
+++ b/java/com/google/gerrit/server/args4j/ObjectIdHandler.java
@@ -16,9 +16,11 @@
 
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
+import org.eclipse.jgit.errors.InvalidObjectIdException;
 import org.eclipse.jgit.lib.ObjectId;
 import org.kohsuke.args4j.CmdLineException;
 import org.kohsuke.args4j.CmdLineParser;
+import org.kohsuke.args4j.NamedOptionDef;
 import org.kohsuke.args4j.OptionDef;
 import org.kohsuke.args4j.spi.OptionHandler;
 import org.kohsuke.args4j.spi.Parameters;
@@ -37,7 +39,14 @@
   @Override
   public int parseArguments(Parameters params) throws CmdLineException {
     final String n = params.getParameter(0);
-    setter.addValue(ObjectId.fromString(n));
+    try {
+      setter.addValue(ObjectId.fromString(n));
+    } catch (InvalidObjectIdException e) {
+      throw new CmdLineException(
+          owner,
+          String.format("expected SHA1 for option %s: %s", ((NamedOptionDef) option).name(), n),
+          e);
+    }
     return 1;
   }
 
diff --git a/java/com/google/gerrit/server/args4j/ProjectHandler.java b/java/com/google/gerrit/server/args4j/ProjectHandler.java
index a1e45e9..dc7fa24 100644
--- a/java/com/google/gerrit/server/args4j/ProjectHandler.java
+++ b/java/com/google/gerrit/server/args4j/ProjectHandler.java
@@ -18,8 +18,8 @@
 
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.ProjectUtil;
 import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.server.ProjectUtil;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.permissions.ProjectPermission;
diff --git a/java/com/google/gerrit/server/cache/CacheInfo.java b/java/com/google/gerrit/server/cache/CacheInfo.java
index 832ca04..76756c2 100644
--- a/java/com/google/gerrit/server/cache/CacheInfo.java
+++ b/java/com/google/gerrit/server/cache/CacheInfo.java
@@ -16,6 +16,7 @@
 
 import com.google.common.cache.Cache;
 import com.google.common.cache.CacheStats;
+import com.google.gerrit.common.Nullable;
 
 public class CacheInfo {
 
@@ -53,6 +54,7 @@
     }
   }
 
+  @Nullable
   private static String duration(double ns) {
     if (ns < 0.5) {
       return null;
@@ -118,6 +120,7 @@
       disk = percent(value, total);
     }
 
+    @Nullable
     private static Integer percent(long value, long total) {
       if (total <= 0) {
         return null;
diff --git a/java/com/google/gerrit/server/cache/PerThreadProjectCache.java b/java/com/google/gerrit/server/cache/PerThreadProjectCache.java
index 86f1d2d..8394343 100644
--- a/java/com/google/gerrit/server/cache/PerThreadProjectCache.java
+++ b/java/com/google/gerrit/server/cache/PerThreadProjectCache.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.cache;
 
 import com.google.common.collect.Maps;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.entities.Project;
 import java.util.Map;
 import java.util.function.Supplier;
@@ -39,6 +40,7 @@
 
   private PerThreadProjectCache() {}
 
+  @CanIgnoreReturnValue
   public static <T> T getOrCompute(PerThreadCache.Key<Project.NameKey> key, Supplier<T> loader) {
     PerThreadCache perThreadCache = PerThreadCache.get();
     if (perThreadCache != null) {
diff --git a/java/com/google/gerrit/server/cache/PerThreadRefDbCache.java b/java/com/google/gerrit/server/cache/PerThreadRefDbCache.java
new file mode 100644
index 0000000..82c2856b
--- /dev/null
+++ b/java/com/google/gerrit/server/cache/PerThreadRefDbCache.java
@@ -0,0 +1,44 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.cache;
+
+import java.io.File;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.function.Function;
+import org.eclipse.jgit.internal.storage.file.RefDirectory;
+import org.eclipse.jgit.lib.RefDatabase;
+
+/** A per request thread cache of RefDatabases by directory (Project). */
+public class PerThreadRefDbCache {
+  protected static final PerThreadCache.Key<PerThreadRefDbCache> REFDB_CACHE_KEY =
+      PerThreadCache.Key.create(PerThreadRefDbCache.class);
+
+  public static RefDatabase getRefDatabase(File path, RefDatabase refDb) {
+    if (PerThreadCache.get() != null) {
+      return PerThreadCache.get()
+          .get(REFDB_CACHE_KEY, PerThreadRefDbCache::new)
+          .computeIfAbsent(path, p -> ((RefDirectory) refDb).createSnapshottingRefDirectory());
+    }
+    return refDb;
+  }
+
+  protected final Map<File, RefDatabase> refDbByRefsDir = new HashMap<>();
+
+  public RefDatabase computeIfAbsent(
+      File path, Function<? super File, ? extends RefDatabase> mappingFunction) {
+    return refDbByRefsDir.computeIfAbsent(path, mappingFunction);
+  }
+}
diff --git a/java/com/google/gerrit/server/cache/PersistentCacheBaseFactory.java b/java/com/google/gerrit/server/cache/PersistentCacheBaseFactory.java
index ec527ba..e9b254b 100644
--- a/java/com/google/gerrit/server/cache/PersistentCacheBaseFactory.java
+++ b/java/com/google/gerrit/server/cache/PersistentCacheBaseFactory.java
@@ -18,6 +18,7 @@
 import com.google.common.cache.CacheLoader;
 import com.google.common.cache.LoadingCache;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SitePaths;
 import java.io.IOException;
@@ -80,6 +81,7 @@
     return !diskEnabled || diskLimit <= 0;
   }
 
+  @Nullable
   private static Path getCacheDir(SitePaths site, String name) {
     if (name == null) {
       return null;
diff --git a/java/com/google/gerrit/server/cache/h2/H2CacheDefProxy.java b/java/com/google/gerrit/server/cache/h2/H2CacheDefProxy.java
index aa62745..b744058 100644
--- a/java/com/google/gerrit/server/cache/h2/H2CacheDefProxy.java
+++ b/java/com/google/gerrit/server/cache/h2/H2CacheDefProxy.java
@@ -47,6 +47,7 @@
     return source.refreshAfterWrite();
   }
 
+  @Nullable
   @Override
   public Weigher<K, V> weigher() {
     Weigher<K, V> weigher = source.weigher();
diff --git a/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java b/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java
index 2213ce3..27a09ed 100644
--- a/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java
+++ b/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java
@@ -104,6 +104,7 @@
     this.mem = mem;
   }
 
+  @Nullable
   @Override
   public V getIfPresent(Object objKey) {
     if (!keyType.getRawType().isInstance(objKey)) {
@@ -427,6 +428,7 @@
       return b == null || b.mightContain(key);
     }
 
+    @Nullable
     private BloomFilter<K> buildBloomFilter() {
       SqlHandle c = null;
       try {
@@ -476,6 +478,7 @@
       }
     }
 
+    @Nullable
     ValueHolder<V> getIfPresent(K key) {
       SqlHandle c = null;
       try {
@@ -731,6 +734,7 @@
       }
     }
 
+    @Nullable
     private SqlHandle close(SqlHandle h) {
       if (h != null) {
         h.close();
@@ -790,6 +794,7 @@
       }
     }
 
+    @Nullable
     private PreparedStatement closeStatement(PreparedStatement ps) {
       if (ps != null) {
         try {
diff --git a/java/com/google/gerrit/server/cache/serialize/entities/StoredCommentLinkInfoSerializer.java b/java/com/google/gerrit/server/cache/serialize/entities/StoredCommentLinkInfoSerializer.java
index 5aa7a2a..5ac9ac4 100644
--- a/java/com/google/gerrit/server/cache/serialize/entities/StoredCommentLinkInfoSerializer.java
+++ b/java/com/google/gerrit/server/cache/serialize/entities/StoredCommentLinkInfoSerializer.java
@@ -30,7 +30,6 @@
         .setPrefix(emptyToNull(proto.getPrefix()))
         .setSuffix(emptyToNull(proto.getSuffix()))
         .setText(emptyToNull(proto.getText()))
-        .setHtml(emptyToNull(proto.getHtml()))
         .setEnabled(proto.getEnabled())
         .setOverrideOnly(proto.getOverrideOnly())
         .build();
@@ -44,7 +43,6 @@
         .setPrefix(nullToEmpty(autoValue.getPrefix()))
         .setSuffix(nullToEmpty(autoValue.getSuffix()))
         .setText(nullToEmpty(autoValue.getText()))
-        .setHtml(nullToEmpty(autoValue.getHtml()))
         .setEnabled(Optional.ofNullable(autoValue.getEnabled()).orElse(true))
         .setOverrideOnly(autoValue.getOverrideOnly())
         .build();
diff --git a/java/com/google/gerrit/server/change/ActionJson.java b/java/com/google/gerrit/server/change/ActionJson.java
index 63e2c08..e5a9534 100644
--- a/java/com/google/gerrit/server/change/ActionJson.java
+++ b/java/com/google/gerrit/server/change/ActionJson.java
@@ -106,6 +106,7 @@
     to.actions = toActionMap(rsrc, visitors, changeInfo, copy(visitors, to));
   }
 
+  @Nullable
   private ChangeInfo copy(List<ActionVisitor> visitors, ChangeInfo changeInfo) {
     if (visitors.isEmpty()) {
       return null;
@@ -122,7 +123,6 @@
         changeInfo.removedFromAttentionSet == null
             ? null
             : ImmutableMap.copyOf(changeInfo.removedFromAttentionSet);
-    copy.assignee = changeInfo.assignee;
     copy.hashtags = changeInfo.hashtags;
     copy.changeId = changeInfo.changeId;
     copy.submitType = changeInfo.submitType;
@@ -152,6 +152,7 @@
     return copy;
   }
 
+  @Nullable
   private RevisionInfo copy(List<ActionVisitor> visitors, RevisionInfo revisionInfo) {
     if (visitors.isEmpty()) {
       return null;
@@ -164,6 +165,7 @@
     copy.ref = revisionInfo.ref;
     copy.created = revisionInfo.created;
     copy.uploader = revisionInfo.uploader;
+    copy.realUploader = revisionInfo.realUploader;
     copy.fetch = revisionInfo.fetch;
     copy.kind = revisionInfo.kind;
     copy.description = revisionInfo.description;
diff --git a/java/com/google/gerrit/server/change/ArchiveFormatInternal.java b/java/com/google/gerrit/server/change/ArchiveFormatInternal.java
index f6e9ff9..0ed1f11 100644
--- a/java/com/google/gerrit/server/change/ArchiveFormatInternal.java
+++ b/java/com/google/gerrit/server/change/ArchiveFormatInternal.java
@@ -17,6 +17,7 @@
 import java.io.Closeable;
 import java.io.IOException;
 import java.io.OutputStream;
+import java.util.Locale;
 import org.apache.commons.compress.archivers.ArchiveOutputStream;
 import org.eclipse.jgit.api.ArchiveCommand;
 import org.eclipse.jgit.api.ArchiveCommand.Format;
@@ -47,7 +48,7 @@
   }
 
   public String getShortName() {
-    return name().toLowerCase();
+    return name().toLowerCase(Locale.US);
   }
 
   public String getMimeType() {
diff --git a/java/com/google/gerrit/server/change/BatchAbandon.java b/java/com/google/gerrit/server/change/BatchAbandon.java
index 2efa027..9070006 100644
--- a/java/com/google/gerrit/server/change/BatchAbandon.java
+++ b/java/com/google/gerrit/server/change/BatchAbandon.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.change;
 
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
+
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestApiException;
@@ -25,6 +27,7 @@
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.update.context.RefUpdateContext;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -68,24 +71,27 @@
       return;
     }
     AccountState accountState = user.isIdentifiedUser() ? user.asIdentifiedUser().state() : null;
-    try (BatchUpdate u = updateFactory.create(project, user, TimeUtil.now())) {
-      u.setNotify(notify);
-      for (ChangeData change : changes) {
-        if (!project.equals(change.project())) {
-          throw new ResourceConflictException(
-              String.format(
-                  "Project name \"%s\" doesn't match \"%s\"",
-                  change.project().get(), project.get()));
+    try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+      try (BatchUpdate u = updateFactory.create(project, user, TimeUtil.now())) {
+        u.setNotify(notify);
+        for (ChangeData change : changes) {
+          if (!project.equals(change.project())) {
+            throw new ResourceConflictException(
+                String.format(
+                    "Project name \"%s\" doesn't match \"%s\"",
+                    change.project().get(), project.get()));
+          }
+          u.addOp(change.getId(), abandonOpFactory.create(accountState, msgTxt));
+          u.addOp(
+              change.getId(),
+              storeSubmitRequirementsOpFactory.create(
+                  change.submitRequirements().values(), change));
         }
-        u.addOp(change.getId(), abandonOpFactory.create(accountState, msgTxt));
-        u.addOp(
-            change.getId(),
-            storeSubmitRequirementsOpFactory.create(change.submitRequirements().values(), change));
-      }
-      u.execute();
+        u.execute();
 
-      if (cfg.getCleanupAccountPatchReview()) {
-        cleanupAccountPatchReview(changes);
+        if (cfg.getCleanupAccountPatchReview()) {
+          cleanupAccountPatchReview(changes);
+        }
       }
     }
   }
diff --git a/java/com/google/gerrit/server/change/ChangeInserter.java b/java/com/google/gerrit/server/change/ChangeInserter.java
index edaca70..8773bb7 100644
--- a/java/com/google/gerrit/server/change/ChangeInserter.java
+++ b/java/com/google/gerrit/server/change/ChangeInserter.java
@@ -29,7 +29,10 @@
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Streams;
 import com.google.common.flogger.FluentLogger;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.BooleanProjectConfig;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.LabelType;
@@ -242,32 +245,38 @@
     return change;
   }
 
+  @CanIgnoreReturnValue
   public ChangeInserter setTopic(String topic) {
     checkState(change == null, "setTopic(String) only valid before creating change");
     this.topic = topic;
     return this;
   }
 
+  @CanIgnoreReturnValue
   public ChangeInserter setCherryPickOf(PatchSet.Id cherryPickOf) {
     this.cherryPickOf = cherryPickOf;
     return this;
   }
 
+  @CanIgnoreReturnValue
   public ChangeInserter setMessage(String message) {
     this.message = message;
     return this;
   }
 
+  @CanIgnoreReturnValue
   public ChangeInserter setPatchSetDescription(String patchSetDescription) {
     this.patchSetDescription = patchSetDescription;
     return this;
   }
 
+  @CanIgnoreReturnValue
   public ChangeInserter setValidate(boolean validate) {
     this.validate = validate;
     return this;
   }
 
+  @CanIgnoreReturnValue
   public ChangeInserter setReviewersAndCcs(
       Iterable<Account.Id> reviewers, Iterable<Account.Id> ccs) {
     return setReviewersAndCcsAsStrings(
@@ -275,35 +284,57 @@
         Iterables.transform(ccs, Account.Id::toString));
   }
 
+  @CanIgnoreReturnValue
+  public ChangeInserter setReviewersAndCcsIgnoreVisibility(
+      Iterable<Account.Id> reviewers, Iterable<Account.Id> ccs) {
+    return setReviewersAndCcsAsStrings(
+        Iterables.transform(reviewers, Account.Id::toString),
+        Iterables.transform(ccs, Account.Id::toString),
+        /* skipVisibilityCheck= */ true);
+  }
+
+  @CanIgnoreReturnValue
   public ChangeInserter setReviewersAndCcsAsStrings(
       Iterable<String> reviewers, Iterable<String> ccs) {
+    return setReviewersAndCcsAsStrings(reviewers, ccs, /* skipVisibilityCheck= */ false);
+  }
+
+  @CanIgnoreReturnValue
+  private ChangeInserter setReviewersAndCcsAsStrings(
+      Iterable<String> reviewers, Iterable<String> ccs, boolean skipVisibilityCheck) {
     reviewerInputs =
         Streams.concat(
                 Streams.stream(reviewers)
                     .distinct()
-                    .map(id -> newReviewerInput(id, ReviewerState.REVIEWER)),
-                Streams.stream(ccs).distinct().map(id -> newReviewerInput(id, ReviewerState.CC)))
+                    .map(id -> newReviewerInput(id, ReviewerState.REVIEWER, skipVisibilityCheck)),
+                Streams.stream(ccs)
+                    .distinct()
+                    .map(id -> newReviewerInput(id, ReviewerState.CC, skipVisibilityCheck)))
             .collect(toImmutableList());
     return this;
   }
 
+  @CanIgnoreReturnValue
   public ChangeInserter setPrivate(boolean isPrivate) {
     checkState(change == null, "setPrivate(boolean) only valid before creating change");
     this.isPrivate = isPrivate;
     return this;
   }
 
+  @CanIgnoreReturnValue
   public ChangeInserter setWorkInProgress(boolean workInProgress) {
     this.workInProgress = workInProgress;
     return this;
   }
 
+  @CanIgnoreReturnValue
   public ChangeInserter setStatus(Change.Status status) {
     checkState(change == null, "setStatus(Change.Status) only valid before creating change");
     this.status = status;
     return this;
   }
 
+  @CanIgnoreReturnValue
   public ChangeInserter setGroups(List<String> groups) {
     requireNonNull(groups, "groups may not be empty");
     checkState(patchSet == null, "setGroups(List<String>) only valid before creating change");
@@ -311,6 +342,7 @@
     return this;
   }
 
+  @CanIgnoreReturnValue
   public ChangeInserter setValidationOptions(
       ImmutableListMultimap<String, String> validationOptions) {
     requireNonNull(validationOptions, "validationOptions may not be null");
@@ -322,21 +354,25 @@
     return this;
   }
 
+  @CanIgnoreReturnValue
   public ChangeInserter setFireRevisionCreated(boolean fireRevisionCreated) {
     this.fireRevisionCreated = fireRevisionCreated;
     return this;
   }
 
+  @CanIgnoreReturnValue
   public ChangeInserter setSendMail(boolean sendMail) {
     this.sendMail = sendMail;
     return this;
   }
 
+  @CanIgnoreReturnValue
   public ChangeInserter setRequestScopePropagator(RequestScopePropagator r) {
     this.requestScopePropagator = r;
     return this;
   }
 
+  @CanIgnoreReturnValue
   public ChangeInserter setRevertOf(Change.Id revertOf) {
     this.revertOf = revertOf;
     return this;
@@ -351,6 +387,7 @@
     return patchSet;
   }
 
+  @CanIgnoreReturnValue
   public ChangeInserter setApprovals(Map<String, Short> approvals) {
     this.approvals = approvals;
     return this;
@@ -368,11 +405,13 @@
    * @param updateRef whether to update the ref during {@link #updateRepo(RepoContext)}.
    */
   @Deprecated
+  @CanIgnoreReturnValue
   public ChangeInserter setUpdateRef(boolean updateRef) {
     this.updateRef = updateRef;
     return this;
   }
 
+  @Nullable
   public String getChangeMessage() {
     if (message == null) {
       return null;
@@ -486,7 +525,7 @@
   public void postUpdate(PostUpdateContext ctx) throws Exception {
     reviewerAdditions.postUpdate(ctx);
     NotifyResolver.Result notify = ctx.getNotify(change.getId());
-    if (sendMail && notify.shouldNotify()) {
+    if (sendMail) {
       Runnable sender =
           new Runnable() {
             @Override
@@ -595,7 +634,8 @@
     }
   }
 
-  private static InternalReviewerInput newReviewerInput(String reviewer, ReviewerState state) {
+  private static InternalReviewerInput newReviewerInput(
+      String reviewer, ReviewerState state, boolean skipVisibilityCheck) {
     // Disable individual emails when adding reviewers, as all reviewers will receive the single
     // bulk new change email.
     InternalReviewerInput input =
@@ -606,12 +646,17 @@
     // certain commit footers: putting a nonexistent user in a footer should not cause an error. In
     // theory we could provide finer control to do this for some reviewers and not others, but it's
     // not worth complicating the ChangeInserter interface further at this time.
-    input.otherFailureBehavior = ReviewerModifier.FailureBehavior.IGNORE;
+    input.otherFailureBehavior = ReviewerModifier.FailureBehavior.IGNORE_EXCEPT_NOT_FOUND;
+
+    input.skipVisibilityCheck = skipVisibilityCheck;
 
     return input;
   }
 
   private ImmutableList<InternalReviewerInput> getReviewerInputs() {
+    if (projectState.is(BooleanProjectConfig.SKIP_ADDING_AUTHOR_AND_COMMITTER_AS_REVIEWERS)) {
+      return reviewerInputs;
+    }
     return Streams.concat(
             reviewerInputs.stream(),
             Streams.stream(
diff --git a/java/com/google/gerrit/server/change/ChangeJson.java b/java/com/google/gerrit/server/change/ChangeJson.java
index c7e0799..d143d90 100644
--- a/java/com/google/gerrit/server/change/ChangeJson.java
+++ b/java/com/google/gerrit/server/change/ChangeJson.java
@@ -46,6 +46,7 @@
 import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ImmutableSortedMap;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Maps;
@@ -149,7 +150,7 @@
   public static final SubmitRuleOptions SUBMIT_RULE_OPTIONS_STRICT =
       ChangeField.SUBMIT_RULE_OPTIONS_STRICT.toBuilder().build();
 
-  static final ImmutableSet<ListChangesOption> REQUIRE_LAZY_LOAD =
+  public static final ImmutableSet<ListChangesOption> REQUIRE_LAZY_LOAD =
       ImmutableSet.of(
           ALL_COMMITS,
           ALL_REVISIONS,
@@ -632,7 +633,6 @@
                       a -> a.account().get(),
                       a -> AttentionSetUtil.createAttentionSetInfo(a, accountLoader)));
     }
-    out.assignee = in.getAssignee() != null ? accountLoader.get(in.getAssignee()) : null;
     out.hashtags = cd.hashtags();
     out.changeId = in.getKey().get();
     if (in.isNew()) {
@@ -703,6 +703,7 @@
             !cd.change().isAbandoned()
                 ? labelsJson.permittedLabels(user.getAccountId(), cd)
                 : ImmutableMap.of();
+        out.removableLabels = labelsJson.removableLabels(accountLoader, user, cd);
       }
     }
 
@@ -947,11 +948,12 @@
       }
       src = Collections.singletonList(ps);
     }
-    Map<PatchSet.Id, PatchSet> map = Maps.newHashMapWithExpectedSize(src.size());
+    // Sort by patch set ID in increasing order to have a stable output.
+    ImmutableSortedMap.Builder<PatchSet.Id, PatchSet> map = ImmutableSortedMap.naturalOrder();
     for (PatchSet patchSet : src) {
       map.put(patchSet.id(), patchSet);
     }
-    return map;
+    return map.build();
   }
 
   /** Populate the 'starred' field. */
diff --git a/java/com/google/gerrit/server/change/ChangeResource.java b/java/com/google/gerrit/server/change/ChangeResource.java
index 919586e..c5c0be0 100644
--- a/java/com/google/gerrit/server/change/ChangeResource.java
+++ b/java/com/google/gerrit/server/change/ChangeResource.java
@@ -177,9 +177,6 @@
     byte[] buf = new byte[20];
     Set<Account.Id> accounts = new HashSet<>();
     accounts.add(getChange().getOwner());
-    if (getChange().getAssignee() != null) {
-      accounts.add(getChange().getAssignee());
-    }
     try {
       patchSetUtil.byChange(getNotes()).stream().map(PatchSet::uploader).forEach(accounts::add);
 
diff --git a/java/com/google/gerrit/server/change/ConsistencyChecker.java b/java/com/google/gerrit/server/change/ConsistencyChecker.java
index 0775647..063903b 100644
--- a/java/com/google/gerrit/server/change/ConsistencyChecker.java
+++ b/java/com/google/gerrit/server/change/ConsistencyChecker.java
@@ -18,6 +18,7 @@
 import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.gerrit.entities.RefNames.REFS_CHANGES;
 import static com.google.gerrit.server.ChangeUtil.PS_ID_ORDER;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
 import static java.util.Comparator.comparing;
 import static java.util.Objects.requireNonNull;
 
@@ -57,6 +58,7 @@
 import com.google.gerrit.server.update.RepoContext;
 import com.google.gerrit.server.update.RetryHelper;
 import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.update.context.RefUpdateContext;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -174,29 +176,31 @@
   public Result check(ChangeNotes notes, @Nullable FixInput f) {
     requireNonNull(notes);
     try {
-      return retryHelper
-          .changeUpdate(
-              "checkChangeConsistency",
-              buf -> {
-                try {
-                  reset();
-                  this.updateFactory = buf;
-                  this.notes = notes;
-                  fix = f;
-                  checkImpl();
-                  return result();
-                } finally {
-                  if (rw != null) {
-                    rw.getObjectReader().close();
-                    rw.close();
-                    oi.close();
+      try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+        return retryHelper
+            .changeUpdate(
+                "checkChangeConsistency",
+                buf -> {
+                  try {
+                    reset();
+                    this.updateFactory = buf;
+                    this.notes = notes;
+                    fix = f;
+                    checkImpl();
+                    return result();
+                  } finally {
+                    if (rw != null) {
+                      rw.getObjectReader().close();
+                      rw.close();
+                      oi.close();
+                    }
+                    if (repo != null) {
+                      repo.close();
+                    }
                   }
-                  if (repo != null) {
-                    repo.close();
-                  }
-                }
-              })
-          .call();
+                })
+            .call();
+      }
     } catch (RestApiException e) {
       return logAndReturnOneProblem(e, notes, "Error checking change: " + e.getMessage());
     } catch (UpdateException e) {
@@ -764,6 +768,7 @@
     return serverIdent.get();
   }
 
+  @Nullable
   private RevCommit parseCommit(ObjectId objId, String desc) {
     try {
       return rw.parseCommit(objId);
diff --git a/java/com/google/gerrit/server/change/DeleteReviewerByEmailOp.java b/java/com/google/gerrit/server/change/DeleteReviewerByEmailOp.java
index b512a2d..f3fd68e 100644
--- a/java/com/google/gerrit/server/change/DeleteReviewerByEmailOp.java
+++ b/java/com/google/gerrit/server/change/DeleteReviewerByEmailOp.java
@@ -76,9 +76,6 @@
     if (sendEmail) {
       try {
         NotifyResolver.Result notify = ctx.getNotify(change.getId());
-        if (!notify.shouldNotify()) {
-          return;
-        }
         DeleteReviewerSender emailSender =
             deleteReviewerSenderFactory.create(ctx.getProject(), change.getId());
         emailSender.setFrom(ctx.getAccountId());
diff --git a/java/com/google/gerrit/server/change/DeleteReviewerOp.java b/java/com/google/gerrit/server/change/DeleteReviewerOp.java
index 1199be5..fc07592 100644
--- a/java/com/google/gerrit/server/change/DeleteReviewerOp.java
+++ b/java/com/google/gerrit/server/change/DeleteReviewerOp.java
@@ -187,21 +187,19 @@
       if (input.notify == null
           && currChange.isWorkInProgress()
           && !oldApprovals.isEmpty()
-          && notify.handling().compareTo(NotifyHandling.OWNER) < 0) {
+          && notify.handling().equals(NotifyHandling.NONE)) {
         // Override NotifyHandling from the context to notify owner if votes were removed on a WIP
         // change.
         notify = notify.withHandling(NotifyHandling.OWNER);
       }
       try {
-        if (notify.shouldNotify()) {
-          emailReviewers(
-              ctx.getProject(),
-              currChange,
-              mailMessage,
-              Timestamp.from(ctx.getWhen()),
-              notify,
-              ctx.getRepoView());
-        }
+        emailReviewers(
+            ctx.getProject(),
+            currChange,
+            mailMessage,
+            Timestamp.from(ctx.getWhen()),
+            notify,
+            ctx.getRepoView());
       } catch (Exception err) {
         logger.atSevere().withCause(err).log(
             "Cannot email update for change %s", currChange.getId());
diff --git a/java/com/google/gerrit/server/change/EmailNewPatchSet.java b/java/com/google/gerrit/server/change/EmailNewPatchSet.java
index f6ae6a3..f67ce4a 100644
--- a/java/com/google/gerrit/server/change/EmailNewPatchSet.java
+++ b/java/com/google/gerrit/server/change/EmailNewPatchSet.java
@@ -16,6 +16,7 @@
 
 import com.google.common.collect.ImmutableSet;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
@@ -52,7 +53,7 @@
     EmailNewPatchSet create(
         PostUpdateContext postUpdateContext,
         PatchSet patchSet,
-        String message,
+        @Nullable String message,
         ImmutableSet<PatchSetApproval> outdatedApprovals,
         @Assisted("reviewers") ImmutableSet<Account.Id> reviewers,
         @Assisted("extraCcs") ImmutableSet<Account.Id> extraCcs,
@@ -75,7 +76,7 @@
       MessageIdGenerator messageIdGenerator,
       @Assisted PostUpdateContext postUpdateContext,
       @Assisted PatchSet patchSet,
-      @Assisted String message,
+      @Nullable @Assisted String message,
       @Assisted ImmutableSet<PatchSetApproval> outdatedApprovals,
       @Assisted("reviewers") ImmutableSet<Account.Id> reviewers,
       @Assisted("extraCcs") ImmutableSet<Account.Id> extraCcs,
diff --git a/java/com/google/gerrit/server/change/FileInfoJsonImpl.java b/java/com/google/gerrit/server/change/FileInfoJsonImpl.java
index 44b4ded..d9c30d7 100644
--- a/java/com/google/gerrit/server/change/FileInfoJsonImpl.java
+++ b/java/com/google/gerrit/server/change/FileInfoJsonImpl.java
@@ -42,6 +42,7 @@
     this.diffs = diffOperations;
   }
 
+  @Nullable
   @Override
   public Map<String, FileInfo> getFileInfoMap(
       Change change, ObjectId objectId, @Nullable PatchSet base)
@@ -63,6 +64,7 @@
     }
   }
 
+  @Nullable
   @Override
   public Map<String, FileInfo> getFileInfoMap(
       Project.NameKey project, ObjectId objectId, int parent)
@@ -102,6 +104,14 @@
       fileInfo.oldPath = FilePathAdapter.getOldPath(fileDiff.oldPath(), fileDiff.changeType());
       fileInfo.sizeDelta = fileDiff.sizeDelta();
       fileInfo.size = fileDiff.size();
+      fileInfo.oldMode =
+          fileDiff.oldMode().isPresent() && !fileDiff.oldMode().get().equals(Patch.FileMode.MISSING)
+              ? fileDiff.oldMode().get().getMode()
+              : null;
+      fileInfo.newMode =
+          fileDiff.newMode().isPresent() && !fileDiff.newMode().get().equals(Patch.FileMode.MISSING)
+              ? fileDiff.newMode().get().getMode()
+              : null;
       if (fileDiff.patchType().get() == Patch.PatchType.BINARY) {
         fileInfo.binary = true;
       } else {
diff --git a/java/com/google/gerrit/server/change/GetRelatedChangesUtil.java b/java/com/google/gerrit/server/change/GetRelatedChangesUtil.java
index b1f9726..834a623 100644
--- a/java/com/google/gerrit/server/change/GetRelatedChangesUtil.java
+++ b/java/com/google/gerrit/server/change/GetRelatedChangesUtil.java
@@ -65,6 +65,33 @@
    */
   public List<RelatedChangesSorter.PatchSetData> getRelated(ChangeData changeData, PatchSet basePs)
       throws IOException, PermissionBackendException {
+    List<ChangeData> cds = getUnsortedRelated(changeData, basePs, false);
+    if (cds.isEmpty()) {
+      return Collections.emptyList();
+    }
+    return sorter.sort(cds, basePs);
+  }
+
+  /**
+   * Gets ancestor changes of a specific change revision.
+   *
+   * @param changeData the change of the inputted revision.
+   * @param basePs the revision that the method checks for related changes.
+   * @param alwaysIncludeOriginalChange whether to return the given change when no ancestors found.
+   * @return list of ancestor changes, sorted via {@link RelatedChangesSorter}
+   */
+  public List<RelatedChangesSorter.PatchSetData> getAncestors(
+      ChangeData changeData, PatchSet basePs, boolean alwaysIncludeOriginalChange)
+      throws IOException, PermissionBackendException {
+    List<ChangeData> cds = getUnsortedRelated(changeData, basePs, alwaysIncludeOriginalChange);
+    if (cds.isEmpty()) {
+      return Collections.emptyList();
+    }
+    return sorter.sortAncestors(cds, basePs);
+  }
+
+  private List<ChangeData> getUnsortedRelated(
+      ChangeData changeData, PatchSet basePs, boolean alwaysIncludeOriginalChange) {
     Set<String> groups = getAllGroups(changeData.patchSets());
     logger.atFine().log("groups = %s", groups);
     if (groups.isEmpty()) {
@@ -78,12 +105,10 @@
       return Collections.emptyList();
     }
     if (cds.size() == 1 && cds.get(0).getId().equals(changeData.getId())) {
-      return Collections.emptyList();
+      return alwaysIncludeOriginalChange ? cds : Collections.emptyList();
     }
 
-    cds = reloadChangeIfStale(cds, changeData, basePs);
-
-    return sorter.sort(cds, basePs);
+    return reloadChangeIfStale(cds, changeData, basePs);
   }
 
   private List<ChangeData> reloadChangeIfStale(
diff --git a/java/com/google/gerrit/server/change/LabelNormalizer.java b/java/com/google/gerrit/server/change/LabelNormalizer.java
index 79e2054..b1fcf48 100644
--- a/java/com/google/gerrit/server/change/LabelNormalizer.java
+++ b/java/com/google/gerrit/server/change/LabelNormalizer.java
@@ -21,6 +21,7 @@
 import com.google.auto.value.AutoValue;
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
 import com.google.common.collect.Streams;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.LabelType;
@@ -43,6 +44,16 @@
  * what labels are defined for the project. The label definition can change between the time a vote
  * is originally made and a later point, for example when a change is submitted. This class
  * normalizes old votes against current project configuration.
+ *
+ * <p>Normalizing a vote means making it compliant with the current label definition:
+ *
+ * <ul>
+ *   <li>If the voting value is greater than the max allowed value according to the label
+ *       definition, the voting value is changed to the max allowed value.
+ *   <li>If the voting value is lower than the min allowed value according to the label definition,
+ *       the voting value is changed to the min allowed value.
+ *   <li>If the label definition for a vote is missing, the vote is deleted.
+ * </ul>
  */
 @Singleton
 public class LabelNormalizer {
@@ -121,6 +132,20 @@
     return Result.create(unchanged, updated, deleted);
   }
 
+  /**
+   * Returns a copy of the given approval normalized to the defined ranges for the label type. If
+   * the approval is for an unknown label {@link Optional#empty()} is returned
+   *
+   * @param notes change notes containing the given approval
+   * @param approval approval that should be normalized
+   */
+  public Optional<PatchSetApproval> normalize(ChangeNotes notes, PatchSetApproval approval) {
+    Result result = normalize(notes, ImmutableSet.of(approval));
+    return Optional.ofNullable(
+        Iterables.getFirst(
+            result.unchanged(), Iterables.getFirst(result.updated(), /* defaultValue= */ null)));
+  }
+
   private PatchSetApproval applyTypeFloor(LabelType lt, PatchSetApproval a) {
     PatchSetApproval.Builder b = a.toBuilder();
     LabelValue atMin = lt.getMin();
diff --git a/java/com/google/gerrit/server/change/LabelsJson.java b/java/com/google/gerrit/server/change/LabelsJson.java
index 69a84dd8..5555ba6 100644
--- a/java/com/google/gerrit/server/change/LabelsJson.java
+++ b/java/com/google/gerrit/server/change/LabelsJson.java
@@ -36,15 +36,19 @@
 import com.google.gerrit.entities.LabelValue;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.SubmitRecord;
+import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ApprovalInfo;
 import com.google.gerrit.extensions.common.LabelInfo;
 import com.google.gerrit.extensions.common.VotingRangeInfo;
 import com.google.gerrit.server.ChangeUtil;
+import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.AccountLoader;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
 import com.google.gerrit.server.permissions.LabelPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.DeleteVoteControl;
+import com.google.gerrit.server.project.RemoveReviewerControl;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -69,10 +73,17 @@
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   private final PermissionBackend permissionBackend;
+  private final DeleteVoteControl deleteVoteControl;
+  private final RemoveReviewerControl removeReviewerControl;
 
   @Inject
-  LabelsJson(PermissionBackend permissionBackend) {
+  LabelsJson(
+      PermissionBackend permissionBackend,
+      DeleteVoteControl deleteVoteControl,
+      RemoveReviewerControl removeReviewerControl) {
     this.permissionBackend = permissionBackend;
+    this.deleteVoteControl = deleteVoteControl;
+    this.removeReviewerControl = removeReviewerControl;
   }
 
   /**
@@ -80,6 +91,7 @@
    * lazily populate accounts. Callers have to call {@link AccountLoader#fill()} afterwards to
    * populate all accounts in the returned {@link LabelInfo}s.
    */
+  @Nullable
   Map<String, LabelInfo> labelsFor(
       AccountLoader accountLoader, ChangeData cd, boolean standard, boolean detailed)
       throws PermissionBackendException {
@@ -132,6 +144,46 @@
     return permitted.asMap();
   }
 
+  /**
+   * Returns A map of all labels that the provided user has permission to remove.
+   *
+   * @param accountLoader to load the reviewers' data with.
+   * @param user a Gerrit user.
+   * @param cd {@link ChangeData} corresponding to a specific gerrit change.
+   * @return A Map of {@code labelName} -> {Map of {@code value} -> List of {@link AccountInfo}}
+   *     that the user can remove votes from.
+   */
+  Map<String, Map<String, List<AccountInfo>>> removableLabels(
+      AccountLoader accountLoader, CurrentUser user, ChangeData cd)
+      throws PermissionBackendException {
+    if (cd.change().isMerged()) {
+      return new HashMap<>();
+    }
+
+    Map<String, Map<String, List<AccountInfo>>> res = new HashMap<>();
+    LabelTypes labelTypes = cd.getLabelTypes();
+    for (PatchSetApproval approval : cd.currentApprovals()) {
+      Optional<LabelType> labelType = labelTypes.byLabel(approval.labelId());
+      if (!labelType.isPresent()) {
+        continue;
+      }
+      if (!(deleteVoteControl.testDeleteVotePermissions(user, cd, approval, labelType.get())
+          || removeReviewerControl.testRemoveReviewer(
+              cd, user, approval.accountId(), approval.value()))) {
+        continue;
+      }
+      if (!res.containsKey(approval.label())) {
+        res.put(approval.label(), new HashMap<>());
+      }
+      String labelValue = LabelValue.formatValue(approval.value());
+      if (!res.get(approval.label()).containsKey(labelValue)) {
+        res.get(approval.label()).put(labelValue, new ArrayList<>());
+      }
+      res.get(approval.label()).get(labelValue).add(accountLoader.get(approval.accountId()));
+    }
+    return res;
+  }
+
   private static void clearOnlyZerosEntries(SetMultimap<String, String> permitted) {
     List<String> toClear = Lists.newArrayListWithCapacity(permitted.keySet().size());
     for (Map.Entry<String, Collection<String>> e : permitted.asMap().entrySet()) {
@@ -216,10 +268,10 @@
     }
   }
 
-  private Map<String, Short> currentLabels(Account.Id accountId, ChangeData cd) {
+  private Map<String, Short> currentLabels(@Nullable Account.Id accountId, ChangeData cd) {
     Map<String, Short> result = new HashMap<>();
     for (PatchSetApproval psa : cd.currentApprovals()) {
-      if (psa.accountId().equals(accountId)) {
+      if (accountId == null || psa.accountId().equals(accountId)) {
         result.put(psa.label(), psa.value());
       }
     }
diff --git a/java/com/google/gerrit/server/change/NotifyResolver.java b/java/com/google/gerrit/server/change/NotifyResolver.java
index 27951ca..ff87bff 100644
--- a/java/com/google/gerrit/server/change/NotifyResolver.java
+++ b/java/com/google/gerrit/server/change/NotifyResolver.java
@@ -66,7 +66,7 @@
     }
 
     public boolean shouldNotify() {
-      return !accounts().isEmpty() || handling().compareTo(NotifyHandling.NONE) > 0;
+      return !accounts().isEmpty() || !handling().equals(NotifyHandling.NONE);
     }
   }
 
diff --git a/java/com/google/gerrit/server/change/PatchSetInserter.java b/java/com/google/gerrit/server/change/PatchSetInserter.java
index f7bec1c0..4a09f84 100644
--- a/java/com/google/gerrit/server/change/PatchSetInserter.java
+++ b/java/com/google/gerrit/server/change/PatchSetInserter.java
@@ -15,12 +15,14 @@
 package com.google.gerrit.server.change;
 
 import static com.google.common.base.Preconditions.checkState;
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.CC;
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
 import static java.util.Objects.requireNonNull;
 
 import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
@@ -285,7 +287,7 @@
         psUtil.insert(
             ctx.getRevWalk(), ctx.getUpdate(psId), psId, commitId, newGroups, null, description);
 
-    if (ctx.getNotify(change.getId()).handling() != NotifyHandling.NONE) {
+    if (!ctx.getNotify(change.getId()).handling().equals(NotifyHandling.NONE)) {
       oldReviewers = approvalsUtil.getReviewers(ctx.getNotes());
     }
 
@@ -360,17 +362,17 @@
   @Override
   public void postUpdate(PostUpdateContext ctx) {
     NotifyResolver.Result notify = ctx.getNotify(change.getId());
-    if (notify.shouldNotify() && sendEmail) {
-      requireNonNull(mailMessage);
-
+    if (sendEmail) {
       emailNewPatchSetFactory
           .create(
               ctx,
               patchSet,
               mailMessage,
-              approvalCopierResult.outdatedApprovals(),
-              oldReviewers.byState(REVIEWER),
-              oldReviewers.byState(CC),
+              approvalCopierResult.outdatedApprovals().stream()
+                  .map(ApprovalCopier.Result.PatchSetApprovalData::patchSetApproval)
+                  .collect(toImmutableSet()),
+              oldReviewers == null ? ImmutableSet.of() : oldReviewers.byState(REVIEWER),
+              oldReviewers == null ? ImmutableSet.of() : oldReviewers.byState(CC),
               changeKind,
               preUpdateMetaId)
           .sendAsync();
diff --git a/java/com/google/gerrit/server/change/RebaseChangeOp.java b/java/com/google/gerrit/server/change/RebaseChangeOp.java
index 4de21d6..ed87c76 100644
--- a/java/com/google/gerrit/server/change/RebaseChangeOp.java
+++ b/java/com/google/gerrit/server/change/RebaseChangeOp.java
@@ -22,7 +22,9 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.MergeConflictException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
@@ -30,6 +32,8 @@
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.IdentifiedUser.GenericFactory;
+import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.change.RebaseUtil.Base;
 import com.google.gerrit.server.git.CodeReviewCommit;
 import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
@@ -46,8 +50,9 @@
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.gerrit.server.update.RepoContext;
-import com.google.inject.Inject;
+import com.google.gerrit.server.util.AccountTemplateUtil;
 import com.google.inject.assistedinject.Assisted;
+import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
 import java.util.List;
 import java.util.Map;
@@ -73,19 +78,24 @@
 public class RebaseChangeOp implements BatchUpdateOp {
   public interface Factory {
     RebaseChangeOp create(ChangeNotes notes, PatchSet originalPatchSet, ObjectId baseCommitId);
+
+    RebaseChangeOp create(ChangeNotes notes, PatchSet originalPatchSet, Change.Id baseChangeId);
   }
 
   private final PatchSetInserter.Factory patchSetInserterFactory;
   private final MergeUtilFactory mergeUtilFactory;
   private final RebaseUtil rebaseUtil;
   private final ChangeResource.Factory changeResourceFactory;
+  private final ChangeNotes.Factory notesFactory;
 
   private final ChangeNotes notes;
   private final PatchSet originalPatchSet;
   private final IdentifiedUser.GenericFactory identifiedUserFactory;
   private final ProjectCache projectCache;
+  private final Project.NameKey projectName;
 
   private ObjectId baseCommitId;
+  private Change.Id baseChangeId;
   private PersonIdent committerIdent;
   private boolean fireRevisionCreated = true;
   private boolean validate = true;
@@ -104,26 +114,78 @@
   private PatchSetInserter patchSetInserter;
   private PatchSet rebasedPatchSet;
 
-  @Inject
+  @AssistedInject
   RebaseChangeOp(
       PatchSetInserter.Factory patchSetInserterFactory,
       MergeUtilFactory mergeUtilFactory,
       RebaseUtil rebaseUtil,
       ChangeResource.Factory changeResourceFactory,
-      IdentifiedUser.GenericFactory identifiedUserFactory,
+      ChangeNotes.Factory notesFactory,
+      GenericFactory identifiedUserFactory,
       ProjectCache projectCache,
       @Assisted ChangeNotes notes,
       @Assisted PatchSet originalPatchSet,
       @Assisted ObjectId baseCommitId) {
+    this(
+        patchSetInserterFactory,
+        mergeUtilFactory,
+        rebaseUtil,
+        changeResourceFactory,
+        notesFactory,
+        identifiedUserFactory,
+        projectCache,
+        notes,
+        originalPatchSet);
+    this.baseCommitId = baseCommitId;
+    this.baseChangeId = null;
+  }
+
+  @AssistedInject
+  RebaseChangeOp(
+      PatchSetInserter.Factory patchSetInserterFactory,
+      MergeUtilFactory mergeUtilFactory,
+      RebaseUtil rebaseUtil,
+      ChangeResource.Factory changeResourceFactory,
+      ChangeNotes.Factory notesFactory,
+      GenericFactory identifiedUserFactory,
+      ProjectCache projectCache,
+      @Assisted ChangeNotes notes,
+      @Assisted PatchSet originalPatchSet,
+      @Assisted Change.Id baseChangeId) {
+    this(
+        patchSetInserterFactory,
+        mergeUtilFactory,
+        rebaseUtil,
+        changeResourceFactory,
+        notesFactory,
+        identifiedUserFactory,
+        projectCache,
+        notes,
+        originalPatchSet);
+    this.baseChangeId = baseChangeId;
+    this.baseCommitId = null;
+  }
+
+  private RebaseChangeOp(
+      PatchSetInserter.Factory patchSetInserterFactory,
+      MergeUtilFactory mergeUtilFactory,
+      RebaseUtil rebaseUtil,
+      ChangeResource.Factory changeResourceFactory,
+      ChangeNotes.Factory notesFactory,
+      GenericFactory identifiedUserFactory,
+      ProjectCache projectCache,
+      ChangeNotes notes,
+      PatchSet originalPatchSet) {
     this.patchSetInserterFactory = patchSetInserterFactory;
     this.mergeUtilFactory = mergeUtilFactory;
     this.rebaseUtil = rebaseUtil;
     this.changeResourceFactory = changeResourceFactory;
+    this.notesFactory = notesFactory;
     this.identifiedUserFactory = identifiedUserFactory;
     this.projectCache = projectCache;
     this.notes = notes;
+    this.projectName = notes.getProjectName();
     this.originalPatchSet = originalPatchSet;
-    this.baseCommitId = baseCommitId;
   }
 
   public RebaseChangeOp setCommitterIdent(PersonIdent committerIdent) {
@@ -204,14 +266,23 @@
 
   @Override
   public void updateRepo(RepoContext ctx)
-      throws MergeConflictException, InvalidChangeOperationException, RestApiException, IOException,
-          NoSuchChangeException, PermissionBackendException {
+      throws InvalidChangeOperationException, RestApiException, IOException, NoSuchChangeException,
+          PermissionBackendException {
     // Ok that originalPatchSet was not read in a transaction, since we just
     // need its revision.
     RevWalk rw = ctx.getRevWalk();
     RevCommit original = rw.parseCommit(originalPatchSet.commitId());
     rw.parseBody(original);
-    RevCommit baseCommit = rw.parseCommit(baseCommitId);
+    RevCommit baseCommit;
+    if (baseCommitId != null && baseChangeId == null) {
+      baseCommit = rw.parseCommit(baseCommitId);
+    } else if (baseChangeId != null) {
+      baseCommit =
+          PatchSetUtil.getCurrentRevCommitIncludingPending(ctx, notesFactory, baseChangeId);
+    } else {
+      throw new IllegalStateException(
+          "Exactly one of base commit and base change must be provided.");
+    }
     CurrentUser changeOwner = identifiedUserFactory.create(notes.getChange().getOwner());
 
     String newCommitMessage;
@@ -224,12 +295,12 @@
       newCommitMessage = original.getFullMessage();
     }
 
-    rebasedCommit = rebaseCommit(ctx, original, baseCommit, newCommitMessage);
+    rebasedCommit = rebaseCommit(ctx, original, baseCommit, newCommitMessage, notes.getChangeId());
     Base base =
         rebaseUtil.parseBase(
             new RevisionResource(
                 changeResourceFactory.create(notes, changeOwner), originalPatchSet),
-            baseCommitId.name());
+            baseCommit.getName());
 
     rebasedPatchSetId =
         ChangeUtil.nextPatchSetIdFromChangeRefs(
@@ -256,7 +327,8 @@
 
     if (postMessage) {
       patchSetInserter.setMessage(
-          messageForRebasedChange(rebasedPatchSetId, originalPatchSet.id(), rebasedCommit));
+          messageForRebasedChange(
+              ctx.getIdentifiedUser(), rebasedPatchSetId, originalPatchSet.id(), rebasedCommit));
     }
 
     if (base != null && !base.notes().getChange().isMerged()) {
@@ -274,13 +346,22 @@
   }
 
   private static String messageForRebasedChange(
-      PatchSet.Id rebasePatchSetId, PatchSet.Id originalPatchSetId, CodeReviewCommit commit) {
+      IdentifiedUser user,
+      PatchSet.Id rebasePatchSetId,
+      PatchSet.Id originalPatchSetId,
+      CodeReviewCommit commit) {
     StringBuilder stringBuilder =
         new StringBuilder(
             String.format(
                 "Patch Set %d: Patch Set %d was rebased",
                 rebasePatchSetId.get(), originalPatchSetId.get()));
 
+    if (user.isImpersonating()) {
+      stringBuilder.append(
+          String.format(
+              " on behalf of %s", AccountTemplateUtil.getAccountTemplate(user.getAccountId())));
+    }
+
     if (!commit.getFilesWithGitConflicts().isEmpty()) {
       stringBuilder.append("\n\nThe following files contain Git conflicts:\n");
       commit.getFilesWithGitConflicts().stream()
@@ -320,8 +401,7 @@
   }
 
   private MergeUtil newMergeUtil() {
-    ProjectState project =
-        projectCache.get(notes.getProjectName()).orElseThrow(illegalState(notes.getProjectName()));
+    ProjectState project = projectCache.get(projectName).orElseThrow(illegalState(projectName));
     return forceContentMerge
         ? mergeUtilFactory.create(project, true)
         : mergeUtilFactory.create(project);
@@ -338,7 +418,11 @@
    * @throws IOException the merge failed for another reason.
    */
   private CodeReviewCommit rebaseCommit(
-      RepoContext ctx, RevCommit original, ObjectId base, String commitMessage)
+      RepoContext ctx,
+      RevCommit original,
+      ObjectId base,
+      String commitMessage,
+      Change.Id originalChangeId)
       throws ResourceConflictException, IOException {
     RevCommit parentCommit = original.getParent(0);
 
@@ -372,8 +456,9 @@
 
       if (!allowConflicts || !(merger instanceof ResolveMerger)) {
         throw new MergeConflictException(
-            "The change could not be rebased due to a conflict during merge.\n\n"
-                + MergeUtil.createConflictMessage(conflicts));
+            String.format(
+                "Change %s could not be rebased due to a conflict during merge.\n\n%s",
+                originalChangeId.toString(), MergeUtil.createConflictMessage(conflicts)));
       }
 
       Map<String, MergeResult<? extends Sequence>> mergeResults =
@@ -413,7 +498,6 @@
               cb.getAuthor(), cb.getCommitter().getWhen(), cb.getCommitter().getTimeZone()));
     }
     ObjectId objectId = ctx.getInserter().insert(cb);
-    ctx.getInserter().flush();
     CodeReviewCommit commit = ((CodeReviewRevWalk) ctx.getRevWalk()).parseCommit(objectId);
     commit.setFilesWithGitConflicts(filesWithGitConflicts);
     return commit;
diff --git a/java/com/google/gerrit/server/change/RebaseUtil.java b/java/com/google/gerrit/server/change/RebaseUtil.java
index 2d36df2..56ab936 100644
--- a/java/com/google/gerrit/server/change/RebaseUtil.java
+++ b/java/com/google/gerrit/server/change/RebaseUtil.java
@@ -17,22 +17,39 @@
 import com.google.auto.value.AutoValue;
 import com.google.common.flogger.FluentLogger;
 import com.google.common.primitives.Ints;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.extensions.api.changes.RebaseInput;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.git.ObjectIds;
+import com.google.gerrit.server.ChangeUtil;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.permissions.ChangePermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.RefPermission;
+import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
+import com.google.gerrit.server.update.BatchUpdate;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import java.io.IOException;
 import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
@@ -42,24 +59,251 @@
 public class RebaseUtil {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
+  private final Provider<PersonIdent> serverIdent;
+  private final IdentifiedUser.GenericFactory userFactory;
+  private final PermissionBackend permissionBackend;
+  private final ChangeResource.Factory changeResourceFactory;
+  private final GitRepositoryManager repoManager;
   private final Provider<InternalChangeQuery> queryProvider;
   private final ChangeNotes.Factory notesFactory;
   private final PatchSetUtil psUtil;
+  private final RebaseChangeOp.Factory rebaseFactory;
 
   @Inject
   RebaseUtil(
+      @GerritPersonIdent Provider<PersonIdent> serverIdent,
+      IdentifiedUser.GenericFactory userFactory,
+      PermissionBackend permissionBackend,
+      ChangeResource.Factory changeResourceFactory,
+      GitRepositoryManager repoManager,
       Provider<InternalChangeQuery> queryProvider,
       ChangeNotes.Factory notesFactory,
-      PatchSetUtil psUtil) {
+      PatchSetUtil psUtil,
+      RebaseChangeOp.Factory rebaseFactory) {
+    this.serverIdent = serverIdent;
+    this.userFactory = userFactory;
+    this.permissionBackend = permissionBackend;
+    this.changeResourceFactory = changeResourceFactory;
+    this.repoManager = repoManager;
     this.queryProvider = queryProvider;
     this.notesFactory = notesFactory;
     this.psUtil = psUtil;
+    this.rebaseFactory = rebaseFactory;
+  }
+
+  /**
+   * Checks that the uploader has permissions to create a new patch set and creates a new {@link
+   * RevisionResource} that contains the uploader (aka the impersonated user) as the current user
+   * which can be used for {@link BatchUpdate} to do the rebase on behalf of the uploader.
+   *
+   * <p>The following permissions are required for the uploader:
+   *
+   * <ul>
+   *   <li>The {@code Read} permission that allows to see the change.
+   *   <li>The {@code Push} permission that allows upload.
+   *   <li>The {@code Add Patch Set} permission, required if the change is owned by another user
+   *       (change owners implicitly have this permission).
+   *   <li>The {@code Forge Author} permission if the patch set that is rebased has a forged author
+   *       (author != uploader).
+   *   <li>The {@code Forge Server} permission if the patch set that is rebased has the server
+   *       identity as the author.
+   * </ul>
+   *
+   * <p>Usually the uploader should have all these permission since they were already required for
+   * the original upload, but there is the edge case that the uploader had the permission when doing
+   * the original upload and then the permission was revoked.
+   *
+   * <p>Note that patch sets with a forged committer (committer != uploader) can be rebased on
+   * behalf of the uploader, even if the uploader doesn't have the {@code Forge Committer}
+   * permission. This is because on rebase on behalf of the uploader the uploader will become the
+   * committer of the new rebased patch set, hence for the rebased patch set the committer is no
+   * longer forged (committer == uploader) and hence the {@code Forge Committer} permission is not
+   * required.
+   *
+   * <p>Note that the {@code Rebase} permission is not required for the uploader since the {@code
+   * Rebase} permission is specifically about allowing a user to do a rebase via the web UI by
+   * clicking on the {@code REBASE} button and the uploader is not clicking on this button.
+   *
+   * <p>The permissions of the uploader are checked explicitly here so that we can return a {@code
+   * 409 Conflict} response with a proper error message if they are missing (the error message says
+   * that the permission is missing for the uploader). The normal code path also checks these
+   * permission but the exception thrown there would result in a {@code 403 Forbidden} response and
+   * the error message would wrongly look like the caller (i.e. the rebaser) is missing the
+   * permission.
+   *
+   * <p>Note that this method doesn't check permissions for the rebaser (aka the impersonating user
+   * aka the calling user). Callers should check the permissions for the rebaser before calling this
+   * method.
+   *
+   * @param rsrc the revision resource that should be rebased
+   * @param rebaseInput the request input containing options for the rebase
+   * @return revision resource that contains the uploader (aka the impersonated user) as the current
+   *     user which can be used for {@link BatchUpdate} to do the rebase on behalf of the uploader
+   */
+  public RevisionResource onBehalfOf(RevisionResource rsrc, RebaseInput rebaseInput)
+      throws IOException, PermissionBackendException, BadRequestException,
+          ResourceConflictException {
+    if (rebaseInput.allowConflicts) {
+      throw new BadRequestException(
+          "allow_conflicts and on_behalf_of_uploader are mutually exclusive");
+    }
+
+    if (rsrc.getPatchSet().id().get() != rsrc.getChange().currentPatchSetId().get()) {
+      throw new BadRequestException(
+          String.format(
+              "change %s: non-current patch set cannot be rebased on behalf of the uploader",
+              rsrc.getChange().getId()));
+    }
+
+    CurrentUser caller = rsrc.getUser();
+    Account.Id uploaderId = rsrc.getPatchSet().uploader();
+    IdentifiedUser uploader = userFactory.runAs(/*remotePeer= */ null, uploaderId, caller);
+    logger.atFine().log(
+        "%s is rebasing patch set %s of project %s on behalf of uploader %s",
+        caller.getLoggableName(),
+        rsrc.getPatchSet().id(),
+        rsrc.getProject(),
+        uploader.getLoggableName());
+
+    checkPermissionForUploader(
+        uploader,
+        rsrc.getNotes(),
+        ChangePermission.READ,
+        String.format(
+            "change %s: uploader %s cannot read change",
+            rsrc.getChange().getId(), uploader.getLoggableName()));
+    checkPermissionForUploader(
+        uploader,
+        rsrc.getNotes(),
+        ChangePermission.ADD_PATCH_SET,
+        String.format(
+            "change %s: uploader %s cannot add patch set",
+            rsrc.getChange().getId(), uploader.getLoggableName()));
+
+    try (Repository repo = repoManager.openRepository(rsrc.getProject())) {
+      RevCommit commit = repo.parseCommit(rsrc.getPatchSet().commitId());
+
+      if (!uploader.hasEmailAddress(commit.getAuthorIdent().getEmailAddress())) {
+        checkPermissionForUploader(
+            uploader,
+            rsrc.getNotes(),
+            RefPermission.FORGE_AUTHOR,
+            String.format(
+                "change %s: author of patch set %d is forged and the uploader %s cannot forge author",
+                rsrc.getChange().getId(),
+                rsrc.getPatchSet().id().get(),
+                uploader.getLoggableName()));
+
+        if (serverIdent.get().getEmailAddress().equals(commit.getAuthorIdent().getEmailAddress())) {
+          checkPermissionForUploader(
+              uploader,
+              rsrc.getNotes(),
+              RefPermission.FORGE_SERVER,
+              String.format(
+                  "change %s: author of patch set %d is the server identity and the uploader %s cannot forge"
+                      + " the server identity",
+                  rsrc.getChange().getId(),
+                  rsrc.getPatchSet().id().get(),
+                  uploader.getLoggableName()));
+        }
+      }
+    }
+
+    return new RevisionResource(
+        changeResourceFactory.create(rsrc.getNotes(), uploader), rsrc.getPatchSet());
+  }
+
+  private void checkPermissionForUploader(
+      IdentifiedUser uploader,
+      ChangeNotes changeNotes,
+      ChangePermission changePermission,
+      String errorMessage)
+      throws PermissionBackendException, ResourceConflictException {
+    try {
+      permissionBackend.user(uploader).change(changeNotes).check(changePermission);
+    } catch (AuthException e) {
+      throw new ResourceConflictException(errorMessage, e);
+    }
+  }
+
+  private void checkPermissionForUploader(
+      IdentifiedUser uploader,
+      ChangeNotes changeNotes,
+      RefPermission refPermission,
+      String errorMessage)
+      throws PermissionBackendException, ResourceConflictException {
+    try {
+      permissionBackend.user(uploader).ref(changeNotes.getChange().getDest()).check(refPermission);
+    } catch (AuthException e) {
+      throw new ResourceConflictException(errorMessage, e);
+    }
+  }
+
+  /**
+   * Checks whether the given change fulfills all preconditions to be rebased.
+   *
+   * <p>This method does not check whether the calling user is allowed to rebase the change.
+   */
+  public void verifyRebasePreconditions(RevWalk rw, ChangeNotes changeNotes, PatchSet patchSet)
+      throws ResourceConflictException, IOException {
+    // Not allowed to rebase if the current patch set is locked.
+    psUtil.checkPatchSetNotLocked(changeNotes);
+
+    Change change = changeNotes.getChange();
+    if (!change.isNew()) {
+      throw new ResourceConflictException(
+          String.format("Change %s is %s", change.getId(), ChangeUtil.status(change)));
+    }
+
+    if (!hasOneParent(rw, patchSet)) {
+      throw new ResourceConflictException(
+          String.format(
+              "Error rebasing %s. Cannot rebase %s",
+              change.getId(),
+              countParents(rw, patchSet) > 1 ? "merge commits" : "commit with no ancestor"));
+    }
+  }
+
+  public static boolean hasOneParent(RevWalk rw, PatchSet ps) throws IOException {
+    // Prevent rebase of exotic changes (merge commit, no ancestor).
+    return countParents(rw, ps) == 1;
+  }
+
+  private static int countParents(RevWalk rw, PatchSet ps) throws IOException {
+    RevCommit c = rw.parseCommit(ps.commitId());
+    return c.getParentCount();
+  }
+
+  private static boolean isMergedInto(RevWalk rw, PatchSet base, PatchSet tip) throws IOException {
+    ObjectId baseId = base.commitId();
+    ObjectId tipId = tip.commitId();
+    return rw.isMergedInto(rw.parseCommit(baseId), rw.parseCommit(tipId));
   }
 
   public boolean canRebase(PatchSet patchSet, BranchNameKey dest, Repository git, RevWalk rw) {
     try {
-      findBaseRevision(patchSet, dest, git, rw);
-      return true;
+      RevCommit commit = rw.parseCommit(patchSet.commitId());
+
+      if (commit.getParentCount() > 1) {
+        throw new UnprocessableEntityException("Cannot rebase a change with multiple parents.");
+      } else if (commit.getParentCount() == 0) {
+        throw new UnprocessableEntityException(
+            "Cannot rebase a change without any parents (is this the initial commit?).");
+      }
+
+      Ref destRef = git.getRefDatabase().exactRef(dest.branch());
+      if (destRef == null) {
+        throw new UnprocessableEntityException(
+            "The destination branch does not exist: " + dest.branch());
+      }
+
+      // Change can be rebased if its parent commit differs from the HEAD commit of the destination
+      // branch.
+      // It's possible that the change is part of a chain that is based on the HEAD commit of the
+      // destination branch and the chain cannot be rebased, but then the change can still be
+      // rebased onto the destination branch to break the relation to its parent change.
+      ObjectId parentId = commit.getParent(0);
+      return !destRef.getObjectId().equals(parentId);
     } catch (RestApiException e) {
       return false;
     } catch (StorageException | IOException e) {
@@ -71,6 +315,7 @@
 
   @AutoValue
   public abstract static class Base {
+    @Nullable
     private static Base create(ChangeNotes notes, PatchSet ps) {
       if (notes == null) {
         return null;
@@ -127,6 +372,100 @@
   }
 
   /**
+   * Parse or find the commit onto which a patch set should be rebased.
+   *
+   * <p>If a {@code rebaseInput.base} is provided, parse it. Otherwise, finds the latest patch set
+   * of the change corresponding to this commit's parent, or the destination branch tip in the case
+   * where the parent's change is merged.
+   *
+   * @param git the repository.
+   * @param rw the RevWalk.
+   * @param permissionBackend to check base reading permissions with.
+   * @param rsrc to find the base for
+   * @param rebaseInput to optionally parse the base from.
+   * @param verifyNeedsRebase whether to verify if the change base is not already up to date
+   * @return the commit onto which the patch set should be rebased.
+   * @throws RestApiException if rebase is not possible.
+   * @throws IOException if accessing the repository fails.
+   * @throws PermissionBackendException if the user don't have permissions to read the base change.
+   */
+  public ObjectId parseOrFindBaseRevision(
+      Repository git,
+      RevWalk rw,
+      PermissionBackend permissionBackend,
+      RevisionResource rsrc,
+      RebaseInput rebaseInput,
+      boolean verifyNeedsRebase)
+      throws RestApiException, IOException, PermissionBackendException {
+    Change change = rsrc.getChange();
+
+    if (rebaseInput == null || rebaseInput.base == null) {
+      return findBaseRevision(rsrc.getPatchSet(), change.getDest(), git, rw, verifyNeedsRebase);
+    }
+
+    String inputBase = rebaseInput.base.trim();
+
+    if (inputBase.isEmpty()) {
+      return getDestRefTip(git, change.getDest());
+    }
+
+    Base base;
+    try {
+      base = parseBase(rsrc, inputBase);
+    } catch (NoSuchChangeException e) {
+      throw new UnprocessableEntityException(
+          String.format("Base change not found: %s", inputBase), e);
+    }
+    if (base == null) {
+      throw new ResourceConflictException(
+          "base revision is missing from the destination branch: " + inputBase);
+    }
+    return getLatestRevisionForBaseChange(rw, permissionBackend, rsrc, base);
+  }
+
+  private ObjectId getDestRefTip(Repository git, BranchNameKey destRefKey)
+      throws ResourceConflictException, IOException {
+    // Remove existing dependency to other patch set.
+    Ref destRef = git.exactRef(destRefKey.branch());
+    if (destRef == null) {
+      throw new ResourceConflictException(
+          "can't rebase onto tip of branch " + destRefKey.branch() + "; branch doesn't exist");
+    }
+    return destRef.getObjectId();
+  }
+
+  private ObjectId getLatestRevisionForBaseChange(
+      RevWalk rw, PermissionBackend permissionBackend, RevisionResource childRsrc, Base base)
+      throws ResourceConflictException, AuthException, PermissionBackendException, IOException {
+
+    Change child = childRsrc.getChange();
+    PatchSet.Id baseId = base.patchSet().id();
+    if (child.getId().equals(baseId.changeId())) {
+      throw new ResourceConflictException(
+          String.format("cannot rebase change %s onto itself", childRsrc.getChange().getId()));
+    }
+
+    permissionBackend.user(childRsrc.getUser()).change(base.notes()).check(ChangePermission.READ);
+
+    Change baseChange = base.notes().getChange();
+    if (!baseChange.getProject().equals(child.getProject())) {
+      throw new ResourceConflictException(
+          "base change is in wrong project: " + baseChange.getProject());
+    } else if (!baseChange.getDest().equals(child.getDest())) {
+      throw new ResourceConflictException(
+          "base change is targeting wrong branch: " + baseChange.getDest());
+    } else if (baseChange.isAbandoned()) {
+      throw new ResourceConflictException("base change is abandoned: " + baseChange.getKey());
+    } else if (isMergedInto(rw, childRsrc.getPatchSet(), base.patchSet())) {
+      throw new ResourceConflictException(
+          "base change "
+              + baseChange.getKey()
+              + " is a descendant of the current change - recursion not allowed");
+    }
+    return base.patchSet().commitId();
+  }
+
+  /**
    * Find the commit onto which a patch set should be rebased.
    *
    * <p>This is defined as the latest patch set of the change corresponding to this commit's parent,
@@ -136,12 +475,17 @@
    * @param destBranch the destination branch.
    * @param git the repository.
    * @param rw the RevWalk.
+   * @param verifyNeedsRebase whether to verify if the change base is not already up to date
    * @return the commit onto which the patch set should be rebased.
    * @throws RestApiException if rebase is not possible.
    * @throws IOException if accessing the repository fails.
    */
   public ObjectId findBaseRevision(
-      PatchSet patchSet, BranchNameKey destBranch, Repository git, RevWalk rw)
+      PatchSet patchSet,
+      BranchNameKey destBranch,
+      Repository git,
+      RevWalk rw,
+      boolean verifyNeedsRebase)
       throws RestApiException, IOException {
     ObjectId baseId = null;
     RevCommit commit = rw.parseCommit(patchSet.commitId());
@@ -168,7 +512,7 @@
         }
 
         if (depChange.isNew()) {
-          if (depPatchSet.id().equals(depChange.currentPatchSetId())) {
+          if (verifyNeedsRebase && depPatchSet.id().equals(depChange.currentPatchSetId())) {
             throw new ResourceConflictException(
                 "Change is already based on the latest patch set of the dependent change.");
           }
@@ -187,10 +531,29 @@
             "The destination branch does not exist: " + destBranch.branch());
       }
       baseId = destRef.getObjectId();
-      if (baseId.equals(parentId)) {
+      if (verifyNeedsRebase && baseId.equals(parentId)) {
         throw new ResourceConflictException("Change is already up to date.");
       }
     }
     return baseId;
   }
+
+  public RebaseChangeOp getRebaseOp(RevisionResource revRsrc, RebaseInput input, ObjectId baseRev) {
+    return applyRebaseInputToOp(
+        rebaseFactory.create(revRsrc.getNotes(), revRsrc.getPatchSet(), baseRev), input);
+  }
+
+  public RebaseChangeOp getRebaseOp(
+      RevisionResource revRsrc, RebaseInput input, Change.Id baseChange) {
+    return applyRebaseInputToOp(
+        rebaseFactory.create(revRsrc.getNotes(), revRsrc.getPatchSet(), baseChange), input);
+  }
+
+  private RebaseChangeOp applyRebaseInputToOp(RebaseChangeOp op, RebaseInput input) {
+    return op.setForceContentMerge(true)
+        .setAllowConflicts(input.allowConflicts)
+        .setValidationOptions(
+            ValidationOptionsUtil.getValidateOptionsAsMultimap(input.validationOptions))
+        .setFireRevisionCreated(true);
+  }
 }
diff --git a/java/com/google/gerrit/server/change/RelatedChangesSorter.java b/java/com/google/gerrit/server/change/RelatedChangesSorter.java
index b6e3121..f4b1a83c 100644
--- a/java/com/google/gerrit/server/change/RelatedChangesSorter.java
+++ b/java/com/google/gerrit/server/change/RelatedChangesSorter.java
@@ -75,16 +75,7 @@
     checkArgument(!in.isEmpty(), "Input may not be empty");
     // Map of all patch sets, keyed by commit SHA-1.
     Map<ObjectId, PatchSetData> byId = collectById(in);
-    PatchSetData start = byId.get(startPs.commitId());
-    requireNonNull(
-        start,
-        () ->
-            String.format(
-                "commit %s of patch set %s not found in %s",
-                startPs.commitId().name(),
-                startPs.id(),
-                byId.entrySet().stream()
-                    .collect(toMap(e -> e.getKey().name(), e -> e.getValue().patchSet().id()))));
+    PatchSetData start = getCheckedPatchSetData(byId, startPs);
 
     // Map of patch set -> immediate parent.
     ListMultimap<PatchSetData, PatchSetData> parents =
@@ -120,6 +111,34 @@
     return result;
   }
 
+  public List<PatchSetData> sortAncestors(List<ChangeData> in, PatchSet startPs)
+      throws IOException, PermissionBackendException {
+    checkArgument(!in.isEmpty(), "Input may not be empty");
+    // Map of all patch sets, keyed by commit SHA-1.
+    Map<ObjectId, PatchSetData> byId = collectById(in);
+    PatchSetData start = getCheckedPatchSetData(byId, startPs);
+
+    // Map of patch set -> immediate parent.
+    ListMultimap<PatchSetData, PatchSetData> parents =
+        MultimapBuilder.hashKeys(in.size()).arrayListValues(3).build();
+
+    for (ChangeData cd : in) {
+      for (PatchSet ps : cd.patchSets()) {
+        PatchSetData thisPsd = requireNonNull(byId.get(ps.commitId()));
+
+        for (RevCommit p : thisPsd.commit().getParents()) {
+          PatchSetData parentPsd = byId.get(p);
+          if (parentPsd != null) {
+            parents.put(thisPsd, parentPsd);
+          }
+        }
+      }
+    }
+
+    Collection<PatchSetData> ancestors = walkAncestors(parents, start);
+    return List.copyOf(ancestors);
+  }
+
   private Map<ObjectId, PatchSetData> collectById(List<ChangeData> in) throws IOException {
     Project.NameKey project = in.get(0).change().getProject();
     Map<ObjectId, PatchSetData> result = Maps.newHashMapWithExpectedSize(in.size() * 3);
@@ -143,6 +162,19 @@
     return result;
   }
 
+  private PatchSetData getCheckedPatchSetData(Map<ObjectId, PatchSetData> byId, PatchSet ps) {
+    PatchSetData psData = byId.get(ps.commitId());
+    return requireNonNull(
+        psData,
+        () ->
+            String.format(
+                "commit %s of patch set %s not found in %s",
+                ps.commitId().name(),
+                ps.id(),
+                byId.entrySet().stream()
+                    .collect(toMap(e -> e.getKey().name(), e -> e.getValue().patchSet().id()))));
+  }
+
   private Collection<PatchSetData> walkAncestors(
       ListMultimap<PatchSetData, PatchSetData> parents, PatchSetData start)
       throws PermissionBackendException {
diff --git a/java/com/google/gerrit/server/change/ReviewerModifier.java b/java/com/google/gerrit/server/change/ReviewerModifier.java
index 9580565..b5e0181 100644
--- a/java/com/google/gerrit/server/change/ReviewerModifier.java
+++ b/java/com/google/gerrit/server/change/ReviewerModifier.java
@@ -94,9 +94,21 @@
   public static final int DEFAULT_MAX_REVIEWERS_WITHOUT_CHECK = 10;
   public static final int DEFAULT_MAX_REVIEWERS = 20;
 
+  /**
+   * Controls which failures should be ignored.
+   *
+   * <p>If a failure is ignored the operation succeeds, but the reviewer is not added. If not
+   * ignored a failure means that the operation fails.
+   */
   public enum FailureBehavior {
+    // All failures cause the operation to fail.
     FAIL,
-    IGNORE;
+
+    // Only not found failures cause the operation to fail, all other failures are ignored.
+    IGNORE_EXCEPT_NOT_FOUND,
+
+    // All failures are ignored.
+    IGNORE_ALL;
   }
 
   private enum FailureType {
@@ -113,6 +125,9 @@
      * resolving to an account/group/email.
      */
     public FailureBehavior otherFailureBehavior = FailureBehavior.FAIL;
+
+    /** Whether the visibility check for the reviewer account should be skipped. */
+    public boolean skipVisibilityCheck = false;
   }
 
   public static InternalReviewerInput newReviewerInput(
@@ -143,7 +158,7 @@
     in.reviewer = accountId.toString();
     in.state = CC;
     in.notify = notify;
-    in.otherFailureBehavior = FailureBehavior.IGNORE;
+    in.otherFailureBehavior = FailureBehavior.IGNORE_ALL;
     return Optional.of(in);
   }
 
@@ -262,7 +277,14 @@
     IdentifiedUser reviewerUser;
     boolean exactMatchFound = false;
     try {
-      reviewerUser = accountResolver.resolveIncludeInactive(input.reviewer).asUniqueUser();
+      if (ReviewerState.REMOVED.equals(input.state)
+          || (input instanceof InternalReviewerInput
+              && ((InternalReviewerInput) input).skipVisibilityCheck)) {
+        reviewerUser =
+            accountResolver.resolveIncludeInactiveIgnoreVisibility(input.reviewer).asUniqueUser();
+      } else {
+        reviewerUser = accountResolver.resolveIncludeInactive(input.reviewer).asUniqueUser();
+      }
       if (input.reviewer.equalsIgnoreCase(reviewerUser.getName())
           || input.reviewer.equals(String.valueOf(reviewerUser.getAccountId()))) {
         exactMatchFound = true;
@@ -577,7 +599,9 @@
           (input instanceof InternalReviewerInput)
               ? ((InternalReviewerInput) input).otherFailureBehavior
               : FailureBehavior.FAIL;
-      return failureType == FailureType.OTHER && behavior == FailureBehavior.IGNORE;
+      return behavior == FailureBehavior.IGNORE_ALL
+          || (failureType == FailureType.OTHER
+              && behavior == FailureBehavior.IGNORE_EXCEPT_NOT_FOUND);
     }
   }
 
diff --git a/java/com/google/gerrit/server/change/RevisionJson.java b/java/com/google/gerrit/server/change/RevisionJson.java
index bd9c52b..c4fd5be 100644
--- a/java/com/google/gerrit/server/change/RevisionJson.java
+++ b/java/com/google/gerrit/server/change/RevisionJson.java
@@ -289,6 +289,9 @@
     out.ref = in.refName();
     out.setCreated(in.createdOn());
     out.uploader = accountLoader.get(in.uploader());
+    if (!in.uploader().equals(in.realUploader())) {
+      out.realUploader = accountLoader.get(in.realUploader());
+    }
     out.fetch = makeFetchMap(cd, in);
     out.kind = changeKindCache.getChangeKind(rw, repo != null ? repo.getConfig() : null, cd, in);
     out.description = in.description().orElse(null);
diff --git a/java/com/google/gerrit/server/change/SetAssigneeOp.java b/java/com/google/gerrit/server/change/SetAssigneeOp.java
deleted file mode 100644
index fd3e972..0000000
--- a/java/com/google/gerrit/server/change/SetAssigneeOp.java
+++ /dev/null
@@ -1,140 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import static java.util.Objects.requireNonNull;
-
-import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.entities.Change;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.server.ChangeMessagesUtil;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.extensions.events.AssigneeChanged;
-import com.google.gerrit.server.mail.send.MessageIdGenerator;
-import com.google.gerrit.server.mail.send.SetAssigneeSender;
-import com.google.gerrit.server.notedb.ChangeUpdate;
-import com.google.gerrit.server.plugincontext.PluginSetContext;
-import com.google.gerrit.server.update.BatchUpdateOp;
-import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.PostUpdateContext;
-import com.google.gerrit.server.util.AccountTemplateUtil;
-import com.google.gerrit.server.validators.AssigneeValidationListener;
-import com.google.gerrit.server.validators.ValidationException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.assistedinject.Assisted;
-
-public class SetAssigneeOp implements BatchUpdateOp {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
-  public interface Factory {
-    SetAssigneeOp create(IdentifiedUser assignee);
-  }
-
-  private final ChangeMessagesUtil cmUtil;
-  private final PluginSetContext<AssigneeValidationListener> validationListeners;
-  private final IdentifiedUser newAssignee;
-  private final AssigneeChanged assigneeChanged;
-  private final SetAssigneeSender.Factory setAssigneeSenderFactory;
-  private final Provider<IdentifiedUser> user;
-  private final IdentifiedUser.GenericFactory userFactory;
-  private final MessageIdGenerator messageIdGenerator;
-
-  private Change change;
-  private IdentifiedUser oldAssignee;
-
-  @Inject
-  SetAssigneeOp(
-      ChangeMessagesUtil cmUtil,
-      PluginSetContext<AssigneeValidationListener> validationListeners,
-      AssigneeChanged assigneeChanged,
-      SetAssigneeSender.Factory setAssigneeSenderFactory,
-      Provider<IdentifiedUser> user,
-      IdentifiedUser.GenericFactory userFactory,
-      MessageIdGenerator messageIdGenerator,
-      @Assisted IdentifiedUser newAssignee) {
-    this.cmUtil = cmUtil;
-    this.validationListeners = validationListeners;
-    this.assigneeChanged = assigneeChanged;
-    this.setAssigneeSenderFactory = setAssigneeSenderFactory;
-    this.user = user;
-    this.userFactory = userFactory;
-    this.messageIdGenerator = messageIdGenerator;
-    this.newAssignee = requireNonNull(newAssignee, "assignee");
-  }
-
-  @Override
-  public boolean updateChange(ChangeContext ctx) throws RestApiException {
-    change = ctx.getChange();
-    if (newAssignee.getAccountId().equals(change.getAssignee())) {
-      return false;
-    }
-    try {
-      validationListeners.runEach(
-          l -> l.validateAssignee(change, newAssignee.getAccount()), ValidationException.class);
-    } catch (ValidationException e) {
-      throw new ResourceConflictException(e.getMessage(), e);
-    }
-
-    if (change.getAssignee() != null) {
-      oldAssignee = userFactory.create(change.getAssignee());
-    }
-
-    ChangeUpdate update = ctx.getUpdate(change.currentPatchSetId());
-    // notedb
-    update.setAssignee(newAssignee.getAccountId());
-    // reviewdb
-    change.setAssignee(newAssignee.getAccountId());
-    addMessage(ctx);
-    return true;
-  }
-
-  private void addMessage(ChangeContext ctx) {
-    StringBuilder msg = new StringBuilder();
-    msg.append("Assignee ");
-    if (oldAssignee == null) {
-      msg.append("added: ");
-      msg.append(AccountTemplateUtil.getAccountTemplate(newAssignee.getAccountId()));
-    } else {
-      msg.append("changed from: ");
-      msg.append(AccountTemplateUtil.getAccountTemplate(oldAssignee.getAccountId()));
-      msg.append(" to: ");
-      msg.append(AccountTemplateUtil.getAccountTemplate(newAssignee.getAccountId()));
-    }
-    cmUtil.setChangeMessage(ctx, msg.toString(), ChangeMessagesUtil.TAG_SET_ASSIGNEE);
-  }
-
-  @Override
-  public void postUpdate(PostUpdateContext ctx) {
-    try {
-      SetAssigneeSender emailSender =
-          setAssigneeSenderFactory.create(
-              change.getProject(), change.getId(), newAssignee.getAccountId());
-      emailSender.setFrom(user.get().getAccountId());
-      emailSender.setMessageId(
-          messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), change.currentPatchSetId()));
-      emailSender.send();
-    } catch (Exception err) {
-      logger.atSevere().withCause(err).log(
-          "Cannot send email to new assignee of change %s", change.getId());
-    }
-    assigneeChanged.fire(
-        ctx.getChangeData(change),
-        ctx.getAccount(),
-        oldAssignee != null ? oldAssignee.state() : null,
-        ctx.getWhen());
-  }
-}
diff --git a/java/com/google/gerrit/server/change/ValidationOptionsUtil.java b/java/com/google/gerrit/server/change/ValidationOptionsUtil.java
new file mode 100644
index 0000000..137239c
--- /dev/null
+++ b/java/com/google/gerrit/server/change/ValidationOptionsUtil.java
@@ -0,0 +1,38 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import com.google.common.collect.ImmutableListMultimap;
+import com.google.gerrit.common.Nullable;
+import java.util.Map;
+
+/** Utilities for validation options parsing. */
+public final class ValidationOptionsUtil {
+  public static ImmutableListMultimap<String, String> getValidateOptionsAsMultimap(
+      @Nullable Map<String, String> validationOptions) {
+    if (validationOptions == null) {
+      return ImmutableListMultimap.of();
+    }
+
+    ImmutableListMultimap.Builder<String, String> validationOptionsBuilder =
+        ImmutableListMultimap.builder();
+    validationOptions
+        .entrySet()
+        .forEach(e -> validationOptionsBuilder.put(e.getKey(), e.getValue()));
+    return validationOptionsBuilder.build();
+  }
+
+  private ValidationOptionsUtil() {}
+}
diff --git a/java/com/google/gerrit/server/change/WorkInProgressOp.java b/java/com/google/gerrit/server/change/WorkInProgressOp.java
index 04fd1c0..a0bf5b4 100644
--- a/java/com/google/gerrit/server/change/WorkInProgressOp.java
+++ b/java/com/google/gerrit/server/change/WorkInProgressOp.java
@@ -124,7 +124,8 @@
     stateChanged.fire(ctx.getChangeData(change), ps, ctx.getAccount(), ctx.getWhen());
     NotifyResolver.Result notify = ctx.getNotify(change.getId());
     if (workInProgress
-        || notify.handling().compareTo(NotifyHandling.OWNER_REVIEWERS) < 0
+        || notify.handling().equals(NotifyHandling.OWNER)
+        || notify.handling().equals(NotifyHandling.NONE)
         || !sendEmail) {
       return;
     }
diff --git a/java/com/google/gerrit/server/comment/CommentContextLoader.java b/java/com/google/gerrit/server/comment/CommentContextLoader.java
index 9a1710d..79e5312 100644
--- a/java/com/google/gerrit/server/comment/CommentContextLoader.java
+++ b/java/com/google/gerrit/server/comment/CommentContextLoader.java
@@ -225,6 +225,11 @@
 
   private static Optional<Range> getStartAndEndLines(ContextInput comment) {
     if (comment.range() != null) {
+      if (comment.range().endLine < comment.range().startLine) {
+        // Seems like comments, created in reply to robot comments sometimes have invalid ranges
+        // Fix here, otherwise the range is invalid and we throw an error later on.
+        return Optional.of(Range.create(comment.range().startLine, comment.range().startLine + 1));
+      }
       return Optional.of(Range.create(comment.range().startLine, comment.range().endLine + 1));
     } else if (comment.lineNumber() > 0) {
       return Optional.of(Range.create(comment.lineNumber(), comment.lineNumber() + 1));
diff --git a/java/com/google/gerrit/server/config/CapabilityConstants.java b/java/com/google/gerrit/server/config/CapabilityConstants.java
index 59819bb..1f799c6 100644
--- a/java/com/google/gerrit/server/config/CapabilityConstants.java
+++ b/java/com/google/gerrit/server/config/CapabilityConstants.java
@@ -45,4 +45,5 @@
   public String viewConnections;
   public String viewPlugins;
   public String viewQueue;
+  public String viewSecondaryEmails;
 }
diff --git a/java/com/google/gerrit/server/config/ConfigUpdatedEvent.java b/java/com/google/gerrit/server/config/ConfigUpdatedEvent.java
index 7fd075e..5e6a520 100644
--- a/java/com/google/gerrit/server/config/ConfigUpdatedEvent.java
+++ b/java/com/google/gerrit/server/config/ConfigUpdatedEvent.java
@@ -18,6 +18,7 @@
 import com.google.common.collect.Multimap;
 import java.util.Collections;
 import java.util.LinkedHashSet;
+import java.util.Locale;
 import java.util.Objects;
 import java.util.Set;
 import org.apache.commons.lang3.StringUtils;
@@ -139,7 +140,7 @@
 
     @Override
     public String toString() {
-      return StringUtils.capitalize(name().toLowerCase());
+      return StringUtils.capitalize(name().toLowerCase(Locale.US));
     }
   }
 
diff --git a/java/com/google/gerrit/server/config/DownloadConfig.java b/java/com/google/gerrit/server/config/DownloadConfig.java
index 85081e4..4fdbd4a 100644
--- a/java/com/google/gerrit/server/config/DownloadConfig.java
+++ b/java/com/google/gerrit/server/config/DownloadConfig.java
@@ -17,6 +17,7 @@
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.CoreDownloadSchemes;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo.DownloadCommand;
 import com.google.gerrit.server.change.ArchiveFormatInternal;
@@ -28,6 +29,7 @@
 import java.util.EnumSet;
 import java.util.HashSet;
 import java.util.List;
+import java.util.Locale;
 import java.util.Set;
 import org.eclipse.jgit.lib.Config;
 
@@ -97,9 +99,10 @@
     return list.size() == 1 && list.get(0) == null;
   }
 
+  @Nullable
   private static String toCoreScheme(String s) {
     try {
-      Field f = CoreDownloadSchemes.class.getField(s.toUpperCase());
+      Field f = CoreDownloadSchemes.class.getField(s.toUpperCase(Locale.US));
       int m = Modifier.PUBLIC | Modifier.STATIC | Modifier.FINAL;
       if ((f.getModifiers() & m) == m && f.getType() == String.class) {
         return (String) f.get(null);
diff --git a/java/com/google/gerrit/server/config/GerritGlobalModule.java b/java/com/google/gerrit/server/config/GerritGlobalModule.java
index 13024a2..4325ec4 100644
--- a/java/com/google/gerrit/server/config/GerritGlobalModule.java
+++ b/java/com/google/gerrit/server/config/GerritGlobalModule.java
@@ -36,7 +36,6 @@
 import com.google.gerrit.extensions.events.AccountActivationListener;
 import com.google.gerrit.extensions.events.AccountIndexedListener;
 import com.google.gerrit.extensions.events.AgreementSignupListener;
-import com.google.gerrit.extensions.events.AssigneeChangedListener;
 import com.google.gerrit.extensions.events.AttentionSetListener;
 import com.google.gerrit.extensions.events.ChangeAbandonedListener;
 import com.google.gerrit.extensions.events.ChangeDeletedListener;
@@ -191,16 +190,15 @@
 import com.google.gerrit.server.project.ProjectCacheImpl;
 import com.google.gerrit.server.project.ProjectNameLockManager;
 import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.project.PrologRulesWarningValidator;
 import com.google.gerrit.server.project.SubmitRequirementConfigValidator;
 import com.google.gerrit.server.project.SubmitRequirementsEvaluatorImpl;
 import com.google.gerrit.server.project.SubmitRuleEvaluator;
-import com.google.gerrit.server.query.FileEditsPredicate;
 import com.google.gerrit.server.query.approval.ApprovalModule;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeIsVisibleToPredicate;
 import com.google.gerrit.server.query.change.ChangeQueryBuilder;
 import com.google.gerrit.server.query.change.ConflictsCacheImpl;
-import com.google.gerrit.server.query.change.DistinctVotersPredicate;
 import com.google.gerrit.server.quota.QuotaEnforcer;
 import com.google.gerrit.server.restapi.change.OnPostReview;
 import com.google.gerrit.server.restapi.change.SuggestReviewers;
@@ -216,12 +214,14 @@
 import com.google.gerrit.server.submit.MergeSuperSetComputation;
 import com.google.gerrit.server.submit.SubmitStrategy;
 import com.google.gerrit.server.submit.SubscriptionGraph;
+import com.google.gerrit.server.submitrequirement.predicate.DistinctVotersPredicate;
+import com.google.gerrit.server.submitrequirement.predicate.FileEditsPredicate;
+import com.google.gerrit.server.submitrequirement.predicate.HasSubmoduleUpdatePredicate;
 import com.google.gerrit.server.tools.ToolsCatalog;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.util.IdGenerator;
 import com.google.gerrit.server.util.ThreadLocalRequestContext;
 import com.google.gerrit.server.validators.AccountActivationValidationListener;
-import com.google.gerrit.server.validators.AssigneeValidationListener;
 import com.google.gerrit.server.validators.GroupCreationValidationListener;
 import com.google.gerrit.server.validators.HashtagValidationListener;
 import com.google.gerrit.server.validators.OutgoingEmailValidationListener;
@@ -300,6 +300,7 @@
     factory(ChangeJson.AssistedFactory.class);
     factory(ChangeIsVisibleToPredicate.Factory.class);
     factory(DistinctVotersPredicate.Factory.class);
+    factory(HasSubmoduleUpdatePredicate.Factory.class);
     factory(DeadlineChecker.Factory.class);
     factory(EmailNewPatchSet.Factory.class);
     factory(MultiProgressMonitor.Factory.class);
@@ -359,7 +360,6 @@
     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);
     DynamicSet.setOf(binder(), CommentAddedListener.class);
@@ -403,6 +403,7 @@
     DynamicSet.setOf(binder(), CommitValidationListener.class);
     DynamicSet.bind(binder(), CommitValidationListener.class)
         .to(SubmitRequirementConfigValidator.class);
+    DynamicSet.bind(binder(), CommitValidationListener.class).to(PrologRulesWarningValidator.class);
     DynamicSet.setOf(binder(), CommentValidator.class);
     DynamicSet.setOf(binder(), ChangeMessageModifier.class);
     DynamicSet.setOf(binder(), RefOperationValidationListener.class);
@@ -440,7 +441,6 @@
     DynamicSet.setOf(binder(), AccountExternalIdCreator.class);
     DynamicSet.setOf(binder(), WebUiPlugin.class);
     DynamicItem.itemOf(binder(), AccountPatchReviewStore.class);
-    DynamicSet.setOf(binder(), AssigneeValidationListener.class);
     DynamicSet.setOf(binder(), ActionVisitor.class);
     DynamicItem.itemOf(binder(), MergeSuperSetComputation.class);
     DynamicItem.itemOf(binder(), ProjectNameLockManager.class);
diff --git a/java/com/google/gerrit/server/config/GitwebConfig.java b/java/com/google/gerrit/server/config/GitwebConfig.java
index 99bd62d..f8c0592 100644
--- a/java/com/google/gerrit/server/config/GitwebConfig.java
+++ b/java/com/google/gerrit/server/config/GitwebConfig.java
@@ -99,6 +99,7 @@
     return values.length > 0 && isNullOrEmpty(values[0]);
   }
 
+  @Nullable
   private static GitwebType typeFromConfig(Config cfg) {
     GitwebType defaultType = defaultType(cfg.getString("gitweb", null, "type"));
     if (defaultType == null) {
@@ -136,6 +137,7 @@
     return type;
   }
 
+  @Nullable
   private static GitwebType defaultType(String typeName) {
     GitwebType type = new GitwebType();
     switch (nullToEmpty(typeName)) {
@@ -283,6 +285,7 @@
       this.tag = parse(type.getTag());
     }
 
+    @Nullable
     @Override
     public WebLinkInfo getBranchWebLink(String projectName, String branchName) {
       if (branch != null) {
@@ -295,6 +298,7 @@
       return null;
     }
 
+    @Nullable
     @Override
     public WebLinkInfo getTagWebLink(String projectName, String tagName) {
       if (tag != null) {
@@ -304,6 +308,7 @@
       return null;
     }
 
+    @Nullable
     @Override
     public WebLinkInfo getFileHistoryWebLink(String projectName, String revision, String fileName) {
       if (fileHistory != null) {
@@ -317,6 +322,7 @@
       return null;
     }
 
+    @Nullable
     @Override
     public WebLinkInfo getFileWebLink(
         String projectName, String revision, String hash, String fileName) {
@@ -331,6 +337,7 @@
       return null;
     }
 
+    @Nullable
     @Override
     public WebLinkInfo getPatchSetWebLink(
         String projectName, String commit, String commitMessage, String branchName) {
@@ -359,6 +366,7 @@
       return getPatchSetWebLink(projectName, commit, commitMessage, branchName);
     }
 
+    @Nullable
     @Override
     public WebLinkInfo getProjectWeblink(String projectName) {
       if (project != null) {
@@ -375,9 +383,12 @@
     }
 
     private WebLinkInfo link(String rest) {
-      return new WebLinkInfo(type.getLinkName(), null, url + rest, null);
+      WebLinkInfo webLink = new WebLinkInfo(type.getLinkName(), null, url + rest);
+      webLink.tooltip = "Open in GitWeb";
+      return webLink;
     }
 
+    @Nullable
     private static ParameterizedString parse(String pattern) {
       if (!isNullOrEmpty(pattern)) {
         return new ParameterizedString(pattern);
diff --git a/java/com/google/gerrit/server/config/ProjectConfigEntry.java b/java/com/google/gerrit/server/config/ProjectConfigEntry.java
index c09988e3..e11d6aa 100644
--- a/java/com/google/gerrit/server/config/ProjectConfigEntry.java
+++ b/java/com/google/gerrit/server/config/ProjectConfigEntry.java
@@ -17,6 +17,7 @@
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
@@ -360,6 +361,7 @@
       }
     }
 
+    @Nullable
     private ProjectConfig parseConfig(Project.NameKey p, String idStr)
         throws IOException, ConfigInvalidException, RepositoryNotFoundException {
       ObjectId id = ObjectId.fromString(idStr);
@@ -382,14 +384,17 @@
     }
   }
 
+  @Nullable
   private static Boolean toBoolean(String value) {
     return value != null ? Boolean.parseBoolean(value) : null;
   }
 
+  @Nullable
   private static Integer toInt(String value) {
     return value != null ? Integer.parseInt(value) : null;
   }
 
+  @Nullable
   private static Long toLong(String value) {
     return value != null ? Long.parseLong(value) : null;
   }
diff --git a/java/com/google/gerrit/server/config/RepositoryConfig.java b/java/com/google/gerrit/server/config/RepositoryConfig.java
index f722321..d569c87 100644
--- a/java/com/google/gerrit/server/config/RepositoryConfig.java
+++ b/java/com/google/gerrit/server/config/RepositoryConfig.java
@@ -55,6 +55,7 @@
         cfg.getStringList(SECTION_NAME, findSubSection(project.get()), OWNER_GROUP_NAME));
   }
 
+  @Nullable
   public Path getBasePath(Project.NameKey project) {
     String basePath = cfg.getString(SECTION_NAME, findSubSection(project.get()), BASE_PATH_NAME);
     return basePath != null ? Paths.get(basePath) : null;
diff --git a/java/com/google/gerrit/server/config/SitePaths.java b/java/com/google/gerrit/server/config/SitePaths.java
index 5e268da..9f85857 100644
--- a/java/com/google/gerrit/server/config/SitePaths.java
+++ b/java/com/google/gerrit/server/config/SitePaths.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.config;
 
 import com.google.common.collect.Iterables;
+import com.google.gerrit.common.Nullable;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
@@ -29,7 +30,6 @@
   public static final String CSS_FILENAME = "GerritSite.css";
   public static final String HEADER_FILENAME = "GerritSiteHeader.html";
   public static final String FOOTER_FILENAME = "GerritSiteFooter.html";
-  public static final String THEME_FILENAME = "gerrit-theme.html";
   public static final String THEME_JS_FILENAME = "gerrit-theme.js";
 
   public final Path site_path;
@@ -69,8 +69,7 @@
   public final Path site_css;
   public final Path site_header;
   public final Path site_footer;
-  public final Path site_theme; // For PolyGerrit UI only.
-  public final Path site_theme_js; // For PolyGerrit UI only.
+  public final Path site_theme_js;
   public final Path site_gitweb;
 
   /** {@code true} if {@link #site_path} has not been initialized. */
@@ -118,9 +117,6 @@
     site_header = etc_dir.resolve(HEADER_FILENAME);
     site_footer = etc_dir.resolve(FOOTER_FILENAME);
     site_gitweb = etc_dir.resolve("gitweb_config.perl");
-
-    // For PolyGerrit UI.
-    site_theme = static_dir.resolve(THEME_FILENAME);
     site_theme_js = static_dir.resolve(THEME_JS_FILENAME);
 
     boolean isNew;
@@ -140,6 +136,7 @@
    * @param path the path string to resolve. May be null.
    * @return the resolved path; null if {@code path} was null or empty.
    */
+  @Nullable
   public Path resolve(String path) {
     if (path != null && !path.isEmpty()) {
       Path loc = site_path.resolve(path).normalize();
diff --git a/java/com/google/gerrit/server/data/ChangeAttribute.java b/java/com/google/gerrit/server/data/ChangeAttribute.java
index ec27c0c..cdc982f 100644
--- a/java/com/google/gerrit/server/data/ChangeAttribute.java
+++ b/java/com/google/gerrit/server/data/ChangeAttribute.java
@@ -32,7 +32,6 @@
   public int number;
   public String subject;
   public AccountAttribute owner;
-  public AccountAttribute assignee;
   public String url;
   public String commitMessage;
   public List<String> hashtags;
diff --git a/java/com/google/gerrit/server/documentation/MarkdownFormatter.java b/java/com/google/gerrit/server/documentation/MarkdownFormatter.java
index c2c0b05..0e911b9 100644
--- a/java/com/google/gerrit/server/documentation/MarkdownFormatter.java
+++ b/java/com/google/gerrit/server/documentation/MarkdownFormatter.java
@@ -21,6 +21,7 @@
 
 import com.google.common.base.Strings;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.vladsch.flexmark.ast.Heading;
 import com.vladsch.flexmark.ast.util.TextCollectingVisitor;
 import com.vladsch.flexmark.html.HtmlRenderer;
@@ -126,6 +127,7 @@
     return findTitle(parseMarkdown(md));
   }
 
+  @Nullable
   private String findTitle(Node root) {
     if (root instanceof Heading) {
       Heading h = (Heading) root;
diff --git a/java/com/google/gerrit/server/documentation/QueryDocumentationExecutor.java b/java/com/google/gerrit/server/documentation/QueryDocumentationExecutor.java
index 7d3ddf1..cd49ea6 100644
--- a/java/com/google/gerrit/server/documentation/QueryDocumentationExecutor.java
+++ b/java/com/google/gerrit/server/documentation/QueryDocumentationExecutor.java
@@ -16,6 +16,7 @@
 
 import com.google.common.collect.ImmutableMap;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
@@ -99,6 +100,7 @@
     }
   }
 
+  @Nullable
   protected Directory readIndexDirectory() throws IOException {
     Directory dir = new ByteBuffersDirectory();
     byte[] buffer = new byte[4096];
diff --git a/java/com/google/gerrit/server/edit/ChangeEditModifier.java b/java/com/google/gerrit/server/edit/ChangeEditModifier.java
index 765dd59..e813c09 100644
--- a/java/com/google/gerrit/server/edit/ChangeEditModifier.java
+++ b/java/com/google/gerrit/server/edit/ChangeEditModifier.java
@@ -15,13 +15,16 @@
 package com.google.gerrit.server.edit;
 
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
 
 import com.google.common.base.Charsets;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.BooleanProjectConfig;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.MergeConflictException;
@@ -34,6 +37,7 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.config.UrlFormatter;
 import com.google.gerrit.server.edit.tree.ChangeFileContentModification;
 import com.google.gerrit.server.edit.tree.DeleteFileModification;
 import com.google.gerrit.server.edit.tree.RenameFileModification;
@@ -48,6 +52,7 @@
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.InvalidChangeOperationException;
 import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.update.context.RefUpdateContext;
 import com.google.gerrit.server.util.CommitMessageUtil;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
@@ -100,6 +105,7 @@
   private final PatchSetUtil patchSetUtil;
   private final ProjectCache projectCache;
   private final NoteDbEdits noteDbEdits;
+  private final DynamicItem<UrlFormatter> urlFormatter;
 
   @Inject
   ChangeEditModifier(
@@ -110,15 +116,16 @@
       ChangeEditUtil changeEditUtil,
       PatchSetUtil patchSetUtil,
       ProjectCache projectCache,
-      GitReferenceUpdated gitRefUpdated) {
+      GitReferenceUpdated gitReferenceUpdated,
+      DynamicItem<UrlFormatter> urlFormatter) {
     this.currentUser = currentUser;
     this.permissionBackend = permissionBackend;
     this.zoneId = gerritIdent.getZoneId();
     this.changeEditUtil = changeEditUtil;
     this.patchSetUtil = patchSetUtil;
     this.projectCache = projectCache;
-
-    noteDbEdits = new NoteDbEdits(zoneId, indexer, currentUser, gitRefUpdated);
+    noteDbEdits = new NoteDbEdits(gitReferenceUpdated, zoneId, indexer, currentUser);
+    this.urlFormatter = urlFormatter;
   }
 
   /**
@@ -175,10 +182,14 @@
               notes.getChangeId(), currentPatchSet.id()));
     }
 
-    rebase(repository, changeEdit, currentPatchSet);
+    rebase(notes.getProjectName(), repository, changeEdit, currentPatchSet);
   }
 
-  private void rebase(Repository repository, ChangeEdit changeEdit, PatchSet currentPatchSet)
+  private void rebase(
+      Project.NameKey project,
+      Repository repository,
+      ChangeEdit changeEdit,
+      PatchSet currentPatchSet)
       throws IOException, MergeConflictException, InvalidChangeOperationException {
     RevCommit currentEditCommit = changeEdit.getEditCommit();
     if (currentEditCommit.getParentCount() == 0) {
@@ -196,7 +207,13 @@
         createCommit(repository, basePatchSetCommit, newTreeId, commitMessage, nowTimestamp);
 
     noteDbEdits.baseEditOnDifferentPatchset(
-        repository, changeEdit, currentPatchSet, currentEditCommit, newEditCommitId, nowTimestamp);
+        project,
+        repository,
+        changeEdit,
+        currentPatchSet,
+        currentEditCommit,
+        newEditCommitId,
+        nowTimestamp);
   }
 
   /**
@@ -229,13 +246,18 @@
    * @param notes the {@link ChangeNotes} of the change whose change edit should be modified
    * @param filePath the path of the file whose contents should be modified
    * @param newContent the new file content
+   * @param newGitFileMode the new file mode in octal format. {@code null} indicates no change
    * @throws AuthException if the user isn't authenticated or not allowed to use change edits
    * @throws BadRequestException if the user provided bad input (e.g. invalid file paths)
    * @throws InvalidChangeOperationException if the file already had the specified content
    * @throws ResourceConflictException if the project state does not permit the operation
    */
   public void modifyFile(
-      Repository repository, ChangeNotes notes, String filePath, RawInput newContent)
+      Repository repository,
+      ChangeNotes notes,
+      String filePath,
+      RawInput newContent,
+      @Nullable Integer newGitFileMode)
       throws AuthException, BadRequestException, InvalidChangeOperationException, IOException,
           PermissionBackendException, ResourceConflictException {
     modifyCommit(
@@ -243,7 +265,8 @@
         notes,
         new ModificationIntention.LatestCommit(),
         CommitModification.builder()
-            .addTreeModification(new ChangeFileContentModification(filePath, newContent))
+            .addTreeModification(
+                new ChangeFileContentModification(filePath, newContent, newGitFileMode))
             .build());
   }
 
@@ -392,8 +415,10 @@
     ObjectId newEditCommit =
         createCommit(repository, basePatchsetCommit, newTreeId, newCommitMessage, nowTimestamp);
 
-    return editBehavior.updateEditInStorage(
-        repository, notes, basePatchset, newEditCommit, nowTimestamp);
+    try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+      return editBehavior.updateEditInStorage(
+          repository, notes, basePatchset, newEditCommit, nowTimestamp);
+    }
   }
 
   private void assertCanEdit(ChangeNotes notes)
@@ -495,7 +520,8 @@
           "New commit message cannot be same as existing commit message");
     }
 
-    ChangeUtil.ensureChangeIdIsCorrect(requireChangeId, currentChangeId, newCommitMessage);
+    ChangeUtil.ensureChangeIdIsCorrect(
+        requireChangeId, currentChangeId, newCommitMessage, urlFormatter.get());
 
     return newCommitMessage;
   }
@@ -715,17 +741,17 @@
     private final ZoneId zoneId;
     private final ChangeIndexer indexer;
     private final Provider<CurrentUser> currentUser;
-    private final GitReferenceUpdated gitRefUpdated;
+    private final GitReferenceUpdated gitReferenceUpdated;
 
     NoteDbEdits(
+        GitReferenceUpdated gitReferenceUpdated,
         ZoneId zoneId,
         ChangeIndexer indexer,
-        Provider<CurrentUser> currentUser,
-        GitReferenceUpdated gitRefUpdated) {
+        Provider<CurrentUser> currentUser) {
       this.zoneId = zoneId;
       this.indexer = indexer;
       this.currentUser = currentUser;
-      this.gitRefUpdated = gitRefUpdated;
+      this.gitReferenceUpdated = gitReferenceUpdated;
     }
 
     ChangeEdit createEdit(
@@ -755,6 +781,10 @@
       return RefNames.refsEdit(me.getAccountId(), change.getId(), basePatchset.id());
     }
 
+    private AccountState getUpdater() {
+      return currentUser.get().asIdentifiedUser().state();
+    }
+
     ChangeEdit updateEdit(
         Project.NameKey projectName,
         Repository repository,
@@ -781,27 +811,29 @@
         ObjectId targetObjectId,
         Instant timestamp)
         throws IOException {
-      AccountState userAccountState = currentUser.get().asIdentifiedUser().state();
-      RefUpdate ru = repository.updateRef(refName);
-      ru.setExpectedOldObjectId(currentObjectId);
-      ru.setNewObjectId(targetObjectId);
-      ru.setRefLogIdent(getRefLogIdent(timestamp));
-      ru.setRefLogMessage("inline edit (amend)", false);
-      ru.setForceUpdate(true);
-      try (RevWalk revWalk = new RevWalk(repository)) {
-        RefUpdate.Result res = ru.update(revWalk);
-        String message = "cannot update " + ru.getName() + " in " + projectName + ": " + res;
-        if (res == RefUpdate.Result.LOCK_FAILURE) {
-          throw new LockFailureException(message, ru);
+      try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+        RefUpdate ru = repository.updateRef(refName);
+        ru.setExpectedOldObjectId(currentObjectId);
+        ru.setNewObjectId(targetObjectId);
+        ru.setRefLogIdent(getRefLogIdent(timestamp));
+        ru.setRefLogMessage("inline edit (amend)", false);
+        ru.setForceUpdate(true);
+        try (RevWalk revWalk = new RevWalk(repository)) {
+          RefUpdate.Result res = ru.update(revWalk);
+          String message = "cannot update " + ru.getName() + " in " + projectName + ": " + res;
+          if (res == RefUpdate.Result.LOCK_FAILURE) {
+            throw new LockFailureException(message, ru);
+          }
+          if (res != RefUpdate.Result.NEW && res != RefUpdate.Result.FORCED) {
+            throw new IOException(message);
+          }
         }
-        if (res != RefUpdate.Result.NEW && res != RefUpdate.Result.FORCED) {
-          throw new IOException(message);
-        }
-        gitRefUpdated.fire(projectName, ru, userAccountState);
+        gitReferenceUpdated.fire(projectName, ru, getUpdater());
       }
     }
 
     void baseEditOnDifferentPatchset(
+        Project.NameKey project,
         Repository repository,
         ChangeEdit changeEdit,
         PatchSet currentPatchSet,
@@ -811,6 +843,7 @@
         throws IOException {
       String newEditRefName = getEditRefName(changeEdit.getChange(), currentPatchSet);
       updateReferenceWithNameChange(
+          project,
           repository,
           changeEdit.getRefName(),
           currentEditCommit,
@@ -821,6 +854,7 @@
     }
 
     private void updateReferenceWithNameChange(
+        Project.NameKey projectName,
         Repository repository,
         String currentRefName,
         ObjectId currentObjectId,
@@ -828,19 +862,23 @@
         ObjectId targetObjectId,
         Instant timestamp)
         throws IOException {
-      BatchRefUpdate batchRefUpdate = repository.getRefDatabase().newBatchUpdate();
-      batchRefUpdate.addCommand(new ReceiveCommand(ObjectId.zeroId(), targetObjectId, newRefName));
-      batchRefUpdate.addCommand(
-          new ReceiveCommand(currentObjectId, ObjectId.zeroId(), currentRefName));
-      batchRefUpdate.setRefLogMessage("rebase edit", false);
-      batchRefUpdate.setRefLogIdent(getRefLogIdent(timestamp));
-      try (RevWalk revWalk = new RevWalk(repository)) {
-        batchRefUpdate.execute(revWalk, NullProgressMonitor.INSTANCE);
-      }
-      for (ReceiveCommand cmd : batchRefUpdate.getCommands()) {
-        if (cmd.getResult() != ReceiveCommand.Result.OK) {
-          throw new IOException("failed: " + cmd);
+      try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+        BatchRefUpdate batchRefUpdate = repository.getRefDatabase().newBatchUpdate();
+        batchRefUpdate.addCommand(
+            new ReceiveCommand(ObjectId.zeroId(), targetObjectId, newRefName));
+        batchRefUpdate.addCommand(
+            new ReceiveCommand(currentObjectId, ObjectId.zeroId(), currentRefName));
+        batchRefUpdate.setRefLogMessage("rebase edit", false);
+        batchRefUpdate.setRefLogIdent(getRefLogIdent(timestamp));
+        try (RevWalk revWalk = new RevWalk(repository)) {
+          batchRefUpdate.execute(revWalk, NullProgressMonitor.INSTANCE);
         }
+        for (ReceiveCommand cmd : batchRefUpdate.getCommands()) {
+          if (cmd.getResult() != ReceiveCommand.Result.OK) {
+            throw new IOException("failed: " + cmd);
+          }
+        }
+        gitReferenceUpdated.fire(projectName, batchRefUpdate, getUpdater());
       }
     }
 
diff --git a/java/com/google/gerrit/server/edit/ChangeEditUtil.java b/java/com/google/gerrit/server/edit/ChangeEditUtil.java
index 96a84f6..e7de322 100644
--- a/java/com/google/gerrit/server/edit/ChangeEditUtil.java
+++ b/java/com/google/gerrit/server/edit/ChangeEditUtil.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.edit;
 
 import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
 
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
@@ -29,7 +30,6 @@
 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.change.ChangeKindCache;
 import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.change.PatchSetInserter;
@@ -41,6 +41,7 @@
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.RepoContext;
 import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.update.context.RefUpdateContext;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -71,7 +72,7 @@
   private final Provider<CurrentUser> userProvider;
   private final ChangeKindCache changeKindCache;
   private final PatchSetUtil psUtil;
-  private final GitReferenceUpdated gitRefUpdated;
+  private final GitReferenceUpdated gitReferenceUpdated;
 
   @Inject
   ChangeEditUtil(
@@ -81,14 +82,14 @@
       Provider<CurrentUser> userProvider,
       ChangeKindCache changeKindCache,
       PatchSetUtil psUtil,
-      GitReferenceUpdated gitRefUpdated) {
+      GitReferenceUpdated gitReferenceUpdated) {
     this.gitManager = gitManager;
     this.patchSetInserterFactory = patchSetInserterFactory;
     this.indexer = indexer;
     this.userProvider = userProvider;
     this.changeKindCache = changeKindCache;
     this.psUtil = psUtil;
-    this.gitRefUpdated = gitRefUpdated;
+    this.gitReferenceUpdated = gitReferenceUpdated;
   }
 
   /**
@@ -189,20 +190,22 @@
       } else {
         message.append("Published edit on patch set ").append(basePatchSet.number()).append(".");
       }
-
-      try (BatchUpdate bu = updateFactory.create(change.getProject(), user, TimeUtil.now())) {
-        bu.setRepository(repo, rw, oi);
-        bu.setNotify(notify);
-        bu.addOp(change.getId(), inserter.setMessage(message.toString()));
-        bu.addOp(
-            change.getId(),
-            new BatchUpdateOp() {
-              @Override
-              public void updateRepo(RepoContext ctx) throws Exception {
-                ctx.addRefUpdate(edit.getEditCommit().copy(), ObjectId.zeroId(), edit.getRefName());
-              }
-            });
-        bu.execute();
+      try (RefUpdateContext changeCtx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+        try (BatchUpdate bu = updateFactory.create(change.getProject(), user, TimeUtil.now())) {
+          bu.setRepository(repo, rw, oi);
+          bu.setNotify(notify);
+          bu.addOp(change.getId(), inserter.setMessage(message.toString()));
+          bu.addOp(
+              change.getId(),
+              new BatchUpdateOp() {
+                @Override
+                public void updateRepo(RepoContext ctx) throws Exception {
+                  ctx.addRefUpdate(
+                      edit.getEditCommit().copy(), ObjectId.zeroId(), edit.getRefName());
+                }
+              });
+          bu.execute();
+        }
       }
     }
   }
@@ -243,31 +246,34 @@
   }
 
   private void deleteRef(Repository repo, ChangeEdit edit) throws IOException {
-    AccountState userAccountState = userProvider.get().asIdentifiedUser().state();
-    String refName = edit.getRefName();
-    RefUpdate ru = repo.updateRef(refName, true);
-    ru.setExpectedOldObjectId(edit.getEditCommit());
-    ru.setForceUpdate(true);
-    RefUpdate.Result result = ru.delete();
-    switch (result) {
-      case FORCED:
-      case NEW:
-        gitRefUpdated.fire(edit.getChange().getProject(), ru, userAccountState);
-        break;
-      case NO_CHANGE:
-        break;
-      case LOCK_FAILURE:
-        throw new LockFailureException(String.format("Failed to delete ref %s", refName), ru);
-      case FAST_FORWARD:
-      case IO_FAILURE:
-      case NOT_ATTEMPTED:
-      case REJECTED:
-      case REJECTED_CURRENT_BRANCH:
-      case RENAMED:
-      case REJECTED_MISSING_OBJECT:
-      case REJECTED_OTHER_REASON:
-      default:
-        throw new IOException(String.format("Failed to delete ref %s: %s", refName, result));
+    try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+      String refName = edit.getRefName();
+      RefUpdate ru = repo.updateRef(refName, true);
+      ru.setExpectedOldObjectId(edit.getEditCommit());
+      ru.setForceUpdate(true);
+      RefUpdate.Result result = ru.delete();
+      switch (result) {
+        case FORCED:
+        case NEW:
+        case NO_CHANGE:
+          break;
+        case LOCK_FAILURE:
+          throw new LockFailureException(String.format("Failed to delete ref %s", refName), ru);
+        case FAST_FORWARD:
+        case IO_FAILURE:
+        case NOT_ATTEMPTED:
+        case REJECTED:
+        case REJECTED_CURRENT_BRANCH:
+        case RENAMED:
+        case REJECTED_MISSING_OBJECT:
+        case REJECTED_OTHER_REASON:
+        default:
+          throw new IOException(String.format("Failed to delete ref %s: %s", refName, result));
+      }
+      gitReferenceUpdated.fire(
+          edit.getChange().getProject(),
+          ru,
+          /* updater= */ userProvider.get().asIdentifiedUser().state());
     }
   }
 
diff --git a/java/com/google/gerrit/server/edit/tree/ChangeFileContentModification.java b/java/com/google/gerrit/server/edit/tree/ChangeFileContentModification.java
index 9c0b92a..96c6685 100644
--- a/java/com/google/gerrit/server/edit/tree/ChangeFileContentModification.java
+++ b/java/com/google/gerrit/server/edit/tree/ChangeFileContentModification.java
@@ -22,6 +22,7 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.flogger.FluentLogger;
 import com.google.common.io.ByteStreams;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.restapi.RawInput;
 import java.io.IOException;
 import java.io.InputStream;
@@ -42,16 +43,26 @@
 
   private final String filePath;
   private final RawInput newContent;
+  private final Integer newGitFileMode;
 
   public ChangeFileContentModification(String filePath, RawInput newContent) {
     this.filePath = filePath;
     this.newContent = requireNonNull(newContent, "new content required");
+    this.newGitFileMode = null;
+  }
+
+  public ChangeFileContentModification(
+      String filePath, RawInput newContent, @Nullable Integer newGitFileMode) {
+    this.filePath = filePath;
+    this.newContent = requireNonNull(newContent, "new content required");
+    this.newGitFileMode = newGitFileMode;
   }
 
   @Override
   public List<DirCacheEditor.PathEdit> getPathEdits(
       Repository repository, ObjectId treeId, ImmutableList<? extends ObjectId> parents) {
-    DirCacheEditor.PathEdit changeContentEdit = new ChangeContent(filePath, newContent, repository);
+    DirCacheEditor.PathEdit changeContentEdit =
+        new ChangeContent(filePath, newContent, repository, newGitFileMode);
     return Collections.singletonList(changeContentEdit);
   }
 
@@ -70,16 +81,32 @@
 
     private final RawInput newContent;
     private final Repository repository;
+    private final Integer newGitFileMode;
 
-    ChangeContent(String filePath, RawInput newContent, Repository repository) {
+    ChangeContent(
+        String filePath,
+        RawInput newContent,
+        Repository repository,
+        @Nullable Integer newGitFileMode) {
       super(filePath);
       this.newContent = newContent;
       this.repository = repository;
+      this.newGitFileMode = newGitFileMode;
+    }
+
+    private boolean isValidGitFileMode(int gitFileMode) {
+      return (gitFileMode == 100755) || (gitFileMode == 100644);
     }
 
     @Override
     public void apply(DirCacheEntry dirCacheEntry) {
       try {
+        if (newGitFileMode != null && newGitFileMode != 0) {
+          if (!isValidGitFileMode(newGitFileMode)) {
+            throw new IllegalStateException("GitFileMode " + newGitFileMode + " is invalid");
+          }
+          dirCacheEntry.setFileMode(FileMode.fromBits(newGitFileMode));
+        }
         if (dirCacheEntry.getFileMode() == FileMode.GITLINK) {
           dirCacheEntry.setLength(0);
           dirCacheEntry.setLastModified(Instant.EPOCH);
diff --git a/java/com/google/gerrit/server/events/AssigneeChangedEvent.java b/java/com/google/gerrit/server/events/AssigneeChangedEvent.java
deleted file mode 100644
index 490d6d14..0000000
--- a/java/com/google/gerrit/server/events/AssigneeChangedEvent.java
+++ /dev/null
@@ -1,29 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.events;
-
-import com.google.common.base.Supplier;
-import com.google.gerrit.entities.Change;
-import com.google.gerrit.server.data.AccountAttribute;
-
-public class AssigneeChangedEvent extends ChangeEvent {
-  static final String TYPE = "assignee-changed";
-  public Supplier<AccountAttribute> changer;
-  public Supplier<AccountAttribute> oldAssignee;
-
-  public AssigneeChangedEvent(Change change) {
-    super(TYPE, change);
-  }
-}
diff --git a/java/com/google/gerrit/server/events/EventFactory.java b/java/com/google/gerrit/server/events/EventFactory.java
index 95f6d96..678f4d0 100644
--- a/java/com/google/gerrit/server/events/EventFactory.java
+++ b/java/com/google/gerrit/server/events/EventFactory.java
@@ -20,6 +20,7 @@
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Lists;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
@@ -132,7 +133,6 @@
     a.subject = change.getSubject();
     a.url = getChangeUrl(change);
     a.owner = asAccountAttribute(change.getOwner(), accountLoader);
-    a.assignee = asAccountAttribute(change.getAssignee(), accountLoader);
     a.status = change.getStatus();
     a.createdOn = change.getCreatedOn().getEpochSecond();
     a.wip = change.isWorkInProgress() ? true : null;
@@ -523,6 +523,7 @@
   }
 
   /** Create an AuthorAttribute for the given account suitable for serialization to JSON. */
+  @Nullable
   public AccountAttribute asAccountAttribute(Account.Id id) {
     if (id == null) {
       return null;
@@ -590,6 +591,7 @@
   }
 
   /** Get a link to the change; null if the server doesn't know its own address. */
+  @Nullable
   private String getChangeUrl(Change change) {
     if (change != null) {
       return urlFormatter.get().getChangeViewUrl(change.getProject(), change.getId()).orElse(null);
diff --git a/java/com/google/gerrit/server/events/EventTypes.java b/java/com/google/gerrit/server/events/EventTypes.java
index f040220..c2c057c 100644
--- a/java/com/google/gerrit/server/events/EventTypes.java
+++ b/java/com/google/gerrit/server/events/EventTypes.java
@@ -23,7 +23,6 @@
   private static final Map<String, Class<?>> typesByString = new HashMap<>();
 
   static {
-    register(AssigneeChangedEvent.TYPE, AssigneeChangedEvent.class);
     register(ChangeAbandonedEvent.TYPE, ChangeAbandonedEvent.class);
     register(ChangeDeletedEvent.TYPE, ChangeDeletedEvent.class);
     register(ChangeMergedEvent.TYPE, ChangeMergedEvent.class);
diff --git a/java/com/google/gerrit/server/events/StreamEventsApiListener.java b/java/com/google/gerrit/server/events/StreamEventsApiListener.java
index afe2a7c..50c15b7 100644
--- a/java/com/google/gerrit/server/events/StreamEventsApiListener.java
+++ b/java/com/google/gerrit/server/events/StreamEventsApiListener.java
@@ -20,6 +20,7 @@
 import com.google.common.base.Suppliers;
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
@@ -31,7 +32,6 @@
 import com.google.gerrit.extensions.common.ApprovalInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.RevisionInfo;
-import com.google.gerrit.extensions.events.AssigneeChangedListener;
 import com.google.gerrit.extensions.events.ChangeAbandonedListener;
 import com.google.gerrit.extensions.events.ChangeDeletedListener;
 import com.google.gerrit.extensions.events.ChangeMergedListener;
@@ -72,8 +72,7 @@
 
 @Singleton
 public class StreamEventsApiListener
-    implements AssigneeChangedListener,
-        ChangeAbandonedListener,
+    implements ChangeAbandonedListener,
         ChangeDeletedListener,
         ChangeMergedListener,
         ChangeRestoredListener,
@@ -94,7 +93,6 @@
   public static class StreamEventsApiListenerModule extends AbstractModule {
     @Override
     protected void configure() {
-      DynamicSet.bind(binder(), AssigneeChangedListener.class).to(StreamEventsApiListener.class);
       DynamicSet.bind(binder(), ChangeAbandonedListener.class).to(StreamEventsApiListener.class);
       DynamicSet.bind(binder(), ChangeDeletedListener.class).to(StreamEventsApiListener.class);
       DynamicSet.bind(binder(), ChangeMergedListener.class).to(StreamEventsApiListener.class);
@@ -234,6 +232,7 @@
         });
   }
 
+  @Nullable
   String[] hashtagArray(Collection<String> hashtags) {
     if (hashtags != null && !hashtags.isEmpty()) {
       return Sets.newHashSet(hashtags).toArray(new String[hashtags.size()]);
@@ -242,23 +241,6 @@
   }
 
   @Override
-  public void onAssigneeChanged(AssigneeChangedListener.Event ev) {
-    try {
-      ChangeNotes notes = getNotes(ev.getChange());
-      Change change = notes.getChange();
-      AssigneeChangedEvent event = new AssigneeChangedEvent(change);
-
-      event.change = changeAttributeSupplier(change, notes);
-      event.changer = accountAttributeSupplier(ev.getWho());
-      event.oldAssignee = accountAttributeSupplier(ev.getOldAssignee());
-
-      dispatcher.run(d -> d.postEvent(change, event));
-    } catch (StorageException e) {
-      logger.atSevere().withCause(e).log("Failed to dispatch event");
-    }
-  }
-
-  @Override
   public void onTopicEdited(TopicEditedListener.Event ev) {
     try {
       ChangeNotes notes = getNotes(ev.getChange());
diff --git a/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java b/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java
index b876341..e294d55 100644
--- a/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java
+++ b/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java
@@ -20,14 +20,13 @@
 public class ExperimentFeaturesConstants {
 
   /** Features that are known experiments and can be referenced in the code. */
-  public static String UI_FEATURE_PATCHSET_COMMENTS = "UiFeature__patchset_comments";
-
-  public static String UI_FEATURE_SUBMIT_REQUIREMENTS_UI = "UiFeature__submit_requirements_ui";
-
-  public static String GERRIT_BACKEND_REQUEST_FEATURE_REMOVE_REVISION_ETAG =
-      "GerritBackendRequestFeature__remove_revision_etag";
+  public static String GERRIT_BACKEND_FEATURE_ATTACH_NONCE_TO_DOCUMENTATION =
+      "GerritBackendFeature__attach_nonce_to_documentation";
 
   /** Features, enabled by default in the current release. */
-  public static final ImmutableSet<String> DEFAULT_ENABLED_FEATURES =
-      ImmutableSet.of(UI_FEATURE_PATCHSET_COMMENTS, UI_FEATURE_SUBMIT_REQUIREMENTS_UI);
+  public static final ImmutableSet<String> DEFAULT_ENABLED_FEATURES = ImmutableSet.of();
+
+  /** On BatchUpdate, do not await index completion before returning to the user */
+  public static String GERRIT_BACKEND_FEATURE_DO_NOT_AWAIT_CHANGE_INDEXING =
+      "GerritBackendFeature__do_not_await_change_indexing";
 }
diff --git a/java/com/google/gerrit/server/extensions/events/AssigneeChanged.java b/java/com/google/gerrit/server/extensions/events/AssigneeChanged.java
deleted file mode 100644
index 8e4d1e2..0000000
--- a/java/com/google/gerrit/server/extensions/events/AssigneeChanged.java
+++ /dev/null
@@ -1,76 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.extensions.events;
-
-import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.extensions.api.changes.NotifyHandling;
-import com.google.gerrit.extensions.common.AccountInfo;
-import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.extensions.events.AssigneeChangedListener;
-import com.google.gerrit.server.account.AccountState;
-import com.google.gerrit.server.plugincontext.PluginSetContext;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-import java.time.Instant;
-
-/** Helper class to fire an event when a user has been set as assignee on a change. */
-@Singleton
-public class AssigneeChanged {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
-  private final PluginSetContext<AssigneeChangedListener> listeners;
-  private final EventUtil util;
-
-  @Inject
-  AssigneeChanged(PluginSetContext<AssigneeChangedListener> listeners, EventUtil util) {
-    this.listeners = listeners;
-    this.util = util;
-  }
-
-  public void fire(
-      ChangeData changeData, AccountState accountState, AccountState oldAssignee, Instant when) {
-    if (listeners.isEmpty()) {
-      return;
-    }
-    try {
-      Event event =
-          new Event(
-              util.changeInfo(changeData),
-              util.accountInfo(accountState),
-              util.accountInfo(oldAssignee),
-              when);
-      listeners.runEach(l -> l.onAssigneeChanged(event));
-    } catch (StorageException e) {
-      logger.atSevere().withCause(e).log("Couldn't fire event");
-    }
-  }
-
-  /** Event to be fired when a user has been set as assignee on a change. */
-  private static class Event extends AbstractChangeEvent implements AssigneeChangedListener.Event {
-    private final AccountInfo oldAssignee;
-
-    Event(ChangeInfo change, AccountInfo editor, AccountInfo oldAssignee, Instant when) {
-      super(change, editor, when, NotifyHandling.ALL);
-      this.oldAssignee = oldAssignee;
-    }
-
-    @Override
-    public AccountInfo getOldAssignee() {
-      return oldAssignee;
-    }
-  }
-}
diff --git a/java/com/google/gerrit/server/extensions/events/EventUtil.java b/java/com/google/gerrit/server/extensions/events/EventUtil.java
index b669571..7c8777f 100644
--- a/java/com/google/gerrit/server/extensions/events/EventUtil.java
+++ b/java/com/google/gerrit/server/extensions/events/EventUtil.java
@@ -99,6 +99,7 @@
     return revisionJsonFactory.create(changeOptions).getRevisionInfo(cd, ps);
   }
 
+  @Nullable
   public AccountInfo accountInfo(@Nullable AccountState accountState) {
     if (accountState == null || accountState.account().id() == null) {
       return null;
diff --git a/java/com/google/gerrit/server/fixes/FixCalculator.java b/java/com/google/gerrit/server/fixes/FixCalculator.java
index df20fbf..c471245 100644
--- a/java/com/google/gerrit/server/fixes/FixCalculator.java
+++ b/java/com/google/gerrit/server/fixes/FixCalculator.java
@@ -17,10 +17,12 @@
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.entities.Comment;
 import com.google.gerrit.entities.FixReplacement;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.jgit.diff.ReplaceEdit;
+import com.google.gerrit.server.patch.IntraLineLoader;
 import com.google.gerrit.server.patch.Text;
 import java.util.ArrayList;
 import java.util.Comparator;
@@ -47,7 +49,8 @@
   public static String getNewFileContent(
       String originalContent, List<FixReplacement> fixReplacements)
       throws ResourceConflictException {
-    FixResult fixResult = calculateFix(new Text(originalContent.getBytes(UTF_8)), fixReplacements);
+    FixResult fixResult =
+        calculateFix(new Text(originalContent.getBytes(UTF_8)), fixReplacements, false);
     return fixResult.text.getString(0, fixResult.text.size(), false);
   }
 
@@ -60,7 +63,8 @@
    * @throws ResourceConflictException if the fixReplacements contains invalid data (for example, if
    *     an item points to an invalid range or if some ranges are intersected).
    */
-  public static FixResult calculateFix(Text originalText, List<FixReplacement> fixReplacements)
+  public static FixResult calculateFix(
+      Text originalText, List<FixReplacement> fixReplacements, boolean intraline)
       throws ResourceConflictException {
     List<FixReplacement> sortedReplacements = new ArrayList<>(fixReplacements);
     sortedReplacements.sort(ASC_RANGE_FIX_REPLACEMENT_COMPARATOR);
@@ -70,7 +74,7 @@
               "Cannot calculate fix replacement for range %s",
               toString(sortedReplacements.get(0).range)));
     }
-    ContentBuilder builder = new ContentBuilder(originalText);
+    ContentBuilder builder = new ContentBuilder(originalText, intraline);
     for (FixReplacement fixReplacement : sortedReplacements) {
       try {
         builder.addReplacement(fixReplacement);
@@ -164,12 +168,16 @@
     }
 
     private final ContentProcessor contentProcessor;
+    private final Text src;
+    private final boolean intraline;
     final ImmutableList.Builder<Edit> edits;
     FixRegion currentRegion;
 
-    ContentBuilder(Text src) {
+    ContentBuilder(Text src, boolean intraline) {
       this.contentProcessor = new ContentProcessor(src);
+      this.src = src;
       this.edits = new ImmutableList.Builder<>();
+      this.intraline = intraline;
     }
 
     void addReplacement(FixReplacement replacement) {
@@ -193,9 +201,18 @@
       }
     }
 
+    private ImmutableList<Edit> buildEdits() {
+      if (this.intraline) {
+        return IntraLineLoader.compute(
+                this.src, this.getNewText(), edits.build(), ImmutableSet.of())
+            .getEdits();
+      }
+      return edits.build();
+    }
+
     public FixResult build() {
       finish();
-      return new FixResult(edits.build(), this.getNewText());
+      return new FixResult(this.buildEdits(), this.getNewText());
     }
 
     private void finishExistingEdit() {
diff --git a/java/com/google/gerrit/server/git/BanCommit.java b/java/com/google/gerrit/server/git/BanCommit.java
index e27197c..e00012a 100644
--- a/java/com/google/gerrit/server/git/BanCommit.java
+++ b/java/com/google/gerrit/server/git/BanCommit.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.git;
 
 import static com.google.gerrit.entities.RefNames.REFS_REJECT_COMMITS;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.BAN_COMMIT;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.gerrit.entities.Project;
@@ -26,6 +27,7 @@
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.permissions.ProjectPermission;
+import com.google.gerrit.server.update.context.RefUpdateContext;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -128,21 +130,23 @@
         banCommitNotes.set(commitToBan, noteId);
       }
       NotesBranchUtil notesBranchUtil = notesBranchUtilFactory.create(project, repo, inserter);
-      NoteMap newlyCreated =
-          notesBranchUtil.commitNewNotes(
-              banCommitNotes,
-              REFS_REJECT_COMMITS,
-              createPersonIdent(),
-              buildCommitMessage(commitsToBan, reason));
+      try (RefUpdateContext ctx = RefUpdateContext.open(BAN_COMMIT)) {
+        NoteMap newlyCreated =
+            notesBranchUtil.commitNewNotes(
+                banCommitNotes,
+                REFS_REJECT_COMMITS,
+                createPersonIdent(),
+                buildCommitMessage(commitsToBan, reason));
 
-      for (Note n : banCommitNotes) {
-        if (newlyCreated.contains(n)) {
-          result.commitBanned(n);
-        } else {
-          result.commitAlreadyBanned(n);
+        for (Note n : banCommitNotes) {
+          if (newlyCreated.contains(n)) {
+            result.commitBanned(n);
+          } else {
+            result.commitAlreadyBanned(n);
+          }
         }
+        return result;
       }
-      return result;
     }
   }
 
diff --git a/java/com/google/gerrit/server/git/ChangesByProjectCacheImpl.java b/java/com/google/gerrit/server/git/ChangesByProjectCacheImpl.java
index 1e1c7a3..094287b 100644
--- a/java/com/google/gerrit/server/git/ChangesByProjectCacheImpl.java
+++ b/java/com/google/gerrit/server/git/ChangesByProjectCacheImpl.java
@@ -132,7 +132,8 @@
             "Querying changes of project", Metadata.builder().projectName(project.get()).build())) {
       return queryProvider
           .get()
-          .setRequestedFields(ChangeField.CHANGE, ChangeField.REVIEWER, ChangeField.REF_STATE)
+          .setRequestedFields(
+              ChangeField.CHANGE_SPEC, ChangeField.REVIEWER_SPEC, ChangeField.REF_STATE_SPEC)
           .byProject(project);
     }
   }
diff --git a/java/com/google/gerrit/server/git/CommitUtil.java b/java/com/google/gerrit/server/git/CommitUtil.java
index fa46bf4..ffb6c66 100644
--- a/java/com/google/gerrit/server/git/CommitUtil.java
+++ b/java/com/google/gerrit/server/git/CommitUtil.java
@@ -15,9 +15,9 @@
 package com.google.gerrit.server.git;
 
 import static com.google.common.base.MoreObjects.firstNonNull;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
 
 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;
@@ -26,10 +26,13 @@
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.RevertInput;
 import com.google.gerrit.extensions.common.CommitInfo;
+import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.CommonConverters;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.GerritPersonIdent;
@@ -38,17 +41,21 @@
 import com.google.gerrit.server.change.ChangeInserter;
 import com.google.gerrit.server.change.ChangeMessages;
 import com.google.gerrit.server.change.NotifyResolver;
+import com.google.gerrit.server.change.ValidationOptionsUtil;
 import com.google.gerrit.server.extensions.events.ChangeReverted;
 import com.google.gerrit.server.mail.send.MessageIdGenerator;
 import com.google.gerrit.server.mail.send.RevertedSender;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
 import com.google.gerrit.server.notedb.Sequences;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.update.context.RefUpdateContext;
 import com.google.gerrit.server.util.CommitMessageUtil;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -58,15 +65,18 @@
 import java.time.Instant;
 import java.util.ArrayList;
 import java.util.HashSet;
-import java.util.Map;
+import java.util.List;
 import java.util.Set;
 import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.errors.InvalidObjectIdException;
+import org.eclipse.jgit.errors.MissingObjectException;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.ObjectReader;
 import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
@@ -85,6 +95,7 @@
   private final NotifyResolver notifyResolver;
   private final RevertedSender.Factory revertedSenderFactory;
   private final ChangeMessagesUtil cmUtil;
+  private final ChangeNotes.Factory changeNotesFactory;
   private final ChangeReverted changeReverted;
   private final BatchUpdate.Factory updateFactory;
   private final MessageIdGenerator messageIdGenerator;
@@ -99,6 +110,7 @@
       NotifyResolver notifyResolver,
       RevertedSender.Factory revertedSenderFactory,
       ChangeMessagesUtil cmUtil,
+      ChangeNotes.Factory changeNotesFactory,
       ChangeReverted changeReverted,
       BatchUpdate.Factory updateFactory,
       MessageIdGenerator messageIdGenerator) {
@@ -110,6 +122,7 @@
     this.notifyResolver = notifyResolver;
     this.revertedSenderFactory = revertedSenderFactory;
     this.cmUtil = cmUtil;
+    this.changeNotesFactory = changeNotesFactory;
     this.changeReverted = changeReverted;
     this.updateFactory = updateFactory;
     this.messageIdGenerator = messageIdGenerator;
@@ -151,18 +164,19 @@
       ChangeNotes notes, CurrentUser user, RevertInput input, Instant timestamp)
       throws RestApiException, UpdateException, ConfigInvalidException, IOException {
     String message = Strings.emptyToNull(input.message);
-
-    try (Repository git = repoManager.openRepository(notes.getProjectName());
-        ObjectInserter oi = git.newObjectInserter();
-        ObjectReader reader = oi.newReader();
-        RevWalk revWalk = new RevWalk(reader)) {
-      ObjectId generatedChangeId = CommitMessageUtil.generateChangeId();
-      ObjectId revCommit =
-          createRevertCommit(message, notes, user, timestamp, oi, revWalk, generatedChangeId);
-      return createRevertChangeFromCommit(
-          revCommit, input, notes, user, generatedChangeId, timestamp, oi, revWalk, git);
-    } catch (RepositoryNotFoundException e) {
-      throw new ResourceNotFoundException(notes.getChangeId().toString(), e);
+    try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+      try (Repository git = repoManager.openRepository(notes.getProjectName());
+          ObjectInserter oi = git.newObjectInserter();
+          ObjectReader reader = oi.newReader();
+          RevWalk revWalk = new RevWalk(reader)) {
+        ObjectId generatedChangeId = CommitMessageUtil.generateChangeId();
+        ObjectId revCommit =
+            createRevertCommit(message, notes, user, timestamp, oi, revWalk, generatedChangeId);
+        return createRevertChangeFromCommit(
+            revCommit, input, notes, user, generatedChangeId, timestamp, oi, revWalk, git);
+      } catch (RepositoryNotFoundException e) {
+        throw new ResourceNotFoundException(notes.getChangeId().toString(), e);
+      }
     }
   }
 
@@ -190,6 +204,41 @@
   }
 
   /**
+   * Creates a commit with the specified tree ID.
+   *
+   * @param oi ObjectInserter for inserting the newly created commit.
+   * @param authorIdent of the new commit
+   * @param committerIdent of the new commit
+   * @param parentCommit of the new commit. Can be null.
+   * @param commitMessage for the new commit.
+   * @param treeId of the content for the new commit.
+   * @return the newly created commit.
+   * @throws IOException if fails to insert the commit.
+   */
+  public static ObjectId createCommitWithTree(
+      ObjectInserter oi,
+      PersonIdent authorIdent,
+      PersonIdent committerIdent,
+      @Nullable RevCommit parentCommit,
+      String commitMessage,
+      ObjectId treeId)
+      throws IOException {
+    logger.atFine().log("Creating commit with tree: %s", treeId.getName());
+    CommitBuilder commit = new CommitBuilder();
+    commit.setTreeId(treeId);
+    if (parentCommit != null) {
+      commit.setParentId(parentCommit);
+    }
+    commit.setAuthor(authorIdent);
+    commit.setCommitter(committerIdent);
+    commit.setMessage(commitMessage);
+
+    ObjectId id = oi.insert(commit);
+    oi.flush();
+    return id;
+  }
+
+  /**
    * Creates a revert commit.
    *
    * @param message Commit message for the revert commit.
@@ -227,12 +276,6 @@
     RevCommit parentToCommitToRevert = commitToRevert.getParent(0);
     revWalk.parseHeaders(parentToCommitToRevert);
 
-    CommitBuilder revertCommitBuilder = new CommitBuilder();
-    revertCommitBuilder.addParentId(commitToRevert);
-    revertCommitBuilder.setTreeId(parentToCommitToRevert.getTree());
-    revertCommitBuilder.setAuthor(authorIdent);
-    revertCommitBuilder.setCommitter(authorIdent);
-
     Change changeToRevert = notes.getChange();
     String subject = changeToRevert.getSubject();
     if (subject.length() > 63) {
@@ -244,11 +287,11 @@
               ChangeMessages.get().revertChangeDefaultMessage, subject, patch.commitId().name());
     }
     if (generatedChangeId != null) {
-      revertCommitBuilder.setMessage(ChangeIdUtil.insertId(message, generatedChangeId, true));
+      message = ChangeIdUtil.insertId(message, generatedChangeId, true);
     }
-    ObjectId id = oi.insert(revertCommitBuilder);
-    oi.flush();
-    return id;
+
+    return createCommitWithTree(
+        oi, authorIdent, committerIdent, commitToRevert, message, parentToCommitToRevert.getTree());
   }
 
   private Change.Id createRevertChangeFromCommit(
@@ -263,20 +306,21 @@
       Repository git)
       throws IOException, RestApiException, UpdateException, ConfigInvalidException {
     RevCommit revertCommit = revWalk.parseCommit(revertCommitId);
-    Change changeToRevert = notes.getChange();
     Change.Id changeId = Change.id(seq.nextChangeId());
     if (input.workInProgress) {
-      input.notify = firstNonNull(input.notify, NotifyHandling.OWNER);
+      input.notify = firstNonNull(input.notify, NotifyHandling.NONE);
     }
     NotifyResolver.Result notify =
         notifyResolver.resolve(firstNonNull(input.notify, NotifyHandling.ALL), input.notifyDetails);
 
+    Change changeToRevert = notes.getChange();
     ChangeInserter ins =
         changeInserterFactory
-            .create(changeId, revertCommit, notes.getChange().getDest().branch())
+            .create(changeId, revertCommit, changeToRevert.getDest().branch())
             .setTopic(input.topic == null ? changeToRevert.getTopic() : input.topic.trim());
     ins.setMessage("Uploaded patch set 1.");
-    ins.setValidationOptions(getValidateOptionsAsMultimap(input.validationOptions));
+    ins.setValidationOptions(
+        ValidationOptionsUtil.getValidateOptionsAsMultimap(input.validationOptions));
 
     ReviewerSet reviewerSet = approvalsUtil.getReviewers(notes);
 
@@ -286,7 +330,7 @@
     reviewers.remove(user.getAccountId());
     Set<Account.Id> ccs = new HashSet<>(reviewerSet.byState(ReviewerStateInternal.CC));
     ccs.remove(user.getAccountId());
-    ins.setReviewersAndCcs(reviewers, ccs);
+    ins.setReviewersAndCcsIgnoreVisibility(reviewers, ccs);
     ins.setRevertOf(notes.getChangeId());
     ins.setWorkInProgress(input.workInProgress);
 
@@ -294,68 +338,149 @@
       bu.setRepository(git, revWalk, oi);
       bu.setNotify(notify);
       bu.insertChange(ins);
-      bu.addOp(changeId, new NotifyOp(changeToRevert, ins));
-      bu.addOp(changeToRevert.getId(), new PostRevertedMessageOp(generatedChangeId));
+      if (!input.workInProgress) {
+        addChangeRevertedNotificationOps(
+            bu, changeToRevert.getId(), changeId, generatedChangeId.name());
+      }
       bu.execute();
     }
     return changeId;
   }
 
-  private 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();
+  /**
+   * Notify the owners of a change that their change is being reverted.
+   *
+   * @param bu to append the notification actions to.
+   * @param revertedChangeId to be notified.
+   * @param revertingChangeId to notify about.
+   * @param revertingChangeKey to notify about.
+   */
+  public void addChangeRevertedNotificationOps(
+      BatchUpdate bu,
+      Change.Id revertedChangeId,
+      Change.Id revertingChangeId,
+      String revertingChangeKey) {
+    bu.addOp(revertingChangeId, new ChangeRevertedNotifyOp(revertedChangeId, revertingChangeId));
+    bu.addOp(revertedChangeId, new PostRevertedMessageOp(revertingChangeKey));
   }
 
-  private class NotifyOp implements BatchUpdateOp {
-    private final Change change;
-    private final ChangeInserter ins;
+  private class ChangeRevertedNotifyOp implements BatchUpdateOp {
+    private final Change.Id revertedChangeId;
+    private final Change.Id revertingChangeId;
 
-    NotifyOp(Change change, ChangeInserter ins) {
-      this.change = change;
-      this.ins = ins;
+    ChangeRevertedNotifyOp(Change.Id revertedChangeId, Change.Id revertingChangeId) {
+      this.revertedChangeId = revertedChangeId;
+      this.revertingChangeId = revertingChangeId;
     }
 
     @Override
     public void postUpdate(PostUpdateContext ctx) throws Exception {
-      changeReverted.fire(
-          ctx.getChangeData(change), ctx.getChangeData(ins.getChange()), ctx.getWhen());
+      ChangeData revertedChange =
+          ctx.getChangeData(changeNotesFactory.createChecked(ctx.getProject(), revertedChangeId));
+      ChangeData revertingChange =
+          ctx.getChangeData(changeNotesFactory.createChecked(ctx.getProject(), revertingChangeId));
+      changeReverted.fire(revertedChange, revertingChange, ctx.getWhen());
       try {
-        RevertedSender emailSender = revertedSenderFactory.create(ctx.getProject(), change.getId());
+        RevertedSender emailSender =
+            revertedSenderFactory.create(ctx.getProject(), revertedChange.getId());
         emailSender.setFrom(ctx.getAccountId());
-        emailSender.setNotify(ctx.getNotify(change.getId()));
+        emailSender.setNotify(ctx.getNotify(revertedChangeId));
         emailSender.setMessageId(
-            messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), change.currentPatchSetId()));
+            messageIdGenerator.fromChangeUpdate(
+                ctx.getRepoView(), revertedChange.currentPatchSet().id()));
         emailSender.send();
       } catch (Exception err) {
         logger.atSevere().withCause(err).log(
-            "Cannot send email for revert change %s", change.getId());
+            "Cannot send email for revert change %s", revertedChangeId);
       }
     }
   }
 
   private class PostRevertedMessageOp implements BatchUpdateOp {
-    private final ObjectId computedChangeId;
+    private final String revertingChangeKey;
 
-    PostRevertedMessageOp(ObjectId computedChangeId) {
-      this.computedChangeId = computedChangeId;
+    PostRevertedMessageOp(String revertingChangeKey) {
+      this.revertingChangeKey = revertingChangeKey;
     }
 
     @Override
     public boolean updateChange(ChangeContext ctx) {
       cmUtil.setChangeMessage(
           ctx,
-          "Created a revert of this change as I" + computedChangeId.name(),
+          "Created a revert of this change as I" + revertingChangeKey,
           ChangeMessagesUtil.TAG_REVERT);
       return true;
     }
   }
+
+  /**
+   * Returns the parent commit for a new commit.
+   *
+   * <p>If {@code baseSha1} is provided, the method verifies it can be used as a base. If {@code
+   * baseSha1} is not provided the tip of the {@code destRef} is returned.
+   *
+   * @param project The name of the project.
+   * @param changeQuery Used for looking up the base commit.
+   * @param revWalk Used for parsing the base commit.
+   * @param destRef The destination branch.
+   * @param baseSha1 The hash of the base commit. Nullable.
+   * @return the base commit. Either the commit matching the provided hash, or the direct parent if
+   *     a hash was not provided.
+   * @throws IOException if the branch reference cannot be parsed.
+   * @throws RestApiException if the base commit cannot be fetched.
+   */
+  public static RevCommit getBaseCommit(
+      String project,
+      InternalChangeQuery changeQuery,
+      RevWalk revWalk,
+      Ref destRef,
+      @Nullable String baseSha1)
+      throws IOException, RestApiException {
+    RevCommit destRefTip = revWalk.parseCommit(destRef.getObjectId());
+    // The tip commit of the destination ref is the default base for the newly created change.
+    if (Strings.isNullOrEmpty(baseSha1)) {
+      return destRefTip;
+    }
+
+    ObjectId baseObjectId;
+    try {
+      baseObjectId = ObjectId.fromString(baseSha1);
+    } catch (InvalidObjectIdException e) {
+      throw new BadRequestException(
+          String.format("Base %s doesn't represent a valid SHA-1", baseSha1), e);
+    }
+
+    RevCommit baseCommit;
+    try {
+      baseCommit = revWalk.parseCommit(baseObjectId);
+    } catch (MissingObjectException e) {
+      throw new UnprocessableEntityException(
+          String.format("Base %s doesn't exist", baseObjectId.name()), e);
+    }
+
+    changeQuery.enforceVisibility(true);
+    List<ChangeData> changeDatas = changeQuery.byBranchCommit(project, destRef.getName(), baseSha1);
+
+    if (changeDatas.isEmpty()) {
+      if (revWalk.isMergedInto(baseCommit, destRefTip)) {
+        // The base commit is a merged commit with no change associated.
+        return baseCommit;
+      }
+      throw new UnprocessableEntityException(
+          String.format("Commit %s does not exist on branch %s", baseSha1, destRef.getName()));
+    } else if (changeDatas.size() != 1) {
+      throw new ResourceConflictException("Multiple changes found for commit " + baseSha1);
+    }
+
+    Change change = changeDatas.get(0).change();
+    if (!change.isAbandoned()) {
+      // The base commit is a valid change revision.
+      return baseCommit;
+    }
+
+    throw new ResourceConflictException(
+        String.format(
+            "Change %s with commit %s is %s",
+            change.getChangeId(), baseSha1, ChangeUtil.status(change)));
+  }
 }
diff --git a/java/com/google/gerrit/server/git/DynamicRefDbRepository.java b/java/com/google/gerrit/server/git/DynamicRefDbRepository.java
new file mode 100644
index 0000000..2e81ad4
--- /dev/null
+++ b/java/com/google/gerrit/server/git/DynamicRefDbRepository.java
@@ -0,0 +1,83 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.function.BiFunction;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.internal.storage.file.FileRepository;
+import org.eclipse.jgit.lib.RefDatabase;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.lib.RepositoryCache;
+import org.eclipse.jgit.util.FS;
+
+/** A FileRepository with a dynamic RefDatabase supplied via a BiFunction. */
+public class DynamicRefDbRepository extends FileRepository {
+  public static class FileKey extends RepositoryCache.FileKey {
+    private BiFunction<File, RefDatabase, RefDatabase> refDatabaseSupplier;
+
+    public static FileKey lenient(
+        File directory, FS fs, BiFunction<File, RefDatabase, RefDatabase> refDatabaseSupplier) {
+      final File gitdir = resolve(directory, fs);
+      return new FileKey(gitdir != null ? gitdir : directory, fs, refDatabaseSupplier);
+    }
+
+    private final FS fs;
+
+    /**
+     * @param directory exact location of the repository.
+     * @param fs the file system abstraction which will be necessary to perform certain file system
+     *     operations.
+     */
+    public FileKey(
+        File directory, FS fs, BiFunction<File, RefDatabase, RefDatabase> refDatabaseSupplier) {
+      super(canonical(directory), fs);
+      this.fs = fs;
+      this.refDatabaseSupplier = refDatabaseSupplier;
+    }
+
+    @Override
+    public Repository open(boolean mustExist) throws IOException {
+      if (mustExist && !isGitRepository(getFile(), fs))
+        throw new RepositoryNotFoundException(getFile());
+      return new DynamicRefDbRepository(getFile(), refDatabaseSupplier);
+    }
+
+    private static File canonical(File path) {
+      try {
+        return path.getCanonicalFile();
+      } catch (IOException e) {
+        return path.getAbsoluteFile();
+      }
+    }
+  }
+
+  private final File path;
+  private final BiFunction<File, RefDatabase, RefDatabase> refDatabaseSupplier;
+
+  public DynamicRefDbRepository(
+      File path, BiFunction<File, RefDatabase, RefDatabase> refDatabaseSupplier)
+      throws IOException {
+    super(path);
+    this.path = path;
+    this.refDatabaseSupplier = refDatabaseSupplier;
+  }
+
+  @Override
+  public RefDatabase getRefDatabase() {
+    return refDatabaseSupplier.apply(path, super.getRefDatabase());
+  }
+}
diff --git a/java/com/google/gerrit/server/git/GroupCollector.java b/java/com/google/gerrit/server/git/GroupCollector.java
index 5bbe5e2..455b221 100644
--- a/java/com/google/gerrit/server/git/GroupCollector.java
+++ b/java/com/google/gerrit/server/git/GroupCollector.java
@@ -27,6 +27,7 @@
 import com.google.common.collect.Sets;
 import com.google.common.collect.SortedSetMultimap;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.PatchSetUtil;
@@ -258,6 +259,7 @@
     return actual;
   }
 
+  @Nullable
   private ObjectId parseGroup(ObjectId forCommit, String group) {
     try {
       return ObjectId.fromString(group);
diff --git a/java/com/google/gerrit/server/git/LocalDiskRepositoryManager.java b/java/com/google/gerrit/server/git/LocalDiskRepositoryManager.java
index 57d37fa..5eb913d 100644
--- a/java/com/google/gerrit/server/git/LocalDiskRepositoryManager.java
+++ b/java/com/google/gerrit/server/git/LocalDiskRepositoryManager.java
@@ -20,6 +20,7 @@
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.events.LifecycleListener;
 import com.google.gerrit.lifecycle.LifecycleModule;
+import com.google.gerrit.server.cache.PerThreadRefDbCache;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.inject.Inject;
@@ -112,6 +113,7 @@
 
   private final Path basePath;
   private final Map<Project.NameKey, FileKey> fileKeyByProject = new ConcurrentHashMap<>();
+  private final boolean usePerRequestRefCache;
 
   @Inject
   LocalDiskRepositoryManager(SitePaths site, @GerritServerConfig Config cfg) {
@@ -119,6 +121,7 @@
     if (basePath == null) {
       throw new IllegalStateException("gerrit.basePath must be configured");
     }
+    usePerRequestRefCache = cfg.getBoolean("core", null, "usePerRequestRefCache", true);
   }
 
   /**
@@ -168,7 +171,13 @@
     if (isUnreasonableName(name)) {
       throw new RepositoryNotFoundException("Invalid name: " + name);
     }
-    FileKey location = FileKey.lenient(getBasePath(name).resolve(name.get()).toFile(), FS.DETECTED);
+    FileKey location =
+        usePerRequestRefCache
+            ? DynamicRefDbRepository.FileKey.lenient(
+                getBasePath(name).resolve(name.get()).toFile(),
+                FS.DETECTED,
+                (path, refDb) -> PerThreadRefDbCache.getRefDatabase(path, refDb))
+            : FileKey.lenient(getBasePath(name).resolve(name.get()).toFile(), FS.DETECTED);
     try {
       Repository repo = RepositoryCache.open(location);
       fileKeyByProject.put(name, location);
diff --git a/java/com/google/gerrit/server/git/MergeUtil.java b/java/com/google/gerrit/server/git/MergeUtil.java
index 5186cf6..7e284de 100644
--- a/java/com/google/gerrit/server/git/MergeUtil.java
+++ b/java/com/google/gerrit/server/git/MergeUtil.java
@@ -1048,6 +1048,7 @@
     }
   }
 
+  @Nullable
   public static CodeReviewCommit findAnyMergedInto(
       CodeReviewRevWalk rw, Iterable<CodeReviewCommit> commits, CodeReviewCommit tip)
       throws IOException {
diff --git a/java/com/google/gerrit/server/git/MultiProgressMonitor.java b/java/com/google/gerrit/server/git/MultiProgressMonitor.java
index b88985d..ab5c988 100644
--- a/java/com/google/gerrit/server/git/MultiProgressMonitor.java
+++ b/java/com/google/gerrit/server/git/MultiProgressMonitor.java
@@ -16,8 +16,10 @@
 
 import static com.google.gerrit.server.DeadlineChecker.getTimeoutFormatter;
 import static java.util.concurrent.TimeUnit.MILLISECONDS;
+import static java.util.concurrent.TimeUnit.MINUTES;
 import static java.util.concurrent.TimeUnit.NANOSECONDS;
 
+import com.google.common.base.CharMatcher;
 import com.google.common.base.Strings;
 import com.google.common.base.Ticker;
 import com.google.common.flogger.FluentLogger;
@@ -597,9 +599,12 @@
   }
 
   private void send(StringBuilder s) {
+    String progress = s.toString();
+    logger.atInfo().atMostEvery(1, MINUTES).log(
+        "%s", CharMatcher.javaIsoControl().removeFrom(progress));
     if (!clientDisconnected) {
       try {
-        out.write(Constants.encode(s.toString()));
+        out.write(Constants.encode(progress));
         out.flush();
       } catch (IOException e) {
         logger.atWarning().withCause(e).log(
diff --git a/java/com/google/gerrit/server/git/PermissionAwareReadOnlyRefDatabase.java b/java/com/google/gerrit/server/git/PermissionAwareReadOnlyRefDatabase.java
index f66a089..fb34753 100644
--- a/java/com/google/gerrit/server/git/PermissionAwareReadOnlyRefDatabase.java
+++ b/java/com/google/gerrit/server/git/PermissionAwareReadOnlyRefDatabase.java
@@ -82,6 +82,7 @@
     throw new UnsupportedOperationException("PermissionAwareReadOnlyRefDatabase is read-only");
   }
 
+  @Nullable
   @Override
   public Ref exactRef(String name) throws IOException {
     Ref ref = getDelegate().getRefDatabase().exactRef(name);
diff --git a/java/com/google/gerrit/server/git/SearchingChangeCacheImpl.java b/java/com/google/gerrit/server/git/SearchingChangeCacheImpl.java
index 7944913..83024e3 100644
--- a/java/com/google/gerrit/server/git/SearchingChangeCacheImpl.java
+++ b/java/com/google/gerrit/server/git/SearchingChangeCacheImpl.java
@@ -40,9 +40,9 @@
 import com.google.inject.Singleton;
 import com.google.inject.TypeLiteral;
 import com.google.inject.name.Named;
-import java.util.ArrayList;
-import java.util.Collections;
+import java.util.HashMap;
 import java.util.List;
+import java.util.Map;
 import java.util.concurrent.ExecutionException;
 import java.util.stream.Stream;
 import org.eclipse.jgit.lib.Repository;
@@ -151,14 +151,20 @@
         List<ChangeData> cds =
             queryProvider
                 .get()
-                .setRequestedFields(ChangeField.CHANGE, ChangeField.REVIEWER)
+                .setRequestedFields(ChangeField.CHANGE_SPEC, ChangeField.REVIEWER_SPEC)
                 .byProject(key);
-        List<CachedChange> result = new ArrayList<>(cds.size());
+        Map<Change.Id, CachedChange> result = new HashMap<>(cds.size());
         for (ChangeData cd : cds) {
-          result.add(
+          if (result.containsKey(cd.getId())) {
+            logger.atWarning().log(
+                "Duplicate changes returned from change query by project %s: %s, %s",
+                key, cd.change(), result.get(cd.getId()).change());
+          }
+          result.put(
+              cd.getId(),
               new AutoValue_SearchingChangeCacheImpl_CachedChange(cd.change(), cd.reviewers()));
         }
-        return Collections.unmodifiableList(result);
+        return List.copyOf(result.values());
       }
     }
   }
diff --git a/java/com/google/gerrit/server/git/WorkQueue.java b/java/com/google/gerrit/server/git/WorkQueue.java
index 3032bfe..e8b7c62 100644
--- a/java/com/google/gerrit/server/git/WorkQueue.java
+++ b/java/com/google/gerrit/server/git/WorkQueue.java
@@ -18,8 +18,10 @@
 
 import com.google.common.base.CaseFormat;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.events.LifecycleListener;
+import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.lifecycle.LifecycleModule;
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.MetricMaker;
@@ -27,6 +29,7 @@
 import com.google.gerrit.server.config.ScheduleConfig.Schedule;
 import com.google.gerrit.server.logging.LoggingContext;
 import com.google.gerrit.server.logging.LoggingContextAwareRunnable;
+import com.google.gerrit.server.plugincontext.PluginMapContext;
 import com.google.gerrit.server.util.IdGenerator;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -49,8 +52,8 @@
 import java.util.concurrent.ThreadFactory;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.TimeoutException;
-import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
 import org.eclipse.jgit.lib.Config;
 
 /** Delayed execution of tasks using a background thread pool. */
@@ -58,6 +61,30 @@
 public class WorkQueue {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
+  /**
+   * To register a TaskListener, which will be called directly before Tasks run, and directly after
+   * they complete, bind the TaskListener like this:
+   *
+   * <p><code>
+   *   bind(TaskListener.class)
+   *       .annotatedWith(Exports.named("MyListener"))
+   *       .to(MyListener.class);
+   * </code>
+   */
+  public interface TaskListener {
+    public static class NoOp implements TaskListener {
+      @Override
+      public void onStart(Task<?> task) {}
+
+      @Override
+      public void onStop(Task<?> task) {}
+    }
+
+    void onStart(Task<?> task);
+
+    void onStop(Task<?> task);
+  }
+
   public static class Lifecycle implements LifecycleListener {
     private final WorkQueue workQueue;
 
@@ -78,6 +105,7 @@
   public static class WorkQueueModule extends LifecycleModule {
     @Override
     protected void configure() {
+      DynamicMap.mapOf(binder(), WorkQueue.TaskListener.class);
       bind(WorkQueue.class);
       listener().to(Lifecycle.class);
     }
@@ -87,18 +115,32 @@
   private final IdGenerator idGenerator;
   private final MetricMaker metrics;
   private final CopyOnWriteArrayList<Executor> queues;
+  private final PluginMapContext<TaskListener> listeners;
 
   @Inject
-  WorkQueue(IdGenerator idGenerator, @GerritServerConfig Config cfg, MetricMaker metrics) {
-    this(idGenerator, Math.max(cfg.getInt("execution", "defaultThreadPoolSize", 2), 2), metrics);
+  WorkQueue(
+      IdGenerator idGenerator,
+      @GerritServerConfig Config cfg,
+      MetricMaker metrics,
+      PluginMapContext<TaskListener> listeners) {
+    this(
+        idGenerator,
+        Math.max(cfg.getInt("execution", "defaultThreadPoolSize", 2), 2),
+        metrics,
+        listeners);
   }
 
   /** Constructor to allow binding the WorkQueue more explicitly in a vhost setup. */
-  public WorkQueue(IdGenerator idGenerator, int defaultThreadPoolSize, MetricMaker metrics) {
+  public WorkQueue(
+      IdGenerator idGenerator,
+      int defaultThreadPoolSize,
+      MetricMaker metrics,
+      PluginMapContext<TaskListener> listeners) {
     this.idGenerator = idGenerator;
     this.metrics = metrics;
     this.queues = new CopyOnWriteArrayList<>();
     this.defaultQueue = createQueue(defaultThreadPoolSize, "WorkQueue", true);
+    this.listeners = listeners;
   }
 
   /** Get the default work queue, for miscellaneous tasks. */
@@ -200,6 +242,7 @@
   }
 
   /** Locate a task by its unique id, null if no task matches. */
+  @Nullable
   public Task<?> getTask(int id) {
     Task<?> result = null;
     for (Executor e : queues) {
@@ -215,6 +258,7 @@
     return result;
   }
 
+  @Nullable
   public ScheduledThreadPoolExecutor getExecutor(String queueName) {
     for (Executor e : queues) {
       if (e.queueName.equals(queueName)) {
@@ -438,6 +482,14 @@
     Collection<Task<?>> getTasks() {
       return all.values();
     }
+
+    public void onStart(Task<?> task) {
+      listeners.runEach(extension -> extension.getProvider().get().onStart(task));
+    }
+
+    public void onStop(Task<?> task) {
+      listeners.runEach(extension -> extension.getProvider().get().onStop(task));
+    }
   }
 
   private static void logUncaughtException(Thread t, Throwable e) {
@@ -474,18 +526,23 @@
      * <ol>
      *   <li>{@link #SLEEPING}: if scheduled with a non-zero delay.
      *   <li>{@link #READY}: waiting for an available worker thread.
+     *   <li>{@link #STARTING}: onStart() actively executing on a worker thread.
      *   <li>{@link #RUNNING}: actively executing on a worker thread.
+     *   <li>{@link #STOPPING}: onStop() actively executing on a worker thread.
      *   <li>{@link #DONE}: finished executing, if not periodic.
      * </ol>
      */
     public enum State {
       // Ordered like this so ordinal matches the order we would
       // prefer to see tasks sorted in: done before running,
-      // running before ready, ready before sleeping.
+      // stopping before running, running before starting,
+      // starting before ready, ready before sleeping.
       //
       DONE,
       CANCELLED,
+      STOPPING,
       RUNNING,
+      STARTING,
       READY,
       SLEEPING,
       OTHER
@@ -495,15 +552,16 @@
     private final RunnableScheduledFuture<V> task;
     private final Executor executor;
     private final int taskId;
-    private final AtomicBoolean running;
     private final Instant startTime;
 
+    // runningState is non-null when listener or task code is running in an executor thread
+    private final AtomicReference<State> runningState = new AtomicReference<>();
+
     Task(Runnable runnable, RunnableScheduledFuture<V> task, Executor executor, int taskId) {
       this.runnable = runnable;
       this.task = task;
       this.executor = executor;
       this.taskId = taskId;
-      this.running = new AtomicBoolean();
       this.startTime = Instant.now();
     }
 
@@ -514,10 +572,13 @@
     public State getState() {
       if (isCancelled()) {
         return State.CANCELLED;
+      }
+
+      State r = runningState.get();
+      if (r != null) {
+        return r;
       } else if (isDone() && !isPeriodic()) {
         return State.DONE;
-      } else if (running.get()) {
-        return State.RUNNING;
       }
 
       final long delay = getDelay(TimeUnit.MILLISECONDS);
@@ -538,14 +599,14 @@
     @Override
     public boolean cancel(boolean mayInterruptIfRunning) {
       if (task.cancel(mayInterruptIfRunning)) {
-        // Tiny abuse of running: if the task needs to know it was
-        // canceled (to clean up resources) and it hasn't started
+        // Tiny abuse of runningState: if the task needs to know it
+        // was canceled (to clean up resources) and it hasn't started
         // yet the task's run method won't execute. So we tag it
         // as running and allow it to clean up. This ensures we do
         // not invoke cancel twice.
         //
         if (runnable instanceof CancelableRunnable) {
-          if (running.compareAndSet(false, true)) {
+          if (runningState.compareAndSet(null, State.RUNNING)) {
             ((CancelableRunnable) runnable).cancel();
           } else if (runnable instanceof CanceledWhileRunning) {
             ((CanceledWhileRunning) runnable).setCanceledWhileRunning();
@@ -605,16 +666,21 @@
 
     @Override
     public void run() {
-      if (running.compareAndSet(false, true)) {
+      if (runningState.compareAndSet(null, State.STARTING)) {
         String oldThreadName = Thread.currentThread().getName();
         try {
+          executor.onStart(this);
+          runningState.set(State.RUNNING);
           Thread.currentThread().setName(oldThreadName + "[" + task.toString() + "]");
           task.run();
         } finally {
           Thread.currentThread().setName(oldThreadName);
+          runningState.set(State.STOPPING);
+          executor.onStop(this);
           if (isPeriodic()) {
-            running.set(false);
+            runningState.set(null);
           } else {
+            runningState.set(State.DONE);
             executor.remove(this);
           }
         }
diff --git a/java/com/google/gerrit/server/git/meta/TabFile.java b/java/com/google/gerrit/server/git/meta/TabFile.java
index 80570a5..5f76b39 100644
--- a/java/com/google/gerrit/server/git/meta/TabFile.java
+++ b/java/com/google/gerrit/server/git/meta/TabFile.java
@@ -17,6 +17,7 @@
 import static com.google.common.collect.ImmutableList.toImmutableList;
 
 import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.server.git.ValidationError;
 import java.io.BufferedReader;
 import java.io.IOException;
@@ -84,6 +85,7 @@
     return map;
   }
 
+  @Nullable
   protected static String asText(String left, String right, Map<String, String> entries) {
     if (entries.isEmpty()) {
       return null;
@@ -96,6 +98,7 @@
     return asText(left, right, rows);
   }
 
+  @Nullable
   protected static String asText(String left, String right, List<Row> rows) {
     if (rows.isEmpty()) {
       return null;
diff --git a/java/com/google/gerrit/server/git/meta/VersionedMetaData.java b/java/com/google/gerrit/server/git/meta/VersionedMetaData.java
index 61bd8a8..4f0bde8 100644
--- a/java/com/google/gerrit/server/git/meta/VersionedMetaData.java
+++ b/java/com/google/gerrit/server/git/meta/VersionedMetaData.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.git.meta;
 
 import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.VERSIONED_META_DATA_CHANGE;
 
 import com.google.common.base.MoreObjects;
 import com.google.common.flogger.FluentLogger;
@@ -27,6 +28,7 @@
 import com.google.gerrit.server.logging.Metadata;
 import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.logging.TraceContext.TraceTimer;
+import com.google.gerrit.server.update.context.RefUpdateContext;
 import com.google.gerrit.server.util.CommitMessageUtil;
 import java.io.BufferedReader;
 import java.io.File;
@@ -438,53 +440,55 @@
 
       private RevCommit updateRef(AnyObjectId oldId, AnyObjectId newId, String refName)
           throws IOException {
-        BatchRefUpdate bru = update.getBatch();
-        if (bru != null) {
-          bru.addCommand(new ReceiveCommand(oldId.toObjectId(), newId.toObjectId(), refName));
-          if (objInserter == null) {
-            inserter.flush();
-          }
-          revision = rw.parseCommit(newId);
-          return revision;
-        }
-
-        RefUpdate ru = db.updateRef(refName);
-        ru.setExpectedOldObjectId(oldId);
-        ru.setNewObjectId(newId);
-        ru.setRefLogIdent(update.getCommitBuilder().getAuthor());
-        String message = update.getCommitBuilder().getMessage();
-        if (message == null) {
-          message = "meta data update";
-        }
-        try (BufferedReader reader = new BufferedReader(new StringReader(message))) {
-          // read the subject line and use it as reflog message
-          ru.setRefLogMessage("commit: " + reader.readLine(), true);
-        }
-        logger.atFine().log("Saving commit '%s' on project '%s'", message.trim(), projectName);
-        inserter.flush();
-        RefUpdate.Result result = ru.update();
-        switch (result) {
-          case NEW:
-          case FAST_FORWARD:
-            revision = rw.parseCommit(ru.getNewObjectId());
-            update.fireGitRefUpdatedEvent(ru);
-            logger.atFine().log(
-                "Saved commit '%s' as revision '%s' on project '%s'",
-                message.trim(), revision.name(), projectName);
+        try (RefUpdateContext ctx = RefUpdateContext.open(VERSIONED_META_DATA_CHANGE)) {
+          BatchRefUpdate bru = update.getBatch();
+          if (bru != null) {
+            bru.addCommand(new ReceiveCommand(oldId.toObjectId(), newId.toObjectId(), refName));
+            if (objInserter == null) {
+              inserter.flush();
+            }
+            revision = rw.parseCommit(newId);
             return revision;
-          case LOCK_FAILURE:
-            throw new LockFailureException(errorMsg(ru, db.getDirectory()), ru);
-          case FORCED:
-          case IO_FAILURE:
-          case NOT_ATTEMPTED:
-          case NO_CHANGE:
-          case REJECTED:
-          case REJECTED_CURRENT_BRANCH:
-          case RENAMED:
-          case REJECTED_MISSING_OBJECT:
-          case REJECTED_OTHER_REASON:
-          default:
-            throw new GitUpdateFailureException(errorMsg(ru, db.getDirectory()), ru);
+          }
+
+          RefUpdate ru = db.updateRef(refName);
+          ru.setExpectedOldObjectId(oldId);
+          ru.setNewObjectId(newId);
+          ru.setRefLogIdent(update.getCommitBuilder().getAuthor());
+          String message = update.getCommitBuilder().getMessage();
+          if (message == null) {
+            message = "meta data update";
+          }
+          try (BufferedReader reader = new BufferedReader(new StringReader(message))) {
+            // read the subject line and use it as reflog message
+            ru.setRefLogMessage("commit: " + reader.readLine(), true);
+          }
+          logger.atFine().log("Saving commit '%s' on project '%s'", message.trim(), projectName);
+          inserter.flush();
+          RefUpdate.Result result = ru.update();
+          switch (result) {
+            case NEW:
+            case FAST_FORWARD:
+              revision = rw.parseCommit(ru.getNewObjectId());
+              update.fireGitRefUpdatedEvent(ru);
+              logger.atFine().log(
+                  "Saved commit '%s' as revision '%s' on project '%s'",
+                  message.trim(), revision.name(), projectName);
+              return revision;
+            case LOCK_FAILURE:
+              throw new LockFailureException(errorMsg(ru, db.getDirectory()), ru);
+            case FORCED:
+            case IO_FAILURE:
+            case NOT_ATTEMPTED:
+            case NO_CHANGE:
+            case REJECTED:
+            case REJECTED_CURRENT_BRANCH:
+            case RENAMED:
+            case REJECTED_MISSING_OBJECT:
+            case REJECTED_OTHER_REASON:
+            default:
+              throw new GitUpdateFailureException(errorMsg(ru, db.getDirectory()), ru);
+          }
         }
       }
     };
diff --git a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
index 0f5e3bc..2baca53 100644
--- a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
+++ b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
@@ -33,6 +33,8 @@
 import static com.google.gerrit.server.git.validators.CommitValidators.NEW_PATCHSET_PATTERN;
 import static com.google.gerrit.server.mail.MailUtil.getRecipientsFromFooters;
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.DIRECT_PUSH;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.Objects.requireNonNull;
 import static java.util.stream.Collectors.joining;
@@ -192,6 +194,8 @@
 import com.google.gerrit.server.update.SubmissionListener;
 import com.google.gerrit.server.update.SuperprojectUpdateOnSubmission;
 import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.update.context.RefUpdateContext;
+import com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType;
 import com.google.gerrit.server.util.LabelVote;
 import com.google.gerrit.server.util.MagicBranch;
 import com.google.gerrit.server.util.RequestScopePropagator;
@@ -439,6 +443,7 @@
   private MessageSender messageSender;
   private ReceiveCommitsResult.Builder result;
   private ImmutableMap<String, String> loggingTags;
+  private ImmutableList<String> transitionalPluginOptions;
 
   /** This object is for single use only. */
   private boolean used;
@@ -590,6 +595,8 @@
         useRefCache
             ? ReceivePackRefCache.withAdvertisedRefs(() -> allRefsWatcher.getAllRefs())
             : ReceivePackRefCache.noCache(receivePack.getRepository().getRefDatabase());
+    this.transitionalPluginOptions =
+        ImmutableList.copyOf(config.getStringList("plugins", null, "transitionalPushOptions"));
   }
 
   void init() {
@@ -756,13 +763,15 @@
         String pushKind = magicBranch != null && magicBranch.submit ? "direct_submit" : "magic";
         metrics.pushCount.increment(pushKind, project.getName(), getUpdateType(magicCommands));
       }
-      if (!regularCommands.isEmpty()) {
-        metrics.pushCount.increment("direct", project.getName(), getUpdateType(regularCommands));
-      }
+      try (RefUpdateContext ctx = RefUpdateContext.open(DIRECT_PUSH)) {
+        if (!regularCommands.isEmpty()) {
+          metrics.pushCount.increment("direct", project.getName(), getUpdateType(regularCommands));
+        }
 
-      if (!regularCommands.isEmpty()) {
-        handleRegularCommands(regularCommands, progress);
-        return;
+        if (!regularCommands.isEmpty()) {
+          handleRegularCommands(regularCommands, progress);
+          return;
+        }
       }
 
       boolean first = true;
@@ -883,7 +892,10 @@
                   case UPDATE:
                   case UPDATE_NONFASTFORWARD:
                     Task closeProgress = progress.beginSubTask("closed", UNKNOWN);
-                    autoCloseChanges(c, closeProgress);
+                    try (RefUpdateContext ctx =
+                        RefUpdateContext.open(RefUpdateType.AUTO_CLOSE_CHANGES)) {
+                      autoCloseChanges(c, closeProgress);
+                    }
                     closeProgress.end();
                     break;
 
@@ -1012,59 +1024,61 @@
 
   private void insertChangesAndPatchSets(
       ImmutableList<CreateRequest> newChanges, Task replaceProgress) {
-    try (TraceTimer traceTimer =
-        newTimer(
-            "insertChangesAndPatchSets", Metadata.builder().resourceCount(newChanges.size()))) {
-      ReceiveCommand magicBranchCmd = magicBranch != null ? magicBranch.cmd : null;
-      if (magicBranchCmd != null && magicBranchCmd.getResult() != NOT_ATTEMPTED) {
-        logger.atWarning().log(
-            "Skipping change updates on %s because ref update failed: %s %s",
-            project.getName(),
-            magicBranchCmd.getResult(),
-            Strings.nullToEmpty(magicBranchCmd.getMessage()));
-        return;
-      }
-      try {
-        if (!newChanges.isEmpty()) {
-          // TODO: Retry lock failures on new change insertions. The retry will
-          //  likely have to move to a higher layer to be able to achieve that
-          //  due to state that needs to be reset with each retry attempt.
-          insertChangesAndPatchSets(magicBranchCmd, newChanges, replaceProgress);
-        } else {
-          retryHelper
-              .changeUpdate(
-                  "insertPatchSets",
-                  updateFactory -> {
-                    insertChangesAndPatchSets(magicBranchCmd, newChanges, replaceProgress);
-                    return null;
-                  })
-              .defaultTimeoutMultiplier(5)
-              .call();
+    try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+      try (TraceTimer traceTimer =
+          newTimer(
+              "insertChangesAndPatchSets", Metadata.builder().resourceCount(newChanges.size()))) {
+        ReceiveCommand magicBranchCmd = magicBranch != null ? magicBranch.cmd : null;
+        if (magicBranchCmd != null && magicBranchCmd.getResult() != NOT_ATTEMPTED) {
+          logger.atWarning().log(
+              "Skipping change updates on %s because ref update failed: %s %s",
+              project.getName(),
+              magicBranchCmd.getResult(),
+              Strings.nullToEmpty(magicBranchCmd.getMessage()));
+          return;
         }
-      } catch (ResourceConflictException e) {
-        addError(e.getMessage());
-        reject(magicBranchCmd, "conflict");
-      } catch (BadRequestException | UnprocessableEntityException | AuthException e) {
-        logger.atFine().withCause(e).log("Rejecting due to client error");
-        reject(magicBranchCmd, e.getMessage());
-      } catch (RestApiException | IOException | UpdateException e) {
-        throw new StorageException("Can't insert change/patch set for " + project.getName(), e);
-      }
-
-      if (magicBranch != null && magicBranch.submit) {
         try {
-          submit(newChanges, replaceByChange.values());
+          if (!newChanges.isEmpty()) {
+            // TODO: Retry lock failures on new change insertions. The retry will
+            //  likely have to move to a higher layer to be able to achieve that
+            //  due to state that needs to be reset with each retry attempt.
+            insertChangesAndPatchSets(magicBranchCmd, newChanges, replaceProgress);
+          } else {
+            retryHelper
+                .changeUpdate(
+                    "insertPatchSets",
+                    updateFactory -> {
+                      insertChangesAndPatchSets(magicBranchCmd, newChanges, replaceProgress);
+                      return null;
+                    })
+                .defaultTimeoutMultiplier(5)
+                .call();
+          }
         } catch (ResourceConflictException e) {
           addError(e.getMessage());
           reject(magicBranchCmd, "conflict");
-        } catch (RestApiException
-            | StorageException
-            | UpdateException
-            | IOException
-            | ConfigInvalidException
-            | PermissionBackendException e) {
-          logger.atSevere().withCause(e).log("Error submitting changes to %s", project.getName());
-          reject(magicBranchCmd, "error during submit");
+        } catch (BadRequestException | UnprocessableEntityException | AuthException e) {
+          logger.atFine().withCause(e).log("Rejecting due to client error");
+          reject(magicBranchCmd, e.getMessage());
+        } catch (RestApiException | IOException | UpdateException e) {
+          throw new StorageException("Can't insert change/patch set for " + project.getName(), e);
+        }
+
+        if (magicBranch != null && magicBranch.submit) {
+          try {
+            submit(newChanges, replaceByChange.values());
+          } catch (ResourceConflictException e) {
+            addError(e.getMessage());
+            reject(magicBranchCmd, "conflict");
+          } catch (RestApiException
+              | StorageException
+              | UpdateException
+              | IOException
+              | ConfigInvalidException
+              | PermissionBackendException e) {
+            logger.atSevere().withCause(e).log("Error submitting changes to %s", project.getName());
+            reject(magicBranchCmd, "error during submit");
+          }
         }
       }
     }
@@ -1096,15 +1110,15 @@
                 publishCommentsOp.create(replace.psId, project.getNameKey()));
             Optional<ChangeNotes> changeNotes = getChangeNotes(replace.notes.getChangeId());
             if (!changeNotes.isPresent()) {
-              // If not present, no need to update attention set here since this is a
-              // new change.
+              // If not present, no need to update attention set here since this is
+              // a new change.
               continue;
             }
             List<HumanComment> drafts =
                 commentsUtil.draftByChangeAuthor(changeNotes.get(), user.getAccountId());
             if (drafts.isEmpty()) {
-              // If no comments, attention set shouldn't update since the user didn't
-              // reply.
+              // If no comments, attention set shouldn't update since the user
+              // didn't reply.
               continue;
             }
             replyAttentionSetUpdates.processAutomaticAttentionSetRulesOnReply(
@@ -2155,6 +2169,9 @@
   }
 
   private boolean isPluginPushOption(String pushOptionName) {
+    if (transitionalPluginOptions.contains(pushOptionName)) {
+      return true;
+    }
     return StreamSupport.stream(pluginPushOptions.entries().spliterator(), /* parallel= */ false)
         .anyMatch(e -> pushOptionName.equals(e.getPluginName() + "~" + e.get().getName()));
   }
diff --git a/java/com/google/gerrit/server/git/receive/ReceiveCommitsAdvertiseRefsHook.java b/java/com/google/gerrit/server/git/receive/ReceiveCommitsAdvertiseRefsHook.java
index e545c70..7c22bd8 100644
--- a/java/com/google/gerrit/server/git/receive/ReceiveCommitsAdvertiseRefsHook.java
+++ b/java/com/google/gerrit/server/git/receive/ReceiveCommitsAdvertiseRefsHook.java
@@ -111,10 +111,10 @@
               .get()
               .setRequestedFields(
                   // Required for ChangeIsVisibleToPrdicate.
-                  ChangeField.CHANGE,
-                  ChangeField.REVIEWER,
+                  ChangeField.CHANGE_SPEC,
+                  ChangeField.REVIEWER_SPEC,
                   // Required during advertiseOpenChanges.
-                  ChangeField.PATCH_SET)
+                  ChangeField.PATCH_SET_SPEC)
               .enforceVisibility(true)
               .setLimit(limit)
               .query(
diff --git a/java/com/google/gerrit/server/git/receive/ReplaceOp.java b/java/com/google/gerrit/server/git/receive/ReplaceOp.java
index 644f82e..0e17342 100644
--- a/java/com/google/gerrit/server/git/receive/ReplaceOp.java
+++ b/java/com/google/gerrit/server/git/receive/ReplaceOp.java
@@ -405,7 +405,7 @@
 
     // Ignore failures for reasons like the reviewer being inactive or being unable to see the
     // change. See discussion in ChangeInserter.
-    input.otherFailureBehavior = ReviewerModifier.FailureBehavior.IGNORE;
+    input.otherFailureBehavior = ReviewerModifier.FailureBehavior.IGNORE_EXCEPT_NOT_FOUND;
 
     return input;
   }
@@ -441,6 +441,7 @@
         update, message.toString(), ChangeMessagesUtil.uploadedPatchSetTag(workInProgress));
   }
 
+  @Nullable
   private String changeKindMessage(ChangeKind changeKind) {
     switch (changeKind) {
       case MERGE_FIRST_PARENT_UPDATE:
@@ -509,7 +510,9 @@
             ctx,
             newPatchSet,
             mailMessage,
-            approvalCopierResult.outdatedApprovals(),
+            approvalCopierResult.outdatedApprovals().stream()
+                .map(ApprovalCopier.Result.PatchSetApprovalData::patchSetApproval)
+                .collect(toImmutableSet()),
             Streams.concat(
                     oldRecipients.getReviewers().stream(),
                     reviewerAdditions.flattenResults(ReviewerOp.Result::addedReviewers).stream()
@@ -594,6 +597,7 @@
     return Optional.of(
         "The following approvals got outdated and were removed:\n"
             + approvalCopierResult.outdatedApprovals().stream()
+                .map(ApprovalCopier.Result.PatchSetApprovalData::patchSetApproval)
                 .map(
                     outdatedApproval ->
                         String.format(
@@ -624,6 +628,7 @@
     return cmd;
   }
 
+  @Nullable
   private static String findMergedInto(Context ctx, String first, RevCommit commit) {
     try {
       RevWalk rw = ctx.getRevWalk();
diff --git a/java/com/google/gerrit/server/group/GroupResolver.java b/java/com/google/gerrit/server/group/GroupResolver.java
index 546614c..001a153 100644
--- a/java/com/google/gerrit/server/group/GroupResolver.java
+++ b/java/com/google/gerrit/server/group/GroupResolver.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.group;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.entities.GroupReference;
@@ -84,6 +85,7 @@
    * @param id ID of the group, can be a group UUID, a group name or a legacy group ID
    * @return the group, null if no group is found for the given group ID
    */
+  @Nullable
   public GroupDescription.Basic parseId(String id) {
     logger.atFine().log("Parsing group %s", id);
 
diff --git a/java/com/google/gerrit/server/group/SystemGroupBackend.java b/java/com/google/gerrit/server/group/SystemGroupBackend.java
index 5a9b9e5..0471acc 100644
--- a/java/com/google/gerrit/server/group/SystemGroupBackend.java
+++ b/java/com/google/gerrit/server/group/SystemGroupBackend.java
@@ -21,6 +21,7 @@
 import com.google.common.base.MoreObjects;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.entities.GroupReference;
@@ -140,6 +141,7 @@
     return isSystemGroup(uuid);
   }
 
+  @Nullable
   @Override
   public GroupDescription.Basic get(AccountGroup.UUID uuid) {
     final GroupReference ref = uuids.get(uuid);
@@ -157,11 +159,13 @@
         return ref.getUUID();
       }
 
+      @Nullable
       @Override
       public String getUrl() {
         return null;
       }
 
+      @Nullable
       @Override
       public String getEmailAddress() {
         return null;
diff --git a/java/com/google/gerrit/server/group/db/GroupsUpdate.java b/java/com/google/gerrit/server/group/db/GroupsUpdate.java
index c0c934b..14f8825 100644
--- a/java/com/google/gerrit/server/group/db/GroupsUpdate.java
+++ b/java/com/google/gerrit/server/group/db/GroupsUpdate.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.group.db;
 
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.GROUPS_UPDATE;
+
 import com.google.auto.value.AutoValue;
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Throwables;
@@ -45,6 +47,7 @@
 import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.logging.TraceContext.TraceTimer;
 import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.context.RefUpdateContext;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
@@ -115,7 +118,6 @@
   private final RetryHelper retryHelper;
 
   @AssistedInject
-  @SuppressWarnings("BindingAnnotationWithoutInject")
   GroupsUpdate(
       GitRepositoryManager repoManager,
       AllUsersName allUsersName,
@@ -150,7 +152,6 @@
   }
 
   @AssistedInject
-  @SuppressWarnings("BindingAnnotationWithoutInject")
   GroupsUpdate(
       GitRepositoryManager repoManager,
       AllUsersName allUsersName,
@@ -185,7 +186,6 @@
         Optional.of(currentUser));
   }
 
-  @SuppressWarnings("BindingAnnotationWithoutInject")
   private GroupsUpdate(
       GitRepositoryManager repoManager,
       AllUsersName allUsersName,
@@ -308,16 +308,18 @@
   private InternalGroup createGroupInNoteDbWithRetry(
       InternalGroupCreation groupCreation, GroupDelta groupDelta)
       throws IOException, ConfigInvalidException, DuplicateKeyException {
-    try {
-      return retryHelper
-          .groupUpdate("createGroup", () -> createGroupInNoteDb(groupCreation, groupDelta))
-          .call();
-    } catch (Exception e) {
-      Throwables.throwIfUnchecked(e);
-      Throwables.throwIfInstanceOf(e, IOException.class);
-      Throwables.throwIfInstanceOf(e, ConfigInvalidException.class);
-      Throwables.throwIfInstanceOf(e, DuplicateKeyException.class);
-      throw new IOException(e);
+    try (RefUpdateContext ctx = RefUpdateContext.open(GROUPS_UPDATE)) {
+      try {
+        return retryHelper
+            .groupUpdate("createGroup", () -> createGroupInNoteDb(groupCreation, groupDelta))
+            .call();
+      } catch (Exception e) {
+        Throwables.throwIfUnchecked(e);
+        Throwables.throwIfInstanceOf(e, IOException.class);
+        Throwables.throwIfInstanceOf(e, ConfigInvalidException.class);
+        Throwables.throwIfInstanceOf(e, DuplicateKeyException.class);
+        throw new IOException(e);
+      }
     }
   }
 
@@ -364,30 +366,32 @@
   @VisibleForTesting
   public UpdateResult updateGroupInNoteDb(AccountGroup.UUID groupUuid, GroupDelta groupDelta)
       throws IOException, ConfigInvalidException, DuplicateKeyException, NoSuchGroupException {
-    try (Repository allUsersRepo = repoManager.openRepository(allUsersName)) {
-      GroupConfig groupConfig = GroupConfig.loadForGroup(allUsersName, allUsersRepo, groupUuid);
-      groupConfig.setGroupDelta(groupDelta, auditLogFormatter);
-      if (!groupConfig.getLoadedGroup().isPresent()) {
-        throw new NoSuchGroupException(groupUuid);
+    try (RefUpdateContext ctx = RefUpdateContext.open(GROUPS_UPDATE)) {
+      try (Repository allUsersRepo = repoManager.openRepository(allUsersName)) {
+        GroupConfig groupConfig = GroupConfig.loadForGroup(allUsersName, allUsersRepo, groupUuid);
+        groupConfig.setGroupDelta(groupDelta, auditLogFormatter);
+        if (!groupConfig.getLoadedGroup().isPresent()) {
+          throw new NoSuchGroupException(groupUuid);
+        }
+
+        InternalGroup originalGroup = groupConfig.getLoadedGroup().get();
+        GroupNameNotes groupNameNotes = null;
+        if (groupDelta.getName().isPresent()) {
+          AccountGroup.NameKey oldName = originalGroup.getNameKey();
+          AccountGroup.NameKey newName = groupDelta.getName().get();
+          groupNameNotes =
+              GroupNameNotes.forRename(allUsersName, allUsersRepo, groupUuid, oldName, newName);
+        }
+
+        commit(allUsersRepo, groupConfig, groupNameNotes);
+
+        InternalGroup updatedGroup =
+            groupConfig
+                .getLoadedGroup()
+                .orElseThrow(
+                    () -> new IllegalStateException("Updated group wasn't automatically loaded"));
+        return getUpdateResult(originalGroup, updatedGroup);
       }
-
-      InternalGroup originalGroup = groupConfig.getLoadedGroup().get();
-      GroupNameNotes groupNameNotes = null;
-      if (groupDelta.getName().isPresent()) {
-        AccountGroup.NameKey oldName = originalGroup.getNameKey();
-        AccountGroup.NameKey newName = groupDelta.getName().get();
-        groupNameNotes =
-            GroupNameNotes.forRename(allUsersName, allUsersRepo, groupUuid, oldName, newName);
-      }
-
-      commit(allUsersRepo, groupConfig, groupNameNotes);
-
-      InternalGroup updatedGroup =
-          groupConfig
-              .getLoadedGroup()
-              .orElseThrow(
-                  () -> new IllegalStateException("Updated group wasn't automatically loaded"));
-      return getUpdateResult(originalGroup, updatedGroup);
     }
   }
 
diff --git a/java/com/google/gerrit/server/group/db/testing/BUILD b/java/com/google/gerrit/server/group/db/testing/BUILD
index 8f33f98..a4f49e9 100644
--- a/java/com/google/gerrit/server/group/db/testing/BUILD
+++ b/java/com/google/gerrit/server/group/db/testing/BUILD
@@ -8,6 +8,7 @@
     srcs = glob(["*.java"]),
     deps = [
         "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/testing:test-ref-update-context",
         "//lib:guava",
         "//lib:jgit",
         "//lib:jgit-junit",
diff --git a/java/com/google/gerrit/server/group/db/testing/GroupTestUtil.java b/java/com/google/gerrit/server/group/db/testing/GroupTestUtil.java
index fa06281..e36ccf0 100644
--- a/java/com/google/gerrit/server/group/db/testing/GroupTestUtil.java
+++ b/java/com/google/gerrit/server/group/db/testing/GroupTestUtil.java
@@ -14,8 +14,11 @@
 
 package com.google.gerrit.server.group.db.testing;
 
+import static com.google.gerrit.testing.TestActionRefUpdateContext.openTestRefUpdateContext;
+
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.update.context.RefUpdateContext;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.Ref;
@@ -45,25 +48,27 @@
       String fileName,
       String contents)
       throws Exception {
-    try (RevWalk rw = new RevWalk(allUsersRepo);
-        TestRepository<Repository> testRepository = new TestRepository<>(allUsersRepo, rw)) {
-      TestRepository<Repository>.CommitBuilder builder =
-          testRepository
-              .branch(refName)
-              .commit()
-              .add(fileName, contents)
-              .message("update group file")
-              .author(serverIdent)
-              .committer(serverIdent);
+    try (RefUpdateContext ctx = openTestRefUpdateContext()) {
+      try (RevWalk rw = new RevWalk(allUsersRepo);
+          TestRepository<Repository> testRepository = new TestRepository<>(allUsersRepo, rw)) {
+        TestRepository<Repository>.CommitBuilder builder =
+            testRepository
+                .branch(refName)
+                .commit()
+                .add(fileName, contents)
+                .message("update group file")
+                .author(serverIdent)
+                .committer(serverIdent);
 
-      Ref ref = allUsersRepo.exactRef(refName);
-      if (ref != null) {
-        RevCommit c = rw.parseCommit(ref.getObjectId());
-        if (c != null) {
-          builder.parent(c);
+        Ref ref = allUsersRepo.exactRef(refName);
+        if (ref != null) {
+          RevCommit c = rw.parseCommit(ref.getObjectId());
+          if (c != null) {
+            builder.parent(c);
+          }
         }
+        builder.create();
       }
-      builder.create();
     }
   }
 
diff --git a/java/com/google/gerrit/server/group/testing/BUILD b/java/com/google/gerrit/server/group/testing/BUILD
index 77bb777..bb2b20d 100644
--- a/java/com/google/gerrit/server/group/testing/BUILD
+++ b/java/com/google/gerrit/server/group/testing/BUILD
@@ -7,6 +7,7 @@
     testonly = True,
     srcs = glob(["*.java"]),
     deps = [
+        "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/server",
         "//lib:guava",
diff --git a/java/com/google/gerrit/server/group/testing/TestGroupBackend.java b/java/com/google/gerrit/server/group/testing/TestGroupBackend.java
index 2d9c798..1f3dbcb 100644
--- a/java/com/google/gerrit/server/group/testing/TestGroupBackend.java
+++ b/java/com/google/gerrit/server/group/testing/TestGroupBackend.java
@@ -18,6 +18,7 @@
 import static java.util.Objects.requireNonNull;
 
 import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.GroupDescription;
@@ -32,7 +33,7 @@
 
 /** Implementation of GroupBackend for tests. */
 public class TestGroupBackend implements GroupBackend {
-  private static final String PREFIX = "testbackend:";
+  public static final String PREFIX = "testbackend:";
 
   private final Map<AccountGroup.UUID, GroupDescription.Basic> groups = new HashMap<>();
   private final Map<Account.Id, GroupMembership> memberships = new HashMap<>();
@@ -72,11 +73,13 @@
           }
 
           @Override
+          @Nullable
           public String getEmailAddress() {
             return null;
           }
 
           @Override
+          @Nullable
           public String getUrl() {
             return null;
           }
@@ -116,6 +119,7 @@
     return false;
   }
 
+  @Nullable
   @Override
   public GroupDescription.Basic get(AccountGroup.UUID uuid) {
     return uuid == null ? null : groups.get(uuid);
diff --git a/java/com/google/gerrit/server/index/IndexUtils.java b/java/com/google/gerrit/server/index/IndexUtils.java
index 80cc463..352d376 100644
--- a/java/com/google/gerrit/server/index/IndexUtils.java
+++ b/java/com/google/gerrit/server/index/IndexUtils.java
@@ -14,9 +14,9 @@
 
 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_STR;
-import static com.google.gerrit.server.index.change.ChangeField.PROJECT;
+import static com.google.gerrit.server.index.change.ChangeField.CHANGE_SPEC;
+import static com.google.gerrit.server.index.change.ChangeField.NUMERIC_ID_STR_SPEC;
+import static com.google.gerrit.server.index.change.ChangeField.PROJECT_SPEC;
 
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Sets;
@@ -77,14 +77,14 @@
     // change ID and project, which can either come via the Change field or
     // separate fields.
     Set<String> fs = opts.fields();
-    if (fs.contains(CHANGE.getName())) {
+    if (fs.contains(CHANGE_SPEC.getName())) {
       // A Change is always sufficient.
       return fs;
     }
-    if (fs.contains(PROJECT.getName()) && fs.contains(LEGACY_ID_STR.getName())) {
+    if (fs.contains(PROJECT_SPEC.getName()) && fs.contains(NUMERIC_ID_STR_SPEC.getName())) {
       return fs;
     }
-    return Sets.union(fs, ImmutableSet.of(LEGACY_ID_STR.getName(), PROJECT.getName()));
+    return Sets.union(fs, ImmutableSet.of(NUMERIC_ID_STR_SPEC.getName(), PROJECT_SPEC.getName()));
   }
 
   /**
@@ -116,9 +116,9 @@
    */
   public static Set<String> projectFields(QueryOptions opts) {
     Set<String> fs = opts.fields();
-    return fs.contains(ProjectField.NAME.getName())
+    return fs.contains(ProjectField.NAME_SPEC.getName())
         ? fs
-        : Sets.union(fs, ImmutableSet.of(ProjectField.NAME.getName()));
+        : Sets.union(fs, ImmutableSet.of(ProjectField.NAME_SPEC.getName()));
   }
 
   private IndexUtils() {
diff --git a/java/com/google/gerrit/server/index/account/AccountField.java b/java/com/google/gerrit/server/index/account/AccountField.java
index c802205..e675003 100644
--- a/java/com/google/gerrit/server/index/account/AccountField.java
+++ b/java/com/google/gerrit/server/index/account/AccountField.java
@@ -66,7 +66,8 @@
    * External IDs.
    *
    * <p>This field includes secondary emails. Use this field only if the current user is allowed to
-   * see secondary emails (requires the {@link GlobalCapability#MODIFY_ACCOUNT} capability).
+   * see secondary emails (requires the {@link GlobalCapability#VIEW_SECONDARY_EMAILS} capability or
+   * the {@link GlobalCapability#MODIFY_ACCOUNT} capability).
    */
   public static final IndexedField<AccountState, Iterable<String>> EXTERNAL_ID_FIELD =
       IndexedField.<AccountState>iterableStringBuilder("ExternalId")
@@ -80,8 +81,9 @@
    * Fuzzy prefix match on name and email parts.
    *
    * <p>This field includes parts from the secondary emails. Use this field only if the current user
-   * is allowed to see secondary emails (requires the {@link GlobalCapability#MODIFY_ACCOUNT}
-   * capability).
+   * is allowed to see secondary emails (requires requires the {@link
+   * GlobalCapability#VIEW_SECONDARY_EMAILS} capability or the {@link
+   * GlobalCapability#MODIFY_ACCOUNT} capability).
    *
    * <p>Use the {@link AccountField#NAME_PART_NO_SECONDARY_EMAIL_SPEC} if the current user can't see
    * secondary emails.
@@ -111,9 +113,7 @@
       NAME_PART_NO_SECONDARY_EMAIL_SPEC = NAME_PART_NO_SECONDARY_EMAIL_FIELD.prefix("name2");
 
   public static final IndexedField<AccountState, String> FULL_NAME_FIELD =
-      IndexedField.<AccountState>stringBuilder("FullName")
-          .required()
-          .build(a -> a.account().fullName());
+      IndexedField.<AccountState>stringBuilder("FullName").build(a -> a.account().fullName());
 
   public static final IndexedField<AccountState, String>.SearchSpec FULL_NAME_SPEC =
       FULL_NAME_FIELD.exact("full_name");
@@ -152,7 +152,7 @@
           .build(
               a -> {
                 String preferredEmail = a.account().preferredEmail();
-                return preferredEmail != null ? preferredEmail.toLowerCase() : null;
+                return preferredEmail != null ? preferredEmail.toLowerCase(Locale.US) : null;
               });
 
   public static final IndexedField<AccountState, String>.SearchSpec
diff --git a/java/com/google/gerrit/server/index/account/AccountIndex.java b/java/com/google/gerrit/server/index/account/AccountIndex.java
index ca7264c..66b85af 100644
--- a/java/com/google/gerrit/server/index/account/AccountIndex.java
+++ b/java/com/google/gerrit/server/index/account/AccountIndex.java
@@ -20,6 +20,7 @@
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.query.account.AccountPredicates;
+import java.util.function.Function;
 
 /**
  * Index for Gerrit accounts. This class is mainly used for typing the generic parent class that
@@ -32,4 +33,6 @@
   default Predicate<AccountState> keyPredicate(Account.Id id) {
     return AccountPredicates.id(getSchema(), id);
   }
+
+  Function<AccountState, Account.Id> ENTITY_TO_KEY = (a) -> a.account().id();
 }
diff --git a/java/com/google/gerrit/server/index/account/AccountSchemaDefinitions.java b/java/com/google/gerrit/server/index/account/AccountSchemaDefinitions.java
index 31fbf36..8e7d964 100644
--- a/java/com/google/gerrit/server/index/account/AccountSchemaDefinitions.java
+++ b/java/com/google/gerrit/server/index/account/AccountSchemaDefinitions.java
@@ -34,7 +34,6 @@
   static final Schema<AccountState> V8 =
       schema(
           /* version= */ 8,
-          ImmutableList.of(),
           ImmutableList.of(
               AccountField.ID_FIELD,
               AccountField.ACTIVE_FIELD,
diff --git a/java/com/google/gerrit/server/index/change/AllChangesIndexer.java b/java/com/google/gerrit/server/index/change/AllChangesIndexer.java
index ace3d6c..4f411a2 100644
--- a/java/com/google/gerrit/server/index/change/AllChangesIndexer.java
+++ b/java/com/google/gerrit/server/index/change/AllChangesIndexer.java
@@ -27,6 +27,7 @@
 import com.google.common.util.concurrent.ListenableFuture;
 import com.google.common.util.concurrent.ListeningExecutorService;
 import com.google.common.util.concurrent.UncheckedExecutionException;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.index.SiteIndexer;
@@ -117,6 +118,11 @@
         ImmutableMap<Change.Id, ObjectId> metaIdByChange) {
       return new AutoValue_AllChangesIndexer_ProjectSlice(name, slice, slices, metaIdByChange);
     }
+
+    private static ProjectSlice oneSlice(
+        Project.NameKey name, ImmutableMap<Change.Id, ObjectId> metaIdByChange) {
+      return new AutoValue_AllChangesIndexer_ProjectSlice(name, 0, 1, metaIdByChange);
+    }
   }
 
   @Override
@@ -180,50 +186,39 @@
     return Result.create(sw, ok.get(), nDone, nFailed);
   }
 
+  @Nullable
   public Callable<Void> reindexProject(
       ChangeIndexer indexer, Project.NameKey project, Task done, Task failed) {
     try (Repository repo = repoManager.openRepository(project)) {
-      return reindexProject(
-          indexer, project, 0, 1, ChangeNotes.Factory.scanChangeIds(repo), done, failed);
+      return reindexProjectSlice(
+          indexer,
+          ProjectSlice.oneSlice(project, ChangeNotes.Factory.scanChangeIds(repo)),
+          done,
+          failed);
     } catch (IOException e) {
       logger.atSevere().log("%s", e.getMessage());
       return null;
     }
   }
 
-  public Callable<Void> reindexProject(
-      ChangeIndexer indexer,
-      Project.NameKey project,
-      int slice,
-      int slices,
-      ImmutableMap<Change.Id, ObjectId> metaIdByChange,
-      Task done,
-      Task failed) {
-    return new ProjectIndexer(indexer, project, slice, slices, metaIdByChange, done, failed);
+  public Callable<Void> reindexProjectSlice(
+      ChangeIndexer indexer, ProjectSlice projectSlice, Task done, Task failed) {
+    return new ProjectSliceIndexer(indexer, projectSlice, done, failed);
   }
 
-  private class ProjectIndexer implements Callable<Void> {
+  private class ProjectSliceIndexer implements Callable<Void> {
     private final ChangeIndexer indexer;
-    private final Project.NameKey project;
-    private final int slice;
-    private final int slices;
-    private final ImmutableMap<Change.Id, ObjectId> metaIdByChange;
+    private final ProjectSlice projectSlice;
     private final ProgressMonitor done;
     private final ProgressMonitor failed;
 
-    private ProjectIndexer(
+    private ProjectSliceIndexer(
         ChangeIndexer indexer,
-        Project.NameKey project,
-        int slice,
-        int slices,
-        ImmutableMap<Change.Id, ObjectId> metaIdByChange,
+        ProjectSlice projectSlice,
         ProgressMonitor done,
         ProgressMonitor failed) {
       this.indexer = indexer;
-      this.project = project;
-      this.slice = slice;
-      this.slices = slices;
-      this.metaIdByChange = metaIdByChange;
+      this.projectSlice = projectSlice;
       this.done = done;
       this.failed = failed;
     }
@@ -237,7 +232,10 @@
       // but the goal is to invalidate that cache as infrequently as we possibly can. And besides,
       // we don't have concrete proof that improving packfile locality would help.
       notesFactory
-          .scan(metaIdByChange, project, id -> (id.get() % slices) == slice)
+          .scan(
+              projectSlice.metaIdByChange(),
+              projectSlice.name(),
+              id -> (id.get() % projectSlice.slices()) == projectSlice.slice())
           .forEach(r -> index(r));
       OnlineReindexMode.end();
       return null;
@@ -276,10 +274,15 @@
 
     @Override
     public String toString() {
-      if (slices == 1) {
-        return "Index all changes of project " + project.get();
+      if (projectSlice.slices() == 1) {
+        return "Index all changes of project " + projectSlice.name();
       }
-      return "Index changes slice " + slice + "/" + slices + " of project " + project.get();
+      return "Index changes slice "
+          + projectSlice.slice()
+          + "/"
+          + projectSlice.slices()
+          + " of project "
+          + projectSlice.name();
     }
   }
 
@@ -347,7 +350,7 @@
           int size = metaIdByChange.size();
           if (size > 0) {
             changeCount.addAndGet(size);
-            int slices = 1 + size / PROJECT_SLICE_MAX_REFS;
+            int slices = 1 + (size - 1) / PROJECT_SLICE_MAX_REFS;
             if (slices > 1) {
               verboseWriter.println(
                   "Submitting " + name + " for indexing in " + slices + " slices");
@@ -360,12 +363,9 @@
               ProjectSlice projectSlice = ProjectSlice.create(name, slice, slices, metaIdByChange);
               ListenableFuture<?> future =
                   executor.submit(
-                      reindexProject(
+                      reindexProjectSlice(
                           indexerFactory.create(executor, index),
-                          name,
-                          slice,
-                          slices,
-                          projectSlice.metaIdByChange(),
+                          projectSlice,
                           doneTask,
                           failedTask));
               String description = "project " + name + " (" + slice + "/" + slices + ")";
diff --git a/java/com/google/gerrit/server/index/change/ChangeField.java b/java/com/google/gerrit/server/index/change/ChangeField.java
index d3f6268..7057ff7 100644
--- a/java/com/google/gerrit/server/index/change/ChangeField.java
+++ b/java/com/google/gerrit/server/index/change/ChangeField.java
@@ -18,13 +18,6 @@
 import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.common.collect.ImmutableListMultimap.toImmutableListMultimap;
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
-import static com.google.gerrit.index.FieldDef.exact;
-import static com.google.gerrit.index.FieldDef.fullText;
-import static com.google.gerrit.index.FieldDef.intRange;
-import static com.google.gerrit.index.FieldDef.integer;
-import static com.google.gerrit.index.FieldDef.prefix;
-import static com.google.gerrit.index.FieldDef.storedOnly;
-import static com.google.gerrit.index.FieldDef.timestamp;
 import static com.google.gerrit.server.util.AttentionSetUtil.additionsOnly;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.stream.Collectors.joining;
@@ -44,6 +37,7 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.common.io.Files;
 import com.google.common.primitives.Longs;
+import com.google.common.reflect.TypeToken;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Address;
@@ -61,15 +55,16 @@
 import com.google.gerrit.entities.converter.PatchSetApprovalProtoConverter;
 import com.google.gerrit.entities.converter.PatchSetProtoConverter;
 import com.google.gerrit.entities.converter.ProtoConverter;
-import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.IndexedField;
 import com.google.gerrit.index.RefState;
 import com.google.gerrit.index.SchemaFieldDefs;
 import com.google.gerrit.index.SchemaUtil;
 import com.google.gerrit.json.OutputFormat;
-import com.google.gerrit.proto.Protos;
+import com.google.gerrit.proto.Entities;
 import com.google.gerrit.server.ReviewerByEmailSet;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.StarredChangesUtil;
+import com.google.gerrit.server.cache.proto.Cache;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.index.change.StalenessChecker.RefStatePattern;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
@@ -127,69 +122,118 @@
 
   // TODO: Rename LEGACY_ID to NUMERIC_ID
   /** Legacy change ID. */
-  public static final FieldDef<ChangeData, String> LEGACY_ID_STR =
-      exact("legacy_id_str").stored().build(cd -> String.valueOf(cd.getVirtualId().get()));
+  public static final IndexedField<ChangeData, String> NUMERIC_ID_STR_FIELD =
+      IndexedField.<ChangeData>stringBuilder("NumericIdStr")
+          .stored()
+          .required()
+          // The numeric change id is integer in string form
+          .size(10)
+          .build(cd -> String.valueOf(cd.getVirtualId().get()));
+
+  public static final IndexedField<ChangeData, String>.SearchSpec NUMERIC_ID_STR_SPEC =
+      NUMERIC_ID_STR_FIELD.exact("legacy_id_str");
 
   /** Newer style Change-Id key. */
-  public static final FieldDef<ChangeData, String> ID =
-      prefix(ChangeQueryBuilder.FIELD_CHANGE_ID).build(changeGetter(c -> c.getKey().get()));
+  public static final IndexedField<ChangeData, String> CHANGE_ID_FIELD =
+      IndexedField.<ChangeData>stringBuilder("ChangeId")
+          .required()
+          // The new style key is in form Isha1
+          .size(41)
+          .build(changeGetter(c -> c.getKey().get()));
+
+  public static final IndexedField<ChangeData, String>.SearchSpec CHANGE_ID_SPEC =
+      CHANGE_ID_FIELD.prefix(ChangeQueryBuilder.FIELD_CHANGE_ID);
 
   /** Change status string, in the same format as {@code status:}. */
-  public static final FieldDef<ChangeData, String> STATUS =
-      exact(ChangeQueryBuilder.FIELD_STATUS)
+  public static final IndexedField<ChangeData, String> STATUS_FIELD =
+      IndexedField.<ChangeData>stringBuilder("Status")
+          .required()
+          .size(20)
           .build(changeGetter(c -> ChangeStatusPredicate.canonicalize(c.getStatus())));
 
+  public static final IndexedField<ChangeData, String>.SearchSpec STATUS_SPEC =
+      STATUS_FIELD.exact(ChangeQueryBuilder.FIELD_STATUS);
+
   /** Project containing the change. */
-  public static final FieldDef<ChangeData, String> PROJECT =
-      exact(ChangeQueryBuilder.FIELD_PROJECT)
+  public static final IndexedField<ChangeData, String> PROJECT_FIELD =
+      IndexedField.<ChangeData>stringBuilder("Project")
+          .required()
           .stored()
+          .size(200)
           .build(changeGetter(c -> c.getProject().get()));
 
+  public static final IndexedField<ChangeData, String>.SearchSpec PROJECT_SPEC =
+      PROJECT_FIELD.exact(ChangeQueryBuilder.FIELD_PROJECT);
+
   /** Project containing the change, as a prefix field. */
-  public static final FieldDef<ChangeData, String> PROJECTS =
-      prefix(ChangeQueryBuilder.FIELD_PROJECTS).build(changeGetter(c -> c.getProject().get()));
+  public static final IndexedField<ChangeData, String>.SearchSpec PROJECTS_SPEC =
+      PROJECT_FIELD.prefix(ChangeQueryBuilder.FIELD_PROJECTS);
 
   /** Reference (aka branch) the change will submit onto. */
-  public static final FieldDef<ChangeData, String> REF =
-      exact(ChangeQueryBuilder.FIELD_REF).build(changeGetter(c -> c.getDest().branch()));
+  public static final IndexedField<ChangeData, String> REF_FIELD =
+      IndexedField.<ChangeData>stringBuilder("Ref")
+          .required()
+          .size(300)
+          .build(changeGetter(c -> c.getDest().branch()));
+
+  public static final IndexedField<ChangeData, String>.SearchSpec REF_SPEC =
+      REF_FIELD.exact(ChangeQueryBuilder.FIELD_REF);
 
   /** Topic, a short annotation on the branch. */
-  public static final FieldDef<ChangeData, String> EXACT_TOPIC =
-      exact("topic4").build(ChangeField::getTopic);
+  public static final IndexedField<ChangeData, String> TOPIC_FIELD =
+      IndexedField.<ChangeData>stringBuilder("Topic").size(500).build(ChangeField::getTopic);
+
+  public static final IndexedField<ChangeData, String>.SearchSpec EXACT_TOPIC =
+      TOPIC_FIELD.exact("topic4");
 
   /** Topic, a short annotation on the branch. */
-  public static final FieldDef<ChangeData, String> FUZZY_TOPIC =
-      fullText("topic5").build(ChangeField::getTopic);
+  public static final IndexedField<ChangeData, String>.SearchSpec FUZZY_TOPIC =
+      TOPIC_FIELD.fullText("topic5");
 
   /** Topic, a short annotation on the branch. */
-  public static final FieldDef<ChangeData, String> PREFIX_TOPIC =
-      prefix("topic6").build(ChangeField::getTopic);
+  public static final IndexedField<ChangeData, String>.SearchSpec PREFIX_TOPIC =
+      TOPIC_FIELD.prefix("topic6");
 
-  /** Submission id assigned by MergeOp. */
-  public static final FieldDef<ChangeData, String> SUBMISSIONID =
-      exact(ChangeQueryBuilder.FIELD_SUBMISSIONID).build(changeGetter(Change::getSubmissionId));
+  /** {@link com.google.gerrit.entities.SubmissionId} assigned by MergeOp. */
+  public static final IndexedField<ChangeData, String> SUBMISSIONID_FIELD =
+      IndexedField.<ChangeData>stringBuilder("SubmissionId")
+          .size(500)
+          .build(changeGetter(Change::getSubmissionId));
+
+  public static final IndexedField<ChangeData, String>.SearchSpec SUBMISSIONID_SPEC =
+      SUBMISSIONID_FIELD.exact(ChangeQueryBuilder.FIELD_SUBMISSIONID);
 
   /** Last update time since January 1, 1970. */
   // TODO(issue-15518): Migrate type for timestamp index fields from Timestamp to Instant
-  public static final FieldDef<ChangeData, Timestamp> UPDATED =
-      timestamp("updated2")
+  public static final IndexedField<ChangeData, Timestamp> UPDATED_FIELD =
+      IndexedField.<ChangeData>timestampBuilder("LastUpdated")
           .stored()
           .build(changeGetter(change -> Timestamp.from(change.getLastUpdatedOn())));
 
+  public static final IndexedField<ChangeData, Timestamp>.SearchSpec UPDATED_SPEC =
+      UPDATED_FIELD.timestamp("updated2");
+
   /** When this change was merged, time since January 1, 1970. */
   // TODO(issue-15518): Migrate type for timestamp index fields from Timestamp to Instant
-  public static final FieldDef<ChangeData, Timestamp> MERGED_ON =
-      timestamp(ChangeQueryBuilder.FIELD_MERGED_ON)
+  public static final IndexedField<ChangeData, Timestamp> MERGED_ON_FIELD =
+      IndexedField.<ChangeData>timestampBuilder("MergedOn")
           .stored()
           .build(
               cd -> cd.getMergedOn().map(Timestamp::from).orElse(null),
               (cd, field) -> cd.setMergedOn(field != null ? field.toInstant() : null));
 
+  public static final IndexedField<ChangeData, Timestamp>.SearchSpec MERGED_ON_SPEC =
+      MERGED_ON_FIELD.timestamp(ChangeQueryBuilder.FIELD_MERGED_ON);
+
   /** List of full file paths modified in the current patch set. */
-  public static final FieldDef<ChangeData, Iterable<String>> PATH =
-      // Named for backwards compatibility.
-      exact(ChangeQueryBuilder.FIELD_FILE)
-          .buildRepeatable(cd -> firstNonNull(cd.currentFilePaths(), ImmutableList.of()));
+  public static final IndexedField<ChangeData, Iterable<String>> PATH_FIELD =
+      IndexedField.<ChangeData>iterableStringBuilder("ModifiedFile")
+          .build(cd -> firstNonNull(cd.currentFilePaths(), ImmutableList.of()));
+
+  public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec PATH_SPEC =
+      PATH_FIELD
+          // Named for backwards compatibility.
+          .exact(ChangeQueryBuilder.FIELD_FILE);
 
   public static Set<String> getFileParts(ChangeData cd) {
     List<String> paths = cd.currentFilePaths();
@@ -205,24 +249,27 @@
   }
 
   /** Hashtags tied to a change */
-  public static final FieldDef<ChangeData, Iterable<String>> HASHTAG =
-      exact(ChangeQueryBuilder.FIELD_HASHTAG)
-          .buildRepeatable(cd -> cd.hashtags().stream().map(String::toLowerCase).collect(toSet()));
+  public static final IndexedField<ChangeData, Iterable<String>> HASHTAG_FIELD =
+      IndexedField.<ChangeData>iterableStringBuilder("Hashtag")
+          .size(200)
+          .build(cd -> cd.hashtags().stream().map(String::toLowerCase).collect(toSet()));
+
+  public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec HASHTAG_SPEC =
+      HASHTAG_FIELD.exact(ChangeQueryBuilder.FIELD_HASHTAG);
 
   /** Hashtags as fulltext field for in-string search. */
-  public static final FieldDef<ChangeData, Iterable<String>> FUZZY_HASHTAG =
-      fullText("hashtag2")
-          .buildRepeatable(cd -> cd.hashtags().stream().map(String::toLowerCase).collect(toSet()));
+  public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec FUZZY_HASHTAG =
+      HASHTAG_FIELD.fullText("hashtag2");
 
   /** Hashtags as prefix field for in-string search. */
-  public static final FieldDef<ChangeData, Iterable<String>> PREFIX_HASHTAG =
-      prefix("hashtag3")
-          .buildRepeatable(cd -> cd.hashtags().stream().map(String::toLowerCase).collect(toSet()));
+  public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec PREFIX_HASHTAG =
+      HASHTAG_FIELD.prefix("hashtag3");
 
   /** Hashtags with original case. */
-  public static final FieldDef<ChangeData, Iterable<byte[]>> HASHTAG_CASE_AWARE =
-      storedOnly("_hashtag")
-          .buildRepeatable(
+  public static final IndexedField<ChangeData, Iterable<byte[]>> HASHTAG_CASE_AWARE_FIELD =
+      IndexedField.<ChangeData>iterableByteArrayBuilder("HashtagCaseAware")
+          .stored()
+          .build(
               cd -> cd.hashtags().stream().map(t -> t.getBytes(UTF_8)).collect(toSet()),
               (cd, field) ->
                   cd.setHashtags(
@@ -230,13 +277,24 @@
                           .map(f -> new String(f, UTF_8))
                           .collect(toImmutableSet())));
 
+  public static final IndexedField<ChangeData, Iterable<byte[]>>.SearchSpec
+      HASHTAG_CASE_AWARE_SPEC = HASHTAG_CASE_AWARE_FIELD.storedOnly("_hashtag");
+
   /** Components of each file path modified in the current patch set. */
-  public static final FieldDef<ChangeData, Iterable<String>> FILE_PART =
-      exact(ChangeQueryBuilder.FIELD_FILEPART).buildRepeatable(ChangeField::getFileParts);
+  public static final IndexedField<ChangeData, Iterable<String>> FILE_PART_FIELD =
+      IndexedField.<ChangeData>iterableStringBuilder("FilePart").build(ChangeField::getFileParts);
+
+  public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec FILE_PART_SPEC =
+      FILE_PART_FIELD.exact(ChangeQueryBuilder.FIELD_FILEPART);
 
   /** File extensions of each file modified in the current patch set. */
-  public static final FieldDef<ChangeData, Iterable<String>> EXTENSION =
-      exact(ChangeQueryBuilder.FIELD_EXTENSION).buildRepeatable(ChangeField::getExtensions);
+  public static final IndexedField<ChangeData, Iterable<String>> EXTENSION_FIELD =
+      IndexedField.<ChangeData>iterableStringBuilder("Extension")
+          .size(100)
+          .build(ChangeField::getExtensions);
+
+  public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec EXTENSION_SPEC =
+      EXTENSION_FIELD.exact(ChangeQueryBuilder.FIELD_EXTENSION);
 
   public static Set<String> getExtensions(ChangeData cd) {
     return extensions(cd).collect(toSet());
@@ -246,8 +304,12 @@
    * File extensions of each file modified in the current patch set as a sorted list. The purpose of
    * this field is to allow matching changes that only touch files with certain file extensions.
    */
-  public static final FieldDef<ChangeData, String> ONLY_EXTENSIONS =
-      exact(ChangeQueryBuilder.FIELD_ONLY_EXTENSIONS).build(ChangeField::getAllExtensionsAsList);
+  public static final IndexedField<ChangeData, String> ONLY_EXTENSIONS_FIELD =
+      IndexedField.<ChangeData>stringBuilder("OnlyExtensions")
+          .build(ChangeField::getAllExtensionsAsList);
+
+  public static final IndexedField<ChangeData, String>.SearchSpec ONLY_EXTENSIONS_SPEC =
+      ONLY_EXTENSIONS_FIELD.exact(ChangeQueryBuilder.FIELD_ONLY_EXTENSIONS);
 
   public static String getAllExtensionsAsList(ChangeData cd) {
     return extensions(cd).distinct().sorted().collect(joining(","));
@@ -271,8 +333,11 @@
   }
 
   /** Footers from the commit message of the current patch set. */
-  public static final FieldDef<ChangeData, Iterable<String>> FOOTER =
-      exact(ChangeQueryBuilder.FIELD_FOOTER).buildRepeatable(ChangeField::getFooters);
+  public static final IndexedField<ChangeData, Iterable<String>> FOOTER_FIELD =
+      IndexedField.<ChangeData>iterableStringBuilder("Footer").build(ChangeField::getFooters);
+
+  public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec FOOTER_SPEC =
+      FOOTER_FIELD.exact(ChangeQueryBuilder.FIELD_FOOTER);
 
   public static Set<String> getFooters(ChangeData cd) {
     return cd.commitFooters().stream()
@@ -281,16 +346,23 @@
   }
 
   /** Footers from the commit message of the current patch set. */
-  public static final FieldDef<ChangeData, Iterable<String>> FOOTER_NAME =
-      exact(ChangeQueryBuilder.FIELD_FOOTER_NAME).buildRepeatable(ChangeField::getFootersNames);
+  public static final IndexedField<ChangeData, Iterable<String>> FOOTER_NAME_FIELD =
+      IndexedField.<ChangeData>iterableStringBuilder("FooterName")
+          .build(ChangeField::getFootersNames);
+
+  public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec FOOTER_NAME =
+      FOOTER_NAME_FIELD.exact(ChangeQueryBuilder.FIELD_FOOTER_NAME);
 
   public static Set<String> getFootersNames(ChangeData cd) {
     return cd.commitFooters().stream().map(f -> f.getKey()).collect(toSet());
   }
 
   /** Folders that are touched by the current patch set. */
-  public static final FieldDef<ChangeData, Iterable<String>> DIRECTORY =
-      exact(ChangeQueryBuilder.FIELD_DIRECTORY).buildRepeatable(ChangeField::getDirectories);
+  public static final IndexedField<ChangeData, Iterable<String>> DIRECTORY_FIELD =
+      IndexedField.<ChangeData>iterableStringBuilder("DirField").build(ChangeField::getDirectories);
+
+  public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec DIRECTORY_SPEC =
+      DIRECTORY_FIELD.exact(ChangeQueryBuilder.FIELD_DIRECTORY);
 
   public static Set<String> getDirectories(ChangeData cd) {
     List<String> paths = cd.currentFilePaths();
@@ -325,31 +397,47 @@
   }
 
   /** Owner/creator of the change. */
-  public static final FieldDef<ChangeData, Integer> OWNER =
-      integer(ChangeQueryBuilder.FIELD_OWNER).build(changeGetter(c -> c.getOwner().get()));
+  public static final IndexedField<ChangeData, Integer> OWNER_FIELD =
+      IndexedField.<ChangeData>integerBuilder("Owner")
+          .required()
+          .build(changeGetter(c -> c.getOwner().get()));
+
+  public static final IndexedField<ChangeData, Integer>.SearchSpec OWNER_SPEC =
+      OWNER_FIELD.integer(ChangeQueryBuilder.FIELD_OWNER);
 
   /** Uploader of the latest patch set. */
-  public static final FieldDef<ChangeData, Integer> UPLOADER =
-      integer(ChangeQueryBuilder.FIELD_UPLOADER).build(cd -> cd.currentPatchSet().uploader().get());
+  public static final IndexedField<ChangeData, Integer> UPLOADER_FIELD =
+      IndexedField.<ChangeData>integerBuilder("Uploader")
+          .required()
+          .build(cd -> cd.currentPatchSet().uploader().get());
+
+  public static final IndexedField<ChangeData, Integer>.SearchSpec UPLOADER_SPEC =
+      UPLOADER_FIELD.integer(ChangeQueryBuilder.FIELD_UPLOADER);
 
   /** References the source change number that this change was cherry-picked from. */
-  public static final FieldDef<ChangeData, Integer> CHERRY_PICK_OF_CHANGE =
-      integer(ChangeQueryBuilder.FIELD_CHERRY_PICK_OF_CHANGE)
+  public static final IndexedField<ChangeData, Integer> CHERRY_PICK_OF_CHANGE_FIELD =
+      IndexedField.<ChangeData>integerBuilder("CherryPickOfChange")
           .build(
               cd ->
                   cd.change().getCherryPickOf() != null
                       ? cd.change().getCherryPickOf().changeId().get()
                       : null);
 
+  public static final IndexedField<ChangeData, Integer>.SearchSpec CHERRY_PICK_OF_CHANGE =
+      CHERRY_PICK_OF_CHANGE_FIELD.integer(ChangeQueryBuilder.FIELD_CHERRY_PICK_OF_CHANGE);
+
   /** References the source change patch-set that this change was cherry-picked from. */
-  public static final FieldDef<ChangeData, Integer> CHERRY_PICK_OF_PATCHSET =
-      integer(ChangeQueryBuilder.FIELD_CHERRY_PICK_OF_PATCHSET)
+  public static final IndexedField<ChangeData, Integer> CHERRY_PICK_OF_PATCHSET_FIELD =
+      IndexedField.<ChangeData>integerBuilder("CherryPickOfPatchset")
           .build(
               cd ->
                   cd.change().getCherryPickOf() != null
                       ? cd.change().getCherryPickOf().get()
                       : null);
 
+  public static final IndexedField<ChangeData, Integer>.SearchSpec CHERRY_PICK_OF_PATCHSET =
+      CHERRY_PICK_OF_PATCHSET_FIELD.integer(ChangeQueryBuilder.FIELD_CHERRY_PICK_OF_PATCHSET);
+
   /** This class decouples the internal and API types from storage. */
   private static class StoredAttentionSetEntry {
     final long timestampMillis;
@@ -374,25 +462,35 @@
    * Users included in the attention set of the change. This omits timestamp, reason and possible
    * future fields.
    *
-   * @see #ATTENTION_SET_FULL
+   * @see #ATTENTION_SET_FULL_SPEC
    */
-  public static final FieldDef<ChangeData, Iterable<Integer>> ATTENTION_SET_USERS =
-      integer(ChangeQueryBuilder.FIELD_ATTENTION_SET_USERS)
-          .buildRepeatable(ChangeField::getAttentionSetUserIds);
+  public static final IndexedField<ChangeData, Iterable<Integer>> ATTENTION_SET_USERS_FIELD =
+      IndexedField.<ChangeData>iterableIntegerBuilder("AttentionSetUsers")
+          .build(ChangeField::getAttentionSetUserIds);
+
+  public static final IndexedField<ChangeData, Iterable<Integer>>.SearchSpec ATTENTION_SET_USERS =
+      ATTENTION_SET_USERS_FIELD.integer(ChangeQueryBuilder.FIELD_ATTENTION_SET_USERS);
 
   /** Number of changes that contain attention set. */
-  public static final FieldDef<ChangeData, Integer> ATTENTION_SET_USERS_COUNT =
-      intRange(ChangeQueryBuilder.FIELD_ATTENTION_SET_USERS_COUNT)
+  public static final IndexedField<ChangeData, Integer> ATTENTION_SET_USERS_COUNT_FIELD =
+      IndexedField.<ChangeData>integerBuilder("AttentionSetUsersCount")
+          .stored()
           .build(cd -> additionsOnly(cd.attentionSet()).size());
 
+  public static final IndexedField<ChangeData, Integer>.SearchSpec ATTENTION_SET_USERS_COUNT =
+      ATTENTION_SET_USERS_COUNT_FIELD.integerRange(
+          ChangeQueryBuilder.FIELD_ATTENTION_SET_USERS_COUNT);
+
   /**
    * The full attention set data including timestamp, reason and possible future fields.
    *
    * @see #ATTENTION_SET_USERS
    */
-  public static final FieldDef<ChangeData, Iterable<byte[]>> ATTENTION_SET_FULL =
-      storedOnly(ChangeQueryBuilder.FIELD_ATTENTION_SET_FULL)
-          .buildRepeatable(
+  public static final IndexedField<ChangeData, Iterable<byte[]>> ATTENTION_SET_FULL_FIELD =
+      IndexedField.<ChangeData>iterableByteArrayBuilder("AttentionSetFull")
+          .stored()
+          .required()
+          .build(
               ChangeField::storedAttentionSet,
               (cd, value) ->
                   parseAttentionSet(
@@ -401,61 +499,91 @@
                           .collect(toImmutableSet()),
                       cd));
 
+  public static final IndexedField<ChangeData, Iterable<byte[]>>.SearchSpec
+      ATTENTION_SET_FULL_SPEC =
+          ATTENTION_SET_FULL_FIELD.storedOnly(ChangeQueryBuilder.FIELD_ATTENTION_SET_FULL);
+
   /** The user assigned to the change. */
-  public static final FieldDef<ChangeData, Integer> ASSIGNEE =
-      integer(ChangeQueryBuilder.FIELD_ASSIGNEE)
-          .build(changeGetter(c -> c.getAssignee() != null ? c.getAssignee().get() : NO_ASSIGNEE));
+  // The getter always returns NO_ASSIGNEE, since assignee field is deprecated.
+  @Deprecated
+  public static final IndexedField<ChangeData, Integer> ASSIGNEE_FIELD =
+      IndexedField.<ChangeData>integerBuilder("Assignee").build(changeGetter(c -> NO_ASSIGNEE));
+
+  @Deprecated
+  public static final IndexedField<ChangeData, Integer>.SearchSpec ASSIGNEE_SPEC =
+      ASSIGNEE_FIELD.integer(ChangeQueryBuilder.FIELD_ASSIGNEE);
 
   /** Reviewer(s) associated with the change. */
-  public static final FieldDef<ChangeData, Iterable<String>> REVIEWER =
-      exact("reviewer2")
+  public static final IndexedField<ChangeData, Iterable<String>> REVIEWER_FIELD =
+      IndexedField.<ChangeData>iterableStringBuilder("Reviewer")
           .stored()
-          .buildRepeatable(
+          .build(
               cd -> getReviewerFieldValues(cd.reviewers()),
               (cd, field) -> cd.setReviewers(parseReviewerFieldValues(cd.getId(), field)));
 
+  public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec REVIEWER_SPEC =
+      REVIEWER_FIELD.exact("reviewer2");
+
   /** Reviewer(s) associated with the change that do not have a gerrit account. */
-  public static final FieldDef<ChangeData, Iterable<String>> REVIEWER_BY_EMAIL =
-      exact("reviewer_by_email")
+  public static final IndexedField<ChangeData, Iterable<String>> REVIEWER_BY_EMAIL_FIELD =
+      IndexedField.<ChangeData>iterableStringBuilder("ReviewerByEmail")
           .stored()
-          .buildRepeatable(
+          .build(
               cd -> getReviewerByEmailFieldValues(cd.reviewersByEmail()),
               (cd, field) ->
                   cd.setReviewersByEmail(parseReviewerByEmailFieldValues(cd.getId(), field)));
 
+  public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec REVIEWER_BY_EMAIL =
+      REVIEWER_BY_EMAIL_FIELD.exact("reviewer_by_email");
+
   /** Reviewer(s) modified during change's current WIP phase. */
-  public static final FieldDef<ChangeData, Iterable<String>> PENDING_REVIEWER =
-      exact(ChangeQueryBuilder.FIELD_PENDING_REVIEWER)
+  public static final IndexedField<ChangeData, Iterable<String>> PENDING_REVIEWER_FIELD =
+      IndexedField.<ChangeData>iterableStringBuilder("PendingReviewer")
           .stored()
-          .buildRepeatable(
+          .build(
               cd -> getReviewerFieldValues(cd.pendingReviewers()),
               (cd, field) -> cd.setPendingReviewers(parseReviewerFieldValues(cd.getId(), field)));
 
+  public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec PENDING_REVIEWER_SPEC =
+      PENDING_REVIEWER_FIELD.exact(ChangeQueryBuilder.FIELD_PENDING_REVIEWER);
+
   /** Reviewer(s) by email modified during change's current WIP phase. */
-  public static final FieldDef<ChangeData, Iterable<String>> PENDING_REVIEWER_BY_EMAIL =
-      exact(ChangeQueryBuilder.FIELD_PENDING_REVIEWER_BY_EMAIL)
+  public static final IndexedField<ChangeData, Iterable<String>> PENDING_REVIEWER_BY_EMAIL_FIELD =
+      IndexedField.<ChangeData>iterableStringBuilder("PendingReviewerByEmail")
           .stored()
-          .buildRepeatable(
+          .build(
               cd -> getReviewerByEmailFieldValues(cd.pendingReviewersByEmail()),
               (cd, field) ->
                   cd.setPendingReviewersByEmail(
                       parseReviewerByEmailFieldValues(cd.getId(), field)));
 
+  public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec
+      PENDING_REVIEWER_BY_EMAIL =
+          PENDING_REVIEWER_BY_EMAIL_FIELD.exact(ChangeQueryBuilder.FIELD_PENDING_REVIEWER_BY_EMAIL);
+
   /** References a change that this change reverts. */
-  public static final FieldDef<ChangeData, Integer> REVERT_OF =
-      integer(ChangeQueryBuilder.FIELD_REVERTOF)
+  public static final IndexedField<ChangeData, Integer> REVERT_OF_FIELD =
+      IndexedField.<ChangeData>integerBuilder("RevertOf")
           .build(cd -> cd.change().getRevertOf() != null ? cd.change().getRevertOf().get() : null);
 
-  public static final FieldDef<ChangeData, String> IS_PURE_REVERT =
-      fullText(ChangeQueryBuilder.FIELD_PURE_REVERT)
+  public static final IndexedField<ChangeData, Integer>.SearchSpec REVERT_OF =
+      REVERT_OF_FIELD.integer(ChangeQueryBuilder.FIELD_REVERTOF);
+
+  public static final IndexedField<ChangeData, String> IS_PURE_REVERT_FIELD =
+      IndexedField.<ChangeData>stringBuilder("IsPureRevert")
+          .size(1)
           .build(cd -> Boolean.TRUE.equals(cd.isPureRevert()) ? "1" : "0");
 
+  public static final IndexedField<ChangeData, String>.SearchSpec IS_PURE_REVERT_SPEC =
+      IS_PURE_REVERT_FIELD.fullText(ChangeQueryBuilder.FIELD_PURE_REVERT);
+
   /**
    * Determines if a change is submittable based on {@link
    * com.google.gerrit.entities.SubmitRequirement}s.
    */
-  public static final FieldDef<ChangeData, String> IS_SUBMITTABLE =
-      exact(ChangeQueryBuilder.FIELD_IS_SUBMITTABLE)
+  public static final IndexedField<ChangeData, String> IS_SUBMITTABLE_FIELD =
+      IndexedField.<ChangeData>stringBuilder("IsSubmittable")
+          .size(1)
           .build(
               cd ->
                   // All submit requirements should be fulfilled
@@ -464,6 +592,9 @@
                       ? "1"
                       : "0");
 
+  public static final IndexedField<ChangeData, String>.SearchSpec IS_SUBMITTABLE_SPEC =
+      IS_SUBMITTABLE_FIELD.exact(ChangeQueryBuilder.FIELD_IS_SUBMITTABLE);
+
   @VisibleForTesting
   static List<String> getReviewerFieldValues(ReviewerSet reviewers) {
     List<String> r = new ArrayList<>(reviewers.asTable().size() * 2);
@@ -639,25 +770,37 @@
   }
 
   /** Commit ID of any patch set on the change, using prefix match. */
-  public static final FieldDef<ChangeData, Iterable<String>> COMMIT =
-      prefix(ChangeQueryBuilder.FIELD_COMMIT).buildRepeatable(ChangeField::getRevisions);
+  public static final IndexedField<ChangeData, Iterable<String>> COMMIT_FIELD =
+      IndexedField.<ChangeData>iterableStringBuilder("CommitId")
+          .size(40)
+          .required()
+          .build(ChangeField::getRevisions);
+
+  public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec COMMIT_SPEC =
+      COMMIT_FIELD.prefix(ChangeQueryBuilder.FIELD_COMMIT);
 
   /** Commit ID of any patch set on the change, using exact match. */
-  public static final FieldDef<ChangeData, Iterable<String>> EXACT_COMMIT =
-      exact(ChangeQueryBuilder.FIELD_EXACTCOMMIT).buildRepeatable(ChangeField::getRevisions);
+  public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec EXACT_COMMIT_SPEC =
+      COMMIT_FIELD.exact(ChangeQueryBuilder.FIELD_EXACTCOMMIT);
 
   private static ImmutableSet<String> getRevisions(ChangeData cd) {
     return cd.patchSets().stream().map(ps -> ps.commitId().name()).collect(toImmutableSet());
   }
 
   /** Tracking id extracted from a footer. */
-  public static final FieldDef<ChangeData, Iterable<String>> TR =
-      exact(ChangeQueryBuilder.FIELD_TR)
-          .buildRepeatable(cd -> ImmutableSet.copyOf(cd.trackingFooters().values()));
+  public static final IndexedField<ChangeData, Iterable<String>> TR_FIELD =
+      IndexedField.<ChangeData>iterableStringBuilder("TrackingFooter")
+          .build(cd -> ImmutableSet.copyOf(cd.trackingFooters().values()));
+
+  public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec TR_SPEC =
+      TR_FIELD.exact(ChangeQueryBuilder.FIELD_TR);
 
   /** List of labels on the current patch set including change owner votes. */
-  public static final FieldDef<ChangeData, Iterable<String>> LABEL =
-      exact("label2").buildRepeatable(cd -> getLabels(cd));
+  public static final IndexedField<ChangeData, Iterable<String>> LABEL_FIELD =
+      IndexedField.<ChangeData>iterableStringBuilder("Label").required().build(cd -> getLabels(cd));
+
+  public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec LABEL_SPEC =
+      LABEL_FIELD.exact("label2");
 
   private static Iterable<String> getLabels(ChangeData cd) {
     Set<String> allApprovals = new HashSet<>();
@@ -800,41 +943,90 @@
    * The exact email address, or any part of the author name or email address, in the current patch
    * set.
    */
-  public static final FieldDef<ChangeData, Iterable<String>> AUTHOR =
-      fullText(ChangeQueryBuilder.FIELD_AUTHOR).buildRepeatable(ChangeField::getAuthorParts);
+  public static final IndexedField<ChangeData, Iterable<String>> AUTHOR_PARTS_FIELD =
+      IndexedField.<ChangeData>iterableStringBuilder("AuthorParts")
+          .required()
+          .description(
+              "The exact email address, or any part of the author name or email address, in the current patch set.")
+          .build(ChangeField::getAuthorParts);
+
+  public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec AUTHOR_PARTS_SPEC =
+      AUTHOR_PARTS_FIELD.fullText(ChangeQueryBuilder.FIELD_AUTHOR);
 
   /** The exact name, email address and NameEmail of the author. */
-  public static final FieldDef<ChangeData, Iterable<String>> EXACT_AUTHOR =
-      exact(ChangeQueryBuilder.FIELD_EXACTAUTHOR)
-          .buildRepeatable(ChangeField::getAuthorNameAndEmail);
+  public static final IndexedField<ChangeData, Iterable<String>> EXACT_AUTHOR_FIELD =
+      IndexedField.<ChangeData>iterableStringBuilder("ExactAuthor")
+          .required()
+          .description("The exact name, email address and NameEmail of the author.")
+          .build(ChangeField::getAuthorNameAndEmail);
+
+  public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec EXACT_AUTHOR_SPEC =
+      EXACT_AUTHOR_FIELD.exact(ChangeQueryBuilder.FIELD_EXACTAUTHOR);
 
   /**
    * The exact email address, or any part of the committer name or email address, in the current
    * patch set.
    */
-  public static final FieldDef<ChangeData, Iterable<String>> COMMITTER =
-      fullText(ChangeQueryBuilder.FIELD_COMMITTER).buildRepeatable(ChangeField::getCommitterParts);
+  public static final IndexedField<ChangeData, Iterable<String>> COMMITTER_PARTS_FIELD =
+      IndexedField.<ChangeData>iterableStringBuilder("CommitterParts")
+          .description(
+              "The exact email address, or any part of the committer name or email address, in the current patch set.")
+          .required()
+          .build(ChangeField::getCommitterParts);
+
+  public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec COMMITTER_PARTS_SPEC =
+      COMMITTER_PARTS_FIELD.fullText(ChangeQueryBuilder.FIELD_COMMITTER);
 
   /** The exact name, email address, and NameEmail of the committer. */
-  public static final FieldDef<ChangeData, Iterable<String>> EXACT_COMMITTER =
-      exact(ChangeQueryBuilder.FIELD_EXACTCOMMITTER)
-          .buildRepeatable(ChangeField::getCommitterNameAndEmail);
+  public static final IndexedField<ChangeData, Iterable<String>> EXACT_COMMITTER_FIELD =
+      IndexedField.<ChangeData>iterableStringBuilder("ExactCommiter")
+          .required()
+          .description("The exact name, email address, and NameEmail of the committer.")
+          .build(ChangeField::getCommitterNameAndEmail);
+
+  public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec EXACT_COMMITTER_SPEC =
+      EXACT_COMMITTER_FIELD.exact(ChangeQueryBuilder.FIELD_EXACTCOMMITTER);
 
   /** Serialized change object, used for pre-populating results. */
-  public static final FieldDef<ChangeData, byte[]> CHANGE =
-      storedOnly("_change")
+  private static final TypeToken<Entities.Change> CHANGE_TYPE_TOKEN =
+      new TypeToken<>() {
+        private static final long serialVersionUID = 1L;
+      };
+
+  public static final IndexedField<ChangeData, Entities.Change> CHANGE_FIELD =
+      IndexedField.<ChangeData, Entities.Change>builder("Change", CHANGE_TYPE_TOKEN)
+          .stored()
+          .required()
+          .protoConverter(Optional.of(ChangeProtoConverter.INSTANCE))
           .build(
-              changeGetter(change -> toProto(ChangeProtoConverter.INSTANCE, change)),
-              (cd, field) -> cd.setChange(parseProtoFrom(field, ChangeProtoConverter.INSTANCE)));
+              changeGetter(change -> entityToProto(ChangeProtoConverter.INSTANCE, change)),
+              (cd, value) ->
+                  cd.setChange(decodeProtoToEntity(value, ChangeProtoConverter.INSTANCE)));
+
+  public static final IndexedField<ChangeData, Entities.Change>.SearchSpec CHANGE_SPEC =
+      CHANGE_FIELD.storedOnly("_change");
 
   /** Serialized approvals for the current patch set, used for pre-populating results. */
-  public static final FieldDef<ChangeData, Iterable<byte[]>> APPROVAL =
-      storedOnly("_approval")
-          .buildRepeatable(
-              cd -> toProtos(PatchSetApprovalProtoConverter.INSTANCE, cd.currentApprovals()),
+  private static final TypeToken<Iterable<Entities.PatchSetApproval>> APPROVAL_TYPE_TOKEN =
+      new TypeToken<>() {
+        private static final long serialVersionUID = 1L;
+      };
+
+  public static final IndexedField<ChangeData, Iterable<Entities.PatchSetApproval>> APPROVAL_FIELD =
+      IndexedField.<ChangeData, Iterable<Entities.PatchSetApproval>>builder(
+              "Approval", APPROVAL_TYPE_TOKEN)
+          .stored()
+          .required()
+          .protoConverter(Optional.of(PatchSetApprovalProtoConverter.INSTANCE))
+          .build(
+              cd ->
+                  entitiesToProtos(PatchSetApprovalProtoConverter.INSTANCE, cd.currentApprovals()),
               (cd, field) ->
                   cd.setCurrentApprovals(
-                      decodeProtos(field, PatchSetApprovalProtoConverter.INSTANCE)));
+                      decodeProtosToEntities(field, PatchSetApprovalProtoConverter.INSTANCE)));
+
+  public static final IndexedField<ChangeData, Iterable<Entities.PatchSetApproval>>.SearchSpec
+      APPROVAL_SPEC = APPROVAL_FIELD.storedOnly("_approval");
 
   public static String formatLabel(String label, int value) {
     return formatLabel(label, value, /* accountId= */ null, /* count= */ null);
@@ -850,7 +1042,7 @@
 
   public static String formatLabel(
       String label, int value, @Nullable Account.Id accountId, @Nullable Integer count) {
-    return label.toLowerCase()
+    return label.toLowerCase(Locale.US)
         + (value >= 0 ? "+" : "")
         + value
         + (accountId != null ? "," + formatAccount(accountId) : "")
@@ -863,7 +1055,7 @@
 
   public static String formatLabel(
       String label, String value, @Nullable Account.Id accountId, @Nullable Integer count) {
-    return label.toLowerCase()
+    return label.toLowerCase(Locale.US)
         + "="
         + value
         + (accountId != null ? "," + formatAccount(accountId) : "")
@@ -880,18 +1072,41 @@
   }
 
   /** Commit message of the current patch set. */
-  public static final FieldDef<ChangeData, String> COMMIT_MESSAGE =
-      fullText(ChangeQueryBuilder.FIELD_MESSAGE).build(ChangeData::commitMessage);
+  public static final IndexedField<ChangeData, String> COMMIT_MESSAGE_FIELD =
+      IndexedField.<ChangeData>stringBuilder("CommitMessage")
+          .required()
+          .build(ChangeData::commitMessage);
 
-  /** Commit message of the current patch set. */
-  public static final FieldDef<ChangeData, String> COMMIT_MESSAGE_EXACT =
-      exact(ChangeQueryBuilder.FIELD_MESSAGE_EXACT)
+  public static final IndexedField<ChangeData, String>.SearchSpec COMMIT_MESSAGE =
+      COMMIT_MESSAGE_FIELD.fullText(ChangeQueryBuilder.FIELD_MESSAGE);
+
+  /** Commit message of the current patch set, used to exactly match the commit message */
+  public static final IndexedField<ChangeData, String> COMMIT_MESSAGE_EXACT_FIELD =
+      IndexedField.<ChangeData>stringBuilder("CommitMessageExact")
+          .required()
+          .description(
+              "Same as CommitMessage, but truncated, since supporting such large tokens may be problematic for indexes.")
           .build(cd -> truncateStringValueToMaxTermLength(cd.commitMessage()));
 
+  public static final IndexedField<ChangeData, String>.SearchSpec COMMIT_MESSAGE_EXACT =
+      COMMIT_MESSAGE_EXACT_FIELD.exact(ChangeQueryBuilder.FIELD_MESSAGE_EXACT);
+
+  /** Subject of the current patch set (aka first line of the commit message). */
+  public static final IndexedField<ChangeData, String> SUBJECT_FIELD =
+      IndexedField.<ChangeData>stringBuilder("Subject")
+          .required()
+          .build(changeGetter(Change::getSubject));
+
+  public static final IndexedField<ChangeData, String>.SearchSpec SUBJECT_SPEC =
+      SUBJECT_FIELD.fullText(ChangeQueryBuilder.FIELD_SUBJECT);
+
+  public static final IndexedField<ChangeData, String>.SearchSpec PREFIX_SUBJECT_SPEC =
+      SUBJECT_FIELD.prefix(ChangeQueryBuilder.FIELD_PREFIX_SUBJECT);
+
   /** Summary or inline comment. */
-  public static final FieldDef<ChangeData, Iterable<String>> COMMENT =
-      fullText(ChangeQueryBuilder.FIELD_COMMENT)
-          .buildRepeatable(
+  public static final IndexedField<ChangeData, Iterable<String>> COMMENT_FIELD =
+      IndexedField.<ChangeData>iterableStringBuilder("Comment")
+          .build(
               cd ->
                   Stream.concat(
                           cd.publishedComments().stream().map(c -> c.message),
@@ -902,22 +1117,35 @@
                           cd.messages().stream().map(ChangeMessage::getMessage))
                       .collect(toSet()));
 
+  public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec COMMENT_SPEC =
+      COMMENT_FIELD.fullText(ChangeQueryBuilder.FIELD_COMMENT);
+
   /** Number of unresolved comment threads of the change, including robot comments. */
-  public static final FieldDef<ChangeData, Integer> UNRESOLVED_COMMENT_COUNT =
-      intRange(ChangeQueryBuilder.FIELD_UNRESOLVED_COMMENT_COUNT)
+  public static final IndexedField<ChangeData, Integer> UNRESOLVED_COMMENT_COUNT_FIELD =
+      IndexedField.<ChangeData>integerBuilder("UnresolvedCommentCount")
+          .stored()
           .build(
               ChangeData::unresolvedCommentCount,
               (cd, field) -> cd.setUnresolvedCommentCount(field));
 
+  public static final IndexedField<ChangeData, Integer>.SearchSpec UNRESOLVED_COMMENT_COUNT_SPEC =
+      UNRESOLVED_COMMENT_COUNT_FIELD.integerRange(
+          ChangeQueryBuilder.FIELD_UNRESOLVED_COMMENT_COUNT);
+
   /** Total number of published inline comments of the change, including robot comments. */
-  public static final FieldDef<ChangeData, Integer> TOTAL_COMMENT_COUNT =
-      intRange("total_comments")
+  public static final IndexedField<ChangeData, Integer> TOTAL_COMMENT_COUNT_FIELD =
+      IndexedField.<ChangeData>integerBuilder("TotalCommentCount")
+          .stored()
           .build(ChangeData::totalCommentCount, (cd, field) -> cd.setTotalCommentCount(field));
 
+  public static final IndexedField<ChangeData, Integer>.SearchSpec TOTAL_COMMENT_COUNT_SPEC =
+      TOTAL_COMMENT_COUNT_FIELD.integerRange("total_comments");
+
   /** Whether the change is mergeable. */
-  public static final FieldDef<ChangeData, String> MERGEABLE =
-      exact(ChangeQueryBuilder.FIELD_MERGEABLE)
+  public static final IndexedField<ChangeData, String> MERGEABLE_FIELD =
+      IndexedField.<ChangeData>stringBuilder("Mergeable")
           .stored()
+          .size(1)
           .build(
               cd -> {
                 Boolean m = cd.isMergeable();
@@ -928,10 +1156,14 @@
               },
               (cd, field) -> cd.setMergeable(field == null ? false : field.equals("1")));
 
+  public static final IndexedField<ChangeData, String>.SearchSpec MERGEABLE_SPEC =
+      MERGEABLE_FIELD.exact(ChangeQueryBuilder.FIELD_MERGEABLE);
+
   /** Whether the change is a merge commit. */
-  public static final FieldDef<ChangeData, String> MERGE =
-      exact(ChangeQueryBuilder.FIELD_MERGE)
+  public static final IndexedField<ChangeData, String> MERGE_FIELD =
+      IndexedField.<ChangeData>stringBuilder("Merge")
           .stored()
+          .size(1)
           .build(
               cd -> {
                 Boolean m = cd.isMerge();
@@ -941,15 +1173,23 @@
                 return m ? "1" : "0";
               });
 
+  public static final IndexedField<ChangeData, String>.SearchSpec MERGE_SPEC =
+      MERGE_FIELD.exact(ChangeQueryBuilder.FIELD_MERGE);
+
   /** Whether the change is a cherry pick of another change. */
-  public static final FieldDef<ChangeData, String> CHERRY_PICK =
-      exact(ChangeQueryBuilder.FIELD_CHERRYPICK)
+  public static final IndexedField<ChangeData, String> CHERRY_PICK_FIELD =
+      IndexedField.<ChangeData>stringBuilder("CherryPick")
           .stored()
+          .size(1)
           .build(cd -> cd.change().getCherryPickOf() != null ? "1" : "0");
 
+  public static final IndexedField<ChangeData, String>.SearchSpec CHERRY_PICK_SPEC =
+      CHERRY_PICK_FIELD.exact(ChangeQueryBuilder.FIELD_CHERRYPICK);
+
   /** The number of inserted lines in this change. */
-  public static final FieldDef<ChangeData, Integer> ADDED =
-      intRange(ChangeQueryBuilder.FIELD_ADDED)
+  public static final IndexedField<ChangeData, Integer> ADDED_LINES_FIELD =
+      IndexedField.<ChangeData>integerBuilder("AddedLines")
+          .stored()
           .build(
               cd -> cd.changedLines().isPresent() ? cd.changedLines().get().insertions : null,
               (cd, field) -> {
@@ -958,9 +1198,13 @@
                 }
               });
 
+  public static final IndexedField<ChangeData, Integer>.SearchSpec ADDED_LINES_SPEC =
+      ADDED_LINES_FIELD.integerRange(ChangeQueryBuilder.FIELD_ADDED);
+
   /** The number of deleted lines in this change. */
-  public static final FieldDef<ChangeData, Integer> DELETED =
-      intRange(ChangeQueryBuilder.FIELD_DELETED)
+  public static final IndexedField<ChangeData, Integer> DELETED_LINES_FIELD =
+      IndexedField.<ChangeData>integerBuilder("DeletedLines")
+          .stored()
           .build(
               cd -> cd.changedLines().isPresent() ? cd.changedLines().get().deletions : null,
               (cd, field) -> {
@@ -969,28 +1213,49 @@
                 }
               });
 
+  public static final IndexedField<ChangeData, Integer>.SearchSpec DELETED_LINES_SPEC =
+      DELETED_LINES_FIELD.integerRange(ChangeQueryBuilder.FIELD_DELETED);
+
   /** The total number of modified lines in this change. */
-  public static final FieldDef<ChangeData, Integer> DELTA =
-      intRange(ChangeQueryBuilder.FIELD_DELTA)
+  public static final IndexedField<ChangeData, Integer> DELTA_LINES_FIELD =
+      IndexedField.<ChangeData>integerBuilder("DeltaLines")
+          .stored()
           .build(cd -> cd.changedLines().map(c -> c.insertions + c.deletions).orElse(null));
 
+  public static final IndexedField<ChangeData, Integer>.SearchSpec DELTA_LINES_SPEC =
+      DELTA_LINES_FIELD.integerRange(ChangeQueryBuilder.FIELD_DELTA);
+
   /** Determines if this change is private. */
-  public static final FieldDef<ChangeData, String> PRIVATE =
-      exact(ChangeQueryBuilder.FIELD_PRIVATE).build(cd -> cd.change().isPrivate() ? "1" : "0");
+  public static final IndexedField<ChangeData, String> PRIVATE_FIELD =
+      IndexedField.<ChangeData>stringBuilder("IsPrivate")
+          .size(1)
+          .build(cd -> cd.change().isPrivate() ? "1" : "0");
+
+  public static final IndexedField<ChangeData, String>.SearchSpec PRIVATE_SPEC =
+      PRIVATE_FIELD.exact(ChangeQueryBuilder.FIELD_PRIVATE);
 
   /** Determines if this change is work in progress. */
-  public static final FieldDef<ChangeData, String> WIP =
-      exact(ChangeQueryBuilder.FIELD_WIP).build(cd -> cd.change().isWorkInProgress() ? "1" : "0");
+  public static final IndexedField<ChangeData, String> WIP_FIELD =
+      IndexedField.<ChangeData>stringBuilder("WIP")
+          .size(1)
+          .build(cd -> cd.change().isWorkInProgress() ? "1" : "0");
+
+  public static final IndexedField<ChangeData, String>.SearchSpec WIP_SPEC =
+      WIP_FIELD.exact(ChangeQueryBuilder.FIELD_WIP);
 
   /** Determines if this change has started review. */
-  public static final FieldDef<ChangeData, String> STARTED =
-      exact(ChangeQueryBuilder.FIELD_STARTED)
+  public static final IndexedField<ChangeData, String> STARTED_FIELD =
+      IndexedField.<ChangeData>stringBuilder("ReviewStarted")
+          .size(1)
           .build(cd -> cd.change().hasReviewStarted() ? "1" : "0");
 
+  public static final IndexedField<ChangeData, String>.SearchSpec STARTED_SPEC =
+      STARTED_FIELD.exact(ChangeQueryBuilder.FIELD_STARTED);
+
   /** Users who have commented on this change. */
-  public static final FieldDef<ChangeData, Iterable<Integer>> COMMENTBY =
-      integer(ChangeQueryBuilder.FIELD_COMMENTBY)
-          .buildRepeatable(
+  public static final IndexedField<ChangeData, Iterable<Integer>> COMMENTBY_FIELD =
+      IndexedField.<ChangeData>iterableIntegerBuilder("CommentBy")
+          .build(
               cd ->
                   Stream.concat(
                           cd.messages().stream().map(ChangeMessage::getAuthor),
@@ -999,11 +1264,14 @@
                       .map(Account.Id::get)
                       .collect(toSet()));
 
+  public static final IndexedField<ChangeData, Iterable<Integer>>.SearchSpec COMMENTBY_SPEC =
+      COMMENTBY_FIELD.integer(ChangeQueryBuilder.FIELD_COMMENTBY);
+
   /** Star labels on this change in the format: &lt;account-id&gt;:&lt;label&gt; */
-  public static final FieldDef<ChangeData, Iterable<String>> STAR =
-      exact(ChangeQueryBuilder.FIELD_STAR)
+  public static final IndexedField<ChangeData, Iterable<String>> STAR_FIELD =
+      IndexedField.<ChangeData>iterableStringBuilder("Star")
           .stored()
-          .buildRepeatable(
+          .build(
               cd ->
                   Iterables.transform(
                       cd.stars().entries(),
@@ -1015,33 +1283,61 @@
                           .map(f -> StarredChangesUtil.StarField.parse(f))
                           .collect(toImmutableListMultimap(e -> e.accountId(), e -> e.label()))));
 
+  public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec STAR_SPEC =
+      STAR_FIELD.exact(ChangeQueryBuilder.FIELD_STAR);
+
   /** Users that have starred the change with any label. */
-  public static final FieldDef<ChangeData, Iterable<Integer>> STARBY =
-      integer(ChangeQueryBuilder.FIELD_STARBY)
-          .buildRepeatable(cd -> Iterables.transform(cd.stars().keySet(), Account.Id::get));
+  public static final IndexedField<ChangeData, Iterable<Integer>> STARBY_FIELD =
+      IndexedField.<ChangeData>iterableIntegerBuilder("StarBy")
+          .build(cd -> Iterables.transform(cd.stars().keySet(), Account.Id::get));
+
+  public static final IndexedField<ChangeData, Iterable<Integer>>.SearchSpec STARBY_SPEC =
+      STARBY_FIELD.integer(ChangeQueryBuilder.FIELD_STARBY);
 
   /** Opaque group identifiers for this change's patch sets. */
-  public static final FieldDef<ChangeData, Iterable<String>> GROUP =
-      exact(ChangeQueryBuilder.FIELD_GROUP)
-          .buildRepeatable(
+  public static final IndexedField<ChangeData, Iterable<String>> GROUP_FIELD =
+      IndexedField.<ChangeData>iterableStringBuilder("Group")
+          .build(
               cd -> cd.patchSets().stream().flatMap(ps -> ps.groups().stream()).collect(toSet()));
 
+  public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec GROUP_SPEC =
+      GROUP_FIELD.exact(ChangeQueryBuilder.FIELD_GROUP);
+
   /** Serialized patch set object, used for pre-populating results. */
-  public static final FieldDef<ChangeData, Iterable<byte[]>> PATCH_SET =
-      storedOnly("_patch_set")
-          .buildRepeatable(
-              cd -> toProtos(PatchSetProtoConverter.INSTANCE, cd.patchSets()),
-              (cd, field) -> cd.setPatchSets(decodeProtos(field, PatchSetProtoConverter.INSTANCE)));
+  private static final TypeToken<Iterable<Entities.PatchSet>> PATCH_SET_TYPE_TOKEN =
+      new TypeToken<>() {
+        private static final long serialVersionUID = 1L;
+      };
+
+  public static final IndexedField<ChangeData, Iterable<Entities.PatchSet>> PATCH_SET_FIELD =
+      IndexedField.<ChangeData, Iterable<Entities.PatchSet>>builder(
+              "PatchSet", PATCH_SET_TYPE_TOKEN)
+          .stored()
+          .required()
+          .protoConverter(Optional.of(PatchSetProtoConverter.INSTANCE))
+          .build(
+              cd -> entitiesToProtos(PatchSetProtoConverter.INSTANCE, cd.patchSets()),
+              (cd, value) ->
+                  cd.setPatchSets(decodeProtosToEntities(value, PatchSetProtoConverter.INSTANCE)));
+
+  public static final IndexedField<ChangeData, Iterable<Entities.PatchSet>>.SearchSpec
+      PATCH_SET_SPEC = PATCH_SET_FIELD.storedOnly("_patch_set");
 
   /** Users who have edits on this change. */
-  public static final FieldDef<ChangeData, Iterable<Integer>> EDITBY =
-      integer(ChangeQueryBuilder.FIELD_EDITBY)
-          .buildRepeatable(cd -> cd.editsByUser().stream().map(Account.Id::get).collect(toSet()));
+  public static final IndexedField<ChangeData, Iterable<Integer>> EDITBY_FIELD =
+      IndexedField.<ChangeData>iterableIntegerBuilder("EditBy")
+          .build(cd -> cd.editsByUser().stream().map(Account.Id::get).collect(toSet()));
+
+  public static final IndexedField<ChangeData, Iterable<Integer>>.SearchSpec EDITBY_SPEC =
+      EDITBY_FIELD.integer(ChangeQueryBuilder.FIELD_EDITBY);
 
   /** Users who have draft comments on this change. */
-  public static final FieldDef<ChangeData, Iterable<Integer>> DRAFTBY =
-      integer(ChangeQueryBuilder.FIELD_DRAFTBY)
-          .buildRepeatable(cd -> cd.draftsByUser().stream().map(Account.Id::get).collect(toSet()));
+  public static final IndexedField<ChangeData, Iterable<Integer>> DRAFTBY_FIELD =
+      IndexedField.<ChangeData>iterableIntegerBuilder("DraftBy")
+          .build(cd -> cd.draftsByUser().stream().map(Account.Id::get).collect(toSet()));
+
+  public static final IndexedField<ChangeData, Iterable<Integer>>.SearchSpec DRAFTBY_SPEC =
+      DRAFTBY_FIELD.integer(ChangeQueryBuilder.FIELD_DRAFTBY);
 
   public static final Integer NOT_REVIEWED = -1;
 
@@ -1055,10 +1351,10 @@
    * <p>If the latest update is by the change owner, then the special value {@link #NOT_REVIEWED} is
    * emitted.
    */
-  public static final FieldDef<ChangeData, Iterable<Integer>> REVIEWEDBY =
-      integer(ChangeQueryBuilder.FIELD_REVIEWEDBY)
+  public static final IndexedField<ChangeData, Iterable<Integer>> REVIEWEDBY_FIELD =
+      IndexedField.<ChangeData>iterableIntegerBuilder("ReviewedBy")
           .stored()
-          .buildRepeatable(
+          .build(
               cd -> {
                 Set<Account.Id> reviewedBy = cd.reviewedBy();
                 if (reviewedBy.isEmpty()) {
@@ -1072,6 +1368,9 @@
                           .map(Account::id)
                           .collect(toImmutableSet())));
 
+  public static final IndexedField<ChangeData, Iterable<Integer>>.SearchSpec REVIEWEDBY_SPEC =
+      REVIEWEDBY_FIELD.integer(ChangeQueryBuilder.FIELD_REVIEWEDBY);
+
   public static final SubmitRuleOptions SUBMIT_RULE_OPTIONS_LENIENT =
       SubmitRuleOptions.builder().recomputeOnClosedChanges(true).build();
 
@@ -1079,9 +1378,9 @@
       SubmitRuleOptions.builder().build();
 
   /** All submit rules results in the form of "$ruleName,$status". */
-  public static final FieldDef<ChangeData, Iterable<String>> SUBMIT_RULE_RESULT =
-      exact("submit_rule_result")
-          .buildRepeatable(
+  public static final IndexedField<ChangeData, Iterable<String>> SUBMIT_RULE_RESULT_FIELD =
+      IndexedField.<ChangeData>iterableStringBuilder("SubmitRuleResult")
+          .build(
               cd -> {
                 List<String> result = new ArrayList<>();
                 List<SubmitRecord> submitRecords = cd.submitRecords(SUBMIT_RULE_OPTIONS_STRICT);
@@ -1091,6 +1390,9 @@
                 return result;
               });
 
+  public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec
+      SUBMIT_RULE_RESULT_SPEC = SUBMIT_RULE_RESULT_FIELD.exact("submit_rule_result");
+
   /**
    * JSON type for storing SubmitRecords.
    *
@@ -1177,12 +1479,17 @@
     }
   }
 
-  public static final FieldDef<ChangeData, Iterable<String>> SUBMIT_RECORD =
-      exact("submit_record").buildRepeatable(ChangeField::formatSubmitRecordValues);
+  public static final IndexedField<ChangeData, Iterable<String>> SUBMIT_RECORD_FIELD =
+      IndexedField.<ChangeData>iterableStringBuilder("SubmitRecord")
+          .build(ChangeField::formatSubmitRecordValues);
 
-  public static final FieldDef<ChangeData, Iterable<byte[]>> STORED_SUBMIT_RECORD_STRICT =
-      storedOnly("full_submit_record_strict")
-          .buildRepeatable(
+  public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec SUBMIT_RECORD_SPEC =
+      SUBMIT_RECORD_FIELD.exact("submit_record");
+
+  public static final IndexedField<ChangeData, Iterable<byte[]>> STORED_SUBMIT_RECORD_STRICT_FIELD =
+      IndexedField.<ChangeData>iterableByteArrayBuilder("FullSubmitRecordStrict")
+          .stored()
+          .build(
               cd -> storedSubmitRecords(cd, SUBMIT_RULE_OPTIONS_STRICT),
               (cd, field) ->
                   parseSubmitRecords(
@@ -1192,17 +1499,27 @@
                       SUBMIT_RULE_OPTIONS_STRICT,
                       cd));
 
-  public static final FieldDef<ChangeData, Iterable<byte[]>> STORED_SUBMIT_RECORD_LENIENT =
-      storedOnly("full_submit_record_lenient")
-          .buildRepeatable(
-              cd -> storedSubmitRecords(cd, SUBMIT_RULE_OPTIONS_LENIENT),
-              (cd, field) ->
-                  parseSubmitRecords(
-                      StreamSupport.stream(field.spliterator(), false)
-                          .map(f -> new String(f, UTF_8))
-                          .collect(toSet()),
-                      SUBMIT_RULE_OPTIONS_LENIENT,
-                      cd));
+  public static final IndexedField<ChangeData, Iterable<byte[]>>.SearchSpec
+      STORED_SUBMIT_RECORD_STRICT_SPEC =
+          STORED_SUBMIT_RECORD_STRICT_FIELD.storedOnly("full_submit_record_strict");
+
+  public static final IndexedField<ChangeData, Iterable<byte[]>>
+      STORED_SUBMIT_RECORD_LENIENT_FIELD =
+          IndexedField.<ChangeData>iterableByteArrayBuilder("FullSubmitRecordLenient")
+              .stored()
+              .build(
+                  cd -> storedSubmitRecords(cd, SUBMIT_RULE_OPTIONS_LENIENT),
+                  (cd, field) ->
+                      parseSubmitRecords(
+                          StreamSupport.stream(field.spliterator(), false)
+                              .map(f -> new String(f, UTF_8))
+                              .collect(toSet()),
+                          SUBMIT_RULE_OPTIONS_LENIENT,
+                          cd));
+
+  public static final IndexedField<ChangeData, Iterable<byte[]>>.SearchSpec
+      STORED_SUBMIT_RECORD_LENIENT_SPEC =
+          STORED_SUBMIT_RECORD_LENIENT_FIELD.storedOnly("full_submit_record_lenient");
 
   public static void parseSubmitRecords(
       Collection<String> values, SubmitRuleOptions opts, ChangeData out) {
@@ -1250,7 +1567,7 @@
         continue;
       }
       for (SubmitRecord.Label label : rec.labels) {
-        String sl = label.status.toString() + ',' + label.label.toLowerCase();
+        String sl = label.status.toString() + ',' + label.label.toLowerCase(Locale.US);
         result.add(sl);
         String slc = sl + ',';
         if (label.appliedBy != null) {
@@ -1279,50 +1596,63 @@
           result.add(
               SubmitRecord.Label.Status.OK.name()
                   + ","
-                  + srResult.submitRequirement().name().toLowerCase());
+                  + srResult.submitRequirement().name().toLowerCase(Locale.US));
           result.add(
               SubmitRecord.Label.Status.MAY.name()
                   + ","
-                  + srResult.submitRequirement().name().toLowerCase());
+                  + srResult.submitRequirement().name().toLowerCase(Locale.US));
           break;
         case UNSATISFIED:
           result.add(
               SubmitRecord.Label.Status.NEED.name()
                   + ","
-                  + srResult.submitRequirement().name().toLowerCase());
+                  + srResult.submitRequirement().name().toLowerCase(Locale.US));
           result.add(
               SubmitRecord.Label.Status.REJECT.name()
                   + ","
-                  + srResult.submitRequirement().name().toLowerCase());
+                  + srResult.submitRequirement().name().toLowerCase(Locale.US));
           break;
         case NOT_APPLICABLE:
         case ERROR:
           result.add(
               SubmitRecord.Label.Status.IMPOSSIBLE.name()
                   + ","
-                  + srResult.submitRequirement().name().toLowerCase());
+                  + srResult.submitRequirement().name().toLowerCase(Locale.US));
       }
     }
     return result;
   }
 
   /** Serialized submit requirements, used for pre-populating results. */
-  public static final FieldDef<ChangeData, Iterable<byte[]>> STORED_SUBMIT_REQUIREMENTS =
-      storedOnly("full_submit_requirements")
-          .buildRepeatable(
-              cd ->
-                  toProtos(
-                      SubmitRequirementProtoConverter.INSTANCE, cd.submitRequirements().values()),
-              (cd, field) -> parseSubmitRequirements(field, cd));
+  private static final TypeToken<Iterable<Cache.SubmitRequirementResultProto>>
+      STORED_SUBMIT_REQUIREMENTS_TYPE_TOKEN =
+          new TypeToken<>() {
+            private static final long serialVersionUID = 1L;
+          };
 
-  private static void parseSubmitRequirements(Iterable<byte[]> values, ChangeData out) {
+  public static final IndexedField<ChangeData, Iterable<Cache.SubmitRequirementResultProto>>
+      STORED_SUBMIT_REQUIREMENTS_FIELD =
+          IndexedField.<ChangeData, Iterable<Cache.SubmitRequirementResultProto>>builder(
+                  "StoredSubmitRequirements", STORED_SUBMIT_REQUIREMENTS_TYPE_TOKEN)
+              .stored()
+              .required()
+              .protoConverter(Optional.of(SubmitRequirementProtoConverter.INSTANCE))
+              .build(
+                  cd ->
+                      entitiesToProtos(
+                          SubmitRequirementProtoConverter.INSTANCE,
+                          cd.submitRequirements().values()),
+                  (cd, value) -> parseSubmitRequirements(value, cd));
+
+  public static final IndexedField<ChangeData, Iterable<Cache.SubmitRequirementResultProto>>
+          .SearchSpec
+      STORED_SUBMIT_REQUIREMENTS_SPEC =
+          STORED_SUBMIT_REQUIREMENTS_FIELD.storedOnly("full_submit_requirements");
+
+  private static void parseSubmitRequirements(
+      Iterable<Cache.SubmitRequirementResultProto> values, ChangeData out) {
     out.setSubmitRequirements(
-        StreamSupport.stream(values.spliterator(), false)
-            .map(
-                f ->
-                    SubmitRequirementProtoConverter.INSTANCE.fromProto(
-                        Protos.parseUnchecked(
-                            SubmitRequirementProtoConverter.INSTANCE.getParser(), f)))
+        decodeProtosToEntities(values, SubmitRequirementProtoConverter.INSTANCE).stream()
             .filter(sr -> !sr.isLegacy())
             .collect(
                 ImmutableMap.toImmutableMap(sr -> sr.submitRequirement(), Function.identity())));
@@ -1333,9 +1663,10 @@
    *
    * <p>Emitted as UTF-8 encoded strings of the form {@code project:ref/name:[hex sha]}.
    */
-  public static final FieldDef<ChangeData, Iterable<byte[]>> REF_STATE =
-      storedOnly("ref_state")
-          .buildRepeatable(
+  public static final IndexedField<ChangeData, Iterable<byte[]>> REF_STATE_FIELD =
+      IndexedField.<ChangeData>iterableByteArrayBuilder("RefState")
+          .stored()
+          .build(
               cd -> {
                 List<byte[]> result = new ArrayList<>();
                 cd.getRefStates()
@@ -1345,15 +1676,19 @@
               },
               (cd, field) -> cd.setRefStates(RefState.parseStates(field)));
 
+  public static final IndexedField<ChangeData, Iterable<byte[]>>.SearchSpec REF_STATE_SPEC =
+      REF_STATE_FIELD.storedOnly("ref_state");
+
   /**
    * All ref wildcard patterns that were used in the course of indexing this document.
    *
    * <p>Emitted as UTF-8 encoded strings of the form {@code project:ref/name/*}. See {@link
    * RefStatePattern} for the pattern format.
    */
-  public static final FieldDef<ChangeData, Iterable<byte[]>> REF_STATE_PATTERN =
-      storedOnly("ref_state_pattern")
-          .buildRepeatable(
+  public static final IndexedField<ChangeData, Iterable<byte[]>> REF_STATE_PATTERN_FIELD =
+      IndexedField.<ChangeData>iterableByteArrayBuilder("RefStatePattern")
+          .stored()
+          .build(
               cd -> {
                 Change.Id id = cd.getId();
                 Project.NameKey project = cd.change().getProject();
@@ -1372,6 +1707,10 @@
               },
               (cd, field) -> cd.setRefStatePatterns(field));
 
+  public static final IndexedField<ChangeData, Iterable<byte[]>>.SearchSpec REF_STATE_PATTERN_SPEC =
+      REF_STATE_PATTERN_FIELD.storedOnly("ref_state_pattern");
+
+  @Nullable
   private static String getTopic(ChangeData cd) {
     Change c = cd.change();
     if (c == null) {
@@ -1380,24 +1719,28 @@
     return firstNonNull(c.getTopic(), "");
   }
 
-  private static <T> List<byte[]> toProtos(ProtoConverter<?, T> converter, Collection<T> objects) {
-    return objects.stream().map(object -> toProto(converter, object)).collect(toImmutableList());
+  private static <V extends MessageLite, T> V entityToProto(
+      ProtoConverter<V, T> converter, T object) {
+    return converter.toProto(object);
   }
 
-  private static <T> byte[] toProto(ProtoConverter<?, T> converter, T object) {
-    return Protos.toByteArray(converter.toProto(object));
-  }
-
-  private static <T> List<T> decodeProtos(Iterable<byte[]> raw, ProtoConverter<?, T> converter) {
-    return StreamSupport.stream(raw.spliterator(), false)
-        .map(bytes -> parseProtoFrom(bytes, converter))
+  private static <V extends MessageLite, T> List<V> entitiesToProtos(
+      ProtoConverter<V, T> converter, Collection<T> objects) {
+    return objects.stream()
+        .map(object -> entityToProto(converter, object))
         .collect(toImmutableList());
   }
 
-  private static <P extends MessageLite, T> T parseProtoFrom(
-      byte[] bytes, ProtoConverter<P, T> converter) {
-    P message = Protos.parseUnchecked(converter.getParser(), bytes, 0, bytes.length);
-    return converter.fromProto(message);
+  private static <V extends MessageLite, T> List<T> decodeProtosToEntities(
+      Iterable<V> raw, ProtoConverter<V, T> converter) {
+    return StreamSupport.stream(raw.spliterator(), false)
+        .map(proto -> decodeProtoToEntity(proto, converter))
+        .collect(toImmutableList());
+  }
+
+  private static <V extends MessageLite, T> T decodeProtoToEntity(
+      V proto, ProtoConverter<V, T> converter) {
+    return converter.fromProto(proto);
   }
 
   private static <T> SchemaFieldDefs.Getter<ChangeData, T> changeGetter(Function<Change, T> func) {
diff --git a/java/com/google/gerrit/server/index/change/ChangeIndex.java b/java/com/google/gerrit/server/index/change/ChangeIndex.java
index 6fc2665..74e9af1 100644
--- a/java/com/google/gerrit/server/index/change/ChangeIndex.java
+++ b/java/com/google/gerrit/server/index/change/ChangeIndex.java
@@ -20,6 +20,7 @@
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangePredicates;
+import java.util.function.Function;
 
 /**
  * Index for Gerrit changes. This class is mainly used for typing the generic parent class that
@@ -32,4 +33,6 @@
   default Predicate<ChangeData> keyPredicate(Change.Id id) {
     return ChangePredicates.idStr(id);
   }
+
+  Function<ChangeData, Change.Id> ENTITY_TO_KEY = ChangeData::getId;
 }
diff --git a/java/com/google/gerrit/server/index/change/ChangeIndexRewriter.java b/java/com/google/gerrit/server/index/change/ChangeIndexRewriter.java
index 05fb780..bb4b24c 100644
--- a/java/com/google/gerrit/server/index/change/ChangeIndexRewriter.java
+++ b/java/com/google/gerrit/server/index/change/ChangeIndexRewriter.java
@@ -188,6 +188,7 @@
    * @throws QueryParseException if the underlying index implementation does not support this
    *     predicate.
    */
+  @Nullable
   private Predicate<ChangeData> rewriteImpl(
       Predicate<ChangeData> in, ChangeIndex index, QueryOptions opts, MutableInteger leafTerms)
       throws QueryParseException {
diff --git a/java/com/google/gerrit/server/index/change/ChangeIndexer.java b/java/com/google/gerrit/server/index/change/ChangeIndexer.java
index a30e4a6..517809a 100644
--- a/java/com/google/gerrit/server/index/change/ChangeIndexer.java
+++ b/java/com/google/gerrit/server/index/change/ChangeIndexer.java
@@ -46,6 +46,7 @@
 import java.util.Collection;
 import java.util.Collections;
 import java.util.Map;
+import java.util.Optional;
 import java.util.Set;
 import java.util.concurrent.Callable;
 import java.util.concurrent.ConcurrentHashMap;
@@ -299,9 +300,9 @@
    * @param id change to delete.
    * @return future for the deleting task, the result of the future is always {@code null}
    */
-  public ListenableFuture<ChangeData> deleteAsync(Change.Id id) {
+  public ListenableFuture<ChangeData> deleteAsync(Project.NameKey project, Change.Id id) {
     fireChangeScheduledForDeletionFromIndexEvent(id.get());
-    return submit(new DeleteTask(id));
+    return submit(new DeleteTask(id, Optional.of(project)));
   }
 
   /**
@@ -314,8 +315,12 @@
     doDelete(id);
   }
 
+  private void doDelete(Project.NameKey project, Change.Id id) {
+    new DeleteTask(id, Optional.of(project)).call();
+  }
+
   private void doDelete(Change.Id id) {
-    new DeleteTask(id).call();
+    new DeleteTask(id, Optional.empty()).call();
   }
 
   /**
@@ -424,6 +429,7 @@
       return future;
     }
 
+    @Nullable
     @Override
     public ChangeData callImpl() throws Exception {
       // Remove this task from queuedIndexTasks. This is done right at the beginning of this task so
@@ -439,7 +445,7 @@
         doIndex(changeData);
         return changeData;
       } catch (NoSuchChangeException e) {
-        doDelete(id);
+        doDelete(project, id);
       }
       return null;
     }
@@ -472,11 +478,14 @@
   // Not AbstractIndexTask as it doesn't need a request context.
   private class DeleteTask implements Callable<ChangeData> {
     private final Change.Id id;
+    private final Optional<Project.NameKey> project;
 
-    private DeleteTask(Change.Id id) {
+    private DeleteTask(Change.Id id, Optional<Project.NameKey> project) {
       this.id = id;
+      this.project = project;
     }
 
+    @Nullable
     @Override
     public ChangeData call() {
       logger.atFine().log("Delete change %d from index.", id.get());
@@ -491,7 +500,12 @@
                     .changeId(id.get())
                     .indexVersion(i.getSchema().getVersion())
                     .build())) {
-          i.delete(id);
+          // Some index implementation require ProjectKey to build a database key
+          // If delete(K) method is used, this will require changeId -> projectKey lookup (index
+          // query), which is expensive.
+          // Use changeData with ProjectKey and deleteByValue(V) method, if possible
+          project.ifPresentOrElse(
+              p -> i.deleteByValue(changeDataFactory.create(p, id)), () -> i.delete(id));
         } catch (RuntimeException e) {
           throw new StorageException(
               String.format(
diff --git a/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java b/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
index 6116f5a..e74ce8f 100644
--- a/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
+++ b/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
@@ -16,6 +16,8 @@
 
 import static com.google.gerrit.index.SchemaUtil.schema;
 
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.index.IndexedField;
 import com.google.gerrit.index.Schema;
 import com.google.gerrit.index.SchemaDefinitions;
 import com.google.gerrit.server.query.change.ChangeData;
@@ -27,84 +29,155 @@
  * com.google.gerrit.index.IndexUpgradeValidator}.
  */
 public class ChangeSchemaDefinitions extends SchemaDefinitions<ChangeData> {
-  /** Added new field {@link ChangeField#IS_SUBMITTABLE} based on submit requirements. */
+  /** Added new field {@link ChangeField#IS_SUBMITTABLE_SPEC} based on submit requirements. */
   @Deprecated
   static final Schema<ChangeData> V74 =
       schema(
           /* version= */ 74,
-          ChangeField.ADDED,
-          ChangeField.APPROVAL,
-          ChangeField.ASSIGNEE,
-          ChangeField.ATTENTION_SET_FULL,
-          ChangeField.ATTENTION_SET_USERS,
-          ChangeField.ATTENTION_SET_USERS_COUNT,
-          ChangeField.AUTHOR,
-          ChangeField.CHANGE,
-          ChangeField.CHERRY_PICK,
-          ChangeField.CHERRY_PICK_OF_CHANGE,
-          ChangeField.CHERRY_PICK_OF_PATCHSET,
-          ChangeField.COMMENT,
-          ChangeField.COMMENTBY,
-          ChangeField.COMMIT,
-          ChangeField.COMMIT_MESSAGE,
-          ChangeField.COMMITTER,
-          ChangeField.DELETED,
-          ChangeField.DELTA,
-          ChangeField.DIRECTORY,
-          ChangeField.DRAFTBY,
-          ChangeField.EDITBY,
-          ChangeField.EXACT_AUTHOR,
-          ChangeField.EXACT_COMMIT,
-          ChangeField.EXACT_COMMITTER,
-          ChangeField.EXACT_TOPIC,
-          ChangeField.EXTENSION,
-          ChangeField.FILE_PART,
-          ChangeField.FOOTER,
-          ChangeField.FUZZY_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_STR,
-          ChangeField.MERGE,
-          ChangeField.MERGEABLE,
-          ChangeField.MERGED_ON,
-          ChangeField.ONLY_EXTENSIONS,
-          ChangeField.OWNER,
-          ChangeField.PATCH_SET,
-          ChangeField.PATH,
-          ChangeField.PENDING_REVIEWER,
-          ChangeField.PENDING_REVIEWER_BY_EMAIL,
-          ChangeField.PRIVATE,
-          ChangeField.PROJECT,
-          ChangeField.PROJECTS,
-          ChangeField.REF,
-          ChangeField.REF_STATE,
-          ChangeField.REF_STATE_PATTERN,
-          ChangeField.REVERT_OF,
-          ChangeField.REVIEWEDBY,
-          ChangeField.REVIEWER,
-          ChangeField.REVIEWER_BY_EMAIL,
-          ChangeField.STAR,
-          ChangeField.STARBY,
-          ChangeField.STARTED,
-          ChangeField.STATUS,
-          ChangeField.STORED_SUBMIT_RECORD_LENIENT,
-          ChangeField.STORED_SUBMIT_RECORD_STRICT,
-          ChangeField.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);
+          ImmutableList.<IndexedField<ChangeData, ?>>of(
+              ChangeField.ADDED_LINES_FIELD,
+              ChangeField.APPROVAL_FIELD,
+              ChangeField.ASSIGNEE_FIELD,
+              ChangeField.ATTENTION_SET_FULL_FIELD,
+              ChangeField.ATTENTION_SET_USERS_COUNT_FIELD,
+              ChangeField.ATTENTION_SET_USERS_FIELD,
+              ChangeField.AUTHOR_PARTS_FIELD,
+              ChangeField.CHANGE_FIELD,
+              ChangeField.CHANGE_ID_FIELD,
+              ChangeField.CHERRY_PICK_FIELD,
+              ChangeField.CHERRY_PICK_OF_CHANGE_FIELD,
+              ChangeField.CHERRY_PICK_OF_PATCHSET_FIELD,
+              ChangeField.COMMENTBY_FIELD,
+              ChangeField.COMMENT_FIELD,
+              ChangeField.COMMITTER_PARTS_FIELD,
+              ChangeField.COMMIT_FIELD,
+              ChangeField.COMMIT_MESSAGE_FIELD,
+              ChangeField.DELETED_LINES_FIELD,
+              ChangeField.DELTA_LINES_FIELD,
+              ChangeField.DIRECTORY_FIELD,
+              ChangeField.DRAFTBY_FIELD,
+              ChangeField.EDITBY_FIELD,
+              ChangeField.EXACT_AUTHOR_FIELD,
+              ChangeField.EXACT_COMMITTER_FIELD,
+              ChangeField.EXTENSION_FIELD,
+              ChangeField.FILE_PART_FIELD,
+              ChangeField.FOOTER_FIELD,
+              ChangeField.GROUP_FIELD,
+              ChangeField.HASHTAG_CASE_AWARE_FIELD,
+              ChangeField.HASHTAG_FIELD,
+              ChangeField.IS_PURE_REVERT_FIELD,
+              ChangeField.IS_SUBMITTABLE_FIELD,
+              ChangeField.LABEL_FIELD,
+              ChangeField.MERGEABLE_FIELD,
+              ChangeField.MERGED_ON_FIELD,
+              ChangeField.MERGE_FIELD,
+              ChangeField.NUMERIC_ID_STR_FIELD,
+              ChangeField.ONLY_EXTENSIONS_FIELD,
+              ChangeField.OWNER_FIELD,
+              ChangeField.PATCH_SET_FIELD,
+              ChangeField.PATH_FIELD,
+              ChangeField.PENDING_REVIEWER_BY_EMAIL_FIELD,
+              ChangeField.PENDING_REVIEWER_FIELD,
+              ChangeField.PRIVATE_FIELD,
+              ChangeField.PROJECT_FIELD,
+              ChangeField.REF_FIELD,
+              ChangeField.REF_STATE_FIELD,
+              ChangeField.REF_STATE_PATTERN_FIELD,
+              ChangeField.REVERT_OF_FIELD,
+              ChangeField.REVIEWEDBY_FIELD,
+              ChangeField.REVIEWER_BY_EMAIL_FIELD,
+              ChangeField.REVIEWER_FIELD,
+              ChangeField.STARBY_FIELD,
+              ChangeField.STARTED_FIELD,
+              ChangeField.STAR_FIELD,
+              ChangeField.STATUS_FIELD,
+              ChangeField.STORED_SUBMIT_RECORD_LENIENT_FIELD,
+              ChangeField.STORED_SUBMIT_RECORD_STRICT_FIELD,
+              ChangeField.STORED_SUBMIT_REQUIREMENTS_FIELD,
+              ChangeField.SUBMISSIONID_FIELD,
+              ChangeField.SUBMIT_RECORD_FIELD,
+              ChangeField.SUBMIT_RULE_RESULT_FIELD,
+              ChangeField.TOPIC_FIELD,
+              ChangeField.TOTAL_COMMENT_COUNT_FIELD,
+              ChangeField.TR_FIELD,
+              ChangeField.UNRESOLVED_COMMENT_COUNT_FIELD,
+              ChangeField.UPDATED_FIELD,
+              ChangeField.UPLOADER_FIELD,
+              ChangeField.WIP_FIELD),
+          ImmutableList.<IndexedField<ChangeData, ?>.SearchSpec>of(
+              ChangeField.ADDED_LINES_SPEC,
+              ChangeField.APPROVAL_SPEC,
+              ChangeField.ASSIGNEE_SPEC,
+              ChangeField.ATTENTION_SET_FULL_SPEC,
+              ChangeField.ATTENTION_SET_USERS,
+              ChangeField.ATTENTION_SET_USERS_COUNT,
+              ChangeField.AUTHOR_PARTS_SPEC,
+              ChangeField.CHANGE_ID_SPEC,
+              ChangeField.CHANGE_SPEC,
+              ChangeField.CHERRY_PICK_OF_CHANGE,
+              ChangeField.CHERRY_PICK_OF_PATCHSET,
+              ChangeField.CHERRY_PICK_SPEC,
+              ChangeField.COMMENTBY_SPEC,
+              ChangeField.COMMENT_SPEC,
+              ChangeField.COMMITTER_PARTS_SPEC,
+              ChangeField.COMMIT_MESSAGE,
+              ChangeField.COMMIT_SPEC,
+              ChangeField.DELETED_LINES_SPEC,
+              ChangeField.DELTA_LINES_SPEC,
+              ChangeField.DIRECTORY_SPEC,
+              ChangeField.DRAFTBY_SPEC,
+              ChangeField.EDITBY_SPEC,
+              ChangeField.EXACT_AUTHOR_SPEC,
+              ChangeField.EXACT_COMMITTER_SPEC,
+              ChangeField.EXACT_COMMIT_SPEC,
+              ChangeField.EXACT_TOPIC,
+              ChangeField.EXTENSION_SPEC,
+              ChangeField.FILE_PART_SPEC,
+              ChangeField.FOOTER_SPEC,
+              ChangeField.FUZZY_HASHTAG,
+              ChangeField.FUZZY_TOPIC,
+              ChangeField.GROUP_SPEC,
+              ChangeField.HASHTAG_CASE_AWARE_SPEC,
+              ChangeField.HASHTAG_SPEC,
+              ChangeField.IS_PURE_REVERT_SPEC,
+              ChangeField.IS_SUBMITTABLE_SPEC,
+              ChangeField.LABEL_SPEC,
+              ChangeField.MERGEABLE_SPEC,
+              ChangeField.MERGED_ON_SPEC,
+              ChangeField.MERGE_SPEC,
+              ChangeField.NUMERIC_ID_STR_SPEC,
+              ChangeField.ONLY_EXTENSIONS_SPEC,
+              ChangeField.OWNER_SPEC,
+              ChangeField.PATCH_SET_SPEC,
+              ChangeField.PATH_SPEC,
+              ChangeField.PENDING_REVIEWER_BY_EMAIL,
+              ChangeField.PENDING_REVIEWER_SPEC,
+              ChangeField.PRIVATE_SPEC,
+              ChangeField.PROJECTS_SPEC,
+              ChangeField.PROJECT_SPEC,
+              ChangeField.REF_SPEC,
+              ChangeField.REF_STATE_PATTERN_SPEC,
+              ChangeField.REF_STATE_SPEC,
+              ChangeField.REVERT_OF,
+              ChangeField.REVIEWEDBY_SPEC,
+              ChangeField.REVIEWER_BY_EMAIL,
+              ChangeField.REVIEWER_SPEC,
+              ChangeField.STARBY_SPEC,
+              ChangeField.STARTED_SPEC,
+              ChangeField.STAR_SPEC,
+              ChangeField.STATUS_SPEC,
+              ChangeField.STORED_SUBMIT_RECORD_LENIENT_SPEC,
+              ChangeField.STORED_SUBMIT_RECORD_STRICT_SPEC,
+              ChangeField.STORED_SUBMIT_REQUIREMENTS_SPEC,
+              ChangeField.SUBMISSIONID_SPEC,
+              ChangeField.SUBMIT_RECORD_SPEC,
+              ChangeField.SUBMIT_RULE_RESULT_SPEC,
+              ChangeField.TOTAL_COMMENT_COUNT_SPEC,
+              ChangeField.TR_SPEC,
+              ChangeField.UNRESOLVED_COMMENT_COUNT_SPEC,
+              ChangeField.UPDATED_SPEC,
+              ChangeField.UPLOADER_SPEC,
+              ChangeField.WIP_SPEC));
 
   /**
    * Added new field {@link ChangeField#PREFIX_HASHTAG} and {@link ChangeField#PREFIX_TOPIC} to
@@ -114,29 +187,66 @@
   static final Schema<ChangeData> V75 =
       new Schema.Builder<ChangeData>()
           .add(V74)
-          .add(ChangeField.PREFIX_HASHTAG)
-          .add(ChangeField.PREFIX_TOPIC)
+          .addSearchSpecs(ChangeField.PREFIX_HASHTAG)
+          .addSearchSpecs(ChangeField.PREFIX_TOPIC)
           .build();
 
   /** Added new field {@link ChangeField#FOOTER_NAME}. */
   @Deprecated
   static final Schema<ChangeData> V76 =
-      new Schema.Builder<ChangeData>().add(V75).add(ChangeField.FOOTER_NAME).build();
+      new Schema.Builder<ChangeData>()
+          .add(V75)
+          .addIndexedFields(ChangeField.FOOTER_NAME_FIELD)
+          .addSearchSpecs(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();
+      new Schema.Builder<ChangeData>()
+          .add(V76)
+          .addIndexedFields(ChangeField.COMMIT_MESSAGE_EXACT_FIELD)
+          .addSearchSpecs(ChangeField.COMMIT_MESSAGE_EXACT)
+          .build();
 
   // Upgrade Lucene to 7.x requires reindexing.
   @Deprecated static final Schema<ChangeData> V78 = schema(V77);
 
   /** Remove draft and star fields. */
+  @Deprecated
   static final Schema<ChangeData> V79 =
       new Schema.Builder<ChangeData>()
           .add(V78)
-          .remove(ChangeField.DRAFTBY, ChangeField.STAR, ChangeField.STARBY)
+          .remove(ChangeField.STAR_SPEC, ChangeField.STARBY_SPEC, ChangeField.DRAFTBY_SPEC)
+          .remove(ChangeField.STAR_FIELD, ChangeField.STARBY_FIELD, ChangeField.DRAFTBY_FIELD)
           .build();
+
+  /** Add subject field. */
+  @Deprecated
+  static final Schema<ChangeData> V80 =
+      new Schema.Builder<ChangeData>()
+          .add(V79)
+          .addIndexedFields(ChangeField.SUBJECT_FIELD)
+          .addSearchSpecs(ChangeField.SUBJECT_SPEC)
+          .build();
+
+  /** Add prefixsubject field. */
+  @Deprecated
+  static final Schema<ChangeData> V81 =
+      new Schema.Builder<ChangeData>()
+          .add(V80)
+          .addSearchSpecs(ChangeField.PREFIX_SUBJECT_SPEC)
+          .build();
+
+  /** Remove assignee field. */
+  @SuppressWarnings("deprecation")
+  static final Schema<ChangeData> V82 =
+      new Schema.Builder<ChangeData>()
+          .add(V81)
+          .remove(ChangeField.ASSIGNEE_SPEC)
+          .remove(ChangeField.ASSIGNEE_FIELD)
+          .build();
+
   /**
    * Name of the change index to be used when contacting index backends or loading configurations.
    */
diff --git a/java/com/google/gerrit/server/index/change/IndexedChangeQuery.java b/java/com/google/gerrit/server/index/change/IndexedChangeQuery.java
index 339d7bb..8f5e36e 100644
--- a/java/com/google/gerrit/server/index/change/IndexedChangeQuery.java
+++ b/java/com/google/gerrit/server/index/change/IndexedChangeQuery.java
@@ -15,8 +15,9 @@
 package com.google.gerrit.server.index.change;
 
 import static com.google.common.base.Preconditions.checkState;
-import static com.google.gerrit.server.index.change.ChangeField.CHANGE;
-import static com.google.gerrit.server.index.change.ChangeField.PROJECT;
+import static com.google.gerrit.server.index.change.ChangeField.CHANGE_SPEC;
+import static com.google.gerrit.server.index.change.ChangeField.NUMERIC_ID_STR_SPEC;
+import static com.google.gerrit.server.index.change.ChangeField.PROJECT_SPEC;
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.collect.ImmutableList;
@@ -68,10 +69,13 @@
       int pageSizeMultiplier,
       int limit,
       Set<String> fields) {
-    // Always include project since it is needed to load the change from NoteDb.
-    if (!fields.contains(CHANGE.getName()) && !fields.contains(PROJECT.getName())) {
+    // Always include project and change id since both are needed to load the change from NoteDb.
+    if (!fields.contains(CHANGE_SPEC.getName())
+        && !(fields.contains(PROJECT_SPEC.getName())
+            && fields.contains(NUMERIC_ID_STR_SPEC.getName()))) {
       fields = new HashSet<>(fields);
-      fields.add(PROJECT.getName());
+      fields.add(PROJECT_SPEC.getName());
+      fields.add(NUMERIC_ID_STR_SPEC.getName());
     }
     return QueryOptions.create(config, start, pageSize, pageSizeMultiplier, limit, fields);
   }
@@ -176,6 +180,6 @@
 
   @Override
   public boolean hasChange() {
-    return index.getSchema().hasField(ChangeField.CHANGE);
+    return index.getSchema().hasField(ChangeField.CHANGE_SPEC);
   }
 }
diff --git a/java/com/google/gerrit/server/index/change/StalenessChecker.java b/java/com/google/gerrit/server/index/change/StalenessChecker.java
index ad5cc2b..eb4af01 100644
--- a/java/com/google/gerrit/server/index/change/StalenessChecker.java
+++ b/java/com/google/gerrit/server/index/change/StalenessChecker.java
@@ -56,9 +56,9 @@
 
   public static final ImmutableSet<String> FIELDS =
       ImmutableSet.of(
-          ChangeField.CHANGE.getName(),
-          ChangeField.REF_STATE.getName(),
-          ChangeField.REF_STATE_PATTERN.getName());
+          ChangeField.CHANGE_SPEC.getName(),
+          ChangeField.REF_STATE_SPEC.getName(),
+          ChangeField.REF_STATE_PATTERN_SPEC.getName());
 
   private final ChangeIndexCollection indexes;
   private final GitRepositoryManager repoManager;
@@ -82,8 +82,8 @@
       return StalenessCheckResult
           .notStale(); // No index; caller couldn't do anything if it is stale.
     }
-    if (!i.getSchema().hasField(ChangeField.REF_STATE)
-        || !i.getSchema().hasField(ChangeField.REF_STATE_PATTERN)) {
+    if (!i.getSchema().hasField(ChangeField.REF_STATE_SPEC)
+        || !i.getSchema().hasField(ChangeField.REF_STATE_PATTERN_SPEC)) {
       return StalenessCheckResult.notStale(); // Index version not new enough for this check.
     }
 
diff --git a/java/com/google/gerrit/server/index/group/GroupIndex.java b/java/com/google/gerrit/server/index/group/GroupIndex.java
index 28c0384..f6a9224 100644
--- a/java/com/google/gerrit/server/index/group/GroupIndex.java
+++ b/java/com/google/gerrit/server/index/group/GroupIndex.java
@@ -20,6 +20,7 @@
 import com.google.gerrit.index.IndexDefinition;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.server.query.group.GroupPredicates;
+import java.util.function.Function;
 
 /**
  * Index for internal Gerrit groups. This class is mainly used for typing the generic parent class
@@ -33,4 +34,6 @@
   default Predicate<InternalGroup> keyPredicate(AccountGroup.UUID uuid) {
     return GroupPredicates.uuid(uuid);
   }
+
+  Function<InternalGroup, AccountGroup.UUID> ENTITY_TO_KEY = (g) -> g.getGroupUUID();
 }
diff --git a/java/com/google/gerrit/server/index/group/GroupSchemaDefinitions.java b/java/com/google/gerrit/server/index/group/GroupSchemaDefinitions.java
index 26f9e96..f0f3510 100644
--- a/java/com/google/gerrit/server/index/group/GroupSchemaDefinitions.java
+++ b/java/com/google/gerrit/server/index/group/GroupSchemaDefinitions.java
@@ -33,7 +33,6 @@
   static final Schema<InternalGroup> V5 =
       schema(
           /* version= */ 5,
-          ImmutableList.of(),
           ImmutableList.of(
               GroupField.CREATED_ON_FIELD,
               GroupField.DESCRIPTION_FIELD,
diff --git a/java/com/google/gerrit/server/index/project/StalenessChecker.java b/java/com/google/gerrit/server/index/project/StalenessChecker.java
index 9c44c00..b2e24e4 100644
--- a/java/com/google/gerrit/server/index/project/StalenessChecker.java
+++ b/java/com/google/gerrit/server/index/project/StalenessChecker.java
@@ -40,7 +40,7 @@
  */
 public class StalenessChecker {
   private static final ImmutableSet<String> FIELDS =
-      ImmutableSet.of(ProjectField.NAME.getName(), ProjectField.REF_STATE.getName());
+      ImmutableSet.of(ProjectField.NAME_SPEC.getName(), ProjectField.REF_STATE_SPEC.getName());
 
   private final ProjectCache projectCache;
   private final ProjectIndexCollection indexes;
@@ -74,7 +74,7 @@
     }
 
     SetMultimap<Project.NameKey, RefState> indexedRefStates =
-        RefState.parseStates(result.get().getValue(ProjectField.REF_STATE));
+        RefState.parseStates(result.get().getValue(ProjectField.REF_STATE_SPEC));
 
     SetMultimap<Project.NameKey, RefState> currentRefStates =
         MultimapBuilder.hashKeys().hashSetValues().build();
diff --git a/java/com/google/gerrit/server/ioutil/BUILD b/java/com/google/gerrit/server/ioutil/BUILD
index fd0c4f1..6b9ecdf 100644
--- a/java/com/google/gerrit/server/ioutil/BUILD
+++ b/java/com/google/gerrit/server/ioutil/BUILD
@@ -5,6 +5,7 @@
     srcs = glob(["**/*.java"]),
     visibility = ["//visibility:public"],
     deps = [
+        "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/entities",
         "//lib:automaton",
         "//lib:guava",
diff --git a/java/com/google/gerrit/server/ioutil/BasicSerialization.java b/java/com/google/gerrit/server/ioutil/BasicSerialization.java
index 296cf22..b43655a 100644
--- a/java/com/google/gerrit/server/ioutil/BasicSerialization.java
+++ b/java/com/google/gerrit/server/ioutil/BasicSerialization.java
@@ -31,6 +31,7 @@
 
 import static java.nio.charset.StandardCharsets.UTF_8;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.CodedEnum;
 import java.io.EOFException;
 import java.io.IOException;
@@ -129,6 +130,7 @@
   }
 
   /** Read a UTF-8 string, prefixed by its byte length in a varint. */
+  @Nullable
   public static String readString(InputStream input) throws IOException {
     final byte[] bin = readBytes(input);
     if (bin.length == 0) {
diff --git a/java/com/google/gerrit/server/ioutil/HostPlatform.java b/java/com/google/gerrit/server/ioutil/HostPlatform.java
index e27d17c..b912c52 100644
--- a/java/com/google/gerrit/server/ioutil/HostPlatform.java
+++ b/java/com/google/gerrit/server/ioutil/HostPlatform.java
@@ -16,6 +16,7 @@
 
 import java.security.AccessController;
 import java.security.PrivilegedAction;
+import java.util.Locale;
 
 public final class HostPlatform {
   private static final boolean win32 = compute("windows");
@@ -34,7 +35,7 @@
     final String osDotName =
         AccessController.doPrivileged(
             (PrivilegedAction<String>) () -> System.getProperty("os.name"));
-    return osDotName != null && osDotName.toLowerCase().contains(platform);
+    return osDotName != null && osDotName.toLowerCase(Locale.US).contains(platform);
   }
 
   private HostPlatform() {}
diff --git a/java/com/google/gerrit/server/mail/EmailModule.java b/java/com/google/gerrit/server/mail/EmailModule.java
index c659b5f..50f26bb 100644
--- a/java/com/google/gerrit/server/mail/EmailModule.java
+++ b/java/com/google/gerrit/server/mail/EmailModule.java
@@ -31,7 +31,6 @@
 import com.google.gerrit.server.mail.send.ReplacePatchSetSender;
 import com.google.gerrit.server.mail.send.RestoredSender;
 import com.google.gerrit.server.mail.send.RevertedSender;
-import com.google.gerrit.server.mail.send.SetAssigneeSender;
 
 public class EmailModule extends FactoryModule {
   @Override
@@ -50,7 +49,6 @@
     factory(ReplacePatchSetSender.Factory.class);
     factory(RestoredSender.Factory.class);
     factory(RevertedSender.Factory.class);
-    factory(SetAssigneeSender.Factory.class);
     factory(AddToAttentionSetSender.Factory.class);
     factory(RemoveFromAttentionSetSender.Factory.class);
   }
diff --git a/java/com/google/gerrit/server/mail/EmailSettings.java b/java/com/google/gerrit/server/mail/EmailSettings.java
index 15b61d0..c411af5 100644
--- a/java/com/google/gerrit/server/mail/EmailSettings.java
+++ b/java/com/google/gerrit/server/mail/EmailSettings.java
@@ -38,7 +38,6 @@
   public final Encryption encryption;
   public final long fetchInterval; // in milliseconds
   public final boolean sendNewPatchsetEmails;
-  public final boolean isAttentionSetEnabled;
 
   @Inject
   EmailSettings(@GerritServerConfig Config cfg) {
@@ -61,6 +60,5 @@
             TimeUnit.MILLISECONDS.convert(60, TimeUnit.SECONDS),
             TimeUnit.MILLISECONDS);
     sendNewPatchsetEmails = cfg.getBoolean("change", null, "sendNewPatchsetEmails", true);
-    isAttentionSetEnabled = cfg.getBoolean("change", null, "enableAttentionSet", true);
   }
 }
diff --git a/java/com/google/gerrit/server/mail/receive/MailProcessor.java b/java/com/google/gerrit/server/mail/receive/MailProcessor.java
index e362c4b..93da997 100644
--- a/java/com/google/gerrit/server/mail/receive/MailProcessor.java
+++ b/java/com/google/gerrit/server/mail/receive/MailProcessor.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.mail.receive;
 
 import static com.google.gerrit.entities.Patch.PATCHSET_LEVEL;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.base.Strings;
@@ -69,6 +70,7 @@
 import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.gerrit.server.update.RetryHelper;
 import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.update.context.RefUpdateContext;
 import com.google.gerrit.server.util.ManualRequestContext;
 import com.google.gerrit.server.util.OneOffRequestContext;
 import com.google.gerrit.server.util.time.TimeUtil;
@@ -85,7 +87,13 @@
 import java.util.Optional;
 import java.util.Set;
 
-/** A service that can attach the comments from a {@link MailMessage} to a change. */
+/**
+ * Users can post comments on gerrit changes by replying directly to gerrit emails. This service
+ * parses the {@link MailMessage} sent by users and attaches the comments to a change.
+ *
+ * <p>This functionality can be configured or disabled by host. See {@link
+ * com.google.gerrit.server.mail.receive.MailReceiver.MailReceiverModule}
+ */
 @Singleton
 public class MailProcessor {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
@@ -313,9 +321,11 @@
       }
 
       Op o = new Op(PatchSet.id(cd.getId(), metadata.patchSet), parsedComments, message.id());
-      BatchUpdate batchUpdate = buf.create(project, ctx.getUser(), TimeUtil.now());
-      batchUpdate.addOp(cd.getId(), o);
-      batchUpdate.execute();
+      try (RefUpdateContext updCtx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+        BatchUpdate batchUpdate = buf.create(project, ctx.getUser(), TimeUtil.now());
+        batchUpdate.addOp(cd.getId(), o);
+        batchUpdate.execute();
+      }
     }
   }
 
diff --git a/java/com/google/gerrit/server/mail/receive/MailReceiver.java b/java/com/google/gerrit/server/mail/receive/MailReceiver.java
index 23e1cc3..a308168 100644
--- a/java/com/google/gerrit/server/mail/receive/MailReceiver.java
+++ b/java/com/google/gerrit/server/mail/receive/MailReceiver.java
@@ -131,27 +131,20 @@
       if (async) {
         @SuppressWarnings("unused")
         Future<?> possiblyIgnoredError =
-            workQueue
-                .getDefaultQueue()
-                .submit(
-                    () -> {
-                      try {
-                        mailProcessor.process(m);
-                        requestDeletion(m.id());
-                      } catch (RestApiException | UpdateException e) {
-                        logger.atSevere().withCause(e).log(
-                            "Mail: Can't process message %s . Won't delete.", m.id());
-                      }
-                    });
+            workQueue.getDefaultQueue().submit(() -> processMessage(m));
       } else {
         // Synchronous processing is used only in tests.
-        try {
-          mailProcessor.process(m);
-          requestDeletion(m.id());
-        } catch (RestApiException | UpdateException e) {
-          logger.atSevere().withCause(e).log("Mail: Can't process messages. Won't delete.");
-        }
+        processMessage(m);
       }
     }
   }
+
+  private void processMessage(MailMessage m) {
+    try {
+      mailProcessor.process(m);
+      requestDeletion(m.id());
+    } catch (RestApiException | UpdateException e) {
+      logger.atSevere().withCause(e).log("Mail: Can't process message %s . Won't delete.", m.id());
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/mail/send/AddKeySender.java b/java/com/google/gerrit/server/mail/send/AddKeySender.java
index 652766a..73a46a4 100644
--- a/java/com/google/gerrit/server/mail/send/AddKeySender.java
+++ b/java/com/google/gerrit/server/mail/send/AddKeySender.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.mail.send;
 
 import com.google.common.base.Joiner;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.exceptions.EmailException;
 import com.google.gerrit.extensions.api.changes.RecipientType;
 import com.google.gerrit.server.IdentifiedUser;
@@ -67,7 +68,7 @@
     super.init();
     setHeader("Subject", String.format("[Gerrit Code Review] New %s Keys Added", getKeyType()));
     setMessageId(messageIdGenerator.fromAccountUpdate(user.getAccountId()));
-    add(RecipientType.TO, user.getAccountId());
+    addByAccountId(RecipientType.TO, user.getAccountId());
   }
 
   @Override
@@ -111,10 +112,12 @@
     return "Unknown";
   }
 
+  @Nullable
   private String getSshKey() {
     return (sshKey != null) ? sshKey.sshPublicKey() + "\n" : null;
   }
 
+  @Nullable
   private String getGpgKeys() {
     if (gpgKeys != null) {
       return Joiner.on("\n").join(gpgKeys);
diff --git a/java/com/google/gerrit/server/mail/send/BranchEmailUtils.java b/java/com/google/gerrit/server/mail/send/BranchEmailUtils.java
new file mode 100644
index 0000000..acba4ea
--- /dev/null
+++ b/java/com/google/gerrit/server/mail/send/BranchEmailUtils.java
@@ -0,0 +1,98 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.mail.send;
+
+import com.google.common.collect.Iterables;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.mail.MailHeader;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/** Contains utils for email notification related to the events on project+branch. */
+class BranchEmailUtils {
+
+  /** Set a reasonable list id so that filters can be used to sort messages. */
+  static void setListIdHeader(OutgoingEmail email, BranchNameKey branch) {
+    email.setHeader(
+        "List-Id",
+        "<gerrit-" + branch.project().get().replace('/', '-') + "." + email.getGerritHost() + ">");
+    if (email.getSettingsUrl() != null) {
+      email.setHeader("List-Unsubscribe", "<" + email.getSettingsUrl() + ">");
+    }
+  }
+
+  /** Add branch information to soy template params. */
+  static void addBranchData(OutgoingEmail email, EmailArguments args, BranchNameKey branch) {
+    Map<String, Object> soyContext = email.getSoyContext();
+    Map<String, Object> soyContextEmailData = email.getSoyContextEmailData();
+
+    String projectName = branch.project().get();
+    soyContext.put("projectName", projectName);
+    // shortProjectName is the project name with the path abbreviated.
+    soyContext.put("shortProjectName", getShortProjectName(projectName));
+
+    // instanceAndProjectName is the instance's name followed by the abbreviated project path
+    soyContext.put(
+        "instanceAndProjectName",
+        getInstanceAndProjectName(args.instanceNameProvider.get(), projectName));
+    soyContext.put("addInstanceNameInSubject", args.addInstanceNameInSubject);
+
+    soyContextEmailData.put("sshHost", getSshHost(email.getGerritHost(), args.sshAddresses));
+
+    Map<String, String> branchData = new HashMap<>();
+    branchData.put("shortName", branch.shortName());
+    soyContext.put("branch", branchData);
+
+    email.addFooter(MailHeader.PROJECT.withDelimiter() + branch.project().get());
+    email.addFooter(MailHeader.BRANCH.withDelimiter() + branch.shortName());
+  }
+
+  @Nullable
+  private static String getSshHost(String gerritHost, List<String> sshAddresses) {
+    String host = Iterables.getFirst(sshAddresses, null);
+    if (host == null) {
+      return null;
+    }
+    if (host.startsWith("*:")) {
+      return gerritHost + host.substring(1);
+    }
+    return host;
+  }
+
+  /** Shortens project/repo name to only show part after the last '/'. */
+  static String getShortProjectName(String projectName) {
+    int lastIndexSlash = projectName.lastIndexOf('/');
+    if (lastIndexSlash == 0) {
+      return projectName.substring(1); // Remove the first slash
+    }
+    if (lastIndexSlash == -1) { // No slash in the project name
+      return projectName;
+    }
+
+    return "..." + projectName.substring(lastIndexSlash + 1);
+  }
+
+  /** Returns a project/repo name that includes instance as prefix. */
+  static String getInstanceAndProjectName(String instanceName, String projectName) {
+    if (instanceName == null || instanceName.isEmpty()) {
+      return getShortProjectName(projectName);
+    }
+    // Extract the project name (everything after the last slash) and prepends it with gerrit's
+    // instance name
+    return instanceName + "/" + projectName.substring(projectName.lastIndexOf('/') + 1);
+  }
+}
diff --git a/java/com/google/gerrit/server/mail/send/ChangeEmail.java b/java/com/google/gerrit/server/mail/send/ChangeEmail.java
index 8be5548..62471ac 100644
--- a/java/com/google/gerrit/server/mail/send/ChangeEmail.java
+++ b/java/com/google/gerrit/server/mail/send/ChangeEmail.java
@@ -24,7 +24,9 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Address;
 import com.google.gerrit.entities.AttentionSetUpdate;
+import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.ChangeSizeBucket;
 import com.google.gerrit.entities.NotifyConfig.NotifyType;
@@ -42,6 +44,7 @@
 import com.google.gerrit.server.StarredChangesUtil;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.mail.send.ProjectWatch.Watchers;
+import com.google.gerrit.server.mail.send.ProjectWatch.Watchers.WatcherList;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
 import com.google.gerrit.server.patch.DiffNotAvailableException;
 import com.google.gerrit.server.patch.DiffOptions;
@@ -77,7 +80,7 @@
 import org.eclipse.jgit.util.TemporaryBuffer;
 
 /** Sends an email to one or more interested parties. */
-public abstract class ChangeEmail extends NotificationEmail {
+public abstract class ChangeEmail extends OutgoingEmail {
 
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
@@ -99,19 +102,25 @@
   protected PatchSetInfo patchSetInfo;
   protected String changeMessage;
   protected Instant timestamp;
+  protected BranchNameKey branch;
 
   protected ProjectState projectState;
-  protected Set<Account.Id> authors;
-  protected boolean emailOnlyAuthors;
+  private Set<Account.Id> authors;
+  private boolean emailOnlyAuthors;
   protected boolean emailOnlyAttentionSetIfEnabled;
+  // Watchers ignore attention set rules.
+  protected Set<Account.Id> watcherAccounts = new HashSet<>();
+  // Watcher can only be an email if it's specified in notify section of ProjectConfig.
+  protected Set<Address> watcherEmails = new HashSet<>();
 
   protected ChangeEmail(EmailArguments args, String messageClass, ChangeData changeData) {
-    super(args, messageClass, changeData.change().getDest());
+    super(args, messageClass);
     this.changeData = changeData;
     change = changeData.change();
     emailOnlyAuthors = false;
     emailOnlyAttentionSetIfEnabled = true;
     currentAttentionSet = getAttentionSet();
+    branch = changeData.change().getDest();
   }
 
   @Override
@@ -192,7 +201,6 @@
         }
       }
     }
-    authors = getAuthors();
 
     try {
       stars = changeData.stars();
@@ -201,6 +209,7 @@
     }
 
     super.init();
+    BranchEmailUtils.setListIdHeader(this, branch);
     if (timestamp != null) {
       setHeader(FieldName.DATE, timestamp);
     }
@@ -211,13 +220,13 @@
     setChangeUrlHeader();
     setCommitIdHeader();
 
-    if (notify.handling().compareTo(NotifyHandling.OWNER_REVIEWERS) >= 0) {
+    if (notify.handling().equals(NotifyHandling.OWNER_REVIEWERS)
+        || notify.handling().equals(NotifyHandling.ALL)) {
       try {
-        addByEmail(
-            RecipientType.CC, changeData.reviewersByEmail().byState(ReviewerStateInternal.CC));
-        addByEmail(
-            RecipientType.CC,
-            changeData.reviewersByEmail().byState(ReviewerStateInternal.REVIEWER));
+        changeData.reviewersByEmail().byState(ReviewerStateInternal.CC).stream()
+            .forEach(address -> addByEmail(RecipientType.CC, address));
+        changeData.reviewersByEmail().byState(ReviewerStateInternal.REVIEWER).stream()
+            .forEach(address -> addByEmail(RecipientType.CC, address));
       } catch (StorageException e) {
         throw new EmailException("Failed to add unregistered CCs " + change.getChangeId(), e);
       }
@@ -242,7 +251,9 @@
   }
 
   private int getInsertionsCount() {
-    return listModifiedFiles().values().stream()
+    return listModifiedFiles().entrySet().stream()
+        .filter(e -> !Patch.COMMIT_MSG.equals(e.getKey()))
+        .map(Map.Entry::getValue)
         .map(FileDiffOutput::insertions)
         .reduce(0, Integer::sum);
   }
@@ -323,8 +334,8 @@
                     + "{1,choice,0#0 insertions|1#1 insertion|1<{1} insertions}(+), " //
                     + "{2,choice,0#0 deletions|1#1 deletion|1<{2} deletions}(-)" //
                     + "\n",
-                modifiedFiles.size() - 1, //
-                getInsertionsCount(), //
+                modifiedFiles.size() - 1, // -1 to account for the commit message
+                getInsertionsCount(),
                 getDeletionsCount()));
         detail.append("\n");
       }
@@ -373,9 +384,9 @@
   }
 
   /** TO or CC all vested parties (change owner, patch set uploader, author). */
-  protected void rcptToAuthors(RecipientType rt) {
-    for (Account.Id id : authors) {
-      add(rt, id);
+  protected void addAuthors(RecipientType rt) {
+    for (Account.Id id : getAuthors()) {
+      addByAccountId(rt, id);
     }
   }
 
@@ -387,13 +398,45 @@
 
     for (Map.Entry<Account.Id, Collection<String>> e : stars.asMap().entrySet()) {
       if (e.getValue().contains(StarredChangesUtil.DEFAULT_LABEL)) {
-        super.add(RecipientType.BCC, e.getKey());
+        super.addByAccountId(RecipientType.BCC, e.getKey());
       }
     }
   }
 
-  @Override
-  protected final Watchers getWatchers(NotifyType type, boolean includeWatchersFromNotifyConfig) {
+  /** Include users and groups that want notification of events. */
+  protected void includeWatchers(NotifyType type) {
+    includeWatchers(type, true);
+  }
+
+  /** Include users and groups that want notification of events. */
+  protected void includeWatchers(NotifyType type, boolean includeWatchersFromNotifyConfig) {
+    try {
+      Watchers matching = getWatchers(type, includeWatchersFromNotifyConfig);
+      addWatchers(RecipientType.TO, matching.to);
+      addWatchers(RecipientType.CC, matching.cc);
+      addWatchers(RecipientType.BCC, matching.bcc);
+    } catch (StorageException err) {
+      // Just don't CC everyone. Better to send a partial message to those
+      // we already have queued up then to fail deliver entirely to people
+      // who have a lower interest in the change.
+      logger.atWarning().withCause(err).log("Cannot BCC watchers for %s", type);
+    }
+  }
+
+  /** Add users or email addresses to the TO, CC, or BCC list. */
+  private void addWatchers(RecipientType type, WatcherList watcherList) {
+    watcherAccounts.addAll(watcherList.accounts);
+    for (Account.Id user : watcherList.accounts) {
+      addByAccountId(type, user);
+    }
+
+    watcherEmails.addAll(watcherList.emails);
+    for (Address addr : watcherList.emails) {
+      addByEmail(type, addr);
+    }
+  }
+
+  private final Watchers getWatchers(NotifyType type, boolean includeWatchersFromNotifyConfig) {
     if (!NotifyHandling.ALL.equals(notify.handling())) {
       return new Watchers();
     }
@@ -411,7 +454,7 @@
 
     try {
       for (Account.Id id : changeData.reviewers().all()) {
-        add(RecipientType.CC, id);
+        addByAccountId(RecipientType.CC, id);
       }
     } catch (StorageException err) {
       logger.atWarning().withCause(err).log("Cannot CC users that reviewed updated change");
@@ -427,7 +470,7 @@
 
     try {
       for (Account.Id id : changeData.reviewers().byState(ReviewerStateInternal.REVIEWER)) {
-        add(RecipientType.CC, id);
+        addByAccountId(RecipientType.CC, id);
       }
     } catch (StorageException err) {
       logger.atWarning().withCause(err).log("Cannot CC users that commented on updated change");
@@ -435,43 +478,54 @@
   }
 
   @Override
-  protected void add(RecipientType rt, Account.Id to) {
-    addRecipient(rt, to, /* isWatcher= */ false);
+  protected boolean isRecipientAllowed(Address addr) throws PermissionBackendException {
+    if (!projectState.statePermitsRead()) {
+      return false;
+    }
+    if (emailOnlyAuthors) {
+      return false;
+    }
+
+    // If the email is a watcher email, skip permission check. An email can only be a watcher if
+    // it is specified in notify section of ProjectConfig, so we trust that the recipient is
+    // allowed.
+    if (watcherEmails.contains(addr)) {
+      return true;
+    }
+    return args.permissionBackend
+        .user(args.anonymousUser.get())
+        .change(changeData)
+        .test(ChangePermission.READ);
   }
 
-  /** This bypasses the EmailStrategy.ATTENTION_SET_ONLY strategy when adding the recipient. */
   @Override
-  protected void addWatcher(RecipientType rt, Account.Id to) {
-    addRecipient(rt, to, /* isWatcher= */ true);
-  }
-
-  private void addRecipient(RecipientType rt, Account.Id to, boolean isWatcher) {
-    if (!isWatcher) {
+  protected boolean isRecipientAllowed(Account.Id to) throws PermissionBackendException {
+    if (!projectState.statePermitsRead()) {
+      return false;
+    }
+    if (emailOnlyAuthors && !getAuthors().contains(to)) {
+      return false;
+    }
+    // Watchers ignore AttentionSet rules.
+    if (!watcherAccounts.contains(to)) {
       Optional<AccountState> accountState = args.accountCache.get(to);
       if (emailOnlyAttentionSetIfEnabled
           && accountState.isPresent()
           && accountState.get().generalPreferences().getEmailStrategy()
               == EmailStrategy.ATTENTION_SET_ONLY
           && !currentAttentionSet.contains(to)) {
-        return;
+        return false;
       }
     }
-    if (emailOnlyAuthors && !authors.contains(to)) {
-      return;
-    }
-    super.add(rt, to);
-  }
 
-  @Override
-  protected boolean isVisibleTo(Account.Id to) throws PermissionBackendException {
-    if (!projectState.statePermitsRead()) {
-      return false;
-    }
     return args.permissionBackend.absentUser(to).change(changeData).test(ChangePermission.READ);
   }
 
-  /** Find all users who are authors of any part of this change. */
+  /** Lazily finds all users who are authors of any part of this change. */
   protected Set<Account.Id> getAuthors() {
+    if (this.authors != null) {
+      return this.authors;
+    }
     Set<Account.Id> authors = new HashSet<>();
 
     switch (notify.handling()) {
@@ -497,12 +551,13 @@
         break;
     }
 
-    return authors;
+    return this.authors = authors;
   }
 
   @Override
   protected void setupSoyContext() {
     super.setupSoyContext();
+    BranchEmailUtils.addBranchData(this, args, branch);
 
     soyContext.put("changeId", change.getKey().get());
     soyContext.put("coverLetter", getCoverLetter());
@@ -546,9 +601,6 @@
     footers.add(MailHeader.CHANGE_NUMBER.withDelimiter() + change.getChangeId());
     footers.add(MailHeader.PATCH_SET.withDelimiter() + patchSet.number());
     footers.add(MailHeader.OWNER.withDelimiter() + getNameEmailFor(change.getOwner()));
-    if (change.getAssignee() != null) {
-      footers.add(MailHeader.ASSIGNEE.withDelimiter() + getNameEmailFor(change.getAssignee()));
-    }
     for (String reviewer : getEmailsByState(ReviewerStateInternal.REVIEWER)) {
       footers.add(MailHeader.REVIEWER.withDelimiter() + reviewer);
     }
@@ -558,8 +610,7 @@
     for (Account.Id attentionUser : currentAttentionSet) {
       footers.add(MailHeader.ATTENTION.withDelimiter() + getNameEmailFor(attentionUser));
     }
-    // Since this would be user visible, only show it if attention set is enabled
-    if (args.settings.isAttentionSetEnabled && !currentAttentionSet.isEmpty()) {
+    if (!currentAttentionSet.isEmpty()) {
       // We need names rather than account ids / emails to make it user readable.
       soyContext.put(
           "attentionSet",
diff --git a/java/com/google/gerrit/server/mail/send/CommentSender.java b/java/com/google/gerrit/server/mail/send/CommentSender.java
index 3c821cc..3711ca2 100644
--- a/java/com/google/gerrit/server/mail/send/CommentSender.java
+++ b/java/com/google/gerrit/server/mail/send/CommentSender.java
@@ -87,16 +87,19 @@
     public List<Comment> comments = new ArrayList<>();
 
     /** Returns a web link to a comment for a change. */
+    @Nullable
     public String getCommentLink(String uuid) {
       return args.urlFormatter.get().getInlineCommentView(change, uuid).orElse(null);
     }
 
     /** Returns a web link to the comment tab view of a change. */
+    @Nullable
     public String getCommentsTabLink() {
       return args.urlFormatter.get().getCommentsTabView(change).orElse(null);
     }
 
     /** Returns a web link to the findings tab view of a change. */
+    @Nullable
     public String getFindingsTabLink() {
       return args.urlFormatter.get().getFindingsTabView(change).orElse(null);
     }
@@ -169,10 +172,11 @@
   protected void init() throws EmailException {
     super.init();
 
-    if (notify.handling().compareTo(NotifyHandling.OWNER_REVIEWERS) >= 0) {
+    if (notify.handling().equals(NotifyHandling.OWNER_REVIEWERS)
+        || notify.handling().equals(NotifyHandling.ALL)) {
       ccAllApprovals();
     }
-    if (notify.handling().compareTo(NotifyHandling.ALL) >= 0) {
+    if (notify.handling().equals(NotifyHandling.ALL)) {
       bccStarredBy();
       includeWatchers(NotifyType.ALL_COMMENTS, !change.isWorkInProgress() && !change.isPrivate());
     }
@@ -505,6 +509,7 @@
     return false;
   }
 
+  @Nullable
   private Repository getRepository() {
     try {
       return args.server.openRepository(projectState.getNameKey());
diff --git a/java/com/google/gerrit/server/mail/send/DeleteKeySender.java b/java/com/google/gerrit/server/mail/send/DeleteKeySender.java
index d6d306c..22c26b1 100644
--- a/java/com/google/gerrit/server/mail/send/DeleteKeySender.java
+++ b/java/com/google/gerrit/server/mail/send/DeleteKeySender.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.mail.send;
 
 import com.google.common.base.Joiner;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.exceptions.EmailException;
 import com.google.gerrit.extensions.api.changes.RecipientType;
 import com.google.gerrit.server.IdentifiedUser;
@@ -70,7 +71,7 @@
     super.init();
     setHeader("Subject", String.format("[Gerrit Code Review] %s Keys Deleted", getKeyType()));
     setMessageId(messageIdGenerator.fromAccountUpdate(user.getAccountId()));
-    add(RecipientType.TO, user.getAccountId());
+    addByAccountId(RecipientType.TO, user.getAccountId());
   }
 
   @Override
@@ -109,10 +110,12 @@
     throw new IllegalStateException("key type is not SSH or GPG");
   }
 
+  @Nullable
   private String getSshKey() {
     return (sshKey != null) ? sshKey.sshPublicKey() + "\n" : null;
   }
 
+  @Nullable
   private String getGpgKeyFingerprints() {
     if (!gpgKeyFingerprints.isEmpty()) {
       return Joiner.on("\n").join(gpgKeyFingerprints);
diff --git a/java/com/google/gerrit/server/mail/send/DeleteReviewerSender.java b/java/com/google/gerrit/server/mail/send/DeleteReviewerSender.java
index 70676e3..52a16ac 100644
--- a/java/com/google/gerrit/server/mail/send/DeleteReviewerSender.java
+++ b/java/com/google/gerrit/server/mail/send/DeleteReviewerSender.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.mail.send;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Address;
 import com.google.gerrit.entities.Change;
@@ -61,8 +62,8 @@
     bccStarredBy();
     ccExistingReviewers();
     includeWatchers(NotifyType.ALL_COMMENTS);
-    reviewers.stream().forEach(r -> add(RecipientType.TO, r));
-    addByEmail(RecipientType.TO, reviewersByEmail);
+    reviewers.stream().forEach(r -> addByAccountId(RecipientType.TO, r));
+    reviewersByEmail.stream().forEach(address -> addByEmail(RecipientType.TO, address));
   }
 
   @Override
@@ -73,6 +74,7 @@
     }
   }
 
+  @Nullable
   public List<String> getReviewerNames() {
     if (reviewers.isEmpty() && reviewersByEmail.isEmpty()) {
       return null;
diff --git a/java/com/google/gerrit/server/mail/send/HttpPasswordUpdateSender.java b/java/com/google/gerrit/server/mail/send/HttpPasswordUpdateSender.java
index 045c6a4..5fb66bb 100644
--- a/java/com/google/gerrit/server/mail/send/HttpPasswordUpdateSender.java
+++ b/java/com/google/gerrit/server/mail/send/HttpPasswordUpdateSender.java
@@ -50,7 +50,7 @@
     setMessageId(
         messageIdGenerator.fromReasonAccountIdAndTimestamp(
             "HTTP_password_change", user.getAccountId(), TimeUtil.now()));
-    add(RecipientType.TO, user.getAccountId());
+    addByAccountId(RecipientType.TO, user.getAccountId());
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/mail/send/InboundEmailRejectionSender.java b/java/com/google/gerrit/server/mail/send/InboundEmailRejectionSender.java
index 2e0eeb3..0ddb0ad 100644
--- a/java/com/google/gerrit/server/mail/send/InboundEmailRejectionSender.java
+++ b/java/com/google/gerrit/server/mail/send/InboundEmailRejectionSender.java
@@ -63,7 +63,7 @@
     setListIdHeader();
     setHeader(FieldName.SUBJECT, "[Gerrit Code Review] Unable to process your email");
 
-    add(RecipientType.TO, to);
+    addByEmail(RecipientType.TO, to);
 
     if (!threadId.isEmpty()) {
       setHeader(MailHeader.REFERENCES.fieldName(), threadId);
diff --git a/java/com/google/gerrit/server/mail/send/MailSoySauceLoader.java b/java/com/google/gerrit/server/mail/send/MailSoySauceLoader.java
index 8ee8fc2..0eaafb8 100644
--- a/java/com/google/gerrit/server/mail/send/MailSoySauceLoader.java
+++ b/java/com/google/gerrit/server/mail/send/MailSoySauceLoader.java
@@ -17,6 +17,8 @@
 import static com.google.common.base.Preconditions.checkArgument;
 
 import com.google.common.io.CharStreams;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.inject.Inject;
@@ -24,13 +26,13 @@
 import com.google.inject.Singleton;
 import com.google.template.soy.SoyFileSet;
 import com.google.template.soy.jbcsrc.api.SoySauce;
-import com.google.template.soy.shared.SoyAstCache;
 import java.io.IOException;
 import java.io.Reader;
 import java.net.URL;
 import java.nio.charset.StandardCharsets;
 import java.nio.file.Files;
 import java.nio.file.Path;
+import org.eclipse.jgit.util.FileUtils;
 
 /**
  * Configures and loads Soy Sauce object for rendering email templates.
@@ -86,60 +88,78 @@
     "RestoredHtml.soy",
     "Reverted.soy",
     "RevertedHtml.soy",
-    "SetAssignee.soy",
-    "SetAssigneeHtml.soy",
   };
 
+  private static final SoySauce DEFAULT = getDefault(null).build().compileTemplates();
+
   private final SitePaths site;
-  private final SoyAstCache cache;
   private final PluginSetContext<MailSoyTemplateProvider> templateProviders;
 
   @Inject
-  MailSoySauceLoader(
-      SitePaths site,
-      SoyAstCache cache,
-      PluginSetContext<MailSoyTemplateProvider> templateProviders) {
+  MailSoySauceLoader(SitePaths site, PluginSetContext<MailSoyTemplateProvider> templateProviders) {
     this.site = site;
-    this.cache = cache;
     this.templateProviders = templateProviders;
   }
 
   public SoySauce load() {
-    SoyFileSet.Builder builder = SoyFileSet.builder();
-    builder.setSoyAstCache(cache);
-    for (String name : TEMPLATES) {
-      addTemplate(builder, "com/google/gerrit/server/mail/", name);
+    if (!hasCustomTemplates(site, templateProviders)) {
+      return DEFAULT;
     }
+
+    SoyFileSet.Builder builder = getDefault(site);
     templateProviders.runEach(
-        e -> e.getFileNames().forEach(p -> addTemplate(builder, e.getPath(), p)));
+        e -> e.getFileNames().forEach(p -> addTemplate(builder, site, e.getPath(), p)));
     return builder.build().compileTemplates();
   }
 
-  private void addTemplate(SoyFileSet.Builder builder, String resourcePath, String name)
+  private static boolean hasCustomTemplates(
+      SitePaths site, PluginSetContext<MailSoyTemplateProvider> templateProviders) {
+    try {
+      if (!templateProviders.isEmpty()) {
+        return true;
+      }
+      return Files.exists(site.mail_dir) && FileUtils.hasFiles(site.mail_dir);
+    } catch (IOException e) {
+      throw new StorageException(e);
+    }
+  }
+
+  private static SoyFileSet.Builder getDefault(@Nullable SitePaths site) {
+    SoyFileSet.Builder builder = SoyFileSet.builder();
+    for (String name : TEMPLATES) {
+      addTemplate(builder, site, "com/google/gerrit/server/mail/", name);
+    }
+    return builder;
+  }
+
+  private static void addTemplate(
+      SoyFileSet.Builder builder, @Nullable SitePaths site, String resourcePath, String name)
       throws ProvisionException {
     if (!resourcePath.endsWith("/")) {
       resourcePath += "/";
     }
     String logicalPath = resourcePath + name;
 
-    // Load as a file in the mail templates directory if present.
-    Path tmpl = site.mail_dir.resolve(name);
-    if (Files.isRegularFile(tmpl)) {
-      String content;
-      // TODO(davido): Consider using JGit's FileSnapshot to cache based on
-      // mtime.
-      try (Reader r = Files.newBufferedReader(tmpl, StandardCharsets.UTF_8)) {
-        content = CharStreams.toString(r);
-      } catch (IOException err) {
-        throw new ProvisionException(
-            "Failed to read template file " + tmpl.toAbsolutePath().toString(), err);
+    if (site != null) {
+      // Load as a file in the mail templates directory if present.
+      Path tmpl = site.mail_dir.resolve(name);
+      if (Files.isRegularFile(tmpl)) {
+        String content;
+        // TODO(davido): Consider using JGit's FileSnapshot to cache based on
+        // mtime.
+        try (Reader r = Files.newBufferedReader(tmpl, StandardCharsets.UTF_8)) {
+          content = CharStreams.toString(r);
+        } catch (IOException err) {
+          throw new ProvisionException(
+              "Failed to read template file " + tmpl.toAbsolutePath(), err);
+        }
+        builder.add(content, logicalPath);
+        return;
       }
-      builder.add(content, logicalPath);
-      return;
     }
 
     // Otherwise load the template as a resource.
-    URL resource = this.getClass().getClassLoader().getResource(logicalPath);
+    URL resource = MailSoySauceLoader.class.getClassLoader().getResource(logicalPath);
     checkArgument(resource != null, "resource %s not found.", logicalPath);
     builder.add(resource, logicalPath);
   }
diff --git a/java/com/google/gerrit/server/mail/send/MergedSender.java b/java/com/google/gerrit/server/mail/send/MergedSender.java
index 693c669..ce2e3dc 100644
--- a/java/com/google/gerrit/server/mail/send/MergedSender.java
+++ b/java/com/google/gerrit/server/mail/send/MergedSender.java
@@ -14,8 +14,11 @@
 
 package com.google.gerrit.server.mail.send;
 
+import static com.google.common.base.Preconditions.checkNotNull;
+
 import com.google.common.collect.HashBasedTable;
 import com.google.common.collect.Table;
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.LabelType;
@@ -26,12 +29,16 @@
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.EmailException;
 import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.server.change.NotifyResolver;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.util.Optional;
 
 /** Send notice about a change successfully merged. */
 public class MergedSender extends ReplyToChangeSender {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   public interface Factory {
     MergedSender create(
         Project.NameKey project, Change.Id changeId, Optional<String> stickyApprovalDiff);
@@ -49,13 +56,28 @@
     super(args, "merged", newChangeData(args, project, changeId));
     labelTypes = changeData.getLabelTypes();
     this.stickyApprovalDiff = stickyApprovalDiff;
+    // We want to send the submit email even if the "send only when in attention set" is enabled.
+    emailOnlyAttentionSetIfEnabled = false;
+  }
+
+  @Override
+  public void setNotify(NotifyResolver.Result notify) {
+    checkNotNull(notify);
+    if (!stickyApprovalDiff.isEmpty()) {
+      if (!notify.handling().equals(NotifyHandling.ALL)) {
+        logger.atFine().log(
+            "Requested to notify %s, but for change submission with sticky approval diff,"
+                + " Notify=ALL is enforced.",
+            notify.handling().name());
+      }
+      this.notify = NotifyResolver.Result.create(NotifyHandling.ALL, notify.accounts());
+    } else {
+      this.notify = notify;
+    }
   }
 
   @Override
   protected void init() throws EmailException {
-    // We want to send the submit email even if the "send only when in attention set" is enabled.
-    emailOnlyAttentionSetIfEnabled = false;
-
     super.init();
 
     ccAllApprovals();
diff --git a/java/com/google/gerrit/server/mail/send/NewChangeSender.java b/java/com/google/gerrit/server/mail/send/NewChangeSender.java
index e899fc5..aabf7ca 100644
--- a/java/com/google/gerrit/server/mail/send/NewChangeSender.java
+++ b/java/com/google/gerrit/server/mail/send/NewChangeSender.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.mail.send;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Address;
 import com.google.gerrit.exceptions.EmailException;
@@ -74,18 +75,18 @@
         break;
       case ALL:
       default:
-        extraCC.stream().forEach(cc -> add(RecipientType.CC, cc));
-        extraCCByEmail.stream().forEach(cc -> add(RecipientType.CC, cc));
+        extraCC.stream().forEach(cc -> addByAccountId(RecipientType.CC, cc));
+        extraCCByEmail.stream().forEach(cc -> addByEmail(RecipientType.CC, cc));
         // $FALL-THROUGH$
       case OWNER_REVIEWERS:
-        reviewers.stream().forEach(r -> add(RecipientType.TO, r, true));
-        addByEmail(RecipientType.TO, reviewersByEmail, true);
-        removedReviewers.stream().forEach(r -> add(RecipientType.TO, r, true));
-        addByEmail(RecipientType.TO, removedByEmailReviewers, true);
+        reviewers.stream().forEach(r -> addByAccountId(RecipientType.TO, r, true));
+        reviewersByEmail.stream().forEach(r -> addByEmail(RecipientType.TO, r, true));
+        removedReviewers.stream().forEach(r -> addByAccountId(RecipientType.TO, r, true));
+        removedByEmailReviewers.stream().forEach(r -> addByEmail(RecipientType.TO, r, true));
         break;
     }
 
-    rcptToAuthors(RecipientType.CC);
+    addAuthors(RecipientType.CC);
   }
 
   @Override
@@ -96,7 +97,8 @@
     }
   }
 
-  public List<String> getReviewerNames() {
+  @Nullable
+  private List<String> getReviewerNames() {
     if (reviewers.isEmpty()) {
       return null;
     }
@@ -107,7 +109,8 @@
     return names;
   }
 
-  public List<String> getRemovedReviewerNames() {
+  @Nullable
+  private List<String> getRemovedReviewerNames() {
     if (removedReviewers.isEmpty() && removedByEmailReviewers.isEmpty()) {
       return null;
     }
diff --git a/java/com/google/gerrit/server/mail/send/NotificationEmail.java b/java/com/google/gerrit/server/mail/send/NotificationEmail.java
deleted file mode 100644
index 5b209ce..0000000
--- a/java/com/google/gerrit/server/mail/send/NotificationEmail.java
+++ /dev/null
@@ -1,153 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.mail.send;
-
-import com.google.common.annotations.VisibleForTesting;
-import com.google.common.collect.Iterables;
-import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.entities.Account;
-import com.google.gerrit.entities.Address;
-import com.google.gerrit.entities.BranchNameKey;
-import com.google.gerrit.entities.NotifyConfig.NotifyType;
-import com.google.gerrit.exceptions.EmailException;
-import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.extensions.api.changes.RecipientType;
-import com.google.gerrit.mail.MailHeader;
-import com.google.gerrit.server.mail.send.ProjectWatch.Watchers;
-import com.google.gerrit.server.mail.send.ProjectWatch.Watchers.WatcherList;
-import java.util.HashMap;
-import java.util.Map;
-
-/** Common class for notifications that are related to a project and branch */
-public abstract class NotificationEmail extends OutgoingEmail {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
-  protected BranchNameKey branch;
-
-  protected NotificationEmail(EmailArguments args, String messageClass, BranchNameKey branch) {
-    super(args, messageClass);
-    this.branch = branch;
-  }
-
-  @Override
-  protected void init() throws EmailException {
-    super.init();
-    setListIdHeader();
-  }
-
-  private void setListIdHeader() {
-    // Set a reasonable list id so that filters can be used to sort messages
-    setHeader(
-        "List-Id",
-        "<gerrit-" + branch.project().get().replace('/', '-') + "." + getGerritHost() + ">");
-    if (getSettingsUrl() != null) {
-      setHeader("List-Unsubscribe", "<" + getSettingsUrl() + ">");
-    }
-  }
-
-  /** Include users and groups that want notification of events. */
-  protected void includeWatchers(NotifyType type) {
-    includeWatchers(type, true);
-  }
-
-  /** Include users and groups that want notification of events. */
-  protected void includeWatchers(NotifyType type, boolean includeWatchersFromNotifyConfig) {
-    try {
-      Watchers matching = getWatchers(type, includeWatchersFromNotifyConfig);
-      add(RecipientType.TO, matching.to);
-      add(RecipientType.CC, matching.cc);
-      add(RecipientType.BCC, matching.bcc);
-    } catch (StorageException err) {
-      // Just don't CC everyone. Better to send a partial message to those
-      // we already have queued up then to fail deliver entirely to people
-      // who have a lower interest in the change.
-      logger.atWarning().withCause(err).log("Cannot BCC watchers for %s", type);
-    }
-  }
-
-  /** Returns all watchers that are relevant */
-  protected abstract Watchers getWatchers(NotifyType type, boolean includeWatchersFromNotifyConfig);
-
-  /** Add users or email addresses to the TO, CC, or BCC list. */
-  protected void add(RecipientType type, WatcherList watcherList) {
-    for (Account.Id user : watcherList.accounts) {
-      addWatcher(type, user);
-    }
-    for (Address addr : watcherList.emails) {
-      add(type, addr);
-    }
-  }
-
-  protected abstract void addWatcher(RecipientType type, Account.Id to);
-
-  public String getSshHost() {
-    String host = Iterables.getFirst(args.sshAddresses, null);
-    if (host == null) {
-      return null;
-    }
-    if (host.startsWith("*:")) {
-      return getGerritHost() + host.substring(1);
-    }
-    return host;
-  }
-
-  @Override
-  protected void setupSoyContext() {
-    super.setupSoyContext();
-
-    String projectName = branch.project().get();
-    soyContext.put("projectName", projectName);
-    // shortProjectName is the project name with the path abbreviated.
-    soyContext.put("shortProjectName", getShortProjectName(projectName));
-
-    // instanceAndProjectName is the instance's name followed by the abbreviated project path
-    soyContext.put(
-        "instanceAndProjectName",
-        getInstanceAndProjectName(args.instanceNameProvider.get(), projectName));
-    soyContext.put("addInstanceNameInSubject", args.addInstanceNameInSubject);
-
-    soyContextEmailData.put("sshHost", getSshHost());
-
-    Map<String, String> branchData = new HashMap<>();
-    branchData.put("shortName", branch.shortName());
-    soyContext.put("branch", branchData);
-
-    footers.add(MailHeader.PROJECT.withDelimiter() + branch.project().get());
-    footers.add(MailHeader.BRANCH.withDelimiter() + branch.shortName());
-  }
-
-  @VisibleForTesting
-  protected static String getShortProjectName(String projectName) {
-    int lastIndexSlash = projectName.lastIndexOf('/');
-    if (lastIndexSlash == 0) {
-      return projectName.substring(1); // Remove the first slash
-    }
-    if (lastIndexSlash == -1) { // No slash in the project name
-      return projectName;
-    }
-
-    return "..." + projectName.substring(lastIndexSlash + 1);
-  }
-
-  @VisibleForTesting
-  protected static String getInstanceAndProjectName(String instanceName, String projectName) {
-    if (instanceName == null || instanceName.isEmpty()) {
-      return getShortProjectName(projectName);
-    }
-    // Extract the project name (everything after the last slash) and prepends it with gerrit's
-    // instance name
-    return instanceName + "/" + projectName.substring(projectName.lastIndexOf('/') + 1);
-  }
-}
diff --git a/java/com/google/gerrit/server/mail/send/OutgoingEmail.java b/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
index bfc1f5b..aba8f62 100644
--- a/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
+++ b/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
@@ -44,7 +44,6 @@
 import java.net.URL;
 import java.time.Instant;
 import java.util.ArrayList;
-import java.util.Collection;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.Iterator;
@@ -166,7 +165,7 @@
             logger.atFine().log(
                 "CC email sender %s because the email strategy of this user is %s",
                 fromUser.get().account().id(), CC_ON_OWN_COMMENTS);
-            add(RecipientType.CC, fromId);
+            addByAccountId(RecipientType.CC, fromId);
           } else if (isImpersonating) {
             // If we are impersonating a user, make sure they receive a CC of
             // this message regardless of email strategy, unless email notifications are explicitly
@@ -176,7 +175,7 @@
                 "CC email sender %s because the email is sent on behalf of and email notifications"
                     + " are enabled for this user.",
                 fromUser.get().account().id());
-            add(RecipientType.CC, fromId);
+            addByAccountId(RecipientType.CC, fromId);
 
           } else if (!notify.accounts().containsValue(fromId) && rcptTo.remove(fromId)) {
             // If they don't want a copy, but we queued one up anyway,
@@ -331,7 +330,7 @@
     setHeader(MailHeader.AUTO_SUBMITTED.fieldName(), "auto-generated");
 
     for (RecipientType recipientType : notify.accounts().keySet()) {
-      notify.accounts().get(recipientType).stream().forEach(a -> add(recipientType, a));
+      notify.accounts().get(recipientType).stream().forEach(a -> addByAccountId(recipientType, a));
     }
 
     setHeader(MailHeader.MESSAGE_TYPE.fieldName(), messageClass);
@@ -380,10 +379,12 @@
     return SystemReader.getInstance().getHostname();
   }
 
+  @Nullable
   public String getSettingsUrl() {
     return args.urlFormatter.get().getSettingsUrl().orElse(null);
   }
 
+  @Nullable
   private String getGerritUrl() {
     return args.urlFormatter.get().getWebUrl().orElse(null);
   }
@@ -471,6 +472,7 @@
    * @param accountId user to fetch.
    * @return name/email of account, username, or null if unset or the accountId is null.
    */
+  @Nullable
   protected String getUserNameEmailFor(@Nullable Account.Id accountId) {
     if (accountId == null) {
       return null;
@@ -522,51 +524,86 @@
     return true;
   }
 
-  /** Schedule this message for delivery to the listed address. */
-  protected final void addByEmail(RecipientType rt, Collection<Address> list) {
-    addByEmail(rt, list, false);
+  /**
+   * Adds a recipient that the email will be sent to.
+   *
+   * @param rt category of recipient (TO, CC, BCC)
+   * @param addr Name and email of the recipient.
+   */
+  public final void addByEmail(RecipientType rt, Address addr) {
+    addByEmail(rt, addr, false);
   }
 
-  /** Schedule this message for delivery to the listed address. */
-  protected final void addByEmail(RecipientType rt, Collection<Address> list, boolean override) {
-    for (final Address id : list) {
-      add(rt, id, override);
-    }
-  }
-
-  /** Schedule delivery of this message to the given account. */
-  protected void add(RecipientType rt, Account.Id to) {
-    add(rt, to, false);
-  }
-
-  protected void add(RecipientType rt, Account.Id to, boolean override) {
+  /**
+   * Adds a recipient that the email will be sent to.
+   *
+   * @param rt category of recipient (TO, CC, BCC).
+   * @param addr Name and email of the recipient.
+   * @param override if the recipient was added previously and override is false no change is made
+   *     regardless of {@code rt}.
+   */
+  public final void addByEmail(RecipientType rt, Address addr, boolean override) {
     try {
-      if (!rcptTo.contains(to) && isVisibleTo(to)) {
-        rcptTo.add(to);
-        add(rt, toAddress(to), override);
+      if (isRecipientAllowed(addr)) {
+        add(rt, addr, override);
       }
     } catch (PermissionBackendException e) {
-      logger.atSevere().withCause(e).log("Error reading database for account: %s", to);
+      logger.atSevere().withCause(e).log("Error checking permissions for email address: %s", addr);
     }
   }
 
   /**
-   * Returns whether this email is visible to the given account
+   * Returns whether this email is allowed to be sent to the given address
+   *
+   * @param addr email address of recipient.
+   * @throws PermissionBackendException thrown if checking a permission fails due to an error in the
+   *     permission backend
+   */
+  protected boolean isRecipientAllowed(Address addr) throws PermissionBackendException {
+    return true;
+  }
+
+  /**
+   * Adds a recipient that the email will be sent to.
+   *
+   * @param rt category of recipient (TO, CC, BCC)
+   * @param to Gerrit Account of the recipient.
+   */
+  protected void addByAccountId(RecipientType rt, Account.Id to) {
+    addByAccountId(rt, to, false);
+  }
+
+  /**
+   * Adds a recipient that the email will be sent to.
+   *
+   * @param rt category of recipient (TO, CC, BCC)
+   * @param to Gerrit Account of the recipient.
+   * @param override if the recipient was added previously and override is false no change is made
+   *     regardless of {@code rt}.
+   */
+  protected void addByAccountId(RecipientType rt, Account.Id to, boolean override) {
+    try {
+      if (!rcptTo.contains(to) && isRecipientAllowed(to)) {
+        rcptTo.add(to);
+        add(rt, toAddress(to), override);
+      }
+    } catch (PermissionBackendException e) {
+      logger.atSevere().withCause(e).log("Error checking permissions for account: %s", to);
+    }
+  }
+
+  /**
+   * Returns whether this email is allowed to be sent to the given account
    *
    * @param to account.
    * @throws PermissionBackendException thrown if checking a permission fails due to an error in the
    *     permission backend
    */
-  protected boolean isVisibleTo(Account.Id to) throws PermissionBackendException {
+  protected boolean isRecipientAllowed(Account.Id to) throws PermissionBackendException {
     return true;
   }
 
-  /** Schedule delivery of this message to the given account. */
-  protected final void add(RecipientType rt, Address addr) {
-    add(rt, addr, false);
-  }
-
-  protected final void add(RecipientType rt, Address addr, boolean override) {
+  private final void add(RecipientType rt, Address addr, boolean override) {
     if (addr != null && addr.email() != null && addr.email().length() > 0) {
       if (!args.validator.isValid(addr.email())) {
         logger.atWarning().log("Not emailing %s (invalid email address)", addr.email());
@@ -594,6 +631,7 @@
     }
   }
 
+  @Nullable
   private Address toAddress(Account.Id id) {
     Optional<Account> accountState = args.accountCache.get(id).map(AccountState::account);
     if (!accountState.isPresent()) {
@@ -623,6 +661,26 @@
     soyContext.put("email", soyContextEmailData);
   }
 
+  /** Mutable map of parameters passed into email templates when rendering. */
+  public Map<String, Object> getSoyContext() {
+    return this.soyContext;
+  }
+
+  // TODO: It's not clear why we need this explicit separation. Probably worth
+  // simplifying.
+  /** Mutable content of `email` parameter in the templates. */
+  public Map<String, Object> getSoyContextEmailData() {
+    return this.soyContextEmailData;
+  }
+
+  /**
+   * Add a line to email footer with additional information. Typically, in the form of {@literal
+   * <key>: <value>}.
+   */
+  public void addFooter(String footer) {
+    footers.add(footer);
+  }
+
   private String getInstanceName() {
     return args.instanceNameProvider.get();
   }
diff --git a/java/com/google/gerrit/server/mail/send/ProjectWatch.java b/java/com/google/gerrit/server/mail/send/ProjectWatch.java
index 9299d74..f4c211d 100644
--- a/java/com/google/gerrit/server/mail/send/ProjectWatch.java
+++ b/java/com/google/gerrit/server/mail/send/ProjectWatch.java
@@ -16,6 +16,7 @@
 
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Lists;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
@@ -24,6 +25,7 @@
 import com.google.gerrit.entities.GroupReference;
 import com.google.gerrit.entities.NotifyConfig;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.server.CurrentUser;
@@ -35,11 +37,13 @@
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeQueryBuilder;
 import com.google.gerrit.server.query.change.GroupBackedUser;
+import java.io.IOException;
 import java.util.ArrayList;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
+import org.eclipse.jgit.errors.ConfigInvalidException;
 
 public class ProjectWatch {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
@@ -240,13 +244,13 @@
   }
 
   private boolean filterMatch(CurrentUser user, String filter) throws QueryParseException {
-    ChangeQueryBuilder qb;
+    WatcherChangeQueryBuilder qb;
     Predicate<ChangeData> p = null;
 
     if (user == null) {
-      qb = args.queryBuilder.get().asUser(args.anonymousUser.get());
+      qb = WatcherChangeQueryBuilder.asUser(args.queryBuilder.get(), args.anonymousUser.get());
     } else {
-      qb = args.queryBuilder.get().asUser(user);
+      qb = WatcherChangeQueryBuilder.asUser(args.queryBuilder.get(), user);
       p = qb.isVisible();
     }
 
@@ -260,4 +264,40 @@
     }
     return p == null || p.asMatchable().match(changeData);
   }
+
+  private static class WatcherChangeQueryBuilder extends ChangeQueryBuilder {
+    private WatcherChangeQueryBuilder(Arguments args) {
+      super(args);
+    }
+
+    public static WatcherChangeQueryBuilder asUser(ChangeQueryBuilder other, CurrentUser user) {
+      return new WatcherChangeQueryBuilder(other.getArgs().asUser(user));
+    }
+
+    @Override
+    protected Predicate<ChangeData> defaultField(String query) throws QueryParseException {
+      if (query.startsWith("refs/")) {
+        return ref(query);
+      }
+
+      // Adapt the capacity of this list when adding more default predicates.
+      List<Predicate<ChangeData>> predicates = Lists.newArrayListWithCapacity(11);
+      predicates.add(file(query));
+      try {
+        predicates.add(label(query));
+      } catch (StorageException | IOException | ConfigInvalidException | QueryParseException e) {
+        // Skip.
+      }
+      predicates.add(commit(query));
+      predicates.add(message(query));
+      predicates.add(comment(query));
+      predicates.add(projects(query));
+      predicates.add(ref(query));
+      predicates.add(branch(query));
+      predicates.add(topic(query));
+      // Adapt the capacity of the "predicates" list when adding more default
+      // predicates.
+      return Predicate.or(predicates);
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/mail/send/RegisterNewEmailSender.java b/java/com/google/gerrit/server/mail/send/RegisterNewEmailSender.java
index a54a652..f7bc336 100644
--- a/java/com/google/gerrit/server/mail/send/RegisterNewEmailSender.java
+++ b/java/com/google/gerrit/server/mail/send/RegisterNewEmailSender.java
@@ -54,7 +54,7 @@
   protected void init() throws EmailException {
     super.init();
     setHeader("Subject", "[Gerrit Code Review] Email Verification");
-    add(RecipientType.TO, Address.create(addr));
+    addByEmail(RecipientType.TO, Address.create(addr));
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/mail/send/ReplacePatchSetSender.java b/java/com/google/gerrit/server/mail/send/ReplacePatchSetSender.java
index 0d32dd5..188c5d8 100644
--- a/java/com/google/gerrit/server/mail/send/ReplacePatchSetSender.java
+++ b/java/com/google/gerrit/server/mail/send/ReplacePatchSetSender.java
@@ -123,12 +123,12 @@
       reviewers.remove(fromId);
     }
     if (args.settings.sendNewPatchsetEmails) {
-      if (notify.handling() == NotifyHandling.ALL
-          || notify.handling() == NotifyHandling.OWNER_REVIEWERS) {
-        reviewers.stream().forEach(r -> add(RecipientType.TO, r));
-        extraCC.stream().forEach(cc -> add(RecipientType.CC, cc));
+      if (notify.handling().equals(NotifyHandling.ALL)
+          || notify.handling().equals(NotifyHandling.OWNER_REVIEWERS)) {
+        reviewers.stream().forEach(r -> addByAccountId(RecipientType.TO, r));
+        extraCC.stream().forEach(cc -> addByAccountId(RecipientType.CC, cc));
       }
-      rcptToAuthors(RecipientType.CC);
+      addAuthors(RecipientType.CC);
     }
     bccStarredBy();
     includeWatchers(NotifyType.NEW_PATCHSETS, !change.isWorkInProgress() && !change.isPrivate());
@@ -142,6 +142,7 @@
     }
   }
 
+  @Nullable
   public ImmutableList<String> getReviewerNames() {
     List<String> names = new ArrayList<>();
     for (Account.Id id : reviewers) {
diff --git a/java/com/google/gerrit/server/mail/send/ReplyToChangeSender.java b/java/com/google/gerrit/server/mail/send/ReplyToChangeSender.java
index c765430..696cd17 100644
--- a/java/com/google/gerrit/server/mail/send/ReplyToChangeSender.java
+++ b/java/com/google/gerrit/server/mail/send/ReplyToChangeSender.java
@@ -38,6 +38,6 @@
     setHeader("In-Reply-To", threadId);
     setHeader("References", threadId);
 
-    rcptToAuthors(RecipientType.TO);
+    addAuthors(RecipientType.TO);
   }
 }
diff --git a/java/com/google/gerrit/server/mail/send/SetAssigneeSender.java b/java/com/google/gerrit/server/mail/send/SetAssigneeSender.java
deleted file mode 100644
index 29f4c69..0000000
--- a/java/com/google/gerrit/server/mail/send/SetAssigneeSender.java
+++ /dev/null
@@ -1,69 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.mail.send;
-
-import com.google.gerrit.entities.Account;
-import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.Project;
-import com.google.gerrit.exceptions.EmailException;
-import com.google.gerrit.extensions.api.changes.RecipientType;
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
-
-/**
- * Sender that informs a user by email that they were set as assignee on a change.
- *
- * <p>In contrast to other change emails this email is not sent to the change authors (owner, patch
- * set uploader, author). This is why this class extends {@link ChangeEmail} directly, instead of
- * extending {@link ReplyToChangeSender}.
- */
-public class SetAssigneeSender extends ChangeEmail {
-  public interface Factory {
-    SetAssigneeSender create(Project.NameKey project, Change.Id changeId, Account.Id assignee);
-  }
-
-  private final Account.Id assignee;
-
-  @Inject
-  public SetAssigneeSender(
-      EmailArguments args,
-      @Assisted Project.NameKey project,
-      @Assisted Change.Id changeId,
-      @Assisted Account.Id assignee) {
-    super(args, "setassignee", newChangeData(args, project, changeId));
-    this.assignee = assignee;
-  }
-
-  @Override
-  protected void init() throws EmailException {
-    super.init();
-
-    add(RecipientType.TO, assignee);
-  }
-
-  @Override
-  protected void formatChange() throws EmailException {
-    appendText(textTemplate("SetAssignee"));
-    if (useHtml()) {
-      appendHtml(soyHtmlTemplate("SetAssigneeHtml"));
-    }
-  }
-
-  @Override
-  protected void setupSoyContext() {
-    super.setupSoyContext();
-    soyContextEmailData.put("assigneeName", getNameFor(assignee));
-  }
-}
diff --git a/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java b/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java
index 93f29f6..5ffb5fb 100644
--- a/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java
+++ b/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java
@@ -204,6 +204,7 @@
     return load();
   }
 
+  @Nullable
   public ObjectId loadRevision() {
     if (loaded) {
       return getRevision();
diff --git a/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java b/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java
index e6f1622..708d59f 100644
--- a/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java
+++ b/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java
@@ -101,6 +101,7 @@
         user);
   }
 
+  @Nullable
   private static Account.Id accountId(CurrentUser u) {
     checkUserType(u);
     return (u instanceof IdentifiedUser) ? u.getAccountId() : null;
@@ -163,6 +164,10 @@
     return accountId;
   }
 
+  public Account.Id getRealAccountId() {
+    return realAccountId;
+  }
+
   /** Whether no updates have been done. */
   public abstract boolean isEmpty();
 
@@ -206,6 +211,7 @@
    *     deleted.
    * @throws IOException if a lower-level error occurred.
    */
+  @Nullable
   final ObjectId apply(RevWalk rw, ObjectInserter ins, ObjectId curr) throws IOException {
     if (isEmpty()) {
       return null;
diff --git a/java/com/google/gerrit/server/notedb/AllUsersAsyncUpdate.java b/java/com/google/gerrit/server/notedb/AllUsersAsyncUpdate.java
index 5417494..0289e17 100644
--- a/java/com/google/gerrit/server/notedb/AllUsersAsyncUpdate.java
+++ b/java/com/google/gerrit/server/notedb/AllUsersAsyncUpdate.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.base.MoreObjects.firstNonNull;
 import static com.google.common.base.Preconditions.checkState;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
 
 import com.google.common.collect.Iterables;
 import com.google.common.collect.ListMultimap;
@@ -25,6 +26,7 @@
 import com.google.gerrit.server.FanOutExecutor;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.update.context.RefUpdateContext;
 import com.google.inject.Inject;
 import java.io.IOException;
 import java.util.Map;
@@ -90,26 +92,28 @@
     Future<?> possiblyIgnoredError =
         executor.submit(
             () -> {
-              try (OpenRepo allUsersRepo = OpenRepo.open(repoManager, allUsersName)) {
-                allUsersRepo.addUpdatesNoLimits(draftUpdates);
-                allUsersRepo.flush();
-                BatchRefUpdate bru = allUsersRepo.repo.getRefDatabase().newBatchUpdate();
-                bru.setPushCertificate(pushCert);
-                if (refLogMessage != null) {
-                  bru.setRefLogMessage(refLogMessage, false);
-                } else {
-                  bru.setRefLogMessage(
-                      firstNonNull(NoteDbUtil.guessRestApiHandler(), "Update NoteDb refs async"),
-                      false);
+              try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+                try (OpenRepo allUsersRepo = OpenRepo.open(repoManager, allUsersName)) {
+                  allUsersRepo.addUpdatesNoLimits(draftUpdates);
+                  allUsersRepo.flush();
+                  BatchRefUpdate bru = allUsersRepo.repo.getRefDatabase().newBatchUpdate();
+                  bru.setPushCertificate(pushCert);
+                  if (refLogMessage != null) {
+                    bru.setRefLogMessage(refLogMessage, false);
+                  } else {
+                    bru.setRefLogMessage(
+                        firstNonNull(NoteDbUtil.guessRestApiHandler(), "Update NoteDb refs async"),
+                        false);
+                  }
+                  bru.setRefLogIdent(refLogIdent != null ? refLogIdent : serverIdent);
+                  bru.setAtomic(true);
+                  allUsersRepo.cmds.addTo(bru);
+                  bru.setAllowNonFastForwards(true);
+                  RefUpdateUtil.executeChecked(bru, allUsersRepo.rw);
+                } catch (IOException e) {
+                  logger.atSevere().withCause(e).log(
+                      "Failed to delete draft comments asynchronously after publishing them");
                 }
-                bru.setRefLogIdent(refLogIdent != null ? refLogIdent : serverIdent);
-                bru.setAtomic(true);
-                allUsersRepo.cmds.addTo(bru);
-                bru.setAllowNonFastForwards(true);
-                RefUpdateUtil.executeChecked(bru, allUsersRepo.rw);
-              } catch (IOException e) {
-                logger.atSevere().withCause(e).log(
-                    "Failed to delete draft comments asynchronously after publishing them");
               }
             });
   }
diff --git a/java/com/google/gerrit/server/notedb/ChangeDraftUpdate.java b/java/com/google/gerrit/server/notedb/ChangeDraftUpdate.java
index 73161d7..0dcf786 100644
--- a/java/com/google/gerrit/server/notedb/ChangeDraftUpdate.java
+++ b/java/com/google/gerrit/server/notedb/ChangeDraftUpdate.java
@@ -19,6 +19,7 @@
 import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
 
 import com.google.auto.value.AutoValue;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Comment;
@@ -187,6 +188,7 @@
     return clonedUpdate;
   }
 
+  @Nullable
   private CommitBuilder storeCommentsInNotes(
       RevWalk rw, ObjectInserter ins, ObjectId curr, CommitBuilder cb)
       throws ConfigInvalidException, IOException {
diff --git a/java/com/google/gerrit/server/notedb/ChangeNoteFooters.java b/java/com/google/gerrit/server/notedb/ChangeNoteFooters.java
new file mode 100644
index 0000000..3be55ea
--- /dev/null
+++ b/java/com/google/gerrit/server/notedb/ChangeNoteFooters.java
@@ -0,0 +1,44 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb;
+
+import org.eclipse.jgit.revwalk.FooterKey;
+
+/** Footers, that can be set in NoteDb commits. */
+public class ChangeNoteFooters {
+  public static final FooterKey FOOTER_ATTENTION = new FooterKey("Attention");
+  public static final FooterKey FOOTER_BRANCH = new FooterKey("Branch");
+  public static final FooterKey FOOTER_CHANGE_ID = new FooterKey("Change-id");
+  public static final FooterKey FOOTER_COMMIT = new FooterKey("Commit");
+  public static final FooterKey FOOTER_CURRENT = new FooterKey("Current");
+  public static final FooterKey FOOTER_GROUPS = new FooterKey("Groups");
+  public static final FooterKey FOOTER_HASHTAGS = new FooterKey("Hashtags");
+  public static final FooterKey FOOTER_LABEL = new FooterKey("Label");
+  public static final FooterKey FOOTER_COPIED_LABEL = new FooterKey("Copied-Label");
+  public static final FooterKey FOOTER_PATCH_SET = new FooterKey("Patch-set");
+  public static final FooterKey FOOTER_PATCH_SET_DESCRIPTION =
+      new FooterKey("Patch-set-description");
+  public static final FooterKey FOOTER_PRIVATE = new FooterKey("Private");
+  public static final FooterKey FOOTER_REAL_USER = new FooterKey("Real-user");
+  public static final FooterKey FOOTER_STATUS = new FooterKey("Status");
+  public static final FooterKey FOOTER_SUBJECT = new FooterKey("Subject");
+  public static final FooterKey FOOTER_SUBMISSION_ID = new FooterKey("Submission-id");
+  public static final FooterKey FOOTER_SUBMITTED_WITH = new FooterKey("Submitted-with");
+  public static final FooterKey FOOTER_TOPIC = new FooterKey("Topic");
+  public static final FooterKey FOOTER_TAG = new FooterKey("Tag");
+  public static final FooterKey FOOTER_WORK_IN_PROGRESS = new FooterKey("Work-in-progress");
+  public static final FooterKey FOOTER_REVERT_OF = new FooterKey("Revert-of");
+  public static final FooterKey FOOTER_CHERRY_PICK_OF = new FooterKey("Cherry-pick-of");
+}
diff --git a/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java b/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java
index 178cf9b..881cd96 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java
@@ -15,8 +15,6 @@
 package com.google.gerrit.server.notedb;
 
 import com.google.auto.value.AutoValue;
-import com.google.common.base.Splitter;
-import com.google.common.base.Strings;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.json.OutputFormat;
@@ -24,46 +22,16 @@
 import com.google.gson.Gson;
 import com.google.inject.Inject;
 import java.time.Instant;
-import java.util.ArrayList;
-import java.util.List;
 import java.util.Optional;
-import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.revwalk.FooterKey;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.util.RawParseUtils;
 
 public class ChangeNoteUtil {
 
-  public static final FooterKey FOOTER_ATTENTION = new FooterKey("Attention");
-  public static final FooterKey FOOTER_ASSIGNEE = new FooterKey("Assignee");
-  public static final FooterKey FOOTER_BRANCH = new FooterKey("Branch");
-  public static final FooterKey FOOTER_CHANGE_ID = new FooterKey("Change-id");
-  public static final FooterKey FOOTER_COMMIT = new FooterKey("Commit");
-  public static final FooterKey FOOTER_CURRENT = new FooterKey("Current");
-  public static final FooterKey FOOTER_GROUPS = new FooterKey("Groups");
-  public static final FooterKey FOOTER_HASHTAGS = new FooterKey("Hashtags");
-  public static final FooterKey FOOTER_LABEL = new FooterKey("Label");
-  public static final FooterKey FOOTER_COPIED_LABEL = new FooterKey("Copied-Label");
-  public static final FooterKey FOOTER_PATCH_SET = new FooterKey("Patch-set");
-  public static final FooterKey FOOTER_PATCH_SET_DESCRIPTION =
-      new FooterKey("Patch-set-description");
-  public static final FooterKey FOOTER_PRIVATE = new FooterKey("Private");
-  public static final FooterKey FOOTER_REAL_USER = new FooterKey("Real-user");
-  public static final FooterKey FOOTER_STATUS = new FooterKey("Status");
-  public static final FooterKey FOOTER_SUBJECT = new FooterKey("Subject");
-  public static final FooterKey FOOTER_SUBMISSION_ID = new FooterKey("Submission-id");
-  public static final FooterKey FOOTER_SUBMITTED_WITH = new FooterKey("Submitted-with");
-  public static final FooterKey FOOTER_TOPIC = new FooterKey("Topic");
-  public static final FooterKey FOOTER_TAG = new FooterKey("Tag");
-  public static final FooterKey FOOTER_WORK_IN_PROGRESS = new FooterKey("Work-in-progress");
-  public static final FooterKey FOOTER_REVERT_OF = new FooterKey("Revert-of");
-  public static final FooterKey FOOTER_CHERRY_PICK_OF = new FooterKey("Cherry-pick-of");
-
   static final String GERRIT_USER_TEMPLATE = "Gerrit User %d";
 
   private static final Gson gson = OutputFormat.JSON_COMPACT.newGson();
-  private static final String LABEL_VOTE_UUID_SEPARATOR = ", ";
 
   private final ChangeNoteJson changeNoteJson;
   private final String serverId;
@@ -252,303 +220,4 @@
         new AttentionStatusInNoteDb(
             stringBuilder.toString(), attentionSetUpdate.operation(), attentionSetUpdate.reason()));
   }
-
-  /**
-   * {@link com.google.gerrit.entities.PatchSetApproval}, parsed from {@link #FOOTER_LABEL} or
-   * {@link #FOOTER_COPIED_LABEL}.
-   *
-   * <p>In comparison to {@link com.google.gerrit.entities.PatchSetApproval}, this entity represent
-   * the raw fields, parsed from the NoteDB footer line, without any interpretation of the parsed
-   * values. See {@link #parseApproval} and {@link #parseCopiedApproval} for the valid {@link
-   * #footerLine} values.
-   */
-  @AutoValue
-  public abstract static class ParsedPatchSetApproval {
-
-    /** The original footer value, that this entity was parsed from. */
-    public abstract String footerLine();
-
-    public abstract boolean isRemoval();
-
-    /** Either <LABEL>=VOTE or <LABEL> for {@link #isRemoval}. */
-    public abstract String labelVote();
-
-    public abstract Optional<String> uuid();
-
-    public abstract Optional<String> accountIdent();
-
-    public abstract Optional<String> realAccountIdent();
-
-    public abstract Optional<String> tag();
-
-    public static Builder builder() {
-      return new AutoValue_ChangeNoteUtil_ParsedPatchSetApproval.Builder();
-    }
-
-    @AutoValue.Builder
-    public abstract static class Builder {
-
-      abstract Builder footerLine(String labelLine);
-
-      abstract Builder isRemoval(boolean isRemoval);
-
-      abstract Builder labelVote(String labelVote);
-
-      abstract Builder uuid(Optional<String> uuid);
-
-      abstract Builder accountIdent(Optional<String> accountIdent);
-
-      abstract Builder realAccountIdent(Optional<String> realAccountIdent);
-
-      abstract Builder tag(Optional<String> tag);
-
-      abstract ParsedPatchSetApproval build();
-    }
-  }
-
-  /**
-   * Delegates parsing of {@link ParsedPatchSetApproval} from {@link #FOOTER_LABEL} line to
-   * dedicated methods: {@link #parseAddedApproval} and {@link #parseRemovedApproval}
-   * correspondingly.
-   */
-  public static ParsedPatchSetApproval parseApproval(String footerLine)
-      throws ConfigInvalidException {
-    try {
-      return footerLine.startsWith("-")
-          ? parseRemovedApproval(footerLine)
-          : parseAddedApproval(footerLine);
-    } catch (StringIndexOutOfBoundsException ex) {
-      throw parseException(FOOTER_LABEL, footerLine, ex);
-    }
-  }
-
-  /**
-   * Parses added {@link ParsedPatchSetApproval} from {@link #FOOTER_LABEL} line.
-   *
-   * <p>Valid added approval footer examples:
-   *
-   * <ul>
-   *   <li>Label: &lt;LABEL&gt;=VOTE
-   *   <li>Label: &lt;LABEL&gt;=VOTE &lt;Gerrit Account&gt;
-   *   <li>Label: &lt;LABEL&gt;=VOTE, &lt;UUID&gt;
-   *   <li>Label: &lt;LABEL&gt;=VOTE, &lt;UUID&gt; &lt;Gerrit Account&gt;
-   * </ul>
-   *
-   * <p>&lt;UUID&gt; is optional, since the approval might have been granted before {@link
-   * com.google.gerrit.entities.PatchSetApproval.UUID} was introduced.
-   *
-   * <p><Gerrit Account> is only persisted in cases, when the account, that granted the vote does
-   * not match the account, that issued {@link ChangeUpdate} (created this NoteDB commit).
-   */
-  private static ParsedPatchSetApproval parseAddedApproval(String footerLine)
-      throws ConfigInvalidException {
-    ParsedPatchSetApproval.Builder rawPatchSetApproval =
-        ParsedPatchSetApproval.builder().footerLine(footerLine);
-    rawPatchSetApproval.isRemoval(false);
-    // We need some additional logic to differentiate between labels that have a UUID and those that
-    // have a user with a comma. This allows us to separate the following cases (note that the
-    // leading `Label: ` has been elided at this point):
-    //   Label: <LABEL>=VOTE, <UUID> <Gerrit Account>
-    //   Label: <LABEL>=VOTE <Gerrit, Account>
-    int reviewerStartOffset = 0;
-    int scoreStart = footerLine.indexOf('=') + 1;
-    StringBuilder labelNameScore = new StringBuilder(footerLine.substring(0, scoreStart));
-    for (int i = scoreStart; i < footerLine.length(); i++) {
-      char currentChar = footerLine.charAt(i);
-
-      // If we hit ',' before ' ' we have a UUID
-      if (currentChar == ',') {
-        labelNameScore.append(footerLine, scoreStart, i);
-        int uuidStart = i + LABEL_VOTE_UUID_SEPARATOR.length();
-        int uuidEnd = footerLine.indexOf(' ', uuidStart);
-        String uuid = footerLine.substring(uuidStart, uuidEnd > 0 ? uuidEnd : footerLine.length());
-        checkFooter(!Strings.isNullOrEmpty(uuid), FOOTER_LABEL, footerLine);
-        rawPatchSetApproval.uuid(Optional.of(uuid));
-        reviewerStartOffset = uuidStart + uuid.length();
-        break;
-      }
-
-      // Otherwise we don't
-      if (currentChar == ' ') {
-        labelNameScore.append(footerLine, scoreStart, i);
-        break;
-      }
-
-      // If we hit neither we're defensive assign the whole line
-      if (i == footerLine.length() - 1) {
-        labelNameScore = new StringBuilder(footerLine);
-        break;
-      }
-    }
-
-    rawPatchSetApproval.labelVote(labelNameScore.toString());
-
-    int reviewerStart = footerLine.indexOf(' ', reviewerStartOffset);
-    if (reviewerStart > 0) {
-      String ident = footerLine.substring(reviewerStart + 1);
-      rawPatchSetApproval.accountIdent(Optional.of(ident));
-    }
-    return rawPatchSetApproval.build();
-  }
-
-  /**
-   * Parses removed {@link ParsedPatchSetApproval} from {@link #FOOTER_LABEL} line.
-   *
-   * <p>Valid removed approval footer examples:
-   *
-   * <ul>
-   *   <li>-&lt;LABEL&gt;
-   *   <li>-&lt;LABEL&gt; &lt;Gerrit Account&gt;
-   * </ul>
-   *
-   * <p>&lt;Gerrit Account&gt; is only persisted in cases, when the account, that granted the vote
-   * does not match the account, that issued {@link ChangeUpdate} (created this NoteDB commit).
-   */
-  private static ParsedPatchSetApproval parseRemovedApproval(String footerLine) {
-    ParsedPatchSetApproval.Builder rawPatchSetApproval =
-        ParsedPatchSetApproval.builder().footerLine(footerLine);
-    rawPatchSetApproval.isRemoval(true);
-    int labelStart = 1;
-    int reviewerStart = footerLine.indexOf(' ', labelStart);
-
-    rawPatchSetApproval.labelVote(
-        reviewerStart != -1
-            ? footerLine.substring(labelStart, reviewerStart)
-            : footerLine.substring(labelStart));
-
-    if (reviewerStart > 0) {
-      String ident = footerLine.substring(reviewerStart + 1);
-      rawPatchSetApproval.accountIdent(Optional.of(ident));
-    }
-    return rawPatchSetApproval.build();
-  }
-
-  /**
-   * Parses copied {@link ParsedPatchSetApproval} from {@link #FOOTER_COPIED_LABEL} line.
-   *
-   * <p>Footer example: Copied-Label: <LABEL>=VOTE, <UUID> <Gerrit Account>,<Gerrit Real Account>
-   * :"<TAG>"
-   *
-   * <ul>
-   *   <li>":<"TAG>"" is optional.
-   *   <li><Gerrit Real Account> is also optional, if it was not set.
-   *   <li><UUID> is optional, since the approval might have been granted before {@link
-   *       com.google.gerrit.entities.PatchSetApproval.UUID} was introduced.
-   *   <li>The label, vote, and the Gerrit account are mandatory (unlike FOOTER_LABEL where Gerrit
-   *       Account is also optional since by default it's the committer).
-   * </ul>
-   *
-   * <p>Footer example for removal: Copied-Label: -<LABEL> <Gerrit Account>,<Gerrit Real Account>
-   *
-   * <ul>
-   *   <li><Gerrit Real Account> is also optional, if it was not set.
-   * </ul>
-   */
-  public static ParsedPatchSetApproval parseCopiedApproval(String labelLine)
-      throws ConfigInvalidException {
-    try {
-      ParsedPatchSetApproval.Builder rawPatchSetApproval =
-          ParsedPatchSetApproval.builder().footerLine(labelLine);
-
-      boolean isRemoval = labelLine.startsWith("-");
-      rawPatchSetApproval.isRemoval(isRemoval);
-      int labelStart = isRemoval ? 1 : 0;
-      int tagStart = isRemoval ? -1 : labelLine.indexOf(":\"");
-      int uuidStart = parseCopiedApprovalUuidStart(labelLine, tagStart);
-
-      checkFooter(!isRemoval || uuidStart == -1, FOOTER_LABEL, labelLine);
-
-      // Weird tag that contains uuid delimiter. The uuid is actually not present.
-      if (tagStart != -1 && uuidStart > tagStart) {
-        uuidStart = -1;
-      }
-
-      int identitiesStart =
-          labelLine.indexOf(
-              ' ', uuidStart != -1 ? uuidStart + LABEL_VOTE_UUID_SEPARATOR.length() : 0);
-      checkFooter(
-          identitiesStart != -1 && identitiesStart < labelLine.length(),
-          FOOTER_COPIED_LABEL,
-          labelLine);
-
-      String labelVoteStr =
-          labelLine.substring(labelStart, uuidStart != -1 ? uuidStart : identitiesStart);
-      rawPatchSetApproval.labelVote(labelVoteStr);
-      if (uuidStart != -1) {
-        String uuid = labelLine.substring(uuidStart + 2, identitiesStart);
-        checkFooter(!Strings.isNullOrEmpty(uuid), FOOTER_COPIED_LABEL, labelLine);
-        rawPatchSetApproval.uuid(Optional.of(uuid));
-      }
-      // The first account is the accountId, and second (if applicable) is the realAccountId.
-      List<String> identities =
-          parseIdentities(
-              labelLine.substring(
-                  identitiesStart + 1, tagStart == -1 ? labelLine.length() : tagStart));
-      checkFooter(identities.size() >= 1, FOOTER_COPIED_LABEL, labelLine);
-
-      rawPatchSetApproval.accountIdent(Optional.of(identities.get(0)));
-
-      if (identities.size() > 1) {
-        rawPatchSetApproval.realAccountIdent(Optional.of(identities.get(1)));
-      }
-
-      if (tagStart != -1) {
-        // tagStart+2 skips ":\"" to parse the actual tag. Tags are in brackets.
-        // line.length()-1 skips the last ".
-        String tag = labelLine.substring(tagStart + 2, labelLine.length() - 1);
-        rawPatchSetApproval.tag(Optional.of(tag));
-      }
-      return rawPatchSetApproval.build();
-    } catch (StringIndexOutOfBoundsException ex) {
-      throw parseException(FOOTER_COPIED_LABEL, labelLine, ex);
-    }
-  }
-
-  // Return the UUID start index or -1 if no UUID is present
-  private static int parseCopiedApprovalUuidStart(String line, int tagStart) {
-    int separatorIndex = line.indexOf(LABEL_VOTE_UUID_SEPARATOR);
-
-    // The first part of the condition checks whether the footer has the following format:
-    //   Copied-Label: <LABEL>=VOTE <Gerrit Account>,<Gerrit Real Account> :"<TAG>"
-    //   Weird tag that contains uuid delimiter. The uuid is actually not present.
-    if ((tagStart != -1 && separatorIndex > tagStart)
-        ||
-
-        // The second part of the condition allows us to distinguish the following two lines:
-        //   Label2=+1, 577fb248e474018276351785930358ec0450e9f7 Gerrit User 1 <1@gerrit>
-        //   Label2=+1 User Name (company_name, department) <2@gerrit>
-        (line.indexOf(' ') < separatorIndex)) {
-      return -1;
-    }
-    return separatorIndex;
-  }
-
-  // Splitting on "," breaks for identities containing commas. The below re-implements splitting on
-  // "(?<=>),", but it's 3-5x faster, as performance matters here.
-  private static List<String> parseIdentities(String line) {
-    List<String> idents = Splitter.on(',').splitToList(line);
-    List<String> identitiesList = new ArrayList<>();
-    for (int i = 0; i < idents.size(); i++) {
-      if (i == 0 || idents.get(i - 1).endsWith(">")) {
-        identitiesList.add(idents.get(i));
-      } else {
-        int lastIndex = identitiesList.size() - 1;
-        identitiesList.set(lastIndex, identitiesList.get(lastIndex) + "," + idents.get(i));
-      }
-    }
-    return identitiesList;
-  }
-
-  private static void checkFooter(boolean expr, FooterKey footer, String actual)
-      throws ConfigInvalidException {
-    if (!expr) {
-      throw parseException(footer, actual, /*cause=*/ null);
-    }
-  }
-
-  private static ConfigInvalidException parseException(
-      FooterKey footer, String actual, Throwable cause) {
-    return new ConfigInvalidException(
-        String.format("invalid %s: %s", footer.getName(), actual), cause);
-  }
 }
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotes.java b/java/com/google/gerrit/server/notedb/ChangeNotes.java
index f4d29e8..c5d2428 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.ImmutableSet.toImmutableSet;
 import static com.google.gerrit.entities.RefNames.changeMetaRef;
 import static java.util.Comparator.comparing;
 
@@ -30,7 +29,6 @@
 import com.google.common.collect.ImmutableSortedMap;
 import com.google.common.collect.ImmutableSortedSet;
 import com.google.common.collect.ListMultimap;
-import com.google.common.collect.Lists;
 import com.google.common.collect.Multimaps;
 import com.google.common.collect.Ordering;
 import com.google.common.flogger.FluentLogger;
@@ -52,7 +50,6 @@
 import com.google.gerrit.entities.RobotComment;
 import com.google.gerrit.entities.SubmitRecord;
 import com.google.gerrit.entities.SubmitRequirementResult;
-import com.google.gerrit.server.AssigneeStatusUpdate;
 import com.google.gerrit.server.ReviewerByEmailSet;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.ReviewerStatusUpdate;
@@ -427,8 +424,7 @@
 
   public ImmutableSortedMap<PatchSet.Id, PatchSet> getPatchSets() {
     if (patchSets == null) {
-      ImmutableSortedMap.Builder<PatchSet.Id, PatchSet> b =
-          ImmutableSortedMap.orderedBy(comparing(PatchSet.Id::get));
+      ImmutableSortedMap.Builder<PatchSet.Id, PatchSet> b = ImmutableSortedMap.naturalOrder();
       b.putAll(state.patchSets());
       patchSets = b.build();
     }
@@ -494,26 +490,6 @@
     return state.submitRequirementsResult();
   }
 
-  /**
-   * Returns an ImmutableSet of Account.Ids of all users that have been assigned to this change. The
-   * order of the set is the order in which they were assigned.
-   */
-  public ImmutableSet<Account.Id> getPastAssignees() {
-    return Lists.reverse(state.assigneeUpdates()).stream()
-        .map(AssigneeStatusUpdate::currentAssignee)
-        .filter(Optional::isPresent)
-        .map(Optional::get)
-        .collect(toImmutableSet());
-  }
-
-  /**
-   * Returns an ImmutableList of AssigneeStatusUpdate of all the updates to the assignee field to
-   * this change. The order of the list is from most recent updates to least recent.
-   */
-  public ImmutableList<AssigneeStatusUpdate> getAssigneeUpdates() {
-    return state.assigneeUpdates();
-  }
-
   /** Returns an ImmutableSet of all hashtags for this change sorted in alphabetical order. */
   public ImmutableSet<String> getHashtags() {
     return ImmutableSortedSet.copyOf(state.hashtags());
@@ -694,6 +670,7 @@
     return change.getProject();
   }
 
+  @Nullable
   @Override
   protected ObjectId readRef(Repository repo) throws IOException {
     return refs != null ? refs.get(getRefName()).orElse(null) : super.readRef(repo);
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesCache.java b/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
index 6b22a2d..b98ecdd 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
@@ -181,8 +181,6 @@
           + P
           + list(state.reviewerUpdates(), 4 * O + K + K + P)
           + P
-          + list(state.assigneeUpdates(), 4 * O + K + K)
-          + P
           + set(state.attentionSet(), 4 * O + K + I + str(15))
           + P
           + list(state.allAttentionSetUpdates(), 4 * O + K + I + str(15))
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesCommit.java b/java/com/google/gerrit/server/notedb/ChangeNotesCommit.java
index 76573f6..84de569 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesCommit.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesCommit.java
@@ -15,8 +15,8 @@
 package com.google.gerrit.server.notedb;
 
 import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_ATTENTION;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_PATCH_SET;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_ATTENTION;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_PATCH_SET;
 
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.MultimapBuilder;
@@ -25,6 +25,7 @@
 import com.google.gerrit.server.git.InsertedObject;
 import java.io.IOException;
 import java.util.List;
+import java.util.Locale;
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
 import org.eclipse.jgit.errors.MissingObjectException;
 import org.eclipse.jgit.lib.AnyObjectId;
@@ -125,10 +126,10 @@
       List<FooterLine> src = getFooterLines();
       footerLines = MultimapBuilder.hashKeys(src.size()).arrayListValues(1).build();
       for (FooterLine fl : src) {
-        footerLines.put(fl.getKey().toLowerCase(), fl.getValue());
+        footerLines.put(fl.getKey().toLowerCase(Locale.US), fl.getValue());
       }
     }
-    return footerLines.get(key.getName().toLowerCase());
+    return footerLines.get(key.getName().toLowerCase(Locale.US));
   }
 
   public boolean isAttentionSetCommitOnly(boolean hasChangeMessage) {
@@ -137,7 +138,7 @@
             .keySet()
             .equals(
                 Sets.newHashSet(
-                    FOOTER_PATCH_SET.getName().toLowerCase(),
-                    FOOTER_ATTENTION.getName().toLowerCase()));
+                    FOOTER_PATCH_SET.getName().toLowerCase(Locale.US),
+                    FOOTER_ATTENTION.getName().toLowerCase(Locale.US)));
   }
 }
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesParseApprovalUtil.java b/java/com/google/gerrit/server/notedb/ChangeNotesParseApprovalUtil.java
new file mode 100644
index 0000000..420f0c2
--- /dev/null
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesParseApprovalUtil.java
@@ -0,0 +1,334 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb;
+
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_COPIED_LABEL;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_LABEL;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.base.Splitter;
+import com.google.common.base.Strings;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.revwalk.FooterKey;
+
+/**
+ * Util to extract {@link com.google.gerrit.entities.PatchSetApproval} from {@link
+ * ChangeNoteFooters#FOOTER_LABEL} or {@link ChangeNoteFooters#FOOTER_COPIED_LABEL}.
+ */
+public class ChangeNotesParseApprovalUtil {
+  private static final String LABEL_VOTE_UUID_SEPARATOR = ", ";
+
+  /**
+   * {@link com.google.gerrit.entities.PatchSetApproval}, parsed from {@link
+   * ChangeNoteFooters#FOOTER_LABEL} or {@link ChangeNoteFooters#FOOTER_COPIED_LABEL}.
+   *
+   * <p>In comparison to {@link com.google.gerrit.entities.PatchSetApproval}, this entity represent
+   * the raw fields, parsed from the NoteDB footer line, without any interpretation of the parsed
+   * values. See {@link #parseApproval} and {@link #parseCopiedApproval} for the valid {@link
+   * #footerLine} values.
+   */
+  @AutoValue
+  public abstract static class ParsedPatchSetApproval {
+
+    /** The original footer value, that this entity was parsed from. */
+    public abstract String footerLine();
+
+    public abstract boolean isRemoval();
+
+    /** Either <LABEL>=VOTE or <LABEL> for {@link #isRemoval}. */
+    public abstract String labelVote();
+
+    public abstract Optional<String> uuid();
+
+    public abstract Optional<String> accountIdent();
+
+    public abstract Optional<String> realAccountIdent();
+
+    public abstract Optional<String> tag();
+
+    public static Builder builder() {
+      return new AutoValue_ChangeNotesParseApprovalUtil_ParsedPatchSetApproval.Builder();
+    }
+
+    @AutoValue.Builder
+    public abstract static class Builder {
+
+      abstract Builder footerLine(String labelLine);
+
+      abstract Builder isRemoval(boolean isRemoval);
+
+      abstract Builder labelVote(String labelVote);
+
+      abstract Builder uuid(Optional<String> uuid);
+
+      abstract Builder accountIdent(Optional<String> accountIdent);
+
+      abstract Builder realAccountIdent(Optional<String> realAccountIdent);
+
+      abstract Builder tag(Optional<String> tag);
+
+      abstract ParsedPatchSetApproval build();
+    }
+  }
+
+  /**
+   * Delegates parsing of {@link ParsedPatchSetApproval} from {@link ChangeNoteFooters#FOOTER_LABEL}
+   * line to dedicated methods: {@link #parseAddedApproval} and {@link #parseRemovedApproval}
+   * correspondingly.
+   */
+  public static ParsedPatchSetApproval parseApproval(String footerLine)
+      throws ConfigInvalidException {
+    try {
+      return footerLine.startsWith("-")
+          ? parseRemovedApproval(footerLine)
+          : parseAddedApproval(footerLine);
+    } catch (StringIndexOutOfBoundsException ex) {
+      throw parseException(FOOTER_LABEL, footerLine, ex);
+    }
+  }
+
+  /**
+   * Parses added {@link ParsedPatchSetApproval} from {@link ChangeNoteFooters#FOOTER_LABEL} line.
+   *
+   * <p>Valid added approval footer examples:
+   *
+   * <ul>
+   *   <li>Label: &lt;LABEL&gt;=VOTE
+   *   <li>Label: &lt;LABEL&gt;=VOTE &lt;Gerrit Account&gt;
+   *   <li>Label: &lt;LABEL&gt;=VOTE, &lt;UUID&gt;
+   *   <li>Label: &lt;LABEL&gt;=VOTE, &lt;UUID&gt; &lt;Gerrit Account&gt;
+   * </ul>
+   *
+   * <p>&lt;UUID&gt; is optional, since the approval might have been granted before {@link
+   * com.google.gerrit.entities.PatchSetApproval.UUID} was introduced.
+   *
+   * <p><Gerrit Account> is only persisted in cases, when the account, that granted the vote does
+   * not match the account, that issued {@link ChangeUpdate} (created this NoteDB commit).
+   */
+  private static ParsedPatchSetApproval parseAddedApproval(String footerLine)
+      throws ConfigInvalidException {
+    ParsedPatchSetApproval.Builder rawPatchSetApproval =
+        ParsedPatchSetApproval.builder().footerLine(footerLine);
+    rawPatchSetApproval.isRemoval(false);
+    // We need some additional logic to differentiate between labels that have a UUID and those that
+    // have a user with a comma. This allows us to separate the following cases (note that the
+    // leading `Label: ` has been elided at this point):
+    //   Label: <LABEL>=VOTE, <UUID> <Gerrit Account>
+    //   Label: <LABEL>=VOTE <Gerrit, Account>
+    int reviewerStartOffset = 0;
+    int scoreStart = footerLine.indexOf('=') + 1;
+    StringBuilder labelNameScore = new StringBuilder(footerLine.substring(0, scoreStart));
+    for (int i = scoreStart; i < footerLine.length(); i++) {
+      char currentChar = footerLine.charAt(i);
+
+      // If we hit ',' before ' ' we have a UUID
+      if (currentChar == ',') {
+        labelNameScore.append(footerLine, scoreStart, i);
+        int uuidStart = i + LABEL_VOTE_UUID_SEPARATOR.length();
+        int uuidEnd = footerLine.indexOf(' ', uuidStart);
+        String uuid = footerLine.substring(uuidStart, uuidEnd > 0 ? uuidEnd : footerLine.length());
+        checkFooter(!Strings.isNullOrEmpty(uuid), FOOTER_LABEL, footerLine);
+        rawPatchSetApproval.uuid(Optional.of(uuid));
+        reviewerStartOffset = uuidStart + uuid.length();
+        break;
+      }
+
+      // Otherwise we don't
+      if (currentChar == ' ') {
+        labelNameScore.append(footerLine, scoreStart, i);
+        break;
+      }
+
+      // If we hit neither we're defensive assign the whole line
+      if (i == footerLine.length() - 1) {
+        labelNameScore = new StringBuilder(footerLine);
+        break;
+      }
+    }
+
+    rawPatchSetApproval.labelVote(labelNameScore.toString());
+
+    int reviewerStart = footerLine.indexOf(' ', reviewerStartOffset);
+    if (reviewerStart > 0) {
+      String ident = footerLine.substring(reviewerStart + 1);
+      rawPatchSetApproval.accountIdent(Optional.of(ident));
+    }
+    return rawPatchSetApproval.build();
+  }
+
+  /**
+   * Parses removed {@link ParsedPatchSetApproval} from {@link ChangeNoteFooters#FOOTER_LABEL} line.
+   *
+   * <p>Valid removed approval footer examples:
+   *
+   * <ul>
+   *   <li>-&lt;LABEL&gt;
+   *   <li>-&lt;LABEL&gt; &lt;Gerrit Account&gt;
+   * </ul>
+   *
+   * <p>&lt;Gerrit Account&gt; is only persisted in cases, when the account, that granted the vote
+   * does not match the account, that issued {@link ChangeUpdate} (created this NoteDB commit).
+   */
+  private static ParsedPatchSetApproval parseRemovedApproval(String footerLine) {
+    ParsedPatchSetApproval.Builder rawPatchSetApproval =
+        ParsedPatchSetApproval.builder().footerLine(footerLine);
+    rawPatchSetApproval.isRemoval(true);
+    int labelStart = 1;
+    int reviewerStart = footerLine.indexOf(' ', labelStart);
+
+    rawPatchSetApproval.labelVote(
+        reviewerStart != -1
+            ? footerLine.substring(labelStart, reviewerStart)
+            : footerLine.substring(labelStart));
+
+    if (reviewerStart > 0) {
+      String ident = footerLine.substring(reviewerStart + 1);
+      rawPatchSetApproval.accountIdent(Optional.of(ident));
+    }
+    return rawPatchSetApproval.build();
+  }
+
+  /**
+   * Parses copied {@link ParsedPatchSetApproval} from {@link ChangeNoteFooters#FOOTER_COPIED_LABEL}
+   * line.
+   *
+   * <p>Footer example: Copied-Label: <LABEL>=VOTE, <UUID> <Gerrit Account>,<Gerrit Real Account>
+   * :"<TAG>"
+   *
+   * <ul>
+   *   <li>":<"TAG>"" is optional.
+   *   <li><Gerrit Real Account> is also optional, if it was not set.
+   *   <li><UUID> is optional, since the approval might have been granted before {@link
+   *       com.google.gerrit.entities.PatchSetApproval.UUID} was introduced.
+   *   <li>The label, vote, and the Gerrit account are mandatory (unlike FOOTER_LABEL where Gerrit
+   *       Account is also optional since by default it's the committer).
+   * </ul>
+   *
+   * <p>Footer example for removal: Copied-Label: -<LABEL> <Gerrit Account>,<Gerrit Real Account>
+   *
+   * <ul>
+   *   <li><Gerrit Real Account> is also optional, if it was not set.
+   * </ul>
+   */
+  public static ParsedPatchSetApproval parseCopiedApproval(String labelLine)
+      throws ConfigInvalidException {
+    try {
+      ParsedPatchSetApproval.Builder rawPatchSetApproval =
+          ParsedPatchSetApproval.builder().footerLine(labelLine);
+
+      boolean isRemoval = labelLine.startsWith("-");
+      rawPatchSetApproval.isRemoval(isRemoval);
+      int labelStart = isRemoval ? 1 : 0;
+      int tagStart = isRemoval ? -1 : labelLine.indexOf(":\"");
+      int uuidStart = parseCopiedApprovalUuidStart(labelLine, tagStart);
+
+      checkFooter(!isRemoval || uuidStart == -1, FOOTER_LABEL, labelLine);
+
+      // Weird tag that contains uuid delimiter. The uuid is actually not present.
+      if (tagStart != -1 && uuidStart > tagStart) {
+        uuidStart = -1;
+      }
+
+      int identitiesStart =
+          labelLine.indexOf(
+              ' ', uuidStart != -1 ? uuidStart + LABEL_VOTE_UUID_SEPARATOR.length() : 0);
+      checkFooter(
+          identitiesStart != -1 && identitiesStart < labelLine.length(),
+          FOOTER_COPIED_LABEL,
+          labelLine);
+
+      String labelVoteStr =
+          labelLine.substring(labelStart, uuidStart != -1 ? uuidStart : identitiesStart);
+      rawPatchSetApproval.labelVote(labelVoteStr);
+      if (uuidStart != -1) {
+        String uuid = labelLine.substring(uuidStart + 2, identitiesStart);
+        checkFooter(!Strings.isNullOrEmpty(uuid), FOOTER_COPIED_LABEL, labelLine);
+        rawPatchSetApproval.uuid(Optional.of(uuid));
+      }
+      // The first account is the accountId, and second (if applicable) is the realAccountId.
+      List<String> identities =
+          parseIdentities(
+              labelLine.substring(
+                  identitiesStart + 1, tagStart == -1 ? labelLine.length() : tagStart));
+
+      rawPatchSetApproval.accountIdent(Optional.of(identities.get(0)));
+
+      if (identities.size() > 1) {
+        rawPatchSetApproval.realAccountIdent(Optional.of(identities.get(1)));
+      }
+
+      if (tagStart != -1) {
+        // tagStart+2 skips ":\"" to parse the actual tag. Tags are in brackets.
+        // line.length()-1 skips the last ".
+        String tag = labelLine.substring(tagStart + 2, labelLine.length() - 1);
+        rawPatchSetApproval.tag(Optional.of(tag));
+      }
+      return rawPatchSetApproval.build();
+    } catch (StringIndexOutOfBoundsException ex) {
+      throw parseException(FOOTER_COPIED_LABEL, labelLine, ex);
+    }
+  }
+
+  // Return the UUID start index or -1 if no UUID is present
+  private static int parseCopiedApprovalUuidStart(String line, int tagStart) {
+    int separatorIndex = line.indexOf(LABEL_VOTE_UUID_SEPARATOR);
+
+    // The first part of the condition checks whether the footer has the following format:
+    //   Copied-Label: <LABEL>=VOTE <Gerrit Account>,<Gerrit Real Account> :"<TAG>"
+    //   Weird tag that contains uuid delimiter. The uuid is actually not present.
+    if ((tagStart != -1 && separatorIndex > tagStart)
+        ||
+
+        // The second part of the condition allows us to distinguish the following two lines:
+        //   Label2=+1, 577fb248e474018276351785930358ec0450e9f7 Gerrit User 1 <1@gerrit>
+        //   Label2=+1 User Name (company_name, department) <2@gerrit>
+        (line.indexOf(' ') < separatorIndex)) {
+      return -1;
+    }
+    return separatorIndex;
+  }
+
+  // Splitting on "," breaks for identities containing commas. The below re-implements splitting on
+  // "(?<=>),", but it's 3-5x faster, as performance matters here.
+  private static List<String> parseIdentities(String line) {
+    List<String> idents = Splitter.on(',').splitToList(line);
+    List<String> identitiesList = new ArrayList<>();
+    for (int i = 0; i < idents.size(); i++) {
+      if (i == 0 || idents.get(i - 1).endsWith(">")) {
+        identitiesList.add(idents.get(i));
+      } else {
+        int lastIndex = identitiesList.size() - 1;
+        identitiesList.set(lastIndex, identitiesList.get(lastIndex) + "," + idents.get(i));
+      }
+    }
+    return identitiesList;
+  }
+
+  private static void checkFooter(boolean expr, FooterKey footer, String actual)
+      throws ConfigInvalidException {
+    if (!expr) {
+      throw parseException(footer, actual, /*cause=*/ null);
+    }
+  }
+
+  private static ConfigInvalidException parseException(
+      FooterKey footer, String actual, Throwable cause) {
+    return new ConfigInvalidException(
+        String.format("invalid %s: %s", footer.getName(), actual), cause);
+  }
+}
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesParser.java b/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
index e8b75b1..e06dad3 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
@@ -15,29 +15,28 @@
 package com.google.gerrit.server.notedb;
 
 import static com.google.common.base.MoreObjects.firstNonNull;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_ASSIGNEE;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_ATTENTION;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_BRANCH;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_CHANGE_ID;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_CHERRY_PICK_OF;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_COMMIT;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_COPIED_LABEL;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_CURRENT;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_GROUPS;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_HASHTAGS;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_LABEL;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_PATCH_SET;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_PATCH_SET_DESCRIPTION;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_PRIVATE;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_REAL_USER;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_REVERT_OF;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_STATUS;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_SUBJECT;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_SUBMISSION_ID;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_SUBMITTED_WITH;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_TAG;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_TOPIC;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_WORK_IN_PROGRESS;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_ATTENTION;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_BRANCH;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_CHANGE_ID;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_CHERRY_PICK_OF;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_COMMIT;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_COPIED_LABEL;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_CURRENT;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_GROUPS;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_HASHTAGS;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_LABEL;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_PATCH_SET;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_PATCH_SET_DESCRIPTION;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_PRIVATE;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_REAL_USER;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_REVERT_OF;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_STATUS;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_SUBJECT;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_SUBMISSION_ID;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_SUBMITTED_WITH;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_TAG;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_TOPIC;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_WORK_IN_PROGRESS;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.parseCommitMessageRange;
 import static java.util.Comparator.comparing;
 import static java.util.Comparator.comparingInt;
@@ -76,12 +75,11 @@
 import com.google.gerrit.entities.SubmitRecord.Label.Status;
 import com.google.gerrit.entities.SubmitRequirementResult;
 import com.google.gerrit.metrics.Timer0;
-import com.google.gerrit.server.AssigneeStatusUpdate;
 import com.google.gerrit.server.ReviewerByEmailSet;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.ReviewerStatusUpdate;
-import com.google.gerrit.server.notedb.ChangeNoteUtil.ParsedPatchSetApproval;
 import com.google.gerrit.server.notedb.ChangeNotesCommit.ChangeNotesRevWalk;
+import com.google.gerrit.server.notedb.ChangeNotesParseApprovalUtil.ParsedPatchSetApproval;
 import com.google.gerrit.server.util.LabelVote;
 import java.io.IOException;
 import java.nio.charset.Charset;
@@ -95,6 +93,7 @@
 import java.util.Iterator;
 import java.util.LinkedHashMap;
 import java.util.List;
+import java.util.Locale;
 import java.util.Map;
 import java.util.Objects;
 import java.util.Optional;
@@ -160,7 +159,6 @@
   /** Holds all updates to attention set. */
   private final List<AttentionSetUpdate> allAttentionSetUpdates;
 
-  private final List<AssigneeStatusUpdate> assigneeUpdates;
   private final List<SubmitRecord> submitRecords;
   private final ListMultimap<ObjectId, HumanComment> humanComments;
   private final List<SubmitRequirementResult> submitRequirementResults;
@@ -225,7 +223,6 @@
     reviewerUpdates = new ArrayList<>();
     latestAttentionStatus = new HashMap<>();
     allAttentionSetUpdates = new ArrayList<>();
-    assigneeUpdates = new ArrayList<>();
     submitRecords = Lists.newArrayListWithExpectedSize(1);
     allChangeMessages = new ArrayList<>();
     humanComments = MultimapBuilder.hashKeys().arrayListValues().build();
@@ -298,7 +295,6 @@
         buildReviewerUpdates(),
         ImmutableSet.copyOf(latestAttentionStatus.values()),
         allAttentionSetUpdates,
-        assigneeUpdates,
         submitRecords,
         buildAllMessages(),
         humanComments,
@@ -327,6 +323,7 @@
     return result;
   }
 
+  @Nullable
   private PatchSet.Id buildCurrentPatchSetId() {
     // currentPatchSets are in parse order, i.e. newest first. Pick the first
     // patch set that was marked as current, excluding deleted patch sets.
@@ -492,7 +489,6 @@
 
     parseHashtags(commit);
     parseAttentionSetUpdates(commit);
-    parseAssigneeUpdates(commitTimestamp, commit);
 
     parseSubmission(commit, commitTimestamp);
 
@@ -511,7 +507,7 @@
 
     ObjectId currRev = parseRevision(commit);
     if (currRev != null) {
-      parsePatchSet(psId, currRev, accountId, commitTimestamp);
+      parsePatchSet(psId, currRev, accountId, realAccountId, commitTimestamp);
     }
     parseCurrentPatchSet(psId, commit);
 
@@ -580,6 +576,7 @@
     return parseOneFooter(commit, FOOTER_SUBMISSION_ID);
   }
 
+  @Nullable
   private String parseBranch(ChangeNotesCommit commit) throws ConfigInvalidException {
     String branch = parseOneFooter(commit, FOOTER_BRANCH);
     return branch != null ? RefNames.fullName(branch) : null;
@@ -607,6 +604,7 @@
     return parseOneFooter(commit, FOOTER_TOPIC);
   }
 
+  @Nullable
   private String parseOneFooter(ChangeNotesCommit commit, FooterKey footerKey)
       throws ConfigInvalidException {
     List<String> footerLines = commit.getFooterLineValues(footerKey);
@@ -627,6 +625,7 @@
     return line;
   }
 
+  @Nullable
   private ObjectId parseRevision(ChangeNotesCommit commit) throws ConfigInvalidException {
     String sha = parseOneFooter(commit, FOOTER_COMMIT);
     if (sha == null) {
@@ -641,7 +640,8 @@
     }
   }
 
-  private void parsePatchSet(PatchSet.Id psId, ObjectId rev, Account.Id accountId, Instant ts)
+  private void parsePatchSet(
+      PatchSet.Id psId, ObjectId rev, Account.Id accountId, Account.Id realAccountId, Instant ts)
       throws ConfigInvalidException {
     if (accountId == null) {
       throw parseException("patch set %s requires an identified user as uploader", psId.get());
@@ -658,6 +658,7 @@
         .id(psId)
         .commitId(rev)
         .uploader(accountId)
+        .realUploader(realAccountId)
         .createdOn(ts);
     // Fields not set here:
     // * Groups, parsed earlier in parseGroups.
@@ -736,22 +737,6 @@
     }
   }
 
-  private void parseAssigneeUpdates(Instant ts, ChangeNotesCommit commit)
-      throws ConfigInvalidException {
-    String assigneeValue = parseOneFooter(commit, FOOTER_ASSIGNEE);
-    if (assigneeValue != null) {
-      Optional<Account.Id> parsedAssignee;
-      if (assigneeValue.equals("")) {
-        // Empty footer found, assignee deleted
-        parsedAssignee = Optional.empty();
-      } else {
-        PersonIdent ident = RawParseUtils.parsePersonIdent(assigneeValue);
-        parsedAssignee = Optional.ofNullable(parseIdent(ident));
-      }
-      assigneeUpdates.add(AssigneeStatusUpdate.create(ts, ownerId, parsedAssignee));
-    }
-  }
-
   private void parseTag(ChangeNotesCommit commit) throws ConfigInvalidException {
     tag = null;
     List<String> tagLines = commit.getFooterLineValues(FOOTER_TAG);
@@ -764,6 +749,7 @@
     }
   }
 
+  @Nullable
   private Change.Status parseStatus(ChangeNotesCommit commit) throws ConfigInvalidException {
     List<String> statusLines = commit.getFooterLineValues(FOOTER_STATUS);
     if (statusLines.isEmpty()) {
@@ -772,7 +758,7 @@
       throw expectedOneFooter(FOOTER_STATUS, statusLines);
     }
     Change.Status status =
-        Enums.getIfPresent(Change.Status.class, statusLines.get(0).toUpperCase()).orNull();
+        Enums.getIfPresent(Change.Status.class, statusLines.get(0).toUpperCase(Locale.US)).orNull();
     if (status == null) {
       throw invalidFooter(FOOTER_STATUS, statusLines.get(0));
     }
@@ -802,6 +788,7 @@
     return PatchSet.id(id, psId);
   }
 
+  @Nullable
   private PatchSetState parsePatchSetState(ChangeNotesCommit commit) throws ConfigInvalidException {
     String psIdLine = parseExactlyOneFooter(commit, FOOTER_PATCH_SET);
     int s = psIdLine.indexOf(' ');
@@ -813,7 +800,7 @@
       PatchSetState state =
           Enums.getIfPresent(
                   PatchSetState.class,
-                  withParens.substring(1, withParens.length() - 1).toUpperCase())
+                  withParens.substring(1, withParens.length() - 1).toUpperCase(Locale.US))
               .orNull();
       if (state != null) {
         return state;
@@ -942,7 +929,8 @@
   /** Parses copied {@link PatchSetApproval}. */
   private void parseCopiedApproval(PatchSet.Id psId, Instant ts, String line)
       throws ConfigInvalidException {
-    ParsedPatchSetApproval parsedPatchSetApproval = ChangeNoteUtil.parseCopiedApproval(line);
+    ParsedPatchSetApproval parsedPatchSetApproval =
+        ChangeNotesParseApprovalUtil.parseCopiedApproval(line);
     checkFooter(
         parsedPatchSetApproval.accountIdent().isPresent(),
         FOOTER_COPIED_LABEL,
@@ -1000,7 +988,8 @@
       throw parseException("patch set %s requires an identified user as uploader", psId.get());
     }
     PatchSetApproval.Builder psa;
-    ParsedPatchSetApproval parsedPatchSetApproval = ChangeNoteUtil.parseApproval(line);
+    ParsedPatchSetApproval parsedPatchSetApproval =
+        ChangeNotesParseApprovalUtil.parseApproval(line);
     if (line.startsWith("-")) {
       psa = parseRemoveApproval(psId, accountId, realAccountId, ts, parsedPatchSetApproval);
     } else {
@@ -1009,7 +998,7 @@
     bufferedApprovals.add(psa);
   }
 
-  /** Parses {@link PatchSetApproval} out of the {@link ChangeNoteUtil#FOOTER_LABEL} value. */
+  /** Parses {@link PatchSetApproval} out of the {@link ChangeNoteFooters#FOOTER_LABEL} value. */
   private PatchSetApproval.Builder parseAddApproval(
       PatchSet.Id psId,
       Account.Id committerId,
@@ -1151,6 +1140,7 @@
     }
   }
 
+  @Nullable
   private Account.Id parseIdent(ChangeNotesCommit commit) throws ConfigInvalidException {
     // Check if the author name/email is the same as the committer name/email,
     // i.e. was the server ident at the time this commit was made.
@@ -1236,6 +1226,7 @@
     throw invalidFooter(FOOTER_WORK_IN_PROGRESS, raw);
   }
 
+  @Nullable
   private Change.Id parseRevertOf(ChangeNotesCommit commit) throws ConfigInvalidException {
     String footer = parseOneFooter(commit, FOOTER_REVERT_OF);
     if (footer == null) {
@@ -1249,7 +1240,7 @@
   }
 
   /**
-   * Parses {@link ChangeNoteUtil#FOOTER_CHERRY_PICK_OF} of the commit.
+   * Parses {@link ChangeNoteFooters#FOOTER_CHERRY_PICK_OF} of the commit.
    *
    * @param commit the commit to parse.
    * @return {@link Optional} value of the parsed footer or {@code null} if the footer is missing in
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesState.java b/java/com/google/gerrit/server/notedb/ChangeNotesState.java
index b0079d7..1715b43 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesState.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesState.java
@@ -50,12 +50,10 @@
 import com.google.gerrit.entities.converter.PatchSetProtoConverter;
 import com.google.gerrit.json.OutputFormat;
 import com.google.gerrit.proto.Protos;
-import com.google.gerrit.server.AssigneeStatusUpdate;
 import com.google.gerrit.server.ReviewerByEmailSet;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.ReviewerStatusUpdate;
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto;
-import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto.AssigneeStatusUpdateProto;
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto.AttentionSetUpdateProto;
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto.ChangeColumnsProto;
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto.ReviewerByEmailSetEntryProto;
@@ -68,7 +66,6 @@
 import java.time.Instant;
 import java.util.List;
 import java.util.Map;
-import java.util.Optional;
 import java.util.Set;
 import org.eclipse.jgit.lib.ObjectId;
 
@@ -124,7 +121,6 @@
       List<ReviewerStatusUpdate> reviewerUpdates,
       Set<AttentionSetUpdate> attentionSetUpdates,
       List<AttentionSetUpdate> allAttentionSetUpdates,
-      List<AssigneeStatusUpdate> assigneeUpdates,
       List<SubmitRecord> submitRecords,
       List<ChangeMessage> changeMessages,
       ListMultimap<ObjectId, HumanComment> publishedComments,
@@ -178,7 +174,6 @@
         .reviewerUpdates(reviewerUpdates)
         .attentionSet(attentionSetUpdates)
         .allAttentionSetUpdates(allAttentionSetUpdates)
-        .assigneeUpdates(assigneeUpdates)
         .submitRecords(submitRecords)
         .changeMessages(changeMessages)
         .publishedComments(publishedComments)
@@ -320,8 +315,6 @@
   /** Returns all attention set updates. */
   abstract ImmutableList<AttentionSetUpdate> allAttentionSetUpdates();
 
-  abstract ImmutableList<AssigneeStatusUpdate> assigneeUpdates();
-
   abstract ImmutableList<SubmitRecord> submitRecords();
 
   abstract ImmutableList<ChangeMessage> changeMessages();
@@ -369,9 +362,6 @@
     change.setTopic(Strings.emptyToNull(c.topic()));
     change.setLastUpdatedOn(c.lastUpdatedOn());
     change.setSubmissionId(c.submissionId());
-    if (!assigneeUpdates().isEmpty()) {
-      change.setAssignee(assigneeUpdates().get(0).currentAssignee().orElse(null));
-    }
     change.setPrivate(c.isPrivate());
     change.setWorkInProgress(c.workInProgress());
     change.setReviewStarted(c.reviewStarted());
@@ -404,7 +394,6 @@
           .reviewerUpdates(ImmutableList.of())
           .attentionSet(ImmutableSet.of())
           .allAttentionSetUpdates(ImmutableList.of())
-          .assigneeUpdates(ImmutableList.of())
           .submitRecords(ImmutableList.of())
           .changeMessages(ImmutableList.of())
           .publishedComments(ImmutableListMultimap.of())
@@ -442,8 +431,6 @@
 
     abstract Builder allAttentionSetUpdates(List<AttentionSetUpdate> attentionSetUpdates);
 
-    abstract Builder assigneeUpdates(List<AssigneeStatusUpdate> assigneeUpdates);
-
     abstract Builder submitRecords(List<SubmitRecord> submitRecords);
 
     abstract Builder changeMessages(List<ChangeMessage> changeMessages);
@@ -519,7 +506,6 @@
       object
           .allAttentionSetUpdates()
           .forEach(u -> b.addAllAttentionSetUpdate(toAttentionSetUpdateProto(u)));
-      object.assigneeUpdates().forEach(u -> b.addAssigneeUpdate(toAssigneeStatusUpdateProto(u)));
       object
           .submitRecords()
           .forEach(r -> b.addSubmitRecord(GSON.toJson(new StoredSubmitRecord(r))));
@@ -616,17 +602,6 @@
           .build();
     }
 
-    private static AssigneeStatusUpdateProto toAssigneeStatusUpdateProto(AssigneeStatusUpdate u) {
-      AssigneeStatusUpdateProto.Builder builder =
-          AssigneeStatusUpdateProto.newBuilder()
-              .setTimestampMillis(u.date().toEpochMilli())
-              .setUpdatedBy(u.updatedBy().get())
-              .setHasCurrentAssignee(u.currentAssignee().isPresent());
-
-      u.currentAssignee().ifPresent(assignee -> builder.setCurrentAssignee(assignee.get()));
-      return builder.build();
-    }
-
     @Override
     public ChangeNotesState deserialize(byte[] in) {
       ChangeNotesStateProto proto = Protos.parseUnchecked(ChangeNotesStateProto.parser(), in);
@@ -659,7 +634,6 @@
               .attentionSet(toAttentionSetUpdates(proto.getAttentionSetUpdateList()))
               .allAttentionSetUpdates(
                   toAllAttentionSetUpdates(proto.getAllAttentionSetUpdateList()))
-              .assigneeUpdates(toAssigneeStatusUpdateList(proto.getAssigneeUpdateList()))
               .submitRecords(
                   proto.getSubmitRecordList().stream()
                       .map(r -> GSON.fromJson(r, StoredSubmitRecord.class).toSubmitRecord())
@@ -783,20 +757,5 @@
       }
       return b.build();
     }
-
-    private static ImmutableList<AssigneeStatusUpdate> toAssigneeStatusUpdateList(
-        List<AssigneeStatusUpdateProto> protos) {
-      ImmutableList.Builder<AssigneeStatusUpdate> b = ImmutableList.builder();
-      for (AssigneeStatusUpdateProto proto : protos) {
-        b.add(
-            AssigneeStatusUpdate.create(
-                Instant.ofEpochMilli(proto.getTimestampMillis()),
-                Account.id(proto.getUpdatedBy()),
-                proto.getHasCurrentAssignee()
-                    ? Optional.of(Account.id(proto.getCurrentAssignee()))
-                    : Optional.empty()));
-      }
-      return b.build();
-    }
   }
 }
diff --git a/java/com/google/gerrit/server/notedb/ChangeUpdate.java b/java/com/google/gerrit/server/notedb/ChangeUpdate.java
index 62c734b..0a895fb 100644
--- a/java/com/google/gerrit/server/notedb/ChangeUpdate.java
+++ b/java/com/google/gerrit/server/notedb/ChangeUpdate.java
@@ -18,31 +18,31 @@
 import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.gerrit.entities.RefNames.changeMetaRef;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_ASSIGNEE;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_ATTENTION;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_BRANCH;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_CHANGE_ID;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_CHERRY_PICK_OF;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_COMMIT;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_COPIED_LABEL;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_CURRENT;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_GROUPS;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_HASHTAGS;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_LABEL;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_PATCH_SET;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_PATCH_SET_DESCRIPTION;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_PRIVATE;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_REAL_USER;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_REVERT_OF;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_STATUS;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_SUBJECT;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_SUBMISSION_ID;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_SUBMITTED_WITH;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_TAG;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_TOPIC;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_WORK_IN_PROGRESS;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_ATTENTION;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_BRANCH;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_CHANGE_ID;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_CHERRY_PICK_OF;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_COMMIT;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_COPIED_LABEL;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_CURRENT;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_GROUPS;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_HASHTAGS;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_LABEL;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_PATCH_SET;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_PATCH_SET_DESCRIPTION;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_PRIVATE;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_REAL_USER;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_REVERT_OF;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_STATUS;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_SUBJECT;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_SUBMISSION_ID;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_SUBMITTED_WITH;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_TAG;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_TOPIC;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_WORK_IN_PROGRESS;
 import static com.google.gerrit.server.notedb.NoteDbUtil.sanitizeFooter;
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
 import static java.util.Comparator.naturalOrder;
 import static java.util.Objects.requireNonNull;
 import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
@@ -80,6 +80,7 @@
 import com.google.gerrit.server.account.ServiceUserClassifier;
 import com.google.gerrit.server.approval.PatchSetApprovalUuidGenerator;
 import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.update.context.RefUpdateContext;
 import com.google.gerrit.server.util.AttentionSetUtil;
 import com.google.gerrit.server.util.LabelVote;
 import com.google.gerrit.server.validators.ValidationException;
@@ -94,6 +95,7 @@
 import java.util.HashSet;
 import java.util.LinkedHashMap;
 import java.util.List;
+import java.util.Locale;
 import java.util.Map;
 import java.util.Objects;
 import java.util.Optional;
@@ -160,7 +162,6 @@
   private String commit;
   private Map<Account.Id, AttentionSetUpdate> plannedAttentionSetUpdates;
   private boolean ignoreFurtherAttentionSetUpdates;
-  private Optional<Account.Id> assignee;
   private Set<String> hashtags;
   private String changeMessage;
   private String tag;
@@ -250,9 +251,11 @@
   }
 
   public ObjectId commit() throws IOException {
-    try (NoteDbUpdateManager updateManager = updateManagerFactory.create(getProjectName())) {
-      updateManager.add(this);
-      updateManager.execute();
+    try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+      try (NoteDbUpdateManager updateManager = updateManagerFactory.create(getProjectName())) {
+        updateManager.add(this);
+        updateManager.execute();
+      }
     }
     return getResult();
   }
@@ -510,15 +513,6 @@
     return attentionSetUpdatesBuilder.build();
   }
 
-  public void setAssignee(Account.Id assignee) {
-    checkArgument(assignee != null, "use removeAssignee");
-    this.assignee = Optional.of(assignee);
-  }
-
-  public void removeAssignee() {
-    this.assignee = Optional.empty();
-  }
-
   public Map<Account.Id, ReviewerStateInternal> getReviewers() {
     return reviewers;
   }
@@ -571,6 +565,7 @@
   }
 
   /** Returns the tree id for the updated tree */
+  @Nullable
   private ObjectId storeRevisionNotes(RevWalk rw, ObjectInserter inserter, ObjectId curr)
       throws ConfigInvalidException, IOException {
     if (submitRequirementResults == null && comments.isEmpty() && pushCert == null) {
@@ -747,7 +742,7 @@
     }
 
     if (status != null) {
-      addFooter(msg, FOOTER_STATUS, status.name().toLowerCase());
+      addFooter(msg, FOOTER_STATUS, status.name().toLowerCase(Locale.US));
       if (status.equals(Change.Status.ABANDONED)) {
         clearAttentionSet("Change was abandoned");
       }
@@ -764,15 +759,6 @@
       addFooter(msg, FOOTER_COMMIT, commit);
     }
 
-    if (assignee != null) {
-      if (assignee.isPresent()) {
-        addFooter(msg, FOOTER_ASSIGNEE);
-        noteUtil.appendAccountIdIdentString(msg, assignee.get()).append('\n');
-      } else {
-        addFooter(msg, FOOTER_ASSIGNEE).append('\n');
-      }
-    }
-
     Joiner comma = Joiner.on(',');
     if (hashtags != null) {
       addFooter(msg, FOOTER_HASHTAGS, comma.join(hashtags));
@@ -1100,7 +1086,7 @@
             // remove users that are currently being removed from the attention set.
             .filter(
                 a ->
-                    plannedAttentionSetUpdates.getOrDefault(a, /*defaultValue= */ null) == null
+                    plannedAttentionSetUpdates.getOrDefault(a, /* defaultValue= */ null) == null
                         || plannedAttentionSetUpdates.get(a).operation().equals(Operation.REMOVE))
             // remove users that are still active on the change.
             .filter(a -> !isActiveOnChange(currentReviewers, a))
@@ -1144,7 +1130,7 @@
   private void addPatchSetFooter(StringBuilder sb, PatchSet.Id ps) {
     addFooter(sb, FOOTER_PATCH_SET).append(ps.get());
     if (psState != null) {
-      sb.append(" (").append(psState.name().toLowerCase()).append(')');
+      sb.append(" (").append(psState.name().toLowerCase(Locale.US)).append(')');
     }
     sb.append('\n');
   }
@@ -1172,7 +1158,6 @@
         && status == null
         && submissionId == null
         && submitRecords == null
-        && assignee == null
         && hashtags == null
         && topic == null
         && commit == null
diff --git a/java/com/google/gerrit/server/notedb/CommitRewriter.java b/java/com/google/gerrit/server/notedb/CommitRewriter.java
index 84776cf..270fc32 100644
--- a/java/com/google/gerrit/server/notedb/CommitRewriter.java
+++ b/java/com/google/gerrit/server/notedb/CommitRewriter.java
@@ -15,12 +15,12 @@
 
 import static com.google.common.base.MoreObjects.firstNonNull;
 import static com.google.common.base.Preconditions.checkState;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_ASSIGNEE;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_ATTENTION;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_LABEL;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_REAL_USER;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_SUBMITTED_WITH;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_TAG;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_ATTENTION;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_LABEL;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_REAL_USER;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_SUBMITTED_WITH;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_TAG;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
 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;
@@ -49,6 +49,7 @@
 import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.notedb.ChangeNoteUtil.AttentionStatusInNoteDb;
 import com.google.gerrit.server.notedb.ChangeNoteUtil.CommitMessageRange;
+import com.google.gerrit.server.update.context.RefUpdateContext;
 import com.google.gerrit.server.util.AccountTemplateUtil;
 import com.google.gson.Gson;
 import com.google.inject.Inject;
@@ -89,6 +90,7 @@
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.FooterKey;
 import org.eclipse.jgit.revwalk.FooterLine;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevSort;
@@ -110,6 +112,10 @@
 @UsedAt(UsedAt.Project.GOOGLE)
 @Singleton
 public class CommitRewriter {
+  // Reading and Writing assignee footer no longer supported. We keep the definition here to be able
+  // to rewrite older commit messages.
+  public static final FooterKey FOOTER_ASSIGNEE = new FooterKey("Assignee");
+
   /** Options to run {@link #backfillProject}. */
   public static class RunOptions implements Serializable {
     private static final long serialVersionUID = 1L;
@@ -340,13 +346,15 @@
     if (refsUpdate == null) {
       return;
     }
-    if (!refsUpdate.batchRefUpdate().getCommands().isEmpty()) {
-      if (!options.dryRun) {
-        refsUpdate.inserter().flush();
-        RefUpdateUtil.executeChecked(refsUpdate.batchRefUpdate(), refsUpdate.revWalk());
+    try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+      if (!refsUpdate.batchRefUpdate().getCommands().isEmpty()) {
+        if (!options.dryRun) {
+          refsUpdate.inserter().flush();
+          RefUpdateUtil.executeChecked(refsUpdate.batchRefUpdate(), refsUpdate.revWalk());
+        }
       }
+      refsUpdate.close();
     }
-    refsUpdate.close();
   }
 
   /**
@@ -368,7 +376,9 @@
       }
     }
     accounts.addAll(changeNotes.getAllPastReviewers());
-    accounts.addAll(changeNotes.getPastAssignees());
+    // Change Notes class can no longer read or write assignees, we skip assignee accounts at
+    // verifyCommit stage.
+    // accounts.addAll(changeNotes.getPastAssignees());
     changeNotes
         .getAttentionSetUpdates()
         .forEach(attentionSetUpdate -> accounts.add(attentionSetUpdate.account()));
@@ -896,7 +906,7 @@
             commitMessageRange.get().subjectEnd());
     Optional<String> fixedChangeMessage = Optional.empty();
     String originalChangeMessage = null;
-    if (commitMessageRange.isPresent() && commitMessageRange.get().hasChangeMessage()) {
+    if (commitMessageRange.get().hasChangeMessage()) {
       originalChangeMessage =
           RawParseUtils.decode(
                   enc,
diff --git a/java/com/google/gerrit/server/notedb/DeleteZombieCommentsRefs.java b/java/com/google/gerrit/server/notedb/DeleteZombieCommentsRefs.java
index c8d93f8..3f3ede1 100644
--- a/java/com/google/gerrit/server/notedb/DeleteZombieCommentsRefs.java
+++ b/java/com/google/gerrit/server/notedb/DeleteZombieCommentsRefs.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.gerrit.entities.RefNames.REFS_DRAFT_COMMENTS;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
 import static org.eclipse.jgit.lib.Constants.EMPTY_TREE_ID;
 
 import com.google.auto.value.AutoValue;
@@ -36,6 +37,7 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.update.context.RefUpdateContext;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
@@ -402,17 +404,19 @@
 
   private void deleteBatchZombieRefs(Repository allUsersRepo, List<Ref> refsBatch)
       throws IOException {
-    List<ReceiveCommand> deleteCommands =
-        refsBatch.stream()
-            .map(
-                zombieRef ->
-                    new ReceiveCommand(
-                        zombieRef.getObjectId(), ObjectId.zeroId(), zombieRef.getName()))
-            .collect(toImmutableList());
-    BatchRefUpdate bru = allUsersRepo.getRefDatabase().newBatchUpdate();
-    bru.setAtomic(true);
-    bru.addCommand(deleteCommands);
-    RefUpdateUtil.executeChecked(bru, allUsersRepo);
+    try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+      List<ReceiveCommand> deleteCommands =
+          refsBatch.stream()
+              .map(
+                  zombieRef ->
+                      new ReceiveCommand(
+                          zombieRef.getObjectId(), ObjectId.zeroId(), zombieRef.getName()))
+              .collect(toImmutableList());
+      BatchRefUpdate bru = allUsersRepo.getRefDatabase().newBatchUpdate();
+      bru.setAtomic(true);
+      bru.addCommand(deleteCommands);
+      RefUpdateUtil.executeChecked(bru, allUsersRepo);
+    }
   }
 
   private List<Ref> filterZombieRefs(Repository allUsersRepo, List<Ref> allDraftRefs)
diff --git a/java/com/google/gerrit/server/notedb/DraftCommentNotes.java b/java/com/google/gerrit/server/notedb/DraftCommentNotes.java
index 5d8f57f..bdfe378 100644
--- a/java/com/google/gerrit/server/notedb/DraftCommentNotes.java
+++ b/java/com/google/gerrit/server/notedb/DraftCommentNotes.java
@@ -141,6 +141,7 @@
     return args.allUsers;
   }
 
+  @Nullable
   @VisibleForTesting
   NoteMap getNoteMap() {
     return revisionNoteMap != null ? revisionNoteMap.noteMap : null;
diff --git a/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java b/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java
index ad1f4c5..0939ada 100644
--- a/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java
+++ b/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java
@@ -365,6 +365,7 @@
                 cu -> cu.getAttentionSetUpdates().stream()));
   }
 
+  @Nullable
   private BatchRefUpdate execute(OpenRepo or, boolean dryrun, @Nullable PushCertificate pushCert)
       throws IOException {
     if (or == null || or.cmds.isEmpty()) {
diff --git a/java/com/google/gerrit/server/notedb/NoteDbUtil.java b/java/com/google/gerrit/server/notedb/NoteDbUtil.java
index 3b467c4..b1a4447 100644
--- a/java/com/google/gerrit/server/notedb/NoteDbUtil.java
+++ b/java/com/google/gerrit/server/notedb/NoteDbUtil.java
@@ -18,6 +18,7 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.primitives.Ints;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.server.account.externalids.ExternalId;
@@ -117,6 +118,7 @@
    * Returns the name of the REST API handler that is in the stack trace of the caller of this
    * method.
    */
+  @Nullable
   static String guessRestApiHandler() {
     StackTraceElement[] trace = Thread.currentThread().getStackTrace();
     int i = findRestApiServlet(trace);
diff --git a/java/com/google/gerrit/server/notedb/RepoSequence.java b/java/com/google/gerrit/server/notedb/RepoSequence.java
index d743921..9aaac19 100644
--- a/java/com/google/gerrit/server/notedb/RepoSequence.java
+++ b/java/com/google/gerrit/server/notedb/RepoSequence.java
@@ -17,6 +17,7 @@
 import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.gerrit.entities.RefNames.REFS;
 import static com.google.gerrit.entities.RefNames.REFS_SEQUENCES;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.REPO_SEQ;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.Objects.requireNonNull;
 import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
@@ -39,6 +40,7 @@
 import com.google.gerrit.git.RefUpdateUtil;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.update.context.RefUpdateContext;
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.List;
@@ -265,29 +267,31 @@
    * @param count the number of sequence numbers which should be retrieved
    */
   private void acquire(int count) {
-    try (Repository repo = repoManager.openRepository(projectName);
-        RevWalk rw = new RevWalk(repo)) {
-      logger.atFine().log("acquire %d ids on %s in %s", count, refName, projectName);
-      Optional<IntBlob> blob = IntBlob.parse(repo, refName, rw);
-      afterReadRef.run();
-      ObjectId oldId;
-      int next;
-      if (!blob.isPresent()) {
-        oldId = ObjectId.zeroId();
-        next = seed.get();
-      } else {
-        oldId = blob.get().id();
-        next = blob.get().value();
+    try (RefUpdateContext ctx = RefUpdateContext.open(REPO_SEQ)) {
+      try (Repository repo = repoManager.openRepository(projectName);
+          RevWalk rw = new RevWalk(repo)) {
+        logger.atFine().log("acquire %d ids on %s in %s", count, refName, projectName);
+        Optional<IntBlob> blob = IntBlob.parse(repo, refName, rw);
+        afterReadRef.run();
+        ObjectId oldId;
+        int next;
+        if (!blob.isPresent()) {
+          oldId = ObjectId.zeroId();
+          next = seed.get();
+        } else {
+          oldId = blob.get().id();
+          next = blob.get().value();
+        }
+        next = Math.max(floor, next);
+        RefUpdate refUpdate =
+            IntBlob.tryStore(repo, rw, projectName, refName, oldId, next + count, gitRefUpdated);
+        RefUpdateUtil.checkResult(refUpdate);
+        counter = next;
+        limit = counter + count;
+        acquireCount++;
+      } catch (IOException e) {
+        throw new StorageException(e);
       }
-      next = Math.max(floor, next);
-      RefUpdate refUpdate =
-          IntBlob.tryStore(repo, rw, projectName, refName, oldId, next + count, gitRefUpdated);
-      RefUpdateUtil.checkResult(refUpdate);
-      counter = next;
-      limit = counter + count;
-      acquireCount++;
-    } catch (IOException e) {
-      throw new StorageException(e);
     }
   }
 
diff --git a/java/com/google/gerrit/server/notedb/RobotCommentUpdate.java b/java/com/google/gerrit/server/notedb/RobotCommentUpdate.java
index 7f067f5..e1e6305 100644
--- a/java/com/google/gerrit/server/notedb/RobotCommentUpdate.java
+++ b/java/com/google/gerrit/server/notedb/RobotCommentUpdate.java
@@ -19,6 +19,7 @@
 import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
 
 import com.google.common.collect.Sets;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Project;
@@ -100,6 +101,7 @@
     put.add(c);
   }
 
+  @Nullable
   private CommitBuilder storeCommentsInNotes(
       RevWalk rw, ObjectInserter ins, ObjectId curr, CommitBuilder cb)
       throws ConfigInvalidException, IOException {
diff --git a/java/com/google/gerrit/server/patch/AutoMerger.java b/java/com/google/gerrit/server/patch/AutoMerger.java
index 1f4720d..0818f23 100644
--- a/java/com/google/gerrit/server/patch/AutoMerger.java
+++ b/java/com/google/gerrit/server/patch/AutoMerger.java
@@ -164,10 +164,14 @@
   public Optional<ReceiveCommand> createAutoMergeCommitIfNecessary(
       RepoView repoView, RevWalk rw, ObjectInserter ins, RevCommit maybeMergeCommit)
       throws IOException {
-    if (maybeMergeCommit.getParentCount() != 2 || !save) {
+    if (maybeMergeCommit.getParentCount() != 2) {
       logger.atFine().log("AutoMerge not required");
       return Optional.empty();
     }
+    if (!save) {
+      logger.atFine().log("Saving AutoMerge is disabled");
+      return Optional.empty();
+    }
 
     String automergeRef = RefNames.refsCacheAutomerge(maybeMergeCommit.name());
     logger.atFine().log("AutoMerge ref=%s, mergeCommit=%s", automergeRef, maybeMergeCommit.name());
diff --git a/java/com/google/gerrit/server/patch/BaseCommitUtil.java b/java/com/google/gerrit/server/patch/BaseCommitUtil.java
index 56a01b9..a264793 100644
--- a/java/com/google/gerrit/server/patch/BaseCommitUtil.java
+++ b/java/com/google/gerrit/server/patch/BaseCommitUtil.java
@@ -26,13 +26,11 @@
 import java.io.IOException;
 import java.util.Optional;
 import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.ObjectReader;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevObject;
 import org.eclipse.jgit.revwalk.RevWalk;
 
 /** A utility class for computing the base commit / parent for a specific patchset commit. */
@@ -51,7 +49,8 @@
     this.repoManager = repoManager;
   }
 
-  RevObject getBaseCommit(Project.NameKey project, ObjectId newCommit, @Nullable Integer parentNum)
+  @Nullable
+  RevCommit getBaseCommit(Project.NameKey project, ObjectId newCommit, @Nullable Integer parentNum)
       throws IOException {
     try (Repository repo = repoManager.openRepository(project);
         ObjectInserter ins = newInserter(repo);
@@ -89,9 +88,12 @@
    *     commitId} has a single parent, it will be returned.
    * @param commitId 20 bytes commitId SHA-1 hash.
    * @return Returns the parent commit of the commit represented by the commitId parameter. Note
-   *     that auto-merge is not supported for commits having more than two parents.
+   *     that auto-merge is not supported for commits having more than two parents. If the commit
+   *     has no parents (initial commit) or more than 2 parents {@code null} is returned as the
+   *     parent commit.
    */
-  RevObject getParentCommit(
+  @Nullable
+  RevCommit getParentCommit(
       Repository repo,
       ObjectInserter ins,
       RevWalk rw,
@@ -101,7 +103,7 @@
     RevCommit current = rw.parseCommit(commitId);
     switch (current.getParentCount()) {
       case 0:
-        return rw.parseAny(emptyTree(ins));
+        return null;
       case 1:
         return current.getParent(0);
       default:
@@ -145,10 +147,4 @@
   private ObjectInserter newInserter(Repository repo) {
     return saveAutomerge ? repo.newObjectInserter() : new InMemoryInserter(repo);
   }
-
-  private static ObjectId emptyTree(ObjectInserter ins) throws IOException {
-    ObjectId id = ins.insert(Constants.OBJ_TREE, new byte[] {});
-    ins.flush();
-    return id;
-  }
 }
diff --git a/java/com/google/gerrit/server/patch/DiffOperationsImpl.java b/java/com/google/gerrit/server/patch/DiffOperationsImpl.java
index b57ab60..4d0bcc8 100644
--- a/java/com/google/gerrit/server/patch/DiffOperationsImpl.java
+++ b/java/com/google/gerrit/server/patch/DiffOperationsImpl.java
@@ -55,6 +55,7 @@
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.util.io.DisabledOutputStream;
 
@@ -488,7 +489,16 @@
     DiffParameters.Builder result =
         DiffParameters.builder().project(project).newCommit(newCommit).parent(parent);
     if (parent > 0) {
-      result.baseCommit(baseCommitUtil.getBaseCommit(project, newCommit, parent));
+      RevCommit baseCommit = baseCommitUtil.getBaseCommit(project, newCommit, parent);
+      if (baseCommit == null) {
+        // The specified parent doesn't exist or is not supported, fall back to comparing against
+        // the root.
+        result.baseCommit(ObjectId.zeroId());
+        result.comparisonType(ComparisonType.againstRoot());
+        return result.build();
+      }
+
+      result.baseCommit(baseCommit);
       result.comparisonType(ComparisonType.againstParent(parent));
       return result.build();
     }
diff --git a/java/com/google/gerrit/server/patch/DiffUtil.java b/java/com/google/gerrit/server/patch/DiffUtil.java
index 70a3208..115830e 100644
--- a/java/com/google/gerrit/server/patch/DiffUtil.java
+++ b/java/com/google/gerrit/server/patch/DiffUtil.java
@@ -15,19 +15,29 @@
 
 package com.google.gerrit.server.patch;
 
+import com.google.common.base.Strings;
 import com.google.common.collect.ArrayListMultimap;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ListMultimap;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Patch.ChangeType;
+import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.server.patch.diff.ModifiedFilesCache;
 import com.google.gerrit.server.patch.gitdiff.GitModifiedFilesCache;
 import com.google.gerrit.server.patch.gitdiff.ModifiedFile;
 import java.io.IOException;
+import java.io.OutputStream;
 import java.util.Comparator;
 import java.util.List;
+import java.util.Optional;
+import org.eclipse.jgit.diff.DiffFormatter;
 import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevTree;
 import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.treewalk.filter.PathFilter;
 
 /**
  * A utility class used by the diff cache interfaces {@link GitModifiedFilesCache} and {@link
@@ -116,6 +126,81 @@
     return 0;
   }
 
+  /**
+   * Get formatted diff between the given commits, either for a single path if specified, or for the
+   * full trees.
+   *
+   * @param repo to get the diff from
+   * @param baseCommit to compare with
+   * @param childCommit to compare
+   * @param path to narrow the diff to
+   * @param out to append the diff to
+   * @throws IOException if the diff couldn't be written
+   */
+  public static void getFormattedDiff(
+      Repository repo,
+      RevCommit baseCommit,
+      RevCommit childCommit,
+      @Nullable String path,
+      OutputStream out)
+      throws IOException {
+    getFormattedDiff(repo, null, baseCommit.getTree(), childCommit.getTree(), path, out);
+  }
+
+  public static void getFormattedDiff(
+      Repository repo,
+      @Nullable ObjectReader reader,
+      RevTree baseTree,
+      RevTree childTree,
+      @Nullable String path,
+      OutputStream out)
+      throws IOException {
+    try (DiffFormatter fmt = new DiffFormatter(out)) {
+      fmt.setRepository(repo);
+      if (reader != null) {
+        fmt.setReader(reader, repo.getConfig());
+      }
+      if (path != null) {
+        fmt.setPathFilter(PathFilter.create(path));
+      }
+      fmt.format(baseTree, childTree);
+      fmt.flush();
+    }
+  }
+
+  public static String cleanPatch(final String patch) {
+    String res = removePatchHeader(patch);
+    return res
+        // Remove "index NN..NN" lines
+        .replaceAll("(?m)^index.*", "")
+        // Remove hunk-headers lines
+        .replaceAll("(?m)^@@ .*", "")
+        // Remove empty lines
+        .replaceAll("\n+", "\n")
+        // Trim
+        .trim();
+  }
+
+  public static String removePatchHeader(final String patch) {
+    String res = patch.trim();
+    if (!res.startsWith("diff --") && res.contains("\ndiff --")) {
+      return res.substring(patch.indexOf("\ndiff --"), patch.length() - 1);
+    }
+    return res;
+  }
+
+  public static Optional<String> getPatchHeader(final String patch) {
+    if (patch.startsWith("diff --")) {
+      return Optional.empty();
+    }
+    return Optional.ofNullable(
+        Strings.emptyToNull(patch.trim().substring(0, patch.indexOf("\ndiff --git"))));
+  }
+
+  public static String cleanPatch(BinaryResult bin) throws IOException {
+    return cleanPatch(bin.asString());
+  }
+
   private static boolean isRootOrMergeCommit(RevCommit commit) {
     return commit.getParentCount() != 1;
   }
diff --git a/java/com/google/gerrit/server/patch/FilePathAdapter.java b/java/com/google/gerrit/server/patch/FilePathAdapter.java
index 2c98f1a..d0b7ac6 100644
--- a/java/com/google/gerrit/server/patch/FilePathAdapter.java
+++ b/java/com/google/gerrit/server/patch/FilePathAdapter.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.patch;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Patch.ChangeType;
 import java.util.Optional;
 
@@ -30,6 +31,7 @@
   /**
    * Converts the old file path of the new diff cache output to the old diff cache representation.
    */
+  @Nullable
   public static String getOldPath(Optional<String> oldName, ChangeType changeType) {
     switch (changeType) {
       case DELETED:
diff --git a/java/com/google/gerrit/server/patch/IntraLineLoader.java b/java/com/google/gerrit/server/patch/IntraLineLoader.java
index d6afa88..3eb50d3 100644
--- a/java/com/google/gerrit/server/patch/IntraLineLoader.java
+++ b/java/com/google/gerrit/server/patch/IntraLineLoader.java
@@ -40,7 +40,7 @@
 import org.eclipse.jgit.diff.MyersDiff;
 import org.eclipse.jgit.lib.Config;
 
-class IntraLineLoader implements Callable<IntraLineDiff> {
+public class IntraLineLoader implements Callable<IntraLineDiff> {
   static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   interface Factory {
@@ -105,7 +105,7 @@
     }
   }
 
-  static IntraLineDiff compute(
+  public static IntraLineDiff compute(
       Text aText,
       Text bText,
       ImmutableList<Edit> immutableEdits,
diff --git a/java/com/google/gerrit/server/patch/PatchScriptBuilder.java b/java/com/google/gerrit/server/patch/PatchScriptBuilder.java
index 33300e3..8dff536 100644
--- a/java/com/google/gerrit/server/patch/PatchScriptBuilder.java
+++ b/java/com/google/gerrit/server/patch/PatchScriptBuilder.java
@@ -19,6 +19,7 @@
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.PatchScript;
 import com.google.gerrit.common.data.PatchScript.DisplayMethod;
 import com.google.gerrit.entities.FixReplacement;
@@ -121,7 +122,7 @@
     if (a.mode == FileMode.MISSING) {
       throw new ResourceNotFoundException(String.format("File %s not found", fileName));
     }
-    FixCalculator.FixResult fixResult = FixCalculator.calculateFix(a.src, fixReplacements);
+    FixCalculator.FixResult fixResult = FixCalculator.calculateFix(a.src, fixReplacements, true);
     PatchSide b =
         new PatchSide(
             null,
@@ -209,6 +210,7 @@
     }
   }
 
+  @Nullable
   private static String oldName(PatchFileChange entry) {
     switch (entry.getChangeType()) {
       case ADDED:
@@ -224,6 +226,7 @@
     }
   }
 
+  @Nullable
   private static String newName(PatchFileChange entry) {
     switch (entry.getChangeType()) {
       case DELETED:
@@ -412,6 +415,7 @@
           treeId, path, id, mode, srcContent, src, mimeType, displayMethod, fileMode);
     }
 
+    @Nullable
     private TreeWalk find(ObjectReader reader, String path, ObjectId within) throws IOException {
       if (path == null || within == null) {
         return null;
diff --git a/java/com/google/gerrit/server/patch/filediff/FileDiffCacheImpl.java b/java/com/google/gerrit/server/patch/filediff/FileDiffCacheImpl.java
index 3e4e72d..d1bda5c 100644
--- a/java/com/google/gerrit/server/patch/filediff/FileDiffCacheImpl.java
+++ b/java/com/google/gerrit/server/patch/filediff/FileDiffCacheImpl.java
@@ -97,7 +97,7 @@
         persist(DIFF, FileDiffCacheKey.class, FileDiffOutput.class)
             .maximumWeight(10 << 20)
             .weigher(FileDiffWeigher.class)
-            .version(8)
+            .version(9)
             .keySerializer(FileDiffCacheKey.Serializer.INSTANCE)
             .valueSerializer(FileDiffOutput.Serializer.INSTANCE)
             .loader(FileDiffLoader.class);
@@ -443,6 +443,8 @@
                 .patchType(mainGitDiff.patchType())
                 .oldPath(mainGitDiff.oldPath())
                 .newPath(mainGitDiff.newPath())
+                .oldMode(mainGitDiff.oldMode())
+                .newMode(mainGitDiff.newMode())
                 .headerLines(FileHeaderUtil.getHeaderLines(mainGitDiff.fileHeader()))
                 .edits(asTaggedEdits(mainGitDiff.edits(), rebaseEdits))
                 .size(newSize)
diff --git a/java/com/google/gerrit/server/patch/filediff/FileDiffOutput.java b/java/com/google/gerrit/server/patch/filediff/FileDiffOutput.java
index 31fe77a..9286f47 100644
--- a/java/com/google/gerrit/server/patch/filediff/FileDiffOutput.java
+++ b/java/com/google/gerrit/server/patch/filediff/FileDiffOutput.java
@@ -17,9 +17,12 @@
 import static com.google.gerrit.server.patch.DiffUtil.stringSize;
 
 import com.google.auto.value.AutoValue;
+import com.google.common.base.Converter;
+import com.google.common.base.Enums;
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.Patch.ChangeType;
+import com.google.gerrit.entities.Patch.FileMode;
 import com.google.gerrit.entities.Patch.PatchType;
 import com.google.gerrit.proto.Protos;
 import com.google.gerrit.server.cache.proto.Cache.FileDiffOutputProto;
@@ -61,6 +64,18 @@
    */
   public abstract Optional<String> newPath();
 
+  /**
+   * The file mode of the old file at the old git tree diff identified by {@link #oldCommitId()}
+   * ()}.
+   */
+  public abstract Optional<Patch.FileMode> oldMode();
+
+  /**
+   * The file mode of the new file at the new git tree diff identified by {@link #newCommitId()}
+   * ()}.
+   */
+  public abstract Optional<Patch.FileMode> newMode();
+
   /** The change type of the underlying file, e.g. added, deleted, renamed, etc... */
   public abstract Patch.ChangeType changeType();
 
@@ -201,6 +216,10 @@
 
     public abstract Builder newPath(Optional<String> value);
 
+    public abstract Builder oldMode(Optional<Patch.FileMode> oldMode);
+
+    public abstract Builder newMode(Optional<Patch.FileMode> newMode);
+
     public abstract Builder changeType(ChangeType value);
 
     public abstract Builder patchType(Optional<PatchType> value);
@@ -221,6 +240,9 @@
   public enum Serializer implements CacheSerializer<FileDiffOutput> {
     INSTANCE;
 
+    private static final Converter<String, FileMode> FILE_MODE_CONVERTER =
+        Enums.stringConverter(Patch.FileMode.class);
+
     private static final FieldDescriptor OLD_PATH_DESCRIPTOR =
         FileDiffOutputProto.getDescriptor().findFieldByNumber(1);
 
@@ -233,6 +255,12 @@
     private static final FieldDescriptor NEGATIVE_DESCRIPTOR =
         FileDiffOutputProto.getDescriptor().findFieldByNumber(12);
 
+    private static final FieldDescriptor OLD_MODE_DESCRIPTOR =
+        FileDiffOutputProto.getDescriptor().findFieldByNumber(13);
+
+    private static final FieldDescriptor NEW_MODE_DESCRIPTOR =
+        FileDiffOutputProto.getDescriptor().findFieldByNumber(14);
+
     @Override
     public byte[] serialize(FileDiffOutput fileDiff) {
       ObjectIdConverter idConverter = ObjectIdConverter.create();
@@ -277,6 +305,13 @@
         builder.setNegative(fileDiff.negative().get());
       }
 
+      if (fileDiff.oldMode().isPresent()) {
+        builder.setOldMode(FILE_MODE_CONVERTER.reverse().convert(fileDiff.oldMode().get()));
+      }
+      if (fileDiff.newMode().isPresent()) {
+        builder.setNewMode(FILE_MODE_CONVERTER.reverse().convert(fileDiff.newMode().get()));
+      }
+
       return Protos.toByteArray(builder.build());
     }
 
@@ -318,6 +353,12 @@
       if (proto.hasField(NEGATIVE_DESCRIPTOR)) {
         builder.negative(Optional.of(proto.getNegative()));
       }
+      if (proto.hasField(OLD_MODE_DESCRIPTOR)) {
+        builder.oldMode(Optional.of(FILE_MODE_CONVERTER.convert(proto.getOldMode())));
+      }
+      if (proto.hasField(NEW_MODE_DESCRIPTOR)) {
+        builder.newMode(Optional.of(FILE_MODE_CONVERTER.convert(proto.getNewMode())));
+      }
       return builder.build();
     }
   }
diff --git a/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCacheImpl.java b/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCacheImpl.java
index 0cfaa66..72dc434 100644
--- a/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCacheImpl.java
+++ b/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCacheImpl.java
@@ -28,6 +28,7 @@
 import com.google.common.collect.MultimapBuilder;
 import com.google.common.collect.Multimaps;
 import com.google.common.collect.Streams;
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
@@ -66,6 +67,7 @@
 import org.eclipse.jgit.diff.DiffFormatter;
 import org.eclipse.jgit.diff.HistogramDiff;
 import org.eclipse.jgit.diff.RawTextComparator;
+import org.eclipse.jgit.errors.MissingObjectException;
 import org.eclipse.jgit.lib.AbbreviatedObjectId;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
@@ -76,6 +78,7 @@
 /** Implementation of the {@link GitFileDiffCache} */
 @Singleton
 public class GitFileDiffCacheImpl implements GitFileDiffCache {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
   private static final String GIT_DIFF = "git_file_diff";
 
   public static Module module() {
@@ -340,8 +343,7 @@
         throws IOException {
       if (!key.useTimeout()) {
         try (CloseablePool<DiffFormatter>.Handle formatter = diffPool.get()) {
-          FileHeader fileHeader = formatter.get().toFileHeader(diffEntry);
-          return GitFileDiff.create(diffEntry, fileHeader);
+          return GitFileDiff.create(diffEntry, getFileHeader(formatter, diffEntry));
         }
       }
       // This submits the DiffFormatter to a different thread. The CloseablePool and our usage of it
@@ -353,7 +355,7 @@
           diffExecutor.submit(
               () -> {
                 try (CloseablePool<DiffFormatter>.Handle formatter = diffPool.get()) {
-                  return GitFileDiff.create(diffEntry, formatter.get().toFileHeader(diffEntry));
+                  return GitFileDiff.create(diffEntry, getFileHeader(formatter, diffEntry));
                 }
               });
       try {
@@ -385,6 +387,46 @@
           ? diffEntry.getOldPath()
           : diffEntry.getNewPath();
     }
+
+    private FileHeader getFileHeader(
+        CloseablePool<DiffFormatter>.Handle formatter, DiffEntry diffEntry) throws IOException {
+      logger.atFine().log("getting file header for %s", formatDiffEntryForLogging(diffEntry));
+      try {
+        return formatter.get().toFileHeader(diffEntry);
+      } catch (MissingObjectException e) {
+        throw new IOException(
+            String.format("Failed to get file header for %s", formatDiffEntryForLogging(diffEntry)),
+            e);
+      }
+    }
+
+    private String formatDiffEntryForLogging(DiffEntry diffEntry) {
+      StringBuilder buf = new StringBuilder();
+      buf.append("DiffEntry[");
+      buf.append(diffEntry.getChangeType());
+      buf.append(" ");
+      switch (diffEntry.getChangeType()) {
+        case ADD:
+          buf.append(String.format("%s (%s)", diffEntry.getNewPath(), diffEntry.getNewId().name()));
+          break;
+        case COPY:
+        case RENAME:
+          buf.append(
+              String.format(
+                  "%s (%s) -> %s (%s)",
+                  diffEntry.getOldPath(),
+                  diffEntry.getOldId().name(),
+                  diffEntry.getNewPath(),
+                  diffEntry.getNewId().name()));
+          break;
+        case DELETE:
+        case MODIFY:
+          buf.append(String.format("%s (%s)", diffEntry.getOldPath(), diffEntry.getOldId().name()));
+          break;
+      }
+      buf.append("]");
+      return buf.toString();
+    }
   }
 
   /**
diff --git a/java/com/google/gerrit/server/permissions/AbstractLabelPermission.java b/java/com/google/gerrit/server/permissions/AbstractLabelPermission.java
new file mode 100644
index 0000000..622f0cf
--- /dev/null
+++ b/java/com/google/gerrit/server/permissions/AbstractLabelPermission.java
@@ -0,0 +1,155 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.permissions;
+
+import static com.google.gerrit.server.permissions.AbstractLabelPermission.ForUser.ON_BEHALF_OF;
+import static java.util.Objects.requireNonNull;
+
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.server.util.LabelVote;
+
+/** Abstract permission representing a label. */
+public abstract class AbstractLabelPermission implements ChangePermissionOrLabel {
+  public enum ForUser {
+    SELF,
+    ON_BEHALF_OF
+  }
+
+  protected final ForUser forUser;
+  protected final String name;
+
+  /**
+   * Construct a reference to an abstract label permission.
+   *
+   * @param forUser {@code SELF} (default) or {@code ON_BEHALF_OF} for labelAs behavior.
+   * @param name name of the label, e.g. {@code "Code-Review"} or {@code "Verified"}.
+   */
+  public AbstractLabelPermission(ForUser forUser, String name) {
+    this.forUser = requireNonNull(forUser, "ForUser");
+    this.name = LabelType.checkName(name);
+  }
+
+  /** Returns {@code SELF} or {@code ON_BEHALF_OF} (or labelAs). */
+  public ForUser forUser() {
+    return forUser;
+  }
+
+  /** Returns name of the label, e.g. {@code "Code-Review"}. */
+  public String label() {
+    return name;
+  }
+
+  protected abstract String permissionPrefix();
+
+  protected String permissionName() {
+    if (forUser == ON_BEHALF_OF) {
+      return permissionPrefix() + "As";
+    }
+    return permissionPrefix();
+  }
+
+  @Override
+  public final String describeForException() {
+    if (forUser == ON_BEHALF_OF) {
+      return permissionPrefix() + " on behalf of " + name;
+    }
+    return permissionPrefix() + " " + name;
+  }
+
+  @Override
+  public final int hashCode() {
+    return (permissionPrefix() + name).hashCode();
+  }
+
+  @Override
+  @SuppressWarnings("EqualsGetClass")
+  public final boolean equals(Object other) {
+    if (this.getClass().isAssignableFrom(other.getClass())) {
+      AbstractLabelPermission b = (AbstractLabelPermission) other;
+      return forUser == b.forUser && name.equals(b.name);
+    }
+    return false;
+  }
+
+  @Override
+  public final String toString() {
+    return permissionName() + "[" + name + ']';
+  }
+
+  /** A {@link AbstractLabelPermission} at a specific value. */
+  public abstract static class WithValue implements ChangePermissionOrLabel {
+    private final ForUser forUser;
+    private final LabelVote label;
+
+    /**
+     * Construct a reference to an abstract label permission at a specific value.
+     *
+     * @param forUser {@code SELF} (default) or {@code ON_BEHALF_OF} for labelAs behavior.
+     * @param label label name and vote.
+     */
+    public WithValue(ForUser forUser, LabelVote label) {
+      this.forUser = requireNonNull(forUser, "ForUser");
+      this.label = requireNonNull(label, "LabelVote");
+    }
+
+    /** Returns {@code SELF} or {@code ON_BEHALF_OF} (or labelAs). */
+    public ForUser forUser() {
+      return forUser;
+    }
+
+    /** Returns name of the label, e.g. {@code "Code-Review"}. */
+    public String label() {
+      return label.label();
+    }
+
+    /** Returns specific value of the label, e.g. 1 or 2. */
+    public short value() {
+      return label.value();
+    }
+
+    public abstract String permissionName();
+
+    @Override
+    public final String describeForException() {
+      if (forUser == ON_BEHALF_OF) {
+        return permissionName() + " on behalf of " + label.formatWithEquals();
+      }
+      return permissionName() + " " + label.formatWithEquals();
+    }
+
+    @Override
+    public final int hashCode() {
+      return (permissionName() + label).hashCode();
+    }
+
+    @Override
+    @SuppressWarnings("EqualsGetClass")
+    public final boolean equals(Object other) {
+      if (this.getClass().isAssignableFrom(other.getClass())) {
+        AbstractLabelPermission.WithValue b = (AbstractLabelPermission.WithValue) other;
+        return forUser == b.forUser && label.equals(b.label);
+      }
+      return false;
+    }
+
+    @Override
+    public final String toString() {
+      if (forUser == ON_BEHALF_OF) {
+        return permissionName() + "As[" + label.format() + ']';
+      }
+      return permissionName() + "[" + label.format() + ']';
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/permissions/ChangeControl.java b/java/com/google/gerrit/server/permissions/ChangeControl.java
index cadb21c..db15da80 100644
--- a/java/com/google/gerrit/server/permissions/ChangeControl.java
+++ b/java/com/google/gerrit/server/permissions/ChangeControl.java
@@ -14,8 +14,8 @@
 
 package com.google.gerrit.server.permissions;
 
+import static com.google.gerrit.server.permissions.AbstractLabelPermission.ForUser.ON_BEHALF_OF;
 import static com.google.gerrit.server.permissions.DefaultPermissionMappings.labelPermissionName;
-import static com.google.gerrit.server.permissions.LabelPermission.ForUser.ON_BEHALF_OF;
 
 import com.google.common.collect.Maps;
 import com.google.common.collect.Sets;
@@ -84,6 +84,19 @@
         && refControl.asForRef().testOrFalse(RefPermission.CREATE_CHANGE);
   }
 
+  /**
+   * Can this user rebase this change on behalf of the uploader?
+   *
+   * <p>This only checks the permissions of the rebaser (aka the impersonating user).
+   *
+   * <p>In addition rebase on behalf of the uploader requires the uploader (aka the impersonated
+   * user) to have permissions to create the new patch set. These permissions need to be checked
+   * separately.
+   */
+  private boolean canRebaseOnBehalfOfUploader() {
+    return (isOwner() || refControl.canSubmit(isOwner()) || refControl.canRebase());
+  }
+
   /** Can this user restore this change? */
   private boolean canRestore() {
     // Anyone who can abandon the change can restore it, as long as they can create changes.
@@ -120,16 +133,6 @@
     return false;
   }
 
-  /** Is this user assigned to this change? */
-  private boolean isAssignee() {
-    Account.Id currentAssignee = getChange().getAssignee();
-    if (currentAssignee != null && getUser().isIdentifiedUser()) {
-      Account.Id id = getUser().getAccountId();
-      return id.equals(currentAssignee);
-    }
-    return false;
-  }
-
   /** Is this user a reviewer for the change? */
   private boolean isReviewer(ChangeData cd) {
     if (getUser().isIdentifiedUser()) {
@@ -171,13 +174,6 @@
     return false;
   }
 
-  private boolean canEditAssignee() {
-    return isOwner()
-        || getProjectControl().isOwner()
-        || refControl.canPerform(Permission.EDIT_ASSIGNEE)
-        || isAssignee();
-  }
-
   /** Can this user edit the hashtag name? */
   private boolean canEditHashtags() {
     return isOwner() // owner (aka creator) of the change can edit hashtags
@@ -216,7 +212,10 @@
     public void check(ChangePermissionOrLabel perm)
         throws AuthException, PermissionBackendException {
       if (!can(perm)) {
-        throw new AuthException(perm.describeForException() + " not permitted");
+        throw new AuthException(
+            perm.describeForException()
+                + " not permitted"
+                + perm.hintForException().map(hint -> " (" + hint + ")").orElse(""));
       }
     }
 
@@ -240,10 +239,10 @@
     private boolean can(ChangePermissionOrLabel perm) throws PermissionBackendException {
       if (perm instanceof ChangePermission) {
         return can((ChangePermission) perm);
-      } else if (perm instanceof LabelPermission) {
-        return can((LabelPermission) perm);
-      } else if (perm instanceof LabelPermission.WithValue) {
-        return can((LabelPermission.WithValue) perm);
+      } else if (perm instanceof AbstractLabelPermission) {
+        return can((AbstractLabelPermission) perm);
+      } else if (perm instanceof AbstractLabelPermission.WithValue) {
+        return can((AbstractLabelPermission.WithValue) perm);
       }
       throw new PermissionBackendException(perm + " unsupported");
     }
@@ -259,8 +258,6 @@
             return getProjectControl().isAdmin() || refControl.canDeleteChanges(isOwner());
           case ADD_PATCH_SET:
             return canAddPatchSet();
-          case EDIT_ASSIGNEE:
-            return canEditAssignee();
           case EDIT_DESCRIPTION:
             return canEditDescription();
           case EDIT_HASHTAGS:
@@ -269,6 +266,8 @@
             return canEditTopicName();
           case REBASE:
             return canRebase();
+          case REBASE_ON_BEHALF_OF_UPLOADER:
+            return canRebaseOnBehalfOfUploader();
           case RESTORE:
             return canRestore();
           case REVERT:
@@ -288,11 +287,11 @@
       throw new PermissionBackendException(perm + " unsupported");
     }
 
-    private boolean can(LabelPermission perm) {
+    private boolean can(AbstractLabelPermission perm) {
       return !label(labelPermissionName(perm)).isEmpty();
     }
 
-    private boolean can(LabelPermission.WithValue perm) {
+    private boolean can(AbstractLabelPermission.WithValue perm) {
       PermissionRange r = label(labelPermissionName(perm));
       if (perm.forUser() == ON_BEHALF_OF && r.isEmpty()) {
         return false;
diff --git a/java/com/google/gerrit/server/permissions/ChangePermission.java b/java/com/google/gerrit/server/permissions/ChangePermission.java
index 63b0378..7741adac 100644
--- a/java/com/google/gerrit/server/permissions/ChangePermission.java
+++ b/java/com/google/gerrit/server/permissions/ChangePermission.java
@@ -16,7 +16,9 @@
 
 import static java.util.Objects.requireNonNull;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.api.access.GerritPermission;
+import java.util.Optional;
 
 public enum ChangePermission implements ChangePermissionOrLabel {
   READ,
@@ -35,7 +37,6 @@
    * change is not locked by calling {@code PatchSetUtil.isPatchSetLocked}.
    */
   ABANDON,
-  EDIT_ASSIGNEE,
   EDIT_DESCRIPTION,
   EDIT_HASHTAGS,
   EDIT_TOPIC_NAME,
@@ -53,24 +54,53 @@
    * <p>Before checking this permission, the caller should first verify the current patch set of the
    * change is not locked by calling {@code PatchSetUtil.isPatchSetLocked}.
    */
-  REBASE,
+  REBASE(
+      /* description= */ null,
+      /* hint= */ "change owners and users with the 'Submit' or 'Rebase' permission can rebase"
+          + " if they have the 'Push' permission"),
+  /**
+   * Permission that is required for a user to rebase a change on behalf of the uploader.
+   *
+   * <p>This only covers the permissions of the rebaser (aka the impersonating user).
+   *
+   * <p>In addition rebase on behalf of the uploader requires the uploader (aka the impersonated
+   * user) to have permissions to create the new patch set. These permissions need to be checked
+   * separately.
+   */
+  REBASE_ON_BEHALF_OF_UPLOADER(
+      /* description= */ null,
+      /* hint= */ "change owners and users with the 'Submit' or 'Rebase' permission can rebase on"
+          + " behalf of the uploader"),
   REVERT,
   SUBMIT,
   SUBMIT_AS("submit on behalf of other users"),
   TOGGLE_WORK_IN_PROGRESS_STATE;
 
   private final String description;
+  private final String hint;
 
   ChangePermission() {
     this.description = null;
+    this.hint = null;
   }
 
   ChangePermission(String description) {
     this.description = requireNonNull(description);
+    this.hint = null;
+  }
+
+  ChangePermission(@Nullable String description, String hint) {
+    this.description = description;
+    this.hint = requireNonNull(hint);
   }
 
   @Override
   public String describeForException() {
     return description != null ? description : GerritPermission.describeEnumValue(this);
   }
+
+  @Override
+  public Optional<String> hintForException() {
+    return Optional.ofNullable(hint);
+  }
 }
diff --git a/java/com/google/gerrit/server/permissions/ChangePermissionOrLabel.java b/java/com/google/gerrit/server/permissions/ChangePermissionOrLabel.java
index 2824efd..9254158 100644
--- a/java/com/google/gerrit/server/permissions/ChangePermissionOrLabel.java
+++ b/java/com/google/gerrit/server/permissions/ChangePermissionOrLabel.java
@@ -15,6 +15,17 @@
 package com.google.gerrit.server.permissions;
 
 import com.google.gerrit.extensions.api.access.GerritPermission;
+import java.util.Optional;
 
-/** A {@link ChangePermission} or a {@link LabelPermission}. */
-public interface ChangePermissionOrLabel extends GerritPermission {}
+/** A {@link ChangePermission} or a {@link AbstractLabelPermission}. */
+public interface ChangePermissionOrLabel extends GerritPermission {
+  /**
+   * A hint that explains under which conditions this permission is permitted.
+   *
+   * <p>This is useful for permissions that are not directly assigned but are indirectly permitted
+   * by the user having other permissions or being the change owner.
+   */
+  default Optional<String> hintForException() {
+    return Optional.empty();
+  }
+}
diff --git a/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java b/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java
index bf4d05a..a4ee052 100644
--- a/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java
+++ b/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java
@@ -195,12 +195,17 @@
         case MODIFY_ACCOUNT:
         case READ_AS:
         case STREAM_EVENTS:
+        case VIEW_ACCESS:
         case VIEW_ALL_ACCOUNTS:
         case VIEW_CONNECTIONS:
         case VIEW_PLUGINS:
-        case VIEW_ACCESS:
           return has(globalPermissionName(perm)) || isAdmin();
 
+        case VIEW_SECONDARY_EMAILS:
+          return has(globalPermissionName(perm))
+              || has(globalPermissionName(GlobalPermission.MODIFY_ACCOUNT))
+              || isAdmin();
+
         case ACCESS_DATABASE:
         case RUN_AS:
           return has(globalPermissionName(perm));
diff --git a/java/com/google/gerrit/server/permissions/DefaultPermissionMappings.java b/java/com/google/gerrit/server/permissions/DefaultPermissionMappings.java
index 9d69d9b..958de1b 100644
--- a/java/com/google/gerrit/server/permissions/DefaultPermissionMappings.java
+++ b/java/com/google/gerrit/server/permissions/DefaultPermissionMappings.java
@@ -24,7 +24,7 @@
 import com.google.gerrit.extensions.api.access.GlobalOrPluginPermission;
 import com.google.gerrit.extensions.api.access.PluginPermission;
 import com.google.gerrit.extensions.api.access.PluginProjectPermission;
-import com.google.gerrit.server.permissions.LabelPermission.ForUser;
+import com.google.gerrit.server.permissions.AbstractLabelPermission.ForUser;
 import java.util.EnumSet;
 import java.util.Optional;
 import java.util.Set;
@@ -61,6 +61,7 @@
           .put(GlobalPermission.VIEW_CONNECTIONS, GlobalCapability.VIEW_CONNECTIONS)
           .put(GlobalPermission.VIEW_PLUGINS, GlobalCapability.VIEW_PLUGINS)
           .put(GlobalPermission.VIEW_QUEUE, GlobalCapability.VIEW_QUEUE)
+          .put(GlobalPermission.VIEW_SECONDARY_EMAILS, GlobalCapability.VIEW_SECONDARY_EMAILS)
           .build();
 
   static {
@@ -90,7 +91,6 @@
       ImmutableBiMap.<ChangePermission, String>builder()
           .put(ChangePermission.READ, Permission.READ)
           .put(ChangePermission.ABANDON, Permission.ABANDON)
-          .put(ChangePermission.EDIT_ASSIGNEE, Permission.EDIT_ASSIGNEE)
           .put(ChangePermission.EDIT_HASHTAGS, Permission.EDIT_HASHTAGS)
           .put(ChangePermission.EDIT_TOPIC_NAME, Permission.EDIT_TOPIC_NAME)
           .put(ChangePermission.REMOVE_REVIEWER, Permission.REMOVE_REVIEWER)
@@ -160,19 +160,29 @@
     return Optional.ofNullable(CHANGE_PERMISSIONS.inverse().get(permissionName));
   }
 
-  public static String labelPermissionName(LabelPermission labelPermission) {
-    if (labelPermission.forUser() == ForUser.ON_BEHALF_OF) {
-      return Permission.forLabelAs(labelPermission.label());
+  public static String labelPermissionName(AbstractLabelPermission labelPermission) {
+    if (labelPermission instanceof LabelPermission) {
+      if (labelPermission.forUser() == ForUser.ON_BEHALF_OF) {
+        return Permission.forLabelAs(labelPermission.label());
+      }
+      return Permission.forLabel(labelPermission.label());
+    } else if (labelPermission instanceof LabelRemovalPermission) {
+      return Permission.forRemoveLabel(labelPermission.label());
     }
-    return Permission.forLabel(labelPermission.label());
+    throw new IllegalStateException("invalid AbstractLabelPermission subtype");
   }
 
   // TODO(dborowitz): Can these share a common superinterface?
-  public static String labelPermissionName(LabelPermission.WithValue labelPermission) {
-    if (labelPermission.forUser() == ForUser.ON_BEHALF_OF) {
-      return Permission.forLabelAs(labelPermission.label());
+  public static String labelPermissionName(AbstractLabelPermission.WithValue labelPermission) {
+    if (labelPermission instanceof LabelPermission.WithValue) {
+      if (labelPermission.forUser() == ForUser.ON_BEHALF_OF) {
+        return Permission.forLabelAs(labelPermission.label());
+      }
+      return Permission.forLabel(labelPermission.label());
+    } else if (labelPermission instanceof LabelRemovalPermission.WithValue) {
+      return Permission.forRemoveLabel(labelPermission.label());
     }
-    return Permission.forLabel(labelPermission.label());
+    throw new IllegalStateException("invalid AbstractLabelPermission.WithValue subtype");
   }
 
   private DefaultPermissionMappings() {}
diff --git a/java/com/google/gerrit/server/permissions/GitVisibleChangeFilter.java b/java/com/google/gerrit/server/permissions/GitVisibleChangeFilter.java
index a23228f..640ea9a 100644
--- a/java/com/google/gerrit/server/permissions/GitVisibleChangeFilter.java
+++ b/java/com/google/gerrit/server/permissions/GitVisibleChangeFilter.java
@@ -14,8 +14,6 @@
 
 package com.google.gerrit.server.permissions;
 
-import static com.google.common.collect.ImmutableMap.toImmutableMap;
-
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.flogger.FluentLogger;
@@ -24,9 +22,9 @@
 import com.google.gerrit.server.git.ChangesByProjectCache;
 import com.google.gerrit.server.query.change.ChangeData;
 import java.io.IOException;
+import java.util.HashMap;
 import java.util.Objects;
 import java.util.Set;
-import java.util.function.Function;
 import java.util.stream.Stream;
 import org.eclipse.jgit.lib.Repository;
 
@@ -64,16 +62,18 @@
       ImmutableSet<Change.Id> changes) {
     Stream<ChangeData> changeDatas = Stream.empty();
     if (changes.size() < CHANGE_LIMIT_FOR_DIRECT_FILTERING) {
+      logger.atFine().log("Loading changes one by one for project %s", projectName);
       changeDatas = loadChangeDatasOneByOne(changes, changeDataFactory, projectName);
     } else {
+      logger.atFine().log("Loading changes from ChangesByProjectCache for project %s", projectName);
       try {
         changeDatas = changesByProjectCache.streamChangeDatas(projectName, repository);
       } catch (IOException e) {
         logger.atWarning().withCause(e).log("Unable to streamChangeDatas for %s", projectName);
       }
     }
-
-    return changeDatas
+    HashMap<Change.Id, ChangeData> result = new HashMap<>();
+    changeDatas
         .filter(cd -> changes.contains(cd.getId()))
         .filter(
             cd -> {
@@ -87,7 +87,16 @@
                 return false;
               }
             })
-        .collect(toImmutableMap(ChangeData::getId, Function.identity()));
+        .forEach(
+            cd -> {
+              if (result.containsKey(cd.getId())) {
+                logger.atWarning().log(
+                    "Duplicate change datas for the repo %s: [%s, %s]",
+                    projectName, cd, result.get(cd.getId()));
+              }
+              result.put(cd.getId(), cd);
+            });
+    return ImmutableMap.copyOf(result);
   }
 
   /** Get a stream of changes by loading them individually. */
diff --git a/java/com/google/gerrit/server/permissions/GlobalPermission.java b/java/com/google/gerrit/server/permissions/GlobalPermission.java
index c0b44e5..3429978 100644
--- a/java/com/google/gerrit/server/permissions/GlobalPermission.java
+++ b/java/com/google/gerrit/server/permissions/GlobalPermission.java
@@ -58,7 +58,8 @@
   VIEW_CACHES,
   VIEW_CONNECTIONS,
   VIEW_PLUGINS,
-  VIEW_QUEUE;
+  VIEW_QUEUE,
+  VIEW_SECONDARY_EMAILS;
 
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
diff --git a/java/com/google/gerrit/server/permissions/LabelPermission.java b/java/com/google/gerrit/server/permissions/LabelPermission.java
index c266caa..4652364 100644
--- a/java/com/google/gerrit/server/permissions/LabelPermission.java
+++ b/java/com/google/gerrit/server/permissions/LabelPermission.java
@@ -14,24 +14,14 @@
 
 package com.google.gerrit.server.permissions;
 
-import static com.google.gerrit.server.permissions.LabelPermission.ForUser.ON_BEHALF_OF;
-import static com.google.gerrit.server.permissions.LabelPermission.ForUser.SELF;
-import static java.util.Objects.requireNonNull;
+import static com.google.gerrit.server.permissions.AbstractLabelPermission.ForUser.SELF;
 
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.LabelValue;
 import com.google.gerrit.server.util.LabelVote;
 
 /** Permission representing a label. */
-public class LabelPermission implements ChangePermissionOrLabel {
-  public enum ForUser {
-    SELF,
-    ON_BEHALF_OF;
-  }
-
-  private final ForUser forUser;
-  private final String name;
-
+public class LabelPermission extends AbstractLabelPermission {
   /**
    * Construct a reference to a label permission.
    *
@@ -67,55 +57,16 @@
    * @param name name of the label, e.g. {@code "Code-Review"} or {@code "Verified"}.
    */
   public LabelPermission(ForUser forUser, String name) {
-    this.forUser = requireNonNull(forUser, "ForUser");
-    this.name = LabelType.checkName(name);
-  }
-
-  /** Returns {@code SELF} or {@code ON_BEHALF_OF} (or labelAs). */
-  public ForUser forUser() {
-    return forUser;
-  }
-
-  /** Returns name of the label, e.g. {@code "Code-Review"}. */
-  public String label() {
-    return name;
+    super(forUser, name);
   }
 
   @Override
-  public String describeForException() {
-    if (forUser == ON_BEHALF_OF) {
-      return "label on behalf of " + name;
-    }
-    return "label " + name;
-  }
-
-  @Override
-  public int hashCode() {
-    return name.hashCode();
-  }
-
-  @Override
-  public boolean equals(Object other) {
-    if (other instanceof LabelPermission) {
-      LabelPermission b = (LabelPermission) other;
-      return forUser == b.forUser && name.equals(b.name);
-    }
-    return false;
-  }
-
-  @Override
-  public String toString() {
-    if (forUser == ON_BEHALF_OF) {
-      return "LabelAs[" + name + ']';
-    }
-    return "Label[" + name + ']';
+  public String permissionPrefix() {
+    return "label";
   }
 
   /** A {@link LabelPermission} at a specific value. */
-  public static class WithValue implements ChangePermissionOrLabel {
-    private final ForUser forUser;
-    private final LabelVote label;
-
+  public static class WithValue extends AbstractLabelPermission.WithValue {
     /**
      * Construct a reference to a label at a specific value.
      *
@@ -195,53 +146,12 @@
      * @param label label name and vote.
      */
     public WithValue(ForUser forUser, LabelVote label) {
-      this.forUser = requireNonNull(forUser, "ForUser");
-      this.label = requireNonNull(label, "LabelVote");
-    }
-
-    /** Returns {@code SELF} or {@code ON_BEHALF_OF} (or labelAs). */
-    public ForUser forUser() {
-      return forUser;
-    }
-
-    /** Returns name of the label, e.g. {@code "Code-Review"}. */
-    public String label() {
-      return label.label();
-    }
-
-    /** Returns specific value of the label, e.g. 1 or 2. */
-    public short value() {
-      return label.value();
+      super(forUser, label);
     }
 
     @Override
-    public String describeForException() {
-      if (forUser == ON_BEHALF_OF) {
-        return "label on behalf of " + label.formatWithEquals();
-      }
-      return "label " + label.formatWithEquals();
-    }
-
-    @Override
-    public int hashCode() {
-      return label.hashCode();
-    }
-
-    @Override
-    public boolean equals(Object other) {
-      if (other instanceof WithValue) {
-        WithValue b = (WithValue) other;
-        return forUser == b.forUser && label.equals(b.label);
-      }
-      return false;
-    }
-
-    @Override
-    public String toString() {
-      if (forUser == ON_BEHALF_OF) {
-        return "LabelAs[" + label.format() + ']';
-      }
-      return "Label[" + label.format() + ']';
+    public String permissionName() {
+      return "label";
     }
   }
 }
diff --git a/java/com/google/gerrit/server/permissions/LabelRemovalPermission.java b/java/com/google/gerrit/server/permissions/LabelRemovalPermission.java
new file mode 100644
index 0000000..2553601
--- /dev/null
+++ b/java/com/google/gerrit/server/permissions/LabelRemovalPermission.java
@@ -0,0 +1,94 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.permissions;
+
+import static com.google.gerrit.server.permissions.AbstractLabelPermission.ForUser.SELF;
+
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.LabelValue;
+import com.google.gerrit.server.util.LabelVote;
+
+/** Permission representing a label removal. */
+public class LabelRemovalPermission extends AbstractLabelPermission {
+  /**
+   * Construct a reference to a label removal permission.
+   *
+   * @param type type description of the label.
+   */
+  public LabelRemovalPermission(LabelType type) {
+    this(type.getName());
+  }
+
+  /**
+   * Construct a reference to a label removal permission.
+   *
+   * @param name name of the label, e.g. {@code "Code-Review"} or {@code "Verified"}.
+   */
+  public LabelRemovalPermission(String name) {
+    super(SELF, name);
+  }
+
+  @Override
+  public String permissionPrefix() {
+    return "removeLabel";
+  }
+
+  /** A {@link LabelRemovalPermission} at a specific value. */
+  public static class WithValue extends AbstractLabelPermission.WithValue {
+    /**
+     * Construct a reference to a label removal at a specific value.
+     *
+     * @param type description of the label.
+     * @param value numeric score assigned to the label.
+     */
+    public WithValue(LabelType type, LabelValue value) {
+      this(type.getName(), value.getValue());
+    }
+
+    /**
+     * Construct a reference to a label removal at a specific value.
+     *
+     * @param type description of the label.
+     * @param value numeric score assigned to the label.
+     */
+    public WithValue(LabelType type, short value) {
+      this(type.getName(), value);
+    }
+
+    /**
+     * Construct a reference to a label removal at a specific value.
+     *
+     * @param name name of the label, e.g. {@code "Code-Review"} or {@code "Verified"}.
+     * @param value numeric score assigned to the label.
+     */
+    public WithValue(String name, short value) {
+      this(LabelVote.create(name, value));
+    }
+
+    /**
+     * Construct a reference to a label removal at a specific value.
+     *
+     * @param label label name and vote.
+     */
+    public WithValue(LabelVote label) {
+      super(SELF, label);
+    }
+
+    @Override
+    public String permissionName() {
+      return "removeLabel";
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/permissions/PermissionBackend.java b/java/com/google/gerrit/server/permissions/PermissionBackend.java
index 8c731f6..ac9ac98 100644
--- a/java/com/google/gerrit/server/permissions/PermissionBackend.java
+++ b/java/com/google/gerrit/server/permissions/PermissionBackend.java
@@ -474,6 +474,18 @@
     }
 
     /**
+     * Test which values of a label the user may be able to remove.
+     *
+     * @param label definition of the label to test values of.
+     * @return set containing values the user may be able to use; may be empty if none.
+     * @throws PermissionBackendException if failure consulting backend configuration.
+     */
+    public Set<LabelRemovalPermission.WithValue> testRemoval(LabelType label)
+        throws PermissionBackendException {
+      return test(removalValuesOf(requireNonNull(label, "LabelType")));
+    }
+
+    /**
      * Test which values of a group of labels the user may be able to set.
      *
      * @param types definition of the labels to test values of.
@@ -486,10 +498,29 @@
       return test(types.stream().flatMap(t -> valuesOf(t).stream()).collect(toSet()));
     }
 
+    /**
+     * Test which values of a group of labels the user may be able to remove.
+     *
+     * @param types definition of the labels to test values of.
+     * @return set containing values the user may be able to use; may be empty if none.
+     * @throws PermissionBackendException if failure consulting backend configuration.
+     */
+    public Set<LabelRemovalPermission.WithValue> testLabelRemovals(Collection<LabelType> types)
+        throws PermissionBackendException {
+      requireNonNull(types, "LabelType");
+      return test(types.stream().flatMap(t -> removalValuesOf(t).stream()).collect(toSet()));
+    }
+
     private static Set<LabelPermission.WithValue> valuesOf(LabelType label) {
       return label.getValues().stream()
           .map(v -> new LabelPermission.WithValue(label, v))
           .collect(toSet());
     }
+
+    private static Set<LabelRemovalPermission.WithValue> removalValuesOf(LabelType label) {
+      return label.getValues().stream()
+          .map(v -> new LabelRemovalPermission.WithValue(label, v))
+          .collect(toSet());
+    }
   }
 }
diff --git a/java/com/google/gerrit/server/permissions/ProjectControl.java b/java/com/google/gerrit/server/permissions/ProjectControl.java
index 7f2e62b..fab894e 100644
--- a/java/com/google/gerrit/server/permissions/ProjectControl.java
+++ b/java/com/google/gerrit/server/permissions/ProjectControl.java
@@ -21,6 +21,7 @@
 import static com.google.gerrit.server.util.MagicBranch.NEW_CHANGE;
 
 import com.google.common.collect.Sets;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.AccessSection;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.BranchNameKey;
@@ -269,6 +270,7 @@
     return false;
   }
 
+  @Nullable
   private Boolean canPerform(String permissionName, AccessSection section, Permission permission) {
     for (PermissionRule rule : permission.getRules()) {
       if (rule.isBlock() || rule.isDeny() || !match(rule)) {
diff --git a/java/com/google/gerrit/server/permissions/RefControl.java b/java/com/google/gerrit/server/permissions/RefControl.java
index 1a3741b..7f9692b 100644
--- a/java/com/google/gerrit/server/permissions/RefControl.java
+++ b/java/com/google/gerrit/server/permissions/RefControl.java
@@ -18,6 +18,7 @@
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.PermissionRange;
@@ -177,6 +178,7 @@
   }
 
   /** The range of permitted values associated with a label permission. */
+  @Nullable
   PermissionRange getRange(String permission, boolean isChangeOwner) {
     if (Permission.hasRange(permission)) {
       return toRange(permission, isChangeOwner);
diff --git a/java/com/google/gerrit/server/plugins/JarScanner.java b/java/com/google/gerrit/server/plugins/JarScanner.java
index e119bf1..122e3f4 100644
--- a/java/com/google/gerrit/server/plugins/JarScanner.java
+++ b/java/com/google/gerrit/server/plugins/JarScanner.java
@@ -24,6 +24,7 @@
 import com.google.common.collect.Maps;
 import com.google.common.collect.MultimapBuilder;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import java.io.IOException;
 import java.io.InputStream;
 import java.lang.annotation.Annotation;
@@ -212,6 +213,7 @@
       this.superName = superName;
     }
 
+    @Nullable
     @Override
     public AnnotationVisitor visitAnnotation(String desc, boolean visible) {
       if (!visible) {
diff --git a/java/com/google/gerrit/server/plugins/PluginLoader.java b/java/com/google/gerrit/server/plugins/PluginLoader.java
index 8d17d85..3263636 100644
--- a/java/com/google/gerrit/server/plugins/PluginLoader.java
+++ b/java/com/google/gerrit/server/plugins/PluginLoader.java
@@ -27,6 +27,7 @@
 import com.google.common.collect.SetMultimap;
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.events.LifecycleListener;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.systemstatus.ServerInformation;
@@ -713,6 +714,7 @@
     return Iterables.filter(paths, p -> !p.getFileName().toString().endsWith(".disabled"));
   }
 
+  @Nullable
   public String getGerritPluginName(Path srcPath) {
     String fileName = srcPath.getFileName().toString();
     if (isUiPlugin(fileName)) {
diff --git a/java/com/google/gerrit/server/plugins/ServerPlugin.java b/java/com/google/gerrit/server/plugins/ServerPlugin.java
index 320b618..af948b0 100644
--- a/java/com/google/gerrit/server/plugins/ServerPlugin.java
+++ b/java/com/google/gerrit/server/plugins/ServerPlugin.java
@@ -110,6 +110,7 @@
     }
   }
 
+  @Nullable
   @SuppressWarnings("unchecked")
   protected static Class<? extends Module> load(@Nullable String name, ClassLoader pluginLoader)
       throws ClassNotFoundException {
diff --git a/java/com/google/gerrit/server/plugins/ServerPluginInfoModule.java b/java/com/google/gerrit/server/plugins/ServerPluginInfoModule.java
index 60dff84..e91f7b7 100644
--- a/java/com/google/gerrit/server/plugins/ServerPluginInfoModule.java
+++ b/java/com/google/gerrit/server/plugins/ServerPluginInfoModule.java
@@ -72,13 +72,15 @@
     if (!ready) {
       synchronized (dataDir) {
         if (!ready) {
-          try {
-            Files.createDirectories(dataDir);
-          } catch (IOException e) {
-            throw new ProvisionException(
-                String.format(
-                    "Cannot create %s for plugin %s", dataDir.toAbsolutePath(), plugin.getName()),
-                e);
+          if (!Files.isDirectory(dataDir)) {
+            try {
+              Files.createDirectories(dataDir);
+            } catch (IOException e) {
+              throw new ProvisionException(
+                  String.format(
+                      "Cannot create %s for plugin %s", dataDir.toAbsolutePath(), plugin.getName()),
+                  e);
+            }
           }
           ready = true;
         }
diff --git a/java/com/google/gerrit/server/project/BooleanProjectConfigTransformations.java b/java/com/google/gerrit/server/project/BooleanProjectConfigTransformations.java
index ae9828a..9ccbf90 100644
--- a/java/com/google/gerrit/server/project/BooleanProjectConfigTransformations.java
+++ b/java/com/google/gerrit/server/project/BooleanProjectConfigTransformations.java
@@ -71,6 +71,11 @@
           .put(
               BooleanProjectConfig.WORK_IN_PROGRESS_BY_DEFAULT,
               new Mapper(i -> i.workInProgressByDefault, (i, v) -> i.workInProgressByDefault = v))
+          .put(
+              BooleanProjectConfig.SKIP_ADDING_AUTHOR_AND_COMMITTER_AS_REVIEWERS,
+              new Mapper(
+                  i -> i.skipAddingAuthorAndCommitterAsReviewers,
+                  (i, v) -> i.skipAddingAuthorAndCommitterAsReviewers = v))
           .build();
 
   static {
diff --git a/java/com/google/gerrit/server/project/CommentLinkProvider.java b/java/com/google/gerrit/server/project/CommentLinkProvider.java
index c2ac68a..88f045e 100644
--- a/java/com/google/gerrit/server/project/CommentLinkProvider.java
+++ b/java/com/google/gerrit/server/project/CommentLinkProvider.java
@@ -48,7 +48,7 @@
         ImmutableList.builderWithExpectedSize(subsections.size());
     for (String name : subsections) {
       try {
-        StoredCommentLinkInfo cl = ProjectConfig.buildCommentLink(cfg, name, true);
+        StoredCommentLinkInfo cl = ProjectConfig.buildCommentLink(cfg, name);
         if (cl.getOverrideOnly()) {
           logger.atWarning().log("commentlink %s empty except for \"enabled\"", name);
           continue;
diff --git a/java/com/google/gerrit/server/project/CreateProjectArgs.java b/java/com/google/gerrit/server/project/CreateProjectArgs.java
index c1b7b86..8da0510 100644
--- a/java/com/google/gerrit/server/project/CreateProjectArgs.java
+++ b/java/com/google/gerrit/server/project/CreateProjectArgs.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.project;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.client.InheritableBoolean;
@@ -48,7 +49,7 @@
     newChangeForAllNotInTarget = InheritableBoolean.INHERIT;
     enableSignedPush = InheritableBoolean.INHERIT;
     requireSignedPush = InheritableBoolean.INHERIT;
-    submitType = SubmitType.MERGE_IF_NECESSARY;
+    submitType = SubmitType.INHERIT;
     rejectEmptyCommit = InheritableBoolean.INHERIT;
   }
 
@@ -56,6 +57,7 @@
     return projectName;
   }
 
+  @Nullable
   public String getProjectName() {
     return projectName != null ? projectName.get() : null;
   }
diff --git a/java/com/google/gerrit/server/project/DeleteVoteControl.java b/java/com/google/gerrit/server/project/DeleteVoteControl.java
new file mode 100644
index 0000000..3f3f88a
--- /dev/null
+++ b/java/com/google/gerrit/server/project/DeleteVoteControl.java
@@ -0,0 +1,81 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.project;
+
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.LabelRemovalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.RefPermission;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.inject.Inject;
+import java.util.Set;
+
+public class DeleteVoteControl {
+  private final PermissionBackend permissionBackend;
+  private final ChangeData.Factory changeDataFactory;
+
+  @Inject
+  public DeleteVoteControl(
+      PermissionBackend permissionBackend, ChangeData.Factory changeDataFactory) {
+    this.permissionBackend = permissionBackend;
+    this.changeDataFactory = changeDataFactory;
+  }
+
+  public boolean testDeleteVotePermissions(
+      CurrentUser user, ChangeNotes notes, PatchSetApproval approval, LabelType labelType)
+      throws PermissionBackendException {
+    return testDeleteVotePermissions(user, changeDataFactory.create(notes), approval, labelType);
+  }
+
+  public boolean testDeleteVotePermissions(
+      CurrentUser user, ChangeData cd, PatchSetApproval approval, LabelType labelType)
+      throws PermissionBackendException {
+    if (canRemoveReviewerWithoutRemoveLabelPermission(
+        cd.change(), user, approval.accountId(), approval.value())) {
+      return true;
+    }
+    // Test if the user is allowed to remove vote of the given label type and value.
+    Set<LabelRemovalPermission.WithValue> allowed =
+        permissionBackend.user(user).change(cd).testRemoval(labelType);
+    return allowed.contains(new LabelRemovalPermission.WithValue(labelType, approval.value()));
+  }
+
+  private boolean canRemoveReviewerWithoutRemoveLabelPermission(
+      Change change, CurrentUser user, Account.Id reviewer, int value)
+      throws PermissionBackendException {
+    if (user.isIdentifiedUser()) {
+      Account.Id aId = user.getAccountId();
+      if (aId.equals(reviewer)) {
+        return true; // A user can always remove their own votes.
+      } else if (aId.equals(change.getOwner()) && 0 <= value) {
+        return true; // The change owner may remove any zero or positive score.
+      }
+    }
+
+    // Users with the remove reviewer permission, the branch owner, project
+    // owner and site admin can remove anyone
+    PermissionBackend.WithUser withUser = permissionBackend.user(user);
+    PermissionBackend.ForProject forProject = withUser.project(change.getProject());
+    return forProject.ref(change.getDest().branch()).test(RefPermission.WRITE_CONFIG)
+        || withUser.test(GlobalPermission.ADMINISTRATE_SERVER);
+  }
+}
diff --git a/java/com/google/gerrit/server/project/GroupList.java b/java/com/google/gerrit/server/project/GroupList.java
index 98dc44a..1b0ba97 100644
--- a/java/com/google/gerrit/server/project/GroupList.java
+++ b/java/com/google/gerrit/server/project/GroupList.java
@@ -126,6 +126,7 @@
     byUUID.put(uuid, reference);
   }
 
+  @Nullable
   public String asText() {
     if (byUUID.isEmpty()) {
       return null;
diff --git a/java/com/google/gerrit/server/project/LabelDefinitionJson.java b/java/com/google/gerrit/server/project/LabelDefinitionJson.java
index 235eb34..f46c2b1 100644
--- a/java/com/google/gerrit/server/project/LabelDefinitionJson.java
+++ b/java/com/google/gerrit/server/project/LabelDefinitionJson.java
@@ -16,6 +16,7 @@
 
 import static java.util.stream.Collectors.toMap;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.LabelValue;
 import com.google.gerrit.entities.Project;
@@ -39,8 +40,9 @@
     return label;
   }
 
+  @Nullable
   private static Boolean toBoolean(boolean v) {
-    return v ? v : null;
+    return v ? Boolean.TRUE : null;
   }
 
   private LabelDefinitionJson() {}
diff --git a/java/com/google/gerrit/server/project/ProjectCacheImpl.java b/java/com/google/gerrit/server/project/ProjectCacheImpl.java
index 67c031e..6498d1b 100644
--- a/java/com/google/gerrit/server/project/ProjectCacheImpl.java
+++ b/java/com/google/gerrit/server/project/ProjectCacheImpl.java
@@ -24,6 +24,7 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.ImmutableSortedSet;
 import com.google.common.collect.Sets;
+import com.google.common.collect.Streams;
 import com.google.common.flogger.FluentLogger;
 import com.google.common.hash.Hashing;
 import com.google.common.util.concurrent.Futures;
@@ -54,6 +55,7 @@
 import com.google.gerrit.server.config.AllProjectsConfigProvider;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.logging.Metadata;
 import com.google.gerrit.server.logging.TraceContext;
@@ -67,6 +69,7 @@
 import com.google.protobuf.ByteString;
 import java.io.IOException;
 import java.time.Duration;
+import java.util.Arrays;
 import java.util.Objects;
 import java.util.Optional;
 import java.util.Set;
@@ -75,6 +78,7 @@
 import java.util.concurrent.locks.ReentrantLock;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
@@ -158,6 +162,7 @@
     };
   }
 
+  private final Config config;
   private final AllProjectsName allProjectsName;
   private final AllUsersName allUsersName;
   private final LoadingCache<Project.NameKey, CachedProjectConfig> inMemoryProjectCache;
@@ -169,13 +174,15 @@
 
   @Inject
   ProjectCacheImpl(
-      final AllProjectsName allProjectsName,
-      final AllUsersName allUsersName,
+      @GerritServerConfig Config config,
+      AllProjectsName allProjectsName,
+      AllUsersName allUsersName,
       @Named(CACHE_NAME) LoadingCache<Project.NameKey, CachedProjectConfig> inMemoryProjectCache,
       @Named(CACHE_LIST) LoadingCache<ListKey, ImmutableSortedSet<Project.NameKey>> list,
       Provider<ProjectIndexer> indexer,
       MetricMaker metricMaker,
       ProjectState.Factory projectStateFactory) {
+    this.config = config;
     this.allProjectsName = allProjectsName;
     this.allUsersName = allUsersName;
     this.inMemoryProjectCache = inMemoryProjectCache;
@@ -293,14 +300,21 @@
   @Override
   public Set<AccountGroup.UUID> guessRelevantGroupUUIDs() {
     try (Timer0.Context ignored = guessRelevantGroupsLatency.start()) {
-      return all().stream()
-          .map(n -> inMemoryProjectCache.getIfPresent(n))
-          .filter(Objects::nonNull)
-          .flatMap(p -> p.getAllGroupUUIDs().stream())
-          // getAllGroupUUIDs shouldn't really return null UUIDs, but harden
-          // against them just in case there is a bug or corner case.
-          .filter(id -> id != null && id.get() != null)
-          .collect(toSet());
+      Set<AccountGroup.UUID> relevantGroupUuids =
+          Streams.concat(
+                  Arrays.stream(
+                          config.getStringList("groups", /* subsection= */ null, "relevantGroup"))
+                      .map(AccountGroup::uuid),
+                  all().stream()
+                      .map(n -> inMemoryProjectCache.getIfPresent(n))
+                      .filter(Objects::nonNull)
+                      .flatMap(p -> p.getAllGroupUUIDs().stream())
+                      // getAllGroupUUIDs shouldn't really return null UUIDs, but harden
+                      // against them just in case there is a bug or corner case.
+                      .filter(id -> id != null && id.get() != null))
+              .collect(toSet());
+      logger.atFine().log("relevant group UUIDs: %s", relevantGroupUuids);
+      return relevantGroupUuids;
     }
   }
 
diff --git a/java/com/google/gerrit/server/project/ProjectConfig.java b/java/com/google/gerrit/server/project/ProjectConfig.java
index 47b0a53..6c8087e0 100644
--- a/java/com/google/gerrit/server/project/ProjectConfig.java
+++ b/java/com/google/gerrit/server/project/ProjectConfig.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.server.project;
 
-import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.gerrit.entities.Permission.isPermission;
@@ -104,6 +103,8 @@
 public class ProjectConfig extends VersionedMetaData implements ValidationError.Sink {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
+  public static final String RULES_PL_FILE = "rules.pl";
+
   public static final String COMMENTLINK = "commentlink";
   public static final String LABEL = "label";
   public static final String KEY_LABEL_DESCRIPTION = "description";
@@ -131,7 +132,6 @@
           KEY_SR_OVERRIDE_IN_CHILD_PROJECTS);
 
   public static final String KEY_MATCH = "match";
-  private static final String KEY_HTML = "html";
   public static final String KEY_LINK = "link";
   public static final String KEY_PREFIX = "prefix";
   public static final String KEY_SUFFIX = "suffix";
@@ -318,7 +318,7 @@
     return builder.build();
   }
 
-  public static StoredCommentLinkInfo buildCommentLink(Config cfg, String name, boolean allowRaw)
+  public static StoredCommentLinkInfo buildCommentLink(Config cfg, String name)
       throws IllegalArgumentException {
     String match = cfg.getString(COMMENTLINK, name, KEY_MATCH);
     if (match != null) {
@@ -335,9 +335,6 @@
     String linkSuffix = cfg.getString(COMMENTLINK, name, KEY_SUFFIX);
     String linkText = cfg.getString(COMMENTLINK, name, KEY_TEXT);
 
-    String html = cfg.getString(COMMENTLINK, name, KEY_HTML);
-    boolean hasHtml = !Strings.isNullOrEmpty(html);
-
     String rawEnabled = cfg.getString(COMMENTLINK, name, KEY_ENABLED);
     Boolean enabled;
     if (rawEnabled != null) {
@@ -345,12 +342,8 @@
     } else {
       enabled = null;
     }
-    checkArgument(allowRaw || !hasHtml, "Raw html replacement not allowed");
 
-    if (Strings.isNullOrEmpty(match)
-        && Strings.isNullOrEmpty(link)
-        && !hasHtml
-        && enabled != null) {
+    if (Strings.isNullOrEmpty(match) && Strings.isNullOrEmpty(link) && enabled != null) {
       if (enabled) {
         return StoredCommentLinkInfo.enabled(name);
       }
@@ -362,7 +355,6 @@
         .setPrefix(linkPrefix)
         .setSuffix(linkSuffix)
         .setText(linkText)
-        .setHtml(html)
         .setEnabled(enabled)
         .setOverrideOnly(false)
         .build();
@@ -667,7 +659,7 @@
     }
     readGroupList();
 
-    rulesId = getObjectId("rules.pl");
+    rulesId = getObjectId(RULES_PL_FILE);
     Config rc = readConfig(PROJECT_CONFIG, baseConfig);
     Project.Builder p = Project.builder(projectName);
     p.setDescription(Strings.nullToEmpty(rc.getString(PROJECT, null, KEY_DESCRIPTION)));
@@ -728,7 +720,7 @@
     Map<String, String> lowerNames = Maps.newHashMapWithExpectedSize(2);
     extensionPanelSections = new LinkedHashMap<>();
     for (String name : rc.getSubsections(EXTENSION_PANELS)) {
-      String lower = name.toLowerCase();
+      String lower = name.toLowerCase(Locale.US);
       if (lowerNames.containsKey(lower)) {
         error(
             String.format(
@@ -977,7 +969,8 @@
     Map<String, String> lowerNames = new HashMap<>();
     submitRequirementSections = new LinkedHashMap<>();
     for (String name : rc.getSubsections(SUBMIT_REQUIREMENT)) {
-      String lower = name.toLowerCase();
+      checkDuplicateSrDefinition(rc, name);
+      String lower = name.toLowerCase(Locale.US);
       if (lowerNames.containsKey(lower)) {
         error(
             String.format(
@@ -1034,6 +1027,40 @@
     }
   }
 
+  private void checkDuplicateSrDefinition(Config rc, String srName) {
+    if (rc.getStringList(SUBMIT_REQUIREMENT, srName, KEY_SR_DESCRIPTION).length > 1) {
+      error(
+          String.format(
+              "Multiple definitions of %s for submit requirement '%s'",
+              KEY_SR_DESCRIPTION, srName));
+    }
+    if (rc.getStringList(SUBMIT_REQUIREMENT, srName, KEY_SR_APPLICABILITY_EXPRESSION).length > 1) {
+      error(
+          String.format(
+              "Multiple definitions of %s for submit requirement '%s'",
+              KEY_SR_APPLICABILITY_EXPRESSION, srName));
+    }
+    if (rc.getStringList(SUBMIT_REQUIREMENT, srName, KEY_SR_SUBMITTABILITY_EXPRESSION).length > 1) {
+      error(
+          String.format(
+              "Multiple definitions of %s for submit requirement '%s'",
+              KEY_SR_SUBMITTABILITY_EXPRESSION, srName));
+    }
+    if (rc.getStringList(SUBMIT_REQUIREMENT, srName, KEY_SR_OVERRIDE_EXPRESSION).length > 1) {
+      error(
+          String.format(
+              "Multiple definitions of %s for submit requirement '%s'",
+              KEY_SR_OVERRIDE_EXPRESSION, srName));
+    }
+    if (rc.getStringList(SUBMIT_REQUIREMENT, srName, KEY_SR_OVERRIDE_IN_CHILD_PROJECTS).length
+        > 1) {
+      error(
+          String.format(
+              "Multiple definitions of %s for submit requirement '%s'",
+              KEY_SR_OVERRIDE_IN_CHILD_PROJECTS, srName));
+    }
+  }
+
   /**
    * Report unsupported submit requirement parameters as errors.
    *
@@ -1075,7 +1102,7 @@
     Map<String, String> lowerNames = Maps.newHashMapWithExpectedSize(2);
     labelSections = new LinkedHashMap<>();
     for (String name : rc.getSubsections(LABEL)) {
-      String lower = name.toLowerCase();
+      String lower = name.toLowerCase(Locale.US);
       if (lowerNames.containsKey(lower)) {
         error(String.format("Label \"%s\" conflicts with \"%s\"", name, lowerNames.get(lower)));
       }
@@ -1118,9 +1145,11 @@
         error(
             String.format(
                 "Invalid %s for label \"%s\". Valid names are: %s",
-                KEY_FUNCTION, name, Joiner.on(", ").join(LabelFunction.ALL.keySet())));
+                KEY_FUNCTION,
+                name,
+                Joiner.on(", ").join(LabelFunction.ALL_NON_DEPRECATED.keySet())));
       }
-      label.setFunction(function.orElse(null));
+      function.ifPresent(label::setFunction);
       label.setCopyCondition(rc.getString(LABEL, name, KEY_COPY_CONDITION));
 
       if (!values.isEmpty()) {
@@ -1168,6 +1197,7 @@
     return false;
   }
 
+  @Nullable
   private List<String> getStringListOrNull(
       Config rc, String section, String subSection, String name) {
     String[] ac = rc.getStringList(section, subSection, name);
@@ -1179,7 +1209,7 @@
     commentLinkSections = new LinkedHashMap<>(subsections.size());
     for (String name : subsections) {
       try {
-        commentLinkSections.put(name, buildCommentLink(rc, name, false));
+        commentLinkSections.put(name, buildCommentLink(rc, name));
       } catch (PatternSyntaxException e) {
         error(
             String.format(
@@ -1269,7 +1299,8 @@
           parsedConfig.fromText(cfg);
           projectLevelConfigs.put(pathInfo.path, parsedConfig);
         } catch (ConfigInvalidException e) {
-          logger.atWarning().withCause(e).log("Unable to parse config");
+          logger.atWarning().withCause(e).log(
+              "Unable to parse config for project %s", projectName.get());
         }
       }
     }
@@ -1337,6 +1368,7 @@
     return true;
   }
 
+  @Nullable
   public static String validMaxObjectSizeLimit(String value) throws ConfigInvalidException {
     if (value == null) {
       return null;
@@ -1380,9 +1412,9 @@
     unsetSection(rc, COMMENTLINK);
     if (commentLinkSections != null) {
       for (StoredCommentLinkInfo cm : commentLinkSections.values()) {
-        rc.setString(COMMENTLINK, cm.getName(), KEY_MATCH, cm.getMatch());
-        if (!Strings.isNullOrEmpty(cm.getHtml())) {
-          rc.setString(COMMENTLINK, cm.getName(), KEY_HTML, cm.getHtml());
+        // Match and Link can be empty if the commentlink is override only.
+        if (!Strings.isNullOrEmpty(cm.getMatch())) {
+          rc.setString(COMMENTLINK, cm.getName(), KEY_MATCH, cm.getMatch());
         }
         if (!Strings.isNullOrEmpty(cm.getLink())) {
           rc.setString(COMMENTLINK, cm.getName(), KEY_LINK, cm.getLink());
@@ -1498,7 +1530,7 @@
     if (capability != null) {
       Set<String> have = new HashSet<>();
       for (Permission permission : sort(capability.getPermissions())) {
-        have.add(permission.getName().toLowerCase());
+        have.add(permission.getName().toLowerCase(Locale.US));
 
         boolean needRange = GlobalCapability.hasRange(permission.getName());
         List<String> rules = new ArrayList<>();
@@ -1512,7 +1544,7 @@
         rc.setStringList(CAPABILITY, null, permission.getName(), rules);
       }
       for (String varName : rc.getNames(CAPABILITY)) {
-        if (!have.contains(varName.toLowerCase())) {
+        if (!have.contains(varName.toLowerCase(Locale.US))) {
           rc.unset(CAPABILITY, null, varName);
         }
       }
@@ -1543,7 +1575,7 @@
 
       Set<String> have = new HashSet<>();
       for (Permission permission : sort(as.getPermissions())) {
-        have.add(permission.getName().toLowerCase());
+        have.add(permission.getName().toLowerCase(Locale.US));
 
         boolean needRange = Permission.hasRange(permission.getName());
         List<String> rules = new ArrayList<>();
@@ -1559,7 +1591,7 @@
 
       for (String varName : rc.getNames(ACCESS, refName)) {
         if (isCoreOrPluginPermission(convertLegacyPermission(varName))
-            && !have.contains(varName.toLowerCase())) {
+            && !have.contains(varName.toLowerCase(Locale.US))) {
           rc.unset(ACCESS, refName, varName);
         }
       }
diff --git a/java/com/google/gerrit/server/project/ProjectCreator.java b/java/com/google/gerrit/server/project/ProjectCreator.java
index f1c161d..485d926 100644
--- a/java/com/google/gerrit/server/project/ProjectCreator.java
+++ b/java/com/google/gerrit/server/project/ProjectCreator.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.project;
 
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.INIT_REPO;
 
 import com.google.common.base.MoreObjects;
 import com.google.common.base.Strings;
@@ -43,6 +44,7 @@
 import com.google.gerrit.server.git.RepositoryExistsException;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
+import com.google.gerrit.server.update.context.RefUpdateContext;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import java.io.IOException;
@@ -105,36 +107,38 @@
 
   public ProjectState createProject(CreateProjectArgs args)
       throws BadRequestException, ResourceConflictException, IOException, ConfigInvalidException {
-    final Project.NameKey nameKey = args.getProject();
-    try {
-      final String head = args.permissionsOnly ? RefNames.REFS_CONFIG : args.branch.get(0);
-      Status status = repoManager.getRepositoryStatus(nameKey);
-      if (!status.equals(Status.NON_EXISTENT)) {
-        throw new RepositoryExistsException(nameKey, "Repository status: " + status);
-      }
-      try (Repository repo = repoManager.createRepository(nameKey)) {
-        RefUpdate u = repo.updateRef(Constants.HEAD);
-        u.disableRefLog();
-        u.link(head);
-
-        createProjectConfig(args);
-
-        if (!args.permissionsOnly && args.createEmptyCommit) {
-          createEmptyCommits(repo, nameKey, args.branch);
+    try (RefUpdateContext ctx = RefUpdateContext.open(INIT_REPO)) {
+      final Project.NameKey nameKey = args.getProject();
+      try {
+        final String head = args.permissionsOnly ? RefNames.REFS_CONFIG : args.branch.get(0);
+        Status status = repoManager.getRepositoryStatus(nameKey);
+        if (!status.equals(Status.NON_EXISTENT)) {
+          throw new RepositoryExistsException(nameKey, "Repository status: " + status);
         }
+        try (Repository repo = repoManager.createRepository(nameKey)) {
+          RefUpdate u = repo.updateRef(Constants.HEAD);
+          u.disableRefLog();
+          u.link(head);
 
-        fire(nameKey, head);
+          createProjectConfig(args);
 
-        return projectCache.get(nameKey).orElseThrow(illegalState(nameKey));
+          if (!args.permissionsOnly && args.createEmptyCommit) {
+            createEmptyCommits(repo, nameKey, args.branch);
+          }
+
+          fire(nameKey, head);
+
+          return projectCache.get(nameKey).orElseThrow(illegalState(nameKey));
+        }
+      } catch (RepositoryExistsException e) {
+        throw new ResourceConflictException(
+            "Cannot create "
+                + nameKey.get()
+                + " because the name is already occupied by another project.",
+            e);
+      } catch (RepositoryNotFoundException badName) {
+        throw new BadRequestException("invalid project name: " + nameKey, badName);
       }
-    } catch (RepositoryExistsException e) {
-      throw new ResourceConflictException(
-          "Cannot create "
-              + nameKey.get()
-              + " because the name is already occupied by another project.",
-          e);
-    } catch (RepositoryNotFoundException badName) {
-      throw new BadRequestException("invalid project name: " + nameKey, badName);
     }
   }
 
diff --git a/java/com/google/gerrit/server/project/ProjectHierarchyIterator.java b/java/com/google/gerrit/server/project/ProjectHierarchyIterator.java
index ccb5651..929399a 100644
--- a/java/com/google/gerrit/server/project/ProjectHierarchyIterator.java
+++ b/java/com/google/gerrit/server/project/ProjectHierarchyIterator.java
@@ -18,6 +18,7 @@
 import com.google.common.collect.Lists;
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.config.AllProjectsName;
 import java.util.Iterator;
@@ -63,6 +64,7 @@
     return n;
   }
 
+  @Nullable
   private ProjectState computeNext(ProjectState n) {
     Project.NameKey parentName = n.getProject().getParent();
     if (parentName != null && visit(parentName)) {
diff --git a/java/com/google/gerrit/server/project/ProjectState.java b/java/com/google/gerrit/server/project/ProjectState.java
index b350f3c..a3f8009 100644
--- a/java/com/google/gerrit/server/project/ProjectState.java
+++ b/java/com/google/gerrit/server/project/ProjectState.java
@@ -56,6 +56,7 @@
 import java.util.HashSet;
 import java.util.LinkedHashMap;
 import java.util.List;
+import java.util.Locale;
 import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
@@ -380,7 +381,7 @@
     Map<String, SubmitRequirement> requirements = new LinkedHashMap<>();
     for (ProjectState s : treeInOrder()) {
       for (SubmitRequirement requirement : s.getConfig().getSubmitRequirementSections().values()) {
-        String lowerName = requirement.name().toLowerCase();
+        String lowerName = requirement.name().toLowerCase(Locale.US);
         SubmitRequirement old = requirements.get(lowerName);
         if (old == null || old.allowOverrideInChildProjects()) {
           requirements.put(lowerName, requirement);
@@ -395,7 +396,7 @@
     Map<String, LabelType> types = new LinkedHashMap<>();
     for (ProjectState s : treeInOrder()) {
       for (LabelType type : s.getConfig().getLabelSections().values()) {
-        String lower = type.getName().toLowerCase();
+        String lower = type.getName().toLowerCase(Locale.US);
         LabelType old = types.get(lower);
         if (old == null || old.isCanOverride()) {
           types.put(lower, type);
@@ -449,11 +450,11 @@
   public List<CommentLinkInfo> getCommentLinks() {
     Map<String, CommentLinkInfo> cls = new LinkedHashMap<>();
     for (CommentLinkInfo cl : commentLinks) {
-      cls.put(cl.name.toLowerCase(), cl);
+      cls.put(cl.name.toLowerCase(Locale.US), cl);
     }
     for (ProjectState s : treeInOrder()) {
       for (StoredCommentLinkInfo cl : s.getConfig().getCommentLinkSections().values()) {
-        String name = cl.getName().toLowerCase();
+        String name = cl.getName().toLowerCase(Locale.US);
         if (cl.getOverrideOnly()) {
           CommentLinkInfo parent = cls.get(name);
           if (parent == null) {
diff --git a/java/com/google/gerrit/server/project/ProjectsConsistencyChecker.java b/java/com/google/gerrit/server/project/ProjectsConsistencyChecker.java
index ab4bb70..9463b39 100644
--- a/java/com/google/gerrit/server/project/ProjectsConsistencyChecker.java
+++ b/java/com/google/gerrit/server/project/ProjectsConsistencyChecker.java
@@ -264,7 +264,7 @@
               .changeIndexQuery(
                   "projectsConsistencyCheckerQueryChanges",
                   q ->
-                      q.setRequestedFields(ChangeField.CHANGE, ChangeField.PATCH_SET)
+                      q.setRequestedFields(ChangeField.CHANGE_SPEC, ChangeField.PATCH_SET_SPEC)
                           .query(and(basePredicate, or(predicates))))
               .call();
 
diff --git a/java/com/google/gerrit/server/project/PrologRulesWarningValidator.java b/java/com/google/gerrit/server/project/PrologRulesWarningValidator.java
new file mode 100644
index 0000000..5683fe7
--- /dev/null
+++ b/java/com/google/gerrit/server/project/PrologRulesWarningValidator.java
@@ -0,0 +1,88 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.project;
+
+import static com.google.gerrit.server.project.ProjectConfig.RULES_PL_FILE;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Patch.ChangeType;
+import com.google.gerrit.entities.RefNames;
+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.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+/**
+ * A validator than emits a warning for newly added prolog rules file via git push. Modification and
+ * deletion are allowed so that clients can get rid of prolog rules.
+ */
+@Singleton
+public class PrologRulesWarningValidator implements CommitValidationListener {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private final DiffOperations diffOperations;
+
+  @Inject
+  public PrologRulesWarningValidator(DiffOperations diffOperations) {
+    this.diffOperations = diffOperations;
+  }
+
+  @Override
+  public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
+      throws CommitValidationException {
+    try {
+      if (receiveEvent.refName.equals(RefNames.REFS_CONFIG)
+          && isFileAdded(receiveEvent, RULES_PL_FILE)) {
+        return ImmutableList.of(
+            new CommitValidationMessage(
+                "Uploading a new 'rules.pl' file is discouraged."
+                    + " Please consider adding submit-requirements instead.",
+                ValidationMessage.Type.WARNING));
+      }
+    } catch (DiffNotAvailableException e) {
+      logger.atWarning().withCause(e).log("Failed to retrieve the file diff.");
+    }
+    return ImmutableList.of();
+  }
+
+  private boolean isFileAdded(CommitReceivedEvent receiveEvent, String fileName)
+      throws DiffNotAvailableException {
+    List<Map.Entry<String, FileDiffOutput>> matchingEntries =
+        diffOperations
+            .listModifiedFilesAgainstParent(
+                receiveEvent.project.getNameKey(),
+                receiveEvent.commit,
+                /* parentNum=*/ 0,
+                DiffOptions.DEFAULTS)
+            .entrySet().stream()
+            .filter(e -> fileName.equals(e.getKey()))
+            .collect(Collectors.toList());
+    if (matchingEntries.size() != 1) {
+      return false;
+    }
+    return matchingEntries.iterator().next().getValue().changeType().equals(ChangeType.ADDED);
+  }
+}
diff --git a/java/com/google/gerrit/server/project/RefUtil.java b/java/com/google/gerrit/server/project/RefUtil.java
index e86ad41..07f7ba5 100644
--- a/java/com/google/gerrit/server/project/RefUtil.java
+++ b/java/com/google/gerrit/server/project/RefUtil.java
@@ -23,6 +23,7 @@
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import java.io.IOException;
 import java.util.Collections;
+import org.eclipse.jgit.errors.AmbiguousObjectException;
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
 import org.eclipse.jgit.errors.MissingObjectException;
 import org.eclipse.jgit.errors.RevisionSyntaxException;
@@ -49,6 +50,9 @@
     } catch (RevisionSyntaxException e) {
       throw new UnprocessableEntityException(
           String.format("base revision \"%s\" is invalid", baseRevision), e);
+    } catch (AmbiguousObjectException e) {
+      throw new UnprocessableEntityException(
+          String.format("base revision \"%s\" is ambiguous", baseRevision), e);
     }
   }
 
diff --git a/java/com/google/gerrit/server/project/RemoveReviewerControl.java b/java/com/google/gerrit/server/project/RemoveReviewerControl.java
index 1bc309c..3fda87a 100644
--- a/java/com/google/gerrit/server/project/RemoveReviewerControl.java
+++ b/java/com/google/gerrit/server/project/RemoveReviewerControl.java
@@ -32,10 +32,12 @@
 @Singleton
 public class RemoveReviewerControl {
   private final PermissionBackend permissionBackend;
+  private final ChangeData.Factory changeDataFactory;
 
   @Inject
-  RemoveReviewerControl(PermissionBackend permissionBackend) {
+  RemoveReviewerControl(PermissionBackend permissionBackend, ChangeData.Factory changeDataFactory) {
     this.permissionBackend = permissionBackend;
+    this.changeDataFactory = changeDataFactory;
   }
 
   /**
@@ -64,6 +66,20 @@
 
   /** Returns true if the user is allowed to remove this reviewer. */
   public boolean testRemoveReviewer(
+      ChangeNotes notes, CurrentUser currentUser, PatchSetApproval approval)
+      throws PermissionBackendException {
+    return testRemoveReviewer(notes, currentUser, approval.accountId(), approval.value());
+  }
+
+  /** Returns true if the user is allowed to remove this reviewer. */
+  public boolean testRemoveReviewer(
+      ChangeNotes notes, CurrentUser currentUser, Account.Id reviewer, int value)
+      throws PermissionBackendException {
+    return testRemoveReviewer(changeDataFactory.create(notes), currentUser, reviewer, value);
+  }
+
+  /** Returns true if the user is allowed to remove this reviewer. */
+  public boolean testRemoveReviewer(
       ChangeData cd, CurrentUser currentUser, Account.Id reviewer, int value)
       throws PermissionBackendException {
     if (canRemoveReviewerWithoutPermissionCheck(
diff --git a/java/com/google/gerrit/server/project/SectionMatcher.java b/java/com/google/gerrit/server/project/SectionMatcher.java
index 3d7175f..eaebab2 100644
--- a/java/com/google/gerrit/server/project/SectionMatcher.java
+++ b/java/com/google/gerrit/server/project/SectionMatcher.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.project;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.AccessSection;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.CurrentUser;
@@ -25,6 +26,7 @@
  * of which sections are relevant to any given input reference.
  */
 public class SectionMatcher extends RefPatternMatcher {
+  @Nullable
   static SectionMatcher wrap(Project.NameKey project, AccessSection section) {
     String ref = section.getName();
     if (AccessSection.isValidRefSectionName(ref)) {
diff --git a/java/com/google/gerrit/server/project/SubmitRequirementsEvaluatorImpl.java b/java/com/google/gerrit/server/project/SubmitRequirementsEvaluatorImpl.java
index d749fd3..0991f20 100644
--- a/java/com/google/gerrit/server/project/SubmitRequirementsEvaluatorImpl.java
+++ b/java/com/google/gerrit/server/project/SubmitRequirementsEvaluatorImpl.java
@@ -18,6 +18,7 @@
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
 
 import com.google.common.collect.ImmutableMap;
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.SubmitRequirement;
 import com.google.gerrit.entities.SubmitRequirementExpression;
 import com.google.gerrit.entities.SubmitRequirementExpressionResult;
@@ -35,6 +36,7 @@
 import com.google.inject.Module;
 import com.google.inject.Provider;
 import com.google.inject.Scopes;
+import java.util.Locale;
 import java.util.Map;
 import java.util.Optional;
 import java.util.function.Function;
@@ -42,6 +44,7 @@
 
 /** Evaluates submit requirements for different change data. */
 public class SubmitRequirementsEvaluatorImpl implements SubmitRequirementsEvaluator {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   private final Provider<SubmitRequirementChangeQueryBuilder> queryBuilder;
   private final ProjectCache projectCache;
@@ -111,6 +114,29 @@
                 : Optional.empty();
       }
 
+      if (applicabilityResult.isPresent()) {
+        logger.atFine().log(
+            "Applicability expression result for SR name '%s':"
+                + " passing atoms: %s, failing atoms: %s",
+            sr.name(),
+            applicabilityResult.get().passingAtoms(),
+            applicabilityResult.get().failingAtoms());
+      }
+      if (submittabilityResult.isPresent()) {
+        logger.atFine().log(
+            "Submittability expression result for SR name '%s':"
+                + " passing atoms: %s, failing atoms: %s",
+            sr.name(),
+            submittabilityResult.get().passingAtoms(),
+            submittabilityResult.get().failingAtoms());
+      }
+      if (overrideResult.isPresent()) {
+        logger.atFine().log(
+            "Override expression result for SR name '%s':"
+                + " passing atoms: %s, failing atoms: %s",
+            sr.name(), overrideResult.get().passingAtoms(), overrideResult.get().failingAtoms());
+      }
+
       return SubmitRequirementResult.builder()
           .legacy(Optional.of(false))
           .submitRequirement(sr)
@@ -130,6 +156,8 @@
       PredicateResult predicateResult = evaluatePredicateTree(predicate, changeData);
       return SubmitRequirementExpressionResult.create(expression, predicateResult);
     } catch (QueryParseException | SubmitRequirementEvaluationException e) {
+      logger.atWarning().withCause(e).log(
+          "Failed to evaluate submit requirement expression: %s", expression.expressionString());
       return SubmitRequirementExpressionResult.error(expression, e.getMessage());
     }
   }
@@ -180,7 +208,8 @@
     return globalSubmitRequirements.stream()
         .collect(
             toImmutableMap(
-                globalRequirement -> globalRequirement.name().toLowerCase(), Function.identity()));
+                globalRequirement -> globalRequirement.name().toLowerCase(Locale.US),
+                Function.identity()));
   }
 
   /** Evaluate the predicate recursively using change data. */
diff --git a/java/com/google/gerrit/server/project/SubmitRequirementsUtil.java b/java/com/google/gerrit/server/project/SubmitRequirementsUtil.java
index e54e5af..403e526 100644
--- a/java/com/google/gerrit/server/project/SubmitRequirementsUtil.java
+++ b/java/com/google/gerrit/server/project/SubmitRequirementsUtil.java
@@ -28,6 +28,7 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.util.HashMap;
+import java.util.Locale;
 import java.util.Map;
 import java.util.Set;
 import java.util.regex.Pattern;
@@ -142,10 +143,12 @@
             // (projectConfigRequirements should not contain legacy entries)
             // TODO(ghareeb): remove the filter statement
             .filter(entry -> !entry.getValue().isLegacy())
-            .collect(Collectors.toMap(sr -> sr.getKey().name().toLowerCase(), sr -> sr.getValue()));
+            .collect(
+                Collectors.toMap(
+                    sr -> sr.getKey().name().toLowerCase(Locale.US), sr -> sr.getValue()));
     for (Map.Entry<SubmitRequirement, SubmitRequirementResult> legacy :
         legacyRequirements.entrySet()) {
-      String srName = legacy.getKey().name().toLowerCase();
+      String srName = legacy.getKey().name().toLowerCase(Locale.US);
       SubmitRequirementResult projectConfigResult = requirementsByName.get(srName);
       SubmitRequirementResult legacyResult = legacy.getValue();
       // If there's no project config requirement with the same name as the legacy requirement
diff --git a/java/com/google/gerrit/server/query/account/AccountPredicates.java b/java/com/google/gerrit/server/query/account/AccountPredicates.java
index 433abe6..fa75542 100644
--- a/java/com/google/gerrit/server/query/account/AccountPredicates.java
+++ b/java/com/google/gerrit/server/query/account/AccountPredicates.java
@@ -14,6 +14,9 @@
 
 package com.google.gerrit.server.query.account;
 
+import static com.google.gerrit.server.index.account.AccountField.USERNAME_SPEC;
+
+import com.google.common.base.Ascii;
 import com.google.common.collect.Lists;
 import com.google.common.primitives.Ints;
 import com.google.gerrit.entities.Account;
@@ -59,7 +62,9 @@
         }
       }
     }
-    preds.add(username(query));
+    if (schema.hasField(USERNAME_SPEC)) {
+      preds.add(username(query));
+    }
     // Adapt the capacity of the "predicates" list when adding more default
     // predicates.
     return Predicate.or(preds);
@@ -76,14 +81,14 @@
 
   public static Predicate<AccountState> emailIncludingSecondaryEmails(String email) {
     return new AccountPredicate(
-        AccountField.EMAIL_SPEC, AccountQueryBuilder.FIELD_EMAIL, email.toLowerCase());
+        AccountField.EMAIL_SPEC, AccountQueryBuilder.FIELD_EMAIL, Ascii.toLowerCase(email));
   }
 
   public static Predicate<AccountState> preferredEmail(String email) {
     return new AccountPredicate(
         AccountField.PREFERRED_EMAIL_LOWER_CASE_SPEC,
         AccountQueryBuilder.FIELD_PREFERRED_EMAIL,
-        email.toLowerCase());
+        Ascii.toLowerCase(email));
   }
 
   public static Predicate<AccountState> preferredEmailExact(String email) {
@@ -95,14 +100,14 @@
 
   public static Predicate<AccountState> equalsNameIncludingSecondaryEmails(String name) {
     return new AccountPredicate(
-        AccountField.NAME_PART_SPEC, AccountQueryBuilder.FIELD_NAME, name.toLowerCase());
+        AccountField.NAME_PART_SPEC, AccountQueryBuilder.FIELD_NAME, Ascii.toLowerCase(name));
   }
 
   public static Predicate<AccountState> equalsName(String name) {
     return new AccountPredicate(
         AccountField.NAME_PART_NO_SECONDARY_EMAIL_SPEC,
         AccountQueryBuilder.FIELD_NAME,
-        name.toLowerCase());
+        Ascii.toLowerCase(name));
   }
 
   public static Predicate<AccountState> externalIdIncludingSecondaryEmails(String externalId) {
@@ -123,7 +128,7 @@
 
   public static Predicate<AccountState> username(String username) {
     return new AccountPredicate(
-        AccountField.USERNAME_SPEC, AccountQueryBuilder.FIELD_USERNAME, username.toLowerCase());
+        USERNAME_SPEC, AccountQueryBuilder.FIELD_USERNAME, Ascii.toLowerCase(username));
   }
 
   public static Predicate<AccountState> watchedProject(Project.NameKey project) {
diff --git a/java/com/google/gerrit/server/query/account/AccountQueryBuilder.java b/java/com/google/gerrit/server/query/account/AccountQueryBuilder.java
index ed950c8..2f4a923 100644
--- a/java/com/google/gerrit/server/query/account/AccountQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/account/AccountQueryBuilder.java
@@ -14,9 +14,12 @@
 package com.google.gerrit.server.query.account;
 
 import com.google.common.base.Splitter;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Lists;
 import com.google.common.flogger.FluentLogger;
 import com.google.common.primitives.Ints;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.exceptions.NotSignedInException;
 import com.google.gerrit.exceptions.StorageException;
@@ -37,6 +40,8 @@
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.query.account.AccountPredicates.AccountPredicate;
+import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.ProvisionException;
@@ -61,6 +66,7 @@
 
   public static class Arguments {
     final ChangeFinder changeFinder;
+    final ChangeData.Factory changeDataFactory;
     final PermissionBackend permissionBackend;
 
     private final Provider<CurrentUser> self;
@@ -71,9 +77,11 @@
         Provider<CurrentUser> self,
         AccountIndexCollection indexes,
         ChangeFinder changeFinder,
+        ChangeData.Factory changeDataFactory,
         PermissionBackend permissionBackend) {
       this.self = self;
       this.indexes = indexes;
+      this.changeDataFactory = changeDataFactory;
       this.changeFinder = changeFinder;
       this.permissionBackend = permissionBackend;
     }
@@ -98,6 +106,7 @@
       }
     }
 
+    @Nullable
     Schema<AccountState> schema() {
       Index<?, AccountState> index = indexes != null ? indexes.getSearchIndex() : null;
       return index != null ? index.getSchema() : null;
@@ -119,7 +128,17 @@
     if (!changeNotes.isPresent()) {
       throw error(String.format("change %s not found", change));
     }
-
+    if (changeNotes.get().getChange().isPrivate()) {
+      Account.Id caller = self();
+      ChangeData cd = args.changeDataFactory.create(changeNotes.get());
+      Account.Id owner = cd.change().getOwner();
+      ImmutableSet<Account.Id> reviewersAndCC = cd.reviewers().all();
+      if (!(caller.equals(owner) || reviewersAndCC.contains(caller))) {
+        throw error(String.format("change %s not found", change));
+      }
+      return orAccountPredicate(
+          ImmutableList.<Account.Id>builder().add(owner).addAll(reviewersAndCC).build());
+    }
     if (!args.permissionBackend
         .user(args.getUser())
         .change(changeNotes.get())
@@ -132,7 +151,7 @@
   @Operator
   public Predicate<AccountState> email(String email)
       throws PermissionBackendException, QueryParseException {
-    if (canSeeSecondaryEmails()) {
+    if (canViewSecondaryEmails()) {
       return AccountPredicates.emailIncludingSecondaryEmails(email);
     }
 
@@ -140,7 +159,7 @@
       return AccountPredicates.preferredEmail(email);
     }
 
-    throw new QueryParseException("'email' operator is not supported by account index version");
+    throw new QueryParseException("'email' operator is not supported on this gerrit host");
   }
 
   @Operator
@@ -166,7 +185,7 @@
   @Operator
   public Predicate<AccountState> name(String name)
       throws PermissionBackendException, QueryParseException {
-    if (canSeeSecondaryEmails()) {
+    if (canViewSecondaryEmails()) {
       return AccountPredicates.equalsNameIncludingSecondaryEmails(name);
     }
 
@@ -191,7 +210,7 @@
   @Override
   protected Predicate<AccountState> defaultField(String query) {
     Predicate<AccountState> defaultPredicate =
-        AccountPredicates.defaultPredicate(args.schema(), checkedCanSeeSecondaryEmails(), query);
+        AccountPredicates.defaultPredicate(args.schema(), checkedCanViewSecondaryEmails(), query);
     if (query.startsWith("cansee:")) {
       try {
         return cansee(query.substring(7));
@@ -214,13 +233,13 @@
     return args.getIdentifiedUser().getAccountId();
   }
 
-  private boolean canSeeSecondaryEmails() throws PermissionBackendException, QueryParseException {
-    return args.permissionBackend.user(args.getUser()).test(GlobalPermission.MODIFY_ACCOUNT);
+  private boolean canViewSecondaryEmails() throws PermissionBackendException, QueryParseException {
+    return args.permissionBackend.user(args.getUser()).test(GlobalPermission.VIEW_SECONDARY_EMAILS);
   }
 
-  private boolean checkedCanSeeSecondaryEmails() {
+  private boolean checkedCanViewSecondaryEmails() {
     try {
-      return canSeeSecondaryEmails();
+      return canViewSecondaryEmails();
     } catch (PermissionBackendException e) {
       logger.atSevere().withCause(e).log("Permission check failed");
       return false;
@@ -229,4 +248,14 @@
       return false;
     }
   }
+
+  /** Creates an OR predicate of the account IDs of the {@code accounts} parameter. */
+  private Predicate<AccountState> orAccountPredicate(ImmutableList<Account.Id> accounts) {
+    Predicate<AccountState> result =
+        AccountPredicate.or(AccountPredicates.id(args.schema(), accounts.get(0)));
+    for (int i = 1; i < accounts.size(); i += 1) {
+      result = AccountPredicate.or(result, AccountPredicates.id(args.schema(), accounts.get(i)));
+    }
+    return result;
+  }
 }
diff --git a/java/com/google/gerrit/server/query/account/InternalAccountQuery.java b/java/com/google/gerrit/server/query/account/InternalAccountQuery.java
index 98a12d5..fa1758a 100644
--- a/java/com/google/gerrit/server/query/account/InternalAccountQuery.java
+++ b/java/com/google/gerrit/server/query/account/InternalAccountQuery.java
@@ -23,6 +23,7 @@
 import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.Multimap;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.index.IndexConfig;
@@ -71,6 +72,7 @@
     return query(AccountPredicates.externalIdIncludingSecondaryEmails(externalId.toString()));
   }
 
+  @Nullable
   @UsedAt(UsedAt.Project.COLLABNET)
   public AccountState oneByExternalId(ExternalId.Key externalId) {
     List<AccountState> accountStates = byExternalId(externalId);
diff --git a/java/com/google/gerrit/server/query/approval/ApprovalQueryBuilder.java b/java/com/google/gerrit/server/query/approval/ApprovalQueryBuilder.java
index 11749cc..ed876c1 100644
--- a/java/com/google/gerrit/server/query/approval/ApprovalQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/approval/ApprovalQueryBuilder.java
@@ -28,6 +28,7 @@
 import com.google.gerrit.server.group.GroupResolver;
 import com.google.inject.Inject;
 import java.util.Arrays;
+import java.util.Locale;
 import java.util.Optional;
 
 public class ApprovalQueryBuilder extends QueryBuilder<ApprovalContext, ApprovalQueryBuilder> {
@@ -113,7 +114,7 @@
 
   private static <T extends Enum<T>> Optional<T> parseEnumValue(Class<T> clazz, String value) {
     return Optional.ofNullable(
-        Enums.getIfPresent(clazz, value.toUpperCase().replace('-', '_')).orNull());
+        Enums.getIfPresent(clazz, value.toUpperCase(Locale.US).replace('-', '_')).orNull());
   }
 
   private <T extends Enum<T>> String formatEnumValues(Class<T> clazz) {
diff --git a/java/com/google/gerrit/server/query/change/AddedPredicate.java b/java/com/google/gerrit/server/query/change/AddedPredicate.java
index 1f526c5..698884c 100644
--- a/java/com/google/gerrit/server/query/change/AddedPredicate.java
+++ b/java/com/google/gerrit/server/query/change/AddedPredicate.java
@@ -19,11 +19,11 @@
 
 public class AddedPredicate extends IntegerRangeChangePredicate {
   public AddedPredicate(String value) throws QueryParseException {
-    super(ChangeField.ADDED, value);
+    super(ChangeField.ADDED_LINES_SPEC, value);
   }
 
   @Override
   protected Integer getValueInt(ChangeData changeData) {
-    return ChangeField.ADDED.get(changeData);
+    return ChangeField.ADDED_LINES_SPEC.get(changeData);
   }
 }
diff --git a/java/com/google/gerrit/server/query/change/AfterPredicate.java b/java/com/google/gerrit/server/query/change/AfterPredicate.java
index 2514989..d3e3477 100644
--- a/java/com/google/gerrit/server/query/change/AfterPredicate.java
+++ b/java/com/google/gerrit/server/query/change/AfterPredicate.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.query.change;
 
-import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
 import com.google.gerrit.index.query.QueryParseException;
 import java.sql.Timestamp;
 import java.time.Instant;
@@ -26,7 +26,7 @@
 public class AfterPredicate extends TimestampRangeChangePredicate {
   protected final Instant cut;
 
-  public AfterPredicate(FieldDef<ChangeData, Timestamp> def, String name, String value)
+  public AfterPredicate(SchemaField<ChangeData, Timestamp> def, String name, String value)
       throws QueryParseException {
     super(def, name, value);
     cut = parse(value);
diff --git a/java/com/google/gerrit/server/query/change/AgePredicate.java b/java/com/google/gerrit/server/query/change/AgePredicate.java
index c1138bd..8a9ba2e 100644
--- a/java/com/google/gerrit/server/query/change/AgePredicate.java
+++ b/java/com/google/gerrit/server/query/change/AgePredicate.java
@@ -27,7 +27,7 @@
   protected final Instant cut;
 
   public AgePredicate(String value) {
-    super(ChangeField.UPDATED, ChangeQueryBuilder.FIELD_AGE, value);
+    super(ChangeField.UPDATED_SPEC, ChangeQueryBuilder.FIELD_AGE, value);
 
     long s = ConfigUtil.getTimeUnit(getValue(), 0, SECONDS);
     long ms = MILLISECONDS.convert(s, SECONDS);
diff --git a/java/com/google/gerrit/server/query/change/BeforePredicate.java b/java/com/google/gerrit/server/query/change/BeforePredicate.java
index 5d682fb..e9ddbff 100644
--- a/java/com/google/gerrit/server/query/change/BeforePredicate.java
+++ b/java/com/google/gerrit/server/query/change/BeforePredicate.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.query.change;
 
-import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
 import com.google.gerrit.index.query.QueryParseException;
 import java.sql.Timestamp;
 import java.time.Instant;
@@ -26,7 +26,7 @@
 public class BeforePredicate extends TimestampRangeChangePredicate {
   protected final Instant cut;
 
-  public BeforePredicate(FieldDef<ChangeData, Timestamp> def, String name, String value)
+  public BeforePredicate(SchemaField<ChangeData, Timestamp> def, String name, String value)
       throws QueryParseException {
     super(def, name, value);
     cut = parse(value);
diff --git a/java/com/google/gerrit/server/query/change/BooleanPredicate.java b/java/com/google/gerrit/server/query/change/BooleanPredicate.java
index 6ca3acc..d6df7e0 100644
--- a/java/com/google/gerrit/server/query/change/BooleanPredicate.java
+++ b/java/com/google/gerrit/server/query/change/BooleanPredicate.java
@@ -14,10 +14,10 @@
 
 package com.google.gerrit.server.query.change;
 
-import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
 
 public class BooleanPredicate extends ChangeIndexPredicate {
-  public BooleanPredicate(FieldDef<ChangeData, String> field) {
+  public BooleanPredicate(SchemaField<ChangeData, String> field) {
     super(field, "1");
   }
 
diff --git a/java/com/google/gerrit/server/query/change/BranchSetIndexPredicate.java b/java/com/google/gerrit/server/query/change/BranchSetIndexPredicate.java
new file mode 100644
index 0000000..eb35b14
--- /dev/null
+++ b/java/com/google/gerrit/server/query/change/BranchSetIndexPredicate.java
@@ -0,0 +1,61 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.change;
+
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.index.query.OrPredicate;
+import com.google.gerrit.index.query.Predicate;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+/** A Predicate to match any number of BranchNameKeys with O(1) efficiency */
+public class BranchSetIndexPredicate extends OrPredicate<ChangeData> {
+  private final String name;
+  private final Set<BranchNameKey> branches;
+
+  public BranchSetIndexPredicate(String name, Set<BranchNameKey> branches) throws StorageException {
+    super(getPredicates(branches));
+    this.name = name;
+    this.branches = branches;
+  }
+
+  @Override
+  public boolean match(ChangeData changeData) {
+    Change change = changeData.change();
+    if (change == null) {
+      return false;
+    }
+
+    return branches.contains(change.getDest());
+  }
+
+  @Override
+  public String toString() {
+    return "BranchSetIndexPredicate[" + name + "]" + super.toString();
+  }
+
+  private static List<Predicate<ChangeData>> getPredicates(Set<BranchNameKey> branches) {
+    return branches.stream()
+        .map(
+            branchNameKey ->
+                Predicate.and(
+                    ChangePredicates.project(branchNameKey.project()),
+                    ChangePredicates.ref(branchNameKey.branch())))
+        .collect(Collectors.toList());
+  }
+}
diff --git a/java/com/google/gerrit/server/query/change/ChangeData.java b/java/com/google/gerrit/server/query/change/ChangeData.java
index 9a32a03..f982235 100644
--- a/java/com/google/gerrit/server/query/change/ChangeData.java
+++ b/java/com/google/gerrit/server/query/change/ChangeData.java
@@ -343,6 +343,7 @@
             .id(PatchSet.id(id, currentPatchSetId))
             .commitId(commitId)
             .uploader(Account.id(1000))
+            .realUploader(Account.id(1000))
             .createdOn(TimeUtil.now())
             .build();
     return cd;
@@ -407,7 +408,7 @@
    * Map from {@link com.google.gerrit.entities.Account.Id} to the tip of the edit ref for this
    * change and a given user.
    */
-  private Table<Account.Id, PatchSet.Id, ObjectId> editsByUser;
+  private Table<Account.Id, PatchSet.Id, Ref> editRefsByUser;
 
   private Set<Account.Id> reviewedBy;
   /**
@@ -729,6 +730,7 @@
     return notes;
   }
 
+  @Nullable
   public PatchSet currentPatchSet() {
     if (currentPatchSet == null) {
       Change c = change();
@@ -773,6 +775,7 @@
     currentApprovals = approvals;
   }
 
+  @Nullable
   public String commitMessage() {
     if (commitMessage == null) {
       if (!loadCommitData()) {
@@ -796,6 +799,7 @@
     return trackingFooters.extract(commitFooters());
   }
 
+  @Nullable
   public PersonIdent getAuthor() {
     if (author == null) {
       if (!loadCommitData()) {
@@ -805,6 +809,7 @@
     return author;
   }
 
+  @Nullable
   public PersonIdent getCommitter() {
     if (committer == null) {
       if (!loadCommitData()) {
@@ -900,6 +905,7 @@
   }
 
   /** Returns patch with the given ID, or null if it does not exist. */
+  @Nullable
   public PatchSet patchSet(PatchSet.Id psId) {
     if (currentPatchSet != null && currentPatchSet.id().equals(psId)) {
       return currentPatchSet;
@@ -1047,6 +1053,7 @@
     return robotComments;
   }
 
+  @Nullable
   public Integer unresolvedCommentCount() {
     if (unresolvedCommentCount == null) {
       if (!lazyload()) {
@@ -1069,6 +1076,7 @@
     this.unresolvedCommentCount = count;
   }
 
+  @Nullable
   public Integer totalCommentCount() {
     if (totalCommentCount == null) {
       if (!lazyload()) {
@@ -1114,7 +1122,7 @@
    * submit requirements are evaluated online.
    *
    * <p>For changes loaded from the index, the value will be set from index field {@link
-   * com.google.gerrit.server.index.change.ChangeField#STORED_SUBMIT_REQUIREMENTS}.
+   * com.google.gerrit.server.index.change.ChangeField#STORED_SUBMIT_REQUIREMENTS_FIELD}.
    */
   public Map<SubmitRequirement, SubmitRequirementResult> submitRequirements() {
     if (submitRequirements == null) {
@@ -1248,8 +1256,8 @@
     return editRefs().rowKeySet();
   }
 
-  public Table<Account.Id, PatchSet.Id, ObjectId> editRefs() {
-    if (editsByUser == null) {
+  public Table<Account.Id, PatchSet.Id, Ref> editRefs() {
+    if (editRefsByUser == null) {
       if (!lazyload()) {
         return HashBasedTable.create();
       }
@@ -1257,7 +1265,7 @@
       if (c == null) {
         return HashBasedTable.create();
       }
-      editsByUser = HashBasedTable.create();
+      editRefsByUser = HashBasedTable.create();
       Change.Id id = requireNonNull(change.getId());
       try (Repository repo = repoManager.openRepository(project())) {
         for (Ref ref : repo.getRefDatabase().getRefsByPrefix(RefNames.REFS_USERS)) {
@@ -1268,7 +1276,7 @@
           if (id.equals(ps.changeId())) {
             Account.Id accountId = Account.Id.fromRef(ref.getName());
             if (accountId != null) {
-              editsByUser.put(accountId, ps, ref.getObjectId());
+              editRefsByUser.put(accountId, ps, ref);
             }
           }
         }
@@ -1276,7 +1284,7 @@
         throw new StorageException(e);
       }
     }
-    return editsByUser;
+    return editRefsByUser;
   }
 
   public Set<Account.Id> draftsByUser() {
@@ -1424,13 +1432,13 @@
       }
 
       ImmutableSetMultimap.Builder<NameKey, RefState> result = ImmutableSetMultimap.builder();
-      for (Table.Cell<Account.Id, PatchSet.Id, ObjectId> edit : editRefs().cellSet()) {
+      for (Table.Cell<Account.Id, PatchSet.Id, Ref> edit : editRefs().cellSet()) {
         result.put(
             project,
             RefState.create(
                 RefNames.refsEdit(
                     edit.getRowKey(), edit.getColumnKey().changeId(), edit.getColumnKey()),
-                edit.getValue()));
+                edit.getValue().getObjectId()));
       }
 
       // TODO: instantiating the notes is too much. We don't want to parse NoteDb, we just want the
@@ -1460,21 +1468,6 @@
             .forEach(r -> draftsByUser.put(Account.Id.fromRef(r.ref()), r.id()));
       }
     }
-    if (editsByUser == null) {
-      // Recover edit refs as well. Edits are represented as refs in the repository.
-      // ChangeData exposes #editsByUser which just provides a Set of Account.Ids of users who
-      // have edits on this change. Recovering this list from RefStates makes it available even
-      // on ChangeData instances retrieved from the index.
-      editsByUser = HashBasedTable.create();
-      if (refStates.containsKey(project())) {
-        refStates.get(project()).stream()
-            .filter(r -> RefNames.isRefsEdit(r.ref()))
-            .forEach(
-                r ->
-                    editsByUser.put(
-                        Account.Id.fromRef(r.ref()), PatchSet.Id.fromEditRef(r.ref()), r.id()));
-      }
-    }
   }
 
   public ImmutableList<byte[]> getRefStatePatterns() {
diff --git a/java/com/google/gerrit/server/query/change/ChangeIndexCardinalPredicate.java b/java/com/google/gerrit/server/query/change/ChangeIndexCardinalPredicate.java
index 6540d80..e39b3e2 100644
--- a/java/com/google/gerrit/server/query/change/ChangeIndexCardinalPredicate.java
+++ b/java/com/google/gerrit/server/query/change/ChangeIndexCardinalPredicate.java
@@ -14,20 +14,20 @@
 
 package com.google.gerrit.server.query.change;
 
-import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
 import com.google.gerrit.index.query.HasCardinality;
 
 public class ChangeIndexCardinalPredicate extends ChangeIndexPredicate implements HasCardinality {
   protected final int cardinality;
 
   protected ChangeIndexCardinalPredicate(
-      FieldDef<ChangeData, ?> def, String value, int cardinality) {
+      SchemaField<ChangeData, ?> def, String value, int cardinality) {
     super(def, value);
     this.cardinality = cardinality;
   }
 
   protected ChangeIndexCardinalPredicate(
-      FieldDef<ChangeData, ?> def, String name, String value, int cardinality) {
+      SchemaField<ChangeData, ?> def, String name, String value, int cardinality) {
     super(def, name, value);
     this.cardinality = cardinality;
   }
diff --git a/java/com/google/gerrit/server/query/change/ChangeIndexPostFilterPredicate.java b/java/com/google/gerrit/server/query/change/ChangeIndexPostFilterPredicate.java
index d86d366..c69f021 100644
--- a/java/com/google/gerrit/server/query/change/ChangeIndexPostFilterPredicate.java
+++ b/java/com/google/gerrit/server/query/change/ChangeIndexPostFilterPredicate.java
@@ -14,18 +14,19 @@
 
 package com.google.gerrit.server.query.change;
 
-import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
 
 /**
  * Predicate that is mapped to a field in the change index, with additional filtering done in the
  * {@code match} method.
  */
 public abstract class ChangeIndexPostFilterPredicate extends ChangeIndexPredicate {
-  protected ChangeIndexPostFilterPredicate(FieldDef<ChangeData, ?> def, String value) {
+  protected ChangeIndexPostFilterPredicate(SchemaField<ChangeData, ?> def, String value) {
     super(def, value);
   }
 
-  protected ChangeIndexPostFilterPredicate(FieldDef<ChangeData, ?> def, String name, String value) {
+  protected ChangeIndexPostFilterPredicate(
+      SchemaField<ChangeData, ?> def, String name, String value) {
     super(def, name, value);
   }
 }
diff --git a/java/com/google/gerrit/server/query/change/ChangeIndexPredicate.java b/java/com/google/gerrit/server/query/change/ChangeIndexPredicate.java
index ccd4109..a897a8d 100644
--- a/java/com/google/gerrit/server/query/change/ChangeIndexPredicate.java
+++ b/java/com/google/gerrit/server/query/change/ChangeIndexPredicate.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.query.change;
 
-import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
 import com.google.gerrit.index.query.IndexPredicate;
 import com.google.gerrit.index.query.Predicate;
 
@@ -32,11 +32,11 @@
     return ChangeStatusPredicate.NONE;
   }
 
-  protected ChangeIndexPredicate(FieldDef<ChangeData, ?> def, String value) {
+  protected ChangeIndexPredicate(SchemaField<ChangeData, ?> def, String value) {
     super(def, value);
   }
 
-  protected ChangeIndexPredicate(FieldDef<ChangeData, ?> def, String name, String value) {
+  protected ChangeIndexPredicate(SchemaField<ChangeData, ?> def, String name, String value) {
     super(def, name, value);
   }
 }
diff --git a/java/com/google/gerrit/server/query/change/ChangePredicates.java b/java/com/google/gerrit/server/query/change/ChangePredicates.java
index 5f9abc3..528d0ce 100644
--- a/java/com/google/gerrit/server/query/change/ChangePredicates.java
+++ b/java/com/google/gerrit/server/query/change/ChangePredicates.java
@@ -46,14 +46,6 @@
   }
 
   /**
-   * Returns a predicate that matches changes that are assigned to the provided {@link
-   * com.google.gerrit.entities.Account.Id}.
-   */
-  public static Predicate<ChangeData> assignee(Account.Id id) {
-    return new ChangeIndexPredicate(ChangeField.ASSIGNEE, id.toString());
-  }
-
-  /**
    * Returns a predicate that matches changes that are a revert of the provided {@link
    * com.google.gerrit.entities.Change.Id}.
    */
@@ -66,7 +58,7 @@
    * com.google.gerrit.entities.Account.Id}.
    */
   public static Predicate<ChangeData> commentBy(Account.Id id) {
-    return new ChangeIndexPredicate(ChangeField.COMMENTBY, id.toString());
+    return new ChangeIndexPredicate(ChangeField.COMMENTBY_SPEC, id.toString());
   }
 
   /**
@@ -74,7 +66,7 @@
    * com.google.gerrit.entities.Account.Id} has a pending change edit.
    */
   public static Predicate<ChangeData> editBy(Account.Id id) {
-    return new ChangeIndexPredicate(ChangeField.EDITBY, id.toString());
+    return new ChangeIndexPredicate(ChangeField.EDITBY_SPEC, id.toString());
   }
 
   /**
@@ -95,10 +87,9 @@
    * Returns a predicate that matches changes where the provided {@link
    * com.google.gerrit.entities.Account.Id} has starred changes with {@code label}.
    */
-  public static Predicate<ChangeData> starBy(
-      StarredChangesUtil starredChangesUtil, Account.Id id, String label) {
+  public static Predicate<ChangeData> starBy(StarredChangesUtil starredChangesUtil, Account.Id id) {
     Set<Predicate<ChangeData>> starredChanges =
-        starredChangesUtil.byAccountId(id, label).stream()
+        starredChangesUtil.byAccountId(id).stream()
             .map(ChangePredicates::idStr)
             .collect(toImmutableSet());
     return starredChanges.isEmpty() ? ChangeIndexPredicate.none() : Predicate.or(starredChanges);
@@ -111,7 +102,7 @@
   public static Predicate<ChangeData> reviewedBy(Collection<Account.Id> ids) {
     List<Predicate<ChangeData>> predicates = new ArrayList<>(ids.size());
     for (Account.Id id : ids) {
-      predicates.add(new ChangeIndexPredicate(ChangeField.REVIEWEDBY, id.toString()));
+      predicates.add(new ChangeIndexPredicate(ChangeField.REVIEWEDBY_SPEC, id.toString()));
     }
     return Predicate.or(predicates);
   }
@@ -119,7 +110,7 @@
   /** Returns a predicate that matches changes that were not yet reviewed. */
   public static Predicate<ChangeData> unreviewed() {
     return Predicate.not(
-        new ChangeIndexPredicate(ChangeField.REVIEWEDBY, ChangeField.NOT_REVIEWED.toString()));
+        new ChangeIndexPredicate(ChangeField.REVIEWEDBY_SPEC, ChangeField.NOT_REVIEWED.toString()));
   }
 
   /**
@@ -127,8 +118,12 @@
    * com.google.gerrit.entities.Change.Id}.
    */
   public static Predicate<ChangeData> idStr(Change.Id id) {
+    return idStr(id.toString());
+  }
+
+  public static Predicate<ChangeData> idStr(String id) {
     return new ChangeIndexCardinalPredicate(
-        ChangeField.LEGACY_ID_STR, ChangeQueryBuilder.FIELD_CHANGE, id.toString(), 1);
+        ChangeField.NUMERIC_ID_STR_SPEC, ChangeQueryBuilder.FIELD_CHANGE, id, 1);
   }
 
   /**
@@ -136,7 +131,7 @@
    * com.google.gerrit.entities.Account.Id}.
    */
   public static Predicate<ChangeData> owner(Account.Id id) {
-    return new ChangeIndexCardinalPredicate(ChangeField.OWNER, id.toString(), 5000);
+    return new ChangeIndexCardinalPredicate(ChangeField.OWNER_SPEC, id.toString(), 5000);
   }
 
   /**
@@ -144,7 +139,7 @@
    * provided {@link com.google.gerrit.entities.Account.Id}.
    */
   public static Predicate<ChangeData> uploader(Account.Id id) {
-    return new ChangeIndexPredicate(ChangeField.UPLOADER, id.toString());
+    return new ChangeIndexPredicate(ChangeField.UPLOADER_SPEC, id.toString());
   }
 
   /**
@@ -170,12 +165,12 @@
    * com.google.gerrit.entities.Project.NameKey}.
    */
   public static Predicate<ChangeData> project(Project.NameKey id) {
-    return new ChangeIndexCardinalPredicate(ChangeField.PROJECT, id.get(), 1_000_000);
+    return new ChangeIndexCardinalPredicate(ChangeField.PROJECT_SPEC, id.get(), 1_000_000);
   }
 
   /** Returns a predicate that matches changes targeted at the provided {@code refName}. */
   public static Predicate<ChangeData> ref(String refName) {
-    return new ChangeIndexCardinalPredicate(ChangeField.REF, refName, 10_000);
+    return new ChangeIndexCardinalPredicate(ChangeField.REF_SPEC, refName, 10_000);
   }
 
   /** Returns a predicate that matches changes in the provided {@code topic}. */
@@ -195,26 +190,26 @@
 
   /** Returns a predicate that matches changes submitted in the provided {@code changeSet}. */
   public static Predicate<ChangeData> submissionId(String changeSet) {
-    return new ChangeIndexPredicate(ChangeField.SUBMISSIONID, changeSet);
+    return new ChangeIndexPredicate(ChangeField.SUBMISSIONID_SPEC, changeSet);
   }
 
   /** Returns a predicate that matches changes that modified the provided {@code path}. */
   public static Predicate<ChangeData> path(String path) {
-    return new ChangeIndexPredicate(ChangeField.PATH, path);
+    return new ChangeIndexPredicate(ChangeField.PATH_SPEC, path);
   }
 
   /** Returns a predicate that matches changes tagged with the provided {@code hashtag}. */
   public static Predicate<ChangeData> hashtag(String hashtag) {
     // Use toLowerCase without locale to match behavior in ChangeField.
     return new ChangeIndexPredicate(
-        ChangeField.HASHTAG, HashtagsUtil.cleanupHashtag(hashtag).toLowerCase());
+        ChangeField.HASHTAG_SPEC, HashtagsUtil.cleanupHashtag(hashtag).toLowerCase(Locale.US));
   }
 
   /** Returns a predicate that matches changes tagged with the provided {@code hashtag}. */
   public static Predicate<ChangeData> fuzzyHashtag(String hashtag) {
     // Use toLowerCase without locale to match behavior in ChangeField.
     return new ChangeIndexPredicate(
-        ChangeField.FUZZY_HASHTAG, HashtagsUtil.cleanupHashtag(hashtag).toLowerCase());
+        ChangeField.FUZZY_HASHTAG, HashtagsUtil.cleanupHashtag(hashtag).toLowerCase(Locale.US));
   }
 
   /**
@@ -223,16 +218,16 @@
   public static Predicate<ChangeData> prefixHashtag(String hashtag) {
     // Use toLowerCase without locale to match behavior in ChangeField.
     return new ChangeIndexPredicate(
-        ChangeField.PREFIX_HASHTAG, HashtagsUtil.cleanupHashtag(hashtag).toLowerCase());
+        ChangeField.PREFIX_HASHTAG, HashtagsUtil.cleanupHashtag(hashtag).toLowerCase(Locale.US));
   }
 
   /** Returns a predicate that matches changes that modified the provided {@code file}. */
   public static Predicate<ChangeData> file(ChangeQueryBuilder.Arguments args, String file) {
     Predicate<ChangeData> eqPath = path(file);
-    if (!args.getSchema().hasField(ChangeField.FILE_PART)) {
+    if (!args.getSchema().hasField(ChangeField.FILE_PART_SPEC)) {
       return eqPath;
     }
-    return Predicate.or(eqPath, new ChangeIndexPredicate(ChangeField.FILE_PART, file));
+    return Predicate.or(eqPath, new ChangeIndexPredicate(ChangeField.FILE_PART_SPEC, file));
   }
 
   /**
@@ -247,7 +242,7 @@
     if (indexEquals > 0 && (indexEquals < indexColon || indexColon < 0)) {
       footer = footer.substring(0, indexEquals) + ": " + footer.substring(indexEquals + 1);
     }
-    return new ChangeIndexPredicate(ChangeField.FOOTER, footer.toLowerCase(Locale.US));
+    return new ChangeIndexPredicate(ChangeField.FOOTER_SPEC, footer.toLowerCase(Locale.US));
   }
 
   /**
@@ -263,22 +258,23 @@
    */
   public static Predicate<ChangeData> directory(String directory) {
     return new ChangeIndexPredicate(
-        ChangeField.DIRECTORY, CharMatcher.is('/').trimFrom(directory).toLowerCase(Locale.US));
+        ChangeField.DIRECTORY_SPEC, CharMatcher.is('/').trimFrom(directory).toLowerCase(Locale.US));
   }
 
   /** Returns a predicate that matches changes with the provided {@code trackingId}. */
   public static Predicate<ChangeData> trackingId(String trackingId) {
-    return new ChangeIndexCardinalPredicate(ChangeField.TR, trackingId, 5);
+    return new ChangeIndexCardinalPredicate(ChangeField.TR_SPEC, trackingId, 5);
   }
 
   /** Returns a predicate that matches changes authored by the provided {@code exactAuthor}. */
   public static Predicate<ChangeData> exactAuthor(String exactAuthor) {
-    return new ChangeIndexPredicate(ChangeField.EXACT_AUTHOR, exactAuthor.toLowerCase(Locale.US));
+    return new ChangeIndexPredicate(
+        ChangeField.EXACT_AUTHOR_SPEC, exactAuthor.toLowerCase(Locale.US));
   }
 
   /** Returns a predicate that matches changes authored by the provided {@code author}. */
   public static Predicate<ChangeData> author(String author) {
-    return new ChangeIndexPredicate(ChangeField.AUTHOR, author);
+    return new ChangeIndexPredicate(ChangeField.AUTHOR_PARTS_SPEC, author);
   }
 
   /**
@@ -287,7 +283,7 @@
    */
   public static Predicate<ChangeData> exactCommitter(String exactCommitter) {
     return new ChangeIndexPredicate(
-        ChangeField.EXACT_COMMITTER, exactCommitter.toLowerCase(Locale.US));
+        ChangeField.EXACT_COMMITTER_SPEC, exactCommitter.toLowerCase(Locale.US));
   }
 
   /**
@@ -295,12 +291,13 @@
    * committer}.
    */
   public static Predicate<ChangeData> committer(String comitter) {
-    return new ChangeIndexPredicate(ChangeField.COMMITTER, comitter.toLowerCase(Locale.US));
+    return new ChangeIndexPredicate(
+        ChangeField.COMMITTER_PARTS_SPEC, comitter.toLowerCase(Locale.US));
   }
 
   /** Returns a predicate that matches changes whose ID starts with the provided {@code id}. */
   public static Predicate<ChangeData> idPrefix(String id) {
-    return new ChangeIndexCardinalPredicate(ChangeField.ID, id, 5);
+    return new ChangeIndexCardinalPredicate(ChangeField.CHANGE_ID_SPEC, id, 5);
   }
 
   /**
@@ -308,7 +305,7 @@
    * its name.
    */
   public static Predicate<ChangeData> projectPrefix(String prefix) {
-    return new ChangeIndexPredicate(ChangeField.PROJECTS, prefix);
+    return new ChangeIndexPredicate(ChangeField.PROJECTS_SPEC, prefix);
   }
 
   /**
@@ -317,9 +314,9 @@
    */
   public static Predicate<ChangeData> commitPrefix(String commitId) {
     if (commitId.length() == ObjectIds.STR_LEN) {
-      return new ChangeIndexCardinalPredicate(ChangeField.EXACT_COMMIT, commitId, 5);
+      return new ChangeIndexCardinalPredicate(ChangeField.EXACT_COMMIT_SPEC, commitId, 5);
     }
-    return new ChangeIndexCardinalPredicate(ChangeField.COMMIT, commitId, 5);
+    return new ChangeIndexCardinalPredicate(ChangeField.COMMIT_SPEC, commitId, 5);
   }
 
   /**
@@ -330,12 +327,20 @@
     return new ChangeIndexPredicate(ChangeField.COMMIT_MESSAGE, message);
   }
 
+  public static Predicate<ChangeData> subject(String subject) {
+    return new ChangeIndexPredicate(ChangeField.SUBJECT_SPEC, subject);
+  }
+
+  public static Predicate<ChangeData> prefixSubject(String subject) {
+    return new ChangeIndexPredicate(ChangeField.PREFIX_SUBJECT_SPEC, subject);
+  }
+
   /**
    * Returns a predicate that matches changes where the provided {@code comment} appears in any
    * comment on any patch set of the change. Uses full-text search semantics.
    */
   public static Predicate<ChangeData> comment(String comment) {
-    return new ChangeIndexPredicate(ChangeField.COMMENT, comment);
+    return new ChangeIndexPredicate(ChangeField.COMMENT_SPEC, comment);
   }
 
   /**
@@ -346,7 +351,7 @@
    * in the form of 'gerrit~$rule_name'.
    */
   public static Predicate<ChangeData> submitRuleStatus(String value) {
-    return new ChangeIndexPredicate(ChangeField.SUBMIT_RULE_RESULT, value);
+    return new ChangeIndexPredicate(ChangeField.SUBMIT_RULE_RESULT_SPEC, value);
   }
 
   /**
@@ -354,7 +359,7 @@
    * to "1", or non-pure reverts if {@code value} is "0".
    */
   public static Predicate<ChangeData> pureRevert(String value) {
-    return new ChangeIndexPredicate(ChangeField.IS_PURE_REVERT, value);
+    return new ChangeIndexPredicate(ChangeField.IS_PURE_REVERT_SPEC, value);
   }
 
   /**
@@ -365,6 +370,6 @@
    * com.google.gerrit.entities.SubmitRequirement}s.
    */
   public static Predicate<ChangeData> isSubmittable(String value) {
-    return new ChangeIndexPredicate(ChangeField.IS_SUBMITTABLE, value);
+    return new ChangeIndexPredicate(ChangeField.IS_SUBMITTABLE_SPEC, value);
   }
 }
diff --git a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
index 4c548e0..816936b 100644
--- a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
@@ -24,11 +24,13 @@
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Enums;
 import com.google.common.base.Splitter;
+import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
 import com.google.common.primitives.Ints;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.Address;
@@ -42,9 +44,9 @@
 import com.google.gerrit.exceptions.NotSignedInException;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.index.Schema;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
 import com.google.gerrit.index.SchemaUtil;
 import com.google.gerrit.index.query.LimitPredicate;
 import com.google.gerrit.index.query.Predicate;
@@ -63,6 +65,7 @@
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.account.GroupBackends;
 import com.google.gerrit.server.account.GroupMembers;
+import com.google.gerrit.server.account.QueryList;
 import com.google.gerrit.server.account.VersionedAccountDestinations;
 import com.google.gerrit.server.account.VersionedAccountQueries;
 import com.google.gerrit.server.change.ChangeTriplet;
@@ -99,6 +102,7 @@
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
+import java.util.Locale;
 import java.util.Map;
 import java.util.Objects;
 import java.util.Optional;
@@ -151,7 +155,7 @@
   public static final String FIELD_ATTENTION_SET_USERS = "attentionusers";
   public static final String FIELD_ATTENTION_SET_USERS_COUNT = "attentionuserscount";
   public static final String FIELD_ATTENTION_SET_FULL = "attentionfull";
-  public static final String FIELD_ASSIGNEE = "assignee";
+  @Deprecated public static final String FIELD_ASSIGNEE = "assignee";
   public static final String FIELD_AUTHOR = "author";
   public static final String FIELD_EXACTAUTHOR = "exactauthor";
 
@@ -184,6 +188,8 @@
   public static final String FIELD_MERGEABLE = "mergeable2";
   public static final String FIELD_MERGED_ON = "mergedon";
   public static final String FIELD_MESSAGE = "message";
+  public static final String FIELD_SUBJECT = "subject";
+  public static final String FIELD_PREFIX_SUBJECT = "prefixsubject";
   public static final String FIELD_MESSAGE_EXACT = "messageexact";
   public static final String FIELD_OWNER = "owner";
   public static final String FIELD_OWNERIN = "ownerin";
@@ -221,9 +227,12 @@
   public static final String ARG_ID_GROUP = "group";
   public static final String ARG_ID_OWNER = "owner";
   public static final String ARG_ID_NON_UPLOADER = "non_uploader";
+  public static final String ARG_ID_NON_CONTRIBUTOR = "non_contributor";
   public static final String ARG_COUNT = "count";
   public static final Account.Id OWNER_ACCOUNT_ID = Account.id(0);
   public static final Account.Id NON_UPLOADER_ACCOUNT_ID = Account.id(-1);
+  public static final Account.Id NON_CONTRIBUTOR_ACCOUNT_ID = Account.id(-2);
+  public static final Account.Id NON_EXISTING_ACCOUNT_ID = Account.id(-1000);
 
   public static final String OPERATOR_MERGED_BEFORE = "mergedbefore";
   public static final String OPERATOR_MERGED_AFTER = "mergedafter";
@@ -407,7 +416,7 @@
       this.submitRules = submitRules;
     }
 
-    Arguments asUser(CurrentUser otherUser) {
+    public Arguments asUser(CurrentUser otherUser) {
       return new Arguments(
           queryProvider,
           rewriter,
@@ -475,21 +484,23 @@
       }
     }
 
+    @Nullable
     Schema<ChangeData> getSchema() {
       return index != null ? index.getSchema() : null;
     }
   }
 
-  private final Arguments args;
+  protected final Arguments args;
   protected Map<String, String> hasOperandAliases = Collections.emptyMap();
-  private Map<Account.Id, DestinationList> destinationListByAccount = new HashMap<>();
+  private final Map<Account.Id, DestinationList> destinationListByAccount = new HashMap<>();
+  private final Map<Account.Id, QueryList> queryListByAccount = new HashMap<>();
 
   private static final Splitter RULE_SPLITTER = Splitter.on("=");
   private static final Splitter PLUGIN_SPLITTER = Splitter.on("_");
-  private static final Splitter LABEL_SPLITTER = Splitter.on(",");
+  protected static final Splitter LABEL_SPLITTER = Splitter.on(",");
 
   @Inject
-  ChangeQueryBuilder(Arguments args) {
+  protected ChangeQueryBuilder(Arguments args) {
     this(mydef, args);
   }
 
@@ -513,6 +524,10 @@
     return new ChangeQueryBuilder(builderDef, args.asUser(user));
   }
 
+  public Arguments getArgs() {
+    return args;
+  }
+
   @Operator
   public Predicate<ChangeData> age(String value) {
     return new AgePredicate(value);
@@ -520,7 +535,7 @@
 
   @Operator
   public Predicate<ChangeData> before(String value) throws QueryParseException {
-    return new BeforePredicate(ChangeField.UPDATED, ChangeQueryBuilder.OPERATOR_BEFORE, value);
+    return new BeforePredicate(ChangeField.UPDATED_SPEC, ChangeQueryBuilder.OPERATOR_BEFORE, value);
   }
 
   @Operator
@@ -530,7 +545,7 @@
 
   @Operator
   public Predicate<ChangeData> after(String value) throws QueryParseException {
-    return new AfterPredicate(ChangeField.UPDATED, ChangeQueryBuilder.OPERATOR_AFTER, value);
+    return new AfterPredicate(ChangeField.UPDATED_SPEC, ChangeQueryBuilder.OPERATOR_AFTER, value);
   }
 
   @Operator
@@ -540,16 +555,16 @@
 
   @Operator
   public Predicate<ChangeData> mergedBefore(String value) throws QueryParseException {
-    checkFieldAvailable(ChangeField.MERGED_ON, OPERATOR_MERGED_BEFORE);
+    checkOperatorAvailable(ChangeField.MERGED_ON_SPEC, OPERATOR_MERGED_BEFORE);
     return new BeforePredicate(
-        ChangeField.MERGED_ON, ChangeQueryBuilder.OPERATOR_MERGED_BEFORE, value);
+        ChangeField.MERGED_ON_SPEC, ChangeQueryBuilder.OPERATOR_MERGED_BEFORE, value);
   }
 
   @Operator
   public Predicate<ChangeData> mergedAfter(String value) throws QueryParseException {
-    checkFieldAvailable(ChangeField.MERGED_ON, OPERATOR_MERGED_AFTER);
+    checkOperatorAvailable(ChangeField.MERGED_ON_SPEC, OPERATOR_MERGED_AFTER);
     return new AfterPredicate(
-        ChangeField.MERGED_ON, ChangeQueryBuilder.OPERATOR_MERGED_AFTER, value);
+        ChangeField.MERGED_ON_SPEC, ChangeQueryBuilder.OPERATOR_MERGED_AFTER, value);
   }
 
   @Operator
@@ -630,7 +645,7 @@
     }
 
     if ("attention".equalsIgnoreCase(value)) {
-      checkFieldAvailable(ChangeField.ATTENTION_SET_USERS, "has:attention");
+      checkOperatorAvailable(ChangeField.ATTENTION_SET_USERS, "has:attention");
       return new IsAttentionPredicate();
     }
 
@@ -673,13 +688,14 @@
     }
 
     if ("uploader".equalsIgnoreCase(value)) {
-      checkFieldAvailable(ChangeField.UPLOADER, "is:uploader");
+      checkOperatorAvailable(ChangeField.UPLOADER_SPEC, "is:uploader");
       return ChangePredicates.uploader(self());
     }
 
     if ("reviewer".equalsIgnoreCase(value)) {
       return Predicate.and(
-          Predicate.not(new BooleanPredicate(ChangeField.WIP)), ReviewerPredicate.reviewer(self()));
+          Predicate.not(new BooleanPredicate(ChangeField.WIP_SPEC)),
+          ReviewerPredicate.reviewer(self()));
     }
 
     if ("cc".equalsIgnoreCase(value)) {
@@ -688,40 +704,33 @@
 
     if ("mergeable".equalsIgnoreCase(value)) {
       if (!args.indexMergeable) {
-        throw new QueryParseException("'is:mergeable' operator is not supported by server");
+        throw new QueryParseException(
+            "'is:mergeable' operator is not supported on this gerrit host");
       }
-      return new BooleanPredicate(ChangeField.MERGEABLE);
+      return new BooleanPredicate(ChangeField.MERGEABLE_SPEC);
     }
 
     if ("merge".equalsIgnoreCase(value)) {
-      checkFieldAvailable(ChangeField.MERGE, "is:merge");
-      return new BooleanPredicate(ChangeField.MERGE);
+      checkOperatorAvailable(ChangeField.MERGE_SPEC, "is:merge");
+      return new BooleanPredicate(ChangeField.MERGE_SPEC);
     }
 
     if ("private".equalsIgnoreCase(value)) {
-      return new BooleanPredicate(ChangeField.PRIVATE);
+      return new BooleanPredicate(ChangeField.PRIVATE_SPEC);
     }
 
     if ("attention".equalsIgnoreCase(value)) {
-      checkFieldAvailable(ChangeField.ATTENTION_SET_USERS, "is:attention");
+      checkOperatorAvailable(ChangeField.ATTENTION_SET_USERS, "is:attention");
       return new IsAttentionPredicate();
     }
 
-    if ("assigned".equalsIgnoreCase(value)) {
-      return Predicate.not(ChangePredicates.assignee(Account.id(ChangeField.NO_ASSIGNEE)));
-    }
-
-    if ("unassigned".equalsIgnoreCase(value)) {
-      return ChangePredicates.assignee(Account.id(ChangeField.NO_ASSIGNEE));
-    }
-
     if ("pure-revert".equalsIgnoreCase(value)) {
-      checkFieldAvailable(ChangeField.IS_PURE_REVERT, "is:pure-revert");
+      checkOperatorAvailable(ChangeField.IS_PURE_REVERT_SPEC, "is:pure-revert");
       return ChangePredicates.pureRevert("1");
     }
 
     if ("submittable".equalsIgnoreCase(value)) {
-      if (!args.index.getSchema().hasField(ChangeField.IS_SUBMITTABLE)) {
+      if (!args.index.getSchema().hasField(ChangeField.IS_SUBMITTABLE_SPEC)) {
         // SubmittablePredicate will match if *any* of the submit records are OK,
         // but we need to check that they're *all* OK, so check that none of the
         // submit records match any of the negative cases. To avoid checking yet
@@ -732,22 +741,22 @@
             Predicate.not(new SubmittablePredicate(SubmitRecord.Status.NOT_READY)),
             Predicate.not(new SubmittablePredicate(SubmitRecord.Status.RULE_ERROR)));
       }
-      checkFieldAvailable(ChangeField.IS_SUBMITTABLE, "is:submittable");
+      checkOperatorAvailable(ChangeField.IS_SUBMITTABLE_SPEC, "is:submittable");
       return new IsSubmittablePredicate();
     }
 
     if ("started".equalsIgnoreCase(value)) {
-      checkFieldAvailable(ChangeField.STARTED, "is:started");
-      return new BooleanPredicate(ChangeField.STARTED);
+      checkOperatorAvailable(ChangeField.STARTED_SPEC, "is:started");
+      return new BooleanPredicate(ChangeField.STARTED_SPEC);
     }
 
     if ("wip".equalsIgnoreCase(value)) {
-      return new BooleanPredicate(ChangeField.WIP);
+      return new BooleanPredicate(ChangeField.WIP_SPEC);
     }
 
     if ("cherrypick".equalsIgnoreCase(value)) {
-      checkFieldAvailable(ChangeField.CHERRY_PICK, "is:cherrypick");
-      return new BooleanPredicate(ChangeField.CHERRY_PICK);
+      checkOperatorAvailable(ChangeField.CHERRY_PICK_SPEC, "is:cherrypick");
+      return new BooleanPredicate(ChangeField.CHERRY_PICK_SPEC);
     }
 
     // for plugins the value will be operandName_pluginName
@@ -769,7 +778,7 @@
   @Operator
   public Predicate<ChangeData> conflicts(String value) throws QueryParseException {
     if (!args.conflictsPredicateEnabled) {
-      throw new QueryParseException("'conflicts:' operator is not supported by server");
+      throw new QueryParseException("'conflicts:' operator is not supported on this gerrit host");
     }
     List<Change> changes = parseChange(value);
     List<Predicate<ChangeData>> or = new ArrayList<>(changes.size());
@@ -881,7 +890,7 @@
       return ChangePredicates.hashtag(hashtag);
     }
 
-    checkFieldAvailable(ChangeField.FUZZY_HASHTAG, "inhashtag");
+    checkOperatorAvailable(ChangeField.FUZZY_HASHTAG, "inhashtag");
     return ChangePredicates.fuzzyHashtag(hashtag);
   }
 
@@ -891,7 +900,7 @@
       return ChangePredicates.hashtag(hashtag);
     }
 
-    checkFieldAvailable(ChangeField.PREFIX_HASHTAG, "prefixhashtag");
+    checkOperatorAvailable(ChangeField.PREFIX_HASHTAG, "prefixhashtag");
     return ChangePredicates.prefixHashtag(hashtag);
   }
 
@@ -917,7 +926,7 @@
       return ChangePredicates.exactTopic(name);
     }
 
-    checkFieldAvailable(ChangeField.PREFIX_TOPIC, "prefixtopic");
+    checkOperatorAvailable(ChangeField.PREFIX_TOPIC, "prefixtopic");
     return ChangePredicates.prefixTopic(name);
   }
 
@@ -983,7 +992,7 @@
 
   @Operator
   public Predicate<ChangeData> hasfooter(String footerName) throws QueryParseException {
-    checkFieldAvailable(ChangeField.FOOTER_NAME, "hasfooter");
+    checkOperatorAvailable(ChangeField.FOOTER_NAME, "hasfooter");
     return ChangePredicates.hasFooter(footerName);
   }
 
@@ -1039,8 +1048,10 @@
             accounts = Collections.singleton(OWNER_ACCOUNT_ID);
           } else if (value.equals(ARG_ID_NON_UPLOADER)) {
             accounts = Collections.singleton(NON_UPLOADER_ACCOUNT_ID);
+          } else if (value.equals(ARG_ID_NON_CONTRIBUTOR)) {
+            accounts = Collections.singleton(NON_CONTRIBUTOR_ACCOUNT_ID);
           } else {
-            accounts = parseAccount(value);
+            accounts = parseAccountIgnoreVisibility(value);
           }
         } else if (key.equalsIgnoreCase(ARG_ID_GROUP)) {
           group = parseGroup(value).getUUID();
@@ -1068,15 +1079,17 @@
         if (accounts != null || group != null) {
           throw new QueryParseException("more than one user/group specified (" + value + ")");
         }
-        try {
-          if (value.equals(ARG_ID_OWNER)) {
-            accounts = Collections.singleton(OWNER_ACCOUNT_ID);
-          } else if (value.equals(ARG_ID_NON_UPLOADER)) {
-            accounts = Collections.singleton(NON_UPLOADER_ACCOUNT_ID);
-          } else {
-            accounts = parseAccount(value);
-          }
-        } catch (QueryParseException qpex) {
+        if (value.equals(ARG_ID_OWNER)) {
+          accounts = Collections.singleton(OWNER_ACCOUNT_ID);
+        } else if (value.equals(ARG_ID_NON_UPLOADER)) {
+          accounts = Collections.singleton(NON_UPLOADER_ACCOUNT_ID);
+        } else if (value.equals(ARG_ID_NON_CONTRIBUTOR)) {
+          accounts = Collections.singleton(NON_CONTRIBUTOR_ACCOUNT_ID);
+        } else {
+          accounts = parseAccountIgnoreVisibility(value);
+        }
+
+        if (accounts.contains(NON_EXISTING_ACCOUNT_ID)) {
           // If it doesn't match an account, see if it matches a group
           // (accounts get precedence)
           try {
@@ -1096,7 +1109,7 @@
     // submit record status, interpret as a submit record query.
     int eq = name.indexOf('=');
     if (eq > 0) {
-      String statusName = name.substring(eq + 1).toUpperCase();
+      String statusName = name.substring(eq + 1).toUpperCase(Locale.US);
       if (!isInt(statusName) && !MagicLabelValue.tryParse(statusName).isPresent()) {
         SubmitRecord.Label.Status status =
             Enums.getIfPresent(SubmitRecord.Label.Status.class, statusName).orNull();
@@ -1107,9 +1120,16 @@
       }
     }
 
+    validateLabelArgs(accounts);
     return new LabelPredicate(args, name, accounts, group, count, countOp);
   }
 
+  protected void validateLabelArgs(Set<Account.Id> accounts) throws QueryParseException {
+    if (accounts != null && accounts.contains(NON_CONTRIBUTOR_ACCOUNT_ID)) {
+      throw new QueryParseException("non_contributor arg is not allowed in change queries");
+    }
+  }
+
   /** Assert that keys {@code k1} and {@code k2} do not exist in {@code labelArgs} together. */
   private void assertDisjunctive(PredicateArgs labelArgs, String k1, String k2)
       throws QueryParseException {
@@ -1134,15 +1154,29 @@
   @Operator
   public Predicate<ChangeData> message(String text) throws QueryParseException {
     if (text.startsWith("^")) {
-      checkFieldAvailable(ChangeField.COMMIT_MESSAGE_EXACT, "messageexact");
+      checkFieldAvailable(
+          ChangeField.COMMIT_MESSAGE_EXACT,
+          "'message' operator with regular expression is not supported on this gerrit host");
       return new RegexMessagePredicate(text);
     }
     return ChangePredicates.message(text);
   }
 
+  @Operator
+  public Predicate<ChangeData> subject(String value) throws QueryParseException {
+    checkOperatorAvailable(ChangeField.SUBJECT_SPEC, ChangeQueryBuilder.FIELD_SUBJECT);
+    return ChangePredicates.subject(value);
+  }
+
+  @Operator
+  public Predicate<ChangeData> prefixsubject(String value) throws QueryParseException {
+    checkOperatorAvailable(
+        ChangeField.PREFIX_SUBJECT_SPEC, ChangeQueryBuilder.FIELD_PREFIX_SUBJECT);
+    return ChangePredicates.prefixSubject(value);
+  }
+
   private Predicate<ChangeData> starredBySelf() throws QueryParseException {
-    return ChangePredicates.starBy(
-        args.starredChangesUtil, self(), StarredChangesUtil.DEFAULT_LABEL);
+    return ChangePredicates.starBy(args.starredChangesUtil, self());
   }
 
   private Predicate<ChangeData> draftBySelf() throws QueryParseException {
@@ -1201,7 +1235,7 @@
   @Operator
   public Predicate<ChangeData> owner(String who)
       throws QueryParseException, IOException, ConfigInvalidException {
-    return owner(parseAccount(who, (AccountState s) -> true));
+    return owner(parseAccountIgnoreVisibility(who, (AccountState s) -> true));
   }
 
   private Predicate<ChangeData> owner(Set<Account.Id> who) {
@@ -1214,7 +1248,7 @@
 
   private Predicate<ChangeData> ownerDefaultField(String who)
       throws QueryParseException, IOException, ConfigInvalidException {
-    Set<Account.Id> accounts = parseAccount(who);
+    Set<Account.Id> accounts = parseAccountIgnoreVisibility(who);
     if (accounts.size() > MAX_ACCOUNTS_PER_DEFAULT_FIELD) {
       return Predicate.any();
     }
@@ -1224,8 +1258,8 @@
   @Operator
   public Predicate<ChangeData> uploader(String who)
       throws QueryParseException, IOException, ConfigInvalidException {
-    checkFieldAvailable(ChangeField.UPLOADER, "uploader");
-    return uploader(parseAccount(who, (AccountState s) -> true));
+    checkOperatorAvailable(ChangeField.UPLOADER_SPEC, "uploader");
+    return uploader(parseAccountIgnoreVisibility(who, (AccountState s) -> true));
   }
 
   private Predicate<ChangeData> uploader(Set<Account.Id> who) {
@@ -1239,8 +1273,8 @@
   @Operator
   public Predicate<ChangeData> attention(String who)
       throws QueryParseException, IOException, ConfigInvalidException {
-    checkFieldAvailable(ChangeField.ATTENTION_SET_USERS, "attention");
-    return attention(parseAccount(who, (AccountState s) -> true));
+    checkOperatorAvailable(ChangeField.ATTENTION_SET_USERS, "attention");
+    return attention(parseAccountIgnoreVisibility(who, (AccountState s) -> true));
   }
 
   private Predicate<ChangeData> attention(Set<Account.Id> who) {
@@ -1248,20 +1282,6 @@
   }
 
   @Operator
-  public Predicate<ChangeData> assignee(String who)
-      throws QueryParseException, IOException, ConfigInvalidException {
-    return assignee(parseAccount(who, (AccountState s) -> true));
-  }
-
-  private Predicate<ChangeData> assignee(Set<Account.Id> who) {
-    List<Predicate<ChangeData>> p = Lists.newArrayListWithCapacity(who.size());
-    for (Account.Id id : who) {
-      p.add(ChangePredicates.assignee(id));
-    }
-    return Predicate.or(p);
-  }
-
-  @Operator
   public Predicate<ChangeData> ownerin(String group) throws QueryParseException, IOException {
     GroupReference g = parseGroup(group);
     AccountGroup.UUID groupId = g.getUUID();
@@ -1279,7 +1299,7 @@
 
   @Operator
   public Predicate<ChangeData> uploaderin(String group) throws QueryParseException, IOException {
-    checkFieldAvailable(ChangeField.UPLOADER, "uploaderin");
+    checkOperatorAvailable(ChangeField.UPLOADER_SPEC, "uploaderin");
 
     GroupReference g = parseGroup(group);
     AccountGroup.UUID groupId = g.getUUID();
@@ -1319,7 +1339,7 @@
     if (Objects.equals(byState, Predicate.<ChangeData>any())) {
       return Predicate.any();
     }
-    return Predicate.and(Predicate.not(new BooleanPredicate(ChangeField.WIP)), byState);
+    return Predicate.and(Predicate.not(new BooleanPredicate(ChangeField.WIP_SPEC)), byState);
   }
 
   @Operator
@@ -1376,7 +1396,7 @@
   @Operator
   public Predicate<ChangeData> commentby(String who)
       throws QueryParseException, IOException, ConfigInvalidException {
-    return commentby(parseAccount(who));
+    return commentby(parseAccountIgnoreVisibility(who));
   }
 
   private Predicate<ChangeData> commentby(Set<Account.Id> who) {
@@ -1390,7 +1410,7 @@
   @Operator
   public Predicate<ChangeData> from(String who)
       throws QueryParseException, IOException, ConfigInvalidException {
-    Set<Account.Id> ownerIds = parseAccount(who);
+    Set<Account.Id> ownerIds = parseAccountIgnoreVisibility(who);
     return Predicate.or(owner(ownerIds), commentby(ownerIds));
   }
 
@@ -1401,16 +1421,16 @@
     String name = null;
     Account.Id account = null;
 
-    try (Repository git = args.repoManager.openRepository(args.allUsersName)) {
-      // [name=]<name>
-      if (inputArgs.keyValue.containsKey(ARG_ID_NAME)) {
-        name = inputArgs.keyValue.get(ARG_ID_NAME).value();
-      } else if (inputArgs.positional.size() == 1) {
-        name = Iterables.getOnlyElement(inputArgs.positional);
-      } else if (inputArgs.positional.size() > 1) {
-        throw new QueryParseException("Error parsing named query: " + value);
-      }
+    // [name=]<name>
+    if (inputArgs.keyValue.containsKey(ARG_ID_NAME)) {
+      name = inputArgs.keyValue.get(ARG_ID_NAME).value();
+    } else if (inputArgs.positional.size() == 1) {
+      name = Iterables.getOnlyElement(inputArgs.positional);
+    } else if (inputArgs.positional.size() > 1) {
+      throw new QueryParseException("Error parsing named query: " + value);
+    }
 
+    try {
       // [,user=<user>]
       if (inputArgs.keyValue.containsKey(ARG_ID_USER)) {
         Set<Account.Id> accounts = parseAccount(inputArgs.keyValue.get(ARG_ID_USER).value());
@@ -1424,9 +1444,7 @@
         account = self();
       }
 
-      VersionedAccountQueries q = VersionedAccountQueries.forUser(account);
-      q.load(args.allUsersName, git);
-      String query = q.getQueryList().getQuery(name);
+      String query = getQueryList(account).getQuery(name);
       if (query != null) {
         return parse(query);
       }
@@ -1439,10 +1457,27 @@
     throw new QueryParseException("Unknown named query: " + name);
   }
 
+  protected QueryList getQueryList(Account.Id account) throws ConfigInvalidException, IOException {
+    QueryList ql = queryListByAccount.get(account);
+    if (ql == null) {
+      ql = loadQueryList(account);
+      queryListByAccount.put(account, ql);
+    }
+    return ql;
+  }
+
+  protected QueryList loadQueryList(Account.Id account) throws ConfigInvalidException, IOException {
+    VersionedAccountQueries q = VersionedAccountQueries.forUser(account);
+    try (Repository git = args.repoManager.openRepository(args.allUsersName)) {
+      q.load(args.allUsersName, git);
+    }
+    return q.getQueryList();
+  }
+
   @Operator
   public Predicate<ChangeData> reviewedby(String who)
       throws QueryParseException, IOException, ConfigInvalidException {
-    return ChangePredicates.reviewedBy(parseAccount(who));
+    return ChangePredicates.reviewedBy(parseAccountIgnoreVisibility(who));
   }
 
   @Operator
@@ -1452,16 +1487,16 @@
     String name = null;
     Account.Id account = null;
 
-    try (Repository git = args.repoManager.openRepository(args.allUsersName)) {
-      // [name=]<name>
-      if (inputArgs.keyValue.containsKey(ARG_ID_NAME)) {
-        name = inputArgs.keyValue.get(ARG_ID_NAME).value();
-      } else if (inputArgs.positional.size() == 1) {
-        name = Iterables.getOnlyElement(inputArgs.positional);
-      } else if (inputArgs.positional.size() > 1) {
-        throw new QueryParseException("Error parsing named destination: " + value);
-      }
+    // [name=]<name>
+    if (inputArgs.keyValue.containsKey(ARG_ID_NAME)) {
+      name = inputArgs.keyValue.get(ARG_ID_NAME).value();
+    } else if (inputArgs.positional.size() == 1) {
+      name = Iterables.getOnlyElement(inputArgs.positional);
+    } else if (inputArgs.positional.size() > 1) {
+      throw new QueryParseException("Error parsing named destination: " + value);
+    }
 
+    try {
       // [,user=<user>]
       if (inputArgs.keyValue.containsKey(ARG_ID_USER)) {
         Set<Account.Id> accounts = parseAccount(inputArgs.keyValue.get(ARG_ID_USER).value());
@@ -1475,9 +1510,9 @@
         account = self();
       }
 
-      Set<BranchNameKey> destinations = getDestinationList(git, account).getDestinations(name);
+      Set<BranchNameKey> destinations = getDestinationList(account).getDestinations(name);
       if (destinations != null && !destinations.isEmpty()) {
-        return new DestinationPredicate(destinations, value);
+        return new BranchSetIndexPredicate(FIELD_DESTINATION + ":" + value, destinations);
       }
     } catch (RepositoryNotFoundException e) {
       throw new QueryParseException(
@@ -1488,24 +1523,31 @@
     throw new QueryParseException("Unknown named destination: " + name);
   }
 
-  protected DestinationList getDestinationList(Repository git, Account.Id account)
+  protected DestinationList getDestinationList(Account.Id account)
       throws ConfigInvalidException, RepositoryNotFoundException, IOException {
     DestinationList dl = destinationListByAccount.get(account);
     if (dl == null) {
-      dl = loadDestinationList(git, account);
+      dl = loadDestinationList(account);
       destinationListByAccount.put(account, dl);
     }
     return dl;
   }
 
-  protected DestinationList loadDestinationList(Repository git, Account.Id account)
+  protected DestinationList loadDestinationList(Account.Id account)
       throws ConfigInvalidException, RepositoryNotFoundException, IOException {
     VersionedAccountDestinations d = VersionedAccountDestinations.forUser(account);
-    d.load(args.allUsersName, git);
+    try (Repository git = args.repoManager.openRepository(args.allUsersName)) {
+      d.load(args.allUsersName, git);
+    }
     return d.getDestinationList();
   }
 
   @Operator
+  public Predicate<ChangeData> a(String who) throws QueryParseException {
+    return author(who);
+  }
+
+  @Operator
   public Predicate<ChangeData> author(String who) throws QueryParseException {
     return getAuthorOrCommitterPredicate(
         who.trim(), ChangePredicates::exactAuthor, ChangePredicates::author);
@@ -1537,8 +1579,8 @@
 
   @Operator
   public Predicate<ChangeData> cherryPickOf(String value) throws QueryParseException {
-    checkFieldAvailable(ChangeField.CHERRY_PICK_OF_CHANGE, "cherryPickOf");
-    checkFieldAvailable(ChangeField.CHERRY_PICK_OF_PATCHSET, "cherryPickOf");
+    checkOperatorAvailable(ChangeField.CHERRY_PICK_OF_CHANGE, "cherryPickOf");
+    checkOperatorAvailable(ChangeField.CHERRY_PICK_OF_PATCHSET, "cherryPickOf");
     if (Ints.tryParse(value) != null) {
       return ChangePredicates.cherryPickOf(Change.id(Ints.tryParse(value)));
     }
@@ -1610,11 +1652,16 @@
     return Predicate.or(predicates);
   }
 
-  protected void checkFieldAvailable(FieldDef<ChangeData, ?> field, String operator)
+  private void checkOperatorAvailable(SchemaField<ChangeData, ?> field, String operator)
+      throws QueryParseException {
+    checkFieldAvailable(
+        field, String.format("'%s' operator is not supported on this gerrit host", operator));
+  }
+
+  protected void checkFieldAvailable(SchemaField<ChangeData, ?> field, String errorMessage)
       throws QueryParseException {
     if (!args.index.getSchema().hasField(field)) {
-      throw new QueryParseException(
-          String.format("'%s' operator is not supported by change index version", operator));
+      throw new QueryParseException(errorMessage);
     }
   }
 
@@ -1665,7 +1712,7 @@
   private Set<Account.Id> parseAccount(String who)
       throws QueryParseException, IOException, ConfigInvalidException {
     try {
-      return args.accountResolver.resolve(who).asNonEmptyIdSet();
+      return args.accountResolver.resolveAsUser(args.getUser(), who).asNonEmptyIdSet();
     } catch (UnresolvableAccountException e) {
       if (e.isSelf()) {
         throw new QueryRequiresAuthException(e.getMessage(), e);
@@ -1674,16 +1721,42 @@
     }
   }
 
-  private Set<Account.Id> parseAccount(
-      String who, java.util.function.Predicate<AccountState> activityFilter)
-      throws QueryParseException, IOException, ConfigInvalidException {
+  private Set<Account.Id> parseAccountIgnoreVisibility(String who)
+      throws QueryRequiresAuthException, IOException, ConfigInvalidException {
     try {
-      return args.accountResolver.resolve(who, activityFilter).asNonEmptyIdSet();
+      return args.accountResolver
+          .resolveAsUserIgnoreVisibility(args.getUser(), who)
+          .asNonEmptyIdSet();
     } catch (UnresolvableAccountException e) {
       if (e.isSelf()) {
         throw new QueryRequiresAuthException(e.getMessage(), e);
       }
-      throw new QueryParseException(e.getMessage(), e);
+      return ImmutableSet.of(NON_EXISTING_ACCOUNT_ID);
+    }
+  }
+
+  private Set<Account.Id> parseAccountIgnoreVisibility(
+      String who, java.util.function.Predicate<AccountState> activityFilter)
+      throws QueryRequiresAuthException, IOException, ConfigInvalidException {
+    try {
+      return args.accountResolver
+          .resolveAsUserIgnoreVisibility(args.getUser(), who, activityFilter)
+          .asNonEmptyIdSet();
+    } catch (UnresolvableAccountException e) {
+      // Thrown if no account was found.
+
+      // Users can always see their own account. This means if self was being resolved and there was
+      // no match the user wasn't logged in and the request was done anonymously.
+      if (e.isSelf()) {
+        throw new QueryRequiresAuthException(e.getMessage(), e);
+      }
+
+      // If no account is found, we don't want to fail with an error as this would allow users to
+      // probe the existence of accounts (error -> account doesn't exist, empty result -> account
+      // exists but didn't take part in any visible changes). Hence, we return a special account ID
+      // (NON_EXISTING_ACCOUNT_ID) that doesn't match any account so the query can be normally
+      // executed
+      return ImmutableSet.of(NON_EXISTING_ACCOUNT_ID);
     }
   }
 
@@ -1724,7 +1797,8 @@
     return value;
   }
 
-  private Account.Id self() throws QueryParseException {
+  /** Returns {@link com.google.gerrit.entities.Account.Id} of the identified calling user. */
+  public Account.Id self() throws QueryParseException {
     return args.getIdentifiedUser().getAccountId();
   }
 
@@ -1739,7 +1813,7 @@
 
     Predicate<ChangeData> reviewerPredicate = null;
     try {
-      Set<Account.Id> accounts = parseAccount(who);
+      Set<Account.Id> accounts = parseAccountIgnoreVisibility(who);
       if (!forDefaultField || accounts.size() <= MAX_ACCOUNTS_PER_DEFAULT_FIELD) {
         reviewerPredicate =
             Predicate.or(
diff --git a/java/com/google/gerrit/server/query/change/ChangeRegexPredicate.java b/java/com/google/gerrit/server/query/change/ChangeRegexPredicate.java
index 24b8b7a..d1c487e 100644
--- a/java/com/google/gerrit/server/query/change/ChangeRegexPredicate.java
+++ b/java/com/google/gerrit/server/query/change/ChangeRegexPredicate.java
@@ -14,17 +14,17 @@
 
 package com.google.gerrit.server.query.change;
 
-import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
 import com.google.gerrit.index.query.Matchable;
 import com.google.gerrit.index.query.RegexPredicate;
 
 public abstract class ChangeRegexPredicate extends RegexPredicate<ChangeData>
     implements Matchable<ChangeData> {
-  protected ChangeRegexPredicate(FieldDef<ChangeData, ?> def, String value) {
+  protected ChangeRegexPredicate(SchemaField<ChangeData, ?> def, String value) {
     super(def, value);
   }
 
-  protected ChangeRegexPredicate(FieldDef<ChangeData, ?> def, String name, String value) {
+  protected ChangeRegexPredicate(SchemaField<ChangeData, ?> def, String name, String value) {
     super(def, name, value);
   }
 }
diff --git a/java/com/google/gerrit/server/query/change/ChangeStatusPredicate.java b/java/com/google/gerrit/server/query/change/ChangeStatusPredicate.java
index 5840db4..d949ea8 100644
--- a/java/com/google/gerrit/server/query/change/ChangeStatusPredicate.java
+++ b/java/com/google/gerrit/server/query/change/ChangeStatusPredicate.java
@@ -26,6 +26,7 @@
 import com.google.gerrit.server.index.change.ChangeField;
 import java.util.ArrayList;
 import java.util.List;
+import java.util.Locale;
 import java.util.Map;
 import java.util.NavigableMap;
 import java.util.Objects;
@@ -74,11 +75,11 @@
   }
 
   public static String canonicalize(Change.Status status) {
-    return status.name().toLowerCase();
+    return status.name().toLowerCase(Locale.US);
   }
 
   public static Predicate<ChangeData> parse(String value) throws QueryParseException {
-    String lower = value.toLowerCase();
+    String lower = value.toLowerCase(Locale.US);
     NavigableMap<String, Predicate<ChangeData>> head = PREDICATES.tailMap(lower, true);
     if (!head.isEmpty()) {
       // Assume no statuses share a common prefix so we can only walk one entry.
@@ -105,7 +106,7 @@
   @Nullable private final Change.Status status;
 
   private ChangeStatusPredicate(@Nullable Change.Status status) {
-    super(ChangeField.STATUS, status != null ? canonicalize(status) : INVALID_STATUS);
+    super(ChangeField.STATUS_SPEC, status != null ? canonicalize(status) : INVALID_STATUS);
     this.status = status;
   }
 
diff --git a/java/com/google/gerrit/server/query/change/DeletedPredicate.java b/java/com/google/gerrit/server/query/change/DeletedPredicate.java
index d4bdc67..40e4c6e 100644
--- a/java/com/google/gerrit/server/query/change/DeletedPredicate.java
+++ b/java/com/google/gerrit/server/query/change/DeletedPredicate.java
@@ -19,11 +19,11 @@
 
 public class DeletedPredicate extends IntegerRangeChangePredicate {
   public DeletedPredicate(String value) throws QueryParseException {
-    super(ChangeField.DELETED, value);
+    super(ChangeField.DELETED_LINES_SPEC, value);
   }
 
   @Override
   protected Integer getValueInt(ChangeData changeData) {
-    return ChangeField.DELETED.get(changeData);
+    return ChangeField.DELETED_LINES_SPEC.get(changeData);
   }
 }
diff --git a/java/com/google/gerrit/server/query/change/DeltaPredicate.java b/java/com/google/gerrit/server/query/change/DeltaPredicate.java
index 821ec94..e9eaa32 100644
--- a/java/com/google/gerrit/server/query/change/DeltaPredicate.java
+++ b/java/com/google/gerrit/server/query/change/DeltaPredicate.java
@@ -19,11 +19,11 @@
 
 public class DeltaPredicate extends IntegerRangeChangePredicate {
   public DeltaPredicate(String value) throws QueryParseException {
-    super(ChangeField.DELTA, value);
+    super(ChangeField.DELTA_LINES_SPEC, value);
   }
 
   @Override
   protected Integer getValueInt(ChangeData changeData) {
-    return ChangeField.DELTA.get(changeData);
+    return ChangeField.DELTA_LINES_SPEC.get(changeData);
   }
 }
diff --git a/java/com/google/gerrit/server/query/change/DestinationPredicate.java b/java/com/google/gerrit/server/query/change/DestinationPredicate.java
deleted file mode 100644
index 3c3d70f..0000000
--- a/java/com/google/gerrit/server/query/change/DestinationPredicate.java
+++ /dev/null
@@ -1,43 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.query.change;
-
-import com.google.gerrit.entities.BranchNameKey;
-import com.google.gerrit.entities.Change;
-import com.google.gerrit.index.query.PostFilterPredicate;
-import java.util.Set;
-
-public class DestinationPredicate extends PostFilterPredicate<ChangeData> {
-  protected Set<BranchNameKey> destinations;
-
-  public DestinationPredicate(Set<BranchNameKey> destinations, String value) {
-    super(ChangeQueryBuilder.FIELD_DESTINATION, value);
-    this.destinations = destinations;
-  }
-
-  @Override
-  public boolean match(ChangeData object) {
-    Change change = object.change();
-    if (change == null) {
-      return false;
-    }
-    return destinations.contains(change.getDest());
-  }
-
-  @Override
-  public int getCost() {
-    return 1;
-  }
-}
diff --git a/java/com/google/gerrit/server/query/change/EqualsLabelPredicates.java b/java/com/google/gerrit/server/query/change/EqualsLabelPredicates.java
index f572063..0d6dc3c 100644
--- a/java/com/google/gerrit/server/query/change/EqualsLabelPredicates.java
+++ b/java/com/google/gerrit/server/query/change/EqualsLabelPredicates.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.query.change;
 
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
@@ -24,6 +25,8 @@
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.index.query.PostFilterPredicate;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.AccountResolver;
+import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.permissions.ChangePermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
@@ -31,9 +34,14 @@
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.query.change.ChangeData.StorageConstraint;
+import java.io.IOException;
+import java.util.List;
 import java.util.Optional;
+import org.eclipse.jgit.errors.ConfigInvalidException;
 
 public class EqualsLabelPredicates {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   public static class PostFilterEqualsLabelPredicate extends PostFilterPredicate<ChangeData> {
     private final Matcher matcher;
 
@@ -66,9 +74,9 @@
         LabelPredicate.Args args,
         String label,
         int expVal,
-        Account.Id account,
+        @Nullable Account.Id account,
         @Nullable Integer count) {
-      super(ChangeField.LABEL, ChangeField.formatLabel(label, expVal, account, count));
+      super(ChangeField.LABEL_SPEC, ChangeField.formatLabel(label, expVal, account, count));
       this.matcher = new Matcher(args, label, expVal, account, count);
     }
 
@@ -84,6 +92,7 @@
   }
 
   private static class Matcher {
+    protected final AccountResolver accountResolver;
     protected final ProjectCache projectCache;
     protected final PermissionBackend permissionBackend;
     protected final IdentifiedUser.GenericFactory userFactory;
@@ -99,7 +108,7 @@
     @Nullable protected final Integer count;
 
     /** Account ID that has voted on the label. */
-    protected final Account.Id account;
+    @Nullable protected final Account.Id account;
 
     protected final AccountGroup.UUID group;
 
@@ -111,9 +120,10 @@
         LabelPredicate.Args args,
         String label,
         int expVal,
-        Account.Id account,
+        @Nullable Account.Id account,
         @Nullable Integer count) {
       this.permissionBackend = args.permissionBackend;
+      this.accountResolver = args.accountResolver;
       this.projectCache = args.projectCache;
       this.userFactory = args.userFactory;
       this.group = args.group;
@@ -192,6 +202,14 @@
             && cd.currentPatchSet().uploader().equals(approver)) {
           return false;
         }
+
+        if (account.equals(ChangeQueryBuilder.NON_CONTRIBUTOR_ACCOUNT_ID)) {
+          if ((cd.currentPatchSet().uploader().equals(approver)
+              || matchAccount(cd.getCommitter().getEmailAddress(), approver)
+              || matchAccount(cd.getAuthor().getEmailAddress(), approver))) {
+            return false;
+          }
+        }
       }
 
       IdentifiedUser reviewer = userFactory.create(approver);
@@ -213,12 +231,29 @@
       }
     }
 
+    /**
+     * Returns true if the {@code email} parameter belongs to the account identified by the {@code
+     * accountId} parameter.
+     */
+    private boolean matchAccount(String email, Account.Id accountId) {
+      try {
+        List<AccountState> accountsList = accountResolver.resolve(email).asList();
+        return accountsList.stream().anyMatch(c -> c.account().id().equals(accountId));
+      } catch (ConfigInvalidException | IOException e) {
+        logger.atWarning().withCause(e).log("Failed to resolve account %s", email);
+      }
+      return false;
+    }
+
     private boolean isMagicUser() {
-      return account.equals(ChangeQueryBuilder.OWNER_ACCOUNT_ID)
-          || account.equals(ChangeQueryBuilder.NON_UPLOADER_ACCOUNT_ID);
+      return account != null
+          && (account.equals(ChangeQueryBuilder.OWNER_ACCOUNT_ID)
+              || account.equals(ChangeQueryBuilder.NON_UPLOADER_ACCOUNT_ID)
+              || account.equals(ChangeQueryBuilder.NON_CONTRIBUTOR_ACCOUNT_ID));
     }
   }
 
+  @Nullable
   public static LabelType type(LabelTypes types, String toFind) {
     if (types.byLabel(toFind).isPresent()) {
       return types.byLabel(toFind).get();
diff --git a/java/com/google/gerrit/server/query/change/FileExtensionListPredicate.java b/java/com/google/gerrit/server/query/change/FileExtensionListPredicate.java
index c16bc83..830df98 100644
--- a/java/com/google/gerrit/server/query/change/FileExtensionListPredicate.java
+++ b/java/com/google/gerrit/server/query/change/FileExtensionListPredicate.java
@@ -29,7 +29,7 @@
   }
 
   FileExtensionListPredicate(String value) {
-    super(ChangeField.ONLY_EXTENSIONS, clean(value));
+    super(ChangeField.ONLY_EXTENSIONS_SPEC, clean(value));
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/query/change/FileExtensionPredicate.java b/java/com/google/gerrit/server/query/change/FileExtensionPredicate.java
index 39715cf..d15c5dc 100644
--- a/java/com/google/gerrit/server/query/change/FileExtensionPredicate.java
+++ b/java/com/google/gerrit/server/query/change/FileExtensionPredicate.java
@@ -26,7 +26,7 @@
   }
 
   FileExtensionPredicate(String value) {
-    super(ChangeField.EXTENSION, clean(value));
+    super(ChangeField.EXTENSION_SPEC, clean(value));
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/query/change/GroupPredicate.java b/java/com/google/gerrit/server/query/change/GroupPredicate.java
index f470cf9..c4aba0d 100644
--- a/java/com/google/gerrit/server/query/change/GroupPredicate.java
+++ b/java/com/google/gerrit/server/query/change/GroupPredicate.java
@@ -20,7 +20,7 @@
 
 public class GroupPredicate extends ChangeIndexPredicate {
   public GroupPredicate(String group) {
-    super(ChangeField.GROUP, group);
+    super(ChangeField.GROUP_SPEC, group);
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/query/change/IntegerRangeChangePredicate.java b/java/com/google/gerrit/server/query/change/IntegerRangeChangePredicate.java
index 312c04e..b6059f7 100644
--- a/java/com/google/gerrit/server/query/change/IntegerRangeChangePredicate.java
+++ b/java/com/google/gerrit/server/query/change/IntegerRangeChangePredicate.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.query.change;
 
-import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
 import com.google.gerrit.index.query.IntegerRangePredicate;
 import com.google.gerrit.index.query.Matchable;
 import com.google.gerrit.index.query.QueryParseException;
@@ -22,7 +22,7 @@
 public abstract class IntegerRangeChangePredicate extends IntegerRangePredicate<ChangeData>
     implements Matchable<ChangeData> {
 
-  protected IntegerRangeChangePredicate(FieldDef<ChangeData, Integer> type, String value)
+  protected IntegerRangeChangePredicate(SchemaField<ChangeData, Integer> type, String value)
       throws QueryParseException {
     super(type, value);
   }
diff --git a/java/com/google/gerrit/server/query/change/InternalChangeQuery.java b/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
index 99c1ca1..62c070c 100644
--- a/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
+++ b/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
@@ -26,6 +26,7 @@
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Sets;
+import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Project;
@@ -103,6 +104,7 @@
     return query(ChangePredicates.idStr(id));
   }
 
+  @UsedAt(UsedAt.Project.GOOGLE)
   public List<ChangeData> byLegacyChangeIds(Collection<Change.Id> ids) {
     List<Predicate<ChangeData>> preds = new ArrayList<>(ids.size());
     for (Change.Id id : ids) {
@@ -115,15 +117,6 @@
     return query(byBranchKeyPred(branch, key));
   }
 
-  public List<ChangeData> byBranchKeyOpen(Project.NameKey project, String branch, Change.Key key) {
-    return query(and(byBranchKeyPred(BranchNameKey.create(project, branch), key), open()));
-  }
-
-  public static Predicate<ChangeData> byBranchKeyOpenPred(
-      Project.NameKey project, String branch, Change.Key key) {
-    return and(byBranchKeyPred(BranchNameKey.create(project, branch), key), open());
-  }
-
   private static Predicate<ChangeData> byBranchKeyPred(BranchNameKey branch, Change.Key key) {
     return and(ref(branch), project(branch.project()), change(key));
   }
diff --git a/java/com/google/gerrit/server/query/change/IsSubmittablePredicate.java b/java/com/google/gerrit/server/query/change/IsSubmittablePredicate.java
index 17de132..aeee744 100644
--- a/java/com/google/gerrit/server/query/change/IsSubmittablePredicate.java
+++ b/java/com/google/gerrit/server/query/change/IsSubmittablePredicate.java
@@ -20,7 +20,7 @@
 
 public class IsSubmittablePredicate extends BooleanPredicate {
   public IsSubmittablePredicate() {
-    super(ChangeField.IS_SUBMITTABLE);
+    super(ChangeField.IS_SUBMITTABLE_SPEC);
   }
 
   /**
@@ -53,11 +53,11 @@
   public static Predicate<ChangeData> rewrite(Predicate<ChangeData> in) {
     if (in instanceof IsSubmittablePredicate) {
       return Predicate.and(
-          new BooleanPredicate(ChangeField.IS_SUBMITTABLE), ChangeStatusPredicate.open());
+          new BooleanPredicate(ChangeField.IS_SUBMITTABLE_SPEC), ChangeStatusPredicate.open());
     }
     if (in instanceof NotPredicate && in.getChild(0) instanceof IsSubmittablePredicate) {
       return Predicate.or(
-          Predicate.not(new BooleanPredicate(ChangeField.IS_SUBMITTABLE)),
+          Predicate.not(new BooleanPredicate(ChangeField.IS_SUBMITTABLE_SPEC)),
           ChangeStatusPredicate.closed());
     }
     return in;
diff --git a/java/com/google/gerrit/server/query/change/IsUnresolvedPredicate.java b/java/com/google/gerrit/server/query/change/IsUnresolvedPredicate.java
index 27309af..ffa29ba 100644
--- a/java/com/google/gerrit/server/query/change/IsUnresolvedPredicate.java
+++ b/java/com/google/gerrit/server/query/change/IsUnresolvedPredicate.java
@@ -23,11 +23,11 @@
   }
 
   public IsUnresolvedPredicate(String value) throws QueryParseException {
-    super(ChangeField.UNRESOLVED_COMMENT_COUNT, value);
+    super(ChangeField.UNRESOLVED_COMMENT_COUNT_SPEC, value);
   }
 
   @Override
   protected Integer getValueInt(ChangeData changeData) {
-    return ChangeField.UNRESOLVED_COMMENT_COUNT.get(changeData);
+    return ChangeField.UNRESOLVED_COMMENT_COUNT_SPEC.get(changeData);
   }
 }
diff --git a/java/com/google/gerrit/server/query/change/LabelPredicate.java b/java/com/google/gerrit/server/query/change/LabelPredicate.java
index 2afaada..5a38958 100644
--- a/java/com/google/gerrit/server/query/change/LabelPredicate.java
+++ b/java/com/google/gerrit/server/query/change/LabelPredicate.java
@@ -23,6 +23,7 @@
 import com.google.gerrit.index.query.RangeUtil;
 import com.google.gerrit.index.query.RangeUtil.Range;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.AccountResolver;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.project.ProjectCache;
@@ -37,6 +38,7 @@
   protected static final int MAX_COUNT = 5; // inclusive
 
   protected static class Args {
+    protected final AccountResolver accountResolver;
     protected final ProjectCache projectCache;
     protected final PermissionBackend permissionBackend;
     protected final IdentifiedUser.GenericFactory userFactory;
@@ -48,6 +50,7 @@
     protected final GroupBackend groupBackend;
 
     protected Args(
+        AccountResolver accountResolver,
         ProjectCache projectCache,
         PermissionBackend permissionBackend,
         IdentifiedUser.GenericFactory userFactory,
@@ -57,6 +60,7 @@
         @Nullable Integer count,
         @Nullable PredicateArgs.Operator countOp,
         GroupBackend groupBackend) {
+      this.accountResolver = accountResolver;
       this.projectCache = projectCache;
       this.permissionBackend = permissionBackend;
       this.userFactory = userFactory;
@@ -93,6 +97,7 @@
     super(
         predicates(
             new Args(
+                a.accountResolver,
                 a.projectCache,
                 a.permissionBackend,
                 a.userFactory,
diff --git a/java/com/google/gerrit/server/query/change/MagicLabelPredicates.java b/java/com/google/gerrit/server/query/change/MagicLabelPredicates.java
index c9c8c45..420ab61d 100644
--- a/java/com/google/gerrit/server/query/change/MagicLabelPredicates.java
+++ b/java/com/google/gerrit/server/query/change/MagicLabelPredicates.java
@@ -16,6 +16,7 @@
 
 import static com.google.gerrit.server.query.change.EqualsLabelPredicates.type;
 
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
@@ -30,6 +31,8 @@
 import java.util.Optional;
 
 public class MagicLabelPredicates {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   public static class PostFilterMagicLabelPredicate extends PostFilterPredicate<ChangeData> {
     private static class PostFilterMatcher extends Matcher {
       public PostFilterMatcher(
@@ -62,6 +65,14 @@
     public int getCost() {
       return 2;
     }
+
+    public String getLabel() {
+      return matcher.getLabel();
+    }
+
+    public boolean ignoresUploaderApprovals() {
+      return matcher.ignoresUploaderApprovals();
+    }
   }
 
   public static class IndexMagicLabelPredicate extends ChangeIndexPredicate {
@@ -69,7 +80,7 @@
       public IndexMatcher(
           LabelPredicate.Args args,
           MagicLabelVote magicLabelVote,
-          Account.Id account,
+          @Nullable Account.Id account,
           @Nullable Integer count) {
         super(args, magicLabelVote, account, count);
       }
@@ -91,10 +102,10 @@
     public IndexMagicLabelPredicate(
         LabelPredicate.Args args,
         MagicLabelVote magicLabelVote,
-        Account.Id account,
+        @Nullable Account.Id account,
         @Nullable Integer count) {
       super(
-          ChangeField.LABEL,
+          ChangeField.LABEL_SPEC,
           ChangeField.formatLabel(
               magicLabelVote.label(), magicLabelVote.value().name(), account, count));
       this.matcher = new IndexMatcher(args, magicLabelVote, account, count);
@@ -104,12 +115,20 @@
     public boolean match(ChangeData changeData) {
       return matcher.match(changeData);
     }
+
+    public String getLabel() {
+      return matcher.getLabel();
+    }
+
+    public boolean ignoresUploaderApprovals() {
+      return matcher.ignoresUploaderApprovals();
+    }
   }
 
   private abstract static class Matcher {
     protected final LabelPredicate.Args args;
     protected final MagicLabelVote magicLabelVote;
-    protected final Account.Id account;
+    @Nullable protected final Account.Id account;
     @Nullable protected final Integer count;
 
     public Matcher(
@@ -120,7 +139,7 @@
     public Matcher(
         LabelPredicate.Args args,
         MagicLabelVote magicLabelVote,
-        Account.Id account,
+        @Nullable Account.Id account,
         @Nullable Integer count) {
       this.account = account;
       this.args = args;
@@ -156,6 +175,19 @@
       throw new IllegalStateException("Unsupported magic label value: " + magicLabelVote.value());
     }
 
+    public String getLabel() {
+      return magicLabelVote.label();
+    }
+
+    public boolean ignoresUploaderApprovals() {
+      logger.atFine().log("account = %s", account);
+      if (account != null) {
+        return account.equals(ChangeQueryBuilder.NON_UPLOADER_ACCOUNT_ID)
+            || account.equals(ChangeQueryBuilder.NON_CONTRIBUTOR_ACCOUNT_ID);
+      }
+      return false;
+    }
+
     private boolean matchAny(ChangeData changeData, LabelType labelType) {
       List<Predicate<ChangeData>> predicates = new ArrayList<>();
       for (LabelValue labelValue : labelType.getValues()) {
diff --git a/java/com/google/gerrit/server/query/change/RegexDirectoryPredicate.java b/java/com/google/gerrit/server/query/change/RegexDirectoryPredicate.java
index 1787c76..315785c 100644
--- a/java/com/google/gerrit/server/query/change/RegexDirectoryPredicate.java
+++ b/java/com/google/gerrit/server/query/change/RegexDirectoryPredicate.java
@@ -22,7 +22,7 @@
   protected final RunAutomaton pattern;
 
   public RegexDirectoryPredicate(String re) {
-    super(ChangeField.DIRECTORY, re);
+    super(ChangeField.DIRECTORY_SPEC, re);
 
     if (re.startsWith("^")) {
       re = re.substring(1);
diff --git a/java/com/google/gerrit/server/query/change/RegexHashtagPredicate.java b/java/com/google/gerrit/server/query/change/RegexHashtagPredicate.java
index 24efa6a..f62780a 100644
--- a/java/com/google/gerrit/server/query/change/RegexHashtagPredicate.java
+++ b/java/com/google/gerrit/server/query/change/RegexHashtagPredicate.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.query.change;
 
-import static com.google.gerrit.server.index.change.ChangeField.HASHTAG;
+import static com.google.gerrit.server.index.change.ChangeField.HASHTAG_SPEC;
 
 import dk.brics.automaton.RegExp;
 import dk.brics.automaton.RunAutomaton;
@@ -23,7 +23,7 @@
   protected final RunAutomaton pattern;
 
   public RegexHashtagPredicate(String re) {
-    super(HASHTAG, re);
+    super(HASHTAG_SPEC, re);
 
     if (re.startsWith("^")) {
       re = re.substring(1);
diff --git a/java/com/google/gerrit/server/query/change/RegexPathPredicate.java b/java/com/google/gerrit/server/query/change/RegexPathPredicate.java
index 4c3c04c..9368047 100644
--- a/java/com/google/gerrit/server/query/change/RegexPathPredicate.java
+++ b/java/com/google/gerrit/server/query/change/RegexPathPredicate.java
@@ -19,7 +19,7 @@
 
 public class RegexPathPredicate extends ChangeRegexPredicate {
   public RegexPathPredicate(String re) {
-    super(ChangeField.PATH, re);
+    super(ChangeField.PATH_SPEC, re);
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/query/change/RegexProjectPredicate.java b/java/com/google/gerrit/server/query/change/RegexProjectPredicate.java
index bbdfc66..a51dcc4 100644
--- a/java/com/google/gerrit/server/query/change/RegexProjectPredicate.java
+++ b/java/com/google/gerrit/server/query/change/RegexProjectPredicate.java
@@ -24,7 +24,7 @@
   protected final RunAutomaton pattern;
 
   public RegexProjectPredicate(String re) {
-    super(ChangeField.PROJECT, re);
+    super(ChangeField.PROJECT_SPEC, re);
 
     if (re.startsWith("^")) {
       re = re.substring(1);
diff --git a/java/com/google/gerrit/server/query/change/RegexRefPredicate.java b/java/com/google/gerrit/server/query/change/RegexRefPredicate.java
index b2dba72..cc556ba 100644
--- a/java/com/google/gerrit/server/query/change/RegexRefPredicate.java
+++ b/java/com/google/gerrit/server/query/change/RegexRefPredicate.java
@@ -24,7 +24,7 @@
   protected final RunAutomaton pattern;
 
   public RegexRefPredicate(String re) throws QueryParseException {
-    super(ChangeField.REF, re);
+    super(ChangeField.REF_SPEC, re);
 
     if (re.startsWith("^")) {
       re = re.substring(1);
diff --git a/java/com/google/gerrit/server/query/change/ReviewerPredicate.java b/java/com/google/gerrit/server/query/change/ReviewerPredicate.java
index 57f5213..b355afb 100644
--- a/java/com/google/gerrit/server/query/change/ReviewerPredicate.java
+++ b/java/com/google/gerrit/server/query/change/ReviewerPredicate.java
@@ -40,7 +40,7 @@
   protected final Account.Id id;
 
   private ReviewerPredicate(ReviewerStateInternal state, Account.Id id) {
-    super(ChangeField.REVIEWER, ChangeField.getReviewerFieldValue(state, id));
+    super(ChangeField.REVIEWER_SPEC, ChangeField.getReviewerFieldValue(state, id));
     this.state = state;
     this.id = id;
   }
diff --git a/java/com/google/gerrit/server/query/change/SubmitRecordPredicate.java b/java/com/google/gerrit/server/query/change/SubmitRecordPredicate.java
index ecddbb6..243712d 100644
--- a/java/com/google/gerrit/server/query/change/SubmitRecordPredicate.java
+++ b/java/com/google/gerrit/server/query/change/SubmitRecordPredicate.java
@@ -20,12 +20,13 @@
 import com.google.gerrit.entities.SubmitRecord;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.server.index.change.ChangeField;
+import java.util.Locale;
 import java.util.Set;
 
 public class SubmitRecordPredicate extends ChangeIndexPredicate {
   public static Predicate<ChangeData> create(
       String label, SubmitRecord.Label.Status status, Set<Account.Id> accounts) {
-    String lowerLabel = label.toLowerCase();
+    String lowerLabel = label.toLowerCase(Locale.US);
     if (accounts == null || accounts.isEmpty()) {
       return new SubmitRecordPredicate(status.name() + ',' + lowerLabel);
     }
@@ -36,7 +37,7 @@
   }
 
   private SubmitRecordPredicate(String value) {
-    super(ChangeField.SUBMIT_RECORD, value);
+    super(ChangeField.SUBMIT_RECORD_SPEC, value);
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/query/change/SubmitRequirementChangeQueryBuilder.java b/java/com/google/gerrit/server/query/change/SubmitRequirementChangeQueryBuilder.java
index 2580a1b..cb92ddd 100644
--- a/java/com/google/gerrit/server/query/change/SubmitRequirementChangeQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/change/SubmitRequirementChangeQueryBuilder.java
@@ -14,13 +14,24 @@
 
 package com.google.gerrit.server.query.change;
 
-import com.google.gerrit.index.FieldDef;
+import com.google.common.base.Splitter;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryBuilder;
 import com.google.gerrit.index.query.QueryParseException;
-import com.google.gerrit.server.query.FileEditsPredicate;
-import com.google.gerrit.server.query.FileEditsPredicate.FileEditsArgs;
+import com.google.gerrit.server.submitrequirement.predicate.ConstantPredicate;
+import com.google.gerrit.server.submitrequirement.predicate.DistinctVotersPredicate;
+import com.google.gerrit.server.submitrequirement.predicate.FileEditsPredicate;
+import com.google.gerrit.server.submitrequirement.predicate.FileEditsPredicate.FileEditsArgs;
+import com.google.gerrit.server.submitrequirement.predicate.HasSubmoduleUpdatePredicate;
+import com.google.gerrit.server.submitrequirement.predicate.RegexAuthorEmailPredicate;
+import com.google.gerrit.server.submitrequirement.predicate.RegexCommitterEmailPredicate;
+import com.google.gerrit.server.submitrequirement.predicate.RegexUploaderEmailPredicateFactory;
 import com.google.inject.Inject;
+import java.util.List;
+import java.util.Locale;
+import java.util.Set;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 import java.util.regex.PatternSyntaxException;
@@ -37,6 +48,7 @@
       new QueryBuilder.Definition<>(SubmitRequirementChangeQueryBuilder.class);
 
   private final DistinctVotersPredicate.Factory distinctVotersPredicateFactory;
+  private final HasSubmoduleUpdatePredicate.Factory hasSubmoduleUpdateFactory;
 
   /**
    * Regular expression for the {@link #file(String)} operator. Field value is of the form:
@@ -48,20 +60,28 @@
   private static final Pattern FILE_EDITS_PATTERN =
       Pattern.compile("'((?:(?:\\\\')|(?:[^']))*)',withDiffContaining='((?:(?:\\\\')|(?:[^']))*)'");
 
+  public static final String SUBMODULE_UPDATE_HAS_ARG = "submodule-update";
+  private static final Splitter SUBMODULE_UPDATE_SPLITTER = Splitter.on(",");
+
   private final FileEditsPredicate.Factory fileEditsPredicateFactory;
+  private final RegexUploaderEmailPredicateFactory regexUploaderEmailPredicateFactory;
 
   @Inject
   SubmitRequirementChangeQueryBuilder(
       Arguments args,
       DistinctVotersPredicate.Factory distinctVotersPredicateFactory,
-      FileEditsPredicate.Factory fileEditsPredicateFactory) {
+      FileEditsPredicate.Factory fileEditsPredicateFactory,
+      HasSubmoduleUpdatePredicate.Factory hasSubmoduleUpdateFactory,
+      RegexUploaderEmailPredicateFactory regexUploaderEmailPredicateFactory) {
     super(def, args);
     this.distinctVotersPredicateFactory = distinctVotersPredicateFactory;
     this.fileEditsPredicateFactory = fileEditsPredicateFactory;
+    this.hasSubmoduleUpdateFactory = hasSubmoduleUpdateFactory;
+    this.regexUploaderEmailPredicateFactory = regexUploaderEmailPredicateFactory;
   }
 
   @Override
-  protected void checkFieldAvailable(FieldDef<ChangeData, ?> field, String operator) {
+  protected void checkFieldAvailable(SchemaField<ChangeData, ?> field, String operator) {
     // Submit requirements don't rely on the index, so they can be used regardless of index schema
     // version.
   }
@@ -79,12 +99,53 @@
     return super.is(value);
   }
 
+  @Override
+  public Predicate<ChangeData> has(String value) throws QueryParseException {
+    if (value.toLowerCase(Locale.US).startsWith(SUBMODULE_UPDATE_HAS_ARG)) {
+      List<String> args = SUBMODULE_UPDATE_SPLITTER.splitToList(value);
+      if (args.size() > 2) {
+        throw error(
+            String.format(
+                "wrong number of arguments for the has:%s operator", SUBMODULE_UPDATE_HAS_ARG));
+      } else if (args.size() == 2) {
+        List<String> baseValue = Splitter.on("=").splitToList(args.get(1));
+        if (baseValue.size() != 2) {
+          throw error("unexpected base value format");
+        }
+        if (!baseValue.get(0).toLowerCase(Locale.US).equals("base")) {
+          throw error("unexpected base value format");
+        }
+        try {
+          int base = Integer.parseInt(baseValue.get(1));
+          return hasSubmoduleUpdateFactory.create(base);
+        } catch (NumberFormatException e) {
+          throw error(
+              String.format(
+                  "failed to parse the parent number %s: %s", baseValue.get(1), e.getMessage()));
+        }
+      } else {
+        return hasSubmoduleUpdateFactory.create(0);
+      }
+    }
+    return super.has(value);
+  }
+
   @Operator
   public Predicate<ChangeData> authoremail(String who) throws QueryParseException {
     return new RegexAuthorEmailPredicate(who);
   }
 
   @Operator
+  public Predicate<ChangeData> committerEmail(String who) throws QueryParseException {
+    return new RegexCommitterEmailPredicate(who);
+  }
+
+  @Operator
+  public Predicate<ChangeData> uploaderEmail(String who) throws QueryParseException {
+    return regexUploaderEmailPredicateFactory.create(who);
+  }
+
+  @Operator
   public Predicate<ChangeData> distinctvoters(String value) throws QueryParseException {
     return distinctVotersPredicateFactory.create(value);
   }
@@ -120,6 +181,9 @@
     return fileEditsPredicateFactory.create(FileEditsArgs.create(filePattern, contentPattern));
   }
 
+  @Override
+  protected void validateLabelArgs(Set<Account.Id> accountIds) throws QueryParseException {}
+
   private static void validateRegularExpression(String pattern, String errorMessage)
       throws QueryParseException {
     try {
diff --git a/java/com/google/gerrit/server/query/change/SubmittablePredicate.java b/java/com/google/gerrit/server/query/change/SubmittablePredicate.java
index 060a92e..e543ac3 100644
--- a/java/com/google/gerrit/server/query/change/SubmittablePredicate.java
+++ b/java/com/google/gerrit/server/query/change/SubmittablePredicate.java
@@ -21,7 +21,7 @@
   protected final SubmitRecord.Status status;
 
   public SubmittablePredicate(SubmitRecord.Status status) {
-    super(ChangeField.SUBMIT_RECORD, status.name());
+    super(ChangeField.SUBMIT_RECORD_SPEC, status.name());
     this.status = status;
   }
 
diff --git a/java/com/google/gerrit/server/query/change/TimestampRangeChangePredicate.java b/java/com/google/gerrit/server/query/change/TimestampRangeChangePredicate.java
index abbd0c9..0b2d32d 100644
--- a/java/com/google/gerrit/server/query/change/TimestampRangeChangePredicate.java
+++ b/java/com/google/gerrit/server/query/change/TimestampRangeChangePredicate.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.query.change;
 
-import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
 import com.google.gerrit.index.query.Matchable;
 import com.google.gerrit.index.query.TimestampRangePredicate;
 import java.sql.Timestamp;
@@ -22,7 +22,7 @@
 public abstract class TimestampRangeChangePredicate extends TimestampRangePredicate<ChangeData>
     implements Matchable<ChangeData> {
   protected TimestampRangeChangePredicate(
-      FieldDef<ChangeData, Timestamp> def, String name, String value) {
+      SchemaField<ChangeData, Timestamp> def, String name, String value) {
     super(def, name, value);
   }
 
diff --git a/java/com/google/gerrit/server/query/group/InternalGroupQuery.java b/java/com/google/gerrit/server/query/group/InternalGroupQuery.java
index b9b58b8..078acd4 100644
--- a/java/com/google/gerrit/server/query/group/InternalGroupQuery.java
+++ b/java/com/google/gerrit/server/query/group/InternalGroupQuery.java
@@ -17,7 +17,9 @@
 import static com.google.common.collect.ImmutableList.toImmutableList;
 
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
+import com.google.common.collect.Maps;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
@@ -27,8 +29,12 @@
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.server.index.group.GroupIndexCollection;
 import com.google.inject.Inject;
+import java.util.HashSet;
 import java.util.List;
+import java.util.Map;
 import java.util.Optional;
+import java.util.Set;
+import java.util.stream.Collectors;
 
 /**
  * Query wrapper for the group index.
@@ -57,8 +63,29 @@
     return query(GroupPredicates.member(memberId));
   }
 
-  public List<InternalGroup> bySubgroup(AccountGroup.UUID subgroupId) {
-    return query(GroupPredicates.subgroup(subgroupId));
+  /**
+   * Get all immediate parents of the provided {@code subgroupIds}.
+   *
+   * @return map pointing from children to list of its immediate parents
+   */
+  public Map<AccountGroup.UUID, ImmutableSet<AccountGroup.UUID>> bySubgroups(
+      ImmutableSet<AccountGroup.UUID> subgroupIds) {
+    List<Predicate<InternalGroup>> predicates =
+        subgroupIds.stream().map(e -> GroupPredicates.subgroup(e)).collect(Collectors.toList());
+    List<InternalGroup> groups = query(Predicate.or(predicates));
+
+    Map<AccountGroup.UUID, Set<AccountGroup.UUID>> parentsByChild =
+        Maps.newHashMapWithExpectedSize(groups.size());
+    subgroupIds.stream().forEach(c -> parentsByChild.put(c, new HashSet<>()));
+    for (InternalGroup parent : groups) {
+      for (AccountGroup.UUID child : parent.getSubgroups()) {
+        if (subgroupIds.contains(child)) {
+          parentsByChild.get(child).add(parent.getGroupUUID());
+        }
+      }
+    }
+    return parentsByChild.entrySet().stream()
+        .collect(Collectors.toMap(Map.Entry::getKey, e -> ImmutableSet.copyOf(e.getValue())));
   }
 
   private Optional<InternalGroup> getOnlyGroup(
diff --git a/java/com/google/gerrit/server/query/project/ProjectPredicates.java b/java/com/google/gerrit/server/query/project/ProjectPredicates.java
index 8b4048f..a7b0743 100644
--- a/java/com/google/gerrit/server/query/project/ProjectPredicates.java
+++ b/java/com/google/gerrit/server/query/project/ProjectPredicates.java
@@ -25,23 +25,23 @@
 /** Utility class to create predicates for project index queries. */
 public class ProjectPredicates {
   public static Predicate<ProjectData> name(Project.NameKey nameKey) {
-    return new ProjectPredicate(ProjectField.NAME, nameKey.get());
+    return new ProjectPredicate(ProjectField.NAME_SPEC, nameKey.get());
   }
 
   public static Predicate<ProjectData> parent(Project.NameKey parentNameKey) {
-    return new ProjectPredicate(ProjectField.PARENT_NAME, parentNameKey.get());
+    return new ProjectPredicate(ProjectField.PARENT_NAME_SPEC, parentNameKey.get());
   }
 
   public static Predicate<ProjectData> inname(String name) {
-    return new ProjectPredicate(ProjectField.NAME_PART, name.toLowerCase(Locale.US));
+    return new ProjectPredicate(ProjectField.NAME_PART_SPEC, name.toLowerCase(Locale.US));
   }
 
   public static Predicate<ProjectData> description(String description) {
-    return new ProjectPredicate(ProjectField.DESCRIPTION, description);
+    return new ProjectPredicate(ProjectField.DESCRIPTION_SPEC, description);
   }
 
   public static Predicate<ProjectData> state(ProjectState state) {
-    return new ProjectPredicate(ProjectField.STATE, state.name());
+    return new ProjectPredicate(ProjectField.STATE_SPEC, state.name());
   }
 
   private ProjectPredicates() {}
diff --git a/java/com/google/gerrit/server/query/project/ProjectQueryBuilder.java b/java/com/google/gerrit/server/query/project/ProjectQueryBuilder.java
index d234546..616468e 100644
--- a/java/com/google/gerrit/server/query/project/ProjectQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/project/ProjectQueryBuilder.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2017 The Android Open Source Project
+// Copyright (C) 2023 The Android Open Source Project
 //
 // Licensed under the Apache License, Version 2.0 (the "License");
 // you may not use this file except in compliance with the License.
@@ -14,93 +14,21 @@
 
 package com.google.gerrit.server.query.project;
 
-import com.google.common.base.Strings;
-import com.google.common.collect.Lists;
-import com.google.common.primitives.Ints;
-import com.google.gerrit.entities.Project;
-import com.google.gerrit.extensions.client.ProjectState;
 import com.google.gerrit.index.project.ProjectData;
-import com.google.gerrit.index.query.LimitPredicate;
 import com.google.gerrit.index.query.Predicate;
-import com.google.gerrit.index.query.QueryBuilder;
 import com.google.gerrit.index.query.QueryParseException;
-import com.google.inject.Inject;
 import java.util.List;
 
-/** Parses a query string meant to be applied to project objects. */
-public class ProjectQueryBuilder extends QueryBuilder<ProjectData, ProjectQueryBuilder> {
-  public static final String FIELD_LIMIT = "limit";
+/**
+ * Provides methods required for parsing projects queries.
+ *
+ * <p>Internally (at google), this interface has a different implementation, comparing to upstream.
+ */
+public interface ProjectQueryBuilder {
+  String FIELD_LIMIT = "limit";
 
-  private static final QueryBuilder.Definition<ProjectData, ProjectQueryBuilder> mydef =
-      new QueryBuilder.Definition<>(ProjectQueryBuilder.class);
-
-  @Inject
-  ProjectQueryBuilder() {
-    super(mydef, null);
-  }
-
-  @Operator
-  public Predicate<ProjectData> name(String name) {
-    return ProjectPredicates.name(Project.nameKey(name));
-  }
-
-  @Operator
-  public Predicate<ProjectData> parent(String parentName) {
-    return ProjectPredicates.parent(Project.nameKey(parentName));
-  }
-
-  @Operator
-  public Predicate<ProjectData> inname(String namePart) {
-    if (namePart.isEmpty()) {
-      return name(namePart);
-    }
-    return ProjectPredicates.inname(namePart);
-  }
-
-  @Operator
-  public Predicate<ProjectData> description(String description) throws QueryParseException {
-    if (Strings.isNullOrEmpty(description)) {
-      throw error("description operator requires a value");
-    }
-
-    return ProjectPredicates.description(description);
-  }
-
-  @Operator
-  public Predicate<ProjectData> state(String state) throws QueryParseException {
-    if (Strings.isNullOrEmpty(state)) {
-      throw error("state operator requires a value");
-    }
-    ProjectState parsedState;
-    try {
-      parsedState = ProjectState.valueOf(state.replace('-', '_').toUpperCase());
-    } catch (IllegalArgumentException e) {
-      throw error("state operator must be either 'active' or 'read-only'", e);
-    }
-    if (parsedState == ProjectState.HIDDEN) {
-      throw error("state operator must be either 'active' or 'read-only'");
-    }
-    return ProjectPredicates.state(parsedState);
-  }
-
-  @Override
-  protected Predicate<ProjectData> defaultField(String query) throws QueryParseException {
-    // Adapt the capacity of this list when adding more default predicates.
-    List<Predicate<ProjectData>> preds = Lists.newArrayListWithCapacity(3);
-    preds.add(name(query));
-    preds.add(inname(query));
-    if (!Strings.isNullOrEmpty(query)) {
-      preds.add(description(query));
-    }
-    return Predicate.or(preds);
-  }
-
-  @Operator
-  public Predicate<ProjectData> limit(String query) throws QueryParseException {
-    Integer limit = Ints.tryParse(query);
-    if (limit == null) {
-      throw error("Invalid limit: " + query);
-    }
-    return new LimitPredicate<>(FIELD_LIMIT, limit);
-  }
+  /** See {@link com.google.gerrit.index.query.QueryBuilder#parse(String)}. */
+  Predicate<ProjectData> parse(String query) throws QueryParseException;
+  /** See {@link com.google.gerrit.index.query.QueryBuilder#parse(List)}. */
+  List<Predicate<ProjectData>> parse(List<String> queries) throws QueryParseException;
 }
diff --git a/java/com/google/gerrit/server/query/project/ProjectQueryBuilderImpl.java b/java/com/google/gerrit/server/query/project/ProjectQueryBuilderImpl.java
new file mode 100644
index 0000000..599683e
--- /dev/null
+++ b/java/com/google/gerrit/server/query/project/ProjectQueryBuilderImpl.java
@@ -0,0 +1,106 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.project;
+
+import com.google.common.base.Strings;
+import com.google.common.collect.Lists;
+import com.google.common.primitives.Ints;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.client.ProjectState;
+import com.google.gerrit.index.project.ProjectData;
+import com.google.gerrit.index.query.LimitPredicate;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.index.query.QueryBuilder;
+import com.google.gerrit.index.query.QueryParseException;
+import com.google.inject.Inject;
+import java.util.List;
+import java.util.Locale;
+
+/** Parses a query string meant to be applied to project objects. */
+public class ProjectQueryBuilderImpl extends QueryBuilder<ProjectData, ProjectQueryBuilderImpl>
+    implements ProjectQueryBuilder {
+  private static final QueryBuilder.Definition<ProjectData, ProjectQueryBuilderImpl> mydef =
+      new QueryBuilder.Definition<>(ProjectQueryBuilderImpl.class);
+
+  @Inject
+  ProjectQueryBuilderImpl() {
+    super(mydef, null);
+  }
+
+  @Operator
+  public Predicate<ProjectData> name(String name) {
+    return ProjectPredicates.name(Project.nameKey(name));
+  }
+
+  @Operator
+  public Predicate<ProjectData> parent(String parentName) {
+    return ProjectPredicates.parent(Project.nameKey(parentName));
+  }
+
+  @Operator
+  public Predicate<ProjectData> inname(String namePart) {
+    if (namePart.isEmpty()) {
+      return name(namePart);
+    }
+    return ProjectPredicates.inname(namePart);
+  }
+
+  @Operator
+  public Predicate<ProjectData> description(String description) throws QueryParseException {
+    if (Strings.isNullOrEmpty(description)) {
+      throw error("description operator requires a value");
+    }
+
+    return ProjectPredicates.description(description);
+  }
+
+  @Operator
+  public Predicate<ProjectData> state(String state) throws QueryParseException {
+    if (Strings.isNullOrEmpty(state)) {
+      throw error("state operator requires a value");
+    }
+    ProjectState parsedState;
+    try {
+      parsedState = ProjectState.valueOf(state.replace('-', '_').toUpperCase(Locale.US));
+    } catch (IllegalArgumentException e) {
+      throw error("state operator must be either 'active' or 'read-only'", e);
+    }
+    if (parsedState == ProjectState.HIDDEN) {
+      throw error("state operator must be either 'active' or 'read-only'");
+    }
+    return ProjectPredicates.state(parsedState);
+  }
+
+  @Override
+  protected Predicate<ProjectData> defaultField(String query) throws QueryParseException {
+    // Adapt the capacity of this list when adding more default predicates.
+    List<Predicate<ProjectData>> preds = Lists.newArrayListWithCapacity(3);
+    preds.add(name(query));
+    preds.add(inname(query));
+    if (!Strings.isNullOrEmpty(query)) {
+      preds.add(description(query));
+    }
+    return Predicate.or(preds);
+  }
+
+  @Operator
+  public Predicate<ProjectData> limit(String query) throws QueryParseException {
+    Integer limit = Ints.tryParse(query);
+    if (limit == null) {
+      throw error("Invalid limit: " + query);
+    }
+    return new LimitPredicate<>(FIELD_LIMIT, limit);
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/BUILD b/java/com/google/gerrit/server/restapi/BUILD
index 62da2f2..dd0ec78d 100644
--- a/java/com/google/gerrit/server/restapi/BUILD
+++ b/java/com/google/gerrit/server/restapi/BUILD
@@ -34,6 +34,7 @@
         "//lib/auto:auto-factory",
         "//lib/auto:auto-value",
         "//lib/auto:auto-value-annotations",
+        "//lib/commons:codec",
         "//lib/commons:compress",
         "//lib/commons:lang3",
         "//lib/errorprone:annotations",
diff --git a/java/com/google/gerrit/server/restapi/account/DeleteDraftComments.java b/java/com/google/gerrit/server/restapi/account/DeleteDraftComments.java
index e35ffdb..4b16143 100644
--- a/java/com/google/gerrit/server/restapi/account/DeleteDraftComments.java
+++ b/java/com/google/gerrit/server/restapi/account/DeleteDraftComments.java
@@ -11,91 +11,33 @@
 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 // See the License for the specific language governing permissions and
 // limitations under the License.
-
 package com.google.gerrit.server.restapi.account;
 
-import static com.google.common.collect.ImmutableList.toImmutableList;
-
-import com.google.common.base.CharMatcher;
-import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.entities.Account;
-import com.google.gerrit.entities.HumanComment;
-import com.google.gerrit.entities.PatchSet;
-import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.accounts.DeleteDraftCommentsInput;
 import com.google.gerrit.extensions.api.accounts.DeletedDraftCommentInfo;
-import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.index.query.Predicate;
-import com.google.gerrit.index.query.QueryParseException;
-import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.account.AccountResource;
-import com.google.gerrit.server.change.ChangeJson;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.query.change.ChangePredicates;
-import com.google.gerrit.server.query.change.ChangeQueryBuilder;
-import com.google.gerrit.server.query.change.InternalChangeQuery;
-import com.google.gerrit.server.restapi.change.CommentJson;
-import com.google.gerrit.server.restapi.change.CommentJson.HumanCommentFormatter;
-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.UpdateException;
-import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-import java.time.Instant;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.LinkedHashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.Objects;
 
 @Singleton
 public class DeleteDraftComments
     implements RestModifyView<AccountResource, DeleteDraftCommentsInput> {
-
   private final Provider<CurrentUser> userProvider;
-  private final BatchUpdate.Factory batchUpdateFactory;
-  private final ChangeQueryBuilder queryBuilder;
-  private final Provider<InternalChangeQuery> queryProvider;
-  private final ChangeData.Factory changeDataFactory;
-  private final ChangeJson.Factory changeJsonFactory;
-  private final Provider<CommentJson> commentJsonProvider;
-  private final CommentsUtil commentsUtil;
-  private final PatchSetUtil psUtil;
+  private final DeleteDraftCommentsUtil deleteDraftCommentsUtil;
 
   @Inject
   DeleteDraftComments(
-      Provider<CurrentUser> userProvider,
-      BatchUpdate.Factory batchUpdateFactory,
-      ChangeQueryBuilder queryBuilder,
-      Provider<InternalChangeQuery> queryProvider,
-      ChangeData.Factory changeDataFactory,
-      ChangeJson.Factory changeJsonFactory,
-      Provider<CommentJson> commentJsonProvider,
-      CommentsUtil commentsUtil,
-      PatchSetUtil psUtil) {
+      Provider<CurrentUser> userProvider, DeleteDraftCommentsUtil deleteDraftCommentsUtil) {
     this.userProvider = userProvider;
-    this.batchUpdateFactory = batchUpdateFactory;
-    this.queryBuilder = queryBuilder;
-    this.queryProvider = queryProvider;
-    this.changeDataFactory = changeDataFactory;
-    this.changeJsonFactory = changeJsonFactory;
-    this.commentJsonProvider = commentJsonProvider;
-    this.commentsUtil = commentsUtil;
-    this.psUtil = psUtil;
+    this.deleteDraftCommentsUtil = deleteDraftCommentsUtil;
   }
 
   @Override
@@ -114,82 +56,6 @@
       // hasSameAccountId check.)
       throw new AuthException("Cannot delete drafts of other user");
     }
-
-    HumanCommentFormatter humanCommentFormatter =
-        commentJsonProvider.get().newHumanCommentFormatter();
-    Account.Id accountId = rsrc.getUser().getAccountId();
-    Instant now = TimeUtil.now();
-    Map<Project.NameKey, BatchUpdate> updates = new LinkedHashMap<>();
-    List<Op> ops = new ArrayList<>();
-    for (ChangeData cd :
-        queryProvider
-            .get()
-            // Don't attempt to mutate any changes the user can't currently see.
-            .enforceVisibility(true)
-            .query(predicate(accountId, input))) {
-      BatchUpdate update =
-          updates.computeIfAbsent(
-              cd.project(), p -> batchUpdateFactory.create(p, rsrc.getUser(), now));
-      Op op = new Op(humanCommentFormatter, accountId);
-      update.addOp(cd.getId(), op);
-      ops.add(op);
-    }
-
-    // Currently there's no way to let some updates succeed even if others fail. Even if there were,
-    // all updates from this operation only happen in All-Users and thus are fully atomic, so
-    // allowing partial failure would have little value.
-    BatchUpdate.execute(updates.values(), ImmutableList.of(), false);
-
-    return Response.ok(
-        ops.stream().map(Op::getResult).filter(Objects::nonNull).collect(toImmutableList()));
-  }
-
-  private Predicate<ChangeData> predicate(Account.Id accountId, DeleteDraftCommentsInput input)
-      throws BadRequestException {
-    Predicate<ChangeData> hasDraft = ChangePredicates.draftBy(commentsUtil, accountId);
-    if (CharMatcher.whitespace().trimFrom(Strings.nullToEmpty(input.query)).isEmpty()) {
-      return hasDraft;
-    }
-    try {
-      return Predicate.and(hasDraft, queryBuilder.parse(input.query));
-    } catch (QueryParseException e) {
-      throw new BadRequestException("Invalid query: " + e.getMessage(), e);
-    }
-  }
-
-  private class Op implements BatchUpdateOp {
-    private final HumanCommentFormatter humanCommentFormatter;
-    private final Account.Id accountId;
-    private DeletedDraftCommentInfo result;
-
-    Op(HumanCommentFormatter humanCommentFormatter, Account.Id accountId) {
-      this.humanCommentFormatter = humanCommentFormatter;
-      this.accountId = accountId;
-    }
-
-    @Override
-    public boolean updateChange(ChangeContext ctx) throws PermissionBackendException {
-      ImmutableList.Builder<CommentInfo> comments = ImmutableList.builder();
-      boolean dirty = false;
-      for (HumanComment c : commentsUtil.draftByChangeAuthor(ctx.getNotes(), accountId)) {
-        dirty = true;
-        PatchSet.Id psId = PatchSet.id(ctx.getChange().getId(), c.key.patchSetId);
-        commentsUtil.setCommentCommitId(c, ctx.getChange(), psUtil.get(ctx.getNotes(), psId));
-        commentsUtil.deleteHumanComments(ctx.getUpdate(psId), Collections.singleton(c));
-        comments.add(humanCommentFormatter.format(c));
-      }
-      if (dirty) {
-        result = new DeletedDraftCommentInfo();
-        result.change =
-            changeJsonFactory.noOptions().format(changeDataFactory.create(ctx.getNotes()));
-        result.deleted = comments.build();
-      }
-      return dirty;
-    }
-
-    @Nullable
-    DeletedDraftCommentInfo getResult() {
-      return result;
-    }
+    return Response.ok(deleteDraftCommentsUtil.deleteDraftComments(rsrc.getUser(), input.query));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/account/DeleteDraftCommentsUtil.java b/java/com/google/gerrit/server/restapi/account/DeleteDraftCommentsUtil.java
new file mode 100644
index 0000000..9e02592
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/account/DeleteDraftCommentsUtil.java
@@ -0,0 +1,169 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+package com.google.gerrit.server.restapi.account;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
+
+import com.google.common.base.CharMatcher;
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.HumanComment;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.api.accounts.DeletedDraftCommentInfo;
+import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+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.CommentsUtil;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.change.ChangeJson;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.ChangePredicates;
+import com.google.gerrit.server.query.change.ChangeQueryBuilder;
+import com.google.gerrit.server.query.change.InternalChangeQuery;
+import com.google.gerrit.server.restapi.change.CommentJson;
+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.UpdateException;
+import com.google.gerrit.server.update.context.RefUpdateContext;
+import com.google.gerrit.server.util.time.TimeUtil;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+@Singleton
+public class DeleteDraftCommentsUtil {
+  private final BatchUpdate.Factory batchUpdateFactory;
+  private final ChangeQueryBuilder queryBuilder;
+  private final Provider<InternalChangeQuery> queryProvider;
+  private final ChangeData.Factory changeDataFactory;
+  private final ChangeJson.Factory changeJsonFactory;
+  private final Provider<CommentJson> commentJsonProvider;
+  private final CommentsUtil commentsUtil;
+  private final PatchSetUtil psUtil;
+
+  @Inject
+  public DeleteDraftCommentsUtil(
+      BatchUpdate.Factory batchUpdateFactory,
+      ChangeQueryBuilder queryBuilder,
+      Provider<InternalChangeQuery> queryProvider,
+      ChangeData.Factory changeDataFactory,
+      ChangeJson.Factory changeJsonFactory,
+      Provider<CommentJson> commentJsonProvider,
+      CommentsUtil commentsUtil,
+      PatchSetUtil psUtil) {
+    this.batchUpdateFactory = batchUpdateFactory;
+    this.queryBuilder = queryBuilder;
+    this.queryProvider = queryProvider;
+    this.changeDataFactory = changeDataFactory;
+    this.changeJsonFactory = changeJsonFactory;
+    this.commentJsonProvider = commentJsonProvider;
+    this.commentsUtil = commentsUtil;
+    this.psUtil = psUtil;
+  }
+
+  public ImmutableList<DeletedDraftCommentInfo> deleteDraftComments(
+      IdentifiedUser user, String query) throws RestApiException, UpdateException {
+    CommentJson.HumanCommentFormatter humanCommentFormatter =
+        commentJsonProvider.get().newHumanCommentFormatter();
+    Account.Id accountId = user.getAccountId();
+    Instant now = TimeUtil.now();
+    Map<Project.NameKey, BatchUpdate> updates = new LinkedHashMap<>();
+    List<Op> ops = new ArrayList<>();
+    for (ChangeData cd :
+        queryProvider
+            .get()
+            // Don't attempt to mutate any changes the user can't currently see.
+            .enforceVisibility(true)
+            .query(predicate(accountId, query))) {
+      BatchUpdate update =
+          updates.computeIfAbsent(cd.project(), p -> batchUpdateFactory.create(p, user, now));
+      Op op = new Op(humanCommentFormatter, accountId);
+      update.addOp(cd.getId(), op);
+      ops.add(op);
+    }
+    try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+      // Currently there's no way to let some updates succeed even if others fail. Even if there
+      // were,
+      // all updates from this operation only happen in All-Users and thus are fully atomic, so
+      // allowing partial failure would have little value.
+      BatchUpdate.execute(updates.values(), ImmutableList.of(), false);
+    }
+    return ops.stream().map(Op::getResult).filter(Objects::nonNull).collect(toImmutableList());
+  }
+
+  private Predicate<ChangeData> predicate(Account.Id accountId, String query)
+      throws BadRequestException {
+    Predicate<ChangeData> hasDraft = ChangePredicates.draftBy(commentsUtil, accountId);
+    if (CharMatcher.whitespace().trimFrom(Strings.nullToEmpty(query)).isEmpty()) {
+      return hasDraft;
+    }
+    try {
+      return Predicate.and(hasDraft, queryBuilder.parse(query));
+    } catch (QueryParseException e) {
+      throw new BadRequestException("Invalid query: " + e.getMessage(), e);
+    }
+  }
+
+  private class Op implements BatchUpdateOp {
+    private final CommentJson.HumanCommentFormatter humanCommentFormatter;
+    private final Account.Id accountId;
+    private DeletedDraftCommentInfo result;
+
+    Op(CommentJson.HumanCommentFormatter humanCommentFormatter, Account.Id accountId) {
+      this.humanCommentFormatter = humanCommentFormatter;
+      this.accountId = accountId;
+    }
+
+    @Override
+    public boolean updateChange(ChangeContext ctx) throws PermissionBackendException {
+      ImmutableList.Builder<CommentInfo> comments = ImmutableList.builder();
+      boolean dirty = false;
+      for (HumanComment c : commentsUtil.draftByChangeAuthor(ctx.getNotes(), accountId)) {
+        dirty = true;
+        PatchSet.Id psId = PatchSet.id(ctx.getChange().getId(), c.key.patchSetId);
+        commentsUtil.setCommentCommitId(c, ctx.getChange(), psUtil.get(ctx.getNotes(), psId));
+        commentsUtil.deleteHumanComments(ctx.getUpdate(psId), Collections.singleton(c));
+        comments.add(humanCommentFormatter.format(c));
+      }
+      if (dirty) {
+        result = new DeletedDraftCommentInfo();
+        result.change =
+            changeJsonFactory.noOptions().format(changeDataFactory.create(ctx.getNotes()));
+        result.deleted = comments.build();
+      }
+      return dirty;
+    }
+
+    @Nullable
+    DeletedDraftCommentInfo getResult() {
+      return result;
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/account/GetCapabilities.java b/java/com/google/gerrit/server/restapi/account/GetCapabilities.java
index 6ab2c44..c45694e 100644
--- a/java/com/google/gerrit/server/restapi/account/GetCapabilities.java
+++ b/java/com/google/gerrit/server/restapi/account/GetCapabilities.java
@@ -45,6 +45,7 @@
 import com.google.inject.Singleton;
 import java.util.HashSet;
 import java.util.LinkedHashMap;
+import java.util.Locale;
 import java.util.Map;
 import java.util.Set;
 import org.kohsuke.args4j.Option;
@@ -124,7 +125,7 @@
   }
 
   private boolean want(String name) {
-    return query == null || query.contains(name.toLowerCase());
+    return query == null || query.contains(name.toLowerCase(Locale.US));
   }
 
   private void addRanges(Map<String, Object> have, AccountLimits limits) {
diff --git a/java/com/google/gerrit/server/restapi/account/GetEmails.java b/java/com/google/gerrit/server/restapi/account/GetEmails.java
index 4d70eb9..a518532 100644
--- a/java/com/google/gerrit/server/restapi/account/GetEmails.java
+++ b/java/com/google/gerrit/server/restapi/account/GetEmails.java
@@ -52,7 +52,7 @@
   public Response<List<EmailInfo>> apply(AccountResource rsrc)
       throws AuthException, PermissionBackendException {
     if (!self.get().hasSameAccountId(rsrc.getUser())) {
-      permissionBackend.currentUser().check(GlobalPermission.MODIFY_ACCOUNT);
+      permissionBackend.currentUser().check(GlobalPermission.VIEW_SECONDARY_EMAILS);
     }
     return Response.ok(
         rsrc.getUser().getEmailAddresses().stream()
diff --git a/java/com/google/gerrit/server/restapi/account/GetExternalIds.java b/java/com/google/gerrit/server/restapi/account/GetExternalIds.java
index a3c48b9..d7a5da11 100644
--- a/java/com/google/gerrit/server/restapi/account/GetExternalIds.java
+++ b/java/com/google/gerrit/server/restapi/account/GetExternalIds.java
@@ -18,6 +18,7 @@
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Lists;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.common.AccountExternalIdInfo;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
@@ -92,7 +93,8 @@
     return Response.ok(result);
   }
 
+  @Nullable
   private static Boolean toBoolean(boolean v) {
-    return v ? v : null;
+    return v ? Boolean.TRUE : null;
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/account/GetWatchedProjects.java b/java/com/google/gerrit/server/restapi/account/GetWatchedProjects.java
index 8d65aac..d8ad3cf 100644
--- a/java/com/google/gerrit/server/restapi/account/GetWatchedProjects.java
+++ b/java/com/google/gerrit/server/restapi/account/GetWatchedProjects.java
@@ -19,6 +19,7 @@
 
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.NotifyConfig.NotifyType;
 import com.google.gerrit.extensions.client.ProjectWatchInfo;
@@ -93,6 +94,7 @@
     return pwi;
   }
 
+  @Nullable
   private static Boolean toBoolean(boolean value) {
     return value ? true : null;
   }
diff --git a/java/com/google/gerrit/server/restapi/account/QueryAccounts.java b/java/com/google/gerrit/server/restapi/account/QueryAccounts.java
index 30534b5..9fc0c42 100644
--- a/java/com/google/gerrit/server/restapi/account/QueryAccounts.java
+++ b/java/com/google/gerrit/server/restapi/account/QueryAccounts.java
@@ -173,7 +173,7 @@
     }
     boolean modifyAccountCapabilityChecked = false;
     if (options.contains(ListAccountsOption.ALL_EMAILS)) {
-      permissionBackend.currentUser().check(GlobalPermission.MODIFY_ACCOUNT);
+      permissionBackend.currentUser().check(GlobalPermission.VIEW_SECONDARY_EMAILS);
       modifyAccountCapabilityChecked = true;
       fillOptions.add(FillOptions.EMAIL);
       fillOptions.add(FillOptions.SECONDARY_EMAILS);
@@ -185,7 +185,7 @@
       if (modifyAccountCapabilityChecked) {
         fillOptions.add(FillOptions.SECONDARY_EMAILS);
       } else {
-        if (permissionBackend.currentUser().test(GlobalPermission.MODIFY_ACCOUNT)) {
+        if (permissionBackend.currentUser().test(GlobalPermission.VIEW_SECONDARY_EMAILS)) {
           fillOptions.add(FillOptions.SECONDARY_EMAILS);
         }
       }
@@ -240,7 +240,7 @@
       if (suggest) {
         return Response.ok(ImmutableList.of());
       }
-      throw new BadRequestException(e.getMessage());
+      throw new BadRequestException(e.getMessage(), e);
     }
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/account/StarredChanges.java b/java/com/google/gerrit/server/restapi/account/StarredChanges.java
index 12abf3d..173f24b 100644
--- a/java/com/google/gerrit/server/restapi/account/StarredChanges.java
+++ b/java/com/google/gerrit/server/restapi/account/StarredChanges.java
@@ -131,10 +131,7 @@
 
       try {
         starredChangesUtil.star(
-            self.get().getAccountId(),
-            change.getProject(),
-            change.getId(),
-            StarredChangesUtil.Operation.ADD);
+            self.get().getAccountId(), change.getId(), StarredChangesUtil.Operation.ADD);
       } catch (MutuallyExclusiveLabelsException e) {
         throw new ResourceConflictException(e.getMessage());
       } catch (IllegalLabelException e) {
@@ -182,10 +179,7 @@
         throw new AuthException("not allowed remove starred change");
       }
       starredChangesUtil.star(
-          self.get().getAccountId(),
-          rsrc.getChange().getProject(),
-          rsrc.getChange().getId(),
-          StarredChangesUtil.Operation.REMOVE);
+          self.get().getAccountId(), rsrc.getChange().getId(), StarredChangesUtil.Operation.REMOVE);
       return Response.none();
     }
   }
diff --git a/java/com/google/gerrit/server/restapi/change/Abandon.java b/java/com/google/gerrit/server/restapi/change/Abandon.java
index 8dd0e78..36080a4 100644
--- a/java/com/google/gerrit/server/restapi/change/Abandon.java
+++ b/java/com/google/gerrit/server/restapi/change/Abandon.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.restapi.change;
 
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
+
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.extensions.api.changes.AbandonInput;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
@@ -36,6 +38,7 @@
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.update.context.RefUpdateContext;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -126,16 +129,18 @@
     AccountState accountState = user.isIdentifiedUser() ? user.asIdentifiedUser().state() : null;
     AbandonOp op = abandonOpFactory.create(accountState, msgTxt);
     ChangeData changeData = changeDataFactory.create(notes.getProjectName(), notes.getChangeId());
-    try (BatchUpdate u = updateFactory.create(notes.getProjectName(), user, TimeUtil.now())) {
-      u.setNotify(notify);
-      u.addOp(notes.getChangeId(), op);
-      u.addOp(
-          notes.getChangeId(),
-          storeSubmitRequirementsOpFactory.create(
-              changeData.submitRequirements().values(), changeData));
-      u.execute();
+    try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+      try (BatchUpdate u = updateFactory.create(notes.getProjectName(), user, TimeUtil.now())) {
+        u.setNotify(notify);
+        u.addOp(notes.getChangeId(), op);
+        u.addOp(
+            notes.getChangeId(),
+            storeSubmitRequirementsOpFactory.create(
+                changeData.submitRequirements().values(), changeData));
+        u.execute();
+      }
+      return op.getChange();
     }
-    return op.getChange();
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/restapi/change/AddToAttentionSet.java b/java/com/google/gerrit/server/restapi/change/AddToAttentionSet.java
index 03d383f..155e66f 100644
--- a/java/com/google/gerrit/server/restapi/change/AddToAttentionSet.java
+++ b/java/com/google/gerrit/server/restapi/change/AddToAttentionSet.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.restapi.change;
 
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
+
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.api.changes.AttentionSetInput;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
@@ -32,6 +34,7 @@
 import com.google.gerrit.server.permissions.ChangePermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.context.RefUpdateContext;
 import com.google.gerrit.server.util.AttentionSetUtil;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
@@ -87,17 +90,18 @@
         .test(ChangePermission.READ)) {
       throw new AuthException("read not permitted for " + attentionUserId);
     }
-
-    try (BatchUpdate bu =
-        updateFactory.create(
-            changeResource.getChange().getProject(), changeResource.getUser(), TimeUtil.now())) {
-      AddToAttentionSetOp op = opFactory.create(attentionUserId, input.reason, true);
-      bu.addOp(changeResource.getId(), op);
-      NotifyHandling notify = input.notify == null ? NotifyHandling.OWNER : input.notify;
-      NotifyResolver.Result notifyResult = notifyResolver.resolve(notify, input.notifyDetails);
-      bu.setNotify(notifyResult);
-      bu.execute();
-      return Response.ok(accountLoaderFactory.create(true).fillOne(attentionUserId));
+    try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+      try (BatchUpdate bu =
+          updateFactory.create(
+              changeResource.getChange().getProject(), changeResource.getUser(), TimeUtil.now())) {
+        AddToAttentionSetOp op = opFactory.create(attentionUserId, input.reason, true);
+        bu.addOp(changeResource.getId(), op);
+        NotifyHandling notify = input.notify == null ? NotifyHandling.OWNER : input.notify;
+        NotifyResolver.Result notifyResult = notifyResolver.resolve(notify, input.notifyDetails);
+        bu.setNotify(notifyResult);
+        bu.execute();
+        return Response.ok(accountLoaderFactory.create(true).fillOne(attentionUserId));
+      }
     }
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/AllowedFormats.java b/java/com/google/gerrit/server/restapi/change/AllowedFormats.java
index e3ab135..763212d 100644
--- a/java/com/google/gerrit/server/restapi/change/AllowedFormats.java
+++ b/java/com/google/gerrit/server/restapi/change/AllowedFormats.java
@@ -22,6 +22,7 @@
 import com.google.gerrit.server.config.DownloadConfig;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
+import java.util.Locale;
 import java.util.Set;
 
 @Singleton
@@ -36,7 +37,7 @@
       for (String ext : format.getSuffixes()) {
         exts.put(ext, format);
       }
-      exts.put(format.name().toLowerCase(), format);
+      exts.put(format.name().toLowerCase(Locale.US), format);
     }
     extensions = exts.build();
 
diff --git a/java/com/google/gerrit/server/restapi/change/ApplyPatch.java b/java/com/google/gerrit/server/restapi/change/ApplyPatch.java
new file mode 100644
index 0000000..58cd010
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/ApplyPatch.java
@@ -0,0 +1,235 @@
+// 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.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
+
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project.NameKey;
+import com.google.gerrit.extensions.api.changes.ApplyPatchPatchSetInput;
+import com.google.gerrit.extensions.client.ListChangesOption;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.PreconditionFailedException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.server.ChangeUtil;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.change.ChangeJson;
+import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.change.PatchSetInserter;
+import com.google.gerrit.server.git.CodeReviewCommit;
+import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
+import com.google.gerrit.server.git.CommitUtil;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.ContributorAgreementsChecker;
+import com.google.gerrit.server.project.InvalidChangeOperationException;
+import com.google.gerrit.server.project.NoSuchChangeException;
+import com.google.gerrit.server.project.NoSuchProjectException;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.InternalChangeQuery;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.update.context.RefUpdateContext;
+import com.google.gerrit.server.util.time.TimeUtil;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.util.List;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.patch.PatchApplier;
+import org.eclipse.jgit.revwalk.FooterLine;
+import org.eclipse.jgit.revwalk.RevCommit;
+
+@Singleton
+public class ApplyPatch implements RestModifyView<ChangeResource, ApplyPatchPatchSetInput> {
+  private final ChangeJson.Factory jsonFactory;
+  private final ContributorAgreementsChecker contributorAgreements;
+  private final Provider<IdentifiedUser> user;
+  private final GitRepositoryManager gitManager;
+  private final BatchUpdate.Factory batchUpdateFactory;
+  private final PatchSetInserter.Factory patchSetInserterFactory;
+  private final Provider<InternalChangeQuery> queryProvider;
+  private final ZoneId serverZoneId;
+
+  @Inject
+  ApplyPatch(
+      ChangeJson.Factory jsonFactory,
+      ContributorAgreementsChecker contributorAgreements,
+      Provider<IdentifiedUser> user,
+      GitRepositoryManager gitManager,
+      BatchUpdate.Factory batchUpdateFactory,
+      PatchSetInserter.Factory patchSetInserterFactory,
+      Provider<InternalChangeQuery> queryProvider,
+      @GerritPersonIdent PersonIdent myIdent) {
+    this.jsonFactory = jsonFactory;
+    this.contributorAgreements = contributorAgreements;
+    this.user = user;
+    this.gitManager = gitManager;
+    this.batchUpdateFactory = batchUpdateFactory;
+    this.patchSetInserterFactory = patchSetInserterFactory;
+    this.queryProvider = queryProvider;
+    this.serverZoneId = myIdent.getZoneId();
+  }
+
+  @Override
+  public Response<ChangeInfo> apply(ChangeResource rsrc, ApplyPatchPatchSetInput input)
+      throws IOException, UpdateException, RestApiException, PermissionBackendException,
+          ConfigInvalidException, NoSuchProjectException, InvalidChangeOperationException {
+    NameKey project = rsrc.getProject();
+    contributorAgreements.check(project, rsrc.getUser());
+    BranchNameKey destBranch = rsrc.getChange().getDest();
+
+    try (Repository repo = gitManager.openRepository(project);
+        // This inserter and revwalk *must* be passed to any BatchUpdates
+        // created later on, to ensure the applied commit is flushed
+        // before patch sets are updated.
+        ObjectInserter oi = repo.newObjectInserter();
+        ObjectReader reader = oi.newReader();
+        CodeReviewRevWalk revWalk = CodeReviewCommit.newRevWalk(reader)) {
+      Ref destRef = repo.getRefDatabase().exactRef(destBranch.branch());
+      if (destRef == null) {
+        throw new ResourceNotFoundException(
+            String.format("Branch %s does not exist.", destBranch.branch()));
+      }
+      ChangeData destChange = rsrc.getChangeData();
+      if (destChange == null) {
+        throw new PreconditionFailedException(
+            "patch:apply cannot be called without a destination change.");
+      }
+
+      if (destChange.change().isClosed()) {
+        throw new PreconditionFailedException(
+            String.format(
+                "patch:apply with Change-Id %s could not update the existing change %d "
+                    + "in destination branch %s of project %s, because the change was closed (%s)",
+                destChange.getId(),
+                destChange.getId().get(),
+                destBranch.branch(),
+                destBranch.project(),
+                destChange.change().getStatus().name()));
+      }
+
+      RevCommit latestPatchset = revWalk.parseCommit(destChange.currentPatchSet().commitId());
+
+      RevCommit baseCommit;
+      if (!Strings.isNullOrEmpty(input.base)) {
+        baseCommit =
+            CommitUtil.getBaseCommit(
+                project.get(), queryProvider.get(), revWalk, destRef, input.base);
+      } else {
+        if (latestPatchset.getParentCount() != 1) {
+          throw new BadRequestException(
+              String.format(
+                  "Cannot parse base commit for a change with none or multiple parents. Change ID: %s.",
+                  destChange.getId()));
+        }
+        baseCommit = revWalk.parseCommit(latestPatchset.getParent(0));
+      }
+      PatchApplier.Result applyResult =
+          ApplyPatchUtil.applyPatch(repo, oi, input.patch, baseCommit);
+      ObjectId treeId = applyResult.getTreeId();
+
+      Instant now = TimeUtil.now();
+      PersonIdent committerIdent = user.get().newCommitterIdent(now, serverZoneId);
+      PersonIdent authorIdent =
+          input.author == null
+              ? committerIdent
+              : new PersonIdent(input.author.name, input.author.email, now, serverZoneId);
+      List<FooterLine> footerLines = latestPatchset.getFooterLines();
+      String messageWithNoFooters =
+          !Strings.isNullOrEmpty(input.commitMessage)
+              ? input.commitMessage
+              : removeFooters(latestPatchset.getFullMessage(), footerLines);
+      String commitMessage =
+          ApplyPatchUtil.buildCommitMessage(
+              messageWithNoFooters,
+              footerLines,
+              input.patch.patch,
+              ApplyPatchUtil.getResultPatch(repo, reader, baseCommit, revWalk.lookupTree(treeId)),
+              applyResult.getErrors());
+
+      ObjectId appliedCommit =
+          CommitUtil.createCommitWithTree(
+              oi, authorIdent, committerIdent, baseCommit, commitMessage, treeId);
+      CodeReviewCommit commit = revWalk.parseCommit(appliedCommit);
+      oi.flush();
+
+      Change resultChange;
+      try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.now())) {
+        bu.setRepository(repo, revWalk, oi);
+        resultChange =
+            insertPatchSet(bu, repo, patchSetInserterFactory, destChange.notes(), commit);
+      } catch (NoSuchChangeException | RepositoryNotFoundException e) {
+        throw new ResourceConflictException(e.getMessage());
+      }
+      List<ListChangesOption> opts = input.responseFormatOptions;
+      if (opts == null) {
+        opts = ImmutableList.of();
+      }
+      ChangeInfo changeInfo = jsonFactory.create(opts).format(resultChange);
+      return Response.ok(changeInfo);
+    }
+  }
+
+  private static Change insertPatchSet(
+      BatchUpdate bu,
+      Repository git,
+      PatchSetInserter.Factory patchSetInserterFactory,
+      ChangeNotes destNotes,
+      CodeReviewCommit commit)
+      throws IOException, UpdateException, RestApiException {
+    try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+      Change destChange = destNotes.getChange();
+      PatchSet.Id psId = ChangeUtil.nextPatchSetId(git, destChange.currentPatchSetId());
+      PatchSetInserter inserter = patchSetInserterFactory.create(destNotes, psId, commit);
+      inserter.setMessage(buildMessageForPatchSet(psId));
+      bu.addOp(destChange.getId(), inserter);
+      bu.execute();
+      return inserter.getChange();
+    }
+  }
+
+  private static String buildMessageForPatchSet(PatchSet.Id psId) {
+    return new StringBuilder(String.format("Uploaded patch set %s.", psId.get())).toString();
+  }
+
+  private String removeFooters(String originalMessage, List<FooterLine> footerLines) {
+    if (footerLines.isEmpty()) {
+      return originalMessage;
+    }
+    return originalMessage.substring(0, originalMessage.indexOf(footerLines.get(0).getKey()));
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/ApplyPatchUtil.java b/java/com/google/gerrit/server/restapi/change/ApplyPatchUtil.java
new file mode 100644
index 0000000..a5df0f8
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/ApplyPatchUtil.java
@@ -0,0 +1,194 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.gerrit.extensions.api.changes.ApplyPatchInput;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.patch.DiffUtil;
+import com.google.gerrit.server.util.CommitMessageUtil;
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.stream.Collectors;
+import org.apache.commons.codec.binary.Base64;
+import org.apache.commons.lang3.StringUtils;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.patch.Patch;
+import org.eclipse.jgit.patch.PatchApplier;
+import org.eclipse.jgit.revwalk.FooterLine;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevTree;
+
+/** Utility for applying a patch. */
+public final class ApplyPatchUtil {
+
+  /**
+   * Applies the given patch on top of the merge tip, using the given object inserter.
+   *
+   * @param repo to apply the patch in
+   * @param oi to operate with
+   * @param input the patch for applying
+   * @param mergeTip the tip to apply the patch on
+   * @return the tree ID with the applied patch
+   * @throws IOException if unable to create the jgit PatchApplier object
+   * @throws RestApiException for any other failure
+   */
+  public static PatchApplier.Result applyPatch(
+      Repository repo, ObjectInserter oi, ApplyPatchInput input, RevCommit mergeTip)
+      throws IOException, RestApiException {
+    checkNotNull(mergeTip);
+    RevTree tip = mergeTip.getTree();
+    Patch patch = new Patch();
+    try (InputStream patchStream =
+        new ByteArrayInputStream(decodeIfNecessary(input.patch).getBytes(StandardCharsets.UTF_8))) {
+      patch.parse(patchStream);
+      if (!patch.getErrors().isEmpty()) {
+        throw new BadRequestException(
+            "Invalid patch format. Got the following errors:\n"
+                + patch.getErrors().stream()
+                    .map(Objects::toString)
+                    .collect(Collectors.joining("\n"))
+                + "\nFor the patch:\n"
+                + input.patch);
+      }
+    }
+    try {
+      PatchApplier applier = new PatchApplier(repo, tip, oi);
+      PatchApplier.Result applyResult = applier.applyPatch(patch);
+      return applyResult;
+    } catch (IOException e) {
+      throw RestApiException.wrap("Cannot apply patch: " + input.patch, e);
+    }
+  }
+
+  /**
+   * Build commit message for commits with applied patch.
+   *
+   * <p>Message structure:
+   *
+   * <ol>
+   *   <li>Provided {@code message}.
+   *   <li>In case of errors while applying the patch - a warning message which includes the errors;
+   *       as well as the original patch's header if available, or the full original patch
+   *       otherwise.
+   *   <li>If there are no explicit errors, but the result change's patch is not the same as the
+   *       original patch - a warning message which includes the diff; as well as the original
+   *       patch's header if available, or the full original patch otherwise.
+   *   <li>The provided {@code footerLines}, if any.
+   * </ol>
+   *
+   * @param message the first message piece, excluding footers
+   * @param footerLines footer lines to append to the message
+   * @param originalPatch to compare the result patch to
+   * @param resultPatch to validate accuracy for
+   * @return the commit message
+   * @throws BadRequestException if the commit message cannot be sanitized
+   */
+  public static String buildCommitMessage(
+      String message,
+      List<FooterLine> footerLines,
+      String originalPatch,
+      String resultPatch,
+      List<PatchApplier.Result.Error> errors)
+      throws BadRequestException {
+    StringBuilder res = new StringBuilder(message.trim());
+
+    boolean appendOriginalPatch = false;
+    String decodedOriginalPatch = decodeIfNecessary(originalPatch);
+    if (!errors.isEmpty()) {
+      res.append(
+          "\n\nNOTE FOR REVIEWERS - errors occurred while applying the patch."
+              + "\nPLEASE REVIEW CAREFULLY.\nErrors:\n"
+              + errors.stream().map(Objects::toString).collect(Collectors.joining("\n")));
+      appendOriginalPatch = true;
+    } else {
+      // Only surface the diff if no explicit errors occurred.
+      Optional<String> patchDiff = verifyAppliedPatch(decodedOriginalPatch, resultPatch);
+      if (!patchDiff.isEmpty()) {
+        res.append(
+            "\n\nNOTE FOR REVIEWERS - original patch and result patch are not identical."
+                + "\nPLEASE REVIEW CAREFULLY.\nDiffs between the patches:\n "
+                + patchDiff.get());
+        appendOriginalPatch = true;
+      }
+    }
+
+    if (appendOriginalPatch) {
+      Optional<String> originalPatchHeader = DiffUtil.getPatchHeader(decodedOriginalPatch);
+      String patchDescription =
+          (originalPatchHeader.isEmpty() ? decodedOriginalPatch : originalPatchHeader.get()).trim();
+      res.append(
+          "\n\nOriginal patch:\n "
+              + patchDescription.substring(0, Math.min(patchDescription.length(), 1024)));
+    }
+
+    if (!footerLines.isEmpty()) {
+      res.append('\n');
+    }
+    for (FooterLine footer : footerLines) {
+      res.append("\n" + footer.toString());
+    }
+    return CommitMessageUtil.checkAndSanitizeCommitMessage(res.toString());
+  }
+
+  /**
+   * Fetch the patch of the result tree.
+   *
+   * @param repo in which the patch was applied
+   * @param reader for the repo objects, including {@code resultTree}
+   * @param baseCommit to generate patch against
+   * @param resultTree to generate the patch for
+   * @return the result patch
+   * @throws IOException if the result patch cannot be written
+   */
+  public static String getResultPatch(
+      Repository repo, ObjectReader reader, RevCommit baseCommit, RevTree resultTree)
+      throws IOException {
+    try (OutputStream resultPatchStream = new ByteArrayOutputStream()) {
+      DiffUtil.getFormattedDiff(
+          repo, reader, baseCommit.getTree(), resultTree, null, resultPatchStream);
+      return resultPatchStream.toString();
+    }
+  }
+
+  private static Optional<String> verifyAppliedPatch(String originalPatch, String resultPatch) {
+    String cleanOriginalPatch = DiffUtil.cleanPatch(originalPatch);
+    String cleanResultPatch = DiffUtil.cleanPatch(resultPatch);
+    if (cleanOriginalPatch.equals(cleanResultPatch)) {
+      return Optional.empty();
+    }
+    return Optional.of(StringUtils.difference(cleanOriginalPatch, cleanResultPatch));
+  }
+
+  private static String decodeIfNecessary(String patch) {
+    if (Base64.isBase64(patch)) {
+      return new String(org.eclipse.jgit.util.Base64.decode(patch), StandardCharsets.UTF_8);
+    }
+    return patch;
+  }
+
+  private ApplyPatchUtil() {}
+}
diff --git a/java/com/google/gerrit/server/restapi/change/ChangeEdits.java b/java/com/google/gerrit/server/restapi/change/ChangeEdits.java
index 19cfd6a..6fd75de 100644
--- a/java/com/google/gerrit/server/restapi/change/ChangeEdits.java
+++ b/java/com/google/gerrit/server/restapi/change/ChangeEdits.java
@@ -283,6 +283,7 @@
   /** Put handler that is activated when PUT request is called on collection element. */
   @Singleton
   public static class Put implements RestModifyView<ChangeEditResource, FileContentInput> {
+
     private static final Pattern BINARY_DATA_PATTERN =
         Pattern.compile("data:([\\w/.-]*);([\\w]+),(.*)");
     private static final String BASE64 = "base64";
@@ -340,8 +341,17 @@
         throw new ResourceConflictException("Invalid path: " + path);
       }
 
+      if (fileContentInput.fileMode != null) {
+        if ((fileContentInput.fileMode != 100644) && (fileContentInput.fileMode != 100755)) {
+          throw new BadRequestException(
+              "file_mode ("
+                  + fileContentInput.fileMode
+                  + ") was invalid: supported values are 0, 644, or 755.");
+        }
+      }
       try (Repository repository = repositoryManager.openRepository(rsrc.getProject())) {
-        editModifier.modifyFile(repository, rsrc.getNotes(), path, newContent);
+        editModifier.modifyFile(
+            repository, rsrc.getNotes(), path, newContent, fileContentInput.fileMode);
       } catch (InvalidChangeOperationException e) {
         throw new ResourceConflictException(e.getMessage());
       }
diff --git a/java/com/google/gerrit/server/restapi/change/ChangeRestApiModule.java b/java/com/google/gerrit/server/restapi/change/ChangeRestApiModule.java
index 718759a..33e6342 100644
--- a/java/com/google/gerrit/server/restapi/change/ChangeRestApiModule.java
+++ b/java/com/google/gerrit/server/restapi/change/ChangeRestApiModule.java
@@ -42,7 +42,6 @@
 import com.google.gerrit.server.change.RebaseChangeOp;
 import com.google.gerrit.server.change.RemoveFromAttentionSetOp;
 import com.google.gerrit.server.change.ReviewerResource;
-import com.google.gerrit.server.change.SetAssigneeOp;
 import com.google.gerrit.server.change.SetCherryPickOp;
 import com.google.gerrit.server.change.SetHashtagsOp;
 import com.google.gerrit.server.change.SetPrivateOp;
@@ -91,10 +90,6 @@
     delete(ATTENTION_SET_ENTRY_KIND).to(RemoveFromAttentionSet.class);
     post(ATTENTION_SET_ENTRY_KIND, "delete").to(RemoveFromAttentionSet.class);
     postOnCollection(ATTENTION_SET_ENTRY_KIND).to(AddToAttentionSet.class);
-    get(CHANGE_KIND, "assignee").to(GetAssignee.class);
-    get(CHANGE_KIND, "past_assignees").to(GetPastAssignees.class);
-    put(CHANGE_KIND, "assignee").to(PutAssignee.class);
-    delete(CHANGE_KIND, "assignee").to(DeleteAssignee.class);
     get(CHANGE_KIND, "hashtags").to(GetHashtags.class);
     get(CHANGE_KIND, "comments").to(ListChangeComments.class);
     get(CHANGE_KIND, "robotcomments").to(ListChangeRobotComments.class);
@@ -113,6 +108,7 @@
     post(CHANGE_KIND, "submit").to(Submit.CurrentRevision.class);
     get(CHANGE_KIND, "submitted_together").to(SubmittedTogether.class);
     post(CHANGE_KIND, "rebase").to(Rebase.CurrentRevision.class);
+    post(CHANGE_KIND, "rebase:chain").to(RebaseChain.class);
     post(CHANGE_KIND, "index").to(Index.class);
     post(CHANGE_KIND, "move").to(Move.class);
     post(CHANGE_KIND, "private").to(PostPrivate.class);
@@ -122,6 +118,7 @@
     post(CHANGE_KIND, "ready").to(SetReadyForReview.class);
     put(CHANGE_KIND, "message").to(PutMessage.class);
     post(CHANGE_KIND, "check.submit_requirement").to(CheckSubmitRequirement.class);
+    post(CHANGE_KIND, "patch:apply").to(ApplyPatch.class);
 
     get(CHANGE_KIND, "suggest_reviewers").to(SuggestChangeReviewers.class);
     child(CHANGE_KIND, "reviewers").to(Reviewers.class);
@@ -218,7 +215,6 @@
     factory(PreviewFix.Factory.class);
     factory(RebaseChangeOp.Factory.class);
     factory(ReviewerResource.Factory.class);
-    factory(SetAssigneeOp.Factory.class);
     factory(SetCherryPickOp.Factory.class);
     factory(SetHashtagsOp.Factory.class);
     factory(SetTopicOp.Factory.class);
diff --git a/java/com/google/gerrit/server/restapi/change/ChangesCollection.java b/java/com/google/gerrit/server/restapi/change/ChangesCollection.java
index a0c5b16..9715a5d 100644
--- a/java/com/google/gerrit/server/restapi/change/ChangesCollection.java
+++ b/java/com/google/gerrit/server/restapi/change/ChangesCollection.java
@@ -39,6 +39,7 @@
 import java.io.IOException;
 import java.util.List;
 import java.util.Optional;
+import org.eclipse.jgit.lib.ObjectId;
 
 @Singleton
 public class ChangesCollection implements RestCollection<TopLevelResource, ChangeResource> {
@@ -49,6 +50,7 @@
   private final ChangeResource.Factory changeResourceFactory;
   private final PermissionBackend permissionBackend;
   private final ProjectCache projectCache;
+  private final ChangeNotes.Factory changeNotesFactory;
 
   @Inject
   public ChangesCollection(
@@ -58,7 +60,9 @@
       ChangeFinder changeFinder,
       ChangeResource.Factory changeResourceFactory,
       PermissionBackend permissionBackend,
-      ProjectCache projectCache) {
+      ProjectCache projectCache,
+      ChangeNotes.Factory changeNotesFactory) {
+    this.changeNotesFactory = changeNotesFactory;
     this.user = user;
     this.queryFactory = queryFactory;
     this.views = views;
@@ -78,6 +82,11 @@
     return views;
   }
 
+  /**
+   * Parses {@link ChangeResource} from {@link com.google.gerrit.entities.Change.Id}
+   *
+   * <p>Reads the change from index, since project is unknown.
+   */
   @Override
   public ChangeResource parse(TopLevelResource root, IdString id)
       throws RestApiException, PermissionBackendException, IOException {
@@ -96,6 +105,29 @@
     return changeResourceFactory.create(change, user.get());
   }
 
+  /**
+   * Parses {@link ChangeResource} from {@link com.google.gerrit.entities.Change.Id} in {@code
+   * project} at {@code metaRevId}
+   *
+   * <p>Read change from ChangeNotesCache, so the method can be used upon creation, when the change
+   * might not be yet available in the index.
+   */
+  public ChangeResource parse(Project.NameKey project, Change.Id id, ObjectId metaRevId)
+      throws ResourceConflictException, ResourceNotFoundException, PermissionBackendException {
+    checkProjectStatePermitsRead(project);
+    ChangeNotes change = changeNotesFactory.createChecked(project, id, metaRevId);
+    if (!canRead(change)) {
+      throw new ResourceNotFoundException(toIdString(id));
+    }
+
+    return changeResourceFactory.create(change, user.get());
+  }
+
+  /**
+   * Parses {@link ChangeResource} from {@link com.google.gerrit.entities.Change.Id}
+   *
+   * <p>Reads the change from index, since project is unknown.
+   */
   public ChangeResource parse(Change.Id id)
       throws ResourceConflictException, ResourceNotFoundException, PermissionBackendException {
     List<ChangeNotes> notes = changeFinder.find(id);
diff --git a/java/com/google/gerrit/server/restapi/change/CherryPickChange.java b/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
index 66f8be7..1bfb6bd 100644
--- a/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
+++ b/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
@@ -16,10 +16,10 @@
 
 import static com.google.common.base.MoreObjects.firstNonNull;
 import static com.google.gerrit.server.project.ProjectCache.noSuchProject;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
 
 import com.google.auto.value.AutoValue;
 import com.google.common.base.Strings;
-import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
@@ -31,9 +31,7 @@
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.MergeConflictException;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
@@ -44,8 +42,10 @@
 import com.google.gerrit.server.change.PatchSetInserter;
 import com.google.gerrit.server.change.ResetCherryPickOp;
 import com.google.gerrit.server.change.SetCherryPickOp;
+import com.google.gerrit.server.change.ValidationOptionsUtil;
 import com.google.gerrit.server.git.CodeReviewCommit;
 import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
+import com.google.gerrit.server.git.CommitUtil;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.GroupCollector;
 import com.google.gerrit.server.git.MergeUtil;
@@ -63,6 +63,7 @@
 import com.google.gerrit.server.submit.MergeIdenticalTreeException;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.update.context.RefUpdateContext;
 import com.google.gerrit.server.util.CommitMessageUtil;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
@@ -73,11 +74,8 @@
 import java.time.ZoneId;
 import java.util.HashSet;
 import java.util.List;
-import java.util.Map;
 import java.util.Set;
 import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.errors.InvalidObjectIdException;
-import org.eclipse.jgit.errors.MissingObjectException;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.ObjectReader;
@@ -85,7 +83,6 @@
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.util.ChangeIdUtil;
 
 @Singleton
@@ -267,7 +264,9 @@
             String.format("Branch %s does not exist.", dest.branch()));
       }
 
-      RevCommit baseCommit = getBaseCommit(destRef, project.get(), revWalk, input.base);
+      RevCommit baseCommit =
+          CommitUtil.getBaseCommit(
+              project.get(), queryProvider.get(), revWalk, destRef, input.base);
 
       CodeReviewCommit commitToCherryPick = revWalk.parseCommit(sourceCommit);
 
@@ -334,107 +333,57 @@
       } catch (MergeIdenticalTreeException | MergeConflictException e) {
         throw new IntegrationConflictException("Cherry pick failed: " + e.getMessage(), e);
       }
-
-      try (BatchUpdate bu = batchUpdateFactory.create(project, identifiedUser, timestamp)) {
-        bu.setRepository(git, revWalk, oi);
-        bu.setNotify(resolveNotify(input));
-        Change.Id changeId;
-        String newTopic = null;
-        if (input.topic != null) {
-          newTopic = Strings.emptyToNull(input.topic.trim());
+      try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+        try (BatchUpdate bu = batchUpdateFactory.create(project, identifiedUser, timestamp)) {
+          bu.setRepository(git, revWalk, oi);
+          bu.setNotify(resolveNotify(input));
+          Change.Id changeId;
+          String newTopic = null;
+          if (input.topic != null) {
+            newTopic = Strings.emptyToNull(input.topic.trim());
+          }
+          if (newTopic == null
+              && sourceChange != null
+              && !Strings.isNullOrEmpty(sourceChange.getTopic())) {
+            newTopic = sourceChange.getTopic() + "-" + dest.shortName();
+          }
+          if (destChange != null) {
+            // The change key exists on the destination branch. The cherry pick
+            // will be added as a new patch set.
+            changeId =
+                insertPatchSet(
+                    bu,
+                    git,
+                    destChange.notes(),
+                    cherryPickCommit,
+                    sourceChange,
+                    newTopic,
+                    input,
+                    workInProgress);
+          } else {
+            // Change key not found on destination branch. We can create a new
+            // change.
+            changeId =
+                createNewChange(
+                    bu,
+                    cherryPickCommit,
+                    dest.branch(),
+                    newTopic,
+                    project,
+                    sourceChange,
+                    sourceCommit,
+                    input,
+                    revertedChange,
+                    idForNewChange,
+                    workInProgress);
+          }
+          bu.execute();
+          return Result.create(changeId, cherryPickCommit.getFilesWithGitConflicts());
         }
-        if (newTopic == null
-            && sourceChange != null
-            && !Strings.isNullOrEmpty(sourceChange.getTopic())) {
-          newTopic = sourceChange.getTopic() + "-" + dest.shortName();
-        }
-        if (destChange != null) {
-          // The change key exists on the destination branch. The cherry pick
-          // will be added as a new patch set.
-          changeId =
-              insertPatchSet(
-                  bu,
-                  git,
-                  destChange.notes(),
-                  cherryPickCommit,
-                  sourceChange,
-                  newTopic,
-                  input,
-                  workInProgress);
-        } else {
-          // Change key not found on destination branch. We can create a new
-          // change.
-          changeId =
-              createNewChange(
-                  bu,
-                  cherryPickCommit,
-                  dest.branch(),
-                  newTopic,
-                  project,
-                  sourceChange,
-                  sourceCommit,
-                  input,
-                  revertedChange,
-                  idForNewChange,
-                  workInProgress);
-        }
-        bu.execute();
-        return Result.create(changeId, cherryPickCommit.getFilesWithGitConflicts());
       }
     }
   }
 
-  private RevCommit getBaseCommit(Ref destRef, String project, RevWalk revWalk, String base)
-      throws RestApiException, IOException {
-    RevCommit destRefTip = revWalk.parseCommit(destRef.getObjectId());
-    // The tip commit of the destination ref is the default base for the newly created change.
-    if (Strings.isNullOrEmpty(base)) {
-      return destRefTip;
-    }
-
-    ObjectId baseObjectId;
-    try {
-      baseObjectId = ObjectId.fromString(base);
-    } catch (InvalidObjectIdException e) {
-      throw new BadRequestException(
-          String.format("Base %s doesn't represent a valid SHA-1", base), e);
-    }
-
-    RevCommit baseCommit;
-    try {
-      baseCommit = revWalk.parseCommit(baseObjectId);
-    } catch (MissingObjectException e) {
-      throw new UnprocessableEntityException(
-          String.format("Base %s doesn't exist", baseObjectId.name()), e);
-    }
-
-    InternalChangeQuery changeQuery = queryProvider.get();
-    changeQuery.enforceVisibility(true);
-    List<ChangeData> changeDatas = changeQuery.byBranchCommit(project, destRef.getName(), base);
-
-    if (changeDatas.isEmpty()) {
-      if (revWalk.isMergedInto(baseCommit, destRefTip)) {
-        // The base commit is a merged commit with no change associated.
-        return baseCommit;
-      }
-      throw new UnprocessableEntityException(
-          String.format("Commit %s does not exist on branch %s", base, destRef.getName()));
-    } else if (changeDatas.size() != 1) {
-      throw new ResourceConflictException("Multiple changes found for commit " + base);
-    }
-
-    Change change = changeDatas.get(0).change();
-    if (!change.isAbandoned()) {
-      // The base commit is a valid change revision.
-      return baseCommit;
-    }
-
-    throw new ResourceConflictException(
-        String.format(
-            "Change %s with commit %s is %s",
-            change.getChangeId(), base, ChangeUtil.status(change)));
-  }
-
   private Change.Id insertPatchSet(
       BatchUpdate bu,
       Repository git,
@@ -456,7 +405,8 @@
     if (shouldSetToReady(cherryPickCommit, destNotes, workInProgress)) {
       inserter.setWorkInProgress(false);
     }
-    inserter.setValidationOptions(getValidateOptionsAsMultimap(input.validationOptions));
+    inserter.setValidationOptions(
+        ValidationOptionsUtil.getValidateOptionsAsMultimap(input.validationOptions));
     bu.addOp(destChange.getId(), inserter);
     PatchSet.Id sourcePatchSetId = sourceChange == null ? null : sourceChange.currentPatchSetId();
     // If sourceChange is not provided, reset cherryPickOf to avoid stale value.
@@ -507,7 +457,8 @@
           (sourceChange != null && sourceChange.isWorkInProgress())
               || !cherryPickCommit.getFilesWithGitConflicts().isEmpty());
     }
-    ins.setValidationOptions(getValidateOptionsAsMultimap(input.validationOptions));
+    ins.setValidationOptions(
+        ValidationOptionsUtil.getValidateOptionsAsMultimap(input.validationOptions));
     BranchNameKey sourceBranch = sourceChange == null ? null : sourceChange.getDest();
     PatchSet.Id sourcePatchSetId = sourceChange == null ? null : sourceChange.currentPatchSetId();
     ins.setMessage(
@@ -529,7 +480,7 @@
       reviewers.remove(user.get().getAccountId());
       Set<Account.Id> ccs = new HashSet<>(reviewerSet.byState(ReviewerStateInternal.CC));
       ccs.remove(user.get().getAccountId());
-      ins.setReviewersAndCcs(reviewers, ccs);
+      ins.setReviewersAndCcsIgnoreVisibility(reviewers, ccs);
     }
     // If there is a base, and the base is not merged, the groups will be overridden by the base's
     // groups.
@@ -553,20 +504,6 @@
     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 NotifyResolver.Result resolveNotify(CherryPickInput input)
       throws BadRequestException, ConfigInvalidException, IOException {
     return notifyResolver.resolve(
diff --git a/java/com/google/gerrit/server/restapi/change/CommentJson.java b/java/com/google/gerrit/server/restapi/change/CommentJson.java
index 81b6fb3..8ebe71f 100644
--- a/java/com/google/gerrit/server/restapi/change/CommentJson.java
+++ b/java/com/google/gerrit/server/restapi/change/CommentJson.java
@@ -263,6 +263,7 @@
       return rci;
     }
 
+    @Nullable
     private List<FixSuggestionInfo> toFixSuggestionInfos(
         @Nullable List<FixSuggestion> fixSuggestions) {
       if (fixSuggestions == null || fixSuggestions.isEmpty()) {
diff --git a/java/com/google/gerrit/server/restapi/change/CreateChange.java b/java/com/google/gerrit/server/restapi/change/CreateChange.java
index 760d99d..a1bb987 100644
--- a/java/com/google/gerrit/server/restapi/change/CreateChange.java
+++ b/java/com/google/gerrit/server/restapi/change/CreateChange.java
@@ -15,10 +15,12 @@
 package com.google.gerrit.server.restapi.change;
 
 import static com.google.common.base.MoreObjects.firstNonNull;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
 import static org.eclipse.jgit.lib.Constants.SIGNED_OFF_BY_TAG;
 
 import com.google.common.base.Joiner;
 import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.Iterables;
 import com.google.common.flogger.FluentLogger;
@@ -34,6 +36,7 @@
 import com.google.gerrit.extensions.api.accounts.AccountInput;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.client.ChangeStatus;
+import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ChangeInput;
@@ -61,6 +64,7 @@
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.git.CodeReviewCommit;
 import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
+import com.google.gerrit.server.git.CommitUtil;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.MergeUtil;
 import com.google.gerrit.server.git.MergeUtilFactory;
@@ -79,6 +83,7 @@
 import com.google.gerrit.server.restapi.project.ProjectsCollection;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.update.context.RefUpdateContext;
 import com.google.gerrit.server.util.CommitMessageUtil;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
@@ -94,7 +99,6 @@
 import org.eclipse.jgit.errors.InvalidObjectIdException;
 import org.eclipse.jgit.errors.MissingObjectException;
 import org.eclipse.jgit.errors.NoMergeBaseException;
-import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.ObjectId;
@@ -104,6 +108,7 @@
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.lib.TreeFormatter;
+import org.eclipse.jgit.patch.PatchApplier;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.util.ChangeIdUtil;
@@ -293,6 +298,10 @@
       }
     }
 
+    if (input.merge != null && input.patch != null) {
+      throw new BadRequestException("Only one of `merge` and `patch` arguments can be set.");
+    }
+
     if (input.author != null
         && (Strings.isNullOrEmpty(input.author.email)
             || Strings.isNullOrEmpty(input.author.name))) {
@@ -325,92 +334,137 @@
       BatchUpdate.Factory updateFactory)
       throws RestApiException, PermissionBackendException, IOException, ConfigInvalidException,
           UpdateException {
-    logger.atFine().log(
-        "Creating new change for target branch %s in project %s"
-            + " (new branch = %s, base change = %s, base commit = %s)",
-        input.branch, projectState.getName(), input.newBranch, input.baseChange, input.baseCommit);
-
-    try (Repository git = gitManager.openRepository(projectState.getNameKey());
-        ObjectInserter oi = git.newObjectInserter();
-        ObjectReader reader = oi.newReader();
-        CodeReviewRevWalk rw = CodeReviewCommit.newRevWalk(reader)) {
-      PatchSet basePatchSet = null;
-      List<String> groups = Collections.emptyList();
-
-      if (input.baseChange != null) {
-        ChangeNotes baseChange = getBaseChange(input.baseChange);
-        basePatchSet = psUtil.current(baseChange);
-        groups = basePatchSet.groups();
-        logger.atFine().log("base patch set = %s (groups = %s)", basePatchSet.id(), groups);
-      }
-
-      ObjectId parentCommit =
-          getParentCommit(
-              git, rw, input.branch, input.newBranch, basePatchSet, input.baseCommit, input.merge);
+    try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
       logger.atFine().log(
-          "parent commit = %s", parentCommit != null ? parentCommit.name() : "NULL");
+          "Creating new change for target branch %s in project %s"
+              + " (new branch = %s, base change = %s, base commit = %s)",
+          input.branch,
+          projectState.getName(),
+          input.newBranch,
+          input.baseChange,
+          input.baseCommit);
 
-      RevCommit mergeTip = parentCommit == null ? null : rw.parseCommit(parentCommit);
+      try (Repository git = gitManager.openRepository(projectState.getNameKey());
+          ObjectInserter oi = git.newObjectInserter();
+          ObjectReader reader = oi.newReader();
+          CodeReviewRevWalk rw = CodeReviewCommit.newRevWalk(reader)) {
+        PatchSet basePatchSet = null;
+        List<String> groups = Collections.emptyList();
 
-      Instant now = TimeUtil.now();
-
-      PersonIdent committer = me.newCommitterIdent(now, serverZoneId);
-      PersonIdent author =
-          input.author == null
-              ? committer
-              : new PersonIdent(input.author.name, input.author.email, now, serverZoneId);
-
-      String commitMessage = getCommitMessage(input.subject, me);
-
-      CodeReviewCommit c;
-      if (input.merge != null) {
-        // create a merge commit
-        c =
-            newMergeCommit(
-                git, oi, rw, projectState, mergeTip, input.merge, author, committer, commitMessage);
-        if (!c.getFilesWithGitConflicts().isEmpty()) {
-          logger.atFine().log(
-              "merge commit has conflicts in the following files: %s",
-              c.getFilesWithGitConflicts());
+        if (input.baseChange != null) {
+          ChangeNotes baseChange = getBaseChange(input.baseChange);
+          basePatchSet = psUtil.current(baseChange);
+          groups = basePatchSet.groups();
+          logger.atFine().log("base patch set = %s (groups = %s)", basePatchSet.id(), groups);
         }
-      } else {
-        // create an empty commit
-        c = newCommit(oi, rw, author, committer, mergeTip, commitMessage);
-      }
-      // Flush inserter so that commit becomes visible to validators
-      oi.flush();
 
-      Change.Id changeId = Change.id(seq.nextChangeId());
-      ChangeInserter ins = changeInserterFactory.create(changeId, c, input.branch);
-      ins.setMessage(messageForNewChange(ins.getPatchSetId(), c));
-      ins.setTopic(input.topic);
-      ins.setPrivate(input.isPrivate);
-      ins.setWorkInProgress(input.workInProgress || !c.getFilesWithGitConflicts().isEmpty());
-      ins.setGroups(groups);
+        ObjectId parentCommit =
+            getParentCommit(
+                git,
+                rw,
+                input.branch,
+                input.newBranch,
+                basePatchSet,
+                input.baseCommit,
+                input.merge);
+        logger.atFine().log(
+            "parent commit = %s", parentCommit != null ? parentCommit.name() : "NULL");
 
-      if (input.validationOptions != null) {
-        ImmutableListMultimap.Builder<String, String> validationOptions =
-            ImmutableListMultimap.builder();
-        input
-            .validationOptions
-            .entrySet()
-            .forEach(e -> validationOptions.put(e.getKey(), e.getValue()));
-        ins.setValidationOptions(validationOptions.build());
-      }
+        RevCommit mergeTip = parentCommit == null ? null : rw.parseCommit(parentCommit);
 
-      try (BatchUpdate bu = updateFactory.create(projectState.getNameKey(), me, now)) {
-        bu.setRepository(git, rw, oi);
-        bu.setNotify(
-            notifyResolver.resolve(
-                firstNonNull(input.notify, NotifyHandling.ALL), input.notifyDetails));
-        bu.insertChange(ins);
-        bu.execute();
+        Instant now = TimeUtil.now();
+
+        PersonIdent committer = me.newCommitterIdent(now, serverZoneId);
+        PersonIdent author =
+            input.author == null
+                ? committer
+                : new PersonIdent(input.author.name, input.author.email, now, serverZoneId);
+
+        String commitMessage = getCommitMessage(input.subject, me);
+
+        CodeReviewCommit c;
+        if (input.merge != null) {
+          // create a merge commit
+          c =
+              newMergeCommit(
+                  git,
+                  oi,
+                  rw,
+                  projectState,
+                  mergeTip,
+                  input.merge,
+                  author,
+                  committer,
+                  commitMessage);
+          if (!c.getFilesWithGitConflicts().isEmpty()) {
+            logger.atFine().log(
+                "merge commit has conflicts in the following files: %s",
+                c.getFilesWithGitConflicts());
+          }
+        } else if (input.patch != null) {
+          // create a commit with the given patch.
+          if (mergeTip == null) {
+            throw new BadRequestException("Cannot apply patch on top of an empty tree.");
+          }
+          PatchApplier.Result applyResult =
+              ApplyPatchUtil.applyPatch(git, oi, input.patch, mergeTip);
+          ObjectId treeId = applyResult.getTreeId();
+          String appliedPatchCommitMessage =
+              getCommitMessage(
+                  ApplyPatchUtil.buildCommitMessage(
+                      input.subject,
+                      ImmutableList.of(),
+                      input.patch.patch,
+                      ApplyPatchUtil.getResultPatch(git, reader, mergeTip, rw.lookupTree(treeId)),
+                      applyResult.getErrors()),
+                  me);
+          c =
+              rw.parseCommit(
+                  CommitUtil.createCommitWithTree(
+                      oi, author, committer, mergeTip, appliedPatchCommitMessage, treeId));
+        } else {
+          // create an empty commit.
+          c = createEmptyCommit(oi, rw, author, committer, mergeTip, commitMessage);
+        }
+        // Flush inserter so that commit becomes visible to validators
+        oi.flush();
+
+        Change.Id changeId = Change.id(seq.nextChangeId());
+        ChangeInserter ins = changeInserterFactory.create(changeId, c, input.branch);
+        ins.setMessage(messageForNewChange(ins.getPatchSetId(), c));
+        ins.setTopic(input.topic);
+        ins.setPrivate(input.isPrivate);
+        ins.setWorkInProgress(input.workInProgress || !c.getFilesWithGitConflicts().isEmpty());
+        ins.setGroups(groups);
+
+        if (input.validationOptions != null) {
+          ImmutableListMultimap.Builder<String, String> validationOptions =
+              ImmutableListMultimap.builder();
+          input
+              .validationOptions
+              .entrySet()
+              .forEach(e -> validationOptions.put(e.getKey(), e.getValue()));
+          ins.setValidationOptions(validationOptions.build());
+        }
+
+        try (BatchUpdate bu = updateFactory.create(projectState.getNameKey(), me, now)) {
+          bu.setRepository(git, rw, oi);
+          bu.setNotify(
+              notifyResolver.resolve(
+                  firstNonNull(input.notify, NotifyHandling.ALL), input.notifyDetails));
+          bu.insertChange(ins);
+          bu.execute();
+        }
+        List<ListChangesOption> opts = input.responseFormatOptions;
+        if (opts == null) {
+          opts = ImmutableList.of();
+        }
+        ChangeInfo changeInfo = jsonFactory.create(opts).format(ins.getChange());
+        changeInfo.containsGitConflicts = !c.getFilesWithGitConflicts().isEmpty() ? true : null;
+        return changeInfo;
+      } catch (InvalidMergeStrategyException | MergeWithConflictsNotSupportedException e) {
+        throw new BadRequestException(e.getMessage());
       }
-      ChangeInfo changeInfo = jsonFactory.noOptions().format(ins.getChange());
-      changeInfo.containsGitConflicts = !c.getFilesWithGitConflicts().isEmpty() ? true : null;
-      return changeInfo;
-    } catch (InvalidMergeStrategyException | MergeWithConflictsNotSupportedException e) {
-      throw new BadRequestException(e.getMessage());
     }
   }
 
@@ -526,7 +580,7 @@
     return commitMessage;
   }
 
-  private static CodeReviewCommit newCommit(
+  private static CodeReviewCommit createEmptyCommit(
       ObjectInserter oi,
       CodeReviewRevWalk rw,
       PersonIdent authorIdent,
@@ -535,17 +589,14 @@
       String commitMessage)
       throws IOException {
     logger.atFine().log("Creating empty commit");
-    CommitBuilder commit = new CommitBuilder();
-    if (mergeTip == null) {
-      commit.setTreeId(emptyTreeId(oi));
-    } else {
-      commit.setTreeId(mergeTip.getTree().getId());
-      commit.setParentId(mergeTip);
-    }
-    commit.setAuthor(authorIdent);
-    commit.setCommitter(committerIdent);
-    commit.setMessage(commitMessage);
-    return rw.parseCommit(insert(oi, commit));
+    ObjectId treeID = mergeTip == null ? emptyTreeId(oi) : mergeTip.getTree().getId();
+    return rw.parseCommit(
+        CommitUtil.createCommitWithTree(
+            oi, authorIdent, committerIdent, mergeTip, commitMessage, treeID));
+  }
+
+  private static ObjectId emptyTreeId(ObjectInserter inserter) throws IOException {
+    return inserter.insert(new TreeFormatter());
   }
 
   private CodeReviewCommit newMergeCommit(
@@ -615,14 +666,4 @@
 
     return stringBuilder.toString();
   }
-
-  private static ObjectId insert(ObjectInserter inserter, CommitBuilder commit) throws IOException {
-    ObjectId id = inserter.insert(commit);
-    inserter.flush();
-    return id;
-  }
-
-  private static ObjectId emptyTreeId(ObjectInserter inserter) throws IOException {
-    return inserter.insert(new TreeFormatter());
-  }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/CreateDraftComment.java b/java/com/google/gerrit/server/restapi/change/CreateDraftComment.java
index 9e9cf6a..cd0025f 100644
--- a/java/com/google/gerrit/server/restapi/change/CreateDraftComment.java
+++ b/java/com/google/gerrit/server/restapi/change/CreateDraftComment.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.restapi.change;
 
 import static com.google.gerrit.entities.Patch.PATCHSET_LEVEL;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
 
 import com.google.common.base.Strings;
 import com.google.gerrit.entities.HumanComment;
@@ -36,6 +37,7 @@
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.update.context.RefUpdateContext;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -81,13 +83,15 @@
       throw new BadRequestException(
           String.format("Invalid inReplyTo, comment %s not found", in.inReplyTo));
     }
-
-    try (BatchUpdate bu = updateFactory.create(rsrc.getProject(), rsrc.getUser(), TimeUtil.now())) {
-      Op op = new Op(rsrc.getPatchSet().id(), in);
-      bu.addOp(rsrc.getChange().getId(), op);
-      bu.execute();
-      return Response.created(
-          commentJson.get().setFillAccounts(false).newHumanCommentFormatter().format(op.comment));
+    try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+      try (BatchUpdate bu =
+          updateFactory.create(rsrc.getProject(), rsrc.getUser(), TimeUtil.now())) {
+        Op op = new Op(rsrc.getPatchSet().id(), in);
+        bu.addOp(rsrc.getChange().getId(), op);
+        bu.execute();
+        return Response.created(
+            commentJson.get().setFillAccounts(false).newHumanCommentFormatter().format(op.comment));
+      }
     }
   }
 
diff --git a/java/com/google/gerrit/server/restapi/change/CreateMergePatchSet.java b/java/com/google/gerrit/server/restapi/change/CreateMergePatchSet.java
index 4b66cdc..51094b7 100644
--- a/java/com/google/gerrit/server/restapi/change/CreateMergePatchSet.java
+++ b/java/com/google/gerrit/server/restapi/change/CreateMergePatchSet.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.restapi.change;
 
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
 
 import com.google.common.base.MoreObjects;
 import com.google.common.base.Strings;
@@ -63,6 +64,7 @@
 import com.google.gerrit.server.submit.MergeIdenticalTreeException;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.update.context.RefUpdateContext;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -200,18 +202,20 @@
       PatchSet.Id nextPsId = ChangeUtil.nextPatchSetId(ps.id());
       PatchSetInserter psInserter =
           patchSetInserterFactory.create(rsrc.getNotes(), nextPsId, newCommit);
-      try (BatchUpdate bu = updateFactory.create(project, me, now)) {
-        bu.setRepository(git, rw, oi);
-        bu.setNotify(NotifyResolver.Result.none());
-        psInserter
-            .setMessage(messageForChange(nextPsId, newCommit))
-            .setWorkInProgress(!newCommit.getFilesWithGitConflicts().isEmpty())
-            .setCheckAddPatchSetPermission(false);
-        if (groups != null) {
-          psInserter.setGroups(groups);
+      try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+        try (BatchUpdate bu = updateFactory.create(project, me, now)) {
+          bu.setRepository(git, rw, oi);
+          bu.setNotify(NotifyResolver.Result.none());
+          psInserter
+              .setMessage(messageForChange(nextPsId, newCommit))
+              .setWorkInProgress(!newCommit.getFilesWithGitConflicts().isEmpty())
+              .setCheckAddPatchSetPermission(false);
+          if (groups != null) {
+            psInserter.setGroups(groups);
+          }
+          bu.addOp(rsrc.getId(), psInserter);
+          bu.execute();
         }
-        bu.addOp(rsrc.getId(), psInserter);
-        bu.execute();
       }
 
       ChangeJson json = jsonFactory.create(ListChangesOption.CURRENT_REVISION);
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteAssignee.java b/java/com/google/gerrit/server/restapi/change/DeleteAssignee.java
deleted file mode 100644
index d818210..0000000
--- a/java/com/google/gerrit/server/restapi/change/DeleteAssignee.java
+++ /dev/null
@@ -1,118 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.restapi.change;
-
-import com.google.gerrit.entities.Account;
-import com.google.gerrit.entities.Change;
-import com.google.gerrit.extensions.common.AccountInfo;
-import com.google.gerrit.extensions.common.Input;
-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.IdentifiedUser;
-import com.google.gerrit.server.account.AccountLoader;
-import com.google.gerrit.server.account.AccountState;
-import com.google.gerrit.server.change.ChangeResource;
-import com.google.gerrit.server.extensions.events.AssigneeChanged;
-import com.google.gerrit.server.notedb.ChangeUpdate;
-import com.google.gerrit.server.permissions.ChangePermission;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.update.BatchUpdate;
-import com.google.gerrit.server.update.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.time.TimeUtil;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-
-@Singleton
-public class DeleteAssignee implements RestModifyView<ChangeResource, Input> {
-  private final BatchUpdate.Factory updateFactory;
-  private final ChangeMessagesUtil cmUtil;
-  private final AssigneeChanged assigneeChanged;
-  private final IdentifiedUser.GenericFactory userFactory;
-  private final AccountLoader.Factory accountLoaderFactory;
-
-  @Inject
-  DeleteAssignee(
-      BatchUpdate.Factory updateFactory,
-      ChangeMessagesUtil cmUtil,
-      AssigneeChanged assigneeChanged,
-      IdentifiedUser.GenericFactory userFactory,
-      AccountLoader.Factory accountLoaderFactory) {
-    this.updateFactory = updateFactory;
-    this.cmUtil = cmUtil;
-    this.assigneeChanged = assigneeChanged;
-    this.userFactory = userFactory;
-    this.accountLoaderFactory = accountLoaderFactory;
-  }
-
-  @Override
-  public Response<AccountInfo> apply(ChangeResource rsrc, Input input)
-      throws RestApiException, UpdateException, PermissionBackendException {
-    rsrc.permissions().check(ChangePermission.EDIT_ASSIGNEE);
-
-    try (BatchUpdate bu = updateFactory.create(rsrc.getProject(), rsrc.getUser(), TimeUtil.now())) {
-      Op op = new Op();
-      bu.addOp(rsrc.getChange().getId(), op);
-      bu.execute();
-      Account.Id deletedAssignee = op.getDeletedAssignee();
-      return deletedAssignee == null
-          ? Response.none()
-          : Response.ok(accountLoaderFactory.create(true).fillOne(deletedAssignee));
-    }
-  }
-
-  private class Op implements BatchUpdateOp {
-    private Change change;
-    private AccountState deletedAssignee;
-
-    @Override
-    public boolean updateChange(ChangeContext ctx) throws RestApiException {
-      change = ctx.getChange();
-      ChangeUpdate update = ctx.getUpdate(change.currentPatchSetId());
-      Account.Id currentAssigneeId = change.getAssignee();
-      if (currentAssigneeId == null) {
-        return false;
-      }
-      IdentifiedUser deletedAssigneeUser = userFactory.create(currentAssigneeId);
-      deletedAssignee = deletedAssigneeUser.state();
-      update.removeAssignee();
-      addMessage(ctx, deletedAssigneeUser);
-      return true;
-    }
-
-    public Account.Id getDeletedAssignee() {
-      return deletedAssignee != null ? deletedAssignee.account().id() : null;
-    }
-
-    private void addMessage(ChangeContext ctx, IdentifiedUser deletedAssignee) {
-      cmUtil.setChangeMessage(
-          ctx,
-          "Assignee deleted: "
-              + AccountTemplateUtil.getAccountTemplate(deletedAssignee.getAccountId()),
-          ChangeMessagesUtil.TAG_DELETE_ASSIGNEE);
-    }
-
-    @Override
-    public void postUpdate(PostUpdateContext ctx) {
-      assigneeChanged.fire(
-          ctx.getChangeData(change), ctx.getAccount(), deletedAssignee, ctx.getWhen());
-    }
-  }
-}
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteChange.java b/java/com/google/gerrit/server/restapi/change/DeleteChange.java
index 8298abb..9153703 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteChange.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteChange.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.restapi.change;
 
 import static com.google.gerrit.extensions.conditions.BooleanCondition.and;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
 
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.extensions.common.Input;
@@ -30,6 +31,7 @@
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.update.context.RefUpdateContext;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -53,11 +55,13 @@
       throw new MethodNotAllowedException("delete not permitted");
     }
     rsrc.permissions().check(ChangePermission.DELETE);
-
-    try (BatchUpdate bu = updateFactory.create(rsrc.getProject(), rsrc.getUser(), TimeUtil.now())) {
-      Change.Id id = rsrc.getChange().getId();
-      bu.addOp(id, opFactory.create(id));
-      bu.execute();
+    try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+      try (BatchUpdate bu =
+          updateFactory.create(rsrc.getProject(), rsrc.getUser(), TimeUtil.now())) {
+        Change.Id id = rsrc.getChange().getId();
+        bu.addOp(id, opFactory.create(id));
+        bu.execute();
+      }
     }
     return Response.none();
   }
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteChangeMessage.java b/java/com/google/gerrit/server/restapi/change/DeleteChangeMessage.java
index 588d56e..ca6bfad 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteChangeMessage.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteChangeMessage.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.restapi.change;
 
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
 import static java.util.Objects.requireNonNull;
 
 import com.google.common.base.Strings;
@@ -41,6 +42,7 @@
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.update.context.RefUpdateContext;
 import com.google.gerrit.server.util.AccountTemplateUtil;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
@@ -88,9 +90,11 @@
         createNewChangeMessage(user.asIdentifiedUser().getAccountId(), input.reason);
     DeleteChangeMessageOp deleteChangeMessageOp =
         new DeleteChangeMessageOp(resource.getChangeMessageId(), newChangeMessage);
-    try (BatchUpdate batchUpdate =
-        updateFactory.create(resource.getChangeResource().getProject(), user, TimeUtil.now())) {
-      batchUpdate.addOp(resource.getChangeId(), deleteChangeMessageOp).execute();
+    try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+      try (BatchUpdate batchUpdate =
+          updateFactory.create(resource.getChangeResource().getProject(), user, TimeUtil.now())) {
+        batchUpdate.addOp(resource.getChangeId(), deleteChangeMessageOp).execute();
+      }
     }
 
     ChangeMessageInfo updatedMessageInfo =
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteComment.java b/java/com/google/gerrit/server/restapi/change/DeleteComment.java
index 2056664..1397582 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteComment.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteComment.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.restapi.change;
 
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
+
 import com.google.common.base.Strings;
 import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.PatchSet;
@@ -35,6 +37,7 @@
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.update.context.RefUpdateContext;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -83,9 +86,13 @@
 
     String newMessage = getCommentNewMessage(user.asIdentifiedUser().getName(), input.reason);
     DeleteCommentOp deleteCommentOp = new DeleteCommentOp(rsrc, newMessage);
-    try (BatchUpdate batchUpdate =
-        updateFactory.create(rsrc.getRevisionResource().getProject(), user, TimeUtil.now())) {
-      batchUpdate.addOp(rsrc.getRevisionResource().getChange().getId(), deleteCommentOp).execute();
+    try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+      try (BatchUpdate batchUpdate =
+          updateFactory.create(rsrc.getRevisionResource().getProject(), user, TimeUtil.now())) {
+        batchUpdate
+            .addOp(rsrc.getRevisionResource().getChange().getId(), deleteCommentOp)
+            .execute();
+      }
     }
 
     ChangeNotes updatedNotes =
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteDraftComment.java b/java/com/google/gerrit/server/restapi/change/DeleteDraftComment.java
index 7d28a39..f55e9c7 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteDraftComment.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteDraftComment.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.restapi.change;
 
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
+
 import com.google.gerrit.entities.Comment;
 import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.PatchSet;
@@ -30,6 +32,7 @@
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.update.context.RefUpdateContext;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -53,11 +56,13 @@
   @Override
   public Response<CommentInfo> apply(DraftCommentResource rsrc, Input input)
       throws RestApiException, UpdateException {
-    try (BatchUpdate bu =
-        updateFactory.create(rsrc.getChange().getProject(), rsrc.getUser(), TimeUtil.now())) {
-      Op op = new Op(rsrc.getComment().key);
-      bu.addOp(rsrc.getChange().getId(), op);
-      bu.execute();
+    try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+      try (BatchUpdate bu =
+          updateFactory.create(rsrc.getChange().getProject(), rsrc.getUser(), TimeUtil.now())) {
+        Op op = new Op(rsrc.getComment().key);
+        bu.addOp(rsrc.getChange().getId(), op);
+        bu.execute();
+      }
     }
     return Response.none();
   }
diff --git a/java/com/google/gerrit/server/restapi/change/DeletePrivate.java b/java/com/google/gerrit/server/restapi/change/DeletePrivate.java
index 08725b5..5c63bd7 100644
--- a/java/com/google/gerrit/server/restapi/change/DeletePrivate.java
+++ b/java/com/google/gerrit/server/restapi/change/DeletePrivate.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.restapi.change;
 
 import static com.google.gerrit.extensions.conditions.BooleanCondition.or;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
 
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.common.InputWithMessage;
@@ -30,6 +31,7 @@
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.update.context.RefUpdateContext;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -62,8 +64,11 @@
     }
 
     SetPrivateOp op = setPrivateOpFactory.create(false, input);
-    try (BatchUpdate u = updateFactory.create(rsrc.getProject(), rsrc.getUser(), TimeUtil.now())) {
-      u.addOp(rsrc.getId(), op).execute();
+    try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+      try (BatchUpdate u =
+          updateFactory.create(rsrc.getProject(), rsrc.getUser(), TimeUtil.now())) {
+        u.addOp(rsrc.getId(), op).execute();
+      }
     }
 
     return Response.none();
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteReviewer.java b/java/com/google/gerrit/server/restapi/change/DeleteReviewer.java
index 7a409e8..cbc3b5e 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteReviewer.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteReviewer.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.restapi.change;
 
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
+
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.extensions.api.changes.DeleteReviewerInput;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
@@ -27,6 +29,7 @@
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.update.context.RefUpdateContext;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -53,21 +56,22 @@
     if (input == null) {
       input = new DeleteReviewerInput();
     }
-
-    try (BatchUpdate bu =
-        updateFactory.create(
-            rsrc.getChangeResource().getProject(),
-            rsrc.getChangeResource().getUser(),
-            TimeUtil.now())) {
-      bu.setNotify(getNotify(rsrc.getChange(), input));
-      BatchUpdateOp op;
-      if (rsrc.isByEmail()) {
-        op = deleteReviewerByEmailOpFactory.create(rsrc.getReviewerByEmail());
-      } else {
-        op = deleteReviewerOpFactory.create(rsrc.getReviewerUser().getAccount(), input);
+    try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+      try (BatchUpdate bu =
+          updateFactory.create(
+              rsrc.getChangeResource().getProject(),
+              rsrc.getChangeResource().getUser(),
+              TimeUtil.now())) {
+        bu.setNotify(getNotify(rsrc.getChange(), input));
+        BatchUpdateOp op;
+        if (rsrc.isByEmail()) {
+          op = deleteReviewerByEmailOpFactory.create(rsrc.getReviewerByEmail());
+        } else {
+          op = deleteReviewerOpFactory.create(rsrc.getReviewerUser().getAccount(), input);
+        }
+        bu.addOp(rsrc.getChange().getId(), op);
+        bu.execute();
       }
-      bu.addOp(rsrc.getChange().getId(), op);
-      bu.execute();
     }
     return Response.none();
   }
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteVote.java b/java/com/google/gerrit/server/restapi/change/DeleteVote.java
index 9fa3160..b3d7fa2 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteVote.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteVote.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.restapi.change;
 
 import static com.google.common.base.MoreObjects.firstNonNull;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
 
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.extensions.api.changes.DeleteVoteInput;
@@ -32,6 +33,7 @@
 import com.google.gerrit.server.change.VoteResource;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.update.context.RefUpdateContext;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -80,34 +82,37 @@
     if (r.getRevisionResource() != null && !r.getRevisionResource().isCurrent()) {
       throw new MethodNotAllowedException("Cannot delete vote on non-current patch set");
     }
-
-    try (BatchUpdate bu =
-        updateFactory.create(
-            change.getProject(), r.getChangeResource().getUser(), TimeUtil.now())) {
-      bu.setNotify(
-          notifyResolver.resolve(
-              firstNonNull(input.notify, NotifyHandling.ALL), input.notifyDetails));
-      bu.addOp(
-          change.getId(),
-          deleteVoteOpFactory.create(
-              r.getChange().getProject(),
-              r.getReviewerUser().state(),
-              rsrc.getLabel(),
-              input,
-              true));
-      if (!input.ignoreAutomaticAttentionSetRules
-          && !r.getReviewerUser().getAccountId().equals(currentUserProvider.get().getAccountId())) {
+    try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+      try (BatchUpdate bu =
+          updateFactory.create(
+              change.getProject(), r.getChangeResource().getUser(), TimeUtil.now())) {
+        bu.setNotify(
+            notifyResolver.resolve(
+                firstNonNull(input.notify, NotifyHandling.ALL), input.notifyDetails));
         bu.addOp(
             change.getId(),
-            attentionSetOpFactory.create(
-                r.getReviewerUser().getAccountId(),
-                /* reason= */ "Their vote was deleted",
-                /* notify= */ false));
+            deleteVoteOpFactory.create(
+                r.getChange().getProject(),
+                r.getReviewerUser().state(),
+                rsrc.getLabel(),
+                input,
+                true));
+        if (!input.ignoreAutomaticAttentionSetRules
+            && !r.getReviewerUser()
+                .getAccountId()
+                .equals(currentUserProvider.get().getAccountId())) {
+          bu.addOp(
+              change.getId(),
+              attentionSetOpFactory.create(
+                  r.getReviewerUser().getAccountId(),
+                  /* reason= */ "Their vote was deleted",
+                  /* notify= */ false));
+        }
+        if (input.ignoreAutomaticAttentionSetRules) {
+          bu.addOp(change.getId(), new AttentionSetUnchangedOp());
+        }
+        bu.execute();
       }
-      if (input.ignoreAutomaticAttentionSetRules) {
-        bu.addOp(change.getId(), new AttentionSetUnchangedOp());
-      }
-      bu.execute();
     }
 
     return Response.none();
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteVoteOp.java b/java/com/google/gerrit/server/restapi/change/DeleteVoteOp.java
index 432f0da..3ac4d22 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteVoteOp.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteVoteOp.java
@@ -20,6 +20,7 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.LabelTypes;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
@@ -37,7 +38,9 @@
 import com.google.gerrit.server.mail.send.DeleteVoteSender;
 import com.google.gerrit.server.mail.send.MessageIdGenerator;
 import com.google.gerrit.server.mail.send.ReplyToChangeSender;
+import com.google.gerrit.server.permissions.LabelRemovalPermission;
 import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.DeleteVoteControl;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.RemoveReviewerControl;
 import com.google.gerrit.server.update.BatchUpdateOp;
@@ -75,6 +78,7 @@
   private final VoteDeleted voteDeleted;
   private final DeleteVoteSender.Factory deleteVoteSenderFactory;
 
+  private final DeleteVoteControl deleteVoteControl;
   private final RemoveReviewerControl removeReviewerControl;
   private final MessageIdGenerator messageIdGenerator;
 
@@ -96,8 +100,9 @@
       ChangeMessagesUtil cmUtil,
       VoteDeleted voteDeleted,
       DeleteVoteSender.Factory deleteVoteSenderFactory,
-      RemoveReviewerControl removeReviewerControl,
+      DeleteVoteControl deleteVoteControl,
       MessageIdGenerator messageIdGenerator,
+      RemoveReviewerControl removeReviewerControl,
       @Assisted Project.NameKey projectName,
       @Assisted AccountState reviewerToDeleteVoteFor,
       @Assisted String label,
@@ -109,6 +114,7 @@
     this.cmUtil = cmUtil;
     this.voteDeleted = voteDeleted;
     this.deleteVoteSenderFactory = deleteVoteSenderFactory;
+    this.deleteVoteControl = deleteVoteControl;
     this.removeReviewerControl = removeReviewerControl;
     this.messageIdGenerator = messageIdGenerator;
 
@@ -143,19 +149,16 @@
         newApprovals.put(a.label(), a.value());
         continue;
       } else if (enforcePermissions) {
-        // For regular users, check if they are allowed to remove the vote.
-        try {
-          removeReviewerControl.checkRemoveReviewer(ctx.getNotes(), ctx.getUser(), a);
-        } catch (AuthException e) {
-          throw new AuthException("delete vote not permitted", e);
-        }
+        checkPermissions(ctx, labelTypes.byLabel(a.labelId()).get(), a);
       }
       // Set the approval to 0 if vote is being removed.
       newApprovals.put(a.label(), (short) 0);
-      found = true;
-
-      // Set old value, as required by VoteDeleted.
-      oldApprovals.put(a.label(), a.value());
+      // If the value is 0, we treat it as already deleted, so no additional actions is required
+      if (a.value() != 0) {
+        found = true;
+        // Set old value, as required by VoteDeleted.
+        oldApprovals.put(a.label(), a.value());
+      }
       break;
     }
     if (!found) {
@@ -185,18 +188,16 @@
     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();
+      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());
     }
@@ -211,4 +212,21 @@
         user.isIdentifiedUser() ? user.asIdentifiedUser().state() : null,
         ctx.getWhen());
   }
+
+  private void checkPermissions(ChangeContext ctx, LabelType labelType, PatchSetApproval approval)
+      throws PermissionBackendException, AuthException {
+    boolean permitted =
+        removeReviewerControl.testRemoveReviewer(ctx.getNotes(), ctx.getUser(), approval)
+            || deleteVoteControl.testDeleteVotePermissions(
+                ctx.getUser(), ctx.getNotes(), approval, labelType);
+    if (!permitted) {
+      throw new AuthException(
+          "Delete vote not permitted.",
+          new AuthException(
+              "Both "
+                  + new LabelRemovalPermission.WithValue(labelType, approval.value())
+                      .describeForException()
+                  + " and remove-reviewer are not permitted"));
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/Files.java b/java/com/google/gerrit/server/restapi/change/Files.java
index e89cf6c..96e5645 100644
--- a/java/com/google/gerrit/server/restapi/change/Files.java
+++ b/java/com/google/gerrit/server/restapi/change/Files.java
@@ -370,6 +370,7 @@
           : Iterables.getFirst(fileDiffList.values(), null).oldCommitId();
     }
 
+    @Nullable
     private ObjectId getNewId(Map<String, FileDiffOutput> fileDiffList) {
       return fileDiffList.isEmpty()
           ? null
diff --git a/java/com/google/gerrit/server/restapi/change/GetAssignee.java b/java/com/google/gerrit/server/restapi/change/GetAssignee.java
deleted file mode 100644
index a5820bf..0000000
--- a/java/com/google/gerrit/server/restapi/change/GetAssignee.java
+++ /dev/null
@@ -1,45 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.restapi.change;
-
-import com.google.gerrit.entities.Account;
-import com.google.gerrit.extensions.common.AccountInfo;
-import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.server.account.AccountLoader;
-import com.google.gerrit.server.change.ChangeResource;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-import java.util.Optional;
-
-@Singleton
-public class GetAssignee implements RestReadView<ChangeResource> {
-  private final AccountLoader.Factory accountLoaderFactory;
-
-  @Inject
-  GetAssignee(AccountLoader.Factory accountLoaderFactory) {
-    this.accountLoaderFactory = accountLoaderFactory;
-  }
-
-  @Override
-  public Response<AccountInfo> apply(ChangeResource rsrc) throws PermissionBackendException {
-    Optional<Account.Id> assignee = Optional.ofNullable(rsrc.getChange().getAssignee());
-    if (assignee.isPresent()) {
-      return Response.ok(accountLoaderFactory.create(true).fillOne(assignee.get()));
-    }
-    return Response.none();
-  }
-}
diff --git a/java/com/google/gerrit/server/restapi/change/GetChange.java b/java/com/google/gerrit/server/restapi/change/GetChange.java
index a81171a..d126d8a 100644
--- a/java/com/google/gerrit/server/restapi/change/GetChange.java
+++ b/java/com/google/gerrit/server/restapi/change/GetChange.java
@@ -144,6 +144,7 @@
         cds, this, Streams.stream(pdiFactories.entries()));
   }
 
+  @Nullable
   private ObjectId verifyMetaId(Change change, @Nullable ObjectId id) throws RestApiException {
     if (id == null) {
       return null;
diff --git a/java/com/google/gerrit/server/restapi/change/GetPastAssignees.java b/java/com/google/gerrit/server/restapi/change/GetPastAssignees.java
deleted file mode 100644
index c1c9a34..0000000
--- a/java/com/google/gerrit/server/restapi/change/GetPastAssignees.java
+++ /dev/null
@@ -1,54 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.restapi.change;
-
-import static java.util.stream.Collectors.toList;
-
-import com.google.gerrit.entities.Account;
-import com.google.gerrit.extensions.common.AccountInfo;
-import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.server.account.AccountLoader;
-import com.google.gerrit.server.change.ChangeResource;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-import java.util.Collections;
-import java.util.List;
-import java.util.Set;
-
-@Singleton
-public class GetPastAssignees implements RestReadView<ChangeResource> {
-  private final AccountLoader.Factory accountLoaderFactory;
-
-  @Inject
-  GetPastAssignees(AccountLoader.Factory accountLoaderFactory) {
-    this.accountLoaderFactory = accountLoaderFactory;
-  }
-
-  @Override
-  public Response<List<AccountInfo>> apply(ChangeResource rsrc) throws PermissionBackendException {
-
-    Set<Account.Id> pastAssignees = rsrc.getNotes().load().getPastAssignees();
-    if (pastAssignees == null) {
-      return Response.ok(Collections.emptyList());
-    }
-
-    AccountLoader accountLoader = accountLoaderFactory.create(true);
-    List<AccountInfo> infos = pastAssignees.stream().map(accountLoader::get).collect(toList());
-    accountLoader.fill();
-    return Response.ok(infos);
-  }
-}
diff --git a/java/com/google/gerrit/server/restapi/change/GetPatch.java b/java/com/google/gerrit/server/restapi/change/GetPatch.java
index dea4dc4..d8946a7 100644
--- a/java/com/google/gerrit/server/restapi/change/GetPatch.java
+++ b/java/com/google/gerrit/server/restapi/change/GetPatch.java
@@ -24,6 +24,7 @@
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.patch.DiffUtil;
 import com.google.inject.Inject;
 import java.io.IOException;
 import java.io.OutputStream;
@@ -32,12 +33,10 @@
 import java.util.Locale;
 import java.util.zip.ZipEntry;
 import java.util.zip.ZipOutputStream;
-import org.eclipse.jgit.diff.DiffFormatter;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
-import org.eclipse.jgit.treewalk.filter.PathFilter;
 import org.kohsuke.args4j.Option;
 
 public class GetPatch implements RestReadView<RevisionResource> {
@@ -98,14 +97,7 @@
                 if (path == null) {
                   out.write(formatEmailHeader(commit).getBytes(UTF_8));
                 }
-                try (DiffFormatter fmt = new DiffFormatter(out)) {
-                  fmt.setRepository(repo);
-                  if (path != null) {
-                    fmt.setPathFilter(PathFilter.create(path));
-                  }
-                  fmt.format(base.getTree(), commit.getTree());
-                  fmt.flush();
-                }
+                DiffUtil.getFormattedDiff(repo, base, commit, path, out);
               }
 
               @Override
diff --git a/java/com/google/gerrit/server/restapi/change/Mergeable.java b/java/com/google/gerrit/server/restapi/change/Mergeable.java
index 8aa2554..9797bda 100644
--- a/java/com/google/gerrit/server/restapi/change/Mergeable.java
+++ b/java/com/google/gerrit/server/restapi/change/Mergeable.java
@@ -29,7 +29,9 @@
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.change.MergeabilityCache;
+import com.google.gerrit.server.change.MergeabilityComputationBehavior;
 import com.google.gerrit.server.change.RevisionResource;
+import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.MergeUtilFactory;
 import com.google.gerrit.server.index.change.ChangeIndexer;
@@ -46,6 +48,7 @@
 import java.util.Objects;
 import java.util.Optional;
 import java.util.concurrent.Future;
+import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
@@ -59,6 +62,7 @@
       usage = "test mergeability for other branches too")
   private boolean otherBranches;
 
+  private final Config cfg;
   private final GitRepositoryManager gitManager;
   private final ProjectCache projectCache;
   private final MergeUtilFactory mergeUtilFactory;
@@ -69,6 +73,7 @@
 
   @Inject
   Mergeable(
+      @GerritServerConfig Config cfg,
       GitRepositoryManager gitManager,
       ProjectCache projectCache,
       MergeUtilFactory mergeUtilFactory,
@@ -76,6 +81,7 @@
       ChangeIndexer indexer,
       MergeabilityCache cache,
       SubmitRuleEvaluator.Factory submitRuleEvaluatorFactory) {
+    this.cfg = cfg;
     this.gitManager = gitManager;
     this.projectCache = projectCache;
     this.mergeUtilFactory = mergeUtilFactory;
@@ -175,7 +181,8 @@
     boolean mergeable = cache.get(commit, ref, type, strategy, change.getDest(), git);
     // TODO(dborowitz): Include something else in the change ETag that it's possible to bump here,
     // such as cache or secondary index update time.
-    if (!Objects.equals(mergeable, old)) {
+    if (!Objects.equals(mergeable, old)
+        && MergeabilityComputationBehavior.fromConfig(cfg).includeInIndex()) {
       @SuppressWarnings("unused")
       Future<?> possiblyIgnoredError = indexer.indexAsync(change.getProject(), change.getId());
     }
diff --git a/java/com/google/gerrit/server/restapi/change/Move.java b/java/com/google/gerrit/server/restapi/change/Move.java
index f4f0500..2b0de12 100644
--- a/java/com/google/gerrit/server/restapi/change/Move.java
+++ b/java/com/google/gerrit/server/restapi/change/Move.java
@@ -19,6 +19,7 @@
 import static com.google.gerrit.server.permissions.RefPermission.CREATE_CHANGE;
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
 import static com.google.gerrit.server.query.change.ChangeData.asChanges;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
 
 import com.google.common.base.Strings;
 import com.google.gerrit.common.Nullable;
@@ -59,6 +60,7 @@
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.update.context.RefUpdateContext;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -156,9 +158,11 @@
     projectCache.get(project).orElseThrow(illegalState(project)).checkStatePermitsWrite();
 
     Op op = new Op(input);
-    try (BatchUpdate u = updateFactory.create(project, caller, TimeUtil.now())) {
-      u.addOp(change.getId(), op);
-      u.execute();
+    try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+      try (BatchUpdate u = updateFactory.create(project, caller, TimeUtil.now())) {
+        u.addOp(change.getId(), op);
+        u.execute();
+      }
     }
     return Response.ok(json.noOptions().format(op.getChange()));
   }
@@ -212,7 +216,8 @@
       }
 
       Change.Key changeKey = change.getKey();
-      if (!asChanges(queryProvider.get().byBranchKey(newDestKey, changeKey)).isEmpty()) {
+      if (!asChanges(queryProvider.get().setLimit(1).byBranchKey(newDestKey, changeKey))
+          .isEmpty()) {
         throw new ResourceConflictException(
             "Destination "
                 + newDestKey.shortName()
diff --git a/java/com/google/gerrit/server/restapi/change/OnPostReview.java b/java/com/google/gerrit/server/restapi/change/OnPostReview.java
index b179d02..4999f18 100644
--- a/java/com/google/gerrit/server/restapi/change/OnPostReview.java
+++ b/java/com/google/gerrit/server/restapi/change/OnPostReview.java
@@ -18,6 +18,7 @@
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.notedb.ChangeNotes;
+import java.time.Instant;
 import java.util.Map;
 import java.util.Optional;
 
@@ -28,6 +29,7 @@
    * Allows implementors to return a message that should be included into the change message that is
    * posted on post review.
    *
+   * @param when the timestamp at which the review is posted
    * @param user the user that posts the review
    * @param changeNotes the change on which post review is performed
    * @param patchSet the patch set on which post review is performed
@@ -37,6 +39,7 @@
    *     {@link Optional#empty()} if the change message should not be extended
    */
   default Optional<String> getChangeMessageAddOn(
+      Instant when,
       IdentifiedUser user,
       ChangeNotes changeNotes,
       PatchSet patchSet,
diff --git a/java/com/google/gerrit/server/restapi/change/PostHashtags.java b/java/com/google/gerrit/server/restapi/change/PostHashtags.java
index bcaa145..a503eda 100644
--- a/java/com/google/gerrit/server/restapi/change/PostHashtags.java
+++ b/java/com/google/gerrit/server/restapi/change/PostHashtags.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.restapi.change;
 
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
+
 import com.google.common.collect.ImmutableSortedSet;
 import com.google.gerrit.extensions.api.changes.HashtagsInput;
 import com.google.gerrit.extensions.restapi.Response;
@@ -26,6 +28,7 @@
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.update.context.RefUpdateContext;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -46,13 +49,14 @@
   public Response<ImmutableSortedSet<String>> apply(ChangeResource req, HashtagsInput input)
       throws RestApiException, UpdateException, PermissionBackendException {
     req.permissions().check(ChangePermission.EDIT_HASHTAGS);
-
-    try (BatchUpdate bu =
-        updateFactory.create(req.getChange().getProject(), req.getUser(), TimeUtil.now())) {
-      SetHashtagsOp op = hashtagsFactory.create(input);
-      bu.addOp(req.getId(), op);
-      bu.execute();
-      return Response.ok(op.getUpdatedHashtags());
+    try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+      try (BatchUpdate bu =
+          updateFactory.create(req.getChange().getProject(), req.getUser(), TimeUtil.now())) {
+        SetHashtagsOp op = hashtagsFactory.create(input);
+        bu.addOp(req.getId(), op);
+        bu.execute();
+        return Response.ok(op.getUpdatedHashtags());
+      }
     }
   }
 
diff --git a/java/com/google/gerrit/server/restapi/change/PostPrivate.java b/java/com/google/gerrit/server/restapi/change/PostPrivate.java
index 45d7250..56b81b8 100644
--- a/java/com/google/gerrit/server/restapi/change/PostPrivate.java
+++ b/java/com/google/gerrit/server/restapi/change/PostPrivate.java
@@ -16,6 +16,7 @@
 
 import static com.google.gerrit.extensions.conditions.BooleanCondition.and;
 import static com.google.gerrit.extensions.conditions.BooleanCondition.or;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
 
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.extensions.common.InputWithMessage;
@@ -33,6 +34,7 @@
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.update.context.RefUpdateContext;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -74,8 +76,11 @@
     }
 
     SetPrivateOp op = setPrivateOpFactory.create(true, input);
-    try (BatchUpdate u = updateFactory.create(rsrc.getProject(), rsrc.getUser(), TimeUtil.now())) {
-      u.addOp(rsrc.getId(), op).execute();
+    try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+      try (BatchUpdate u =
+          updateFactory.create(rsrc.getProject(), rsrc.getUser(), TimeUtil.now())) {
+        u.addOp(rsrc.getId(), op).execute();
+      }
     }
 
     return Response.created();
diff --git a/java/com/google/gerrit/server/restapi/change/PostReview.java b/java/com/google/gerrit/server/restapi/change/PostReview.java
index 7a6ac0d..9940637 100644
--- a/java/com/google/gerrit/server/restapi/change/PostReview.java
+++ b/java/com/google/gerrit/server/restapi/change/PostReview.java
@@ -17,8 +17,9 @@
 import static com.google.common.base.MoreObjects.firstNonNull;
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.gerrit.entities.Patch.PATCHSET_LEVEL;
-import static com.google.gerrit.server.permissions.LabelPermission.ForUser.ON_BEHALF_OF;
+import static com.google.gerrit.server.permissions.AbstractLabelPermission.ForUser.ON_BEHALF_OF;
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.stream.Collectors.groupingBy;
 import static java.util.stream.Collectors.toList;
@@ -104,6 +105,7 @@
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.update.context.RefUpdateContext;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -156,7 +158,6 @@
 
   private final BatchUpdate.Factory updateFactory;
   private final PostReviewOp.Factory postReviewOpFactory;
-  private final PostReviewCopyApprovalsOpFactory postReviewCopyApprovalsOpFactory;
   private final ChangeResource.Factory changeResourceFactory;
   private final ChangeData.Factory changeDataFactory;
   private final AccountCache accountCache;
@@ -180,7 +181,6 @@
   PostReview(
       BatchUpdate.Factory updateFactory,
       PostReviewOp.Factory postReviewOpFactory,
-      PostReviewCopyApprovalsOpFactory postReviewCopyApprovalsOpFactory,
       ChangeResource.Factory changeResourceFactory,
       ChangeData.Factory changeDataFactory,
       AccountCache accountCache,
@@ -200,7 +200,6 @@
       ReviewerAdded reviewerAdded) {
     this.updateFactory = updateFactory;
     this.postReviewOpFactory = postReviewOpFactory;
-    this.postReviewCopyApprovalsOpFactory = postReviewCopyApprovalsOpFactory;
     this.changeResourceFactory = changeResourceFactory;
     this.changeDataFactory = changeDataFactory;
     this.accountCache = accountCache;
@@ -305,93 +304,93 @@
 
     // Notify based on ReviewInput, ignoring the notify settings from any ReviewerInputs.
     NotifyResolver.Result notify = notifyResolver.resolve(input.notify, input.notifyDetails);
+    try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+      try (BatchUpdate bu =
+          updateFactory.create(revision.getChange().getProject(), revision.getUser(), ts)) {
+        bu.setNotify(notify);
 
-    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()) {
-        ccOrReviewer = input.labels.values().stream().anyMatch(v -> v != 0);
-        if (ccOrReviewer) {
-          logger.atFine().log("calling user is cc/reviewer on the change due to voting on a label");
-        }
-      }
-
-      if (!ccOrReviewer) {
-        // Check if user was already CCed or reviewing prior to this review.
-        ReviewerSet currentReviewers =
-            approvalsUtil.getReviewers(revision.getChangeResource().getNotes());
-        ccOrReviewer = currentReviewers.all().contains(account.id());
-        if (ccOrReviewer) {
-          logger.atFine().log("calling user is already cc/reviewer on the change");
-        }
-      }
-
-      // Apply reviewer changes first. Revision emails should be sent to the
-      // updated set of reviewers. Also keep track of whether the user added
-      // themselves as a reviewer or to the CC list.
-      logger.atFine().log("adding reviewer additions");
-      for (ReviewerModification reviewerResult : reviewerResults) {
-        reviewerResult.op.suppressEmail(); // Send a single batch email below.
-        reviewerResult.op.suppressEvent(); // Send events below, if possible as batch.
-        bu.addOp(revision.getChange().getId(), reviewerResult.op);
-        if (!ccOrReviewer && reviewerResult.reviewers.contains(account)) {
-          logger.atFine().log("calling user is explicitly added as reviewer or CC");
-          ccOrReviewer = true;
-        }
-      }
-
-      if (!ccOrReviewer) {
-        // User posting this review isn't currently in the reviewer or CC list,
-        // isn't being explicitly added, and isn't voting on any label.
-        // Automatically CC them on this change so they receive replies.
-        logger.atFine().log("CCing calling user");
-        ReviewerModification selfAddition =
-            reviewerModifier.ccCurrentUser(revision.getUser(), revision);
-        selfAddition.op.suppressEmail();
-        selfAddition.op.suppressEvent();
-        bu.addOp(revision.getChange().getId(), selfAddition.op);
-      }
-
-      // Add WorkInProgressOp if requested.
-      if ((input.ready || input.workInProgress)
-          && didWorkInProgressChange(revision.getChange().isWorkInProgress(), input)) {
-        if (input.ready && input.workInProgress) {
-          output.error = ERROR_WIP_READY_MUTUALLY_EXCLUSIVE;
-          return Response.withStatusCode(SC_BAD_REQUEST, output);
+        Account account = revision.getUser().asIdentifiedUser().getAccount();
+        boolean ccOrReviewer = false;
+        if (input.labels != null && !input.labels.isEmpty()) {
+          ccOrReviewer = input.labels.values().stream().anyMatch(v -> v != 0);
+          if (ccOrReviewer) {
+            logger.atFine().log(
+                "calling user is cc/reviewer on the change due to voting on a label");
+          }
         }
 
-        revision
-            .getChangeResource()
-            .permissions()
-            .check(ChangePermission.TOGGLE_WORK_IN_PROGRESS_STATE);
-
-        if (input.ready) {
-          output.ready = true;
+        if (!ccOrReviewer) {
+          // Check if user was already CCed or reviewing prior to this review.
+          ReviewerSet currentReviewers =
+              approvalsUtil.getReviewers(revision.getChangeResource().getNotes());
+          ccOrReviewer = currentReviewers.all().contains(account.id());
+          if (ccOrReviewer) {
+            logger.atFine().log("calling user is already cc/reviewer on the change");
+          }
         }
 
-        logger.atFine().log("setting work-in-progress to %s", input.workInProgress);
-        WorkInProgressOp wipOp =
-            workInProgressOpFactory.create(input.workInProgress, new WorkInProgressOp.Input());
-        wipOp.suppressEmail();
-        bu.addOp(revision.getChange().getId(), wipOp);
+        // Apply reviewer changes first. Revision emails should be sent to the
+        // updated set of reviewers. Also keep track of whether the user added
+        // themselves as a reviewer or to the CC list.
+        logger.atFine().log("adding reviewer additions");
+        for (ReviewerModification reviewerResult : reviewerResults) {
+          reviewerResult.op.suppressEmail(); // Send a single batch email below.
+          reviewerResult.op.suppressEvent(); // Send events below, if possible as batch.
+          bu.addOp(revision.getChange().getId(), reviewerResult.op);
+          if (!ccOrReviewer && reviewerResult.reviewers.contains(account)) {
+            logger.atFine().log("calling user is explicitly added as reviewer or CC");
+            ccOrReviewer = true;
+          }
+        }
+
+        if (!ccOrReviewer) {
+          // User posting this review isn't currently in the reviewer or CC list,
+          // isn't being explicitly added, and isn't voting on any label.
+          // Automatically CC them on this change so they receive replies.
+          logger.atFine().log("CCing calling user");
+          ReviewerModification selfAddition =
+              reviewerModifier.ccCurrentUser(revision.getUser(), revision);
+          selfAddition.op.suppressEmail();
+          selfAddition.op.suppressEvent();
+          bu.addOp(revision.getChange().getId(), selfAddition.op);
+        }
+
+        // Add WorkInProgressOp if requested.
+        if ((input.ready || input.workInProgress)
+            && didWorkInProgressChange(revision.getChange().isWorkInProgress(), input)) {
+          if (input.ready && input.workInProgress) {
+            output.error = ERROR_WIP_READY_MUTUALLY_EXCLUSIVE;
+            return Response.withStatusCode(SC_BAD_REQUEST, output);
+          }
+
+          revision
+              .getChangeResource()
+              .permissions()
+              .check(ChangePermission.TOGGLE_WORK_IN_PROGRESS_STATE);
+
+          if (input.ready) {
+            output.ready = true;
+          }
+
+          logger.atFine().log("setting work-in-progress to %s", input.workInProgress);
+          WorkInProgressOp wipOp =
+              workInProgressOpFactory.create(input.workInProgress, new WorkInProgressOp.Input());
+          wipOp.suppressEmail();
+          bu.addOp(revision.getChange().getId(), wipOp);
+        }
+
+        // Add the review ops.
+        logger.atFine().log("posting review");
+        PostReviewOp postReviewOp =
+            postReviewOpFactory.create(
+                projectState, revision.getPatchSet().id(), input, revision.getAccountId());
+        bu.addOp(revision.getChange().getId(), postReviewOp);
+
+        // Adjust the attention set based on the input
+        replyAttentionSetUpdates.updateAttentionSet(
+            bu, revision.getNotes(), input, revision.getUser());
+        bu.execute();
       }
-
-      // Add the review ops.
-      logger.atFine().log("posting review");
-      PostReviewOp postReviewOp =
-          postReviewOpFactory.create(projectState, revision.getPatchSet().id(), input);
-      bu.addOp(revision.getChange().getId(), postReviewOp);
-      bu.addOp(
-          revision.getChange().getId(),
-          postReviewCopyApprovalsOpFactory.create(revision.getPatchSet().id()));
-
-      // Adjust the attention set based on the input
-      replyAttentionSetUpdates.updateAttentionSet(
-          bu, revision.getNotes(), input, revision.getUser());
-      bu.execute();
     }
 
     // Re-read change to take into account results of the update.
diff --git a/java/com/google/gerrit/server/restapi/change/PostReviewCopyApprovalsOp.java b/java/com/google/gerrit/server/restapi/change/PostReviewCopyApprovalsOp.java
deleted file mode 100644
index 88d2d7b..0000000
--- a/java/com/google/gerrit/server/restapi/change/PostReviewCopyApprovalsOp.java
+++ /dev/null
@@ -1,236 +0,0 @@
-// Copyright (C) 2022 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.restapi.change;
-
-import static com.google.common.collect.ImmutableList.toImmutableList;
-
-import com.google.auto.factory.AutoFactory;
-import com.google.auto.factory.Provided;
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableTable;
-import com.google.common.collect.Table.Cell;
-import com.google.gerrit.entities.Account;
-import com.google.gerrit.entities.LabelId;
-import com.google.gerrit.entities.PatchSet;
-import com.google.gerrit.entities.PatchSetApproval;
-import com.google.gerrit.server.PatchSetUtil;
-import com.google.gerrit.server.approval.ApprovalCopier;
-import com.google.gerrit.server.update.BatchUpdateOp;
-import com.google.gerrit.server.update.ChangeContext;
-import java.io.IOException;
-import java.util.Optional;
-
-/**
- * Batch update operation that copy approvals that have been newly applied on outdated patch sets to
- * the follow-up patch sets if they are copyable and no non-copied approvals prevent the copying.
- *
- * <p>Must be invoked after the batch update operation which applied new approvals on outdated patch
- * sets (e.g. after {@link PostReviewOp}.
- */
-@AutoFactory
-public class PostReviewCopyApprovalsOp implements BatchUpdateOp {
-  private final ApprovalCopier approvalCopier;
-  private final PatchSetUtil patchSetUtil;
-  private final PatchSet.Id patchSetId;
-
-  private ChangeContext ctx;
-  private ImmutableList<PatchSet.Id> followUpPatchSets;
-
-  PostReviewCopyApprovalsOp(
-      @Provided ApprovalCopier approvalCopier,
-      @Provided PatchSetUtil patchSetUtil,
-      PatchSet.Id patchSetId) {
-    this.approvalCopier = approvalCopier;
-    this.patchSetUtil = patchSetUtil;
-    this.patchSetId = patchSetId;
-  }
-
-  @Override
-  public boolean updateChange(ChangeContext ctx) throws IOException {
-    if (ctx.getNotes().getCurrentPatchSet().id().equals(patchSetId)) {
-      // the updated patch set is the current patch, there a no follow-up patch set to which new
-      // approvals could be copied
-      return false;
-    }
-
-    init(ctx);
-
-    boolean dirty = false;
-    ImmutableTable<String, Account.Id, Optional<PatchSetApproval>> newApprovals =
-        ctx.getUpdate(patchSetId).getApprovals();
-    for (Cell<String, Account.Id, Optional<PatchSetApproval>> cell : newApprovals.cellSet()) {
-      String label = cell.getRowKey();
-      Account.Id approverId = cell.getColumnKey();
-      PatchSetApproval.Key psaKey =
-          PatchSetApproval.key(patchSetId, approverId, LabelId.create(label));
-
-      if (isRemoval(cell)) {
-        if (removeCopies(psaKey)) {
-          dirty = true;
-        }
-        continue;
-      }
-
-      PatchSet patchSet = patchSetUtil.get(ctx.getNotes(), patchSetId);
-      PatchSetApproval psaOrig = cell.getValue().get();
-
-      // Target patch sets to which the approval is copyable.
-      ImmutableList<PatchSet.Id> targetPatchSets =
-          approvalCopier.forApproval(
-              ctx.getNotes(),
-              patchSet,
-              psaKey.accountId(),
-              psaKey.labelId().get(),
-              psaOrig.value());
-
-      // Iterate over all follow-up patch sets, in patch set order.
-      for (PatchSet.Id followUpPatchSetId : followUpPatchSets) {
-        if (hasOverrideOf(followUpPatchSetId, psaKey)) {
-          // a non-copied approval exists that overrides any copied approval
-          // -> do not copy the approval to this patch set nor to any follow-up patch sets
-          break;
-        }
-
-        if (targetPatchSets.contains(followUpPatchSetId)) {
-          // The approval is copyable to the new patch set.
-
-          if (hasCopyOfWithValue(followUpPatchSetId, psaKey, psaOrig.value())) {
-            // a copy approval with the exact value already exists
-            continue;
-          }
-
-          // add/update the copied approval on the target patch set
-          PatchSetApproval copiedPatchSetApproval = psaOrig.copyWithPatchSet(followUpPatchSetId);
-          ctx.getUpdate(followUpPatchSetId).putCopiedApproval(copiedPatchSetApproval);
-          dirty = true;
-        } else {
-          // The approval is not copyable to the new patch set.
-
-          if (hasCopyOf(followUpPatchSetId, psaKey)) {
-            // a copy approval exists and should be removed
-            removeCopy(followUpPatchSetId, psaKey);
-            dirty = true;
-          }
-        }
-      }
-    }
-
-    return dirty;
-  }
-
-  private void init(ChangeContext ctx) {
-    this.ctx = ctx;
-
-    // compute follow-up patch sets (sorted by patch set ID)
-    this.followUpPatchSets =
-        ctx.getNotes().getPatchSets().keySet().stream()
-            .filter(psId -> psId.get() > patchSetId.get())
-            .collect(toImmutableList());
-  }
-
-  /**
-   * Whether the given cell entry from the approval table represents the removal of an approval.
-   *
-   * @param cell cell entry from the approval table
-   * @return {@code true} if the approval is not set or the approval has {@code 0} as the value,
-   *     otherwise {@code false}
-   */
-  private boolean isRemoval(Cell<String, Account.Id, Optional<PatchSetApproval>> cell) {
-    return cell.getValue().isEmpty() || cell.getValue().get().value() == 0;
-  }
-
-  /**
-   * Removes copies of the given approval from all follow-up patch sets.
-   *
-   * @param psaKey the key of the patch set approval for which copies should be removed from all
-   *     follow-up patch sets
-   * @return whether any copy approval has been removed
-   */
-  private boolean removeCopies(PatchSetApproval.Key psaKey) {
-    boolean dirty = false;
-    for (PatchSet.Id followUpPatchSet : followUpPatchSets) {
-      if (hasCopyOf(followUpPatchSet, psaKey)) {
-        removeCopy(followUpPatchSet, psaKey);
-      } else {
-        // Do not remove copy from this follow-up patch sets and also not from any further follow-up
-        // patch sets (if the further follow-up patch sets have copies they are copies of a
-        // non-copied approval on this follow-up patch set and hence those should not be removed).
-        break;
-      }
-    }
-    return dirty;
-  }
-
-  /**
-   * Removes the copy approval with the given key from the given patch set.
-   *
-   * @param patchSet patch set from which the copy approval with the given key should be removed
-   * @param psaKey the key of the patch set approval for which copies should be removed from the
-   *     given patch set
-   */
-  private void removeCopy(PatchSet.Id patchSet, PatchSetApproval.Key psaKey) {
-    ctx.getUpdate(patchSet)
-        .removeCopiedApprovalFor(
-            ctx.getIdentifiedUser().getRealUser().isIdentifiedUser()
-                ? ctx.getIdentifiedUser().getRealUser().getAccountId()
-                : null,
-            psaKey.accountId(),
-            psaKey.labelId().get());
-  }
-
-  /**
-   * Whether the given patch set has a copy approval with the given key.
-   *
-   * @param patchSetId the ID of the patch for which it should be checked whether it has a copy
-   *     approval with the given key
-   * @param psaKey the key of the patch set approval
-   */
-  private boolean hasCopyOf(PatchSet.Id patchSetId, PatchSetApproval.Key psaKey) {
-    return ctx.getNotes().getApprovals().onlyCopied().get(patchSetId).stream()
-        .anyMatch(psa -> areAccountAndLabelTheSame(psa.key(), psaKey));
-  }
-
-  /**
-   * Whether the given patch set has a copy approval with the given key and value.
-   *
-   * @param patchSetId the ID of the patch for which it should be checked whether it has a copy
-   *     approval with the given key and value
-   * @param psaKey the key of the patch set approval
-   */
-  private boolean hasCopyOfWithValue(
-      PatchSet.Id patchSetId, PatchSetApproval.Key psaKey, short value) {
-    return ctx.getNotes().getApprovals().onlyCopied().get(patchSetId).stream()
-        .anyMatch(psa -> areAccountAndLabelTheSame(psa.key(), psaKey) && psa.value() == value);
-  }
-
-  /**
-   * Whether the given patch set has a normal approval with the given key that overrides copy
-   * approvals with that key.
-   *
-   * @param patchSetId the ID of the patch for which it should be checked whether it has a normal
-   *     approval with the given key that overrides copy approvals with that key
-   * @param psaKey the key of the patch set approval
-   */
-  private boolean hasOverrideOf(PatchSet.Id patchSetId, PatchSetApproval.Key psaKey) {
-    return ctx.getNotes().getApprovals().onlyNonCopied().get(patchSetId).stream()
-        .anyMatch(psa -> areAccountAndLabelTheSame(psa.key(), psaKey));
-  }
-
-  private boolean areAccountAndLabelTheSame(
-      PatchSetApproval.Key psaKey1, PatchSetApproval.Key psaKey2) {
-    return psaKey1.accountId().equals(psaKey2.accountId())
-        && psaKey1.labelId().equals(psaKey2.labelId());
-  }
-}
diff --git a/java/com/google/gerrit/server/restapi/change/PostReviewOp.java b/java/com/google/gerrit/server/restapi/change/PostReviewOp.java
index 9274f52..a8f8adf 100644
--- a/java/com/google/gerrit/server/restapi/change/PostReviewOp.java
+++ b/java/com/google/gerrit/server/restapi/change/PostReviewOp.java
@@ -18,15 +18,22 @@
 import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.gerrit.entities.Patch.PATCHSET_LEVEL;
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
+import static java.util.Comparator.comparing;
 import static java.util.stream.Collectors.joining;
 import static java.util.stream.Collectors.toList;
 import static java.util.stream.Collectors.toSet;
 
+import com.google.auto.value.AutoValue;
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Joiner;
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableTable;
+import com.google.common.collect.MultimapBuilder;
+import com.google.common.collect.SortedSetMultimap;
 import com.google.common.collect.Streams;
+import com.google.common.collect.Table.Cell;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Comment;
 import com.google.gerrit.entities.FixReplacement;
 import com.google.gerrit.entities.FixSuggestion;
@@ -55,9 +62,9 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.PublishCommentUtil;
+import com.google.gerrit.server.approval.ApprovalCopier;
 import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.change.EmailReviewComments;
-import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.extensions.events.CommentAdded;
 import com.google.gerrit.server.logging.Metadata;
@@ -90,12 +97,89 @@
 
 public class PostReviewOp implements BatchUpdateOp {
   interface Factory {
-    PostReviewOp create(ProjectState projectState, PatchSet.Id psId, ReviewInput in);
+    PostReviewOp create(
+        ProjectState projectState, PatchSet.Id psId, ReviewInput in, Account.Id reviewerId);
+  }
+
+  /**
+   * Update of a copied label that has been performed on a follow-up patch set after a vote has been
+   * applied on an outdated patch set (follow-up patch sets = all patch sets that are newer than the
+   * outdated patch set on which the user voted).
+   */
+  @AutoValue
+  abstract static class CopiedLabelUpdate {
+    /**
+     * Type of the update that has been performed for a copied vote on a follow-up patch set.
+     *
+     * <p>Whether the copied vote has been added
+     *
+     * <ul>
+     *   <li>added to
+     *   <li>updated on
+     *   <li>removed from
+     * </ul>
+     *
+     * a follow-up patch set.
+     */
+    enum Type {
+      /** A copied vote was added. No copied vote existed for this label yet. */
+      ADDED,
+
+      /** An existing copied vote has been updated. */
+      UPDATED,
+
+      /** An existing copied vote has been removed. */
+      REMOVED;
+    }
+
+    /** The ID of the (follow-up) patch set on which the copied label update has been performed. */
+    abstract PatchSet.Id patchSetId();
+
+    /**
+     * The old copied label vote that has been updated or that has been removed.
+     *
+     * <p>Not set if {@link #type()} is {@link Type#ADDED}.
+     */
+    abstract Optional<LabelVote> oldLabelVote();
+
+    /**
+     * The type of the update that has been performed for the copied vote on the (follow-up) patch
+     * set.
+     */
+    abstract Type type();
+
+    /** Returns a string with the patch set number and if present the old label vote. */
+    private String formatPatchSetWithOldLabelVote() {
+      StringBuilder b = new StringBuilder();
+      b.append(patchSetId().get());
+      if (oldLabelVote().isPresent()) {
+        b.append(" (was ").append(oldLabelVote().get().format()).append(")");
+      }
+      return b.toString();
+    }
+
+    private static CopiedLabelUpdate added(PatchSet.Id patchSetId) {
+      return create(patchSetId, Optional.empty(), Type.ADDED);
+    }
+
+    private static CopiedLabelUpdate updated(PatchSet.Id patchSetId, LabelVote oldLabelVote) {
+      return create(patchSetId, Optional.of(oldLabelVote), Type.UPDATED);
+    }
+
+    private static CopiedLabelUpdate removed(PatchSet.Id patchSetId, LabelVote oldLabelVote) {
+      return create(patchSetId, Optional.of(oldLabelVote), Type.REMOVED);
+    }
+
+    private static CopiedLabelUpdate create(
+        PatchSet.Id patchSetId, Optional<LabelVote> oldLabelVote, Type type) {
+      return new AutoValue_PostReviewOp_CopiedLabelUpdate(patchSetId, oldLabelVote, type);
+    }
   }
 
   @VisibleForTesting
   public static final String START_REVIEW_MESSAGE = "This change is ready for review.";
 
+  private final ApprovalCopier approvalCopier;
   private final ApprovalsUtil approvalsUtil;
   private final ChangeMessagesUtil cmUtil;
   private final CommentsUtil commentsUtil;
@@ -109,6 +193,7 @@
   private final ProjectState projectState;
   private final PatchSet.Id psId;
   private final ReviewInput in;
+  private final Account.Id reviewerId;
   private final boolean publishPatchSetLevelComment;
 
   private IdentifiedUser user;
@@ -117,12 +202,15 @@
   private String mailMessage;
   private List<Comment> comments = new ArrayList<>();
   private List<LabelVote> labelDelta = new ArrayList<>();
+  private SortedSetMultimap<LabelVote, CopiedLabelUpdate> labelUpdatesOnFollowUpPatchSets =
+      MultimapBuilder.hashKeys().treeSetValues(comparing(CopiedLabelUpdate::patchSetId)).build();
   private Map<String, Short> approvals = new HashMap<>();
   private Map<String, Short> oldApprovals = new HashMap<>();
 
   @Inject
   PostReviewOp(
       @GerritServerConfig Config gerritConfig,
+      ApprovalCopier approvalCopier,
       ApprovalsUtil approvalsUtil,
       ChangeMessagesUtil cmUtil,
       CommentsUtil commentsUtil,
@@ -134,7 +222,9 @@
       PluginSetContext<OnPostReview> onPostReviews,
       @Assisted ProjectState projectState,
       @Assisted PatchSet.Id psId,
-      @Assisted ReviewInput in) {
+      @Assisted ReviewInput in,
+      @Assisted Account.Id reviewerId) {
+    this.approvalCopier = approvalCopier;
     this.approvalsUtil = approvalsUtil;
     this.publishCommentUtil = publishCommentUtil;
     this.psUtil = psUtil;
@@ -150,6 +240,7 @@
     this.projectState = projectState;
     this.psId = psId;
     this.in = in;
+    this.reviewerId = reviewerId;
   }
 
   @Override
@@ -171,6 +262,9 @@
     try (TraceContext.TraceTimer ignored = newTimer("updateLabels")) {
       dirty |= updateLabels(projectState, ctx);
     }
+    try (TraceContext.TraceTimer ignored = newTimer("updateCopiedApprovals")) {
+      dirty |= updateCopiedApprovalsOnFollowUpPatchSets(ctx);
+    }
     try (TraceContext.TraceTimer ignored = newTimer("insertMessage")) {
       dirty |= insertMessage(ctx);
     }
@@ -182,12 +276,9 @@
     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();
-    }
+    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
@@ -558,10 +649,11 @@
           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.
+        // Only allow voting again the values are different, if the real account differs or if the
+        // vote is copied over from a past patch-set.
       } else if (c != null
           && (c.value() != ent.getValue()
+              || !c.realAccountId().equals(reviewerId)
               || (inLabels.containsKey(c.label()) && isApprovalCopiedOver(c, ctx.getNotes())))) {
         PatchSetApproval.Builder b =
             c.toBuilder()
@@ -705,12 +797,226 @@
     return current;
   }
 
+  /**
+   * Copies approvals that have been newly applied on outdated patch sets to the follow-up patch
+   * sets if they are copyable and no non-copied approvals prevent the copying.
+   *
+   * <p>Must be invoked after the new approvals on outdated patch sets have been applied (e.g. after
+   * {@link #updateLabels(ProjectState, ChangeContext)}.
+   *
+   * @param ctx the change context
+   * @return {@code true} if an update was done, otherwise {@code false}
+   */
+  private boolean updateCopiedApprovalsOnFollowUpPatchSets(ChangeContext ctx) throws IOException {
+    if (ctx.getNotes().getCurrentPatchSet().id().equals(psId)) {
+      // the updated patch set is the current patch, there a no follow-up patch set to which new
+      // approvals could be copied
+      return false;
+    }
+
+    // compute follow-up patch sets (sorted by patch set ID)
+    ImmutableList<PatchSet.Id> followUpPatchSets =
+        ctx.getNotes().getPatchSets().keySet().stream()
+            .filter(patchSetId -> patchSetId.get() > psId.get())
+            .collect(toImmutableList());
+
+    boolean dirty = false;
+    ImmutableTable<String, Account.Id, Optional<PatchSetApproval>> newApprovals =
+        ctx.getUpdate(psId).getApprovals();
+    for (Cell<String, Account.Id, Optional<PatchSetApproval>> cell : newApprovals.cellSet()) {
+      PatchSetApproval psaOrig = cell.getValue().get();
+
+      if (isRemoval(cell)) {
+        if (removeCopies(ctx, followUpPatchSets, psaOrig)) {
+          dirty = true;
+        }
+        continue;
+      }
+
+      PatchSet patchSet = psUtil.get(ctx.getNotes(), psId);
+
+      // Target patch sets to which the approval is copyable.
+      ImmutableList<PatchSet.Id> targetPatchSets =
+          approvalCopier.forApproval(
+              ctx.getNotes(), patchSet, psaOrig.accountId(), psaOrig.label(), psaOrig.value());
+
+      // Iterate over all follow-up patch sets, in patch set order.
+      for (PatchSet.Id followUpPatchSetId : followUpPatchSets) {
+        if (hasOverrideOf(ctx, followUpPatchSetId, psaOrig.key())) {
+          // a non-copied approval exists that overrides any copied approval
+          // -> do not copy the approval to this patch set nor to any follow-up patch sets
+          break;
+        }
+
+        if (targetPatchSets.contains(followUpPatchSetId)) {
+          // The approval is copyable to the new patch set.
+
+          if (hasCopyOfWithValue(ctx, followUpPatchSetId, psaOrig)) {
+            // a copy approval with the exact value already exists
+            continue;
+          }
+
+          // add/update the copied approval on the target patch set
+          Optional<PatchSetApproval> copiedPsa = getCopyOf(ctx, followUpPatchSetId, psaOrig.key());
+          PatchSetApproval copiedPatchSetApproval = psaOrig.copyWithPatchSet(followUpPatchSetId);
+          ctx.getUpdate(followUpPatchSetId).putCopiedApproval(copiedPatchSetApproval);
+          labelUpdatesOnFollowUpPatchSets.put(
+              LabelVote.createFrom(psaOrig),
+              copiedPsa.isPresent()
+                  ? CopiedLabelUpdate.updated(
+                      followUpPatchSetId, LabelVote.createFrom(copiedPsa.get()))
+                  : CopiedLabelUpdate.added(followUpPatchSetId));
+          dirty = true;
+        } else {
+          // The approval is not copyable to the new patch set.
+          Optional<PatchSetApproval> copiedPsa = getCopyOf(ctx, followUpPatchSetId, psaOrig.key());
+          if (copiedPsa.isPresent()) {
+            // a copy approval exists and should be removed
+            removeCopy(ctx, psaOrig, copiedPsa.get());
+            dirty = true;
+          }
+        }
+      }
+    }
+
+    return dirty;
+  }
+
+  /**
+   * Whether the given cell entry from the approval table represents the removal of an approval.
+   *
+   * @param cell cell entry from the approval table
+   * @return {@code true} if the approval is not set or the approval has {@code 0} as the value,
+   *     otherwise {@code false}
+   */
+  private boolean isRemoval(Cell<String, Account.Id, Optional<PatchSetApproval>> cell) {
+    return cell.getValue().isEmpty() || cell.getValue().get().value() == 0;
+  }
+
+  /**
+   * Removes copies of the given approval from all follow-up patch sets.
+   *
+   * @param ctx the change context
+   * @param followUpPatchSets the follow-up patch sets of the patch set on which the review is
+   *     posted
+   * @param psaOrig the original patch set approval for which copies should be removed from all
+   *     follow-up patch sets
+   * @return whether any copy approval has been removed
+   */
+  private boolean removeCopies(
+      ChangeContext ctx, ImmutableList<PatchSet.Id> followUpPatchSets, PatchSetApproval psaOrig) {
+    boolean dirty = false;
+    for (PatchSet.Id followUpPatchSet : followUpPatchSets) {
+      Optional<PatchSetApproval> copiedPsa = getCopyOf(ctx, followUpPatchSet, psaOrig.key());
+      if (copiedPsa.isPresent()) {
+        removeCopy(ctx, psaOrig, copiedPsa.get());
+      } else {
+        // Do not remove copy from this follow-up patch sets and also not from any further follow-up
+        // patch sets (if the further follow-up patch sets have copies they are copies of a
+        // non-copied approval on this follow-up patch set and hence those should not be removed).
+        break;
+      }
+    }
+    return dirty;
+  }
+
+  /**
+   * Removes the copy approval with the given key from the given patch set.
+   *
+   * @param ctx the change context
+   * @param psaOrig the original patch set approval for which copies should be removed from the
+   *     given patch set
+   * @param copiedPsa the copied patch set approval that should be removed
+   */
+  private void removeCopy(ChangeContext ctx, PatchSetApproval psaOrig, PatchSetApproval copiedPsa) {
+    ctx.getUpdate(copiedPsa.patchSetId())
+        .removeCopiedApprovalFor(
+            ctx.getIdentifiedUser().getRealUser().isIdentifiedUser()
+                ? ctx.getIdentifiedUser().getRealUser().getAccountId()
+                : null,
+            copiedPsa.accountId(),
+            copiedPsa.labelId().get());
+    labelUpdatesOnFollowUpPatchSets.put(
+        LabelVote.createFrom(psaOrig),
+        CopiedLabelUpdate.removed(copiedPsa.patchSetId(), LabelVote.createFrom(copiedPsa)));
+  }
+
+  /**
+   * Retrieves the copy of the given approval from the given patch set if it exists.
+   *
+   * @param ctx the change context
+   * @param patchSetId the ID of the patch from which it the copied approval should be returned
+   * @param psaKey the key of the patch set approval for which the copied approval should be
+   *     returned
+   * @return the copy of the given approval from the given patch set if it exists
+   */
+  private Optional<PatchSetApproval> getCopyOf(
+      ChangeContext ctx, PatchSet.Id patchSetId, PatchSetApproval.Key psaKey) {
+    return ctx.getNotes().getApprovals().onlyCopied().get(patchSetId).stream()
+        .filter(psa -> areAccountAndLabelTheSame(psa.key(), psaKey))
+        .findAny();
+  }
+
+  /**
+   * Whether the given patch set has a copy approval with the given key and value.
+   *
+   * @param ctx the change context
+   * @param patchSetId the ID of the patch for which it should be checked whether it has a copy
+   *     approval with the given key and value
+   * @param psaOrig the original patch set approval
+   */
+  private boolean hasCopyOfWithValue(
+      ChangeContext ctx, PatchSet.Id patchSetId, PatchSetApproval psaOrig) {
+    return ctx.getNotes().getApprovals().onlyCopied().get(patchSetId).stream()
+        .anyMatch(
+            psa ->
+                areAccountAndLabelTheSame(psa.key(), psaOrig.key())
+                    && psa.value() == psaOrig.value());
+  }
+
+  /**
+   * Whether the given patch set has a normal approval with the given key that overrides copy
+   * approvals with that key.
+   *
+   * @param ctx the change context
+   * @param patchSetId the ID of the patch for which it should be checked whether it has a normal
+   *     approval with the given key that overrides copy approvals with that key
+   * @param psaKey the key of the patch set approval
+   */
+  private boolean hasOverrideOf(
+      ChangeContext ctx, PatchSet.Id patchSetId, PatchSetApproval.Key psaKey) {
+    return ctx.getNotes().getApprovals().onlyNonCopied().get(patchSetId).stream()
+        .anyMatch(psa -> areAccountAndLabelTheSame(psa.key(), psaKey));
+  }
+
+  private boolean areAccountAndLabelTheSame(
+      PatchSetApproval.Key psaKey1, PatchSetApproval.Key psaKey2) {
+    return psaKey1.accountId().equals(psaKey2.accountId())
+        && psaKey1.labelId().equals(psaKey2.labelId());
+  }
+
   private boolean insertMessage(ChangeContext ctx) {
     String msg = Strings.nullToEmpty(in.message).trim();
 
     StringBuilder buf = new StringBuilder();
-    for (LabelVote d : labelDelta) {
-      buf.append(" ").append(d.format());
+    for (String formattedLabelVote :
+        labelDelta.stream().map(LabelVote::format).sorted().collect(toImmutableList())) {
+      buf.append(" ").append(formattedLabelVote);
+    }
+    if (!labelUpdatesOnFollowUpPatchSets.isEmpty()) {
+      buf.append("\n\nCopied votes on follow-up patch sets have been updated:");
+      for (Map.Entry<LabelVote, Collection<CopiedLabelUpdate>> e :
+          labelUpdatesOnFollowUpPatchSets.asMap().entrySet().stream()
+              .sorted(Map.Entry.comparingByKey(comparing(LabelVote::label)))
+              .collect(toImmutableList())) {
+        Optional<String> copyCondition =
+            projectState
+                .getLabelTypes(ctx.getNotes())
+                .byLabel(e.getKey().label())
+                .map(LabelType::getCopyCondition)
+                .map(Optional::get);
+        buf.append(formatVotesCopiedToFollowUpPatchSets(e.getKey(), e.getValue(), copyCondition));
+      }
     }
     if (comments.size() == 1) {
       buf.append("\n\n(1 comment)");
@@ -729,7 +1035,8 @@
     onPostReviews.runEach(
         onPostReview ->
             onPostReview
-                .getChangeMessageAddOn(user, ctx.getNotes(), ps, oldApprovals, approvals)
+                .getChangeMessageAddOn(
+                    ctx.getWhen(), user, ctx.getNotes(), ps, oldApprovals, approvals)
                 .ifPresent(
                     pluginMessage ->
                         pluginMessages.add(
@@ -748,6 +1055,88 @@
     return true;
   }
 
+  /**
+   * Given a label vote that has been applied on an outdated patch set, this method formats the
+   * updates to the copied labels on the follow-up patch sets that have been performed for that
+   * label vote.
+   *
+   * <p>If label votes have been copied to follow-up patch sets the formatted message is
+   * "<label-vote> has been copied to patch sets: 3, 4 (copy condition: "<copy-condition>").".
+   *
+   * <p>If existing copied votes on follow-up patch sets have been updated, the old copied votes are
+   * included into the message: "<label-vote> has been copied to patch sets: 3 (was
+   * <old-label-vote>), 4 (was <old-label-vote>) (copy condition: "<copy-condition>").".
+   *
+   * <p>If existing copied votes on follow-up patch sets have been removed (because the new vote is
+   * not copyable) the message is: "Copied <label> vote has been removed from patch set 3 (was
+   * <old-label-vote>), 4 (was <old-label-vote>) (copy condition: "<copy-condition>").".
+   *
+   * <p>If copied votes have been both added/updated and removed, 2 messages are returned.
+   *
+   * <p>Each returned message is formatted as a list item (prefixed with '* ').
+   *
+   * <p>Passing atoms in copy conditions are not highlighted. This is because the passing atoms can
+   * be different for different follow-up patch sets (e.g. 'changekind:TRIVIAL_REBASE OR
+   * changekind:NO_CODE_CHANGE' can have 'changekind:TRIVIAL_REBASE' passing for one follow-up patch
+   * set and 'changekind:NO_CODE_CHANGE' passing for another follow-up patch set). Including the
+   * copy condition once per follow-up patch set with differently highlighted passing atoms would
+   * make the message unreadable. Hence we don't highlight passing atoms here.
+   *
+   * @param labelVote the label vote that has been applied on an outdated patch set
+   * @param followUpPatchSetUpdates updates to copied votes on follow-up patch sets that have been
+   *     done by copying the label vote on the outdated patch set to follow-up patch sets
+   * @param copyCondition the copy condition of the label for which a vote was applied on an
+   *     outdated patch set
+   * @return formatted string to be included into a change message
+   */
+  private String formatVotesCopiedToFollowUpPatchSets(
+      LabelVote labelVote,
+      Collection<CopiedLabelUpdate> followUpPatchSetUpdates,
+      Optional<String> copyCondition) {
+    StringBuilder b = new StringBuilder();
+
+    // Add line for added/updated copied approvals.
+    ImmutableList<CopiedLabelUpdate> additionsAndUpdates =
+        followUpPatchSetUpdates.stream()
+            .filter(
+                copiedLabelUpdate ->
+                    copiedLabelUpdate.type() == CopiedLabelUpdate.Type.ADDED
+                        || copiedLabelUpdate.type() == CopiedLabelUpdate.Type.UPDATED)
+            .collect(toImmutableList());
+    if (!additionsAndUpdates.isEmpty()) {
+      b.append("\n* ");
+      b.append(labelVote.format());
+      b.append(" has been copied to patch set ");
+      b.append(
+          additionsAndUpdates.stream()
+              .map(CopiedLabelUpdate::formatPatchSetWithOldLabelVote)
+              .collect(joining(", ")));
+      copyCondition.ifPresent(cc -> b.append(" (copy condition: \"" + cc + "\")"));
+      b.append(".");
+    }
+
+    // Add line for removed copied approvals.
+    ImmutableList<CopiedLabelUpdate> removals =
+        followUpPatchSetUpdates.stream()
+            .filter(copiedLabelUpdate -> copiedLabelUpdate.type() == CopiedLabelUpdate.Type.REMOVED)
+            .collect(toImmutableList());
+    if (!removals.isEmpty()) {
+      b.append("\n* Copied ");
+      b.append(labelVote.label());
+      b.append(" vote has been removed from patch set ");
+      b.append(
+          removals.stream()
+              .map(CopiedLabelUpdate::formatPatchSetWithOldLabelVote)
+              .collect(joining(", ")));
+      b.append(" since the new ");
+      b.append(labelVote.value() != 0 ? labelVote.format() : labelVote.formatWithEquals());
+      b.append(" vote is not copyable");
+      copyCondition.ifPresent(cc -> b.append(" (copy condition: \"" + cc + "\")"));
+      b.append(".");
+    }
+    return b.toString();
+  }
+
   private void addLabelDelta(String name, short value) {
     labelDelta.add(LabelVote.create(name, value));
   }
diff --git a/java/com/google/gerrit/server/restapi/change/PostReviewers.java b/java/com/google/gerrit/server/restapi/change/PostReviewers.java
index 9bc80a4..e46f9e4 100644
--- a/java/com/google/gerrit/server/restapi/change/PostReviewers.java
+++ b/java/com/google/gerrit/server/restapi/change/PostReviewers.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.restapi.change;
 
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
+
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.ReviewerInput;
@@ -31,6 +33,7 @@
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.update.context.RefUpdateContext;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -70,11 +73,14 @@
     if (modification.op == null) {
       return Response.ok(modification.result);
     }
-    try (BatchUpdate bu = updateFactory.create(rsrc.getProject(), rsrc.getUser(), TimeUtil.now())) {
-      bu.setNotify(resolveNotify(rsrc, input));
-      Change.Id id = rsrc.getChange().getId();
-      bu.addOp(id, modification.op);
-      bu.execute();
+    try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+      try (BatchUpdate bu =
+          updateFactory.create(rsrc.getProject(), rsrc.getUser(), TimeUtil.now())) {
+        bu.setNotify(resolveNotify(rsrc, input));
+        Change.Id id = rsrc.getChange().getId();
+        bu.addOp(id, modification.op);
+        bu.execute();
+      }
     }
 
     // Re-read change to take into account results of the update.
diff --git a/java/com/google/gerrit/server/restapi/change/PutAssignee.java b/java/com/google/gerrit/server/restapi/change/PutAssignee.java
deleted file mode 100644
index d41620e..0000000
--- a/java/com/google/gerrit/server/restapi/change/PutAssignee.java
+++ /dev/null
@@ -1,133 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.restapi.change;
-
-import com.google.common.base.Strings;
-import com.google.gerrit.extensions.api.changes.AssigneeInput;
-import com.google.gerrit.extensions.api.changes.NotifyHandling;
-import com.google.gerrit.extensions.api.changes.ReviewerInput;
-import com.google.gerrit.extensions.client.ReviewerState;
-import com.google.gerrit.extensions.common.AccountInfo;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.extensions.webui.UiAction;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.ReviewerSet;
-import com.google.gerrit.server.account.AccountLoader;
-import com.google.gerrit.server.account.AccountResolver;
-import com.google.gerrit.server.approval.ApprovalsUtil;
-import com.google.gerrit.server.change.ChangeResource;
-import com.google.gerrit.server.change.ReviewerModifier;
-import com.google.gerrit.server.change.ReviewerModifier.ReviewerModification;
-import com.google.gerrit.server.change.SetAssigneeOp;
-import com.google.gerrit.server.permissions.ChangePermission;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.update.BatchUpdate;
-import com.google.gerrit.server.update.UpdateException;
-import com.google.gerrit.server.util.time.TimeUtil;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-
-@Singleton
-public class PutAssignee
-    implements RestModifyView<ChangeResource, AssigneeInput>, UiAction<ChangeResource> {
-
-  private final BatchUpdate.Factory updateFactory;
-  private final AccountResolver accountResolver;
-  private final SetAssigneeOp.Factory assigneeFactory;
-  private final ReviewerModifier reviewerModifier;
-  private final AccountLoader.Factory accountLoaderFactory;
-  private final PermissionBackend permissionBackend;
-  private final ApprovalsUtil approvalsUtil;
-
-  @Inject
-  PutAssignee(
-      BatchUpdate.Factory updateFactory,
-      AccountResolver accountResolver,
-      SetAssigneeOp.Factory assigneeFactory,
-      ReviewerModifier reviewerModifier,
-      AccountLoader.Factory accountLoaderFactory,
-      PermissionBackend permissionBackend,
-      ApprovalsUtil approvalsUtil) {
-    this.updateFactory = updateFactory;
-    this.accountResolver = accountResolver;
-    this.assigneeFactory = assigneeFactory;
-    this.reviewerModifier = reviewerModifier;
-    this.accountLoaderFactory = accountLoaderFactory;
-    this.permissionBackend = permissionBackend;
-    this.approvalsUtil = approvalsUtil;
-  }
-
-  @Override
-  public Response<AccountInfo> apply(ChangeResource rsrc, AssigneeInput input)
-      throws RestApiException, UpdateException, IOException, PermissionBackendException,
-          ConfigInvalidException {
-    rsrc.permissions().check(ChangePermission.EDIT_ASSIGNEE);
-
-    input.assignee = Strings.nullToEmpty(input.assignee).trim();
-    if (input.assignee.isEmpty()) {
-      throw new BadRequestException("missing assignee field");
-    }
-
-    IdentifiedUser assignee = accountResolver.resolve(input.assignee).asUniqueUser();
-    try {
-      permissionBackend
-          .absentUser(assignee.getAccountId())
-          .change(rsrc.getNotes())
-          .check(ChangePermission.READ);
-    } catch (AuthException e) {
-      throw new AuthException("read not permitted for " + input.assignee, e);
-    }
-
-    try (BatchUpdate bu =
-        updateFactory.create(rsrc.getChange().getProject(), rsrc.getUser(), TimeUtil.now())) {
-      SetAssigneeOp op = assigneeFactory.create(assignee);
-      bu.addOp(rsrc.getId(), op);
-
-      ReviewerSet currentReviewers = approvalsUtil.getReviewers(rsrc.getNotes());
-      if (!currentReviewers.all().contains(assignee.getAccountId())) {
-        ReviewerModification reviewersAddition = addAssigneeAsCC(rsrc, input.assignee);
-        reviewersAddition.op.suppressEmail();
-        bu.addOp(rsrc.getId(), reviewersAddition.op);
-      }
-
-      bu.execute();
-      return Response.ok(accountLoaderFactory.create(true).fillOne(assignee.getAccountId()));
-    }
-  }
-
-  private ReviewerModification addAssigneeAsCC(ChangeResource rsrc, String assignee)
-      throws IOException, PermissionBackendException, ConfigInvalidException {
-    ReviewerInput reviewerInput = new ReviewerInput();
-    reviewerInput.reviewer = assignee;
-    reviewerInput.state = ReviewerState.CC;
-    reviewerInput.confirmed = true;
-    reviewerInput.notify = NotifyHandling.NONE;
-    return reviewerModifier.prepare(rsrc.getNotes(), rsrc.getUser(), reviewerInput, false);
-  }
-
-  @Override
-  public UiAction.Description getDescription(ChangeResource rsrc) {
-    return new UiAction.Description()
-        .setLabel("Edit Assignee")
-        .setVisible(rsrc.permissions().testCond(ChangePermission.EDIT_ASSIGNEE));
-  }
-}
diff --git a/java/com/google/gerrit/server/restapi/change/PutDescription.java b/java/com/google/gerrit/server/restapi/change/PutDescription.java
index 5b5bc15..0d633db 100644
--- a/java/com/google/gerrit/server/restapi/change/PutDescription.java
+++ b/java/com/google/gerrit/server/restapi/change/PutDescription.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.restapi.change;
 
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
+
 import com.google.common.base.Strings;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.extensions.common.DescriptionInput;
@@ -31,6 +33,7 @@
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.update.context.RefUpdateContext;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -56,10 +59,12 @@
     rsrc.permissions().check(ChangePermission.EDIT_DESCRIPTION);
 
     Op op = new Op(input != null ? input : new DescriptionInput(), rsrc.getPatchSet().id());
-    try (BatchUpdate u =
-        updateFactory.create(rsrc.getChange().getProject(), rsrc.getUser(), TimeUtil.now())) {
-      u.addOp(rsrc.getChange().getId(), op);
-      u.execute();
+    try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+      try (BatchUpdate u =
+          updateFactory.create(rsrc.getChange().getProject(), rsrc.getUser(), TimeUtil.now())) {
+        u.addOp(rsrc.getChange().getId(), op);
+        u.execute();
+      }
     }
     return Strings.isNullOrEmpty(op.newDescription)
         ? Response.none()
diff --git a/java/com/google/gerrit/server/restapi/change/PutDraftComment.java b/java/com/google/gerrit/server/restapi/change/PutDraftComment.java
index 6411087..681e1b1 100644
--- a/java/com/google/gerrit/server/restapi/change/PutDraftComment.java
+++ b/java/com/google/gerrit/server/restapi/change/PutDraftComment.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.restapi.change;
 
 import static com.google.gerrit.entities.Patch.PATCHSET_LEVEL;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
 
 import com.google.gerrit.entities.Comment;
 import com.google.gerrit.entities.HumanComment;
@@ -36,6 +37,7 @@
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.update.context.RefUpdateContext;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -86,13 +88,15 @@
       throw new BadRequestException(
           String.format("Invalid inReplyTo, comment %s not found", in.inReplyTo));
     }
-    try (BatchUpdate bu =
-        updateFactory.create(rsrc.getChange().getProject(), rsrc.getUser(), TimeUtil.now())) {
-      Op op = new Op(rsrc.getComment().key, in);
-      bu.addOp(rsrc.getChange().getId(), op);
-      bu.execute();
-      return Response.ok(
-          commentJson.get().setFillAccounts(false).newHumanCommentFormatter().format(op.comment));
+    try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+      try (BatchUpdate bu =
+          updateFactory.create(rsrc.getChange().getProject(), rsrc.getUser(), TimeUtil.now())) {
+        Op op = new Op(rsrc.getComment().key, in);
+        bu.addOp(rsrc.getChange().getId(), op);
+        bu.execute();
+        return Response.ok(
+            commentJson.get().setFillAccounts(false).newHumanCommentFormatter().format(op.comment));
+      }
     }
   }
 
diff --git a/java/com/google/gerrit/server/restapi/change/PutMessage.java b/java/com/google/gerrit/server/restapi/change/PutMessage.java
index f898dca..41710a6 100644
--- a/java/com/google/gerrit/server/restapi/change/PutMessage.java
+++ b/java/com/google/gerrit/server/restapi/change/PutMessage.java
@@ -15,11 +15,13 @@
 package com.google.gerrit.server.restapi.change;
 
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
 
 import com.google.gerrit.entities.BooleanProjectConfig;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.common.CommitMessageInput;
+import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
@@ -33,6 +35,7 @@
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.change.PatchSetInserter;
+import com.google.gerrit.server.config.UrlFormatter;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.permissions.ChangePermission;
@@ -41,6 +44,7 @@
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.update.context.RefUpdateContext;
 import com.google.gerrit.server.util.CommitMessageUtil;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
@@ -70,6 +74,7 @@
   private final PatchSetUtil psUtil;
   private final NotifyResolver notifyResolver;
   private final ProjectCache projectCache;
+  private final DynamicItem<UrlFormatter> urlFormatter;
 
   @Inject
   PutMessage(
@@ -81,7 +86,8 @@
       @GerritPersonIdent PersonIdent gerritIdent,
       PatchSetUtil psUtil,
       NotifyResolver notifyResolver,
-      ProjectCache projectCache) {
+      ProjectCache projectCache,
+      DynamicItem<UrlFormatter> urlFormatter) {
     this.updateFactory = updateFactory;
     this.repositoryManager = repositoryManager;
     this.userProvider = userProvider;
@@ -91,6 +97,7 @@
     this.psUtil = psUtil;
     this.notifyResolver = notifyResolver;
     this.projectCache = projectCache;
+    this.urlFormatter = urlFormatter;
   }
 
   @Override
@@ -114,7 +121,8 @@
             .orElseThrow(illegalState(resource.getProject()))
             .is(BooleanProjectConfig.REQUIRE_CHANGE_ID),
         resource.getChange().getKey().get(),
-        sanitizedCommitMessage);
+        sanitizedCommitMessage,
+        urlFormatter.get());
 
     try (Repository repository = repositoryManager.openRepository(resource.getProject());
         RevWalk revWalk = new RevWalk(repository);
@@ -127,21 +135,24 @@
       }
 
       Instant ts = TimeUtil.now();
-      try (BatchUpdate bu =
-          updateFactory.create(resource.getChange().getProject(), userProvider.get(), ts)) {
-        // Ensure that BatchUpdate will update the same repo
-        bu.setRepository(repository, new RevWalk(objectInserter.newReader()), objectInserter);
+      try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+        try (BatchUpdate bu =
+            updateFactory.create(resource.getChange().getProject(), userProvider.get(), ts)) {
+          // Ensure that BatchUpdate will update the same repo
+          bu.setRepository(repository, new RevWalk(objectInserter.newReader()), objectInserter);
 
-        PatchSet.Id psId = ChangeUtil.nextPatchSetId(repository, ps.id());
-        ObjectId newCommit =
-            createCommit(objectInserter, patchSetCommit, sanitizedCommitMessage, ts);
-        PatchSetInserter inserter = psInserterFactory.create(resource.getNotes(), psId, newCommit);
-        inserter.setMessage(
-            String.format("Patch Set %s: Commit message was updated.", psId.getId()));
-        inserter.setDescription("Edit commit message");
-        bu.setNotify(resolveNotify(input, resource));
-        bu.addOp(resource.getChange().getId(), inserter);
-        bu.execute();
+          PatchSet.Id psId = ChangeUtil.nextPatchSetId(repository, ps.id());
+          ObjectId newCommit =
+              createCommit(objectInserter, patchSetCommit, sanitizedCommitMessage, ts);
+          PatchSetInserter inserter =
+              psInserterFactory.create(resource.getNotes(), psId, newCommit);
+          inserter.setMessage(
+              String.format("Patch Set %s: Commit message was updated.", psId.getId()));
+          inserter.setDescription("Edit commit message");
+          bu.setNotify(resolveNotify(input, resource));
+          bu.addOp(resource.getChange().getId(), inserter);
+          bu.execute();
+        }
       }
     }
     return Response.ok("ok");
diff --git a/java/com/google/gerrit/server/restapi/change/PutTopic.java b/java/com/google/gerrit/server/restapi/change/PutTopic.java
index c9b436e..b1e5d5a 100644
--- a/java/com/google/gerrit/server/restapi/change/PutTopic.java
+++ b/java/com/google/gerrit/server/restapi/change/PutTopic.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.restapi.change;
 
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
+
 import com.google.common.base.Strings;
 import com.google.gerrit.extensions.api.changes.TopicInput;
 import com.google.gerrit.extensions.restapi.BadRequestException;
@@ -28,6 +30,7 @@
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.update.context.RefUpdateContext;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -62,10 +65,12 @@
     }
 
     SetTopicOp op = topicOpFactory.create(sanitizedInput.topic);
-    try (BatchUpdate u =
-        updateFactory.create(req.getChange().getProject(), req.getUser(), TimeUtil.now())) {
-      u.addOp(req.getId(), op);
-      u.execute();
+    try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+      try (BatchUpdate u =
+          updateFactory.create(req.getChange().getProject(), req.getUser(), TimeUtil.now())) {
+        u.addOp(req.getId(), op);
+        u.execute();
+      }
     }
 
     if (Strings.isNullOrEmpty(sanitizedInput.topic)) {
diff --git a/java/com/google/gerrit/server/restapi/change/Rebase.java b/java/com/google/gerrit/server/restapi/change/Rebase.java
index 5e30dae..167f784 100644
--- a/java/com/google/gerrit/server/restapi/change/Rebase.java
+++ b/java/com/google/gerrit/server/restapi/change/Rebase.java
@@ -15,53 +15,43 @@
 package com.google.gerrit.server.restapi.change;
 
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
 
-import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Sets;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.extensions.api.changes.RebaseInput;
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.extensions.webui.UiAction;
-import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.change.RebaseChangeOp;
 import com.google.gerrit.server.change.RebaseUtil;
-import com.google.gerrit.server.change.RebaseUtil.Base;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.git.CodeReviewCommit;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.permissions.ChangePermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.update.context.RefUpdateContext;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.util.Map;
-import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.ObjectReader;
-import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
 
 @Singleton
@@ -72,156 +62,88 @@
 
   private final BatchUpdate.Factory updateFactory;
   private final GitRepositoryManager repoManager;
-  private final RebaseChangeOp.Factory rebaseFactory;
   private final RebaseUtil rebaseUtil;
   private final ChangeJson.Factory json;
   private final PermissionBackend permissionBackend;
   private final ProjectCache projectCache;
   private final PatchSetUtil patchSetUtil;
+  private final RebaseMetrics rebaseMetrics;
 
   @Inject
   public Rebase(
       BatchUpdate.Factory updateFactory,
       GitRepositoryManager repoManager,
-      RebaseChangeOp.Factory rebaseFactory,
       RebaseUtil rebaseUtil,
       ChangeJson.Factory json,
       PermissionBackend permissionBackend,
       ProjectCache projectCache,
-      PatchSetUtil patchSetUtil) {
+      PatchSetUtil patchSetUtil,
+      RebaseMetrics rebaseMetrics) {
     this.updateFactory = updateFactory;
     this.repoManager = repoManager;
-    this.rebaseFactory = rebaseFactory;
     this.rebaseUtil = rebaseUtil;
     this.json = json;
     this.permissionBackend = permissionBackend;
     this.projectCache = projectCache;
     this.patchSetUtil = patchSetUtil;
+    this.rebaseMetrics = rebaseMetrics;
   }
 
   @Override
   public Response<ChangeInfo> apply(RevisionResource rsrc, RebaseInput input)
       throws UpdateException, RestApiException, IOException, PermissionBackendException {
-    // Not allowed to rebase if the current patch set is locked.
-    patchSetUtil.checkPatchSetNotLocked(rsrc.getNotes());
 
-    rsrc.permissions().check(ChangePermission.REBASE);
+    if (input.onBehalfOfUploader && !rsrc.getPatchSet().uploader().equals(rsrc.getAccountId())) {
+      rsrc.permissions().check(ChangePermission.REBASE_ON_BEHALF_OF_UPLOADER);
+      rsrc = rebaseUtil.onBehalfOf(rsrc, input);
+    } else {
+      input.onBehalfOfUploader = false;
+      rsrc.permissions().check(ChangePermission.REBASE);
+    }
+
     projectCache
         .get(rsrc.getProject())
         .orElseThrow(illegalState(rsrc.getProject()))
         .checkStatePermitsWrite();
 
     Change change = rsrc.getChange();
-    try (Repository repo = repoManager.openRepository(change.getProject());
-        ObjectInserter oi = repo.newObjectInserter();
-        ObjectReader reader = oi.newReader();
-        RevWalk rw = CodeReviewCommit.newRevWalk(reader);
-        BatchUpdate bu =
-            updateFactory.create(change.getProject(), rsrc.getUser(), TimeUtil.now())) {
-      if (!change.isNew()) {
-        throw new ResourceConflictException("change is " + ChangeUtil.status(change));
-      } else if (!hasOneParent(rw, rsrc.getPatchSet())) {
-        throw new ResourceConflictException(
-            "cannot rebase merge commits or commit with no ancestor");
+    try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+      try (Repository repo = repoManager.openRepository(change.getProject());
+          ObjectInserter oi = repo.newObjectInserter();
+          ObjectReader reader = oi.newReader();
+          RevWalk rw = CodeReviewCommit.newRevWalk(reader);
+          BatchUpdate bu =
+              updateFactory.create(change.getProject(), rsrc.getUser(), TimeUtil.now())) {
+        rebaseUtil.verifyRebasePreconditions(rw, rsrc.getNotes(), rsrc.getPatchSet());
+
+        RebaseChangeOp rebaseOp =
+            rebaseUtil.getRebaseOp(
+                rsrc,
+                input,
+                rebaseUtil.parseOrFindBaseRevision(repo, rw, permissionBackend, rsrc, input, true));
+
+        // TODO(dborowitz): Why no notification? This seems wrong; dig up blame.
+        bu.setNotify(NotifyResolver.Result.none());
+        bu.setRepository(repo, rw, oi);
+        bu.addOp(change.getId(), rebaseOp);
+        bu.execute();
+
+        rebaseMetrics.countRebase(input.onBehalfOfUploader, input.allowConflicts);
+
+        ChangeInfo changeInfo = json.create(OPTIONS).format(change.getProject(), change.getId());
+        changeInfo.containsGitConflicts =
+            !rebaseOp.getRebasedCommit().getFilesWithGitConflicts().isEmpty() ? true : null;
+        return Response.ok(changeInfo);
       }
-      RebaseChangeOp rebaseOp =
-          rebaseFactory
-              .create(rsrc.getNotes(), rsrc.getPatchSet(), findBaseRev(repo, rw, rsrc, input))
-              .setForceContentMerge(true)
-              .setAllowConflicts(input.allowConflicts)
-              .setValidationOptions(getValidateOptionsAsMultimap(input.validationOptions))
-              .setFireRevisionCreated(true);
-      // TODO(dborowitz): Why no notification? This seems wrong; dig up blame.
-      bu.setNotify(NotifyResolver.Result.none());
-      bu.setRepository(repo, rw, oi);
-      bu.addOp(change.getId(), rebaseOp);
-      bu.execute();
-
-      ChangeInfo changeInfo = json.create(OPTIONS).format(change.getProject(), change.getId());
-      changeInfo.containsGitConflicts =
-          !rebaseOp.getRebasedCommit().getFilesWithGitConflicts().isEmpty() ? true : null;
-      return Response.ok(changeInfo);
     }
   }
 
-  private ObjectId findBaseRev(
-      Repository repo, RevWalk rw, RevisionResource rsrc, RebaseInput input)
-      throws RestApiException, IOException, NoSuchChangeException, AuthException,
-          PermissionBackendException {
-    BranchNameKey destRefKey = rsrc.getChange().getDest();
-    if (input == null || input.base == null) {
-      return rebaseUtil.findBaseRevision(rsrc.getPatchSet(), destRefKey, repo, rw);
-    }
-
-    Change change = rsrc.getChange();
-    String str = input.base.trim();
-    if (str.equals("")) {
-      // Remove existing dependency to other patch set.
-      Ref destRef = repo.exactRef(destRefKey.branch());
-      if (destRef == null) {
-        throw new ResourceConflictException(
-            "can't rebase onto tip of branch " + destRefKey.branch() + "; branch doesn't exist");
-      }
-      return destRef.getObjectId();
-    }
-
-    Base base;
-    try {
-      base = rebaseUtil.parseBase(rsrc, str);
-      if (base == null) {
-        throw new ResourceConflictException(
-            "base revision is missing from the destination branch: " + str);
-      }
-    } catch (NoSuchChangeException e) {
-      throw new UnprocessableEntityException(
-          String.format("Base change not found: %s", input.base), e);
-    }
-
-    PatchSet.Id baseId = base.patchSet().id();
-    if (change.getId().equals(baseId.changeId())) {
-      throw new ResourceConflictException("cannot rebase change onto itself");
-    }
-
-    permissionBackend.user(rsrc.getUser()).change(base.notes()).check(ChangePermission.READ);
-
-    Change baseChange = base.notes().getChange();
-    if (!baseChange.getProject().equals(change.getProject())) {
-      throw new ResourceConflictException(
-          "base change is in wrong project: " + baseChange.getProject());
-    } else if (!baseChange.getDest().equals(change.getDest())) {
-      throw new ResourceConflictException(
-          "base change is targeting wrong branch: " + baseChange.getDest());
-    } else if (baseChange.isAbandoned()) {
-      throw new ResourceConflictException("base change is abandoned: " + baseChange.getKey());
-    } else if (isMergedInto(rw, rsrc.getPatchSet(), base.patchSet())) {
-      throw new ResourceConflictException(
-          "base change "
-              + baseChange.getKey()
-              + " is a descendant of the current change - recursion not allowed");
-    }
-    return base.patchSet().commitId();
-  }
-
-  private boolean isMergedInto(RevWalk rw, PatchSet base, PatchSet tip) throws IOException {
-    ObjectId baseId = base.commitId();
-    ObjectId tipId = tip.commitId();
-    return rw.isMergedInto(rw.parseCommit(baseId), rw.parseCommit(tipId));
-  }
-
-  private boolean hasOneParent(RevWalk rw, PatchSet ps) throws IOException {
-    // Prevent rebase of exotic changes (merge commit, no ancestor).
-    RevCommit c = rw.parseCommit(ps.commitId());
-    return c.getParentCount() == 1;
-  }
-
   @Override
   public UiAction.Description getDescription(RevisionResource rsrc) throws IOException {
     UiAction.Description description =
         new UiAction.Description()
             .setLabel("Rebase")
-            .setTitle(
-                "Rebase onto tip of branch or parent change. Makes you the uploader of this "
-                    + "change which can affect validity of approvals.")
+            .setTitle("Rebase onto tip of branch or parent change.")
             .setVisible(false);
 
     Change change = rsrc.getChange();
@@ -241,31 +163,25 @@
     boolean enabled = false;
     try (Repository repo = repoManager.openRepository(change.getDest().project());
         RevWalk rw = new RevWalk(repo)) {
-      if (hasOneParent(rw, rsrc.getPatchSet())) {
+      if (RebaseUtil.hasOneParent(rw, rsrc.getPatchSet())) {
         enabled = rebaseUtil.canRebase(rsrc.getPatchSet(), change.getDest(), repo, rw);
       }
     }
 
-    if (rsrc.permissions().testOrFalse(ChangePermission.REBASE)) {
-      return description.setVisible(true).setEnabled(enabled);
+    boolean canRebase = rsrc.permissions().testOrFalse(ChangePermission.REBASE);
+    boolean canRebaseOnBehalfOfUploader =
+        rsrc.permissions().testOrFalse(ChangePermission.REBASE_ON_BEHALF_OF_UPLOADER);
+    if (canRebase || canRebaseOnBehalfOfUploader) {
+      return description
+          .setOption("rebase", canRebase)
+          .setOption("rebase_on_behalf_of_uploader", canRebaseOnBehalfOfUploader)
+          .setEnabled(enabled)
+          .setVisible(true);
     }
+
     return description;
   }
 
-  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();
-  }
-
   public static class CurrentRevision implements RestModifyView<ChangeResource, RebaseInput> {
     private final PatchSetUtil psUtil;
     private final Rebase rebase;
diff --git a/java/com/google/gerrit/server/restapi/change/RebaseChain.java b/java/com/google/gerrit/server/restapi/change/RebaseChain.java
new file mode 100644
index 0000000..343fb72
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/RebaseChain.java
@@ -0,0 +1,334 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.gerrit.server.project.ProjectCache.illegalState;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Lists;
+import com.google.common.collect.Sets;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.api.changes.RebaseInput;
+import com.google.gerrit.extensions.client.ListChangesOption;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.RebaseChainInfo;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.webui.UiAction;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.change.ChangeJson;
+import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.change.GetRelatedChangesUtil;
+import com.google.gerrit.server.change.NotifyResolver;
+import com.google.gerrit.server.change.RebaseChangeOp;
+import com.google.gerrit.server.change.RebaseUtil;
+import com.google.gerrit.server.change.RelatedChangesSorter.PatchSetData;
+import com.google.gerrit.server.change.RevisionResource;
+import com.google.gerrit.server.git.CodeReviewCommit;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.permissions.ChangePermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.update.context.RefUpdateContext;
+import com.google.gerrit.server.util.time.TimeUtil;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+/** Rest API for rebasing an ancestry chain of changes. */
+@Singleton
+public class RebaseChain
+    implements RestModifyView<ChangeResource, RebaseInput>, UiAction<ChangeResource> {
+  private static final ImmutableSet<ListChangesOption> OPTIONS =
+      Sets.immutableEnumSet(ListChangesOption.CURRENT_REVISION, ListChangesOption.CURRENT_COMMIT);
+
+  private final GitRepositoryManager repoManager;
+  private final RebaseUtil rebaseUtil;
+  private final GetRelatedChangesUtil getRelatedChangesUtil;
+  private final ChangeResource.Factory changeResourceFactory;
+  private final ChangeData.Factory changeDataFactory;
+  private final PermissionBackend permissionBackend;
+  private final BatchUpdate.Factory updateFactory;
+  private final ChangeNotes.Factory notesFactory;
+  private final ProjectCache projectCache;
+  private final PatchSetUtil patchSetUtil;
+  private final ChangeJson.Factory json;
+  private final RebaseMetrics rebaseMetrics;
+
+  @Inject
+  RebaseChain(
+      GitRepositoryManager repoManager,
+      RebaseUtil rebaseUtil,
+      GetRelatedChangesUtil getRelatedChangesUtil,
+      ChangeResource.Factory changeResourceFactory,
+      ChangeData.Factory changeDataFactory,
+      PermissionBackend permissionBackend,
+      BatchUpdate.Factory updateFactory,
+      ChangeNotes.Factory notesFactory,
+      ProjectCache projectCache,
+      PatchSetUtil patchSetUtil,
+      ChangeJson.Factory json,
+      RebaseMetrics rebaseMetrics) {
+    this.repoManager = repoManager;
+    this.getRelatedChangesUtil = getRelatedChangesUtil;
+    this.changeDataFactory = changeDataFactory;
+    this.rebaseUtil = rebaseUtil;
+    this.changeResourceFactory = changeResourceFactory;
+    this.permissionBackend = permissionBackend;
+    this.updateFactory = updateFactory;
+    this.notesFactory = notesFactory;
+    this.projectCache = projectCache;
+    this.patchSetUtil = patchSetUtil;
+    this.json = json;
+    this.rebaseMetrics = rebaseMetrics;
+  }
+
+  @Override
+  public Response<RebaseChainInfo> apply(ChangeResource tipRsrc, RebaseInput input)
+      throws IOException, PermissionBackendException, RestApiException, UpdateException {
+    if (input.onBehalfOfUploader) {
+      tipRsrc.permissions().check(ChangePermission.REBASE_ON_BEHALF_OF_UPLOADER);
+      if (input.allowConflicts) {
+        throw new BadRequestException(
+            "allow_conflicts and on_behalf_of_uploader are mutually exclusive");
+      }
+    } else {
+      tipRsrc.permissions().check(ChangePermission.REBASE);
+    }
+
+    Project.NameKey project = tipRsrc.getProject();
+    projectCache.get(project).orElseThrow(illegalState(project)).checkStatePermitsWrite();
+
+    CurrentUser user = tipRsrc.getUser();
+
+    boolean anyRebaseOnBehalfOfUploader = false;
+    List<Change.Id> upToDateAncestors = new ArrayList<>();
+    Map<Change.Id, RebaseChangeOp> rebaseOps = new LinkedHashMap<>();
+    try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+      try (Repository repo = repoManager.openRepository(project);
+          ObjectInserter oi = repo.newObjectInserter();
+          ObjectReader reader = oi.newReader();
+          RevWalk rw = CodeReviewCommit.newRevWalk(reader);
+          BatchUpdate bu = updateFactory.create(project, user, TimeUtil.now())) {
+        List<PatchSetData> chain = getChainForCurrentPatchSet(tipRsrc);
+
+        boolean ancestorsAreUpToDate = true;
+        for (int i = 0; i < chain.size(); i++) {
+          ChangeData changeData = chain.get(i).data();
+          PatchSet ps = patchSetUtil.current(changeData.notes());
+          if (ps == null) {
+            throw new IllegalStateException(
+                "current revision is missing for change " + changeData.getId());
+          }
+
+          RevisionResource revRsrc =
+              new RevisionResource(changeResourceFactory.create(changeData, user), ps);
+          if (input.onBehalfOfUploader
+              && !revRsrc.getPatchSet().uploader().equals(revRsrc.getAccountId())) {
+            revRsrc = rebaseUtil.onBehalfOf(revRsrc, input);
+            revRsrc.permissions().check(ChangePermission.REBASE_ON_BEHALF_OF_UPLOADER);
+            anyRebaseOnBehalfOfUploader = true;
+          } else {
+            revRsrc.permissions().check(ChangePermission.REBASE);
+          }
+          rebaseUtil.verifyRebasePreconditions(rw, changeData.notes(), ps);
+
+          boolean isUpToDate = false;
+          RebaseChangeOp rebaseOp = null;
+          if (i == 0) {
+            ObjectId desiredBase =
+                rebaseUtil.parseOrFindBaseRevision(
+                    repo, rw, permissionBackend, revRsrc, input, false);
+            if (currentBase(rw, ps).equals(desiredBase)) {
+              isUpToDate = true;
+            } else {
+              rebaseOp = rebaseUtil.getRebaseOp(revRsrc, input, desiredBase);
+            }
+          } else {
+            if (ancestorsAreUpToDate) {
+              ObjectId latestCommittedBase =
+                  PatchSetUtil.getCurrentCommittedRevCommit(
+                      project, rw, notesFactory, chain.get(i - 1).id());
+              isUpToDate = currentBase(rw, ps).equals(latestCommittedBase);
+            }
+            if (!isUpToDate) {
+              rebaseOp = rebaseUtil.getRebaseOp(revRsrc, input, chain.get(i - 1).id());
+            }
+          }
+
+          if (isUpToDate) {
+            upToDateAncestors.add(changeData.getId());
+            continue;
+          }
+          ancestorsAreUpToDate = false;
+          bu.addOp(revRsrc.getChange().getId(), revRsrc.getUser(), rebaseOp);
+          rebaseOps.put(revRsrc.getChange().getId(), rebaseOp);
+        }
+
+        if (ancestorsAreUpToDate) {
+          throw new ResourceConflictException("The whole chain is already up to date.");
+        }
+
+        bu.setNotify(NotifyResolver.Result.none());
+        bu.setRepository(repo, rw, oi);
+        bu.execute();
+      }
+    }
+
+    rebaseMetrics.countRebaseChain(anyRebaseOnBehalfOfUploader, input.allowConflicts);
+
+    RebaseChainInfo res = new RebaseChainInfo();
+    res.rebasedChanges = new ArrayList<>();
+    ChangeJson changeJson = json.create(OPTIONS);
+    for (Change.Id c : upToDateAncestors) {
+      res.rebasedChanges.add(changeJson.format(project, c));
+    }
+    for (Map.Entry<Change.Id, RebaseChangeOp> e : rebaseOps.entrySet()) {
+      Change.Id id = e.getKey();
+      RebaseChangeOp op = e.getValue();
+      ChangeInfo changeInfo = changeJson.format(project, id);
+      changeInfo.containsGitConflicts =
+          !op.getRebasedCommit().getFilesWithGitConflicts().isEmpty() ? true : null;
+      res.rebasedChanges.add(changeInfo);
+    }
+    if (res.rebasedChanges.stream()
+        .anyMatch(i -> i.containsGitConflicts != null && i.containsGitConflicts)) {
+      res.containsGitConflicts = true;
+    }
+    return Response.ok(res);
+  }
+
+  @Override
+  public Description getDescription(ChangeResource tipRsrc) throws Exception {
+    UiAction.Description description =
+        new UiAction.Description()
+            .setLabel("Rebase Chain")
+            .setTitle(
+                "Rebase the ancestry chain onto the tip of the target branch. Makes you the "
+                    + "uploader of the changes which can affect validity of approvals.")
+            .setVisible(false);
+
+    Change tip = tipRsrc.getChange();
+    if (!tip.isNew()) {
+      return description;
+    }
+    if (!projectCache
+        .get(tipRsrc.getProject())
+        .orElseThrow(illegalState(tipRsrc.getProject()))
+        .statePermitsWrite()) {
+      return description;
+    }
+
+    if (patchSetUtil.isPatchSetLocked(tipRsrc.getNotes())) {
+      return description;
+    }
+
+    boolean visible = true;
+    boolean enabled;
+    try (Repository repo = repoManager.openRepository(tipRsrc.getProject());
+        RevWalk rw = new RevWalk(repo)) {
+      List<PatchSetData> chain = getChainForCurrentPatchSet(tipRsrc);
+      if (chain.size() <= 1) {
+        return description;
+      }
+      PatchSetData oldestAncestor = chain.get(0);
+      enabled =
+          rebaseUtil.canRebase(
+              oldestAncestor.patchSet(), oldestAncestor.data().change().getDest(), repo, rw);
+
+      ImmutableList<RevisionResource> chainAsRevisionResources =
+          chain.stream()
+              .map(
+                  ps ->
+                      new RevisionResource(
+                          changeResourceFactory.create(ps.data(), tipRsrc.getUser()),
+                          ps.patchSet()))
+              .collect(toImmutableList());
+
+      boolean canRebase =
+          chainAsRevisionResources.stream()
+              .allMatch(psRsrc -> psRsrc.permissions().testOrFalse(ChangePermission.REBASE));
+      boolean canRebaseOnBehalfOfUploader =
+          chainAsRevisionResources.stream()
+              .allMatch(
+                  psRsrc ->
+                      psRsrc
+                          .permissions()
+                          .testOrFalse(ChangePermission.REBASE_ON_BEHALF_OF_UPLOADER));
+
+      if (!canRebase && !canRebaseOnBehalfOfUploader) {
+        visible = false;
+      } else {
+        for (RevisionResource psRsrc : chainAsRevisionResources) {
+          if (patchSetUtil.isPatchSetLocked(psRsrc.getNotes())
+              || !RebaseUtil.hasOneParent(rw, psRsrc.getPatchSet())) {
+            enabled = false;
+            break;
+          }
+        }
+      }
+
+      return description
+          .setVisible(visible)
+          .setOption("rebase", canRebase)
+          .setOption("rebase_on_behalf_of_uploader", canRebaseOnBehalfOfUploader)
+          .setEnabled(enabled);
+    }
+  }
+
+  private ObjectId currentBase(RevWalk rw, PatchSet ps) throws IOException {
+    return rw.parseCommit(ps.commitId()).getParent(0);
+  }
+
+  private List<PatchSetData> getChainForCurrentPatchSet(ChangeResource rsrc)
+      throws PermissionBackendException, IOException {
+    List<PatchSetData> ancestors =
+        Lists.reverse(
+            getRelatedChangesUtil.getAncestors(
+                changeDataFactory.create(rsrc.getNotes()),
+                patchSetUtil.current(rsrc.getNotes()),
+                true));
+    int eldestOpenAncestor = 0;
+    for (PatchSetData ps : ancestors) {
+      if (ps.data().change().isMerged()) {
+        eldestOpenAncestor++;
+      }
+    }
+    return ancestors.subList(eldestOpenAncestor, ancestors.size());
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/RebaseMetrics.java b/java/com/google/gerrit/server/restapi/change/RebaseMetrics.java
new file mode 100644
index 0000000..d6577ea
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/RebaseMetrics.java
@@ -0,0 +1,61 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import com.google.gerrit.metrics.Counter3;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.Field;
+import com.google.gerrit.metrics.MetricMaker;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+/** Metrics for the rebase REST endpoints ({@link Rebase} and {@link RebaseChain}). */
+@Singleton
+public class RebaseMetrics {
+  private final Counter3<Boolean, Boolean, Boolean> countRebases;
+
+  @Inject
+  public RebaseMetrics(MetricMaker metricMaker) {
+    this.countRebases =
+        metricMaker.newCounter(
+            "change/count_rebases",
+            new Description("Total number of rebases").setRate(),
+            Field.ofBoolean("on_behalf_of_uploader", (metadataBuilder, isOnBehalfOfUploader) -> {})
+                .description("Whether the rebase was done on behalf of the uploader.")
+                .build(),
+            Field.ofBoolean("rebase_chain", (metadataBuilder, isRebaseChain) -> {})
+                .description("Whether a chain was rebased.")
+                .build(),
+            Field.ofBoolean("allow_conflicts", (metadataBuilder, allow_conflicts) -> {})
+                .description("Whether the rebase was done with allowing conflicts.")
+                .build());
+  }
+
+  public void countRebase(boolean isOnBehalfOfUploader, boolean allowConflicts) {
+    countRebase(isOnBehalfOfUploader, /* isRebaseChain= */ false, allowConflicts);
+  }
+
+  public void countRebaseChain(boolean isOnBehalfOfUploader, boolean allowConflicts) {
+    countRebase(isOnBehalfOfUploader, /* isRebaseChain= */ true, allowConflicts);
+  }
+
+  private void countRebase(
+      boolean isOnBehalfOfUploader, boolean isRebaseChain, boolean allowConflicts) {
+    countRebases.increment(
+        /* field1= */ isOnBehalfOfUploader,
+        /* field2= */ isRebaseChain,
+        /* field3= */ allowConflicts);
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/RemoveFromAttentionSet.java b/java/com/google/gerrit/server/restapi/change/RemoveFromAttentionSet.java
index bd3e8ec..d761fa7 100644
--- a/java/com/google/gerrit/server/restapi/change/RemoveFromAttentionSet.java
+++ b/java/com/google/gerrit/server/restapi/change/RemoveFromAttentionSet.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.restapi.change;
 
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
+
 import com.google.common.base.Strings;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.api.changes.AttentionSetInput;
@@ -30,6 +32,7 @@
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.update.context.RefUpdateContext;
 import com.google.gerrit.server.util.AttentionSetUtil;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
@@ -79,16 +82,18 @@
       }
     }
     ChangeResource changeResource = attentionResource.getChangeResource();
-    try (BatchUpdate bu =
-        updateFactory.create(
-            changeResource.getProject(), changeResource.getUser(), TimeUtil.now())) {
-      RemoveFromAttentionSetOp op =
-          opFactory.create(attentionResource.getAccountId(), input.reason, true);
-      bu.addOp(changeResource.getId(), op);
-      NotifyHandling notify = input.notify == null ? NotifyHandling.OWNER : input.notify;
-      NotifyResolver.Result notifyResult = notifyResolver.resolve(notify, input.notifyDetails);
-      bu.setNotify(notifyResult);
-      bu.execute();
+    try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+      try (BatchUpdate bu =
+          updateFactory.create(
+              changeResource.getProject(), changeResource.getUser(), TimeUtil.now())) {
+        RemoveFromAttentionSetOp op =
+            opFactory.create(attentionResource.getAccountId(), input.reason, true);
+        bu.addOp(changeResource.getId(), op);
+        NotifyHandling notify = input.notify == null ? NotifyHandling.OWNER : input.notify;
+        NotifyResolver.Result notifyResult = notifyResolver.resolve(notify, input.notifyDetails);
+        bu.setNotify(notifyResult);
+        bu.execute();
+      }
     }
     return Response.none();
   }
diff --git a/java/com/google/gerrit/server/restapi/change/ReplyAttentionSetUpdates.java b/java/com/google/gerrit/server/restapi/change/ReplyAttentionSetUpdates.java
index 3d9d588..d21bc9a 100644
--- a/java/com/google/gerrit/server/restapi/change/ReplyAttentionSetUpdates.java
+++ b/java/com/google/gerrit/server/restapi/change/ReplyAttentionSetUpdates.java
@@ -199,8 +199,9 @@
       }
       return;
     }
-    // The rest of the conditions only apply if the change is ready for review.
-    if (!readyForReview) {
+    // The rest of the conditions only apply if the change is ready for review and reply is not
+    // posted by a bot.
+    if (!readyForReview || serviceUserClassifier.isServiceUser(currentUser.getAccountId())) {
       return;
     }
 
@@ -259,10 +260,13 @@
 
   /**
    * Bots don't process automatic rules, the only attention set change they do is this rule: Add
-   * owner and uploader when a bot votes negatively.
+   * owner and uploader when a bot votes negatively, but only if the change is open.
    */
   private void botsWithNegativeLabelsAddOwnerAndUploader(
       BatchUpdate bu, ChangeNotes changeNotes, ReviewInput input) {
+    if (changeNotes.getChange().isClosed()) {
+      return;
+    }
     if (input.labels != null && input.labels.values().stream().anyMatch(vote -> vote < 0)) {
       Account.Id uploader = changeNotes.getCurrentPatchSet().uploader();
       Account.Id owner = changeNotes.getChange().getOwner();
diff --git a/java/com/google/gerrit/server/restapi/change/Restore.java b/java/com/google/gerrit/server/restapi/change/Restore.java
index 19d0677..6ac9c21 100644
--- a/java/com/google/gerrit/server/restapi/change/Restore.java
+++ b/java/com/google/gerrit/server/restapi/change/Restore.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.restapi.change;
 
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
 
 import com.google.common.base.Strings;
 import com.google.common.flogger.FluentLogger;
@@ -47,6 +48,7 @@
 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.update.context.RefUpdateContext;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -99,11 +101,13 @@
         .checkStatePermitsWrite();
 
     Op op = new Op(input);
-    try (BatchUpdate u =
-        updateFactory.create(rsrc.getChange().getProject(), rsrc.getUser(), TimeUtil.now())) {
-      u.addOp(rsrc.getId(), op).execute();
+    try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+      try (BatchUpdate u =
+          updateFactory.create(rsrc.getChange().getProject(), rsrc.getUser(), TimeUtil.now())) {
+        u.addOp(rsrc.getId(), op).execute();
+      }
+      return Response.ok(json.noOptions().format(op.change));
     }
-    return Response.ok(json.noOptions().format(op.change));
   }
 
   private class Op implements BatchUpdateOp {
diff --git a/java/com/google/gerrit/server/restapi/change/RevertSubmission.java b/java/com/google/gerrit/server/restapi/change/RevertSubmission.java
index 4e5027b..691fc75 100644
--- a/java/com/google/gerrit/server/restapi/change/RevertSubmission.java
+++ b/java/com/google/gerrit/server/restapi/change/RevertSubmission.java
@@ -19,6 +19,7 @@
 import static com.google.gerrit.server.permissions.ChangePermission.REVERT;
 import static com.google.gerrit.server.permissions.RefPermission.CREATE_CHANGE;
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
 import static java.util.Objects.requireNonNull;
 
 import com.google.common.base.Strings;
@@ -42,7 +43,6 @@
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.webui.UiAction;
-import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.PatchSetUtil;
@@ -53,11 +53,8 @@
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.change.WalkSorter;
 import com.google.gerrit.server.change.WalkSorter.PatchSetData;
-import com.google.gerrit.server.extensions.events.ChangeReverted;
 import com.google.gerrit.server.git.CommitUtil;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.mail.send.MessageIdGenerator;
-import com.google.gerrit.server.mail.send.RevertedSender;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.Sequences;
 import com.google.gerrit.server.permissions.ChangePermission;
@@ -73,8 +70,8 @@
 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.update.context.RefUpdateContext;
 import com.google.gerrit.server.util.CommitMessageUtil;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
@@ -88,6 +85,7 @@
 import java.util.Comparator;
 import java.util.Iterator;
 import java.util.List;
+import java.util.Locale;
 import java.util.Set;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
@@ -115,17 +113,13 @@
   private final ChangeJson.Factory json;
   private final GitRepositoryManager repoManager;
   private final WalkSorter sorter;
-  private final ChangeMessagesUtil cmUtil;
   private final CommitUtil commitUtil;
   private final ChangeNotes.Factory changeNotesFactory;
-  private final ChangeReverted changeReverted;
-  private final RevertedSender.Factory revertedSenderFactory;
   private final Sequences seq;
   private final NotifyResolver notifyResolver;
   private final BatchUpdate.Factory updateFactory;
   private final ChangeResource.Factory changeResourceFactory;
   private final GetRelated getRelated;
-  private final MessageIdGenerator messageIdGenerator;
 
   private CherryPickInput cherryPickInput;
   private List<ChangeInfo> results;
@@ -145,17 +139,13 @@
       ChangeJson.Factory json,
       GitRepositoryManager repoManager,
       WalkSorter sorter,
-      ChangeMessagesUtil cmUtil,
       CommitUtil commitUtil,
       ChangeNotes.Factory changeNotesFactory,
-      ChangeReverted changeReverted,
-      RevertedSender.Factory revertedSenderFactory,
       Sequences seq,
       NotifyResolver notifyResolver,
       BatchUpdate.Factory updateFactory,
       ChangeResource.Factory changeResourceFactory,
-      GetRelated getRelated,
-      MessageIdGenerator messageIdGenerator) {
+      GetRelated getRelated) {
     this.queryProvider = queryProvider;
     this.user = user;
     this.permissionBackend = permissionBackend;
@@ -166,17 +156,13 @@
     this.json = json;
     this.repoManager = repoManager;
     this.sorter = sorter;
-    this.cmUtil = cmUtil;
     this.commitUtil = commitUtil;
     this.changeNotesFactory = changeNotesFactory;
-    this.changeReverted = changeReverted;
-    this.revertedSenderFactory = revertedSenderFactory;
     this.seq = seq;
     this.notifyResolver = notifyResolver;
     this.updateFactory = updateFactory;
     this.changeResourceFactory = changeResourceFactory;
     this.getRelated = getRelated;
-    this.messageIdGenerator = messageIdGenerator;
     results = new ArrayList<>();
     cherryPickInput = null;
   }
@@ -211,7 +197,8 @@
     }
     if (topic == null) {
       return String.format(
-          "revert-%s-%s", submissionId, RandomStringUtils.randomAlphabetic(10).toUpperCase());
+          "revert-%s-%s",
+          submissionId, RandomStringUtils.randomAlphabetic(10).toUpperCase(Locale.US));
     }
     return topic;
   }
@@ -253,13 +240,11 @@
     cherryPickInput = createCherryPickInput(revertInput);
     Instant timestamp = TimeUtil.now();
 
+    String initialMessage = revertInput.message;
     for (BranchNameKey projectAndBranch : changesPerProjectAndBranch.keySet()) {
       cherryPickInput.base = null;
       Project.NameKey project = projectAndBranch.project();
       cherryPickInput.destination = projectAndBranch.branch();
-      if (revertInput.workInProgress) {
-        cherryPickInput.notify = firstNonNull(cherryPickInput.notify, NotifyHandling.OWNER);
-      }
       Collection<ChangeData> changesInProjectAndBranch =
           changesPerProjectAndBranch.get(projectAndBranch);
 
@@ -273,6 +258,7 @@
               .collect(Collectors.toSet());
 
       revertAllChangesInProjectAndBranch(
+          initialMessage,
           revertInput,
           project,
           sortedChangesInProjectAndBranch,
@@ -285,7 +271,9 @@
     return revertSubmissionInfo;
   }
 
+  // Warning: reuses and modifies revertInput.message.
   private void revertAllChangesInProjectAndBranch(
+      String initialMessage,
       RevertInput revertInput,
       Project.NameKey project,
       Iterator<PatchSetData> sortedChangesInProjectAndBranch,
@@ -293,8 +281,6 @@
       Instant timestamp)
       throws IOException, RestApiException, UpdateException, ConfigInvalidException,
           PermissionBackendException {
-
-    String initialMessage = revertInput.message;
     while (sortedChangesInProjectAndBranch.hasNext()) {
       ChangeNotes changeNotes = sortedChangesInProjectAndBranch.next().data().notes();
       if (cherryPickInput.base == null) {
@@ -302,6 +288,7 @@
         cherryPickInput.base = getBase(changeNotes, commitIdsInProjectAndBranch).name();
       }
 
+      // Set revert message for the current revert change.
       revertInput.message = getMessage(initialMessage, changeNotes);
       if (cherryPickInput.base.equals(changeNotes.getCurrentPatchSet().commitId().getName())) {
         // This is the code in case this is the first revert of this project + branch, and the
@@ -323,25 +310,26 @@
     cherryPickInput.message = revertInput.message;
     ObjectId generatedChangeId = CommitMessageUtil.generateChangeId();
     Change.Id cherryPickRevertChangeId = Change.id(seq.nextChangeId());
-    try (BatchUpdate bu = updateFactory.create(project, user.get(), TimeUtil.now())) {
-      bu.setNotify(
-          notifyResolver.resolve(
-              firstNonNull(cherryPickInput.notify, NotifyHandling.ALL),
-              cherryPickInput.notifyDetails));
-      bu.addOp(
-          changeNotes.getChange().getId(),
-          new CreateCherryPickOp(
-              revCommitId,
-              generatedChangeId,
-              cherryPickRevertChangeId,
-              timestamp,
-              revertInput.workInProgress));
-      bu.addOp(changeNotes.getChange().getId(), new PostRevertedMessageOp(generatedChangeId));
-      bu.addOp(
-          cherryPickRevertChangeId,
-          new NotifyOp(changeNotes.getChange(), cherryPickRevertChangeId));
-
-      bu.execute();
+    try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+      try (BatchUpdate bu = updateFactory.create(project, user.get(), TimeUtil.now())) {
+        bu.setNotify(
+            notifyResolver.resolve(
+                firstNonNull(cherryPickInput.notify, NotifyHandling.ALL),
+                cherryPickInput.notifyDetails));
+        bu.addOp(
+            changeNotes.getChange().getId(),
+            new CreateCherryPickOp(
+                revCommitId,
+                generatedChangeId,
+                cherryPickRevertChangeId,
+                timestamp,
+                revertInput.workInProgress));
+        if (!revertInput.workInProgress) {
+          commitUtil.addChangeRevertedNotificationOps(
+              bu, changeNotes.getChangeId(), cherryPickRevertChangeId, generatedChangeId.name());
+        }
+        bu.execute();
+      }
     }
   }
 
@@ -366,6 +354,9 @@
     // change is created for the cherry-picked commit. Notifications are sent only for this change,
     // but not for the intermediately created revert commit.
     cherryPickInput.notify = revertInput.notify;
+    if (revertInput.workInProgress) {
+      cherryPickInput.notify = firstNonNull(cherryPickInput.notify, NotifyHandling.NONE);
+    }
     cherryPickInput.notifyDetails = revertInput.notifyDetails;
     cherryPickInput.parent = 1;
     cherryPickInput.keepReviewers = true;
@@ -598,55 +589,4 @@
       return true;
     }
   }
-
-  private class NotifyOp implements BatchUpdateOp {
-    private final Change change;
-    private final Change.Id revertChangeId;
-
-    NotifyOp(Change change, Change.Id revertChangeId) {
-      this.change = change;
-      this.revertChangeId = revertChangeId;
-    }
-
-    @Override
-    public void postUpdate(PostUpdateContext ctx) throws Exception {
-      changeReverted.fire(
-          ctx.getChangeData(change),
-          ctx.getChangeData(changeNotesFactory.createChecked(ctx.getProject(), revertChangeId)),
-          ctx.getWhen());
-      try {
-        RevertedSender emailSender = revertedSenderFactory.create(ctx.getProject(), change.getId());
-        emailSender.setFrom(ctx.getAccountId());
-        emailSender.setNotify(ctx.getNotify(change.getId()));
-        emailSender.setMessageId(
-            messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), change.currentPatchSetId()));
-        emailSender.send();
-      } catch (Exception err) {
-        logger.atSevere().withCause(err).log(
-            "Cannot send email for revert change %s", change.getId());
-      }
-    }
-  }
-
-  /**
-   * create a message that describes the revert if the cherry-pick is successful, and point the
-   * revert of the change towards the cherry-pick. The cherry-pick is the updated change that acts
-   * as "revert-of" the original change.
-   */
-  private class PostRevertedMessageOp implements BatchUpdateOp {
-    private final ObjectId computedChangeId;
-
-    PostRevertedMessageOp(ObjectId computedChangeId) {
-      this.computedChangeId = computedChangeId;
-    }
-
-    @Override
-    public boolean updateChange(ChangeContext ctx) throws Exception {
-      cmUtil.setChangeMessage(
-          ctx,
-          "Created a revert of this change as I" + computedChangeId.getName(),
-          ChangeMessagesUtil.TAG_REVERT);
-      return true;
-    }
-  }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/ReviewerRecommender.java b/java/com/google/gerrit/server/restapi/change/ReviewerRecommender.java
index d8d51d4..07e54ce 100644
--- a/java/com/google/gerrit/server/restapi/change/ReviewerRecommender.java
+++ b/java/com/google/gerrit/server/restapi/change/ReviewerRecommender.java
@@ -208,7 +208,7 @@
           queryProvider
               .get()
               .setLimit(numberOfRelevantChanges)
-              .setRequestedFields(ChangeField.REVIEWER)
+              .setRequestedFields(ChangeField.REVIEWER_SPEC)
               .query(changeQueryBuilder.owner("self"));
       Map<Account.Id, MutableDouble> suggestions = new LinkedHashMap<>();
       // Put those candidates at the bottom of the list
diff --git a/java/com/google/gerrit/server/restapi/change/Revisions.java b/java/com/google/gerrit/server/restapi/change/Revisions.java
index bdc6816..8ab8a19 100644
--- a/java/com/google/gerrit/server/restapi/change/Revisions.java
+++ b/java/com/google/gerrit/server/restapi/change/Revisions.java
@@ -157,6 +157,7 @@
               .id(PatchSet.id(change.getId(), 0))
               .commitId(editCommit)
               .uploader(change.getUser().getAccountId())
+              .realUploader(change.getUser().getAccountId())
               .createdOn(editCommit.getCommitterIdent().getWhenAsInstant())
               .build();
       if (commitId == null || editCommit.equals(commitId)) {
diff --git a/java/com/google/gerrit/server/restapi/change/SetReadyForReview.java b/java/com/google/gerrit/server/restapi/change/SetReadyForReview.java
index 9f019b6..7d3fe98 100644
--- a/java/com/google/gerrit/server/restapi/change/SetReadyForReview.java
+++ b/java/com/google/gerrit/server/restapi/change/SetReadyForReview.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.base.MoreObjects.firstNonNull;
 import static com.google.gerrit.extensions.conditions.BooleanCondition.and;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
 
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
@@ -29,10 +30,12 @@
 import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.change.WorkInProgressOp;
 import com.google.gerrit.server.change.WorkInProgressOp.Input;
+import com.google.gerrit.server.git.CommitUtil;
 import com.google.gerrit.server.permissions.ChangePermission;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.update.context.RefUpdateContext;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -42,11 +45,16 @@
     implements RestModifyView<ChangeResource, Input>, UiAction<ChangeResource> {
   private final BatchUpdate.Factory updateFactory;
   private final WorkInProgressOp.Factory opFactory;
+  private final CommitUtil commitUtil;
 
   @Inject
-  SetReadyForReview(BatchUpdate.Factory updateFactory, WorkInProgressOp.Factory opFactory) {
+  SetReadyForReview(
+      BatchUpdate.Factory updateFactory,
+      WorkInProgressOp.Factory opFactory,
+      CommitUtil commitUtil) {
     this.updateFactory = updateFactory;
     this.opFactory = opFactory;
+    this.commitUtil = commitUtil;
   }
 
   @Override
@@ -62,12 +70,18 @@
     if (!change.isWorkInProgress()) {
       throw new ResourceConflictException("change is not work in progress");
     }
-
-    try (BatchUpdate bu = updateFactory.create(rsrc.getProject(), rsrc.getUser(), TimeUtil.now())) {
-      bu.setNotify(NotifyResolver.Result.create(firstNonNull(input.notify, NotifyHandling.ALL)));
-      bu.addOp(rsrc.getChange().getId(), opFactory.create(false, input));
-      bu.execute();
-      return Response.ok();
+    try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+      try (BatchUpdate bu =
+          updateFactory.create(rsrc.getProject(), rsrc.getUser(), TimeUtil.now())) {
+        bu.setNotify(NotifyResolver.Result.create(firstNonNull(input.notify, NotifyHandling.ALL)));
+        bu.addOp(rsrc.getChange().getId(), opFactory.create(false, input));
+        if (change.getRevertOf() != null) {
+          commitUtil.addChangeRevertedNotificationOps(
+              bu, change.getRevertOf(), change.getId(), change.getKey().get());
+        }
+        bu.execute();
+        return Response.ok();
+      }
     }
   }
 
diff --git a/java/com/google/gerrit/server/restapi/change/SetWorkInProgress.java b/java/com/google/gerrit/server/restapi/change/SetWorkInProgress.java
index 0ad5180..306aeea 100644
--- a/java/com/google/gerrit/server/restapi/change/SetWorkInProgress.java
+++ b/java/com/google/gerrit/server/restapi/change/SetWorkInProgress.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.base.MoreObjects.firstNonNull;
 import static com.google.gerrit.extensions.conditions.BooleanCondition.and;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
 
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
@@ -33,6 +34,7 @@
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.update.context.RefUpdateContext;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -62,12 +64,14 @@
     if (change.isWorkInProgress()) {
       throw new ResourceConflictException("change is already work in progress");
     }
-
-    try (BatchUpdate bu = updateFactory.create(rsrc.getProject(), rsrc.getUser(), TimeUtil.now())) {
-      bu.setNotify(NotifyResolver.Result.create(firstNonNull(input.notify, NotifyHandling.NONE)));
-      bu.addOp(rsrc.getChange().getId(), opFactory.create(true, input));
-      bu.execute();
-      return Response.ok();
+    try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+      try (BatchUpdate bu =
+          updateFactory.create(rsrc.getProject(), rsrc.getUser(), TimeUtil.now())) {
+        bu.setNotify(NotifyResolver.Result.create(firstNonNull(input.notify, NotifyHandling.NONE)));
+        bu.addOp(rsrc.getChange().getId(), opFactory.create(true, input));
+        bu.execute();
+        return Response.ok();
+      }
     }
   }
 
diff --git a/java/com/google/gerrit/server/restapi/change/Submit.java b/java/com/google/gerrit/server/restapi/change/Submit.java
index 560f4e0..b1f1da5 100644
--- a/java/com/google/gerrit/server/restapi/change/Submit.java
+++ b/java/com/google/gerrit/server/restapi/change/Submit.java
@@ -43,11 +43,11 @@
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.extensions.webui.UiAction;
+import com.google.gerrit.server.BranchUtil;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.PatchSetUtil;
-import com.google.gerrit.server.ProjectUtil;
 import com.google.gerrit.server.account.AccountResolver;
 import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.change.ChangeResource;
@@ -198,7 +198,7 @@
     Change change = rsrc.getChange();
     if (!change.isNew()) {
       throw new ResourceConflictException("change is " + ChangeUtil.status(change));
-    } else if (!ProjectUtil.branchExists(repoManager, change.getDest())) {
+    } else if (!BranchUtil.branchExists(repoManager, change.getDest())) {
       throw new ResourceConflictException(
           String.format("destination branch \"%s\" not found.", change.getDest().branch()));
     } else if (!rsrc.getPatchSet().id().equals(change.currentPatchSetId())) {
@@ -234,6 +234,7 @@
    * @param user the user who is checking to submit
    * @return a reason why any of the changes is not submittable or null
    */
+  @Nullable
   private String problemsForSubmittingChangeset(ChangeData cd, ChangeSet cs, CurrentUser user) {
     try {
       if (cs.furtherHiddenChanges()) {
@@ -297,6 +298,7 @@
     return null;
   }
 
+  @Nullable
   @Override
   public UiAction.Description getDescription(RevisionResource resource)
       throws IOException, PermissionBackendException {
@@ -372,6 +374,7 @@
         .setEnabled(Boolean.TRUE.equals(enabled));
   }
 
+  @Nullable
   public Collection<ChangeData> unmergeableChanges(ChangeSet cs) throws IOException {
     Set<ChangeData> mergeabilityMap = new HashSet<>();
     Set<ObjectId> outDatedPatchsets = new HashSet<>();
diff --git a/java/com/google/gerrit/server/restapi/config/GetServerInfo.java b/java/com/google/gerrit/server/restapi/config/GetServerInfo.java
index a046100..63f2239 100644
--- a/java/com/google/gerrit/server/restapi/config/GetServerInfo.java
+++ b/java/com/google/gerrit/server/restapi/config/GetServerInfo.java
@@ -56,8 +56,6 @@
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.documentation.QueryDocumentationExecutor;
-import com.google.gerrit.server.index.change.ChangeField;
-import com.google.gerrit.server.index.change.ChangeIndexCollection;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.plugincontext.PluginItemContext;
 import com.google.gerrit.server.plugincontext.PluginMapContext;
@@ -94,7 +92,6 @@
   private final QueryDocumentationExecutor docSearcher;
   private final ProjectCache projectCache;
   private final AgreementJson agreementJson;
-  private final ChangeIndexCollection indexes;
   private final SitePaths sitePaths;
   private final @Nullable @GerritInstanceId String instanceId;
 
@@ -118,7 +115,6 @@
       QueryDocumentationExecutor docSearcher,
       ProjectCache projectCache,
       AgreementJson agreementJson,
-      ChangeIndexCollection indexes,
       SitePaths sitePaths,
       @Nullable @GerritInstanceId String instanceId) {
     this.config = config;
@@ -139,7 +135,6 @@
     this.docSearcher = docSearcher;
     this.projectCache = projectCache;
     this.agreementJson = agreementJson;
-    this.indexes = indexes;
     this.sitePaths = sitePaths;
     this.instanceId = instanceId;
   }
@@ -224,11 +219,6 @@
   private ChangeConfigInfo getChangeInfo() {
     ChangeConfigInfo info = new ChangeConfigInfo();
     info.allowBlame = toBoolean(config.getBoolean("change", "allowBlame", true));
-    boolean hasAssigneeInIndex =
-        indexes.getSearchIndex().getSchema().hasField(ChangeField.ASSIGNEE);
-    info.showAssigneeInChangesTable =
-        toBoolean(
-            config.getBoolean("change", "showAssigneeInChangesTable", false) && hasAssigneeInIndex);
     info.updateDelay =
         (int) ConfigUtil.getTimeUnit(config, "change", null, "updateDelay", 300, TimeUnit.SECONDS);
     info.submitWholeTopic = toBoolean(MergeSuperSet.wholeTopicEnabled(config));
@@ -236,10 +226,7 @@
         toBoolean(this.config.getBoolean("change", null, "disablePrivateChanges", false));
     info.mergeabilityComputationBehavior =
         MergeabilityComputationBehavior.fromConfig(config).name();
-    info.enableAttentionSet =
-        toBoolean(this.config.getBoolean("change", null, "enableAttentionSet", true));
-    info.enableAssignee =
-        toBoolean(this.config.getBoolean("change", null, "enableAssignee", false));
+    info.enableRobotComments = toBoolean(config.getBoolean("change", "enableRobotComments", true));
     info.conflictsPredicateEnabled =
         toBoolean(config.getBoolean("change", "conflictsPredicateEnabled", true));
     return info;
@@ -310,6 +297,7 @@
     return info;
   }
 
+  @Nullable
   private String getDocUrl() {
     String docUrl = config.getString("gerrit", null, "docUrl");
     if (Strings.isNullOrEmpty(docUrl)) {
@@ -332,15 +320,13 @@
     return info;
   }
 
-  private static final String DEFAULT_THEME = "/static/" + SitePaths.THEME_FILENAME;
   private static final String DEFAULT_THEME_JS = "/static/" + SitePaths.THEME_JS_FILENAME;
 
+  @Nullable
   private String getDefaultTheme() {
     if (config.getString("theme", null, "enableDefault") == null) {
       // If not explicitly enabled or disabled, check for the existence of the theme file.
-      return Files.exists(sitePaths.site_theme_js)
-          ? DEFAULT_THEME_JS
-          : Files.exists(sitePaths.site_theme) ? DEFAULT_THEME : null;
+      return Files.exists(sitePaths.site_theme_js) ? DEFAULT_THEME_JS : null;
     }
     if (config.getBoolean("theme", null, "enableDefault", true)) {
       // Return non-null theme path without checking for file existence. Even if the file doesn't
@@ -351,6 +337,7 @@
     return null;
   }
 
+  @Nullable
   private SshdInfo getSshdInfo() {
     String[] addr = config.getStringList("sshd", null, "listenAddress");
     if (addr.length == 1 && isOff(addr[0])) {
@@ -387,7 +374,8 @@
     return Arrays.asList(config.getStringList("dashboard", null, "submitRequirementColumns"));
   }
 
+  @Nullable
   private static Boolean toBoolean(boolean v) {
-    return v ? v : null;
+    return v ? Boolean.TRUE : null;
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/config/GetSummary.java b/java/com/google/gerrit/server/restapi/config/GetSummary.java
index d0a1498..34cf550 100644
--- a/java/com/google/gerrit/server/restapi/config/GetSummary.java
+++ b/java/com/google/gerrit/server/restapi/config/GetSummary.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.restapi.config;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
 import com.google.gerrit.extensions.restapi.Response;
@@ -90,14 +91,22 @@
   private TaskSummaryInfo getTaskSummary() {
     Collection<Task<?>> pending = workQueue.getTasks();
     int tasksTotal = pending.size();
+    int tasksStopping = 0;
     int tasksRunning = 0;
+    int tasksStarting = 0;
     int tasksReady = 0;
     int tasksSleeping = 0;
     for (Task<?> task : pending) {
       switch (task.getState()) {
+        case STOPPING:
+          tasksStopping++;
+          break;
         case RUNNING:
           tasksRunning++;
           break;
+        case STARTING:
+          tasksStarting++;
+          break;
         case READY:
           tasksReady++;
           break;
@@ -113,7 +122,9 @@
 
     TaskSummaryInfo taskSummary = new TaskSummaryInfo();
     taskSummary.total = toInteger(tasksTotal);
+    taskSummary.stopping = toInteger(tasksStopping);
     taskSummary.running = toInteger(tasksRunning);
+    taskSummary.starting = toInteger(tasksStarting);
     taskSummary.ready = toInteger(tasksReady);
     taskSummary.sleeping = toInteger(tasksSleeping);
     return taskSummary;
@@ -211,6 +222,7 @@
     return jvmSummary;
   }
 
+  @Nullable
   private static Integer toInteger(int i) {
     return i != 0 ? i : null;
   }
@@ -247,7 +259,9 @@
 
   public static class TaskSummaryInfo {
     public Integer total;
+    public Integer stopping;
     public Integer running;
+    public Integer starting;
     public Integer ready;
     public Integer sleeping;
   }
diff --git a/java/com/google/gerrit/server/restapi/config/ReloadConfig.java b/java/com/google/gerrit/server/restapi/config/ReloadConfig.java
index 9ce7ffd..c8f2ed6 100644
--- a/java/com/google/gerrit/server/restapi/config/ReloadConfig.java
+++ b/java/com/google/gerrit/server/restapi/config/ReloadConfig.java
@@ -33,6 +33,7 @@
 import java.util.Collection;
 import java.util.Collections;
 import java.util.List;
+import java.util.Locale;
 import java.util.Map;
 import java.util.stream.Collectors;
 
@@ -59,7 +60,8 @@
         updates.asMap().entrySet().stream()
             .collect(
                 Collectors.toMap(
-                    e -> e.getKey().name().toLowerCase(), e -> toEntryInfos(e.getValue()))));
+                    e -> e.getKey().name().toLowerCase(Locale.US),
+                    e -> toEntryInfos(e.getValue()))));
   }
 
   private static List<ConfigUpdateEntryInfo> toEntryInfos(
diff --git a/java/com/google/gerrit/server/restapi/group/CreateGroup.java b/java/com/google/gerrit/server/restapi/group/CreateGroup.java
index e617931..9d36aaa 100644
--- a/java/com/google/gerrit/server/restapi/group/CreateGroup.java
+++ b/java/com/google/gerrit/server/restapi/group/CreateGroup.java
@@ -17,6 +17,7 @@
 import com.google.common.base.MoreObjects;
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
@@ -181,6 +182,7 @@
     return Response.created(json.format(new InternalGroupDescription(createGroup(args))));
   }
 
+  @Nullable
   private AccountGroup.UUID owner(GroupInput input) throws UnprocessableEntityException {
     if (input.ownerId != null) {
       GroupDescription.Internal d = groups.parseInternal(Url.decode(input.ownerId));
diff --git a/java/com/google/gerrit/server/restapi/group/ListGroups.java b/java/com/google/gerrit/server/restapi/group/ListGroups.java
index b94e44d..4d9a1e9 100644
--- a/java/com/google/gerrit/server/restapi/group/ListGroups.java
+++ b/java/com/google/gerrit/server/restapi/group/ListGroups.java
@@ -21,6 +21,7 @@
 import com.google.common.base.MoreObjects;
 import com.google.common.base.Strings;
 import com.google.common.collect.Lists;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.GroupDescription;
@@ -408,6 +409,7 @@
     }
   }
 
+  @Nullable
   private Pattern getRegexPattern() {
     return Strings.isNullOrEmpty(matchRegex) ? null : Pattern.compile(matchRegex);
   }
diff --git a/java/com/google/gerrit/server/restapi/project/BanCommit.java b/java/com/google/gerrit/server/restapi/project/BanCommit.java
index eb5473d..2dd7bd8 100644
--- a/java/com/google/gerrit/server/restapi/project/BanCommit.java
+++ b/java/com/google/gerrit/server/restapi/project/BanCommit.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.restapi.project;
 
 import com.google.common.collect.Lists;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.api.projects.BanCommitInput;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
@@ -64,6 +65,7 @@
     return Response.ok(r);
   }
 
+  @Nullable
   private static List<String> transformCommits(List<ObjectId> commits) {
     if (commits == null || commits.isEmpty()) {
       return null;
diff --git a/java/com/google/gerrit/server/restapi/project/ConfigInfoCreator.java b/java/com/google/gerrit/server/restapi/project/ConfigInfoCreator.java
index 904a16f..192e624 100644
--- a/java/com/google/gerrit/server/restapi/project/ConfigInfoCreator.java
+++ b/java/com/google/gerrit/server/restapi/project/ConfigInfoCreator.java
@@ -17,6 +17,7 @@
 import com.google.common.base.MoreObjects;
 import com.google.common.base.Strings;
 import com.google.common.collect.Iterables;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.BooleanProjectConfig;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.projects.CommentLinkInfo;
@@ -125,6 +126,7 @@
     return info;
   }
 
+  @Nullable
   private static Map<String, Map<String, ConfigParameterInfo>> getPluginConfig(
       ProjectState project,
       DynamicMap<ProjectConfigEntry> pluginConfigEntries,
diff --git a/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java b/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java
index a949ff2..458ae4d 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.restapi.project;
 
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
 
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
@@ -46,6 +47,7 @@
 import com.google.gerrit.server.project.ProjectResource;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.update.context.RefUpdateContext;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -151,24 +153,26 @@
 
       md.setInsertChangeId(true);
       Change.Id changeId = Change.id(seq.nextChangeId());
+      try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+        RevCommit commit =
+            config.commitToNewRef(
+                md, PatchSet.id(changeId, Change.INITIAL_PATCH_SET_ID).toRefName());
 
-      RevCommit commit =
-          config.commitToNewRef(md, PatchSet.id(changeId, Change.INITIAL_PATCH_SET_ID).toRefName());
+        if (commit.name().equals(oldCommitSha1)) {
+          throw new BadRequestException("no change");
+        }
 
-      if (commit.name().equals(oldCommitSha1)) {
-        throw new BadRequestException("no change");
-      }
-
-      try (ObjectInserter objInserter = md.getRepository().newObjectInserter();
-          ObjectReader objReader = objInserter.newReader();
-          RevWalk rw = new RevWalk(objReader);
-          BatchUpdate bu =
-              updateFactory.create(rsrc.getNameKey(), rsrc.getUser(), TimeUtil.now())) {
-        bu.setRepository(md.getRepository(), rw, objInserter);
-        ChangeInserter ins = newInserter(changeId, commit);
-        bu.insertChange(ins);
-        bu.execute();
-        return Response.created(jsonFactory.noOptions().format(ins.getChange()));
+        try (ObjectInserter objInserter = md.getRepository().newObjectInserter();
+            ObjectReader objReader = objInserter.newReader();
+            RevWalk rw = new RevWalk(objReader);
+            BatchUpdate bu =
+                updateFactory.create(rsrc.getNameKey(), rsrc.getUser(), TimeUtil.now())) {
+          bu.setRepository(md.getRepository(), rw, objInserter);
+          ChangeInserter ins = newInserter(changeId, commit);
+          bu.insertChange(ins);
+          bu.execute();
+          return Response.created(jsonFactory.noOptions().format(ins.getChange()));
+        }
       }
     } catch (InvalidNameException e) {
       throw new BadRequestException(e.toString());
diff --git a/java/com/google/gerrit/server/restapi/project/CreateBranch.java b/java/com/google/gerrit/server/restapi/project/CreateBranch.java
index c39b1f4..412559b 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateBranch.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateBranch.java
@@ -15,10 +15,9 @@
 package com.google.gerrit.server.restapi.project;
 
 import static com.google.gerrit.entities.RefNames.isConfigRef;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.BRANCH_MODIFICATION;
 
 import com.google.common.base.Strings;
-import com.google.common.collect.ImmutableListMultimap;
-import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.projects.BranchInfo;
@@ -32,6 +31,7 @@
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.git.LockFailureException;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.change.ValidationOptionsUtil;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.permissions.PermissionBackend;
@@ -43,12 +43,12 @@
 import com.google.gerrit.server.project.ProjectResource;
 import com.google.gerrit.server.project.RefUtil;
 import com.google.gerrit.server.project.RefValidationHelper;
+import com.google.gerrit.server.update.context.RefUpdateContext;
 import com.google.gerrit.server.util.MagicBranch;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.util.Map;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
@@ -89,130 +89,132 @@
       throws BadRequestException, AuthException, ResourceConflictException,
           UnprocessableEntityException, IOException, PermissionBackendException,
           NoSuchProjectException {
-    String ref = id.get();
-    if (input == null) {
-      input = new BranchInput();
-    }
-    if (input.ref != null && !ref.equals(input.ref)) {
-      throw new BadRequestException("ref must match URL");
-    }
-    if (input.revision != null) {
-      input.revision = input.revision.trim();
-    }
-    if (Strings.isNullOrEmpty(input.revision)) {
-      input.revision = Constants.HEAD;
-    }
-    while (ref.startsWith("/")) {
-      ref = ref.substring(1);
-    }
-    ref = RefNames.fullName(ref);
-    if (!Repository.isValidRefName(ref)) {
-      throw new BadRequestException("invalid branch name \"" + ref + "\"");
-    }
-    if (MagicBranch.isMagicBranch(ref)) {
-      throw new BadRequestException(
-          "not allowed to create branches under \""
-              + MagicBranch.getMagicRefNamePrefix(ref)
-              + "\"");
-    }
-    if (!isBranchAllowed(ref)) {
-      throw new BadRequestException(
-          "Cannot create a branch with name \""
-              + ref
-              + "\". Not allowed to create branches under Gerrit internal or tags refs.");
-    }
-
-    BranchNameKey name = BranchNameKey.create(rsrc.getNameKey(), ref);
-    try (Repository repo = repoManager.openRepository(rsrc.getNameKey())) {
-      ObjectId revid = RefUtil.parseBaseRevision(repo, input.revision);
-      RevWalk rw = RefUtil.verifyConnected(repo, revid);
-      RevObject object = rw.parseAny(revid);
-
-      if (ref.startsWith(Constants.R_HEADS)) {
-        // Ensure that what we start the branch from is a commit. If we
-        // were given a tag, dereference to the commit instead.
-        //
-        object = rw.parseCommit(object);
+    try (RefUpdateContext ctx = RefUpdateContext.open(BRANCH_MODIFICATION)) {
+      String ref = id.get();
+      if (input == null) {
+        input = new BranchInput();
+      }
+      if (input.ref != null && !ref.equals(input.ref)) {
+        throw new BadRequestException("ref must match URL");
+      }
+      if (input.revision != null) {
+        input.revision = input.revision.trim();
+      }
+      if (Strings.isNullOrEmpty(input.revision)) {
+        input.revision = Constants.HEAD;
+      }
+      while (ref.startsWith("/")) {
+        ref = ref.substring(1);
+      }
+      ref = RefNames.fullName(ref);
+      if (!Repository.isValidRefName(ref)) {
+        throw new BadRequestException("invalid branch name \"" + ref + "\"");
+      }
+      if (MagicBranch.isMagicBranch(ref)) {
+        throw new BadRequestException(
+            "not allowed to create branches under \""
+                + MagicBranch.getMagicRefNamePrefix(ref)
+                + "\"");
+      }
+      if (!isBranchAllowed(ref)) {
+        throw new BadRequestException(
+            "Cannot create a branch with name \""
+                + ref
+                + "\". Not allowed to create branches under Gerrit internal or tags refs.");
       }
 
-      Ref sourceRef = repo.exactRef(input.revision);
-      if (sourceRef == null) {
-        createRefControl.checkCreateRef(identifiedUser, repo, name, object, /* forPush= */ false);
-      } else {
-        if (sourceRef.isSymbolic()) {
-          sourceRef = sourceRef.getTarget();
+      BranchNameKey name = BranchNameKey.create(rsrc.getNameKey(), ref);
+      try (Repository repo = repoManager.openRepository(rsrc.getNameKey())) {
+        ObjectId revid = RefUtil.parseBaseRevision(repo, input.revision);
+        RevWalk rw = RefUtil.verifyConnected(repo, revid);
+        RevObject object = rw.parseAny(revid);
+
+        if (ref.startsWith(Constants.R_HEADS)) {
+          // Ensure that what we start the branch from is a commit. If we
+          // were given a tag, dereference to the commit instead.
+          //
+          object = rw.parseCommit(object);
         }
-        createRefControl.checkCreateRef(
-            identifiedUser,
-            repo,
-            name,
-            object,
-            /* forPush= */ false,
-            BranchNameKey.create(rsrc.getNameKey(), sourceRef.getName()));
-      }
 
-      RefUpdate u = repo.updateRef(ref);
-      u.setExpectedOldObjectId(ObjectId.zeroId());
-      u.setNewObjectId(object.copy());
-      u.setRefLogIdent(identifiedUser.get().newRefLogIdent());
-      u.setRefLogMessage("created via REST from " + input.revision, false);
-      refCreationValidator.validateRefOperation(
-          rsrc.getName(),
-          identifiedUser.get(),
-          u,
-          getValidateOptionsAsMultimap(input.validationOptions));
-      RefUpdate.Result result = u.update(rw);
-      switch (result) {
-        case FAST_FORWARD:
-        case NEW:
-        case NO_CHANGE:
-          referenceUpdated.fire(
-              name.project(), u, ReceiveCommand.Type.CREATE, identifiedUser.get().state());
-          break;
-        case LOCK_FAILURE:
-          if (repo.getRefDatabase().exactRef(ref) != null) {
-            throw new ResourceConflictException("branch \"" + ref + "\" already exists");
+        Ref sourceRef = repo.exactRef(input.revision);
+        if (sourceRef == null) {
+          createRefControl.checkCreateRef(identifiedUser, repo, name, object, /* forPush= */ false);
+        } else {
+          if (sourceRef.isSymbolic()) {
+            sourceRef = sourceRef.getTarget();
           }
-          String refPrefix = RefUtil.getRefPrefix(ref);
-          while (!Constants.R_HEADS.equals(refPrefix)) {
-            if (repo.getRefDatabase().exactRef(refPrefix) != null) {
-              throw new ResourceConflictException(
-                  "Cannot create branch \""
-                      + ref
-                      + "\" since it conflicts with branch \""
-                      + refPrefix
-                      + "\".");
+          createRefControl.checkCreateRef(
+              identifiedUser,
+              repo,
+              name,
+              object,
+              /* forPush= */ false,
+              BranchNameKey.create(rsrc.getNameKey(), sourceRef.getName()));
+        }
+
+        RefUpdate u = repo.updateRef(ref);
+        u.setExpectedOldObjectId(ObjectId.zeroId());
+        u.setNewObjectId(object.copy());
+        u.setRefLogIdent(identifiedUser.get().newRefLogIdent());
+        u.setRefLogMessage("created via REST from " + input.revision, false);
+        refCreationValidator.validateRefOperation(
+            rsrc.getName(),
+            identifiedUser.get(),
+            u,
+            ValidationOptionsUtil.getValidateOptionsAsMultimap(input.validationOptions));
+        RefUpdate.Result result = u.update(rw);
+        switch (result) {
+          case FAST_FORWARD:
+          case NEW:
+          case NO_CHANGE:
+            referenceUpdated.fire(
+                name.project(), u, ReceiveCommand.Type.CREATE, identifiedUser.get().state());
+            break;
+          case LOCK_FAILURE:
+            if (repo.getRefDatabase().exactRef(ref) != null) {
+              throw new ResourceConflictException("branch \"" + ref + "\" already exists");
             }
-            refPrefix = RefUtil.getRefPrefix(refPrefix);
-          }
-          throw new LockFailureException(String.format("Failed to create %s", ref), u);
-        case FORCED:
-        case IO_FAILURE:
-        case NOT_ATTEMPTED:
-        case REJECTED:
-        case REJECTED_CURRENT_BRANCH:
-        case RENAMED:
-        case REJECTED_MISSING_OBJECT:
-        case REJECTED_OTHER_REASON:
-        default:
-          throw new IOException(String.format("Failed to create %s: %s", ref, result.name()));
-      }
+            String refPrefix = RefUtil.getRefPrefix(ref);
+            while (!Constants.R_HEADS.equals(refPrefix)) {
+              if (repo.getRefDatabase().exactRef(refPrefix) != null) {
+                throw new ResourceConflictException(
+                    "Cannot create branch \""
+                        + ref
+                        + "\" since it conflicts with branch \""
+                        + refPrefix
+                        + "\".");
+              }
+              refPrefix = RefUtil.getRefPrefix(refPrefix);
+            }
+            throw new LockFailureException(String.format("Failed to create %s", ref), u);
+          case FORCED:
+          case IO_FAILURE:
+          case NOT_ATTEMPTED:
+          case REJECTED:
+          case REJECTED_CURRENT_BRANCH:
+          case RENAMED:
+          case REJECTED_MISSING_OBJECT:
+          case REJECTED_OTHER_REASON:
+          default:
+            throw new IOException(String.format("Failed to create %s: %s", ref, result.name()));
+        }
 
-      BranchInfo info = new BranchInfo();
-      info.ref = ref;
-      info.revision = revid.getName();
+        BranchInfo info = new BranchInfo();
+        info.ref = ref;
+        info.revision = revid.getName();
 
-      if (isConfigRef(name.branch())) {
-        // Never allow to delete the meta config branch.
-        info.canDelete = null;
-      } else {
-        info.canDelete =
-            permissionBackend.currentUser().ref(name).testOrFalse(RefPermission.DELETE)
-                    && rsrc.getProjectState().statePermitsWrite()
-                ? true
-                : null;
+        if (isConfigRef(name.branch())) {
+          // Never allow to delete the meta config branch.
+          info.canDelete = null;
+        } else {
+          info.canDelete =
+              permissionBackend.currentUser().ref(name).testOrFalse(RefPermission.DELETE)
+                      && rsrc.getProjectState().statePermitsWrite()
+                  ? true
+                  : null;
+        }
+        return Response.created(info);
       }
-      return Response.created(info);
     }
   }
 
@@ -220,18 +222,4 @@
   private boolean isBranchAllowed(String branch) {
     return !RefNames.isGerritRef(branch) && !branch.startsWith(RefNames.REFS_TAGS);
   }
-
-  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();
-  }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/CreateChange.java b/java/com/google/gerrit/server/restapi/project/CreateChange.java
index 59efd06..e30759d 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateChange.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateChange.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.restapi.project;
 
 import com.google.common.base.Strings;
+import com.google.gerrit.entities.ProjectUtil;
 import com.google.gerrit.exceptions.InvalidNameException;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ChangeInput;
@@ -59,7 +60,8 @@
       throw new AuthException("Authentication required");
     }
 
-    if (!Strings.isNullOrEmpty(input.project) && !rsrc.getName().equals(input.project)) {
+    if (!Strings.isNullOrEmpty(input.project)
+        && !rsrc.getName().equals(ProjectUtil.sanitizeProjectName(input.project))) {
       throw new BadRequestException("project must match URL");
     }
 
diff --git a/java/com/google/gerrit/server/restapi/project/CreateProject.java b/java/com/google/gerrit/server/restapi/project/CreateProject.java
index 8203346..cfdadd9 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateProject.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateProject.java
@@ -21,6 +21,7 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Lists;
 import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.entities.ProjectUtil;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
 import com.google.gerrit.extensions.api.projects.ConfigInput;
@@ -35,7 +36,6 @@
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestCollectionCreateView;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
-import com.google.gerrit.server.ProjectUtil;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.GerritServerConfig;
diff --git a/java/com/google/gerrit/server/restapi/project/CreateTag.java b/java/com/google/gerrit/server/restapi/project/CreateTag.java
index 63734bb..b12ceef 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateTag.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateTag.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.restapi.project;
 
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.TAG_MODIFICATION;
 import static org.eclipse.jgit.lib.Constants.R_TAGS;
 
 import com.google.common.base.Strings;
@@ -28,6 +29,7 @@
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestCollectionCreateView;
+import com.google.gerrit.git.LockFailureException;
 import com.google.gerrit.server.WebLinks;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.GitRepositoryManager;
@@ -39,6 +41,7 @@
 import com.google.gerrit.server.project.ProjectResource;
 import com.google.gerrit.server.project.RefUtil;
 import com.google.gerrit.server.project.TagResource;
+import com.google.gerrit.server.update.context.RefUpdateContext;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -46,6 +49,7 @@
 import java.time.ZoneId;
 import org.eclipse.jgit.api.Git;
 import org.eclipse.jgit.api.TagCommand;
+import org.eclipse.jgit.api.errors.ConcurrentRefUpdateException;
 import org.eclipse.jgit.api.errors.GitAPIException;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.ObjectId;
@@ -97,64 +101,71 @@
     ref = RefUtil.normalizeTagRef(ref);
     PermissionBackend.ForRef perm =
         permissionBackend.currentUser().project(resource.getNameKey()).ref(ref);
+    try (RefUpdateContext ctx = RefUpdateContext.open(TAG_MODIFICATION)) {
+      try (Repository repo = repoManager.openRepository(resource.getNameKey())) {
+        ObjectId revid = RefUtil.parseBaseRevision(repo, input.revision);
+        RevWalk rw = RefUtil.verifyConnected(repo, revid);
+        // Reachability through tags does not influence a commit's visibility, so no need to check
+        // for
+        // visibility.
+        RevObject object = rw.parseAny(revid);
+        rw.reset();
+        boolean isAnnotated = Strings.emptyToNull(input.message) != null;
+        boolean isSigned = isAnnotated && input.message.contains("-----BEGIN PGP SIGNATURE-----\n");
+        if (isSigned) {
+          throw new MethodNotAllowedException("Cannot create signed tag \"" + ref + "\"");
+        } else if (isAnnotated) {
+          if (!check(perm, RefPermission.CREATE_TAG)) {
+            throw new AuthException("Cannot create annotated tag \"" + ref + "\"");
+          }
 
-    try (Repository repo = repoManager.openRepository(resource.getNameKey())) {
-      ObjectId revid = RefUtil.parseBaseRevision(repo, input.revision);
-      RevWalk rw = RefUtil.verifyConnected(repo, revid);
-      // Reachability through tags does not influence a commit's visibility, so no need to check for
-      // visibility.
-      RevObject object = rw.parseAny(revid);
-      rw.reset();
-      boolean isAnnotated = Strings.emptyToNull(input.message) != null;
-      boolean isSigned = isAnnotated && input.message.contains("-----BEGIN PGP SIGNATURE-----\n");
-      if (isSigned) {
-        throw new MethodNotAllowedException("Cannot create signed tag \"" + ref + "\"");
-      } else if (isAnnotated) {
-        if (!check(perm, RefPermission.CREATE_TAG)) {
-          throw new AuthException("Cannot create annotated tag \"" + ref + "\"");
+        } else {
+          perm.check(RefPermission.CREATE);
+        }
+        if (repo.getRefDatabase().exactRef(ref) != null) {
+          throw new ResourceConflictException("tag \"" + ref + "\" already exists");
         }
 
-      } else {
-        perm.check(RefPermission.CREATE);
-      }
-      if (repo.getRefDatabase().exactRef(ref) != null) {
-        throw new ResourceConflictException("tag \"" + ref + "\" already exists");
-      }
+        try (Git git = new Git(repo)) {
+          TagCommand tag =
+              git.tag()
+                  .setObjectId(object)
+                  .setName(ref.substring(R_TAGS.length()))
+                  .setAnnotated(isAnnotated)
+                  .setSigned(isSigned);
 
-      try (Git git = new Git(repo)) {
-        TagCommand tag =
-            git.tag()
-                .setObjectId(object)
-                .setName(ref.substring(R_TAGS.length()))
-                .setAnnotated(isAnnotated)
-                .setSigned(isSigned);
+          if (isAnnotated) {
+            tag.setMessage(input.message)
+                .setTagger(
+                    resource
+                        .getUser()
+                        .asIdentifiedUser()
+                        .newCommitterIdent(TimeUtil.now(), ZoneId.systemDefault()));
+          }
 
-        if (isAnnotated) {
-          tag.setMessage(input.message)
-              .setTagger(
-                  resource
-                      .getUser()
-                      .asIdentifiedUser()
-                      .newCommitterIdent(TimeUtil.now(), ZoneId.systemDefault()));
+          try {
+            Ref result = tag.call();
+            tagCache.updateFastForward(
+                resource.getNameKey(), ref, ObjectId.zeroId(), result.getObjectId());
+            referenceUpdated.fire(
+                resource.getNameKey(),
+                ref,
+                ObjectId.zeroId(),
+                result.getObjectId(),
+                resource.getUser().asIdentifiedUser().state());
+            try (RevWalk w = new RevWalk(repo)) {
+              return Response.created(
+                  ListTags.createTagInfo(perm, result, w, resource.getProjectState(), links));
+            }
+          } catch (ConcurrentRefUpdateException e) {
+            LockFailureException.throwIfLockFailure(e);
+            throw e;
+          }
         }
-
-        Ref result = tag.call();
-        tagCache.updateFastForward(
-            resource.getNameKey(), ref, ObjectId.zeroId(), result.getObjectId());
-        referenceUpdated.fire(
-            resource.getNameKey(),
-            ref,
-            ObjectId.zeroId(),
-            result.getObjectId(),
-            resource.getUser().asIdentifiedUser().state());
-        try (RevWalk w = new RevWalk(repo)) {
-          return Response.created(
-              ListTags.createTagInfo(perm, result, w, resource.getProjectState(), links));
-        }
+      } catch (GitAPIException e) {
+        logger.atSevere().withCause(e).log("Cannot create tag \"%s\"", ref);
+        throw new IOException(e);
       }
-    } catch (GitAPIException e) {
-      logger.atSevere().withCause(e).log("Cannot create tag \"%s\"", ref);
-      throw new IOException(e);
     }
   }
 
diff --git a/java/com/google/gerrit/server/restapi/project/DashboardsCollection.java b/java/com/google/gerrit/server/restapi/project/DashboardsCollection.java
index ca48109..455358a 100644
--- a/java/com/google/gerrit/server/restapi/project/DashboardsCollection.java
+++ b/java/com/google/gerrit/server/restapi/project/DashboardsCollection.java
@@ -225,6 +225,7 @@
     return info;
   }
 
+  @Nullable
   private static String replace(String project, String input) {
     return input == null ? input : input.replace("${project}", project);
   }
diff --git a/java/com/google/gerrit/server/restapi/project/DeleteBranch.java b/java/com/google/gerrit/server/restapi/project/DeleteBranch.java
index 6248a61..227a01b0 100644
--- a/java/com/google/gerrit/server/restapi/project/DeleteBranch.java
+++ b/java/com/google/gerrit/server/restapi/project/DeleteBranch.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.restapi.project;
 
 import static com.google.gerrit.entities.RefNames.isConfigRef;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.BRANCH_MODIFICATION;
 import static org.eclipse.jgit.lib.Constants.R_HEADS;
 
 import com.google.gerrit.entities.RefNames;
@@ -27,6 +28,7 @@
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.BranchResource;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
+import com.google.gerrit.server.update.context.RefUpdateContext;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -58,8 +60,9 @@
     if (!queryProvider.get().setLimit(1).byBranchOpen(rsrc.getBranchKey()).isEmpty()) {
       throw new ResourceConflictException("branch " + rsrc.getBranchKey() + " has open changes");
     }
-
-    deleteRef.deleteSingleRef(rsrc.getProjectState(), rsrc.getRef(), R_HEADS);
+    try (RefUpdateContext ctx = RefUpdateContext.open(BRANCH_MODIFICATION)) {
+      deleteRef.deleteSingleRef(rsrc.getProjectState(), rsrc.getRef(), R_HEADS);
+    }
     return Response.none();
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/DeleteBranches.java b/java/com/google/gerrit/server/restapi/project/DeleteBranches.java
index ca5962e..a1b5f81e 100644
--- a/java/com/google/gerrit/server/restapi/project/DeleteBranches.java
+++ b/java/com/google/gerrit/server/restapi/project/DeleteBranches.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.restapi.project;
 
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.BRANCH_MODIFICATION;
 import static org.eclipse.jgit.lib.Constants.R_HEADS;
 
 import com.google.common.collect.ImmutableSet;
@@ -26,6 +27,7 @@
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ProjectResource;
+import com.google.gerrit.server.update.context.RefUpdateContext;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
@@ -52,9 +54,10 @@
       // Never allow to delete the meta config branch.
       throw new MethodNotAllowedException("not allowed to delete branch " + RefNames.REFS_CONFIG);
     }
-
-    deleteRef.deleteMultipleRefs(
-        project.getProjectState(), ImmutableSet.copyOf(input.branches), R_HEADS);
+    try (RefUpdateContext ctx = RefUpdateContext.open(BRANCH_MODIFICATION)) {
+      deleteRef.deleteMultipleRefs(
+          project.getProjectState(), ImmutableSet.copyOf(input.branches), R_HEADS);
+    }
     return Response.none();
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/DeleteRef.java b/java/com/google/gerrit/server/restapi/project/DeleteRef.java
index 1e351f8..388946e 100644
--- a/java/com/google/gerrit/server/restapi/project/DeleteRef.java
+++ b/java/com/google/gerrit/server/restapi/project/DeleteRef.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.gerrit.entities.RefNames.isConfigRef;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.BRANCH_MODIFICATION;
 import static java.lang.String.format;
 import static org.eclipse.jgit.lib.Constants.R_REFS;
 import static org.eclipse.jgit.lib.Constants.R_TAGS;
@@ -41,6 +42,7 @@
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.project.RefValidationHelper;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
+import com.google.gerrit.server.update.context.RefUpdateContext;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -102,59 +104,61 @@
    */
   public void deleteSingleRef(ProjectState projectState, String ref, @Nullable String prefix)
       throws IOException, ResourceConflictException, AuthException, PermissionBackendException {
-    if (prefix != null && !ref.startsWith(R_REFS)) {
-      ref = prefix + ref;
-    }
-
-    projectState.checkStatePermitsWrite();
-    permissionBackend
-        .currentUser()
-        .project(projectState.getNameKey())
-        .ref(ref)
-        .check(RefPermission.DELETE);
-
-    try (Repository repository = repoManager.openRepository(projectState.getNameKey())) {
-      Ref refObj = repository.exactRef(ref);
-      if (refObj == null) {
-        throw new ResourceConflictException(String.format("ref %s doesn't exist", ref));
+    try (RefUpdateContext ctx = RefUpdateContext.open(BRANCH_MODIFICATION)) {
+      if (prefix != null && !ref.startsWith(R_REFS)) {
+        ref = prefix + ref;
       }
-      RefUpdate u = repository.updateRef(ref);
-      u.setExpectedOldObjectId(refObj.getObjectId());
-      u.setNewObjectId(ObjectId.zeroId());
-      u.setForceUpdate(true);
-      refDeletionValidator.validateRefOperation(
-          projectState.getName(),
-          identifiedUser.get(),
-          u,
-          /* pushOptions */ ImmutableListMultimap.of());
-      RefUpdate.Result result = u.delete();
 
-      switch (result) {
-        case NEW:
-        case NO_CHANGE:
-        case FAST_FORWARD:
-        case FORCED:
-          referenceUpdated.fire(
-              projectState.getNameKey(),
-              u,
-              ReceiveCommand.Type.DELETE,
-              identifiedUser.get().state());
-          break;
+      projectState.checkStatePermitsWrite();
+      permissionBackend
+          .currentUser()
+          .project(projectState.getNameKey())
+          .ref(ref)
+          .check(RefPermission.DELETE);
 
-        case REJECTED_CURRENT_BRANCH:
-          logger.atFine().log("Cannot delete current branch %s: %s", ref, result.name());
-          throw new ResourceConflictException("cannot delete current branch");
+      try (Repository repository = repoManager.openRepository(projectState.getNameKey())) {
+        Ref refObj = repository.exactRef(ref);
+        if (refObj == null) {
+          throw new ResourceConflictException(String.format("ref %s doesn't exist", ref));
+        }
+        RefUpdate u = repository.updateRef(ref);
+        u.setExpectedOldObjectId(refObj.getObjectId());
+        u.setNewObjectId(ObjectId.zeroId());
+        u.setForceUpdate(true);
+        refDeletionValidator.validateRefOperation(
+            projectState.getName(),
+            identifiedUser.get(),
+            u,
+            /* pushOptions */ ImmutableListMultimap.of());
+        RefUpdate.Result result = u.delete();
 
-        case LOCK_FAILURE:
-          throw new LockFailureException(String.format("Cannot delete %s", ref), u);
-        case IO_FAILURE:
-        case NOT_ATTEMPTED:
-        case REJECTED:
-        case RENAMED:
-        case REJECTED_MISSING_OBJECT:
-        case REJECTED_OTHER_REASON:
-        default:
-          throw new StorageException(String.format("Cannot delete %s: %s", ref, result.name()));
+        switch (result) {
+          case NEW:
+          case NO_CHANGE:
+          case FAST_FORWARD:
+          case FORCED:
+            referenceUpdated.fire(
+                projectState.getNameKey(),
+                u,
+                ReceiveCommand.Type.DELETE,
+                identifiedUser.get().state());
+            break;
+
+          case REJECTED_CURRENT_BRANCH:
+            logger.atFine().log("Cannot delete current branch %s: %s", ref, result.name());
+            throw new ResourceConflictException("cannot delete current branch");
+
+          case LOCK_FAILURE:
+            throw new LockFailureException(String.format("Cannot delete %s", ref), u);
+          case IO_FAILURE:
+          case NOT_ATTEMPTED:
+          case REJECTED:
+          case RENAMED:
+          case REJECTED_MISSING_OBJECT:
+          case REJECTED_OTHER_REASON:
+          default:
+            throw new StorageException(String.format("Cannot delete %s: %s", ref, result.name()));
+        }
       }
     }
   }
diff --git a/java/com/google/gerrit/server/restapi/project/DeleteTag.java b/java/com/google/gerrit/server/restapi/project/DeleteTag.java
index 8d0a3d3..e22c90f 100644
--- a/java/com/google/gerrit/server/restapi/project/DeleteTag.java
+++ b/java/com/google/gerrit/server/restapi/project/DeleteTag.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.restapi.project;
 
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.TAG_MODIFICATION;
+
 import com.google.common.base.Preconditions;
 import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.restapi.Response;
@@ -22,6 +24,7 @@
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.RefUtil;
 import com.google.gerrit.server.project.TagResource;
+import com.google.gerrit.server.update.context.RefUpdateContext;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
@@ -44,7 +47,9 @@
 
     Preconditions.checkState(tag.startsWith(Constants.R_TAGS));
 
-    deleteRef.deleteSingleRef(resource.getProjectState(), tag);
+    try (RefUpdateContext ctx = RefUpdateContext.open(TAG_MODIFICATION)) {
+      deleteRef.deleteSingleRef(resource.getProjectState(), tag);
+    }
     return Response.none();
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/DeleteTags.java b/java/com/google/gerrit/server/restapi/project/DeleteTags.java
index 7ac3aff..a015d2b 100644
--- a/java/com/google/gerrit/server/restapi/project/DeleteTags.java
+++ b/java/com/google/gerrit/server/restapi/project/DeleteTags.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.restapi.project;
 
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.TAG_MODIFICATION;
 import static org.eclipse.jgit.lib.Constants.R_TAGS;
 
 import com.google.common.collect.ImmutableSet;
@@ -24,6 +25,7 @@
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ProjectResource;
+import com.google.gerrit.server.update.context.RefUpdateContext;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
@@ -43,12 +45,14 @@
     if (input == null || input.tags == null || input.tags.isEmpty()) {
       throw new BadRequestException("tags must be specified");
     }
-
-    // If input.tags = ["refs/heads/bla"], this will actually delete the 'ref/heads/bla' branch,
-    // rather than refs/tags/refs/heads/bla.
-    // Since this is checked against DELETE permissions for refs/heads/bla, we'll let it go through.
-    deleteRef.deleteMultipleRefs(
-        project.getProjectState(), ImmutableSet.copyOf(input.tags), R_TAGS);
+    try (RefUpdateContext ctx = RefUpdateContext.open(TAG_MODIFICATION)) {
+      // If input.tags = ["refs/heads/bla"], this will actually delete the 'ref/heads/bla' branch,
+      // rather than refs/tags/refs/heads/bla.
+      // Since this is checked against DELETE permissions for refs/heads/bla, we'll let it go
+      // through.
+      deleteRef.deleteMultipleRefs(
+          project.getProjectState(), ImmutableSet.copyOf(input.tags), R_TAGS);
+    }
     return Response.none();
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/GetAccess.java b/java/com/google/gerrit/server/restapi/project/GetAccess.java
index 651e7f0..e1a3c0c 100644
--- a/java/com/google/gerrit/server/restapi/project/GetAccess.java
+++ b/java/com/google/gerrit/server/restapi/project/GetAccess.java
@@ -26,6 +26,7 @@
 import com.google.common.collect.ImmutableBiMap;
 import com.google.common.collect.Iterables;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.AccessSection;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.GroupDescription;
@@ -340,6 +341,7 @@
     return accessSectionInfo;
   }
 
+  @Nullable
   private static Boolean toBoolean(boolean value) {
     return value ? true : null;
   }
diff --git a/java/com/google/gerrit/server/restapi/project/ProjectsCollection.java b/java/com/google/gerrit/server/restapi/project/ProjectsCollection.java
index 6174798..f9602bc 100644
--- a/java/com/google/gerrit/server/restapi/project/ProjectsCollection.java
+++ b/java/com/google/gerrit/server/restapi/project/ProjectsCollection.java
@@ -18,6 +18,7 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.ProjectUtil;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
@@ -32,7 +33,6 @@
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.json.OutputFormat;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.ProjectUtil;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.permissions.ProjectPermission;
diff --git a/java/com/google/gerrit/server/restapi/project/PutConfig.java b/java/com/google/gerrit/server/restapi/project/PutConfig.java
index d4077c8..d4b30c2 100644
--- a/java/com/google/gerrit/server/restapi/project/PutConfig.java
+++ b/java/com/google/gerrit/server/restapi/project/PutConfig.java
@@ -313,7 +313,7 @@
           cfg.setString(COMMENTLINK, name, KEY_TEXT, value.text);
         }
         cfg.setBoolean(COMMENTLINK, name, KEY_ENABLED, value.enabled == null || value.enabled);
-        projectConfig.addCommentLinkSection(ProjectConfig.buildCommentLink(cfg, name, false));
+        projectConfig.addCommentLinkSection(ProjectConfig.buildCommentLink(cfg, name));
       } else {
         // Delete the commentlink section
         projectConfig.removeCommentLinkSection(name);
diff --git a/java/com/google/gerrit/server/restapi/project/SetAccess.java b/java/com/google/gerrit/server/restapi/project/SetAccess.java
index 23d60fe..6957275 100644
--- a/java/com/google/gerrit/server/restapi/project/SetAccess.java
+++ b/java/com/google/gerrit/server/restapi/project/SetAccess.java
@@ -19,6 +19,9 @@
 import com.google.gerrit.entities.AccessSection;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.InvalidNameException;
+import com.google.gerrit.extensions.api.access.AccessSectionInfo;
+import com.google.gerrit.extensions.api.access.PermissionInfo;
+import com.google.gerrit.extensions.api.access.PermissionRuleInfo;
 import com.google.gerrit.extensions.api.access.ProjectAccessInfo;
 import com.google.gerrit.extensions.api.access.ProjectAccessInput;
 import com.google.gerrit.extensions.restapi.BadRequestException;
@@ -39,6 +42,7 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.util.List;
+import java.util.Map;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
 @Singleton
@@ -80,6 +84,8 @@
       throws Exception {
     MetaDataUpdate.User metaDataUpdateUser = metaDataUpdateFactory.get();
 
+    validateInput(input);
+
     ProjectConfig config;
 
     List<AccessSection> removals =
@@ -137,4 +143,65 @@
 
     return Response.ok(getAccess.apply(rsrc.getNameKey()));
   }
+
+  private static void validateInput(ProjectAccessInput input) throws BadRequestException {
+    if (input.add != null) {
+      for (Map.Entry<String, AccessSectionInfo> accessSectionEntry : input.add.entrySet()) {
+        validateAccessSection(accessSectionEntry.getKey(), accessSectionEntry.getValue());
+      }
+    }
+  }
+
+  private static void validateAccessSection(String ref, AccessSectionInfo accessSectionInfo)
+      throws BadRequestException {
+    if (accessSectionInfo != null) {
+      for (Map.Entry<String, PermissionInfo> permissionEntry :
+          accessSectionInfo.permissions.entrySet()) {
+        validatePermission(ref, permissionEntry.getKey(), permissionEntry.getValue());
+      }
+    }
+  }
+
+  private static void validatePermission(
+      String ref, String permission, PermissionInfo permissionInfo) throws BadRequestException {
+    if (permissionInfo != null) {
+      for (Map.Entry<String, PermissionRuleInfo> permissionRuleEntry :
+          permissionInfo.rules.entrySet()) {
+        validatePermissionRule(
+            ref, permission, permissionRuleEntry.getKey(), permissionRuleEntry.getValue());
+      }
+    }
+  }
+
+  private static void validatePermissionRule(
+      String ref, String permission, String groupId, PermissionRuleInfo permissionRuleInfo)
+      throws BadRequestException {
+    if (permissionRuleInfo != null) {
+      if (permissionRuleInfo.min != null || permissionRuleInfo.max != null) {
+        if (permissionRuleInfo.min == null) {
+          throw new BadRequestException(
+              String.format(
+                  "Invalid range for permission rule that assigns %s to group %s on ref %s:"
+                      + " ..%d (min is required if max is set)",
+                  permission, groupId, ref, permissionRuleInfo.max));
+        }
+
+        if (permissionRuleInfo.max == null) {
+          throw new BadRequestException(
+              String.format(
+                  "Invalid range for permission rule that assigns %s to group %s on ref %s:"
+                      + " %d.. (max is required if min is set)",
+                  permission, groupId, ref, permissionRuleInfo.min));
+        }
+
+        if (permissionRuleInfo.min > permissionRuleInfo.max) {
+          throw new BadRequestException(
+              String.format(
+                  "Invalid range for permission rule that assigns %s to group %s on ref %s:"
+                      + " %d..%d (min must be <= max)",
+                  permission, groupId, ref, permissionRuleInfo.min, permissionRuleInfo.max));
+        }
+      }
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/rules/PrologRuleEvaluator.java b/java/com/google/gerrit/server/rules/PrologRuleEvaluator.java
index cab5b45..bfcbffc 100644
--- a/java/com/google/gerrit/server/rules/PrologRuleEvaluator.java
+++ b/java/com/google/gerrit/server/rules/PrologRuleEvaluator.java
@@ -51,6 +51,7 @@
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
+import java.util.Locale;
 
 /**
  * Evaluates a submit-like Prolog rule found in the rules.pl file of the current project and filters
@@ -381,7 +382,7 @@
 
     String typeName = typeTerm.name();
     try {
-      return SubmitTypeRecord.OK(SubmitType.valueOf(typeName.toUpperCase()));
+      return SubmitTypeRecord.OK(SubmitType.valueOf(typeName.toUpperCase(Locale.US)));
     } catch (IllegalArgumentException e) {
       return typeError(
           "Submit type rule "
diff --git a/java/com/google/gerrit/server/rules/RulesCache.java b/java/com/google/gerrit/server/rules/RulesCache.java
index 773c75e..167b84e 100644
--- a/java/com/google/gerrit/server/rules/RulesCache.java
+++ b/java/com/google/gerrit/server/rules/RulesCache.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.rules;
 
+import static com.google.gerrit.server.project.ProjectConfig.RULES_PL_FILE;
 import static com.googlecode.prolog_cafe.lang.PrologMachineCopy.save;
 
 import com.google.common.base.Joiner;
@@ -184,13 +185,14 @@
     // Dynamically consult the rules into the machine's internal database.
     //
     String rules = read(project, rulesId);
-    PrologMachineCopy pmc = consultRules("rules.pl", new StringReader(rules));
+    PrologMachineCopy pmc = consultRules(RULES_PL_FILE, new StringReader(rules));
     if (pmc == null) {
       throw new CompileException("Cannot consult rules of " + project);
     }
     return pmc;
   }
 
+  @Nullable
   private PrologMachineCopy consultRules(String name, Reader rules) throws CompileException {
     BufferingPrologControl ctl = newEmptyMachine(systemLoader);
     PushbackReader in = new PushbackReader(rules, Prolog.PUSHBACK_SIZE);
diff --git a/java/com/google/gerrit/server/schema/AllProjectsCreator.java b/java/com/google/gerrit/server/schema/AllProjectsCreator.java
index 9907b1c..dc83d4a 100644
--- a/java/com/google/gerrit/server/schema/AllProjectsCreator.java
+++ b/java/com/google/gerrit/server/schema/AllProjectsCreator.java
@@ -21,6 +21,7 @@
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.server.schema.AclUtil.grant;
 import static com.google.gerrit.server.schema.AclUtil.rule;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.INIT_REPO;
 
 import com.google.gerrit.common.Version;
 import com.google.gerrit.common.data.GlobalCapability;
@@ -39,6 +40,7 @@
 import com.google.gerrit.server.notedb.RepoSequence;
 import com.google.gerrit.server.notedb.Sequences;
 import com.google.gerrit.server.project.ProjectConfig;
+import com.google.gerrit.server.update.context.RefUpdateContext;
 import com.google.inject.Inject;
 import java.io.IOException;
 import org.eclipse.jgit.errors.ConfigInvalidException;
@@ -84,18 +86,20 @@
   }
 
   public void create(AllProjectsInput input) throws IOException, ConfigInvalidException {
-    try (Repository git = repositoryManager.openRepository(allProjectsName)) {
-      initAllProjects(git, input);
-    } catch (RepositoryNotFoundException notFound) {
-      // A repository may be missing if this project existed only to store
-      // inheritable permissions. For example 'All-Projects'.
-      try (Repository git = repositoryManager.createRepository(allProjectsName)) {
+    try (RefUpdateContext updCtx = RefUpdateContext.open(INIT_REPO)) {
+      try (Repository git = repositoryManager.openRepository(allProjectsName)) {
         initAllProjects(git, input);
-        RefUpdate u = git.updateRef(Constants.HEAD);
-        u.link(RefNames.REFS_CONFIG);
-      } catch (RepositoryNotFoundException err) {
-        String name = allProjectsName.get();
-        throw new IOException("Cannot create repository " + name, err);
+      } catch (RepositoryNotFoundException notFound) {
+        // A repository may be missing if this project existed only to store
+        // inheritable permissions. For example 'All-Projects'.
+        try (Repository git = repositoryManager.createRepository(allProjectsName)) {
+          initAllProjects(git, input);
+          RefUpdate u = git.updateRef(Constants.HEAD);
+          u.link(RefNames.REFS_CONFIG);
+        } catch (RepositoryNotFoundException err) {
+          String name = allProjectsName.get();
+          throw new IOException("Cannot create repository " + name, err);
+        }
       }
     }
   }
@@ -149,19 +153,16 @@
 
     config.upsertAccessSection(
         AccessSection.HEADS,
-        heads -> {
-          initDefaultAclsForRegisteredUsers(heads, codeReviewLabel, config);
-        });
+        heads -> initDefaultAclsForRegisteredUsers(heads, codeReviewLabel, config));
 
     config.upsertAccessSection(
         AccessSection.GLOBAL_CAPABILITIES,
-        capabilities -> {
-          input
-              .serviceUsersGroup()
-              .ifPresent(
-                  batchUsersGroup ->
-                      initDefaultAclsForBatchUsers(capabilities, config, batchUsersGroup));
-        });
+        capabilities ->
+            input
+                .serviceUsersGroup()
+                .ifPresent(
+                    batchUsersGroup ->
+                        initDefaultAclsForBatchUsers(capabilities, config, batchUsersGroup)));
 
     input
         .administratorsGroup()
@@ -171,16 +172,10 @@
   private void initDefaultAclsForRegisteredUsers(
       AccessSection.Builder heads, LabelType codeReviewLabel, ProjectConfig config) {
     config.upsertAccessSection(
-        "refs/for/*",
-        refsFor -> {
-          grant(config, refsFor, Permission.ADD_PATCH_SET, registered);
-        });
+        "refs/for/*", refsFor -> grant(config, refsFor, Permission.ADD_PATCH_SET, registered));
 
     config.upsertAccessSection(
-        "refs/meta/version",
-        version -> {
-          grant(config, version, Permission.READ, anonymous);
-        });
+        "refs/meta/version", version -> grant(config, version, Permission.READ, anonymous));
 
     grant(config, heads, codeReviewLabel, -1, 1, registered);
     grant(config, heads, Permission.FORGE_AUTHOR, registered);
@@ -208,15 +203,11 @@
       ProjectConfig config, LabelType codeReviewLabel, GroupReference adminsGroup) {
     config.upsertAccessSection(
         AccessSection.GLOBAL_CAPABILITIES,
-        capabilities -> {
-          grant(config, capabilities, GlobalCapability.ADMINISTRATE_SERVER, adminsGroup);
-        });
+        capabilities ->
+            grant(config, capabilities, GlobalCapability.ADMINISTRATE_SERVER, adminsGroup));
 
     config.upsertAccessSection(
-        AccessSection.ALL,
-        all -> {
-          grant(config, all, Permission.READ, adminsGroup);
-        });
+        AccessSection.ALL, all -> grant(config, all, Permission.READ, adminsGroup));
 
     config.upsertAccessSection(
         AccessSection.HEADS,
diff --git a/java/com/google/gerrit/server/schema/AllUsersCreator.java b/java/com/google/gerrit/server/schema/AllUsersCreator.java
index f2fe7f6..63fbaf9 100644
--- a/java/com/google/gerrit/server/schema/AllUsersCreator.java
+++ b/java/com/google/gerrit/server/schema/AllUsersCreator.java
@@ -18,6 +18,7 @@
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.server.schema.AclUtil.grant;
 import static com.google.gerrit.server.schema.AllProjectsInput.getDefaultCodeReviewLabel;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.INIT_REPO;
 
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.UsedAt;
@@ -35,6 +36,7 @@
 import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.RefPattern;
+import com.google.gerrit.server.update.context.RefUpdateContext;
 import com.google.inject.Inject;
 import java.io.IOException;
 import org.eclipse.jgit.errors.ConfigInvalidException;
@@ -90,16 +92,18 @@
   }
 
   public void create() throws IOException, ConfigInvalidException {
-    try (Repository git = mgr.openRepository(allUsersName)) {
-      initAllUsers(git);
-    } catch (RepositoryNotFoundException notFound) {
-      try (Repository git = mgr.createRepository(allUsersName)) {
+    try (RefUpdateContext ctx = RefUpdateContext.open(INIT_REPO)) {
+      try (Repository git = mgr.openRepository(allUsersName)) {
         initAllUsers(git);
-        RefUpdate u = git.updateRef(Constants.HEAD);
-        u.link(RefNames.REFS_CONFIG);
-      } catch (RepositoryNotFoundException err) {
-        String name = allUsersName.get();
-        throw new IOException("Cannot create repository " + name, err);
+      } catch (RepositoryNotFoundException notFound) {
+        try (Repository git = mgr.createRepository(allUsersName)) {
+          initAllUsers(git);
+          RefUpdate u = git.updateRef(Constants.HEAD);
+          u.link(RefNames.REFS_CONFIG);
+        } catch (RepositoryNotFoundException err) {
+          String name = allUsersName.get();
+          throw new IOException("Cannot create repository " + name, err);
+        }
       }
     }
   }
diff --git a/java/com/google/gerrit/server/schema/MigrateLabelFunctionsToSubmitRequirement.java b/java/com/google/gerrit/server/schema/MigrateLabelFunctionsToSubmitRequirement.java
new file mode 100644
index 0000000..2ca79342
--- /dev/null
+++ b/java/com/google/gerrit/server/schema/MigrateLabelFunctionsToSubmitRequirement.java
@@ -0,0 +1,469 @@
+// 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 com.google.gerrit.server.project.ProjectConfig.RULES_PL_FILE;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.LabelFunction;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementExpression;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.meta.MetaDataUpdate;
+import com.google.gerrit.server.project.ProjectConfig;
+import com.google.gerrit.server.project.ProjectLevelConfig;
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Optional;
+import java.util.stream.Collectors;
+import javax.inject.Inject;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.treewalk.TreeWalk;
+
+/**
+ * A class with logic for migrating existing label functions to submit requirements and resetting
+ * the label functions to {@link LabelFunction#NO_BLOCK}.
+ *
+ * <p>Important note: Callers should do this migration only if this gerrit installation has no
+ * Prolog submit rules (i.e. no rules.pl file in refs/meta/config). Otherwise, the newly created
+ * submit requirements might not behave as intended.
+ *
+ * <p>The conversion is done as follows:
+ *
+ * <ul>
+ *   <li>MaxWithBlock is translated to submittableIf = label:$lbl=MAX AND -label:$lbl=MIN
+ *   <li>MaxNoBlock is translated to submittableIf = label:$lbl=MAX
+ *   <li>AnyWithBlock is translated to submittableIf = -label:$lbl=MIN
+ *   <li>NoBlock/NoOp are translated to applicableIf = is:false (not applicable)
+ *   <li>PatchSetLock labels are left as is
+ * </ul>
+ *
+ * If the label has {@link LabelType#isIgnoreSelfApproval()}, the max vote is appended with the
+ * 'user=non_uploader' argument.
+ *
+ * <p>For labels that were skipped, i.e. had only one "zero" predefined value, the migrator creates
+ * a non-applicable submit-requirement for them. This is done so that if a parent project had a
+ * submit-requirement with the same name, then it's not inherited by this project.
+ *
+ * <p>If there is an existing label and there exists a "submit requirement" with the same name, the
+ * migrator checks if the submit-requirement to be created matches the one in project.config. If
+ * they don't match, a warning message is printed, otherwise nothing happens. In either cases, the
+ * existing submit-requirement is not altered.
+ */
+public class MigrateLabelFunctionsToSubmitRequirement {
+  public static final String COMMIT_MSG = "Migrate label functions to submit requirements";
+  private final GitRepositoryManager repoManager;
+  private final PersonIdent serverUser;
+
+  public enum Status {
+    /**
+     * The migrator updated the project config and created new submit requirements and/or did reset
+     * label functions.
+     */
+    MIGRATED,
+
+    /** The project had prolog rules, and the migration was skipped. */
+    HAS_PROLOG,
+
+    /**
+     * The project was migrated with a previous run of this class. The migration for this run was
+     * skipped.
+     */
+    PREVIOUSLY_MIGRATED,
+
+    /**
+     * Migration was run for the project but did not update the project.config because it was
+     * up-to-date.
+     */
+    NO_CHANGE
+  }
+
+  @Inject
+  public MigrateLabelFunctionsToSubmitRequirement(
+      GitRepositoryManager repoManager, @GerritPersonIdent PersonIdent serverUser) {
+    this.repoManager = repoManager;
+    this.serverUser = serverUser;
+  }
+
+  /**
+   * For each label function, create a corresponding submit-requirement and set the label function
+   * to NO_BLOCK. Blocking label functions are substituted with blocking submit-requirements.
+   * Non-blocking label functions are substituted with non-applicable submit requirements, allowing
+   * the label vote to be surfaced as a trigger vote (optional label).
+   *
+   * @return {@link Status} reflecting the status of the migration.
+   */
+  public Status executeMigration(Project.NameKey project, UpdateUI ui)
+      throws IOException, ConfigInvalidException {
+    if (hasPrologRules(project)) {
+      ui.message(String.format("Skipping project %s because it has prolog rules", project));
+      return Status.HAS_PROLOG;
+    }
+    ProjectLevelConfig.Bare projectConfig =
+        new ProjectLevelConfig.Bare(ProjectConfig.PROJECT_CONFIG);
+    boolean migrationPerformed = false;
+    try (Repository repo = repoManager.openRepository(project);
+        MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED, project, repo)) {
+      if (hasMigrationAlreadyRun(repo)) {
+        ui.message(
+            String.format(
+                "Skipping migrating label functions to submit requirements for project '%s'"
+                    + " because it has been previously migrated",
+                project));
+        return Status.PREVIOUSLY_MIGRATED;
+      }
+      projectConfig.load(project, repo);
+      Config cfg = projectConfig.getConfig();
+      Map<String, LabelAttributes> labelTypes = getLabelTypes(cfg);
+      Map<String, SubmitRequirement> existingSubmitRequirements = loadSubmitRequirements(cfg);
+      boolean updated = false;
+      for (Map.Entry<String, LabelAttributes> lt : labelTypes.entrySet()) {
+        String labelName = lt.getKey();
+        LabelAttributes attributes = lt.getValue();
+        if (attributes.function().equals("PatchSetLock")) {
+          // PATCH_SET_LOCK functions should be left as is
+          continue;
+        }
+        // If the function is other than "NoBlock" we want to reset the label function regardless
+        // of whether there exists a "submit requirement".
+        if (!attributes.function().equals("NoBlock")) {
+          updated = true;
+          writeLabelFunction(cfg, labelName, "NoBlock");
+        }
+        Optional<SubmitRequirement> sr = createSrFromLabelDef(labelName, attributes);
+        if (!sr.isPresent()) {
+          continue;
+        }
+        // Make the operation idempotent by skipping creating the submit-requirement if one was
+        // already created or previously existed.
+        if (existingSubmitRequirements.containsKey(labelName.toLowerCase(Locale.ROOT))) {
+          if (!sr.get()
+              .equals(existingSubmitRequirements.get(labelName.toLowerCase(Locale.ROOT)))) {
+            ui.message(
+                String.format(
+                    "Warning: Skipping creating a submit requirement for label '%s'. An existing "
+                        + "submit requirement is already present but its definition is not "
+                        + "identical to the existing label definition.",
+                    labelName));
+          }
+          continue;
+        }
+        updated = true;
+        ui.message(
+            String.format(
+                "Project %s: Creating a submit requirement for label %s", project, labelName));
+        writeSubmitRequirement(cfg, sr.get());
+      }
+      if (updated) {
+        commit(projectConfig, md);
+        migrationPerformed = true;
+      }
+    }
+    return migrationPerformed ? Status.MIGRATED : Status.NO_CHANGE;
+  }
+
+  /**
+   * Returns a Map containing label names as string in keys along with some of its attributes (that
+   * we need in the migration) like canOverride, ignoreSelfApproval and function in the value.
+   */
+  private Map<String, LabelAttributes> getLabelTypes(Config cfg) {
+    Map<String, LabelAttributes> labelTypes = new HashMap<>();
+    for (String labelName : cfg.getSubsections(ProjectConfig.LABEL)) {
+      String function = cfg.getString(ProjectConfig.LABEL, labelName, ProjectConfig.KEY_FUNCTION);
+      boolean canOverride =
+          cfg.getBoolean(
+              ProjectConfig.LABEL,
+              labelName,
+              ProjectConfig.KEY_CAN_OVERRIDE,
+              /* defaultValue= */ true);
+      boolean ignoreSelfApproval =
+          cfg.getBoolean(
+              ProjectConfig.LABEL,
+              labelName,
+              ProjectConfig.KEY_IGNORE_SELF_APPROVAL,
+              /* defaultValue= */ false);
+      ImmutableList<String> values =
+          ImmutableList.<String>builder()
+              .addAll(
+                  Arrays.asList(
+                      cfg.getStringList(ProjectConfig.LABEL, labelName, ProjectConfig.KEY_VALUE)))
+              .build();
+      ImmutableList<String> refPatterns =
+          ImmutableList.<String>builder()
+              .addAll(
+                  Arrays.asList(
+                      cfg.getStringList(ProjectConfig.LABEL, labelName, ProjectConfig.KEY_BRANCH)))
+              .build();
+      LabelAttributes attributes =
+          LabelAttributes.create(
+              function == null ? "MaxWithBlock" : function,
+              canOverride,
+              ignoreSelfApproval,
+              values,
+              refPatterns);
+      labelTypes.put(labelName, attributes);
+    }
+    return labelTypes;
+  }
+
+  private void writeSubmitRequirement(Config cfg, SubmitRequirement sr) {
+    if (sr.description().isPresent()) {
+      cfg.setString(
+          ProjectConfig.SUBMIT_REQUIREMENT,
+          sr.name(),
+          ProjectConfig.KEY_SR_DESCRIPTION,
+          sr.description().get());
+    }
+    if (sr.applicabilityExpression().isPresent()) {
+      cfg.setString(
+          ProjectConfig.SUBMIT_REQUIREMENT,
+          sr.name(),
+          ProjectConfig.KEY_SR_APPLICABILITY_EXPRESSION,
+          sr.applicabilityExpression().get().expressionString());
+    }
+    cfg.setString(
+        ProjectConfig.SUBMIT_REQUIREMENT,
+        sr.name(),
+        ProjectConfig.KEY_SR_SUBMITTABILITY_EXPRESSION,
+        sr.submittabilityExpression().expressionString());
+    if (sr.overrideExpression().isPresent()) {
+      cfg.setString(
+          ProjectConfig.SUBMIT_REQUIREMENT,
+          sr.name(),
+          ProjectConfig.KEY_SR_OVERRIDE_EXPRESSION,
+          sr.overrideExpression().get().expressionString());
+    }
+    cfg.setBoolean(
+        ProjectConfig.SUBMIT_REQUIREMENT,
+        sr.name(),
+        ProjectConfig.KEY_SR_OVERRIDE_IN_CHILD_PROJECTS,
+        sr.allowOverrideInChildProjects());
+  }
+
+  private void writeLabelFunction(Config cfg, String labelName, String function) {
+    cfg.setString(ProjectConfig.LABEL, labelName, ProjectConfig.KEY_FUNCTION, function);
+  }
+
+  private void commit(ProjectLevelConfig.Bare projectConfig, MetaDataUpdate md) throws IOException {
+    md.getCommitBuilder().setAuthor(serverUser);
+    md.getCommitBuilder().setCommitter(serverUser);
+    md.setMessage(COMMIT_MSG);
+    projectConfig.commit(md);
+  }
+
+  private static Optional<SubmitRequirement> createSrFromLabelDef(
+      String labelName, LabelAttributes attributes) {
+    if (isLabelSkipped(attributes.values())) {
+      return Optional.of(createNonApplicableSr(labelName, attributes.canOverride()));
+    } else if (isBlockingOrRequiredLabel(attributes.function())) {
+      return Optional.of(createBlockingOrRequiredSr(labelName, attributes));
+    }
+    return Optional.empty();
+  }
+
+  private static SubmitRequirement createNonApplicableSr(String labelName, boolean canOverride) {
+    return SubmitRequirement.builder()
+        .setName(labelName)
+        .setApplicabilityExpression(SubmitRequirementExpression.of("is:false"))
+        .setSubmittabilityExpression(SubmitRequirementExpression.create("is:true"))
+        .setAllowOverrideInChildProjects(canOverride)
+        .build();
+  }
+
+  /**
+   * Create a "submit requirement" that is only satisfied if the label is voted with the max votes
+   * and/or not voted by the min vote, according to the label attributes.
+   */
+  private static SubmitRequirement createBlockingOrRequiredSr(
+      String labelName, LabelAttributes attributes) {
+    SubmitRequirement.Builder builder =
+        SubmitRequirement.builder()
+            .setName(labelName)
+            .setAllowOverrideInChildProjects(attributes.canOverride());
+    String maxPart =
+        String.format("label:%s=MAX", labelName)
+            + (attributes.ignoreSelfApproval() ? ",user=non_uploader" : "");
+    switch (attributes.function()) {
+      case "MaxWithBlock":
+        builder.setSubmittabilityExpression(
+            SubmitRequirementExpression.create(
+                String.format("%s AND -label:%s=MIN", maxPart, labelName)));
+        break;
+      case "AnyWithBlock":
+        builder.setSubmittabilityExpression(
+            SubmitRequirementExpression.create(String.format("-label:%s=MIN", labelName)));
+        break;
+      case "MaxNoBlock":
+        builder.setSubmittabilityExpression(SubmitRequirementExpression.create(maxPart));
+        break;
+      default:
+        break;
+    }
+    if (!attributes.refPatterns().isEmpty()) {
+      builder.setApplicabilityExpression(
+          SubmitRequirementExpression.of(
+              String.join(
+                  " OR ",
+                  attributes.refPatterns().stream()
+                      .map(b -> "branch:\\\"" + b + "\\\"")
+                      .collect(Collectors.toList()))));
+    }
+    return builder.build();
+  }
+
+  private static boolean isBlockingOrRequiredLabel(String function) {
+    return function.equals("AnyWithBlock")
+        || function.equals("MaxWithBlock")
+        || function.equals("MaxNoBlock");
+  }
+
+  /**
+   * Returns true if the label definition was skipped in the project, i.e. it had only one defined
+   * value: zero.
+   */
+  private static boolean isLabelSkipped(List<String> values) {
+    return values.isEmpty() || (values.size() == 1 && values.get(0).startsWith("0"));
+  }
+
+  public boolean anyProjectHasProlog(Collection<Project.NameKey> allProjects) throws IOException {
+    for (Project.NameKey p : allProjects) {
+      if (hasPrologRules(p)) {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  private boolean hasPrologRules(Project.NameKey project) throws IOException {
+    try (Repository repo = repoManager.openRepository(project);
+        RevWalk rw = new RevWalk(repo);
+        ObjectReader reader = rw.getObjectReader()) {
+      Ref refsConfig = repo.exactRef(RefNames.REFS_CONFIG);
+      if (refsConfig == null) {
+        // Project does not have a refs/meta/config and no rules.pl consequently.
+        return false;
+      }
+      RevCommit commit = repo.parseCommit(refsConfig.getObjectId());
+      try (TreeWalk tw = TreeWalk.forPath(reader, RULES_PL_FILE, commit.getTree())) {
+        if (tw != null) {
+          return true;
+        }
+      }
+
+      return false;
+    }
+  }
+
+  /**
+   * Returns a map containing submit requirement names in lower name as keys, with {@link
+   * com.google.gerrit.entities.SubmitRequirement} as value.
+   */
+  private Map<String, SubmitRequirement> loadSubmitRequirements(Config rc) {
+    Map<String, SubmitRequirement> allRequirements = new LinkedHashMap<>();
+    for (String name : rc.getSubsections(ProjectConfig.SUBMIT_REQUIREMENT)) {
+      String description =
+          rc.getString(ProjectConfig.SUBMIT_REQUIREMENT, name, ProjectConfig.KEY_SR_DESCRIPTION);
+      String applicabilityExpr =
+          rc.getString(
+              ProjectConfig.SUBMIT_REQUIREMENT,
+              name,
+              ProjectConfig.KEY_SR_APPLICABILITY_EXPRESSION);
+      String submittabilityExpr =
+          rc.getString(
+              ProjectConfig.SUBMIT_REQUIREMENT,
+              name,
+              ProjectConfig.KEY_SR_SUBMITTABILITY_EXPRESSION);
+      String overrideExpr =
+          rc.getString(
+              ProjectConfig.SUBMIT_REQUIREMENT, name, ProjectConfig.KEY_SR_OVERRIDE_EXPRESSION);
+      boolean canInherit =
+          rc.getBoolean(
+              ProjectConfig.SUBMIT_REQUIREMENT,
+              name,
+              ProjectConfig.KEY_SR_OVERRIDE_IN_CHILD_PROJECTS,
+              false);
+      SubmitRequirement submitRequirement =
+          SubmitRequirement.builder()
+              .setName(name)
+              .setDescription(Optional.ofNullable(description))
+              .setApplicabilityExpression(SubmitRequirementExpression.of(applicabilityExpr))
+              .setSubmittabilityExpression(SubmitRequirementExpression.create(submittabilityExpr))
+              .setOverrideExpression(SubmitRequirementExpression.of(overrideExpr))
+              .setAllowOverrideInChildProjects(canInherit)
+              .build();
+      allRequirements.put(name.toLowerCase(Locale.ROOT), submitRequirement);
+    }
+    return allRequirements;
+  }
+
+  private static boolean hasMigrationAlreadyRun(Repository repo) throws IOException {
+    try (RevWalk revWalk = new RevWalk(repo)) {
+      Ref refsMetaConfig = repo.exactRef(RefNames.REFS_CONFIG);
+      if (refsMetaConfig == null) {
+        return false;
+      }
+      revWalk.markStart(revWalk.parseCommit(refsMetaConfig.getObjectId()));
+      RevCommit commit;
+      while ((commit = revWalk.next()) != null) {
+        if (COMMIT_MSG.equals(commit.getShortMessage())) {
+          return true;
+        }
+      }
+      return false;
+    }
+  }
+
+  @AutoValue
+  abstract static class LabelAttributes {
+    abstract String function();
+
+    abstract boolean canOverride();
+
+    abstract boolean ignoreSelfApproval();
+
+    abstract ImmutableList<String> values();
+
+    abstract ImmutableList<String> refPatterns();
+
+    static LabelAttributes create(
+        String function,
+        boolean canOverride,
+        boolean ignoreSelfApproval,
+        ImmutableList<String> values,
+        ImmutableList<String> refPatterns) {
+      return new AutoValue_MigrateLabelFunctionsToSubmitRequirement_LabelAttributes(
+          function, canOverride, ignoreSelfApproval, values, refPatterns);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/schema/NoteDbSchemaUpdater.java b/java/com/google/gerrit/server/schema/NoteDbSchemaUpdater.java
index 0e22af9..57ec7ef 100644
--- a/java/com/google/gerrit/server/schema/NoteDbSchemaUpdater.java
+++ b/java/com/google/gerrit/server/schema/NoteDbSchemaUpdater.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.schema;
 
 import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.OFFLINE_OPERATION;
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.collect.ImmutableList;
@@ -26,6 +27,7 @@
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.notedb.Sequences;
+import com.google.gerrit.server.update.context.RefUpdateContext;
 import com.google.inject.Inject;
 import java.io.IOException;
 import java.util.stream.IntStream;
@@ -87,15 +89,16 @@
       // seeded refs/meta/version during AllProjectsCreator, so it won't hit this block.
       checkNoteDbConfigFor216();
     }
-
-    for (int nextVersion : requiredUpgrades(currentVersion, schemaVersions.keySet())) {
-      try {
-        ui.message(String.format("Migrating data to schema %d ...", nextVersion));
-        NoteDbSchemaVersions.get(schemaVersions, nextVersion).upgrade(args, ui);
-        versionManager.increment(nextVersion - 1);
-      } catch (Exception e) {
-        throw new StorageException(
-            String.format("Failed to upgrade to schema version %d", nextVersion), e);
+    try (RefUpdateContext ctx = RefUpdateContext.open(OFFLINE_OPERATION)) {
+      for (int nextVersion : requiredUpgrades(currentVersion, schemaVersions.keySet())) {
+        try {
+          ui.message(String.format("Migrating data to schema %d ...", nextVersion));
+          NoteDbSchemaVersions.get(schemaVersions, nextVersion).upgrade(args, ui);
+          versionManager.increment(nextVersion - 1);
+        } catch (Exception e) {
+          throw new StorageException(
+              String.format("Failed to upgrade to schema version %d", nextVersion), e);
+        }
       }
     }
   }
diff --git a/java/com/google/gerrit/server/schema/SchemaCreatorImpl.java b/java/com/google/gerrit/server/schema/SchemaCreatorImpl.java
index 26ae4a8..38e45ab 100644
--- a/java/com/google/gerrit/server/schema/SchemaCreatorImpl.java
+++ b/java/com/google/gerrit/server/schema/SchemaCreatorImpl.java
@@ -38,6 +38,8 @@
 import com.google.gerrit.server.index.group.GroupIndex;
 import com.google.gerrit.server.index.group.GroupIndexCollection;
 import com.google.gerrit.server.notedb.Sequences;
+import com.google.gerrit.server.update.context.RefUpdateContext;
+import com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType;
 import com.google.inject.Inject;
 import java.io.IOException;
 import org.eclipse.jgit.errors.ConfigInvalidException;
@@ -91,31 +93,33 @@
 
   @Override
   public void create() throws IOException, ConfigInvalidException {
-    GroupReference admins = createGroupReference("Administrators");
-    GroupReference serviceUsers = createGroupReference(ServiceUserClassifier.SERVICE_USERS);
+    try (RefUpdateContext ctx = RefUpdateContext.open(RefUpdateType.INIT_REPO)) {
+      GroupReference admins = createGroupReference("Administrators");
+      GroupReference serviceUsers = createGroupReference(ServiceUserClassifier.SERVICE_USERS);
 
-    AllProjectsInput allProjectsInput =
-        AllProjectsInput.builder()
-            .administratorsGroup(admins)
-            .serviceUsersGroup(serviceUsers)
-            .build();
-    allProjectsCreator.create(allProjectsInput);
-    // We have to create the All-Users repository before we can use it to store the groups in it.
-    allUsersCreator.setAdministrators(admins).create();
+      AllProjectsInput allProjectsInput =
+          AllProjectsInput.builder()
+              .administratorsGroup(admins)
+              .serviceUsersGroup(serviceUsers)
+              .build();
+      allProjectsCreator.create(allProjectsInput);
+      // We have to create the All-Users repository before we can use it to store the groups in it.
+      allUsersCreator.setAdministrators(admins).create();
 
-    // Don't rely on injection to construct Sequences, as the default GitReferenceUpdated has a
-    // thick dependency stack which may not all be available at schema creation time.
-    Sequences seqs =
-        new Sequences(
-            config,
-            repoManager,
-            GitReferenceUpdated.DISABLED,
-            allProjectsName,
-            allUsersName,
-            metricMaker);
-    try (Repository allUsersRepo = repoManager.openRepository(allUsersName)) {
-      createAdminsGroup(seqs, allUsersRepo, admins);
-      createBatchUsersGroup(seqs, allUsersRepo, serviceUsers, admins.getUUID());
+      // Don't rely on injection to construct Sequences, as the default GitReferenceUpdated has a
+      // thick dependency stack which may not all be available at schema creation time.
+      Sequences seqs =
+          new Sequences(
+              config,
+              repoManager,
+              GitReferenceUpdated.DISABLED,
+              allProjectsName,
+              allUsersName,
+              metricMaker);
+      try (Repository allUsersRepo = repoManager.openRepository(allUsersName)) {
+        createAdminsGroup(seqs, allUsersRepo, admins);
+        createBatchUsersGroup(seqs, allUsersRepo, serviceUsers, admins.getUUID());
+      }
     }
   }
 
diff --git a/java/com/google/gerrit/server/schema/Schema_184.java b/java/com/google/gerrit/server/schema/Schema_184.java
index 436c57b..a7e9506 100644
--- a/java/com/google/gerrit/server/schema/Schema_184.java
+++ b/java/com/google/gerrit/server/schema/Schema_184.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.schema;
 
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.OFFLINE_OPERATION;
+
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.GroupReference;
@@ -29,6 +31,7 @@
 import com.google.gerrit.server.group.db.GroupNameNotes;
 import com.google.gerrit.server.index.group.GroupIndex;
 import com.google.gerrit.server.index.group.GroupIndexCollection;
+import com.google.gerrit.server.update.context.RefUpdateContext;
 import java.io.IOException;
 import java.util.Optional;
 import org.eclipse.jgit.lib.BatchRefUpdate;
@@ -84,17 +87,19 @@
       GroupConfig groupConfig,
       GroupNameNotes groupNameNotes)
       throws IOException {
-    BatchRefUpdate batchRefUpdate = allUsersRepo.getRefDatabase().newBatchUpdate();
-    try (MetaDataUpdate metaDataUpdate =
-        createMetaDataUpdate(allUsersName, serverUser, allUsersRepo, batchRefUpdate)) {
-      groupConfig.commit(metaDataUpdate);
+    try (RefUpdateContext ctx = RefUpdateContext.open(OFFLINE_OPERATION)) {
+      BatchRefUpdate batchRefUpdate = allUsersRepo.getRefDatabase().newBatchUpdate();
+      try (MetaDataUpdate metaDataUpdate =
+          createMetaDataUpdate(allUsersName, serverUser, allUsersRepo, batchRefUpdate)) {
+        groupConfig.commit(metaDataUpdate);
+      }
+      // MetaDataUpdates unfortunately can't be reused. -> Create a new one.
+      try (MetaDataUpdate metaDataUpdate =
+          createMetaDataUpdate(allUsersName, serverUser, allUsersRepo, batchRefUpdate)) {
+        groupNameNotes.commit(metaDataUpdate);
+      }
+      RefUpdateUtil.executeChecked(batchRefUpdate, allUsersRepo);
     }
-    // MetaDataUpdates unfortunately can't be reused. -> Create a new one.
-    try (MetaDataUpdate metaDataUpdate =
-        createMetaDataUpdate(allUsersName, serverUser, allUsersRepo, batchRefUpdate)) {
-      groupNameNotes.commit(metaDataUpdate);
-    }
-    RefUpdateUtil.executeChecked(batchRefUpdate, allUsersRepo);
   }
 
   private MetaDataUpdate createMetaDataUpdate(
diff --git a/java/com/google/gerrit/server/securestore/DefaultSecureStore.java b/java/com/google/gerrit/server/securestore/DefaultSecureStore.java
index 02ff159..37e7278 100644
--- a/java/com/google/gerrit/server/securestore/DefaultSecureStore.java
+++ b/java/com/google/gerrit/server/securestore/DefaultSecureStore.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.securestore;
 
 import com.google.gerrit.common.FileUtil;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.inject.Inject;
 import com.google.inject.ProvisionException;
@@ -54,6 +55,7 @@
     return sec.getStringList(section, subsection, name);
   }
 
+  @Nullable
   @Override
   public synchronized String[] getListForPlugin(
       String pluginName, String section, String subsection, String name) {
diff --git a/java/com/google/gerrit/server/securestore/SecureStore.java b/java/com/google/gerrit/server/securestore/SecureStore.java
index b53e38c..855c978 100644
--- a/java/com/google/gerrit/server/securestore/SecureStore.java
+++ b/java/com/google/gerrit/server/securestore/SecureStore.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.securestore;
 
 import com.google.common.collect.Lists;
+import com.google.gerrit.common.Nullable;
 import java.util.List;
 
 /**
@@ -53,6 +54,7 @@
    *
    * @return decrypted String value or {@code null} if not found
    */
+  @Nullable
   public final String get(String section, String subsection, String name) {
     String[] values = getList(section, subsection, name);
     if (values != null && values.length > 0) {
@@ -67,6 +69,7 @@
    *
    * @return decrypted String value or {@code null} if not found
    */
+  @Nullable
   public final String getForPlugin(
       String pluginName, String section, String subsection, String name) {
     String[] values = getListForPlugin(pluginName, section, subsection, name);
diff --git a/java/com/google/gerrit/server/submit/CherryPick.java b/java/com/google/gerrit/server/submit/CherryPick.java
index b218347..0471b67 100644
--- a/java/com/google/gerrit/server/submit/CherryPick.java
+++ b/java/com/google/gerrit/server/submit/CherryPick.java
@@ -19,6 +19,7 @@
 import static java.util.Objects.requireNonNull;
 
 import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.BooleanProjectConfig;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetInfo;
@@ -142,6 +143,7 @@
       patchSetInfo = args.patchSetInfoFactory.get(ctx.getRevWalk(), newCommit, psId);
     }
 
+    @Nullable
     @Override
     public PatchSet updateChangeImpl(ChangeContext ctx) throws NoSuchChangeException, IOException {
       if (newCommit == null && toMerge.getStatusCode() == SKIPPED_IDENTICAL_TREE) {
diff --git a/java/com/google/gerrit/server/submit/EmailMerge.java b/java/com/google/gerrit/server/submit/EmailMerge.java
index 3d38f6c..7aa3716 100644
--- a/java/com/google/gerrit/server/submit/EmailMerge.java
+++ b/java/com/google/gerrit/server/submit/EmailMerge.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.submit;
 
+import com.google.common.base.Strings;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Change;
@@ -93,7 +94,10 @@
     RequestContext old = requestContext.setContext(this);
     try {
       MergedSender emailSender =
-          mergedSenderFactory.create(project, change.getId(), Optional.of(stickyApprovalDiff));
+          mergedSenderFactory.create(
+              project,
+              change.getId(),
+              Optional.ofNullable(Strings.emptyToNull(stickyApprovalDiff)));
       if (submitter != null) {
         emailSender.setFrom(submitter.getAccountId());
       }
diff --git a/java/com/google/gerrit/server/submit/MergeMetrics.java b/java/com/google/gerrit/server/submit/MergeMetrics.java
new file mode 100644
index 0000000..4c11925
--- /dev/null
+++ b/java/com/google/gerrit/server/submit/MergeMetrics.java
@@ -0,0 +1,164 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.submit;
+
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.metrics.Counter0;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.MetricMaker;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.MagicLabelPredicates;
+import com.google.gerrit.server.query.change.SubmitRequirementChangeQueryBuilder;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+
+/** Metrics are recorded when a change is merged (aka submitted). */
+public class MergeMetrics {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private final Provider<SubmitRequirementChangeQueryBuilder> submitRequirementChangequeryBuilder;
+
+  // TODO: This metric is for measuring the impact of allowing users to rebase changes on behalf of
+  // the uploader. Once this feature has been rolled out and its impact as been measured, we may
+  // remove this metric.
+  private final Counter0 countChangesThatWereSubmittedWithRebaserApproval;
+
+  @Inject
+  public MergeMetrics(
+      Provider<SubmitRequirementChangeQueryBuilder> submitRequirementChangequeryBuilder,
+      MetricMaker metricMaker) {
+    this.submitRequirementChangequeryBuilder = submitRequirementChangequeryBuilder;
+
+    this.countChangesThatWereSubmittedWithRebaserApproval =
+        metricMaker.newCounter(
+            "change/submitted_with_rebaser_approval",
+            new Description(
+                    "Number of rebased changes that were submitted with a Code-Review approval of"
+                        + " the rebaser that would not have been submittable if the rebase was not"
+                        + " done on behalf of the uploader.")
+                .setRate());
+  }
+
+  public void countChangesThatWereSubmittedWithRebaserApproval(ChangeData cd) {
+    if (isRebaseOnBehalfOfUploader(cd)
+        && hasCodeReviewApprovalOfRealUploader(cd)
+        && ignoresCodeReviewApprovalsOfUploader(cd)) {
+      // 1. The patch set that is being submitted was created by rebasing on behalf of the uploader.
+      // The uploader of the patch set is the original uploader on whom's behalf the rebase was
+      // done. The real uploader is the user that did the rebase on behalf of the uploader (e.g. by
+      // clicking on the rebase button).
+      //
+      // 2. The change has Code-Review approvals of the real uploader (aka the rebaser).
+      //
+      // 3. Code-Review approvals of the uploader are ignored.
+      //
+      // If instead of a rebase on behalf of the uploader a normal rebase would have been done the
+      // rebaser would have been the uploader of the patch set. In this case the Code-Review
+      // approval of the rebaser would not have counted since Code-Review approvals of the uploader
+      // are ignored.
+      //
+      // In this case we assume that the change would not be submittable if a normal rebase had been
+      // done. This is not always correct (e.g. if there are approvals of multiple reviewers) but
+      // it's good enough for the metric.
+      countChangesThatWereSubmittedWithRebaserApproval.increment();
+    }
+  }
+
+  private boolean isRebaseOnBehalfOfUploader(ChangeData cd) {
+    // If the uploader differs from the real uploader the upload of the patch set has been
+    // impersonated. Impersonating the uploader is only allowed on rebase by rebasing on behalf of
+    // the uploader. Hence if the current patch set has different accounts as uploader and real
+    // uploader we can assume that it was created by rebase on behalf of the uploader.
+    boolean isRebaseOnBehalfOfUploader =
+        !cd.currentPatchSet().uploader().equals(cd.currentPatchSet().realUploader());
+    logger.atFine().log("isRebaseOnBehalfOfUploader = %s", isRebaseOnBehalfOfUploader);
+    return isRebaseOnBehalfOfUploader;
+  }
+
+  private boolean hasCodeReviewApprovalOfRealUploader(ChangeData cd) {
+    boolean hasCodeReviewApprovalOfRealUploader =
+        cd.currentApprovals().stream()
+            .anyMatch(psa -> psa.accountId().equals(cd.currentPatchSet().realUploader()));
+    logger.atFine().log(
+        "hasCodeReviewApprovalOfRealUploader = %s", hasCodeReviewApprovalOfRealUploader);
+    return hasCodeReviewApprovalOfRealUploader;
+  }
+
+  private boolean ignoresCodeReviewApprovalsOfUploader(ChangeData cd) {
+    for (SubmitRequirement submitRequirement : cd.submitRequirements().keySet()) {
+      try {
+        Predicate<ChangeData> predicate =
+            submitRequirementChangequeryBuilder
+                .get()
+                .parse(submitRequirement.submittabilityExpression().expressionString());
+        boolean ignoresCodeReviewApprovalsOfUploader =
+            ignoresCodeReviewApprovalsOfUploader(predicate);
+        logger.atFine().log(
+            "ignoresCodeReviewApprovalsOfUploader = %s", ignoresCodeReviewApprovalsOfUploader);
+        if (ignoresCodeReviewApprovalsOfUploader) {
+          return true;
+        }
+      } catch (QueryParseException e) {
+        logger.atFine().log(
+            "Failed to parse submit requirement expression %s: %s",
+            submitRequirement.submittabilityExpression().expressionString(), e.getMessage());
+        // ignore and inspect the next submit requirement
+      }
+    }
+    return false;
+  }
+
+  private boolean ignoresCodeReviewApprovalsOfUploader(Predicate<ChangeData> predicate) {
+    logger.atFine().log(
+        "predicate = (%s) %s (child count = %d)",
+        predicate.getClass().getName(), predicate, predicate.getChildCount());
+    if (predicate.getChildCount() == 0) {
+      // Submit requirements may require a Code-Review approval but ignore approvals by the
+      // uploader. This is done by using a label predicate with 'user=non_uploader' or
+      // 'user=non_contributor', e.g. 'label:Code-Review=+2,user=non_uploader'. After the submit
+      // requirement expression has been parsed these label predicates are represented by
+      // MagicLabelPredicate in the predicate tree. Hence to know whether Code-Review approvals of
+      // the uploader are ignored, we must check if there is any MagicLabelPredicate for the
+      // Code-Review label that ignores approvals of the uploader (aka has user set to non_uploader
+      // or non_contributor).
+      if (predicate instanceof MagicLabelPredicates.PostFilterMagicLabelPredicate) {
+        MagicLabelPredicates.PostFilterMagicLabelPredicate magicLabelPredicate =
+            (MagicLabelPredicates.PostFilterMagicLabelPredicate) predicate;
+        if (magicLabelPredicate.getLabel().equalsIgnoreCase("Code-Review")
+            && magicLabelPredicate.ignoresUploaderApprovals()) {
+          return true;
+        }
+      } else if (predicate instanceof MagicLabelPredicates.IndexMagicLabelPredicate) {
+        MagicLabelPredicates.IndexMagicLabelPredicate magicLabelPredicate =
+            (MagicLabelPredicates.IndexMagicLabelPredicate) predicate;
+        if (magicLabelPredicate.getLabel().equalsIgnoreCase("Code-Review")
+            && magicLabelPredicate.ignoresUploaderApprovals()) {
+          return true;
+        }
+      }
+      return false;
+    }
+
+    for (Predicate<ChangeData> childPredicate : predicate.getChildren()) {
+      if (ignoresCodeReviewApprovalsOfUploader(childPredicate)) {
+        return true;
+      }
+    }
+    return false;
+  }
+}
diff --git a/java/com/google/gerrit/server/submit/MergeOp.java b/java/com/google/gerrit/server/submit/MergeOp.java
index 27eb0a4..d299614 100644
--- a/java/com/google/gerrit/server/submit/MergeOp.java
+++ b/java/com/google/gerrit/server/submit/MergeOp.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.base.MoreObjects.firstNonNull;
 import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.MERGE_CHANGE;
 import static java.util.Comparator.comparing;
 import static java.util.Objects.requireNonNull;
 import static java.util.stream.Collectors.toSet;
@@ -84,6 +85,7 @@
 import com.google.gerrit.server.update.SubmissionListener;
 import com.google.gerrit.server.update.SuperprojectUpdateOnSubmission;
 import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.update.context.RefUpdateContext;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -244,6 +246,7 @@
   private final RetryHelper retryHelper;
   private final ChangeData.Factory changeDataFactory;
   private final StoreSubmitRequirementsOp.Factory storeSubmitRequirementsOpFactory;
+  private final MergeMetrics mergeMetrics;
 
   // Changes that were updated by this MergeOp.
   private final Map<Change.Id, Change> updatedChanges;
@@ -278,7 +281,8 @@
       TopicMetrics topicMetrics,
       RetryHelper retryHelper,
       ChangeData.Factory changeDataFactory,
-      StoreSubmitRequirementsOp.Factory storeSubmitRequirementsOpFactory) {
+      StoreSubmitRequirementsOp.Factory storeSubmitRequirementsOpFactory,
+      MergeMetrics mergeMetrics) {
     this.cmUtil = cmUtil;
     this.batchUpdateFactory = batchUpdateFactory;
     this.internalUserFactory = internalUserFactory;
@@ -296,6 +300,7 @@
     this.changeDataFactory = changeDataFactory;
     this.updatedChanges = new HashMap<>();
     this.storeSubmitRequirementsOpFactory = storeSubmitRequirementsOpFactory;
+    this.mergeMetrics = mergeMetrics;
   }
 
   @Override
@@ -374,6 +379,7 @@
           commitStatus.problem(cd.getId(), "Change " + cd.getId() + " is work in progress");
         } else {
           checkSubmitRequirements(cd);
+          mergeMetrics.countChangesThatWereSubmittedWithRebaserApproval(cd);
         }
       } catch (ResourceConflictException e) {
         commitStatus.problem(cd.getId(), e.getMessage());
@@ -535,9 +541,6 @@
             // Multiply the timeout by the number of projects we're actually attempting to
             // submit. Times 2 to retry more persistently, to increase success rate.
             .defaultTimeoutMultiplier(filteredNoteDbChangeSet.projects().size() * 2)
-            // By default, we only retry lock failures. Here it's better to also retry unexpected
-            // runtime exceptions.
-            .retryOn(t -> t instanceof RuntimeException)
             .call();
         submissionExecutor.afterExecutions(orm);
 
@@ -608,95 +611,98 @@
   private void integrateIntoHistory(
       ChangeSet cs, SubmissionExecutor submissionExecutor, boolean checkSubmitRules)
       throws RestApiException, UpdateException {
-    checkArgument(!cs.furtherHiddenChanges(), "cannot integrate hidden changes into history");
-    logger.atFine().log("Beginning merge attempt on %s", cs);
-    Map<BranchNameKey, BranchBatch> toSubmit = new HashMap<>();
+    try (RefUpdateContext ctx = RefUpdateContext.open(MERGE_CHANGE)) {
+      checkArgument(!cs.furtherHiddenChanges(), "cannot integrate hidden changes into history");
+      logger.atFine().log("Beginning merge attempt on %s", cs);
+      Map<BranchNameKey, BranchBatch> toSubmit = new HashMap<>();
 
-    ListMultimap<BranchNameKey, ChangeData> cbb;
-    try {
-      cbb = cs.changesByBranch();
-    } catch (StorageException e) {
-      throw new StorageException("Error reading changes to submit", e);
-    }
-    Set<BranchNameKey> branches = cbb.keySet();
-
-    for (BranchNameKey branch : branches) {
-      OpenRepo or = openRepo(branch.project());
-      if (or != null) {
-        toSubmit.put(branch, validateChangeList(or, cbb.get(branch)));
-      }
-    }
-
-    // Done checks that don't involve running submit strategies.
-    commitStatus.maybeFailVerbose();
-
-    try {
-      SubscriptionGraph subscriptionGraph = subscriptionGraphFactory.compute(branches, orm);
-      SubmoduleCommits submoduleCommits = submoduleCommitsFactory.create(orm);
-      UpdateOrderCalculator updateOrderCalculator = new UpdateOrderCalculator(subscriptionGraph);
-      List<SubmitStrategy> strategies =
-          getSubmitStrategies(
-              toSubmit, updateOrderCalculator, submoduleCommits, subscriptionGraph, dryrun);
-      this.projects = updateOrderCalculator.getProjectsInOrder();
-      List<BatchUpdate> batchUpdates =
-          orm.batchUpdates(
-              projects, /* refLogMessage= */ checkSubmitRules ? "merged" : "forced-merge");
-      // Group batch updates by project
-      Map<Project.NameKey, BatchUpdate> batchUpdatesByProject =
-          batchUpdates.stream().collect(Collectors.toMap(b -> b.getProject(), Function.identity()));
-      for (Map.Entry<Change.Id, ChangeData> entry : cs.changesById().entrySet()) {
-        Project.NameKey project = entry.getValue().project();
-        Change.Id changeId = entry.getKey();
-        ChangeData cd = entry.getValue();
-        batchUpdatesByProject
-            .get(project)
-            .addOp(
-                changeId,
-                storeSubmitRequirementsOpFactory.create(
-                    cd.submitRequirementsIncludingLegacy().values(), cd));
-      }
+      ListMultimap<BranchNameKey, ChangeData> cbb;
       try {
-        submissionExecutor.setAdditionalBatchUpdateListeners(
-            ImmutableList.of(new SubmitStrategyListener(submitInput, strategies, commitStatus)));
-        submissionExecutor.execute(batchUpdates);
-      } finally {
-        // If the BatchUpdate fails it can be that merging some of the changes was actually
-        // successful. This is why we must to collect the updated changes also when an
-        // exception was thrown.
-        strategies.forEach(s -> updatedChanges.putAll(s.getUpdatedChanges()));
+        cbb = cs.changesByBranch();
+      } catch (StorageException e) {
+        throw new StorageException("Error reading changes to submit", e);
+      }
+      Set<BranchNameKey> branches = cbb.keySet();
 
-        // Do not leave executed BatchUpdates in the OpenRepos
-        if (!dryrun) {
-          orm.resetUpdates(ImmutableSet.copyOf(this.projects));
+      for (BranchNameKey branch : branches) {
+        OpenRepo or = openRepo(branch.project());
+        if (or != null) {
+          toSubmit.put(branch, validateChangeList(or, cbb.get(branch)));
         }
       }
-    } catch (NoSuchProjectException e) {
-      throw new ResourceNotFoundException(e.getMessage());
-    } catch (IOException e) {
-      throw new StorageException(e);
-    } catch (SubmoduleConflictException e) {
-      throw new IntegrationConflictException(e.getMessage(), e);
-    } catch (UpdateException e) {
-      if (e.getCause() instanceof LockFailureException) {
-        // Lock failures are a special case: RetryHelper depends on this specific causal chain in
-        // order to trigger a retry. The downside of throwing here is we will not get the nicer
-        // error message constructed below, in the case where this is the final attempt and the
-        // operation is not retried further. This is not a huge downside, and is hopefully so rare
-        // as to be unnoticeable, assuming RetryHelper is retrying sufficiently.
-        throw e;
-      }
 
-      // BatchUpdate may have inadvertently wrapped an IntegrationConflictException
-      // thrown by some legacy SubmitStrategyOp code that intended the error
-      // message to be user-visible. Copy the message from the wrapped
-      // exception.
-      //
-      // If you happen across one of these, the correct fix is to convert the
-      // inner IntegrationConflictException to a ResourceConflictException.
-      if (e.getCause() instanceof IntegrationConflictException) {
-        throw (IntegrationConflictException) e.getCause();
+      // Done checks that don't involve running submit strategies.
+      commitStatus.maybeFailVerbose();
+
+      try {
+        SubscriptionGraph subscriptionGraph = subscriptionGraphFactory.compute(branches, orm);
+        SubmoduleCommits submoduleCommits = submoduleCommitsFactory.create(orm);
+        UpdateOrderCalculator updateOrderCalculator = new UpdateOrderCalculator(subscriptionGraph);
+        List<SubmitStrategy> strategies =
+            getSubmitStrategies(
+                toSubmit, updateOrderCalculator, submoduleCommits, subscriptionGraph, dryrun);
+        this.projects = updateOrderCalculator.getProjectsInOrder();
+        List<BatchUpdate> batchUpdates =
+            orm.batchUpdates(
+                projects, /* refLogMessage= */ checkSubmitRules ? "merged" : "forced-merge");
+        // Group batch updates by project
+        Map<Project.NameKey, BatchUpdate> batchUpdatesByProject =
+            batchUpdates.stream()
+                .collect(Collectors.toMap(b -> b.getProject(), Function.identity()));
+        for (Map.Entry<Change.Id, ChangeData> entry : cs.changesById().entrySet()) {
+          Project.NameKey project = entry.getValue().project();
+          Change.Id changeId = entry.getKey();
+          ChangeData cd = entry.getValue();
+          batchUpdatesByProject
+              .get(project)
+              .addOp(
+                  changeId,
+                  storeSubmitRequirementsOpFactory.create(
+                      cd.submitRequirementsIncludingLegacy().values(), cd));
+        }
+        try {
+          submissionExecutor.setAdditionalBatchUpdateListeners(
+              ImmutableList.of(new SubmitStrategyListener(submitInput, strategies, commitStatus)));
+          submissionExecutor.execute(batchUpdates);
+        } finally {
+          // If the BatchUpdate fails it can be that merging some of the changes was actually
+          // successful. This is why we must to collect the updated changes also when an
+          // exception was thrown.
+          strategies.forEach(s -> updatedChanges.putAll(s.getUpdatedChanges()));
+
+          // Do not leave executed BatchUpdates in the OpenRepos
+          if (!dryrun) {
+            orm.resetUpdates(ImmutableSet.copyOf(this.projects));
+          }
+        }
+      } catch (NoSuchProjectException e) {
+        throw new ResourceNotFoundException(e.getMessage());
+      } catch (IOException e) {
+        throw new StorageException(e);
+      } catch (SubmoduleConflictException e) {
+        throw new IntegrationConflictException(e.getMessage(), e);
+      } catch (UpdateException e) {
+        if (e.getCause() instanceof LockFailureException) {
+          // Lock failures are a special case: RetryHelper depends on this specific causal chain in
+          // order to trigger a retry. The downside of throwing here is we will not get the nicer
+          // error message constructed below, in the case where this is the final attempt and the
+          // operation is not retried further. This is not a huge downside, and is hopefully so rare
+          // as to be unnoticeable, assuming RetryHelper is retrying sufficiently.
+          throw e;
+        }
+
+        // BatchUpdate may have inadvertently wrapped an IntegrationConflictException
+        // thrown by some legacy SubmitStrategyOp code that intended the error
+        // message to be user-visible. Copy the message from the wrapped
+        // exception.
+        //
+        // If you happen across one of these, the correct fix is to convert the
+        // inner IntegrationConflictException to a ResourceConflictException.
+        if (e.getCause() instanceof IntegrationConflictException) {
+          throw (IntegrationConflictException) e.getCause();
+        }
+        throw new MergeUpdateException(genericMergeError(cs), e);
       }
-      throw new MergeUpdateException(genericMergeError(cs), e);
     }
   }
 
@@ -929,11 +935,13 @@
     }
   }
 
+  @Nullable
   private SubmitType getSubmitType(ChangeData cd) {
     SubmitTypeRecord str = cd.submitTypeRecord();
     return str.isOk() ? str.type : null;
   }
 
+  @Nullable
   private OpenRepo openRepo(Project.NameKey project) {
     try {
       return orm.getRepo(project);
diff --git a/java/com/google/gerrit/server/submit/RebaseSubmitStrategy.java b/java/com/google/gerrit/server/submit/RebaseSubmitStrategy.java
index cfb2f88..5f58a74 100644
--- a/java/com/google/gerrit/server/submit/RebaseSubmitStrategy.java
+++ b/java/com/google/gerrit/server/submit/RebaseSubmitStrategy.java
@@ -20,6 +20,7 @@
 import static com.google.gerrit.server.submit.CommitMergeStatus.SKIPPED_IDENTICAL_TREE;
 
 import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.BooleanProjectConfig;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.exceptions.StorageException;
@@ -238,6 +239,7 @@
       acceptMergeTip(args.mergeTip);
     }
 
+    @Nullable
     @Override
     public PatchSet updateChangeImpl(ChangeContext ctx)
         throws NoSuchChangeException, ResourceConflictException, IOException, BadRequestException {
diff --git a/java/com/google/gerrit/server/submit/SubmitStrategy.java b/java/com/google/gerrit/server/submit/SubmitStrategy.java
index f638078..bdda3fc5 100644
--- a/java/com/google/gerrit/server/submit/SubmitStrategy.java
+++ b/java/com/google/gerrit/server/submit/SubmitStrategy.java
@@ -21,6 +21,7 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Sets;
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.SubmissionId;
@@ -76,6 +77,8 @@
  * merged.
  */
 public abstract class SubmitStrategy {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   public static Module module() {
     return new FactoryModule() {
       @Override
@@ -275,6 +278,7 @@
       Change.Id id = c.change().getId();
       bu.addOp(id, args.setPrivateOpFactory.create(false, null));
       ImplicitIntegrateOp implicitIntegrateOp = new ImplicitIntegrateOp(args, c);
+      logger.atFine().log("Add implicit integrate op: %s", implicitIntegrateOp);
       bu.addOp(id, implicitIntegrateOp);
       maybeAddTestHelperOp(bu, id);
       this.submitStrategyOps.add(implicitIntegrateOp);
@@ -282,6 +286,7 @@
 
     // Then ops for explicitly merged changes
     for (SubmitStrategyOp op : ops) {
+      logger.atFine().log("Add explicit integrate op: %s", op);
       bu.addOp(op.getId(), args.setPrivateOpFactory.create(false, null));
       bu.addOp(op.getId(), op);
       maybeAddTestHelperOp(bu, op.getId());
diff --git a/java/com/google/gerrit/server/submit/SubmitStrategyOp.java b/java/com/google/gerrit/server/submit/SubmitStrategyOp.java
index d06940c..96dc326 100644
--- a/java/com/google/gerrit/server/submit/SubmitStrategyOp.java
+++ b/java/com/google/gerrit/server/submit/SubmitStrategyOp.java
@@ -21,7 +21,9 @@
 import static java.util.Comparator.comparing;
 import static java.util.Objects.requireNonNull;
 
+import com.google.common.base.MoreObjects;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.LabelId;
@@ -126,7 +128,8 @@
       logger.atFine().log("No merge tip, no update to perform");
       return;
     }
-    logger.atFine().log("Moved tip from %s to %s", tipBefore, tipAfter);
+    logger.atFine().log(
+        "Moved tip from %s to %s (branch = %s)", tipBefore, tipAfter, getDest().branch());
 
     checkProjectConfig(ctx, tipAfter);
 
@@ -158,6 +161,7 @@
     }
   }
 
+  @Nullable
   private CodeReviewCommit getAlreadyMergedCommit(RepoContext ctx) throws IOException {
     CodeReviewCommit tip = args.mergeTip.getInitialTip();
     if (tip == null) {
@@ -565,4 +569,14 @@
           e);
     }
   }
+
+  @Override
+  public String toString() {
+    return MoreObjects.toStringHelper(this)
+        .add("commit", getCommit().name())
+        .add("changeId", getId())
+        .add("dest", getDest().branch())
+        .add("project", getProject())
+        .toString();
+  }
 }
diff --git a/java/com/google/gerrit/server/submit/SubmoduleCommits.java b/java/com/google/gerrit/server/submit/SubmoduleCommits.java
index 37df66b..1fd3ad6 100644
--- a/java/com/google/gerrit/server/submit/SubmoduleCommits.java
+++ b/java/com/google/gerrit/server/submit/SubmoduleCommits.java
@@ -18,6 +18,7 @@
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.SubmoduleSubscription;
 import com.google.gerrit.exceptions.StorageException;
@@ -212,6 +213,7 @@
     return newCommit;
   }
 
+  @Nullable
   private RevCommit updateSubmodule(
       DirCache dc, DirCacheEditor ed, StringBuilder msgbuf, SubmoduleSubscription s)
       throws SubmoduleConflictException, IOException {
diff --git a/java/com/google/gerrit/server/submit/SubmoduleOp.java b/java/com/google/gerrit/server/submit/SubmoduleOp.java
index ba736fa..cebb5e3 100644
--- a/java/com/google/gerrit/server/submit/SubmoduleOp.java
+++ b/java/com/google/gerrit/server/submit/SubmoduleOp.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.submit;
 
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.UPDATE_SUPERPROJECT;
+
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.entities.BranchNameKey;
@@ -25,6 +27,7 @@
 import com.google.gerrit.server.submit.MergeOpRepoManager.OpenRepo;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.update.context.RefUpdateContext;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
@@ -103,10 +106,12 @@
           }
         }
       }
-      BatchUpdate.execute(
-          orm.batchUpdates(superProjects, /* refLogMessage= */ "merged"),
-          ImmutableList.of(),
-          dryrun);
+      try (RefUpdateContext ctx = RefUpdateContext.open(UPDATE_SUPERPROJECT)) {
+        BatchUpdate.execute(
+            orm.batchUpdates(superProjects, /* refLogMessage= */ "merged"),
+            ImmutableList.of(),
+            dryrun);
+      }
     } catch (UpdateException | IOException | NoSuchProjectException e) {
       throw new StorageException("Cannot update gitlinks", e);
     }
diff --git a/java/com/google/gerrit/server/query/change/ConstantPredicate.java b/java/com/google/gerrit/server/submitrequirement/predicate/ConstantPredicate.java
similarity index 85%
rename from java/com/google/gerrit/server/query/change/ConstantPredicate.java
rename to java/com/google/gerrit/server/submitrequirement/predicate/ConstantPredicate.java
index f0a85fe..c493fa4 100644
--- a/java/com/google/gerrit/server/query/change/ConstantPredicate.java
+++ b/java/com/google/gerrit/server/submitrequirement/predicate/ConstantPredicate.java
@@ -12,8 +12,10 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.query.change;
+package com.google.gerrit.server.submitrequirement.predicate;
 
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.SubmitRequirementPredicate;
 import com.google.inject.Singleton;
 
 /**
diff --git a/java/com/google/gerrit/server/query/change/DistinctVotersPredicate.java b/java/com/google/gerrit/server/submitrequirement/predicate/DistinctVotersPredicate.java
similarity index 95%
rename from java/com/google/gerrit/server/query/change/DistinctVotersPredicate.java
rename to java/com/google/gerrit/server/submitrequirement/predicate/DistinctVotersPredicate.java
index 5a51f5d..e392989 100644
--- a/java/com/google/gerrit/server/query/change/DistinctVotersPredicate.java
+++ b/java/com/google/gerrit/server/submitrequirement/predicate/DistinctVotersPredicate.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.query.change;
+package com.google.gerrit.server.submitrequirement.predicate;
 
 import com.google.common.base.Splitter;
 import com.google.common.collect.ImmutableList;
@@ -22,6 +22,8 @@
 import com.google.gerrit.index.query.QueryParseException;
 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.query.change.SubmitRequirementPredicate;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.util.Optional;
diff --git a/java/com/google/gerrit/server/query/FileEditsPredicate.java b/java/com/google/gerrit/server/submitrequirement/predicate/FileEditsPredicate.java
similarity index 98%
rename from java/com/google/gerrit/server/query/FileEditsPredicate.java
rename to java/com/google/gerrit/server/submitrequirement/predicate/FileEditsPredicate.java
index 7058765..515dc4a 100644
--- a/java/com/google/gerrit/server/query/FileEditsPredicate.java
+++ b/java/com/google/gerrit/server/submitrequirement/predicate/FileEditsPredicate.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.query;
+package com.google.gerrit.server.submitrequirement.predicate;
 
 import com.google.auto.value.AutoValue;
 import com.google.common.collect.Iterables;
diff --git a/java/com/google/gerrit/server/submitrequirement/predicate/HasSubmoduleUpdatePredicate.java b/java/com/google/gerrit/server/submitrequirement/predicate/HasSubmoduleUpdatePredicate.java
new file mode 100644
index 0000000..1774628
--- /dev/null
+++ b/java/com/google/gerrit/server/submitrequirement/predicate/HasSubmoduleUpdatePredicate.java
@@ -0,0 +1,108 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.submitrequirement.predicate;
+
+import static com.google.gerrit.server.query.change.SubmitRequirementChangeQueryBuilder.SUBMODULE_UPDATE_HAS_ARG;
+
+import com.google.gerrit.entities.Patch.FileMode;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.patch.DiffNotAvailableException;
+import com.google.gerrit.server.patch.DiffOperations;
+import com.google.gerrit.server.patch.DiffOptions;
+import com.google.gerrit.server.patch.filediff.FileDiffOutput;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.SubmitRequirementPredicate;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.io.IOException;
+import java.util.Map;
+import java.util.Optional;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+/**
+ * Submit requirement predicate that returns true if the diff of the latest patchset against the
+ * parent number identified by {@link #base} has a submodule modified file, that is, a .gitmodules
+ * or a git link file.
+ */
+public class HasSubmoduleUpdatePredicate extends SubmitRequirementPredicate {
+  private static final String GIT_MODULES_FILE = ".gitmodules";
+
+  private final DiffOperations diffOperations;
+  private final GitRepositoryManager repoManager;
+  private final int base;
+
+  public interface Factory {
+    HasSubmoduleUpdatePredicate create(int base);
+  }
+
+  @Inject
+  HasSubmoduleUpdatePredicate(
+      DiffOperations diffOperations, GitRepositoryManager repoManager, @Assisted int base) {
+    super("has", SUBMODULE_UPDATE_HAS_ARG);
+    this.diffOperations = diffOperations;
+    this.repoManager = repoManager;
+    this.base = base;
+  }
+
+  @Override
+  public boolean match(ChangeData cd) {
+    try {
+      try (Repository repo = repoManager.openRepository(cd.project());
+          RevWalk rw = new RevWalk(repo)) {
+        RevCommit revCommit = rw.parseCommit(cd.currentPatchSet().commitId());
+        if (base > revCommit.getParentCount()) {
+          return false;
+        }
+      }
+      Map<String, FileDiffOutput> diffList =
+          diffOperations.listModifiedFilesAgainstParent(
+              cd.project(), cd.currentPatchSet().commitId(), base, DiffOptions.DEFAULTS);
+      return diffList.values().stream().anyMatch(HasSubmoduleUpdatePredicate::isGitLink);
+    } catch (DiffNotAvailableException e) {
+      throw new StorageException(
+          String.format(
+              "Failed to evaluate the diff for commit %s against parent number %d",
+              cd.currentPatchSet().commitId(), base),
+          e);
+    } catch (IOException e) {
+      throw new StorageException(
+          String.format("Failed to open repo for project %s", cd.project()), e);
+    }
+  }
+
+  /**
+   * Return true if the modified file is a {@link #GIT_MODULES_FILE} or a git link regardless of if
+   * the modification type is add, remove or modify.
+   */
+  private static boolean isGitLink(FileDiffOutput fileDiffOutput) {
+    Optional<String> oldPath = fileDiffOutput.oldPath();
+    Optional<String> newPath = fileDiffOutput.newPath();
+    Optional<FileMode> oldMode = fileDiffOutput.oldMode();
+    Optional<FileMode> newMode = fileDiffOutput.newMode();
+
+    return (oldPath.isPresent() && oldPath.get().equals(GIT_MODULES_FILE))
+        || (newPath.isPresent() && newPath.get().equals(GIT_MODULES_FILE))
+        || (oldMode.isPresent() && oldMode.get().equals(FileMode.GITLINK))
+        || (newMode.isPresent() && newMode.get().equals(FileMode.GITLINK));
+  }
+
+  @Override
+  public int getCost() {
+    return 1;
+  }
+}
diff --git a/java/com/google/gerrit/server/query/change/RegexAuthorEmailPredicate.java b/java/com/google/gerrit/server/submitrequirement/predicate/RegexAuthorEmailPredicate.java
similarity index 89%
rename from java/com/google/gerrit/server/query/change/RegexAuthorEmailPredicate.java
rename to java/com/google/gerrit/server/submitrequirement/predicate/RegexAuthorEmailPredicate.java
index 22891bc..eb7f666 100644
--- a/java/com/google/gerrit/server/query/change/RegexAuthorEmailPredicate.java
+++ b/java/com/google/gerrit/server/submitrequirement/predicate/RegexAuthorEmailPredicate.java
@@ -12,9 +12,11 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.query.change;
+package com.google.gerrit.server.submitrequirement.predicate;
 
 import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.SubmitRequirementPredicate;
 import dk.brics.automaton.RegExp;
 import dk.brics.automaton.RunAutomaton;
 
diff --git a/java/com/google/gerrit/server/query/change/RegexAuthorEmailPredicate.java b/java/com/google/gerrit/server/submitrequirement/predicate/RegexCommitterEmailPredicate.java
similarity index 64%
copy from java/com/google/gerrit/server/query/change/RegexAuthorEmailPredicate.java
copy to java/com/google/gerrit/server/submitrequirement/predicate/RegexCommitterEmailPredicate.java
index 22891bc..f991d31 100644
--- a/java/com/google/gerrit/server/query/change/RegexAuthorEmailPredicate.java
+++ b/java/com/google/gerrit/server/submitrequirement/predicate/RegexCommitterEmailPredicate.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2022 The Android Open Source Project
+// Copyright (C) 2023 The Android Open Source Project
 //
 // Licensed under the Apache License, Version 2.0 (the "License");
 // you may not use this file except in compliance with the License.
@@ -12,21 +12,23 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.query.change;
+package com.google.gerrit.server.submitrequirement.predicate;
 
 import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.SubmitRequirementPredicate;
 import dk.brics.automaton.RegExp;
 import dk.brics.automaton.RunAutomaton;
 
 /**
- * A submit requirement predicate that matches with changes having the author email's address
+ * A submit requirement predicate that matches with changes having the committer email's address
  * matching a specific regular expression pattern.
  */
-public class RegexAuthorEmailPredicate extends SubmitRequirementPredicate {
-  protected final RunAutomaton authorEmailPattern;
+public class RegexCommitterEmailPredicate extends SubmitRequirementPredicate {
+  protected final RunAutomaton committerEmailPattern;
 
-  public RegexAuthorEmailPredicate(String pattern) throws QueryParseException {
-    super("authoremail", pattern);
+  public RegexCommitterEmailPredicate(String pattern) throws QueryParseException {
+    super("committeremail", pattern);
 
     if (pattern.startsWith("^")) {
       pattern = pattern.substring(1);
@@ -37,7 +39,7 @@
     }
 
     try {
-      this.authorEmailPattern = new RunAutomaton(new RegExp(pattern).toAutomaton());
+      this.committerEmailPattern = new RunAutomaton(new RegExp(pattern).toAutomaton());
     } catch (IllegalArgumentException e) {
       throw new QueryParseException(String.format("invalid regular expression: %s", pattern), e);
     }
@@ -45,7 +47,7 @@
 
   @Override
   public boolean match(ChangeData cd) {
-    return authorEmailPattern.run(cd.getAuthor().getEmailAddress());
+    return committerEmailPattern.run(cd.getCommitter().getEmailAddress());
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/submitrequirement/predicate/RegexUploaderEmailPredicate.java b/java/com/google/gerrit/server/submitrequirement/predicate/RegexUploaderEmailPredicate.java
new file mode 100644
index 0000000..9566546
--- /dev/null
+++ b/java/com/google/gerrit/server/submitrequirement/predicate/RegexUploaderEmailPredicate.java
@@ -0,0 +1,71 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.submitrequirement.predicate;
+
+import com.google.auto.factory.AutoFactory;
+import com.google.auto.factory.Provided;
+import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.SubmitRequirementPredicate;
+import dk.brics.automaton.RegExp;
+import dk.brics.automaton.RunAutomaton;
+import java.util.Optional;
+
+/**
+ * A submit requirement predicate that matches with changes having the uploader's email address
+ * matching a specific regular expression pattern.
+ */
+@AutoFactory
+public class RegexUploaderEmailPredicate extends SubmitRequirementPredicate {
+  protected final RunAutomaton uploaderEmailPattern;
+  private final AccountCache accountCache;
+
+  public RegexUploaderEmailPredicate(@Provided AccountCache accountCache, String pattern)
+      throws QueryParseException {
+    super("uploaderemail", pattern);
+    this.accountCache = accountCache;
+
+    if (pattern.startsWith("^")) {
+      pattern = pattern.substring(1);
+    }
+
+    if (pattern.endsWith("$") && !pattern.endsWith("\\$")) {
+      pattern = pattern.substring(0, pattern.length() - 1);
+    }
+
+    try {
+      this.uploaderEmailPattern = new RunAutomaton(new RegExp(pattern).toAutomaton());
+    } catch (IllegalArgumentException e) {
+      throw new QueryParseException(String.format("invalid regular expression: %s", pattern), e);
+    }
+  }
+
+  @Override
+  public boolean match(ChangeData cd) {
+    Optional<AccountState> accountState = accountCache.get(cd.currentPatchSet().uploader());
+    if (!accountState.isPresent()) {
+      return false;
+    }
+    String email = accountState.get().account().preferredEmail();
+    return email == null ? false : uploaderEmailPattern.run(email);
+  }
+
+  @Override
+  public int getCost() {
+    return 1;
+  }
+}
diff --git a/java/com/google/gerrit/server/update/BatchUpdate.java b/java/com/google/gerrit/server/update/BatchUpdate.java
index a17d015..9250513 100644
--- a/java/com/google/gerrit/server/update/BatchUpdate.java
+++ b/java/com/google/gerrit/server/update/BatchUpdate.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkState;
+import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.common.collect.ImmutableMultiset.toImmutableMultiset;
 import static com.google.common.flogger.LazyArgs.lazy;
 import static java.util.Comparator.comparing;
@@ -23,6 +24,8 @@
 import static java.util.stream.Collectors.toMap;
 import static java.util.stream.Collectors.toSet;
 
+import com.google.auto.value.AutoValue;
+import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Throwables;
 import com.google.common.collect.ArrayListMultimap;
 import com.google.common.collect.ImmutableList;
@@ -34,6 +37,7 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.common.util.concurrent.Futures;
 import com.google.common.util.concurrent.ListenableFuture;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.entities.BranchNameKey;
@@ -50,8 +54,12 @@
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.RefLogIdentityProvider;
+import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.change.NotifyResolver;
+import com.google.gerrit.server.experiments.ExperimentFeatures;
+import com.google.gerrit.server.experiments.ExperimentFeaturesConstants;
 import com.google.gerrit.server.extensions.events.AttentionSetObserver;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.GitRepositoryManager;
@@ -243,6 +251,12 @@
   }
 
   class ContextImpl implements Context {
+    private final CurrentUser contextUser;
+
+    ContextImpl(@Nullable CurrentUser contextUser) {
+      this.contextUser = contextUser != null ? contextUser : user;
+    }
+
     @Override
     public RepoView getRepoView() throws IOException {
       return BatchUpdate.this.getRepoView();
@@ -270,7 +284,7 @@
 
     @Override
     public CurrentUser getUser() {
-      return user;
+      return contextUser;
     }
 
     @Override
@@ -281,6 +295,10 @@
   }
 
   private class RepoContextImpl extends ContextImpl implements RepoContext {
+    RepoContextImpl(@Nullable CurrentUser contextUser) {
+      super(contextUser);
+    }
+
     @Override
     public ObjectInserter getInserter() throws IOException {
       return getRepoView().getInserterWrapper();
@@ -296,21 +314,22 @@
     private final ChangeNotes notes;
 
     /**
-     * Updates where the caller instructed us to create one NoteDb commit per update. Keyed by
-     * PatchSet.Id only for convenience.
+     * Updates where the caller allowed us to combine potentially multiple adjustments into a single
+     * commit in NoteDb by re-using the same ChangeUpdate instance. Will still be one commit per
+     * patch set.
      */
     private final Map<PatchSet.Id, ChangeUpdate> defaultUpdates;
 
     /**
-     * Updates where the caller allowed us to combine potentially multiple adjustments into a single
-     * commit in NoteDb by re-using the same ChangeUpdate instance. Will still be one commit per
-     * patch set.
+     * Updates where the caller instructed us to create one NoteDb commit per update. Keyed by
+     * PatchSet.Id only for convenience.
      */
     private final ListMultimap<PatchSet.Id, ChangeUpdate> distinctUpdates;
 
     private boolean deleted;
 
-    ChangeContextImpl(ChangeNotes notes) {
+    ChangeContextImpl(@Nullable CurrentUser contextUser, ChangeNotes notes) {
+      super(contextUser);
       this.notes = requireNonNull(notes);
       defaultUpdates = new TreeMap<>(comparing(PatchSet.Id::get));
       distinctUpdates = ArrayListMultimap.create();
@@ -334,7 +353,7 @@
     }
 
     private ChangeUpdate getNewChangeUpdate(PatchSet.Id psId) {
-      ChangeUpdate u = changeUpdateFactory.create(notes, user, when);
+      ChangeUpdate u = changeUpdateFactory.create(notes, getUser(), getWhen());
       if (newChanges.containsKey(notes.getChangeId())) {
         u.setAllowWriteToNewRef(true);
       }
@@ -356,7 +375,9 @@
   private class PostUpdateContextImpl extends ContextImpl implements PostUpdateContext {
     private final Map<Change.Id, ChangeData> changeDatas;
 
-    PostUpdateContextImpl(Map<Change.Id, ChangeData> changeDatas) {
+    PostUpdateContextImpl(
+        @Nullable CurrentUser contextUser, Map<Change.Id, ChangeData> changeDatas) {
+      super(contextUser);
       this.changeDatas = changeDatas;
     }
 
@@ -374,29 +395,37 @@
 
   /** Per-change result status from {@link #executeChangeOps}. */
   private enum ChangeResult {
+    /** Change was not modified by any of the batch update ops. */
     SKIPPED,
+
+    /** Change was inserted or updated. */
     UPSERTED,
+
+    /** Change was deleted. */
     DELETED
   }
 
   private final GitRepositoryManager repoManager;
+  private final AccountCache accountCache;
   private final ChangeData.Factory changeDataFactory;
   private final ChangeNotes.Factory changeNotesFactory;
   private final ChangeUpdate.Factory changeUpdateFactory;
   private final NoteDbUpdateManager.Factory updateManagerFactory;
   private final ChangeIndexer indexer;
   private final GitReferenceUpdated gitRefUpdated;
+  private final RefLogIdentityProvider refLogIdentityProvider;
 
   private final Project.NameKey project;
   private final CurrentUser user;
   private final Instant when;
   private final ZoneId zoneId;
 
-  private final ListMultimap<Change.Id, BatchUpdateOp> ops =
+  private final ListMultimap<Change.Id, OpData<BatchUpdateOp>> ops =
       MultimapBuilder.linkedHashKeys().arrayListValues().build();
   private final Map<Change.Id, Change> newChanges = new HashMap<>();
-  private final List<RepoOnlyOp> repoOnlyOps = new ArrayList<>();
+  private final List<OpData<RepoOnlyOp>> repoOnlyOps = new ArrayList<>();
   private final Map<Change.Id, NotifyHandling> perChangeNotifyHandling = new HashMap<>();
+  private final ExperimentFeatures experimentFeatures;
 
   private RepoView repoView;
   private BatchRefUpdate batchRefUpdate;
@@ -414,27 +443,33 @@
   BatchUpdate(
       GitRepositoryManager repoManager,
       @GerritPersonIdent PersonIdent serverIdent,
+      AccountCache accountCache,
       ChangeData.Factory changeDataFactory,
       ChangeNotes.Factory changeNotesFactory,
       ChangeUpdate.Factory changeUpdateFactory,
       NoteDbUpdateManager.Factory updateManagerFactory,
       ChangeIndexer indexer,
       GitReferenceUpdated gitRefUpdated,
+      RefLogIdentityProvider refLogIdentityProvider,
       AttentionSetObserver attentionSetObserver,
+      ExperimentFeatures experimentFeatures,
       @Assisted Project.NameKey project,
       @Assisted CurrentUser user,
       @Assisted Instant when) {
     this.repoManager = repoManager;
+    this.accountCache = accountCache;
     this.changeDataFactory = changeDataFactory;
     this.changeNotesFactory = changeNotesFactory;
     this.changeUpdateFactory = changeUpdateFactory;
     this.updateManagerFactory = updateManagerFactory;
     this.indexer = indexer;
     this.gitRefUpdated = gitRefUpdated;
+    this.refLogIdentityProvider = refLogIdentityProvider;
+    this.attentionSetObserver = attentionSetObserver;
+    this.experimentFeatures = experimentFeatures;
     this.project = project;
     this.user = user;
     this.when = when;
-    this.attentionSetObserver = attentionSetObserver;
     zoneId = serverIdent.getZoneId();
   }
 
@@ -544,48 +579,77 @@
             toMap(entry -> BranchNameKey.create(project, entry.getKey()), Map.Entry::getValue));
   }
 
+  /**
+   * Adds a {@link BatchUpdate} for a change.
+   *
+   * <p>The op is executed by the user for which the {@link BatchUpdate} has been created.
+   */
+  @CanIgnoreReturnValue
   public BatchUpdate addOp(Change.Id id, BatchUpdateOp op) {
     checkArgument(!(op instanceof InsertChangeOp), "use insertChange");
     requireNonNull(op);
-    ops.put(id, op);
+    ops.put(id, OpData.create(op, user));
     return this;
   }
 
+  /** Adds a {@link BatchUpdate} for a change that should be executed by the given context user. */
+  @CanIgnoreReturnValue
+  public BatchUpdate addOp(Change.Id id, CurrentUser contextUser, BatchUpdateOp op) {
+    checkArgument(!(op instanceof InsertChangeOp), "use insertChange");
+    requireNonNull(op);
+    ops.put(id, OpData.create(op, contextUser));
+    return this;
+  }
+
+  /**
+   * Adds a {@link RepoOnlyOp}.
+   *
+   * <p>The op is executed by the user for which the {@link BatchUpdate} has been created.
+   */
+  @CanIgnoreReturnValue
   public BatchUpdate addRepoOnlyOp(RepoOnlyOp op) {
     checkArgument(!(op instanceof BatchUpdateOp), "use addOp()");
-    repoOnlyOps.add(op);
+    repoOnlyOps.add(OpData.create(op, user));
     return this;
   }
 
+  /** Adds a {@link RepoOnlyOp} that should be executed by the given context user. */
+  @CanIgnoreReturnValue
+  public BatchUpdate addRepoOnlyOp(CurrentUser contextUser, RepoOnlyOp op) {
+    checkArgument(!(op instanceof BatchUpdateOp), "use addOp()");
+    repoOnlyOps.add(OpData.create(op, contextUser));
+    return this;
+  }
+
+  @CanIgnoreReturnValue
   public BatchUpdate insertChange(InsertChangeOp op) throws IOException {
-    Context ctx = new ContextImpl();
+    Context ctx = new ContextImpl(user);
     Change c = op.createChange(ctx);
     checkArgument(
         !newChanges.containsKey(c.getId()), "only one op allowed to create change %s", c.getId());
     newChanges.put(c.getId(), c);
-    ops.get(c.getId()).add(0, op);
+    ops.get(c.getId()).add(0, OpData.create(op, user));
     return this;
   }
 
   private void executeUpdateRepo() throws UpdateException, RestApiException {
     try {
       logDebug("Executing updateRepo on %d ops", ops.size());
-      RepoContextImpl ctx = new RepoContextImpl();
-      for (Map.Entry<Change.Id, BatchUpdateOp> op : ops.entries()) {
+      for (Map.Entry<Change.Id, OpData<BatchUpdateOp>> e : ops.entries()) {
+        BatchUpdateOp op = e.getValue().op();
+        RepoContextImpl ctx = new RepoContextImpl(e.getValue().user());
         try (TraceContext.TraceTimer ignored =
             TraceContext.newTimer(
                 op.getClass().getSimpleName() + "#updateRepo",
-                Metadata.builder()
-                    .projectName(project.get())
-                    .changeId(op.getKey().get())
-                    .build())) {
-          op.getValue().updateRepo(ctx);
+                Metadata.builder().projectName(project.get()).changeId(e.getKey().get()).build())) {
+          op.updateRepo(ctx);
         }
       }
 
       logDebug("Executing updateRepo on %d RepoOnlyOps", repoOnlyOps.size());
-      for (RepoOnlyOp op : repoOnlyOps) {
-        op.updateRepo(ctx);
+      for (OpData<RepoOnlyOp> opData : repoOnlyOps) {
+        RepoContextImpl ctx = new RepoContextImpl(opData.user());
+        opData.op().updateRepo(ctx);
       }
 
       if (onSubmitValidators != null && !getRefUpdates().isEmpty()) {
@@ -594,7 +658,7 @@
         // first update's executeRefUpdates has finished, hence after first repo's refs have been
         // updated, which is too late.
         onSubmitValidators.validate(
-            project, ctx.getRevWalk().getObjectReader(), repoView.getCommands());
+            project, getRepoView().getRevWalk().getObjectReader(), repoView.getCommands());
       }
     } catch (Exception e) {
       Throwables.throwIfInstanceOf(e, RestApiException.class);
@@ -602,18 +666,25 @@
     }
   }
 
+  private boolean indexAsync() {
+    return experimentFeatures.isFeatureEnabled(
+        ExperimentFeaturesConstants.GERRIT_BACKEND_FEATURE_DO_NOT_AWAIT_CHANGE_INDEXING);
+  }
+
   private void fireRefChangeEvent() {
     if (batchRefUpdate != null) {
       gitRefUpdated.fire(project, batchRefUpdate, getAccount().orElse(null));
     }
   }
 
-  private void fireAttentionSetUpdateEvents(PostUpdateContext ctx) {
+  private void fireAttentionSetUpdateEvents(Map<Change.Id, ChangeData> changeDatas) {
     for (ProjectChangeKey key : attentionSetUpdates.keySet()) {
-      ChangeData change = ctx.getChangeData(key.projectName(), key.changeId());
-      AccountState account = getAccount().orElse(null);
+      ChangeData change =
+          changeDatas.computeIfAbsent(
+              key.changeId(), id -> changeDataFactory.create(key.projectName(), key.changeId()));
       for (AttentionSetUpdate update : attentionSetUpdates.get(key)) {
-        attentionSetObserver.fire(change, account, update, ctx.getWhen());
+        attentionSetObserver.fire(
+            change, accountCache.getEvenIfMissing(update.account()), update, when);
       }
     }
   }
@@ -622,11 +693,13 @@
     private final NoteDbUpdateManager manager;
     private final boolean dryrun;
     private final Map<Change.Id, ChangeResult> results;
+    private final boolean indexAsync;
 
-    ChangesHandle(NoteDbUpdateManager manager, boolean dryrun) {
+    ChangesHandle(NoteDbUpdateManager manager, boolean dryrun, boolean indexAsync) {
       this.manager = manager;
       this.dryrun = dryrun;
       results = new HashMap<>();
+      this.indexAsync = indexAsync;
     }
 
     @Override
@@ -669,7 +742,7 @@
             indexFutures.add(indexer.indexAsync(project, id));
             break;
           case DELETED:
-            indexFutures.add(indexer.deleteAsync(id));
+            indexFutures.add(indexer.deleteAsync(project, id));
             break;
           case SKIPPED:
             break;
@@ -677,6 +750,17 @@
             throw new IllegalStateException("unexpected result: " + e.getValue());
         }
       }
+      if (indexAsync) {
+        // We want to index asynchronously. However, the callers will await all
+        // index futures. This allows us to - even in synchronous case -
+        // parallelize indexing changes.
+        // Returning immediate futures for newly-created change data objects
+        // while letting the actual futures go will make actual indexing
+        // asynchronous.
+        return results.keySet().stream()
+            .map(cId -> Futures.immediateFuture(changeDataFactory.create(project, cId)))
+            .collect(toImmutableList());
+      }
       return indexFutures.build();
     }
   }
@@ -698,37 +782,50 @@
                 .setBatchUpdateListeners(batchUpdateListeners)
                 .setChangeRepo(
                     repo, repoView.getRevWalk(), repoView.getInserter(), repoView.getCommands()),
-            dryrun);
-    if (user.isIdentifiedUser()) {
-      handle.manager.setRefLogIdent(user.asIdentifiedUser().newRefLogIdent(when, zoneId));
-    }
+            dryrun,
+            indexAsync());
+    getRefLogIdent().ifPresent(handle.manager::setRefLogIdent);
     handle.manager.setRefLogMessage(refLogMessage);
     handle.manager.setPushCertificate(pushCert);
-    for (Map.Entry<Change.Id, Collection<BatchUpdateOp>> e : ops.asMap().entrySet()) {
+    for (Map.Entry<Change.Id, Collection<OpData<BatchUpdateOp>>> e : ops.asMap().entrySet()) {
       Change.Id id = e.getKey();
-      ChangeContextImpl ctx = newChangeContext(id);
       boolean dirty = false;
+      boolean deleted = false;
+      List<ChangeUpdate> changeUpdates = new ArrayList<>();
+      ChangeContextImpl ctx = null;
       logDebug(
           "Applying %d ops for change %s: %s",
           e.getValue().size(),
           id,
           lazy(() -> e.getValue().stream().map(op -> op.getClass().getName()).collect(toSet())));
-      for (BatchUpdateOp op : e.getValue()) {
+      for (OpData<BatchUpdateOp> opData : e.getValue()) {
+        if (ctx == null) {
+          ctx = newChangeContext(opData.user(), id);
+        } else if (!ctx.getUser().equals(opData.user())) {
+          ctx.defaultUpdates.values().forEach(changeUpdates::add);
+          ctx.distinctUpdates.values().forEach(changeUpdates::add);
+          ctx = newChangeContext(opData.user(), id);
+        }
         try (TraceContext.TraceTimer ignored =
             TraceContext.newTimer(
-                op.getClass().getSimpleName() + "#updateChange",
+                opData.getClass().getSimpleName() + "#updateChange",
                 Metadata.builder().projectName(project.get()).changeId(id.get()).build())) {
-          dirty |= op.updateChange(ctx);
+          dirty |= opData.op().updateChange(ctx);
+          deleted |= ctx.deleted;
         }
       }
+      if (ctx != null) {
+        ctx.defaultUpdates.values().forEach(changeUpdates::add);
+        ctx.distinctUpdates.values().forEach(changeUpdates::add);
+      }
+
       if (!dirty) {
         logDebug("No ops reported dirty, short-circuiting");
         handle.setResult(id, ChangeResult.SKIPPED);
         continue;
       }
-      ctx.defaultUpdates.values().forEach(handle.manager::add);
-      ctx.distinctUpdates.values().forEach(handle.manager::add);
-      if (ctx.deleted) {
+      changeUpdates.forEach(handle.manager::add);
+      if (deleted) {
         logDebug("Change %s was deleted", id);
         handle.manager.deleteChange(id);
         handle.setResult(id, ChangeResult.DELETED);
@@ -739,7 +836,48 @@
     return handle;
   }
 
-  private ChangeContextImpl newChangeContext(Change.Id id) {
+  /**
+   * Creates the ref log identity that should be used for the ref updates that are done by this
+   * {@code BatchUpdate}.
+   *
+   * <p>The ref log identity is created for the users for which operations should be executed. If
+   * all operations are executed by the same user the ref log identity is created for that user. If
+   * operations are executed for multiple users a shared reflog identity is created.
+   */
+  @VisibleForTesting
+  Optional<PersonIdent> getRefLogIdent() {
+    if (ops.isEmpty()) {
+      return Optional.empty();
+    }
+
+    // If all updates are done by identified users, create a shared ref log identity.
+    if (ops.values().stream()
+        .map(OpData::user)
+        .allMatch(currentUser -> currentUser.isIdentifiedUser())) {
+      return Optional.of(
+          refLogIdentityProvider.newRefLogIdent(
+              ops.values().stream()
+                  .map(OpData::user)
+                  .map(CurrentUser::asIdentifiedUser)
+                  .collect(toImmutableList()),
+              when,
+              zoneId));
+    }
+
+    // Fail if some but not all updates are done by identified users. At the moment we do not
+    // support batching updates of identified users and non-identified users (e.g. updates done on
+    // behalf of the server).
+    checkState(
+        ops.values().stream()
+            .map(OpData::user)
+            .noneMatch(currentUser -> currentUser.isIdentifiedUser()),
+        "batching updates of identified users and non-identified users is not supported");
+
+    // As fallback the server identity will be used as the ref log identity.
+    return Optional.empty();
+  }
+
+  private ChangeContextImpl newChangeContext(@Nullable CurrentUser contextUser, Change.Id id) {
     logDebug("Opening change %s for update", id);
     Change c = newChanges.get(id);
     boolean isNew = c != null;
@@ -752,27 +890,30 @@
       logDebug("Change %s is new", id);
     }
     ChangeNotes notes = changeNotesFactory.createForBatchUpdate(c, !isNew);
-    return new ChangeContextImpl(notes);
+    return new ChangeContextImpl(contextUser, notes);
   }
 
   private void executePostOps(Map<Change.Id, ChangeData> changeDatas) throws Exception {
-    PostUpdateContextImpl ctx = new PostUpdateContextImpl(changeDatas);
-    for (BatchUpdateOp op : ops.values()) {
+    for (OpData<BatchUpdateOp> opData : ops.values()) {
+      PostUpdateContextImpl ctx = new PostUpdateContextImpl(opData.user(), changeDatas);
       try (TraceContext.TraceTimer ignored =
-          TraceContext.newTimer(op.getClass().getSimpleName() + "#postUpdate", Metadata.empty())) {
-        op.postUpdate(ctx);
+          TraceContext.newTimer(
+              opData.getClass().getSimpleName() + "#postUpdate", Metadata.empty())) {
+        opData.op().postUpdate(ctx);
       }
     }
 
-    for (RepoOnlyOp op : repoOnlyOps) {
+    for (OpData<RepoOnlyOp> opData : repoOnlyOps) {
+      PostUpdateContextImpl ctx = new PostUpdateContextImpl(opData.user(), changeDatas);
       try (TraceContext.TraceTimer ignored =
-          TraceContext.newTimer(op.getClass().getSimpleName() + "#postUpdate", Metadata.empty())) {
-        op.postUpdate(ctx);
+          TraceContext.newTimer(
+              opData.getClass().getSimpleName() + "#postUpdate", Metadata.empty())) {
+        opData.op().postUpdate(ctx);
       }
     }
     try (TraceContext.TraceTimer ignored =
         TraceContext.newTimer("fireAttentionSetUpdates#postUpdate", Metadata.empty())) {
-      fireAttentionSetUpdateEvents(ctx);
+      fireAttentionSetUpdateEvents(changeDatas);
     }
   }
 
@@ -803,4 +944,18 @@
       logger.atFine().log(msg, arg1, arg2, arg3);
     }
   }
+
+  /** Data needed to execute a {@link RepoOnlyOp} or a {@link BatchUpdateOp}. */
+  @AutoValue
+  abstract static class OpData<T extends RepoOnlyOp> {
+    /** Op that should be executed. */
+    abstract T op();
+
+    /** User that should be used to execute the {@link #op}. */
+    abstract CurrentUser user();
+
+    static <T extends RepoOnlyOp> OpData<T> create(T op, CurrentUser user) {
+      return new AutoValue_BatchUpdate_OpData<>(op, user);
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/update/context/RefUpdateContext.java b/java/com/google/gerrit/server/update/context/RefUpdateContext.java
new file mode 100644
index 0000000..56d536a
--- /dev/null
+++ b/java/com/google/gerrit/server/update/context/RefUpdateContext.java
@@ -0,0 +1,178 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.update.context;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkState;
+
+import com.google.common.collect.ImmutableList;
+import java.util.ArrayDeque;
+import java.util.Deque;
+
+/**
+ * Passes additional information about an operation to the {@code BatchRefUpdate#execute} method.
+ *
+ * <p>To pass the additional information {@link RefUpdateContext}, wraps a code into an open
+ * RefUpdateContext, e.g.:
+ *
+ * <pre>{@code
+ * try(RefUpdateContext ctx = RefUpdateContext.open(RefUpdateType.CHANGE_MODIFICATION)) {
+ *   ...
+ *   // some code which modifies a ref using BatchRefUpdate.execute method
+ * }
+ * }</pre>
+ *
+ * When the {@code BatchRefUpdate#execute} method is executed, it can get all opened contexts and
+ * use it for an additional actions, e.g. it can put it in the reflog.
+ *
+ * <p>The information provided by this class is used internally in google.
+ *
+ * <p>The InMemoryRepositoryManager file makes some validation to ensure that RefUpdateContext is
+ * used correctly within the code (see thee validateRefUpdateContext method).
+ *
+ * <p>The class includes only operations from open-source gerrit and can be extended (see {@code
+ * TestActionRefUpdateContext} for example how to extend it).
+ */
+public class RefUpdateContext implements AutoCloseable {
+  private static final ThreadLocal<Deque<RefUpdateContext>> current = new ThreadLocal<>();
+
+  /**
+   * List of possible ref-update types.
+   *
+   * <p>Items in this enum are not fine-grained; different actions are shared the same type (e.g.
+   * {@link #CHANGE_MODIFICATION} includes posting comments, change edits and attention set update).
+   *
+   * <p>It is expected, that each type of operation should include only specific ref(s); check the
+   * validateRefUpdateContext in InMemoryRepositoryManager for relation between RefUpdateType and
+   * ref name.
+   */
+  public enum RefUpdateType {
+    /**
+     * Indicates that the context is implemented as a descendant of the {@link RefUpdateContext} .
+     *
+     * <p>The {@link #getUpdateType()} returns this type for all descendant of {@link
+     * RefUpdateContext}. This type is never returned if the context is exactly {@link
+     * RefUpdateContext}.
+     */
+    OTHER,
+    /**
+     * A ref is updated as a part of change-related operation.
+     *
+     * <p>This covers multiple different cases - creating and uploading changes and patchsets,
+     * comments operations, change edits, etc...
+     */
+    CHANGE_MODIFICATION,
+    /** A ref is updated during merge-change operation. */
+    MERGE_CHANGE,
+    /** A ref is updated as a part of a repo sequence operation. */
+    REPO_SEQ,
+    /** A ref is updated as a part of a repo initialization. */
+    INIT_REPO,
+    /** A ref is udpated as a part of gpg keys modification. */
+    GPG_KEYS_MODIFICATION,
+    /** A ref is updated as a part of group(s) update */
+    GROUPS_UPDATE,
+    /** A ref is updated as a part of account(s) update. */
+    ACCOUNTS_UPDATE,
+    /** A ref is updated as a part of direct push. */
+    DIRECT_PUSH,
+    /** A ref is updated as a part of explicit branch or ref update operation. */
+    BRANCH_MODIFICATION,
+    /** A ref is updated as a part of explicit tag update operation. */
+    TAG_MODIFICATION,
+    /**
+     * A tag is updated as a part of an offline operation.
+     *
+     * <p>Offline operation - an operation which is executed separately from the gerrit server and
+     * can't be triggered by any gerrit API. E.g. schema update.
+     */
+    OFFLINE_OPERATION,
+    /** A tag is updated as a part of an update-superproject flow. */
+    UPDATE_SUPERPROJECT,
+    /** A ref is updated as a part of explicit HEAD update operation. */
+    HEAD_MODIFICATION,
+    /** A ref is updated as a part of versioned meta data change. */
+    VERSIONED_META_DATA_CHANGE,
+    /** A ref is updated as a part of commit-ban operation. */
+    BAN_COMMIT,
+    /**
+     * A ref is updated inside a plugin.
+     *
+     * <p>If a plugin updates one of a special refs - it must also open a nested context.
+     */
+    PLUGIN,
+    /** A ref is updated as a part of auto-close-changes. */
+    AUTO_CLOSE_CHANGES
+  }
+
+  /** Opens a provided context. */
+  protected static <T extends RefUpdateContext> T open(T ctx) {
+    getCurrent().addLast(ctx);
+    return ctx;
+  }
+
+  /** Opens a context of a give type. */
+  public static RefUpdateContext open(RefUpdateType updateType) {
+    checkArgument(updateType != RefUpdateType.OTHER, "The OTHER type is for internal use only.");
+    return open(new RefUpdateContext(updateType));
+  }
+
+  /** Returns the list of opened contexts; the first element is the outermost context. */
+  public static ImmutableList<RefUpdateContext> getOpenedContexts() {
+    return ImmutableList.copyOf(getCurrent());
+  }
+
+  /** Checks if there is an open context of the given type. */
+  public static boolean hasOpen(RefUpdateType type) {
+    return getCurrent().stream().anyMatch(ctx -> ctx.getUpdateType() == type);
+  }
+
+  private final RefUpdateType updateType;
+
+  private RefUpdateContext(RefUpdateType updateType) {
+    this.updateType = updateType;
+  }
+
+  protected RefUpdateContext() {
+    this(RefUpdateType.OTHER);
+  }
+
+  protected static final Deque<RefUpdateContext> getCurrent() {
+    Deque<RefUpdateContext> result = current.get();
+    if (result == null) {
+      result = new ArrayDeque<>();
+      current.set(result);
+    }
+    return result;
+  }
+
+  /**
+   * Returns the type of {@link RefUpdateContext}.
+   *
+   * <p>For descendants, always return {@link RefUpdateType#OTHER}
+   */
+  public final RefUpdateType getUpdateType() {
+    return updateType;
+  }
+
+  /** Closes the current context. */
+  @Override
+  public void close() {
+    Deque<RefUpdateContext> openedContexts = getCurrent();
+    checkState(
+        openedContexts.peekLast() == this, "The current context is different from this context.");
+    openedContexts.removeLast();
+  }
+}
diff --git a/java/com/google/gerrit/server/util/AttentionSetEmail.java b/java/com/google/gerrit/server/util/AttentionSetEmail.java
index 1b36139..948b6e3 100644
--- a/java/com/google/gerrit/server/util/AttentionSetEmail.java
+++ b/java/com/google/gerrit/server/util/AttentionSetEmail.java
@@ -18,7 +18,6 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.server.CurrentUser;
-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;
@@ -86,7 +85,7 @@
     this.asyncSender =
         new AsyncSender(
             requestContext,
-            ctx.getIdentifiedUser(),
+            ctx.getUser(),
             sender,
             messageId,
             ctx.getNotify(change.getId()),
@@ -108,7 +107,7 @@
    */
   private static class AsyncSender implements Runnable, RequestContext {
     private final ThreadLocalRequestContext requestContext;
-    private final IdentifiedUser user;
+    private final CurrentUser user;
     private final AttentionSetSender sender;
     private final MessageIdGenerator.MessageId messageId;
     private final NotifyResolver.Result notify;
@@ -118,7 +117,7 @@
 
     AsyncSender(
         ThreadLocalRequestContext requestContext,
-        IdentifiedUser user,
+        CurrentUser user,
         AttentionSetSender sender,
         MessageIdGenerator.MessageId messageId,
         NotifyResolver.Result notify,
diff --git a/java/com/google/gerrit/server/util/LabelVote.java b/java/com/google/gerrit/server/util/LabelVote.java
index 038fe2c..fbcf3ce 100644
--- a/java/com/google/gerrit/server/util/LabelVote.java
+++ b/java/com/google/gerrit/server/util/LabelVote.java
@@ -19,6 +19,7 @@
 import com.google.auto.value.AutoValue;
 import com.google.common.base.Strings;
 import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.PatchSetApproval;
 
 /** A single vote on a label, consisting of a label name and a value. */
 @AutoValue
@@ -68,6 +69,10 @@
     return new AutoValue_LabelVote(LabelType.checkNameInternal(label), value);
   }
 
+  public static LabelVote createFrom(PatchSetApproval psa) {
+    return create(psa.label(), psa.value());
+  }
+
   public abstract String label();
 
   public abstract short value();
diff --git a/java/com/google/gerrit/server/util/MagicBranch.java b/java/com/google/gerrit/server/util/MagicBranch.java
index 924c288..a5ce108 100644
--- a/java/com/google/gerrit/server/util/MagicBranch.java
+++ b/java/com/google/gerrit/server/util/MagicBranch.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.util;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.Capable;
 import com.google.gerrit.entities.Project;
 import java.io.IOException;
@@ -38,6 +39,7 @@
   }
 
   /** Returns the ref name prefix for a magic branch, {@code null} if the branch is not magic */
+  @Nullable
   public static String getMagicRefNamePrefix(String refName) {
     if (refName.startsWith(NEW_CHANGE)) {
       return NEW_CHANGE;
diff --git a/java/com/google/gerrit/server/util/git/BUILD b/java/com/google/gerrit/server/util/git/BUILD
index bbc6bf0..83a230d 100644
--- a/java/com/google/gerrit/server/util/git/BUILD
+++ b/java/com/google/gerrit/server/util/git/BUILD
@@ -5,6 +5,7 @@
     srcs = glob(["**/*.java"]),
     visibility = ["//visibility:public"],
     deps = [
+        "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/entities",
         "//lib:jgit",
         "//lib/flogger:api",
diff --git a/java/com/google/gerrit/server/util/git/SubmoduleSectionParser.java b/java/com/google/gerrit/server/util/git/SubmoduleSectionParser.java
index 97132a3..201a9b7 100644
--- a/java/com/google/gerrit/server/util/git/SubmoduleSectionParser.java
+++ b/java/com/google/gerrit/server/util/git/SubmoduleSectionParser.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.util.git;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.SubmoduleSubscription;
@@ -65,6 +66,7 @@
     return parsedSubscriptions;
   }
 
+  @Nullable
   private SubmoduleSubscription parse(String id) {
     final String url = config.getString("submodule", id, "url");
     final String path = config.getString("submodule", id, "path");
diff --git a/java/com/google/gerrit/server/validators/AssigneeValidationListener.java b/java/com/google/gerrit/server/validators/AssigneeValidationListener.java
deleted file mode 100644
index 514125f..0000000
--- a/java/com/google/gerrit/server/validators/AssigneeValidationListener.java
+++ /dev/null
@@ -1,32 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.validators;
-
-import com.google.gerrit.entities.Account;
-import com.google.gerrit.entities.Change;
-import com.google.gerrit.extensions.annotations.ExtensionPoint;
-
-/** Listener to provide validation of assignees. */
-@ExtensionPoint
-public interface AssigneeValidationListener {
-  /**
-   * Invoked by Gerrit before the assignee of a change is modified.
-   *
-   * @param change the change on which the assignee is changed
-   * @param assignee the new assignee. Null if removed
-   * @throws ValidationException if validation fails
-   */
-  void validateAssignee(Change change, Account assignee) throws ValidationException;
-}
diff --git a/java/com/google/gerrit/sshd/BaseCommand.java b/java/com/google/gerrit/sshd/BaseCommand.java
index 547aff3..a77ada4 100644
--- a/java/com/google/gerrit/sshd/BaseCommand.java
+++ b/java/com/google/gerrit/sshd/BaseCommand.java
@@ -544,6 +544,7 @@
     }
 
     @Override
+    @Nullable
     public String getRemoteName() {
       return null;
     }
diff --git a/java/com/google/gerrit/sshd/Commands.java b/java/com/google/gerrit/sshd/Commands.java
index b6d3401..5d641a0 100644
--- a/java/com/google/gerrit/sshd/Commands.java
+++ b/java/com/google/gerrit/sshd/Commands.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.sshd;
 
 import com.google.auto.value.AutoAnnotation;
+import com.google.gerrit.common.Nullable;
 import com.google.inject.Key;
 import java.lang.annotation.Annotation;
 import org.apache.sshd.server.command.Command;
@@ -78,6 +79,7 @@
     return false;
   }
 
+  @Nullable
   static CommandName parentOf(CommandName name) {
     if (name instanceof NestedCommandNameImpl) {
       return ((NestedCommandNameImpl) name).parent;
diff --git a/java/com/google/gerrit/sshd/DatabasePubKeyAuth.java b/java/com/google/gerrit/sshd/DatabasePubKeyAuth.java
index 6997d96..401d31e 100644
--- a/java/com/google/gerrit/sshd/DatabasePubKeyAuth.java
+++ b/java/com/google/gerrit/sshd/DatabasePubKeyAuth.java
@@ -22,6 +22,7 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.common.io.BaseEncoding;
 import com.google.gerrit.common.FileUtil;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.PeerDaemonUser;
 import com.google.gerrit.server.account.AccountSshKey;
@@ -169,6 +170,7 @@
     return p.keys;
   }
 
+  @Nullable
   private SshKeyCacheEntry find(Iterable<SshKeyCacheEntry> keyList, PublicKey suppliedKey) {
     for (SshKeyCacheEntry k : keyList) {
       if (k.match(suppliedKey)) {
diff --git a/java/com/google/gerrit/sshd/SshAutoRegisterModuleGenerator.java b/java/com/google/gerrit/sshd/SshAutoRegisterModuleGenerator.java
index 5b6d8f9..7adcd24 100644
--- a/java/com/google/gerrit/sshd/SshAutoRegisterModuleGenerator.java
+++ b/java/com/google/gerrit/sshd/SshAutoRegisterModuleGenerator.java
@@ -19,6 +19,7 @@
 
 import com.google.common.collect.LinkedListMultimap;
 import com.google.common.collect.ListMultimap;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.annotations.Export;
 import com.google.gerrit.server.plugins.InvalidPluginException;
 import com.google.gerrit.server.plugins.ModuleGenerator;
@@ -84,6 +85,7 @@
     listeners.put(tl, clazz);
   }
 
+  @Nullable
   @Override
   public Module create() throws InvalidPluginException {
     checkState(command != null, "pluginName must be provided");
diff --git a/java/com/google/gerrit/sshd/SshPluginStarterCallback.java b/java/com/google/gerrit/sshd/SshPluginStarterCallback.java
index 55ecdfe..f807b19 100644
--- a/java/com/google/gerrit/sshd/SshPluginStarterCallback.java
+++ b/java/com/google/gerrit/sshd/SshPluginStarterCallback.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.sshd;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.server.DynamicOptions;
 import com.google.gerrit.server.plugins.Plugin;
@@ -61,6 +62,7 @@
     }
   }
 
+  @Nullable
   private Provider<Command> load(Plugin plugin) {
     if (plugin.getSshInjector() != null) {
       Key<Command> key = Commands.key(plugin.getName());
diff --git a/java/com/google/gerrit/sshd/commands/CreateAccountCommand.java b/java/com/google/gerrit/sshd/commands/CreateAccountCommand.java
index 4da55e2..2e29203 100644
--- a/java/com/google/gerrit/sshd/commands/CreateAccountCommand.java
+++ b/java/com/google/gerrit/sshd/commands/CreateAccountCommand.java
@@ -17,6 +17,7 @@
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.common.collect.Lists;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
@@ -87,6 +88,7 @@
     }
   }
 
+  @Nullable
   private String readSshKey() throws IOException {
     if (sshKey == null) {
       return null;
diff --git a/java/com/google/gerrit/sshd/commands/ReviewCommand.java b/java/com/google/gerrit/sshd/commands/ReviewCommand.java
index e0805c0..143b060 100644
--- a/java/com/google/gerrit/sshd/commands/ReviewCommand.java
+++ b/java/com/google/gerrit/sshd/commands/ReviewCommand.java
@@ -56,6 +56,7 @@
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.LinkedHashMap;
+import java.util.Locale;
 import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
@@ -358,7 +359,7 @@
   }
 
   private static String asOptionName(LabelType type) {
-    return "--" + type.getName().toLowerCase();
+    return "--" + type.getName().toLowerCase(Locale.US);
   }
 
   private static Option newApproveOption(LabelType type, String usage) {
diff --git a/java/com/google/gerrit/sshd/commands/ShowQueue.java b/java/com/google/gerrit/sshd/commands/ShowQueue.java
index 4254e5b..00361ad 100644
--- a/java/com/google/gerrit/sshd/commands/ShowQueue.java
+++ b/java/com/google/gerrit/sshd/commands/ShowQueue.java
@@ -134,7 +134,9 @@
       switch (task.state) {
         case DONE:
         case CANCELLED:
+        case STARTING:
         case RUNNING:
+        case STOPPING:
         case READY:
           start = format(task.state);
           break;
@@ -204,8 +206,12 @@
         return "....... done";
       case CANCELLED:
         return "..... killed";
+      case STOPPING:
+        return "... stopping";
       case RUNNING:
         return "";
+      case STARTING:
+        return "starting ...";
       case READY:
         return "waiting ....";
       case SLEEPING:
diff --git a/java/com/google/gerrit/testing/BUILD b/java/com/google/gerrit/testing/BUILD
index e5234fe..81a6443 100644
--- a/java/com/google/gerrit/testing/BUILD
+++ b/java/com/google/gerrit/testing/BUILD
@@ -5,7 +5,10 @@
     testonly = True,
     srcs = glob(
         ["**/*.java"],
-        exclude = ["AssertableExecutorService.java"],
+        exclude = [
+            "AssertableExecutorService.java",
+            "TestActionRefUpdateContext.java",
+        ],
     ),
     visibility = ["//visibility:public"],
     exports = [
@@ -40,6 +43,7 @@
         "//java/com/google/gerrit/server/restapi",
         "//java/com/google/gerrit/server/schema",
         "//java/com/google/gerrit/server/util/time",
+        "//java/com/google/gerrit/testing:test-ref-update-context",
         "//lib:guava",
         "//lib:h2",
         "//lib:jgit",
@@ -47,6 +51,7 @@
         "//lib:junit",
         "//lib/auto:auto-value",
         "//lib/auto:auto-value-annotations",
+        "//lib/errorprone:annotations",
         "//lib/flogger:api",
         "//lib/guice",
         "//lib/guice:guice-servlet",
@@ -66,3 +71,14 @@
         "//lib/truth",
     ],
 )
+
+java_library(
+    name = "test-ref-update-context",
+    testonly = True,
+    srcs = ["TestActionRefUpdateContext.java"],
+    visibility = ["//visibility:public"],
+    deps = [
+        "//java/com/google/gerrit/server",
+        "//lib/errorprone:annotations",
+    ],
+)
diff --git a/java/com/google/gerrit/testing/FakeAccountPatchReviewStore.java b/java/com/google/gerrit/testing/FakeAccountPatchReviewStore.java
new file mode 100644
index 0000000..1533aeb
--- /dev/null
+++ b/java/com/google/gerrit/testing/FakeAccountPatchReviewStore.java
@@ -0,0 +1,141 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.testing;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableSet;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.extensions.events.LifecycleListener;
+import com.google.gerrit.extensions.registration.DynamicItem;
+import com.google.gerrit.lifecycle.LifecycleModule;
+import com.google.gerrit.server.change.AccountPatchReviewStore;
+import com.google.inject.Singleton;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+
+/**
+ * An implementation of the {@link AccountPatchReviewStore} that's only used in tests. This
+ * implementation stores reviewed files in memory.
+ */
+@Singleton
+public class FakeAccountPatchReviewStore implements AccountPatchReviewStore, LifecycleListener {
+
+  private final Set<Entity> store = new HashSet<>();
+
+  @Override
+  public void start() {}
+
+  @Override
+  public void stop() {}
+
+  public static class FakeAccountPatchReviewStoreModule extends LifecycleModule {
+    @Override
+    protected void configure() {
+      DynamicItem.bind(binder(), AccountPatchReviewStore.class)
+          .to(FakeAccountPatchReviewStore.class);
+      listener().to(FakeAccountPatchReviewStore.class);
+    }
+  }
+
+  @AutoValue
+  abstract static class Entity {
+    abstract PatchSet.Id psId();
+
+    abstract Account.Id accountId();
+
+    abstract String path();
+
+    static Entity create(PatchSet.Id psId, Account.Id accountId, String path) {
+      return new AutoValue_FakeAccountPatchReviewStore_Entity(psId, accountId, path);
+    }
+  }
+
+  @Override
+  @CanIgnoreReturnValue
+  public boolean markReviewed(PatchSet.Id psId, Account.Id accountId, String path) {
+    synchronized (store) {
+      Entity entity = Entity.create(psId, accountId, path);
+      return store.add(entity);
+    }
+  }
+
+  @Override
+  public void markReviewed(PatchSet.Id psId, Account.Id accountId, Collection<String> paths) {
+    paths.forEach(path -> markReviewed(psId, accountId, path));
+  }
+
+  @Override
+  public void clearReviewed(PatchSet.Id psId, Account.Id accountId, String path) {
+    synchronized (store) {
+      store.remove(Entity.create(psId, accountId, path));
+    }
+  }
+
+  @Override
+  public void clearReviewed(PatchSet.Id psId) {
+    synchronized (store) {
+      List<Entity> toRemove = new ArrayList<>();
+      for (Entity entity : store) {
+        if (entity.psId().equals(psId)) {
+          toRemove.add(entity);
+        }
+      }
+      store.removeAll(toRemove);
+    }
+  }
+
+  @Override
+  public void clearReviewed(Change.Id changeId) {
+    synchronized (store) {
+      List<Entity> toRemove = new ArrayList<>();
+      for (Entity entity : store) {
+        if (entity.psId().changeId().equals(changeId)) {
+          toRemove.add(entity);
+        }
+      }
+      store.removeAll(toRemove);
+    }
+  }
+
+  @Override
+  public Optional<PatchSetWithReviewedFiles> findReviewed(PatchSet.Id psId, Account.Id accountId) {
+    synchronized (store) {
+      int matchedPsNumber = -1;
+      Optional<PatchSetWithReviewedFiles> result = Optional.empty();
+      for (Entity entity : store) {
+        if (entity.accountId() != accountId || !entity.psId().changeId().equals(psId.changeId())) {
+          continue;
+        }
+        int entityPsNumber = Integer.parseInt(entity.psId().getId());
+        if (entityPsNumber <= psId.get() && entityPsNumber > matchedPsNumber) {
+          matchedPsNumber = entityPsNumber;
+          result =
+              Optional.of(
+                  PatchSetWithReviewedFiles.create(
+                      PatchSet.id(psId.changeId(), matchedPsNumber),
+                      ImmutableSet.of(entity.path())));
+        }
+      }
+      return result;
+    }
+  }
+}
diff --git a/java/com/google/gerrit/testing/GerritTestName.java b/java/com/google/gerrit/testing/GerritTestName.java
index d287837..14493b6 100644
--- a/java/com/google/gerrit/testing/GerritTestName.java
+++ b/java/com/google/gerrit/testing/GerritTestName.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.testing;
 
 import com.google.common.base.CharMatcher;
+import java.util.Locale;
 import org.junit.BeforeClass;
 import org.junit.rules.TestName;
 import org.junit.rules.TestRule;
@@ -30,7 +31,7 @@
   }
 
   public String getSanitizedMethodName() {
-    String name = delegate.getMethodName().toLowerCase();
+    String name = delegate.getMethodName().toLowerCase(Locale.US);
     name =
         CharMatcher.inRange('a', 'z')
             .or(CharMatcher.inRange('A', 'Z'))
diff --git a/java/com/google/gerrit/testing/InMemoryModule.java b/java/com/google/gerrit/testing/InMemoryModule.java
index d18571b..0002030 100644
--- a/java/com/google/gerrit/testing/InMemoryModule.java
+++ b/java/com/google/gerrit/testing/InMemoryModule.java
@@ -38,6 +38,7 @@
 import com.google.gerrit.metrics.DisabledMetricMaker;
 import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.server.CacheRefreshExecutor;
+import com.google.gerrit.server.DefaultRefLogIdentityProvider;
 import com.google.gerrit.server.FanOutExecutor;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.GerritPersonIdentProvider;
@@ -46,6 +47,7 @@
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.api.GerritApiModule;
 import com.google.gerrit.server.api.PluginApiModule;
+import com.google.gerrit.server.api.projects.ProjectQueryBuilderModule;
 import com.google.gerrit.server.audit.AuditModule;
 import com.google.gerrit.server.cache.h2.H2CacheModule;
 import com.google.gerrit.server.cache.mem.DefaultMemoryCacheModule;
@@ -84,6 +86,7 @@
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.PerThreadRequestScope;
 import com.google.gerrit.server.git.WorkQueue;
+import com.google.gerrit.server.git.WorkQueue.WorkQueueModule;
 import com.google.gerrit.server.group.testing.TestGroupBackend;
 import com.google.gerrit.server.index.account.AccountSchemaDefinitions;
 import com.google.gerrit.server.index.account.AllAccountsIndexer;
@@ -192,6 +195,8 @@
     AuthConfig authConfig = cfgInjector.getInstance(AuthConfig.class);
     install(new AuthModule(authConfig));
     install(new GerritApiModule());
+    install(new ProjectQueryBuilderModule());
+    install(new DefaultRefLogIdentityProvider.Module());
     factory(PluginUser.Factory.class);
     install(new PluginApiModule());
     install(new DefaultPermissionBackendModule());
@@ -200,6 +205,7 @@
     install(new AuditModule());
     install(new SubscriptionGraphModule());
     install(new SuperprojectUpdateSubmissionListenerModule());
+    install(new WorkQueueModule());
 
     bindScope(RequestScoped.class, PerThreadRequestScope.REQUEST);
 
diff --git a/java/com/google/gerrit/testing/InMemoryRepositoryManager.java b/java/com/google/gerrit/testing/InMemoryRepositoryManager.java
index 2051ae3..8c87405 100644
--- a/java/com/google/gerrit/testing/InMemoryRepositoryManager.java
+++ b/java/com/google/gerrit/testing/InMemoryRepositoryManager.java
@@ -14,20 +14,57 @@
 
 package com.google.gerrit.testing;
 
+import static com.google.common.base.Preconditions.checkState;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.ACCOUNTS_UPDATE;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.BAN_COMMIT;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.BRANCH_MODIFICATION;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.DIRECT_PUSH;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.GPG_KEYS_MODIFICATION;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.GROUPS_UPDATE;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.HEAD_MODIFICATION;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.INIT_REPO;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.MERGE_CHANGE;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.OFFLINE_OPERATION;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.PLUGIN;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.REPO_SEQ;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.TAG_MODIFICATION;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.VERSIONED_META_DATA_CHANGE;
+import static java.util.stream.Collectors.toList;
+
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Sets;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.Project.NameKey;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.gpg.PublicKeyStore;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.RepositoryCaseMismatchException;
+import com.google.gerrit.server.update.context.RefUpdateContext;
+import com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType;
 import com.google.inject.Inject;
+import java.util.AbstractMap.SimpleImmutableEntry;
+import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.Collections;
 import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
 import java.util.Map;
+import java.util.Map.Entry;
 import java.util.NavigableSet;
+import java.util.Optional;
+import java.util.function.Predicate;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.internal.storage.dfs.DfsObjDatabase;
+import org.eclipse.jgit.internal.storage.dfs.DfsReftableBatchRefUpdate;
 import org.eclipse.jgit.internal.storage.dfs.DfsRepository;
 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.ProgressMonitor;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.transport.ReceiveCommand;
 
 /** Repository manager that uses in-memory repositories. */
 public class InMemoryRepositoryManager implements GitRepositoryManager {
@@ -56,6 +93,140 @@
       setPerformsAtomicTransactions(true);
     }
 
+    /** Validates that a given ref is updated within the expected context. */
+    private static class RefUpdateContextValidator {
+      /**
+       * A configured singleton for ref context validation.
+       *
+       * <p>Each ref must match no more than 1 special ref from the list below. If ref is not
+       * matched to any special ref predicate, then it is checked against the standard rules - check
+       * the code of the {@link #validateRefUpdateContext} for details.
+       */
+      public static final RefUpdateContextValidator INSTANCE =
+          new RefUpdateContextValidator()
+              .addSpecialRef(RefNames::isSequenceRef, REPO_SEQ)
+              .addSpecialRef(RefNames.HEAD::equals, HEAD_MODIFICATION)
+              .addSpecialRef(RefNames::isRefsChanges, CHANGE_MODIFICATION, MERGE_CHANGE)
+              .addSpecialRef(RefNames::isAutoMergeRef, CHANGE_MODIFICATION)
+              .addSpecialRef(RefNames::isRefsEdit, CHANGE_MODIFICATION, MERGE_CHANGE)
+              .addSpecialRef(RefNames::isTagRef, TAG_MODIFICATION)
+              .addSpecialRef(RefNames::isRejectCommitsRef, BAN_COMMIT)
+              .addSpecialRef(
+                  name -> RefNames.isRefsUsers(name) && !RefNames.isRefsEdit(name),
+                  VERSIONED_META_DATA_CHANGE,
+                  ACCOUNTS_UPDATE,
+                  MERGE_CHANGE)
+              .addSpecialRef(
+                  RefNames::isConfigRef,
+                  VERSIONED_META_DATA_CHANGE,
+                  BRANCH_MODIFICATION,
+                  MERGE_CHANGE)
+              .addSpecialRef(RefNames::isExternalIdRef, VERSIONED_META_DATA_CHANGE, ACCOUNTS_UPDATE)
+              .addSpecialRef(PublicKeyStore.REFS_GPG_KEYS::equals, GPG_KEYS_MODIFICATION)
+              .addSpecialRef(RefNames::isRefsDraftsComments, CHANGE_MODIFICATION)
+              .addSpecialRef(RefNames::isRefsStarredChanges, CHANGE_MODIFICATION)
+              // A user can create a change for updating a group and then merge it.
+              // The GroupsIT#pushToGroupBranchForReviewForNonAllUsersRepoAndSubmit test verifies
+              // this scenario.
+              .addSpecialRef(RefNames::isGroupRef, GROUPS_UPDATE, MERGE_CHANGE);
+
+      private List<Entry<Predicate<String>, ImmutableList<RefUpdateType>>> specialRefs =
+          new ArrayList<>();
+
+      private RefUpdateContextValidator() {}
+
+      public void validateRefUpdateContext(ReceiveCommand cmd) {
+        String refName = cmd.getRefName();
+
+        if (RefUpdateContextCollector.enabled()) {
+          RefUpdateContextCollector.register(refName, RefUpdateContext.getOpenedContexts());
+        }
+        if (TestActionRefUpdateContext.isOpen()
+            || RefUpdateContext.hasOpen(OFFLINE_OPERATION)
+            || RefUpdateContext.hasOpen(INIT_REPO)
+            || RefUpdateContext.hasOpen(DIRECT_PUSH)) {
+          // The action can touch any refs in these contexts.
+          return;
+        }
+
+        Optional<ImmutableList<RefUpdateType>> allowedRefUpdateTypes =
+            RefUpdateContextValidator.INSTANCE.getAllowedRefUpdateTypes(refName);
+
+        if (allowedRefUpdateTypes.isPresent()) {
+          checkState(
+              allowedRefUpdateTypes.get().stream().anyMatch(RefUpdateContext::hasOpen)
+                  || isTestRepoCall(),
+              "Special ref '%s' is updated outside of the expected operation. Wrap code in the correct RefUpdateContext or fix allowed update types",
+              refName);
+          return;
+        }
+        // It is not one of the special ref - update is possible only within specific contexts.
+        checkState(
+            RefUpdateContext.hasOpen(MERGE_CHANGE)
+                || RefUpdateContext.hasOpen(RefUpdateType.BRANCH_MODIFICATION)
+                || RefUpdateContext.hasOpen(RefUpdateType.UPDATE_SUPERPROJECT)
+                // Plugin can update any ref
+                || RefUpdateContext.hasOpen(PLUGIN)
+                || isTestRepoCall(),
+            "Ordinary ref '%s' is updated outside of the expected operation. Wrap code in the correct RefUpdateContext or add the ref as a special ref.",
+            refName);
+      }
+
+      private RefUpdateContextValidator addSpecialRef(
+          Predicate<String> refNamePredicate, RefUpdateType... validRefUpdateTypes) {
+        specialRefs.add(
+            new SimpleImmutableEntry<>(
+                refNamePredicate, ImmutableList.copyOf(validRefUpdateTypes)));
+        return this;
+      }
+
+      private Optional<ImmutableList<RefUpdateType>> getAllowedRefUpdateTypes(String refName) {
+        List<ImmutableList<RefUpdateType>> allowedTypes =
+            specialRefs.stream()
+                .filter(entry -> entry.getKey().test(refName))
+                .map(Entry::getValue)
+                .collect(toList());
+        checkState(
+            allowedTypes.size() <= 1,
+            "refName matches more than 1 predicate. Please fix the specialRefs list, so each reference has no more than one match.");
+        if (allowedTypes.size() == 0) {
+          return Optional.empty();
+        }
+        return Optional.of(allowedTypes.get(0));
+      }
+
+      /**
+       * Returns true if a ref is updated using one of the method in {@link
+       * org.eclipse.jgit.junit.TestRepository}.
+       *
+       * <p>The {@link org.eclipse.jgit.junit.TestRepository} used only in tests and allows to
+       * change refs directly. Wrapping each usage in a test context requires a lot of modification,
+       * so instead we allow any ref updates, which are made using through this class.
+       */
+      private boolean isTestRepoCall() {
+        return Arrays.stream(Thread.currentThread().getStackTrace())
+            .anyMatch(elem -> elem.getClassName().equals("org.eclipse.jgit.junit.TestRepository"));
+      }
+    }
+
+    @Override
+    protected MemRefDatabase createRefDatabase() {
+      return new MemRefDatabase() {
+        @Override
+        public BatchRefUpdate newBatchUpdate() {
+          DfsObjDatabase odb = getRepository().getObjectDatabase();
+          return new DfsReftableBatchRefUpdate(this, odb) {
+            @Override
+            public void execute(RevWalk rw, ProgressMonitor pm, List<String> options) {
+              getCommands().stream()
+                  .forEach(RefUpdateContextValidator.INSTANCE::validateRefUpdateContext);
+              super.execute(rw, pm, options);
+            }
+          };
+        }
+      };
+    }
+
     @Override
     public Description getDescription() {
       return (Description) super.getDescription();
@@ -133,6 +304,6 @@
   }
 
   private static String normalize(Project.NameKey name) {
-    return name.get().toLowerCase();
+    return name.get().toLowerCase(Locale.US);
   }
 }
diff --git a/java/com/google/gerrit/testing/IndexVersions.java b/java/com/google/gerrit/testing/IndexVersions.java
index 3810707..2e843fe 100644
--- a/java/com/google/gerrit/testing/IndexVersions.java
+++ b/java/com/google/gerrit/testing/IndexVersions.java
@@ -26,6 +26,7 @@
 import com.google.gerrit.index.SchemaDefinitions;
 import java.util.ArrayList;
 import java.util.List;
+import java.util.Locale;
 import java.util.Map;
 import java.util.NavigableMap;
 import org.eclipse.jgit.lib.Config;
@@ -73,13 +74,14 @@
    *     if any of the specified schema versions doesn't exist
    */
   public static <V> ImmutableList<Integer> get(SchemaDefinitions<V> schemaDef) {
-    String envVar = schemaDef.getName().toUpperCase() + "_INDEX_VERSIONS";
+    String envVar = schemaDef.getName().toUpperCase(Locale.US) + "_INDEX_VERSIONS";
     String value = System.getenv(envVar);
     if (!Strings.isNullOrEmpty(value)) {
       return get(schemaDef, "env variable " + envVar, value);
     }
 
-    String systemProperty = "gerrit.index." + schemaDef.getName().toLowerCase() + ".versions";
+    String systemProperty =
+        "gerrit.index." + schemaDef.getName().toLowerCase(Locale.US) + ".versions";
     value = System.getProperty(systemProperty);
     return get(schemaDef, "system property " + systemProperty, value);
   }
@@ -138,7 +140,10 @@
                 i -> {
                   Config cfg = baseConfig;
                   cfg.setInt(
-                      "index", "lucene", schemaDef.getName().toLowerCase() + "TestVersion", i);
+                      "index",
+                      "lucene",
+                      schemaDef.getName().toLowerCase(Locale.US) + "TestVersion",
+                      i);
                   return cfg;
                 }));
   }
diff --git a/java/com/google/gerrit/testing/RefUpdateContextCollector.java b/java/com/google/gerrit/testing/RefUpdateContextCollector.java
new file mode 100644
index 0000000..88232d2
--- /dev/null
+++ b/java/com/google/gerrit/testing/RefUpdateContextCollector.java
@@ -0,0 +1,92 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.testing;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.server.update.context.RefUpdateContext;
+import com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType;
+import java.util.AbstractMap.SimpleImmutableEntry;
+import java.util.Map.Entry;
+import java.util.concurrent.ConcurrentLinkedQueue;
+import org.junit.rules.TestRule;
+import org.junit.runner.Description;
+import org.junit.runners.model.Statement;
+
+/**
+ * Stores information about each updated ref in tests, together with associated RefUpdateContext(s).
+ *
+ * <p>This is a {@link TestRule}, which clears the stored data after each test.
+ *
+ * <p>Usage:
+ *
+ * <pre>{@code
+ * class ...Test {
+ *  \@Rule
+ *  public RefUpdateContextCollector refContextCollector = new RefUpdateContextCollector();
+ *  ...
+ *  public void test() {
+ *    // some actions
+ *    assertThat(refContextCollector.getContextsByRef("refs/heads/main")).contains(...)
+ *  }
+ *  }
+ * }</pre>
+ */
+public class RefUpdateContextCollector implements TestRule {
+  private static ConcurrentLinkedQueue<Entry<String, ImmutableList<RefUpdateContext>>>
+      touchedRefsWithContexts = null;
+
+  @Override
+  public Statement apply(Statement statement, Description description) {
+    return new Statement() {
+      @Override
+      public void evaluate() throws Throwable {
+        try {
+          touchedRefsWithContexts = new ConcurrentLinkedQueue<>();
+          statement.evaluate();
+        } finally {
+          touchedRefsWithContexts = null;
+        }
+      }
+    };
+  }
+
+  public static boolean enabled() {
+    return touchedRefsWithContexts != null;
+  }
+
+  public static void register(String refName, ImmutableList<RefUpdateContext> openedContexts) {
+    if (touchedRefsWithContexts == null) {
+      return;
+    }
+    touchedRefsWithContexts.add(new SimpleImmutableEntry<>(refName, openedContexts));
+  }
+
+  public ImmutableList<String> getRefsByUpdateType(RefUpdateType refUpdateType) {
+    return touchedRefsWithContexts.stream()
+        .filter(
+            entry ->
+                entry.getValue().stream()
+                    .map(RefUpdateContext::getUpdateType)
+                    .anyMatch(refUpdateType::equals))
+        .map(Entry::getKey)
+        .collect(toImmutableList());
+  }
+
+  public void clear() {
+    touchedRefsWithContexts.clear();
+  }
+}
diff --git a/java/com/google/gerrit/testing/SshMode.java b/java/com/google/gerrit/testing/SshMode.java
index 41633bd..60bd5187 100644
--- a/java/com/google/gerrit/testing/SshMode.java
+++ b/java/com/google/gerrit/testing/SshMode.java
@@ -18,6 +18,7 @@
 
 import com.google.common.base.Enums;
 import com.google.common.base.Strings;
+import java.util.Locale;
 
 /**
  * Whether to enable/disable tests using SSH by inspecting the global environment.
@@ -43,7 +44,7 @@
     if (Strings.isNullOrEmpty(value)) {
       return YES;
     }
-    value = value.toUpperCase();
+    value = value.toUpperCase(Locale.US);
     SshMode mode = Enums.getIfPresent(SshMode.class, value).orNull();
     if (!Strings.isNullOrEmpty(System.getenv(ENV_VAR))) {
       checkArgument(
diff --git a/java/com/google/gerrit/testing/TestActionRefUpdateContext.java b/java/com/google/gerrit/testing/TestActionRefUpdateContext.java
new file mode 100644
index 0000000..23ec9aa
--- /dev/null
+++ b/java/com/google/gerrit/testing/TestActionRefUpdateContext.java
@@ -0,0 +1,73 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.testing;
+
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
+import com.google.gerrit.server.update.context.RefUpdateContext;
+
+/**
+ * Marks ref updates as a test actions.
+ *
+ * <p>This class should be used in tests only to wrap a portion of test code which directly modifies
+ * references. Usage:
+ *
+ * <pre>{@code
+ * import static ...TestActionRefUpdateContext.openTestRefUpdateContext();
+ *
+ * try(RefUpdateContext ctx=openTestRefUpdateContext()) {
+ *   // Some test code, which modifies a reference.
+ * }
+ * }</pre>
+ *
+ * or
+ *
+ * <pre>{@code
+ * import static ...TestActionRefUpdateContext.testRefAction;
+ *
+ * testRefAction(() -> {doSomethingWithRef()});
+ * T result = testRefAction(() -> { return doSomethingWithRef()});
+ * }</pre>
+ */
+public final class TestActionRefUpdateContext extends RefUpdateContext {
+  public static boolean isOpen() {
+    return getCurrent().stream().anyMatch(ctx -> ctx instanceof TestActionRefUpdateContext);
+  }
+
+  public static TestActionRefUpdateContext openTestRefUpdateContext() {
+    return open(new TestActionRefUpdateContext());
+  }
+
+  @CanIgnoreReturnValue
+  public static <V, E extends Exception> V testRefAction(CallableWithException<V, E> c) throws E {
+    try (RefUpdateContext ctx = openTestRefUpdateContext()) {
+      return c.call();
+    }
+  }
+
+  public static <E extends Exception> void testRefAction(RunnableWithException<E> c) throws E {
+    try (RefUpdateContext ctx = openTestRefUpdateContext()) {
+      c.run();
+    }
+  }
+
+  public interface CallableWithException<V, E extends Exception> {
+    V call() throws E;
+  }
+
+  @FunctionalInterface
+  public interface RunnableWithException<E extends Exception> {
+    void run() throws E;
+  }
+}
diff --git a/java/com/google/gerrit/testing/TestChanges.java b/java/com/google/gerrit/testing/TestChanges.java
index 4a97bc5..5a3c755 100644
--- a/java/com/google/gerrit/testing/TestChanges.java
+++ b/java/com/google/gerrit/testing/TestChanges.java
@@ -32,6 +32,7 @@
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Injector;
 import java.time.ZoneId;
+import java.util.Optional;
 import java.util.concurrent.atomic.AtomicInteger;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.ObjectId;
@@ -72,20 +73,26 @@
         .id(id)
         .commitId(ObjectId.fromString(revision))
         .uploader(userId)
+        .realUploader(userId)
         .createdOn(TimeUtil.now())
         .build();
   }
 
   public static ChangeUpdate newUpdate(
-      Injector injector, Change c, CurrentUser user, boolean shouldExist) throws Exception {
+      Injector injector, Change c, Optional<CurrentUser> user, boolean shouldExist)
+      throws Exception {
     injector =
         injector.createChildInjector(
             new FactoryModule() {
               @Override
               public void configure() {
-                bind(CurrentUser.class).toInstance(user);
+                if (user.isPresent()) {
+                  // user may be already bound in injector
+                  bind(CurrentUser.class).toInstance(user.get());
+                }
               }
             });
+    CurrentUser currentUser = injector.getProvider(CurrentUser.class).get();
     ChangeUpdate update =
         injector
             .getInstance(ChangeUpdate.Factory.class)
@@ -93,7 +100,7 @@
                 new ChangeNotes(
                         injector.getInstance(AbstractChangeNotes.Args.class), c, shouldExist, null)
                     .load(),
-                user,
+                currentUser,
                 TimeUtil.now(),
                 Ordering.natural());
 
@@ -109,7 +116,9 @@
     try (Repository repo = repoManager.openRepository(c.getProject());
         TestRepository<Repository> tr = new TestRepository<>(repo)) {
       PersonIdent ident =
-          user.asIdentifiedUser().newCommitterIdent(update.getWhen(), ZoneId.systemDefault());
+          currentUser
+              .asIdentifiedUser()
+              .newCommitterIdent(update.getWhen(), ZoneId.systemDefault());
       TestRepository<Repository>.CommitBuilder cb =
           tr.commit()
               .author(ident)
diff --git a/java/com/google/gerrit/util/cli/ApiProtocolBufferGenerator.java b/java/com/google/gerrit/util/cli/ApiProtocolBufferGenerator.java
new file mode 100644
index 0000000..27e4b17
--- /dev/null
+++ b/java/com/google/gerrit/util/cli/ApiProtocolBufferGenerator.java
@@ -0,0 +1,176 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.util.cli;
+
+import com.google.common.base.CaseFormat;
+import com.google.common.reflect.ClassPath;
+import java.lang.reflect.Field;
+import java.lang.reflect.ParameterizedType;
+import java.sql.Timestamp;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+/**
+ * Utility to generate Protocol Buffers (*.proto) files from existing POJO API types.
+ *
+ * <p>Usage:
+ *
+ * <ul>
+ *   <li>Print proto representation of all API objects: {@code bazelisk run
+ *       java/com/google/gerrit/util/cli:protogen}
+ * </ul>
+ */
+public class ApiProtocolBufferGenerator {
+  private static String NOTICE =
+      "// Copyright (C) 2023 The Android Open Source Project\n"
+          + "//\n"
+          + "// Licensed under the Apache License, Version 2.0 (the \"License\");\n"
+          + "// you may not use this file except in compliance with the License.\n"
+          + "// You may obtain a copy of the License at\n"
+          + "//\n"
+          + "// http://www.apache.org/licenses/LICENSE-2.0\n"
+          + "//\n"
+          + "// Unless required by applicable law or agreed to in writing, software\n"
+          + "// distributed under the License is distributed on an \"AS IS\" BASIS,\n"
+          + "// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n"
+          + "// See the License for the specific language governing permissions and\n"
+          + "// limitations under the License.";
+
+  private static String PACKAGE = "com.google.gerrit.extensions.common";
+
+  public static void main(String[] args) {
+    try {
+      ClassPath.from(ClassLoader.getSystemClassLoader()).getAllClasses().stream()
+          .filter(c -> c.getPackageName().equalsIgnoreCase(PACKAGE))
+          .filter(c -> c.getName().endsWith("Input") || c.getName().endsWith("Info"))
+          .map(clazz -> clazz.load())
+          .forEach(ApiProtocolBufferGenerator::exportSingleClass);
+    } catch (Exception e) {
+      System.err.println(e);
+    }
+  }
+
+  private static void exportSingleClass(Class<?> clazz) {
+    StringBuilder proto = new StringBuilder(NOTICE);
+    proto.append("\n\nsyntax = \"proto3\";");
+    proto.append("\n\npackage gerrit.api;");
+    proto.append("\n\noption java_package = \"" + PACKAGE + "\";");
+
+    int fieldNumber = 1;
+
+    proto.append("\n\n\nmessage " + clazz.getSimpleName() + " {\n");
+
+    for (Field f : clazz.getFields()) {
+      Class<?> type = f.getType();
+
+      if (type.isAssignableFrom(List.class)) {
+        ParameterizedType list = (ParameterizedType) f.getGenericType();
+        Class<?> genericType = (Class<?>) list.getActualTypeArguments()[0];
+        String protoType =
+            protoType(genericType)
+                .orElseThrow(() -> new IllegalStateException("unknown type: " + genericType));
+        proto.append(
+            String.format(
+                "repeated %s %s = %d;\n", protoType, protoName(f.getName()), fieldNumber));
+      } else if (type.isAssignableFrom(Map.class)) {
+        ParameterizedType map = (ParameterizedType) f.getGenericType();
+        Class<?> key = (Class<?>) map.getActualTypeArguments()[0];
+        if (map.getActualTypeArguments()[1] instanceof ParameterizedType) {
+          // TODO: This is list multimap which proto doesn't support. Move to
+          // it's own types.
+          proto.append(
+              "reserved "
+                  + fieldNumber
+                  + "; // TODO(hiesel): Add support for map<?,repeated <?>>\n");
+        } else {
+          Class<?> value = (Class<?>) map.getActualTypeArguments()[1];
+          String keyProtoType =
+              protoType(key).orElseThrow(() -> new IllegalStateException("unknown type: " + key));
+          String valueProtoType =
+              protoType(value)
+                  .orElseThrow(() -> new IllegalStateException("unknown type: " + value));
+          proto.append(
+              String.format(
+                  "map<%s,%s> %s = %d;\n",
+                  keyProtoType, valueProtoType, protoName(f.getName()), fieldNumber));
+        }
+      } else if (protoType(type).isPresent()) {
+        proto.append(
+            String.format(
+                "%s %s = %d;\n", protoType(type).get(), protoName(f.getName()), fieldNumber));
+      } else {
+        proto.append(
+            "reserved "
+                + fieldNumber
+                + "; // TODO(hiesel): Add support for "
+                + type.getName()
+                + "\n");
+      }
+      fieldNumber++;
+    }
+    proto.append("}");
+
+    System.out.println(proto);
+  }
+
+  private static Optional<String> protoType(Class<?> type) {
+    if (isInt(type)) {
+      return Optional.of("int32");
+    } else if (isLong(type)) {
+      return Optional.of("int64");
+    } else if (isChar(type)) {
+      return Optional.of("string");
+    } else if (isShort(type)) {
+      return Optional.of("int32");
+    } else if (isShort(type)) {
+      return Optional.of("int32");
+    } else if (isBoolean(type)) {
+      return Optional.of("bool");
+    } else if (type.isAssignableFrom(String.class)) {
+      return Optional.of("string");
+    } else if (type.isAssignableFrom(Timestamp.class)) {
+      // See https://gerrit-review.googlesource.com/Documentation/rest-api.html#timestamp
+      return Optional.of("string");
+    } else if (type.getPackageName().startsWith("com.google.gerrit.extensions")) {
+      return Optional.of("gerrit.api." + type.getSimpleName());
+    }
+    return Optional.empty();
+  }
+
+  private static boolean isInt(Class<?> type) {
+    return type.isAssignableFrom(Integer.class) || type.isAssignableFrom(int.class);
+  }
+
+  private static boolean isLong(Class<?> type) {
+    return type.isAssignableFrom(Long.class) || type.isAssignableFrom(long.class);
+  }
+
+  private static boolean isChar(Class<?> type) {
+    return type.isAssignableFrom(Character.class) || type.isAssignableFrom(char.class);
+  }
+
+  private static boolean isShort(Class<?> type) {
+    return type.isAssignableFrom(Short.class) || type.isAssignableFrom(short.class);
+  }
+
+  private static boolean isBoolean(Class<?> type) {
+    return type.isAssignableFrom(Boolean.class) || type.isAssignableFrom(boolean.class);
+  }
+
+  private static String protoName(String name) {
+    return CaseFormat.LOWER_CAMEL.to(CaseFormat.LOWER_UNDERSCORE, name);
+  }
+}
diff --git a/java/com/google/gerrit/util/cli/BUILD b/java/com/google/gerrit/util/cli/BUILD
index ebcc67e..b464f32 100644
--- a/java/com/google/gerrit/util/cli/BUILD
+++ b/java/com/google/gerrit/util/cli/BUILD
@@ -2,7 +2,10 @@
 
 java_library(
     name = "cli",
-    srcs = glob(["**/*.java"]),
+    srcs = glob(
+        ["**/*.java"],
+        exclude = ["ApiProtocolBufferGenerator.java"],
+    ),
     visibility = ["//visibility:public"],
     deps = [
         "//java/com/google/gerrit/common:annotations",
@@ -14,3 +17,15 @@
         "//lib/guice:guice-assistedinject",
     ],
 )
+
+# Util to generate *.proto files from *Info and *Input objects
+java_binary(
+    name = "protogen",
+    srcs = ["ApiProtocolBufferGenerator.java"],
+    main_class = "com.google.gerrit.util.cli.ApiProtocolBufferGenerator",
+    deps = [
+        "//java/com/google/gerrit/extensions:api",
+        "//lib:guava",
+        "//lib:protobuf",
+    ],
+)
diff --git a/java/com/google/gerrit/util/cli/CmdLineParser.java b/java/com/google/gerrit/util/cli/CmdLineParser.java
index 7c42797..a37c027 100644
--- a/java/com/google/gerrit/util/cli/CmdLineParser.java
+++ b/java/com/google/gerrit/util/cli/CmdLineParser.java
@@ -44,6 +44,7 @@
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Lists;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.io.StringWriter;
@@ -567,6 +568,7 @@
      *     and it needed to be exposed.
      */
     @SuppressWarnings("rawtypes")
+    @Nullable
     public OptionHandler findOptionByName(String name) {
       for (OptionHandler h : optionsList) {
         if (h.option instanceof NamedOptionDef) {
diff --git a/javatests/com/google/gerrit/acceptance/ProjectResetterTest.java b/javatests/com/google/gerrit/acceptance/ProjectResetterTest.java
index b6e5b74..33e6692 100644
--- a/javatests/com/google/gerrit/acceptance/ProjectResetterTest.java
+++ b/javatests/com/google/gerrit/acceptance/ProjectResetterTest.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.TestActionRefUpdateContext.openTestRefUpdateContext;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.only;
 import static org.mockito.Mockito.verify;
@@ -32,6 +33,7 @@
 import com.google.gerrit.server.index.account.AccountIndexer;
 import com.google.gerrit.server.index.group.GroupIndexer;
 import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.update.context.RefUpdateContext;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.testing.InMemoryRepositoryManager;
 import com.google.gerrit.testing.TestTimeUtil;
@@ -286,14 +288,16 @@
   }
 
   private Ref createRef(Repository repo, String ref) throws IOException {
-    try (ObjectInserter oi = repo.newObjectInserter();
-        RevWalk rw = new RevWalk(repo)) {
-      ObjectId emptyCommit = createCommit(repo);
-      RefUpdate updateRef = repo.updateRef(ref);
-      updateRef.setExpectedOldObjectId(ObjectId.zeroId());
-      updateRef.setNewObjectId(emptyCommit);
-      assertThat(updateRef.update(rw)).isEqualTo(RefUpdate.Result.NEW);
-      return repo.exactRef(ref);
+    try (RefUpdateContext ctx = openTestRefUpdateContext()) {
+      try (ObjectInserter oi = repo.newObjectInserter();
+          RevWalk rw = new RevWalk(repo)) {
+        ObjectId emptyCommit = createCommit(repo);
+        RefUpdate updateRef = repo.updateRef(ref);
+        updateRef.setExpectedOldObjectId(ObjectId.zeroId());
+        updateRef.setNewObjectId(emptyCommit);
+        assertThat(updateRef.update(rw)).isEqualTo(RefUpdate.Result.NEW);
+        return repo.exactRef(ref);
+      }
     }
   }
 
@@ -302,17 +306,19 @@
   }
 
   private Ref updateRef(Repository repo, Ref ref) throws IOException {
-    try (ObjectInserter oi = repo.newObjectInserter();
-        RevWalk rw = new RevWalk(repo)) {
-      ObjectId emptyCommit = createCommit(repo);
-      RefUpdate updateRef = repo.updateRef(ref.getName());
-      updateRef.setExpectedOldObjectId(ref.getObjectId());
-      updateRef.setNewObjectId(emptyCommit);
-      updateRef.setForceUpdate(true);
-      assertThat(updateRef.update(rw)).isEqualTo(RefUpdate.Result.FORCED);
-      Ref updatedRef = repo.exactRef(ref.getName());
-      assertThat(updatedRef.getObjectId()).isNotEqualTo(ref.getObjectId());
-      return updatedRef;
+    try (RefUpdateContext ctx = openTestRefUpdateContext()) {
+      try (ObjectInserter oi = repo.newObjectInserter();
+          RevWalk rw = new RevWalk(repo)) {
+        ObjectId emptyCommit = createCommit(repo);
+        RefUpdate updateRef = repo.updateRef(ref.getName());
+        updateRef.setExpectedOldObjectId(ref.getObjectId());
+        updateRef.setNewObjectId(emptyCommit);
+        updateRef.setForceUpdate(true);
+        assertThat(updateRef.update(rw)).isEqualTo(RefUpdate.Result.FORCED);
+        Ref updatedRef = repo.exactRef(ref.getName());
+        assertThat(updatedRef.getObjectId()).isNotEqualTo(ref.getObjectId());
+        return updatedRef;
+      }
     }
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/TestMetricMakerTest.java b/javatests/com/google/gerrit/acceptance/TestMetricMakerTest.java
new file mode 100644
index 0000000..3464d21
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/TestMetricMakerTest.java
@@ -0,0 +1,202 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.metrics.Counter0;
+import com.google.gerrit.metrics.Counter1;
+import com.google.gerrit.metrics.Counter2;
+import com.google.gerrit.metrics.Counter3;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.Field;
+import org.junit.Before;
+import org.junit.Test;
+
+/** Tests for {@link TestMetricMaker}. */
+public class TestMetricMakerTest {
+  private TestMetricMaker testMetricMaker = new TestMetricMaker();
+
+  @Before
+  public void setUp() {
+    testMetricMaker.reset();
+  }
+
+  @Test
+  public void counter0() throws Exception {
+    String counterName = "test_counter";
+    Counter0 counter = testMetricMaker.newCounter(counterName, new Description("Test Counter"));
+    assertThat(testMetricMaker.getCount(counterName)).isEqualTo(0);
+
+    counter.increment();
+    assertThat(testMetricMaker.getCount(counterName)).isEqualTo(1);
+
+    counter.incrementBy(/* value= */ 3);
+    assertThat(testMetricMaker.getCount(counterName)).isEqualTo(4);
+  }
+
+  @Test
+  public void counter1_booleanField() throws Exception {
+    String counterName = "test_counter";
+    Counter1<Boolean> counter =
+        testMetricMaker.newCounter(
+            counterName,
+            new Description("Test Counter"),
+            Field.ofBoolean("boolean_field", (metadataBuilder, booleanField) -> {}).build());
+    assertThat(testMetricMaker.getCount(counterName, true)).isEqualTo(0);
+    assertThat(testMetricMaker.getCount(counterName, false)).isEqualTo(0);
+
+    counter.increment(/* field1= */ true);
+    assertThat(testMetricMaker.getCount(counterName, true)).isEqualTo(1);
+    assertThat(testMetricMaker.getCount(counterName, false)).isEqualTo(0);
+
+    counter.incrementBy(/* field1= */ true, /* value= */ 3);
+    assertThat(testMetricMaker.getCount(counterName, true)).isEqualTo(4);
+    assertThat(testMetricMaker.getCount(counterName, false)).isEqualTo(0);
+
+    counter.increment(/* field1= */ false);
+    assertThat(testMetricMaker.getCount(counterName, true)).isEqualTo(4);
+    assertThat(testMetricMaker.getCount(counterName, false)).isEqualTo(1);
+
+    counter.incrementBy(/* field1= */ false, /* value= */ 4);
+    assertThat(testMetricMaker.getCount(counterName, true)).isEqualTo(4);
+    assertThat(testMetricMaker.getCount(counterName, false)).isEqualTo(5);
+
+    assertThat(testMetricMaker.getCount(counterName)).isEqualTo(0);
+  }
+
+  @Test
+  public void counter1_stringField() throws Exception {
+    String counterName = "test_counter";
+    Counter1<String> counter =
+        testMetricMaker.newCounter(
+            counterName,
+            new Description("Test Counter"),
+            Field.ofString("string_field", (metadataBuilder, stringField) -> {}).build());
+    assertThat(testMetricMaker.getCount(counterName, "foo")).isEqualTo(0);
+    assertThat(testMetricMaker.getCount(counterName, "bar")).isEqualTo(0);
+
+    counter.increment(/* field1= */ "foo");
+    assertThat(testMetricMaker.getCount(counterName, "foo")).isEqualTo(1);
+    assertThat(testMetricMaker.getCount(counterName, "bar")).isEqualTo(0);
+
+    counter.incrementBy(/* field1= */ "foo", /* value= */ 3);
+    assertThat(testMetricMaker.getCount(counterName, "foo")).isEqualTo(4);
+    assertThat(testMetricMaker.getCount(counterName, "bar")).isEqualTo(0);
+
+    counter.increment(/* field1= */ "bar");
+    assertThat(testMetricMaker.getCount(counterName, "foo")).isEqualTo(4);
+    assertThat(testMetricMaker.getCount(counterName, "bar")).isEqualTo(1);
+
+    counter.incrementBy(/* field1= */ "bar", /* value= */ 4);
+    assertThat(testMetricMaker.getCount(counterName, "foo")).isEqualTo(4);
+    assertThat(testMetricMaker.getCount(counterName, "bar")).isEqualTo(5);
+
+    assertThat(testMetricMaker.getCount(counterName)).isEqualTo(0);
+  }
+
+  @Test
+  public void counter2() throws Exception {
+    String counterName = "test_counter";
+    Counter2<Boolean, String> counter =
+        testMetricMaker.newCounter(
+            counterName,
+            new Description("Test Counter"),
+            Field.ofBoolean("boolean_field", (metadataBuilder, booleanField) -> {}).build(),
+            Field.ofString("string_field", (metadataBuilder, stringField) -> {}).build());
+    assertThat(testMetricMaker.getCount(counterName, true, "foo")).isEqualTo(0);
+    assertThat(testMetricMaker.getCount(counterName, false, "foo")).isEqualTo(0);
+
+    counter.increment(/* field1= */ true, /* field2= */ "foo");
+    assertThat(testMetricMaker.getCount(counterName, true, "foo")).isEqualTo(1);
+    assertThat(testMetricMaker.getCount(counterName, false, "foo")).isEqualTo(0);
+
+    counter.incrementBy(/* field1= */ true, /* field2= */ "foo", /* value= */ 3);
+    assertThat(testMetricMaker.getCount(counterName, true, "foo")).isEqualTo(4);
+    assertThat(testMetricMaker.getCount(counterName, false, "foo")).isEqualTo(0);
+
+    counter.increment(/* field1= */ false, /* field2= */ "foo");
+    assertThat(testMetricMaker.getCount(counterName, true, "foo")).isEqualTo(4);
+    assertThat(testMetricMaker.getCount(counterName, false, "foo")).isEqualTo(1);
+
+    counter.incrementBy(/* field1= */ false, /* field2= */ "foo", /* value= */ 4);
+    assertThat(testMetricMaker.getCount(counterName, true, "foo")).isEqualTo(4);
+    assertThat(testMetricMaker.getCount(counterName, false, "foo")).isEqualTo(5);
+
+    counter.increment(/* field1= */ true, /* field2= */ "bar");
+    assertThat(testMetricMaker.getCount(counterName, true, "foo")).isEqualTo(4);
+    assertThat(testMetricMaker.getCount(counterName, true, "bar")).isEqualTo(1);
+
+    counter.incrementBy(/* field1= */ true, /* field2= */ "bar", /* value= */ 5);
+    assertThat(testMetricMaker.getCount(counterName, true, "foo")).isEqualTo(4);
+    assertThat(testMetricMaker.getCount(counterName, true, "bar")).isEqualTo(6);
+
+    assertThat(testMetricMaker.getCount(counterName)).isEqualTo(0);
+    assertThat(testMetricMaker.getCount(counterName, true)).isEqualTo(0);
+    assertThat(testMetricMaker.getCount(counterName, false)).isEqualTo(0);
+  }
+
+  @Test
+  public void counter3() throws Exception {
+    String counterName = "test_counter";
+    Counter3<Boolean, String, Integer> counter =
+        testMetricMaker.newCounter(
+            counterName,
+            new Description("Test Counter"),
+            Field.ofBoolean("boolean_field", (metadataBuilder, booleanField) -> {}).build(),
+            Field.ofString("string_field", (metadataBuilder, stringField) -> {}).build(),
+            Field.ofInteger("integer_field", (metadataBuilder, stringField) -> {}).build());
+    assertThat(testMetricMaker.getCount(counterName, true, "foo", 0)).isEqualTo(0);
+    assertThat(testMetricMaker.getCount(counterName, false, "foo", 0)).isEqualTo(0);
+
+    counter.increment(/* field1= */ true, /* field2= */ "foo", /* field3= */ 0);
+    assertThat(testMetricMaker.getCount(counterName, true, "foo", 0)).isEqualTo(1);
+    assertThat(testMetricMaker.getCount(counterName, false, "foo", 0)).isEqualTo(0);
+
+    counter.incrementBy(/* field1= */ true, /* field2= */ "foo", /* field3= */ 0, /* value= */ 3);
+    assertThat(testMetricMaker.getCount(counterName, true, "foo", 0)).isEqualTo(4);
+    assertThat(testMetricMaker.getCount(counterName, false, "foo", 0)).isEqualTo(0);
+
+    counter.increment(/* field1= */ false, /* field2= */ "foo", /* field3= */ 0);
+    assertThat(testMetricMaker.getCount(counterName, true, "foo", 0)).isEqualTo(4);
+    assertThat(testMetricMaker.getCount(counterName, false, "foo", 0)).isEqualTo(1);
+
+    counter.incrementBy(/* field1= */ false, /* field2= */ "foo", /* field3= */ 0, /* value= */ 4);
+    assertThat(testMetricMaker.getCount(counterName, true, "foo", 0)).isEqualTo(4);
+    assertThat(testMetricMaker.getCount(counterName, false, "foo", 0)).isEqualTo(5);
+
+    counter.increment(/* field1= */ true, /* field2= */ "bar", /* field3= */ 0);
+    assertThat(testMetricMaker.getCount(counterName, true, "foo", 0)).isEqualTo(4);
+    assertThat(testMetricMaker.getCount(counterName, true, "bar", 0)).isEqualTo(1);
+
+    counter.incrementBy(/* field1= */ true, /* field2= */ "bar", /* field3= */ 0, /* value= */ 5);
+    assertThat(testMetricMaker.getCount(counterName, true, "foo", 0)).isEqualTo(4);
+    assertThat(testMetricMaker.getCount(counterName, true, "bar", 0)).isEqualTo(6);
+
+    counter.increment(/* field1= */ false, /* field2= */ "foo", /* field3= */ 1);
+    assertThat(testMetricMaker.getCount(counterName, true, "foo", 0)).isEqualTo(4);
+    assertThat(testMetricMaker.getCount(counterName, false, "foo", 1)).isEqualTo(1);
+
+    counter.incrementBy(/* field1= */ false, /* field2= */ "foo", /* field3= */ 1, /* value= */ 6);
+    assertThat(testMetricMaker.getCount(counterName, true, "foo", 0)).isEqualTo(4);
+    assertThat(testMetricMaker.getCount(counterName, false, "foo", 1)).isEqualTo(7);
+
+    assertThat(testMetricMaker.getCount(counterName)).isEqualTo(0);
+    assertThat(testMetricMaker.getCount(counterName, true)).isEqualTo(0);
+    assertThat(testMetricMaker.getCount(counterName, false)).isEqualTo(0);
+    assertThat(testMetricMaker.getCount(counterName, true, "foo")).isEqualTo(0);
+    assertThat(testMetricMaker.getCount(counterName, false, "foo")).isEqualTo(0);
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
index c2b779b..dd04200 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
@@ -42,6 +42,7 @@
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static com.google.gerrit.testing.TestActionRefUpdateContext.testRefAction;
 import static com.google.gerrit.truth.ConfigSubject.assertThat;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.Objects.requireNonNull;
@@ -78,6 +79,7 @@
 import com.google.gerrit.acceptance.testsuite.account.TestSshKeys;
 import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.acceptance.testsuite.request.SshSessionFactory;
 import com.google.gerrit.common.Nullable;
@@ -130,10 +132,12 @@
 import com.google.gerrit.httpd.CacheBasedWebSession;
 import com.google.gerrit.server.ExceptionHook;
 import com.google.gerrit.server.ServerInitiated;
+import com.google.gerrit.server.account.AccountControl;
 import com.google.gerrit.server.account.AccountProperties;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.account.AccountsUpdate;
 import com.google.gerrit.server.account.Emails;
+import com.google.gerrit.server.account.GroupMembership;
 import com.google.gerrit.server.account.VersionedAuthorizedKeys;
 import com.google.gerrit.server.account.externalids.DuplicateExternalIdKeyException;
 import com.google.gerrit.server.account.externalids.ExternalId;
@@ -144,6 +148,7 @@
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
+import com.google.gerrit.server.group.testing.TestGroupBackend;
 import com.google.gerrit.server.index.account.AccountIndexer;
 import com.google.gerrit.server.index.account.StalenessChecker;
 import com.google.gerrit.server.notedb.Sequences;
@@ -175,6 +180,7 @@
 import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.concurrent.atomic.AtomicInteger;
 import java.util.stream.Collectors;
+import java.util.stream.StreamSupport;
 import javax.servlet.http.HttpServletResponse;
 import org.apache.http.HttpResponse;
 import org.apache.http.client.ClientProtocolException;
@@ -241,6 +247,7 @@
   @Inject private ExternalIdKeyFactory externalIdKeyFactory;
   @Inject private ExternalIdFactory externalIdFactory;
   @Inject private AuthConfig authConfig;
+  @Inject private AccountControl.Factory accountControlFactory;
 
   @Inject protected Emails emails;
 
@@ -258,7 +265,7 @@
       if (ref != null) {
         RefUpdate ru = repo.updateRef(REFS_GPG_KEYS);
         ru.setForceUpdate(true);
-        assertThat(ru.delete()).isEqualTo(RefUpdate.Result.FORCED);
+        testRefAction(() -> assertThat(ru.delete()).isEqualTo(RefUpdate.Result.FORCED));
       }
     }
   }
@@ -984,14 +991,15 @@
   }
 
   @Test
-  public void cannotGetEmailsOfOtherAccountWithoutModifyAccount() throws Exception {
+  public void cannotGetEmailsOfOtherAccountWithoutViewSecondaryEmailsAndWithoutModifyAccount()
+      throws Exception {
     String email = "preferred2@example.com";
     TestAccount foo = accountCreator.create(name("foo"), email, "Foo", null);
 
     requestScopeOperations.setApiUser(user.id());
     AuthException thrown =
         assertThrows(AuthException.class, () -> gApi.accounts().id(foo.id().get()).getEmails());
-    assertThat(thrown).hasMessageThat().contains("modify account not permitted");
+    assertThat(thrown).hasMessageThat().contains("view secondary emails not permitted");
   }
 
   @Test
@@ -1882,7 +1890,7 @@
 
       // Mark first key as invalid
       assertThat(info.get(0).valid).isTrue();
-      authorizedKeys.markKeyInvalid(admin.id(), 1);
+      testRefAction(() -> authorizedKeys.markKeyInvalid(admin.id(), 1));
       info = gApi.accounts().self().listSshKeys();
       assertThat(info).hasSize(2);
       assertThat(info.get(0).seq).isEqualTo(1);
@@ -2064,6 +2072,7 @@
     return newEmailInput(email, true);
   }
 
+  @Nullable
   private String getMetaId(Account.Id accountId) throws IOException {
     try (Repository repo = repoManager.openRepository(allUsers);
         RevWalk rw = new RevWalk(repo);
@@ -2427,79 +2436,88 @@
 
     // Manually updating the user ref makes the index document stale.
     String userRef = RefNames.refsUsers(accountId);
-    try (Repository repo = repoManager.openRepository(allUsers);
-        ObjectInserter oi = repo.newObjectInserter();
-        RevWalk rw = new RevWalk(repo)) {
-      RevCommit commit = rw.parseCommit(repo.exactRef(userRef).getObjectId());
+    testRefAction(
+        () -> {
+          try (Repository repo = repoManager.openRepository(allUsers);
+              ObjectInserter oi = repo.newObjectInserter();
+              RevWalk rw = new RevWalk(repo)) {
+            RevCommit commit = rw.parseCommit(repo.exactRef(userRef).getObjectId());
 
-      PersonIdent ident = new PersonIdent(serverIdent.get(), TimeUtil.now());
-      CommitBuilder cb = new CommitBuilder();
-      cb.setTreeId(commit.getTree());
-      cb.setCommitter(ident);
-      cb.setAuthor(ident);
-      cb.setMessage(commit.getFullMessage());
-      ObjectId emptyCommit = oi.insert(cb);
-      oi.flush();
+            PersonIdent ident = new PersonIdent(serverIdent.get(), TimeUtil.now());
+            CommitBuilder cb = new CommitBuilder();
+            cb.setTreeId(commit.getTree());
+            cb.setCommitter(ident);
+            cb.setAuthor(ident);
+            cb.setMessage(commit.getFullMessage());
+            ObjectId emptyCommit = oi.insert(cb);
+            oi.flush();
 
-      RefUpdate updateRef = repo.updateRef(userRef);
-      updateRef.setExpectedOldObjectId(commit.toObjectId());
-      updateRef.setNewObjectId(emptyCommit);
-      assertThat(updateRef.forceUpdate()).isEqualTo(RefUpdate.Result.FORCED);
-    }
+            RefUpdate updateRef = repo.updateRef(userRef);
+            updateRef.setExpectedOldObjectId(commit.toObjectId());
+            updateRef.setNewObjectId(emptyCommit);
+            assertThat(updateRef.forceUpdate()).isEqualTo(RefUpdate.Result.FORCED);
+          }
+        });
     assertStaleAccountAndReindex(accountId);
 
     // Manually inserting/updating/deleting an external ID of the user makes the index document
     // stale.
     try (Repository repo = repoManager.openRepository(allUsers)) {
-      ExternalIdNotes extIdNotes =
-          ExternalIdNotes.load(
-              allUsers,
-              repo,
-              externalIdFactory,
-              authConfig.isUserNameCaseInsensitiveMigrationMode());
+      testRefAction(
+          () -> {
+            ExternalIdNotes extIdNotes =
+                ExternalIdNotes.load(
+                    allUsers,
+                    repo,
+                    externalIdFactory,
+                    authConfig.isUserNameCaseInsensitiveMigrationMode());
 
-      ExternalId.Key key = externalIdKeyFactory.create("foo", "foo");
-      extIdNotes.insert(externalIdFactory.create(key, accountId));
-      try (MetaDataUpdate update = metaDataUpdateFactory.create(allUsers)) {
-        extIdNotes.commit(update);
-      }
-      assertStaleAccountAndReindex(accountId);
+            ExternalId.Key key = externalIdKeyFactory.create("foo", "foo");
+            extIdNotes.insert(externalIdFactory.create(key, accountId));
+            try (MetaDataUpdate update = metaDataUpdateFactory.create(allUsers)) {
+              extIdNotes.commit(update);
+            }
+            assertStaleAccountAndReindex(accountId);
 
-      extIdNotes =
-          ExternalIdNotes.load(
-              allUsers,
-              repo,
-              externalIdFactory,
-              authConfig.isUserNameCaseInsensitiveMigrationMode());
-      extIdNotes.upsert(externalIdFactory.createWithEmail(key, accountId, "foo@example.com"));
-      try (MetaDataUpdate update = metaDataUpdateFactory.create(allUsers)) {
-        extIdNotes.commit(update);
-      }
-      assertStaleAccountAndReindex(accountId);
+            extIdNotes =
+                ExternalIdNotes.load(
+                    allUsers,
+                    repo,
+                    externalIdFactory,
+                    authConfig.isUserNameCaseInsensitiveMigrationMode());
+            extIdNotes.upsert(externalIdFactory.createWithEmail(key, accountId, "foo@example.com"));
+            try (MetaDataUpdate update = metaDataUpdateFactory.create(allUsers)) {
+              extIdNotes.commit(update);
+            }
+            assertStaleAccountAndReindex(accountId);
 
-      extIdNotes =
-          ExternalIdNotes.load(
-              allUsers,
-              repo,
-              externalIdFactory,
-              authConfig.isUserNameCaseInsensitiveMigrationMode());
-      extIdNotes.delete(accountId, key);
-      try (MetaDataUpdate update = metaDataUpdateFactory.create(allUsers)) {
-        extIdNotes.commit(update);
-      }
+            extIdNotes =
+                ExternalIdNotes.load(
+                    allUsers,
+                    repo,
+                    externalIdFactory,
+                    authConfig.isUserNameCaseInsensitiveMigrationMode());
+            extIdNotes.delete(accountId, key);
+            try (MetaDataUpdate update = metaDataUpdateFactory.create(allUsers)) {
+              extIdNotes.commit(update);
+            }
+          });
       assertStaleAccountAndReindex(accountId);
     }
 
     // Manually delete account
-    try (Repository repo = repoManager.openRepository(allUsers);
-        RevWalk rw = new RevWalk(repo)) {
-      RevCommit commit = rw.parseCommit(repo.exactRef(userRef).getObjectId());
-      RefUpdate updateRef = repo.updateRef(userRef);
-      updateRef.setExpectedOldObjectId(commit.toObjectId());
-      updateRef.setNewObjectId(ObjectId.zeroId());
-      updateRef.setForceUpdate(true);
-      assertThat(updateRef.delete()).isEqualTo(RefUpdate.Result.FORCED);
-    }
+    testRefAction(
+        () -> {
+          try (Repository repo = repoManager.openRepository(allUsers);
+              RevWalk rw = new RevWalk(repo)) {
+            RevCommit commit = rw.parseCommit(repo.exactRef(userRef).getObjectId());
+            RefUpdate updateRef = repo.updateRef(userRef);
+            updateRef.setExpectedOldObjectId(commit.toObjectId());
+            updateRef.setNewObjectId(ObjectId.zeroId());
+            updateRef.setForceUpdate(true);
+            assertThat(updateRef.delete()).isEqualTo(RefUpdate.Result.FORCED);
+          }
+        });
     assertStaleAccountAndReindex(accountId);
   }
 
@@ -2882,8 +2900,12 @@
 
     requestScopeOperations.setApiUser(user.id());
     assertThrows(ResourceNotFoundException.class, () -> gApi.accounts().id("secondary"));
+    assertThrows(
+        ResourceNotFoundException.class, () -> gApi.accounts().id("secondary@example.com"));
     requestScopeOperations.setApiUser(admin.id());
     assertThat(gApi.accounts().id("secondary").get()._accountId).isEqualTo(foo.id().get());
+    assertThat(gApi.accounts().id("secondary@example.com").get()._accountId)
+        .isEqualTo(foo.id().get());
   }
 
   @Test
@@ -3117,6 +3139,139 @@
         .isNotEqualTo(updatedUserState.account().metaId());
   }
 
+  @Test
+  @GerritConfig(name = "accounts.visibility", value = "SAME_GROUP")
+  public void accountsCanSeeEachOtherThroughASharedExternalGroupOnlyWhenTheGroupIsMentionedInAcls()
+      throws Exception {
+    TestAccount user2 = accountCreator.user2();
+
+    // user and user2 cannot see each other because they do not share a Gerrit internal group
+    assertThat(
+            accountControlFactory.get(identifiedUserFactory.create(user.id())).canSee(user2.id()))
+        .isFalse();
+    assertThat(
+            accountControlFactory.get(identifiedUserFactory.create(user2.id())).canSee(user.id()))
+        .isFalse();
+
+    // Configure an external group backend that has a single group that contains all users.
+    TestGroupBackend testGroupBackend = createTestGroupBackendWithAllUsersGroup("AllUsers");
+    try (ExtensionRegistry.Registration registration =
+        extensionRegistry.newRegistration().add(testGroupBackend)) {
+      // user and user2 cannot see each other although the external AllUsers group contains both
+      // users. That's because this group is not detected as relevant and hence its memberships are
+      // not checked.
+      assertThat(
+              accountControlFactory.get(identifiedUserFactory.create(user.id())).canSee(user2.id()))
+          .isFalse();
+      assertThat(
+              accountControlFactory.get(identifiedUserFactory.create(user2.id())).canSee(user.id()))
+          .isFalse();
+
+      // Add ACL for the external group.
+      projectOperations
+          .project(project)
+          .forUpdate()
+          .add(
+              TestProjectUpdate.allowLabel("Code-Review")
+                  .range(0, 1)
+                  .ref("refs/heads/*")
+                  .group(AccountGroup.uuid(TestGroupBackend.PREFIX + "AllUsers"))
+                  .build())
+          .update();
+
+      // user and user2 can now see each other because the external AllUsers group that contains
+      // both users is guessed as relevant now that permissions are assigned to this group.
+      assertThat(
+              accountControlFactory.get(identifiedUserFactory.create(user.id())).canSee(user2.id()))
+          .isTrue();
+      assertThat(
+              accountControlFactory.get(identifiedUserFactory.create(user2.id())).canSee(user.id()))
+          .isTrue();
+    }
+  }
+
+  @Test
+  @GerritConfig(name = "accounts.visibility", value = "SAME_GROUP")
+  @GerritConfig(name = "groups.relevantGroup", value = "testbackend:AllUsers")
+  public void accountsCanSeeEachOtherThroughASharedExternalGroupThatIsConfiguredAsRelevant()
+      throws Exception {
+    TestAccount user2 = accountCreator.user2();
+
+    // user and user2 cannot see each other because they do not share a Gerrit internal group
+    assertThat(
+            accountControlFactory.get(identifiedUserFactory.create(user.id())).canSee(user2.id()))
+        .isFalse();
+    assertThat(
+            accountControlFactory.get(identifiedUserFactory.create(user2.id())).canSee(user.id()))
+        .isFalse();
+
+    // Check that the configured relevant group is included into the guessed groups.
+    assertThat(projectCache.guessRelevantGroupUUIDs())
+        .contains(AccountGroup.uuid("testbackend:AllUsers"));
+
+    // Configure an external group backend that has a single group that contains all users.
+    TestGroupBackend testGroupBackend = createTestGroupBackendWithAllUsersGroup("AllUsers");
+    try (ExtensionRegistry.Registration registration =
+        extensionRegistry.newRegistration().add(testGroupBackend)) {
+      // user and user2 can see each other since the external AllUsers that contains both users has
+      // been configured as a relevant group.
+      assertThat(
+              accountControlFactory.get(identifiedUserFactory.create(user.id())).canSee(user2.id()))
+          .isTrue();
+      assertThat(
+              accountControlFactory.get(identifiedUserFactory.create(user2.id())).canSee(user.id()))
+          .isTrue();
+    }
+  }
+
+  private TestGroupBackend createTestGroupBackendWithAllUsersGroup(String nameOfAllUsersGroup)
+      throws IOException {
+    TestGroupBackend testGroupBackend = new TestGroupBackend();
+
+    AccountGroup.UUID allUsersGroupUuid =
+        testGroupBackend.create(nameOfAllUsersGroup).getGroupUUID();
+
+    GroupMembership testGroupMembership =
+        new GroupMembership() {
+          @Override
+          public Set<AccountGroup.UUID> intersection(Iterable<AccountGroup.UUID> groupUuids) {
+            return StreamSupport.stream(groupUuids.spliterator(), /* parallel= */ false)
+                .filter(this::contains)
+                .collect(toSet());
+          }
+
+          @Override
+          public Set<AccountGroup.UUID> getKnownGroups() {
+            // Typically for external group backends it's too expensive to query all groups that the
+            // user is a member of. Instead limit the group membership check to groups that are
+            // guessed to be relevant.
+            return projectCache.guessRelevantGroupUUIDs().stream()
+                // filter out groups of other group backends and groups of this group backend that
+                // don't exist
+                .filter(
+                    uuid -> testGroupBackend.handles(uuid) && testGroupBackend.get(uuid) != null)
+                .collect(toImmutableSet());
+          }
+
+          @Override
+          public boolean containsAnyOf(Iterable<AccountGroup.UUID> groupUuids) {
+            return StreamSupport.stream(groupUuids.spliterator(), /* parallel= */ false)
+                .anyMatch(this::contains);
+          }
+
+          @Override
+          public boolean contains(AccountGroup.UUID groupUuid) {
+            return allUsersGroupUuid.equals(groupUuid);
+          }
+        };
+
+    accounts
+        .allIds()
+        .forEach(accountId -> testGroupBackend.setMembershipsOf(accountId, testGroupMembership));
+
+    return testGroupBackend;
+  }
+
   private void assertExternalIds(Account.Id accountId, ImmutableSet<String> extIds)
       throws Exception {
     assertThat(
@@ -3245,16 +3400,19 @@
   }
 
   private Map<String, GpgKeyInfo> addGpgKey(TestAccount account, String armored) throws Exception {
-    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
-    try (Registration registration =
-        extensionRegistry.newRegistration().add(accountIndexedCounter)) {
-      Map<String, GpgKeyInfo> gpgKeys =
-          gApi.accounts()
-              .id(account.username())
-              .putGpgKeys(ImmutableList.of(armored), ImmutableList.<String>of());
-      accountIndexedCounter.assertReindexOf(gApi.accounts().id(account.username()).get());
-      return gpgKeys;
-    }
+    return testRefAction(
+        () -> {
+          AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
+          try (Registration registration =
+              extensionRegistry.newRegistration().add(accountIndexedCounter)) {
+            Map<String, GpgKeyInfo> gpgKeys =
+                gApi.accounts()
+                    .id(account.username())
+                    .putGpgKeys(ImmutableList.of(armored), ImmutableList.<String>of());
+            accountIndexedCounter.assertReindexOf(gApi.accounts().id(account.username()).get());
+            return gpgKeys;
+          }
+        });
   }
 
   private Map<String, GpgKeyInfo> addGpgKeyNoReindex(String armored) throws Exception {
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIndexerIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIndexerIT.java
index d1258fc..1693411 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIndexerIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIndexerIT.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
+import static com.google.gerrit.testing.TestActionRefUpdateContext.openTestRefUpdateContext;
 
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.api.GerritApi;
@@ -32,6 +33,7 @@
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.index.account.AccountIndexer;
 import com.google.gerrit.server.query.account.InternalAccountQuery;
+import com.google.gerrit.server.update.context.RefUpdateContext;
 import com.google.gerrit.testing.InMemoryTestEnvironment;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -141,16 +143,19 @@
 
   private void updateAccountWithoutCacheOrIndex(Account.Id accountId, AccountDelta accountDelta)
       throws IOException, ConfigInvalidException {
-    try (Repository allUsersRepo = repoManager.openRepository(allUsersName);
-        MetaDataUpdate md =
-            new MetaDataUpdate(GitReferenceUpdated.DISABLED, allUsersName, allUsersRepo)) {
-      PersonIdent ident = serverIdent.get();
-      md.getCommitBuilder().setAuthor(ident);
-      md.getCommitBuilder().setCommitter(ident);
+    try (RefUpdateContext ctx = openTestRefUpdateContext()) {
+      try (Repository allUsersRepo = repoManager.openRepository(allUsersName);
+          MetaDataUpdate md =
+              new MetaDataUpdate(GitReferenceUpdated.DISABLED, allUsersName, allUsersRepo)) {
+        PersonIdent ident = serverIdent.get();
+        md.getCommitBuilder().setAuthor(ident);
+        md.getCommitBuilder().setCommitter(ident);
 
-      AccountConfig accountConfig = new AccountConfig(accountId, allUsersName, allUsersRepo).load();
-      accountConfig.setAccountDelta(accountDelta);
-      accountConfig.commit(md);
+        AccountConfig accountConfig =
+            new AccountConfig(accountId, allUsersName, allUsersRepo).load();
+        accountConfig.setAccountDelta(accountDelta);
+        accountConfig.commit(md);
+      }
     }
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AccountManagerIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AccountManagerIT.java
index 7e23f0e..0d246e3 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/AccountManagerIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AccountManagerIT.java
@@ -18,7 +18,9 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.common.truth.Truth8.assertThat;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_GOOGLE_OAUTH;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static com.google.gerrit.testing.TestActionRefUpdateContext.openTestRefUpdateContext;
 import static java.util.stream.Collectors.toSet;
 
 import com.google.common.collect.ImmutableSet;
@@ -45,6 +47,7 @@
 import com.google.gerrit.server.group.db.GroupsUpdate;
 import com.google.gerrit.server.notedb.Sequences;
 import com.google.gerrit.server.ssh.SshKeyCache;
+import com.google.gerrit.server.update.context.RefUpdateContext;
 import com.google.inject.Inject;
 import com.google.inject.util.Providers;
 import java.util.Optional;
@@ -285,11 +288,13 @@
     // Create orphaned SCHEME_GERRIT external ID.
     Account.Id accountId = Account.id(seq.nextAccountId());
     ExternalId gerritExtId = externalIdFactory.create(gerritExtIdKey, accountId);
-    try (Repository allUsersRepo = repoManager.openRepository(allUsers);
-        MetaDataUpdate md = metaDataUpdateFactory.create(allUsers)) {
-      ExternalIdNotes extIdNotes = extIdNotesFactory.load(allUsersRepo);
-      extIdNotes.insert(gerritExtId);
-      extIdNotes.commit(md);
+    try (RefUpdateContext ctx = openTestRefUpdateContext()) {
+      try (Repository allUsersRepo = repoManager.openRepository(allUsers);
+          MetaDataUpdate md = metaDataUpdateFactory.create(allUsers)) {
+        ExternalIdNotes extIdNotes = extIdNotesFactory.load(allUsersRepo);
+        extIdNotes.insert(gerritExtId);
+        extIdNotes.commit(md);
+      }
     }
 
     AuthRequest who = authRequestFactory.createForUser(username);
@@ -563,6 +568,108 @@
   }
 
   @Test
+  public void errorCreatingOAuthAccountDueToPresentDuplicateUsernameExternalID() throws Exception {
+    String username = "foo";
+    String gerritEmail = "bar@example.com";
+
+    ExternalId.Key gerritExtIdKey = externalIdKeyFactory.create(ExternalId.SCHEME_GERRIT, username);
+    AuthRequest whoGerrit = authRequestFactory.createForUser(username);
+    whoGerrit.setEmailAddress(gerritEmail);
+    AuthResult authResultGerrit = accountManager.authenticate(whoGerrit);
+    assertAuthResultForNewAccount(authResultGerrit, gerritExtIdKey);
+
+    // Check that OAuth externalID is not in use.
+    ExternalId.Key externalExtIdKey = externalIdKeyFactory.create(SCHEME_GOOGLE_OAUTH, username);
+    assertNoSuchExternalIds(externalExtIdKey);
+
+    String googleOAuthEmail = "baz@example.com";
+    AuthRequest whoOAuth = authRequestFactory.createForOAuthUser(username);
+    whoOAuth.setEmailAddress(googleOAuthEmail);
+
+    AccountException thrown =
+        assertThrows(AccountException.class, () -> accountManager.authenticate(whoOAuth));
+    assertThat(thrown).hasMessageThat().contains("Cannot assign external ID \"username:foo\" to");
+  }
+
+  @Test
+  public void errorCreatingOAuthAccountDueToDuplicateEmailExternalIDInNonLDAPExternalId()
+      throws Exception {
+    String username = "foo";
+    String gerritEmail = "foo@example.com";
+
+    ExternalId.Key gerritExtIdKey =
+        externalIdKeyFactory.create(ExternalId.SCHEME_EXTERNAL, username);
+    AuthRequest whoGerrit = authRequestFactory.createForExternalUser(username);
+    whoGerrit.setEmailAddress(gerritEmail);
+    AuthResult authResultGerrit = accountManager.authenticate(whoGerrit);
+    assertAuthResultForNewAccount(authResultGerrit, gerritExtIdKey);
+
+    // Check that OAuth externalID is not in use.
+    ExternalId.Key externalExtIdKey = externalIdKeyFactory.create(SCHEME_GOOGLE_OAUTH, username);
+    assertNoSuchExternalIds(externalExtIdKey);
+
+    String googleOAuthEmail = "foo@example.com";
+    AuthRequest whoOAuth = authRequestFactory.createForOAuthUser(username);
+    whoOAuth.setEmailAddress(googleOAuthEmail);
+
+    AccountException thrown =
+        assertThrows(AccountException.class, () -> accountManager.authenticate(whoOAuth));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("Email 'foo@example.com' in use by another account");
+  }
+
+  @Test
+  public void errorCreatingOAuthAccountDueToDuplicateUsernameIdentityAlreadyInUse()
+      throws Exception {
+    String username = "foo";
+    String gerritEmail = "foo@example.com";
+
+    ExternalId.Key externalExtIdKey =
+        externalIdKeyFactory.create(ExternalId.SCHEME_EXTERNAL, username);
+    AuthRequest whoExternal = authRequestFactory.createForExternalUser(username);
+    whoExternal.setEmailAddress(gerritEmail);
+    AuthResult authResultGerrit = accountManager.authenticate(whoExternal);
+    assertAuthResultForNewAccount(authResultGerrit, externalExtIdKey);
+
+    // Check that OAuth externalID is not in use.
+    ExternalId.Key OAuthExtIdKey = externalIdKeyFactory.create(SCHEME_GOOGLE_OAUTH, username);
+    assertNoSuchExternalIds(OAuthExtIdKey);
+
+    String googleOAuthEmail = "baz@example.com";
+    AuthRequest whoOAuth = authRequestFactory.createForOAuthUser(username);
+    whoExternal.setEmailAddress(googleOAuthEmail);
+
+    AccountException thrown =
+        assertThrows(AccountException.class, () -> accountManager.authenticate(whoOAuth));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("Cannot assign external ID \"username:foo\" to account");
+  }
+
+  @Test
+  public void linkOAuthAccountToLDAPAccountWithEmail() throws Exception {
+    String username = "foo";
+    String email = "foo@example.com";
+    ExternalId.Key gerritExtIdKey = externalIdKeyFactory.create(ExternalId.SCHEME_GERRIT, username);
+    AuthRequest whoGerrit = authRequestFactory.createForUser(username);
+    whoGerrit.setEmailAddress(email);
+    AuthResult authResultGerrit = accountManager.authenticate(whoGerrit);
+    Account.Id accID = authResultGerrit.getAccountId();
+    assertAuthResultForNewAccount(authResultGerrit, gerritExtIdKey);
+    // Check that OAuth externalID is not in use.
+    ExternalId.Key OAuthExtIdKey = externalIdKeyFactory.create(SCHEME_GOOGLE_OAUTH, username);
+    assertNoSuchExternalIds(OAuthExtIdKey);
+
+    AuthRequest whoOAuth = authRequestFactory.createForOAuthUser(username);
+    whoOAuth.setEmailAddress(email);
+    AuthResult authResultOAuth = accountManager.authenticate(whoOAuth);
+    assertAuthResultForExistingAccount(authResultOAuth, accID, OAuthExtIdKey);
+
+    assertThat(authResultOAuth.getAccountId()).isEqualTo(authResultGerrit.getAccountId());
+  }
+
+  @Test
   public void updateExternalIdOnLink() throws Exception {
     // Create an account with a SCHEME_GERRIT external ID and no email
     String username = "foo";
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/BUILD b/javatests/com/google/gerrit/acceptance/api/accounts/BUILD
index 3c605e1..c441402 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/BUILD
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/BUILD
@@ -13,6 +13,7 @@
         "//java/com/google/gerrit/git",
         "//java/com/google/gerrit/mail",
         "//java/com/google/gerrit/server/util/time",
+        "//java/com/google/gerrit/testing:test-ref-update-context",
     ],
 )
 
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java
index 52e2121..59ba00b 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java
@@ -78,7 +78,6 @@
     i.defaultBaseForMerges = DefaultBase.AUTO_MERGE;
     i.disableKeyboardShortcuts = true;
     i.expandInlineDiffs ^= true;
-    i.highlightAssigneeInChangeTable ^= true;
     i.relativeDateInChangeTable ^= true;
     i.sizeBarInChangeTable ^= true;
     i.legacycidInChangeTable ^= true;
diff --git a/javatests/com/google/gerrit/acceptance/api/change/AbandonIT.java b/javatests/com/google/gerrit/acceptance/api/change/AbandonIT.java
index 80431ee..b80ff9b 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/AbandonIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/AbandonIT.java
@@ -46,6 +46,7 @@
 import com.google.gerrit.testing.TestTimeUtil;
 import com.google.inject.Inject;
 import java.util.List;
+import java.util.Locale;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.ObjectId;
@@ -65,7 +66,8 @@
     gApi.changes().id(changeId).abandon();
     ChangeInfo info = get(changeId, MESSAGES);
     assertThat(info.status).isEqualTo(ChangeStatus.ABANDONED);
-    assertThat(Iterables.getLast(info.messages).message.toLowerCase()).contains("abandoned");
+    assertThat(Iterables.getLast(info.messages).message.toLowerCase(Locale.US))
+        .contains("abandoned");
 
     ResourceConflictException thrown =
         assertThrows(ResourceConflictException.class, () -> gApi.changes().id(changeId).abandon());
@@ -82,13 +84,17 @@
 
     ChangeInfo info = get(a.getChangeId(), MESSAGES);
     assertThat(info.status).isEqualTo(ChangeStatus.ABANDONED);
-    assertThat(Iterables.getLast(info.messages).message.toLowerCase()).contains("abandoned");
-    assertThat(Iterables.getLast(info.messages).message.toLowerCase()).contains("deadbeef");
+    assertThat(Iterables.getLast(info.messages).message.toLowerCase(Locale.US))
+        .contains("abandoned");
+    assertThat(Iterables.getLast(info.messages).message.toLowerCase(Locale.US))
+        .contains("deadbeef");
 
     info = get(b.getChangeId(), MESSAGES);
     assertThat(info.status).isEqualTo(ChangeStatus.ABANDONED);
-    assertThat(Iterables.getLast(info.messages).message.toLowerCase()).contains("abandoned");
-    assertThat(Iterables.getLast(info.messages).message.toLowerCase()).contains("deadbeef");
+    assertThat(Iterables.getLast(info.messages).message.toLowerCase(Locale.US))
+        .contains("abandoned");
+    assertThat(Iterables.getLast(info.messages).message.toLowerCase(Locale.US))
+        .contains("deadbeef");
   }
 
   @Test
@@ -292,7 +298,8 @@
     gApi.changes().id(changeId).restore();
     ChangeInfo info = get(changeId, MESSAGES);
     assertThat(info.status).isEqualTo(ChangeStatus.NEW);
-    assertThat(Iterables.getLast(info.messages).message.toLowerCase()).contains("restored");
+    assertThat(Iterables.getLast(info.messages).message.toLowerCase(Locale.US))
+        .contains("restored");
 
     ResourceConflictException thrown =
         assertThrows(ResourceConflictException.class, () -> gApi.changes().id(changeId).restore());
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ApplyPatchIT.java b/javatests/com/google/gerrit/acceptance/api/change/ApplyPatchIT.java
new file mode 100644
index 0000000..f31ae9b
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/change/ApplyPatchIT.java
@@ -0,0 +1,521 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.api.change;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.permissionKey;
+import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_COMMIT;
+import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_FILES;
+import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_REVISION;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.server.patch.DiffUtil.cleanPatch;
+import static com.google.gerrit.server.patch.DiffUtil.removePatchHeader;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.eclipse.jgit.lib.Constants.HEAD;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.extensions.api.accounts.AccountInput;
+import com.google.gerrit.extensions.api.changes.ApplyPatchInput;
+import com.google.gerrit.extensions.api.changes.ApplyPatchPatchSetInput;
+import com.google.gerrit.extensions.api.changes.RevisionApi;
+import com.google.gerrit.extensions.client.ListChangesOption;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.ChangeInput;
+import com.google.gerrit.extensions.common.DiffInfo;
+import com.google.gerrit.extensions.common.GitPerson;
+import com.google.gerrit.extensions.common.testing.GitPersonSubject;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BinaryResult;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.inject.Inject;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.util.Base64;
+import org.junit.Test;
+
+public class ApplyPatchIT extends AbstractDaemonTest {
+
+  private static final String DESTINATION_BRANCH = "destBranch";
+
+  private static final String ADDED_FILE_NAME = "a_new_file.txt";
+  private static final String ADDED_FILE_CONTENT = "First added line\nSecond added line\n";
+  private static final String ADDED_FILE_DIFF =
+      "diff --git a/a_new_file.txt b/a_new_file.txt\n"
+          + "new file mode 100644\n"
+          + "--- /dev/null\n"
+          + "+++ b/a_new_file.txt\n"
+          + "@@ -0,0 +1,2 @@\n"
+          + "+First added line\n"
+          + "+Second added line\n";
+
+  @Inject private ProjectOperations projectOperations;
+  @Inject private RequestScopeOperations requestScopeOperations;
+
+  @Test
+  public void applyAddedFilePatch_success() throws Exception {
+    initDestBranch();
+    ApplyPatchPatchSetInput in = buildInput(ADDED_FILE_DIFF);
+
+    ChangeInfo result = applyPatch(in);
+
+    DiffInfo diff = fetchDiffForFile(result, ADDED_FILE_NAME);
+    assertDiffForNewFile(diff, result.currentRevision, ADDED_FILE_NAME, ADDED_FILE_CONTENT);
+  }
+
+  private static final String MODIFIED_FILE_NAME = "modified_file.txt";
+  private static final String MODIFIED_FILE_ORIGINAL_CONTENT =
+      "First original line\nSecond original line";
+  private static final String MODIFIED_FILE_NEW_CONTENT = "Modified line\n";
+  private static final String MODIFIED_FILE_DIFF =
+      "diff --git a/modified_file.txt b/modified_file.txt\n"
+          + "--- a/modified_file.txt\n"
+          + "+++ b/modified_file.txt\n"
+          + "@@ -1,2 +1 @@\n"
+          + "-First original line\n"
+          + "-Second original line\n"
+          + "+Modified line\n";
+
+  @Test
+  public void applyModifiedFilePatch_success() throws Exception {
+    initBaseWithFile(MODIFIED_FILE_NAME, MODIFIED_FILE_ORIGINAL_CONTENT);
+    ApplyPatchPatchSetInput in = buildInput(MODIFIED_FILE_DIFF);
+
+    ChangeInfo result = applyPatch(in);
+
+    DiffInfo diff = fetchDiffForFile(result, MODIFIED_FILE_NAME);
+    assertDiffForFullyModifiedFile(
+        diff,
+        result.currentRevision,
+        MODIFIED_FILE_NAME,
+        MODIFIED_FILE_ORIGINAL_CONTENT,
+        MODIFIED_FILE_NEW_CONTENT);
+  }
+
+  @Test
+  public void applyDeletedFilePatch_success() throws Exception {
+    final String deletedFileName = "deleted_file.txt";
+    final String deletedFileOriginalContent = "content to be deleted.\n";
+    final String deletedFileDiff =
+        "diff --git a/deleted_file.txt b/deleted_file.txt\n"
+            + "--- a/deleted_file.txt\n"
+            + "+++ /dev/null\n";
+    initBaseWithFile(deletedFileName, deletedFileOriginalContent);
+    ApplyPatchPatchSetInput in = buildInput(deletedFileDiff);
+
+    ChangeInfo result = applyPatch(in);
+
+    DiffInfo diff = fetchDiffForFile(result, deletedFileName);
+    assertDiffForDeletedFile(diff, deletedFileName, deletedFileOriginalContent);
+  }
+
+  @Test
+  public void applyRenamedFilePatch_success() throws Exception {
+    final String renamedFileOriginalName = "renamed_file_origin.txt";
+    final String renamedFileNewName = "renamed_file_new.txt";
+    final String renamedFileDiff =
+        "diff --git a/renamed_file_origin.txt b/renamed_file_new.txt\n"
+            + "rename from renamed_file_origin.txt\n"
+            + "rename to renamed_file_new.txt\n"
+            + "--- a/renamed_file_origin.txt\n"
+            + "+++ b/renamed_file_new.txt\n"
+            + "@@ -1,2 +1 @@\n"
+            + "-First original line\n"
+            + "-Second original line\n"
+            + "+Modified line\n";
+    initBaseWithFile(renamedFileOriginalName, MODIFIED_FILE_ORIGINAL_CONTENT);
+    ApplyPatchPatchSetInput in = buildInput(renamedFileDiff);
+
+    ChangeInfo result = applyPatch(in);
+
+    DiffInfo originalFileDiff = fetchDiffForFile(result, renamedFileOriginalName);
+    assertDiffForDeletedFile(
+        originalFileDiff, renamedFileOriginalName, MODIFIED_FILE_ORIGINAL_CONTENT);
+    DiffInfo newFileDiff = fetchDiffForFile(result, renamedFileNewName);
+    assertDiffForNewFile(
+        newFileDiff, result.currentRevision, renamedFileNewName, MODIFIED_FILE_NEW_CONTENT);
+  }
+
+  @Test
+  public void applyGerritBasedPatchWithSingleFile_success() throws Exception {
+    String head = getHead(repo(), HEAD).name();
+    createBranchWithRevision(BranchNameKey.create(project, "branch"), head);
+    PushOneCommit.Result baseCommit = createChange("Add file", ADDED_FILE_NAME, ADDED_FILE_CONTENT);
+    baseCommit.assertOkStatus();
+    BinaryResult originalPatch = gApi.changes().id(baseCommit.getChangeId()).current().patch();
+    ApplyPatchPatchSetInput in = buildInput(originalPatch.asString());
+    createBranchWithRevision(BranchNameKey.create(project, DESTINATION_BRANCH), head);
+
+    ChangeInfo result = applyPatch(in);
+
+    BinaryResult resultPatch = gApi.changes().id(result.id).current().patch();
+    assertThat(cleanPatch(resultPatch)).isEqualTo(cleanPatch(originalPatch));
+  }
+
+  @Test
+  public void applyGerritBasedPatchWithMultipleFiles_success() throws Exception {
+    PushOneCommit.Result commonBaseCommit =
+        createChange("File for modification", MODIFIED_FILE_NAME, MODIFIED_FILE_ORIGINAL_CONTENT);
+    commonBaseCommit.assertOkStatus();
+    String head = getHead(repo(), HEAD).name();
+    createBranchWithRevision(BranchNameKey.create(project, "branch"), head);
+    PushOneCommit.Result commitToPatch =
+        createChange("Add file", ADDED_FILE_NAME, ADDED_FILE_CONTENT);
+    amendChange(
+        commitToPatch.getChangeId(), "Modify file", MODIFIED_FILE_NAME, MODIFIED_FILE_NEW_CONTENT);
+    commitToPatch.assertOkStatus();
+    BinaryResult originalPatch = gApi.changes().id(commitToPatch.getChangeId()).current().patch();
+    ApplyPatchPatchSetInput in = buildInput(originalPatch.asString());
+    createBranchWithRevision(BranchNameKey.create(project, DESTINATION_BRANCH), head);
+
+    ChangeInfo result = applyPatch(in);
+
+    BinaryResult resultPatch = gApi.changes().id(result.id).current().patch();
+    assertThat(cleanPatch(resultPatch)).isEqualTo(cleanPatch(originalPatch));
+  }
+
+  @Test
+  public void applyGerritBasedPatchUsingRest_success() throws Exception {
+    String head = getHead(repo(), HEAD).name();
+    createBranchWithRevision(BranchNameKey.create(project, DESTINATION_BRANCH), head);
+    PushOneCommit.Result destChange = createChange("refs/for/" + DESTINATION_BRANCH);
+    createBranchWithRevision(BranchNameKey.create(project, "branch"), head);
+    PushOneCommit.Result baseCommit =
+        createChange(testRepo, "branch", "Add file", ADDED_FILE_NAME, ADDED_FILE_CONTENT, "");
+    baseCommit.assertOkStatus();
+    RestResponse patchResp =
+        userRestSession.get("/changes/" + baseCommit.getChangeId() + "/revisions/current/patch");
+    patchResp.assertOK();
+    String originalPatch = new String(Base64.decode(patchResp.getEntityContent()), UTF_8);
+    ApplyPatchPatchSetInput in = buildInput(originalPatch);
+
+    RestResponse resp =
+        adminRestSession.post("/changes/" + destChange.getChangeId() + "/patch:apply", in);
+
+    resp.assertOK();
+    BinaryResult resultPatch = gApi.changes().id(destChange.getChangeId()).current().patch();
+    assertThat(cleanPatch(resultPatch)).isEqualTo(cleanPatch(originalPatch));
+  }
+
+  @Test
+  public void applyGerritBasedPatchUsingRestWithEncodedPatch_success() throws Exception {
+    String head = getHead(repo(), HEAD).name();
+    createBranchWithRevision(BranchNameKey.create(project, DESTINATION_BRANCH), head);
+    PushOneCommit.Result destChange = createChange("refs/for/" + DESTINATION_BRANCH);
+    createBranchWithRevision(BranchNameKey.create(project, "branch"), head);
+    PushOneCommit.Result baseCommit =
+        createChange(testRepo, "branch", "Add file", ADDED_FILE_NAME, ADDED_FILE_CONTENT, "");
+    baseCommit.assertOkStatus();
+    RestResponse patchResp =
+        userRestSession.get("/changes/" + baseCommit.getChangeId() + "/revisions/current/patch");
+    patchResp.assertOK();
+    String originalEncodedPatch = patchResp.getEntityContent();
+    String originalDecodedPatch = new String(Base64.decode(patchResp.getEntityContent()), UTF_8);
+    ApplyPatchPatchSetInput in = buildInput(originalEncodedPatch);
+
+    RestResponse resp =
+        adminRestSession.post("/changes/" + destChange.getChangeId() + "/patch:apply", in);
+
+    resp.assertOK();
+    BinaryResult resultPatch = gApi.changes().id(destChange.getChangeId()).current().patch();
+    assertThat(cleanPatch(resultPatch)).isEqualTo(cleanPatch(originalDecodedPatch));
+  }
+
+  @Test
+  public void applyPatchWithConflict_appendErrorsToCommitMessage() throws Exception {
+    initBaseWithFile(MODIFIED_FILE_NAME, "Unexpected base content");
+    String patch = ADDED_FILE_DIFF + MODIFIED_FILE_DIFF;
+    ApplyPatchPatchSetInput in = buildInput(patch);
+    in.commitMessage = "subject";
+
+    ChangeInfo result = applyPatch(in);
+
+    assertThat(gApi.changes().id(result.id).current().commit(false).message)
+        .isEqualTo(
+            in.commitMessage
+                + "\n\nNOTE FOR REVIEWERS - errors occurred while applying the patch."
+                + "\nPLEASE REVIEW CAREFULLY.\nErrors:\nError applying patch in "
+                + MODIFIED_FILE_NAME
+                + ", hunk HunkHeader[1,2->1,1]: Hunk cannot be applied\n\nOriginal patch:\n "
+                + removePatchHeader(patch)
+                + "\n\nChange-Id: "
+                + result.changeId
+                + "\n");
+    // Error in MODIFIED_FILE should not affect ADDED_FILE results.
+    DiffInfo diff = fetchDiffForFile(result, ADDED_FILE_NAME);
+    assertDiffForNewFile(diff, result.currentRevision, ADDED_FILE_NAME, ADDED_FILE_CONTENT);
+  }
+
+  @Test
+  public void applyPatchWithoutAddPatchSetPermissions_fails() throws Exception {
+    initDestBranch();
+    ApplyPatchPatchSetInput in = buildInput(ADDED_FILE_DIFF);
+    projectOperations
+        .project(allProjects)
+        .forUpdate()
+        .remove(
+            permissionKey(Permission.ADD_PATCH_SET)
+                .ref("refs/for/*")
+                .group(REGISTERED_USERS)
+                .build())
+        .update();
+    PushOneCommit.Result destChange = createChange("dest change", "a file", "with content");
+    // Add-patch is always allowed for the change owner, so we need to use another account.
+    requestScopeOperations.setApiUser(accountCreator.user2().id());
+
+    Throwable error =
+        assertThrows(
+            AuthException.class, () -> gApi.changes().id(destChange.getChangeId()).applyPatch(in));
+
+    assertThat(error).hasMessageThat().contains("patch set");
+  }
+
+  @Test
+  public void applyPatchWithCustomMessage_success() throws Exception {
+    initDestBranch();
+    ApplyPatchPatchSetInput in = buildInput(ADDED_FILE_DIFF);
+    in.commitMessage = "custom commit message";
+
+    ChangeInfo result = applyPatch(in);
+
+    assertThat(gApi.changes().id(result.id).current().commit(false).message)
+        .contains(in.commitMessage);
+  }
+
+  @Test
+  public void applyPatchWithBaseCommit_success() throws Exception {
+    PushOneCommit.Result baseCommit =
+        createChange("base commit", MODIFIED_FILE_NAME, MODIFIED_FILE_ORIGINAL_CONTENT);
+    baseCommit.assertOkStatus();
+    PushOneCommit.Result ignoredCommit =
+        createChange("Ignored file modification", MODIFIED_FILE_NAME, "Ignored file modification");
+    ignoredCommit.assertOkStatus();
+    initDestBranch();
+    ApplyPatchPatchSetInput in = buildInput(MODIFIED_FILE_DIFF);
+    in.base = baseCommit.getCommit().getName();
+
+    ChangeInfo result = applyPatch(in);
+
+    assertThat(gApi.changes().id(result.id).current().commit(false).parents.get(0).commit)
+        .isEqualTo(in.base);
+  }
+
+  @Test
+  public void applyPatchWithDefaultAuthor_success() throws Exception {
+    initDestBranch();
+    ApplyPatchPatchSetInput in = buildInput(ADDED_FILE_DIFF);
+
+    ChangeInfo result = applyPatch(in);
+
+    GitPerson person = gApi.changes().id(result.id).current().commit(false).author;
+    GitPersonSubject.assertThat(person).email().isEqualTo(admin.email());
+  }
+
+  @Test
+  public void applyPatchWithAuthorOverrideMissingEmail_throwsIllegalArgument() throws Exception {
+    initDestBranch();
+    ApplyPatchPatchSetInput in = buildInput(ADDED_FILE_DIFF);
+    in.author = new AccountInput();
+    in.author.name = "name";
+
+    Throwable error = assertThrows(IllegalArgumentException.class, () -> applyPatch(in));
+
+    assertThat(error).hasMessageThat().contains("E-mail");
+  }
+
+  @Test
+  public void applyPatchWithAuthorOverrideMissingName_throwsIllegalArgument() throws Exception {
+    initDestBranch();
+    ApplyPatchPatchSetInput in = buildInput(ADDED_FILE_DIFF);
+    in.author = new AccountInput();
+    in.author.name = null;
+    in.author.email = "gerritlessjane@invalid";
+
+    Throwable error = assertThrows(IllegalArgumentException.class, () -> applyPatch(in));
+
+    assertThat(error).hasMessageThat().contains("Name");
+  }
+
+  @Test
+  public void applyPatchWithAuthorOverride_success() throws Exception {
+    initDestBranch();
+    ApplyPatchPatchSetInput in = buildInput(ADDED_FILE_DIFF);
+    in.author = new AccountInput();
+    in.author.email = "gerritlessjane@invalid";
+    // This is an email address that doesn't exist as account on the Gerrit server.
+    in.author.name = "Gerritless Jane";
+
+    ChangeInfo result = applyPatch(in);
+
+    RevisionApi rApi = gApi.changes().id(result.id).current();
+    GitPerson author = rApi.commit(false).author;
+    GitPersonSubject.assertThat(author).email().isEqualTo(in.author.email);
+    GitPersonSubject.assertThat(author).name().isEqualTo(in.author.name);
+    GitPerson committer = rApi.commit(false).committer;
+    GitPersonSubject.assertThat(committer).email().isEqualTo(admin.getNameEmail().email());
+  }
+
+  @Test
+  public void applyPatchWithAuthorWithoutPermissions_fails() throws Exception {
+    initDestBranch();
+    ApplyPatchPatchSetInput in = buildInput(ADDED_FILE_DIFF);
+    in.author = new AccountInput();
+    in.author.name = "Jane";
+    in.author.email = "jane@invalid";
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.FORGE_AUTHOR).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+
+    Throwable error = assertThrows(ResourceConflictException.class, () -> applyPatch(in));
+
+    assertThat(error).hasMessageThat().contains("forge author");
+  }
+
+  @Test
+  public void applyPatchWithSelfAsForgedAuthor_success() throws Exception {
+    initDestBranch();
+    ApplyPatchPatchSetInput in = buildInput(ADDED_FILE_DIFF);
+    in.author = new AccountInput();
+    in.author.name = admin.fullName();
+    in.author.email = admin.email();
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.FORGE_AUTHOR).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+
+    ChangeInfo result = applyPatch(in);
+
+    GitPerson person = gApi.changes().id(result.id).current().commit(false).author;
+    GitPersonSubject.assertThat(person).email().isEqualTo(admin.email());
+  }
+
+  @Test
+  public void applyPatchWithExplicitBase_overrideParentId() throws Exception {
+    PushOneCommit.Result inputParent = createChange("Input parent", "file1", "content");
+    PushOneCommit.Result parent = createChange("Parent Change", "file2", "content");
+    parent.assertOkStatus();
+    PushOneCommit.Result dest = createChange("Destination Change", "file3", "content");
+    ApplyPatchPatchSetInput in = buildInput(ADDED_FILE_DIFF);
+    in.base = inputParent.getCommit().name();
+
+    gApi.changes().id(dest.getChangeId()).applyPatch(in);
+
+    ChangeInfo result = get(dest.getChangeId(), CURRENT_REVISION, CURRENT_COMMIT);
+    assertThat(result.revisions.get(result.currentRevision).commit.parents.get(0).commit)
+        .isEqualTo(inputParent.getCommit().name());
+
+    BinaryResult resultPatch = gApi.changes().id(dest.getChangeId()).current().patch();
+    assertThat(cleanPatch(resultPatch)).isEqualTo(cleanPatch(ADDED_FILE_DIFF));
+  }
+
+  @Test
+  public void applyPatchWithNoExplicitBase_overwritesLatestPatch() throws Exception {
+    PushOneCommit.Result dest = createChange("Destination Change", "ps1.txt", "ps1 content");
+    RevCommit originalParentCommit = dest.getCommit().getParent(0);
+    ApplyPatchPatchSetInput in = buildInput(ADDED_FILE_DIFF);
+
+    gApi.changes().id(dest.getChangeId()).applyPatch(in);
+
+    ChangeInfo result = get(dest.getChangeId(), CURRENT_REVISION, CURRENT_COMMIT, CURRENT_FILES);
+    assertThat(result.revisions.get(result.currentRevision).commit.parents.get(0).commit)
+        .isEqualTo(originalParentCommit.name());
+    assertThat(result.revisions.get(result.currentRevision).files.keySet())
+        .containsExactly(ADDED_FILE_NAME);
+    assertDiffForNewFile(
+        fetchDiffForFile(result, ADDED_FILE_NAME),
+        result.currentRevision,
+        ADDED_FILE_NAME,
+        ADDED_FILE_CONTENT);
+  }
+
+  @Test
+  public void commitMessage_providedMessage() throws Exception {
+    final String msg = "custom message";
+    initDestBranch();
+    ApplyPatchPatchSetInput in = buildInput(ADDED_FILE_DIFF);
+    in.commitMessage = msg;
+
+    ChangeInfo result = applyPatch(in);
+
+    ChangeInfo info = get(result.changeId, CURRENT_REVISION, CURRENT_COMMIT);
+    assertThat(info.revisions.get(info.currentRevision).commit.message)
+        .isEqualTo(msg + "\n\nChange-Id: " + result.changeId + "\n");
+  }
+
+  @Test
+  public void commitMessage_defaultMessageAndPatchHeader() throws Exception {
+    initDestBranch();
+    ApplyPatchPatchSetInput in = buildInput("Patch header\n" + ADDED_FILE_DIFF);
+
+    ChangeInfo result = applyPatch(in);
+
+    ChangeInfo info = get(result.changeId, CURRENT_REVISION, CURRENT_COMMIT);
+    assertThat(info.revisions.get(info.currentRevision).commit.message)
+        .isEqualTo("Default commit message\n\nChange-Id: " + result.changeId + "\n");
+  }
+
+  @Test
+  public void commitMessage_defaultMessageAndNoPatchHeader() throws Exception {
+    initDestBranch();
+    ApplyPatchPatchSetInput in = buildInput(ADDED_FILE_DIFF);
+
+    ChangeInfo result = applyPatch(in);
+
+    ChangeInfo info = get(result.changeId, CURRENT_REVISION, CURRENT_COMMIT);
+    assertThat(info.revisions.get(info.currentRevision).commit.message)
+        .isEqualTo("Default commit message\n\nChange-Id: " + result.changeId + "\n");
+  }
+
+  private void initDestBranch() throws Exception {
+    String head = getHead(repo(), HEAD).name();
+    createBranchWithRevision(BranchNameKey.create(project, ApplyPatchIT.DESTINATION_BRANCH), head);
+  }
+
+  private void initBaseWithFile(String fileName, String fileContent) throws Exception {
+    PushOneCommit.Result baseCommit =
+        createChange("Add original file: " + fileName, fileName, fileContent);
+    baseCommit.assertOkStatus();
+    initDestBranch();
+  }
+
+  private ApplyPatchPatchSetInput buildInput(String patch) {
+    ApplyPatchPatchSetInput in = new ApplyPatchPatchSetInput();
+    in.patch = new ApplyPatchInput();
+    in.patch.patch = patch;
+    return in;
+  }
+
+  private ChangeInfo applyPatch(ApplyPatchPatchSetInput input) throws RestApiException {
+    input.responseFormatOptions = ImmutableList.of(ListChangesOption.CURRENT_REVISION);
+    return gApi.changes()
+        .create(new ChangeInput(project.get(), DESTINATION_BRANCH, "Default commit message"))
+        .applyPatch(input);
+  }
+
+  private DiffInfo fetchDiffForFile(ChangeInfo result, String fileName) throws RestApiException {
+    return gApi.changes().id(result.id).current().file(fileName).diff();
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
index c9c5c2c..d8bf8ef 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
@@ -28,6 +28,7 @@
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.blockLabel;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.blockLabelRemoval;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.labelPermissionKey;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.permissionKey;
 import static com.google.gerrit.entities.RefNames.changeMetaRef;
@@ -48,15 +49,16 @@
 import static com.google.gerrit.extensions.client.ReviewerState.CC;
 import static com.google.gerrit.extensions.client.ReviewerState.REMOVED;
 import static com.google.gerrit.extensions.client.ReviewerState.REVIEWER;
-import static com.google.gerrit.git.ObjectIds.abbreviateName;
 import static com.google.gerrit.server.StarredChangesUtil.DEFAULT_LABEL;
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
 import static com.google.gerrit.server.group.SystemGroupBackend.CHANGE_OWNER;
 import static com.google.gerrit.server.group.SystemGroupBackend.PROJECT_OWNERS;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.server.project.ProjectConfig.RULES_PL_FILE;
 import static com.google.gerrit.server.project.testing.TestLabels.label;
 import static com.google.gerrit.server.project.testing.TestLabels.value;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static com.google.gerrit.testing.TestActionRefUpdateContext.openTestRefUpdateContext;
 import static com.google.gerrit.truth.CacheStatsSubject.assertThat;
 import static com.google.gerrit.truth.CacheStatsSubject.cloneStats;
 import static java.nio.charset.StandardCharsets.UTF_8;
@@ -101,6 +103,7 @@
 import com.google.gerrit.entities.BooleanProjectConfig;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.EmailHeader.StringEmailHeader;
 import com.google.gerrit.entities.LabelFunction;
 import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.LabelType;
@@ -118,9 +121,8 @@
 import com.google.gerrit.extensions.api.changes.DraftInput;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.NotifyInfo;
-import com.google.gerrit.extensions.api.changes.RebaseInput;
 import com.google.gerrit.extensions.api.changes.RecipientType;
-import com.google.gerrit.extensions.api.changes.RelatedChangeAndCommitInfo;
+import com.google.gerrit.extensions.api.changes.RevertInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput.DraftHandling;
 import com.google.gerrit.extensions.api.changes.ReviewResult;
@@ -147,33 +149,28 @@
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.common.CommitInfo;
-import com.google.gerrit.extensions.common.GitPerson;
 import com.google.gerrit.extensions.common.LabelInfo;
 import com.google.gerrit.extensions.common.RevisionInfo;
 import com.google.gerrit.extensions.common.TrackingIdInfo;
 import com.google.gerrit.extensions.events.AttentionSetListener;
-import com.google.gerrit.extensions.events.WorkInProgressStateChangedListener;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.git.ObjectIds;
 import com.google.gerrit.httpd.raw.IndexPreloadingUtil;
 import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.index.query.PostFilterPredicate;
 import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.account.AccountControl;
+import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.change.ChangeMessages;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.testing.TestChangeETagComputation;
-import com.google.gerrit.server.events.CommitReceivedEvent;
 import com.google.gerrit.server.git.ChangeMessageModifier;
-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.group.SystemGroupBackend;
 import com.google.gerrit.server.index.change.ChangeIndex;
 import com.google.gerrit.server.index.change.ChangeIndexCollection;
@@ -189,13 +186,13 @@
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.context.RefUpdateContext;
 import com.google.gerrit.server.util.AccountTemplateUtil;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.testing.FakeEmailSender.Message;
 import com.google.inject.AbstractModule;
 import com.google.inject.Inject;
 import com.google.inject.name.Named;
-import java.io.ByteArrayOutputStream;
 import java.io.IOException;
 import java.sql.Timestamp;
 import java.text.MessageFormat;
@@ -203,6 +200,7 @@
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
+import java.util.Collections;
 import java.util.EnumSet;
 import java.util.HashMap;
 import java.util.Iterator;
@@ -239,6 +237,7 @@
   @Inject private RequestScopeOperations requestScopeOperations;
   @Inject private ExtensionRegistry extensionRegistry;
   @Inject private IndexOperations.Change changeIndexOperations;
+  @Inject private AccountControl.Factory accountControlFactory;
 
   @Inject
   @Named("diff_intraline")
@@ -420,6 +419,31 @@
   }
 
   @Test
+  public void setReadyForReviewSendsNotificationsForRevertChange() throws Exception {
+    PushOneCommit.Result r = createChange();
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).submit();
+    RevertInput in = new RevertInput();
+    in.workInProgress = true;
+    String changeId = gApi.changes().id(r.getChangeId()).revert(in).get().changeId;
+    requestScopeOperations.setApiUser(admin.id());
+
+    gApi.changes().id(changeId).setReadyForReview();
+
+    assertThat(gApi.changes().id(changeId).get().workInProgress).isNull();
+    // expected messages on source change:
+    // 1. Uploaded patch set 1.
+    // 2. Patch Set 1: Code-Review+2
+    // 3. Change has been successfully merged by Administrator
+    // 4. Patch Set 1: Reverted
+    List<ChangeMessageInfo> sourceMessages =
+        new ArrayList<>(gApi.changes().id(r.getChangeId()).get().messages);
+    assertThat(sourceMessages).hasSize(4);
+    String expectedMessage = String.format("Created a revert of this change as I%s", changeId);
+    assertThat(sourceMessages.get(3).message).isEqualTo(expectedMessage);
+  }
+
+  @Test
   public void hasReviewStarted() throws Exception {
     PushOneCommit.Result r = createWorkInProgressChange();
     String changeId = r.getChangeId();
@@ -645,6 +669,21 @@
   }
 
   @Test
+  public void reviewRemoveInactiveReviewer() throws Exception {
+    PushOneCommit.Result r = createChange();
+    ReviewInput in = ReviewInput.approve().reviewer(user.email());
+    gApi.changes().id(r.getChangeId()).current().review(in);
+
+    accountOperations.account(user.id()).forUpdate().inactive().update();
+    in = ReviewInput.noScore().reviewer(Integer.toString(user.id().get()), REMOVED, false);
+
+    gApi.changes().id(r.getChangeId()).current().review(in);
+    ChangeInfo info = gApi.changes().id(r.getChangeId()).get();
+    assertThat(info.reviewers.get(REVIEWER).stream().map(ai -> ai._accountId).collect(toList()))
+        .containsExactly(admin.id().get());
+  }
+
+  @Test
   public void reviewWithWorkInProgressAndReadyReturnsError() throws Exception {
     PushOneCommit.Result r = createChange();
     ReviewInput in = ReviewInput.noScore();
@@ -752,303 +791,6 @@
     assertThat(thrown).hasMessageThat().contains("Multiple changes found for " + changeId);
   }
 
-  @FunctionalInterface
-  private interface Rebase {
-    void call(String id) throws RestApiException;
-  }
-
-  @Test
-  public void rebaseViaRevisionApi() throws Exception {
-    testRebase(id -> gApi.changes().id(id).current().rebase());
-  }
-
-  @Test
-  public void rebaseViaChangeApi() throws Exception {
-    testRebase(id -> gApi.changes().id(id).rebase());
-  }
-
-  private void testRebase(Rebase rebase) throws Exception {
-    // Create two changes both with the same parent
-    PushOneCommit.Result r = createChange();
-    testRepo.reset("HEAD~1");
-    PushOneCommit.Result r2 = createChange();
-
-    // Approve and submit the first change
-    RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
-    revision.review(ReviewInput.approve());
-    revision.submit();
-
-    // Add an approval whose score should be copied on trivial rebase
-    gApi.changes().id(r2.getChangeId()).current().review(ReviewInput.recommend());
-
-    String changeId = r2.getChangeId();
-    // Rebase the second change
-    rebase.call(changeId);
-
-    // Second change should have 2 patch sets and an approval
-    ChangeInfo c2 = gApi.changes().id(changeId).get(CURRENT_REVISION, DETAILED_LABELS);
-    assertThat(c2.revisions.get(c2.currentRevision)._number).isEqualTo(2);
-
-    // ...and the committer and description should be correct
-    ChangeInfo info = gApi.changes().id(changeId).get(CURRENT_REVISION, CURRENT_COMMIT);
-    GitPerson committer = info.revisions.get(info.currentRevision).commit.committer;
-    assertThat(committer.name).isEqualTo(admin.fullName());
-    assertThat(committer.email).isEqualTo(admin.email());
-    String description = info.revisions.get(info.currentRevision).description;
-    assertThat(description).isEqualTo("Rebase");
-
-    // ...and the approval was copied
-    LabelInfo cr = c2.labels.get(LabelId.CODE_REVIEW);
-    assertThat(cr).isNotNull();
-    assertThat(cr.all).hasSize(1);
-    assertThat(cr.all.get(0).value).isEqualTo(1);
-
-    // Rebasing the second change again should fail
-    ResourceConflictException thrown =
-        assertThrows(
-            ResourceConflictException.class, () -> gApi.changes().id(changeId).current().rebase());
-    assertThat(thrown).hasMessageThat().contains("Change is already up to date");
-  }
-
-  @Test
-  public void rebaseAsUploaderInAttentionSet() throws Exception {
-    // Create two changes both with the same parent
-    PushOneCommit.Result r = createChange();
-    testRepo.reset("HEAD~1");
-    PushOneCommit.Result r2 = createChange();
-
-    // Approve and submit the first change
-    RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
-    revision.review(ReviewInput.approve());
-    revision.submit();
-
-    TestAccount admin2 = accountCreator.admin2();
-    requestScopeOperations.setApiUser(admin2.id());
-    amendChangeWithUploader(r2, project, admin2);
-    gApi.changes()
-        .id(r2.getChangeId())
-        .addToAttentionSet(new AttentionSetInput(admin2.id().toString(), "manual update"));
-
-    gApi.changes().id(r2.getChangeId()).rebase();
-  }
-
-  @Test
-  public void rebaseOnChangeNumber() throws Exception {
-    String branchTip = testRepo.getRepository().exactRef("HEAD").getObjectId().name();
-    PushOneCommit.Result r1 = createChange();
-    testRepo.reset("HEAD~1");
-    PushOneCommit.Result r2 = createChange();
-
-    ChangeInfo ci2 = get(r2.getChangeId(), CURRENT_REVISION, CURRENT_COMMIT);
-    RevisionInfo ri2 = ci2.revisions.get(ci2.currentRevision);
-    assertThat(ri2.commit.parents.get(0).commit).isEqualTo(branchTip);
-
-    Change.Id id1 = r1.getChange().getId();
-    RebaseInput in = new RebaseInput();
-    in.base = id1.toString();
-    gApi.changes().id(r2.getChangeId()).rebase(in);
-
-    Change.Id id2 = r2.getChange().getId();
-    ci2 = get(r2.getChangeId(), CURRENT_REVISION, CURRENT_COMMIT);
-    ri2 = ci2.revisions.get(ci2.currentRevision);
-    assertThat(ri2.commit.parents.get(0).commit).isEqualTo(r1.getCommit().name());
-
-    List<RelatedChangeAndCommitInfo> related =
-        gApi.changes().id(id2.get()).revision(ri2._number).related().changes;
-    assertThat(related).hasSize(2);
-    assertThat(related.get(0)._changeNumber).isEqualTo(id2.get());
-    assertThat(related.get(0)._revisionNumber).isEqualTo(2);
-    assertThat(related.get(1)._changeNumber).isEqualTo(id1.get());
-    assertThat(related.get(1)._revisionNumber).isEqualTo(1);
-  }
-
-  @Test
-  public void rebaseOnClosedChange() throws Exception {
-    String branchTip = testRepo.getRepository().exactRef("HEAD").getObjectId().name();
-    PushOneCommit.Result r1 = createChange();
-    testRepo.reset("HEAD~1");
-    PushOneCommit.Result r2 = createChange();
-
-    ChangeInfo ci2 = get(r2.getChangeId(), CURRENT_REVISION, CURRENT_COMMIT);
-    RevisionInfo ri2 = ci2.revisions.get(ci2.currentRevision);
-    assertThat(ri2.commit.parents.get(0).commit).isEqualTo(branchTip);
-
-    // Submit first change.
-    Change.Id id1 = r1.getChange().getId();
-    gApi.changes().id(id1.get()).current().review(ReviewInput.approve());
-    gApi.changes().id(id1.get()).current().submit();
-
-    // Rebase second change on first change.
-    RebaseInput in = new RebaseInput();
-    in.base = id1.toString();
-    gApi.changes().id(r2.getChangeId()).rebase(in);
-
-    Change.Id id2 = r2.getChange().getId();
-    ci2 = get(r2.getChangeId(), CURRENT_REVISION, CURRENT_COMMIT);
-    ri2 = ci2.revisions.get(ci2.currentRevision);
-    assertThat(ri2.commit.parents.get(0).commit).isEqualTo(r1.getCommit().name());
-
-    assertThat(gApi.changes().id(id2.get()).revision(ri2._number).related().changes).isEmpty();
-  }
-
-  @Test
-  public void rebaseOnNonExistingChange() throws Exception {
-    String changeId = createChange().getChangeId();
-    RebaseInput in = new RebaseInput();
-    in.base = "999999";
-    UnprocessableEntityException exception =
-        assertThrows(
-            UnprocessableEntityException.class, () -> gApi.changes().id(changeId).rebase(in));
-    assertThat(exception).hasMessageThat().isEqualTo("Base change not found: " + in.base);
-  }
-
-  @Test
-  public void rebaseFromRelationChainToClosedChange() throws Exception {
-    PushOneCommit.Result r1 = createChange();
-    testRepo.reset("HEAD~1");
-
-    createChange();
-    PushOneCommit.Result r3 = createChange();
-
-    // Submit first change.
-    Change.Id id1 = r1.getChange().getId();
-    gApi.changes().id(id1.get()).current().review(ReviewInput.approve());
-    gApi.changes().id(id1.get()).current().submit();
-
-    // Rebase third change on first change.
-    RebaseInput in = new RebaseInput();
-    in.base = id1.toString();
-    gApi.changes().id(r3.getChangeId()).rebase(in);
-
-    Change.Id id3 = r3.getChange().getId();
-    ChangeInfo ci3 = get(r3.getChangeId(), CURRENT_REVISION, CURRENT_COMMIT);
-    RevisionInfo ri3 = ci3.revisions.get(ci3.currentRevision);
-    assertThat(ri3.commit.parents.get(0).commit).isEqualTo(r1.getCommit().name());
-
-    assertThat(gApi.changes().id(id3.get()).revision(ri3._number).related().changes).isEmpty();
-  }
-
-  @Test
-  public void rebaseNotAllowedWithoutPermission() throws Exception {
-    // Create two changes both with the same parent
-    PushOneCommit.Result r = createChange();
-    testRepo.reset("HEAD~1");
-    PushOneCommit.Result r2 = createChange();
-
-    // Approve and submit the first change
-    RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
-    revision.review(ReviewInput.approve());
-    revision.submit();
-
-    // Rebase the second
-    String changeId = r2.getChangeId();
-    requestScopeOperations.setApiUser(user.id());
-    AuthException thrown =
-        assertThrows(AuthException.class, () -> gApi.changes().id(changeId).rebase());
-    assertThat(thrown).hasMessageThat().contains("rebase not permitted");
-  }
-
-  @Test
-  public void rebaseAllowedWithPermission() throws Exception {
-    // Create two changes both with the same parent
-    PushOneCommit.Result r = createChange();
-    testRepo.reset("HEAD~1");
-    PushOneCommit.Result r2 = createChange();
-
-    // Approve and submit the first change
-    RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
-    revision.review(ReviewInput.approve());
-    revision.submit();
-
-    projectOperations
-        .project(project)
-        .forUpdate()
-        .add(allow(Permission.REBASE).ref("refs/heads/master").group(REGISTERED_USERS))
-        .update();
-
-    // Rebase the second
-    String changeId = r2.getChangeId();
-    requestScopeOperations.setApiUser(user.id());
-    gApi.changes().id(changeId).rebase();
-  }
-
-  @Test
-  public void rebaseNotAllowedWithoutPushPermission() throws Exception {
-    // Create two changes both with the same parent
-    PushOneCommit.Result r = createChange();
-    testRepo.reset("HEAD~1");
-    PushOneCommit.Result r2 = createChange();
-
-    // Approve and submit the first change
-    RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
-    revision.review(ReviewInput.approve());
-    revision.submit();
-
-    projectOperations
-        .project(project)
-        .forUpdate()
-        .add(allow(Permission.REBASE).ref("refs/heads/master").group(REGISTERED_USERS))
-        .add(block(Permission.PUSH).ref("refs/for/*").group(REGISTERED_USERS))
-        .update();
-
-    // Rebase the second
-    String changeId = r2.getChangeId();
-    requestScopeOperations.setApiUser(user.id());
-    AuthException thrown =
-        assertThrows(AuthException.class, () -> gApi.changes().id(changeId).rebase());
-    assertThat(thrown).hasMessageThat().contains("rebase not permitted");
-  }
-
-  @Test
-  public void rebaseNotAllowedForOwnerWithoutPushPermission() throws Exception {
-    // Create two changes both with the same parent
-    PushOneCommit.Result r = createChange();
-    testRepo.reset("HEAD~1");
-    PushOneCommit.Result r2 = createChange();
-
-    // Approve and submit the first change
-    RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
-    revision.review(ReviewInput.approve());
-    revision.submit();
-
-    projectOperations
-        .project(project)
-        .forUpdate()
-        .add(block(Permission.PUSH).ref("refs/for/*").group(REGISTERED_USERS))
-        .update();
-
-    // Rebase the second
-    String changeId = r2.getChangeId();
-    AuthException thrown =
-        assertThrows(AuthException.class, () -> gApi.changes().id(changeId).rebase());
-    assertThat(thrown).hasMessageThat().contains("rebase not permitted");
-  }
-
-  @Test
-  public void rebaseWithValidationOptions() throws Exception {
-    // Create two changes both with the same parent
-    PushOneCommit.Result r = createChange();
-    testRepo.reset("HEAD~1");
-    PushOneCommit.Result r2 = createChange();
-
-    // Approve and submit the first change
-    RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
-    revision.review(ReviewInput.approve());
-    revision.submit();
-
-    RebaseInput rebaseInput = new RebaseInput();
-    rebaseInput.validationOptions = ImmutableMap.of("key", "value");
-
-    TestCommitValidationListener testCommitValidationListener = new TestCommitValidationListener();
-    try (Registration registration =
-        extensionRegistry.newRegistration().add(testCommitValidationListener)) {
-      // Rebase the second change
-      gApi.changes().id(r2.getChangeId()).current().rebase(rebaseInput);
-      assertThat(testCommitValidationListener.receiveEvent.pushOptions)
-          .containsExactly("key", "value");
-    }
-  }
-
   @Test
   public void deleteNewChangeAsAdmin() throws Exception {
     deleteChangeAsUser(admin, admin);
@@ -1381,166 +1123,6 @@
   }
 
   @Test
-  public void rebaseUpToDateChange() throws Exception {
-    PushOneCommit.Result r = createChange();
-    ResourceConflictException thrown =
-        assertThrows(
-            ResourceConflictException.class,
-            () -> gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).rebase());
-    assertThat(thrown).hasMessageThat().contains("Change is already up to date");
-  }
-
-  @Test
-  public void rebaseConflict() throws Exception {
-    PushOneCommit.Result r1 = createChange();
-    gApi.changes()
-        .id(r1.getChangeId())
-        .revision(r1.getCommit().name())
-        .review(ReviewInput.approve());
-    gApi.changes().id(r1.getChangeId()).revision(r1.getCommit().name()).submit();
-
-    PushOneCommit push =
-        pushFactory.create(
-            admin.newIdent(),
-            testRepo,
-            PushOneCommit.SUBJECT,
-            PushOneCommit.FILE_NAME,
-            "other content",
-            "If09d8782c1e59dd0b33de2b1ec3595d69cc10ad5");
-    PushOneCommit.Result r2 = push.to("refs/for/master");
-    r2.assertOkStatus();
-    ResourceConflictException exception =
-        assertThrows(
-            ResourceConflictException.class,
-            () -> gApi.changes().id(r2.getChangeId()).revision(r2.getCommit().name()).rebase());
-    assertThat(exception)
-        .hasMessageThat()
-        .isEqualTo(
-            String.format(
-                "The change could not be rebased due to a conflict during merge.\n\n"
-                    + "merge conflict(s):\n%s",
-                PushOneCommit.FILE_NAME));
-  }
-
-  @Test
-  public void rebaseDoesNotAddWorkInProgress() throws Exception {
-    PushOneCommit.Result r = createChange();
-
-    // create an unrelated change so that we can rebase
-    testRepo.reset("HEAD~1");
-    PushOneCommit.Result unrelated = createChange();
-    gApi.changes().id(unrelated.getChangeId()).current().review(ReviewInput.approve());
-    gApi.changes().id(unrelated.getChangeId()).current().submit();
-
-    gApi.changes().id(r.getChangeId()).rebase();
-
-    // change is still ready for review after rebase
-    assertThat(gApi.changes().id(r.getChangeId()).get().workInProgress).isNull();
-  }
-
-  @Test
-  public void rebaseDoesNotRemoveWorkInProgress() throws Exception {
-    PushOneCommit.Result r = createChange();
-    change(r).setWorkInProgress();
-
-    // create an unrelated change so that we can rebase
-    testRepo.reset("HEAD~1");
-    PushOneCommit.Result unrelated = createChange();
-    gApi.changes().id(unrelated.getChangeId()).current().review(ReviewInput.approve());
-    gApi.changes().id(unrelated.getChangeId()).current().submit();
-
-    gApi.changes().id(r.getChangeId()).rebase();
-
-    // change is still work in progress after rebase
-    assertThat(gApi.changes().id(r.getChangeId()).get().workInProgress).isTrue();
-  }
-
-  @Test
-  public void rebaseConflict_conflictsAllowed() throws Exception {
-    String patchSetSubject = "patch set change";
-    String patchSetContent = "patch set content";
-    String baseSubject = "base change";
-    String baseContent = "base content";
-
-    PushOneCommit.Result r1 = createChange(baseSubject, PushOneCommit.FILE_NAME, baseContent);
-    gApi.changes()
-        .id(r1.getChangeId())
-        .revision(r1.getCommit().name())
-        .review(ReviewInput.approve());
-    gApi.changes().id(r1.getChangeId()).revision(r1.getCommit().name()).submit();
-
-    testRepo.reset("HEAD~1");
-    PushOneCommit push =
-        pushFactory.create(
-            admin.newIdent(), testRepo, patchSetSubject, PushOneCommit.FILE_NAME, patchSetContent);
-    PushOneCommit.Result r2 = push.to("refs/for/master");
-    r2.assertOkStatus();
-
-    String changeId = r2.getChangeId();
-    RevCommit patchSet = r2.getCommit();
-    RevCommit base = r1.getCommit();
-
-    TestWorkInProgressStateChangedListener wipStateChangedListener =
-        new TestWorkInProgressStateChangedListener();
-    try (Registration registration =
-        extensionRegistry.newRegistration().add(wipStateChangedListener)) {
-      RebaseInput rebaseInput = new RebaseInput();
-      rebaseInput.allowConflicts = true;
-      ChangeInfo changeInfo =
-          gApi.changes().id(changeId).revision(patchSet.name()).rebaseAsInfo(rebaseInput);
-      assertThat(changeInfo.containsGitConflicts).isTrue();
-      assertThat(changeInfo.workInProgress).isTrue();
-    }
-    assertThat(wipStateChangedListener.invoked).isTrue();
-    assertThat(wipStateChangedListener.wip).isTrue();
-
-    // To get the revisions, we must retrieve the change with more change options.
-    ChangeInfo changeInfo =
-        gApi.changes().id(changeId).get(ALL_REVISIONS, CURRENT_COMMIT, CURRENT_REVISION);
-    assertThat(changeInfo.revisions).hasSize(2);
-    assertThat(changeInfo.revisions.get(changeInfo.currentRevision).commit.parents.get(0).commit)
-        .isEqualTo(base.name());
-
-    // Verify that the file content in the created patch set is correct.
-    // We expect that it has conflict markers to indicate the conflict.
-    BinaryResult bin =
-        gApi.changes().id(changeId).current().file(PushOneCommit.FILE_NAME).content();
-    ByteArrayOutputStream os = new ByteArrayOutputStream();
-    bin.writeTo(os);
-    String fileContent = new String(os.toByteArray(), UTF_8);
-    String patchSetSha1 = abbreviateName(patchSet, 6);
-    String baseSha1 = abbreviateName(base, 6);
-    assertThat(fileContent)
-        .isEqualTo(
-            "<<<<<<< PATCH SET ("
-                + patchSetSha1
-                + " "
-                + patchSetSubject
-                + ")\n"
-                + patchSetContent
-                + "\n"
-                + "=======\n"
-                + baseContent
-                + "\n"
-                + ">>>>>>> BASE      ("
-                + baseSha1
-                + " "
-                + baseSubject
-                + ")\n");
-
-    // Verify the message that has been posted on the change.
-    List<ChangeMessageInfo> messages = gApi.changes().id(changeId).messages();
-    assertThat(messages).hasSize(2);
-    assertThat(Iterables.getLast(messages).message)
-        .isEqualTo(
-            "Patch Set 2: Patch Set 1 was rebased\n\n"
-                + "The following files contain Git conflicts:\n"
-                + "* "
-                + PushOneCommit.FILE_NAME
-                + "\n");
-  }
-
-  @Test
   public void attentionSetListener_firesOnChange() throws Exception {
     PushOneCommit.Result r1 = createChange();
     AttentionSetInput addUser = new AttentionSetInput(user.email(), "some reason");
@@ -1573,109 +1155,6 @@
   }
 
   @Test
-  public void rebaseChangeBase() throws Exception {
-    PushOneCommit.Result r1 = createChange();
-    PushOneCommit.Result r2 = createChange();
-    PushOneCommit.Result r3 = createChange();
-    RebaseInput ri = new RebaseInput();
-
-    // rebase r3 directly onto master (break dep. towards r2)
-    ri.base = "";
-    gApi.changes().id(r3.getChangeId()).revision(r3.getCommit().name()).rebase(ri);
-    PatchSet ps3 = r3.getPatchSet();
-    assertThat(ps3.id().get()).isEqualTo(2);
-
-    // rebase r2 onto r3 (referenced by ref)
-    ri.base = ps3.id().toRefName();
-    gApi.changes().id(r2.getChangeId()).revision(r2.getCommit().name()).rebase(ri);
-    PatchSet ps2 = r2.getPatchSet();
-    assertThat(ps2.id().get()).isEqualTo(2);
-
-    // rebase r1 onto r2 (referenced by commit)
-    ri.base = ps2.commitId().name();
-    gApi.changes().id(r1.getChangeId()).revision(r1.getCommit().name()).rebase(ri);
-    PatchSet ps1 = r1.getPatchSet();
-    assertThat(ps1.id().get()).isEqualTo(2);
-
-    // rebase r1 onto r3 (referenced by change number)
-    ri.base = String.valueOf(r3.getChange().getId().get());
-    gApi.changes().id(r1.getChangeId()).revision(ps1.commitId().name()).rebase(ri);
-    assertThat(r1.getPatchSetId().get()).isEqualTo(3);
-  }
-
-  @Test
-  public void rebaseChangeBaseRecursion() throws Exception {
-    PushOneCommit.Result r1 = createChange();
-    PushOneCommit.Result r2 = createChange();
-
-    RebaseInput ri = new RebaseInput();
-    ri.base = r2.getCommit().name();
-    String expectedMessage =
-        "base change "
-            + r2.getChangeId()
-            + " is a descendant of the current change - recursion not allowed";
-    ResourceConflictException thrown =
-        assertThrows(
-            ResourceConflictException.class,
-            () -> gApi.changes().id(r1.getChangeId()).revision(r1.getCommit().name()).rebase(ri));
-    assertThat(thrown).hasMessageThat().contains(expectedMessage);
-  }
-
-  @Test
-  public void rebaseAbandonedChange() throws Exception {
-    PushOneCommit.Result r = createChange();
-    String changeId = r.getChangeId();
-    assertThat(info(changeId).status).isEqualTo(ChangeStatus.NEW);
-    gApi.changes().id(changeId).abandon();
-    ChangeInfo info = info(changeId);
-    assertThat(info.status).isEqualTo(ChangeStatus.ABANDONED);
-
-    ResourceConflictException thrown =
-        assertThrows(
-            ResourceConflictException.class,
-            () -> gApi.changes().id(changeId).revision(r.getCommit().name()).rebase());
-    assertThat(thrown).hasMessageThat().contains("change is abandoned");
-  }
-
-  @Test
-  public void rebaseOntoAbandonedChange() throws Exception {
-    // Create two changes both with the same parent
-    PushOneCommit.Result r = createChange();
-    testRepo.reset("HEAD~1");
-    PushOneCommit.Result r2 = createChange();
-
-    // Abandon the first change
-    String changeId = r.getChangeId();
-    assertThat(info(changeId).status).isEqualTo(ChangeStatus.NEW);
-    gApi.changes().id(changeId).abandon();
-    ChangeInfo info = info(changeId);
-    assertThat(info.status).isEqualTo(ChangeStatus.ABANDONED);
-
-    RebaseInput ri = new RebaseInput();
-    ri.base = r.getCommit().name();
-
-    ResourceConflictException thrown =
-        assertThrows(
-            ResourceConflictException.class,
-            () -> gApi.changes().id(r2.getChangeId()).revision(r2.getCommit().name()).rebase(ri));
-    assertThat(thrown).hasMessageThat().contains("base change is abandoned: " + changeId);
-  }
-
-  @Test
-  public void rebaseOntoSelf() throws Exception {
-    PushOneCommit.Result r = createChange();
-    String changeId = r.getChangeId();
-    String commit = r.getCommit().name();
-    RebaseInput ri = new RebaseInput();
-    ri.base = commit;
-    ResourceConflictException thrown =
-        assertThrows(
-            ResourceConflictException.class,
-            () -> gApi.changes().id(changeId).revision(commit).rebase(ri));
-    assertThat(thrown).hasMessageThat().contains("cannot rebase change onto itself");
-  }
-
-  @Test
   @TestProjectInput(createEmptyCommit = false)
   public void changeNoParentToOneParent() throws Exception {
     // create initial commit with no parent and push it as change, so that patch
@@ -2668,6 +2147,78 @@
   }
 
   @Test
+  @GerritConfig(name = "accounts.visibility", value = "SAME_GROUP")
+  public void removeNonVisibleReviewer() throws Exception {
+    // allow all users to remove reviewers
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.REMOVE_REVIEWER).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    gApi.changes().id(changeId).addReviewer(user.email());
+    AccountInfo reviewerInfo =
+        Iterables.getOnlyElement(
+            gApi.changes().id(changeId).get().reviewers.get(ReviewerState.REVIEWER));
+    assertThat(reviewerInfo._accountId).isEqualTo(user.id().get());
+
+    TestAccount user2 = accountCreator.user2();
+    requestScopeOperations.setApiUser(user2.id());
+
+    // user2 cannot see user
+    assertThat(
+            accountControlFactory.get(identifiedUserFactory.create(user.id())).canSee(user2.id()))
+        .isFalse();
+
+    gApi.changes().id(changeId).reviewer(user.id().toString()).remove(new DeleteReviewerInput());
+    assertThat(gApi.changes().id(changeId).get().reviewers).isEmpty();
+  }
+
+  @Test
+  @GerritConfig(name = "accounts.visibility", value = "SAME_GROUP")
+  public void removeNonVisibleReviewerThroughPostReview() throws Exception {
+    // allow all users to remove reviewers
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.REMOVE_REVIEWER).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    gApi.changes().id(changeId).addReviewer(user.email());
+    AccountInfo reviewerInfo =
+        Iterables.getOnlyElement(
+            gApi.changes().id(changeId).get().reviewers.get(ReviewerState.REVIEWER));
+    assertThat(reviewerInfo._accountId).isEqualTo(user.id().get());
+
+    TestAccount user2 = accountCreator.user2();
+    requestScopeOperations.setApiUser(user2.id());
+
+    // user2 cannot see user
+    assertThat(
+            accountControlFactory.get(identifiedUserFactory.create(user.id())).canSee(user2.id()))
+        .isFalse();
+
+    ReviewerInput reviewerInput = new ReviewerInput();
+    reviewerInput.reviewer = user.email();
+    reviewerInput.state = ReviewerState.REMOVED;
+    ReviewInput reviewInput = new ReviewInput();
+    reviewInput.reviewers = ImmutableList.of(reviewerInput);
+    ReviewResult reviewResult = gApi.changes().id(changeId).current().review(reviewInput);
+    assertThat(reviewResult.error).isNull();
+
+    // user is removed as a reviewer, user2 is added as a CC by doing the post review request that
+    // removed user as a reviewer
+    assertThat(gApi.changes().id(changeId).get().reviewers.get(ReviewerState.REVIEWER)).isNull();
+    reviewerInfo =
+        Iterables.getOnlyElement(gApi.changes().id(changeId).get().reviewers.get(ReviewerState.CC));
+    assertThat(reviewerInfo._accountId).isEqualTo(user2.id().get());
+  }
+
+  @Test
   public void removeReviewerNotPermitted() throws Exception {
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
@@ -2879,7 +2430,68 @@
                     .id(r.getChangeId())
                     .reviewer(admin.id().toString())
                     .deleteVote(LabelId.CODE_REVIEW));
-    assertThat(thrown).hasMessageThat().contains("delete vote not permitted");
+    assertThat(thrown).hasMessageThat().contains("Delete vote not permitted");
+  }
+
+  @Test
+  public void deleteVoteAlwaysPermittedForSelfVotes() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(LabelId.CODE_REVIEW)
+                .ref("refs/heads/*")
+                .group(REGISTERED_USERS)
+                .range(-2, 2))
+        .add(
+            blockLabelRemoval(LabelId.CODE_REVIEW)
+                .ref("refs/heads/*")
+                .group(REGISTERED_USERS)
+                .range(-2, 2))
+        .add(block(Permission.REMOVE_REVIEWER).ref("refs/heads/*").group(REGISTERED_USERS))
+        .update();
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    requestScopeOperations.setApiUser(user.id());
+    gApi.changes().id(changeId).revision(r.getCommit().name()).review(ReviewInput.approve());
+
+    gApi.changes()
+        .id(r.getChangeId())
+        .reviewer(user.id().toString())
+        .deleteVote(LabelId.CODE_REVIEW);
+  }
+
+  @Test
+  public void deleteVoteAlwaysPermittedForAdmin() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(LabelId.CODE_REVIEW)
+                .ref("refs/heads/*")
+                .group(REGISTERED_USERS)
+                .range(-2, 2))
+        .add(
+            blockLabelRemoval(LabelId.CODE_REVIEW)
+                .ref("refs/heads/*")
+                .group(REGISTERED_USERS)
+                .range(-2, 2))
+        .add(block(Permission.REMOVE_REVIEWER).ref("refs/heads/*").group(REGISTERED_USERS))
+        .update();
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    requestScopeOperations.setApiUser(user.id());
+    gApi.changes().id(changeId).revision(r.getCommit().name()).review(ReviewInput.approve());
+
+    requestScopeOperations.setApiUser(admin.id());
+    gApi.changes()
+        .id(r.getChangeId())
+        .reviewer(user.id().toString())
+        .deleteVote(LabelId.CODE_REVIEW);
   }
 
   @Test
@@ -3175,6 +2787,40 @@
   }
 
   @Test
+  public void queryChangesDefaultFieldMatchesOwner() throws Exception {
+    // We have to create a new user since changes are not deleted between tests, which means
+    // querying the standard users will lead to dirty results.
+    TestAccount changeOwner = accountCreator.createValid("changeOwner");
+    requestScopeOperations.setApiUser(changeOwner.id());
+    // Creating a change through the API since PushOneCommit changes are always owned by admin().
+    ChangeInput in = new ChangeInput();
+    in.branch = Constants.MASTER;
+    in.subject = "subject";
+    in.project = project.get();
+    ChangeInfo info = gApi.changes().createAsInfo(in);
+    assertThat(info.owner._accountId).isEqualTo(changeOwner.id().get());
+    requestScopeOperations.setApiUser(user.id());
+    List<ChangeInfo> results = query(changeOwner.email());
+    assertThat(Iterables.getOnlyElement(results).changeId).isEqualTo(info.changeId);
+  }
+
+  @Test
+  public void queryChangesDefaultFieldMatchesReviewer() throws Exception {
+    requestScopeOperations.setApiUser(admin.id());
+    PushOneCommit.Result r =
+        pushFactory
+            .create(admin.newIdent(), testRepo, "subject", "a.txt", "a1")
+            .to("refs/for/master");
+    // We have to create a new user since changes are not deleted between tests, which means
+    // querying the standard users will lead to dirty results.
+    TestAccount changeReviewer = accountCreator.createValid("changeReviewer");
+    gApi.changes().id(r.getChangeId()).addReviewer(changeReviewer.email());
+    requestScopeOperations.setApiUser(user.id());
+    List<ChangeInfo> results = query(changeReviewer.email());
+    assertThat(Iterables.getOnlyElement(results).changeId).isEqualTo(r.getChangeId());
+  }
+
+  @Test
   public void checkReviewedFlagBeforeAndAfterReview() throws Exception {
     PushOneCommit.Result r = createChange();
     ReviewerInput in = new ReviewerInput();
@@ -3284,9 +2930,11 @@
   @Test
   public void submitToSymref() throws Exception {
     // Create symref in the origin repository (testRepo references to a local repository)
-    try (Repository repo = repoManager.openRepository(project)) {
-      RefUpdate u = repo.updateRef("refs/heads/master_symref");
-      assertThat(u.link("refs/heads/master")).isEqualTo(Result.NEW);
+    try (RefUpdateContext ctx = openTestRefUpdateContext()) {
+      try (Repository repo = repoManager.openRepository(project)) {
+        RefUpdate u = repo.updateRef("refs/heads/master_symref");
+        assertThat(u.link("refs/heads/master")).isEqualTo(Result.NEW);
+      }
     }
 
     PushOneCommit.Result r = createChange("refs/for/master_symref");
@@ -3414,6 +3062,18 @@
   }
 
   @Test
+  public void stableRevisionSort() throws Exception {
+    PushOneCommit.Result r1 = createChange();
+    r1.assertOkStatus();
+    PushOneCommit.Result r2 = amendChange(r1.getChangeId());
+    r2.assertOkStatus();
+
+    ChangeInfo actual = gApi.changes().id(r2.getChangeId()).get(ALL_REVISIONS, CURRENT_REVISION);
+    assertThat(actual.revisions).hasSize(2);
+    assertThat(actual.revisions.values().stream().map(r -> r._number)).isInOrder();
+  }
+
+  @Test
   public void defaultSearchDoesNotTouchDatabase() throws Exception {
     requestScopeOperations.setApiUser(admin.id());
     PushOneCommit.Result r1 = createChange();
@@ -3423,7 +3083,11 @@
         .review(ReviewInput.approve());
     gApi.changes().id(r1.getChangeId()).revision(r1.getCommit().name()).submit();
 
-    createChange();
+    PushOneCommit.Result change = createChange();
+    // Populate change with a reasonable set of fields. We can't exhaustively
+    // test all possible variations, but can try to cover a reasonable set.
+    approve(change.getChangeId());
+    gApi.changes().id(change.getChangeId()).addReviewer(user.email());
 
     requestScopeOperations.setApiUser(user.id());
     try (AutoCloseable ignored = disableNoteDb()) {
@@ -3438,6 +3102,34 @@
   }
 
   @Test
+  public void nonLazyloadQueryOptionsDoNotTouchDatabase() throws Exception {
+    requestScopeOperations.setApiUser(admin.id());
+    PushOneCommit.Result r1 = createChange();
+    gApi.changes()
+        .id(r1.getChangeId())
+        .revision(r1.getCommit().name())
+        .review(ReviewInput.approve());
+    gApi.changes().id(r1.getChangeId()).revision(r1.getCommit().name()).submit();
+
+    PushOneCommit.Result change = createChange();
+    // Populate change with a reasonable set of fields. We can't exhaustively
+    // test all possible variations, but can try to cover a reasonable set.
+    approve(change.getChangeId());
+    gApi.changes().id(change.getChangeId()).addReviewer(user.email());
+
+    requestScopeOperations.setApiUser(user.id());
+    try (AutoCloseable ignored = disableNoteDb()) {
+      assertThat(
+              gApi.changes()
+                  .query()
+                  .withQuery("project:{" + project.get() + "} (status:open OR status:closed)")
+                  .withOptions(EnumSet.complementOf(EnumSet.copyOf(ChangeJson.REQUIRE_LAZY_LOAD)))
+                  .get())
+          .hasSize(2);
+    }
+  }
+
+  @Test
   public void votable() throws Exception {
     PushOneCommit.Result r = createChange();
     String triplet = project.get() + "~master~" + r.getChangeId();
@@ -3702,6 +3394,7 @@
     assertThat(change.status).isEqualTo(ChangeStatus.NEW);
     assertThat(change.labels.keySet()).containsExactly(LabelId.CODE_REVIEW);
     assertThat(change.permittedLabels.keySet()).containsExactly(LabelId.CODE_REVIEW);
+    assertThat(change.removableLabels).isEmpty();
 
     // add new label and assert that it's returned for existing changes
     AccountGroup.UUID registeredUsers = systemGroupBackend.getGroup(REGISTERED_USERS).getUUID();
@@ -3731,6 +3424,9 @@
         .id(r.getChangeId())
         .revision(r.getCommit().name())
         .review(new ReviewInput().label(verified.getName(), verified.getMax().getValue()));
+    change = gApi.changes().id(r.getChangeId()).get();
+    assertPermitted(change, LabelId.VERIFIED, -1, 0, 1);
+    assertOnlyRemovableLabel(change, LabelId.VERIFIED, "+1", admin);
 
     try (ProjectConfigUpdate u = updateProject(project)) {
       // remove label and assert that it's no longer returned for existing
@@ -3750,6 +3446,7 @@
     change = gApi.changes().id(r.getChangeId()).get();
     assertThat(change.labels.keySet()).containsExactly(LabelId.CODE_REVIEW);
     assertThat(change.permittedLabels.keySet()).containsExactly(LabelId.CODE_REVIEW);
+    assertThat(change.removableLabels).isEmpty();
 
     // abandon the change and see that the returned labels stay the same
     // while all permitted labels disappear.
@@ -3758,6 +3455,7 @@
     assertThat(change.status).isEqualTo(ChangeStatus.ABANDONED);
     assertThat(change.labels.keySet()).containsExactly(LabelId.CODE_REVIEW);
     assertThat(change.permittedLabels).isEmpty();
+    assertThat(change.removableLabels).isEmpty();
   }
 
   @Test
@@ -3845,52 +3543,6 @@
     assertPermitted(change, LabelId.CODE_REVIEW, 2);
     assertPermitted(change, LabelId.VERIFIED, 0, 1);
     assertLabelDescription(change, LabelId.VERIFIED, TestLabels.VERIFIED_LABEL_DESCRIPTION);
-
-    // Ignore the new label by Prolog submit rule. Permitted ranges are still going to be
-    // returned for the label.
-    GitUtil.fetch(testRepo, RefNames.REFS_CONFIG + ":config");
-    testRepo.reset("config");
-    PushOneCommit push2 =
-        pushFactory.create(
-            admin.newIdent(),
-            testRepo,
-            "Ignore Verified",
-            "rules.pl",
-            "submit_rule(submit(CR)) :-\n  gerrit:max_with_block(-2, 2, 'Code-Review', CR).");
-    push2.to(RefNames.REFS_CONFIG);
-
-    change = gApi.changes().id(r.getChangeId()).get();
-    assertPermitted(change, LabelId.CODE_REVIEW, 2);
-    assertPermitted(change, LabelId.VERIFIED, 0, 1);
-
-    // add an approval on the new label. The label can still be voted +1 although it is ignored
-    // in Prolog. 0 is not permitted because votes cannot be decreased.
-    gApi.changes()
-        .id(r.getChangeId())
-        .revision(r.getCommit().name())
-        .review(new ReviewInput().label(verified.getName(), verified.getMax().getValue()));
-
-    change = gApi.changes().id(r.getChangeId()).get();
-    assertThat(change.labels.keySet()).containsExactly(LabelId.CODE_REVIEW, LabelId.VERIFIED);
-    assertPermitted(change, LabelId.CODE_REVIEW, 2);
-    assertPermitted(change, LabelId.VERIFIED, 1);
-
-    // remove label and assert that it's no longer returned for existing
-    // changes, even if there is an approval for it
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().getLabelSections().remove(verified.getName());
-      u.save();
-    }
-    projectOperations
-        .project(project)
-        .forUpdate()
-        .remove(permissionKey(verified.getName()).ref(heads).group(registeredUsers))
-        .update();
-
-    change = gApi.changes().id(r.getChangeId()).get();
-    assertThat(change.labels.keySet()).containsExactly(LabelId.CODE_REVIEW);
-    assertThat(change.permittedLabels.keySet()).containsExactly(LabelId.CODE_REVIEW);
-    assertPermitted(change, LabelId.CODE_REVIEW, 2);
   }
 
   @Test
@@ -4000,61 +3652,85 @@
   }
 
   @Test
-  public void checkLabelsForMergedChangeWithNonAuthorCodeReview() throws Exception {
-    // Configure Non-Author-Code-Review
-    RevCommit oldHead = projectOperations.project(project).getHead("master");
+  public void uploadingRulesPlIsNotAllowed() throws Exception {
+    projectOperations.project(project).getHead("master");
     GitUtil.fetch(testRepo, RefNames.REFS_CONFIG + ":config");
     testRepo.reset("config");
-    PushOneCommit push2 =
-        pushFactory.create(
+    PushOneCommit.Result pushResult =
+        pushFactory
+            .create(
+                admin.newIdent(),
+                testRepo,
+                "Add prolog rules",
+                RULES_PL_FILE,
+                "submit_rule(S) :-\n"
+                    + "  gerrit:default_submit(X),\n"
+                    + "  X =.. [submit | Ls],\n"
+                    + "  add_non_author_approval(Ls, R),\n"
+                    + "  S =.. [submit | R].\n"
+                    + "\n"
+                    + "add_non_author_approval(S1, S2) :-\n"
+                    + "  gerrit:commit_author(A),\n"
+                    + "  gerrit:commit_label(label('Code-Review', 2), R),\n"
+                    + "  R \\= A, !,\n"
+                    + "  S2 = [label('Non-Author-Code-Review', ok(R)) | S1].\n"
+                    + "add_non_author_approval(S1,"
+                    + " [label('Non-Author-Code-Review', need(_)) | S1]).")
+            .to(RefNames.REFS_CONFIG);
+    pushResult.assertOkStatus();
+    pushResult.assertMessage(
+        String.format(
+            "WARNING: commit %s: Uploading a new 'rules.pl' file is discouraged. "
+                + "Please consider adding submit-requirements instead.",
+            ObjectIds.abbreviateName(pushResult.getCommit())));
+  }
+
+  @Test
+  public void modifyingRulesPlIsAllowed() throws Exception {
+    // Committing the rules.pl change directly to the repository to bypass gerrit validation.
+    modifySubmitRules(
+        "submit_rule(submit(R)) :- \n"
+            + "gerrit:unresolved_comments_count(2), \n"
+            + "!,"
+            + "gerrit:uploader(U), \n"
+            + "R = label('All-Comments-Resolved', ok(U)).\n");
+    GitUtil.fetch(testRepo, RefNames.REFS_CONFIG + ":config");
+    testRepo.reset("config");
+    pushFactory
+        .create(
             admin.newIdent(),
             testRepo,
-            "Configure Non-Author-Code-Review",
-            "rules.pl",
-            "submit_rule(S) :-\n"
-                + "  gerrit:default_submit(X),\n"
-                + "  X =.. [submit | Ls],\n"
-                + "  add_non_author_approval(Ls, R),\n"
-                + "  S =.. [submit | R].\n"
-                + "\n"
-                + "add_non_author_approval(S1, S2) :-\n"
-                + "  gerrit:commit_author(A),\n"
-                + "  gerrit:commit_label(label('Code-Review', 2), R),\n"
-                + "  R \\= A, !,\n"
-                + "  S2 = [label('Non-Author-Code-Review', ok(R)) | S1].\n"
-                + "add_non_author_approval(S1,"
-                + " [label('Non-Author-Code-Review', need(_)) | S1]).");
-    push2.to(RefNames.REFS_CONFIG);
-    testRepo.reset(oldHead);
+            "Update prolog rules",
+            RULES_PL_FILE,
+            "submit_rule(submit(R)) :- \n"
+                + "gerrit:unresolved_comments_count(0), \n"
+                + "!,"
+                + "gerrit:uploader(U), \n"
+                + "R = label('All-Comments-Resolved', ok(U)).\n")
+        .to(RefNames.REFS_CONFIG)
+        .assertOkStatus();
+  }
 
-    String heads = RefNames.REFS_HEADS + "*";
-
-    // Allow user to approve
-    projectOperations
-        .project(project)
-        .forUpdate()
-        .add(
-            allowLabel(TestLabels.codeReview().getName())
-                .ref(heads)
-                .group(REGISTERED_USERS)
-                .range(-2, 2))
-        .update();
-
-    PushOneCommit.Result r = createChange();
-
-    requestScopeOperations.setApiUser(user.id());
-    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
-
-    requestScopeOperations.setApiUser(admin.id());
-    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).submit();
-
-    ChangeInfo change = gApi.changes().id(r.getChangeId()).get();
-    assertThat(change.status).isEqualTo(MERGED);
-    assertThat(change.submissionId).isNotNull();
-    assertThat(change.labels.keySet())
-        .containsExactly(LabelId.CODE_REVIEW, "Non-Author-Code-Review");
-    assertThat(change.permittedLabels.keySet()).containsExactly(LabelId.CODE_REVIEW);
-    assertPermitted(change, LabelId.CODE_REVIEW, 0, 1, 2);
+  @Test
+  public void deletingRulesPlIsAllowed() throws Exception {
+    // Committing the rules.pl change directly to the repository to bypass gerrit validation.
+    modifySubmitRules(
+        "submit_rule(submit(R)) :- \n"
+            + "gerrit:unresolved_comments_count(2), \n"
+            + "!,"
+            + "gerrit:uploader(U), \n"
+            + "R = label('All-Comments-Resolved', ok(U)).\n");
+    GitUtil.fetch(testRepo, RefNames.REFS_CONFIG + ":config");
+    testRepo.reset("config");
+    pushFactory
+        .create(
+            admin.newIdent(),
+            testRepo,
+            /* subject= */ "Remove prolog rules",
+            /* files= */ ImmutableMap.of())
+        .rmFile(RULES_PL_FILE)
+        .to(RefNames.REFS_CONFIG)
+        .assertOkStatus();
   }
 
   @Test
@@ -4070,6 +3746,7 @@
     assertThat(change.submissionId).isNotNull();
     assertThat(change.labels.keySet()).containsExactly(LabelId.CODE_REVIEW);
     assertPermitted(change, LabelId.CODE_REVIEW, 0, 1, 2);
+    assertThat(change.removableLabels).isEmpty();
   }
 
   @Test
@@ -4212,43 +3889,6 @@
   }
 
   @Test
-  public void unresolvedCommentsBlocked() throws Exception {
-    modifySubmitRules(
-        "submit_rule(submit(R)) :- \n"
-            + "gerrit:unresolved_comments_count(0), \n"
-            + "!,"
-            + "gerrit:uploader(U), \n"
-            + "R = label('All-Comments-Resolved', ok(U)).\n"
-            + "submit_rule(submit(R)) :- \n"
-            + "gerrit:unresolved_comments_count(U), \n"
-            + "U > 0,"
-            + "R = label('All-Comments-Resolved', need(_)). \n\n");
-
-    String oldHead = projectOperations.project(project).getHead("master").name();
-    PushOneCommit.Result result1 =
-        pushFactory.create(user.newIdent(), testRepo).to("refs/for/master");
-    testRepo.reset(oldHead);
-    PushOneCommit.Result result2 =
-        pushFactory.create(user.newIdent(), testRepo).to("refs/for/master");
-
-    addComment(result1, "comment 1", true, false, null);
-    addComment(result2, "comment 2", true, true, null);
-
-    gApi.changes().id(result1.getChangeId()).current().submit();
-
-    ResourceConflictException thrown =
-        assertThrows(
-            ResourceConflictException.class,
-            () -> gApi.changes().id(result2.getChangeId()).current().submit());
-    assertThat(thrown)
-        .hasMessageThat()
-        .contains("Failed to submit 1 change due to the following problems");
-    assertThat(thrown)
-        .hasMessageThat()
-        .contains("submit requirement 'All-Comments-Resolved' is unsatisfied");
-  }
-
-  @Test
   public void changeCommitMessage() throws Exception {
     // Tests mutating the commit message as both the owner of the change and a regular user with
     // addPatchSet permission. Asserts that both cases succeed.
@@ -4287,6 +3927,29 @@
   }
 
   @Test
+  public void changeCommitMessageFromChangeIdToLinkFooter() throws Exception {
+    PushOneCommit.Result r = createChange();
+    r.assertOkStatus();
+    assertThat(getCommitMessage(r.getChangeId()))
+        .isEqualTo("test commit\n\nChange-Id: " + r.getChangeId() + "\n");
+
+    requestScopeOperations.setApiUser(admin.id());
+    String newMessage =
+        "modified commit by "
+            + admin.id()
+            + "\n\nLink: "
+            + canonicalWebUrl.get()
+            + "id/"
+            + r.getChangeId()
+            + "\n";
+    gApi.changes().id(r.getChangeId()).setMessage(newMessage);
+    RevisionApi rApi = gApi.changes().id(r.getChangeId()).current();
+    assertThat(rApi.files().keySet()).containsExactly("/COMMIT_MSG", "a.txt");
+    assertThat(getCommitMessage(r.getChangeId())).isEqualTo(newMessage);
+    assertThat(rApi.description()).isEqualTo("Edit commit message");
+  }
+
+  @Test
   public void changeCommitMessageWithNoChangeIdSucceedsIfChangeIdNotRequired() throws Exception {
     ConfigInput configInput = new ConfigInput();
     configInput.requireChangeId = InheritableBoolean.FALSE;
@@ -4577,26 +4240,6 @@
     return gApi.changes().id(changeId).current().file("/COMMIT_MSG").content().asString();
   }
 
-  private void addComment(
-      PushOneCommit.Result r,
-      String message,
-      boolean omitDuplicateComments,
-      Boolean unresolved,
-      String inReplyTo)
-      throws Exception {
-    ReviewInput.CommentInput c = new ReviewInput.CommentInput();
-    c.line = 1;
-    c.message = message;
-    c.path = FILE_NAME;
-    c.unresolved = unresolved;
-    c.inReplyTo = inReplyTo;
-    ReviewInput in = new ReviewInput();
-    in.comments = new HashMap<>();
-    in.comments.put(c.path, Lists.newArrayList(c));
-    in.omitDuplicateComments = omitDuplicateComments;
-    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(in);
-  }
-
   private static Iterable<Account.Id> getReviewers(Collection<AccountInfo> r) {
     if (r == null) {
       return ImmutableList.of();
@@ -4621,10 +4264,12 @@
   }
 
   private void setChangeStatus(Change.Id id, Change.Status newStatus) throws Exception {
-    try (BatchUpdate batchUpdate =
-        batchUpdateFactory.create(project, atrScope.get().getUser(), TimeUtil.now())) {
-      batchUpdate.addOp(id, new ChangeStatusUpdateOp(newStatus));
-      batchUpdate.execute();
+    try (RefUpdateContext ctx = openTestRefUpdateContext()) {
+      try (BatchUpdate batchUpdate =
+          batchUpdateFactory.create(project, atrScope.get().getUser(), TimeUtil.now())) {
+        batchUpdate.addOp(id, new ChangeStatusUpdateOp(newStatus));
+        batchUpdate.execute();
+      }
     }
 
     ChangeStatus changeStatus = gApi.changes().id(id.get()).get().status;
@@ -4658,7 +4303,7 @@
           .commit()
           .author(admin.newIdent())
           .committer(admin.newIdent())
-          .add("rules.pl", newContent)
+          .add(RULES_PL_FILE, newContent)
           .message("Modify rules.pl")
           .create();
     }
@@ -4776,8 +4421,12 @@
             ListChangesOption.SKIP_DIFFSTAT);
 
     PushOneCommit.Result change = createChange();
-    int number = gApi.changes().id(change.getChangeId()).get()._number;
+    // Populate change with a reasonable set of fields. We can't exhaustively
+    // test all possible variations, but can try to cover a reasonable set.
+    approve(change.getChangeId());
+    gApi.changes().id(change.getChangeId()).addReviewer(user.email());
 
+    int number = gApi.changes().id(change.getChangeId()).get()._number;
     try (AutoCloseable ignored = changeIndexOperations.disableReadsAndWrites()) {
       assertThat(gApi.changes().id(project.get(), number).get(options).changeId)
           .isEqualTo(change.getChangeId());
@@ -4931,6 +4580,47 @@
         .contains(String.format("%s has removed %s", admin.fullName(), reviewerInput.reviewer));
   }
 
+  @Test
+  public void emailSubjectContainsChangeSizeBucket() throws Exception {
+    testEmailSubjectContainsChangeSizeBucket(0, "NoOp");
+    testEmailSubjectContainsChangeSizeBucket(1, "XS");
+    testEmailSubjectContainsChangeSizeBucket(9, "XS");
+    testEmailSubjectContainsChangeSizeBucket(10, "S");
+    testEmailSubjectContainsChangeSizeBucket(49, "S");
+    testEmailSubjectContainsChangeSizeBucket(50, "M");
+    testEmailSubjectContainsChangeSizeBucket(249, "M");
+    testEmailSubjectContainsChangeSizeBucket(250, "L");
+    testEmailSubjectContainsChangeSizeBucket(999, "L");
+    testEmailSubjectContainsChangeSizeBucket(1000, "XL");
+  }
+
+  private void testEmailSubjectContainsChangeSizeBucket(
+      int numberOfLines, String expectedSizeBucket) throws Exception {
+    String change;
+    if (numberOfLines == 0) {
+      // create empty change
+      ChangeInput in = new ChangeInput();
+      in.branch = Constants.MASTER;
+      in.subject = "Create a change from the API";
+      in.project = project.get();
+      ChangeInfo info = gApi.changes().create(in).get();
+      change = info.changeId;
+    } else {
+      change =
+          createChange(
+                  "subject",
+                  expectedSizeBucket + "-file-with-" + numberOfLines + "lines.txt",
+                  Collections.nCopies(numberOfLines, "line").stream().collect(joining("\n")))
+              .getChangeId();
+    }
+    sender.clear();
+    gApi.changes().id(change).addReviewer(user.email());
+    List<Message> messages = sender.getMessages();
+    assertThat(messages).hasSize(1);
+    assertThat(((StringEmailHeader) messages.get(0).headers().get("Subject")).getString())
+        .contains("[" + expectedSizeBucket + "]");
+  }
+
   private PushOneCommit.Result createWorkInProgressChange() throws Exception {
     return pushTo("refs/for/master%wip");
   }
@@ -4949,19 +4639,6 @@
     void call(String changeId, String reviewer) throws RestApiException;
   }
 
-  private static class TestWorkInProgressStateChangedListener
-      implements WorkInProgressStateChangedListener {
-    boolean invoked;
-    Boolean wip;
-
-    @Override
-    public void onWorkInProgressStateChanged(WorkInProgressStateChangedListener.Event event) {
-      this.invoked = true;
-      this.wip =
-          event.getChange().workInProgress != null ? event.getChange().workInProgress : false;
-    }
-  }
-
   public static class TestAttentionSetListenerModule extends AbstractModule {
     @Override
     public void configure() {
@@ -4986,15 +4663,4 @@
   private void voteLabel(String changeId, String labelName, int score) throws RestApiException {
     gApi.changes().id(changeId).current().review(new ReviewInput().label(labelName, score));
   }
-
-  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/ChangeIdIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeIdIT.java
index de73c00..1790133 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ChangeIdIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ChangeIdIT.java
@@ -54,6 +54,28 @@
   }
 
   @Test
+  public void projectChangeNumberReturnsChangeWhenProjectEndsWithSlash() throws Exception {
+    Project.NameKey p = projectOperations.newProject().create();
+    ChangeInfo ci = gApi.changes().create(new ChangeInput(p.get(), "master", "msg")).get();
+
+    ChangeInfo changeInfo = gApi.changes().id(p.get() + "/", ci._number).get();
+
+    assertThat(changeInfo.changeId).isEqualTo(ci.changeId);
+    assertThat(changeInfo.project).isEqualTo(p.get());
+  }
+
+  @Test
+  public void projectChangeNumberReturnsChangeWhenProjectEndsWithDotGit() throws Exception {
+    Project.NameKey p = projectOperations.newProject().create();
+    ChangeInfo ci = gApi.changes().create(new ChangeInput(p.get(), "master", "msg")).get();
+
+    ChangeInfo changeInfo = gApi.changes().id(p.get() + ".git", ci._number).get();
+
+    assertThat(changeInfo.changeId).isEqualTo(ci.changeId);
+    assertThat(changeInfo.project).isEqualTo(p.get());
+  }
+
+  @Test
   public void wrongProjectInProjectChangeNumberReturnsNotFound() throws Exception {
     ResourceNotFoundException thrown =
         assertThrows(
diff --git a/javatests/com/google/gerrit/acceptance/api/change/CopiedApprovalsInChangeMessageIT.java b/javatests/com/google/gerrit/acceptance/api/change/CopiedApprovalsInChangeMessageIT.java
index f8cf5fd..2b1bef0 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/CopiedApprovalsInChangeMessageIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/CopiedApprovalsInChangeMessageIT.java
@@ -45,12 +45,29 @@
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.server.approval.ApprovalCopier;
 import com.google.gerrit.server.approval.ApprovalsUtil;
+import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.gerrit.server.project.testing.TestLabels;
 import com.google.gerrit.server.util.AccountTemplateUtil;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
 import org.junit.Test;
 
+/**
+ * Tests to verify that copied/outdated approvals are included into the change message that is
+ * posted on patch set creation. Includes verifying that the copied/outdated approvals in the change
+ * message are correctly formatted.
+ *
+ * <p>Some of the tests only verify the correct formatting of the copied/outdated approvals in the
+ * change message that is done by {@link
+ * ApprovalsUtil#formatApprovalCopierResult(com.google.gerrit.server.approval.ApprovalCopier.Result,
+ * LabelTypes)}. This method does the formatting based on the inputs that it gets, but it doesn't do
+ * any verification of these inputs. This means it's possible to provide inputs that are
+ * inconsistent with the approval copying logic in {@link ApprovalCopier}. E.g. it's possible to
+ * provide "is:MAX" as a passing atom for a "Code-Review-1" vote and have "is:MAX" highlighted as
+ * passing in the message although the "Code-Review-1" vote doesn't match with "is:MAX". For easier
+ * readability the formatting tests avoid using such inconsistent input data, but it's not
+ * impossible that in some cases we made a mistake and the input data is inconsistent.
+ */
 public class CopiedApprovalsInChangeMessageIT extends AbstractDaemonTest {
   @Inject private ApprovalsUtil approvalsUtil;
   @Inject private ProjectOperations projectOperations;
@@ -98,7 +115,11 @@
     PatchSetApproval patchSetApproval = createPatchSetApproval(admin, "Code-Review", 1);
     ApprovalCopier.Result approvalCopierResult =
         ApprovalCopier.Result.create(
-            /* copiedApprovals= */ ImmutableSet.of(patchSetApproval),
+            /* copiedApprovals= */ ImmutableSet.of(
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval,
+                    /* passingAtoms= */ ImmutableSet.of(),
+                    /* failingAtoms= */ ImmutableSet.of())),
             /* outdatedApprovals= */ ImmutableSet.of());
     assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
         .hasValue("Copied Votes:\n* Code-Review+1 (label type is missing)\n");
@@ -111,7 +132,11 @@
     ApprovalCopier.Result approvalCopierResult =
         ApprovalCopier.Result.create(
             /* copiedApprovals= */ ImmutableSet.of(),
-            /* outdatedApprovals= */ ImmutableSet.of(patchSetApproval));
+            /* outdatedApprovals= */ ImmutableSet.of(
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval,
+                    /* passingAtoms= */ ImmutableSet.of(),
+                    /* failingAtoms= */ ImmutableSet.of())));
     assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
         .hasValue("Outdated Votes:\n* Code-Review+1 (label type is missing)\n");
   }
@@ -125,7 +150,11 @@
     PatchSetApproval patchSetApproval = createPatchSetApproval(admin, "Code-Review", 1);
     ApprovalCopier.Result approvalCopierResult =
         ApprovalCopier.Result.create(
-            /* copiedApprovals= */ ImmutableSet.of(patchSetApproval),
+            /* copiedApprovals= */ ImmutableSet.of(
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval,
+                    /* passingAtoms= */ ImmutableSet.of(),
+                    /* failingAtoms= */ ImmutableSet.of())),
             /* outdatedApprovals= */ ImmutableSet.of());
     assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
         .hasValue("Copied Votes:\n* Code-Review+1\n");
@@ -141,7 +170,11 @@
     ApprovalCopier.Result approvalCopierResult =
         ApprovalCopier.Result.create(
             /* copiedApprovals= */ ImmutableSet.of(),
-            /* outdatedApprovals= */ ImmutableSet.of(patchSetApproval));
+            /* outdatedApprovals= */ ImmutableSet.of(
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval,
+                    /* passingAtoms= */ ImmutableSet.of(),
+                    /* failingAtoms= */ ImmutableSet.of())));
     assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
         .hasValue("Outdated Votes:\n* Code-Review+1\n");
   }
@@ -153,13 +186,17 @@
             ImmutableList.of(
                 createLabelType(
                     /* labelName= */ "Code-Review", /* copyCondition= */ "is:MIN OR is:MAX")));
-    PatchSetApproval patchSetApproval = createPatchSetApproval(admin, "Code-Review", 1);
+    PatchSetApproval patchSetApproval = createPatchSetApproval(admin, "Code-Review", -2);
     ApprovalCopier.Result approvalCopierResult =
         ApprovalCopier.Result.create(
-            /* copiedApprovals= */ ImmutableSet.of(patchSetApproval),
+            /* copiedApprovals= */ ImmutableSet.of(
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval,
+                    /* passingAtoms= */ ImmutableSet.of("is:MIN"),
+                    /* failingAtoms= */ ImmutableSet.of("is:MAX"))),
             /* outdatedApprovals= */ ImmutableSet.of());
     assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
-        .hasValue("Copied Votes:\n* Code-Review+1 (copy condition: \"is:MIN OR is:MAX\")\n");
+        .hasValue("Copied Votes:\n* Code-Review-2 (copy condition: \"**is:MIN** OR is:MAX\")\n");
   }
 
   @Test
@@ -168,14 +205,21 @@
         new LabelTypes(
             ImmutableList.of(
                 createLabelType(
-                    /* labelName= */ "Code-Review", /* copyCondition= */ "is:MIN OR is:MAX")));
-    PatchSetApproval patchSetApproval = createPatchSetApproval(admin, "Code-Review", 1);
+                    /* labelName= */ "Code-Review",
+                    /* copyCondition= */ "changekind:TRIVIAL_REBASE is:MAX")));
+    PatchSetApproval patchSetApproval = createPatchSetApproval(admin, "Code-Review", 2);
     ApprovalCopier.Result approvalCopierResult =
         ApprovalCopier.Result.create(
             /* copiedApprovals= */ ImmutableSet.of(),
-            /* outdatedApprovals= */ ImmutableSet.of(patchSetApproval));
+            /* outdatedApprovals= */ ImmutableSet.of(
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval,
+                    /* passingAtoms= */ ImmutableSet.of("is:MAX"),
+                    /* failingAtoms= */ ImmutableSet.of("changekind:TRIVIAL_REBASE"))));
     assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
-        .hasValue("Outdated Votes:\n* Code-Review+1 (copy condition: \"is:MIN OR is:MAX\")\n");
+        .hasValue(
+            "Outdated Votes:\n* Code-Review+2 (copy condition:"
+                + " \"changekind:TRIVIAL_REBASE **is:MAX**\")\n");
   }
 
   @Test
@@ -189,7 +233,11 @@
     PatchSetApproval patchSetApproval = createPatchSetApproval(admin, "Code-Review", 1);
     ApprovalCopier.Result approvalCopierResult =
         ApprovalCopier.Result.create(
-            /* copiedApprovals= */ ImmutableSet.of(patchSetApproval),
+            /* copiedApprovals= */ ImmutableSet.of(
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval,
+                    /* passingAtoms= */ ImmutableSet.of(),
+                    /* failingAtoms= */ ImmutableSet.of())),
             /* outdatedApprovals= */ ImmutableSet.of());
     assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
         .hasValue(
@@ -208,7 +256,11 @@
     ApprovalCopier.Result approvalCopierResult =
         ApprovalCopier.Result.create(
             /* copiedApprovals= */ ImmutableSet.of(),
-            /* outdatedApprovals= */ ImmutableSet.of(patchSetApproval));
+            /* outdatedApprovals= */ ImmutableSet.of(
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval,
+                    /* passingAtoms= */ ImmutableSet.of(),
+                    /* failingAtoms= */ ImmutableSet.of())));
     assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
         .hasValue(
             "Outdated Votes:\n* Code-Review+1 (non-parseable copy condition: \"foo bar baz\")\n");
@@ -225,17 +277,22 @@
                     /* labelName= */ "Code-Review",
                     /* copyCondition= */ String.format(
                         "is:MIN OR (is:MAX approverin:%s)", groupUuid))));
-    PatchSetApproval patchSetApproval = createPatchSetApproval(admin, "Code-Review", 1);
+    PatchSetApproval patchSetApproval = createPatchSetApproval(admin, "Code-Review", 2);
     ApprovalCopier.Result approvalCopierResult =
         ApprovalCopier.Result.create(
-            /* copiedApprovals= */ ImmutableSet.of(patchSetApproval),
+            /* copiedApprovals= */ ImmutableSet.of(
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval,
+                    /* passingAtoms= */ ImmutableSet.of(
+                        "is:MAX", String.format("approverin:%s", groupUuid)),
+                    /* failingAtoms= */ ImmutableSet.of("is:MIN"))),
             /* outdatedApprovals= */ ImmutableSet.of());
     assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
         .hasValue(
             String.format(
                 "Copied Votes:\n"
-                    + "* Code-Review+1 by %s"
-                    + " (copy condition: \"is:MIN OR (is:MAX approverin:%s)\")\n",
+                    + "* Code-Review+2 by %s"
+                    + " (copy condition: \"is:MIN OR (**is:MAX** **approverin:%s**)\")\n",
                 AccountTemplateUtil.getAccountTemplate(admin.id()), groupUuid));
   }
 
@@ -254,12 +311,17 @@
     ApprovalCopier.Result approvalCopierResult =
         ApprovalCopier.Result.create(
             /* copiedApprovals= */ ImmutableSet.of(),
-            /* outdatedApprovals= */ ImmutableSet.of(patchSetApproval));
+            /* outdatedApprovals= */ ImmutableSet.of(
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval,
+                    /* passingAtoms= */ ImmutableSet.of(String.format("approverin:%s", groupUuid)),
+                    /* failingAtoms= */ ImmutableSet.of("is:MIN", "is:MAX"))));
     assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
         .hasValue(
             String.format(
                 "Outdated Votes:\n"
-                    + "* Code-Review+1 by %s (copy condition: \"is:MIN OR (is:MAX approverin:%s)\")\n",
+                    + "* Code-Review+1 by %s (copy condition: \"is:MIN"
+                    + " OR (is:MAX **approverin:%s**)\")\n",
                 AccountTemplateUtil.getAccountTemplate(admin.id()), groupUuid));
   }
 
@@ -275,10 +337,15 @@
                     /* labelName= */ "Code-Review",
                     /* copyCondition= */ String.format(
                         "is:MIN OR (is:MAX approverin:%s)", groupUuid))));
-    PatchSetApproval patchSetApproval = createPatchSetApproval(admin, "Code-Review", 1);
+    PatchSetApproval patchSetApproval = createPatchSetApproval(admin, "Code-Review", 2);
     ApprovalCopier.Result approvalCopierResult =
         ApprovalCopier.Result.create(
-            /* copiedApprovals= */ ImmutableSet.of(patchSetApproval),
+            /* copiedApprovals= */ ImmutableSet.of(
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval,
+                    /* passingAtoms= */ ImmutableSet.of(
+                        "is:MAX", String.format("approverin:%s", groupUuid)),
+                    /* failingAtoms= */ ImmutableSet.of("is:MIN"))),
             /* outdatedApprovals= */ ImmutableSet.of());
 
     // Set 'user' as the current user in the request scope.
@@ -291,8 +358,8 @@
         .hasValue(
             String.format(
                 "Copied Votes:\n"
-                    + "* Code-Review+1 by %s"
-                    + " (copy condition: \"is:MIN OR (is:MAX approverin:%s)\")\n",
+                    + "* Code-Review+2 by %s"
+                    + " (copy condition: \"is:MIN OR (**is:MAX** **approverin:%s**)\")\n",
                 AccountTemplateUtil.getAccountTemplate(admin.id()), groupUuid));
   }
 
@@ -313,7 +380,11 @@
     ApprovalCopier.Result approvalCopierResult =
         ApprovalCopier.Result.create(
             /* copiedApprovals= */ ImmutableSet.of(),
-            /* outdatedApprovals= */ ImmutableSet.of(patchSetApproval));
+            /* outdatedApprovals= */ ImmutableSet.of(
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval,
+                    /* passingAtoms= */ ImmutableSet.of(String.format("approverin:%s", groupUuid)),
+                    /* failingAtoms= */ ImmutableSet.of("is:MIN", "is:MAX"))));
 
     // Set 'user' as the current user in the request scope.
     // 'user' cannot see the Administrators group that is used in the copy condition.
@@ -325,7 +396,8 @@
         .hasValue(
             String.format(
                 "Outdated Votes:\n"
-                    + "* Code-Review+1 by %s (copy condition: \"is:MIN OR (is:MAX approverin:%s)\")\n",
+                    + "* Code-Review+1 by %s (copy condition: \"is:MIN"
+                    + " OR (is:MAX **approverin:%s**)\")\n",
                 AccountTemplateUtil.getAccountTemplate(admin.id()), groupUuid));
   }
 
@@ -344,7 +416,11 @@
     PatchSetApproval patchSetApproval = createPatchSetApproval(admin, "Code-Review", 1);
     ApprovalCopier.Result approvalCopierResult =
         ApprovalCopier.Result.create(
-            /* copiedApprovals= */ ImmutableSet.of(patchSetApproval),
+            /* copiedApprovals= */ ImmutableSet.of(
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval,
+                    /* passingAtoms= */ ImmutableSet.of(),
+                    /* failingAtoms= */ ImmutableSet.of())),
             /* outdatedApprovals= */ ImmutableSet.of());
     assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
         .hasValue(
@@ -371,7 +447,11 @@
     ApprovalCopier.Result approvalCopierResult =
         ApprovalCopier.Result.create(
             /* copiedApprovals= */ ImmutableSet.of(),
-            /* outdatedApprovals= */ ImmutableSet.of(patchSetApproval));
+            /* outdatedApprovals= */ ImmutableSet.of(
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval,
+                    /* passingAtoms= */ ImmutableSet.of(),
+                    /* failingAtoms= */ ImmutableSet.of())));
     assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
         .hasValue(
             String.format(
@@ -388,7 +468,15 @@
     PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Code-Review", 1);
     ApprovalCopier.Result approvalCopierResult =
         ApprovalCopier.Result.create(
-            /* copiedApprovals= */ ImmutableSet.of(patchSetApproval1, patchSetApproval2),
+            /* copiedApprovals= */ ImmutableSet.of(
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval1,
+                    /* passingAtoms= */ ImmutableSet.of(),
+                    /* failingAtoms= */ ImmutableSet.of()),
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval2,
+                    /* passingAtoms= */ ImmutableSet.of(),
+                    /* failingAtoms= */ ImmutableSet.of())),
             /* outdatedApprovals= */ ImmutableSet.of());
     assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
         .hasValue("Copied Votes:\n* Code-Review+1 (label type is missing)\n");
@@ -401,7 +489,15 @@
     PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Verified", 1);
     ApprovalCopier.Result approvalCopierResult =
         ApprovalCopier.Result.create(
-            /* copiedApprovals= */ ImmutableSet.of(patchSetApproval1, patchSetApproval2),
+            /* copiedApprovals= */ ImmutableSet.of(
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval1,
+                    /* passingAtoms= */ ImmutableSet.of(),
+                    /* failingAtoms= */ ImmutableSet.of()),
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval2,
+                    /* passingAtoms= */ ImmutableSet.of(),
+                    /* failingAtoms= */ ImmutableSet.of())),
             /* outdatedApprovals= */ ImmutableSet.of());
     assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
         .hasValue(
@@ -417,7 +513,15 @@
     PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Code-Review", 1);
     ApprovalCopier.Result approvalCopierResult =
         ApprovalCopier.Result.create(
-            /* copiedApprovals= */ ImmutableSet.of(patchSetApproval1, patchSetApproval2),
+            /* copiedApprovals= */ ImmutableSet.of(
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval1,
+                    /* passingAtoms= */ ImmutableSet.of(),
+                    /* failingAtoms= */ ImmutableSet.of()),
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval2,
+                    /* passingAtoms= */ ImmutableSet.of(),
+                    /* failingAtoms= */ ImmutableSet.of())),
             /* outdatedApprovals= */ ImmutableSet.of());
     assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
         .hasValue("Copied Votes:\n* Code-Review+1, Code-Review+2 (label type is missing)\n");
@@ -433,7 +537,15 @@
     PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Code-Review", 1);
     ApprovalCopier.Result approvalCopierResult =
         ApprovalCopier.Result.create(
-            /* copiedApprovals= */ ImmutableSet.of(patchSetApproval1, patchSetApproval2),
+            /* copiedApprovals= */ ImmutableSet.of(
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval1,
+                    /* passingAtoms= */ ImmutableSet.of(),
+                    /* failingAtoms= */ ImmutableSet.of()),
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval2,
+                    /* passingAtoms= */ ImmutableSet.of(),
+                    /* failingAtoms= */ ImmutableSet.of())),
             /* outdatedApprovals= */ ImmutableSet.of());
     assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
         .hasValue("Copied Votes:\n* Code-Review+1\n");
@@ -450,7 +562,15 @@
     PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Verified", 1);
     ApprovalCopier.Result approvalCopierResult =
         ApprovalCopier.Result.create(
-            /* copiedApprovals= */ ImmutableSet.of(patchSetApproval1, patchSetApproval2),
+            /* copiedApprovals= */ ImmutableSet.of(
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval1,
+                    /* passingAtoms= */ ImmutableSet.of(),
+                    /* failingAtoms= */ ImmutableSet.of()),
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval2,
+                    /* passingAtoms= */ ImmutableSet.of(),
+                    /* failingAtoms= */ ImmutableSet.of())),
             /* outdatedApprovals= */ ImmutableSet.of());
     assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
         .hasValue("Copied Votes:\n* Code-Review+1\n* Verified+1\n");
@@ -466,7 +586,15 @@
     PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Code-Review", 1);
     ApprovalCopier.Result approvalCopierResult =
         ApprovalCopier.Result.create(
-            /* copiedApprovals= */ ImmutableSet.of(patchSetApproval1, patchSetApproval2),
+            /* copiedApprovals= */ ImmutableSet.of(
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval1,
+                    /* passingAtoms= */ ImmutableSet.of(),
+                    /* failingAtoms= */ ImmutableSet.of()),
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval2,
+                    /* passingAtoms= */ ImmutableSet.of(),
+                    /* failingAtoms= */ ImmutableSet.of())),
             /* outdatedApprovals= */ ImmutableSet.of());
     assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
         .hasValue("Copied Votes:\n* Code-Review+1, Code-Review+2\n");
@@ -480,14 +608,22 @@
             ImmutableList.of(
                 createLabelType(
                     /* labelName= */ "Code-Review", /* copyCondition= */ "is:MIN OR is:MAX")));
-    PatchSetApproval patchSetApproval1 = createPatchSetApproval(admin, "Code-Review", 1);
-    PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Code-Review", 1);
+    PatchSetApproval patchSetApproval1 = createPatchSetApproval(admin, "Code-Review", 2);
+    PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Code-Review", 2);
     ApprovalCopier.Result approvalCopierResult =
         ApprovalCopier.Result.create(
-            /* copiedApprovals= */ ImmutableSet.of(patchSetApproval1, patchSetApproval2),
+            /* copiedApprovals= */ ImmutableSet.of(
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval1,
+                    /* passingAtoms= */ ImmutableSet.of("is:MAX"),
+                    /* failingAtoms= */ ImmutableSet.of("is:MIN")),
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval2,
+                    /* passingAtoms= */ ImmutableSet.of("is:MAX"),
+                    /* failingAtoms= */ ImmutableSet.of("is:MIN"))),
             /* outdatedApprovals= */ ImmutableSet.of());
     assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
-        .hasValue("Copied Votes:\n* Code-Review+1 (copy condition: \"is:MIN OR is:MAX\")\n");
+        .hasValue("Copied Votes:\n* Code-Review+2 (copy condition: \"is:MIN OR **is:MAX**\")\n");
   }
 
   @Test
@@ -498,39 +634,92 @@
             ImmutableList.of(
                 createLabelType(
                     /* labelName= */ "Code-Review", /* copyCondition= */ "is:MIN OR is:MAX"),
-                createLabelType(
-                    /* labelName= */ "Verified", /* copyCondition= */ "is:MIN OR is:MAX")));
-    PatchSetApproval patchSetApproval1 = createPatchSetApproval(admin, "Code-Review", 1);
+                LabelType.builder(
+                        "Verified",
+                        ImmutableList.of(
+                            LabelValue.create((short) -1, "Fails"),
+                            LabelValue.create((short) 0, "No Vote"),
+                            LabelValue.create((short) 1, "Succeeds")))
+                    .setCopyCondition("is:MIN OR is:MAX")
+                    .build()));
+    PatchSetApproval patchSetApproval1 = createPatchSetApproval(admin, "Code-Review", 2);
     PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Verified", 1);
     ApprovalCopier.Result approvalCopierResult =
         ApprovalCopier.Result.create(
-            /* copiedApprovals= */ ImmutableSet.of(patchSetApproval1, patchSetApproval2),
+            /* copiedApprovals= */ ImmutableSet.of(
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval1,
+                    /* passingAtoms= */ ImmutableSet.of("is:MAX"),
+                    /* failingAtoms= */ ImmutableSet.of("is:MIN")),
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval2,
+                    /* passingAtoms= */ ImmutableSet.of("is:MAX"),
+                    /* failingAtoms= */ ImmutableSet.of("is:MIN"))),
             /* outdatedApprovals= */ ImmutableSet.of());
     assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
         .hasValue(
             "Copied Votes:\n"
-                + "* Code-Review+1 (copy condition: \"is:MIN OR is:MAX\")\n"
-                + "* Verified+1 (copy condition: \"is:MIN OR is:MAX\")\n");
+                + "* Code-Review+2 (copy condition: \"is:MIN OR **is:MAX**\")\n"
+                + "* Verified+1 (copy condition: \"is:MIN OR **is:MAX**\")\n");
   }
 
   @Test
-  public void formatMultipleApprovals_differentValue_withCopyCondition_noUserInPredicate()
-      throws Exception {
+  public void
+      formatMultipleApprovals_differentValue_withCopyCondition_noUserInPredicate_samePassingAtoms()
+          throws Exception {
     LabelTypes labelTypes =
         new LabelTypes(
             ImmutableList.of(
                 createLabelType(
-                    /* labelName= */ "Code-Review", /* copyCondition= */ "is:MIN OR is:MAX")));
+                    /* labelName= */ "Code-Review", /* copyCondition= */ "changekind:REWORK")));
     PatchSetApproval patchSetApproval1 = createPatchSetApproval(admin, "Code-Review", 2);
     PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Code-Review", 1);
     ApprovalCopier.Result approvalCopierResult =
         ApprovalCopier.Result.create(
-            /* copiedApprovals= */ ImmutableSet.of(patchSetApproval1, patchSetApproval2),
+            /* copiedApprovals= */ ImmutableSet.of(
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval1,
+                    /* passingAtoms= */ ImmutableSet.of("changekind:REWORK"),
+                    /* failingAtoms= */ ImmutableSet.of()),
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval2,
+                    /* passingAtoms= */ ImmutableSet.of("changekind:REWORK"),
+                    /* failingAtoms= */ ImmutableSet.of())),
             /* outdatedApprovals= */ ImmutableSet.of());
     assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
         .hasValue(
             "Copied Votes:\n"
-                + "* Code-Review+1, Code-Review+2 (copy condition: \"is:MIN OR is:MAX\")\n");
+                + "* Code-Review+1, Code-Review+2 (copy condition: \"**changekind:REWORK**\")\n");
+  }
+
+  @Test
+  public void
+      formatMultipleApprovals_differentValue_withCopyCondition_noUserInPredicate_differentPassingAtoms()
+          throws Exception {
+    LabelTypes labelTypes =
+        new LabelTypes(
+            ImmutableList.of(
+                createLabelType(
+                    /* labelName= */ "Code-Review", /* copyCondition= */ "is:1 OR is:2")));
+    PatchSetApproval patchSetApproval1 = createPatchSetApproval(admin, "Code-Review", 2);
+    PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Code-Review", 1);
+    ApprovalCopier.Result approvalCopierResult =
+        ApprovalCopier.Result.create(
+            /* copiedApprovals= */ ImmutableSet.of(
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval1,
+                    /* passingAtoms= */ ImmutableSet.of("is:2"),
+                    /* failingAtoms= */ ImmutableSet.of("is:1")),
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval2,
+                    /* passingAtoms= */ ImmutableSet.of("is:1"),
+                    /* failingAtoms= */ ImmutableSet.of("is:2"))),
+            /* outdatedApprovals= */ ImmutableSet.of());
+    assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
+        .hasValue(
+            "Copied Votes:\n"
+                + "* Code-Review+1 (copy condition: \"**is:1** OR is:2\")\n"
+                + "* Code-Review+2 (copy condition: \"is:1 OR **is:2**\")\n");
   }
 
   @Test
@@ -545,7 +734,15 @@
     PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Code-Review", 1);
     ApprovalCopier.Result approvalCopierResult =
         ApprovalCopier.Result.create(
-            /* copiedApprovals= */ ImmutableSet.of(patchSetApproval1, patchSetApproval2),
+            /* copiedApprovals= */ ImmutableSet.of(
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval1,
+                    /* passingAtoms= */ ImmutableSet.of(),
+                    /* failingAtoms= */ ImmutableSet.of()),
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval2,
+                    /* passingAtoms= */ ImmutableSet.of(),
+                    /* failingAtoms= */ ImmutableSet.of())),
             /* outdatedApprovals= */ ImmutableSet.of());
     assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
         .hasValue(
@@ -565,7 +762,15 @@
     PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Verified", 1);
     ApprovalCopier.Result approvalCopierResult =
         ApprovalCopier.Result.create(
-            /* copiedApprovals= */ ImmutableSet.of(patchSetApproval1, patchSetApproval2),
+            /* copiedApprovals= */ ImmutableSet.of(
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval1,
+                    /* passingAtoms= */ ImmutableSet.of(),
+                    /* failingAtoms= */ ImmutableSet.of()),
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval2,
+                    /* passingAtoms= */ ImmutableSet.of(),
+                    /* failingAtoms= */ ImmutableSet.of())),
             /* outdatedApprovals= */ ImmutableSet.of());
     assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
         .hasValue(
@@ -587,7 +792,15 @@
     PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Code-Review", 1);
     ApprovalCopier.Result approvalCopierResult =
         ApprovalCopier.Result.create(
-            /* copiedApprovals= */ ImmutableSet.of(patchSetApproval1, patchSetApproval2),
+            /* copiedApprovals= */ ImmutableSet.of(
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval1,
+                    /* passingAtoms= */ ImmutableSet.of(),
+                    /* failingAtoms= */ ImmutableSet.of()),
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval2,
+                    /* passingAtoms= */ ImmutableSet.of(),
+                    /* failingAtoms= */ ImmutableSet.of())),
             /* outdatedApprovals= */ ImmutableSet.of());
     assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
         .hasValue(
@@ -597,8 +810,9 @@
   }
 
   @Test
-  public void formatMultipleApprovals_sameVote_withCopyCondition_withUserInPredicate()
-      throws Exception {
+  public void
+      formatMultipleApprovals_sameVote_withCopyCondition_withUserInPredicate_samePassingAtoms()
+          throws Exception {
     String groupUuid =
         groupCache.get(AccountGroup.nameKey("Administrators")).get().getGroupUUID().get();
     LabelTypes labelTypes =
@@ -608,24 +822,86 @@
                     /* labelName= */ "Code-Review",
                     /* copyCondition= */ String.format(
                         "is:MIN OR (is:MAX approverin:%s)", groupUuid))));
-    PatchSetApproval patchSetApproval1 = createPatchSetApproval(user, "Code-Review", 1);
-    PatchSetApproval patchSetApproval2 = createPatchSetApproval(admin, "Code-Review", 1);
+    PatchSetApproval patchSetApproval1 = createPatchSetApproval(user, "Code-Review", 2);
+    PatchSetApproval patchSetApproval2 = createPatchSetApproval(admin, "Code-Review", 2);
     ApprovalCopier.Result approvalCopierResult =
         ApprovalCopier.Result.create(
-            /* copiedApprovals= */ ImmutableSet.of(patchSetApproval1, patchSetApproval2),
+            /* copiedApprovals= */ ImmutableSet.of(
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval1,
+                    /* passingAtoms= */ ImmutableSet.of(
+                        "is:MAX", String.format("approverin:%s", groupUuid)),
+                    /* failingAtoms= */ ImmutableSet.of("is:MIN")),
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval2,
+                    /* passingAtoms= */ ImmutableSet.of(
+                        "is:MAX", String.format("approverin:%s", groupUuid)),
+                    /* failingAtoms= */ ImmutableSet.of("is:MIN"))),
             /* outdatedApprovals= */ ImmutableSet.of());
     assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
         .hasValue(
             String.format(
                 "Copied Votes:\n"
-                    + "* Code-Review+1 by %s, %s"
-                    + " (copy condition: \"is:MIN OR (is:MAX approverin:%s)\")\n",
+                    + "* Code-Review+2 by %s, %s"
+                    + " (copy condition: \"is:MIN OR (**is:MAX** **approverin:%s**)\")\n",
                 AccountTemplateUtil.getAccountTemplate(admin.id()),
                 AccountTemplateUtil.getAccountTemplate(user.id()),
                 groupUuid));
   }
 
   @Test
+  public void
+      formatMultipleApprovals_sameVote_withCopyCondition_withUserInPredicate_differentPassingAtoms()
+          throws Exception {
+    String administratorsGroupUuid =
+        groupCache.get(AccountGroup.nameKey("Administrators")).get().getGroupUUID().get();
+    String registeredUsersGroupUuid = SystemGroupBackend.REGISTERED_USERS.get();
+    LabelTypes labelTypes =
+        new LabelTypes(
+            ImmutableList.of(
+                createLabelType(
+                    /* labelName= */ "Code-Review",
+                    /* copyCondition= */ String.format(
+                        "is:MIN OR (is:MAX approverin:%s) OR (is:MAX approverin:%s)",
+                        administratorsGroupUuid, registeredUsersGroupUuid))));
+    PatchSetApproval patchSetApproval1 = createPatchSetApproval(user, "Code-Review", 2);
+    PatchSetApproval patchSetApproval2 = createPatchSetApproval(admin, "Code-Review", 2);
+    ApprovalCopier.Result approvalCopierResult =
+        ApprovalCopier.Result.create(
+            /* copiedApprovals= */ ImmutableSet.of(
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval1,
+                    /* passingAtoms= */ ImmutableSet.of(
+                        "is:MAX", String.format("approverin:%s", registeredUsersGroupUuid)),
+                    /* failingAtoms= */ ImmutableSet.of(
+                        "is:MIN", String.format("approverin:%s", administratorsGroupUuid))),
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval2,
+                    /* passingAtoms= */ ImmutableSet.of(
+                        "is:MAX",
+                        String.format("approverin:%s", administratorsGroupUuid),
+                        String.format("approverin:%s", registeredUsersGroupUuid)),
+                    /* failingAtoms= */ ImmutableSet.of("is:MIN"))),
+            /* outdatedApprovals= */ ImmutableSet.of());
+    assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
+        .hasValue(
+            String.format(
+                "Copied Votes:\n"
+                    + "* Code-Review+2 by %s"
+                    + " (copy condition: \"is:MIN OR (**is:MAX** **approverin:%s**)"
+                    + " OR (**is:MAX** **approverin:%s**)\")\n"
+                    + "* Code-Review+2 by %s"
+                    + " (copy condition: \"is:MIN OR (**is:MAX** approverin:%s)"
+                    + " OR (**is:MAX** **approverin:%s**)\")\n",
+                AccountTemplateUtil.getAccountTemplate(admin.id()),
+                administratorsGroupUuid,
+                registeredUsersGroupUuid,
+                AccountTemplateUtil.getAccountTemplate(user.id()),
+                administratorsGroupUuid,
+                registeredUsersGroupUuid));
+  }
+
+  @Test
   public void formatMultipleApprovals_differentLabel_withCopyCondition_withUserInPredicate()
       throws Exception {
     String groupUuid =
@@ -641,18 +917,30 @@
                     /* labelName= */ "Verified",
                     /* copyCondition= */ String.format(
                         "is:MIN OR (is:MAX approverin:%s)", groupUuid))));
-    PatchSetApproval patchSetApproval1 = createPatchSetApproval(user, "Code-Review", 1);
+    PatchSetApproval patchSetApproval1 = createPatchSetApproval(user, "Code-Review", -2);
     PatchSetApproval patchSetApproval2 = createPatchSetApproval(admin, "Verified", 1);
     ApprovalCopier.Result approvalCopierResult =
         ApprovalCopier.Result.create(
-            /* copiedApprovals= */ ImmutableSet.of(patchSetApproval1, patchSetApproval2),
+            /* copiedApprovals= */ ImmutableSet.of(
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval1,
+                    /* passingAtoms= */ ImmutableSet.of("is:MIN"),
+                    /* failingAtoms= */ ImmutableSet.of(
+                        "is:MAX", String.format("approverin:%s", groupUuid))),
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval2,
+                    /* passingAtoms= */ ImmutableSet.of(
+                        "is:MAX", String.format("approverin:%s", groupUuid)),
+                    /* failingAtoms= */ ImmutableSet.of("is:MIN"))),
             /* outdatedApprovals= */ ImmutableSet.of());
     assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
         .hasValue(
             String.format(
                 "Copied Votes:\n"
-                    + "* Code-Review+1 by %s (copy condition: \"is:MIN OR (is:MAX approverin:%s)\")\n"
-                    + "* Verified+1 by %s (copy condition: \"is:MIN OR (is:MAX approverin:%s)\")\n",
+                    + "* Code-Review-2 by %s (copy condition: \"**is:MIN**"
+                    + " OR (is:MAX approverin:%s)\")\n"
+                    + "* Verified+1 by %s (copy condition: \"is:MIN"
+                    + " OR (**is:MAX** **approverin:%s**)\")\n",
                 AccountTemplateUtil.getAccountTemplate(user.id()),
                 groupUuid,
                 AccountTemplateUtil.getAccountTemplate(admin.id()),
@@ -660,8 +948,9 @@
   }
 
   @Test
-  public void formatMultipleApprovals_differentValue_withCopyCondition_withUserInPredicate()
-      throws Exception {
+  public void
+      formatMultipleApprovals_differentValue_withCopyCondition_withUserInPredicate_samePassingAtoms()
+          throws Exception {
     String groupUuid =
         groupCache.get(AccountGroup.nameKey("Administrators")).get().getGroupUUID().get();
     LabelTypes labelTypes =
@@ -670,51 +959,122 @@
                 createLabelType(
                     /* labelName= */ "Code-Review",
                     /* copyCondition= */ String.format(
-                        "is:MIN OR (is:MAX approverin:%s)", groupUuid))));
+                        "is:MIN OR (is:ANY approverin:%s)", groupUuid))));
     PatchSetApproval patchSetApproval1 = createPatchSetApproval(admin, "Code-Review", 2);
     PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Code-Review", 1);
     ApprovalCopier.Result approvalCopierResult =
         ApprovalCopier.Result.create(
-            /* copiedApprovals= */ ImmutableSet.of(patchSetApproval1, patchSetApproval2),
+            /* copiedApprovals= */ ImmutableSet.of(
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval1,
+                    /* passingAtoms= */ ImmutableSet.of(
+                        "is:ANY", String.format("approverin:%s", groupUuid)),
+                    /* failingAtoms= */ ImmutableSet.of("is:MIN")),
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval2,
+                    /* passingAtoms= */ ImmutableSet.of(
+                        "is:ANY", String.format("approverin:%s", groupUuid)),
+                    /* failingAtoms= */ ImmutableSet.of("is:MIN"))),
             /* outdatedApprovals= */ ImmutableSet.of());
     assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
         .hasValue(
             String.format(
                 "Copied Votes:\n"
                     + "* Code-Review+1 by %s, Code-Review+2 by %s"
-                    + " (copy condition: \"is:MIN OR (is:MAX approverin:%s)\")\n",
+                    + " (copy condition: \"is:MIN OR (**is:ANY** **approverin:%s**)\")\n",
                 AccountTemplateUtil.getAccountTemplate(user.id()),
                 AccountTemplateUtil.getAccountTemplate(admin.id()),
                 groupUuid));
   }
 
   @Test
+  public void
+      formatMultipleApprovals_differentValue_withCopyCondition_withUserInPredicate_differentPassingAtoms()
+          throws Exception {
+    String groupUuid =
+        groupCache.get(AccountGroup.nameKey("Administrators")).get().getGroupUUID().get();
+    LabelTypes labelTypes =
+        new LabelTypes(
+            ImmutableList.of(
+                createLabelType(
+                    /* labelName= */ "Code-Review",
+                    /* copyCondition= */ String.format(
+                        "is:MIN OR (is:1 approverin:%s) OR (is:2 approverin:%s)",
+                        groupUuid, groupUuid))));
+    PatchSetApproval patchSetApproval1 = createPatchSetApproval(admin, "Code-Review", 2);
+    PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Code-Review", 1);
+    ApprovalCopier.Result approvalCopierResult =
+        ApprovalCopier.Result.create(
+            /* copiedApprovals= */ ImmutableSet.of(
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval1,
+                    /* passingAtoms= */ ImmutableSet.of(
+                        "is:2", String.format("approverin:%s", groupUuid)),
+                    /* failingAtoms= */ ImmutableSet.of("is:MIN", "is:1")),
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval2,
+                    /* passingAtoms= */ ImmutableSet.of(
+                        "is:1", String.format("approverin:%s", groupUuid)),
+                    /* failingAtoms= */ ImmutableSet.of("is:MIN", "is:2"))),
+            /* outdatedApprovals= */ ImmutableSet.of());
+    assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
+        .hasValue(
+            String.format(
+                "Copied Votes:\n"
+                    + "* Code-Review+1 by %s"
+                    + " (copy condition: \"is:MIN OR (**is:1** **approverin:%s**)"
+                    + " OR (is:2 **approverin:%s**)\")\n"
+                    + "* Code-Review+2 by %s"
+                    + " (copy condition: \"is:MIN OR (is:1 **approverin:%s**)"
+                    + " OR (**is:2** **approverin:%s**)\")\n",
+                AccountTemplateUtil.getAccountTemplate(user.id()),
+                groupUuid,
+                groupUuid,
+                AccountTemplateUtil.getAccountTemplate(admin.id()),
+                groupUuid,
+                groupUuid));
+  }
+
+  @Test
   public void formatMultipleApprovals_differentAndSameValue_withCopyCondition_withUserInPredicate()
       throws Exception {
     TestAccount user2 = accountCreator.user2();
-    String groupUuid =
-        groupCache.get(AccountGroup.nameKey("Administrators")).get().getGroupUUID().get();
+    String groupUuid = SystemGroupBackend.REGISTERED_USERS.get();
     LabelTypes labelTypes =
         new LabelTypes(
             ImmutableList.of(
                 createLabelType(
                     /* labelName= */ "Code-Review",
                     /* copyCondition= */ String.format(
-                        "is:MIN OR (is:MAX approverin:%s)", groupUuid))));
+                        "is:MIN OR (is:ANY approverin:%s)", groupUuid))));
     PatchSetApproval patchSetApproval1 = createPatchSetApproval(admin, "Code-Review", 2);
     PatchSetApproval patchSetApproval2 = createPatchSetApproval(user2, "Code-Review", 1);
     PatchSetApproval patchSetApproval3 = createPatchSetApproval(user, "Code-Review", 1);
     ApprovalCopier.Result approvalCopierResult =
         ApprovalCopier.Result.create(
             /* copiedApprovals= */ ImmutableSet.of(
-                patchSetApproval1, patchSetApproval2, patchSetApproval3),
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval1,
+                    /* passingAtoms= */ ImmutableSet.of(
+                        "is:ANY", String.format("approverin:%s", groupUuid)),
+                    /* failingAtoms= */ ImmutableSet.of("is:MIN")),
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval2,
+                    /* passingAtoms= */ ImmutableSet.of(
+                        "is:ANY", String.format("approverin:%s", groupUuid)),
+                    /* failingAtoms= */ ImmutableSet.of("is:MIN")),
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval3,
+                    /* passingAtoms= */ ImmutableSet.of(
+                        "is:ANY", String.format("approverin:%s", groupUuid)),
+                    /* failingAtoms= */ ImmutableSet.of("is:MIN"))),
             /* outdatedApprovals= */ ImmutableSet.of());
     assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
         .hasValue(
             String.format(
                 "Copied Votes:\n"
                     + "* Code-Review+1 by %s, %s, Code-Review+2 by %s"
-                    + " (copy condition: \"is:MIN OR (is:MAX approverin:%s)\")\n",
+                    + " (copy condition: \"is:MIN OR (**is:ANY** **approverin:%s**)\")\n",
                 AccountTemplateUtil.getAccountTemplate(user.id()),
                 AccountTemplateUtil.getAccountTemplate(user2.id()),
                 AccountTemplateUtil.getAccountTemplate(admin.id()),
@@ -737,7 +1097,15 @@
     PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Code-Review", 1);
     ApprovalCopier.Result approvalCopierResult =
         ApprovalCopier.Result.create(
-            /* copiedApprovals= */ ImmutableSet.of(patchSetApproval1, patchSetApproval2),
+            /* copiedApprovals= */ ImmutableSet.of(
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval1,
+                    /* passingAtoms= */ ImmutableSet.of(),
+                    /* failingAtoms= */ ImmutableSet.of()),
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval2,
+                    /* passingAtoms= */ ImmutableSet.of(),
+                    /* failingAtoms= */ ImmutableSet.of())),
             /* outdatedApprovals= */ ImmutableSet.of());
     assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
         .hasValue(
@@ -769,7 +1137,15 @@
     PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Verified", 1);
     ApprovalCopier.Result approvalCopierResult =
         ApprovalCopier.Result.create(
-            /* copiedApprovals= */ ImmutableSet.of(patchSetApproval1, patchSetApproval2),
+            /* copiedApprovals= */ ImmutableSet.of(
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval1,
+                    /* passingAtoms= */ ImmutableSet.of(),
+                    /* failingAtoms= */ ImmutableSet.of()),
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval2,
+                    /* passingAtoms= */ ImmutableSet.of(),
+                    /* failingAtoms= */ ImmutableSet.of())),
             /* outdatedApprovals= */ ImmutableSet.of());
     assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
         .hasValue(
@@ -799,7 +1175,15 @@
     PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Code-Review", 1);
     ApprovalCopier.Result approvalCopierResult =
         ApprovalCopier.Result.create(
-            /* copiedApprovals= */ ImmutableSet.of(patchSetApproval1, patchSetApproval2),
+            /* copiedApprovals= */ ImmutableSet.of(
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval1,
+                    /* passingAtoms= */ ImmutableSet.of(),
+                    /* failingAtoms= */ ImmutableSet.of()),
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval2,
+                    /* passingAtoms= */ ImmutableSet.of(),
+                    /* failingAtoms= */ ImmutableSet.of())),
             /* outdatedApprovals= */ ImmutableSet.of());
     assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
         .hasValue(
@@ -849,7 +1233,7 @@
                 + "\n"
                 + "Copied Votes:\n"
                 + "* Code-Review-2 (copy condition: \"changekind:NO_CHANGE"
-                + " OR changekind:TRIVIAL_REBASE OR is:MIN\")\n"
+                + " OR changekind:TRIVIAL_REBASE OR **is:MIN**\")\n"
                 + "\n"
                 + "Outdated Votes:\n"
                 + "* Verified+1\n");
@@ -900,7 +1284,7 @@
                 + "\n"
                 + "Copied Votes:\n"
                 + "* Code-Review-2 (copy condition: \"changekind:NO_CHANGE"
-                + " OR changekind:TRIVIAL_REBASE OR is:MIN\")\n"
+                + " OR changekind:TRIVIAL_REBASE OR **is:MIN**\")\n"
                 + "\n"
                 + "Outdated Votes:\n"
                 + "* Verified+1\n");
@@ -946,7 +1330,7 @@
                 + "\n"
                 + "Copied Votes:\n"
                 + "* Code-Review-2 (copy condition: \"changekind:NO_CHANGE"
-                + " OR changekind:TRIVIAL_REBASE OR is:MIN\")\n"
+                + " OR changekind:TRIVIAL_REBASE OR **is:MIN**\")\n"
                 + "\n"
                 + "Outdated Votes:\n"
                 + "* Verified+1\n");
diff --git a/javatests/com/google/gerrit/acceptance/api/change/CopyApprovalsToFollowUpPatchSetsIT.java b/javatests/com/google/gerrit/acceptance/api/change/CopyApprovalsToFollowUpPatchSetsIT.java
index 9d0e10a..a055201 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/CopyApprovalsToFollowUpPatchSetsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/CopyApprovalsToFollowUpPatchSetsIT.java
@@ -25,6 +25,7 @@
 import static com.google.gerrit.server.project.testing.TestLabels.labelBuilder;
 import static com.google.gerrit.server.project.testing.TestLabels.value;
 
+import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestAccount;
@@ -38,6 +39,7 @@
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.common.ApprovalInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.project.testing.TestLabels;
 import com.google.inject.Inject;
@@ -47,8 +49,8 @@
 import org.junit.Test;
 
 /**
- * Test to verify that {@link com.google.gerrit.server.restapi.change.PostReviewCopyApprovalsOp}
- * copies approvals to follow-up patch sets if possible.
+ * Test to verify that {@link com.google.gerrit.server.restapi.change.PostReviewOp} copies approvals
+ * to follow-up patch sets if possible.
  */
 public class CopyApprovalsToFollowUpPatchSetsIT extends AbstractDaemonTest {
   @Inject private ProjectOperations projectOperations;
@@ -106,9 +108,11 @@
     r.assertOkStatus();
     PatchSet patchSet2 = r.getChange().currentPatchSet();
 
-    // Vote on the first patch set.
+    // Vote on the first patch set and verify the change messages.
     vote(admin, changeId, patchSet1.number(), 2, 1);
+    assertLastChangeMessage(r.getChangeId(), "Patch Set 1: Code-Review+2 Verified+1");
     vote(user, changeId, patchSet1.number(), -2, -1);
+    assertLastChangeMessage(r.getChangeId(), "Patch Set 1: Code-Review-2 Verified-1");
 
     // Verify that no votes have been copied to the current patch set.
     ChangeInfo c = detailedChange(changeId);
@@ -128,8 +132,8 @@
    */
   @Test
   public void newApprovals_copied_noCurrentVote() throws Exception {
-    updateCodeReviewLabel(b -> b.setCopyCondition("is:any"));
-    updateVerifiedLabel(b -> b.setCopyCondition("is:any"));
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:ANY"));
+    updateVerifiedLabel(b -> b.setCopyCondition("is:ANY"));
 
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
@@ -139,9 +143,23 @@
     r.assertOkStatus();
     PatchSet patchSet2 = r.getChange().currentPatchSet();
 
-    // Vote on the first patch set.
+    // Vote on the first patch set and verify the change messages.
     vote(admin, changeId, patchSet1.number(), 2, 1);
+    assertLastChangeMessage(
+        r.getChangeId(),
+        String.format(
+            "Patch Set 1: Code-Review+2 Verified+1\n\n"
+                + "Copied votes on follow-up patch sets have been updated:\n"
+                + "* Code-Review+2 has been copied to patch set 2 (copy condition: \"is:ANY\").\n"
+                + "* Verified+1 has been copied to patch set 2 (copy condition: \"is:ANY\")."));
     vote(user, changeId, patchSet1.number(), -2, -1);
+    assertLastChangeMessage(
+        r.getChangeId(),
+        String.format(
+            "Patch Set 1: Code-Review-2 Verified-1\n\n"
+                + "Copied votes on follow-up patch sets have been updated:\n"
+                + "* Code-Review-2 has been copied to patch set 2 (copy condition: \"is:ANY\").\n"
+                + "* Verified-1 has been copied to patch set 2 (copy condition: \"is:ANY\")."));
 
     // Verify that the votes have been copied to the current patch set.
     ChangeInfo c = detailedChange(changeId);
@@ -161,8 +179,8 @@
    */
   @Test
   public void newApprovals_notCopied_currentVote() throws Exception {
-    updateCodeReviewLabel(b -> b.setCopyCondition("is:any"));
-    updateVerifiedLabel(b -> b.setCopyCondition("is:any"));
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:ANY"));
+    updateVerifiedLabel(b -> b.setCopyCondition("is:ANY"));
 
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
@@ -176,9 +194,13 @@
     vote(admin, changeId, patchSet2.number(), 2, 1);
     vote(user, changeId, patchSet2.number(), -2, -1);
 
-    // Vote on the first patch set.
+    // Vote on the first patch set and verify change message.
     vote(admin, changeId, patchSet1.number(), 1, -1);
+    assertLastChangeMessage(
+        r.getChangeId(), String.format("Patch Set 1: Code-Review+1 Verified-1"));
     vote(user, changeId, patchSet1.number(), -1, 1);
+    assertLastChangeMessage(
+        r.getChangeId(), String.format("Patch Set 1: Code-Review-1 Verified+1"));
 
     // Verify that the votes have not been copied to the current patch set (since a current vote
     // already exists).
@@ -200,8 +222,8 @@
    */
   @Test
   public void newApprovals_notCopied_currentDeletedVote() throws Exception {
-    updateCodeReviewLabel(b -> b.setCopyCondition("is:any"));
-    updateVerifiedLabel(b -> b.setCopyCondition("is:any"));
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:ANY"));
+    updateVerifiedLabel(b -> b.setCopyCondition("is:ANY"));
 
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
@@ -219,9 +241,13 @@
     deleteCurrentVotes(admin, changeId);
     deleteCurrentVotes(user, changeId);
 
-    // Vote on the first patch set.
+    // Vote on the first patch set and verify the change messages.
     vote(admin, changeId, patchSet1.number(), 1, -1);
+    assertLastChangeMessage(
+        r.getChangeId(), String.format("Patch Set 1: Code-Review+1 Verified-1"));
     vote(user, changeId, patchSet1.number(), -1, 1);
+    assertLastChangeMessage(
+        r.getChangeId(), String.format("Patch Set 1: Code-Review-1 Verified+1"));
 
     // Verify that the votes have not been copied to the current patch set (since a deletion vote
     // already exists on the current patch set).
@@ -254,9 +280,11 @@
     r.assertOkStatus();
     PatchSet patchSet2 = r.getChange().currentPatchSet();
 
-    // Update the votes on the first patch set.
+    // Update the votes on the first patch set and verify the change messages.
     vote(admin, changeId, patchSet1.number(), 2, -1);
+    assertLastChangeMessage(r.getChangeId(), "Patch Set 1: Code-Review+2 Verified-1");
     vote(user, changeId, patchSet1.number(), -1, 1);
+    assertLastChangeMessage(r.getChangeId(), "Patch Set 1: Code-Review-1 Verified+1");
 
     // Verify that no votes have been copied to the current patch set.
     ChangeInfo c = detailedChange(changeId);
@@ -278,7 +306,7 @@
   public void updatedApprovals_notCopied_copyingNotEnabled_unsetsCopiedApprovals()
       throws Exception {
     updateCodeReviewLabel(b -> b.setCopyCondition("is:1 OR is:2"));
-    updateVerifiedLabel(b -> b.setCopyCondition("is:max"));
+    updateVerifiedLabel(b -> b.setCopyCondition("is:MAX"));
 
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
@@ -297,9 +325,30 @@
     assertCurrentVotes(c, admin, 1, 1);
     assertCurrentVotes(c, user, 2, 1);
 
-    // Update the votes on the first patch set with votes that are not copied
+    // Update the votes on the first patch set with votes that are not copied and verify the change
+    // messages.
     vote(admin, changeId, patchSet1.number(), -1, -1);
+    assertLastChangeMessage(
+        r.getChangeId(),
+        String.format(
+            "Patch Set 1: Code-Review-1 Verified-1\n\n"
+                + "Copied votes on follow-up patch sets have been updated:\n"
+                + "* Copied Code-Review vote has been removed from patch set 2 (was Code-Review+1)"
+                + " since the new Code-Review-1 vote is not copyable"
+                + " (copy condition: \"is:1 OR is:2\").\n"
+                + "* Copied Verified vote has been removed from patch set 2 (was Verified+1)"
+                + " since the new Verified-1 vote is not copyable (copy condition: \"is:MAX\")."));
     vote(user, changeId, patchSet1.number(), -2, -1);
+    assertLastChangeMessage(
+        r.getChangeId(),
+        String.format(
+            "Patch Set 1: Code-Review-2 Verified-1\n\n"
+                + "Copied votes on follow-up patch sets have been updated:\n"
+                + "* Copied Code-Review vote has been removed from patch set 2 (was Code-Review+2)"
+                + " since the new Code-Review-2 vote is not copyable"
+                + " (copy condition: \"is:1 OR is:2\").\n"
+                + "* Copied Verified vote has been removed from patch set 2 (was Verified+1)"
+                + " since the new Verified-1 vote is not copyable (copy condition: \"is:MAX\")."));
 
     // Verify that the copied votes on the current patch set have been unset.
     c = detailedChange(changeId);
@@ -320,7 +369,7 @@
   @Test
   public void updatedApprovals_copied_noCurrentVote() throws Exception {
     updateCodeReviewLabel(b -> b.setCopyCondition("is:1 OR is:2"));
-    updateVerifiedLabel(b -> b.setCopyCondition("is:max"));
+    updateVerifiedLabel(b -> b.setCopyCondition("is:MAX"));
 
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
@@ -339,9 +388,26 @@
     assertCurrentVotes(c, admin, 0, 0);
     assertCurrentVotes(c, user, 0, 0);
 
-    // Update the votes on the first patch set with votes that are copied.
+    // Update the votes on the first patch set with votes that are copied and verify the change
+    // messages.
     vote(admin, changeId, patchSet1.number(), 2, 1);
+    assertLastChangeMessage(
+        r.getChangeId(),
+        String.format(
+            "Patch Set 1: Code-Review+2 Verified+1\n\n"
+                + "Copied votes on follow-up patch sets have been updated:\n"
+                + "* Code-Review+2 has been copied to patch set 2"
+                + " (copy condition: \"is:1 OR is:2\").\n"
+                + "* Verified+1 has been copied to patch set 2 (copy condition: \"is:MAX\")."));
     vote(user, changeId, patchSet1.number(), 1, 1);
+    assertLastChangeMessage(
+        r.getChangeId(),
+        String.format(
+            "Patch Set 1: Code-Review+1 Verified+1\n\n"
+                + "Copied votes on follow-up patch sets have been updated:\n"
+                + "* Code-Review+1 has been copied to patch set 2"
+                + " (copy condition: \"is:1 OR is:2\").\n"
+                + "* Verified+1 has been copied to patch set 2 (copy condition: \"is:MAX\")."));
 
     // Verify that the votes have been copied to the current patch set.
     c = detailedChange(changeId);
@@ -361,8 +427,8 @@
    */
   @Test
   public void updatedApprovals_notCopied_currentVote() throws Exception {
-    updateCodeReviewLabel(b -> b.setCopyCondition("is:any"));
-    updateVerifiedLabel(b -> b.setCopyCondition("is:any"));
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:ANY"));
+    updateVerifiedLabel(b -> b.setCopyCondition("is:ANY"));
 
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
@@ -380,9 +446,11 @@
     vote(admin, changeId, patchSet2.number(), 2, 1);
     vote(user, changeId, patchSet2.number(), -2, -1);
 
-    // Update the votes on the first patch set.
+    // Update the votes on the first patch set and verify the change messages.
     vote(admin, changeId, patchSet1.number(), -1, 1);
+    assertLastChangeMessage(r.getChangeId(), "Patch Set 1: Code-Review-1 Verified+1");
     vote(user, changeId, patchSet1.number(), 1, -1);
+    assertLastChangeMessage(r.getChangeId(), "Patch Set 1: Code-Review+1 Verified-1");
 
     // Verify that the votes have not been copied to the current patch set (since a current vote
     // already exists).
@@ -404,8 +472,8 @@
    */
   @Test
   public void updatedApprovals_notCopied_currentDeletedVote() throws Exception {
-    updateCodeReviewLabel(b -> b.setCopyCondition("is:any"));
-    updateVerifiedLabel(b -> b.setCopyCondition("is:any"));
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:ANY"));
+    updateVerifiedLabel(b -> b.setCopyCondition("is:ANY"));
 
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
@@ -427,9 +495,11 @@
     deleteCurrentVotes(admin, changeId);
     deleteCurrentVotes(user, changeId);
 
-    // Update the votes on the first patch set.
+    // Update the votes on the first patch set and verify the change messages.
     vote(admin, changeId, patchSet1.number(), -1, 1);
+    assertLastChangeMessage(r.getChangeId(), "Patch Set 1: Code-Review-1 Verified+1");
     vote(user, changeId, patchSet1.number(), 1, -1);
+    assertLastChangeMessage(r.getChangeId(), "Patch Set 1: Code-Review+1 Verified-1");
 
     // Verify that the votes have not been copied to the current patch set (since a deletion vote
     // already exists on the current patch set).
@@ -451,8 +521,8 @@
    */
   @Test
   public void updatedApprovals_copied_currentCopiedVote() throws Exception {
-    updateCodeReviewLabel(b -> b.setCopyCondition("is:any"));
-    updateVerifiedLabel(b -> b.setCopyCondition("is:any"));
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:ANY"));
+    updateVerifiedLabel(b -> b.setCopyCondition("is:ANY"));
 
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
@@ -471,9 +541,28 @@
     assertCurrentVotes(c, admin, -2, -1);
     assertCurrentVotes(c, user, 2, 1);
 
-    // Update the votes on the first patch set with votes that are copied.
+    // Update the votes on the first patch set with votes that are copied and verify the change
+    // messages.
     vote(admin, changeId, patchSet1.number(), 2, 1);
+    assertLastChangeMessage(
+        r.getChangeId(),
+        String.format(
+            "Patch Set 1: Code-Review+2 Verified+1\n\n"
+                + "Copied votes on follow-up patch sets have been updated:\n"
+                + "* Code-Review+2 has been copied to patch set 2 (was Code-Review-2)"
+                + " (copy condition: \"is:ANY\").\n"
+                + "* Verified+1 has been copied to patch set 2 (was Verified-1)"
+                + " (copy condition: \"is:ANY\")."));
     vote(user, changeId, patchSet1.number(), -2, -1);
+    assertLastChangeMessage(
+        r.getChangeId(),
+        String.format(
+            "Patch Set 1: Code-Review-2 Verified-1\n\n"
+                + "Copied votes on follow-up patch sets have been updated:\n"
+                + "* Code-Review-2 has been copied to patch set 2 (was Code-Review+2)"
+                + " (copy condition: \"is:ANY\").\n"
+                + "* Verified-1 has been copied to patch set 2 (was Verified+1)"
+                + " (copy condition: \"is:ANY\")."));
 
     // Verify that the votes have been copied to the current patch set.
     c = detailedChange(changeId);
@@ -509,9 +598,11 @@
     vote(admin, changeId, patchSet2.number(), -2, -1);
     vote(user, changeId, patchSet2.number(), 2, 1);
 
-    // Delete the votes on the first patch set.
+    // Delete the votes on the first patch set and verify the change messages.
     vote(admin, changeId, patchSet1.number(), 0, 0);
+    assertLastChangeMessage(r.getChangeId(), "Patch Set 1: -Code-Review -Verified");
     vote(user, changeId, patchSet1.number(), 0, 0);
+    assertLastChangeMessage(r.getChangeId(), "Patch Set 1: -Code-Review -Verified");
 
     // Verify that the vote deletions have not been copied to the current patch set.
     ChangeInfo c = detailedChange(changeId);
@@ -551,9 +642,11 @@
     assertCurrentVotes(c, admin, 0, 0);
     assertCurrentVotes(c, user, 0, 0);
 
-    // Delete the votes on the first patch set.
+    // Delete the votes on the first patch set and verify the change messages.
     vote(admin, changeId, patchSet1.number(), 0, 0);
+    assertLastChangeMessage(r.getChangeId(), "Patch Set 1: -Code-Review -Verified");
     vote(user, changeId, patchSet1.number(), 0, 0);
+    assertLastChangeMessage(r.getChangeId(), "Patch Set 1: -Code-Review -Verified");
 
     // Verify that there are still no votes on the current patch set.
     c = detailedChange(changeId);
@@ -573,8 +666,8 @@
    */
   @Test
   public void deletedApprovals_notCopied_currentVote() throws Exception {
-    updateCodeReviewLabel(b -> b.setCopyCondition("is:any"));
-    updateVerifiedLabel(b -> b.setCopyCondition("is:any"));
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:ANY"));
+    updateVerifiedLabel(b -> b.setCopyCondition("is:ANY"));
 
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
@@ -592,9 +685,11 @@
     vote(admin, changeId, patchSet2.number(), 2, 1);
     vote(user, changeId, patchSet2.number(), -2, -1);
 
-    // Delete the votes on the first patch set.
+    // Delete the votes on the first patch set and verify the change messages.
     vote(admin, changeId, patchSet1.number(), 0, 0);
+    assertLastChangeMessage(r.getChangeId(), "Patch Set 1: -Code-Review -Verified");
     vote(user, changeId, patchSet1.number(), 0, 0);
+    assertLastChangeMessage(r.getChangeId(), "Patch Set 1: -Code-Review -Verified");
 
     // Verify that the vote deletions have not been copied to the current patch set (since a current
     // vote already exists).
@@ -616,8 +711,8 @@
    */
   @Test
   public void deletedApprovals_notCopied_currentDeletedVote() throws Exception {
-    updateCodeReviewLabel(b -> b.setCopyCondition("is:any"));
-    updateVerifiedLabel(b -> b.setCopyCondition("is:any"));
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:ANY"));
+    updateVerifiedLabel(b -> b.setCopyCondition("is:ANY"));
 
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
@@ -639,9 +734,11 @@
     deleteCurrentVotes(admin, changeId);
     deleteCurrentVotes(user, changeId);
 
-    // Delete the votes on the first patch set.
+    // Delete the votes on the first patch set and verify the change messages.
     vote(admin, changeId, patchSet1.number(), 0, 0);
+    assertLastChangeMessage(r.getChangeId(), "Patch Set 1: -Code-Review -Verified");
     vote(user, changeId, patchSet1.number(), 0, 0);
+    assertLastChangeMessage(r.getChangeId(), "Patch Set 1: -Code-Review -Verified");
 
     // Verify that there are still no votes on the current patch set.
     ChangeInfo c = detailedChange(changeId);
@@ -662,8 +759,8 @@
    */
   @Test
   public void deletedApprovals_copied_currentCopiedVote() throws Exception {
-    updateCodeReviewLabel(b -> b.setCopyCondition("is:any"));
-    updateVerifiedLabel(b -> b.setCopyCondition("is:any"));
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:ANY"));
+    updateVerifiedLabel(b -> b.setCopyCondition("is:ANY"));
 
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
@@ -682,9 +779,29 @@
     assertCurrentVotes(c, admin, -2, -1);
     assertCurrentVotes(c, user, 2, 1);
 
-    // Delete the votes on the first patch set.
+    // Delete the votes on the first patch set and verify the change messages.
     vote(admin, changeId, patchSet1.number(), 0, 0);
+    assertLastChangeMessage(
+        r.getChangeId(),
+        String.format(
+            "Patch Set 1: -Code-Review -Verified\n\n"
+                + "Copied votes on follow-up patch sets have been updated:\n"
+                + "* Copied Code-Review vote has been removed from patch set 2 (was Code-Review-2)"
+                + " since the new Code-Review=0 vote is not copyable"
+                + " (copy condition: \"is:ANY\").\n"
+                + "* Copied Verified vote has been removed from patch set 2 (was Verified-1)"
+                + " since the new Verified=0 vote is not copyable (copy condition: \"is:ANY\")."));
     vote(user, changeId, patchSet1.number(), 0, 0);
+    assertLastChangeMessage(
+        r.getChangeId(),
+        String.format(
+            "Patch Set 1: -Code-Review -Verified\n\n"
+                + "Copied votes on follow-up patch sets have been updated:\n"
+                + "* Copied Code-Review vote has been removed from patch set 2 (was Code-Review+2)"
+                + " since the new Code-Review=0 vote is not copyable"
+                + " (copy condition: \"is:ANY\").\n"
+                + "* Copied Verified vote has been removed from patch set 2 (was Verified+1)"
+                + " since the new Verified=0 vote is not copyable (copy condition: \"is:ANY\")."));
 
     // Verify that the vote deletions have been copied to the current patch set.
     c = detailedChange(changeId);
@@ -701,8 +818,8 @@
   /** Tests that new approvals on an outdated patch set are copied to all follow-up patch sets. */
   @Test
   public void copyNewApprovalAcrossMultipleFollowUpPatchSets() throws Exception {
-    updateCodeReviewLabel(b -> b.setCopyCondition("is:any"));
-    updateVerifiedLabel(b -> b.setCopyCondition("is:any"));
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:ANY"));
+    updateVerifiedLabel(b -> b.setCopyCondition("is:ANY"));
 
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
@@ -720,9 +837,27 @@
     r.assertOkStatus();
     PatchSet patchSet4 = r.getChange().currentPatchSet();
 
-    // Vote on the first patch set.
+    // Vote on the first patch set and verify the change messages.
     vote(admin, changeId, patchSet1.number(), 2, 1);
+    assertLastChangeMessage(
+        r.getChangeId(),
+        String.format(
+            "Patch Set 1: Code-Review+2 Verified+1\n\n"
+                + "Copied votes on follow-up patch sets have been updated:\n"
+                + "* Code-Review+2 has been copied to patch set 2, 3, 4"
+                + " (copy condition: \"is:ANY\").\n"
+                + "* Verified+1 has been copied to patch set 2, 3, 4"
+                + " (copy condition: \"is:ANY\")."));
     vote(user, changeId, patchSet1.number(), -2, -1);
+    assertLastChangeMessage(
+        r.getChangeId(),
+        String.format(
+            "Patch Set 1: Code-Review-2 Verified-1\n\n"
+                + "Copied votes on follow-up patch sets have been updated:\n"
+                + "* Code-Review-2 has been copied to patch set 2, 3, 4"
+                + " (copy condition: \"is:ANY\").\n"
+                + "* Verified-1 has been copied to patch set 2, 3, 4"
+                + " (copy condition: \"is:ANY\")."));
 
     // Verify that votes have been copied to the current patch set.
     ChangeInfo c = detailedChange(changeId);
@@ -748,8 +883,8 @@
   public void
       copyNewApprovalAcrossMultipleFollowUpPatchSets_stopOnFirstFollowUpPatchSetToWhichTheVoteIsNotCopyable()
           throws Exception {
-    updateCodeReviewLabel(b -> b.setCopyCondition("is:1 or is:2"));
-    updateVerifiedLabel(b -> b.setCopyCondition("is:any"));
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:1 OR is:2"));
+    updateVerifiedLabel(b -> b.setCopyCondition("is:ANY"));
 
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
@@ -777,9 +912,25 @@
     assertCurrentVotes(c, admin, 0, -1);
     assertCurrentVotes(c, user, 0, -1);
 
-    // Vote on the first patch set with copyable votes.
+    // Vote on the first patch set with copyable votes and verify the change messages.
     vote(admin, changeId, patchSet1.number(), 2, 1);
+    assertLastChangeMessage(
+        r.getChangeId(),
+        String.format(
+            "Patch Set 1: Code-Review+2 Verified+1\n\n"
+                + "Copied votes on follow-up patch sets have been updated:\n"
+                + "* Code-Review+2 has been copied to patch set 2 "
+                + "(copy condition: \"is:1 OR is:2\").\n"
+                + "* Verified+1 has been copied to patch set 2 (copy condition: \"is:ANY\")."));
     vote(user, changeId, patchSet1.number(), 1, 1);
+    assertLastChangeMessage(
+        r.getChangeId(),
+        String.format(
+            "Patch Set 1: Code-Review+1 Verified+1\n\n"
+                + "Copied votes on follow-up patch sets have been updated:\n"
+                + "* Code-Review+1 has been copied to patch set 2"
+                + " (copy condition: \"is:1 OR is:2\").\n"
+                + "* Verified+1 has been copied to patch set 2 (copy condition: \"is:ANY\")."));
 
     // Verify that votes have been not copied to the current patch set.
     c = detailedChange(changeId);
@@ -804,8 +955,8 @@
    */
   @Test
   public void copyApprovalDeletionAcrossMultipleFollowUpPatchSets() throws Exception {
-    updateCodeReviewLabel(b -> b.setCopyCondition("is:any"));
-    updateVerifiedLabel(b -> b.setCopyCondition("is:any"));
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:ANY"));
+    updateVerifiedLabel(b -> b.setCopyCondition("is:ANY"));
 
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
@@ -832,9 +983,33 @@
     assertCurrentVotes(c, admin, 2, 1);
     assertCurrentVotes(c, user, -2, -1);
 
-    // Delete the votes on the first patch set.
+    // Delete the votes on the first patch set and verify the change messages.
     vote(admin, changeId, patchSet1.number(), 0, 0);
+    assertLastChangeMessage(
+        r.getChangeId(),
+        String.format(
+            "Patch Set 1: -Code-Review -Verified\n\n"
+                + "Copied votes on follow-up patch sets have been updated:\n"
+                + "* Copied Code-Review vote has been removed from patch set"
+                + " 2 (was Code-Review+2), 3 (was Code-Review+2), 4 (was Code-Review+2)"
+                + " since the new Code-Review=0 vote is not copyable"
+                + " (copy condition: \"is:ANY\").\n"
+                + "* Copied Verified vote has been removed from patch set"
+                + " 2 (was Verified+1), 3 (was Verified+1), 4 (was Verified+1)"
+                + " since the new Verified=0 vote is not copyable (copy condition: \"is:ANY\")."));
     vote(user, changeId, patchSet1.number(), 0, 0);
+    assertLastChangeMessage(
+        r.getChangeId(),
+        String.format(
+            "Patch Set 1: -Code-Review -Verified\n\n"
+                + "Copied votes on follow-up patch sets have been updated:\n"
+                + "* Copied Code-Review vote has been removed from patch set"
+                + " 2 (was Code-Review-2), 3 (was Code-Review-2), 4 (was Code-Review-2)"
+                + " since the new Code-Review=0 vote is not copyable"
+                + " (copy condition: \"is:ANY\").\n"
+                + "* Copied Verified vote has been removed from patch set"
+                + " 2 (was Verified-1), 3 (was Verified-1), 4 (was Verified-1)"
+                + " since the new Verified=0 vote is not copyable (copy condition: \"is:ANY\")."));
 
     // Verify that the votes has been copied to the current patch set.
     c = detailedChange(changeId);
@@ -860,8 +1035,8 @@
   public void
       copyApprovalDeletionAcrossMultipleFollowUpPatchSets_stopOnFirstFollowUpPatchSetToWhichTheVoteIsNotCopyable()
           throws Exception {
-    updateCodeReviewLabel(b -> b.setCopyCondition("is:1 or is:2"));
-    updateVerifiedLabel(b -> b.setCopyCondition("is:any"));
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:1 OR is:2"));
+    updateVerifiedLabel(b -> b.setCopyCondition("is:ANY"));
 
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
@@ -894,7 +1069,27 @@
 
     // Delete the votes on the first patch set.
     vote(admin, changeId, patchSet1.number(), 0, 0);
+    assertLastChangeMessage(
+        r.getChangeId(),
+        String.format(
+            "Patch Set 1: -Code-Review -Verified\n\n"
+                + "Copied votes on follow-up patch sets have been updated:\n"
+                + "* Copied Code-Review vote has been removed from patch set 2 (was Code-Review+2)"
+                + " since the new Code-Review=0 vote is not copyable"
+                + " (copy condition: \"is:1 OR is:2\").\n"
+                + "* Copied Verified vote has been removed from patch set 2 (was Verified+1)"
+                + " since the new Verified=0 vote is not copyable (copy condition: \"is:ANY\")."));
     vote(user, changeId, patchSet1.number(), 0, 0);
+    assertLastChangeMessage(
+        r.getChangeId(),
+        String.format(
+            "Patch Set 1: -Code-Review -Verified\n\n"
+                + "Copied votes on follow-up patch sets have been updated:\n"
+                + "* Copied Code-Review vote has been removed from patch set 2 (was Code-Review+1)"
+                + " since the new Code-Review=0 vote is not copyable"
+                + " (copy condition: \"is:1 OR is:2\").\n"
+                + "* Copied Verified vote has been removed from patch set 2 (was Verified-1)"
+                + " since the new Verified=0 vote is not copyable (copy condition: \"is:ANY\")."));
 
     // Verify that the vote deletions have been not copied to the current patch set.
     c = detailedChange(changeId);
@@ -917,8 +1112,8 @@
   /** Tests that new approvals on an outdated patch set are not copied to predecessor patch sets. */
   @Test
   public void notCopyToPredecessorPatchSets() throws Exception {
-    updateCodeReviewLabel(b -> b.setCopyCondition("is:any"));
-    updateVerifiedLabel(b -> b.setCopyCondition("is:any"));
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:ANY"));
+    updateVerifiedLabel(b -> b.setCopyCondition("is:ANY"));
 
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
@@ -936,9 +1131,23 @@
     r.assertOkStatus();
     PatchSet patchSet4 = r.getChange().currentPatchSet();
 
-    // Vote on the third patch set.
+    // Vote on the third patch set and verify the change messages.
     vote(admin, changeId, patchSet3.number(), 2, 1);
+    assertLastChangeMessage(
+        r.getChangeId(),
+        String.format(
+            "Patch Set 3: Code-Review+2 Verified+1\n\n"
+                + "Copied votes on follow-up patch sets have been updated:\n"
+                + "* Code-Review+2 has been copied to patch set 4 (copy condition: \"is:ANY\").\n"
+                + "* Verified+1 has been copied to patch set 4 (copy condition: \"is:ANY\")."));
     vote(user, changeId, patchSet3.number(), -2, -1);
+    assertLastChangeMessage(
+        r.getChangeId(),
+        String.format(
+            "Patch Set 3: Code-Review-2 Verified-1\n\n"
+                + "Copied votes on follow-up patch sets have been updated:\n"
+                + "* Code-Review-2 has been copied to patch set 4 (copy condition: \"is:ANY\").\n"
+                + "* Verified-1 has been copied to patch set 4 (copy condition: \"is:ANY\")."));
 
     // Verify that votes have been copied to the current patch set.
     ChangeInfo c = detailedChange(changeId);
@@ -1057,4 +1266,10 @@
     assertThat(patchSetApproval.get().value()).isEqualTo((short) expectedVote);
     assertThat(patchSetApproval.get().copied()).isEqualTo(expectedToBeCopied);
   }
+
+  private void assertLastChangeMessage(String changeId, String expectedMessage)
+      throws RestApiException {
+    assertThat(Iterables.getLast(gApi.changes().id(changeId).get().messages).message)
+        .isEqualTo(expectedMessage);
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java b/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java
index 9e7a693..a0f0fe6 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java
@@ -16,6 +16,10 @@
 
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.server.project.testing.TestLabels.labelBuilder;
+import static com.google.gerrit.server.project.testing.TestLabels.value;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.util.stream.Collectors.toList;
 import static org.mockito.ArgumentMatchers.any;
@@ -36,13 +40,16 @@
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.config.GerritConfig;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.entities.SubmitRecord;
 import com.google.gerrit.extensions.annotations.Exports;
 import com.google.gerrit.extensions.api.changes.DraftInput;
@@ -79,6 +86,7 @@
 import com.google.inject.Inject;
 import com.google.inject.Module;
 import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
@@ -100,6 +108,7 @@
   @Inject private TestCommentHelper testCommentHelper;
   @Inject private RequestScopeOperations requestScopeOperations;
   @Inject private ExtensionRegistry extensionRegistry;
+  @Inject private ProjectOperations projectOperations;
 
   private static final String COMMENT_TEXT = "The comment text";
   private static final CommentForValidation FILE_COMMENT_FOR_VALIDATION =
@@ -978,6 +987,36 @@
                 user.fullName()));
   }
 
+  @Test
+  public void votesInChangeMessageAreSorted() throws Exception {
+    // Create Verify label and allow voting on it.
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      LabelType.Builder verified =
+          labelBuilder(
+              LabelId.VERIFIED, value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
+      u.getConfig().upsertLabelType(verified.build());
+      u.save();
+    }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(LabelId.VERIFIED)
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-1, 1))
+        .update();
+
+    PushOneCommit.Result r = createChange();
+
+    ReviewInput input = new ReviewInput().label(LabelId.CODE_REVIEW, 2).label(LabelId.VERIFIED, 1);
+    gApi.changes().id(r.getChangeId()).current().review(input);
+
+    Collection<ChangeMessageInfo> messages = gApi.changes().id(r.getChangeId()).get().messages;
+    assertThat(Iterables.getLast(messages).message)
+        .isEqualTo(String.format("Patch Set 1: Code-Review+2 Verified+1"));
+  }
+
   private static class TestListener implements CommentAddedListener {
     public CommentAddedListener.Event lastCommentAddedEvent;
 
@@ -1026,6 +1065,7 @@
 
     @Override
     public Optional<String> getChangeMessageAddOn(
+        Instant when,
         IdentifiedUser user,
         ChangeNotes changeNotes,
         PatchSet patchSet,
diff --git a/javatests/com/google/gerrit/acceptance/api/change/PrivateChangeIT.java b/javatests/com/google/gerrit/acceptance/api/change/PrivateChangeIT.java
index 267f5a7..519c1dc 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/PrivateChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/PrivateChangeIT.java
@@ -18,6 +18,7 @@
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static com.google.gerrit.testing.TestActionRefUpdateContext.testRefAction;
 
 import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
@@ -252,20 +253,22 @@
     try (BatchUpdate u =
         batchUpdateFactory.create(
             project, identifiedUserFactory.create(admin.id()), TimeUtil.now())) {
-      u.addOp(
-              changeId,
-              new BatchUpdateOp() {
-                @Override
-                public boolean updateChange(ChangeContext ctx) {
-                  ctx.getChange().setPrivate(true);
-                  ChangeUpdate update = ctx.getUpdate(ctx.getChange().currentPatchSetId());
-                  ctx.getChange().setPrivate(true);
-                  ctx.getChange().setLastUpdatedOn(ctx.getWhen());
-                  update.setPrivate(true);
-                  return true;
-                }
-              })
-          .execute();
+      testRefAction(
+          () ->
+              u.addOp(
+                      changeId,
+                      new BatchUpdateOp() {
+                        @Override
+                        public boolean updateChange(ChangeContext ctx) {
+                          ctx.getChange().setPrivate(true);
+                          ChangeUpdate update = ctx.getUpdate(ctx.getChange().currentPatchSetId());
+                          ctx.getChange().setPrivate(true);
+                          ctx.getChange().setLastUpdatedOn(ctx.getWhen());
+                          update.setPrivate(true);
+                          return true;
+                        }
+                      })
+                  .execute());
     }
     assertThat(gApi.changes().id(changeId.get()).get().isPrivate).isTrue();
   }
diff --git a/javatests/com/google/gerrit/acceptance/api/change/QueryChangesIT.java b/javatests/com/google/gerrit/acceptance/api/change/QueryChangesIT.java
index 3bfb573..3f3ad37 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/QueryChangesIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/QueryChangesIT.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance.api.change;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowCapability;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
@@ -23,16 +24,20 @@
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
+import com.google.common.truth.Correspondence;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.acceptance.UseClockStep;
 import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
+import com.google.gerrit.acceptance.testsuite.change.ChangeOperations;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.entities.AccessSection;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
@@ -40,9 +45,11 @@
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.restapi.change.QueryChanges;
+import com.google.gerrit.truth.NullAwareCorrespondence;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import java.util.Arrays;
@@ -53,6 +60,7 @@
 
 public class QueryChangesIT extends AbstractDaemonTest {
   @Inject private AccountOperations accountOperations;
+  @Inject private ChangeOperations changeOperations;
   @Inject private ProjectOperations projectOperations;
   @Inject private Provider<QueryChanges> queryChangesProvider;
   @Inject private RequestScopeOperations requestScopeOperations;
@@ -364,6 +372,295 @@
     assertThat(result3).hasSize(1);
   }
 
+  /**
+   * This test verifies that querying by a non-visible account doesn't fail.
+   *
+   * <p>Change queries only return changes that are visible to the calling user. If a non-visible
+   * account participated in such a change the existence of this account is known to everyone who
+   * can see the change. Hence it's OK to that the account visibility check is skipped when querying
+   * changes by non-visible accounts. If the account is visible through any visible change these
+   * changes are returned, otherwise the result is empty (see
+   * emptyResultWhenQueryingByNonVisibleAccountAndMatchingChangesAreNotVisible()), same as for
+   * non-existing accounts (see test emptyResultWhenQueryingByNonExistingAccount()).
+   */
+  @Test
+  @GerritConfig(name = "accounts.visibility", value = "NONE")
+  public void changesCanBeQueriesByNonVisibleAccounts() throws Exception {
+    String ownerEmail = "owner@example.com";
+    Account.Id nonVisibleOwner = accountOperations.newAccount().preferredEmail(ownerEmail).create();
+
+    String reviewerEmail = "reviewer@example.com";
+    Account.Id nonVisibleReviewer =
+        accountOperations.newAccount().preferredEmail(reviewerEmail).create();
+
+    // Create the change.
+    Change.Id changeId = changeOperations.newChange().owner(nonVisibleOwner).create();
+
+    // Add a review.
+    requestScopeOperations.setApiUser(nonVisibleReviewer);
+    gApi.changes().id(changeId.get()).current().review(ReviewInput.recommend());
+
+    requestScopeOperations.setApiUser(user.id());
+
+    // Verify that user can see the change.
+    assertThat(gApi.changes().query("change:" + changeId).get())
+        .comparingElementsUsing(hasChangeId())
+        .containsExactly(changeId);
+
+    // Verify that user cannot see the other accounts.
+    assertThrows(
+        ResourceNotFoundException.class, () -> gApi.accounts().id(nonVisibleOwner.get()).get());
+    assertThrows(
+        ResourceNotFoundException.class, () -> gApi.accounts().id(nonVisibleReviewer.get()).get());
+
+    // Verify that the change is also found if user queries for changes owned/uploaded by
+    // nonVisibleOwner.
+    assertThat(gApi.changes().query("owner:" + ownerEmail).get())
+        .comparingElementsUsing(hasChangeId())
+        .containsExactly(changeId);
+    assertThat(gApi.changes().query("uploader:" + ownerEmail).get())
+        .comparingElementsUsing(hasChangeId())
+        .containsExactly(changeId);
+
+    // Verify that the change is also found if user queries for changes reviewed by
+    // nonVisibleReviewer.
+    assertThat(gApi.changes().query("reviewer:" + reviewerEmail).get())
+        .comparingElementsUsing(hasChangeId())
+        .containsExactly(changeId);
+    assertThat(gApi.changes().query("label:Code-Review+1,user=" + reviewerEmail).get())
+        .comparingElementsUsing(hasChangeId())
+        .containsExactly(changeId);
+  }
+
+  /**
+   * This test verifies that an empty result is returned for a query by a non-existing account.
+   *
+   * <p>Such queries must not return an error so that users cannot probe whether an account exists.
+   * Since we return an empty result for non-visible accounts if there are no matched changes or non
+   * of the matched changes is visible, users could conclude the existence of a account if we would
+   * return an error for non-existing accounts.
+   */
+  @Test
+  public void emptyResultWhenQueryingByNonExistingAccount() throws Exception {
+    assertThat(gApi.changes().query("owner:non-existing@example.com").get()).isEmpty();
+    assertThat(gApi.changes().query("uploader:non-existing@example.com").get()).isEmpty();
+    assertThat(gApi.changes().query("reviewer:non-existing@example.com").get()).isEmpty();
+    assertThat(gApi.changes().query("label:Code-Review+1,user=non-existing@example.com").get())
+        .isEmpty();
+  }
+
+  @Test
+  @GerritConfig(name = "accounts.visibility", value = "NONE")
+  public void emptyResultWhenQueryingByNonVisibleAccountAndMatchingChangesAreNotVisible()
+      throws Exception {
+    String ownerEmail = "owner@example.com";
+    Account.Id nonVisibleOwner = accountOperations.newAccount().preferredEmail(ownerEmail).create();
+
+    String reviewerEmail = "reviewer@example.com";
+    Account.Id nonVisibleReviewer =
+        accountOperations.newAccount().preferredEmail(reviewerEmail).create();
+
+    // Create the change.
+    Change.Id changeId = changeOperations.newChange().owner(nonVisibleOwner).create();
+
+    // Add a review.
+    requestScopeOperations.setApiUser(nonVisibleReviewer);
+    gApi.changes().id(changeId.get()).current().review(ReviewInput.recommend());
+
+    // Block read permission so that the change is not visible.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.READ).ref("refs/heads/master").group(REGISTERED_USERS))
+        .update();
+
+    requestScopeOperations.setApiUser(user.id());
+
+    // Verify that user cannot see the change.
+    assertThat(gApi.changes().query("change:" + changeId).get()).isEmpty();
+
+    // Verify that user cannot see the other accounts.
+    assertThrows(
+        ResourceNotFoundException.class, () -> gApi.accounts().id(nonVisibleOwner.get()).get());
+    assertThrows(
+        ResourceNotFoundException.class, () -> gApi.accounts().id(nonVisibleReviewer.get()).get());
+
+    // Verify that the change is also found if user queries for changes owned/uploaded by
+    // nonVisibleOwner.
+    assertThat(gApi.changes().query("owner:" + ownerEmail).get()).isEmpty();
+    assertThat(gApi.changes().query("uploader:" + ownerEmail).get()).isEmpty();
+
+    // Verify that the change is also found if user queries for changes reviewed by
+    // nonVisibleReviewer.
+    assertThat(gApi.changes().query("reviewer:" + reviewerEmail).get()).isEmpty();
+    assertThat(gApi.changes().query("label:Code-Review+1,user=" + reviewerEmail).get()).isEmpty();
+  }
+
+  @Test
+  public void emptyResultWhenQueryingByNonVisibleSecondaryEmail() throws Exception {
+    String secondaryOwnerEmail = "owner-secondary@example.com";
+    Account.Id owner =
+        accountOperations
+            .newAccount()
+            .preferredEmail("owner@example.com")
+            .addSecondaryEmail(secondaryOwnerEmail)
+            .create();
+
+    String secondaryReviewerEmail = "reviewer-secondary@example.com";
+    Account.Id reviewer =
+        accountOperations
+            .newAccount()
+            .preferredEmail("reviewer@example.com")
+            .addSecondaryEmail(secondaryReviewerEmail)
+            .create();
+
+    // Create the change.
+    Change.Id changeId = changeOperations.newChange().owner(owner).create();
+
+    // Add a review.
+    requestScopeOperations.setApiUser(reviewer);
+    gApi.changes().id(changeId.get()).current().review(ReviewInput.recommend());
+
+    requestScopeOperations.setApiUser(user.id());
+
+    // Verify that user can see the change.
+    assertThat(gApi.changes().query("change:" + changeId).get())
+        .comparingElementsUsing(hasChangeId())
+        .containsExactly(changeId);
+
+    // Verify that user cannot see the other accounts by their secondary email.
+    assertThrows(
+        ResourceNotFoundException.class, () -> gApi.accounts().id(secondaryOwnerEmail).get());
+    assertThrows(
+        ResourceNotFoundException.class, () -> gApi.accounts().id(secondaryReviewerEmail).get());
+
+    // Verify that the change is not found if user queries for changes owned/uploaded by the
+    // secondary email of the owner that is not visible to user.
+    assertThat(gApi.changes().query("owner:" + secondaryOwnerEmail).get()).isEmpty();
+    assertThat(gApi.changes().query("uploader:" + secondaryOwnerEmail).get()).isEmpty();
+
+    // Verify that the change is not found if user queries for changes reviewed by the secondary
+    // email of the reviewer that is not visible to user.
+    assertThat(gApi.changes().query("reviewer:" + secondaryReviewerEmail).get()).isEmpty();
+    assertThat(gApi.changes().query("label:Code-Review+1,user=" + secondaryReviewerEmail).get())
+        .isEmpty();
+  }
+
+  @Test
+  public void changesFoundWhenQueryingBySecondaryEmailWithModifyAccountCapability()
+      throws Exception {
+    testCangesFoundWhenQueryingBySecondaryEmailWithModifyAccountCapability(
+        GlobalCapability.MODIFY_ACCOUNT);
+  }
+
+  @Test
+  public void changesFoundWhenQueryingBySecondaryEmailWithViewSecondaryEmailsCapability()
+      throws Exception {
+    testCangesFoundWhenQueryingBySecondaryEmailWithModifyAccountCapability(
+        GlobalCapability.VIEW_SECONDARY_EMAILS);
+  }
+
+  private void testCangesFoundWhenQueryingBySecondaryEmailWithModifyAccountCapability(
+      String globalCapability) throws Exception {
+    String secondaryOwnerEmail = "owner-secondary@example.com";
+    Account.Id owner =
+        accountOperations
+            .newAccount()
+            .preferredEmail("owner@example.com")
+            .addSecondaryEmail(secondaryOwnerEmail)
+            .create();
+
+    String secondaryReviewerEmail = "reviewer-secondary@example.com";
+    Account.Id reviewer =
+        accountOperations
+            .newAccount()
+            .preferredEmail("reviewer@example.com")
+            .addSecondaryEmail(secondaryReviewerEmail)
+            .create();
+
+    // Create the change.
+    Change.Id changeId = changeOperations.newChange().owner(owner).create();
+
+    // Add a review.
+    requestScopeOperations.setApiUser(reviewer);
+    gApi.changes().id(changeId.get()).current().review(ReviewInput.recommend());
+
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(globalCapability).group(REGISTERED_USERS))
+        .update();
+
+    requestScopeOperations.setApiUser(user.id());
+
+    // Verify that user can see the other accounts by their secondary email.
+    assertThat(gApi.accounts().id(secondaryOwnerEmail).get()._accountId).isEqualTo(owner.get());
+    assertThat(gApi.accounts().id(secondaryReviewerEmail).get()._accountId)
+        .isEqualTo(reviewer.get());
+
+    // Verify that the change is found if user queries for changes owned/uploaded by the secondary
+    // email.
+    assertThat(gApi.changes().query("owner:" + secondaryOwnerEmail).get())
+        .comparingElementsUsing(hasChangeId())
+        .containsExactly(changeId);
+    assertThat(gApi.changes().query("uploader:" + secondaryOwnerEmail).get())
+        .comparingElementsUsing(hasChangeId())
+        .containsExactly(changeId);
+
+    // Verify that the change is found if user queries for changes reviewed by the secondary email.
+    assertThat(gApi.changes().query("reviewer:" + secondaryReviewerEmail).get())
+        .comparingElementsUsing(hasChangeId())
+        .containsExactly(changeId);
+    assertThat(gApi.changes().query("label:Code-Review+1,user=" + secondaryReviewerEmail).get())
+        .comparingElementsUsing(hasChangeId())
+        .containsExactly(changeId);
+  }
+
+  @Test
+  public void changesFoundWhenQueryingByOwnSecondaryEmail() throws Exception {
+    String secondaryOwnerEmail = "owner-secondary@example.com";
+    Account.Id owner =
+        accountOperations
+            .newAccount()
+            .preferredEmail("owner@example.com")
+            .addSecondaryEmail(secondaryOwnerEmail)
+            .create();
+
+    String secondaryReviewerEmail = "reviewer-secondary@example.com";
+    Account.Id reviewer =
+        accountOperations
+            .newAccount()
+            .preferredEmail("reviewer@example.com")
+            .addSecondaryEmail(secondaryReviewerEmail)
+            .create();
+
+    // Create the change.
+    Change.Id changeId = changeOperations.newChange().owner(owner).create();
+
+    // Add a review.
+    requestScopeOperations.setApiUser(reviewer);
+    gApi.changes().id(changeId.get()).current().review(ReviewInput.recommend());
+
+    // Verify that the change is found if owner queries for changes owned/uploaded by their
+    // secondary email.
+    requestScopeOperations.setApiUser(owner);
+    assertThat(gApi.changes().query("owner:" + secondaryOwnerEmail).get())
+        .comparingElementsUsing(hasChangeId())
+        .containsExactly(changeId);
+    assertThat(gApi.changes().query("uploader:" + secondaryOwnerEmail).get())
+        .comparingElementsUsing(hasChangeId())
+        .containsExactly(changeId);
+
+    // Verify that the change is found if reviewer queries for changes reviewed by their secondary
+    // email.
+    requestScopeOperations.setApiUser(reviewer);
+    assertThat(gApi.changes().query("reviewer:" + secondaryReviewerEmail).get())
+        .comparingElementsUsing(hasChangeId())
+        .containsExactly(changeId);
+    assertThat(gApi.changes().query("label:Code-Review+1,user=" + secondaryReviewerEmail).get())
+        .comparingElementsUsing(hasChangeId())
+        .containsExactly(changeId);
+  }
+
   private static void assertNoChangeHasMoreChangesSet(List<ChangeInfo> results) {
     for (ChangeInfo info : results) {
       assertThat(info._moreChanges).isNull();
@@ -383,4 +680,9 @@
       }
     }
   }
+
+  private static Correspondence<ChangeInfo, Change.Id> hasChangeId() {
+    return NullAwareCorrespondence.transforming(
+        changeInfo -> Change.id(changeInfo._number), "hasChangeId");
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/change/RebaseChainOnBehalfOfUploaderIT.java b/javatests/com/google/gerrit/acceptance/api/change/RebaseChainOnBehalfOfUploaderIT.java
new file mode 100644
index 0000000..785186d
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/change/RebaseChainOnBehalfOfUploaderIT.java
@@ -0,0 +1,1401 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.api.change;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth8.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_REAL_USER;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.common.collect.Iterables;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.ExtensionRegistry;
+import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
+import com.google.gerrit.acceptance.TestMetricMaker;
+import com.google.gerrit.acceptance.UseLocalDisk;
+import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
+import com.google.gerrit.acceptance.testsuite.change.ChangeOperations;
+import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementExpression;
+import com.google.gerrit.extensions.api.changes.RebaseInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.common.ActionInfo;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.ChangeMessageInfo;
+import com.google.gerrit.extensions.common.RevisionInfo;
+import com.google.gerrit.extensions.events.RevisionCreatedListener;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.notedb.ChangeNoteUtil;
+import com.google.gerrit.server.project.testing.TestLabels;
+import com.google.gerrit.server.util.AccountTemplateUtil;
+import com.google.inject.Inject;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
+import org.eclipse.jgit.lib.ReflogEntry;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.FooterLine;
+import org.junit.Test;
+
+/**
+ * Tests for the {@link com.google.gerrit.server.restapi.change.RebaseChain} REST endpoint with the
+ * {@link RebaseInput#onBehalfOfUploader} option being set.
+ *
+ * <p>Rebasing a single change on behalf of the uploader is covered by {@link
+ * RebaseOnBehalfOfUploaderIT}.
+ */
+public class RebaseChainOnBehalfOfUploaderIT extends AbstractDaemonTest {
+  @Inject private AccountOperations accountOperations;
+  @Inject private ChangeOperations changeOperations;
+  @Inject private GroupOperations groupOperations;
+  @Inject private ProjectOperations projectOperations;
+  @Inject private RequestScopeOperations requestScopeOperations;
+  @Inject private ExtensionRegistry extensionRegistry;
+  @Inject private TestMetricMaker testMetricMaker;
+
+  @Test
+  public void cannotRebaseOnBehalfOfUploaderWithAllowConflicts() throws Exception {
+    Account.Id uploader = accountOperations.newAccount().create();
+    Change.Id changeId = changeOperations.newChange().owner(uploader).create();
+    RebaseInput rebaseInput = new RebaseInput();
+    rebaseInput.onBehalfOfUploader = true;
+    rebaseInput.allowConflicts = true;
+    BadRequestException exception =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.changes().id(changeId.get()).rebaseChain(rebaseInput));
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo("allow_conflicts and on_behalf_of_uploader are mutually exclusive");
+  }
+
+  @Test
+  public void rebaseChangeOnBehalfOfUploader_withRebasePermission() throws Exception {
+    testRebaseChainOnBehalfOfUploader(Permission.REBASE);
+  }
+
+  @Test
+  public void rebaseChangeOnBehalfOfUploader_withSubmitPermission() throws Exception {
+    testRebaseChainOnBehalfOfUploader(Permission.SUBMIT);
+  }
+
+  private void testRebaseChainOnBehalfOfUploader(String permissionToAllow) throws Exception {
+    Account.Id approver = admin.id();
+    Account.Id rebaser = accountOperations.newAccount().create();
+
+    // Grant permission to rebaser that is required to rebase on behalf of the uploader.
+    AccountGroup.UUID allowedGroup =
+        groupOperations.newGroup().name("can-" + permissionToAllow).addMember(rebaser).create();
+    allowPermission(permissionToAllow, allowedGroup);
+
+    // Block push permission for rebaser, as in contrast to rebase, rebase on behalf of the uploader
+    // doesn't require the rebaser to have the push permission.
+    AccountGroup.UUID cannotUploadGroup =
+        groupOperations.newGroup().name("cannot-upload").addMember(rebaser).create();
+    blockPermission(Permission.PUSH, cannotUploadGroup);
+
+    Change.Id changeToBeTheNewBase = changeOperations.newChange().project(project).create();
+
+    // Create a chain of changes for being rebased, each change with a different uploader.
+    Account.Id uploader1 =
+        accountOperations.newAccount().preferredEmail("uploader1@example.com").create();
+    Change.Id changeToBeRebased1 =
+        changeOperations.newChange().project(project).owner(uploader1).create();
+
+    Account.Id uploader2 =
+        accountOperations.newAccount().preferredEmail("uploader2@example.com").create();
+    Change.Id changeToBeRebased2 =
+        changeOperations
+            .newChange()
+            .project(project)
+            .childOf()
+            .change(changeToBeRebased1)
+            .owner(uploader2)
+            .create();
+
+    Account.Id uploader3 =
+        accountOperations.newAccount().preferredEmail("uploader3@example.com").create();
+    Change.Id changeToBeRebased3 =
+        changeOperations
+            .newChange()
+            .project(project)
+            .childOf()
+            .change(changeToBeRebased2)
+            .owner(uploader3)
+            .create();
+
+    Account.Id uploader4 =
+        accountOperations.newAccount().preferredEmail("uploader4@example.com").create();
+    Change.Id changeToBeRebased4 =
+        changeOperations
+            .newChange()
+            .project(project)
+            .childOf()
+            .change(changeToBeRebased3)
+            .owner(uploader4)
+            .create();
+
+    // Block rebase and submit permission for the uploaders. For rebase on behalf of the uploader
+    // only
+    // the rebaser needs to have these permission, but not the uploaders on whom's behalf the rebase
+    // is done.
+    AccountGroup.UUID cannotRebaseAndSubmitGroup =
+        groupOperations
+            .newGroup()
+            .name("cannot-rebase")
+            .addMember(uploader1)
+            .addMember(uploader2)
+            .addMember(uploader3)
+            .addMember(uploader4)
+            .create();
+    blockPermission(Permission.REBASE, cannotRebaseAndSubmitGroup);
+    blockPermission(Permission.SUBMIT, cannotRebaseAndSubmitGroup);
+
+    // Approve and submit the change that will be the new base for the chain that will be rebased.
+    requestScopeOperations.setApiUser(approver);
+    gApi.changes().id(changeToBeTheNewBase.get()).current().review(ReviewInput.approve());
+    gApi.changes().id(changeToBeTheNewBase.get()).current().submit();
+
+    // Rebase the chain on behalf of the uploaders through changeToBeRebased4
+    requestScopeOperations.setApiUser(rebaser);
+    RebaseInput rebaseInput = new RebaseInput();
+    rebaseInput.onBehalfOfUploader = true;
+
+    TestRevisionCreatedListener testRevisionCreatedListener = new TestRevisionCreatedListener();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(testRevisionCreatedListener)) {
+      gApi.changes().id(changeToBeRebased4.get()).rebaseChain(rebaseInput);
+
+      testRevisionCreatedListener.assertUploaders(changeToBeRebased1, uploader1, rebaser);
+      testRevisionCreatedListener.assertUploaders(changeToBeRebased2, uploader2, rebaser);
+      testRevisionCreatedListener.assertUploaders(changeToBeRebased3, uploader3, rebaser);
+      testRevisionCreatedListener.assertUploaders(changeToBeRebased4, uploader4, rebaser);
+    }
+
+    assertRebase(changeToBeRebased1, 2, uploader1, rebaser);
+    assertRebase(changeToBeRebased2, 2, uploader2, rebaser);
+    assertRebase(changeToBeRebased3, 2, uploader3, rebaser);
+    assertRebase(changeToBeRebased4, 2, uploader4, rebaser);
+  }
+
+  @Test
+  public void rebaseChainOnBehalfOfUploaderMultipleTimesInARow() throws Exception {
+    allowPermissionToAllUsers(Permission.REBASE);
+
+    Account.Id approver = admin.id();
+    Account.Id rebaser = accountOperations.newAccount().create();
+
+    Change.Id changeToBeTheNewBase = changeOperations.newChange().project(project).create();
+
+    // Create a chain of changes for being rebased, each change with a different uploader.
+    Account.Id uploader1 =
+        accountOperations.newAccount().preferredEmail("uploader1@example.com").create();
+    Change.Id changeToBeRebased1 =
+        changeOperations.newChange().project(project).owner(uploader1).create();
+
+    Account.Id uploader2 =
+        accountOperations.newAccount().preferredEmail("uploader2@example.com").create();
+    Change.Id changeToBeRebased2 =
+        changeOperations
+            .newChange()
+            .project(project)
+            .childOf()
+            .change(changeToBeRebased1)
+            .owner(uploader2)
+            .create();
+
+    // Approve and submit the change that will be the new base for the chain that will be rebased.
+    requestScopeOperations.setApiUser(approver);
+    gApi.changes().id(changeToBeTheNewBase.get()).current().review(ReviewInput.approve());
+    gApi.changes().id(changeToBeTheNewBase.get()).current().submit();
+
+    // Rebase the chain on behalf of the uploaders.
+    requestScopeOperations.setApiUser(rebaser);
+    RebaseInput rebaseInput = new RebaseInput();
+    rebaseInput.onBehalfOfUploader = true;
+    gApi.changes().id(changeToBeRebased2.get()).rebaseChain(rebaseInput);
+
+    assertRebase(changeToBeRebased1, 2, uploader1, rebaser);
+    assertRebase(changeToBeRebased2, 2, uploader2, rebaser);
+
+    // Create and submit another change so that we can rebase the chain once again.
+    requestScopeOperations.setApiUser(approver);
+    Change.Id changeToBeTheNewBase2 = changeOperations.newChange().project(project).create();
+    gApi.changes().id(changeToBeTheNewBase2.get()).current().review(ReviewInput.approve());
+    gApi.changes().id(changeToBeTheNewBase2.get()).current().submit();
+
+    // Rebase the chain once again on behalf of the uploaders.
+    requestScopeOperations.setApiUser(rebaser);
+    gApi.changes().id(changeToBeRebased2.get()).rebaseChain(rebaseInput);
+
+    assertRebase(changeToBeRebased1, 3, uploader1, rebaser);
+    assertRebase(changeToBeRebased2, 3, uploader2, rebaser);
+  }
+
+  @Test
+  public void nonChangeOwnerWithoutSubmitAndRebasePermissionCannotRebaseChainOnBehalfOfUploader()
+      throws Exception {
+    Change.Id changeToBeRebased1 = changeOperations.newChange().project(project).create();
+    Change.Id changeToBeRebased2 =
+        changeOperations.newChange().project(project).childOf().change(changeToBeRebased1).create();
+
+    blockPermissionForAllUsers(Permission.REBASE);
+    blockPermissionForAllUsers(Permission.SUBMIT);
+
+    Account.Id rebaserId = accountOperations.newAccount().create();
+    requestScopeOperations.setApiUser(rebaserId);
+    RebaseInput rebaseInput = new RebaseInput();
+    rebaseInput.onBehalfOfUploader = true;
+    AuthException exception =
+        assertThrows(
+            AuthException.class,
+            () -> gApi.changes().id(changeToBeRebased2.get()).rebaseChain(rebaseInput));
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo(
+            "rebase on behalf of uploader not permitted (change owners and users with the 'Submit'"
+                + " or 'Rebase' permission can rebase on behalf of the uploader)");
+  }
+
+  @Test
+  public void cannotRebaseChainOnBehalfOfUploaderIfTheUploaderHasNoReadPermission()
+      throws Exception {
+    String uploaderEmail = "uploader@example.com";
+    testCannotRebaseChainOnBehalfOfUploaderIfTheUploaderHasNoPermission(
+        uploaderEmail,
+        Permission.READ,
+        String.format("uploader %s cannot read change", uploaderEmail));
+  }
+
+  @Test
+  public void cannotRebaseChainOnBehalfOfUploaderIfTheUploaderHasNoPushPermission()
+      throws Exception {
+    String uploaderEmail = "uploader@example.com";
+    testCannotRebaseChainOnBehalfOfUploaderIfTheUploaderHasNoPermission(
+        uploaderEmail,
+        Permission.PUSH,
+        String.format("uploader %s cannot add patch set", uploaderEmail));
+  }
+
+  private void testCannotRebaseChainOnBehalfOfUploaderIfTheUploaderHasNoPermission(
+      String uploaderEmail, String permissionToBlock, String expectedErrorMessage)
+      throws Exception {
+    allowPermissionToAllUsers(Permission.REBASE);
+
+    Account.Id uploader = accountOperations.newAccount().preferredEmail(uploaderEmail).create();
+    Account.Id approver = admin.id();
+    Account.Id rebaser = accountOperations.newAccount().create();
+
+    Change.Id changeToBeTheNewBase = changeOperations.newChange().project(project).create();
+
+    Change.Id changeToBeRebased1 =
+        changeOperations.newChange().project(project).owner(uploader).create();
+    Change.Id changeToBeRebased2 =
+        changeOperations
+            .newChange()
+            .project(project)
+            .owner(uploader)
+            .childOf()
+            .change(changeToBeRebased1)
+            .create();
+
+    // Approve and submit the change that will be the new base for the chain that will be rebased.
+    requestScopeOperations.setApiUser(approver);
+    gApi.changes().id(changeToBeTheNewBase.get()).current().review(ReviewInput.approve());
+    gApi.changes().id(changeToBeTheNewBase.get()).current().submit();
+
+    // Block the required permission for uploader. Without this permission it should not be possible
+    // to rebase the change on behalf of the uploader.
+    AccountGroup.UUID blockedGroup =
+        groupOperations.newGroup().name("cannot-" + permissionToBlock).addMember(uploader).create();
+    blockPermission(permissionToBlock, blockedGroup);
+
+    // Try to rebase the chain on behalf of the uploader.
+    requestScopeOperations.setApiUser(rebaser);
+    RebaseInput rebaseInput = new RebaseInput();
+    rebaseInput.onBehalfOfUploader = true;
+    ResourceConflictException exception =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.changes().id(changeToBeRebased2.get()).rebaseChain(rebaseInput));
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo(String.format("change %s: %s", changeToBeRebased1, expectedErrorMessage));
+  }
+
+  @Test
+  public void rebaseChainOnBehalfOfYourself() throws Exception {
+    allowPermissionToAllUsers(Permission.REBASE);
+
+    Account.Id uploader =
+        accountOperations.newAccount().preferredEmail("uploader@example.com").create();
+    Account.Id approver = admin.id();
+
+    Change.Id changeToBeTheNewBase = changeOperations.newChange().project(project).create();
+
+    Change.Id changeToBeRebased1 =
+        changeOperations.newChange().project(project).owner(uploader).create();
+    Change.Id changeToBeRebased2 =
+        changeOperations
+            .newChange()
+            .project(project)
+            .owner(uploader)
+            .childOf()
+            .change(changeToBeRebased1)
+            .create();
+
+    // Approve and submit the change that will be the new base for the chain that will be rebased.
+    requestScopeOperations.setApiUser(approver);
+    gApi.changes().id(changeToBeTheNewBase.get()).current().review(ReviewInput.approve());
+    gApi.changes().id(changeToBeTheNewBase.get()).current().submit();
+
+    // Rebase the chain as uploader on behalf of the uploader
+    requestScopeOperations.setApiUser(uploader);
+    RebaseInput rebaseInput = new RebaseInput();
+    rebaseInput.onBehalfOfUploader = true;
+    gApi.changes().id(changeToBeRebased2.get()).rebaseChain(rebaseInput);
+
+    assertRebase(changeToBeRebased1, 2, uploader, /* expectedRealUploader= */ null);
+    assertRebase(changeToBeRebased2, 2, uploader, /* expectedRealUploader= */ null);
+  }
+
+  @Test
+  public void cannotRebaseChangeOnBehalfOfYourselfWithoutPushPermission() throws Exception {
+    allowPermissionToAllUsers(Permission.REBASE);
+
+    Account.Id uploader =
+        accountOperations.newAccount().preferredEmail("uploader@example.com").create();
+    Account.Id approver = admin.id();
+
+    Change.Id changeToBeTheNewBase = changeOperations.newChange().project(project).create();
+
+    Change.Id changeToBeRebased1 =
+        changeOperations.newChange().project(project).owner(uploader).create();
+    Change.Id changeToBeRebased2 =
+        changeOperations
+            .newChange()
+            .project(project)
+            .owner(uploader)
+            .childOf()
+            .change(changeToBeRebased1)
+            .create();
+
+    // Approve and submit the change that will be the new base for the chain that will be rebased.
+    requestScopeOperations.setApiUser(approver);
+    gApi.changes().id(changeToBeTheNewBase.get()).current().review(ReviewInput.approve());
+    gApi.changes().id(changeToBeTheNewBase.get()).current().submit();
+
+    // Block push for the uploader aka the rebaser. This permission is required for creating the new
+    // patch set and if it is blocked we expect the rebase to fail.
+    AccountGroup.UUID cannotPushGroup =
+        groupOperations.newGroup().name("cannot-push").addMember(uploader).create();
+    blockPermission(Permission.PUSH, cannotPushGroup);
+
+    // Rebase the chain as uploader on behalf of the uploader
+    requestScopeOperations.setApiUser(uploader);
+    RebaseInput rebaseInput = new RebaseInput();
+    rebaseInput.onBehalfOfUploader = true;
+    AuthException exception =
+        assertThrows(
+            AuthException.class,
+            () -> gApi.changes().id(changeToBeRebased2.get()).rebaseChain(rebaseInput));
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo(
+            "rebase not permitted (change owners and users with the 'Submit' or 'Rebase'"
+                + " permission can rebase if they have the 'Push' permission)");
+  }
+
+  @Test
+  public void rebaseChainOnBehalfOfUploaderWhenUploaderIsNotTheChangeOwner() throws Exception {
+    allowPermissionToAllUsers(Permission.REBASE);
+
+    Account.Id changeOwner =
+        accountOperations.newAccount().preferredEmail("change-owner@example.com").create();
+    Account.Id uploader =
+        accountOperations.newAccount().preferredEmail("uploader@example.com").create();
+    Account.Id approver = admin.id();
+    Account.Id rebaser = accountOperations.newAccount().create();
+
+    Change.Id changeToBeTheNewBase = changeOperations.newChange().project(project).create();
+
+    Change.Id changeToBeRebased1 =
+        changeOperations.newChange().project(project).owner(changeOwner).create();
+    Change.Id changeToBeRebased2 =
+        changeOperations
+            .newChange()
+            .project(project)
+            .owner(changeOwner)
+            .childOf()
+            .change(changeToBeRebased1)
+            .create();
+
+    // Create a second patch set for the second change in the chain that will be rebased so that the
+    // uploader is different to the change owner.
+    // Set author and committer to the uploader so that rebasing on behalf of the uploader doesn't
+    // require the Forge Author and Forge Committer permission.
+    changeOperations
+        .change(changeToBeRebased2)
+        .newPatchset()
+        .uploader(uploader)
+        .author(uploader)
+        .committer(uploader)
+        .create();
+
+    // Approve and submit the change that will be the new base for the chain that will be rebased.
+    requestScopeOperations.setApiUser(approver);
+    gApi.changes().id(changeToBeTheNewBase.get()).current().review(ReviewInput.approve());
+    gApi.changes().id(changeToBeTheNewBase.get()).current().submit();
+
+    // Grant add patch set permission for uploader. Without the add patch set permission it is not
+    // possible to rebase the change on behalf of the uploader since the uploader cannot add a
+    // patch set to a change that is owned by another user.
+    AccountGroup.UUID canAddPatchSet =
+        groupOperations.newGroup().name("can-add-patch-set").addMember(uploader).create();
+    allowPermission(Permission.ADD_PATCH_SET, canAddPatchSet);
+
+    // Rebase the chain on behalf of the uploader
+    requestScopeOperations.setApiUser(rebaser);
+    RebaseInput rebaseInput = new RebaseInput();
+    rebaseInput.onBehalfOfUploader = true;
+    gApi.changes().id(changeToBeRebased2.get()).rebaseChain(rebaseInput);
+
+    assertRebase(changeToBeRebased1, 2, changeOwner, rebaser);
+    assertRebase(changeToBeRebased2, 3, uploader, rebaser);
+  }
+
+  @Test
+  public void
+      cannotRebaseChainOnBehalfOfUploaderWhenUploaderIsNotTheChangeOwnerAndDoesntHaveAddPatchSetPermission()
+          throws Exception {
+    allowPermissionToAllUsers(Permission.REBASE);
+
+    String uploaderEmail = "uploader@example.com";
+    Account.Id uploader = accountOperations.newAccount().preferredEmail(uploaderEmail).create();
+    Account.Id changeOwner = accountOperations.newAccount().create();
+    Account.Id approver = admin.id();
+    Account.Id rebaser = accountOperations.newAccount().create();
+
+    Change.Id changeToBeTheNewBase = changeOperations.newChange().project(project).create();
+
+    Change.Id changeToBeRebased1 =
+        changeOperations.newChange().project(project).owner(changeOwner).create();
+    Change.Id changeToBeRebased2 =
+        changeOperations
+            .newChange()
+            .project(project)
+            .owner(changeOwner)
+            .childOf()
+            .change(changeToBeRebased1)
+            .create();
+
+    // Create a second patch set for the second change in the chain that will be rebased so that the
+    // uploader is different to the change owner.
+    // Set author and committer to the uploader so that rebasing on behalf of the uploader doesn't
+    // require the Forge Author and Forge Committer permission.
+    changeOperations
+        .change(changeToBeRebased2)
+        .newPatchset()
+        .uploader(uploader)
+        .author(uploader)
+        .committer(uploader)
+        .create();
+
+    // Approve and submit the change that will be the new base for the chain that will be rebased.
+    requestScopeOperations.setApiUser(approver);
+    gApi.changes().id(changeToBeTheNewBase.get()).current().review(ReviewInput.approve());
+    gApi.changes().id(changeToBeTheNewBase.get()).current().submit();
+
+    // Block add patch set permission for uploader. Without the add patch set permission it should
+    // not possible to rebase the change on behalf of the uploader since the uploader cannot add a
+    // patch set to a change that is owned by another user.
+    AccountGroup.UUID cannotAddPatchSet =
+        groupOperations.newGroup().name("cannot-add-patch-set").addMember(uploader).create();
+    blockPermission(Permission.ADD_PATCH_SET, cannotAddPatchSet);
+
+    // Try to rebase the chain on behalf of the uploader
+    requestScopeOperations.setApiUser(rebaser);
+    RebaseInput rebaseInput = new RebaseInput();
+    rebaseInput.onBehalfOfUploader = true;
+    ResourceConflictException exception =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.changes().id(changeToBeRebased2.get()).rebaseChain(rebaseInput));
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo(
+            String.format(
+                "change %s: uploader %s cannot add patch set", changeToBeRebased2, uploaderEmail));
+  }
+
+  @Test
+  public void rebaseChainWithForgedAuthorOnBehalfOfUploader() throws Exception {
+    allowPermissionToAllUsers(Permission.REBASE);
+
+    String authorEmail = "author@example.com";
+    Account.Id author = accountOperations.newAccount().preferredEmail(authorEmail).create();
+    Account.Id uploader =
+        accountOperations.newAccount().preferredEmail("uploader@example.com").create();
+    Account.Id approver = admin.id();
+    Account.Id rebaser = accountOperations.newAccount().create();
+
+    Change.Id changeToBeTheNewBase = changeOperations.newChange().project(project).create();
+
+    Change.Id changeToBeRebased1 =
+        changeOperations.newChange().project(project).owner(uploader).author(author).create();
+    Change.Id changeToBeRebased2 =
+        changeOperations
+            .newChange()
+            .project(project)
+            .owner(uploader)
+            .author(author)
+            .childOf()
+            .change(changeToBeRebased1)
+            .create();
+
+    // Approve and submit the change that will be the new base for the chain that will be rebased.
+    requestScopeOperations.setApiUser(approver);
+    gApi.changes().id(changeToBeTheNewBase.get()).current().review(ReviewInput.approve());
+    gApi.changes().id(changeToBeTheNewBase.get()).current().submit();
+
+    // Grant forge author permission for uploader. Without the forge author permission it is not
+    // possible to rebase the change on behalf of the uploader.
+    AccountGroup.UUID canForgeAuthor =
+        groupOperations.newGroup().name("can-forge-author").addMember(uploader).create();
+    allowPermission(Permission.FORGE_AUTHOR, canForgeAuthor);
+
+    // Rebase the second change on behalf of the uploader
+    requestScopeOperations.setApiUser(rebaser);
+    RebaseInput rebaseInput = new RebaseInput();
+    rebaseInput.onBehalfOfUploader = true;
+    gApi.changes().id(changeToBeRebased2.get()).rebaseChain(rebaseInput);
+
+    RevisionInfo currentRevisionInfo =
+        gApi.changes().id(changeToBeRebased1.get()).get().getCurrentRevision();
+    // The change had 1 patch set before the rebase, now it should be 2
+    assertThat(currentRevisionInfo._number).isEqualTo(2);
+    assertThat(currentRevisionInfo.commit.author.email).isEqualTo(authorEmail);
+    assertThat(currentRevisionInfo.uploader._accountId).isEqualTo(uploader.get());
+    assertThat(currentRevisionInfo.realUploader._accountId).isEqualTo(rebaser.get());
+
+    currentRevisionInfo = gApi.changes().id(changeToBeRebased2.get()).get().getCurrentRevision();
+    // The change had 1 patch set before the rebase, now it should be 2
+    assertThat(currentRevisionInfo._number).isEqualTo(2);
+    assertThat(currentRevisionInfo.commit.author.email).isEqualTo(authorEmail);
+    assertThat(currentRevisionInfo.uploader._accountId).isEqualTo(uploader.get());
+    assertThat(currentRevisionInfo.realUploader._accountId).isEqualTo(rebaser.get());
+  }
+
+  @Test
+  public void
+      cannotRebaseChainWithForgedAuthorOnBehalfOfUploaderIfTheUploaderHasNoForgeAuthorPermission()
+          throws Exception {
+    allowPermissionToAllUsers(Permission.REBASE);
+
+    String uploaderEmail = "uploader@example.com";
+    Account.Id uploader = accountOperations.newAccount().preferredEmail(uploaderEmail).create();
+    Account.Id author = accountOperations.newAccount().create();
+    Account.Id approver = admin.id();
+    Account.Id rebaser = accountOperations.newAccount().create();
+
+    Change.Id changeToBeTheNewBase = changeOperations.newChange().project(project).create();
+
+    Change.Id changeToBeRebased1 =
+        changeOperations.newChange().project(project).owner(uploader).author(author).create();
+    Change.Id changeToBeRebased2 =
+        changeOperations
+            .newChange()
+            .project(project)
+            .owner(uploader)
+            .author(author)
+            .childOf()
+            .change(changeToBeRebased1)
+            .create();
+
+    // Approve and submit the change that will be the new base for the chain that will be rebased.
+    requestScopeOperations.setApiUser(approver);
+    gApi.changes().id(changeToBeTheNewBase.get()).current().review(ReviewInput.approve());
+    gApi.changes().id(changeToBeTheNewBase.get()).current().submit();
+
+    // Block forge author permission for uploader. Without the forge author permission it should not
+    // be possible to rebase the chain on behalf of the uploader.
+    AccountGroup.UUID cannotForgeAuthor =
+        groupOperations.newGroup().name("cannot-forge-author").addMember(uploader).create();
+    blockPermission(Permission.FORGE_AUTHOR, cannotForgeAuthor);
+
+    // Try to rebase the second change on behalf of the uploader
+    requestScopeOperations.setApiUser(rebaser);
+    RebaseInput rebaseInput = new RebaseInput();
+    rebaseInput.onBehalfOfUploader = true;
+    ResourceConflictException exception =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.changes().id(changeToBeRebased2.get()).rebaseChain(rebaseInput));
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo(
+            String.format(
+                "change %s: author of patch set 1 is forged and the uploader %s cannot forge author",
+                changeToBeRebased1, uploaderEmail));
+  }
+
+  @Test
+  public void
+      rebaseChainWithForgedCommitterOnBehalfOfUploaderDoesntRequireForgeCommitterPermission()
+          throws Exception {
+    allowPermissionToAllUsers(Permission.REBASE);
+
+    String uploaderEmail = "uploader@example.com";
+    Account.Id uploader = accountOperations.newAccount().preferredEmail(uploaderEmail).create();
+    Account.Id committer =
+        accountOperations.newAccount().preferredEmail("committer@example.com").create();
+    Account.Id approver = admin.id();
+    Account.Id rebaser = accountOperations.newAccount().create();
+
+    Change.Id changeToBeTheNewBase = changeOperations.newChange().project(project).create();
+
+    Change.Id changeToBeRebased1 =
+        changeOperations.newChange().project(project).owner(uploader).committer(committer).create();
+    Change.Id changeToBeRebased2 =
+        changeOperations
+            .newChange()
+            .project(project)
+            .owner(uploader)
+            .committer(committer)
+            .childOf()
+            .change(changeToBeRebased1)
+            .create();
+
+    // Approve and submit the change that will be the new base for the chain that will be rebased.
+    requestScopeOperations.setApiUser(approver);
+    gApi.changes().id(changeToBeTheNewBase.get()).current().review(ReviewInput.approve());
+    gApi.changes().id(changeToBeTheNewBase.get()).current().submit();
+
+    // Rebase the second change on behalf of the uploader
+    requestScopeOperations.setApiUser(rebaser);
+    RebaseInput rebaseInput = new RebaseInput();
+    rebaseInput.onBehalfOfUploader = true;
+    gApi.changes().id(changeToBeRebased2.get()).rebaseChain(rebaseInput);
+
+    RevisionInfo currentRevisionInfo =
+        gApi.changes().id(changeToBeRebased1.get()).get().getCurrentRevision();
+    // The change had 1 patch set before the rebase, now it should be 2
+    assertThat(currentRevisionInfo._number).isEqualTo(2);
+    assertThat(currentRevisionInfo.commit.committer.email).isEqualTo(uploaderEmail);
+    assertThat(currentRevisionInfo.uploader._accountId).isEqualTo(uploader.get());
+    assertThat(currentRevisionInfo.realUploader._accountId).isEqualTo(rebaser.get());
+
+    currentRevisionInfo = gApi.changes().id(changeToBeRebased2.get()).get().getCurrentRevision();
+    // The change had 1 patch set before the rebase, now it should be 2
+    assertThat(currentRevisionInfo._number).isEqualTo(2);
+    assertThat(currentRevisionInfo.commit.committer.email).isEqualTo(uploaderEmail);
+    assertThat(currentRevisionInfo.uploader._accountId).isEqualTo(uploader.get());
+    assertThat(currentRevisionInfo.realUploader._accountId).isEqualTo(rebaser.get());
+  }
+
+  @Test
+  public void rebaseChainWithServerIdentOnBehalfOfUploader() throws Exception {
+    allowPermissionToAllUsers(Permission.REBASE);
+
+    String uploaderEmail = "uploader@example.com";
+    Account.Id uploader = accountOperations.newAccount().preferredEmail(uploaderEmail).create();
+    Account.Id approver = admin.id();
+    Account.Id rebaser = accountOperations.newAccount().create();
+
+    Change.Id changeToBeTheNewBase = changeOperations.newChange().project(project).create();
+
+    Change.Id changeToBeRebased1 =
+        changeOperations
+            .newChange()
+            .project(project)
+            .owner(uploader)
+            .authorIdent(serverIdent.get())
+            .create();
+    Change.Id changeToBeRebased2 =
+        changeOperations
+            .newChange()
+            .project(project)
+            .owner(uploader)
+            .authorIdent(serverIdent.get())
+            .childOf()
+            .change(changeToBeRebased1)
+            .create();
+
+    // Approve and submit the change that will be the new base for the chain that will be rebased.
+    requestScopeOperations.setApiUser(approver);
+    gApi.changes().id(changeToBeTheNewBase.get()).current().review(ReviewInput.approve());
+    gApi.changes().id(changeToBeTheNewBase.get()).current().submit();
+
+    // Grant forge author and forge server permission for uploader. Without these permissions it is
+    // not possible to rebase the change on behalf of the uploader.
+    AccountGroup.UUID canForgeAuthorAndForgeServer =
+        groupOperations
+            .newGroup()
+            .name("can-forge-author-and-forge-server")
+            .addMember(uploader)
+            .create();
+    allowPermission(Permission.FORGE_AUTHOR, canForgeAuthorAndForgeServer);
+    allowPermission(Permission.FORGE_SERVER, canForgeAuthorAndForgeServer);
+
+    // Rebase the chain on behalf of the uploader.
+    requestScopeOperations.setApiUser(rebaser);
+    RebaseInput rebaseInput = new RebaseInput();
+    rebaseInput.onBehalfOfUploader = true;
+    gApi.changes().id(changeToBeRebased2.get()).rebaseChain(rebaseInput);
+
+    RevisionInfo currentRevisionInfo =
+        gApi.changes().id(changeToBeRebased1.get()).get().getCurrentRevision();
+    // The change had 1 patch set before the rebase, now it should be 2
+    assertThat(currentRevisionInfo._number).isEqualTo(2);
+    assertThat(currentRevisionInfo.commit.author.email)
+        .isEqualTo(serverIdent.get().getEmailAddress());
+    assertThat(currentRevisionInfo.uploader._accountId).isEqualTo(uploader.get());
+    assertThat(currentRevisionInfo.realUploader._accountId).isEqualTo(rebaser.get());
+
+    currentRevisionInfo = gApi.changes().id(changeToBeRebased2.get()).get().getCurrentRevision();
+    // The change had 1 patch set before the rebase, now it should be 2
+    assertThat(currentRevisionInfo._number).isEqualTo(2);
+    assertThat(currentRevisionInfo.commit.author.email)
+        .isEqualTo(serverIdent.get().getEmailAddress());
+    assertThat(currentRevisionInfo.uploader._accountId).isEqualTo(uploader.get());
+    assertThat(currentRevisionInfo.realUploader._accountId).isEqualTo(rebaser.get());
+  }
+
+  @Test
+  public void
+      cannotRebaseChainWithServerIdentOnBehalfOfUploaderIfTheUploaderHasNoForgeServerPermission()
+          throws Exception {
+    allowPermissionToAllUsers(Permission.REBASE);
+
+    String uploaderEmail = "uploader@example.com";
+    Account.Id uploader = accountOperations.newAccount().preferredEmail(uploaderEmail).create();
+    Account.Id approver = admin.id();
+    Account.Id rebaser = accountOperations.newAccount().create();
+
+    Change.Id changeToBeTheNewBase = changeOperations.newChange().project(project).create();
+
+    Change.Id changeToBeRebased1 =
+        changeOperations
+            .newChange()
+            .project(project)
+            .owner(uploader)
+            .authorIdent(serverIdent.get())
+            .create();
+    Change.Id changeToBeRebased2 =
+        changeOperations
+            .newChange()
+            .project(project)
+            .owner(uploader)
+            .authorIdent(serverIdent.get())
+            .childOf()
+            .change(changeToBeRebased1)
+            .create();
+
+    // Approve and submit the change that will be the new base for the chain that will be rebased.
+    requestScopeOperations.setApiUser(approver);
+    gApi.changes().id(changeToBeTheNewBase.get()).current().review(ReviewInput.approve());
+    gApi.changes().id(changeToBeTheNewBase.get()).current().submit();
+
+    // Grant forge author permission for uploader, but not the forge server permission. Without the
+    // forge server permission it is not possible to rebase the change on behalf of the uploader.
+    AccountGroup.UUID canForgeAuthor =
+        groupOperations.newGroup().name("can-forge-author").addMember(uploader).create();
+    allowPermission(Permission.FORGE_AUTHOR, canForgeAuthor);
+
+    // Try to rebase the chain on behalf of the uploader.
+    requestScopeOperations.setApiUser(rebaser);
+    RebaseInput rebaseInput = new RebaseInput();
+    rebaseInput.onBehalfOfUploader = true;
+    ResourceConflictException exception =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.changes().id(changeToBeRebased2.get()).rebaseChain(rebaseInput));
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo(
+            String.format(
+                "change %s: author of patch set 1 is the server identity and the uploader %s cannot forge"
+                    + " the server identity",
+                changeToBeRebased1, uploaderEmail));
+  }
+
+  @Test
+  public void rebaseChainActionEnabled_withRebasePermission() throws Exception {
+    allowPermissionToAllUsers(Permission.REBASE);
+    testRebaseChainActionEnabled();
+  }
+
+  @Test
+  public void rebaseChainActionEnabled_withSubmitPermission() throws Exception {
+    allowPermissionToAllUsers(Permission.SUBMIT);
+    testRebaseChainActionEnabled();
+  }
+
+  private void testRebaseChainActionEnabled() throws Exception {
+    Account.Id uploader = accountOperations.newAccount().create();
+    Account.Id approver = admin.id();
+    Account.Id rebaser = accountOperations.newAccount().create();
+
+    // Block push permission for rebaser, as in contrast to rebase, rebase on behalf of the uploader
+    // doesn't require the rebaser to have the push permission.
+    AccountGroup.UUID cannotUploadGroup =
+        groupOperations.newGroup().name("cannot-upload").addMember(rebaser).create();
+    blockPermission(Permission.PUSH, cannotUploadGroup);
+
+    Change.Id changeToBeTheNewBase = changeOperations.newChange().project(project).create();
+
+    Change.Id changeToBeRebased1 =
+        changeOperations.newChange().project(project).owner(uploader).create();
+    Change.Id changeToBeRebased2 =
+        changeOperations
+            .newChange()
+            .project(project)
+            .owner(uploader)
+            .childOf()
+            .change(changeToBeRebased1)
+            .create();
+
+    // Approve and submit the change that will be the new base for the chain so that the chain is
+    // rebasable.
+    requestScopeOperations.setApiUser(approver);
+    gApi.changes().id(changeToBeTheNewBase.get()).current().review(ReviewInput.approve());
+    gApi.changes().id(changeToBeTheNewBase.get()).current().submit();
+
+    requestScopeOperations.setApiUser(rebaser);
+    ChangeInfo changeInfo = gApi.changes().id(changeToBeRebased2.get()).get();
+    assertThat(changeInfo.actions).containsKey("rebase:chain");
+    ActionInfo rebaseActionInfo = changeInfo.actions.get("rebase:chain");
+    assertThat(rebaseActionInfo.enabled).isTrue();
+
+    // rebase is disabled because rebaser doesn't have the 'Push' permission and hence cannot create
+    // new patch sets
+    assertThat(rebaseActionInfo.enabledOptions).containsExactly("rebase_on_behalf_of_uploader");
+  }
+
+  @Test
+  public void rebaseChainActionEnabled_forChangeOwner() throws Exception {
+    Account.Id changeOwner = accountOperations.newAccount().create();
+    Account.Id approver = admin.id();
+
+    Change.Id changeToBeTheNewBase = changeOperations.newChange().project(project).create();
+
+    Change.Id changeToBeRebased1 =
+        changeOperations.newChange().project(project).owner(changeOwner).create();
+    Change.Id changeToBeRebased2 =
+        changeOperations
+            .newChange()
+            .project(project)
+            .owner(changeOwner)
+            .childOf()
+            .change(changeToBeRebased1)
+            .create();
+
+    // Approve and submit the change that will be the new base for the change that will be rebased.
+    requestScopeOperations.setApiUser(approver);
+    gApi.changes().id(changeToBeTheNewBase.get()).current().review(ReviewInput.approve());
+    gApi.changes().id(changeToBeTheNewBase.get()).current().submit();
+
+    requestScopeOperations.setApiUser(changeOwner);
+    ChangeInfo changeInfo = gApi.changes().id(changeToBeRebased2.get()).get();
+    assertThat(changeInfo.actions).containsKey("rebase:chain");
+    ActionInfo rebaseActionInfo = changeInfo.actions.get("rebase:chain");
+    assertThat(rebaseActionInfo.enabled).isTrue();
+
+    // rebase is enabled because change owner has the 'Push' permission and hence can create new
+    // patch sets
+    assertThat(rebaseActionInfo.enabledOptions)
+        .containsExactly("rebase", "rebase_on_behalf_of_uploader");
+  }
+
+  @UseLocalDisk
+  @Test
+  public void rebaseChainWithIdenticalUploadersOnBehalfOfUploaderRecordsUploaderInRefLog()
+      throws Exception {
+    allowPermissionToAllUsers(Permission.REBASE);
+
+    String uploaderEmail = "uploader@example.com";
+    Account.Id uploader = accountOperations.newAccount().preferredEmail(uploaderEmail).create();
+    Account.Id approver = admin.id();
+    Account.Id rebaser = accountOperations.newAccount().create();
+
+    Change.Id changeToBeTheNewBase = changeOperations.newChange().project(project).create();
+
+    Change.Id changeToBeRebased1 =
+        changeOperations.newChange().project(project).owner(uploader).create();
+
+    Change.Id changeToBeRebased2 =
+        changeOperations
+            .newChange()
+            .project(project)
+            .owner(uploader)
+            .childOf()
+            .change(changeToBeRebased1)
+            .create();
+
+    // Approve and submit the change that will be the new base for the chain that will be rebased.
+    requestScopeOperations.setApiUser(approver);
+    gApi.changes().id(changeToBeTheNewBase.get()).current().review(ReviewInput.approve());
+    gApi.changes().id(changeToBeTheNewBase.get()).current().submit();
+
+    try (Repository repo = repoManager.openRepository(project)) {
+      String changeMetaRef1 = RefNames.changeMetaRef(changeToBeRebased1);
+      String patchSetRef1 = RefNames.patchSetRef(PatchSet.id(changeToBeRebased1, 2));
+      String changeMetaRef2 = RefNames.changeMetaRef(changeToBeRebased2);
+      String patchSetRef2 = RefNames.patchSetRef(PatchSet.id(changeToBeRebased2, 2));
+      createRefLogFileIfMissing(repo, changeMetaRef1);
+      createRefLogFileIfMissing(repo, patchSetRef1);
+      createRefLogFileIfMissing(repo, changeMetaRef2);
+      createRefLogFileIfMissing(repo, patchSetRef2);
+
+      // Rebase the chain on behalf of the uploader
+      requestScopeOperations.setApiUser(rebaser);
+      RebaseInput rebaseInput = new RebaseInput();
+      rebaseInput.onBehalfOfUploader = true;
+      gApi.changes().id(changeToBeRebased2.get()).rebaseChain(rebaseInput);
+
+      // The ref log for the patch set ref records the impersonated user aka the uploader.
+      ReflogEntry patchSetRefLogEntry1 = repo.getReflogReader(patchSetRef1).getLastEntry();
+      assertThat(patchSetRefLogEntry1.getWho().getEmailAddress()).isEqualTo(uploaderEmail);
+      ReflogEntry patchSetRefLogEntry2 = repo.getReflogReader(patchSetRef2).getLastEntry();
+      assertThat(patchSetRefLogEntry2.getWho().getEmailAddress()).isEqualTo(uploaderEmail);
+
+      // The ref log for the change meta ref records the impersonated user aka the uploader.
+      ReflogEntry changeMetaRefLogEntry1 = repo.getReflogReader(changeMetaRef1).getLastEntry();
+      assertThat(changeMetaRefLogEntry1.getWho().getEmailAddress()).isEqualTo(uploaderEmail);
+      ReflogEntry changeMetaRefLogEntry2 = repo.getReflogReader(changeMetaRef2).getLastEntry();
+      assertThat(changeMetaRefLogEntry2.getWho().getEmailAddress()).isEqualTo(uploaderEmail);
+    }
+  }
+
+  @UseLocalDisk
+  @Test
+  public void rebaseChainWithDifferentUploadersOnBehalfOfUploaderRecordsCombinedIdentityInRefLog()
+      throws Exception {
+    allowPermissionToAllUsers(Permission.REBASE);
+
+    Account.Id approver = admin.id();
+    Account.Id rebaser = accountOperations.newAccount().create();
+
+    Change.Id changeToBeTheNewBase = changeOperations.newChange().project(project).create();
+
+    Account.Id uploader1 = accountOperations.newAccount().create();
+    Change.Id changeToBeRebased1 =
+        changeOperations.newChange().project(project).owner(uploader1).create();
+
+    Account.Id uploader2 = accountOperations.newAccount().create();
+    Change.Id changeToBeRebased2 =
+        changeOperations
+            .newChange()
+            .project(project)
+            .owner(uploader2)
+            .childOf()
+            .change(changeToBeRebased1)
+            .create();
+
+    // Approve and submit the change that will be the new base for the chain that will be rebased.
+    requestScopeOperations.setApiUser(approver);
+    gApi.changes().id(changeToBeTheNewBase.get()).current().review(ReviewInput.approve());
+    gApi.changes().id(changeToBeTheNewBase.get()).current().submit();
+
+    try (Repository repo = repoManager.openRepository(project)) {
+      String changeMetaRef1 = RefNames.changeMetaRef(changeToBeRebased1);
+      String patchSetRef1 = RefNames.patchSetRef(PatchSet.id(changeToBeRebased1, 2));
+      String changeMetaRef2 = RefNames.changeMetaRef(changeToBeRebased2);
+      String patchSetRef2 = RefNames.patchSetRef(PatchSet.id(changeToBeRebased2, 2));
+      createRefLogFileIfMissing(repo, changeMetaRef1);
+      createRefLogFileIfMissing(repo, patchSetRef1);
+      createRefLogFileIfMissing(repo, changeMetaRef2);
+      createRefLogFileIfMissing(repo, patchSetRef2);
+
+      // Rebase the chain on behalf of the uploader
+      requestScopeOperations.setApiUser(rebaser);
+      RebaseInput rebaseInput = new RebaseInput();
+      rebaseInput.onBehalfOfUploader = true;
+      gApi.changes().id(changeToBeRebased2.get()).rebaseChain(rebaseInput);
+
+      String combinedEmail = String.format("account-%s|account-%s@unknown", uploader1, uploader2);
+
+      // The ref log for the patch set ref records the impersonated user aka the uploader.
+      ReflogEntry patchSetRefLogEntry1 = repo.getReflogReader(patchSetRef1).getLastEntry();
+      assertThat(patchSetRefLogEntry1.getWho().getEmailAddress()).isEqualTo(combinedEmail);
+      ReflogEntry patchSetRefLogEntry2 = repo.getReflogReader(patchSetRef2).getLastEntry();
+      assertThat(patchSetRefLogEntry2.getWho().getEmailAddress()).isEqualTo(combinedEmail);
+
+      // The ref log for the change meta ref records the impersonated user aka the uploader.
+      ReflogEntry changeMetaRefLogEntry1 = repo.getReflogReader(changeMetaRef1).getLastEntry();
+      assertThat(changeMetaRefLogEntry1.getWho().getEmailAddress()).isEqualTo(combinedEmail);
+      ReflogEntry changeMetaRefLogEntry2 = repo.getReflogReader(changeMetaRef2).getLastEntry();
+      assertThat(changeMetaRefLogEntry2.getWho().getEmailAddress()).isEqualTo(combinedEmail);
+    }
+  }
+
+  @Test
+  public void rebaserCanApproveChainAfterRebasingOnBehalfOfUploader() throws Exception {
+    // Require a Code-Review approval from a non-uploader for submit.
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig()
+          .upsertSubmitRequirement(
+              SubmitRequirement.builder()
+                  .setName(TestLabels.codeReview().getName())
+                  .setSubmittabilityExpression(
+                      SubmitRequirementExpression.create(
+                          String.format(
+                              "label:%s=MAX,user=non_uploader", TestLabels.codeReview().getName())))
+                  .setAllowOverrideInChildProjects(false)
+                  .build());
+      u.save();
+    }
+
+    allowPermissionToAllUsers(Permission.REBASE);
+
+    String uploaderEmail = "uploader@example.com";
+    Account.Id uploader = accountOperations.newAccount().preferredEmail(uploaderEmail).create();
+    Account.Id approver = admin.id();
+    Account.Id rebaser = accountOperations.newAccount().create();
+
+    Change.Id changeToBeTheNewBase =
+        changeOperations.newChange().owner(uploader).project(project).create();
+
+    Change.Id changeToBeRebased1 =
+        changeOperations.newChange().project(project).owner(uploader).create();
+    Change.Id changeToBeRebased2 =
+        changeOperations
+            .newChange()
+            .project(project)
+            .owner(uploader)
+            .childOf()
+            .change(changeToBeRebased1)
+            .create();
+
+    // Approve and submit the change that will be the new base for the chain that will be rebased.
+    requestScopeOperations.setApiUser(approver);
+    gApi.changes().id(changeToBeTheNewBase.get()).current().review(ReviewInput.approve());
+    gApi.changes().id(changeToBeTheNewBase.get()).current().submit();
+
+    // Rebase it on behalf of the uploader
+    requestScopeOperations.setApiUser(rebaser);
+    RebaseInput rebaseInput = new RebaseInput();
+    rebaseInput.onBehalfOfUploader = true;
+    gApi.changes().id(changeToBeRebased2.get()).rebaseChain(rebaseInput);
+
+    // Approve the chain as the rebaser.
+    allowVotingOnCodeReviewToAllUsers();
+    gApi.changes().id(changeToBeRebased1.get()).current().review(ReviewInput.approve());
+    gApi.changes().id(changeToBeRebased2.get()).current().review(ReviewInput.approve());
+
+    // The chain is submittable because the approval is from a user (the rebaser) that is not the
+    // uploader.
+    assertThat(gApi.changes().id(changeToBeRebased1.get()).get().submittable).isTrue();
+    assertThat(gApi.changes().id(changeToBeRebased2.get()).get().submittable).isTrue();
+
+    // Create and submit another change so that we can rebase the chain once again.
+    requestScopeOperations.setApiUser(approver);
+    Change.Id changeToBeTheNewBase2 =
+        changeOperations.newChange().project(project).owner(uploader).create();
+    gApi.changes().id(changeToBeTheNewBase2.get()).current().review(ReviewInput.approve());
+    gApi.changes().id(changeToBeTheNewBase2.get()).current().submit();
+
+    // Doing a normal rebase (not on behalf of the uploader) makes the rebaser the uploader. This
+    // makse the chain non-submittable since the approval of the rebaser is ignored now (due to
+    // using 'user=non_uploader' in the submit requirement expression).
+    requestScopeOperations.setApiUser(rebaser);
+    rebaseInput.onBehalfOfUploader = false;
+    gApi.changes().id(changeToBeRebased2.get()).rebaseChain(rebaseInput);
+    gApi.changes().id(changeToBeRebased1.get()).current().review(ReviewInput.approve());
+    gApi.changes().id(changeToBeRebased2.get()).current().review(ReviewInput.approve());
+    assertThat(gApi.changes().id(changeToBeRebased1.get()).get().submittable).isFalse();
+    assertThat(gApi.changes().id(changeToBeRebased2.get()).get().submittable).isFalse();
+  }
+
+  @Test
+  public void testSubmittedWithRebaserApprovalMetric() throws Exception {
+    // Require a Code-Review approval from a non-uploader for submit.
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig()
+          .upsertSubmitRequirement(
+              SubmitRequirement.builder()
+                  .setName(TestLabels.codeReview().getName())
+                  .setSubmittabilityExpression(
+                      SubmitRequirementExpression.create(
+                          String.format(
+                              "label:%s=MAX,user=non_uploader", TestLabels.codeReview().getName())))
+                  .setAllowOverrideInChildProjects(false)
+                  .build());
+      u.save();
+    }
+
+    allowPermissionToAllUsers(Permission.REBASE);
+
+    String uploaderEmail = "uploader@example.com";
+    Account.Id uploader = accountOperations.newAccount().preferredEmail(uploaderEmail).create();
+    Account.Id approver = admin.id();
+    Account.Id rebaser = accountOperations.newAccount().create();
+
+    Change.Id changeToBeTheNewBase =
+        changeOperations.newChange().owner(uploader).project(project).create();
+
+    Change.Id changeToBeRebased1 =
+        changeOperations.newChange().project(project).owner(uploader).create();
+    Change.Id changeToBeRebased2 =
+        changeOperations
+            .newChange()
+            .project(project)
+            .owner(uploader)
+            .childOf()
+            .change(changeToBeRebased1)
+            .create();
+
+    // Approve and submit the change that will be the new base for the chain that will be rebased.
+    requestScopeOperations.setApiUser(approver);
+    gApi.changes().id(changeToBeTheNewBase.get()).current().review(ReviewInput.approve());
+    testMetricMaker.reset();
+    gApi.changes().id(changeToBeTheNewBase.get()).current().submit();
+    assertThat(testMetricMaker.getCount("change/submitted_with_rebaser_approval")).isEqualTo(0);
+
+    // Rebase it on behalf of the uploader
+    requestScopeOperations.setApiUser(rebaser);
+    RebaseInput rebaseInput = new RebaseInput();
+    rebaseInput.onBehalfOfUploader = true;
+    gApi.changes().id(changeToBeRebased2.get()).rebaseChain(rebaseInput);
+
+    // Approve the chain as the rebaser.
+    allowVotingOnCodeReviewToAllUsers();
+    gApi.changes().id(changeToBeRebased1.get()).current().review(ReviewInput.approve());
+    gApi.changes().id(changeToBeRebased2.get()).current().review(ReviewInput.approve());
+
+    // The chain is submittable because the approval is from a user (the rebaser) that is not the
+    // uploader.
+    allowPermissionToAllUsers(Permission.SUBMIT);
+    testMetricMaker.reset();
+    gApi.changes().id(changeToBeRebased1.get()).current().submit();
+    gApi.changes().id(changeToBeRebased2.get()).current().submit();
+    assertThat(testMetricMaker.getCount("change/submitted_with_rebaser_approval")).isEqualTo(2);
+  }
+
+  @Test
+  public void testCountRebasesMetric() throws Exception {
+    allowPermissionToAllUsers(Permission.REBASE);
+
+    Account.Id uploader = accountOperations.newAccount().create();
+    Account.Id approver = admin.id();
+    Account.Id rebaser = accountOperations.newAccount().create();
+
+    Change.Id changeToBeTheNewBase = changeOperations.newChange().project(project).create();
+
+    Change.Id changeToBeRebased1 =
+        changeOperations.newChange().project(project).owner(uploader).create();
+    Change.Id changeToBeRebased2 =
+        changeOperations
+            .newChange()
+            .project(project)
+            .owner(uploader)
+            .childOf()
+            .change(changeToBeRebased1)
+            .create();
+
+    // Approve and submit the change that will be the new base for the chain that will be rebased.
+    requestScopeOperations.setApiUser(approver);
+    gApi.changes().id(changeToBeTheNewBase.get()).current().review(ReviewInput.approve());
+    gApi.changes().id(changeToBeTheNewBase.get()).current().submit();
+
+    // Rebase it on behalf of the uploader
+    testMetricMaker.reset();
+    requestScopeOperations.setApiUser(rebaser);
+    RebaseInput rebaseInput = new RebaseInput();
+    rebaseInput.onBehalfOfUploader = true;
+    gApi.changes().id(changeToBeRebased2.get()).rebaseChain(rebaseInput);
+    // field1 is on_behalf_of_uploader, field2 is rebase_chain, field3 is allow_conflicts
+    assertThat(testMetricMaker.getCount("change/count_rebases", true, true, false)).isEqualTo(1);
+    assertThat(testMetricMaker.getCount("change/count_rebases", true, false, false)).isEqualTo(0);
+    assertThat(testMetricMaker.getCount("change/count_rebases", false, false, false)).isEqualTo(0);
+    assertThat(testMetricMaker.getCount("change/count_rebases", false, true, false)).isEqualTo(0);
+
+    // Create and submit another change so that we can rebase the change once again.
+    requestScopeOperations.setApiUser(approver);
+    Change.Id changeToBeTheNewBase2 =
+        changeOperations.newChange().project(project).owner(uploader).create();
+    gApi.changes().id(changeToBeTheNewBase2.get()).current().review(ReviewInput.approve());
+    gApi.changes().id(changeToBeTheNewBase2.get()).current().submit();
+
+    // Rebase the change once again, this time as the uploader.
+    // If the uploader sets on_behalf_of_uploader = true, the flag is ignored and a normal rebase is
+    // done, hence the metric should count this as a a rebase with on_behalf_of_uploader = false.
+    requestScopeOperations.setApiUser(uploader);
+    testMetricMaker.reset();
+    gApi.changes().id(changeToBeRebased2.get()).rebaseChain(rebaseInput);
+    // field1 is on_behalf_of_uploader, field2 is rebase_chain, field3 is allow_conflicts
+    assertThat(testMetricMaker.getCount("change/count_rebases", false, true, false)).isEqualTo(1);
+    assertThat(testMetricMaker.getCount("change/count_rebases", true, true, false)).isEqualTo(0);
+    assertThat(testMetricMaker.getCount("change/count_rebases", false, false, false)).isEqualTo(0);
+    assertThat(testMetricMaker.getCount("change/count_rebases", true, false, false)).isEqualTo(0);
+  }
+
+  private void assertRebase(
+      Change.Id changeId,
+      int expectedPatchSetNum,
+      Account.Id expectedUploader,
+      @Nullable Account.Id expectedRealUploader)
+      throws RestApiException {
+    assertRebaseRevision(changeId, expectedPatchSetNum, expectedUploader, expectedRealUploader);
+    assetRebaseChangeMessage(changeId, expectedPatchSetNum, expectedUploader, expectedRealUploader);
+    assertRealUserForChangeUpdate(changeId, expectedRealUploader);
+  }
+
+  private void assertRebaseRevision(
+      Change.Id changeId,
+      int expectedPatchSetNum,
+      Account.Id expectedUploader,
+      @Nullable Account.Id expectedRealUploader)
+      throws RestApiException {
+    RevisionInfo currentRevisionInfo = gApi.changes().id(changeId.get()).get().getCurrentRevision();
+
+    assertThat(currentRevisionInfo._number).isEqualTo(expectedPatchSetNum);
+
+    assertThat(currentRevisionInfo.uploader._accountId).isEqualTo(expectedUploader.get());
+
+    if (expectedRealUploader != null) {
+      assertThat(currentRevisionInfo.realUploader._accountId).isEqualTo(expectedRealUploader.get());
+    } else {
+      assertThat(currentRevisionInfo.realUploader).isNull();
+    }
+
+    String uploaderEmail = accountOperations.account(expectedUploader).get().preferredEmail().get();
+    assertThat(currentRevisionInfo.commit.author.email).isEqualTo(uploaderEmail);
+    assertThat(currentRevisionInfo.commit.committer.email).isEqualTo(uploaderEmail);
+  }
+
+  private void assetRebaseChangeMessage(
+      Change.Id changeId,
+      int expectedPatchSetNum,
+      Account.Id expectedUploader,
+      @Nullable Account.Id expectedRealUploader)
+      throws RestApiException {
+    Collection<ChangeMessageInfo> changeMessages = gApi.changes().id(changeId.get()).get().messages;
+
+    // Expect 1 change message per patch set.
+    assertThat(changeMessages).hasSize(expectedPatchSetNum);
+
+    ChangeMessageInfo changeMessage = Iterables.getLast(changeMessages);
+    assertThat(changeMessage.author._accountId).isEqualTo(expectedUploader.get());
+
+    if (expectedRealUploader != null) {
+      assertThat(changeMessage.message)
+          .isEqualTo(
+              String.format(
+                  "Patch Set %d: Patch Set %d was rebased on behalf of %s",
+                  expectedPatchSetNum,
+                  expectedPatchSetNum - 1,
+                  AccountTemplateUtil.getAccountTemplate(expectedUploader)));
+      assertThat(changeMessage.realAuthor._accountId).isEqualTo(expectedRealUploader.get());
+    } else {
+      assertThat(changeMessage.message)
+          .isEqualTo(
+              String.format(
+                  "Patch Set %d: Patch Set %d was rebased",
+                  expectedPatchSetNum, expectedPatchSetNum - 1));
+      assertThat(changeMessage.realAuthor).isNull();
+    }
+  }
+
+  private void assertRealUserForChangeUpdate(
+      Change.Id changeId, @Nullable Account.Id expectedRealUser) {
+    Optional<FooterLine> realUserFooter =
+        projectOperations.project(project).getHead(RefNames.changeMetaRef(changeId))
+            .getFooterLines().stream()
+            .filter(footerLine -> footerLine.matches(FOOTER_REAL_USER))
+            .findFirst();
+
+    if (expectedRealUser != null) {
+      assertThat(realUserFooter.map(FooterLine::getValue))
+          .hasValue(
+              String.format(
+                  "%s <%s>",
+                  ChangeNoteUtil.getAccountIdAsUsername(expectedRealUser),
+                  changeNoteUtil.getAccountIdAsEmailAddress(expectedRealUser)));
+    } else {
+      assertThat(realUserFooter).isEmpty();
+    }
+  }
+
+  private void allowPermissionToAllUsers(String permission) {
+    allowPermission(permission, REGISTERED_USERS);
+  }
+
+  private void allowPermission(String permission, AccountGroup.UUID groupUuid) {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(permission).ref("refs/*").group(groupUuid))
+        .update();
+  }
+
+  private void allowVotingOnCodeReviewToAllUsers() {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(TestLabels.codeReview().getName())
+                .ref("refs/*")
+                .group(REGISTERED_USERS)
+                .range(-2, 2))
+        .update();
+  }
+
+  private void blockPermissionForAllUsers(String permission) {
+    blockPermission(permission, REGISTERED_USERS);
+  }
+
+  private void blockPermission(String permission, AccountGroup.UUID groupUuid) {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(permission).ref("refs/*").group(groupUuid))
+        .update();
+  }
+
+  private static class TestRevisionCreatedListener implements RevisionCreatedListener {
+    private Map<Change.Id, RevisionInfo> revisionInfos = new HashMap<>();
+
+    void assertUploaders(
+        Change.Id changeId, Account.Id expectedUploader, Account.Id expectedRealUploader) {
+      RevisionInfo revisionInfo = revisionInfos.get(changeId);
+      assertThat(revisionInfo.uploader._accountId).isEqualTo(expectedUploader.get());
+      assertThat(revisionInfo.realUploader._accountId).isEqualTo(expectedRealUploader.get());
+    }
+
+    @Override
+    public void onRevisionCreated(RevisionCreatedListener.Event event) {
+      revisionInfos.put(Change.id(event.getChange()._number), event.getRevision());
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/change/RebaseIT.java b/javatests/com/google/gerrit/acceptance/api/change/RebaseIT.java
new file mode 100644
index 0000000..5ecb5a7
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/change/RebaseIT.java
@@ -0,0 +1,1412 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.api.change;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
+import static com.google.gerrit.extensions.client.ListChangesOption.ALL_REVISIONS;
+import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_COMMIT;
+import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_REVISION;
+import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_LABELS;
+import static com.google.gerrit.git.ObjectIds.abbreviateName;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.eclipse.jgit.lib.Constants.HEAD;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Iterables;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.ExtensionRegistry;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.TestMetricMaker;
+import com.google.gerrit.acceptance.testsuite.change.ChangeOperations;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.common.RawInputUtil;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.PatchSet;
+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.RebaseInput;
+import com.google.gerrit.extensions.api.changes.RelatedChangeAndCommitInfo;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.RevisionApi;
+import com.google.gerrit.extensions.api.projects.BranchInput;
+import com.google.gerrit.extensions.client.ChangeStatus;
+import com.google.gerrit.extensions.common.ActionInfo;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.ChangeMessageInfo;
+import com.google.gerrit.extensions.common.GitPerson;
+import com.google.gerrit.extensions.common.LabelInfo;
+import com.google.gerrit.extensions.common.RebaseChainInfo;
+import com.google.gerrit.extensions.common.RevisionInfo;
+import com.google.gerrit.extensions.events.WorkInProgressStateChangedListener;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BinaryResult;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.server.events.CommitReceivedEvent;
+import com.google.gerrit.server.git.validators.CommitValidationException;
+import com.google.gerrit.server.git.validators.CommitValidationListener;
+import com.google.gerrit.server.git.validators.CommitValidationMessage;
+import com.google.inject.Inject;
+import java.io.ByteArrayOutputStream;
+import java.util.Arrays;
+import java.util.List;
+import java.util.stream.Collectors;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.Suite;
+
+@RunWith(Suite.class)
+@Suite.SuiteClasses({
+  RebaseIT.RebaseViaRevisionApi.class, //
+  RebaseIT.RebaseViaChangeApi.class, //
+  RebaseIT.RebaseChain.class, //
+})
+public class RebaseIT {
+  public abstract static class Base extends AbstractDaemonTest {
+    @Inject protected ChangeOperations changeOperations;
+    @Inject protected RequestScopeOperations requestScopeOperations;
+    @Inject protected ProjectOperations projectOperations;
+    @Inject protected ExtensionRegistry extensionRegistry;
+    @Inject protected TestMetricMaker testMetricMaker;
+
+    @FunctionalInterface
+    protected interface RebaseCall {
+      void call(String id) throws RestApiException;
+    }
+
+    @FunctionalInterface
+    protected interface RebaseCallWithInput {
+      void call(String id, RebaseInput in) throws RestApiException;
+    }
+
+    protected RebaseCall rebaseCall;
+    protected RebaseCallWithInput rebaseCallWithInput;
+
+    protected void init(RebaseCall call, RebaseCallWithInput callWithInput) {
+      this.rebaseCall = call;
+      this.rebaseCallWithInput = callWithInput;
+    }
+
+    @Test
+    public void rebaseChange() throws Exception {
+      // Create two changes both with the same parent
+      PushOneCommit.Result r = createChange();
+      testRepo.reset("HEAD~1");
+      PushOneCommit.Result r2 = createChange();
+
+      // Approve and submit the first change
+      RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+      revision.review(ReviewInput.approve());
+      revision.submit();
+
+      // Add an approval whose score should be copied on trivial rebase
+      gApi.changes().id(r2.getChangeId()).current().review(ReviewInput.recommend());
+
+      // Rebase the second change
+      rebaseCall.call(r2.getChangeId());
+
+      verifyRebaseForChange(r2.getChange().getId(), r.getCommit().name(), true, 2);
+
+      // Rebasing the second change again should fail
+      verifyChangeIsUpToDate(r2);
+    }
+
+    @Test
+    public void rebaseAbandonedChange() throws Exception {
+      PushOneCommit.Result r = createChange();
+      String changeId = r.getChangeId();
+      assertThat(info(changeId).status).isEqualTo(ChangeStatus.NEW);
+      gApi.changes().id(changeId).abandon();
+      ChangeInfo info = info(changeId);
+      assertThat(info.status).isEqualTo(ChangeStatus.ABANDONED);
+
+      ResourceConflictException thrown =
+          assertThrows(ResourceConflictException.class, () -> rebaseCall.call(changeId));
+      assertThat(thrown)
+          .hasMessageThat()
+          .contains("Change " + r.getChange().getId() + " is abandoned");
+    }
+
+    @Test
+    public void rebaseOntoAbandonedChange() throws Exception {
+      // Create two changes both with the same parent
+      PushOneCommit.Result r = createChange();
+      testRepo.reset("HEAD~1");
+      PushOneCommit.Result r2 = createChange();
+
+      // Abandon the first change
+      String changeId = r.getChangeId();
+      assertThat(info(changeId).status).isEqualTo(ChangeStatus.NEW);
+      gApi.changes().id(changeId).abandon();
+      ChangeInfo info = info(changeId);
+      assertThat(info.status).isEqualTo(ChangeStatus.ABANDONED);
+
+      RebaseInput ri = new RebaseInput();
+      ri.base = r.getCommit().name();
+
+      ResourceConflictException thrown =
+          assertThrows(
+              ResourceConflictException.class,
+              () -> rebaseCallWithInput.call(r2.getChangeId(), ri));
+      assertThat(thrown).hasMessageThat().contains("base change is abandoned: " + changeId);
+    }
+
+    @Test
+    public void rebaseOntoSelf() throws Exception {
+      PushOneCommit.Result r = createChange();
+      String changeId = r.getChangeId();
+      String commit = r.getCommit().name();
+      RebaseInput ri = new RebaseInput();
+      ri.base = commit;
+      ResourceConflictException thrown =
+          assertThrows(
+              ResourceConflictException.class, () -> rebaseCallWithInput.call(changeId, ri));
+      assertThat(thrown)
+          .hasMessageThat()
+          .contains("cannot rebase change " + r.getChange().getId() + " onto itself");
+    }
+
+    @Test
+    public void rebaseChangeBaseRecursion() throws Exception {
+      PushOneCommit.Result r1 = createChange();
+      PushOneCommit.Result r2 = createChange();
+
+      RebaseInput ri = new RebaseInput();
+      ri.base = r2.getCommit().name();
+      String expectedMessage =
+          "base change "
+              + r2.getChangeId()
+              + " is a descendant of the current change - recursion not allowed";
+      ResourceConflictException thrown =
+          assertThrows(
+              ResourceConflictException.class,
+              () -> rebaseCallWithInput.call(r1.getChangeId(), ri));
+      assertThat(thrown).hasMessageThat().contains(expectedMessage);
+    }
+
+    @Test
+    public void cannotRebaseOntoBaseThatIsNotPresentInTargetBranch() throws Exception {
+      ObjectId initial = repo().exactRef(HEAD).getLeaf().getObjectId();
+
+      BranchInput branchInput = new BranchInput();
+      branchInput.revision = initial.getName();
+      gApi.projects().name(project.get()).branch("foo").create(branchInput);
+
+      PushOneCommit.Result r1 =
+          pushFactory
+              .create(admin.newIdent(), testRepo, "Change on foo branch", "a.txt", "a-content")
+              .to("refs/for/foo");
+      approve(r1.getChangeId());
+      gApi.changes().id(r1.getChangeId()).current().submit();
+
+      // reset HEAD in order to create a sibling of the first change
+      testRepo.reset(initial);
+
+      PushOneCommit.Result r2 =
+          pushFactory
+              .create(admin.newIdent(), testRepo, "Change on master branch", "b.txt", "b-content")
+              .to("refs/for/master");
+
+      RebaseInput rebaseInput = new RebaseInput();
+      rebaseInput.base = r1.getCommit().getName();
+      ResourceConflictException thrown =
+          assertThrows(
+              ResourceConflictException.class,
+              () -> rebaseCallWithInput.call(r2.getChangeId(), rebaseInput));
+      assertThat(thrown)
+          .hasMessageThat()
+          .contains(
+              String.format(
+                  "base change is targeting wrong branch: %s,refs/heads/foo", project.get()));
+
+      rebaseInput.base = "refs/heads/foo";
+      thrown =
+          assertThrows(
+              ResourceConflictException.class,
+              () -> rebaseCallWithInput.call(r2.getChangeId(), rebaseInput));
+      assertThat(thrown)
+          .hasMessageThat()
+          .contains(
+              String.format(
+                  "base revision is missing from the destination branch: %s", rebaseInput.base));
+    }
+
+    @Test
+    public void rebaseUpToDateChange() throws Exception {
+      PushOneCommit.Result r = createChange();
+      verifyChangeIsUpToDate(r);
+    }
+
+    @Test
+    public void rebaseDoesNotAddWorkInProgress() throws Exception {
+      PushOneCommit.Result r = createChange();
+
+      // create an unrelated change so that we can rebase
+      testRepo.reset("HEAD~1");
+      PushOneCommit.Result unrelated = createChange();
+      gApi.changes().id(unrelated.getChangeId()).current().review(ReviewInput.approve());
+      gApi.changes().id(unrelated.getChangeId()).current().submit();
+
+      rebaseCall.call(r.getChangeId());
+
+      // change is still ready for review after rebase
+      assertThat(gApi.changes().id(r.getChangeId()).get().workInProgress).isNull();
+    }
+
+    @Test
+    public void rebaseDoesNotRemoveWorkInProgress() throws Exception {
+      PushOneCommit.Result r = createChange();
+      change(r).setWorkInProgress();
+
+      // create an unrelated change so that we can rebase
+      testRepo.reset("HEAD~1");
+      PushOneCommit.Result unrelated = createChange();
+      gApi.changes().id(unrelated.getChangeId()).current().review(ReviewInput.approve());
+      gApi.changes().id(unrelated.getChangeId()).current().submit();
+
+      rebaseCall.call(r.getChangeId());
+
+      // change is still work in progress after rebase
+      assertThat(gApi.changes().id(r.getChangeId()).get().workInProgress).isTrue();
+    }
+
+    @Test
+    public void rebaseAsUploaderInAttentionSet() throws Exception {
+      // Create two changes both with the same parent
+      PushOneCommit.Result r = createChange();
+      testRepo.reset("HEAD~1");
+      PushOneCommit.Result r2 = createChange();
+
+      // Approve and submit the first change
+      RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+      revision.review(ReviewInput.approve());
+      revision.submit();
+
+      TestAccount admin2 = accountCreator.admin2();
+      requestScopeOperations.setApiUser(admin2.id());
+      amendChangeWithUploader(r2, project, admin2);
+      gApi.changes()
+          .id(r2.getChangeId())
+          .addToAttentionSet(new AttentionSetInput(admin2.id().toString(), "manual update"));
+
+      rebaseCall.call(r2.getChangeId());
+    }
+
+    @Test
+    public void rebaseOnChangeNumber() throws Exception {
+      String branchTip = testRepo.getRepository().exactRef("HEAD").getObjectId().name();
+      PushOneCommit.Result r1 = createChange();
+      testRepo.reset("HEAD~1");
+      PushOneCommit.Result r2 = createChange();
+
+      RevisionInfo ri2 =
+          get(r2.getChangeId(), CURRENT_REVISION, CURRENT_COMMIT).getCurrentRevision();
+      assertThat(ri2.commit.parents.get(0).commit).isEqualTo(branchTip);
+
+      Change.Id id1 = r1.getChange().getId();
+      RebaseInput in = new RebaseInput();
+      in.base = id1.toString();
+      rebaseCallWithInput.call(r2.getChangeId(), in);
+
+      Change.Id id2 = r2.getChange().getId();
+      ri2 = get(r2.getChangeId(), CURRENT_REVISION, CURRENT_COMMIT).getCurrentRevision();
+      assertThat(ri2.commit.parents.get(0).commit).isEqualTo(r1.getCommit().name());
+
+      List<RelatedChangeAndCommitInfo> related =
+          gApi.changes().id(id2.get()).revision(ri2._number).related().changes;
+      assertThat(related).hasSize(2);
+      assertThat(related.get(0)._changeNumber).isEqualTo(id2.get());
+      assertThat(related.get(0)._revisionNumber).isEqualTo(2);
+      assertThat(related.get(1)._changeNumber).isEqualTo(id1.get());
+      assertThat(related.get(1)._revisionNumber).isEqualTo(1);
+    }
+
+    @Test
+    public void rebaseOnClosedChange() throws Exception {
+      String branchTip = testRepo.getRepository().exactRef("HEAD").getObjectId().name();
+      PushOneCommit.Result r1 = createChange();
+      testRepo.reset("HEAD~1");
+      PushOneCommit.Result r2 = createChange();
+
+      RevisionInfo ri2 =
+          get(r2.getChangeId(), CURRENT_REVISION, CURRENT_COMMIT).getCurrentRevision();
+      assertThat(ri2.commit.parents.get(0).commit).isEqualTo(branchTip);
+
+      // Submit first change.
+      Change.Id id1 = r1.getChange().getId();
+      gApi.changes().id(id1.get()).current().review(ReviewInput.approve());
+      gApi.changes().id(id1.get()).current().submit();
+
+      // Rebase second change on first change.
+      RebaseInput in = new RebaseInput();
+      in.base = id1.toString();
+      rebaseCallWithInput.call(r2.getChangeId(), in);
+
+      Change.Id id2 = r2.getChange().getId();
+      ri2 = get(r2.getChangeId(), CURRENT_REVISION, CURRENT_COMMIT).getCurrentRevision();
+      assertThat(ri2.commit.parents.get(0).commit).isEqualTo(r1.getCommit().name());
+
+      assertThat(gApi.changes().id(id2.get()).revision(ri2._number).related().changes).isEmpty();
+    }
+
+    @Test
+    public void rebaseOnNonExistingChange() throws Exception {
+      String changeId = createChange().getChangeId();
+      RebaseInput in = new RebaseInput();
+      in.base = "999999";
+      UnprocessableEntityException exception =
+          assertThrows(
+              UnprocessableEntityException.class, () -> rebaseCallWithInput.call(changeId, in));
+      assertThat(exception).hasMessageThat().contains("Base change not found: " + in.base);
+    }
+
+    @Test
+    public void rebaseNotAllowedWithoutPermission() throws Exception {
+      // Create two changes both with the same parent
+      PushOneCommit.Result r = createChange();
+      testRepo.reset("HEAD~1");
+      PushOneCommit.Result r2 = createChange();
+
+      // Approve and submit the first change
+      RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+      revision.review(ReviewInput.approve());
+      revision.submit();
+
+      // Rebase the second
+      String changeId = r2.getChangeId();
+      requestScopeOperations.setApiUser(user.id());
+      AuthException thrown = assertThrows(AuthException.class, () -> rebaseCall.call(changeId));
+      assertThat(thrown)
+          .hasMessageThat()
+          .isEqualTo(
+              "rebase not permitted (change owners and users with the 'Submit' or 'Rebase'"
+                  + " permission can rebase if they have the 'Push' permission)");
+    }
+
+    @Test
+    public void rebaseAllowedWithPermission() throws Exception {
+      // Create two changes both with the same parent
+      PushOneCommit.Result r = createChange();
+      testRepo.reset("HEAD~1");
+      PushOneCommit.Result r2 = createChange();
+
+      // Approve and submit the first change
+      RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+      revision.review(ReviewInput.approve());
+      revision.submit();
+
+      projectOperations
+          .project(project)
+          .forUpdate()
+          .add(allow(Permission.REBASE).ref("refs/heads/master").group(REGISTERED_USERS))
+          .update();
+
+      // Rebase the second
+      String changeId = r2.getChangeId();
+      requestScopeOperations.setApiUser(user.id());
+      rebaseCall.call(changeId);
+    }
+
+    @Test
+    public void rebaseNotAllowedWithoutPushPermission() throws Exception {
+      // Create two changes both with the same parent
+      PushOneCommit.Result r = createChange();
+      testRepo.reset("HEAD~1");
+      PushOneCommit.Result r2 = createChange();
+
+      // Approve and submit the first change
+      RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+      revision.review(ReviewInput.approve());
+      revision.submit();
+
+      projectOperations
+          .project(project)
+          .forUpdate()
+          .add(allow(Permission.REBASE).ref("refs/heads/master").group(REGISTERED_USERS))
+          .add(block(Permission.PUSH).ref("refs/for/*").group(REGISTERED_USERS))
+          .update();
+
+      // Rebase the second
+      String changeId = r2.getChangeId();
+      requestScopeOperations.setApiUser(user.id());
+      AuthException thrown = assertThrows(AuthException.class, () -> rebaseCall.call(changeId));
+      assertThat(thrown)
+          .hasMessageThat()
+          .isEqualTo(
+              "rebase not permitted (change owners and users with the 'Submit' or 'Rebase'"
+                  + " permission can rebase if they have the 'Push' permission)");
+    }
+
+    @Test
+    public void rebaseNotAllowedForOwnerWithoutPushPermission() throws Exception {
+      // Create two changes both with the same parent
+      PushOneCommit.Result r = createChange();
+      testRepo.reset("HEAD~1");
+      PushOneCommit.Result r2 = createChange();
+
+      // Approve and submit the first change
+      RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+      revision.review(ReviewInput.approve());
+      revision.submit();
+
+      projectOperations
+          .project(project)
+          .forUpdate()
+          .add(block(Permission.PUSH).ref("refs/for/*").group(REGISTERED_USERS))
+          .update();
+
+      // Rebase the second
+      String changeId = r2.getChangeId();
+      AuthException thrown = assertThrows(AuthException.class, () -> rebaseCall.call(changeId));
+      assertThat(thrown)
+          .hasMessageThat()
+          .isEqualTo(
+              "rebase not permitted (change owners and users with the 'Submit' or 'Rebase'"
+                  + " permission can rebase if they have the 'Push' permission)");
+    }
+
+    @Test
+    public void rebaseWithValidationOptions() throws Exception {
+      // Create two changes both with the same parent
+      PushOneCommit.Result r = createChange();
+      testRepo.reset("HEAD~1");
+      PushOneCommit.Result r2 = createChange();
+
+      // Approve and submit the first change
+      RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+      revision.review(ReviewInput.approve());
+      revision.submit();
+
+      RebaseInput rebaseInput = new RebaseInput();
+      rebaseInput.validationOptions = ImmutableMap.of("key", "value");
+
+      TestCommitValidationListener testCommitValidationListener =
+          new TestCommitValidationListener();
+      try (ExtensionRegistry.Registration unusedRegistration =
+          extensionRegistry.newRegistration().add(testCommitValidationListener)) {
+        // Rebase the second change
+        rebaseCallWithInput.call(r2.getChangeId(), rebaseInput);
+        assertThat(testCommitValidationListener.receiveEvent.pushOptions)
+            .containsExactly("key", "value");
+      }
+    }
+
+    @Test
+    public void rebaseChangeWhenChecksRefExists() throws Exception {
+      // Create two changes both with the same parent
+      PushOneCommit.Result r = createChange();
+      testRepo.reset("HEAD~1");
+      PushOneCommit.Result r2 = createChange();
+
+      // Approve and submit the first change
+      RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+      revision.review(ReviewInput.approve());
+      revision.submit();
+
+      // Create checks ref
+      try (TestRepository<Repository> testRepo =
+          new TestRepository<>(repoManager.openRepository(project))) {
+        testRepo.update(
+            RefNames.changeRefPrefix(r2.getChange().getId()) + "checks",
+            testRepo.commit().message("Empty commit"));
+      }
+
+      // Rebase the second change
+      rebaseCall.call(r2.getChangeId());
+
+      verifyRebaseForChange(
+          r2.getChange().getId(),
+          r.getCommit().name(),
+          /* shouldHaveApproval= */ false,
+          /* expectedNumRevisions= */ 2);
+    }
+
+    protected void verifyRebaseForChange(
+        Change.Id changeId, Change.Id baseChangeId, boolean shouldHaveApproval)
+        throws RestApiException {
+      verifyRebaseForChange(changeId, baseChangeId, shouldHaveApproval, 2);
+    }
+
+    protected void verifyRebaseForChange(
+        Change.Id changeId,
+        Change.Id baseChangeId,
+        boolean shouldHaveApproval,
+        int expectedNumRevisions)
+        throws RestApiException {
+      ChangeInfo baseInfo = gApi.changes().id(baseChangeId.get()).get(CURRENT_REVISION);
+      verifyRebaseForChange(
+          changeId, baseInfo.currentRevision, shouldHaveApproval, expectedNumRevisions);
+    }
+
+    protected void verifyRebaseForChange(
+        Change.Id changeId, String baseCommit, boolean shouldHaveApproval, int expectedNumRevisions)
+        throws RestApiException {
+      ChangeInfo info =
+          gApi.changes().id(changeId.get()).get(CURRENT_REVISION, CURRENT_COMMIT, DETAILED_LABELS);
+
+      RevisionInfo r = info.getCurrentRevision();
+      assertThat(r._number).isEqualTo(expectedNumRevisions);
+      assertThat(r.realUploader).isNull();
+
+      // ...and the base should be correct
+      assertThat(r.commit.parents).hasSize(1);
+      assertWithMessage("base commit for change " + changeId)
+          .that(r.commit.parents.get(0).commit)
+          .isEqualTo(baseCommit);
+
+      // ...and the committer and description should be correct
+      GitPerson committer = r.commit.committer;
+      assertThat(committer.name).isEqualTo(admin.fullName());
+      assertThat(committer.email).isEqualTo(admin.email());
+      String description = r.description;
+      assertThat(description).isEqualTo("Rebase");
+
+      if (shouldHaveApproval) {
+        // ...and the approval was copied
+        LabelInfo cr = info.labels.get(LabelId.CODE_REVIEW);
+        assertThat(cr).isNotNull();
+        assertThat(cr.all).isNotNull();
+        assertThat(cr.all).hasSize(1);
+        assertThat(cr.all.get(0).value).isEqualTo(1);
+      }
+    }
+
+    protected void verifyChangeIsUpToDate(PushOneCommit.Result r) {
+      ResourceConflictException thrown =
+          assertThrows(ResourceConflictException.class, () -> rebaseCall.call(r.getChangeId()));
+      assertThat(thrown).hasMessageThat().contains("Change is already up to date");
+    }
+
+    protected static class TestCommitValidationListener implements CommitValidationListener {
+      public CommitReceivedEvent receiveEvent;
+
+      @Override
+      public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
+          throws CommitValidationException {
+        this.receiveEvent = receiveEvent;
+        return ImmutableList.of();
+      }
+    }
+
+    protected static class TestWorkInProgressStateChangedListener
+        implements WorkInProgressStateChangedListener {
+      boolean invoked;
+      Boolean wip;
+
+      @Override
+      public void onWorkInProgressStateChanged(WorkInProgressStateChangedListener.Event event) {
+        this.invoked = true;
+        this.wip =
+            event.getChange().workInProgress != null ? event.getChange().workInProgress : false;
+      }
+    }
+  }
+
+  public abstract static class Rebase extends Base {
+    @Test
+    public void rebaseChangeBase() throws Exception {
+      PushOneCommit.Result r1 = createChange();
+      PushOneCommit.Result r2 = createChange();
+      PushOneCommit.Result r3 = createChange();
+      RebaseInput ri = new RebaseInput();
+
+      // rebase r3 directly onto master (break dep. towards r2)
+      ri.base = "";
+      rebaseCallWithInput.call(r3.getChangeId(), ri);
+      PatchSet ps3 = r3.getPatchSet();
+      assertThat(ps3.id().get()).isEqualTo(2);
+
+      // rebase r2 onto r3 (referenced by ref)
+      ri.base = ps3.id().toRefName();
+      rebaseCallWithInput.call(r2.getChangeId(), ri);
+      PatchSet ps2 = r2.getPatchSet();
+      assertThat(ps2.id().get()).isEqualTo(2);
+
+      // rebase r1 onto r2 (referenced by commit)
+      ri.base = ps2.commitId().name();
+      rebaseCallWithInput.call(r1.getChangeId(), ri);
+      PatchSet ps1 = r1.getPatchSet();
+      assertThat(ps1.id().get()).isEqualTo(2);
+
+      // rebase r1 onto r3 (referenced by change number)
+      ri.base = String.valueOf(r3.getChange().getId().get());
+      rebaseCallWithInput.call(r1.getChangeId(), ri);
+      assertThat(r1.getPatchSetId().get()).isEqualTo(3);
+    }
+
+    @Test
+    public void rebaseWithConflict_conflictsAllowed() throws Exception {
+      String patchSetSubject = "patch set change";
+      String patchSetContent = "patch set content";
+      String baseSubject = "base change";
+      String baseContent = "base content";
+
+      PushOneCommit.Result r1 = createChange(baseSubject, PushOneCommit.FILE_NAME, baseContent);
+      gApi.changes()
+          .id(r1.getChangeId())
+          .revision(r1.getCommit().name())
+          .review(ReviewInput.approve());
+      gApi.changes().id(r1.getChangeId()).revision(r1.getCommit().name()).submit();
+
+      testRepo.reset("HEAD~1");
+      PushOneCommit push =
+          pushFactory.create(
+              admin.newIdent(),
+              testRepo,
+              patchSetSubject,
+              PushOneCommit.FILE_NAME,
+              patchSetContent);
+      PushOneCommit.Result r2 = push.to("refs/for/master");
+      r2.assertOkStatus();
+
+      String changeId = r2.getChangeId();
+      RevCommit patchSet = r2.getCommit();
+      RevCommit base = r1.getCommit();
+
+      TestWorkInProgressStateChangedListener wipStateChangedListener =
+          new TestWorkInProgressStateChangedListener();
+      try (ExtensionRegistry.Registration registration =
+          extensionRegistry.newRegistration().add(wipStateChangedListener)) {
+        RebaseInput rebaseInput = new RebaseInput();
+        rebaseInput.allowConflicts = true;
+        testMetricMaker.reset();
+        ChangeInfo changeInfo =
+            gApi.changes().id(changeId).revision(patchSet.name()).rebaseAsInfo(rebaseInput);
+        assertThat(changeInfo.containsGitConflicts).isTrue();
+        assertThat(changeInfo.workInProgress).isTrue();
+
+        // field1 is on_behalf_of_uploader, field2 is rebase_chain, field3 is allow_conflicts
+        assertThat(testMetricMaker.getCount("change/count_rebases", false, false, true))
+            .isEqualTo(1);
+      }
+      assertThat(wipStateChangedListener.invoked).isTrue();
+      assertThat(wipStateChangedListener.wip).isTrue();
+
+      // To get the revisions, we must retrieve the change with more change options.
+      ChangeInfo changeInfo =
+          gApi.changes().id(changeId).get(ALL_REVISIONS, CURRENT_COMMIT, CURRENT_REVISION);
+      assertThat(changeInfo.revisions).hasSize(2);
+      assertThat(changeInfo.getCurrentRevision().commit.parents.get(0).commit)
+          .isEqualTo(base.name());
+
+      // Verify that the file content in the created patch set is correct.
+      // We expect that it has conflict markers to indicate the conflict.
+      BinaryResult bin =
+          gApi.changes().id(changeId).current().file(PushOneCommit.FILE_NAME).content();
+      ByteArrayOutputStream os = new ByteArrayOutputStream();
+      bin.writeTo(os);
+      String fileContent = new String(os.toByteArray(), UTF_8);
+      String patchSetSha1 = abbreviateName(patchSet, 6);
+      String baseSha1 = abbreviateName(base, 6);
+      assertThat(fileContent)
+          .isEqualTo(
+              "<<<<<<< PATCH SET ("
+                  + patchSetSha1
+                  + " "
+                  + patchSetSubject
+                  + ")\n"
+                  + patchSetContent
+                  + "\n"
+                  + "=======\n"
+                  + baseContent
+                  + "\n"
+                  + ">>>>>>> BASE      ("
+                  + baseSha1
+                  + " "
+                  + baseSubject
+                  + ")\n");
+
+      // Verify the message that has been posted on the change.
+      List<ChangeMessageInfo> messages = gApi.changes().id(changeId).messages();
+      assertThat(messages).hasSize(2);
+      assertThat(Iterables.getLast(messages).message)
+          .isEqualTo(
+              "Patch Set 2: Patch Set 1 was rebased\n\n"
+                  + "The following files contain Git conflicts:\n"
+                  + "* "
+                  + PushOneCommit.FILE_NAME
+                  + "\n");
+    }
+
+    @Test
+    public void rebaseWithConflict_conflictsForbidden() throws Exception {
+      PushOneCommit.Result r1 = createChange();
+      gApi.changes()
+          .id(r1.getChangeId())
+          .revision(r1.getCommit().name())
+          .review(ReviewInput.approve());
+      gApi.changes().id(r1.getChangeId()).revision(r1.getCommit().name()).submit();
+
+      PushOneCommit push =
+          pushFactory.create(
+              admin.newIdent(),
+              testRepo,
+              PushOneCommit.SUBJECT,
+              PushOneCommit.FILE_NAME,
+              "other content",
+              "If09d8782c1e59dd0b33de2b1ec3595d69cc10ad5");
+      PushOneCommit.Result r2 = push.to("refs/for/master");
+      r2.assertOkStatus();
+      ResourceConflictException exception =
+          assertThrows(ResourceConflictException.class, () -> rebaseCall.call(r2.getChangeId()));
+      assertThat(exception)
+          .hasMessageThat()
+          .isEqualTo(
+              String.format(
+                  "Change %s could not be rebased due to a conflict during merge.\n\n"
+                      + "merge conflict(s):\n%s",
+                  r2.getChange().getId(), PushOneCommit.FILE_NAME));
+    }
+
+    @Test
+    public void rebaseFromRelationChainToClosedChange() throws Exception {
+      PushOneCommit.Result r1 = createChange();
+      testRepo.reset("HEAD~1");
+
+      createChange();
+      PushOneCommit.Result r3 = createChange();
+
+      // Submit first change.
+      Change.Id id1 = r1.getChange().getId();
+      gApi.changes().id(id1.get()).current().review(ReviewInput.approve());
+      gApi.changes().id(id1.get()).current().submit();
+
+      // Rebase third change on first change.
+      RebaseInput in = new RebaseInput();
+      in.base = id1.toString();
+      rebaseCallWithInput.call(r3.getChangeId(), in);
+
+      Change.Id id3 = r3.getChange().getId();
+      RevisionInfo ri3 =
+          get(r3.getChangeId(), CURRENT_REVISION, CURRENT_COMMIT).getCurrentRevision();
+      assertThat(ri3.commit.parents.get(0).commit).isEqualTo(r1.getCommit().name());
+
+      assertThat(gApi.changes().id(id3.get()).revision(ri3._number).related().changes).isEmpty();
+    }
+
+    @Test
+    public void testCountRebasesMetric() throws Exception {
+      // Create two changes both with the same parent
+      PushOneCommit.Result r = createChange();
+      testRepo.reset("HEAD~1");
+      PushOneCommit.Result r2 = createChange();
+
+      // Approve and submit the first change
+      RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+      revision.review(ReviewInput.approve());
+      revision.submit();
+
+      // Rebase the second change
+      testMetricMaker.reset();
+      rebaseCallWithInput.call(r2.getChangeId(), new RebaseInput());
+      // field1 is on_behalf_of_uploader, field2 is rebase_chain, field3 is allow_conflicts
+      assertThat(testMetricMaker.getCount("change/count_rebases", false, false, false))
+          .isEqualTo(1);
+      assertThat(testMetricMaker.getCount("change/count_rebases", true, false, false)).isEqualTo(0);
+      assertThat(testMetricMaker.getCount("change/count_rebases", true, true, false)).isEqualTo(0);
+      assertThat(testMetricMaker.getCount("change/count_rebases", false, true, false)).isEqualTo(0);
+    }
+
+    @Test
+    public void rebaseActionEnabledIfChangeCanBeRebased() throws Exception {
+      Change.Id changeToBeTheNewBase = changeOperations.newChange().project(project).create();
+      Change.Id changeToBeRebased = changeOperations.newChange().project(project).create();
+
+      // Change cannot be rebased since its parent commit is the same commit as the HEAD of the
+      // destination branch.
+      RevisionInfo currentRevisionInfo =
+          gApi.changes().id(changeToBeRebased.get()).get().getCurrentRevision();
+      assertThat(currentRevisionInfo.actions).containsKey("rebase");
+      ActionInfo rebaseActionInfo = currentRevisionInfo.actions.get("rebase");
+      assertThat(rebaseActionInfo.enabled).isNull();
+
+      // Approve and submit the change that will be the new base for the chain so that the chain is
+      // rebasable.
+      gApi.changes().id(changeToBeTheNewBase.get()).current().review(ReviewInput.approve());
+      gApi.changes().id(changeToBeTheNewBase.get()).current().submit();
+
+      // Change can be rebased since its parent commit differs from the commit at the HEAD of the
+      // destination branch.
+      currentRevisionInfo = gApi.changes().id(changeToBeRebased.get()).get().getCurrentRevision();
+      assertThat(currentRevisionInfo.actions).containsKey("rebase");
+      rebaseActionInfo = currentRevisionInfo.actions.get("rebase");
+      assertThat(rebaseActionInfo.enabled).isTrue();
+    }
+
+    @Test
+    public void rebaseActionEnabledIfChangeHasAParentChange() throws Exception {
+      Change.Id change1 = changeOperations.newChange().project(project).create();
+      Change.Id change2 =
+          changeOperations.newChange().project(project).childOf().change(change1).create();
+
+      // change1 cannot be rebased since its parent commit is the same commit as the HEAD of the
+      // destination branch.
+      RevisionInfo currentRevisionInfo =
+          gApi.changes().id(change1.get()).get().getCurrentRevision();
+      assertThat(currentRevisionInfo.actions).containsKey("rebase");
+      ActionInfo rebaseActionInfo = currentRevisionInfo.actions.get("rebase");
+      assertThat(rebaseActionInfo.enabled).isNull();
+
+      // change2 can be rebased to break the relation to change1
+      currentRevisionInfo = gApi.changes().id(change2.get()).get().getCurrentRevision();
+      assertThat(currentRevisionInfo.actions).containsKey("rebase");
+      rebaseActionInfo = currentRevisionInfo.actions.get("rebase");
+      assertThat(rebaseActionInfo.enabled).isTrue();
+    }
+  }
+
+  public static class RebaseViaRevisionApi extends Rebase {
+    @Before
+    public void setUp() throws Exception {
+      init(
+          id -> gApi.changes().id(id).current().rebase(),
+          (id, in) -> gApi.changes().id(id).current().rebase(in));
+    }
+
+    @Test
+    public void rebaseOutdatedPatchSet() throws Exception {
+      String fileName1 = "a.txt";
+      String fileContent1 = "some content";
+      String fileName2 = "b.txt";
+      String fileContent2Ps1 = "foo";
+      String fileContent2Ps2 = "foo/bar";
+
+      // Create two changes both with the same parent touching disjunct files
+      PushOneCommit.Result r =
+          pushFactory
+              .create(admin.newIdent(), testRepo, PushOneCommit.SUBJECT, fileName1, fileContent1)
+              .to("refs/for/master");
+      r.assertOkStatus();
+      String changeId1 = r.getChangeId();
+      testRepo.reset("HEAD~1");
+      PushOneCommit push =
+          pushFactory.create(
+              admin.newIdent(), testRepo, PushOneCommit.SUBJECT, fileName2, fileContent2Ps1);
+      PushOneCommit.Result r2 = push.to("refs/for/master");
+      r2.assertOkStatus();
+      String changeId2 = r2.getChangeId();
+
+      // Approve and submit the first change
+      RevisionApi revision = gApi.changes().id(changeId1).current();
+      revision.review(ReviewInput.approve());
+      revision.submit();
+
+      // Amend the second change so that it has 2 patch sets
+      amendChange(
+              changeId2,
+              "refs/for/master",
+              admin,
+              testRepo,
+              PushOneCommit.SUBJECT,
+              fileName2,
+              fileContent2Ps2)
+          .assertOkStatus();
+      assertThat(gApi.changes().id(changeId2).get().getCurrentRevision()._number).isEqualTo(2);
+
+      // Rebase the first patch set of the second change
+      gApi.changes().id(changeId2).revision(1).rebase();
+
+      // Second change should have 3 patch sets
+      assertThat(gApi.changes().id(changeId2).get().getCurrentRevision()._number).isEqualTo(3);
+
+      // ... and the committer and description should be correct
+      ChangeInfo info = gApi.changes().id(changeId2).get(CURRENT_REVISION, CURRENT_COMMIT);
+      GitPerson committer = info.getCurrentRevision().commit.committer;
+      assertThat(committer.name).isEqualTo(admin.fullName());
+      assertThat(committer.email).isEqualTo(admin.email());
+      String description = info.getCurrentRevision().description;
+      assertThat(description).isEqualTo("Rebase");
+
+      // ... and the file contents should match with patch set 1 based on change1
+      assertThat(gApi.changes().id(changeId2).current().file(fileName1).content().asString())
+          .isEqualTo(fileContent1);
+      assertThat(gApi.changes().id(changeId2).current().file(fileName2).content().asString())
+          .isEqualTo(fileContent2Ps1);
+    }
+  }
+
+  public static class RebaseViaChangeApi extends Rebase {
+    @Before
+    public void setUp() throws Exception {
+      init(id -> gApi.changes().id(id).rebase(), (id, in) -> gApi.changes().id(id).rebase(in));
+    }
+  }
+
+  public static class RebaseChain extends Base {
+    @Before
+    public void setUp() throws Exception {
+      init(
+          id -> {
+            @SuppressWarnings("unused")
+            Object unused = gApi.changes().id(id).rebaseChain();
+          },
+          (id, in) -> {
+            @SuppressWarnings("unused")
+            Object unused = gApi.changes().id(id).rebaseChain(in);
+          });
+    }
+
+    @Override
+    protected void verifyChangeIsUpToDate(PushOneCommit.Result r) {
+      ResourceConflictException thrown =
+          assertThrows(ResourceConflictException.class, () -> rebaseCall.call(r.getChangeId()));
+      assertThat(thrown).hasMessageThat().contains("The whole chain is already up to date.");
+    }
+
+    @Test
+    public void rebaseChain() throws Exception {
+      // Create changes with the following hierarchy:
+      // * HEAD
+      //   * r (merged)
+      //   * r2
+      //     * r3
+      //       * r4
+      //         *r5
+      PushOneCommit.Result r = createChange();
+      testRepo.reset("HEAD~1");
+      PushOneCommit.Result r2 = createChange();
+      PushOneCommit.Result r3 = createChange();
+      PushOneCommit.Result r4 = createChange();
+      PushOneCommit.Result r5 = createChange();
+
+      // Approve and submit the first change
+      RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+      revision.review(ReviewInput.approve());
+      revision.submit();
+
+      // Add an approval whose score should be copied on trivial rebase
+      gApi.changes().id(r2.getChangeId()).current().review(ReviewInput.recommend());
+      gApi.changes().id(r3.getChangeId()).current().review(ReviewInput.recommend());
+
+      // Rebase the chain through r4.
+      verifyRebaseChainResponse(
+          gApi.changes().id(r4.getChangeId()).rebaseChain(), false, r2, r3, r4);
+
+      // Only r2, r3 and r4 are rebased.
+      verifyRebaseForChange(r2.getChange().getId(), r.getCommit().name(), true, 2);
+      verifyRebaseForChange(r3.getChange().getId(), r2.getChange().getId(), true);
+      verifyRebaseForChange(r4.getChange().getId(), r3.getChange().getId(), false);
+
+      verifyChangeIsUpToDate(r2);
+      verifyChangeIsUpToDate(r3);
+      verifyChangeIsUpToDate(r4);
+
+      // r5 wasn't rebased.
+      assertThat(
+              gApi.changes()
+                  .id(r5.getChangeId())
+                  .get(CURRENT_REVISION)
+                  .getCurrentRevision()
+                  ._number)
+          .isEqualTo(1);
+
+      // Rebasing r5
+      verifyRebaseChainResponse(
+          gApi.changes().id(r5.getChangeId()).rebaseChain(), false, r2, r3, r4, r5);
+
+      verifyRebaseForChange(r5.getChange().getId(), r4.getChange().getId(), false);
+    }
+
+    @Test
+    public void rebasePartlyOutdatedChain() throws Exception {
+      final String file = "modified_file.txt";
+      final String oldContent = "old content";
+      final String newContent = "new content";
+      // Create changes with the following revision hierarchy:
+      // * HEAD
+      //   * r (merged)
+      //   * r2
+      //     * r3/1    r3/2
+      //       * r4
+      PushOneCommit.Result r = createChange();
+      testRepo.reset("HEAD~1");
+      PushOneCommit.Result r2 = createChange();
+      PushOneCommit.Result r3 = createChange("original patch-set", file, oldContent);
+      PushOneCommit.Result r4 = createChange();
+      gApi.changes()
+          .id(r3.getChangeId())
+          .edit()
+          .modifyFile(file, RawInputUtil.create(newContent.getBytes(UTF_8)));
+      gApi.changes().id(r3.getChangeId()).edit().publish();
+
+      // Approve and submit the first change
+      RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+      revision.review(ReviewInput.approve());
+      revision.submit();
+
+      // Rebase the chain through r4.
+      rebaseCall.call(r4.getChangeId());
+
+      verifyRebaseForChange(r2.getChange().getId(), r.getCommit().name(), false, 2);
+      verifyRebaseForChange(r3.getChange().getId(), r2.getChange().getId(), false, 3);
+      verifyRebaseForChange(r4.getChange().getId(), r3.getChange().getId(), false);
+
+      assertThat(gApi.changes().id(r3.getChangeId()).current().file(file).content().asString())
+          .isEqualTo(newContent);
+
+      verifyChangeIsUpToDate(r2);
+      verifyChangeIsUpToDate(r3);
+      verifyChangeIsUpToDate(r4);
+    }
+
+    @Test
+    public void rebaseChainWithMergedAncestor() throws Exception {
+      final String file = "modified_file.txt";
+      final String newContent = "new content";
+
+      // Create changes with the following hierarchy:
+      // * HEAD
+      //   * r (merged)
+      //   * r2.1         r2.2 (merged)
+      //     * r3
+      //       * r4
+      //         *r5
+      PushOneCommit.Result r = createChange();
+      PushOneCommit.Result r2 = createChange();
+      PushOneCommit.Result r3 = createChange();
+      PushOneCommit.Result r4 = createChange();
+      PushOneCommit.Result r5 = createChange();
+
+      // Approve and submit the first change
+      RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+      revision.review(ReviewInput.approve());
+      revision.submit();
+      testRepo.reset("HEAD~1");
+
+      // Create r2.2
+      gApi.changes()
+          .id(r2.getChangeId())
+          .edit()
+          .modifyFile(file, RawInputUtil.create(newContent.getBytes(UTF_8)));
+      gApi.changes().id(r2.getChangeId()).edit().publish();
+      // Approve and submit r2.2
+      revision = gApi.changes().id(r2.getChangeId()).current();
+      revision.review(ReviewInput.approve());
+      revision.submit();
+
+      // Add an approval whose score should be copied on trivial rebase
+      gApi.changes().id(r3.getChangeId()).current().review(ReviewInput.recommend());
+
+      // Rebase the chain through r4.
+      verifyRebaseChainResponse(gApi.changes().id(r4.getChangeId()).rebaseChain(), false, r3, r4);
+
+      // Only r3 and r4 are rebased.
+      verifyRebaseForChange(r3.getChange().getId(), r2.getChange().getId(), true);
+      verifyRebaseForChange(r4.getChange().getId(), r3.getChange().getId(), false);
+
+      verifyChangeIsUpToDate(r2);
+      verifyChangeIsUpToDate(r3);
+      verifyChangeIsUpToDate(r4);
+
+      // r5 wasn't rebased.
+      assertThat(
+              gApi.changes()
+                  .id(r5.getChangeId())
+                  .get(CURRENT_REVISION)
+                  .getCurrentRevision()
+                  ._number)
+          .isEqualTo(1);
+
+      // Rebasing r5
+      verifyRebaseChainResponse(
+          gApi.changes().id(r5.getChangeId()).rebaseChain(), false, r3, r4, r5);
+
+      verifyRebaseForChange(r5.getChange().getId(), r4.getChange().getId(), false);
+    }
+
+    @Test
+    public void rebaseChainWithConflicts_conflictsForbidden() throws Exception {
+      PushOneCommit.Result r1 = createChange();
+      gApi.changes()
+          .id(r1.getChangeId())
+          .revision(r1.getCommit().name())
+          .review(ReviewInput.approve());
+      gApi.changes().id(r1.getChangeId()).revision(r1.getCommit().name()).submit();
+
+      PushOneCommit push =
+          pushFactory.create(
+              admin.newIdent(),
+              testRepo,
+              PushOneCommit.SUBJECT,
+              PushOneCommit.FILE_NAME,
+              "other content",
+              "I0020020020020020020020020020020020020002");
+      PushOneCommit.Result r2 = push.to("refs/for/master");
+      r2.assertOkStatus();
+      PushOneCommit.Result r3 = createChange("refs/for/master");
+      r3.assertOkStatus();
+      ResourceConflictException exception =
+          assertThrows(
+              ResourceConflictException.class,
+              () -> gApi.changes().id(r3.getChangeId()).rebaseChain());
+      assertThat(exception)
+          .hasMessageThat()
+          .isEqualTo(
+              String.format(
+                  "Change %s could not be rebased due to a conflict during merge.\n\n"
+                      + "merge conflict(s):\n%s",
+                  r2.getChange().getId(), PushOneCommit.FILE_NAME));
+    }
+
+    @Test
+    public void rebaseChainWithConflicts_conflictsAllowed() throws Exception {
+      String patchSetSubject = "patch set change";
+      String patchSetContent = "patch set content";
+      String baseSubject = "base change";
+      String baseContent = "base content";
+
+      PushOneCommit.Result r1 = createChange(baseSubject, PushOneCommit.FILE_NAME, baseContent);
+      gApi.changes()
+          .id(r1.getChangeId())
+          .revision(r1.getCommit().name())
+          .review(ReviewInput.approve());
+      gApi.changes().id(r1.getChangeId()).revision(r1.getCommit().name()).submit();
+
+      testRepo.reset("HEAD~1");
+      PushOneCommit push =
+          pushFactory.create(
+              admin.newIdent(),
+              testRepo,
+              patchSetSubject,
+              PushOneCommit.FILE_NAME,
+              patchSetContent);
+      PushOneCommit.Result r2 = push.to("refs/for/master");
+      r2.assertOkStatus();
+
+      String changeWithConflictId = r2.getChangeId();
+      RevCommit patchSet = r2.getCommit();
+      RevCommit base = r1.getCommit();
+      PushOneCommit.Result r3 = createChange("refs/for/master");
+      r3.assertOkStatus();
+
+      TestWorkInProgressStateChangedListener wipStateChangedListener =
+          new TestWorkInProgressStateChangedListener();
+      try (ExtensionRegistry.Registration registration =
+          extensionRegistry.newRegistration().add(wipStateChangedListener)) {
+        RebaseInput rebaseInput = new RebaseInput();
+        rebaseInput.allowConflicts = true;
+        Response<RebaseChainInfo> res =
+            gApi.changes().id(r3.getChangeId()).rebaseChain(rebaseInput);
+        verifyRebaseChainResponse(res, true, r2, r3);
+        RebaseChainInfo rebaseChainInfo = res.value();
+        ChangeInfo changeWithConflictInfo = rebaseChainInfo.rebasedChanges.get(0);
+        assertThat(changeWithConflictInfo.changeId).isEqualTo(r2.getChangeId());
+        assertThat(changeWithConflictInfo.containsGitConflicts).isTrue();
+        assertThat(changeWithConflictInfo.workInProgress).isTrue();
+        ChangeInfo childChangeInfo = rebaseChainInfo.rebasedChanges.get(1);
+        assertThat(childChangeInfo.changeId).isEqualTo(r3.getChangeId());
+        assertThat(childChangeInfo.containsGitConflicts).isTrue();
+        assertThat(childChangeInfo.workInProgress).isTrue();
+      }
+      assertThat(wipStateChangedListener.invoked).isTrue();
+      assertThat(wipStateChangedListener.wip).isTrue();
+
+      // To get the revisions, we must retrieve the change with more change options.
+      ChangeInfo changeInfo =
+          gApi.changes()
+              .id(changeWithConflictId)
+              .get(ALL_REVISIONS, CURRENT_COMMIT, CURRENT_REVISION);
+      assertThat(changeInfo.revisions).hasSize(2);
+      assertThat(changeInfo.getCurrentRevision().commit.parents.get(0).commit)
+          .isEqualTo(base.name());
+
+      // Verify that the file content in the created patch set is correct.
+      // We expect that it has conflict markers to indicate the conflict.
+      BinaryResult bin =
+          gApi.changes().id(changeWithConflictId).current().file(PushOneCommit.FILE_NAME).content();
+      ByteArrayOutputStream os = new ByteArrayOutputStream();
+      bin.writeTo(os);
+      String fileContent = new String(os.toByteArray(), UTF_8);
+      String patchSetSha1 = abbreviateName(patchSet, 6);
+      String baseSha1 = abbreviateName(base, 6);
+      assertThat(fileContent)
+          .isEqualTo(
+              "<<<<<<< PATCH SET ("
+                  + patchSetSha1
+                  + " "
+                  + patchSetSubject
+                  + ")\n"
+                  + patchSetContent
+                  + "\n"
+                  + "=======\n"
+                  + baseContent
+                  + "\n"
+                  + ">>>>>>> BASE      ("
+                  + baseSha1
+                  + " "
+                  + baseSubject
+                  + ")\n");
+
+      // Verify the message that has been posted on the change.
+      List<ChangeMessageInfo> messages = gApi.changes().id(changeWithConflictId).messages();
+      assertThat(messages).hasSize(2);
+      assertThat(Iterables.getLast(messages).message)
+          .isEqualTo(
+              "Patch Set 2: Patch Set 1 was rebased\n\n"
+                  + "The following files contain Git conflicts:\n"
+                  + "* "
+                  + PushOneCommit.FILE_NAME
+                  + "\n");
+    }
+
+    @Test
+    public void rebaseOntoMidChain() throws Exception {
+      // Create changes with the following hierarchy:
+      // * HEAD
+      //   * r1
+      //   * r2
+      //     * r3
+      //       * r4
+      PushOneCommit.Result r = createChange();
+      r.assertOkStatus();
+      testRepo.reset("HEAD~1");
+      PushOneCommit.Result r2 = createChange();
+      r2.assertOkStatus();
+      PushOneCommit.Result r3 = createChange();
+      r3.assertOkStatus();
+      PushOneCommit.Result r4 = createChange();
+
+      RebaseInput ri = new RebaseInput();
+      ri.base = r3.getCommit().name();
+      ResourceConflictException thrown =
+          assertThrows(
+              ResourceConflictException.class,
+              () -> rebaseCallWithInput.call(r4.getChangeId(), ri));
+      assertThat(thrown).hasMessageThat().contains("recursion not allowed");
+    }
+
+    @Test
+    public void rebaseChainActionEnabled() throws Exception {
+      Change.Id changeToBeTheNewBase = changeOperations.newChange().project(project).create();
+
+      Change.Id changeToBeRebased1 = changeOperations.newChange().project(project).create();
+      Change.Id changeToBeRebased2 =
+          changeOperations
+              .newChange()
+              .project(project)
+              .childOf()
+              .change(changeToBeRebased1)
+              .create();
+
+      // Approve and submit the change that will be the new base for the chain so that the chain is
+      // rebasable.
+      gApi.changes().id(changeToBeTheNewBase.get()).current().review(ReviewInput.approve());
+      gApi.changes().id(changeToBeTheNewBase.get()).current().submit();
+
+      ChangeInfo changeInfo = gApi.changes().id(changeToBeRebased2.get()).get();
+      assertThat(changeInfo.actions).containsKey("rebase:chain");
+      ActionInfo rebaseActionInfo = changeInfo.actions.get("rebase:chain");
+      assertThat(rebaseActionInfo.enabled).isTrue();
+      assertThat(rebaseActionInfo.enabledOptions)
+          .containsExactly("rebase", "rebase_on_behalf_of_uploader");
+    }
+
+    @Test
+    public void rebaseChainWhenChecksRefExists() throws Exception {
+      // Create changes with the following hierarchy:
+      // * HEAD
+      //   * r1
+      //   * r2
+      //     * r3
+      PushOneCommit.Result r = createChange();
+      testRepo.reset("HEAD~1");
+      PushOneCommit.Result r2 = createChange();
+      PushOneCommit.Result r3 = createChange();
+
+      // Create checks ref
+      try (TestRepository<Repository> testRepo =
+          new TestRepository<>(repoManager.openRepository(project))) {
+        testRepo.update(
+            RefNames.changeRefPrefix(r2.getChange().getId()) + "checks",
+            testRepo.commit().message("Empty commit"));
+      }
+
+      // Approve and submit the first change
+      RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+      revision.review(ReviewInput.approve());
+      revision.submit();
+
+      // Add an approval whose score should be copied on trivial rebase
+      gApi.changes().id(r2.getChangeId()).current().review(ReviewInput.recommend());
+      gApi.changes().id(r3.getChangeId()).current().review(ReviewInput.recommend());
+
+      // Rebase the chain through r3.
+      verifyRebaseChainResponse(gApi.changes().id(r3.getChangeId()).rebaseChain(), false, r2, r3);
+    }
+
+    @Test
+    public void testCountRebasesMetric() throws Exception {
+      // Create changes with the following hierarchy:
+      // * HEAD
+      //   * r1
+      //   * r2
+      //     * r3
+      //       * r4
+      PushOneCommit.Result r = createChange();
+      testRepo.reset("HEAD~1");
+      PushOneCommit.Result r2 = createChange();
+      PushOneCommit.Result r3 = createChange();
+      PushOneCommit.Result r4 = createChange();
+
+      // Approve and submit the first change
+      RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+      revision.review(ReviewInput.approve());
+      revision.submit();
+
+      // Rebase the chain.
+      testMetricMaker.reset();
+      verifyRebaseChainResponse(
+          gApi.changes().id(r4.getChangeId()).rebaseChain(), false, r2, r3, r4);
+      // field1 is on_behalf_of_uploader, field2 is rebase_chain, field3 is allow_conflicts
+      assertThat(testMetricMaker.getCount("change/count_rebases", false, true, false)).isEqualTo(1);
+      assertThat(testMetricMaker.getCount("change/count_rebases", false, false, false))
+          .isEqualTo(0);
+      assertThat(testMetricMaker.getCount("change/count_rebases", true, true, false)).isEqualTo(0);
+      assertThat(testMetricMaker.getCount("change/count_rebases", true, false, false)).isEqualTo(0);
+    }
+
+    private void verifyRebaseChainResponse(
+        Response<RebaseChainInfo> res,
+        boolean shouldHaveConflicts,
+        PushOneCommit.Result... changes) {
+      assertThat(res.statusCode()).isEqualTo(200);
+      RebaseChainInfo info = res.value();
+      assertThat(info.rebasedChanges.stream().map(c -> c._number).collect(Collectors.toList()))
+          .containsExactlyElementsIn(
+              Arrays.stream(changes)
+                  .map(c -> c.getChange().getId().get())
+                  .collect(Collectors.toList()))
+          .inOrder();
+      assertThat(info.containsGitConflicts).isEqualTo(shouldHaveConflicts ? true : null);
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/change/RebaseOnBehalfOfUploaderIT.java b/javatests/com/google/gerrit/acceptance/api/change/RebaseOnBehalfOfUploaderIT.java
new file mode 100644
index 0000000..96e3e8e
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/change/RebaseOnBehalfOfUploaderIT.java
@@ -0,0 +1,1229 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.api.change;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth8.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_REAL_USER;
+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.Iterables;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.ExtensionRegistry;
+import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
+import com.google.gerrit.acceptance.TestMetricMaker;
+import com.google.gerrit.acceptance.UseLocalDisk;
+import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
+import com.google.gerrit.acceptance.testsuite.change.ChangeOperations;
+import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementExpression;
+import com.google.gerrit.extensions.api.changes.RebaseInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.common.ActionInfo;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.ChangeMessageInfo;
+import com.google.gerrit.extensions.common.RevisionInfo;
+import com.google.gerrit.extensions.events.RevisionCreatedListener;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.notedb.ChangeNoteUtil;
+import com.google.gerrit.server.project.testing.TestLabels;
+import com.google.gerrit.server.util.AccountTemplateUtil;
+import com.google.inject.Inject;
+import java.util.Collection;
+import java.util.Optional;
+import org.eclipse.jgit.lib.ReflogEntry;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.FooterLine;
+import org.junit.Test;
+
+/**
+ * Tests for the {@link com.google.gerrit.server.restapi.change.Rebase} REST endpoint with the
+ * {@link RebaseInput#onBehalfOfUploader} option being set.
+ *
+ * <p>Rebasing a chain on behalf of the uploader is covered by {@link
+ * RebaseChainOnBehalfOfUploaderIT}.
+ */
+public class RebaseOnBehalfOfUploaderIT extends AbstractDaemonTest {
+  @Inject private AccountOperations accountOperations;
+  @Inject private ChangeOperations changeOperations;
+  @Inject private GroupOperations groupOperations;
+  @Inject private ProjectOperations projectOperations;
+  @Inject private RequestScopeOperations requestScopeOperations;
+  @Inject private ExtensionRegistry extensionRegistry;
+  @Inject private TestMetricMaker testMetricMaker;
+
+  @Test
+  public void cannotRebaseOnBehalfOfUploaderWithAllowConflicts() throws Exception {
+    Account.Id uploader = accountOperations.newAccount().create();
+    Change.Id changeId = changeOperations.newChange().owner(uploader).create();
+    RebaseInput rebaseInput = new RebaseInput();
+    rebaseInput.onBehalfOfUploader = true;
+    rebaseInput.allowConflicts = true;
+    BadRequestException exception =
+        assertThrows(
+            BadRequestException.class, () -> gApi.changes().id(changeId.get()).rebase(rebaseInput));
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo("allow_conflicts and on_behalf_of_uploader are mutually exclusive");
+  }
+
+  @Test
+  public void cannotRebaseNonCurrentPatchSetOnBehalfOfUploader() throws Exception {
+    Account.Id uploader = accountOperations.newAccount().create();
+    Change.Id changeId = changeOperations.newChange().owner(uploader).create();
+    changeOperations.change(changeId).newPatchset().create();
+    RebaseInput rebaseInput = new RebaseInput();
+    rebaseInput.onBehalfOfUploader = true;
+    BadRequestException exception =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.changes().id(changeId.get()).revision(1).rebase(rebaseInput));
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo(
+            String.format(
+                "change %s: non-current patch set cannot be rebased on behalf of the uploader",
+                changeId));
+  }
+
+  @Test
+  public void rebaseChangeOnBehalfOfUploader_withRebasePermission() throws Exception {
+    testRebaseChangeOnBehalfOfUploader(
+        Permission.REBASE,
+        (changeId, rebaseInput) -> gApi.changes().id(changeId.get()).rebase(rebaseInput));
+  }
+
+  @Test
+  public void rebaseCurrentPatchSetOnBehalfOfUploader_withRebasePermission() throws Exception {
+    testRebaseChangeOnBehalfOfUploader(
+        Permission.REBASE,
+        (changeId, rebaseInput) -> gApi.changes().id(changeId.get()).current().rebase(rebaseInput));
+  }
+
+  @Test
+  public void rebaseChangeOnBehalfOfUploader_withSubmitPermission() throws Exception {
+    testRebaseChangeOnBehalfOfUploader(
+        Permission.SUBMIT,
+        (changeId, rebaseInput) -> gApi.changes().id(changeId.get()).rebase(rebaseInput));
+  }
+
+  @Test
+  public void rebaseCurrentPatchSetOnBehalfOfUploader_withSubmitPermission() throws Exception {
+    testRebaseChangeOnBehalfOfUploader(
+        Permission.SUBMIT,
+        (changeId, rebaseInput) -> gApi.changes().id(changeId.get()).current().rebase(rebaseInput));
+  }
+
+  private void testRebaseChangeOnBehalfOfUploader(String permissionToAllow, RebaseCall rebaseCall)
+      throws Exception {
+    String uploaderEmail = "uploader@example.com";
+    Account.Id uploader = accountOperations.newAccount().preferredEmail(uploaderEmail).create();
+    Account.Id changeOwner = accountOperations.newAccount().create();
+    Account.Id approver = admin.id();
+    Account.Id rebaser = accountOperations.newAccount().create();
+
+    // Grant permission to rebaser that is required to rebase on behalf of the uploader.
+    AccountGroup.UUID allowedGroup =
+        groupOperations.newGroup().name("can-" + permissionToAllow).addMember(rebaser).create();
+    allowPermission(permissionToAllow, allowedGroup);
+
+    // Block rebase and submit permission for uploader. For rebase on behalf of the uploader only
+    // the rebaser needs to have this permission, but not the uploader on whom's behalf the rebase
+    // is done.
+    AccountGroup.UUID cannotRebaseAndSubmitGroup =
+        groupOperations.newGroup().name("cannot-rebase").addMember(uploader).create();
+    blockPermission(Permission.REBASE, cannotRebaseAndSubmitGroup);
+    blockPermission(Permission.SUBMIT, cannotRebaseAndSubmitGroup);
+
+    // Block push permission for rebaser, as in contrast to rebase, rebase on behalf of the uploader
+    // doesn't require the rebaser to have the push permission.
+    AccountGroup.UUID cannotUploadGroup =
+        groupOperations.newGroup().name("cannot-upload").addMember(rebaser).create();
+    blockPermission(Permission.PUSH, cannotUploadGroup);
+
+    // Create two changes both with the same parent
+    requestScopeOperations.setApiUser(changeOwner);
+    Change.Id changeToBeTheNewBase =
+        changeOperations.newChange().project(project).owner(changeOwner).create();
+    Change.Id changeToBeRebased =
+        changeOperations.newChange().project(project).owner(changeOwner).create();
+
+    // Create a second patch set for the change that will be rebased so that the uploader is
+    // different to the change owner. This is to verify that being change owner doesn't matter for
+    // the user on whom's behalf the rebase is done.
+    // Set author and committer to the uploader so that rebasing on behalf of the uploader doesn't
+    // require the Forge Author and Forge Committer permission.
+    changeOperations
+        .change(changeToBeRebased)
+        .newPatchset()
+        .uploader(uploader)
+        .author(uploader)
+        .committer(uploader)
+        .create();
+
+    // Approve and submit the change that will be the new base for the change that will be rebased.
+    requestScopeOperations.setApiUser(approver);
+    gApi.changes().id(changeToBeTheNewBase.get()).current().review(ReviewInput.approve());
+    gApi.changes().id(changeToBeTheNewBase.get()).current().submit();
+
+    // Rebase the second change on behalf of the uploader
+    requestScopeOperations.setApiUser(rebaser);
+    RebaseInput rebaseInput = new RebaseInput();
+    rebaseInput.onBehalfOfUploader = true;
+
+    TestRevisionCreatedListener testRevisionCreatedListener = new TestRevisionCreatedListener();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(testRevisionCreatedListener)) {
+      rebaseCall.call(changeToBeRebased, rebaseInput);
+
+      assertThat(testRevisionCreatedListener.revisionInfo.uploader._accountId)
+          .isEqualTo(uploader.get());
+      assertThat(testRevisionCreatedListener.revisionInfo.realUploader._accountId)
+          .isEqualTo(rebaser.get());
+    }
+
+    ChangeInfo changeInfo2 = gApi.changes().id(changeToBeRebased.get()).get();
+    RevisionInfo currentRevisionInfo = changeInfo2.getCurrentRevision();
+    // The change had 2 patch sets before the rebase, now it should be 3
+    assertThat(currentRevisionInfo._number).isEqualTo(3);
+    assertThat(currentRevisionInfo.uploader._accountId).isEqualTo(uploader.get());
+    assertThat(currentRevisionInfo.realUploader._accountId).isEqualTo(rebaser.get());
+    assertThat(currentRevisionInfo.commit.author.email).isEqualTo(uploaderEmail);
+    assertThat(currentRevisionInfo.commit.committer.email).isEqualTo(uploaderEmail);
+
+    // Verify that the rebaser was recorded as realUser in NoteDb.
+    Optional<FooterLine> realUserFooter =
+        projectOperations.project(project).getHead(RefNames.changeMetaRef(changeToBeRebased))
+            .getFooterLines().stream()
+            .filter(footerLine -> footerLine.matches(FOOTER_REAL_USER))
+            .findFirst();
+    assertThat(realUserFooter.map(FooterLine::getValue))
+        .hasValue(
+            String.format(
+                "%s <%s>",
+                ChangeNoteUtil.getAccountIdAsUsername(rebaser),
+                changeNoteUtil.getAccountIdAsEmailAddress(rebaser)));
+
+    // Verify the message that has been posted on the change.
+    Collection<ChangeMessageInfo> changeMessages = changeInfo2.messages;
+    // Before the rebase the change had 2 messages for the upload of the 2 patch sets. Rebase is
+    // expected to add another message.
+    assertThat(changeMessages).hasSize(3);
+    ChangeMessageInfo changeMessage = Iterables.getLast(changeMessages);
+    assertThat(changeMessage.message)
+        .isEqualTo(
+            "Patch Set 3: Patch Set 2 was rebased on behalf of "
+                + AccountTemplateUtil.getAccountTemplate(uploader));
+    assertThat(changeMessage.author._accountId).isEqualTo(uploader.get());
+    assertThat(changeMessage.realAuthor._accountId).isEqualTo(rebaser.get());
+  }
+
+  @Test
+  public void rebaseChangeOnBehalfOfUploaderMultipleTimesInARow() throws Exception {
+    allowPermissionToAllUsers(Permission.REBASE);
+
+    String uploaderEmail = "uploader@example.com";
+    Account.Id uploader = accountOperations.newAccount().preferredEmail(uploaderEmail).create();
+    Account.Id approver = admin.id();
+    Account.Id rebaser = accountOperations.newAccount().create();
+
+    // Create two changes both with the same parent.
+    requestScopeOperations.setApiUser(uploader);
+    Change.Id changeToBeTheNewBase =
+        changeOperations.newChange().project(project).owner(uploader).create();
+    Change.Id changeToBeRebased =
+        changeOperations.newChange().project(project).owner(uploader).create();
+
+    // Approve and submit the change that will be the new base for the change that will be rebased.
+    requestScopeOperations.setApiUser(approver);
+    gApi.changes().id(changeToBeTheNewBase.get()).current().review(ReviewInput.approve());
+    gApi.changes().id(changeToBeTheNewBase.get()).current().submit();
+
+    // Rebase the second change on behalf of the uploader
+    requestScopeOperations.setApiUser(rebaser);
+    RebaseInput rebaseInput = new RebaseInput();
+    rebaseInput.onBehalfOfUploader = true;
+    gApi.changes().id(changeToBeRebased.get()).rebase(rebaseInput);
+
+    ChangeInfo changeInfo2 = gApi.changes().id(changeToBeRebased.get()).get();
+    RevisionInfo currentRevisionInfo = changeInfo2.getCurrentRevision();
+    // The change had 1 patch set before the rebase, now it should be 2
+    assertThat(currentRevisionInfo._number).isEqualTo(2);
+    assertThat(currentRevisionInfo.commit.committer.email).isEqualTo(uploaderEmail);
+    assertThat(currentRevisionInfo.uploader._accountId).isEqualTo(uploader.get());
+    assertThat(currentRevisionInfo.realUploader._accountId).isEqualTo(rebaser.get());
+
+    // Create and submit another change so that we can rebase the change once again.
+    requestScopeOperations.setApiUser(approver);
+    Change.Id changeToBeTheNewBase2 =
+        changeOperations.newChange().project(project).owner(uploader).create();
+    gApi.changes().id(changeToBeTheNewBase2.get()).current().review(ReviewInput.approve());
+    gApi.changes().id(changeToBeTheNewBase2.get()).current().submit();
+
+    // Rebase the change once again on behalf of the uploader.
+    requestScopeOperations.setApiUser(rebaser);
+    gApi.changes().id(changeToBeRebased.get()).rebase(rebaseInput);
+
+    changeInfo2 = gApi.changes().id(changeToBeRebased.get()).get();
+    currentRevisionInfo = changeInfo2.getCurrentRevision();
+    // The change had 2 patch sets before the rebase, now it should be 3
+    assertThat(currentRevisionInfo._number).isEqualTo(3);
+    assertThat(currentRevisionInfo.commit.committer.email).isEqualTo(uploaderEmail);
+    assertThat(currentRevisionInfo.uploader._accountId).isEqualTo(uploader.get());
+    assertThat(currentRevisionInfo.realUploader._accountId).isEqualTo(rebaser.get());
+
+    // Create and submit another change so that we can rebase the change once again.
+    requestScopeOperations.setApiUser(approver);
+    Change.Id changeToBeTheNewBase3 =
+        changeOperations.newChange().project(project).owner(uploader).create();
+    gApi.changes().id(changeToBeTheNewBase3.get()).current().review(ReviewInput.approve());
+    gApi.changes().id(changeToBeTheNewBase3.get()).current().submit();
+
+    // Rebase the change once again on behalf of the uploader, this time by another rebaser.
+    Account.Id rebaser2 = accountOperations.newAccount().create();
+    requestScopeOperations.setApiUser(rebaser2);
+    gApi.changes().id(changeToBeRebased.get()).rebase(rebaseInput);
+
+    changeInfo2 = gApi.changes().id(changeToBeRebased.get()).get();
+    currentRevisionInfo = changeInfo2.getCurrentRevision();
+    // The change had 3 patch sets before the rebase, now it should be 4
+    assertThat(currentRevisionInfo._number).isEqualTo(4);
+    assertThat(currentRevisionInfo.commit.committer.email).isEqualTo(uploaderEmail);
+    assertThat(currentRevisionInfo.uploader._accountId).isEqualTo(uploader.get());
+    assertThat(currentRevisionInfo.realUploader._accountId).isEqualTo(rebaser2.get());
+  }
+
+  @Test
+  public void nonChangeOwnerWithoutSubmitAndRebasePermissionCannotRebaseOnBehalfOfUploader()
+      throws Exception {
+    Change.Id changeId = changeOperations.newChange().project(project).create();
+
+    blockPermissionForAllUsers(Permission.REBASE);
+    blockPermissionForAllUsers(Permission.SUBMIT);
+
+    Account.Id rebaserId = accountOperations.newAccount().create();
+    requestScopeOperations.setApiUser(rebaserId);
+    RebaseInput rebaseInput = new RebaseInput();
+    rebaseInput.onBehalfOfUploader = true;
+    AuthException exception =
+        assertThrows(
+            AuthException.class, () -> gApi.changes().id(changeId.get()).rebase(rebaseInput));
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo(
+            "rebase on behalf of uploader not permitted (change owners and users with the 'Submit'"
+                + " or 'Rebase' permission can rebase on behalf of the uploader)");
+  }
+
+  @Test
+  public void cannotRebaseChangeOnBehalfOfUploaderIfTheUploaderHasNoReadPermission()
+      throws Exception {
+    String uploaderEmail = "uploader@example.com";
+    testCannotRebaseChangeOnBehalfOfUploaderIfTheUploaderHasNoPermission(
+        uploaderEmail,
+        Permission.READ,
+        String.format("uploader %s cannot read change", uploaderEmail));
+  }
+
+  @Test
+  public void cannotRebaseChangeOnBehalfOfUploaderIfTheUploaderHasNoPushPermission()
+      throws Exception {
+    String uploaderEmail = "uploader@example.com";
+    testCannotRebaseChangeOnBehalfOfUploaderIfTheUploaderHasNoPermission(
+        uploaderEmail,
+        Permission.PUSH,
+        String.format("uploader %s cannot add patch set", uploaderEmail));
+  }
+
+  private void testCannotRebaseChangeOnBehalfOfUploaderIfTheUploaderHasNoPermission(
+      String uploaderEmail, String permissionToBlock, String expectedErrorMessage)
+      throws Exception {
+    allowPermissionToAllUsers(Permission.REBASE);
+
+    Account.Id uploader = accountOperations.newAccount().preferredEmail(uploaderEmail).create();
+    Account.Id approver = admin.id();
+    Account.Id rebaser = accountOperations.newAccount().create();
+
+    // Create two changes both with the same parent
+    requestScopeOperations.setApiUser(uploader);
+    Change.Id changeToBeTheNewBase =
+        changeOperations.newChange().project(project).owner(uploader).create();
+    Change.Id changeToBeRebased =
+        changeOperations.newChange().project(project).owner(uploader).create();
+
+    // Approve and submit the change that will be the new base for the change that will be rebased.
+    requestScopeOperations.setApiUser(approver);
+    gApi.changes().id(changeToBeTheNewBase.get()).current().review(ReviewInput.approve());
+    gApi.changes().id(changeToBeTheNewBase.get()).current().submit();
+
+    // Block the required permission for uploader. Without this permission it should not be possible
+    // to rebase the change on behalf of the uploader.
+    AccountGroup.UUID blockedGroup =
+        groupOperations.newGroup().name("cannot-" + permissionToBlock).addMember(uploader).create();
+    blockPermission(permissionToBlock, blockedGroup);
+
+    // Try to rebase the second change on behalf of the uploader
+    requestScopeOperations.setApiUser(rebaser);
+    RebaseInput rebaseInput = new RebaseInput();
+    rebaseInput.onBehalfOfUploader = true;
+    ResourceConflictException exception =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.changes().id(changeToBeRebased.get()).rebase(rebaseInput));
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo(String.format("change %s: %s", changeToBeRebased, expectedErrorMessage));
+  }
+
+  @Test
+  public void rebaseChangeOnBehalfOfYourself() throws Exception {
+    allowPermissionToAllUsers(Permission.REBASE);
+
+    Account.Id uploader =
+        accountOperations.newAccount().preferredEmail("uploader@example.com").create();
+    Account.Id approver = admin.id();
+
+    // Create two changes both with the same parent. Forge the author of the change that will be
+    // rebased.
+    requestScopeOperations.setApiUser(uploader);
+    Change.Id changeToBeTheNewBase =
+        changeOperations.newChange().project(project).owner(uploader).create();
+    Change.Id changeToBeRebased =
+        changeOperations.newChange().project(project).owner(uploader).create();
+
+    // Approve and submit the change that will be the new base for the change that will be rebased.
+    requestScopeOperations.setApiUser(approver);
+    gApi.changes().id(changeToBeTheNewBase.get()).current().review(ReviewInput.approve());
+    gApi.changes().id(changeToBeTheNewBase.get()).current().submit();
+
+    // Rebase the second change as uploader on behalf of the uploader
+    requestScopeOperations.setApiUser(uploader);
+    RebaseInput rebaseInput = new RebaseInput();
+    rebaseInput.onBehalfOfUploader = true;
+    gApi.changes().id(changeToBeRebased.get()).rebase(rebaseInput);
+
+    RevisionInfo currentRevisionInfo =
+        gApi.changes().id(changeToBeRebased.get()).get().getCurrentRevision();
+    // The change had 1 patch set before the rebase, now it should be 2
+    assertThat(currentRevisionInfo._number).isEqualTo(2);
+    assertThat(currentRevisionInfo.uploader._accountId).isEqualTo(uploader.get());
+    assertThat(currentRevisionInfo.realUploader).isNull();
+  }
+
+  @Test
+  public void cannotRebaseChangeOnBehalfOfYourselfWithoutPushPermission() throws Exception {
+    allowPermissionToAllUsers(Permission.REBASE);
+
+    Account.Id uploader =
+        accountOperations.newAccount().preferredEmail("uploader@example.com").create();
+    Account.Id approver = admin.id();
+
+    // Create two changes both with the same parent. Forge the author of the change that will be
+    // rebased.
+    requestScopeOperations.setApiUser(uploader);
+    Change.Id changeToBeTheNewBase =
+        changeOperations.newChange().project(project).owner(uploader).create();
+    Change.Id changeToBeRebased =
+        changeOperations.newChange().project(project).owner(uploader).create();
+
+    // Approve and submit the change that will be the new base for the change that will be rebased.
+    requestScopeOperations.setApiUser(approver);
+    gApi.changes().id(changeToBeTheNewBase.get()).current().review(ReviewInput.approve());
+    gApi.changes().id(changeToBeTheNewBase.get()).current().submit();
+
+    // Block push for uploader. For rebase on behalf of the uploader only
+    // the rebaser needs to have this permission, but not the uploader on whom's behalf the rebase
+    // is done.
+    AccountGroup.UUID cannotPushGroup =
+        groupOperations.newGroup().name("cannot-push").addMember(uploader).create();
+    blockPermission(Permission.PUSH, cannotPushGroup);
+
+    // Rebase the second change as uploader on behalf of the uploader
+    requestScopeOperations.setApiUser(uploader);
+    RebaseInput rebaseInput = new RebaseInput();
+    rebaseInput.onBehalfOfUploader = true;
+    AuthException exception =
+        assertThrows(
+            AuthException.class,
+            () -> gApi.changes().id(changeToBeRebased.get()).rebase(rebaseInput));
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo(
+            "rebase not permitted (change owners and users with the 'Submit' or 'Rebase'"
+                + " permission can rebase if they have the 'Push' permission)");
+  }
+
+  @Test
+  public void rebaseChangeOnBehalfOfUploaderWhenUploaderIsNotTheChangeOwner() throws Exception {
+    allowPermissionToAllUsers(Permission.REBASE);
+
+    Account.Id changeOwner = accountOperations.newAccount().create();
+    Account.Id uploader =
+        accountOperations.newAccount().preferredEmail("uploader@example.com").create();
+    Account.Id approver = admin.id();
+    Account.Id rebaser = accountOperations.newAccount().create();
+
+    // Create two changes both with the same parent. Forge the author of the change that will be
+    // rebased.
+    requestScopeOperations.setApiUser(changeOwner);
+    Change.Id changeToBeTheNewBase =
+        changeOperations.newChange().project(project).owner(changeOwner).create();
+    Change.Id changeToBeRebased =
+        changeOperations.newChange().project(project).owner(changeOwner).create();
+
+    // Create a second patch set for the change that will be rebased so that the uploader is
+    // different to the change owner.
+    // Set author and committer to the uploader so that rebasing on behalf of the uploader doesn't
+    // require the Forge Author and Forge Committer permission.
+    changeOperations
+        .change(changeToBeRebased)
+        .newPatchset()
+        .uploader(uploader)
+        .author(uploader)
+        .committer(uploader)
+        .create();
+
+    // Approve and submit the change that will be the new base for the change that will be rebased.
+    requestScopeOperations.setApiUser(approver);
+    gApi.changes().id(changeToBeTheNewBase.get()).current().review(ReviewInput.approve());
+    gApi.changes().id(changeToBeTheNewBase.get()).current().submit();
+
+    // Grant add patch set permission for uploader. Without the add patch set permission it is not
+    // possible to rebase the change on behalf of the uploader since the uploader cannot add a
+    // patch set to a change that is owned by another user.
+    AccountGroup.UUID canAddPatchSet =
+        groupOperations.newGroup().name("can-add-patch-set").addMember(uploader).create();
+    allowPermission(Permission.ADD_PATCH_SET, canAddPatchSet);
+
+    // Rebase the second change on behalf of the uploader
+    requestScopeOperations.setApiUser(rebaser);
+    RebaseInput rebaseInput = new RebaseInput();
+    rebaseInput.onBehalfOfUploader = true;
+    gApi.changes().id(changeToBeRebased.get()).rebase(rebaseInput);
+
+    RevisionInfo currentRevisionInfo =
+        gApi.changes().id(changeToBeRebased.get()).get().getCurrentRevision();
+    // The change had 2 patch set before the rebase, now it should be 3
+    assertThat(currentRevisionInfo._number).isEqualTo(3);
+    assertThat(currentRevisionInfo.uploader._accountId).isEqualTo(uploader.get());
+    assertThat(currentRevisionInfo.realUploader._accountId).isEqualTo(rebaser.get());
+  }
+
+  @Test
+  public void
+      cannotRebaseChangeOnBehalfOfUploaderWhenUploaderIsNotTheChangeOwnerAndDoesntHaveAddPatchSetPermission()
+          throws Exception {
+    allowPermissionToAllUsers(Permission.REBASE);
+
+    String uploaderEmail = "uploader@example.com";
+    Account.Id uploader = accountOperations.newAccount().preferredEmail(uploaderEmail).create();
+    Account.Id changeOwner = accountOperations.newAccount().create();
+    Account.Id approver = admin.id();
+    Account.Id rebaser = accountOperations.newAccount().create();
+
+    // Create two changes both with the same parent. Forge the author of the change that will be
+    // rebased.
+    requestScopeOperations.setApiUser(changeOwner);
+    Change.Id changeToBeTheNewBase =
+        changeOperations.newChange().project(project).owner(changeOwner).create();
+    Change.Id changeToBeRebased =
+        changeOperations.newChange().project(project).owner(changeOwner).create();
+
+    // Create a second patch set for the change that will be rebased so that the uploader is
+    // different to the change owner.
+    // Set author and committer to the uploader so that rebasing on behalf of the uploader doesn't
+    // require the Forge Author and Forge Committer permission.
+    changeOperations
+        .change(changeToBeRebased)
+        .newPatchset()
+        .uploader(uploader)
+        .author(uploader)
+        .committer(uploader)
+        .create();
+
+    // Approve and submit the change that will be the new base for the change that will be rebased.
+    requestScopeOperations.setApiUser(approver);
+    gApi.changes().id(changeToBeTheNewBase.get()).current().review(ReviewInput.approve());
+    gApi.changes().id(changeToBeTheNewBase.get()).current().submit();
+
+    // Block add patch set permission for uploader. Without the add patch set permission it should
+    // not possible to rebase the change on behalf of the uploader since the uploader cannot add a
+    // patch set to a change that is owned by another user.
+    AccountGroup.UUID cannotAddPatchSet =
+        groupOperations.newGroup().name("cannot-add-patch-set").addMember(uploader).create();
+    blockPermission(Permission.ADD_PATCH_SET, cannotAddPatchSet);
+
+    // Try to rebase the second change on behalf of the uploader
+    requestScopeOperations.setApiUser(rebaser);
+    RebaseInput rebaseInput = new RebaseInput();
+    rebaseInput.onBehalfOfUploader = true;
+    ResourceConflictException exception =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.changes().id(changeToBeRebased.get()).rebase(rebaseInput));
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo(
+            String.format(
+                "change %s: uploader %s cannot add patch set", changeToBeRebased, uploaderEmail));
+  }
+
+  @Test
+  public void rebaseChangeWithForgedAuthorOnBehalfOfUploader() throws Exception {
+    allowPermissionToAllUsers(Permission.REBASE);
+
+    String authorEmail = "author@example.com";
+    Account.Id author = accountOperations.newAccount().preferredEmail(authorEmail).create();
+    Account.Id uploader =
+        accountOperations.newAccount().preferredEmail("uploader@example.com").create();
+    Account.Id approver = admin.id();
+    Account.Id rebaser = accountOperations.newAccount().create();
+
+    // Create two changes both with the same parent. Forge the author of the change that will be
+    // rebased.
+    requestScopeOperations.setApiUser(uploader);
+    Change.Id changeToBeTheNewBase =
+        changeOperations.newChange().project(project).owner(uploader).create();
+    Change.Id changeToBeRebased =
+        changeOperations.newChange().project(project).owner(uploader).author(author).create();
+
+    // Approve and submit the change that will be the new base for the change that will be rebased.
+    requestScopeOperations.setApiUser(approver);
+    gApi.changes().id(changeToBeTheNewBase.get()).current().review(ReviewInput.approve());
+    gApi.changes().id(changeToBeTheNewBase.get()).current().submit();
+
+    // Grant forge author permission for uploader. Without the forge author permission it is not
+    // possible to rebase the change on behalf of the uploader.
+    AccountGroup.UUID canForgeAuthor =
+        groupOperations.newGroup().name("can-forge-author").addMember(uploader).create();
+    allowPermission(Permission.FORGE_AUTHOR, canForgeAuthor);
+
+    // Rebase the second change on behalf of the uploader
+    requestScopeOperations.setApiUser(rebaser);
+    RebaseInput rebaseInput = new RebaseInput();
+    rebaseInput.onBehalfOfUploader = true;
+    gApi.changes().id(changeToBeRebased.get()).rebase(rebaseInput);
+
+    RevisionInfo currentRevisionInfo =
+        gApi.changes().id(changeToBeRebased.get()).get().getCurrentRevision();
+    // The change had 1 patch set before the rebase, now it should be 2
+    assertThat(currentRevisionInfo._number).isEqualTo(2);
+    assertThat(currentRevisionInfo.commit.author.email).isEqualTo(authorEmail);
+    assertThat(currentRevisionInfo.uploader._accountId).isEqualTo(uploader.get());
+    assertThat(currentRevisionInfo.realUploader._accountId).isEqualTo(rebaser.get());
+  }
+
+  @Test
+  public void
+      cannotRebaseChangeWithForgedAuthorOnBehalfOfUploaderIfTheUploaderHasNoForgeAuthorPermission()
+          throws Exception {
+    allowPermissionToAllUsers(Permission.REBASE);
+
+    String uploaderEmail = "uploader@example.com";
+    Account.Id uploader = accountOperations.newAccount().preferredEmail(uploaderEmail).create();
+    Account.Id author = accountOperations.newAccount().create();
+    Account.Id approver = admin.id();
+    Account.Id rebaser = accountOperations.newAccount().create();
+
+    // Create two changes both with the same parent. Forge the author of the change that will be
+    // rebased.
+    requestScopeOperations.setApiUser(uploader);
+    Change.Id changeToBeTheNewBase =
+        changeOperations.newChange().project(project).owner(uploader).create();
+    Change.Id changeToBeRebased =
+        changeOperations.newChange().project(project).owner(uploader).author(author).create();
+
+    // Approve and submit the change that will be the new base for the change that will be rebased.
+    requestScopeOperations.setApiUser(approver);
+    gApi.changes().id(changeToBeTheNewBase.get()).current().review(ReviewInput.approve());
+    gApi.changes().id(changeToBeTheNewBase.get()).current().submit();
+
+    // Block forge author permission for uploader. Without the forge author permission it should not
+    // be possible to rebase the change on behalf of the uploader.
+    AccountGroup.UUID cannotForgeAuthor =
+        groupOperations.newGroup().name("cannot-forge-author").addMember(uploader).create();
+    blockPermission(Permission.FORGE_AUTHOR, cannotForgeAuthor);
+
+    // Try to rebase the second change on behalf of the uploader
+    requestScopeOperations.setApiUser(rebaser);
+    RebaseInput rebaseInput = new RebaseInput();
+    rebaseInput.onBehalfOfUploader = true;
+    ResourceConflictException exception =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.changes().id(changeToBeRebased.get()).rebase(rebaseInput));
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo(
+            String.format(
+                "change %s: author of patch set 1 is forged and the uploader %s cannot forge author",
+                changeToBeRebased, uploaderEmail));
+  }
+
+  @Test
+  public void
+      rebaseChangeWithForgedCommitterOnBehalfOfUploaderDoesntRequireForgeCommitterPermission()
+          throws Exception {
+    allowPermissionToAllUsers(Permission.REBASE);
+
+    String uploaderEmail = "uploader@example.com";
+    Account.Id uploader = accountOperations.newAccount().preferredEmail(uploaderEmail).create();
+    Account.Id committer =
+        accountOperations.newAccount().preferredEmail("committer@example.com").create();
+    Account.Id approver = admin.id();
+    Account.Id rebaser = accountOperations.newAccount().create();
+
+    // Create two changes both with the same parent. Forge the committer of the change that will be
+    // rebased.
+    requestScopeOperations.setApiUser(uploader);
+    Change.Id changeToBeTheNewBase =
+        changeOperations.newChange().project(project).owner(uploader).create();
+    Change.Id changeToBeRebased =
+        changeOperations.newChange().project(project).owner(uploader).committer(committer).create();
+
+    // Approve and submit the change that will be the new base for the change that will be rebased.
+    requestScopeOperations.setApiUser(approver);
+    gApi.changes().id(changeToBeTheNewBase.get()).current().review(ReviewInput.approve());
+    gApi.changes().id(changeToBeTheNewBase.get()).current().submit();
+
+    // Rebase the second change on behalf of the uploader
+    requestScopeOperations.setApiUser(rebaser);
+    RebaseInput rebaseInput = new RebaseInput();
+    rebaseInput.onBehalfOfUploader = true;
+    gApi.changes().id(changeToBeRebased.get()).rebase(rebaseInput);
+
+    RevisionInfo currentRevisionInfo =
+        gApi.changes().id(changeToBeRebased.get()).get().getCurrentRevision();
+    // The change had 1 patch set before the rebase, now it should be 2
+    assertThat(currentRevisionInfo._number).isEqualTo(2);
+    assertThat(currentRevisionInfo.commit.committer.email).isEqualTo(uploaderEmail);
+    assertThat(currentRevisionInfo.uploader._accountId).isEqualTo(uploader.get());
+    assertThat(currentRevisionInfo.realUploader._accountId).isEqualTo(rebaser.get());
+  }
+
+  @Test
+  public void rebaseChangeWithServerIdentOnBehalfOfUploader() throws Exception {
+    allowPermissionToAllUsers(Permission.REBASE);
+
+    String uploaderEmail = "uploader@example.com";
+    Account.Id uploader = accountOperations.newAccount().preferredEmail(uploaderEmail).create();
+    Account.Id approver = admin.id();
+    Account.Id rebaser = accountOperations.newAccount().create();
+
+    // Create two changes both with the same parent. Use the server identity as the author of the
+    // change that will be rebased.
+    requestScopeOperations.setApiUser(uploader);
+    Change.Id changeToBeTheNewBase =
+        changeOperations.newChange().project(project).owner(uploader).create();
+    Change.Id changeToBeRebased =
+        changeOperations
+            .newChange()
+            .project(project)
+            .owner(uploader)
+            .authorIdent(serverIdent.get())
+            .create();
+
+    // Approve and submit the change that will be the new base for the change that will be rebased.
+    requestScopeOperations.setApiUser(approver);
+    gApi.changes().id(changeToBeTheNewBase.get()).current().review(ReviewInput.approve());
+    gApi.changes().id(changeToBeTheNewBase.get()).current().submit();
+
+    // Grant forge author and forge server permission for uploader. Without these permissions it is
+    // not possible to rebase the change on behalf of the uploader.
+    AccountGroup.UUID canForgeAuthorAndForgeServer =
+        groupOperations
+            .newGroup()
+            .name("can-forge-author-and-forge-server")
+            .addMember(uploader)
+            .create();
+    allowPermission(Permission.FORGE_AUTHOR, canForgeAuthorAndForgeServer);
+    allowPermission(Permission.FORGE_SERVER, canForgeAuthorAndForgeServer);
+
+    // Rebase the second change on behalf of the uploader.
+    requestScopeOperations.setApiUser(rebaser);
+    RebaseInput rebaseInput = new RebaseInput();
+    rebaseInput.onBehalfOfUploader = true;
+    gApi.changes().id(changeToBeRebased.get()).rebase(rebaseInput);
+
+    RevisionInfo currentRevisionInfo =
+        gApi.changes().id(changeToBeRebased.get()).get().getCurrentRevision();
+    // The change had 1 patch set before the rebase, now it should be 2
+    assertThat(currentRevisionInfo._number).isEqualTo(2);
+    assertThat(currentRevisionInfo.commit.author.email)
+        .isEqualTo(serverIdent.get().getEmailAddress());
+    assertThat(currentRevisionInfo.uploader._accountId).isEqualTo(uploader.get());
+    assertThat(currentRevisionInfo.realUploader._accountId).isEqualTo(rebaser.get());
+  }
+
+  @Test
+  public void
+      cannotRebaseChangeWithServerIdentOnBehalfOfUploaderIfTheUploaderHasNoForgeServerPermission()
+          throws Exception {
+    allowPermissionToAllUsers(Permission.REBASE);
+
+    String uploaderEmail = "uploader@example.com";
+    Account.Id uploader = accountOperations.newAccount().preferredEmail(uploaderEmail).create();
+    Account.Id approver = admin.id();
+    Account.Id rebaser = accountOperations.newAccount().create();
+
+    // Create two changes both with the same parent. Use the server identity as the author of the
+    // change that will be rebased.
+    requestScopeOperations.setApiUser(uploader);
+    Change.Id changeToBeTheNewBase =
+        changeOperations.newChange().project(project).owner(uploader).create();
+    Change.Id changeToBeRebased =
+        changeOperations
+            .newChange()
+            .project(project)
+            .owner(uploader)
+            .authorIdent(serverIdent.get())
+            .create();
+
+    // Approve and submit the change that will be the new base for the change that will be rebased.
+    requestScopeOperations.setApiUser(approver);
+    gApi.changes().id(changeToBeTheNewBase.get()).current().review(ReviewInput.approve());
+    gApi.changes().id(changeToBeTheNewBase.get()).current().submit();
+
+    // Grant forge author permission for uploader, but not the forge server permission. Without the
+    // forge server permission it is not possible to rebase the change on behalf of the uploader.
+    AccountGroup.UUID canForgeAuthor =
+        groupOperations.newGroup().name("can-forge-author").addMember(uploader).create();
+    allowPermission(Permission.FORGE_AUTHOR, canForgeAuthor);
+
+    // Try to rebase the second change on behalf of the uploader.
+    requestScopeOperations.setApiUser(rebaser);
+    RebaseInput rebaseInput = new RebaseInput();
+    rebaseInput.onBehalfOfUploader = true;
+    ResourceConflictException exception =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.changes().id(changeToBeRebased.get()).rebase(rebaseInput));
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo(
+            String.format(
+                "change %s: author of patch set 1 is the server identity and the uploader %s cannot forge"
+                    + " the server identity",
+                changeToBeRebased, uploaderEmail));
+  }
+
+  @Test
+  public void rebaseActionEnabled_withRebasePermission() throws Exception {
+    allowPermissionToAllUsers(Permission.REBASE);
+    testRebaseActionEnabled();
+  }
+
+  @Test
+  public void rebaseActionEnabled_withSubmitPermission() throws Exception {
+    allowPermissionToAllUsers(Permission.SUBMIT);
+    testRebaseActionEnabled();
+  }
+
+  private void testRebaseActionEnabled() throws Exception {
+    Account.Id uploader = accountOperations.newAccount().create();
+    Account.Id approver = admin.id();
+    Account.Id rebaser = accountOperations.newAccount().create();
+
+    // Block push permission for rebaser, as in contrast to rebase, rebase on behalf of the uploader
+    // doesn't require the rebaser to have the push permission.
+    AccountGroup.UUID cannotUploadGroup =
+        groupOperations.newGroup().name("cannot-upload").addMember(rebaser).create();
+    blockPermission(Permission.PUSH, cannotUploadGroup);
+
+    // Create two changes both with the same parent
+    requestScopeOperations.setApiUser(uploader);
+    Change.Id changeToBeTheNewBase =
+        changeOperations.newChange().project(project).owner(uploader).create();
+    Change.Id changeToBeRebased =
+        changeOperations.newChange().project(project).owner(uploader).create();
+
+    // Approve and submit the change that will be the new base for the change that will be rebased.
+    requestScopeOperations.setApiUser(approver);
+    gApi.changes().id(changeToBeTheNewBase.get()).current().review(ReviewInput.approve());
+    gApi.changes().id(changeToBeTheNewBase.get()).current().submit();
+
+    requestScopeOperations.setApiUser(rebaser);
+    RevisionInfo revisionInfo =
+        gApi.changes().id(changeToBeRebased.get()).get().getCurrentRevision();
+    assertThat(revisionInfo.actions).containsKey("rebase");
+    ActionInfo rebaseActionInfo = revisionInfo.actions.get("rebase");
+    assertThat(rebaseActionInfo.enabled).isTrue();
+
+    // rebase is disabled because rebaser doesn't have the 'Push' permission and hence cannot create
+    // new patch sets
+    assertThat(rebaseActionInfo.enabledOptions).containsExactly("rebase_on_behalf_of_uploader");
+  }
+
+  @Test
+  public void rebaseActionEnabled_forChangeOwner() throws Exception {
+    Account.Id changeOwner = accountOperations.newAccount().create();
+    Account.Id approver = admin.id();
+
+    // Create two changes both with the same parent
+    requestScopeOperations.setApiUser(changeOwner);
+    Change.Id changeToBeTheNewBase =
+        changeOperations.newChange().project(project).owner(changeOwner).create();
+    Change.Id changeToBeRebased =
+        changeOperations.newChange().project(project).owner(changeOwner).create();
+
+    // Approve and submit the change that will be the new base for the change that will be rebased.
+    requestScopeOperations.setApiUser(approver);
+    gApi.changes().id(changeToBeTheNewBase.get()).current().review(ReviewInput.approve());
+    gApi.changes().id(changeToBeTheNewBase.get()).current().submit();
+
+    requestScopeOperations.setApiUser(changeOwner);
+    RevisionInfo revisionInfo =
+        gApi.changes().id(changeToBeRebased.get()).get().getCurrentRevision();
+    assertThat(revisionInfo.actions).containsKey("rebase");
+    ActionInfo rebaseActionInfo = revisionInfo.actions.get("rebase");
+    assertThat(rebaseActionInfo.enabled).isTrue();
+
+    // rebase is enabled because change owner has the 'Push' permission and hence can create new
+    // patch sets
+    assertThat(rebaseActionInfo.enabledOptions)
+        .containsExactly("rebase", "rebase_on_behalf_of_uploader");
+  }
+
+  @UseLocalDisk
+  @Test
+  public void rebaseChangeOnBehalfOfUploaderRecordsUploaderInRefLog() throws Exception {
+    allowPermissionToAllUsers(Permission.REBASE);
+
+    String uploaderEmail = "uploader@example.com";
+    Account.Id uploader = accountOperations.newAccount().preferredEmail(uploaderEmail).create();
+    Account.Id approver = admin.id();
+    Account.Id rebaser = accountOperations.newAccount().create();
+
+    // Create two changes both with the same parent
+    requestScopeOperations.setApiUser(uploader);
+    Change.Id changeToBeTheNewBase =
+        changeOperations.newChange().project(project).owner(uploader).create();
+    Change.Id changeToBeRebased =
+        changeOperations.newChange().project(project).owner(uploader).create();
+
+    // Approve and submit the change that will be the new base for the change that will be rebased.
+    requestScopeOperations.setApiUser(approver);
+    gApi.changes().id(changeToBeTheNewBase.get()).current().review(ReviewInput.approve());
+    gApi.changes().id(changeToBeTheNewBase.get()).current().submit();
+
+    try (Repository repo = repoManager.openRepository(project)) {
+      String changeMetaRef = RefNames.changeMetaRef(changeToBeRebased);
+      String patchSetRef = RefNames.patchSetRef(PatchSet.id(changeToBeRebased, 2));
+      createRefLogFileIfMissing(repo, changeMetaRef);
+      createRefLogFileIfMissing(repo, patchSetRef);
+
+      // Rebase the second change on behalf of the uploader
+      requestScopeOperations.setApiUser(rebaser);
+      RebaseInput rebaseInput = new RebaseInput();
+      rebaseInput.onBehalfOfUploader = true;
+      gApi.changes().id(changeToBeRebased.get()).rebase(rebaseInput);
+
+      // The ref log for the patch set ref records the impersonated user aka the uploader.
+      ReflogEntry patchSetRefLogEntry = repo.getReflogReader(patchSetRef).getLastEntry();
+      assertThat(patchSetRefLogEntry.getWho().getEmailAddress()).isEqualTo(uploaderEmail);
+
+      // The ref log for the change meta ref records the impersonated user aka the uploader.
+      ReflogEntry changeMetaRefLogEntry = repo.getReflogReader(changeMetaRef).getLastEntry();
+      assertThat(changeMetaRefLogEntry.getWho().getEmailAddress()).isEqualTo(uploaderEmail);
+    }
+  }
+
+  @Test
+  public void rebaserCanApproveChangeAfterRebasingOnBehalfOfUploader() throws Exception {
+    // Require a Code-Review approval from a non-uploader for submit.
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig()
+          .upsertSubmitRequirement(
+              SubmitRequirement.builder()
+                  .setName(TestLabels.codeReview().getName())
+                  .setSubmittabilityExpression(
+                      SubmitRequirementExpression.create(
+                          String.format(
+                              "label:%s=MAX,user=non_uploader", TestLabels.codeReview().getName())))
+                  .setAllowOverrideInChildProjects(false)
+                  .build());
+      u.save();
+    }
+
+    allowPermissionToAllUsers(Permission.REBASE);
+
+    String uploaderEmail = "uploader@example.com";
+    Account.Id uploader = accountOperations.newAccount().preferredEmail(uploaderEmail).create();
+    Account.Id approver = admin.id();
+    Account.Id rebaser = accountOperations.newAccount().create();
+
+    // Create two changes both with the same parent
+    requestScopeOperations.setApiUser(uploader);
+    Change.Id changeToBeTheNewBase =
+        changeOperations.newChange().project(project).owner(uploader).create();
+    Change.Id changeToBeRebased =
+        changeOperations.newChange().project(project).owner(uploader).create();
+
+    // Approve and submit the change that will be the new base for the change that will be rebased.
+    requestScopeOperations.setApiUser(approver);
+    gApi.changes().id(changeToBeTheNewBase.get()).current().review(ReviewInput.approve());
+    gApi.changes().id(changeToBeTheNewBase.get()).current().submit();
+
+    // Rebase it on behalf of the uploader
+    requestScopeOperations.setApiUser(rebaser);
+    RebaseInput rebaseInput = new RebaseInput();
+    rebaseInput.onBehalfOfUploader = true;
+    gApi.changes().id(changeToBeRebased.get()).rebase(rebaseInput);
+
+    // Approve the change as the rebaser.
+    allowVotingOnCodeReviewToAllUsers();
+    gApi.changes().id(changeToBeRebased.get()).current().review(ReviewInput.approve());
+
+    // The change is submittable because the approval is from a user (the rebaser) that is not the
+    // uploader.
+    assertThat(gApi.changes().id(changeToBeRebased.get()).get().submittable).isTrue();
+
+    // Create and submit another change so that we can rebase the change once again.
+    requestScopeOperations.setApiUser(approver);
+    Change.Id changeToBeTheNewBase2 =
+        changeOperations.newChange().project(project).owner(uploader).create();
+    gApi.changes().id(changeToBeTheNewBase2.get()).current().review(ReviewInput.approve());
+    gApi.changes().id(changeToBeTheNewBase2.get()).current().submit();
+
+    // Doing a normal rebase (not on behalf of the uploader) makes the rebaser the uploader. This
+    // makse the change non-submittable since the approval of the rebaser is ignored now (due to
+    // using 'user=non_uploader' in the submit requirement expression).
+    requestScopeOperations.setApiUser(rebaser);
+    rebaseInput.onBehalfOfUploader = false;
+    gApi.changes().id(changeToBeRebased.get()).rebase(rebaseInput);
+    gApi.changes().id(changeToBeRebased.get()).current().review(ReviewInput.approve());
+    assertThat(gApi.changes().id(changeToBeRebased.get()).get().submittable).isFalse();
+  }
+
+  @Test
+  public void testSubmittedWithRebaserApprovalMetric() throws Exception {
+    allowVotingOnCodeReviewToAllUsers();
+
+    createVerifiedLabel();
+    allowVotingOnVerifiedToAllUsers();
+
+    // Require a Code-Review approval from a non-uploader for submit.
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig()
+          .upsertSubmitRequirement(
+              SubmitRequirement.builder()
+                  .setName(TestLabels.verified().getName())
+                  .setSubmittabilityExpression(
+                      SubmitRequirementExpression.create(
+                          String.format("label:%s=MAX", TestLabels.verified().getName())))
+                  .setAllowOverrideInChildProjects(false)
+                  .build());
+      u.getConfig()
+          .upsertSubmitRequirement(
+              SubmitRequirement.builder()
+                  .setName(TestLabels.codeReview().getName())
+                  .setSubmittabilityExpression(
+                      SubmitRequirementExpression.create(
+                          String.format(
+                              "label:%s=MAX,user=non_uploader", TestLabels.codeReview().getName())))
+                  .setAllowOverrideInChildProjects(false)
+                  .build());
+      u.save();
+    }
+
+    allowPermissionToAllUsers(Permission.REBASE);
+
+    String uploaderEmail = "uploader@example.com";
+    Account.Id uploader = accountOperations.newAccount().preferredEmail(uploaderEmail).create();
+    Account.Id approver = admin.id();
+    Account.Id rebaser = accountOperations.newAccount().create();
+
+    // Create two changes both with the same parent
+    requestScopeOperations.setApiUser(uploader);
+    Change.Id changeToBeTheNewBase =
+        changeOperations.newChange().project(project).owner(uploader).create();
+    Change.Id changeToBeRebased =
+        changeOperations.newChange().project(project).owner(uploader).create();
+
+    // Approve and submit the change that will be the new base for the change that will be rebased.
+    requestScopeOperations.setApiUser(approver);
+    gApi.changes()
+        .id(changeToBeTheNewBase.get())
+        .current()
+        .review(ReviewInput.approve().label(TestLabels.verified().getName(), 1));
+    testMetricMaker.reset();
+    gApi.changes().id(changeToBeTheNewBase.get()).current().submit();
+    assertThat(testMetricMaker.getCount("change/submitted_with_rebaser_approval")).isEqualTo(0);
+
+    // Rebase it on behalf of the uploader
+    requestScopeOperations.setApiUser(rebaser);
+    RebaseInput rebaseInput = new RebaseInput();
+    rebaseInput.onBehalfOfUploader = true;
+    gApi.changes().id(changeToBeRebased.get()).rebase(rebaseInput);
+
+    // Approve the change as the rebaser.
+    gApi.changes()
+        .id(changeToBeRebased.get())
+        .current()
+        .review(ReviewInput.approve().label(TestLabels.verified().getName(), 1));
+
+    // The change is submittable because the approval is from a user (the rebaser) that is not the
+    // uploader.
+    allowPermissionToAllUsers(Permission.SUBMIT);
+    testMetricMaker.reset();
+    gApi.changes().id(changeToBeRebased.get()).current().submit();
+    assertThat(testMetricMaker.getCount("change/submitted_with_rebaser_approval")).isEqualTo(1);
+  }
+
+  @Test
+  public void testCountRebasesMetric() throws Exception {
+    allowPermissionToAllUsers(Permission.REBASE);
+
+    Account.Id uploader = accountOperations.newAccount().create();
+    Account.Id approver = admin.id();
+    Account.Id rebaser = accountOperations.newAccount().create();
+
+    // Create two changes both with the same parent
+    requestScopeOperations.setApiUser(uploader);
+    Change.Id changeToBeTheNewBase =
+        changeOperations.newChange().project(project).owner(uploader).create();
+    Change.Id changeToBeRebased =
+        changeOperations.newChange().project(project).owner(uploader).create();
+
+    // Approve and submit the change that will be the new base for the change that will be rebased.
+    requestScopeOperations.setApiUser(approver);
+    gApi.changes().id(changeToBeTheNewBase.get()).current().review(ReviewInput.approve());
+    gApi.changes().id(changeToBeTheNewBase.get()).current().submit();
+
+    // Rebase it on behalf of the uploader
+    testMetricMaker.reset();
+    requestScopeOperations.setApiUser(rebaser);
+    RebaseInput rebaseInput = new RebaseInput();
+    rebaseInput.onBehalfOfUploader = true;
+    gApi.changes().id(changeToBeRebased.get()).rebase(rebaseInput);
+    // field1 is on_behalf_of_uploader, field2 is rebase_chain, field3 is allow_conflicts
+    assertThat(testMetricMaker.getCount("change/count_rebases", true, false, false)).isEqualTo(1);
+    assertThat(testMetricMaker.getCount("change/count_rebases", false, false, false)).isEqualTo(0);
+    assertThat(testMetricMaker.getCount("change/count_rebases", true, true, false)).isEqualTo(0);
+    assertThat(testMetricMaker.getCount("change/count_rebases", false, true, false)).isEqualTo(0);
+
+    // Create and submit another change so that we can rebase the change once again.
+    requestScopeOperations.setApiUser(approver);
+    Change.Id changeToBeTheNewBase2 =
+        changeOperations.newChange().project(project).owner(uploader).create();
+    gApi.changes().id(changeToBeTheNewBase2.get()).current().review(ReviewInput.approve());
+    gApi.changes().id(changeToBeTheNewBase2.get()).current().submit();
+
+    // Rebase the change once again, this time as the uploader.
+    // If the uploader sets on_behalf_of_uploader = true, the flag is ignored and a normal rebase is
+    // done, hence the metric should count this as a a rebase with on_behalf_of_uploader = false.
+    requestScopeOperations.setApiUser(uploader);
+    testMetricMaker.reset();
+    gApi.changes().id(changeToBeRebased.get()).rebase(rebaseInput);
+    // field1 is on_behalf_of_uploader, field2 is rebase_chain, field3 is allow_conflicts
+    assertThat(testMetricMaker.getCount("change/count_rebases", false, false, false)).isEqualTo(1);
+    assertThat(testMetricMaker.getCount("change/count_rebases", true, false, false)).isEqualTo(0);
+    assertThat(testMetricMaker.getCount("change/count_rebases", false, true, false)).isEqualTo(0);
+    assertThat(testMetricMaker.getCount("change/count_rebases", true, true, false)).isEqualTo(0);
+  }
+
+  private void allowPermissionToAllUsers(String permission) {
+    allowPermission(permission, REGISTERED_USERS);
+  }
+
+  private void allowPermission(String permission, AccountGroup.UUID groupUuid) {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(permission).ref("refs/*").group(groupUuid))
+        .update();
+  }
+
+  private void allowVotingOnCodeReviewToAllUsers() {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(TestLabels.codeReview().getName())
+                .ref("refs/*")
+                .group(REGISTERED_USERS)
+                .range(-2, 2))
+        .update();
+  }
+
+  private void createVerifiedLabel() throws Exception {
+    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();
+    }
+  }
+
+  private void allowVotingOnVerifiedToAllUsers() {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(TestLabels.verified().getName())
+                .ref("refs/*")
+                .group(REGISTERED_USERS)
+                .range(-1, 1))
+        .update();
+  }
+
+  private void blockPermissionForAllUsers(String permission) {
+    blockPermission(permission, REGISTERED_USERS);
+  }
+
+  private void blockPermission(String permission, AccountGroup.UUID groupUuid) {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(permission).ref("refs/*").group(groupUuid))
+        .update();
+  }
+
+  @FunctionalInterface
+  private interface RebaseCall {
+    void call(Change.Id changeId, RebaseInput rebaseInput) throws RestApiException;
+  }
+
+  private static class TestRevisionCreatedListener implements RevisionCreatedListener {
+    public RevisionInfo revisionInfo;
+
+    @Override
+    public void onRevisionCreated(Event event) {
+      revisionInfo = event.getRevision();
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java b/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java
index 9de33be..4855ba4 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java
@@ -26,14 +26,15 @@
 import com.google.gerrit.acceptance.ExtensionRegistry;
 import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.TestProjectInput;
 import com.google.gerrit.acceptance.config.GerritConfig;
+import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
-import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.changes.ChangeApi;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.RevertInput;
@@ -62,9 +63,9 @@
 import java.util.Collections;
 import java.util.List;
 import java.util.Map;
+import java.util.regex.Pattern;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
-import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.transport.RefSpec;
 import org.junit.Test;
 
@@ -73,63 +74,7 @@
   @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
   @Inject private ExtensionRegistry extensionRegistry;
-
-  @Test
-  public void pureRevertFactBlocksSubmissionOfNonReverts() throws Exception {
-    addPureRevertSubmitRule();
-
-    // Create a change that is not a revert of another change
-    PushOneCommit.Result r1 = pushFactory.create(user.newIdent(), testRepo).to("refs/for/master");
-    approve(r1.getChangeId());
-
-    ResourceConflictException thrown =
-        assertThrows(
-            ResourceConflictException.class,
-            () -> gApi.changes().id(r1.getChangeId()).current().submit());
-    assertThat(thrown)
-        .hasMessageThat()
-        .contains("Failed to submit 1 change due to the following problems");
-    assertThat(thrown)
-        .hasMessageThat()
-        .contains("submit requirement 'Is-Pure-Revert' is unsatisfied.");
-  }
-
-  @Test
-  public void pureRevertFactBlocksSubmissionOfNonPureReverts() throws Exception {
-    PushOneCommit.Result r1 = pushFactory.create(user.newIdent(), testRepo).to("refs/for/master");
-    merge(r1);
-
-    addPureRevertSubmitRule();
-
-    // Create a revert and push a content change
-    String revertId = gApi.changes().id(r1.getChangeId()).revert().get().changeId;
-    amendChange(revertId);
-    approve(revertId);
-
-    ResourceConflictException thrown =
-        assertThrows(
-            ResourceConflictException.class, () -> gApi.changes().id(revertId).current().submit());
-    assertThat(thrown)
-        .hasMessageThat()
-        .contains("Failed to submit 1 change due to the following problems");
-    assertThat(thrown)
-        .hasMessageThat()
-        .contains("submit requirement 'Is-Pure-Revert' is unsatisfied.");
-  }
-
-  @Test
-  public void pureRevertFactAllowsSubmissionOfPureReverts() throws Exception {
-    // Create a change that we can later revert
-    PushOneCommit.Result r1 = pushFactory.create(user.newIdent(), testRepo).to("refs/for/master");
-    merge(r1);
-
-    addPureRevertSubmitRule();
-
-    // Create a revert and submit it
-    String revertId = gApi.changes().id(r1.getChangeId()).revert().get().changeId;
-    approve(revertId);
-    gApi.changes().id(revertId).current().submit();
-  }
+  @Inject private AccountOperations accountOperations;
 
   @Test
   public void pureRevertReturnsTrueForPureRevert() throws Exception {
@@ -267,10 +212,19 @@
     PushOneCommit.Result r = createChange();
     gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
     gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).submit();
-
     RevertInput in = createWipRevertInput();
+
     ChangeInfo revertChange = gApi.changes().id(r.getChangeId()).revert(in).get();
+
     assertThat(revertChange.workInProgress).isTrue();
+    // expected messages on source change:
+    // 1. Uploaded patch set 1.
+    // 2. Patch Set 1: Code-Review+2
+    // 3. Change has been successfully merged by Administrator
+    // No "reverted" message is expected.
+    List<ChangeMessageInfo> sourceMessages =
+        new ArrayList<>(gApi.changes().id(r.getChangeId()).get().messages);
+    assertThat(sourceMessages).hasSize(3);
   }
 
   @Test
@@ -365,14 +319,14 @@
   }
 
   @Test
-  public void revertNotificationsSupressedOnWip() throws Exception {
+  public void revertNotificationsSuppressedOnWip() throws Exception {
     PushOneCommit.Result r = createChange();
     gApi.changes().id(r.getChangeId()).addReviewer(user.email());
     gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
     gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).submit();
 
     sender.clear();
-    // If notify input not specified, the endpoint overrides it to OWNER
+    // If notify input not specified, the endpoint overrides it to NONE
     RevertInput revertInput = createWipRevertInput();
     revertInput.notify = null;
     gApi.changes().id(r.getChangeId()).revert(revertInput).get();
@@ -424,6 +378,73 @@
   }
 
   @Test
+  public void revertAllowedIfUserAccountIsInactive() throws Exception {
+    PushOneCommit.Result r = createChange();
+    ReviewInput in = ReviewInput.approve();
+    in.reviewer(user.email());
+    in.reviewer(accountCreator.user2().email(), ReviewerState.CC, true);
+    // Add user as reviewer that will create the revert
+    in.reviewer(accountCreator.admin2().email());
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(in);
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).submit();
+
+    accountOperations.account(user.id()).forUpdate().inactive().update();
+    accountOperations.account(accountCreator.user2().id()).forUpdate().inactive().update();
+
+    requestScopeOperations.setApiUser(accountCreator.admin2().id());
+    Map<ReviewerState, Collection<AccountInfo>> result =
+        gApi.changes().id(r.getChangeId()).revert().get().reviewers;
+    assertThat(result).containsKey(ReviewerState.REVIEWER);
+
+    // The active user should be preserved as reviewer. For inactive user this test doesn't
+    // fix specific behavior - they can be either preserved or removed depending on the
+    // implementation.
+    List<Integer> reviewers =
+        result.get(ReviewerState.REVIEWER).stream().map(a -> a._accountId).collect(toList());
+    assertThat(reviewers).contains(admin.id().get());
+  }
+
+  @Test
+  @GerritConfig(name = "accounts.visibility", value = "SAME_GROUP")
+  public void revertWithNonVisibleUsers() throws Exception {
+    // Define readable names for the users we use in this test.
+    TestAccount reverter = user;
+    TestAccount changeOwner = admin; // must be admin, since admin cloned testRepo
+    TestAccount reviewer = accountCreator.user2();
+    TestAccount cc =
+        accountCreator.create("user3", "user3@example.com", "User3", /* displayName= */ null);
+
+    // Check that the reverter can neither see the changeOwner, the reviewer nor the cc.
+    requestScopeOperations.setApiUser(reverter.id());
+    assertThatAccountIsNotVisible(changeOwner, reviewer, cc);
+
+    // Create the change.
+    requestScopeOperations.setApiUser(changeOwner.id());
+    PushOneCommit.Result r = createChange();
+
+    // Add reviewer and cc.
+    ReviewInput reviewerInput = ReviewInput.approve();
+    reviewerInput.reviewer(reviewer.email());
+    reviewerInput.cc(cc.email());
+    gApi.changes().id(r.getChangeId()).current().review(reviewerInput);
+
+    // Approve and submit the change.
+    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
+    gApi.changes().id(r.getChangeId()).current().submit();
+
+    // Revert the change.
+    requestScopeOperations.setApiUser(reverter.id());
+    String revertChangeId = gApi.changes().id(r.getChangeId()).revert().get().id;
+
+    // Revert doesn't check the reviewer/CC visibility. Since the reverter can see the reverted
+    // change, they can also see its reviewers/CCs. This means preserving them on the revert change
+    // doesn't expose their account existence and it's OK to keep them even if their accounts are
+    // not visible to the reverter.
+    assertReviewers(revertChangeId, changeOwner, reviewer);
+    assertCcs(revertChangeId, cc);
+  }
+
+  @Test
   @TestProjectInput(createEmptyCommit = false)
   public void revertInitialCommit() throws Exception {
     PushOneCommit.Result r = createChange();
@@ -765,8 +786,7 @@
     gApi.changes().id(secondResult).current().submit();
 
     sender.clear();
-    RevertInput revertInput = new RevertInput();
-    revertInput.workInProgress = true;
+    RevertInput revertInput = createWipRevertInput();
     revertInput.notify = NotifyHandling.NONE;
     gApi.changes().id(secondResult).revertSubmission(revertInput);
     assertThat(sender.getMessages()).isEmpty();
@@ -788,9 +808,14 @@
     // If notify handling is specified, it will be used by the API
     RevertInput revertInput = createWipRevertInput();
     revertInput.notify = NotifyHandling.ALL;
-    gApi.changes().id(changeId2).revertSubmission(revertInput);
+    RevertSubmissionInfo revertChanges = gApi.changes().id(changeId2).revertSubmission(revertInput);
 
-    assertThat(sender.getMessages()).hasSize(4);
+    assertThat(revertChanges.revertChanges).hasSize(2);
+    assertThat(sender.getMessages()).hasSize(2);
+    assertThat(sender.getMessages(revertChanges.revertChanges.get(0).changeId, "newchange"))
+        .hasSize(1);
+    assertThat(sender.getMessages(revertChanges.revertChanges.get(1).changeId, "newchange"))
+        .hasSize(1);
   }
 
   @Test
@@ -801,17 +826,23 @@
     String changeId2 = createChange("second change", "b.txt", "other").getChangeId();
     approve(changeId2);
     gApi.changes().id(changeId2).addReviewer(user.email());
-
     gApi.changes().id(changeId2).current().submit();
-
     sender.clear();
-
     RevertInput revertInput = createWipRevertInput();
+
     RevertSubmissionInfo revertSubmissionInfo =
         gApi.changes().id(changeId2).revertSubmission(revertInput);
 
     assertThat(revertSubmissionInfo.revertChanges.stream().allMatch(r -> r.workInProgress))
         .isTrue();
+
+    // expected messages on source change:
+    // 1. Uploaded patch set 1.
+    // 2. Patch Set 1: Code-Review+2
+    // 3. Change has been successfully merged by Administrator
+    // No "reverted" message is expected.
+    assertThat(gApi.changes().id(changeId1).get().messages).hasSize(3);
+    assertThat(gApi.changes().id(changeId2).get().messages).hasSize(3);
   }
 
   @Test
@@ -1218,10 +1249,38 @@
                 .distinct()
                 .count())
         .isEqualTo(1);
+
+    // Size
     List<ChangeApi> revertChanges = getChangeApis(revertSubmissionInfo);
+    assertThat(revertChanges).hasSize(3);
+    assertThat(gApi.changes().id(revertChanges.get(1).id()).current().related().changes).hasSize(2);
+
+    // Contents
     assertThat(revertChanges.get(0).current().files().get("c.txt").linesDeleted).isEqualTo(1);
     assertThat(revertChanges.get(1).current().files().get("a.txt").linesDeleted).isEqualTo(1);
     assertThat(revertChanges.get(2).current().files().get("b.txt").linesDeleted).isEqualTo(1);
+
+    // Commit message
+    assertThat(revertChanges.get(0).current().commit(false).message)
+        .matches(
+            Pattern.compile(
+                "Revert \"first change\"\n\n"
+                    + "This reverts commit [a-f0-9]+\\.\n\n"
+                    + "Change-Id: I[a-f0-9]+\n"));
+    assertThat(revertChanges.get(1).current().commit(false).message)
+        .matches(
+            Pattern.compile(
+                "Revert \"second change\"\n\n"
+                    + "This reverts commit [a-f0-9]+\\.\n\n"
+                    + "Change-Id: I[a-f0-9]+\n"));
+    assertThat(revertChanges.get(2).current().commit(false).message)
+        .matches(
+            Pattern.compile(
+                "Revert \"third change\"\n\n"
+                    + "This reverts commit [a-f0-9]+\\.\n\n"
+                    + "Change-Id: I[a-f0-9]+\n"));
+
+    // Relationships
     String sha1FirstChange = resultCommits.get(0).getCommit().getName();
     String sha1ThirdChange = resultCommits.get(2).getCommit().getName();
     String sha1SecondRevert = revertChanges.get(2).current().commit(false).commit;
@@ -1231,9 +1290,6 @@
         .isEqualTo(sha1ThirdChange);
     assertThat(revertChanges.get(1).current().commit(false).parents.get(0).commit)
         .isEqualTo(sha1SecondRevert);
-
-    assertThat(revertChanges).hasSize(3);
-    assertThat(gApi.changes().id(revertChanges.get(1).id()).current().related().changes).hasSize(2);
   }
 
   @Test
@@ -1448,34 +1504,6 @@
     return result;
   }
 
-  private void addPureRevertSubmitRule() throws Exception {
-    modifySubmitRules(
-        "submit_rule(submit(R)) :- \n"
-            + "gerrit:pure_revert(1), \n"
-            + "!,"
-            + "gerrit:uploader(U), \n"
-            + "R = label('Is-Pure-Revert', ok(U)).\n"
-            + "submit_rule(submit(R)) :- \n"
-            + "gerrit:pure_revert(U), \n"
-            + "U \\= 1,"
-            + "R = label('Is-Pure-Revert', need(_)). \n\n");
-  }
-
-  private void modifySubmitRules(String newContent) throws Exception {
-    try (Repository repo = repoManager.openRepository(project);
-        TestRepository<Repository> testRepo = new TestRepository<>(repo)) {
-      testRepo
-          .branch(RefNames.REFS_CONFIG)
-          .commit()
-          .author(admin.newIdent())
-          .committer(admin.newIdent())
-          .add("rules.pl", newContent)
-          .message("Modify rules.pl")
-          .create();
-    }
-    projectCache.evict(project);
-  }
-
   private List<ChangeApi> getChangeApis(RevertSubmissionInfo revertSubmissionInfo)
       throws Exception {
     List<ChangeApi> results = new ArrayList<>();
diff --git a/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementIT.java b/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementIT.java
index 242c278..ab2f358 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementIT.java
@@ -33,6 +33,7 @@
 import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.UseTimezone;
 import com.google.gerrit.acceptance.VerifyNoPiiInChangeNotes;
 import com.google.gerrit.acceptance.testsuite.change.IndexOperations;
@@ -424,6 +425,26 @@
   }
 
   @Test
+  public void checkSubmitRequirement_verifiesUploader() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    voteLabel(changeId, "Code-Review", 2);
+    TestAccount anotherUser = accountCreator.createValid("anotherUser");
+
+    SubmitRequirementInput in =
+        createSubmitRequirementInput(
+            "Foo", /* submittabilityExpression= */ "uploader:" + anotherUser.id());
+    SubmitRequirementResultInfo result = gApi.changes().id(changeId).checkSubmitRequirement(in);
+    assertThat(result.status).isEqualTo(SubmitRequirementResultInfo.Status.UNSATISFIED);
+
+    in =
+        createSubmitRequirementInput(
+            "Foo", /* submittabilityExpression= */ "uploader:" + r.getChange().change().getOwner());
+    result = gApi.changes().id(changeId).checkSubmitRequirement(in);
+    assertThat(result.status).isEqualTo(SubmitRequirementResultInfo.Status.SATISFIED);
+  }
+
+  @Test
   public void submitRequirement_withLabelEqualsMax() throws Exception {
     configSubmitRequirement(
         project,
diff --git a/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementPredicateIT.java b/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementPredicateIT.java
index a443739..2fe7038 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementPredicateIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementPredicateIT.java
@@ -17,26 +17,45 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.server.project.testing.TestLabels.codeReview;
 import static com.google.gerrit.server.project.testing.TestLabels.label;
 import static com.google.gerrit.server.project.testing.TestLabels.value;
+import static org.eclipse.jgit.lib.Constants.HEAD;
 
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.Sandboxed;
+import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.UseTimezone;
 import com.google.gerrit.acceptance.VerifyNoPiiInChangeNotes;
 import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
 import com.google.gerrit.acceptance.testsuite.change.ChangeOperations;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.entities.SubmitRequirementExpression;
 import com.google.gerrit.entities.SubmitRequirementExpressionResult;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.server.project.SubmitRequirementsEvaluator;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.merge.MergeStrategy;
+import org.eclipse.jgit.merge.ThreeWayMerger;
+import org.eclipse.jgit.revwalk.RevCommit;
 import org.junit.Before;
 import org.junit.Test;
 
@@ -198,6 +217,293 @@
         "distinctvoters:\"[Code-Review,Custom-Label,Custom-Label2],value=1,count>2\"", c1);
   }
 
+  @Test
+  public void hasSubmoduleUpdate_withSubmoduleChangeInParent1() throws Exception {
+    ObjectId initial = repo().exactRef(HEAD).getLeaf().getObjectId();
+    PushOneCommit.Result r1 = createGitSubmoduleCommit("refs/for/master");
+    testRepo.reset(initial);
+    PushOneCommit.Result r2 = createNormalCommit("refs/for/master", "file1");
+    PushOneCommit.Result merge =
+        createMergeCommitChange(
+            "refs/for/master",
+            r1.getCommit(),
+            r2.getCommit(),
+            mergeAndGetTreeId(r1.getCommit(), r2.getCommit()));
+
+    assertNotMatching("has:submodule-update,base=1", merge.getChange().getId());
+    assertMatching("has:submodule-update,base=2", merge.getChange().getId());
+    assertNotMatching("has:submodule-update", merge.getChange().getId());
+  }
+
+  @Test
+  public void hasSubmoduleUpdate_withSubmoduleChangeInParent2() throws Exception {
+    ObjectId initial = repo().exactRef(HEAD).getLeaf().getObjectId();
+    PushOneCommit.Result r1 = createNormalCommit("refs/for/master", "file1");
+    testRepo.reset(initial);
+    PushOneCommit.Result r2 = createGitSubmoduleCommit("refs/for/master");
+    PushOneCommit.Result merge =
+        createMergeCommitChange(
+            "refs/for/master",
+            r1.getCommit(),
+            r2.getCommit(),
+            mergeAndGetTreeId(r1.getCommit(), r2.getCommit()));
+
+    assertMatching("has:submodule-update,base=1", merge.getChange().getId());
+    assertNotMatching("has:submodule-update,base=2", merge.getChange().getId());
+    assertNotMatching("has:submodule-update", merge.getChange().getId());
+  }
+
+  @Test
+  public void hasSubmoduleUpdate_withoutSubmoduleChange_doesNotMatch() throws Exception {
+    ObjectId initial = repo().exactRef(HEAD).getLeaf().getObjectId();
+    PushOneCommit.Result r1 = createNormalCommit("refs/for/master", "file1");
+    testRepo.reset(initial);
+    PushOneCommit.Result r2 = createNormalCommit("refs/for/master", "file2");
+    PushOneCommit.Result merge =
+        createMergeCommitChange(
+            "refs/for/master",
+            r1.getCommit(),
+            r2.getCommit(),
+            mergeAndGetTreeId(r1.getCommit(), r2.getCommit()));
+
+    assertNotMatching("has:submodule-update,base=1", merge.getChange().getId());
+    assertNotMatching("has:submodule-update,base=2", merge.getChange().getId());
+    assertNotMatching("has:submodule-update", merge.getChange().getId());
+  }
+
+  @Test
+  public void hasSubmoduleUpdate_withBaseParamGreaterThanParentCount_doesNotMatch()
+      throws Exception {
+    ObjectId initial = repo().exactRef(HEAD).getLeaf().getObjectId();
+    PushOneCommit.Result r1 = createNormalCommit("refs/for/master", "file1");
+    testRepo.reset(initial);
+    PushOneCommit.Result r2 = createGitSubmoduleCommit("refs/for/master");
+    PushOneCommit.Result merge =
+        createMergeCommitChange(
+            "refs/for/master",
+            r1.getCommit(),
+            r2.getCommit(),
+            mergeAndGetTreeId(r1.getCommit(), r2.getCommit()));
+
+    assertNotMatching("has:submodule-update,base=3", merge.getChange().getId());
+  }
+
+  @Test
+  public void hasSubmoduleUpdate_withWrongArgs_throws() {
+    assertError(
+        "has:submodule-update,base=xyz",
+        changeOperations.newChange().project(project).create(),
+        "failed to parse the parent number xyz: For input string: \"xyz\"");
+    assertError(
+        "has:submodule-update,base=1,arg=foo",
+        changeOperations.newChange().project(project).create(),
+        "wrong number of arguments for the has:submodule-update operator");
+    assertError(
+        "has:submodule-update,base",
+        changeOperations.newChange().project(project).create(),
+        "unexpected base value format");
+  }
+
+  @Test
+  public void nonContributorLabelVote_match() throws Exception {
+    requestScopeOperations.setApiUser(user.id());
+    TestRepository<InMemoryRepository> clonedRepo = cloneProject(project, user);
+    PushOneCommit.Result r1 =
+        pushFactory
+            .create(user.newIdent(), clonedRepo, "Subject", "file.txt", "text")
+            .to("refs/for/master");
+
+    Change.Id cId = r1.getChange().getId();
+
+    ChangeInfo changeInfo = gApi.changes().id(r1.getChangeId()).get();
+
+    // Assert on uploader, committer and author
+    assertUploader(changeInfo, user.email());
+    assertCommitter(changeInfo, user.email());
+    assertAuthor(changeInfo, user.email());
+
+    // Vote from admin (a.k.a. non uploader/committer/author) matches
+    requestScopeOperations.setApiUser(admin.id());
+    approve(cId.toString());
+    assertMatching("label:Code-Review=+2,user=non_contributor", cId);
+    // Also make sure magic label votes and > operator work
+    assertMatching("label:Code-Review=MAX,user=non_contributor", cId);
+    assertMatching("label:Code-Review>+1,user=non_contributor", cId);
+  }
+
+  @Test
+  public void nonContributorLabelVote_voteFromUploader_doesNotMatch() throws Exception {
+    PushOneCommit.Result r1 = createNormalCommit(user.newIdent(), "refs/for/master", "file1");
+
+    ChangeInfo changeInfo = gApi.changes().id(r1.getChangeId()).get();
+    assertUploader(changeInfo, admin.email());
+
+    // Vote from admin (a.k.a. uploader) does not match
+    requestScopeOperations.setApiUser(admin.id());
+    approve(r1.getChangeId());
+    assertNotMatching("label:Code-Review=+2,user=non_contributor", r1.getChange().getId());
+  }
+
+  @Test
+  @Sandboxed
+  public void nonContributorLabelVote_voteFromAuthor_doesNotMatch() throws Exception {
+    Account.Id authorId =
+        accountOperations
+            .newAccount()
+            .fullname("author")
+            .preferredEmail("authoremail@example.com")
+            .create();
+    Account.Id committerId =
+        accountOperations
+            .newAccount()
+            .fullname("committer")
+            .preferredEmail("committeremail@example.com")
+            .create();
+
+    Change.Id changeId =
+        changeOperations.newChange().author(authorId).committer(committerId).create();
+    ChangeInfo changeInfo = gApi.changes().id(changeId.get()).get();
+    assertAuthor(changeInfo, "authoremail@example.com");
+
+    allowLabelPermission(
+        codeReview().getName(), RefNames.REFS_HEADS + "*", REGISTERED_USERS, -2, +2);
+
+    // Vote from author does not match
+    requestScopeOperations.setApiUser(authorId);
+    approve(changeId.toString());
+    assertNotMatching("label:Code-Review=+2,user=non_contributor", changeId);
+  }
+
+  @Test
+  public void nonContributorLabelVote_voteFromCommitter_doesNotMatch() throws Exception {
+    Account.Id authorId =
+        accountOperations
+            .newAccount()
+            .fullname("author")
+            .preferredEmail("authoremail@example.com")
+            .create();
+    Account.Id committerId =
+        accountOperations
+            .newAccount()
+            .fullname("committer")
+            .preferredEmail("committeremail@example.com")
+            .create();
+
+    Change.Id changeId =
+        changeOperations.newChange().author(authorId).committer(committerId).create();
+    ChangeInfo changeInfo = gApi.changes().id(changeId.get()).get();
+    assertCommitter(changeInfo, "committeremail@example.com");
+
+    allowLabelPermission(
+        codeReview().getName(), RefNames.REFS_HEADS + "*", REGISTERED_USERS, -2, +2);
+
+    // Vote from committer does not match
+    requestScopeOperations.setApiUser(committerId);
+    approve(changeId.toString());
+    assertNotMatching("label:Code-Review=+2,user=non_contributor", changeId);
+  }
+
+  @Test
+  public void nonContributorLabelVote_uploaderAndAuthorDifferent() throws Exception {
+    TestRepository<InMemoryRepository> clonedRepo = cloneProject(project, admin);
+    PushOneCommit.Result r1 =
+        pushFactory
+            .create(user.newIdent(), clonedRepo, "Subject", "file.txt", "text")
+            .to("refs/for/master");
+
+    requestScopeOperations.setApiUser(admin.id());
+    ChangeInfo changeInfo = gApi.changes().id(r1.getChangeId()).get();
+    assertUploader(changeInfo, admin.email());
+    assertAuthor(changeInfo, user.email());
+
+    allowLabelPermission(
+        codeReview().getName(), RefNames.REFS_HEADS + "*", REGISTERED_USERS, -2, +2);
+
+    // Vote from admin (a.k.a. uploader) does not match
+    requestScopeOperations.setApiUser(user.id());
+    approve(r1.getChangeId());
+    assertNotMatching("label:Code-Review=+2,user=non_contributor", r1.getChange().getId());
+
+    // Vote from user (a.k.a. author) does not match
+    requestScopeOperations.setApiUser(admin.id());
+    approve(r1.getChangeId());
+    assertNotMatching("label:Code-Review=+2,user=non_contributor", r1.getChange().getId());
+
+    // Vote from user2 (a.k.a. non-author and non-uploader) matches
+    TestAccount user2 = accountCreator.create();
+    requestScopeOperations.setApiUser(user2.id());
+    approve(r1.getChangeId());
+    assertMatching("label:Code-Review=+2,user=non_contributor", r1.getChange().getId());
+  }
+
+  private static void assertUploader(ChangeInfo changeInfo, String email) {
+    assertThat(changeInfo.revisions.get(changeInfo.currentRevision).uploader.email)
+        .isEqualTo(email);
+  }
+
+  private static void assertCommitter(ChangeInfo changeInfo, String email) {
+    assertThat(changeInfo.revisions.get(changeInfo.currentRevision).commit.committer.email)
+        .isEqualTo(email);
+  }
+
+  private static void assertAuthor(ChangeInfo changeInfo, String email) {
+    assertThat(changeInfo.revisions.get(changeInfo.currentRevision).commit.author.email)
+        .isEqualTo(email);
+  }
+
+  private void allowLabelPermission(
+      String labelName, String refPattern, AccountGroup.UUID group, int minVote, int maxVote) {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allowLabel(labelName).ref(refPattern).group(group).range(minVote, maxVote))
+        .update();
+  }
+
+  private PushOneCommit.Result createGitSubmoduleCommit(String ref) throws Exception {
+    return pushFactory
+        .create(admin.newIdent(), testRepo, "subject", ImmutableMap.of())
+        .addGitSubmodule(
+            "modules/module-a", ObjectId.fromString("19f1787342cb15d7e82a762f6b494e91ccb4dd34"))
+        .to(ref);
+  }
+
+  private PushOneCommit.Result createNormalCommit(
+      PersonIdent personIdent, String ref, String fileName) throws Exception {
+    return pushFactory
+        .create(personIdent, testRepo, "subject", ImmutableMap.of(fileName, fileName))
+        .to(ref);
+  }
+
+  private PushOneCommit.Result createNormalCommit(String ref, String fileName) throws Exception {
+    return pushFactory
+        .create(admin.newIdent(), testRepo, "subject", ImmutableMap.of(fileName, fileName))
+        .to(ref);
+  }
+
+  private PushOneCommit.Result createMergeCommitChange(
+      String ref, RevCommit parent1, RevCommit parent2, @Nullable ObjectId treeId)
+      throws Exception {
+    PushOneCommit m =
+        pushFactory
+            .create(admin.newIdent(), testRepo)
+            .setParents(ImmutableList.of(parent1, parent2));
+    if (treeId != null) {
+      m.setTopLevelTreeId(treeId);
+    }
+    PushOneCommit.Result result = m.to(ref);
+    result.assertOkStatus();
+    return result;
+  }
+
+  private ObjectId mergeAndGetTreeId(RevCommit c1, RevCommit c2) throws Exception {
+    ThreeWayMerger threeWayMerger = MergeStrategy.RESOLVE.newMerger(repo(), true);
+    threeWayMerger.setBase(c1.getParent(0));
+    boolean mergeResult = threeWayMerger.merge(c1, c2);
+    assertThat(mergeResult).isTrue();
+    return threeWayMerger.getResultTreeId();
+  }
+
   private void assertMatching(String requirement, Change.Id change) {
     assertThat(evaluate(requirement, change).status())
         .isEqualTo(SubmitRequirementExpressionResult.Status.PASS);
@@ -208,6 +514,12 @@
         .isEqualTo(SubmitRequirementExpressionResult.Status.FAIL);
   }
 
+  private void assertError(String requirement, Change.Id change, String errorMessage) {
+    SubmitRequirementExpressionResult result = evaluate(requirement, change);
+    assertThat(result.status()).isEqualTo(SubmitRequirementExpressionResult.Status.ERROR);
+    assertThat(result.errorMessage().get()).isEqualTo(errorMessage);
+  }
+
   private SubmitRequirementExpressionResult evaluate(String requirement, Change.Id change) {
     ChangeData cd = changeDataFactory.create(project, change);
     return submitRequirementsEvaluator.evaluateExpression(
diff --git a/javatests/com/google/gerrit/acceptance/api/change/SubmitTypeRuleIT.java b/javatests/com/google/gerrit/acceptance/api/change/SubmitTypeRuleIT.java
index a9afcbc..308e4e0 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/SubmitTypeRuleIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/SubmitTypeRuleIT.java
@@ -21,6 +21,7 @@
 import static com.google.gerrit.extensions.client.SubmitType.MERGE_IF_NECESSARY;
 import static com.google.gerrit.extensions.client.SubmitType.REBASE_ALWAYS;
 import static com.google.gerrit.extensions.client.SubmitType.REBASE_IF_NECESSARY;
+import static com.google.gerrit.server.project.ProjectConfig.RULES_PL_FILE;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.common.collect.ImmutableList;
@@ -60,8 +61,6 @@
   }
 
   private class RulesPl extends VersionedMetaData {
-    private static final String FILENAME = "rules.pl";
-
     private String rule;
 
     @Override
@@ -71,7 +70,7 @@
 
     @Override
     protected void onLoad() throws IOException, ConfigInvalidException {
-      rule = readUTF8(FILENAME);
+      rule = readUTF8(RULES_PL_FILE);
     }
 
     @Override
@@ -84,7 +83,7 @@
         throw new ConfigInvalidException("Invalid submit type rule", e);
       }
 
-      saveUTF8(FILENAME, rule);
+      saveUTF8(RULES_PL_FILE, rule);
       return true;
     }
   }
diff --git a/javatests/com/google/gerrit/acceptance/api/group/GroupIndexerIT.java b/javatests/com/google/gerrit/acceptance/api/group/GroupIndexerIT.java
index ece46c5..98ed56c 100644
--- a/javatests/com/google/gerrit/acceptance/api/group/GroupIndexerIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/group/GroupIndexerIT.java
@@ -14,12 +14,14 @@
 
 package com.google.gerrit.acceptance.api.group;
 
+import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.gerrit.server.group.testing.InternalGroupSubject.internalGroups;
-import static com.google.gerrit.truth.ListSubject.assertThat;
 import static com.google.gerrit.truth.OptionalSubject.assertThat;
 
+import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.exceptions.NoSuchGroupException;
@@ -34,13 +36,12 @@
 import com.google.gerrit.server.index.group.GroupIndexer;
 import com.google.gerrit.server.query.group.InternalGroupQuery;
 import com.google.gerrit.testing.InMemoryTestEnvironment;
-import com.google.gerrit.truth.ListSubject;
 import com.google.gerrit.truth.OptionalSubject;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import java.io.IOException;
-import java.util.List;
 import java.util.Optional;
+import java.util.Set;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.junit.Rule;
 import org.junit.Test;
@@ -53,6 +54,7 @@
   @Inject private GroupCache groupCache;
   @Inject @ServerInitiated private GroupsUpdate groupsUpdate;
   @Inject private Provider<InternalGroupQuery> groupQueryProvider;
+  @Inject private GroupOperations groupOperations;
 
   @Test
   public void indexingUpdatesTheIndex() throws Exception {
@@ -66,8 +68,10 @@
 
     groupIndexer.index(groupUuid);
 
-    List<InternalGroup> parentGroups = groupQueryProvider.get().bySubgroup(subgroupUuid);
-    assertThatGroups(parentGroups).onlyElement().groupUuid().isEqualTo(groupUuid);
+    Set<AccountGroup.UUID> parentGroups =
+        groupQueryProvider.get().bySubgroups(ImmutableSet.of(subgroupUuid)).get(subgroupUuid);
+    assertThat(parentGroups).hasSize(1);
+    assertThat(parentGroups).containsExactly(groupUuid);
   }
 
   @Test
@@ -83,8 +87,10 @@
 
     groupIndexer.index(groupUuid);
 
-    List<InternalGroup> parentGroups = groupQueryProvider.get().bySubgroup(subgroupUuid);
-    assertThatGroups(parentGroups).onlyElement().groupUuid().isEqualTo(groupUuid);
+    Set<AccountGroup.UUID> parentGroups =
+        groupQueryProvider.get().bySubgroups(ImmutableSet.of(subgroupUuid)).get(subgroupUuid);
+    assertThat(parentGroups).hasSize(1);
+    assertThat(parentGroups).containsExactly(groupUuid);
   }
 
   @Test
@@ -111,8 +117,10 @@
 
     groupIndexer.reindexIfStale(groupUuid);
 
-    List<InternalGroup> parentGroups = groupQueryProvider.get().bySubgroup(subgroupUuid);
-    assertThatGroups(parentGroups).onlyElement().groupUuid().isEqualTo(groupUuid);
+    Set<AccountGroup.UUID> parentGroups =
+        groupQueryProvider.get().bySubgroups(ImmutableSet.of(subgroupUuid)).get(subgroupUuid);
+    assertThat(parentGroups).hasSize(1);
+    assertThat(parentGroups).containsExactly(groupUuid);
   }
 
   @Test
@@ -137,6 +145,20 @@
     assertWithMessage("Group should have been reindexed").that(reindexed).isTrue();
   }
 
+  @Test
+  public void getMultipleParents() throws Exception {
+    AccountGroup.UUID sub1 = groupOperations.newGroup().create();
+    AccountGroup.UUID sub2 = groupOperations.newGroup().create();
+    AccountGroup.UUID parent1 = groupOperations.newGroup().addSubgroup(sub1).create();
+    AccountGroup.UUID parent2 = groupOperations.newGroup().addSubgroup(sub2).create();
+    AccountGroup.UUID parent3 = groupOperations.newGroup().addSubgroup(sub2).create();
+
+    assertThat(groupQueryProvider.get().bySubgroups(ImmutableSet.of(sub1, sub2)))
+        .containsExactlyEntriesIn(
+            ImmutableMap.of(
+                sub1, ImmutableSet.of(parent1), sub2, ImmutableSet.of(parent2, parent3)));
+  }
+
   private AccountGroup.UUID createGroup(String name) throws RestApiException {
     GroupInfo group = gApi.groups().create(name).get();
     return AccountGroup.uuid(group.id);
@@ -164,9 +186,4 @@
       Optional<InternalGroup> updatedGroup) {
     return assertThat(updatedGroup, internalGroups());
   }
-
-  private static ListSubject<InternalGroupSubject, InternalGroup> assertThatGroups(
-      List<InternalGroup> parentGroups) {
-    return assertThat(parentGroups, internalGroups());
-  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/group/GroupsConsistencyIT.java b/javatests/com/google/gerrit/acceptance/api/group/GroupsConsistencyIT.java
index e6c3919..1607f09 100644
--- a/javatests/com/google/gerrit/acceptance/api/group/GroupsConsistencyIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/group/GroupsConsistencyIT.java
@@ -18,6 +18,7 @@
 import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowCapability;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.TestActionRefUpdateContext.testRefAction;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
@@ -90,7 +91,7 @@
     try (Repository repo = repoManager.openRepository(allUsers)) {
       RefUpdate ru = repo.updateRef(RefNames.REFS_GROUPNAMES);
       ru.setForceUpdate(true);
-      RefUpdate.Result result = ru.delete();
+      RefUpdate.Result result = testRefAction(() -> ru.delete());
       assertThat(result).isEqualTo(Result.FORCED);
     }
 
@@ -103,7 +104,7 @@
     try (Repository repo = repoManager.openRepository(allUsers)) {
       RefUpdate ru = repo.updateRef(RefNames.refsGroups(AccountGroup.uuid(g1.id)));
       ru.setForceUpdate(true);
-      RefUpdate.Result result = ru.delete();
+      RefUpdate.Result result = testRefAction(() -> ru.delete());
       assertThat(result).isEqualTo(Result.FORCED);
     }
 
@@ -117,7 +118,7 @@
       RefRename ru =
           repo.renameRef(
               RefNames.refsGroups(AccountGroup.uuid(g1.id)), RefNames.REFS_GROUPS + BOGUS_UUID);
-      RefUpdate.Result result = ru.rename();
+      RefUpdate.Result result = testRefAction(() -> ru.rename());
       assertThat(result).isEqualTo(Result.RENAMED);
     }
 
@@ -132,7 +133,7 @@
           repo.renameRef(
               RefNames.refsGroups(AccountGroup.uuid(g1.id)),
               RefNames.refsGroups(AccountGroup.uuid(BOGUS_UUID)));
-      RefUpdate.Result result = ru.rename();
+      RefUpdate.Result result = testRefAction(() -> ru.rename());
       assertThat(result).isEqualTo(Result.RENAMED);
     }
 
diff --git a/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java b/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
index 12f8506..6dbbe9a 100644
--- a/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
@@ -28,6 +28,7 @@
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static com.google.gerrit.testing.TestActionRefUpdateContext.testRefAction;
 import static com.google.gerrit.truth.MapSubject.assertThatMap;
 import static java.lang.annotation.ElementType.METHOD;
 import static java.lang.annotation.RetentionPolicy.RUNTIME;
@@ -113,6 +114,7 @@
 import java.util.Arrays;
 import java.util.Collection;
 import java.util.List;
+import java.util.Locale;
 import java.util.Map;
 import java.util.stream.Stream;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
@@ -462,7 +464,7 @@
   @Test
   public void createDuplicateInternalGroupCaseInsensitiveName() throws Exception {
     String dupGroupName = name("dupGroupA");
-    String dupGroupNameLowerCase = name("dupGroupA").toLowerCase();
+    String dupGroupNameLowerCase = name("dupGroupA").toLowerCase(Locale.US);
     gApi.groups().create(dupGroupName);
     gApi.groups().create(dupGroupNameLowerCase);
     assertThat(gApi.groups().list().getAsMap().keySet()).contains(dupGroupName);
@@ -601,6 +603,20 @@
   }
 
   @Test
+  public void getGroupFromMetaId() throws Exception {
+    AccountGroup.UUID uuid = groupOperations.newGroup().create();
+    InternalGroup preUpdateState = groupCache.get(uuid).get();
+    gApi.groups().id(uuid.toString()).description("New description");
+
+    InternalGroup postUpdateState = groupCache.get(uuid).get();
+    assertThat(postUpdateState).isNotEqualTo(preUpdateState);
+    assertThat(groupCache.getFromMetaId(uuid, preUpdateState.getRefState()))
+        .isEqualTo(preUpdateState);
+    assertThat(groupCache.getFromMetaId(uuid, postUpdateState.getRefState()))
+        .isEqualTo(postUpdateState);
+  }
+
+  @Test
   @GerritConfig(name = "groups.global:Anonymous-Users.name", value = "All Users")
   public void getSystemGroupByConfiguredName() throws Exception {
     GroupReference anonymousUsersGroup = systemGroupBackend.getGroup(ANONYMOUS_USERS);
@@ -1133,7 +1149,7 @@
       RefUpdate ru = repo.updateRef(RefNames.refsGroups(uuid));
       ru.setForceUpdate(true);
       ru.setNewObjectId(ObjectId.zeroId());
-      assertThat(ru.delete()).isEqualTo(RefUpdate.Result.FORCED);
+      testRefAction(() -> assertThat(ru.delete()).isEqualTo(RefUpdate.Result.FORCED));
     }
 
     // Reindex the group.
@@ -1343,7 +1359,7 @@
         updateRef.setExpectedOldObjectId(commit.toObjectId());
         updateRef.setNewObjectId(ObjectId.zeroId());
         updateRef.setForceUpdate(true);
-        assertThat(updateRef.delete()).isEqualTo(RefUpdate.Result.FORCED);
+        testRefAction(() -> assertThat(updateRef.delete()).isEqualTo(RefUpdate.Result.FORCED));
       }
 
       // refs/meta/group-names is only visible with ACCESS_DATABASE
@@ -1443,7 +1459,7 @@
       RefUpdate updateRef = repo.updateRef(groupRef);
       updateRef.setExpectedOldObjectId(commit.toObjectId());
       updateRef.setNewObjectId(emptyCommit);
-      assertThat(updateRef.forceUpdate()).isEqualTo(RefUpdate.Result.FORCED);
+      testRefAction(() -> assertThat(updateRef.forceUpdate()).isEqualTo(RefUpdate.Result.FORCED));
     }
     assertStaleGroupAndReindex(groupUuid);
 
@@ -1455,7 +1471,7 @@
       updateRef.setExpectedOldObjectId(commit.toObjectId());
       updateRef.setNewObjectId(ObjectId.zeroId());
       updateRef.setForceUpdate(true);
-      assertThat(updateRef.delete()).isEqualTo(RefUpdate.Result.FORCED);
+      testRefAction(() -> assertThat(updateRef.delete()).isEqualTo(RefUpdate.Result.FORCED));
     }
     assertStaleGroupAndReindex(groupUuid);
   }
@@ -1500,13 +1516,15 @@
       // then run the reindexer -> only the new group is reindexed.
       String groupName = "foo";
       AccountGroup.UUID groupUuid = AccountGroup.uuid(groupName + "-UUID");
-      groupsUpdate.createGroupInNoteDb(
-          InternalGroupCreation.builder()
-              .setGroupUUID(groupUuid)
-              .setNameKey(AccountGroup.nameKey(groupName))
-              .setId(AccountGroup.id(seq.nextGroupId()))
-              .build(),
-          GroupDelta.builder().build());
+      testRefAction(
+          () ->
+              groupsUpdate.createGroupInNoteDb(
+                  InternalGroupCreation.builder()
+                      .setGroupUUID(groupUuid)
+                      .setNameKey(AccountGroup.nameKey(groupName))
+                      .setId(AccountGroup.id(seq.nextGroupId()))
+                      .build(),
+                  GroupDelta.builder().build()));
       slaveGroupIndexer.run();
       groupIndexedCounter.assertReindexOf(groupUuid);
 
@@ -1522,7 +1540,7 @@
       try (Repository repo = repoManager.openRepository(allUsers)) {
         RefUpdate u = repo.updateRef(RefNames.refsGroups(groupUuid));
         u.setForceUpdate(true);
-        assertThat(u.delete()).isEqualTo(RefUpdate.Result.FORCED);
+        testRefAction(() -> assertThat(u.delete()).isEqualTo(RefUpdate.Result.FORCED));
       }
       slaveGroupIndexer.run();
       groupIndexedCounter.assertReindexOf(groupUuid);
@@ -1608,7 +1626,7 @@
       RefUpdate updateRef = r.updateRef(ref);
       updateRef.setExpectedOldObjectId(ObjectId.zeroId());
       updateRef.setNewObjectId(emptyCommit);
-      assertThat(updateRef.update(rw)).isEqualTo(RefUpdate.Result.NEW);
+      testRefAction(() -> assertThat(updateRef.update(rw)).isEqualTo(RefUpdate.Result.NEW));
     }
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/api/plugin/PluginIT.java b/javatests/com/google/gerrit/acceptance/api/plugin/PluginIT.java
index 18eca27..462d0a5 100644
--- a/javatests/com/google/gerrit/acceptance/api/plugin/PluginIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/plugin/PluginIT.java
@@ -24,6 +24,7 @@
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.RawInputUtil;
 import com.google.gerrit.extensions.api.plugins.InstallPluginInput;
 import com.google.gerrit.extensions.api.plugins.PluginApi;
@@ -198,6 +199,7 @@
     return pluginJarContent(plugin);
   }
 
+  @Nullable
   private String pluginVersion(String plugin) {
     String name = pluginName(plugin);
     if (name.endsWith("empty")) {
@@ -210,6 +212,7 @@
     return dash > 0 ? name.substring(dash + 1) : "";
   }
 
+  @Nullable
   private String pluginApiVersion(String plugin) {
     if (plugin.endsWith("normal.jar")) {
       return "2.16.19-SNAPSHOT";
diff --git a/javatests/com/google/gerrit/acceptance/api/project/AccessIT.java b/javatests/com/google/gerrit/acceptance/api/project/AccessIT.java
index 7c33ec2..a2f1f46 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/AccessIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/AccessIT.java
@@ -22,6 +22,7 @@
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.server.schema.AclUtil.grant;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static com.google.gerrit.testing.TestActionRefUpdateContext.testRefAction;
 import static com.google.gerrit.truth.ConfigSubject.assertThat;
 import static com.google.gerrit.truth.MapSubject.assertThatMap;
 import static java.util.Arrays.asList;
@@ -67,6 +68,7 @@
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.schema.GrantRevertPermission;
 import com.google.inject.Inject;
+import java.util.Arrays;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -74,6 +76,7 @@
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.RefUpdate;
 import org.eclipse.jgit.lib.RefUpdate.Result;
 import org.eclipse.jgit.lib.Repository;
@@ -238,7 +241,7 @@
         Registration registration = newFileHistoryWebLink()) {
       RefUpdate u = repo.updateRef(RefNames.REFS_CONFIG);
       u.setForceUpdate(true);
-      assertThat(u.delete()).isEqualTo(Result.FORCED);
+      testRefAction(() -> assertThat(u.delete()).isEqualTo(Result.FORCED));
 
       // This should not crash.
       pApi().access();
@@ -263,6 +266,38 @@
   }
 
   @Test
+  public void addDuplicatedAccessSection_doesNotAddDuplicateEntry() throws Exception {
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo = createDefaultAccessSectionInfo();
+
+    // Update project config. Record the file content and the refs_config object ID
+    accessInput.add.put(REFS_HEADS, accessSectionInfo);
+    pApi().access(accessInput);
+    ObjectId refsConfigId =
+        projectOperations.project(newProjectName).getHead(RefNames.REFS_CONFIG).getId();
+    List<String> projectConfigLines =
+        Arrays.asList(projectOperations.project(newProjectName).getConfig().toText().split("\n"));
+    assertThat(projectConfigLines)
+        .containsExactly(
+            "[submit]",
+            "\taction = inherit",
+            "[access \"refs/heads/*\"]",
+            "\tlabel-Code-Review = deny group Registered Users",
+            "\tlabel-Code-Review = -1..+1 group Project Owners",
+            "\tpush = group Registered Users");
+
+    // Apply the same update once more. Make sure that the file content and the ref did not change
+    pApi().access(accessInput);
+
+    List<String> newProjectConfigLines =
+        Arrays.asList(projectOperations.project(newProjectName).getConfig().toText().split("\n"));
+    ObjectId newRefsConfigId =
+        projectOperations.project(newProjectName).getHead(RefNames.REFS_CONFIG).getId();
+    assertThat(projectConfigLines).isEqualTo(newProjectConfigLines);
+    assertThat(refsConfigId).isEqualTo(newRefsConfigId);
+  }
+
+  @Test
   public void addAccessSectionForPluginPermission() throws Exception {
     try (Registration registration =
         extensionRegistry
@@ -325,6 +360,79 @@
   }
 
   @Test
+  public void addAccessSectionWithInvalidLabelRange_minGreaterThanMax() throws Exception {
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo = createDefaultAccessSectionInfo();
+
+    PermissionInfo permissionInfo = newPermissionInfo();
+    PermissionRuleInfo permissionRuleInfo =
+        new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
+    permissionInfo.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), permissionRuleInfo);
+    permissionRuleInfo.min = 1;
+    permissionRuleInfo.max = -1;
+    accessSectionInfo.permissions.put("label-Code-Review", permissionInfo);
+
+    accessInput.add.put(REFS_HEADS, accessSectionInfo);
+    BadRequestException ex =
+        assertThrows(BadRequestException.class, () -> pApi().access(accessInput));
+    assertThat(ex)
+        .hasMessageThat()
+        .isEqualTo(
+            String.format(
+                "Invalid range for permission rule that assigns label-Code-Review to group %s"
+                    + " on ref refs/heads/*: 1..-1 (min must be <= max)",
+                SystemGroupBackend.REGISTERED_USERS.get()));
+  }
+
+  @Test
+  public void addAccessSectionWithInvalidLabelRange_minSetMaxMissing() throws Exception {
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo = createDefaultAccessSectionInfo();
+
+    PermissionInfo permissionInfo = newPermissionInfo();
+    PermissionRuleInfo permissionRuleInfo =
+        new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
+    permissionInfo.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), permissionRuleInfo);
+    permissionRuleInfo.min = -1;
+    accessSectionInfo.permissions.put("label-Code-Review", permissionInfo);
+
+    accessInput.add.put(REFS_HEADS, accessSectionInfo);
+    BadRequestException ex =
+        assertThrows(BadRequestException.class, () -> pApi().access(accessInput));
+    assertThat(ex)
+        .hasMessageThat()
+        .isEqualTo(
+            String.format(
+                "Invalid range for permission rule that assigns label-Code-Review to group %s"
+                    + " on ref refs/heads/*: -1.. (max is required if min is set)",
+                SystemGroupBackend.REGISTERED_USERS.get()));
+  }
+
+  @Test
+  public void addAccessSectionWithInvalidLabelRange_maxSetMinMissing() throws Exception {
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo = createDefaultAccessSectionInfo();
+
+    PermissionInfo permissionInfo = newPermissionInfo();
+    PermissionRuleInfo permissionRuleInfo =
+        new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
+    permissionInfo.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), permissionRuleInfo);
+    permissionRuleInfo.max = 1;
+    accessSectionInfo.permissions.put("label-Code-Review", permissionInfo);
+
+    accessInput.add.put(REFS_HEADS, accessSectionInfo);
+    BadRequestException ex =
+        assertThrows(BadRequestException.class, () -> pApi().access(accessInput));
+    assertThat(ex)
+        .hasMessageThat()
+        .isEqualTo(
+            String.format(
+                "Invalid range for permission rule that assigns label-Code-Review to group %s"
+                    + " on ref refs/heads/*: ..1 (min is required if max is set)",
+                SystemGroupBackend.REGISTERED_USERS.get()));
+  }
+
+  @Test
   public void createAccessChangeNop() throws Exception {
     ProjectAccessInput accessInput = newProjectAccessInput();
     assertThrows(BadRequestException.class, () -> pApi().accessChange(accessInput));
@@ -335,7 +443,7 @@
     try (Repository repo = repoManager.openRepository(newProjectName)) {
       RefUpdate ru = repo.updateRef(RefNames.REFS_CONFIG);
       ru.setForceUpdate(true);
-      assertThat(ru.delete()).isEqualTo(Result.FORCED);
+      testRefAction(() -> assertThat(ru.delete()).isEqualTo(Result.FORCED));
 
       ProjectAccessInput accessInput = newProjectAccessInput();
       AccessSectionInfo accessSection = newAccessSectionInfo();
diff --git a/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java b/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java
index b0de1c1..5c46fec 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java
@@ -21,6 +21,7 @@
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.deny;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.permissionKey;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static com.google.gerrit.testing.TestActionRefUpdateContext.testRefAction;
 
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
@@ -388,7 +389,7 @@
     try (Repository repo = repoManager.openRepository(normalProject)) {
       RefUpdate u = repo.updateRef(RefNames.REFS_HEADS + "master");
       u.setForceUpdate(true);
-      assertThat(u.delete()).isEqualTo(Result.FORCED);
+      testRefAction(() -> assertThat(u.delete()).isEqualTo(Result.FORCED));
     }
     AccessCheckInput input = new AccessCheckInput();
     input.account = privilegedUser.email();
diff --git a/javatests/com/google/gerrit/acceptance/api/project/ProjectConfigIT.java b/javatests/com/google/gerrit/acceptance/api/project/ProjectConfigIT.java
index 168819c..28a0196 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/ProjectConfigIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/ProjectConfigIT.java
@@ -18,6 +18,7 @@
 import static com.google.gerrit.acceptance.GitUtil.fetch;
 
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
@@ -25,17 +26,22 @@
 import com.google.gerrit.common.RawInputUtil;
 import com.google.gerrit.entities.LabelFunction;
 import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementExpression;
 import com.google.gerrit.extensions.api.changes.PublishChangeEditInput;
 import com.google.gerrit.extensions.client.ChangeStatus;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ChangeInput;
 import com.google.gerrit.git.ObjectIds;
+import com.google.gerrit.server.group.SystemGroupBackend;
+import com.google.gerrit.server.project.GroupList;
 import com.google.gerrit.server.project.LabelConfigValidator;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.inject.Inject;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.AnyObjectId;
 import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
 import org.junit.Test;
 
 public class ProjectConfigIT extends AbstractDaemonTest {
@@ -131,6 +137,102 @@
   }
 
   @Test
+  public void rejectCreatingLabelWithInvalidFunction() throws Exception {
+    fetchRefsMetaConfig();
+    PushOneCommit push =
+        pushFactory.create(
+            admin.newIdent(),
+            testRepo,
+            "Test Change",
+            ProjectConfig.PROJECT_CONFIG,
+            "[label \"Foo\"]\n  function = INVALID");
+    PushOneCommit.Result r = push.to(RefNames.REFS_CONFIG);
+    r.assertErrorStatus(
+        String.format("commit %s: invalid project configuration", abbreviateName(r.getCommit())));
+    r.assertMessage(
+        String.format(
+            "ERROR: commit %s: invalid project configuration:\n"
+                + "ERROR: commit %s:   project.config: Invalid function for label \"foo\"."
+                + " Valid names are: NoBlock, NoOp, PatchSetLock",
+            abbreviateName(r.getCommit()), abbreviateName(r.getCommit())));
+  }
+
+  @Test
+  public void rejectCreatingLabelPermissionWithInvalidRange_minGreaterThanMax() throws Exception {
+    fetchRefsMetaConfig();
+    PushOneCommit push =
+        pushFactory.create(
+            admin.newIdent(),
+            testRepo,
+            "Test Change",
+            ImmutableMap.of(
+                ProjectConfig.PROJECT_CONFIG,
+                "[access \"refs/heads/*\"]\n  label-Code-Review = 1..-1 group Registered-Users",
+                GroupList.FILE_NAME,
+                String.format("%s\tRegistered-Users", SystemGroupBackend.REGISTERED_USERS.get())));
+    PushOneCommit.Result r = push.to(RefNames.REFS_CONFIG);
+    r.assertErrorStatus(
+        String.format("commit %s: invalid project configuration", abbreviateName(r.getCommit())));
+    r.assertMessage(
+        String.format(
+            "ERROR: commit %s: invalid project configuration:\n"
+                + "ERROR: commit %s:   project.config: invalid rule in"
+                + " access.refs/heads/*.label-Code-Review:"
+                + " invalid range in rule: 1..-1 group Registered-Users",
+            abbreviateName(r.getCommit()), abbreviateName(r.getCommit())));
+  }
+
+  @Test
+  public void rejectCreatingLabelPermissionWithInvalidRange_minSetMaxMissing() throws Exception {
+    fetchRefsMetaConfig();
+    PushOneCommit push =
+        pushFactory.create(
+            admin.newIdent(),
+            testRepo,
+            "Test Change",
+            ImmutableMap.of(
+                ProjectConfig.PROJECT_CONFIG,
+                "[access \"refs/heads/*\"]\n  label-Code-Review = -1.. group Registered-Users",
+                GroupList.FILE_NAME,
+                String.format("%s\tRegistered-Users", SystemGroupBackend.REGISTERED_USERS.get())));
+    PushOneCommit.Result r = push.to(RefNames.REFS_CONFIG);
+    r.assertErrorStatus(
+        String.format("commit %s: invalid project configuration", abbreviateName(r.getCommit())));
+    r.assertMessage(
+        String.format(
+            "ERROR: commit %s: invalid project configuration:\n"
+                + "ERROR: commit %s:   project.config: invalid rule in"
+                + " access.refs/heads/*.label-Code-Review:"
+                + " invalid range in rule: -1.. group Registered-Users",
+            abbreviateName(r.getCommit()), abbreviateName(r.getCommit())));
+  }
+
+  @Test
+  public void rejectCreatingLabelPermissionWithInvalidRange_maxSetMinMissing() throws Exception {
+    fetchRefsMetaConfig();
+    PushOneCommit push =
+        pushFactory.create(
+            admin.newIdent(),
+            testRepo,
+            "Test Change",
+            ImmutableMap.of(
+                ProjectConfig.PROJECT_CONFIG,
+                "[access \"refs/heads/*\"]\n  label-Code-Review = ..1 group Registered-Users",
+                GroupList.FILE_NAME,
+                String.format("%s\tRegistered-Users", SystemGroupBackend.REGISTERED_USERS.get())));
+    PushOneCommit.Result r = push.to(RefNames.REFS_CONFIG);
+    r.assertErrorStatus(
+        String.format("commit %s: invalid project configuration", abbreviateName(r.getCommit())));
+    r.assertMessage(
+        String.format(
+            "ERROR: commit %s: invalid project configuration:\n"
+                + "ERROR: commit %s:   project.config: invalid rule in"
+                + " access.refs/heads/*.label-Code-Review:"
+                + " invalid range in rule: ..1 group Registered-Users",
+            abbreviateName(r.getCommit()), abbreviateName(r.getCommit())));
+  }
+
+  @Test
   public void rejectSettingCopyMinScore() throws Exception {
     testRejectSettingLabelFlag(
         LabelConfigValidator.KEY_COPY_MIN_SCORE, /* value= */ true, "is:MIN");
@@ -389,6 +491,168 @@
   }
 
   @Test
+  public void rejectSubmitRequirement_duplicateDescriptionKeys() throws Exception {
+    fetchRefsMetaConfig();
+    PushOneCommit push =
+        pushFactory.create(
+            admin.newIdent(),
+            testRepo,
+            "Test Change",
+            ProjectConfig.PROJECT_CONFIG,
+            "[submit-requirement \"Foo\"]\n"
+                + "    description = description 1\n "
+                + "    submittableIf = label:Code-Review=MAX\n"
+                + "[submit-requirement \"Foo\"]\n"
+                + "    description = description 2\n");
+    PushOneCommit.Result r = push.to(RefNames.REFS_CONFIG);
+    r.assertErrorStatus(
+        String.format("commit %s: invalid project configuration", abbreviateName(r.getCommit())));
+    r.assertMessage(
+        String.format(
+            "ERROR: commit %s:   project.config: multiple definitions of description"
+                + " for submit requirement 'foo'",
+            abbreviateName(r.getCommit())));
+  }
+
+  @Test
+  public void rejectSubmitRequirement_duplicateApplicableIfKeys() throws Exception {
+    fetchRefsMetaConfig();
+    PushOneCommit push =
+        pushFactory.create(
+            admin.newIdent(),
+            testRepo,
+            "Test Change",
+            ProjectConfig.PROJECT_CONFIG,
+            "[submit-requirement \"Foo\"]\n "
+                + "   applicableIf = is:true\n  "
+                + "   submittableIf = label:Code-Review=MAX\n"
+                + "[submit-requirement \"Foo\"]\n"
+                + "   applicableIf = is:false\n");
+    PushOneCommit.Result r = push.to(RefNames.REFS_CONFIG);
+    r.assertErrorStatus(
+        String.format("commit %s: invalid project configuration", abbreviateName(r.getCommit())));
+    r.assertMessage(
+        String.format(
+            "ERROR: commit %s:   project.config: multiple definitions of applicableif"
+                + " for submit requirement 'foo'",
+            abbreviateName(r.getCommit())));
+  }
+
+  @Test
+  public void rejectSubmitRequirement_duplicateSubmittableIfKeys() throws Exception {
+    fetchRefsMetaConfig();
+    PushOneCommit push =
+        pushFactory.create(
+            admin.newIdent(),
+            testRepo,
+            "Test Change",
+            ProjectConfig.PROJECT_CONFIG,
+            "[submit-requirement \"Foo\"]\n"
+                + "    submittableIf = label:Code-Review=MAX\n"
+                + "[submit-requirement \"Foo\"]\n"
+                + "    submittableIf = label:Code-Review=MIN\n");
+    PushOneCommit.Result r = push.to(RefNames.REFS_CONFIG);
+    r.assertErrorStatus(
+        String.format("commit %s: invalid project configuration", abbreviateName(r.getCommit())));
+    r.assertMessage(
+        String.format(
+            "ERROR: commit %s:   project.config: multiple definitions of submittableif"
+                + " for submit requirement 'foo'",
+            abbreviateName(r.getCommit())));
+  }
+
+  @Test
+  public void rejectSubmitRequirement_duplicateOverrideIfKeys() throws Exception {
+    fetchRefsMetaConfig();
+    PushOneCommit push =
+        pushFactory.create(
+            admin.newIdent(),
+            testRepo,
+            "Test Change",
+            ProjectConfig.PROJECT_CONFIG,
+            "[submit-requirement \"Foo\"]\n"
+                + "  overrideIf = is:true\n "
+                + "  submittableIf = label:Code-Review=MAX\n"
+                + "[submit-requirement \"Foo\"]\n"
+                + "  overrideIf = is:false\n");
+    PushOneCommit.Result r = push.to(RefNames.REFS_CONFIG);
+    r.assertErrorStatus(
+        String.format("commit %s: invalid project configuration", abbreviateName(r.getCommit())));
+    r.assertMessage(
+        String.format(
+            "ERROR: commit %s:   project.config: multiple definitions of overrideif"
+                + " for submit requirement 'foo'",
+            abbreviateName(r.getCommit())));
+  }
+
+  @Test
+  public void rejectSubmitRequirement_duplicateCanOverrideInChildProjectsKey() throws Exception {
+    fetchRefsMetaConfig();
+    PushOneCommit push =
+        pushFactory.create(
+            admin.newIdent(),
+            testRepo,
+            "Test Change",
+            ProjectConfig.PROJECT_CONFIG,
+            "[submit-requirement \"Foo\"]\n"
+                + "    canOverrideInChildProjects = true\n"
+                + "    submittableIf = label:Code-Review=MAX\n"
+                + "[submit-requirement \"Foo\"]\n "
+                + "    canOverrideInChildProjects = false\n");
+    PushOneCommit.Result r = push.to(RefNames.REFS_CONFIG);
+    r.assertErrorStatus(
+        String.format("commit %s: invalid project configuration", abbreviateName(r.getCommit())));
+    r.assertMessage(
+        String.format(
+            "ERROR: commit %s:   project.config: multiple definitions of canoverrideinchildprojects"
+                + " for submit requirement 'foo'",
+            abbreviateName(r.getCommit())));
+  }
+
+  @Test
+  public void submitRequirementsAreParsed_forExistingDuplicateDefinitions() throws Exception {
+    // Duplicate submit requirement definitions are rejected on config change uploads. For setups
+    // already containing duplicate SR definitions, the server is able to parse the "submit
+    // requirements correctly"
+
+    RevCommit revision;
+    // Commit a change to the project config, bypassing server validation.
+    try (TestRepository<Repository> testRepo =
+        new TestRepository<>(repoManager.openRepository(project))) {
+      revision =
+          testRepo
+              .branch(RefNames.REFS_CONFIG)
+              .commit()
+              .add(
+                  ProjectConfig.PROJECT_CONFIG,
+                  "[submit-requirement \"Foo\"]\n"
+                      + "    canOverrideInChildProjects = true\n"
+                      + "    submittableIf = label:Code-Review=MAX\n"
+                      + "[submit-requirement \"Foo\"]\n "
+                      + "    canOverrideInChildProjects = false\n")
+              .parent(projectOperations.project(project).getHead(RefNames.REFS_CONFIG))
+              .create();
+    }
+
+    try (Repository git = repoManager.openRepository(project)) {
+      // Server is able to parse the config.
+      ProjectConfig cfg = projectConfigFactory.create(project);
+      cfg.load(git, revision);
+
+      // One of the two definitions takes precedence and overrides the other.
+      assertThat(cfg.getSubmitRequirementSections())
+          .containsExactly(
+              "Foo",
+              SubmitRequirement.builder()
+                  .setName("Foo")
+                  .setAllowOverrideInChildProjects(false)
+                  .setSubmittabilityExpression(
+                      SubmitRequirementExpression.create("label:Code-Review=MAX"))
+                  .build());
+    }
+  }
+
+  @Test
   public void testRejectChangingLabelFunction_toMaxWithBlock() throws Exception {
     testChangingLabelFunction(
         /* initialLabelFunction= */ LabelFunction.NO_BLOCK,
diff --git a/javatests/com/google/gerrit/acceptance/api/project/ProjectIndexerIT.java b/javatests/com/google/gerrit/acceptance/api/project/ProjectIndexerIT.java
index a625a70..4302b50 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/ProjectIndexerIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/ProjectIndexerIT.java
@@ -54,19 +54,19 @@
   @Inject private IndexOperations.Project projectIndexOperations;
 
   private static final ImmutableSet<String> FIELDS =
-      ImmutableSet.of(ProjectField.NAME.getName(), ProjectField.REF_STATE.getName());
+      ImmutableSet.of(ProjectField.NAME_SPEC.getName(), ProjectField.REF_STATE_SPEC.getName());
 
   @Test
   public void indexProject_indexesRefStateOfProjectAndParents() throws Exception {
     projectIndexer.index(project);
     ProjectIndex i = indexes.getSearchIndex();
-    assertThat(i.getSchema().hasField(ProjectField.REF_STATE)).isTrue();
+    assertThat(i.getSchema().hasField(ProjectField.REF_STATE_SPEC)).isTrue();
 
     Optional<FieldBundle> result =
         i.getRaw(project, QueryOptions.create(indexConfig, 0, 1, FIELDS));
 
     assertThat(result).isPresent();
-    Iterable<byte[]> refState = result.get().getValue(ProjectField.REF_STATE);
+    Iterable<byte[]> refState = result.get().getValue(ProjectField.REF_STATE_SPEC);
     assertThat(refState).isNotEmpty();
 
     Map<Project.NameKey, Collection<RefState>> states = RefState.parseStates(refState).asMap();
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
index f347e19..b570466 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
@@ -227,6 +227,66 @@
   }
 
   @Test
+  public void fileModeChangeIsIncludedInListFilesDiff() throws Exception {
+    String fileName = "file.txt";
+    PushOneCommit push =
+        pushFactory
+            .create(admin.newIdent(), testRepo, "Commit Subject", /* files= */ ImmutableMap.of())
+            .addFile(fileName, "content", /* fileMode= */ 0100644);
+    PushOneCommit.Result result = push.to("refs/for/master");
+    String commitRev1 = gApi.changes().id(result.getChangeId()).get().currentRevision;
+    push =
+        pushFactory
+            .create(admin.newIdent(), testRepo, result.getChangeId())
+            .addFile(fileName, "content", /* fileMode= */ 0100755);
+    result = push.to("refs/for/master");
+    String commitRev2 = gApi.changes().id(result.getChangeId()).get().currentRevision;
+
+    Map<String, FileInfo> changedFiles =
+        gApi.changes().id(result.getChangeId()).revision(commitRev2).files(commitRev1);
+
+    assertThat(changedFiles.get(fileName)).oldMode().isEqualTo(0100644);
+    assertThat(changedFiles.get(fileName)).newMode().isEqualTo(0100755);
+  }
+
+  @Test
+  public void fileMode_oldMode_isMissingInListFilesDiff_forAddedFile() throws Exception {
+    String fileName = "file.txt";
+    PushOneCommit push =
+        pushFactory
+            .create(admin.newIdent(), testRepo, "Commit Subject", /* files= */ ImmutableMap.of())
+            .addFile(fileName, "content", /* fileMode= */ 0100644);
+    PushOneCommit.Result result = push.to("refs/for/master");
+    String commitRev = gApi.changes().id(result.getChangeId()).get().currentRevision;
+
+    Map<String, FileInfo> changedFiles =
+        gApi.changes().id(result.getChangeId()).revision(commitRev).files();
+
+    assertThat(changedFiles.get(fileName)).oldMode().isNull();
+    assertThat(changedFiles.get(fileName)).newMode().isEqualTo(0100644);
+  }
+
+  @Test
+  public void fileMode_newMode_isMissingInListFilesDiff_forDeletedFile() throws Exception {
+    String fileName = "file.txt";
+    PushOneCommit push =
+        pushFactory
+            .create(admin.newIdent(), testRepo, "Commit Subject", /* files= */ ImmutableMap.of())
+            .addFile(fileName, "content", /* fileMode= */ 0100644);
+    PushOneCommit.Result result = push.to("refs/for/master");
+    String commitRev1 = gApi.changes().id(result.getChangeId()).get().currentRevision;
+    push = pushFactory.create(admin.newIdent(), testRepo, result.getChangeId()).rmFile(fileName);
+    result = push.to("refs/for/master");
+    String commitRev2 = gApi.changes().id(result.getChangeId()).get().currentRevision;
+
+    Map<String, FileInfo> changedFiles =
+        gApi.changes().id(result.getChangeId()).revision(commitRev2).files(commitRev1);
+
+    assertThat(changedFiles.get(fileName)).oldMode().isEqualTo(0100644);
+    assertThat(changedFiles.get(fileName)).newMode().isNull();
+  }
+
+  @Test
   public void numberOfLinesInDiffOfDeletedFileWithoutNewlineAtEndIsCorrect() throws Exception {
     String filePath = "a_new_file.txt";
     String fileContent = "Line 1\nLine 2\nLine 3";
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
index 804516a..d0d6fb4 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
@@ -1009,6 +1009,84 @@
   }
 
   @Test
+  @GerritConfig(name = "accounts.visibility", value = "SAME_GROUP")
+  public void cherryPickWithNonVisibleUsers() throws Exception {
+    // Create a target branch for the cherry-pick.
+    createBranch(BranchNameKey.create(project, "stable"));
+
+    // Define readable names for the users we use in this test.
+    TestAccount cherryPicker = user;
+    TestAccount changeOwner = admin;
+    TestAccount reviewer = accountCreator.user2();
+    TestAccount cc =
+        accountCreator.create("user3", "user3@example.com", "User3", /* displayName= */ null);
+    TestAccount authorCommitter =
+        accountCreator.create("user4", "user4@example.com", "User4", /* displayName= */ null);
+
+    // Check that the cherry-picker can neither see the changeOwner, the reviewer, the cc nor the
+    // authorCommitter.
+    requestScopeOperations.setApiUser(cherryPicker.id());
+    assertThatAccountIsNotVisible(changeOwner, reviewer, cc, authorCommitter);
+
+    // Create the change with authorCommitter as the author and the committer.
+    requestScopeOperations.setApiUser(changeOwner.id());
+    PushOneCommit push = pushFactory.create(authorCommitter.newIdent(), testRepo);
+    PushOneCommit.Result r = push.to("refs/for/master");
+    r.assertOkStatus();
+
+    // Check that authorCommitter was set as the author and committer.
+    ChangeInfo changeInfo = gApi.changes().id(r.getChangeId()).get();
+    CommitInfo commit = changeInfo.revisions.get(changeInfo.currentRevision).commit;
+    assertThat(commit.author.email).isEqualTo(authorCommitter.email());
+    assertThat(commit.committer.email).isEqualTo(authorCommitter.email());
+
+    // Pushing a commit with a forged author/committer adds the author/committer as a CC.
+    assertCcs(r.getChangeId(), authorCommitter);
+
+    // Remove the author/committer as a CC because because otherwise there are two signals for CCing
+    // authorCommitter on the cherry-pick change: once because they are author and committer and
+    // once because they are a CC. For authorCommitter we only want to test the first signal here
+    // (the second signal is covered by adding an explicit CC below).
+    gApi.changes().id(r.getChangeId()).reviewer(authorCommitter.email()).remove();
+    assertNoCcs(r.getChangeId());
+
+    // Add reviewer and cc.
+    ReviewInput reviewerInput = ReviewInput.approve();
+    reviewerInput.reviewer(reviewer.email());
+    reviewerInput.cc(cc.email());
+    gApi.changes().id(r.getChangeId()).current().review(reviewerInput);
+
+    // Approve and submit the change.
+    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
+    gApi.changes().id(r.getChangeId()).current().submit();
+
+    // Cherry-pick the change.
+    requestScopeOperations.setApiUser(cherryPicker.id());
+    CherryPickInput cherryPickInput = new CherryPickInput();
+    cherryPickInput.message = "Cherry-pick to stable branch";
+    cherryPickInput.destination = "stable";
+    cherryPickInput.keepReviewers = true;
+    String cherryPickChangeId =
+        gApi.changes().id(r.getChangeId()).current().cherryPick(cherryPickInput).get().id;
+
+    // Cherry-pick doesn't check the visibility of explicit reviewers/CCs. Since the cherry-picker
+    // can see the cherry-picked change, they can also see its reviewers/CCs. This means preserving
+    // them on the cherry-pick change doesn't expose their account existence and it's OK to keep
+    // them even if their accounts are not visible to the cherry-picker.
+    // In contrast to this for implicit CCs that are added for the author/committer the account
+    // visibility is checked, but if their accounts are not visible the CC is silently dropped (so
+    // that the cherry-pick request can still succeed). Since in this case authorCommitter is not
+    // visible, we expect that CCing them is being dropped and hence authorCommitter is not returned
+    // as a CC here. The reason that the visibility for author/committer must be checked is that
+    // author/committer may not match a Gerrit account (if they are forged). This means by seeing
+    // the author/committer on the cherry-picked change, it's not possible to deduce that these
+    // Gerrit accounts exists, but if they would be added as a CC on the cherry-pick change even if
+    // they are not visible the account existence would be exposed.
+    assertReviewers(cherryPickChangeId, changeOwner, reviewer);
+    assertCcs(cherryPickChangeId, cc);
+  }
+
+  @Test
   public void cherryPickToMergedChangeRevision() throws Exception {
     createBranch(BranchNameKey.create(project, "foo"));
 
@@ -1693,7 +1771,6 @@
       assertThat(patchSetLinkInfo.name).isEqualTo(expectedPatchSetLinkInfo.name);
       assertThat(patchSetLinkInfo.imageUrl).isEqualTo(expectedPatchSetLinkInfo.imageUrl);
       assertThat(patchSetLinkInfo.url).isEqualTo(expectedPatchSetLinkInfo.url);
-      assertThat(patchSetLinkInfo.target).isEqualTo(expectedPatchSetLinkInfo.target);
 
       assertThat(commitInfo.resolveConflictsWebLinks).hasSize(1);
       WebLinkInfo resolveCommentsLinkInfo =
@@ -1702,7 +1779,6 @@
       assertThat(resolveCommentsLinkInfo.imageUrl)
           .isEqualTo(expectedResolveConflictsLinkInfo.imageUrl);
       assertThat(resolveCommentsLinkInfo.url).isEqualTo(expectedResolveConflictsLinkInfo.url);
-      assertThat(resolveCommentsLinkInfo.target).isEqualTo(expectedResolveConflictsLinkInfo.target);
     }
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RobotCommentsIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RobotCommentsIT.java
index a16cdb6..1363ce7 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/RobotCommentsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RobotCommentsIT.java
@@ -588,6 +588,22 @@
   }
 
   @Test
+  public void commentWithRangeAndLine_lineIsIgnored() throws Exception {
+    FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
+    fixReplacementInfo1.path = FILE_NAME;
+    fixReplacementInfo1.range = createRange(2, 0, 3, 1);
+    fixReplacementInfo1.replacement = "First modification\n";
+
+    withFixRobotCommentInput.line = 1;
+    withFixRobotCommentInput.range = createRange(2, 0, 3, 1);
+    withFixRobotCommentInput.fixSuggestions = ImmutableList.of(fixSuggestionInfo);
+
+    testCommentHelper.addRobotComment(changeId, withFixRobotCommentInput);
+    List<RobotCommentInfo> robotComments = getRobotComments();
+    assertThat(robotComments.get(0).line).isEqualTo(3);
+  }
+
+  @Test
   public void rangesOfFixReplacementsOfSameFixSuggestionForSameFileMayNotOverlap() {
     FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
     fixReplacementInfo1.path = FILE_NAME;
@@ -1450,7 +1466,7 @@
   }
 
   @Test
-  public void PreviewStoredFixForNonExistingFile() throws Exception {
+  public void previewStoredFixForNonExistingFile() throws Exception {
     FixReplacementInfo replacement = new FixReplacementInfo();
     replacement.path = "a_non_existent_file.txt";
     replacement.range = createRange(1, 0, 2, 0);
@@ -1471,7 +1487,7 @@
   }
 
   @Test
-  public void PreviewStoredFix() throws Exception {
+  public void previewStoredFix() throws Exception {
     FixReplacementInfo fixReplacementInfoFile1 = new FixReplacementInfo();
     fixReplacementInfoFile1.path = FILE_NAME;
     fixReplacementInfoFile1.replacement = "some replacement code";
@@ -1581,7 +1597,7 @@
   }
 
   @Test
-  public void PreviewStoredFixAddNewLineAtEnd() throws Exception {
+  public void previewStoredFixAddNewLineAtEnd() throws Exception {
     FixReplacementInfo replacement = new FixReplacementInfo();
     replacement.path = FILE_NAME3;
     replacement.range = createRange(2, 8, 2, 8);
diff --git a/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java b/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java
index 9fae6c0..4168164 100644
--- a/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java
+++ b/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java
@@ -94,6 +94,7 @@
   private static final String FILE_NAME = "foo";
   private static final String FILE_NAME2 = "foo2";
   private static final String FILE_NAME3 = "foo3";
+  private static final int FILE_MODE = 100644;
   private static final byte[] CONTENT_OLD = "bar".getBytes(UTF_8);
   private static final byte[] CONTENT_NEW = "baz".getBytes(UTF_8);
   private static final String CONTENT_NEW2_STR = "quxÄÜÖßµ";
@@ -107,6 +108,7 @@
       "Uploading to an edit worked!".getBytes(UTF_8);
   private static final String CONTENT_BINARY_ENCODED_NEW3 =
       "data:text/plain,VXBsb2FkaW5nIHRvIGFuIGVkaXQgd29ya2VkIQ==";
+  private static final String CONTENT_BINARY_ENCODED_EMPTY = "data:text/plain;base64,";
 
   @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
@@ -323,6 +325,21 @@
   }
 
   @Test
+  public void updateCommitMessageByEditingMagicCommitMsgFileChangingChangeIdFooterToLinkFooter()
+      throws Exception {
+    createEmptyEditFor(changeId);
+    String updatedCommitMsg =
+        "Foo Bar\n\n\n\nLink: " + canonicalWebUrl.get() + "id/" + changeId + "\n";
+    gApi.changes()
+        .id(changeId)
+        .edit()
+        .modifyFile(Patch.COMMIT_MSG, RawInputUtil.create(updatedCommitMsg.getBytes(UTF_8)));
+    assertThat(getEdit(changeId)).isPresent();
+    ensureSameBytes(
+        getFileContentOfEdit(changeId, Patch.COMMIT_MSG), updatedCommitMsg.getBytes(UTF_8));
+  }
+
+  @Test
   public void updateCommitMessageByEditingMagicCommitMsgFileWithoutContent() throws Exception {
     createEmptyEditFor(changeId);
     BadRequestException ex =
@@ -686,6 +703,26 @@
   }
 
   @Test
+  public void changeEditModifyFileModeRest() throws Exception {
+    createEmptyEditFor(changeId);
+    FileContentInput in = new FileContentInput();
+    in.binary_content = CONTENT_BINARY_ENCODED_NEW;
+    in.fileMode = FILE_MODE;
+    adminRestSession.put(urlEditFile(changeId, FILE_NAME), in).assertNoContent();
+    ensureSameBytes(getFileContentOfEdit(changeId, FILE_NAME), CONTENT_BINARY_DECODED_NEW);
+  }
+
+  @Test
+  public void changeEditModifyFileSetEmptyContentModeRest() throws Exception {
+    createEmptyEditFor(changeId);
+    FileContentInput in = new FileContentInput();
+    in.binary_content = CONTENT_BINARY_ENCODED_EMPTY;
+    in.fileMode = FILE_MODE;
+    adminRestSession.put(urlEditFile(changeId, FILE_NAME), in).assertNoContent();
+    ensureSameBytes(getFileContentOfEdit(changeId, FILE_NAME), "".getBytes(UTF_8));
+  }
+
+  @Test
   public void createAndUploadBinaryInChangeEditOneRequestRest() throws Exception {
     FileContentInput in = new FileContentInput();
     in.binary_content = CONTENT_BINARY_ENCODED_NEW;
diff --git a/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java b/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
index cd1d911..e120f97 100644
--- a/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
+++ b/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
@@ -41,6 +41,7 @@
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.server.project.testing.TestLabels.label;
 import static com.google.gerrit.server.project.testing.TestLabels.value;
+import static com.google.gerrit.testing.TestActionRefUpdateContext.testRefAction;
 import static java.util.Comparator.comparing;
 import static java.util.stream.Collectors.joining;
 import static java.util.stream.Collectors.toList;
@@ -128,6 +129,7 @@
 import java.util.concurrent.atomic.AtomicInteger;
 import java.util.stream.Stream;
 import org.eclipse.jgit.api.errors.GitAPIException;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.AnyObjectId;
 import org.eclipse.jgit.lib.ObjectId;
@@ -334,7 +336,7 @@
       RefUpdate u = repo.updateRef(RefNames.REFS_CONFIG);
       u.setForceUpdate(true);
       u.setExpectedOldObjectId(repo.resolve(RefNames.REFS_CONFIG));
-      assertThat(u.delete(rw)).isEqualTo(Result.FORCED);
+      testRefAction(() -> assertThat(u.delete(rw)).isEqualTo(Result.FORCED));
     }
 
     RevCommit c =
@@ -1154,7 +1156,7 @@
             .add(PushOneCommit.FILE_NAME, PushOneCommit.FILE_CONTENT)
             .message(PushOneCommit.SUBJECT)
             .create();
-    // Push commit as "Admnistrator".
+    // Push commit as "Administrator".
     pushHead(testRepo, "refs/for/master");
 
     String changeId = GitUtil.getChangeId(testRepo, c).get();
@@ -1168,6 +1170,92 @@
   }
 
   @Test
+  public void pushForMasterWithForgedAuthorAndCommitter_skipAddingAuthorAndCommitterAsReviewers()
+      throws Exception {
+    setSkipAddingAuthorAndCommitterAsReviewers(InheritableBoolean.TRUE);
+    TestAccount user2 = accountCreator.user2();
+    // Create a commit with different forged author and committer.
+    RevCommit c =
+        commitBuilder()
+            .author(user.newIdent())
+            .committer(user2.newIdent())
+            .add(PushOneCommit.FILE_NAME, PushOneCommit.FILE_CONTENT)
+            .message(PushOneCommit.SUBJECT)
+            .create();
+    // Push commit as "Administrator".
+    pushHead(testRepo, "refs/for/master");
+
+    String changeId = GitUtil.getChangeId(testRepo, c).get();
+    assertThat(getOwnerEmail(changeId)).isEqualTo(admin.email());
+    assertThat(getReviewerEmails(changeId, ReviewerState.CC)).isEmpty();
+  }
+
+  @Test
+  public void pushForMasterWithNonExistingForgedAuthorAndCommitter() throws Exception {
+    // Create a commit with different forged author and committer.
+    RevCommit c =
+        commitBuilder()
+            .author(new PersonIdent("author", "author@example.com"))
+            .committer(new PersonIdent("committer", "committer@example.com"))
+            .add(PushOneCommit.FILE_NAME, PushOneCommit.FILE_CONTENT)
+            .message(PushOneCommit.SUBJECT)
+            .create();
+    // Push commit as "Administrator".
+    pushHead(testRepo, "refs/for/master");
+
+    String changeId = GitUtil.getChangeId(testRepo, c).get();
+    assertThat(getOwnerEmail(changeId)).isEqualTo(admin.email());
+    assertThat(getReviewerEmails(changeId, ReviewerState.CC)).isEmpty();
+  }
+
+  @Test
+  @GerritConfig(name = "accounts.visibility", value = "SAME_GROUP")
+  public void pushForMasterWithNonVisibleForgedAuthorAndCommitter() throws Exception {
+    // Define readable names for the users we use in this test.
+    TestAccount uploader = user; // cannot use admin since admin can see all users
+    TestAccount author = accountCreator.user2();
+    TestAccount committer =
+        accountCreator.create("user3", "user3@example.com", "User3", /* displayName= */ null);
+
+    // Check that the uploader can neither see the author nor the committer.
+    requestScopeOperations.setApiUser(uploader.id());
+    assertThatAccountIsNotVisible(author, committer);
+
+    // Allow the uploader to forge author and committer.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.FORGE_AUTHOR).ref("refs/heads/master").group(REGISTERED_USERS))
+        .add(allow(Permission.FORGE_COMMITTER).ref("refs/heads/master").group(REGISTERED_USERS))
+        .update();
+
+    // Clone the repo as uploader so that the push is done by the uplaoder.
+    TestRepository<InMemoryRepository> testRepo = cloneProject(project, uploader);
+
+    // Create a commit with different forged author and committer.
+    RevCommit c =
+        testRepo
+            .branch("HEAD")
+            .commit()
+            .insertChangeId()
+            .author(author.newIdent())
+            .committer(committer.newIdent())
+            .add(PushOneCommit.FILE_NAME, PushOneCommit.FILE_CONTENT)
+            .message(PushOneCommit.SUBJECT)
+            .create();
+
+    PushResult r = pushHead(testRepo, "refs/for/master");
+    RemoteRefUpdate refUpdate = r.getRemoteUpdate("refs/for/master");
+    assertThat(refUpdate.getStatus()).isEqualTo(RemoteRefUpdate.Status.OK);
+
+    String changeId = GitUtil.getChangeId(testRepo, c).get();
+    assertThat(getOwnerEmail(changeId)).isEqualTo(uploader.email());
+
+    // author and committer have not been CCed because their accounts are not visible
+    assertThat(getReviewerEmails(changeId, ReviewerState.CC)).isEmpty();
+  }
+
+  @Test
   public void pushNewPatchSetForMasterWithForgedAuthorAndCommitter() throws Exception {
     TestAccount user2 = accountCreator.user2();
     // First patch set has author and committer matching change owner.
@@ -1192,6 +1280,74 @@
         .containsExactly(user.getNameEmail(), user2.getNameEmail());
   }
 
+  @Test
+  public void pushNewPatchSetForMasterWithNonExistingForgedAuthorAndCommitter() throws Exception {
+    // First patch set has author and committer matching change owner.
+    PushOneCommit.Result r = pushTo("refs/for/master");
+
+    assertThat(getOwnerEmail(r.getChangeId())).isEqualTo(admin.email());
+    assertThat(getReviewerEmails(r.getChangeId(), ReviewerState.REVIEWER)).isEmpty();
+
+    amendBuilder()
+        .author(new PersonIdent("author", "author@example.com"))
+        .committer(new PersonIdent("committer", "committer@example.com"))
+        .add(PushOneCommit.FILE_NAME, PushOneCommit.FILE_CONTENT + "2")
+        .create();
+    pushHead(testRepo, "refs/for/master");
+
+    assertThat(getOwnerEmail(r.getChangeId())).isEqualTo(admin.email());
+    assertThat(getReviewerEmails(r.getChangeId(), ReviewerState.CC)).isEmpty();
+  }
+
+  @Test
+  @GerritConfig(name = "accounts.visibility", value = "SAME_GROUP")
+  public void pushNewPatchSetForMasterWithNonVisibleForgedAuthorAndCommitter() throws Exception {
+    // Define readable names for the users we use in this test.
+    TestAccount uploader = user; // cannot use admin since admin can see all users
+    TestAccount author = accountCreator.user2();
+    TestAccount committer =
+        accountCreator.create("user3", "user3@example.com", "User3", /* displayName= */ null);
+
+    // Check that the uploader can neither see the author nor the committer.
+    requestScopeOperations.setApiUser(uploader.id());
+    assertThatAccountIsNotVisible(author, committer);
+
+    // Allow the uploader to forge author and committer.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.FORGE_AUTHOR).ref("refs/heads/master").group(REGISTERED_USERS))
+        .add(allow(Permission.FORGE_COMMITTER).ref("refs/heads/master").group(REGISTERED_USERS))
+        .update();
+
+    // Clone the repo as uploader so that the push is done by the uplaoder.
+    TestRepository<InMemoryRepository> testRepo = cloneProject(project, uploader);
+
+    // First patch set has author and committer matching uploader.
+    PushOneCommit push = pushFactory.create(uploader.newIdent(), testRepo);
+    PushOneCommit.Result r = push.to("refs/for/master");
+    r.assertOkStatus();
+
+    assertThat(getOwnerEmail(r.getChangeId())).isEqualTo(uploader.email());
+    assertThat(getReviewerEmails(r.getChangeId(), ReviewerState.REVIEWER)).isEmpty();
+
+    testRepo
+        .amendRef("HEAD")
+        .author(author.newIdent())
+        .committer(committer.newIdent())
+        .add(PushOneCommit.FILE_NAME, PushOneCommit.FILE_CONTENT + "2")
+        .create();
+
+    PushResult r2 = pushHead(testRepo, "refs/for/master");
+    RemoteRefUpdate refUpdate = r2.getRemoteUpdate("refs/for/master");
+    assertThat(refUpdate.getStatus()).isEqualTo(RemoteRefUpdate.Status.OK);
+
+    assertThat(getOwnerEmail(r.getChangeId())).isEqualTo(uploader.email());
+
+    // author and committer have not been CCed because their accounts are not visible
+    assertThat(getReviewerEmails(r.getChangeId(), ReviewerState.CC)).isEmpty();
+  }
+
   /**
    * There was a bug that allowed a user with Forge Committer Identity access right to upload a
    * commit and put *votes on behalf of another user* on it. This test checks that this is not
@@ -2544,6 +2700,23 @@
   }
 
   @Test
+  @GerritConfig(
+      name = "plugins.transitionalPushOptions",
+      values = {"gerrit~foo", "gerrit~bar"})
+  public void transitionalPushOptionsArePassedToCommitValidationListener() throws Exception {
+    TestValidator validator = new TestValidator();
+    try (Registration registration = extensionRegistry.newRegistration().add(validator)) {
+      PushOneCommit push =
+          pushFactory.create(admin.newIdent(), testRepo, "change2", "b.txt", "content");
+      push.setPushOptions(ImmutableList.of("trace=123", "gerrit~foo", "gerrit~bar=456"));
+      PushOneCommit.Result r = push.to("refs/for/master");
+      r.assertOkStatus();
+      assertThat(validator.pushOptions())
+          .containsExactly("trace", "123", "gerrit~foo", "", "gerrit~bar", "456");
+    }
+  }
+
+  @Test
   public void pluginPushOptionsHelp() throws Exception {
     PluginPushOption fooOption = new TestPluginPushOption("foo", "some description");
     PluginPushOption barOption = new TestPluginPushOption("bar", "other description");
@@ -2910,6 +3083,12 @@
     assertThat(r.getChange().attentionSet()).isEmpty();
   }
 
+  @Test
+  public void pushWithInvalidBaseIsRejected() throws Exception {
+    PushOneCommit.Result r = pushTo("refs/for/master%base=invalid");
+    r.assertErrorStatus("expected SHA1 for option --base: invalid");
+  }
+
   private DraftInput newDraft(String path, int line, String message) {
     DraftInput d = new DraftInput();
     d.path = path;
diff --git a/javatests/com/google/gerrit/acceptance/git/AbstractSubmitOnPush.java b/javatests/com/google/gerrit/acceptance/git/AbstractSubmitOnPush.java
index 0cdac5a..8295550 100644
--- a/javatests/com/google/gerrit/acceptance/git/AbstractSubmitOnPush.java
+++ b/javatests/com/google/gerrit/acceptance/git/AbstractSubmitOnPush.java
@@ -41,7 +41,9 @@
 import com.google.gerrit.server.events.ChangeMergedEvent;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType;
 import com.google.gerrit.testing.FakeEmailSender.Message;
+import com.google.gerrit.testing.RefUpdateContextCollector;
 import com.google.inject.Inject;
 import java.util.List;
 import org.eclipse.jgit.api.errors.GitAPIException;
@@ -54,12 +56,16 @@
 import org.eclipse.jgit.transport.RefSpec;
 import org.eclipse.jgit.transport.RemoteRefUpdate;
 import org.junit.Before;
+import org.junit.Rule;
 import org.junit.Test;
 
 public abstract class AbstractSubmitOnPush extends AbstractDaemonTest {
   @Inject private ApprovalsUtil approvalsUtil;
   @Inject private ProjectOperations projectOperations;
 
+  @Rule
+  public RefUpdateContextCollector refUpdateContextCollector = new RefUpdateContextCollector();
+
   @Before
   public void blockAnonymous() throws Exception {
     blockAnonymousRead();
@@ -229,6 +235,25 @@
   }
 
   @Test
+  public void pushAutoclosesChanges_changeMetaInAutoClosesChangesContext() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.PUSH).ref("refs/heads/master").group(adminGroupUuid()))
+        .update();
+    PushOneCommit.Result r = push("refs/for/master", PushOneCommit.SUBJECT, "one.txt", "One");
+    String refPrefix = r.getChange().getId().toRefPrefix();
+    assertThat(refUpdateContextCollector.getRefsByUpdateType(RefUpdateType.DIRECT_PUSH)).isEmpty();
+    assertThat(refUpdateContextCollector.getRefsByUpdateType(RefUpdateType.AUTO_CLOSE_CHANGES))
+        .isEmpty();
+    git().push().setRefSpecs(new RefSpec(r.getCommit().name() + ":refs/heads/master")).call();
+    assertThat(refUpdateContextCollector.getRefsByUpdateType(RefUpdateType.DIRECT_PUSH))
+        .containsExactly("refs/heads/master", refPrefix + "meta");
+    assertThat(refUpdateContextCollector.getRefsByUpdateType(RefUpdateType.AUTO_CLOSE_CHANGES))
+        .containsExactly(refPrefix + "meta");
+  }
+
+  @Test
   public void mergeOnPushToBranchWithChangeMergedInOther() throws Exception {
     enableCreateNewChangeForAllNotInTarget();
     String master = "refs/heads/master";
diff --git a/javatests/com/google/gerrit/acceptance/git/AbstractSubmoduleSubscription.java b/javatests/com/google/gerrit/acceptance/git/AbstractSubmoduleSubscription.java
index c3bcbd3..206a9d5 100644
--- a/javatests/com/google/gerrit/acceptance/git/AbstractSubmoduleSubscription.java
+++ b/javatests/com/google/gerrit/acceptance/git/AbstractSubmoduleSubscription.java
@@ -17,6 +17,7 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.testing.TestActionRefUpdateContext.testRefAction;
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.collect.Iterables;
@@ -511,7 +512,7 @@
       RefUpdate ru = serverRepo.updateRef(refName);
       ru.setExpectedOldObjectId(oldCommitId);
       ru.setNewObjectId(newCommitId);
-      assertThat(ru.update()).isEqualTo(RefUpdate.Result.FAST_FORWARD);
+      testRefAction(() -> assertThat(ru.update()).isEqualTo(RefUpdate.Result.FAST_FORWARD));
     }
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/git/AutoMergeIT.java b/javatests/com/google/gerrit/acceptance/git/AutoMergeIT.java
index b16394d..3b158a9 100644
--- a/javatests/com/google/gerrit/acceptance/git/AutoMergeIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/AutoMergeIT.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance.git;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.TestActionRefUpdateContext.testRefAction;
 import static org.eclipse.jgit.lib.Constants.HEAD;
 
 import com.google.common.collect.ImmutableList;
@@ -201,7 +202,7 @@
     try (Repository repo = repoManager.openRepository(project)) {
       RefUpdate ru = repo.updateRef(RefNames.refsCacheAutomerge(mergeCommit.name()));
       ru.setForceUpdate(true);
-      assertThat(ru.delete()).isEqualTo(RefUpdate.Result.FORCED);
+      testRefAction(() -> assertThat(ru.delete()).isEqualTo(RefUpdate.Result.FORCED));
     }
     assertNoAutoMergeCreated(mergeCommit);
   }
diff --git a/javatests/com/google/gerrit/acceptance/git/ImplicitMergeCheckIT.java b/javatests/com/google/gerrit/acceptance/git/ImplicitMergeCheckIT.java
index 80cc508..e352e2d 100644
--- a/javatests/com/google/gerrit/acceptance/git/ImplicitMergeCheckIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/ImplicitMergeCheckIT.java
@@ -22,6 +22,7 @@
 import com.google.gerrit.entities.BooleanProjectConfig;
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.git.ObjectIds;
+import java.util.Locale;
 import org.eclipse.jgit.lib.ObjectId;
 import org.junit.Test;
 
@@ -61,7 +62,8 @@
     PushOneCommit.Result m = push("refs/heads/master", "1", "f", "1");
     PushOneCommit.Result c = push("refs/for/stable", "2", "f", "2");
 
-    assertThat(c.getMessage().toLowerCase()).doesNotContain(implicitMergeOf(m.getCommit()));
+    assertThat(c.getMessage().toLowerCase(Locale.US))
+        .doesNotContain(implicitMergeOf(m.getCommit()));
   }
 
   @Test
@@ -74,7 +76,8 @@
     PushOneCommit.Result m = push("refs/heads/master", "1", "f", "1");
     PushOneCommit.Result c = push("refs/for/master", "2", "f", "2");
 
-    assertThat(c.getMessage().toLowerCase()).doesNotContain(implicitMergeOf(m.getCommit()));
+    assertThat(c.getMessage().toLowerCase(Locale.US))
+        .doesNotContain(implicitMergeOf(m.getCommit()));
   }
 
   private String implicitMergeOf(ObjectId commit) throws Exception {
diff --git a/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java b/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
index f58f81c..9e85d8c 100644
--- a/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
@@ -24,6 +24,7 @@
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.deny;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.permissionKey;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.TestActionRefUpdateContext.testRefAction;
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.collect.ImmutableList;
@@ -217,7 +218,7 @@
       RefUpdate mtu = repo.updateRef("refs/tags/master-tag");
       mtu.setExpectedOldObjectId(ObjectId.zeroId());
       mtu.setNewObjectId(repo.exactRef("refs/heads/master").getObjectId());
-      assertThat(mtu.update()).isEqualTo(RefUpdate.Result.NEW);
+      testRefAction(() -> assertThat(mtu.update()).isEqualTo(RefUpdate.Result.NEW));
 
       //   rcMaster (c1 master master-tag) <-- rcBranch (c2 branch branch-tag)
       //       \                                  \
@@ -225,14 +226,14 @@
       RefUpdate btu = repo.updateRef("refs/tags/branch-tag");
       btu.setExpectedOldObjectId(ObjectId.zeroId());
       btu.setNewObjectId(repo.exactRef("refs/heads/branch").getObjectId());
-      assertThat(btu.update()).isEqualTo(RefUpdate.Result.NEW);
+      testRefAction(() -> assertThat(btu.update()).isEqualTo(RefUpdate.Result.NEW));
 
       // Create a tag for the tree of the commit on 'master'
       // tree-tag -> master.tree
       RefUpdate ttu = repo.updateRef("refs/tags/tree-tag");
       ttu.setExpectedOldObjectId(ObjectId.zeroId());
       ttu.setNewObjectId(rcMaster.getTree().toObjectId());
-      assertThat(ttu.update()).isEqualTo(RefUpdate.Result.NEW);
+      testRefAction(() -> assertThat(ttu.update()).isEqualTo(RefUpdate.Result.NEW));
     }
   }
 
@@ -588,14 +589,17 @@
         .forUpdate()
         .add(allow(Permission.READ).ref("refs/heads/branch").group(REGISTERED_USERS))
         .update();
-    // Create a tag for the pending change on 'branch' so that the tag is orphaned
-    try (Repository repo = repoManager.openRepository(project)) {
-      // change4-tag -> psRef4
-      RefUpdate ctu = repo.updateRef("refs/tags/change4-tag");
-      ctu.setExpectedOldObjectId(ObjectId.zeroId());
-      ctu.setNewObjectId(repo.exactRef(psRef4).getObjectId());
-      assertThat(ctu.update()).isEqualTo(RefUpdate.Result.NEW);
-    }
+    testRefAction(
+        () -> {
+          // Create a tag for the pending change on 'branch' so that the tag is orphaned
+          try (Repository repo = repoManager.openRepository(project)) {
+            // change4-tag -> psRef4
+            RefUpdate ctu = repo.updateRef("refs/tags/change4-tag");
+            ctu.setExpectedOldObjectId(ObjectId.zeroId());
+            ctu.setNewObjectId(repo.exactRef(psRef4).getObjectId());
+            assertThat(ctu.update()).isEqualTo(RefUpdate.Result.NEW);
+          }
+        });
 
     requestScopeOperations.setApiUser(user.id());
     assertUploadPackRefs(
@@ -641,7 +645,7 @@
       RefUpdate btu = repo.updateRef("refs/tags/master-newtag");
       btu.setExpectedOldObjectId(ObjectId.zeroId());
       btu.setNewObjectId(r.getCommit());
-      assertThat(btu.update()).isEqualTo(RefUpdate.Result.NEW);
+      testRefAction(() -> assertThat(btu.update()).isEqualTo(RefUpdate.Result.NEW));
     }
 
     assertUploadPackRefs(
@@ -695,7 +699,7 @@
       RefUpdate btu = repo.updateRef("refs/tags/branch-newtag");
       btu.setExpectedOldObjectId(ObjectId.zeroId());
       btu.setNewObjectId(tagRc);
-      assertThat(btu.update()).isEqualTo(RefUpdate.Result.NEW);
+      testRefAction(() -> assertThat(btu.update()).isEqualTo(RefUpdate.Result.NEW));
     }
 
     assertUploadPackRefs(
@@ -751,7 +755,7 @@
       RefUpdate btu = repo.updateRef("refs/tags/branch-newtag");
       btu.setExpectedOldObjectId(ObjectId.zeroId());
       btu.setNewObjectId(tagRc);
-      assertThat(btu.update()).isEqualTo(RefUpdate.Result.NEW);
+      testRefAction(() -> assertThat(btu.update()).isEqualTo(RefUpdate.Result.NEW));
     }
 
     assertUploadPackRefs(
@@ -794,10 +798,11 @@
       RevCommit bRc = r.getCommit();
 
       // rcBranch (c2) <- newcommit1 (branch-oldtag) <- newcommit2 (branch)
-      RefUpdate btu = repo.updateRef("refs/tags/branch-oldtag");
-      btu.setExpectedOldObjectId(ObjectId.zeroId());
-      btu.setNewObjectId(tagRc);
-      assertThat(btu.update()).isEqualTo(RefUpdate.Result.NEW);
+      RefUpdate btu1 = repo.updateRef("refs/tags/branch-oldtag");
+
+      btu1.setExpectedOldObjectId(ObjectId.zeroId());
+      btu1.setNewObjectId(tagRc);
+      testRefAction(() -> assertThat(btu1.update()).isEqualTo(RefUpdate.Result.NEW));
 
       assertUploadPackRefs(
           psRef2,
@@ -811,11 +816,11 @@
           "refs/tags/master-tag");
 
       // rcBranch (c2 branch) <- newcommit1 (branch-oldtag) <- newcommit2
-      btu = repo.updateRef("refs/heads/branch");
-      btu.setExpectedOldObjectId(bRc);
-      btu.setNewObjectId(rcBranch);
-      btu.setForceUpdate(true);
-      assertThat(btu.update()).isEqualTo(RefUpdate.Result.FORCED);
+      RefUpdate btu2 = repo.updateRef("refs/heads/branch");
+      btu2.setExpectedOldObjectId(bRc);
+      btu2.setNewObjectId(rcBranch);
+      btu2.setForceUpdate(true);
+      testRefAction(() -> assertThat(btu2.update()).isEqualTo(RefUpdate.Result.FORCED));
     }
 
     assertUploadPackRefs(
@@ -907,7 +912,7 @@
       btu.setExpectedOldObjectId(tagRc);
       btu.setNewObjectId(rcBranch);
       btu.setForceUpdate(true);
-      assertThat(btu.update()).isEqualTo(RefUpdate.Result.FORCED);
+      testRefAction(() -> assertThat(btu.update()).isEqualTo(RefUpdate.Result.FORCED));
     }
 
     assertUploadPackRefs(
@@ -939,7 +944,7 @@
       RefUpdate btu = repo.updateRef("refs/tags/updated-tag");
       btu.setExpectedOldObjectId(ObjectId.zeroId());
       btu.setNewObjectId(rcBranch);
-      assertThat(btu.update()).isEqualTo(RefUpdate.Result.NEW);
+      testRefAction(() -> assertThat(btu.update()).isEqualTo(RefUpdate.Result.NEW));
 
       assertUploadPackRefs(
           psRef2,
@@ -995,13 +1000,16 @@
         "refs/tags/master-tag");
 
     // rcBranch (c2 branch)
-    try (Repository repo = repoManager.openRepository(project)) {
-      RefUpdate btu = repo.updateRef("refs/tags/branch-tag");
-      btu.setExpectedOldObjectId(rcBranch);
-      btu.setNewObjectId(ObjectId.zeroId());
-      btu.setForceUpdate(true);
-      assertThat(btu.delete()).isEqualTo(RefUpdate.Result.FORCED);
-    }
+    testRefAction(
+        () -> {
+          try (Repository repo = repoManager.openRepository(project)) {
+            RefUpdate btu = repo.updateRef("refs/tags/branch-tag");
+            btu.setExpectedOldObjectId(rcBranch);
+            btu.setNewObjectId(ObjectId.zeroId());
+            btu.setForceUpdate(true);
+            assertThat(btu.delete()).isEqualTo(RefUpdate.Result.FORCED);
+          }
+        });
 
     assertUploadPackRefs(
         psRef2, metaRef2, psRef4, metaRef4, "refs/heads/branch", "refs/tags/master-tag");
diff --git a/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsIT.java b/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsIT.java
index d2aab5b..09957b3 100644
--- a/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsIT.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.TruthJUnit.assume;
+import static com.google.gerrit.server.project.ProjectConfig.RULES_PL_FILE;
 
 import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.NoHttpd;
@@ -742,7 +743,7 @@
           .commit()
           .author(admin.newIdent())
           .committer(admin.newIdent())
-          .add("rules.pl", newContent)
+          .add(RULES_PL_FILE, newContent)
           .message("Modify rules.pl")
           .create();
     }
diff --git a/javatests/com/google/gerrit/acceptance/pgm/MigrateLabelFunctionsToSubmitRequirementIT.java b/javatests/com/google/gerrit/acceptance/pgm/MigrateLabelFunctionsToSubmitRequirementIT.java
new file mode 100644
index 0000000..9d37497
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/pgm/MigrateLabelFunctionsToSubmitRequirementIT.java
@@ -0,0 +1,556 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.pgm;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.Sandboxed;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.api.projects.SubmitRequirementApi;
+import com.google.gerrit.extensions.common.LabelDefinitionInfo;
+import com.google.gerrit.extensions.common.LabelDefinitionInput;
+import com.google.gerrit.extensions.common.SubmitRequirementInfo;
+import com.google.gerrit.extensions.common.SubmitRequirementInput;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.server.schema.MigrateLabelFunctionsToSubmitRequirement;
+import com.google.gerrit.server.schema.MigrateLabelFunctionsToSubmitRequirement.Status;
+import com.google.gerrit.server.schema.UpdateUI;
+import com.google.inject.Inject;
+import java.util.Map;
+import java.util.Set;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.Test;
+
+/** Test for {@link com.google.gerrit.server.schema.MigrateLabelFunctionsToSubmitRequirement}. */
+@Sandboxed
+public class MigrateLabelFunctionsToSubmitRequirementIT extends AbstractDaemonTest {
+
+  @Inject private ProjectOperations projectOperations;
+
+  @Test
+  public void migrateBlockingLabel_maxWithBlock() throws Exception {
+    createLabel("Foo", "MaxWithBlock", /* ignoreSelfApproval= */ false);
+    assertNonExistentSr(/* srName = */ "Foo");
+
+    TestUpdateUI updateUI = runMigration(/* expectedResult= */ Status.MIGRATED);
+    assertThat(updateUI.newlyCreatedSrs).isEqualTo(1);
+    assertThat(updateUI.existingSrsMismatchingWithMigration).isEqualTo(0);
+
+    assertExistentSr(
+        /* srName */ "Foo",
+        /* applicabilityExpression= */ null,
+        /* submittabilityExpression= */ "label:Foo=MAX AND -label:Foo=MIN",
+        /* canOverride= */ true);
+    assertLabelFunction("Foo", "NoBlock");
+  }
+
+  @Test
+  public void migrateBlockingLabel_maxNoBlock() throws Exception {
+    createLabel("Foo", "MaxNoBlock", /* ignoreSelfApproval= */ false);
+    assertNonExistentSr(/* srName = */ "Foo");
+
+    TestUpdateUI updateUI = runMigration(/* expectedResult= */ Status.MIGRATED);
+    assertThat(updateUI.newlyCreatedSrs).isEqualTo(1);
+    assertThat(updateUI.existingSrsMismatchingWithMigration).isEqualTo(0);
+
+    assertExistentSr(
+        /* srName */ "Foo",
+        /* applicabilityExpression= */ null,
+        /* submittabilityExpression= */ "label:Foo=MAX",
+        /* canOverride= */ true);
+    assertLabelFunction("Foo", "NoBlock");
+  }
+
+  @Test
+  public void migrateBlockingLabel_anyWithBlock() throws Exception {
+    createLabel("Foo", "AnyWithBlock", /* ignoreSelfApproval= */ false);
+    assertNonExistentSr(/* srName = */ "Foo");
+
+    TestUpdateUI updateUI = runMigration(/* expectedResult= */ Status.MIGRATED);
+    assertThat(updateUI.newlyCreatedSrs).isEqualTo(1);
+    assertThat(updateUI.existingSrsMismatchingWithMigration).isEqualTo(0);
+
+    assertExistentSr(
+        /* srName */ "Foo",
+        /* applicabilityExpression= */ null,
+        /* submittabilityExpression= */ "-label:Foo=MIN",
+        /* canOverride= */ true);
+    assertLabelFunction("Foo", "NoBlock");
+  }
+
+  @Test
+  public void migrateBlockingLabel_maxWithBlock_withIgnoreSelfApproval() throws Exception {
+    createLabel("Foo", "MaxWithBlock", /* ignoreSelfApproval= */ true);
+    assertNonExistentSr(/* srName = */ "Foo");
+
+    TestUpdateUI updateUI = runMigration(/* expectedResult= */ Status.MIGRATED);
+    assertThat(updateUI.newlyCreatedSrs).isEqualTo(1);
+    assertThat(updateUI.existingSrsMismatchingWithMigration).isEqualTo(0);
+
+    assertExistentSr(
+        /* srName */ "Foo",
+        /* applicabilityExpression= */ null,
+        /* submittabilityExpression= */ "label:Foo=MAX,user=non_uploader AND -label:Foo=MIN",
+        /* canOverride= */ true);
+    assertLabelFunction("Foo", "NoBlock");
+  }
+
+  @Test
+  public void migrateBlockingLabel_maxNoBlock_withIgnoreSelfApproval() throws Exception {
+    createLabel("Foo", "MaxNoBlock", /* ignoreSelfApproval= */ true);
+    assertNonExistentSr(/* srName = */ "Foo");
+
+    TestUpdateUI updateUI = runMigration(/* expectedResult= */ Status.MIGRATED);
+    assertThat(updateUI.newlyCreatedSrs).isEqualTo(1);
+    assertThat(updateUI.existingSrsMismatchingWithMigration).isEqualTo(0);
+
+    assertExistentSr(
+        /* srName */ "Foo",
+        /* applicabilityExpression= */ null,
+        /* submittabilityExpression= */ "label:Foo=MAX,user=non_uploader",
+        /* canOverride= */ true);
+    assertLabelFunction("Foo", "NoBlock");
+  }
+
+  @Test
+  public void migrateNonBlockingLabel_noBlock() throws Exception {
+    // NoBlock labels are left as is, i.e. we don't create a "submit requirement" for them. Those
+    // labels will then be treated as trigger votes in the change page.
+    createLabel("Foo", "NoBlock", /* ignoreSelfApproval= */ false);
+    assertNonExistentSr(/* srName = */ "Foo");
+
+    TestUpdateUI updateUI = runMigration(/* expectedResult= */ Status.NO_CHANGE);
+    assertThat(updateUI.newlyCreatedSrs).isEqualTo(0);
+    assertThat(updateUI.existingSrsMismatchingWithMigration).isEqualTo(0);
+
+    // No SR was created for the label. Label will be treated as a trigger vote.
+    assertNonExistentSr("Foo");
+    // Label function has not changed.
+    assertLabelFunction("Foo", "NoBlock");
+  }
+
+  @Test
+  public void migrateNonBlockingLabel_noOp() throws Exception {
+    // NoOp labels are left as is, i.e. we don't create a "submit requirement" for them. Those
+    // labels will then be treated as trigger votes in the change page.
+    createLabel("Foo", "NoOp", /* ignoreSelfApproval= */ false);
+    assertNonExistentSr(/* srName = */ "Foo");
+
+    TestUpdateUI updateUI = runMigration(/* expectedResult= */ Status.MIGRATED);
+    assertThat(updateUI.newlyCreatedSrs).isEqualTo(0);
+    assertThat(updateUI.existingSrsMismatchingWithMigration).isEqualTo(0);
+
+    // No SR was created for the label. Label will be treated as a trigger vote.
+    assertNonExistentSr("Foo");
+    // The NoOp function is converted to NoBlock. Both are same.
+    assertLabelFunction("Foo", "NoBlock");
+  }
+
+  @Test
+  public void migrateNoBlockLabel_withSingleZeroValue() throws Exception {
+    // Labels that have a single "zero" value are skipped in the project. The migrator creates
+    // non-applicable SR for these labels.
+    createLabel("Foo", "NoBlock", /* ignoreSelfApproval= */ false, ImmutableMap.of("0", "No vote"));
+    assertNonExistentSr(/* srName = */ "Foo");
+
+    TestUpdateUI updateUI = runMigration(/* expectedResult= */ Status.MIGRATED);
+    assertThat(updateUI.newlyCreatedSrs).isEqualTo(1);
+    assertThat(updateUI.existingSrsMismatchingWithMigration).isEqualTo(0);
+
+    // a non-applicable SR was created for the skipped label.
+    assertExistentSr(
+        /* srName */ "Foo",
+        /* applicabilityExpression= */ "is:false",
+        /* submittabilityExpression= */ "is:true",
+        /* canOverride= */ true);
+
+    assertLabelFunction("Foo", "NoBlock");
+  }
+
+  @Test
+  public void migrateMaxWithBlockLabel_withSingleZeroValue() throws Exception {
+    // Labels that have a single "zero" value are skipped in the project. The migrator creates
+    // non-applicable SRs for these labels.
+    createLabel(
+        "Foo", "MaxWithBlock", /* ignoreSelfApproval= */ false, ImmutableMap.of("0", "No vote"));
+    assertNonExistentSr(/* srName = */ "Foo");
+
+    TestUpdateUI updateUI = runMigration(/* expectedResult= */ Status.MIGRATED);
+    assertThat(updateUI.newlyCreatedSrs).isEqualTo(1);
+    assertThat(updateUI.existingSrsMismatchingWithMigration).isEqualTo(0);
+
+    // a non-applicable SR was created for the skipped label.
+    assertExistentSr(
+        /* srName */ "Foo",
+        /* applicabilityExpression= */ "is:false",
+        /* submittabilityExpression= */ "is:true",
+        /* canOverride= */ true);
+
+    // The MaxWithBlock function is converted to NoBlock. This has no effect anyway because the
+    // label was originally skipped.
+    assertLabelFunction("Foo", "NoBlock");
+  }
+
+  @Test
+  public void cannotCreateLabelsWithNoValues() {
+    // This test just asserts the server's behaviour for visibility; admins cannot create a label
+    // without any defined values.
+    Exception thrown =
+        assertThrows(
+            BadRequestException.class,
+            () ->
+                createLabel("Foo", "NoBlock", /* ignoreSelfApproval= */ false, ImmutableMap.of()));
+    assertThat(thrown).hasMessageThat().isEqualTo("values are required");
+  }
+
+  @Test
+  public void migrateNonBlockingLabel_patchSetLock_doesNothing() throws Exception {
+    createLabel("Foo", "PatchSetLock", /* ignoreSelfApproval= */ false);
+    assertNonExistentSr(/* srName = */ "Foo");
+
+    TestUpdateUI updateUI = runMigration(/* expectedResult= */ Status.NO_CHANGE);
+    // No submit requirement created for the patchset lock label function
+    assertThat(updateUI.newlyCreatedSrs).isEqualTo(0);
+    assertThat(updateUI.existingSrsMismatchingWithMigration).isEqualTo(0);
+
+    assertNonExistentSr(/* srName = */ "Foo");
+    assertLabelFunction("Foo", "PatchSetLock");
+  }
+
+  @Test
+  public void migrationIsCommittedWithServerIdent() throws Exception {
+    RevCommit oldMetaCommit = projectOperations.project(project).getHead(RefNames.REFS_CONFIG);
+    createLabel("Foo", "MaxWithBlock", /* ignoreSelfApproval= */ false);
+    assertNonExistentSr(/* srName = */ "Foo");
+
+    TestUpdateUI updateUI = runMigration(/* expectedResult= */ Status.MIGRATED);
+    assertThat(updateUI.newlyCreatedSrs).isEqualTo(1);
+    assertThat(updateUI.existingSrsMismatchingWithMigration).isEqualTo(0);
+
+    assertExistentSr(
+        /* srName */ "Foo",
+        /* applicabilityExpression= */ null,
+        /* submittabilityExpression= */ "label:Foo=MAX AND -label:Foo=MIN",
+        /* canOverride= */ true);
+    assertLabelFunction("Foo", "NoBlock");
+
+    RevCommit newMetaCommit = projectOperations.project(project).getHead(RefNames.REFS_CONFIG);
+    assertThat(newMetaCommit).isNotEqualTo(oldMetaCommit);
+    assertThat(newMetaCommit.getCommitterIdent().getEmailAddress())
+        .isEqualTo(serverIdent.get().getEmailAddress());
+  }
+
+  @Test
+  public void migrateBlockingLabel_withBranchAttribute() throws Exception {
+    createLabelWithBranch(
+        "Foo",
+        "MaxWithBlock",
+        /* ignoreSelfApproval= */ false,
+        ImmutableList.of("refs/heads/master"));
+
+    assertNonExistentSr(/* srName = */ "Foo");
+
+    TestUpdateUI updateUI = runMigration(/* expectedResult= */ Status.MIGRATED);
+    assertThat(updateUI.newlyCreatedSrs).isEqualTo(1);
+    assertThat(updateUI.existingSrsMismatchingWithMigration).isEqualTo(0);
+
+    assertExistentSr(
+        /* srName */ "Foo",
+        /* applicabilityExpression= */ "branch:\\\"refs/heads/master\\\"",
+        /* submittabilityExpression= */ "label:Foo=MAX AND -label:Foo=MIN",
+        /* canOverride= */ true);
+    assertLabelFunction("Foo", "NoBlock");
+  }
+
+  @Test
+  public void migrateBlockingLabel_withMultipleBranchAttributes() throws Exception {
+    createLabelWithBranch(
+        "Foo",
+        "MaxWithBlock",
+        /* ignoreSelfApproval= */ false,
+        ImmutableList.of("refs/heads/master", "refs/heads/develop"));
+
+    assertNonExistentSr(/* srName = */ "Foo");
+
+    TestUpdateUI updateUI = runMigration(/* expectedResult= */ Status.MIGRATED);
+    assertThat(updateUI.newlyCreatedSrs).isEqualTo(1);
+    assertThat(updateUI.existingSrsMismatchingWithMigration).isEqualTo(0);
+
+    assertExistentSr(
+        /* srName */ "Foo",
+        /* applicabilityExpression= */ "branch:\\\"refs/heads/master\\\" "
+            + "OR branch:\\\"refs/heads/develop\\\"",
+        /* submittabilityExpression= */ "label:Foo=MAX AND -label:Foo=MIN",
+        /* canOverride= */ true);
+    assertLabelFunction("Foo", "NoBlock");
+  }
+
+  @Test
+  public void migrateBlockingLabel_withRegexBranchAttribute() throws Exception {
+    createLabelWithBranch(
+        "Foo",
+        "MaxWithBlock",
+        /* ignoreSelfApproval= */ false,
+        ImmutableList.of("^refs/heads/main-.*"));
+
+    assertNonExistentSr(/* srName = */ "Foo");
+
+    TestUpdateUI updateUI = runMigration(/* expectedResult= */ Status.MIGRATED);
+    assertThat(updateUI.newlyCreatedSrs).isEqualTo(1);
+    assertThat(updateUI.existingSrsMismatchingWithMigration).isEqualTo(0);
+
+    assertExistentSr(
+        /* srName */ "Foo",
+        /* applicabilityExpression= */ "branch:\\\"^refs/heads/main-.*\\\"",
+        /* submittabilityExpression= */ "label:Foo=MAX AND -label:Foo=MIN",
+        /* canOverride= */ true);
+    assertLabelFunction("Foo", "NoBlock");
+  }
+
+  @Test
+  public void migrateBlockingLabel_withRegexAndNonRegexBranchAttributes() throws Exception {
+    createLabelWithBranch(
+        "Foo",
+        "MaxWithBlock",
+        /* ignoreSelfApproval= */ false,
+        ImmutableList.of("refs/heads/master", "^refs/heads/main-.*"));
+
+    assertNonExistentSr(/* srName = */ "Foo");
+
+    TestUpdateUI updateUI = runMigration(/* expectedResult= */ Status.MIGRATED);
+    assertThat(updateUI.newlyCreatedSrs).isEqualTo(1);
+    assertThat(updateUI.existingSrsMismatchingWithMigration).isEqualTo(0);
+
+    assertExistentSr(
+        /* srName */ "Foo",
+        /* applicabilityExpression= */ "branch:\\\"refs/heads/master\\\" "
+            + "OR branch:\\\"^refs/heads/main-.*\\\"",
+        /* submittabilityExpression= */ "label:Foo=MAX AND -label:Foo=MIN",
+        /* canOverride= */ true);
+    assertLabelFunction("Foo", "NoBlock");
+  }
+
+  @Test
+  public void migrationIsIdempotent() throws Exception {
+    String oldRefsConfigId;
+    try (Repository repo = repoManager.openRepository(project)) {
+      oldRefsConfigId = repo.exactRef(RefNames.REFS_CONFIG).getObjectId().toString();
+    }
+    createLabel("Foo", "MaxWithBlock", /* ignoreSelfApproval= */ false);
+    assertNonExistentSr(/* srName = */ "Foo");
+
+    // Running the migration causes REFS_CONFIG to change.
+    TestUpdateUI updateUI = runMigration(/* expectedResult= */ Status.MIGRATED);
+    assertThat(updateUI.newlyCreatedSrs).isEqualTo(1);
+    assertThat(updateUI.existingSrsMismatchingWithMigration).isEqualTo(0);
+    try (Repository repo = repoManager.openRepository(project)) {
+      assertThat(oldRefsConfigId)
+          .isNotEqualTo(repo.exactRef(RefNames.REFS_CONFIG).getObjectId().toString());
+      oldRefsConfigId = repo.exactRef(RefNames.REFS_CONFIG).getObjectId().toString();
+    }
+
+    // No new SRs will be created. No conflicting submit requirements either since the migration
+    // detects that a previous run was made and skips the migration.
+    updateUI = runMigration(/* expectedResult= */ Status.PREVIOUSLY_MIGRATED);
+    assertThat(updateUI.newlyCreatedSrs).isEqualTo(0);
+    assertThat(updateUI.existingSrsMismatchingWithMigration).isEqualTo(0);
+    // Running the migration a second time won't update REFS_CONFIG.
+    try (Repository repo = repoManager.openRepository(project)) {
+      assertThat(oldRefsConfigId)
+          .isEqualTo(repo.exactRef(RefNames.REFS_CONFIG).getObjectId().toString());
+    }
+  }
+
+  @Test
+  public void migrationDoesNotCreateANewSubmitRequirement_ifSRAlreadyExists_matchingWithMigration()
+      throws Exception {
+    createLabel("Foo", "MaxWithBlock", /* ignoreSelfApproval= */ false);
+    createSubmitRequirement("Foo", "label:Foo=MAX AND -label:Foo=MIN", /* canOverride= */ true);
+    assertExistentSr(
+        /* srName */ "Foo",
+        /* applicabilityExpression= */ null,
+        /* submittabilityExpression= */ "label:Foo=MAX AND -label:Foo=MIN",
+        /* canOverride= */ true);
+
+    TestUpdateUI updateUI = runMigration(/* expectedResult= */ Status.MIGRATED);
+    // No new submit requirements are created.
+    assertThat(updateUI.newlyCreatedSrs).isEqualTo(0);
+    // No conflicting submit requirements from migration vs. what was previously configured.
+    assertThat(updateUI.existingSrsMismatchingWithMigration).isEqualTo(0);
+
+    // The existing SR was left as is.
+    assertExistentSr(
+        /* srName */ "Foo",
+        /* applicabilityExpression= */ null,
+        /* submittabilityExpression= */ "label:Foo=MAX AND -label:Foo=MIN",
+        /* canOverride= */ true);
+  }
+
+  @Test
+  public void
+      migrationDoesNotCreateANewSubmitRequirement_ifSRAlreadyExists_mismatchingWithMigration()
+          throws Exception {
+    createLabel("Foo", "MaxWithBlock", /* ignoreSelfApproval= */ false);
+    createSubmitRequirement("Foo", "project:" + project, /* canOverride= */ true);
+    assertExistentSr(
+        /* srName */ "Foo",
+        /* applicabilityExpression= */ null,
+        /* submittabilityExpression= */ "project:" + project,
+        /* canOverride= */ true);
+
+    TestUpdateUI updateUI = runMigration(/* expectedResult= */ Status.MIGRATED);
+    // One conflicting submit requirement between migration vs. what was previously configured.
+    assertThat(updateUI.existingSrsMismatchingWithMigration).isEqualTo(1);
+
+    // The existing SR was left as is.
+    assertExistentSr(
+        /* srName */ "Foo",
+        /* applicabilityExpression= */ null,
+        /* submittabilityExpression= */ "project:" + project,
+        /* canOverride= */ true);
+  }
+
+  @Test
+  public void migrationResetsBlockingLabel_ifSRAlreadyExists() throws Exception {
+    createLabel("Foo", "MaxWithBlock", /* ignoreSelfApproval= */ false);
+    createSubmitRequirement("Foo", "owner:" + admin.email(), /* canOverride= */ true);
+
+    TestUpdateUI updateUI = runMigration(/* expectedResult= */ Status.MIGRATED);
+    assertThat(updateUI.newlyCreatedSrs).isEqualTo(0);
+
+    // The label function was reset
+    assertLabelFunction("Foo", "NoBlock");
+  }
+
+  private TestUpdateUI runMigration(Status expectedResult) throws Exception {
+    TestUpdateUI updateUi = new TestUpdateUI();
+    MigrateLabelFunctionsToSubmitRequirement executor =
+        new MigrateLabelFunctionsToSubmitRequirement(repoManager, serverIdent.get());
+    Status status = executor.executeMigration(project, updateUi);
+    assertThat(status).isEqualTo(expectedResult);
+    projectCache.evictAndReindex(project);
+    return updateUi;
+  }
+
+  private void createLabel(String labelName, String function, boolean ignoreSelfApproval)
+      throws Exception {
+    createLabel(
+        labelName,
+        function,
+        ignoreSelfApproval,
+        ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad"));
+  }
+
+  private void createLabel(
+      String labelName, String function, boolean ignoreSelfApproval, Map<String, String> values)
+      throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.name = labelName;
+    input.function = function;
+    input.ignoreSelfApproval = ignoreSelfApproval;
+    input.values = values;
+    gApi.projects().name(project.get()).label(labelName).create(input);
+  }
+
+  private void createLabelWithBranch(
+      String labelName,
+      String function,
+      boolean ignoreSelfApproval,
+      ImmutableList<String> refPatterns)
+      throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.name = labelName;
+    input.function = function;
+    input.ignoreSelfApproval = ignoreSelfApproval;
+    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+    input.branches = refPatterns;
+    gApi.projects().name(project.get()).label(labelName).create(input);
+  }
+
+  @CanIgnoreReturnValue
+  private SubmitRequirementApi createSubmitRequirement(
+      String name, String submitExpression, boolean canOverride) throws Exception {
+    SubmitRequirementInput input = new SubmitRequirementInput();
+    input.name = name;
+    input.submittabilityExpression = submitExpression;
+    input.allowOverrideInChildProjects = canOverride;
+    return gApi.projects().name(project.get()).submitRequirement(name).create(input);
+  }
+
+  private void assertLabelFunction(String labelName, String function) throws Exception {
+    LabelDefinitionInfo info = gApi.projects().name(project.get()).label(labelName).get();
+    assertThat(info.function).isEqualTo(function);
+  }
+
+  private void assertNonExistentSr(String srName) {
+    ResourceNotFoundException foo =
+        assertThrows(
+            ResourceNotFoundException.class,
+            () -> gApi.projects().name(project.get()).submitRequirement("Foo").get());
+    assertThat(foo.getMessage()).isEqualTo("Submit requirement '" + srName + "' does not exist");
+  }
+
+  private void assertExistentSr(
+      String srName,
+      String applicabilityExpression,
+      String submittabilityExpression,
+      boolean canOverride)
+      throws Exception {
+    SubmitRequirementInfo sr = gApi.projects().name(project.get()).submitRequirement(srName).get();
+    assertThat(sr.applicabilityExpression).isEqualTo(applicabilityExpression);
+    assertThat(sr.submittabilityExpression).isEqualTo(submittabilityExpression);
+    assertThat(sr.allowOverrideInChildProjects).isEqualTo(canOverride);
+  }
+
+  private static class TestUpdateUI implements UpdateUI {
+    int existingSrsMismatchingWithMigration = 0;
+    int newlyCreatedSrs = 0;
+
+    @Override
+    public void message(String message) {
+      if (message.startsWith("Warning")) {
+        existingSrsMismatchingWithMigration += 1;
+      } else if (message.startsWith("Project")) {
+        newlyCreatedSrs += 1;
+      }
+    }
+
+    @Override
+    public boolean yesno(boolean defaultValue, String message) {
+      return false;
+    }
+
+    @Override
+    public void waitForUser() {}
+
+    @Override
+    public String readString(String defaultValue, Set<String> allowedValues, String message) {
+      return null;
+    }
+
+    @Override
+    public boolean isBatch() {
+      return false;
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/RestApiServletIT.java b/javatests/com/google/gerrit/acceptance/rest/RestApiServletIT.java
index 4efdbba..a0ae91b 100644
--- a/javatests/com/google/gerrit/acceptance/rest/RestApiServletIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/RestApiServletIT.java
@@ -417,6 +417,16 @@
     }
   }
 
+  @Test
+  public void requestsOnRootCollectionDontRequireTrailingSlash() throws Exception {
+    adminRestSession.get("/access").assertOK();
+    adminRestSession.get("/accounts?q=is:active").assertOK();
+    adminRestSession.get("/changes?q=status:open").assertOK();
+    // GET on /config/ is not supported, hence we cannot test GET on /config
+    adminRestSession.get("/groups").assertOK();
+    adminRestSession.get("/projects").assertOK();
+  }
+
   private ObjectId getMetaRefSha1(Result change) {
     return change.getChange().notes().getRevision();
   }
diff --git a/javatests/com/google/gerrit/acceptance/rest/TraceIT.java b/javatests/com/google/gerrit/acceptance/rest/TraceIT.java
index 64e3762..9710bf4 100644
--- a/javatests/com/google/gerrit/acceptance/rest/TraceIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/TraceIT.java
@@ -43,6 +43,7 @@
 import com.google.gerrit.extensions.events.ChangeIndexedListener;
 import com.google.gerrit.httpd.restapi.ParameterParser;
 import com.google.gerrit.httpd.restapi.RestApiServlet;
+import com.google.gerrit.server.ExceptionHook;
 import com.google.gerrit.server.events.CommitReceivedEvent;
 import com.google.gerrit.server.git.WorkQueue;
 import com.google.gerrit.server.git.validators.CommitValidationException;
@@ -801,7 +802,7 @@
 
   @Test
   @GerritConfig(name = "retry.retryWithTraceOnFailure", value = "true")
-  public void noAutoRetryIfExceptionCausesNormalRetrying() throws Exception {
+  public void autoRetryWithTrace() throws Exception {
     String changeId = createChange().getChangeId();
     approve(changeId);
 
@@ -811,6 +812,49 @@
       RestResponse response = adminRestSession.post("/changes/" + changeId + "/submit");
       assertThat(response.getStatusCode()).isEqualTo(SC_INTERNAL_SERVER_ERROR);
       assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).startsWith("retry-on-failure-");
+      assertThat(traceSubmitRule.traceId).startsWith("retry-on-failure-");
+      assertThat(traceSubmitRule.isLoggingForced).isTrue();
+    }
+  }
+
+  @Test
+  @GerritConfig(name = "retry.retryWithTraceOnFailure", value = "true")
+  public void noAutoRetryIfExceptionCausesNormalRetrying() throws Exception {
+    String changeId = createChange().getChangeId();
+    approve(changeId);
+
+    TraceSubmitRule traceSubmitRule = new TraceSubmitRule();
+    traceSubmitRule.failAlways = true;
+    try (Registration registration =
+        extensionRegistry
+            .newRegistration()
+            .add(traceSubmitRule)
+            .add(
+                new ExceptionHook() {
+                  @Override
+                  public boolean shouldRetry(String actionType, String actionName, Throwable t) {
+                    return true;
+                  }
+                })) {
+      RestResponse response = adminRestSession.post("/changes/" + changeId + "/submit");
+      assertThat(response.getStatusCode()).isEqualTo(SC_INTERNAL_SERVER_ERROR);
+      assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNull();
+      assertThat(traceSubmitRule.traceId).isNull();
+      assertThat(traceSubmitRule.isLoggingForced).isFalse();
+    }
+  }
+
+  @Test
+  public void noAutoRetryWithTraceIfDisabled() throws Exception {
+    String changeId = createChange().getChangeId();
+    approve(changeId);
+
+    TraceSubmitRule traceSubmitRule = new TraceSubmitRule();
+    traceSubmitRule.failOnce = true;
+    try (Registration registration = extensionRegistry.newRegistration().add(traceSubmitRule)) {
+      RestResponse response = adminRestSession.post("/changes/" + changeId + "/submit");
+      assertThat(response.getStatusCode()).isEqualTo(SC_INTERNAL_SERVER_ERROR);
+      assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNull();
       assertThat(traceSubmitRule.traceId).isNull();
       assertThat(traceSubmitRule.isLoggingForced).isFalse();
     }
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/CapabilityInfo.java b/javatests/com/google/gerrit/acceptance/rest/account/CapabilityInfo.java
index 7598062..3ce6d8d 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/CapabilityInfo.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/CapabilityInfo.java
@@ -38,6 +38,7 @@
   public boolean viewConnections;
   public boolean viewPlugins;
   public boolean viewQueue;
+  public boolean viewSecondaryEmails;
 
   static class QueryLimit {
     short min;
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/EmailIT.java b/javatests/com/google/gerrit/acceptance/rest/account/EmailIT.java
index d055875..f40910a 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/EmailIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/EmailIT.java
@@ -33,6 +33,7 @@
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.RefLogIdentityProvider;
 import com.google.gerrit.server.ServerInitiated;
 import com.google.gerrit.server.account.AccountsUpdate;
 import com.google.gerrit.server.account.DefaultRealm;
@@ -51,12 +52,14 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import java.util.List;
+import java.util.Locale;
 import java.util.Optional;
 import java.util.Set;
 import org.junit.Test;
 
 public class EmailIT extends AbstractDaemonTest {
   @Inject private @AnonymousCowardName String anonymousCowardName;
+  @Inject private RefLogIdentityProvider refLogIdentityProvider;
   @Inject private @CanonicalWebUrl Provider<String> canonicalUrl;
   @Inject private @EnablePeerIPInReflogRecord boolean enablePeerIPInReflogRecord;
   @Inject private @ServerInitiated Provider<AccountsUpdate> accountsUpdateProvider;
@@ -177,7 +180,7 @@
     assertThat(gApi.accounts().self().get().email).isNotEqualTo(email);
 
     requestScopeOperations.resetCurrentApiUser();
-    String emailOtherCase = email.toUpperCase();
+    String emailOtherCase = email.toUpperCase(Locale.US);
     gApi.accounts().self().email(emailOtherCase).setPreferred();
     assertThat(gApi.accounts().self().get().email).isEqualTo(email);
   }
@@ -283,6 +286,7 @@
             authConfig,
             realm,
             anonymousCowardName,
+            refLogIdentityProvider,
             canonicalUrl,
             enablePeerIPInReflogRecord,
             accountCache,
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java b/javatests/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java
index 39a32af..61164f7 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java
@@ -949,7 +949,7 @@
     extIdNotes.insert(extId);
     extIdNotes.commit(md);
     assertThat(getAccountId(extIdNotes, scheme, id)).isEqualTo(accountId.get());
-    assertThat(getExternalId(extIdNotes, scheme, id.toLowerCase()).isPresent()).isFalse();
+    assertThat(getExternalId(extIdNotes, scheme, id.toLowerCase(Locale.US)).isPresent()).isFalse();
   }
 
   private void testCaseInsensitiveExternalIdKey(
@@ -959,7 +959,8 @@
     extIdNotes.insert(extId);
     extIdNotes.commit(md);
     assertThat(getAccountId(extIdNotes, scheme, id)).isEqualTo(accountId.get());
-    assertThat(getAccountId(extIdNotes, scheme, id.toLowerCase())).isEqualTo(accountId.get());
+    assertThat(getAccountId(extIdNotes, scheme, id.toLowerCase(Locale.US)))
+        .isEqualTo(accountId.get());
   }
 
   /**
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/GetAccountDetailIT.java b/javatests/com/google/gerrit/acceptance/rest/account/GetAccountDetailIT.java
index c89e11a..b21f97d 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/GetAccountDetailIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/GetAccountDetailIT.java
@@ -91,16 +91,26 @@
     Account.Id id =
         accountOperations
             .newAccount()
-            .preferredEmail("preferred@email")
-            .addSecondaryEmail("secondary@email")
+            .preferredEmail("preferred@eexample.com")
+            .addSecondaryEmail("secondary@example.com")
             .create();
+
     RestResponse r = userRestSession.get("/accounts/secondary/detail/");
     r.assertStatus(404);
+
+    r = userRestSession.get("/accounts/secondary@example.com/detail/");
+    r.assertStatus(404);
+
     // The admin has MODIFY_ACCOUNT permission and can see the user.
     r = adminRestSession.get("/accounts/secondary/detail/");
     r.assertStatus(200);
     AccountDetailInfo info = newGson().fromJson(r.getReader(), AccountDetailInfo.class);
     assertThat(info._accountId).isEqualTo(id.get());
+
+    r = adminRestSession.get("/accounts/secondary@example.com/detail/");
+    r.assertStatus(200);
+    info = newGson().fromJson(r.getReader(), AccountDetailInfo.class);
+    assertThat(info._accountId).isEqualTo(id.get());
   }
 
   private static class CustomAccountTagProvider implements AccountTagProvider {
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java b/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java
index eb827c0..4477140 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java
@@ -20,6 +20,8 @@
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.capabilityKey;
+import static com.google.gerrit.entities.RefNames.changeMetaRef;
+import static com.google.gerrit.entities.RefNames.patchSetRef;
 import static com.google.gerrit.extensions.client.ListChangesOption.MESSAGES;
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
@@ -28,11 +30,13 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Iterables;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.acceptance.RestSession;
 import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.UseLocalDisk;
 import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
@@ -43,8 +47,10 @@
 import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.Patch;
+import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RobotComment;
 import com.google.gerrit.extensions.api.changes.DraftInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
@@ -55,6 +61,7 @@
 import com.google.gerrit.extensions.api.changes.SubmitInput;
 import com.google.gerrit.extensions.api.groups.GroupInput;
 import com.google.gerrit.extensions.client.Side;
+import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
@@ -63,6 +70,7 @@
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.CommentsUtil;
@@ -73,6 +81,11 @@
 import com.google.inject.Inject;
 import org.apache.http.Header;
 import org.apache.http.message.BasicHeader;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.ReflogEntry;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
 import org.junit.After;
 import org.junit.Before;
 import org.junit.Test;
@@ -105,28 +118,210 @@
   }
 
   @Test
+  @UseLocalDisk
   public void voteOnBehalfOf() throws Exception {
     allowCodeReviewOnBehalfOf();
+    TestAccount realUser = admin;
+    TestAccount impersonatedUser = user;
     PushOneCommit.Result r = createChange();
     RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
 
+    try (Repository repo = repoManager.openRepository(project)) {
+      String changeMetaRef = changeMetaRef(r.getChange().getId());
+      createRefLogFileIfMissing(repo, changeMetaRef);
+
+      ReviewInput in = ReviewInput.recommend();
+      in.onBehalfOf = impersonatedUser.id().toString();
+      in.message = "Message on behalf of";
+      revision.review(in);
+
+      PatchSetApproval psa = Iterables.getOnlyElement(r.getChange().approvals().values());
+      assertThat(psa.patchSetId().get()).isEqualTo(1);
+      assertThat(psa.label()).isEqualTo("Code-Review");
+      assertThat(psa.accountId()).isEqualTo(impersonatedUser.id());
+      assertThat(psa.value()).isEqualTo(1);
+      assertThat(psa.realAccountId()).isEqualTo(realUser.id());
+
+      assertLastChangeMessage(r.getChange(), in.message, impersonatedUser, realUser);
+
+      // The change meta commit is created by the server and has the impersonated user as the
+      // author.
+      // Person idents of users in NoteDb commits are obfuscated due to privacy reasons.
+      RevCommit changeMetaCommit = projectOperations.project(project).getHead(changeMetaRef);
+      assertThat(changeMetaCommit.getCommitterIdent().getEmailAddress())
+          .isEqualTo(serverIdent.get().getEmailAddress());
+      assertThat(changeMetaCommit.getAuthorIdent().getEmailAddress())
+          .isEqualTo(changeNoteUtil.getAccountIdAsEmailAddress(impersonatedUser.id()));
+
+      // The ref log for the change meta ref records the impersonated user.
+      ReflogEntry changeMetaRefLogEntry = repo.getReflogReader(changeMetaRef).getLastEntry();
+      assertThat(changeMetaRefLogEntry.getWho().getEmailAddress())
+          .isEqualTo(impersonatedUser.email());
+    }
+  }
+
+  @Test
+  public void overrideImpersonatedVoteWithOtherImpersonatedVote_sameValue() throws Exception {
+    allowCodeReviewOnBehalfOf();
+    TestAccount realUser = admin;
+    TestAccount realUser2 = admin2;
+    TestAccount impersonatedUser = user;
+    PushOneCommit.Result r = createChange();
+    RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+
+    // realUser votes Code-Review+1 on behalf of impersonatedUser
     ReviewInput in = ReviewInput.recommend();
-    in.onBehalfOf = user.id().toString();
+    in.onBehalfOf = impersonatedUser.id().toString();
     in.message = "Message on behalf of";
     revision.review(in);
 
     PatchSetApproval psa = Iterables.getOnlyElement(r.getChange().approvals().values());
     assertThat(psa.patchSetId().get()).isEqualTo(1);
     assertThat(psa.label()).isEqualTo("Code-Review");
-    assertThat(psa.accountId()).isEqualTo(user.id());
+    assertThat(psa.accountId()).isEqualTo(impersonatedUser.id());
     assertThat(psa.value()).isEqualTo(1);
-    assertThat(psa.realAccountId()).isEqualTo(admin.id());
+    assertThat(psa.realAccountId()).isEqualTo(realUser.id());
 
-    ChangeData cd = r.getChange();
-    ChangeMessage m = Iterables.getLast(cmUtil.byChange(cd.notes()));
-    assertThat(m.getMessage()).endsWith(in.message);
-    assertThat(m.getAuthor()).isEqualTo(user.id());
-    assertThat(m.getRealAuthor()).isEqualTo(admin.id());
+    assertLastChangeMessage(r.getChange(), in.message, impersonatedUser, realUser);
+
+    // realUser2 votes Code-Review+1 on behalf of impersonatedUser, this should override the
+    // impersonated Code-Review+1 of realUser with an impersonated Code-Review+1 of realUser2
+    requestScopeOperations.setApiUser(realUser2.id());
+    in = ReviewInput.recommend();
+    in.onBehalfOf = impersonatedUser.id().toString();
+    in.message = "Another message on behalf of";
+    gApi.changes().id(r.getChangeId()).current().review(in);
+
+    psa = Iterables.getOnlyElement(r.getChange().approvals().values());
+    assertThat(psa.patchSetId().get()).isEqualTo(1);
+    assertThat(psa.label()).isEqualTo("Code-Review");
+    assertThat(psa.accountId()).isEqualTo(impersonatedUser.id());
+    assertThat(psa.value()).isEqualTo(1);
+    assertThat(psa.realAccountId()).isEqualTo(realUser2.id());
+
+    assertLastChangeMessage(r.getChange(), in.message, impersonatedUser, realUser2);
+  }
+
+  @Test
+  public void overrideImpersonatedVoteWithOtherImpersonatedVote_differentValue() throws Exception {
+    allowCodeReviewOnBehalfOf();
+    TestAccount realUser = admin;
+    TestAccount realUser2 = admin2;
+    TestAccount impersonatedUser = user;
+    PushOneCommit.Result r = createChange();
+    RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+
+    // realUser votes Code-Review+1 on behalf of impersonatedUser
+    ReviewInput in = ReviewInput.recommend();
+    in.onBehalfOf = impersonatedUser.id().toString();
+    in.message = "Message on behalf of";
+    revision.review(in);
+
+    PatchSetApproval psa = Iterables.getOnlyElement(r.getChange().approvals().values());
+    assertThat(psa.patchSetId().get()).isEqualTo(1);
+    assertThat(psa.label()).isEqualTo("Code-Review");
+    assertThat(psa.accountId()).isEqualTo(impersonatedUser.id());
+    assertThat(psa.value()).isEqualTo(1);
+    assertThat(psa.realAccountId()).isEqualTo(realUser.id());
+
+    assertLastChangeMessage(r.getChange(), in.message, impersonatedUser, realUser);
+
+    // realUser2 votes Code-Review-1 on behalf of impersonatedUser, this should override the
+    // impersonated Code-Review+1 of realUser with an impersonated Code-Review-1 of realUser2
+    requestScopeOperations.setApiUser(realUser2.id());
+    in = ReviewInput.dislike();
+    in.onBehalfOf = impersonatedUser.id().toString();
+    in.message = "Another message on behalf of";
+    gApi.changes().id(r.getChangeId()).current().review(in);
+
+    psa = Iterables.getOnlyElement(r.getChange().approvals().values());
+    assertThat(psa.patchSetId().get()).isEqualTo(1);
+    assertThat(psa.label()).isEqualTo("Code-Review");
+    assertThat(psa.accountId()).isEqualTo(impersonatedUser.id());
+    assertThat(psa.value()).isEqualTo(-1);
+    assertThat(psa.realAccountId()).isEqualTo(realUser2.id());
+
+    assertLastChangeMessage(r.getChange(), in.message, impersonatedUser, realUser2);
+  }
+
+  @Test
+  public void overrideImpersonatedVoteWithNonImpersonatedVote_sameValue() throws Exception {
+    allowCodeReviewOnBehalfOf();
+    TestAccount realUser = admin;
+    TestAccount impersonatedUser = user;
+    PushOneCommit.Result r = createChange();
+    RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+
+    // realUser votes Code-Review+1 on behalf of impersonatedUser
+    ReviewInput in = ReviewInput.recommend();
+    in.onBehalfOf = impersonatedUser.id().toString();
+    in.message = "Message on behalf of";
+    revision.review(in);
+
+    PatchSetApproval psa = Iterables.getOnlyElement(r.getChange().approvals().values());
+    assertThat(psa.patchSetId().get()).isEqualTo(1);
+    assertThat(psa.label()).isEqualTo("Code-Review");
+    assertThat(psa.accountId()).isEqualTo(impersonatedUser.id());
+    assertThat(psa.value()).isEqualTo(1);
+    assertThat(psa.realAccountId()).isEqualTo(realUser.id());
+
+    assertLastChangeMessage(r.getChange(), in.message, impersonatedUser, realUser);
+
+    // impersonatedUser votes Code-Review+1 themselves, this should override the impersonated
+    // Code-Review+1 with a non-impersonated Code-Review+1
+    requestScopeOperations.setApiUser(impersonatedUser.id());
+    in = ReviewInput.recommend();
+    in.message = "Message";
+    gApi.changes().id(r.getChangeId()).current().review(in);
+
+    psa = Iterables.getOnlyElement(r.getChange().approvals().values());
+    assertThat(psa.patchSetId().get()).isEqualTo(1);
+    assertThat(psa.label()).isEqualTo("Code-Review");
+    assertThat(psa.accountId()).isEqualTo(impersonatedUser.id());
+    assertThat(psa.value()).isEqualTo(1);
+    assertThat(psa.realAccountId()).isEqualTo(impersonatedUser.id());
+
+    assertLastChangeMessage(r.getChange(), in.message, impersonatedUser, impersonatedUser);
+  }
+
+  @Test
+  public void overrideImpersonatedVoteWithNonImpersonatedVote_differentValue() throws Exception {
+    allowCodeReviewOnBehalfOf();
+    TestAccount realUser = admin;
+    TestAccount impersonatedUser = user;
+    PushOneCommit.Result r = createChange();
+    RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+
+    // realUser votes Code-Review+1 on behalf of impersonatedUser
+    ReviewInput in = ReviewInput.recommend();
+    in.onBehalfOf = impersonatedUser.id().toString();
+    in.message = "Message on behalf of";
+    revision.review(in);
+
+    PatchSetApproval psa = Iterables.getOnlyElement(r.getChange().approvals().values());
+    assertThat(psa.patchSetId().get()).isEqualTo(1);
+    assertThat(psa.label()).isEqualTo("Code-Review");
+    assertThat(psa.accountId()).isEqualTo(impersonatedUser.id());
+    assertThat(psa.value()).isEqualTo(1);
+    assertThat(psa.realAccountId()).isEqualTo(realUser.id());
+
+    assertLastChangeMessage(r.getChange(), in.message, impersonatedUser, realUser);
+
+    // impersonatedUser votes Code-Review-1 themselves, this should override the impersonated
+    // Code-Review+1 with a non-impersonated Code-Review-1
+    requestScopeOperations.setApiUser(impersonatedUser.id());
+    in = ReviewInput.dislike();
+    in.message = "Message";
+    gApi.changes().id(r.getChangeId()).current().review(in);
+
+    psa = Iterables.getOnlyElement(r.getChange().approvals().values());
+    assertThat(psa.patchSetId().get()).isEqualTo(1);
+    assertThat(psa.label()).isEqualTo("Code-Review");
+    assertThat(psa.accountId()).isEqualTo(impersonatedUser.id());
+    assertThat(psa.value()).isEqualTo(-1);
+    assertThat(psa.realAccountId()).isEqualTo(impersonatedUser.id());
+
+    assertLastChangeMessage(r.getChange(), in.message, impersonatedUser, impersonatedUser);
   }
 
   @Test
@@ -342,21 +537,122 @@
   }
 
   @Test
-  public void submitOnBehalfOf() throws Exception {
-    allowSubmitOnBehalfOf();
-    PushOneCommit.Result r = createChange();
+  @UseLocalDisk
+  public void submitOnBehalfOf_mergeAlways() throws Exception {
+    TestAccount realUser = admin;
+    TestAccount impersonatedUser = admin2;
+
+    // Create a project with MERGE_ALWAYS submit strategy so that a merge commit is created on
+    // submit and we can verify its committer and author and the ref log for the update of the
+    // target branch.
+    Project.NameKey project =
+        projectOperations.newProject().submitType(SubmitType.MERGE_ALWAYS).create();
+
+    testSubmitOnBehalfOf(project, realUser, impersonatedUser);
+
+    // The merge commit is created by the server and has the impersonated user as the author.
+    RevCommit mergeCommit = projectOperations.project(project).getHead("refs/heads/master");
+    assertThat(mergeCommit.getCommitterIdent().getEmailAddress())
+        .isEqualTo(serverIdent.get().getEmailAddress());
+    assertThat(mergeCommit.getAuthorIdent().getEmailAddress()).isEqualTo(impersonatedUser.email());
+
+    // The ref log for the target branch records the impersonated user.
+    try (Repository repo = repoManager.openRepository(project)) {
+      ReflogEntry targetBranchRefLogEntry =
+          repo.getReflogReader("refs/heads/master").getLastEntry();
+      assertThat(targetBranchRefLogEntry.getWho().getEmailAddress())
+          .isEqualTo(impersonatedUser.email());
+    }
+  }
+
+  @Test
+  @UseLocalDisk
+  public void submitOnBehalfOf_rebaseAlways() throws Exception {
+    TestAccount originalAuthor = admin; // user that creates and authors the change that is rebased
+    TestAccount realUser = admin2;
+    TestAccount impersonatedUser = user;
+
+    // Create a project with REBASE_ALWAYS submit strategy so that a new patch set is created on
+    // submit and we can verify its committer and author and the ref log for the update of the
+    // patch set ref and the target branch.
+    Project.NameKey project =
+        projectOperations.newProject().submitType(SubmitType.REBASE_ALWAYS).create();
+
+    ChangeData cd = testSubmitOnBehalfOf(project, realUser, impersonatedUser);
+
+    // Rebase on submit is expected to create a new patch set.
+    assertThat(cd.currentPatchSet().id().get()).isEqualTo(2);
+
+    // The patch set commit is created by the impersonated user and has the author of the rebased
+    // commit as the author.
+    RevCommit newPatchSetCommit =
+        projectOperations.project(project).getHead(cd.currentPatchSet().refName());
+    assertThat(newPatchSetCommit.getCommitterIdent().getEmailAddress())
+        .isEqualTo(impersonatedUser.email());
+    assertThat(newPatchSetCommit.getAuthorIdent().getEmailAddress())
+        .isEqualTo(originalAuthor.email());
+
+    try (Repository repo = repoManager.openRepository(project)) {
+      // The ref log for the patch set ref records the impersonated user.
+      ReflogEntry patchSetRefLogEntry =
+          repo.getReflogReader(cd.currentPatchSet().refName()).getLastEntry();
+      assertThat(patchSetRefLogEntry.getWho().getEmailAddress())
+          .isEqualTo(impersonatedUser.email());
+
+      // The ref log for the target branch records the impersonated user.
+      ReflogEntry targetBranchRefLogEntry =
+          repo.getReflogReader("refs/heads/master").getLastEntry();
+      assertThat(targetBranchRefLogEntry.getWho().getEmailAddress())
+          .isEqualTo(impersonatedUser.email());
+    }
+  }
+
+  @CanIgnoreReturnValue
+  private ChangeData testSubmitOnBehalfOf(
+      Project.NameKey project, TestAccount realUser, TestAccount impersonatedUser)
+      throws Exception {
+    allowSubmitOnBehalfOf(project);
+
+    TestRepository<InMemoryRepository> testRepo = cloneProject(project, realUser);
+
+    PushOneCommit.Result r = createChange(testRepo);
     String changeId = project.get() + "~master~" + r.getChangeId();
     gApi.changes().id(changeId).current().review(ReviewInput.approve());
     SubmitInput in = new SubmitInput();
-    in.onBehalfOf = admin2.email();
-    gApi.changes().id(changeId).current().submit(in);
+    in.onBehalfOf = impersonatedUser.email();
 
-    ChangeData cd = r.getChange();
-    assertThat(cd.change().isMerged()).isTrue();
-    PatchSetApproval submitter =
-        approvalsUtil.getSubmitter(cd.notes(), cd.change().currentPatchSetId());
-    assertThat(submitter.accountId()).isEqualTo(admin2.id());
-    assertThat(submitter.realAccountId()).isEqualTo(admin.id());
+    try (Repository repo = repoManager.openRepository(project)) {
+      String changeMetaRef = changeMetaRef(r.getChange().getId());
+      createRefLogFileIfMissing(repo, changeMetaRef);
+      createRefLogFileIfMissing(repo, "refs/heads/master");
+      createRefLogFileIfMissing(repo, patchSetRef(PatchSet.id(r.getChange().getId(), 2)));
+
+      requestScopeOperations.setApiUser(realUser.id());
+      gApi.changes().id(changeId).current().submit(in);
+
+      ChangeData cd = r.getChange();
+      assertThat(cd.change().isMerged()).isTrue();
+      PatchSetApproval submitter =
+          approvalsUtil.getSubmitter(cd.notes(), cd.change().currentPatchSetId());
+      assertThat(submitter.accountId()).isEqualTo(impersonatedUser.id());
+      assertThat(submitter.realAccountId()).isEqualTo(realUser.id());
+
+      // The change meta commit is created by the server and has the impersonated user as the
+      // author.
+      // Person idents of users in NoteDb commits are obfuscated due to privacy reasons.
+      RevCommit changeMetaCommit = projectOperations.project(project).getHead(changeMetaRef);
+      assertThat(changeMetaCommit.getCommitterIdent().getEmailAddress())
+          .isEqualTo(serverIdent.get().getEmailAddress());
+      assertThat(changeMetaCommit.getAuthorIdent().getEmailAddress())
+          .isEqualTo(changeNoteUtil.getAccountIdAsEmailAddress(impersonatedUser.id()));
+
+      // The ref log for the change meta ref records the impersonated user.
+      ReflogEntry changeMetaRefLogEntry = repo.getReflogReader(changeMetaRef).getLastEntry();
+      assertThat(changeMetaRefLogEntry.getWho().getEmailAddress())
+          .isEqualTo(impersonatedUser.email());
+
+      return cd;
+    }
   }
 
   @Test
@@ -548,11 +844,7 @@
     assertThat(psa.value()).isEqualTo(1);
     assertThat(psa.realAccountId()).isEqualTo(admin.id()); // not user2
 
-    ChangeData cd = r.getChange();
-    ChangeMessage m = Iterables.getLast(cmUtil.byChange(cd.notes()));
-    assertThat(m.getMessage()).endsWith(in.message);
-    assertThat(m.getAuthor()).isEqualTo(user.id());
-    assertThat(m.getRealAuthor()).isEqualTo(admin.id()); // not user2
+    assertLastChangeMessage(r.getChange(), in.message, user, admin);
   }
 
   @Test
@@ -571,10 +863,30 @@
     ChangeInfo info = gApi.changes().id(r.getChangeId()).get(MESSAGES);
     assertThat(info.messages).hasSize(2);
 
-    ChangeMessageInfo changeMessageInfo = Iterables.getLast(info.messages);
-    assertThat(changeMessageInfo.realAuthor).isNotNull();
-    assertThat(changeMessageInfo.realAuthor._accountId)
-        .isEqualTo(accountCreator.user2().id().get());
+    assertLastChangeMessage(r.getChange(), in.message, user, accountCreator.user2());
+  }
+
+  private void assertLastChangeMessage(
+      ChangeData changeData,
+      String expectedMessage,
+      TestAccount expectedAuthor,
+      TestAccount expectedRealAuthor)
+      throws RestApiException {
+    ChangeMessage m = Iterables.getLast(cmUtil.byChange(changeData.notes()));
+    assertThat(m.getMessage()).endsWith(expectedMessage);
+    assertThat(m.getAuthor()).isEqualTo(expectedAuthor.id());
+    assertThat(m.getRealAuthor()).isEqualTo(expectedRealAuthor.id());
+
+    ChangeMessageInfo lastChangeMessageInfo =
+        Iterables.getLast(gApi.changes().id(changeData.getId().get()).get().messages);
+    assertThat(lastChangeMessageInfo.message).endsWith(expectedMessage);
+    assertThat(lastChangeMessageInfo.author._accountId).isEqualTo(expectedAuthor.id().get());
+    if (expectedAuthor.id().equals(expectedRealAuthor.id())) {
+      assertThat(lastChangeMessageInfo.realAuthor).isNull();
+    } else {
+      assertThat(lastChangeMessageInfo.realAuthor._accountId)
+          .isEqualTo(expectedRealAuthor.id().get());
+    }
   }
 
   private void allowCodeReviewOnBehalfOf() throws Exception {
@@ -591,6 +903,10 @@
   }
 
   private void allowSubmitOnBehalfOf() throws Exception {
+    allowSubmitOnBehalfOf(project);
+  }
+
+  private void allowSubmitOnBehalfOf(Project.NameKey project) throws Exception {
     String heads = "refs/heads/*";
     projectOperations
         .project(project)
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/PutUsernameIT.java b/javatests/com/google/gerrit/acceptance/rest/account/PutUsernameIT.java
index f46cf0c..f4e9457 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/PutUsernameIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/PutUsernameIT.java
@@ -20,6 +20,7 @@
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.extensions.api.accounts.UsernameInput;
+import java.util.Locale;
 import org.junit.Test;
 
 public class PutUsernameIT extends AbstractDaemonTest {
@@ -46,7 +47,7 @@
   @GerritConfig(name = "auth.userNameCaseInsensitive", value = "true")
   public void setExistingCaseInsensitive_Conflict() throws Exception {
     UsernameInput in = new UsernameInput();
-    in.username = admin.username().toUpperCase();
+    in.username = admin.username().toUpperCase(Locale.US);
     adminRestSession
         .put("/accounts/" + accountCreator.create().id().get() + "/username", in)
         .assertConflict();
diff --git a/javatests/com/google/gerrit/acceptance/rest/binding/ChangesRestApiBindingsIT.java b/javatests/com/google/gerrit/acceptance/rest/binding/ChangesRestApiBindingsIT.java
index 2d663df..27bd6b9 100644
--- a/javatests/com/google/gerrit/acceptance/rest/binding/ChangesRestApiBindingsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/binding/ChangesRestApiBindingsIT.java
@@ -65,10 +65,6 @@
           RestCall.get("/changes/%s/drafts"),
           RestCall.get("/changes/%s/attention"),
           RestCall.post("/changes/%s/attention"),
-          RestCall.get("/changes/%s/assignee"),
-          RestCall.get("/changes/%s/past_assignees"),
-          RestCall.put("/changes/%s/assignee"),
-          RestCall.delete("/changes/%s/assignee"),
           RestCall.post("/changes/%s/private"),
           RestCall.post("/changes/%s/private.delete"),
           RestCall.delete("/changes/%s/private"),
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
index 0e4f212..f9fb92c 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
@@ -95,6 +95,8 @@
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.context.RefUpdateContext;
+import com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.server.validators.ValidationException;
 import com.google.gerrit.testing.ConfigSuite;
@@ -1125,20 +1127,22 @@
   }
 
   private void setChangeStatusToNew(PushOneCommit.Result... changes) throws Throwable {
-    for (PushOneCommit.Result change : changes) {
-      try (BatchUpdate bu =
-          batchUpdateFactory.create(project, userFactory.create(admin.id()), TimeUtil.now())) {
-        bu.addOp(
-            change.getChange().getId(),
-            new BatchUpdateOp() {
-              @Override
-              public boolean updateChange(ChangeContext ctx) {
-                ctx.getChange().setStatus(Change.Status.NEW);
-                ctx.getUpdate(ctx.getChange().currentPatchSetId()).setStatus(Change.Status.NEW);
-                return true;
-              }
-            });
-        bu.execute();
+    try (RefUpdateContext ctx = RefUpdateContext.open(RefUpdateType.CHANGE_MODIFICATION)) {
+      for (PushOneCommit.Result change : changes) {
+        try (BatchUpdate bu =
+            batchUpdateFactory.create(project, userFactory.create(admin.id()), TimeUtil.now())) {
+          bu.addOp(
+              change.getChange().getId(),
+              new BatchUpdateOp() {
+                @Override
+                public boolean updateChange(ChangeContext ctx) {
+                  ctx.getChange().setStatus(Change.Status.NEW);
+                  ctx.getUpdate(ctx.getChange().currentPatchSetId()).setStatus(Change.Status.NEW);
+                  return true;
+                }
+              });
+          bu.execute();
+        }
       }
     }
   }
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java
index 81c098f..aeebc10 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java
@@ -234,11 +234,11 @@
     PushOneCommit.Result change2 = createChange("Change 2", "a.txt", "other content");
     submitWithConflict(
         change2.getChangeId(),
-        "Cannot rebase "
-            + change2.getCommit().name()
-            + ": The change could not be rebased due to a conflict during merge.\n\n"
-            + "merge conflict(s):\n"
-            + "a.txt");
+        String.format(
+            "Cannot rebase %s: Change %s could not be rebased due to a conflict during merge.\n\n"
+                + "merge conflict(s):\n"
+                + "a.txt",
+            change2.getCommit().name(), change2.getChange().getId()));
     RevCommit head = projectOperations.project(project).getHead("master");
     assertThat(head).isEqualTo(headAfterFirstSubmit);
     assertCurrentRevision(change2.getChangeId(), 1, change2.getCommit());
@@ -362,12 +362,11 @@
 
     submitWithConflict(
         change2.getChangeId(),
-        "Cannot rebase "
-            + change2.getCommit().getName()
-            + ": "
-            + "The change could not be rebased due to a conflict during merge.\n\n"
-            + "merge conflict(s):\n"
-            + "fileName 2");
+        String.format(
+            "Cannot rebase %s: Change %s could not be rebased due to a conflict during merge.\n\n"
+                + "merge conflict(s):\n"
+                + "fileName 2",
+            change2.getCommit().name(), change2.getChange().getId()));
     assertThat(projectOperations.project(project).getHead("master")).isEqualTo(headAfterChange1);
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AssigneeIT.java b/javatests/com/google/gerrit/acceptance/rest/change/AssigneeIT.java
deleted file mode 100644
index be94cdf..0000000
--- a/javatests/com/google/gerrit/acceptance/rest/change/AssigneeIT.java
+++ /dev/null
@@ -1,212 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance.rest.change;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
-import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_LABELS;
-import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
-import static com.google.gerrit.testing.GerritJUnit.assertThrows;
-
-import com.google.common.collect.Iterables;
-import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.NoHttpd;
-import com.google.gerrit.acceptance.PushOneCommit;
-import com.google.gerrit.acceptance.UseClockStep;
-import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
-import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
-import com.google.gerrit.entities.Permission;
-import com.google.gerrit.entities.RefNames;
-import com.google.gerrit.extensions.api.changes.AssigneeInput;
-import com.google.gerrit.extensions.client.ReviewerState;
-import com.google.gerrit.extensions.common.AccountInfo;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.server.account.AccountResolver.UnresolvableAccountException;
-import com.google.gerrit.testing.FakeEmailSender.Message;
-import com.google.inject.Inject;
-import java.util.Iterator;
-import java.util.List;
-import org.eclipse.jgit.transport.RefSpec;
-import org.junit.Test;
-
-@NoHttpd
-@UseClockStep
-public class AssigneeIT extends AbstractDaemonTest {
-  @Inject private ProjectOperations projectOperations;
-  @Inject private RequestScopeOperations requestScopeOperations;
-
-  @Test
-  public void getNoAssignee() throws Exception {
-    PushOneCommit.Result r = createChange();
-    assertThat(getAssignee(r)).isNull();
-  }
-
-  @Test
-  public void addGetAssignee() throws Exception {
-    PushOneCommit.Result r = createChange();
-    assertThat(setAssignee(r, user.email())._accountId).isEqualTo(user.id().get());
-    assertThat(getAssignee(r)._accountId).isEqualTo(user.id().get());
-
-    assertThat(sender.getMessages()).hasSize(1);
-    Message m = sender.getMessages().get(0);
-    assertThat(m.rcpt()).containsExactly(user.getNameEmail());
-  }
-
-  @Test
-  public void setNewAssigneeWhenExists() throws Exception {
-    PushOneCommit.Result r = createChange();
-    setAssignee(r, user.email());
-    assertThat(setAssignee(r, user.email())._accountId).isEqualTo(user.id().get());
-  }
-
-  @Test
-  public void getPastAssignees() throws Exception {
-    PushOneCommit.Result r = createChange();
-    setAssignee(r, user.email());
-    setAssignee(r, admin.email());
-    List<AccountInfo> assignees = getPastAssignees(r);
-    assertThat(assignees).hasSize(2);
-    Iterator<AccountInfo> itr = assignees.iterator();
-    assertThat(itr.next()._accountId).isEqualTo(user.id().get());
-    assertThat(itr.next()._accountId).isEqualTo(admin.id().get());
-  }
-
-  @Test
-  public void assigneeAddedAsCc() throws Exception {
-    PushOneCommit.Result r = createChange();
-    Iterable<AccountInfo> reviewers = getReviewers(r, ReviewerState.CC);
-    assertThat(reviewers).isNull();
-
-    assertThat(setAssignee(r, user.email())._accountId).isEqualTo(user.id().get());
-    reviewers = getReviewers(r, ReviewerState.CC);
-    assertThat(reviewers).hasSize(1);
-    assertThat(Iterables.getFirst(reviewers, null)._accountId).isEqualTo(user.id().get());
-    assertThat(getReviewers(r, ReviewerState.REVIEWER)).isNull();
-  }
-
-  @Test
-  public void assigneeStaysReviewer() throws Exception {
-    PushOneCommit.Result r = createChange();
-    gApi.changes().id(r.getChangeId()).addReviewer(user.email());
-    Iterable<AccountInfo> reviewers = getReviewers(r, ReviewerState.REVIEWER);
-    assertThat(reviewers).hasSize(1);
-    assertThat(Iterables.getFirst(reviewers, null)._accountId).isEqualTo(user.id().get());
-    assertThat(getReviewers(r, ReviewerState.CC)).isNull();
-
-    assertThat(setAssignee(r, user.email())._accountId).isEqualTo(user.id().get());
-    reviewers = getReviewers(r, ReviewerState.REVIEWER);
-    assertThat(reviewers).hasSize(1);
-    assertThat(Iterables.getFirst(reviewers, null)._accountId).isEqualTo(user.id().get());
-    assertThat(getReviewers(r, ReviewerState.CC)).isNull();
-  }
-
-  @Test
-  public void setAlreadyExistingAssignee() throws Exception {
-    PushOneCommit.Result r = createChange();
-    setAssignee(r, user.email());
-    assertThat(setAssignee(r, user.email())._accountId).isEqualTo(user.id().get());
-  }
-
-  @Test
-  public void deleteAssignee() throws Exception {
-    PushOneCommit.Result r = createChange();
-    assertThat(setAssignee(r, user.email())._accountId).isEqualTo(user.id().get());
-    assertThat(deleteAssignee(r)._accountId).isEqualTo(user.id().get());
-    assertThat(getAssignee(r)).isNull();
-  }
-
-  @Test
-  public void deleteAssigneeWhenNoAssignee() throws Exception {
-    PushOneCommit.Result r = createChange();
-    assertThat(deleteAssignee(r)).isNull();
-  }
-
-  @Test
-  public void setAssigneeToInactiveUser() throws Exception {
-    PushOneCommit.Result r = createChange();
-    gApi.accounts().id(user.id().get()).setActive(false);
-    UnresolvableAccountException thrown =
-        assertThrows(UnresolvableAccountException.class, () -> setAssignee(r, user.email()));
-    assertThat(thrown)
-        .hasMessageThat()
-        .isEqualTo(
-            "Account '"
-                + user.email()
-                + "' only matches inactive accounts. To use an inactive account, retry with one"
-                + " of the following exact account IDs:\n"
-                + user.id()
-                + ": User1 <user1@example.com>");
-  }
-
-  @Test
-  public void setAssigneeToInactiveUserById() throws Exception {
-    PushOneCommit.Result r = createChange();
-    gApi.accounts().id(user.id().get()).setActive(false);
-    setAssignee(r, user.id().toString());
-    assertThat(getAssignee(r)._accountId).isEqualTo(user.id().get());
-  }
-
-  @Test
-  public void setAssigneeForNonVisibleChange() throws Exception {
-    git().fetch().setRefSpecs(new RefSpec("refs/meta/config:refs/meta/config")).call();
-    testRepo.reset(RefNames.REFS_CONFIG);
-    PushOneCommit.Result r = createChange("refs/for/refs/meta/config");
-    AuthException thrown = assertThrows(AuthException.class, () -> setAssignee(r, user.email()));
-    assertThat(thrown).hasMessageThat().contains("read not permitted");
-  }
-
-  @Test
-  public void setAssigneeNotAllowedWithoutPermission() throws Exception {
-    PushOneCommit.Result r = createChange();
-    requestScopeOperations.setApiUser(user.id());
-    AuthException thrown = assertThrows(AuthException.class, () -> setAssignee(r, user.email()));
-    assertThat(thrown).hasMessageThat().contains("not permitted");
-  }
-
-  @Test
-  public void setAssigneeAllowedWithPermission() throws Exception {
-    PushOneCommit.Result r = createChange();
-    projectOperations
-        .project(project)
-        .forUpdate()
-        .add(allow(Permission.EDIT_ASSIGNEE).ref("refs/heads/master").group(REGISTERED_USERS))
-        .update();
-    requestScopeOperations.setApiUser(user.id());
-    assertThat(setAssignee(r, user.email())._accountId).isEqualTo(user.id().get());
-  }
-
-  private AccountInfo getAssignee(PushOneCommit.Result r) throws Exception {
-    return change(r).getAssignee();
-  }
-
-  private List<AccountInfo> getPastAssignees(PushOneCommit.Result r) throws Exception {
-    return change(r).getPastAssignees();
-  }
-
-  private Iterable<AccountInfo> getReviewers(PushOneCommit.Result r, ReviewerState state)
-      throws Exception {
-    return get(r.getChangeId(), DETAILED_LABELS).reviewers.get(state);
-  }
-
-  private AccountInfo setAssignee(PushOneCommit.Result r, String identifieer) throws Exception {
-    AssigneeInput input = new AssigneeInput();
-    input.assignee = identifieer;
-    return change(r).setAssignee(input);
-  }
-
-  private AccountInfo deleteAssignee(PushOneCommit.Result r) throws Exception {
-    return change(r).deleteAssignee();
-  }
-}
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java b/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
index ea52690..824e01e 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
@@ -982,6 +982,27 @@
   }
 
   @Test
+  public void robotRepliesDoNotAddToAttentionSet() throws Exception {
+    PushOneCommit.Result r = createChange();
+    change(r).addReviewer(user.email());
+
+    TestAccount robot =
+        accountCreator.create(
+            "robot1",
+            "robot1@example.com",
+            "Ro Bot",
+            "Ro",
+            ServiceUserClassifier.SERVICE_USERS,
+            "Administrators");
+    requestScopeOperations.setApiUser(robot.id());
+
+    ReviewInput reviewInput = new ReviewInput();
+    change(r).current().review(reviewInput);
+
+    assertThat(getAttentionSetUpdatesForUser(r, admin)).isEmpty();
+  }
+
+  @Test
   public void repliesDoNotAddOwnerWhenChangeIsClosed() throws Exception {
     PushOneCommit.Result r = createChange();
     change(r).abandon();
@@ -1488,6 +1509,64 @@
   }
 
   @Test
+  public void robotReviewWithNegativeLabelDoesntAddOwnerIfChangeIsMerged() throws Exception {
+    TestAccount robot =
+        accountCreator.create(
+            "robot2", "robot2@example.com", "Ro Bot", "Ro", ServiceUserClassifier.SERVICE_USERS);
+
+    PushOneCommit.Result r = createChange();
+
+    // The robot votes with Code-Review-1 on patch set 1.
+    // Without this vote the robot cannot (re-)apply a negative vote on the change after it was
+    // merged change later.
+    requestScopeOperations.setApiUser(robot.id());
+    change(r).revision(1).review(ReviewInput.dislike());
+
+    // Amend the change so that patch set 2 gets created.
+    requestScopeOperations.setApiUser(admin.id());
+    amendChange(r.getChangeId()).assertOkStatus();
+
+    // Approve the change.
+    approve(r.getChangeId());
+
+    // User adds a comment so that the admin user is added to the attention set.
+    // This has to be a comment from a user, since comments from robots do not trigger attention set
+    // updates.
+    requestScopeOperations.setApiUser(user.id());
+    ReviewInput reviewInput = new ReviewInput();
+    reviewInput.message = "A comment";
+    change(r).current().review(reviewInput);
+
+    // Verify that the admin user was added to the attention set.
+    AttentionSetUpdate attentionSet =
+        Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, admin));
+    assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.ADD);
+    assertThat(attentionSet).hasReasonThat().isEqualTo("Someone else replied on the change");
+
+    // Submit the change.
+    requestScopeOperations.setApiUser(admin.id());
+    change(r).current().submit();
+
+    // Verify that the attention set was cleared on submit.
+    attentionSet = Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, admin));
+    assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.REMOVE);
+    assertThat(attentionSet).hasReasonThat().isEqualTo("Change was submitted");
+
+    // Re-apply the negative robot vote on patch set 1.
+    // Note it's possible to a apply a negative vote on merged changes if it wasn't already present
+    // since we disallow downgrading votes on merged changes (e.g. downgrade from not present aka 0
+    // to -1 is not allowed).
+    requestScopeOperations.setApiUser(robot.id());
+    change(r).revision(1).review(ReviewInput.dislike());
+
+    // Verify that re-applying the negative robot vote on patch set 1 didn't add the admin user
+    // back to the attention set.
+    attentionSet = Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, admin));
+    assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.REMOVE);
+    assertThat(attentionSet).hasReasonThat().isEqualTo("Change was submitted");
+  }
+
+  @Test
   public void robotCommentDoesNotAddOwnerOnClosedChanges() throws Exception {
     TestAccount robot =
         accountCreator.create(
@@ -1613,7 +1692,6 @@
   }
 
   @Test
-  @GerritConfig(name = "change.enableAttentionSet", value = "true")
   public void attentionSetEmailHeader() throws Exception {
     PushOneCommit.Result r = createChange();
     TestAccount user2 = accountCreator.user2();
@@ -1654,21 +1732,6 @@
   }
 
   @Test
-  @GerritConfig(name = "change.enableAttentionSet", value = "false")
-  public void noReferenceToAttentionSetInEmailsWhenDisabled() throws Exception {
-    PushOneCommit.Result r = createChange();
-    // Add user and to the attention set.
-    change(r).addReviewer(user.id().toString());
-
-    // Attention set is not referenced.
-    assertThat(Iterables.getOnlyElement(sender.getMessages()).body())
-        .doesNotContain("Attention is currently required");
-    assertThat(Iterables.getOnlyElement(sender.getMessages()).htmlBody())
-        .doesNotContain("Attention is currently required");
-    sender.clear();
-  }
-
-  @Test
   public void attentionSetWithEmailFilter() throws Exception {
     PushOneCommit.Result r = createChange();
 
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ChangeIdIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ChangeIdIT.java
index 06e24ab..779d8eb 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ChangeIdIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ChangeIdIT.java
@@ -19,6 +19,7 @@
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.extensions.api.changes.ChangeApi;
+import com.google.gerrit.extensions.restapi.IdString;
 import org.junit.Test;
 
 public class ChangeIdIT extends AbstractDaemonTest {
@@ -47,6 +48,13 @@
   }
 
   @Test
+  public void invalidProjectChangeNumberReturnsNotFound() throws Exception {
+    RestResponse res =
+        adminRestSession.get(changeDetail(IdString.fromDecoded("<%=FOO%>~1").encoded()));
+    res.assertNotFound();
+  }
+
+  @Test
   public void changeNumberReturnsChange() throws Exception {
     PushOneCommit.Result c = createChange();
     RestResponse res = adminRestSession.get(changeDetail(getNumericChangeId(c.getChangeId())));
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java b/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
index dbebbf9..1952b32 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
@@ -19,11 +19,15 @@
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
 import static com.google.gerrit.entities.Permission.CREATE;
 import static com.google.gerrit.entities.Permission.READ;
+import static com.google.gerrit.entities.RefNames.HEAD;
 import static com.google.gerrit.entities.RefNames.changeMetaRef;
+import static com.google.gerrit.extensions.client.ListChangesOption.ALL_REVISIONS;
+import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_COMMIT;
 import static com.google.gerrit.extensions.common.testing.GitPersonSubject.assertThat;
 import static com.google.gerrit.git.ObjectIds.abbreviateName;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static com.google.gerrit.testing.TestActionRefUpdateContext.testRefAction;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static org.eclipse.jgit.lib.Constants.SIGNED_OFF_BY_TAG;
 
@@ -47,18 +51,23 @@
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.accounts.AccountInput;
+import com.google.gerrit.extensions.api.changes.ApplyPatchInput;
 import com.google.gerrit.extensions.api.changes.ChangeApi;
 import com.google.gerrit.extensions.api.changes.CherryPickInput;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.RevisionApi;
 import com.google.gerrit.extensions.api.projects.BranchInput;
+import com.google.gerrit.extensions.api.projects.ConfigInput;
 import com.google.gerrit.extensions.client.ChangeStatus;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
+import com.google.gerrit.extensions.client.InheritableBoolean;
+import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ChangeInput;
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
+import com.google.gerrit.extensions.common.DiffInfo;
 import com.google.gerrit.extensions.common.GitPerson;
 import com.google.gerrit.extensions.common.MergeInput;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -74,6 +83,7 @@
 import com.google.gerrit.server.git.validators.CommitValidationMessage;
 import com.google.gerrit.server.submit.ChangeAlreadyMergedException;
 import com.google.gerrit.testing.FakeEmailSender.Message;
+import com.google.gson.stream.JsonReader;
 import com.google.inject.Inject;
 import java.io.ByteArrayOutputStream;
 import java.util.ArrayList;
@@ -94,6 +104,7 @@
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.transport.RefSpec;
+import org.eclipse.jgit.util.Base64;
 import org.junit.Before;
 import org.junit.Test;
 
@@ -105,16 +116,19 @@
 
   @Before
   public void addNonCommitHead() throws Exception {
-    try (Repository repo = repoManager.openRepository(project);
-        ObjectInserter ins = repo.newObjectInserter()) {
-      ObjectId answer = ins.insert(Constants.OBJ_BLOB, new byte[] {42});
-      ins.flush();
-      ins.close();
+    testRefAction(
+        () -> {
+          try (Repository repo = repoManager.openRepository(project);
+              ObjectInserter ins = repo.newObjectInserter()) {
+            ObjectId answer = ins.insert(Constants.OBJ_BLOB, new byte[] {42});
+            ins.flush();
+            ins.close();
 
-      RefUpdate update = repo.getRefDatabase().newUpdate("refs/heads/answer", false);
-      update.setNewObjectId(answer);
-      assertThat(update.forceUpdate()).isEqualTo(RefUpdate.Result.NEW);
-    }
+            RefUpdate update = repo.getRefDatabase().newUpdate("refs/heads/answer", false);
+            update.setNewObjectId(answer);
+            assertThat(update.forceUpdate()).isEqualTo(RefUpdate.Result.NEW);
+          }
+        });
   }
 
   @Test
@@ -210,6 +224,38 @@
   }
 
   @Test
+  public void formatResponse_fieldsPresentWhenRequested() throws Exception {
+    ChangeInput ci = newChangeInput(ChangeStatus.NEW);
+    String changeId = "I1234000000000000000000000000000000000000";
+    String changeIdLine = "Change-Id: " + changeId;
+    ci.subject = "Subject\n\n" + changeIdLine;
+    ci.responseFormatOptions =
+        ImmutableList.of(ListChangesOption.CURRENT_REVISION, ListChangesOption.CURRENT_ACTIONS);
+    // Must use REST directly because the Java API returns a ChangeApi upon
+    // creation that will do its own formatting when #get is called on it.
+    RestResponse resp = adminRestSession.post("/changes/", ci);
+    resp.assertCreated();
+    ChangeInfo res = readContentFromJson(resp, ChangeInfo.class);
+    assertThat(res.actions).isNotEmpty();
+    assertThat(res.revisions.values()).hasSize(1);
+  }
+
+  @Test
+  public void formatResponse_fieldsAbsentWhenNotRequested() throws Exception {
+    ChangeInput ci = newChangeInput(ChangeStatus.NEW);
+    String changeId = "I1234000000000000000000000000000000000000";
+    String changeIdLine = "Change-Id: " + changeId;
+    ci.subject = "Subject\n\n" + changeIdLine;
+    // Must use REST directly because the Java API returns a ChangeApi upon
+    // creation that will do its own formatting when #get is called on it.
+    RestResponse resp = adminRestSession.post("/changes/", ci);
+    resp.assertCreated();
+    ChangeInfo res = readContentFromJson(resp, ChangeInfo.class);
+    assertThat(res.actions).isNull();
+    assertThat(res.revisions).isNull();
+  }
+
+  @Test
   public void cannotCreateChangeOnGerritInternalRefs() throws Exception {
     requestScopeOperations.setApiUser(admin.id());
     projectOperations
@@ -485,6 +531,20 @@
   }
 
   @Test
+  public void createAuthorNotAddedAsCcWithAvoidAddingOriginalAuthorAsReviewer() throws Exception {
+    ConfigInput config = new ConfigInput();
+    config.skipAddingAuthorAndCommitterAsReviewers = InheritableBoolean.TRUE;
+    gApi.projects().name(project.get()).config(config);
+    ChangeInput input = newChangeInput(ChangeStatus.NEW);
+    input.author = new AccountInput();
+    input.author.email = user.email();
+    input.author.name = user.fullName();
+
+    ChangeInfo info = assertCreateSucceeds(input);
+    assertThat(info.reviewers).isEmpty();
+  }
+
+  @Test
   public void createNewWorkInProgressChange() throws Exception {
     ChangeInput input = newChangeInput(ChangeStatus.NEW);
     input.workInProgress = true;
@@ -923,6 +983,170 @@
   }
 
   @Test
+  public void createChangeWithBothMergeAndPatch_fails() throws Exception {
+    ChangeInput input = newMergeChangeInput("foo", "master", "");
+    input.patch = new ApplyPatchInput();
+    assertCreateFails(
+        input, BadRequestException.class, "Only one of `merge` and `patch` arguments can be set");
+  }
+
+  private static final String PATCH_FILE_NAME = "a_file.txt";
+  private static final String PATCH_NEW_FILE_CONTENT = "First added line\nSecond added line\n";
+  private static final String PATCH_INPUT =
+      "diff --git a/a_file.txt b/a_file.txt\n"
+          + "new file mode 100644\n"
+          + "index 0000000..f0eec86\n"
+          + "--- /dev/null\n"
+          + "+++ b/a_file.txt\n"
+          + "@@ -0,0 +1,2 @@\n"
+          + "+First added line\n"
+          + "+Second added line\n";
+  private static final String MODIFICATION_PATCH_INPUT =
+      "diff --git a/a_file.txt b/a_file.txt\n"
+          + "new file mode 100644\n"
+          + "--- a/a_file.txt\n"
+          + "+++ b/a_file.txt.txt\n"
+          + "@@ -1,2 +1 @@\n"
+          + "-First original line\n"
+          + "-Second original line\n"
+          + "+Modified line\n";
+
+  @Test
+  public void createPatchApplyingChange_success() throws Exception {
+    createBranch(BranchNameKey.create(project, "other"));
+    ChangeInput input = newPatchApplyingChangeInput("other", PATCH_INPUT);
+
+    ChangeInfo info = assertCreateSucceeds(input);
+
+    DiffInfo diff = gApi.changes().id(info.id).current().file(PATCH_FILE_NAME).diff();
+    assertDiffForNewFile(diff, info.currentRevision, PATCH_FILE_NAME, PATCH_NEW_FILE_CONTENT);
+    assertThat(info.revisions.get(info.currentRevision).commit.message)
+        .isEqualTo("apply patch to other\n\nChange-Id: " + info.changeId + "\n");
+  }
+
+  @Test
+  public void createPatchApplyingChange_fromGerritPatch_success() throws Exception {
+    String head = getHead(repo(), HEAD).name();
+    createBranchWithRevision(BranchNameKey.create(project, "branch"), head);
+    PushOneCommit.Result baseCommit =
+        createChange("Add file", PATCH_FILE_NAME, PATCH_NEW_FILE_CONTENT);
+    baseCommit.assertOkStatus();
+    BinaryResult originalPatch = gApi.changes().id(baseCommit.getChangeId()).current().patch();
+    createBranchWithRevision(BranchNameKey.create(project, "other"), head);
+    ChangeInput input = newPatchApplyingChangeInput("other", originalPatch.asString());
+
+    ChangeInfo info = assertCreateSucceeds(input);
+
+    DiffInfo diff = gApi.changes().id(info.id).current().file(PATCH_FILE_NAME).diff();
+    assertDiffForNewFile(diff, info.currentRevision, PATCH_FILE_NAME, PATCH_NEW_FILE_CONTENT);
+  }
+
+  @Test
+  public void createPatchApplyingChange_fromGerritPatchUsingRest_success() throws Exception {
+    String head = getHead(repo(), HEAD).name();
+    createBranchWithRevision(BranchNameKey.create(project, "branch"), head);
+    PushOneCommit.Result baseCommit =
+        createChange("Add file", PATCH_FILE_NAME, PATCH_NEW_FILE_CONTENT);
+    baseCommit.assertOkStatus();
+    createBranchWithRevision(BranchNameKey.create(project, "other"), head);
+    RestResponse patchResp =
+        userRestSession.get("/changes/" + baseCommit.getChangeId() + "/revisions/current/patch");
+    patchResp.assertOK();
+    String originalPatch = new String(Base64.decode(patchResp.getEntityContent()), UTF_8);
+    ChangeInput input = newPatchApplyingChangeInput("other", originalPatch);
+
+    ChangeInfo info = assertCreateSucceedsUsingRest(input);
+
+    DiffInfo diff = gApi.changes().id(info.id).current().file(PATCH_FILE_NAME).diff();
+    assertDiffForNewFile(diff, info.currentRevision, PATCH_FILE_NAME, PATCH_NEW_FILE_CONTENT);
+  }
+
+  @Test
+  public void createPatchApplyingChange_withParentChange_success() throws Exception {
+    Result change = createChange();
+    ChangeInput input = newPatchApplyingChangeInput("other", PATCH_INPUT);
+    input.baseChange = change.getChangeId();
+
+    ChangeInfo info = assertCreateSucceeds(input);
+
+    assertThat(gApi.changes().id(info.id).current().commit(false).parents.get(0).commit)
+        .isEqualTo(change.getCommit().getId().name());
+    DiffInfo diff = gApi.changes().id(info.id).current().file(PATCH_FILE_NAME).diff();
+    assertDiffForNewFile(diff, info.currentRevision, PATCH_FILE_NAME, PATCH_NEW_FILE_CONTENT);
+  }
+
+  @Test
+  public void createPatchApplyingChange_withParentCommit_success() throws Exception {
+    createBranch(BranchNameKey.create(project, "other"));
+    Result baseChange = createChange("refs/heads/other");
+    PushOneCommit.Result ignoredCommit = createChange();
+    ignoredCommit.assertOkStatus();
+    ChangeInput input = newPatchApplyingChangeInput("other", PATCH_INPUT);
+    input.baseCommit = baseChange.getCommit().getId().name();
+
+    ChangeInfo info = assertCreateSucceeds(input);
+
+    assertThat(gApi.changes().id(info.id).current().commit(false).parents.get(0).commit)
+        .isEqualTo(input.baseCommit);
+    DiffInfo diff = gApi.changes().id(info.id).current().file(PATCH_FILE_NAME).diff();
+    assertDiffForNewFile(diff, info.currentRevision, PATCH_FILE_NAME, PATCH_NEW_FILE_CONTENT);
+  }
+
+  @Test
+  public void createPatchApplyingChange_withEmptyTip_fails() throws Exception {
+    ChangeInput input = newPatchApplyingChangeInput("foo", "patch");
+    input.newBranch = true;
+    assertCreateFails(
+        input, BadRequestException.class, "Cannot apply patch on top of an empty tree");
+  }
+
+  @Test
+  public void createPatchApplyingChange_fromBadPatch_fails() throws Exception {
+    final String invalidPatch = "@@ -2,2 +2,3 @@ a\n" + " b\n" + "+c\n" + " d";
+    createBranch(BranchNameKey.create(project, "other"));
+    ChangeInput input = newPatchApplyingChangeInput("other", invalidPatch);
+    assertCreateFails(input, BadRequestException.class, "Invalid patch format");
+  }
+
+  @Test
+  public void createPatchApplyingChange_withAuthorOverride_success() throws Exception {
+    createBranch(BranchNameKey.create(project, "other"));
+    ChangeInput input = newPatchApplyingChangeInput("other", PATCH_INPUT);
+    input.author = new AccountInput();
+    input.author.email = "gerritlessjane@invalid";
+    // This is an email address that doesn't exist as account on the Gerrit server.
+    input.author.name = "Gerritless Jane";
+    ChangeInfo info = assertCreateSucceeds(input);
+
+    RevisionApi rApi = gApi.changes().id(info.id).current();
+    GitPerson author = rApi.commit(false).author;
+    assertThat(author).email().isEqualTo(input.author.email);
+    assertThat(author).name().isEqualTo(input.author.name);
+    GitPerson committer = rApi.commit(false).committer;
+    assertThat(committer).email().isEqualTo(admin.getNameEmail().email());
+  }
+
+  @Test
+  public void createPatchApplyingChange_withConflicts_appendErrorsToCommitMessage()
+      throws Exception {
+    createBranch(BranchNameKey.create(project, "other"));
+    PushOneCommit push =
+        pushFactory.create(
+            admin.newIdent(),
+            testRepo,
+            "Adding unexpected base content, which will cause errors",
+            PATCH_FILE_NAME,
+            "unexpected base content");
+    Result conflictingChange = push.to("refs/heads/other");
+    conflictingChange.assertOkStatus();
+    ChangeInput input = newPatchApplyingChangeInput("other", MODIFICATION_PATCH_INPUT);
+
+    ChangeInfo info = assertCreateSucceeds(input);
+
+    assertThat(info.revisions.get(info.currentRevision).commit.message).contains("errors occurred");
+  }
+
+  @Test
   @UseSystemTime
   public void sha1sOfTwoNewChangesDiffer() throws Exception {
     ChangeInput changeInput = newChangeInput(ChangeStatus.NEW);
@@ -1084,17 +1308,38 @@
 
   private ChangeInfo assertCreateSucceeds(ChangeInput in) throws Exception {
     ChangeInfo out = gApi.changes().create(in).get();
+    validateCreateSucceeds(in, out);
+    return out;
+  }
+
+  private ChangeInfo assertCreateSucceedsUsingRest(ChangeInput in) throws Exception {
+    RestResponse resp = adminRestSession.post("/changes/", in);
+    resp.assertCreated();
+    ChangeInfo res = readContentFromJson(resp, ChangeInfo.class);
+    // The original result doesn't contain any revision data.
+    ChangeInfo out = gApi.changes().id(res.changeId).get(ALL_REVISIONS, CURRENT_COMMIT);
+    validateCreateSucceeds(in, out);
+    return out;
+  }
+
+  private static <T> T readContentFromJson(RestResponse r, Class<T> clazz) throws Exception {
+    try (JsonReader jsonReader = new JsonReader(r.getReader())) {
+      return newGson().fromJson(jsonReader, clazz);
+    }
+  }
+
+  private void validateCreateSucceeds(ChangeInput in, ChangeInfo out) throws Exception {
     assertThat(out.project).isEqualTo(in.project);
     assertThat(RefNames.fullName(out.branch)).isEqualTo(RefNames.fullName(in.branch));
     assertThat(out.subject).isEqualTo(Splitter.on("\n").splitToList(in.subject).get(0));
     assertThat(out.topic).isEqualTo(in.topic);
     assertThat(out.status).isEqualTo(in.status);
-    if (in.isPrivate) {
+    if (Boolean.TRUE.equals(in.isPrivate)) {
       assertThat(out.isPrivate).isTrue();
     } else {
       assertThat(out.isPrivate).isNull();
     }
-    if (in.workInProgress) {
+    if (Boolean.TRUE.equals(in.workInProgress)) {
       assertThat(out.workInProgress).isTrue();
     } else {
       assertThat(out.workInProgress).isNull();
@@ -1103,7 +1348,6 @@
     assertThat(out.submitted).isNull();
     assertThat(out.containsGitConflicts).isNull();
     assertThat(in.status).isEqualTo(ChangeStatus.NEW);
-    return out;
   }
 
   private ChangeInfo assertCreateSucceedsWithConflicts(ChangeInput in) throws Exception {
@@ -1174,6 +1418,19 @@
     return in;
   }
 
+  private ChangeInput newPatchApplyingChangeInput(String targetBranch, String patch) {
+    // create a change applying the given patch on the target branch in gerrit
+    ChangeInput in = new ChangeInput();
+    in.project = project.get();
+    in.branch = targetBranch;
+    in.subject = "apply patch to " + targetBranch;
+    in.status = ChangeStatus.NEW;
+    ApplyPatchInput patchInput = new ApplyPatchInput();
+    patchInput.patch = patch;
+    in.patch = patchInput;
+    return in;
+  }
+
   /**
    * Create an empty commit in master, two new branches with one commit each.
    *
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/DeleteVoteIT.java b/javatests/com/google/gerrit/acceptance/rest/change/DeleteVoteIT.java
index c57d285..6491202 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/DeleteVoteIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/DeleteVoteIT.java
@@ -15,16 +15,24 @@
 package com.google.gerrit.acceptance.rest.change;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabelRemoval;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.blockLabelRemoval;
 import static com.google.gerrit.extensions.client.ReviewerState.REVIEWER;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
@@ -39,56 +47,168 @@
 import org.junit.Test;
 
 public class DeleteVoteIT extends AbstractDaemonTest {
+  @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
 
   @Test
-  public void deleteVoteOnChange() throws Exception {
-    deleteVote(false);
+  public void deleteVoteOnChange_withRemoveLabelPermission() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabelRemoval(LabelId.CODE_REVIEW)
+                .ref("refs/heads/*")
+                .group(REGISTERED_USERS)
+                .range(-2, 2))
+        .add(block(Permission.REMOVE_REVIEWER).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+    verifyDeleteVote(false);
   }
 
   @Test
-  public void deleteVoteOnRevision() throws Exception {
-    deleteVote(true);
+  public void deleteVoteOnChange_withRemoveReviewerPermission() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            blockLabelRemoval(LabelId.CODE_REVIEW)
+                .ref("refs/heads/*")
+                .group(REGISTERED_USERS)
+                .range(-2, 2))
+        .add(allow(Permission.REMOVE_REVIEWER).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+    verifyDeleteVote(false);
   }
 
-  private void deleteVote(boolean onRevisionLevel) throws Exception {
+  @Test
+  public void deleteVoteOnChange_noPermission() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            blockLabelRemoval(LabelId.CODE_REVIEW)
+                .ref("refs/heads/*")
+                .group(REGISTERED_USERS)
+                .range(-2, 2))
+        .add(block(Permission.REMOVE_REVIEWER).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+    verifyCannotDeleteVote(false);
+  }
+
+  @Test
+  public void deleteVoteOnRevision_withRemoveLabelPermission() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabelRemoval(LabelId.CODE_REVIEW)
+                .ref("refs/heads/*")
+                .group(REGISTERED_USERS)
+                .range(-2, 2))
+        .add(block(Permission.REMOVE_REVIEWER).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+    verifyDeleteVote(true);
+  }
+
+  @Test
+  public void deleteVoteOnRevision_withRemoveReviewerPermission() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            blockLabelRemoval(LabelId.CODE_REVIEW)
+                .ref("refs/heads/*")
+                .group(REGISTERED_USERS)
+                .range(-2, 2))
+        .add(allow(Permission.REMOVE_REVIEWER).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+    verifyDeleteVote(true);
+  }
+
+  @Test
+  public void deleteVoteOnRevision_noPermission() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            blockLabelRemoval(LabelId.CODE_REVIEW)
+                .ref("refs/heads/*")
+                .group(REGISTERED_USERS)
+                .range(-2, 2))
+        .add(block(Permission.REMOVE_REVIEWER).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+    verifyCannotDeleteVote(true);
+  }
+
+  @Test
+  public void deleteAlreadyDeletedVote_returnsNotFoundAndWithoutEmails() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.REMOVE_REVIEWER).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+    PushOneCommit.Result r = createChange();
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
+    String deleteAdminVoteEndPoint =
+        "/changes/"
+            + r.getChangeId()
+            + "/reviewers/"
+            + admin.id().toString()
+            + "/votes/Code-Review";
+
+    sender.clear();
+    RestResponse response = userRestSession.delete(deleteAdminVoteEndPoint);
+    response.assertNoContent();
+    assertThat(sender.getMessages()).hasSize(1);
+
+    sender.clear();
+    response = userRestSession.delete(deleteAdminVoteEndPoint);
+    response.assertNotFound();
+    assertThat(sender.getMessages()).isEmpty();
+  }
+
+  private void verifyDeleteVote(boolean onRevisionLevel) throws Exception {
     PushOneCommit.Result r = createChange();
     gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
 
     PushOneCommit.Result r2 = amendChange(r.getChangeId());
 
-    requestScopeOperations.setApiUser(user.id());
+    requestScopeOperations.setApiUser(admin.id());
+    recommend(r.getChangeId());
+
+    TestAccount user2 = accountCreator.user2();
+    requestScopeOperations.setApiUser(user2.id());
     recommend(r.getChangeId());
 
     sender.clear();
-    String endPoint =
+    String deleteAdminVoteEndPoint =
         "/changes/"
             + r.getChangeId()
             + (onRevisionLevel ? ("/revisions/" + r2.getCommit().getName()) : "")
             + "/reviewers/"
-            + user.id().toString()
+            + admin.id().toString()
             + "/votes/Code-Review";
 
-    RestResponse response = adminRestSession.delete(endPoint);
+    RestResponse response = userRestSession.delete(deleteAdminVoteEndPoint);
     response.assertNoContent();
 
     List<FakeEmailSender.Message> messages = sender.getMessages();
     assertThat(messages).hasSize(1);
     FakeEmailSender.Message msg = messages.get(0);
-    assertThat(msg.rcpt()).containsExactly(user.getNameEmail());
-    assertThat(msg.body()).contains(admin.fullName() + " has removed a vote from this change.");
+    assertThat(msg.rcpt()).containsExactly(admin.getNameEmail(), user2.getNameEmail());
+    assertThat(msg.body()).contains(user.fullName() + " has removed a vote from this change.");
     assertThat(msg.body())
-        .contains("Removed Code-Review+1 by " + user.fullName() + " <" + user.email() + ">\n");
+        .contains("Removed Code-Review+1 by " + admin.fullName() + " <" + admin.email() + ">\n");
 
-    endPoint =
+    String viewVotesEndPoint =
         "/changes/"
             + r.getChangeId()
             + (onRevisionLevel ? ("/revisions/" + r2.getCommit().getName()) : "")
             + "/reviewers/"
-            + user.id().toString()
+            + admin.id().toString()
             + "/votes";
 
-    response = adminRestSession.get(endPoint);
+    response = userRestSession.get(viewVotesEndPoint);
     response.assertOK();
 
     Map<String, Short> m =
@@ -99,14 +219,38 @@
     ChangeInfo c = gApi.changes().id(r.getChangeId()).get();
 
     ChangeMessageInfo message = Iterables.getLast(c.messages);
-    assertThat(message.author._accountId).isEqualTo(admin.id().get());
+    assertThat(message.author._accountId).isEqualTo(user.id().get());
     assertThat(message.message)
         .isEqualTo(
             String.format(
                 "Removed Code-Review+1 by %s\n",
-                AccountTemplateUtil.getAccountTemplate(user.id())));
+                AccountTemplateUtil.getAccountTemplate(admin.id())));
     assertThat(getReviewers(c.reviewers.get(REVIEWER)))
-        .containsExactlyElementsIn(ImmutableSet.of(admin.id(), user.id()));
+        .containsExactlyElementsIn(ImmutableSet.of(user2.id(), admin.id()));
+  }
+
+  private void verifyCannotDeleteVote(boolean onRevisionLevel) throws Exception {
+    PushOneCommit.Result r = createChange();
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
+
+    PushOneCommit.Result r2 = amendChange(r.getChangeId());
+
+    requestScopeOperations.setApiUser(admin.id());
+    recommend(r.getChangeId());
+
+    sender.clear();
+    String deleteAdminVoteEndPoint =
+        "/changes/"
+            + r.getChangeId()
+            + (onRevisionLevel ? ("/revisions/" + r2.getCommit().getName()) : "")
+            + "/reviewers/"
+            + admin.id().toString()
+            + "/votes/Code-Review";
+
+    RestResponse response = userRestSession.delete(deleteAdminVoteEndPoint);
+    response.assertForbidden();
+
+    assertThat(sender.getMessages()).isEmpty();
   }
 
   private Iterable<Account.Id> getReviewers(Collection<AccountInfo> r) {
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java
index 80bedcd..6dfa82b 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java
@@ -47,6 +47,7 @@
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.inject.Inject;
 import java.util.List;
+import java.util.Locale;
 import java.util.Set;
 import java.util.stream.IntStream;
 import org.junit.Before;
@@ -336,7 +337,7 @@
     reviewers = suggestReviewers(changeId, user1.username() + " example");
     assertThat(reviewers).hasSize(1);
 
-    reviewers = suggestReviewers(changeId, user4.email().toLowerCase());
+    reviewers = suggestReviewers(changeId, user4.email().toLowerCase(Locale.US));
     assertThat(reviewers).hasSize(1);
     assertThat(reviewers.get(0).account.email).isEqualTo(user4.email());
   }
diff --git a/javatests/com/google/gerrit/acceptance/rest/config/GetTaskIT.java b/javatests/com/google/gerrit/acceptance/rest/config/GetTaskIT.java
index 6d2c6dfa..a9e3cf6 100644
--- a/javatests/com/google/gerrit/acceptance/rest/config/GetTaskIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/config/GetTaskIT.java
@@ -18,6 +18,7 @@
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.server.restapi.config.ListTasks.TaskInfo;
 import com.google.gson.reflect.TypeToken;
 import java.util.List;
@@ -41,6 +42,7 @@
     userRestSession.get("/config/server/tasks/" + getLogFileCompressorTaskId()).assertNotFound();
   }
 
+  @Nullable
   private String getLogFileCompressorTaskId() throws Exception {
     RestResponse r = adminRestSession.get("/config/server/tasks/");
     List<TaskInfo> result =
diff --git a/javatests/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java b/javatests/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java
index a6d660d..b8b63e6 100644
--- a/javatests/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java
@@ -62,8 +62,7 @@
   // change
   @GerritConfig(name = "change.updateDelay", value = "50s")
   @GerritConfig(name = "change.disablePrivateChanges", value = "true")
-  @GerritConfig(name = "change.enableAttentionSet", value = "true")
-  @GerritConfig(name = "change.enableAssignee", value = "true")
+  @GerritConfig(name = "change.enableRobotComments", value = "false")
 
   // download
   @GerritConfig(
@@ -104,8 +103,7 @@
     // change
     assertThat(i.change.updateDelay).isEqualTo(50);
     assertThat(i.change.disablePrivateChanges).isTrue();
-    assertThat(i.change.enableAttentionSet).isTrue();
-    assertThat(i.change.enableAssignee).isTrue();
+    assertThat(i.change.enableRobotComments).isNull();
 
     // download
     assertThat(i.download.archives).containsExactly("tar", "tbz2", "tgz", "txz");
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/AccessIT.java b/javatests/com/google/gerrit/acceptance/rest/project/AccessIT.java
index b94ea37..ca7c3c5 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/AccessIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/AccessIT.java
@@ -109,4 +109,13 @@
             .fromJson(r.getReader(), new TypeToken<Map<String, ProjectAccessInfo>>() {}.getType());
     assertThat(infoByProject.keySet()).containsExactly(project.get());
   }
+
+  @Test
+  public void listAccess_invalidProject() throws Exception {
+    String invalidProject = "<%=FOO%>";
+    RestResponse r =
+        adminRestSession.get("/access/?project=" + IdString.fromDecoded(invalidProject));
+    r.assertNotFound();
+    assertThat(r.getEntityContent()).isEqualTo(invalidProject);
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/CreateChangeIT.java b/javatests/com/google/gerrit/acceptance/rest/project/CreateChangeIT.java
index 0c221aa..7b42d93 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/CreateChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/CreateChangeIT.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.acceptance.rest.project;
 
+import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth8.assertThat;
 import static com.google.gerrit.entities.RefNames.REFS_HEADS;
 
@@ -21,6 +22,7 @@
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.common.ChangeInput;
+import com.google.gerrit.extensions.restapi.IdString;
 import org.junit.Test;
 
 public class CreateChangeIT extends AbstractDaemonTest {
@@ -43,7 +45,44 @@
     ChangeInput input = new ChangeInput();
     input.branch = "foo";
     input.subject = "subject";
-    RestResponse cr = adminRestSession.post("/projects/" + project.get() + "/create.change", input);
-    cr.assertCreated();
+    RestResponse response =
+        adminRestSession.post("/projects/" + project.get() + "/create.change", input);
+    response.assertCreated();
+  }
+
+  @Test
+  public void nonMatchingProjectIsRejected() throws Exception {
+    ChangeInput input = new ChangeInput();
+    input.project = "non-matching-project";
+    input.branch = "master";
+    input.subject = "subject";
+    RestResponse response =
+        adminRestSession.post("/projects/" + project.get() + "/create.change", input);
+    response.assertBadRequest();
+    assertThat(response.getEntityContent()).isEqualTo("project must match URL");
+  }
+
+  @Test
+  public void matchingProjectIsAccepted() throws Exception {
+    ChangeInput input = new ChangeInput();
+    input.project = project.get();
+    input.branch = "master";
+    input.subject = "subject";
+    RestResponse response =
+        adminRestSession.post("/projects/" + project.get() + "/create.change", input);
+    response.assertCreated();
+  }
+
+  @Test
+  public void matchingProjectWithTrailingSlashIsAccepted() throws Exception {
+    ChangeInput input = new ChangeInput();
+    input.project = project.get() + "/";
+    input.branch = "master";
+    input.subject = "subject";
+    RestResponse response =
+        adminRestSession.post(
+            "/projects/" + IdString.fromDecoded(project.get() + "/").encoded() + "/create.change",
+            input);
+    response.assertCreated();
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/FileBranchIT.java b/javatests/com/google/gerrit/acceptance/rest/project/FileBranchIT.java
index 8dce9c3..8c8f267 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/FileBranchIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/FileBranchIT.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static com.google.gerrit.testing.TestActionRefUpdateContext.testRefAction;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
@@ -58,7 +59,7 @@
   @Test
   public void getFileFromSymbolicRefPointingToAnUnbornBranch() throws Exception {
     try (Repository repo = repoManager.openRepository(project)) {
-      repo.updateRef(Constants.HEAD, true).link("refs/heads/non-existing");
+      testRefAction(() -> repo.updateRef(Constants.HEAD, true).link("refs/heads/non-existing"));
     }
     RestResponse response =
         adminRestSession.get(String.format("/projects/%s/branches/HEAD/files/path", project.get()));
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/ListCommitFilesIT.java b/javatests/com/google/gerrit/acceptance/rest/project/ListCommitFilesIT.java
index 3ac2d10..5f02af1 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/ListCommitFilesIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/ListCommitFilesIT.java
@@ -15,17 +15,21 @@
 package com.google.gerrit.acceptance.rest.project;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.GitUtil.assertPushOk;
 import static com.google.gerrit.acceptance.GitUtil.getChangeId;
 import static com.google.gerrit.acceptance.GitUtil.pushHead;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.acceptance.TestProjectInput;
+import com.google.gerrit.entities.Patch;
 import com.google.gerrit.extensions.common.FileInfo;
 import com.google.gson.reflect.TypeToken;
 import java.lang.reflect.Type;
 import java.util.Map;
 import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.transport.PushResult;
 import org.junit.Test;
 
 public class ListCommitFilesIT extends AbstractDaemonTest {
@@ -87,4 +91,31 @@
 
     assertThat(files1).isEqualTo(files2);
   }
+
+  @Test
+  @TestProjectInput(createEmptyCommit = false)
+  public void listFilesOfInitialCommitAgainstFirstParent() throws Exception {
+    // create initial commit with no parent and push it directly to refs/heads/master
+    RevCommit c =
+        testRepo
+            .commit()
+            .message("Initial commit")
+            .add("a.txt", "aContent")
+            .add("b.txt", "bContent")
+            .create();
+    testRepo.reset(c);
+    PushResult r = pushHead(testRepo, "refs/heads/master", false);
+    assertPushOk(r, "refs/heads/master");
+
+    // Request diff against first parent although the initial commit doesn't have a parent
+    RestResponse response =
+        userRestSession.get(
+            "/projects/" + project.get() + "/commits/" + c.name() + "/files/?parent=1");
+    response.assertOK();
+    Type type = new TypeToken<Map<String, FileInfo>>() {}.getType();
+    Map<String, FileInfo> files = newGson().fromJson(response.getReader(), type);
+    response.consume();
+
+    assertThat(files.keySet()).containsExactly(Patch.COMMIT_MSG, "a.txt", "b.txt");
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/server/account/AccountResolverIT.java b/javatests/com/google/gerrit/acceptance/server/account/AccountResolverIT.java
index 0cfa0f8..35ecceb 100644
--- a/javatests/com/google/gerrit/acceptance/server/account/AccountResolverIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/account/AccountResolverIT.java
@@ -26,11 +26,13 @@
 import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
 import com.google.gerrit.acceptance.testsuite.account.TestAccount;
+import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.common.AccountVisibility;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.ServerInitiated;
+import com.google.gerrit.server.account.AccountControl;
 import com.google.gerrit.server.account.AccountResolver;
 import com.google.gerrit.server.account.AccountResolver.Result;
 import com.google.gerrit.server.account.AccountResolver.UnresolvableAccountException;
@@ -41,6 +43,7 @@
 import com.google.gerrit.testing.ConfigSuite;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
+import java.util.Locale;
 import java.util.Optional;
 import org.eclipse.jgit.lib.Config;
 import org.junit.Test;
@@ -56,7 +59,9 @@
   @Inject @ServerInitiated private Provider<AccountsUpdate> accountsUpdateProvider;
   @Inject private AccountOperations accountOperations;
   @Inject private AccountResolver accountResolver;
+  @Inject private AccountControl.Factory accountControlFactory;
   @Inject private Provider<CurrentUser> self;
+  @Inject private GroupOperations groupOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
   @Inject private Sequences sequences;
 
@@ -172,7 +177,7 @@
 
     assertThat(resolve(existingUsername)).containsExactly(idWithUsername);
     assertThat(resolve(existingMixedCaseUsername)).containsExactly(idWithMixedCaseUsername);
-    assertThat(resolve(existingMixedCaseUsername.toLowerCase()))
+    assertThat(resolve(existingMixedCaseUsername.toLowerCase(Locale.US)))
         .containsExactly(idWithMixedCaseUsername);
   }
 
@@ -365,6 +370,37 @@
     assertThat(resolve("doe")).containsExactly(id2);
   }
 
+  @Test
+  @GerritConfig(name = "accounts.visibility", value = "SAME_GROUP")
+  public void resolveAsUser_byFullName_accountThatIsNotVisibleToCurrentUserIsFound()
+      throws Exception {
+    Account.Id currentUser = accountOperations.newAccount().create();
+    Account.Id resolveAsUser = accountOperations.newAccount().create();
+    Account.Id userToBeFound = accountOperations.newAccount().fullname("Somebodys Name").create();
+
+    // Create a group that contains resolveAsUser and userToBeFound, so that resolveAsUser can see
+    // userToBeFound.
+    groupOperations.newGroup().addMember(resolveAsUser).addMember(userToBeFound).create();
+
+    // Verify that resolveAsUser can see userToBeFound.
+    assertThat(canSee(resolveAsUser, userToBeFound)).isTrue();
+
+    // Verify that currentUser cannot see userToBeFound
+    assertThat(canSee(currentUser, userToBeFound)).isFalse();
+
+    // Resolving userToBeFound as resolveAsUser should work even if the currentUser cannot see
+    // userToBeFound.
+    requestScopeOperations.setApiUser(currentUser);
+    String input = accountOperations.account(userToBeFound).get().fullname().get();
+    assertThat(resolveAsUser(resolveAsUser, input)).containsExactly(userToBeFound);
+  }
+
+  private boolean canSee(Account.Id currentUser, Account.Id userToBeSeen) {
+    return accountControlFactory
+        .get(identifiedUserFactory.create(currentUser))
+        .canSee(userToBeSeen);
+  }
+
   private ImmutableSet<Account.Id> resolve(Object input) throws Exception {
     return resolveAsResult(input).asIdSet();
   }
@@ -373,6 +409,16 @@
     return accountResolver.resolve(input.toString());
   }
 
+  private ImmutableSet<Account.Id> resolveAsUser(Account.Id resolveAsUser, Object input)
+      throws Exception {
+    return resolveAsUserAsResult(resolveAsUser, input).asIdSet();
+  }
+
+  private Result resolveAsUserAsResult(Account.Id resolveAsUser, Object input) throws Exception {
+    return accountResolver.resolveAsUser(
+        identifiedUserFactory.create(resolveAsUser), input.toString());
+  }
+
   @SuppressWarnings("deprecation")
   private ImmutableSet<Account.Id> resolveByNameOrEmail(Object input) throws Exception {
     return accountResolver.resolveByNameOrEmail(input.toString()).asIdSet();
diff --git a/javatests/com/google/gerrit/acceptance/server/change/ApprovalCopierIT.java b/javatests/com/google/gerrit/acceptance/server/change/ApprovalCopierIT.java
index 0d06946..379a712 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/ApprovalCopierIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/ApprovalCopierIT.java
@@ -17,18 +17,23 @@
 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.server.change.ApprovalCopierIT.ApprovalDataSubject.assertThat;
+import static com.google.gerrit.acceptance.server.change.ApprovalCopierIT.ApprovalDataSubject.assertThatList;
+import static com.google.gerrit.acceptance.server.change.ApprovalCopierIT.ApprovalDataSubject.hasTestId;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.server.project.testing.TestLabels.labelBuilder;
 import static com.google.gerrit.server.project.testing.TestLabels.value;
+import static com.google.gerrit.truth.ListSubject.elements;
 
 import com.google.auto.value.AutoValue;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.truth.Correspondence;
 import com.google.common.truth.FailureMetadata;
+import com.google.common.truth.StandardSubjectBuilder;
+import com.google.common.truth.StringSubject;
 import com.google.common.truth.Subject;
+import com.google.common.truth.Truth8;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
@@ -43,6 +48,7 @@
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.client.ChangeKind;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.server.approval.ApprovalCopier;
 import com.google.gerrit.server.query.change.ChangeData;
@@ -50,6 +56,7 @@
 import com.google.gerrit.truth.NullAwareCorrespondence;
 import com.google.inject.Inject;
 import java.io.IOException;
+import java.util.Optional;
 import java.util.Set;
 import java.util.function.Predicate;
 import org.eclipse.jgit.lib.Repository;
@@ -71,6 +78,24 @@
 
   @Before
   public void setup() throws Exception {
+    // Overwrite "Code-Review" label that is inherited from All-Projects.
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      LabelType.Builder codeReview =
+          labelBuilder(
+                  LabelId.CODE_REVIEW,
+                  value(2, "Looks good to me, approved"),
+                  value(1, "Looks good to me, but someone else must approve"),
+                  value(0, "No score"),
+                  value(-1, "I would prefer this is not submitted as is"),
+                  value(-2, "This shall not be submitted"))
+              .setCopyCondition(
+                  String.format(
+                      "changekind:%s OR changekind:%s OR is:MIN",
+                      ChangeKind.NO_CHANGE, ChangeKind.TRIVIAL_REBASE.name()));
+      u.getConfig().upsertLabelType(codeReview.build());
+      u.save();
+    }
+
     // Add Verified label.
     try (ProjectConfigUpdate u = updateProject(project)) {
       LabelType.Builder verified =
@@ -153,6 +178,18 @@
         .containsExactly(
             PatchSetApprovalTestId.create(patchSet1Id, admin.id(), LabelId.CODE_REVIEW, 2),
             PatchSetApprovalTestId.create(patchSet1Id, user.id(), LabelId.VERIFIED, 1));
+
+    ApprovalDataSubject codeReviewApprovalSubject =
+        assertThat(approvalCopierResult.outdatedApprovals(), LabelId.CODE_REVIEW, admin.id());
+    codeReviewApprovalSubject.hasPassingAtomsThat().isEmpty();
+    codeReviewApprovalSubject
+        .hasFailingAtomsThat()
+        .containsExactly("changekind:NO_CHANGE", "changekind:TRIVIAL_REBASE", "is:MIN");
+
+    ApprovalDataSubject verifiedApprovalSubject =
+        assertThat(approvalCopierResult.outdatedApprovals(), LabelId.VERIFIED, user.id());
+    verifiedApprovalSubject.hasPassingAtomsThat().isEmpty();
+    verifiedApprovalSubject.hasFailingAtomsThat().containsExactly("is:MIN");
   }
 
   @Test
@@ -176,6 +213,18 @@
             PatchSetApprovalTestId.create(patchSet2Id, admin.id(), LabelId.CODE_REVIEW, -2),
             PatchSetApprovalTestId.create(patchSet2Id, user.id(), LabelId.VERIFIED, -1));
     assertThatList(approvalCopierResult.outdatedApprovals()).isEmpty();
+
+    ApprovalDataSubject codeReviewApprovalSubject =
+        assertThat(approvalCopierResult.copiedApprovals(), LabelId.CODE_REVIEW, admin.id());
+    codeReviewApprovalSubject.hasPassingAtomsThat().containsExactly("is:MIN");
+    codeReviewApprovalSubject
+        .hasFailingAtomsThat()
+        .containsExactly("changekind:NO_CHANGE", "changekind:TRIVIAL_REBASE");
+
+    ApprovalDataSubject verifiedApprovalSubject =
+        assertThat(approvalCopierResult.copiedApprovals(), LabelId.VERIFIED, user.id());
+    verifiedApprovalSubject.hasPassingAtomsThat().containsExactly("is:MIN");
+    verifiedApprovalSubject.hasFailingAtomsThat().isEmpty();
   }
 
   @Test
@@ -230,6 +279,30 @@
         .containsExactly(
             PatchSetApprovalTestId.create(patchSet1Id, user.id(), LabelId.CODE_REVIEW, 1),
             PatchSetApprovalTestId.create(patchSet1Id, admin.id(), LabelId.VERIFIED, 1));
+
+    ApprovalDataSubject copiedCodeReviewApprovalSubject =
+        assertThat(approvalCopierResult.copiedApprovals(), LabelId.CODE_REVIEW, admin.id());
+    copiedCodeReviewApprovalSubject.hasPassingAtomsThat().containsExactly("is:MIN");
+    copiedCodeReviewApprovalSubject
+        .hasFailingAtomsThat()
+        .containsExactly("changekind:NO_CHANGE", "changekind:TRIVIAL_REBASE");
+
+    ApprovalDataSubject copiedVerifiedApprovalSubject =
+        assertThat(approvalCopierResult.copiedApprovals(), LabelId.VERIFIED, user.id());
+    copiedVerifiedApprovalSubject.hasPassingAtomsThat().containsExactly("is:MIN");
+    copiedVerifiedApprovalSubject.hasFailingAtomsThat().isEmpty();
+
+    ApprovalDataSubject outdatedCodeReviewApprovalSubject1 =
+        assertThat(approvalCopierResult.outdatedApprovals(), LabelId.CODE_REVIEW, user.id());
+    outdatedCodeReviewApprovalSubject1.hasPassingAtomsThat().isEmpty();
+    outdatedCodeReviewApprovalSubject1
+        .hasFailingAtomsThat()
+        .containsExactly("changekind:NO_CHANGE", "changekind:TRIVIAL_REBASE", "is:MIN");
+
+    ApprovalDataSubject outdatedVerifiedApprovalSubject1 =
+        assertThat(approvalCopierResult.outdatedApprovals(), LabelId.VERIFIED, admin.id());
+    outdatedVerifiedApprovalSubject1.hasPassingAtomsThat().isEmpty();
+    outdatedVerifiedApprovalSubject1.hasFailingAtomsThat().containsExactly("is:MIN");
   }
 
   @Test
@@ -275,6 +348,11 @@
         .comparingElementsUsing(hasTestId())
         .containsExactly(
             PatchSetApprovalTestId.create(patchSet1Id, admin.id(), LabelId.CODE_REVIEW, -2));
+
+    ApprovalDataSubject codeReviewApprovalSubject1 =
+        assertThat(approvalCopierResult.outdatedApprovals(), LabelId.CODE_REVIEW, admin.id());
+    codeReviewApprovalSubject1.hasPassingAtomsThat().isEmpty();
+    codeReviewApprovalSubject1.hasFailingAtomsThat().isEmpty();
   }
 
   @Test
@@ -347,12 +425,14 @@
     ApprovalCopier.Result approvalCopierResult =
         invokeApprovalCopierForCurrentPatchSet(
             r.getChange().getId(), /* expectedCurrentPatchSetNum= */ 2);
-    ImmutableSet<PatchSetApproval> copiedApprovals = approvalCopierResult.copiedApprovals();
-    assertThatList(filter(copiedApprovals, PatchSetApproval::copied))
+    ImmutableSet<ApprovalCopier.Result.PatchSetApprovalData> copiedApprovals =
+        approvalCopierResult.copiedApprovals();
+    assertThatList(filter(copiedApprovals, approval -> approval.patchSetApproval().copied()))
         .comparingElementsUsing(hasTestId())
         .containsExactly(
             PatchSetApprovalTestId.create(patchSet2Id, user.id(), LabelId.VERIFIED, -1));
-    assertThatList(filter(copiedApprovals, psa -> !psa.copied())).isEmpty();
+    assertThatList(filter(copiedApprovals, approval -> !approval.patchSetApproval().copied()))
+        .isEmpty();
   }
 
   private void vote(String changeId, TestAccount testAccount, String label, int value)
@@ -362,8 +442,9 @@
     requestScopeOperations.setApiUser(admin.id());
   }
 
-  private ImmutableSet<PatchSetApproval> filter(
-      Set<PatchSetApproval> approvals, Predicate<PatchSetApproval> filter) {
+  private ImmutableSet<ApprovalCopier.Result.PatchSetApprovalData> filter(
+      Set<ApprovalCopier.Result.PatchSetApprovalData> approvals,
+      Predicate<ApprovalCopier.Result.PatchSetApprovalData> filter) {
     return approvals.stream().filter(filter).collect(toImmutableSet());
   }
 
@@ -378,20 +459,75 @@
     }
   }
 
-  public static class PatchSetApprovalSubject extends Subject {
-    public static Correspondence<PatchSetApproval, PatchSetApprovalTestId> hasTestId() {
-      return NullAwareCorrespondence.transforming(PatchSetApprovalTestId::create, "has test ID");
+  public static class ApprovalDataSubject extends Subject {
+    public static Correspondence<ApprovalCopier.Result.PatchSetApprovalData, PatchSetApprovalTestId>
+        hasTestId() {
+      return NullAwareCorrespondence.transforming(
+          approvalData -> PatchSetApprovalTestId.create(approvalData.patchSetApproval()),
+          "has test ID");
     }
 
+    public static ApprovalDataSubject assertThat(
+        ApprovalCopier.Result.PatchSetApprovalData approvalData) {
+      return assertAbout(approvalDatas()).that(approvalData);
+    }
+
+    public static ApprovalDataSubject assertThat(
+        ImmutableSet<ApprovalCopier.Result.PatchSetApprovalData> approvalDatas,
+        String labelId,
+        Account.Id accountId) {
+      Optional<ApprovalCopier.Result.PatchSetApprovalData> approvalDataForLabelAndAccount =
+          approvalDatas.stream()
+              .filter(
+                  approvalData ->
+                      approvalData.patchSetApproval().label().equals(labelId)
+                          && approvalData.patchSetApproval().accountId().equals(accountId))
+              .findAny();
+      Truth8.assertThat(approvalDataForLabelAndAccount).isPresent();
+      return assertAbout(approvalDatas()).that(approvalDataForLabelAndAccount.get());
+    }
+
+    public static ListSubject<ApprovalDataSubject, ApprovalCopier.Result.PatchSetApprovalData>
+        assertThatList(ImmutableSet<ApprovalCopier.Result.PatchSetApprovalData> approvalDatas) {
+      return ListSubject.assertThat(approvalDatas.asList(), approvalDatas());
+    }
+
+    private static Factory<ApprovalDataSubject, ApprovalCopier.Result.PatchSetApprovalData>
+        approvalDatas() {
+      return ApprovalDataSubject::new;
+    }
+
+    private final ApprovalCopier.Result.PatchSetApprovalData approvalData;
+
+    private ApprovalDataSubject(
+        FailureMetadata metadata, ApprovalCopier.Result.PatchSetApprovalData approvalData) {
+      super(metadata, approvalData);
+      this.approvalData = approvalData;
+    }
+
+    public ListSubject<StringSubject, String> hasPassingAtomsThat() {
+      return check("passingAtoms()")
+          .about(elements())
+          .that(approvalData().passingAtoms().asList(), StandardSubjectBuilder::that);
+    }
+
+    public ListSubject<StringSubject, String> hasFailingAtomsThat() {
+      return check("failingAtoms()")
+          .about(elements())
+          .that(approvalData().failingAtoms().asList(), StandardSubjectBuilder::that);
+    }
+
+    private ApprovalCopier.Result.PatchSetApprovalData approvalData() {
+      isNotNull();
+      return approvalData;
+    }
+  }
+
+  public static class PatchSetApprovalSubject extends Subject {
     public static PatchSetApprovalSubject assertThat(PatchSetApproval patchSetApproval) {
       return assertAbout(patchSetApprovals()).that(patchSetApproval);
     }
 
-    public static ListSubject<PatchSetApprovalSubject, PatchSetApproval> assertThatList(
-        ImmutableSet<PatchSetApproval> patchSetApprovals) {
-      return ListSubject.assertThat(patchSetApprovals.asList(), patchSetApprovals());
-    }
-
     private static Factory<PatchSetApprovalSubject, PatchSetApproval> patchSetApprovals() {
       return PatchSetApprovalSubject::new;
     }
diff --git a/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java b/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
index 6d980c7..15baa78 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
@@ -185,6 +185,39 @@
   }
 
   @Test
+  public void commentWithRangeAndLine_lineIsIgnored() throws Exception {
+    String file = "file";
+    String contents = "contents";
+    PushOneCommit push =
+        pushFactory.create(admin.newIdent(), testRepo, "first subject", file, contents);
+    PushOneCommit.Result r = push.to("refs/for/master");
+    String changeId = r.getChangeId();
+    String revId = r.getCommit().getName();
+    ReviewInput input = new ReviewInput();
+    CommentInput comment = CommentsUtil.newComment(file, Side.REVISION, 1, "comment 1", false);
+    int rangeEndLine = 3;
+    comment.range = createRange(1, 1, rangeEndLine, 3);
+    input.comments = new HashMap<>();
+    input.comments.put(comment.path, Lists.newArrayList(comment));
+    revision(r).review(input);
+    Map<String, List<CommentInfo>> result = getPublishedComments(changeId, revId);
+    assertThat(result).isNotEmpty();
+    CommentInfo actual = Iterables.getOnlyElement(result.get(comment.path));
+    assertThat(actual.line).isEqualTo(rangeEndLine);
+    input = new ReviewInput();
+    comment = CommentsUtil.newComment(file, Side.REVISION, 1, "comment 1 reply", false);
+    comment.range = createRange(1, 1, rangeEndLine, 3);
+    // Post another comment in reply, and the line is still fixed to the range.endLine
+    comment.inReplyTo = actual.id;
+    input.comments = new HashMap<>();
+    input.comments.put(comment.path, Lists.newArrayList(comment));
+    revision(r).review(input);
+    result = getPublishedComments(changeId, revId);
+    assertThat(result.get(comment.path)).hasSize(2);
+    assertThat(result.get(comment.path).stream().allMatch(c -> c.line == rangeEndLine)).isTrue();
+  }
+
+  @Test
   public void patchsetLevelCommentCanBeAddedAndRetrieved() throws Exception {
     PushOneCommit.Result result = createChange();
     String changeId = result.getChangeId();
@@ -1229,7 +1262,7 @@
                 + c
                 + "/comment/"
                 + ps1List.get(0).id
-                + " \n"
+                + " :\n"
                 + "PS1, Line 1: initial\n"
                 + "what happened to this?\n"
                 + "\n"
@@ -1241,7 +1274,7 @@
                 + c
                 + "/comment/"
                 + ps1List.get(1).id
-                + " \n"
+                + " :\n"
                 + "PS1, Line 1: boring\n"
                 + "Is it that bad?\n"
                 + "\n"
@@ -1255,7 +1288,7 @@
                 + c
                 + "/comment/"
                 + ps2List.get(0).id
-                + " \n"
+                + " :\n"
                 + "PS2, Line 1: initial content\n"
                 + "comment 1 on base\n"
                 + "\n"
@@ -1267,7 +1300,7 @@
                 + c
                 + "/comment/"
                 + ps2List.get(1).id
-                + " \n"
+                + " :\n"
                 + "PS2, Line 2: \n"
                 + "comment 2 on base\n"
                 + "\n"
@@ -1279,7 +1312,7 @@
                 + c
                 + "/comment/"
                 + ps2List.get(2).id
-                + " \n"
+                + " :\n"
                 + "PS2, Line 1: interesting\n"
                 + "better now\n"
                 + "\n"
@@ -1291,7 +1324,7 @@
                 + c
                 + "/comment/"
                 + ps2List.get(3).id
-                + " \n"
+                + " :\n"
                 + "PS2, Line 2: cntent\n"
                 + "typo: content\n"
                 + "\n"
@@ -2073,6 +2106,16 @@
     return range;
   }
 
+  private static Comment.Range createRange(
+      int startLine, int startCharacter, int endLine, int endCharacter) {
+    Comment.Range range = new Comment.Range();
+    range.startLine = startLine;
+    range.startCharacter = startCharacter;
+    range.endLine = endLine;
+    range.endCharacter = endCharacter;
+    return range;
+  }
+
   private static Function<CommentInfo, CommentInput> infoToInput(String path) {
     return info -> {
       CommentInput commentInput = new CommentInput();
diff --git a/javatests/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java b/javatests/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java
index bcde618..55f102f 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java
@@ -17,6 +17,8 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.extensions.common.ProblemInfo.Status.FIXED;
 import static com.google.gerrit.extensions.common.ProblemInfo.Status.FIX_FAILED;
+import static com.google.gerrit.testing.TestActionRefUpdateContext.openTestRefUpdateContext;
+import static com.google.gerrit.testing.TestActionRefUpdateContext.testRefAction;
 import static com.google.gerrit.testing.TestChanges.newPatchSet;
 import static java.util.Objects.requireNonNull;
 
@@ -47,6 +49,7 @@
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.RepoContext;
+import com.google.gerrit.server.update.context.RefUpdateContext;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.testing.TestChanges;
 import com.google.inject.Inject;
@@ -297,7 +300,7 @@
     serverSideTestRepo.reset(serverSideTestRepo.getRepository().exactRef(ref).getObjectId());
     RefUpdate ru = serverSideTestRepo.getRepository().updateRef(ref);
     ru.setForceUpdate(true);
-    assertThat(ru.delete()).isEqualTo(RefUpdate.Result.FORCED);
+    testRefAction(() -> assertThat(ru.delete()).isEqualTo(RefUpdate.Result.FORCED));
 
     assertProblems(notes, null, problem("Destination ref not found (may be new branch): " + ref));
   }
@@ -305,20 +308,21 @@
   @Test
   public void mergedChangeIsNotMerged() throws Exception {
     ChangeNotes notes = insertChange();
-
-    try (BatchUpdate bu = newUpdate(adminId)) {
-      bu.addOp(
-          notes.getChangeId(),
-          new BatchUpdateOp() {
-            @Override
-            public boolean updateChange(ChangeContext ctx) {
-              ctx.getChange().setStatus(Change.Status.MERGED);
-              ctx.getUpdate(ctx.getChange().currentPatchSetId())
-                  .fixStatusToMerged(new SubmissionId(ctx.getChange()));
-              return true;
-            }
-          });
-      bu.execute();
+    try (RefUpdateContext ctx = openTestRefUpdateContext()) {
+      try (BatchUpdate bu = newUpdate(adminId)) {
+        bu.addOp(
+            notes.getChangeId(),
+            new BatchUpdateOp() {
+              @Override
+              public boolean updateChange(ChangeContext ctx) {
+                ctx.getChange().setStatus(Change.Status.MERGED);
+                ctx.getUpdate(ctx.getChange().currentPatchSetId())
+                    .fixStatusToMerged(new SubmissionId(ctx.getChange()));
+                return true;
+              }
+            });
+        bu.execute();
+      }
     }
     notes = reload(notes);
 
@@ -745,19 +749,22 @@
 
   private ChangeNotes insertChange(TestAccount owner, String dest) throws Exception {
     Change.Id id = Change.id(sequences.nextChangeId());
-    ChangeInserter ins;
-    try (BatchUpdate bu = newUpdate(owner.id())) {
-      RevCommit commit = patchSetCommit(PatchSet.id(id, 1));
-      bu.setNotify(NotifyResolver.Result.none());
-      ins =
-          changeInserterFactory
-              .create(id, commit, dest)
-              .setValidate(false)
-              .setFireRevisionCreated(false)
-              .setSendMail(false);
-      bu.insertChange(ins).execute();
-    }
-    return changeNotesFactory.create(project, ins.getChange().getId());
+    return testRefAction(
+        () -> {
+          ChangeInserter ins;
+          try (BatchUpdate bu = newUpdate(owner.id())) {
+            RevCommit commit = patchSetCommit(PatchSet.id(id, 1));
+            bu.setNotify(NotifyResolver.Result.none());
+            ins =
+                changeInserterFactory
+                    .create(id, commit, dest)
+                    .setValidate(false)
+                    .setFireRevisionCreated(false)
+                    .setSendMail(false);
+            bu.insertChange(ins).execute();
+          }
+          return changeNotesFactory.create(project, ins.getChange().getId());
+        });
   }
 
   private PatchSet.Id nextPatchSetId(ChangeNotes notes) throws Exception {
@@ -770,17 +777,20 @@
   }
 
   private ChangeNotes incrementPatchSet(ChangeNotes notes, RevCommit commit) throws Exception {
-    PatchSetInserter ins;
-    try (BatchUpdate bu = newUpdate(notes.getChange().getOwner())) {
-      bu.setNotify(NotifyResolver.Result.none());
-      ins =
-          patchSetInserterFactory
-              .create(notes, nextPatchSetId(notes), commit)
-              .setValidate(false)
-              .setFireRevisionCreated(false);
-      bu.addOp(notes.getChangeId(), ins).execute();
-    }
-    return reload(notes);
+    return testRefAction(
+        () -> {
+          PatchSetInserter ins;
+          try (BatchUpdate bu = newUpdate(notes.getChange().getOwner())) {
+            bu.setNotify(NotifyResolver.Result.none());
+            ins =
+                patchSetInserterFactory
+                    .create(notes, nextPatchSetId(notes), commit)
+                    .setValidate(false)
+                    .setFireRevisionCreated(false);
+            bu.addOp(notes.getChangeId(), ins).execute();
+          }
+          return reload(notes);
+        });
   }
 
   private ChangeNotes reload(ChangeNotes notes) throws Exception {
@@ -822,7 +832,7 @@
   private void deleteRef(String refName) throws Exception {
     RefUpdate ru = serverSideTestRepo.getRepository().updateRef(refName, true);
     ru.setForceUpdate(true);
-    assertThat(ru.delete()).isEqualTo(RefUpdate.Result.FORCED);
+    testRefAction(() -> assertThat(ru.delete()).isEqualTo(RefUpdate.Result.FORCED));
   }
 
   private void addNoteDbCommit(Change.Id id, String commitMessage) throws Exception {
@@ -847,30 +857,33 @@
   }
 
   private ChangeNotes mergeChange(ChangeNotes notes) throws Exception {
-    ObjectId oldId = getDestRef(notes);
-    ObjectId newId = psUtil.current(notes).commitId();
-    String dest = notes.getChange().getDest().branch();
+    return testRefAction(
+        () -> {
+          ObjectId oldId = getDestRef(notes);
+          ObjectId newId = psUtil.current(notes).commitId();
+          String dest = notes.getChange().getDest().branch();
 
-    try (BatchUpdate bu = newUpdate(adminId)) {
-      bu.addOp(
-          notes.getChangeId(),
-          new BatchUpdateOp() {
-            @Override
-            public void updateRepo(RepoContext ctx) throws IOException {
-              ctx.addRefUpdate(oldId, newId, dest);
-            }
+          try (BatchUpdate bu = newUpdate(adminId)) {
+            bu.addOp(
+                notes.getChangeId(),
+                new BatchUpdateOp() {
+                  @Override
+                  public void updateRepo(RepoContext ctx) throws IOException {
+                    ctx.addRefUpdate(oldId, newId, dest);
+                  }
 
-            @Override
-            public boolean updateChange(ChangeContext ctx) {
-              ctx.getChange().setStatus(Change.Status.MERGED);
-              ctx.getUpdate(ctx.getChange().currentPatchSetId())
-                  .fixStatusToMerged(new SubmissionId(ctx.getChange()));
-              return true;
-            }
-          });
-      bu.execute();
-    }
-    return reload(notes);
+                  @Override
+                  public boolean updateChange(ChangeContext ctx) {
+                    ctx.getChange().setStatus(Change.Status.MERGED);
+                    ctx.getUpdate(ctx.getChange().currentPatchSetId())
+                        .fixStatusToMerged(new SubmissionId(ctx.getChange()));
+                    return true;
+                  }
+                });
+            bu.execute();
+          }
+          return reload(notes);
+        });
   }
 
   private static ProblemInfo problem(String message) {
@@ -911,7 +924,7 @@
       ru.setExpectedOldObjectId(ref.getObjectId());
       ru.setNewObjectId(ObjectId.zeroId());
       ru.setForceUpdate(true);
-      Result result = ru.delete();
+      Result result = testRefAction(() -> ru.delete());
       if (result != Result.FORCED) {
         throw new IOException(String.format("Failed to delete ref %s: %s", refName, result.name()));
       }
diff --git a/javatests/com/google/gerrit/acceptance/server/change/DeleteZombieDraftIT.java b/javatests/com/google/gerrit/acceptance/server/change/DeleteZombieDraftIT.java
index 1eef944..107b777 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/DeleteZombieDraftIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/DeleteZombieDraftIT.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance.server.change;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.common.collect.ImmutableList;
@@ -30,6 +31,7 @@
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.server.notedb.ChangeNoteJson;
 import com.google.gerrit.server.notedb.DeleteZombieCommentsRefs;
+import com.google.gerrit.server.update.context.RefUpdateContext;
 import com.google.gerrit.testing.ConfigSuite;
 import com.google.gson.JsonParser;
 import com.google.inject.Inject;
@@ -191,10 +193,12 @@
   }
 
   private void restoreRef(String refName, ObjectId id) throws Exception {
-    try (Repository allUsersRepo = repoManager.openRepository(allUsers)) {
-      RefUpdate u = allUsersRepo.updateRef(refName);
-      u.setNewObjectId(id);
-      u.forceUpdate();
+    try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+      try (Repository allUsersRepo = repoManager.openRepository(allUsers)) {
+        RefUpdate u = allUsersRepo.updateRef(refName);
+        u.setNewObjectId(id);
+        u.forceUpdate();
+      }
     }
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/server/change/GetRelatedIT.java b/javatests/com/google/gerrit/acceptance/server/change/GetRelatedIT.java
index 70b5701..a1ba293 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/GetRelatedIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/GetRelatedIT.java
@@ -20,6 +20,7 @@
 import static com.google.gerrit.acceptance.GitUtil.pushHead;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowCapability;
 import static com.google.gerrit.extensions.common.testing.EditInfoSubject.assertThat;
+import static com.google.gerrit.testing.TestActionRefUpdateContext.openTestRefUpdateContext;
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
@@ -39,6 +40,7 @@
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.extensions.api.changes.GetRelatedOption;
@@ -53,6 +55,7 @@
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.context.RefUpdateContext;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.testing.ConfigSuite;
 import com.google.inject.Inject;
@@ -98,6 +101,35 @@
   }
 
   @Test
+  public void getRelatedAcrossBranchesWithMergeChange() throws Exception {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
+    createBranch(BranchNameKey.create(project, "foo"));
+
+    // Create and merge change on 'foo' branch.
+    merge(createChange("refs/for/foo"));
+
+    // Create change on 'foo' branch
+    PushOneCommit.Result base = createChange("refs/for/foo");
+    base.assertOkStatus();
+    RevCommit c1_1 = base.getCommit();
+    PatchSet.Id ps1_1 = getPatchSetId(c1_1);
+
+    testRepo.reset(initialHead);
+
+    // Create and push merge commit
+    PushOneCommit m = pushFactory.create(admin.newIdent(), testRepo);
+    m.setParents(ImmutableList.of(c1_1, initialHead));
+    PushOneCommit.Result result = m.to("refs/for/master");
+    result.assertOkStatus();
+    RevCommit c2_1 = result.getCommit();
+    PatchSet.Id ps2_1 = getPatchSetId(c2_1);
+
+    for (PatchSet.Id ps : ImmutableList.of(ps2_1, ps1_1)) {
+      assertRelated(ps, changeAndCommit(ps2_1, c2_1, 1), changeAndCommit(ps1_1, c1_1, 1));
+    }
+  }
+
+  @Test
   public void getRelatedLinear() throws Exception {
     // 1,1---2,1
     RevCommit c1_1 = commitBuilder().add("a.txt", "1").message("subject: 1").create();
@@ -703,17 +735,19 @@
   }
 
   private void clearGroups(PatchSet.Id psId) throws Exception {
-    try (BatchUpdate bu = batchUpdateFactory.create(project, user(user), TimeUtil.now())) {
-      bu.addOp(
-          psId.changeId(),
-          new BatchUpdateOp() {
-            @Override
-            public boolean updateChange(ChangeContext ctx) {
-              ctx.getUpdate(psId).setGroups(ImmutableList.of());
-              return true;
-            }
-          });
-      bu.execute();
+    try (RefUpdateContext ctx = openTestRefUpdateContext()) {
+      try (BatchUpdate bu = batchUpdateFactory.create(project, user(user), TimeUtil.now())) {
+        bu.addOp(
+            psId.changeId(),
+            new BatchUpdateOp() {
+              @Override
+              public boolean updateChange(ChangeContext ctx) {
+                ctx.getUpdate(psId).setGroups(ImmutableList.of());
+                return true;
+              }
+            });
+        bu.execute();
+      }
     }
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/server/event/CommentAddedEventIT.java b/javatests/com/google/gerrit/acceptance/server/event/CommentAddedEventIT.java
index fb3259f..f2184de 100644
--- a/javatests/com/google/gerrit/acceptance/server/event/CommentAddedEventIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/event/CommentAddedEventIT.java
@@ -213,7 +213,7 @@
 
   @Test
   @GerritConfig(name = "event.comment-added.publishPatchSetLevelComment", value = "false")
-  public void publishPatchSetLevelComment() throws Exception {
+  public void publishPatchSetLevelComment_disabled() throws Exception {
     PushOneCommit.Result r = createChange();
     TestListener listener = new TestListener();
     try (Registration registration = extensionRegistry.newRegistration().add(listener)) {
@@ -225,6 +225,20 @@
   }
 
   @Test
+  @GerritConfig(name = "event.comment-added.publishPatchSetLevelComment", value = "true")
+  public void publishPatchSetLevelComment_enabled() throws Exception {
+    PushOneCommit.Result r = createChange();
+    TestListener listener = new TestListener();
+    try (Registration registration = extensionRegistry.newRegistration().add(listener)) {
+      String patchSetLevelComment = "a patch set level comment";
+      ReviewInput reviewInput = new ReviewInput().patchSetLevelComment(patchSetLevelComment);
+      revision(r).review(reviewInput);
+      assertThat(listener.getLastCommentAddedEvent().getComment())
+          .isEqualTo(String.format("Patch Set 1:\n\n%s", patchSetLevelComment));
+    }
+  }
+
+  @Test
   public void reviewChange_MultipleVotes() throws Exception {
     TestListener listener = new TestListener();
     try (Registration registration = extensionRegistry.newRegistration().add(listener)) {
diff --git a/javatests/com/google/gerrit/acceptance/server/experiments/ExperimentFeaturesIT.java b/javatests/com/google/gerrit/acceptance/server/experiments/ExperimentFeaturesIT.java
index b2a0ded..e011ffc 100644
--- a/javatests/com/google/gerrit/acceptance/server/experiments/ExperimentFeaturesIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/experiments/ExperimentFeaturesIT.java
@@ -68,10 +68,7 @@
       values = {"UiFeature__patchset_comments", "UiFeature__submit_requirements_ui"})
   public void configOverride_defaultFeatureDisabled() {
     assertThat(experimentFeatures.isFeatureEnabled("enabledFeature")).isTrue();
-    assertThat(
-            experimentFeatures.isFeatureEnabled(
-                ExperimentFeaturesConstants.UI_FEATURE_PATCHSET_COMMENTS))
-        .isFalse();
+    assertThat(experimentFeatures.isFeatureEnabled("UiFeature__patchset_comments")).isFalse();
     assertThat(experimentFeatures.getEnabledExperimentFeatures()).containsExactly("enabledFeature");
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java b/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
index 7603aec..ea836e6 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
@@ -17,6 +17,7 @@
 import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
 import static com.google.gerrit.entities.NotifyConfig.NotifyType.ABANDONED_CHANGES;
 import static com.google.gerrit.entities.NotifyConfig.NotifyType.ALL_COMMENTS;
 import static com.google.gerrit.entities.NotifyConfig.NotifyType.NEW_CHANGES;
@@ -28,9 +29,13 @@
 import static com.google.gerrit.extensions.api.changes.NotifyHandling.OWNER_REVIEWERS;
 import static com.google.gerrit.extensions.client.GeneralPreferencesInfo.EmailStrategy.CC_ON_OWN_COMMENTS;
 import static com.google.gerrit.extensions.client.GeneralPreferencesInfo.EmailStrategy.ENABLED;
+import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.server.project.testing.TestLabels.labelBuilder;
+import static com.google.gerrit.server.project.testing.TestLabels.value;
 
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
 import com.google.common.truth.Truth;
 import com.google.gerrit.acceptance.AbstractNotificationTest;
 import com.google.gerrit.acceptance.PushOneCommit;
@@ -38,11 +43,15 @@
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Address;
 import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.NotifyConfig;
+import com.google.gerrit.entities.NotifyConfig.Header;
+import com.google.gerrit.entities.NotifyConfig.NotifyType;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.changes.AbandonInput;
-import com.google.gerrit.extensions.api.changes.AssigneeInput;
 import com.google.gerrit.extensions.api.changes.DeleteReviewerInput;
 import com.google.gerrit.extensions.api.changes.DeleteVoteInput;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
@@ -304,6 +313,25 @@
     addReviewerToReviewableChange(batch());
   }
 
+  @Test
+  public void addReviewerToChangeNoAnonymousUsersNotified() throws Exception {
+    StagedChange sc = stageReviewableChange();
+    // Remove read permission for anonymous users.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.READ).ref("refs/*").group(ANONYMOUS_USERS))
+        .add(allow(Permission.READ).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+
+    TestAccount reviewer = accountCreator.create("added", "added@example.com", "added", null);
+    addReviewer(singly(), sc.changeId, sc.owner, reviewer.email());
+
+    // No BY_EMAIL cc's.
+    assertThat(sender).sent("newchange", sc).to(reviewer).cc(sc.reviewer).noOneElse();
+    assertThat(sender).didNotSend();
+  }
+
   private void addReviewerToReviewableChangeByOwnerCcingSelf(Adder adder) throws Exception {
     StagedChange sc = stageReviewableChange();
     TestAccount reviewer = accountCreator.create("added", "added@example.com", "added", null);
@@ -670,6 +698,95 @@
   }
 
   @Test
+  public void commentOnChangeWithNotifyConfig() throws Exception {
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      NotifyConfig nc =
+          NotifyConfig.builder()
+              .setName("observer")
+              .setNotify(ImmutableSet.of(NotifyType.ALL))
+              .setHeader(Header.CC)
+              .addAddress(Address.create("observer@example.com"))
+              .build();
+      u.getConfig().putNotifyConfig("observer", nc);
+      u.save();
+    }
+
+    StagedChange sc = stageReviewableChange();
+    review(sc.reviewer, sc.changeId, ENABLED);
+    assertThat(sender)
+        .sent("comment", sc)
+        .to(sc.owner)
+        .cc(sc.ccer)
+        .cc(StagedUsers.REVIEWER_BY_EMAIL, StagedUsers.CC_BY_EMAIL)
+        .cc("observer@example.com")
+        .bcc(sc.starrer)
+        .bcc(ALL_COMMENTS)
+        .noOneElse();
+    assertThat(sender).didNotSend();
+  }
+
+  @Test
+  public void commentOnChangeNotVisibleToAnonymousByReviewer() throws Exception {
+    StagedChange sc = stageReviewableChange();
+
+    // Remove read permission for anonymous users.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.READ).ref("refs/*").group(ANONYMOUS_USERS))
+        .add(allow(Permission.READ).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+
+    review(sc.reviewer, sc.changeId, ENABLED);
+    // Not cc'ed to BY_EMAIL added addresses.
+    assertThat(sender)
+        .sent("comment", sc)
+        .to(sc.owner)
+        .cc(sc.ccer)
+        .bcc(sc.starrer)
+        .bcc(ALL_COMMENTS)
+        .noOneElse();
+    assertThat(sender).didNotSend();
+  }
+
+  @Test
+  public void commentOnChangeNotVisibleToAnonymousByReviewerWithNotifyConfig() throws Exception {
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      NotifyConfig nc =
+          NotifyConfig.builder()
+              .setName("observer")
+              .setNotify(ImmutableSet.of(NotifyType.ALL))
+              .setHeader(Header.CC)
+              .addAddress(Address.create("observer@example.com"))
+              .build();
+      u.getConfig().putNotifyConfig("observer", nc);
+      u.save();
+    }
+
+    StagedChange sc = stageReviewableChange();
+
+    // Remove read permission for anonymous users.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.READ).ref("refs/*").group(ANONYMOUS_USERS))
+        .add(allow(Permission.READ).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+
+    review(sc.reviewer, sc.changeId, ENABLED);
+    // Not cc'ed to BY_EMAIL added addresses.
+    assertThat(sender)
+        .sent("comment", sc)
+        .to(sc.owner)
+        .cc(sc.ccer)
+        .cc("observer@example.com")
+        .bcc(sc.starrer)
+        .bcc(ALL_COMMENTS)
+        .noOneElse();
+    assertThat(sender).didNotSend();
+  }
+
+  @Test
   public void commentOnReviewableChangeByOwnerCcingSelf() throws Exception {
     StagedChange sc = stageReviewableChange();
     review(sc.owner, sc.changeId, CC_ON_OWN_COMMENTS);
@@ -982,7 +1099,7 @@
     StagedPreChange spc = stagePreChange("refs/for/master");
     assertThat(sender)
         .sent("newchange", spc)
-        .title(String.format("[S] Change in %s[master]: test commit", project));
+        .title(String.format("[XS] Change in %s[master]: test commit", project));
     assertThat(sender).didNotSend();
   }
 
@@ -1710,6 +1827,42 @@
     assertThat(sender).didNotSend();
   }
 
+  @Test
+  public void mergeByOtherAlwaysNotifiesAllIfThereIsAStickyApprovalDiff() throws Exception {
+    StagedChange sc = stageChangeReadyForMergeWithStickyApprovalDiff();
+    // The user requests to notify NONE, but if there is a sticky approval diff we notify ALL.
+    merge(sc.changeId, other, NONE);
+    assertThat(sender)
+        .sent("merged", sc)
+        .to(sc.owner)
+        .cc(sc.reviewer)
+        .cc(sc.ccer)
+        .cc(StagedUsers.REVIEWER_BY_EMAIL, StagedUsers.CC_BY_EMAIL)
+        .bcc(sc.starrer)
+        .bcc(SUBMITTED_CHANGES)
+        .bcc(ALL_COMMENTS)
+        .noOneElse();
+    assertThat(sender).didNotSend();
+  }
+
+  @Test
+  public void mergeOnBehalfOfAlwaysNotifiesAllIfThereIsAStickyApprovalDiff() throws Exception {
+    StagedChange sc = stageChangeReadyForMergeWithStickyApprovalDiff();
+    // The user requests to notify NONE, but if there is a sticky approval diff we notify ALL.
+    merge(sc.changeId, other, sc.owner, NONE);
+    assertThat(sender)
+        .sent("merged", sc)
+        .to(sc.owner)
+        .cc(sc.reviewer)
+        .cc(sc.ccer)
+        .cc(StagedUsers.REVIEWER_BY_EMAIL, StagedUsers.CC_BY_EMAIL)
+        .bcc(sc.starrer)
+        .bcc(SUBMITTED_CHANGES)
+        .bcc(ALL_COMMENTS)
+        .noOneElse();
+    assertThat(sender).didNotSend();
+  }
+
   private void merge(String changeId, TestAccount by) throws Exception {
     merge(changeId, by, ENABLED);
   }
@@ -1753,6 +1906,29 @@
     return sc;
   }
 
+  private StagedChange stageChangeReadyForMergeWithStickyApprovalDiff() throws Exception {
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      LabelType.Builder codeReview =
+          labelBuilder(
+                  LabelId.CODE_REVIEW,
+                  value(2, "Looks good to me, approved"),
+                  value(1, "Looks good to me, but someone else must approve"),
+                  value(0, "No score"),
+                  value(-1, "I would prefer this is not submitted as is"),
+                  value(-2, "This shall not be submitted"))
+              .setCopyCondition("is:ANY");
+      u.getConfig().upsertLabelType(codeReview.build());
+      u.save();
+    }
+
+    StagedChange sc = stageReviewableChange();
+    requestScopeOperations.setApiUser(sc.reviewer.id());
+    gApi.changes().id(sc.changeId).current().review(ReviewInput.approve());
+    amendChange(sc.changeId, "refs/for/master", sc.owner, sc.repo).assertOkStatus();
+    sender.clear();
+    return sc;
+  }
+
   /*
    * ReplacePatchSetSender tests.
    */
@@ -2390,154 +2566,6 @@
   }
 
   /*
-   * SetAssigneeSender tests.
-   */
-
-  @Test
-  public void setAssigneeOnReviewableChange() throws Exception {
-    StagedChange sc = stageReviewableChange();
-    assign(sc, sc.owner, sc.assignee);
-    assertThat(sender)
-        .sent("setassignee", sc)
-        .cc(
-            StagedUsers.REVIEWER_BY_EMAIL,
-            StagedUsers.CC_BY_EMAIL) // TODO(logan): This is probably not intended!
-        .to(sc.assignee)
-        .noOneElse();
-    assertThat(sender).didNotSend();
-  }
-
-  @Test
-  public void setAssigneeOnReviewableChangeByOwnerCcingSelf() throws Exception {
-    StagedChange sc = stageReviewableChange();
-    assign(sc, sc.owner, sc.assignee, CC_ON_OWN_COMMENTS);
-    assertThat(sender)
-        .sent("setassignee", sc)
-        .cc(sc.owner)
-        .cc(
-            StagedUsers.REVIEWER_BY_EMAIL,
-            StagedUsers.CC_BY_EMAIL) // TODO(logan): This is probably not intended!
-        .to(sc.assignee)
-        .noOneElse();
-    assertThat(sender).didNotSend();
-  }
-
-  @Test
-  public void setAssigneeOnReviewableChangeByAdmin() throws Exception {
-    StagedChange sc = stageReviewableChange();
-    assign(sc, admin, sc.assignee);
-    assertThat(sender)
-        .sent("setassignee", sc)
-        .cc(
-            StagedUsers.REVIEWER_BY_EMAIL,
-            StagedUsers.CC_BY_EMAIL) // TODO(logan): This is probably not intended!
-        .to(sc.assignee)
-        .noOneElse();
-    assertThat(sender).didNotSend();
-  }
-
-  @Test
-  public void setAssigneeOnReviewableChangeByAdminCcingSelf() throws Exception {
-    StagedChange sc = stageReviewableChange();
-    assign(sc, admin, sc.assignee, CC_ON_OWN_COMMENTS);
-    assertThat(sender)
-        .sent("setassignee", sc)
-        .cc(admin)
-        .cc(
-            StagedUsers.REVIEWER_BY_EMAIL,
-            StagedUsers.CC_BY_EMAIL) // TODO(logan): This is probably not intended!
-        .to(sc.assignee)
-        .noOneElse();
-    assertThat(sender).didNotSend();
-  }
-
-  @Test
-  public void setAssigneeToSelfOnReviewableChange() throws Exception {
-    StagedChange sc = stageReviewableChange();
-    assign(sc, sc.owner, sc.owner);
-    assertThat(sender)
-        .sent("setassignee", sc)
-        .cc(
-            StagedUsers.REVIEWER_BY_EMAIL,
-            StagedUsers.CC_BY_EMAIL) // TODO(logan): This is probably not intended!
-        .noOneElse();
-    assertThat(sender).didNotSend();
-  }
-
-  @Test
-  public void changeAssigneeOnReviewableChange() throws Exception {
-    StagedChange sc = stageReviewableChange();
-    TestAccount other = accountCreator.create("other", "other@example.com", "other", null);
-    assign(sc, sc.owner, other);
-    sender.clear();
-    assign(sc, sc.owner, sc.assignee);
-    assertThat(sender)
-        .sent("setassignee", sc)
-        .cc(
-            StagedUsers.REVIEWER_BY_EMAIL,
-            StagedUsers.CC_BY_EMAIL) // TODO(logan): This is probably not intended!
-        .to(sc.assignee)
-        .noOneElse();
-    assertThat(sender).didNotSend();
-  }
-
-  @Test
-  public void changeAssigneeToSelfOnReviewableChange() throws Exception {
-    StagedChange sc = stageReviewableChange();
-    assign(sc, sc.owner, sc.assignee);
-    sender.clear();
-    assign(sc, sc.owner, sc.owner);
-    assertThat(sender)
-        .sent("setassignee", sc)
-        .cc(
-            StagedUsers.REVIEWER_BY_EMAIL,
-            StagedUsers.CC_BY_EMAIL) // TODO(logan): This is probably not intended!
-        .noOneElse();
-    assertThat(sender).didNotSend();
-  }
-
-  @Test
-  public void setAssigneeOnReviewableWipChange() throws Exception {
-    StagedChange sc = stageReviewableWipChange();
-    assign(sc, sc.owner, sc.assignee);
-    assertThat(sender)
-        .sent("setassignee", sc)
-        .cc(
-            StagedUsers.REVIEWER_BY_EMAIL,
-            StagedUsers.CC_BY_EMAIL) // TODO(logan): This is probably not intended!
-        .to(sc.assignee)
-        .noOneElse();
-    assertThat(sender).didNotSend();
-  }
-
-  @Test
-  public void setAssigneeOnWipChange() throws Exception {
-    StagedChange sc = stageWipChange();
-    assign(sc, sc.owner, sc.assignee);
-    assertThat(sender)
-        .sent("setassignee", sc)
-        .cc(
-            StagedUsers.REVIEWER_BY_EMAIL,
-            StagedUsers.CC_BY_EMAIL) // TODO(logan): This is probably not intended!
-        .to(sc.assignee)
-        .noOneElse();
-    assertThat(sender).didNotSend();
-  }
-
-  private void assign(StagedChange sc, TestAccount by, TestAccount to) throws Exception {
-    assign(sc, by, to, ENABLED);
-  }
-
-  private void assign(StagedChange sc, TestAccount by, TestAccount to, EmailStrategy emailStrategy)
-      throws Exception {
-    setEmailStrategy(by, emailStrategy);
-    requestScopeOperations.setApiUser(by.id());
-    AssigneeInput in = new AssigneeInput();
-    in.assignee = to.email();
-    gApi.changes().id(sc.changeId).setAssignee(in);
-  }
-
-  /*
    * Start review and WIP tests.
    */
 
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/EmailValidatorIT.java b/javatests/com/google/gerrit/acceptance/server/mail/EmailValidatorIT.java
index cc61dfb..3145234 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/EmailValidatorIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/EmailValidatorIT.java
@@ -26,6 +26,7 @@
 import java.io.InputStream;
 import java.io.InputStreamReader;
 import java.lang.reflect.Field;
+import java.util.Locale;
 import org.junit.After;
 import org.junit.BeforeClass;
 import org.junit.Test;
@@ -83,12 +84,13 @@
           continue;
         }
         if (tld.startsWith(UNSUPPORTED_PREFIX)) {
-          String test = "test@example." + tld.toLowerCase().substring(UNSUPPORTED_PREFIX.length());
+          String test =
+              "test@example." + tld.toLowerCase(Locale.US).substring(UNSUPPORTED_PREFIX.length());
           assertWithMessage("expected invalid TLD \"" + test + "\"")
               .that(validator.isValid(test))
               .isFalse();
         } else {
-          String test = "test@example." + tld.toLowerCase();
+          String test = "test@example." + tld.toLowerCase(Locale.US);
           assertWithMessage("failed to validate TLD \"" + test + "\"")
               .that(validator.isValid(test))
               .isTrue();
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/MailSenderIT.java b/javatests/com/google/gerrit/acceptance/server/mail/MailSenderIT.java
index 45a471b..f728995 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/MailSenderIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/MailSenderIT.java
@@ -15,16 +15,25 @@
 package com.google.gerrit.acceptance.server.mail;
 
 import static com.google.common.truth.Truth.assertThat;
+import static java.nio.charset.StandardCharsets.UTF_8;
 
+import com.google.gerrit.acceptance.Sandboxed;
+import com.google.gerrit.acceptance.UseLocalDisk;
 import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.entities.EmailHeader;
 import com.google.gerrit.entities.EmailHeader.StringEmailHeader;
+import com.google.gerrit.server.config.SitePaths;
 import java.net.URI;
+import java.nio.file.Files;
 import java.util.Map;
+import javax.inject.Inject;
 import org.junit.Test;
 
+@UseLocalDisk
 public class MailSenderIT extends AbstractMailIT {
 
+  @Inject private SitePaths sitePaths;
+
   @Test
   @GerritConfig(name = "sendemail.replyToAddress", value = "custom@gerritcodereview.com")
   @GerritConfig(name = "receiveemail.protocol", value = "POP3")
@@ -63,6 +72,20 @@
     assertThat(headerString(headers, "In-Reply-To")).isEqualTo(threadId);
   }
 
+  @Test
+  @Sandboxed
+  public void useCustomTemplates() throws Exception {
+    String customTemplate =
+        "{namespace com.google.gerrit.server.mail.template.ChangeSubject}\n"
+            + "\n"
+            + "{template ChangeSubject kind=\"text\"}CUSTOM-TEMPLATE{/template}\n";
+    Files.write(sitePaths.mail_dir.resolve("ChangeSubject.soy"), customTemplate.getBytes(UTF_8));
+
+    createChangeWithReview(user);
+    String subject = headerString(sender.getMessages().iterator().next().headers(), "Subject");
+    assertThat(subject).isEqualTo("CUSTOM-TEMPLATE");
+  }
+
   private String headerString(Map<String, EmailHeader> headers, String name) {
     EmailHeader header = headers.get(name);
     assertThat(header).isInstanceOf(StringEmailHeader.class);
diff --git a/javatests/com/google/gerrit/acceptance/server/notedb/NoteDbOnlyIT.java b/javatests/com/google/gerrit/acceptance/server/notedb/NoteDbOnlyIT.java
index ab5e1d8..fc746ad 100644
--- a/javatests/com/google/gerrit/acceptance/server/notedb/NoteDbOnlyIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/notedb/NoteDbOnlyIT.java
@@ -19,6 +19,7 @@
 import static com.google.common.truth.Truth8.assertThat;
 import static com.google.gerrit.extensions.client.ListChangesOption.MESSAGES;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static com.google.gerrit.testing.TestActionRefUpdateContext.testRefAction;
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.collect.Iterables;
@@ -100,10 +101,13 @@
           }
         };
 
-    try (BatchUpdate bu = newBatchUpdate(batchUpdateFactory)) {
-      bu.addOp(id, backupMasterOp);
-      bu.execute();
-    }
+    testRefAction(
+        () -> {
+          try (BatchUpdate bu = newBatchUpdate(batchUpdateFactory)) {
+            bu.addOp(id, backupMasterOp);
+            bu.execute();
+          }
+        });
 
     // Ensure backupMasterOp worked.
     assertThat(getRef(backup)).hasValue(master1);
@@ -158,13 +162,16 @@
             .changeUpdate(
                 "testUpdateRefAndAddMessageOp",
                 batchUpdateFactory -> {
-                  try (BatchUpdate bu = newBatchUpdate(batchUpdateFactory)) {
-                    bu.addOp(
-                        id,
-                        new UpdateRefAndAddMessageOp(
-                            updateRepoCalledCount, updateChangeCalledCount));
-                    bu.execute(new ConcurrentWritingListener(afterUpdateReposCalledCount));
-                  }
+                  testRefAction(
+                      () -> {
+                        try (BatchUpdate bu = newBatchUpdate(batchUpdateFactory)) {
+                          bu.addOp(
+                              id,
+                              new UpdateRefAndAddMessageOp(
+                                  updateRepoCalledCount, updateChangeCalledCount));
+                          bu.execute(new ConcurrentWritingListener(afterUpdateReposCalledCount));
+                        }
+                      });
                   return "Done";
                 })
             .call();
diff --git a/javatests/com/google/gerrit/acceptance/server/permissions/ExternalUserPermissionIT.java b/javatests/com/google/gerrit/acceptance/server/permissions/ExternalUserPermissionIT.java
index 3508112..7a55ecb 100644
--- a/javatests/com/google/gerrit/acceptance/server/permissions/ExternalUserPermissionIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/permissions/ExternalUserPermissionIT.java
@@ -30,6 +30,7 @@
 import com.google.gerrit.acceptance.testsuite.change.ChangeOperations;
 import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.GroupDescription;
@@ -123,6 +124,7 @@
                       }
 
                       @Override
+                      @Nullable
                       public String getUrl() {
                         return null;
                       }
diff --git a/javatests/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java b/javatests/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java
index 1900158..432a6c6 100644
--- a/javatests/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java
@@ -22,6 +22,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.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.entities.AccountGroup;
@@ -348,6 +349,173 @@
   }
 
   @Test
+  public void noNotificationForWatchKeywordWhenKeywordMatchesChangeOwner() throws Exception {
+    String watchedProject = projectOperations.newProject().create().get();
+    requestScopeOperations.setApiUser(user.id());
+
+    // watch keyword in project as user
+    watch(watchedProject, admin.email());
+
+    // push a change with owner=keyword -> should not trigger email notification
+    requestScopeOperations.setApiUser(admin.id());
+    TestRepository<InMemoryRepository> watchedRepo =
+        cloneProject(Project.nameKey(watchedProject), admin);
+    PushOneCommit.Result r =
+        pushFactory
+            .create(admin.newIdent(), watchedRepo, "subject", "a.txt", "a1")
+            .to("refs/for/master");
+    r.assertOkStatus();
+
+    // assert email notification for user
+    assertThat(sender.getMessages()).isEmpty();
+  }
+
+  @Test
+  public void noNotificationForWatchKeywordWhenKeywordMatchesChangeReviewer() throws Exception {
+    TestAccount user2 = accountCreator.user2();
+    String watchedProject = projectOperations.newProject().create().get();
+    requestScopeOperations.setApiUser(user.id());
+
+    // watch keyword in project as user
+    watch(watchedProject, user2.email());
+
+    requestScopeOperations.setApiUser(admin.id());
+    TestRepository<InMemoryRepository> watchedRepo =
+        cloneProject(Project.nameKey(watchedProject), admin);
+    PushOneCommit.Result r =
+        pushFactory
+            .create(admin.newIdent(), watchedRepo, "subject", "a.txt", "a1")
+            .to("refs/for/master");
+    r.assertOkStatus();
+    sender.clear();
+
+    // Add reviewer=keyword -> should trigger email notification only to new reviewer
+    gApi.changes().id(r.getChangeId()).addReviewer(user2.email());
+
+    // assert email notification
+    List<Message> messages = sender.getMessages();
+    assertThat(messages).hasSize(1);
+    Message m = messages.get(0);
+    assertNotifyTo(user2);
+    assertThat(m.body()).contains("Change subject: subject\n");
+  }
+
+  @Test
+  public void watchOwner() throws Exception {
+    String watchedProject = projectOperations.newProject().create().get();
+    requestScopeOperations.setApiUser(user.id());
+
+    // watch keyword in project as user
+    watch(watchedProject, "owner:admin");
+
+    // push a change with keyword -> should trigger email notification
+    requestScopeOperations.setApiUser(admin.id());
+    TestRepository<InMemoryRepository> watchedRepo =
+        cloneProject(Project.nameKey(watchedProject), admin);
+    PushOneCommit.Result r =
+        pushFactory
+            .create(admin.newIdent(), watchedRepo, "subject", "a.txt", "a1")
+            .to("refs/for/master");
+    r.assertOkStatus();
+
+    // assert email notification for user
+    List<Message> messages = sender.getMessages();
+    assertThat(messages).hasSize(1);
+    Message m = messages.get(0);
+    assertThat(m.rcpt()).containsExactly(user.getNameEmail());
+    assertThat(m.body()).contains("Change subject: subject\n");
+    assertThat(m.body()).contains("Gerrit-PatchSet: 1\n");
+    sender.clear();
+  }
+
+  @Test
+  @GerritConfig(name = "accounts.visibility", value = "SAME_GROUP")
+  public void watchNonVisibleOwner() throws Exception {
+    String watchedProject = projectOperations.newProject().create().get();
+    requestScopeOperations.setApiUser(user.id());
+
+    // watch keyword in project as user
+    watch(watchedProject, "owner:admin");
+
+    // Verify that 'user' can't see 'admin'
+    assertThatAccountIsNotVisible(admin);
+
+    // push a change with keyword -> should trigger email notification
+    requestScopeOperations.setApiUser(admin.id());
+    TestRepository<InMemoryRepository> watchedRepo =
+        cloneProject(Project.nameKey(watchedProject), admin);
+    PushOneCommit.Result r =
+        pushFactory
+            .create(admin.newIdent(), watchedRepo, "subject", "a.txt", "a1")
+            .to("refs/for/master");
+    r.assertOkStatus();
+
+    // Assert email notification for user.
+    // The non-visible account participated in a change that is visible to user, hence through this
+    // change user can see the non-visible account.
+    // Even if watching by the non-visible account was not possible, user could just watch all
+    // changes that are visible to them and then filter them by the non-visible account locally.
+    List<Message> messages = sender.getMessages();
+    assertThat(messages).hasSize(1);
+    Message m = messages.get(0);
+    assertThat(m.rcpt()).containsExactly(user.getNameEmail());
+    assertThat(m.body()).contains("Change subject: subject\n");
+    assertThat(m.body()).contains("Gerrit-PatchSet: 1\n");
+    sender.clear();
+  }
+
+  @Test
+  public void watchChangesCommentedBySelf() throws Exception {
+    String watchedProject = projectOperations.newProject().create().get();
+    requestScopeOperations.setApiUser(user.id());
+
+    // user watches all changes that have a comment by themselves
+    watch(watchedProject, "commentby:self");
+
+    // pushing a change as admin should not trigger an email to user
+    requestScopeOperations.setApiUser(admin.id());
+    TestRepository<InMemoryRepository> watchedRepo =
+        cloneProject(Project.nameKey(watchedProject), admin);
+    PushOneCommit.Result r =
+        pushFactory
+            .create(admin.newIdent(), watchedRepo, "subject", "a.txt", "a1")
+            .to("refs/for/master");
+    r.assertOkStatus();
+    assertThat(sender.getMessages()).isEmpty();
+
+    // commenting by admin should not trigger an email to user
+    ReviewInput reviewInput = new ReviewInput();
+    reviewInput.message = "A Comment";
+    gApi.changes().id(r.getChangeId()).current().review(reviewInput);
+    assertThat(sender.getMessages()).isEmpty();
+
+    // commenting by user matches the project watch, but doesn't send an email to user because
+    // CC_ON_OWN_COMMENTS is false by default, so the user is removed from the TO list, but an email
+    // is sent to the admin user
+    requestScopeOperations.setApiUser(user.id());
+    gApi.changes().id(r.getChangeId()).current().review(reviewInput);
+    List<Message> messages = sender.getMessages();
+    assertThat(messages).hasSize(1);
+    Message m = messages.get(0);
+    assertThat(m.rcpt()).containsExactly(admin.getNameEmail());
+    assertThat(m.body()).contains("Change subject: subject\n");
+    assertThat(m.body()).contains("Gerrit-PatchSet: 1\n");
+    sender.clear();
+
+    // commenting by admin now triggers an email to user because the change has a comment by user
+    // and hence matches the project watch
+    requestScopeOperations.setApiUser(admin.id());
+    gApi.changes().id(r.getChangeId()).current().review(reviewInput);
+    messages = sender.getMessages();
+    assertThat(messages).hasSize(1);
+    m = messages.get(0);
+    assertThat(m.rcpt()).containsExactly(user.getNameEmail());
+    assertThat(m.body()).contains("Change subject: subject\n");
+    assertThat(m.body()).contains("Gerrit-PatchSet: 1\n");
+    sender.clear();
+  }
+
+  @Test
   public void watchAllProjects() throws Exception {
     String anyProject = projectOperations.newProject().create().get();
     requestScopeOperations.setApiUser(user.id());
diff --git a/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java b/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java
index d3c4949..9e27e93 100644
--- a/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java
@@ -493,6 +493,74 @@
         SubmitRequirementResult.Status.UNSATISFIED);
   }
 
+  @Test
+  public void byCommitterEmail() throws Exception {
+    TestAccount user2 =
+        accountCreator.create("Foo", "user@example.com", "User", /* displayName = */ null);
+    requestScopeOperations.setApiUser(user2.id());
+    ChangeInfo info =
+        gApi.changes().create(new ChangeInput(project.get(), "master", "Test Change")).get();
+    ChangeData cd =
+        changeQueryProvider
+            .get()
+            .byLegacyChangeId(Change.Id.tryParse(Integer.toString(info._number)).get())
+            .get(0);
+
+    // Match by email works
+    checkSubmitRequirementResult(
+        cd,
+        /* submittabilityExpr= */ "committeremail:\"^.*@example\\.com\"",
+        SubmitRequirementResult.Status.SATISFIED);
+    checkSubmitRequirementResult(
+        cd,
+        /* submittabilityExpr= */ "committeremail:\"^user@.*\\.com\"",
+        SubmitRequirementResult.Status.SATISFIED);
+
+    // Match by name does not work
+    checkSubmitRequirementResult(
+        cd,
+        /* submittabilityExpr= */ "committeremail:\"^Foo$\"",
+        SubmitRequirementResult.Status.UNSATISFIED);
+    checkSubmitRequirementResult(
+        cd,
+        /* submittabilityExpr= */ "committeremail:\"^User$\"",
+        SubmitRequirementResult.Status.UNSATISFIED);
+  }
+
+  @Test
+  public void byUploaderEmail() throws Exception {
+    TestAccount user2 =
+        accountCreator.create("Foo", "user@example.com", "User", /* displayName = */ null);
+    requestScopeOperations.setApiUser(user2.id());
+    ChangeInfo info =
+        gApi.changes().create(new ChangeInput(project.get(), "master", "Test Change")).get();
+    ChangeData cd =
+        changeQueryProvider
+            .get()
+            .byLegacyChangeId(Change.Id.tryParse(Integer.toString(info._number)).get())
+            .get(0);
+
+    // Match by email works
+    checkSubmitRequirementResult(
+        cd,
+        /* submittabilityExpr= */ "uploaderemail:\"^.*@example\\.com\"",
+        SubmitRequirementResult.Status.SATISFIED);
+    checkSubmitRequirementResult(
+        cd,
+        /* submittabilityExpr= */ "uploaderemail:\"^user@.*\\.com\"",
+        SubmitRequirementResult.Status.SATISFIED);
+
+    // Match by name does not work
+    checkSubmitRequirementResult(
+        cd,
+        /* submittabilityExpr= */ "uploaderemail:\"^Foo$\"",
+        SubmitRequirementResult.Status.UNSATISFIED);
+    checkSubmitRequirementResult(
+        cd,
+        /* submittabilityExpr= */ "uploaderemail:\"^User$\"",
+        SubmitRequirementResult.Status.UNSATISFIED);
+  }
+
   private void checkSubmitRequirementResult(
       ChangeData cd, String submittabilityExpr, SubmitRequirementResult.Status expectedStatus) {
     SubmitRequirement sr =
diff --git a/javatests/com/google/gerrit/acceptance/server/rules/RulesIT.java b/javatests/com/google/gerrit/acceptance/server/rules/RulesIT.java
index 4f93dd6..2938065 100644
--- a/javatests/com/google/gerrit/acceptance/server/rules/RulesIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/rules/RulesIT.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.acceptance.GitUtil.pushHead;
+import static com.google.gerrit.server.project.ProjectConfig.RULES_PL_FILE;
 
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
@@ -266,7 +267,7 @@
           .commit()
           .author(admin.newIdent())
           .committer(admin.newIdent())
-          .add("rules.pl", newContent)
+          .add(RULES_PL_FILE, newContent)
           .message("Modify rules.pl")
           .create();
     }
diff --git a/javatests/com/google/gerrit/acceptance/server/util/TaskListenerIT.java b/javatests/com/google/gerrit/acceptance/server/util/TaskListenerIT.java
new file mode 100644
index 0000000..fdfef87
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/util/TaskListenerIT.java
@@ -0,0 +1,285 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.server.util;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.extensions.annotations.Exports;
+import com.google.gerrit.server.git.WorkQueue;
+import com.google.gerrit.server.git.WorkQueue.Task;
+import com.google.gerrit.server.git.WorkQueue.TaskListener;
+import com.google.inject.AbstractModule;
+import com.google.inject.Inject;
+import com.google.inject.Module;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+import org.junit.Before;
+import org.junit.Test;
+
+public class TaskListenerIT extends AbstractDaemonTest {
+  /**
+   * Use a LatchedMethod in a method to allow another thread to await the method's call. Once
+   * called, the Latch.call() method will block until another thread calls its LatchedMethods's
+   * complete() method.
+   */
+  private static class LatchedMethod {
+    private static final int AWAIT_TIMEOUT = 20;
+    private static final TimeUnit AWAIT_TIMEUNIT = TimeUnit.MILLISECONDS;
+
+    /** API class meant be used by the class whose method is being latched */
+    private class Latch {
+      /** Ensure that the latched method calls this on entry */
+      public void call() {
+        called.countDown();
+        await(complete);
+      }
+    }
+
+    public Latch latch = new Latch();
+
+    private final CountDownLatch called = new CountDownLatch(1);
+    private final CountDownLatch complete = new CountDownLatch(1);
+
+    /** Assert that the Latch's call() method has not yet been called */
+    public void assertUncalled() {
+      assertThat(called.getCount()).isEqualTo(1);
+    }
+
+    /**
+     * Assert that a timeout does not occur while awaiting Latch's call() method to be called. Fails
+     * if the waiting time elapses before Latch's call() method is called, otherwise passes.
+     */
+    public void assertAwait() {
+      assertThat(await(called)).isEqualTo(true);
+    }
+
+    /** Unblock the Latch's call() method so that it can complete */
+    public void complete() {
+      complete.countDown();
+    }
+
+    @CanIgnoreReturnValue
+    private static boolean await(CountDownLatch latch) {
+      try {
+        return latch.await(AWAIT_TIMEOUT, AWAIT_TIMEUNIT);
+      } catch (InterruptedException e) {
+        return false;
+      }
+    }
+  }
+
+  private static class LatchedRunnable implements Runnable {
+    public LatchedMethod run = new LatchedMethod();
+
+    @Override
+    public void run() {
+      run.latch.call();
+    }
+  }
+
+  private static class ForwardingListener implements TaskListener {
+    public volatile TaskListener delegate;
+    public volatile Task<?> task;
+
+    public void resetDelegate(TaskListener listener) {
+      delegate = listener;
+      task = null;
+    }
+
+    @Override
+    public void onStart(Task<?> task) {
+      if (delegate != null) {
+        if (this.task == null || this.task == task) {
+          this.task = task;
+          delegate.onStart(task);
+        }
+      }
+    }
+
+    @Override
+    public void onStop(Task<?> task) {
+      if (delegate != null) {
+        if (this.task == task) {
+          delegate.onStop(task);
+        }
+      }
+    }
+  }
+
+  private static class LatchedListener implements TaskListener {
+    public LatchedMethod onStart = new LatchedMethod();
+    public LatchedMethod onStop = new LatchedMethod();
+
+    @Override
+    public void onStart(Task<?> task) {
+      onStart.latch.call();
+    }
+
+    @Override
+    public void onStop(Task<?> task) {
+      onStop.latch.call();
+    }
+  }
+
+  private static ForwardingListener forwarder;
+
+  @Inject private WorkQueue workQueue;
+  private ScheduledExecutorService executor;
+
+  private final LatchedListener listener = new LatchedListener();
+  private final LatchedRunnable runnable = new LatchedRunnable();
+
+  @Override
+  public Module createModule() {
+    return new AbstractModule() {
+      @Override
+      public void configure() {
+        // Forwarder.delegate is empty on start to protect test listener from non test tasks
+        // (such as the "Log File Compressor") interference
+        forwarder = new ForwardingListener(); // Only gets bound once for all tests
+        bind(TaskListener.class).annotatedWith(Exports.named("listener")).toInstance(forwarder);
+      }
+    };
+  }
+
+  @Before
+  public void setupExecutorAndForwarder() throws InterruptedException {
+    executor = workQueue.createQueue(1, "TaskListeners");
+
+    // "Log File Compressor"s are likely running and will interfere with tests
+    while (0 != workQueue.getTasks().size()) {
+      for (Task<?> t : workQueue.getTasks()) {
+        @SuppressWarnings("unused")
+        boolean unused = t.cancel(true);
+      }
+      TimeUnit.MILLISECONDS.sleep(1);
+    }
+
+    forwarder.resetDelegate(listener);
+
+    assertQueueSize(0);
+    assertThat(forwarder.task).isEqualTo(null);
+    listener.onStart.assertUncalled();
+    runnable.run.assertUncalled();
+    listener.onStop.assertUncalled();
+  }
+
+  @Test
+  public void onStartThenRunThenOnStopAreCalled() throws Exception {
+    int size = assertQueueBlockedOnExecution(runnable);
+
+    // onStartThenRunThenOnStopAreCalled -> onStart...Called
+    listener.onStart.assertAwait();
+    assertQueueSize(size);
+    runnable.run.assertUncalled();
+    listener.onStop.assertUncalled();
+
+    listener.onStart.complete();
+    // onStartThenRunThenOnStopAreCalled -> ...ThenRun...Called
+    runnable.run.assertAwait();
+    listener.onStop.assertUncalled();
+
+    runnable.run.complete();
+    // onStartThenRunThenOnStopAreCalled -> ...ThenOnStop...Called
+    listener.onStop.assertAwait();
+    assertQueueSize(size);
+
+    listener.onStop.complete();
+    assertAwaitQueueSize(--size);
+  }
+
+  @Test
+  public void firstBlocksSecond() throws Exception {
+    int size = assertQueueBlockedOnExecution(runnable);
+
+    // firstBlocksSecond -> first...
+    listener.onStart.assertAwait();
+    assertQueueSize(size);
+
+    LatchedRunnable runnable2 = new LatchedRunnable();
+    size = assertQueueBlockedOnExecution(runnable2);
+
+    // firstBlocksSecond -> ...BlocksSecond
+    runnable2.run.assertUncalled();
+    assertQueueSize(size); // waiting on first
+
+    listener.onStart.complete();
+    runnable.run.assertAwait();
+    assertQueueSize(size); // waiting on first
+    runnable2.run.assertUncalled();
+
+    runnable.run.complete();
+    listener.onStop.assertAwait();
+    assertQueueSize(size); // waiting on first
+    runnable2.run.assertUncalled();
+
+    listener.onStop.complete();
+    runnable2.run.assertAwait();
+    assertQueueSize(--size);
+
+    runnable2.run.complete();
+    assertAwaitQueueSize(--size);
+  }
+
+  @Test
+  public void states() throws Exception {
+    executor.execute(runnable);
+    listener.onStart.assertAwait();
+    assertStateIs(Task.State.STARTING);
+
+    listener.onStart.complete();
+    runnable.run.assertAwait();
+    assertStateIs(Task.State.RUNNING);
+
+    runnable.run.complete();
+    listener.onStop.assertAwait();
+    assertStateIs(Task.State.STOPPING);
+
+    listener.onStop.complete();
+    assertAwaitQueueIsEmpty();
+    assertStateIs(Task.State.DONE);
+  }
+
+  private void assertStateIs(Task.State state) {
+    assertThat(forwarder.task.getState()).isEqualTo(state);
+  }
+
+  private int assertQueueBlockedOnExecution(Runnable runnable) {
+    int expectedSize = workQueue.getTasks().size() + 1;
+    executor.execute(runnable);
+    assertQueueSize(expectedSize);
+    return expectedSize;
+  }
+
+  private void assertQueueSize(int size) {
+    assertThat(workQueue.getTasks().size()).isEqualTo(size);
+  }
+
+  private void assertAwaitQueueIsEmpty() throws InterruptedException {
+    assertAwaitQueueSize(0);
+  }
+
+  /** Fails if the waiting time elapses before the count is reached, otherwise passes */
+  private void assertAwaitQueueSize(int size) throws InterruptedException {
+    long i = 0;
+    do {
+      TimeUnit.NANOSECONDS.sleep(10);
+      assertThat(i++).isLessThan(100);
+    } while (size != workQueue.getTasks().size());
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/ssh/StreamEventsIT.java b/javatests/com/google/gerrit/acceptance/ssh/StreamEventsIT.java
index 80b8ff0..1b04e80 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/StreamEventsIT.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/StreamEventsIT.java
@@ -30,7 +30,9 @@
 import java.io.IOException;
 import java.io.Reader;
 import java.time.Duration;
+import java.util.Arrays;
 import java.util.List;
+import java.util.concurrent.atomic.AtomicInteger;
 import java.util.function.Supplier;
 import java.util.stream.Collectors;
 import java.util.stream.Stream;
@@ -85,10 +87,13 @@
   @Test
   public void batchRefsUpdatedShowSeparatelyInStreamEvents() throws Exception {
     String refName = createChange().getChange().currentPatchSet().refName();
+    AtomicInteger numberOfFoundEvents = new AtomicInteger(0);
     waitForEvent(
         () ->
-            pollEventsContaining("ref-updated", refName.substring(0, refName.lastIndexOf('/')))
-                    .size()
+            numberOfFoundEvents.addAndGet(
+                    pollEventsContaining(
+                            "ref-updated", refName.substring(0, refName.lastIndexOf('/')))
+                        .size())
                 == 2);
   }
 
@@ -121,8 +126,8 @@
       char[] cbuf = new char[2048];
       StringBuilder eventsOutput = new StringBuilder();
       while (streamEventsReader.ready()) {
-        streamEventsReader.read(cbuf);
-        eventsOutput.append(cbuf);
+        int read = streamEventsReader.read(cbuf);
+        eventsOutput.append(Arrays.copyOfRange(cbuf, 0, read));
       }
       return StreamSupport.stream(
               Splitter.on('\n').trimResults().split(eventsOutput.toString()).spliterator(), false)
diff --git a/javatests/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImplTest.java b/javatests/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImplTest.java
index 6c629c9..5bdf91f 100644
--- a/javatests/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImplTest.java
+++ b/javatests/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImplTest.java
@@ -24,11 +24,13 @@
 import static com.google.gerrit.truth.MapSubject.assertThatMap;
 import static com.google.gerrit.truth.OptionalSubject.assertThat;
 
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.truth.Correspondence;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
+import com.google.gerrit.acceptance.testsuite.account.TestAccount;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.entities.Account;
@@ -36,6 +38,7 @@
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.client.ChangeKind;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ChangeType;
@@ -49,6 +52,7 @@
 import com.google.inject.Inject;
 import java.util.Map;
 import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.PersonIdent;
 import org.junit.Test;
 
 public class ChangeOperationsImplTest extends AbstractDaemonTest {
@@ -145,6 +149,124 @@
   }
 
   @Test
+  public void createdChangeHasDefaultGroupsByDefault() throws Exception {
+    Project.NameKey project = projectOperations.newProject().branches("test-branch").create();
+    Change.Id changeId =
+        changeOperations.newChange().project(project).branch("test-branch").create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    assertThat(getGroups(project, changeId)).containsExactly(change.currentRevision);
+  }
+
+  @Test
+  public void createdChangeHasDefaultGroupsIfBranchTipIsSpecifiedAsParent() throws Exception {
+    Project.NameKey project = projectOperations.newProject().branches("test-branch").create();
+
+    Change.Id changeId =
+        changeOperations
+            .newChange()
+            .project(project)
+            .childOf()
+            .tipOfBranch("refs/heads/test-branch")
+            .create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    assertThat(getGroups(project, changeId)).containsExactly(change.currentRevision);
+  }
+
+  @Test
+  public void createdChangeHasSameGroupsAsOpenParentChange() throws Exception {
+    Project.NameKey project = projectOperations.newProject().create();
+
+    Change.Id parentChangeId = changeOperations.newChange().project(project).create();
+
+    ChangeInfo parentChange = getChangeFromServer(parentChangeId);
+    ImmutableList<String> parentGroups = getGroups(project, parentChangeId);
+    assertThat(parentGroups).containsExactly(parentChange.currentRevision);
+
+    Change.Id changeId =
+        changeOperations.newChange().project(project).childOf().change(parentChangeId).create();
+
+    assertThat(getGroups(project, changeId)).isEqualTo(parentGroups);
+  }
+
+  @Test
+  public void createdChangeHasDefaultGroupsIfClosedChangeIsSpecifiedAsParent() throws Exception {
+    Project.NameKey project = projectOperations.newProject().create();
+
+    Change.Id parentChangeId = changeOperations.newChange().project(project).create();
+    gApi.changes().id(parentChangeId.get()).current().review(ReviewInput.approve());
+    gApi.changes().id(parentChangeId.get()).current().submit();
+
+    Change.Id changeId =
+        changeOperations.newChange().project(project).childOf().change(parentChangeId).create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    assertThat(getGroups(project, changeId)).containsExactly(change.currentRevision);
+  }
+
+  @Test
+  public void createdChangeHasSameGroupsAsPatchSetOfOpenParentChange() throws Exception {
+    Project.NameKey project = projectOperations.newProject().create();
+
+    Change.Id parentChangeId = changeOperations.newChange().project(project).create();
+    TestPatchset parentPatchset = changeOperations.change(parentChangeId).currentPatchset().get();
+    changeOperations.change(parentChangeId).newPatchset().create();
+
+    ImmutableList<String> parentGroups = getGroups(project, parentPatchset.patchsetId());
+    assertThat(parentGroups).containsExactly(parentPatchset.commitId().name());
+
+    Change.Id changeId =
+        changeOperations
+            .newChange()
+            .project(project)
+            .childOf()
+            .patchset(parentPatchset.patchsetId())
+            .create();
+
+    assertThat(getGroups(project, changeId)).isEqualTo(parentGroups);
+  }
+
+  @Test
+  public void createdChangeHasDefaultGroupsIfPatchSetOfClosedChangeIsSpecifiedAsParent()
+      throws Exception {
+    Project.NameKey project = projectOperations.newProject().create();
+
+    Change.Id parentChangeId = changeOperations.newChange().project(project).create();
+    TestPatchset parentPatchset = changeOperations.change(parentChangeId).currentPatchset().get();
+    changeOperations.change(parentChangeId).newPatchset().create();
+    gApi.changes().id(parentChangeId.get()).current().review(ReviewInput.approve());
+    gApi.changes().id(parentChangeId.get()).current().submit();
+
+    Change.Id changeId =
+        changeOperations
+            .newChange()
+            .project(project)
+            .childOf()
+            .patchset(parentPatchset.patchsetId())
+            .create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    assertThat(getGroups(project, changeId)).containsExactly(change.currentRevision);
+  }
+
+  @Test
+  public void createdChangeHasDefaultGroupsIfCommitIsSpecifiedAsParent() throws Exception {
+    Project.NameKey project = projectOperations.newProject().create();
+
+    // Currently, the easiest way to create a commit is by creating another change.
+    Change.Id anotherChangeId = changeOperations.newChange().project(project).create();
+    ObjectId parentCommitId =
+        changeOperations.change(anotherChangeId).currentPatchset().get().commitId();
+
+    Change.Id changeId =
+        changeOperations.newChange().project(project).childOf().commit(parentCommitId).create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    assertThat(getGroups(project, changeId)).containsExactly(change.currentRevision);
+  }
+
+  @Test
   public void createdChangeUsesTipOfTargetBranchAsParentByDefault() throws Exception {
     Project.NameKey project = projectOperations.newProject().branches("test-branch").create();
     ObjectId parentCommitId = projectOperations.project(project).getHead("test-branch").getId();
@@ -611,6 +733,155 @@
   }
 
   @Test
+  public void createdChangeHasOwnerAsAuthor() throws Exception {
+    Change.Id changeId = changeOperations.newChange().create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    TestAccount changeOwner = accountOperations.account(Account.id(change.owner._accountId)).get();
+    RevisionInfo revision = change.revisions.get(change.currentRevision);
+    assertThat(revision.commit.author.name).isEqualTo(changeOwner.fullname().get());
+    assertThat(revision.commit.author.email).isEqualTo(changeOwner.preferredEmail().get());
+  }
+
+  @Test
+  public void createdChangeHasSpecifiedOwnerAsAuthor() throws Exception {
+    String changeOwnerName = "Change Owner";
+    String changeOwnerEmail = "change-owner@example.com";
+    Account.Id changeOwner =
+        accountOperations
+            .newAccount()
+            .fullname(changeOwnerName)
+            .preferredEmail(changeOwnerEmail)
+            .create();
+    Change.Id changeId = changeOperations.newChange().owner(changeOwner).create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    assertThat(change.owner._accountId).isEqualTo(changeOwner.get());
+    RevisionInfo revision = change.revisions.get(change.currentRevision);
+    assertThat(revision.commit.author.name).isEqualTo(changeOwnerName);
+    assertThat(revision.commit.author.email).isEqualTo(changeOwnerEmail);
+  }
+
+  @Test
+  public void createdChangeHasSpecifiedAuthor() throws Exception {
+    String authorName = "Author";
+    String authorEmail = "author@example.com";
+    Account.Id author =
+        accountOperations.newAccount().fullname(authorName).preferredEmail(authorEmail).create();
+    Change.Id changeId = changeOperations.newChange().author(author).create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    assertThat(change.owner._accountId).isNotEqualTo(author.get());
+    RevisionInfo revision = change.revisions.get(change.currentRevision);
+    assertThat(revision.commit.author.name).isEqualTo(authorName);
+    assertThat(revision.commit.author.email).isEqualTo(authorEmail);
+  }
+
+  @Test
+  public void createdChangeHasSpecifiedAuthorIdent() throws Exception {
+    PersonIdent authorIdent = new PersonIdent("Author", "author@example.com");
+    Change.Id changeId = changeOperations.newChange().authorIdent(authorIdent).create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    RevisionInfo revision = change.revisions.get(change.currentRevision);
+    assertThat(revision.commit.author.name).isEqualTo(authorIdent.getName());
+    assertThat(revision.commit.author.email).isEqualTo(authorIdent.getEmailAddress());
+  }
+
+  @Test
+  public void changeCannotBeCreatedWithAuthorAndAuthorIdent() throws Exception {
+    Account.Id author = accountOperations.newAccount().create();
+    PersonIdent authorIdent = new PersonIdent("Author", "author@example.com");
+
+    IllegalStateException exception =
+        assertThrows(
+            IllegalStateException.class,
+            () -> changeOperations.newChange().author(author).authorIdent(authorIdent).create());
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo("author and authorIdent cannot be set together");
+  }
+
+  @Test
+  public void createdChangeHasOwnerAsCommitter() throws Exception {
+    Change.Id changeId = changeOperations.newChange().create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    TestAccount changeOwner = accountOperations.account(Account.id(change.owner._accountId)).get();
+    RevisionInfo revision = change.revisions.get(change.currentRevision);
+    assertThat(revision.commit.committer.name).isEqualTo(changeOwner.fullname().get());
+    assertThat(revision.commit.committer.email).isEqualTo(changeOwner.preferredEmail().get());
+  }
+
+  @Test
+  public void createdChangeHasSpecifiedOwnerAsCommitter() throws Exception {
+    String changeOwnerName = "Change Owner";
+    String changeOwnerEmail = "change-owner@example.com";
+    Account.Id changeOwner =
+        accountOperations
+            .newAccount()
+            .fullname(changeOwnerName)
+            .preferredEmail(changeOwnerEmail)
+            .create();
+    Change.Id changeId = changeOperations.newChange().owner(changeOwner).create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    assertThat(change.owner._accountId).isEqualTo(changeOwner.get());
+    RevisionInfo revision = change.revisions.get(change.currentRevision);
+    assertThat(revision.commit.committer.name).isEqualTo(changeOwnerName);
+    assertThat(revision.commit.committer.email).isEqualTo(changeOwnerEmail);
+  }
+
+  @Test
+  public void createdChangeHasSpecifiedCommitter() throws Exception {
+    String committerName = "Committer";
+    String committerEmail = "committer@example.com";
+    Account.Id committer =
+        accountOperations
+            .newAccount()
+            .fullname(committerName)
+            .preferredEmail(committerEmail)
+            .create();
+    Change.Id changeId = changeOperations.newChange().committer(committer).create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    assertThat(change.owner._accountId).isNotEqualTo(committer.get());
+    RevisionInfo revision = change.revisions.get(change.currentRevision);
+    assertThat(revision.commit.committer.name).isEqualTo(committerName);
+    assertThat(revision.commit.committer.email).isEqualTo(committerEmail);
+  }
+
+  @Test
+  public void createdChangeHasSpecifiedCommitterIdent() throws Exception {
+    PersonIdent committerIdent = new PersonIdent("Committer", "committer@example.com");
+    Change.Id changeId = changeOperations.newChange().committerIdent(committerIdent).create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    RevisionInfo revision = change.revisions.get(change.currentRevision);
+    assertThat(revision.commit.committer.name).isEqualTo(committerIdent.getName());
+    assertThat(revision.commit.committer.email).isEqualTo(committerIdent.getEmailAddress());
+  }
+
+  @Test
+  public void changeCannotBeCreatedWithCommitterAndCommitterIdent() throws Exception {
+    Account.Id committer = accountOperations.newAccount().create();
+    PersonIdent committerIdent = new PersonIdent("Committer", "committer@example.com");
+
+    IllegalStateException exception =
+        assertThrows(
+            IllegalStateException.class,
+            () ->
+                changeOperations
+                    .newChange()
+                    .committer(committer)
+                    .committerIdent(committerIdent)
+                    .create());
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo("committer and committerIdent cannot be set together");
+  }
+
+  @Test
   public void createdChangeHasSpecifiedTopic() throws Exception {
     Change.Id changeId = changeOperations.newChange().topic("test-topic").create();
 
@@ -795,6 +1066,173 @@
   }
 
   @Test
+  public void newPatchsetCanHaveDifferentUploader() throws Exception {
+    Account.Id changeOwner = accountOperations.newAccount().create();
+    Change.Id changeId = changeOperations.newChange().owner(changeOwner).create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    RevisionInfo currentPatchsetRevision = change.revisions.get(change.currentRevision);
+    assertThat(currentPatchsetRevision.uploader._accountId).isEqualTo(changeOwner.get());
+
+    Account.Id newUploader = accountOperations.newAccount().create();
+    changeOperations.change(changeId).newPatchset().uploader(newUploader).create();
+
+    change = getChangeFromServer(changeId);
+    currentPatchsetRevision = change.revisions.get(change.currentRevision);
+    assertThat(currentPatchsetRevision.uploader._accountId).isEqualTo(newUploader.get());
+  }
+
+  @Test
+  public void createdPatchsetPreviousAuthorAsAuthor() throws Exception {
+    String authorName = "Author";
+    String authorEmail = "author@example.com";
+    Account.Id author =
+        accountOperations.newAccount().fullname(authorName).preferredEmail(authorEmail).create();
+    Change.Id changeId = changeOperations.newChange().author(author).create();
+    ChangeInfo change = getChangeFromServer(changeId);
+    RevisionInfo revision = change.revisions.get(change.currentRevision);
+    assertThat(revision.commit.author.name).isEqualTo(authorName);
+    assertThat(revision.commit.author.email).isEqualTo(authorEmail);
+
+    changeOperations.change(changeId).newPatchset().create();
+    change = getChangeFromServer(changeId);
+    revision = change.revisions.get(change.currentRevision);
+    assertThat(revision.commit.author.name).isEqualTo(authorName);
+    assertThat(revision.commit.author.email).isEqualTo(authorEmail);
+  }
+
+  @Test
+  public void createdPatchsetHasSpecifiedAuthor() throws Exception {
+    Change.Id changeId = changeOperations.newChange().create();
+
+    String authorName = "Author";
+    String authorEmail = "author@example.com";
+    Account.Id author =
+        accountOperations.newAccount().fullname(authorName).preferredEmail(authorEmail).create();
+    changeOperations.change(changeId).newPatchset().author(author).create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    assertThat(change.owner._accountId).isNotEqualTo(author.get());
+    RevisionInfo revision = change.revisions.get(change.currentRevision);
+    assertThat(revision.commit.author.name).isEqualTo(authorName);
+    assertThat(revision.commit.author.email).isEqualTo(authorEmail);
+  }
+
+  @Test
+  public void createdPatchsetHasSpecifiedAuthorIdent() throws Exception {
+    Change.Id changeId = changeOperations.newChange().create();
+
+    PersonIdent authorIdent = new PersonIdent("Author", "author@example.com");
+    changeOperations.change(changeId).newPatchset().authorIdent(authorIdent).create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    RevisionInfo revision = change.revisions.get(change.currentRevision);
+    assertThat(revision.commit.author.name).isEqualTo(authorIdent.getName());
+    assertThat(revision.commit.author.email).isEqualTo(authorIdent.getEmailAddress());
+  }
+
+  @Test
+  public void patchsetCannotBeCreatedWithAuthorAndAuthorIdent() throws Exception {
+    Change.Id changeId = changeOperations.newChange().create();
+
+    Account.Id author = accountOperations.newAccount().create();
+    PersonIdent authorIdent = new PersonIdent("Author", "author@example.com");
+
+    IllegalStateException exception =
+        assertThrows(
+            IllegalStateException.class,
+            () ->
+                changeOperations
+                    .change(changeId)
+                    .newPatchset()
+                    .author(author)
+                    .authorIdent(authorIdent)
+                    .create());
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo("author and authorIdent cannot be set together");
+  }
+
+  @Test
+  public void createdPatchsetPreviousCommitterAsCommitter() throws Exception {
+    String committerName = "Committer";
+    String committerEmail = "committer@example.com";
+    Account.Id committer =
+        accountOperations
+            .newAccount()
+            .fullname(committerName)
+            .preferredEmail(committerEmail)
+            .create();
+    Change.Id changeId = changeOperations.newChange().committer(committer).create();
+    ChangeInfo change = getChangeFromServer(changeId);
+    RevisionInfo revision = change.revisions.get(change.currentRevision);
+    assertThat(revision.commit.committer.name).isEqualTo(committerName);
+    assertThat(revision.commit.committer.email).isEqualTo(committerEmail);
+
+    changeOperations.change(changeId).newPatchset().create();
+    change = getChangeFromServer(changeId);
+    revision = change.revisions.get(change.currentRevision);
+    assertThat(revision.commit.committer.name).isEqualTo(committerName);
+    assertThat(revision.commit.committer.email).isEqualTo(committerEmail);
+  }
+
+  @Test
+  public void createdPatchsetHasSpecifiedCommitter() throws Exception {
+    Change.Id changeId = changeOperations.newChange().create();
+
+    String committerName = "Committer";
+    String committerEmail = "committer@example.com";
+    Account.Id committer =
+        accountOperations
+            .newAccount()
+            .fullname(committerName)
+            .preferredEmail(committerEmail)
+            .create();
+    changeOperations.change(changeId).newPatchset().committer(committer).create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    assertThat(change.owner._accountId).isNotEqualTo(committer.get());
+    RevisionInfo revision = change.revisions.get(change.currentRevision);
+    assertThat(revision.commit.committer.name).isEqualTo(committerName);
+    assertThat(revision.commit.committer.email).isEqualTo(committerEmail);
+  }
+
+  @Test
+  public void createdPatchsetHasSpecifiedCommitterIdent() throws Exception {
+    Change.Id changeId = changeOperations.newChange().create();
+
+    PersonIdent committerIdent = new PersonIdent("Committer", "committer@example.com");
+    changeOperations.change(changeId).newPatchset().committerIdent(committerIdent).create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    RevisionInfo revision = change.revisions.get(change.currentRevision);
+    assertThat(revision.commit.committer.name).isEqualTo(committerIdent.getName());
+    assertThat(revision.commit.committer.email).isEqualTo(committerIdent.getEmailAddress());
+  }
+
+  @Test
+  public void patchsetCannotBeCreatedWithCommitterAndCommitterIdent() throws Exception {
+    Change.Id changeId = changeOperations.newChange().create();
+
+    Account.Id committer = accountOperations.newAccount().create();
+    PersonIdent committerIdent = new PersonIdent("Committer", "committer@example.com");
+
+    IllegalStateException exception =
+        assertThrows(
+            IllegalStateException.class,
+            () ->
+                changeOperations
+                    .change(changeId)
+                    .newPatchset()
+                    .committer(committer)
+                    .committerIdent(committerIdent)
+                    .create());
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo("committer and committerIdent cannot be set together");
+  }
+
+  @Test
   public void newPatchsetCanHaveUpdatedCommitMessage() throws Exception {
     Change.Id changeId = changeOperations.newChange().commitMessage("Old message").create();
 
@@ -1316,6 +1754,17 @@
     return gApi.changes().id(changeId.get()).revision(patchsetId.get()).file(filePath).content();
   }
 
+  private ImmutableList<String> getGroups(Project.NameKey projectName, Change.Id changeId) {
+    return changeDataFactory.create(projectName, changeId).currentPatchSet().groups();
+  }
+
+  private ImmutableList<String> getGroups(Project.NameKey projectName, PatchSet.Id patchSetId) {
+    return changeDataFactory
+        .create(projectName, patchSetId.changeId())
+        .patchSet(patchSetId)
+        .groups();
+  }
+
   private Correspondence<CommitInfo, String> hasSha1() {
     return NullAwareCorrespondence.transforming(commitInfo -> commitInfo.commit, "hasSha1");
   }
diff --git a/javatests/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImplTest.java b/javatests/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImplTest.java
index 7543ba8..661802e 100644
--- a/javatests/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImplTest.java
+++ b/javatests/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImplTest.java
@@ -18,11 +18,14 @@
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowCapability;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabelRemoval;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.blockLabel;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.blockLabelRemoval;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.capabilityKey;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.deny;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.labelPermissionKey;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.labelRemovalPermissionKey;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.permissionKey;
 import static com.google.gerrit.common.data.GlobalCapability.ADMINISTRATE_SERVER;
 import static com.google.gerrit.common.data.GlobalCapability.DEFAULT_MAX_QUERY_LIMIT;
@@ -160,7 +163,8 @@
     Project.NameKey key = projectOperations.newProject().create();
     Config config = projectOperations.project(key).getConfig();
     assertThat(config).isNotInstanceOf(StoredConfig.class);
-    assertThat(config).text().isEmpty();
+    assertThat(config).sections().containsExactly("submit");
+    assertThat(config).sectionValues("submit").containsExactly("action", "inherit");
 
     ConfigInput input = new ConfigInput();
     input.description = "my fancy project";
@@ -168,7 +172,7 @@
 
     config = projectOperations.project(key).getConfig();
     assertThat(config).isNotInstanceOf(StoredConfig.class);
-    assertThat(config).sections().containsExactly("project");
+    assertThat(config).sections().containsExactly("project", "submit");
     assertThat(config).subsections("project").isEmpty();
     assertThat(config).sectionValues("project").containsExactly("description", "my fancy project");
   }
@@ -193,7 +197,7 @@
         .update();
 
     Config config = projectOperations.project(key).getConfig();
-    assertThat(config).sections().containsExactly("access");
+    assertThat(config).sections().containsExactly("access", "submit");
     assertThat(config).subsections("access").containsExactly("refs/foo");
     assertThat(config)
         .subsectionValues("access", "refs/foo")
@@ -210,7 +214,7 @@
         .update();
 
     Config config = projectOperations.project(key).getConfig();
-    assertThat(config).sections().containsExactly("access");
+    assertThat(config).sections().containsExactly("access", "submit");
     assertThat(config).subsections("access").containsExactly("refs/foo");
     assertThat(config)
         .subsectionValues("access", "refs/foo")
@@ -227,7 +231,7 @@
         .update();
 
     Config config = projectOperations.project(key).getConfig();
-    assertThat(config).sections().containsExactly("access");
+    assertThat(config).sections().containsExactly("access", "submit");
     assertThat(config).subsections("access").containsExactly("refs/foo");
     assertThat(config)
         .subsectionValues("access", "refs/foo")
@@ -244,7 +248,7 @@
         .update();
 
     Config config = projectOperations.project(key).getConfig();
-    assertThat(config).sections().containsExactly("access");
+    assertThat(config).sections().containsExactly("access", "submit");
     assertThat(config).subsections("access").containsExactly("refs/foo");
     assertThat(config)
         .subsectionValues("access", "refs/foo")
@@ -262,7 +266,7 @@
         .update();
 
     Config config = projectOperations.project(key).getConfig();
-    assertThat(config).sections().containsExactly("access");
+    assertThat(config).sections().containsExactly("access", "submit");
     assertThat(config).subsections("access").containsExactly("refs/foo");
     assertThat(config)
         .subsectionValues("access", "refs/foo")
@@ -277,7 +281,7 @@
         .update();
 
     config = projectOperations.project(key).getConfig();
-    assertThat(config).sections().containsExactly("access");
+    assertThat(config).sections().containsExactly("access", "submit");
     assertThat(config).subsections("access").containsExactly("refs/foo");
     assertThat(config)
         .subsectionValues("access", "refs/foo")
@@ -318,7 +322,7 @@
         .update();
 
     Config config = projectOperations.project(key).getConfig();
-    assertThat(config).sections().containsExactly("access");
+    assertThat(config).sections().containsExactly("access", "submit");
     assertThat(config).subsections("access").containsExactly("refs/foo");
     assertThat(config)
         .subsectionValues("access", "refs/foo")
@@ -328,31 +332,28 @@
   }
 
   @Test
-  public void addDuplicatePermissions() throws Exception {
+  public void addDuplicatePermissions_isIgnored() throws Exception {
     TestPermission permission =
         TestProjectUpdate.allow(Permission.ABANDON).ref("refs/foo").group(REGISTERED_USERS).build();
     Project.NameKey key = projectOperations.newProject().create();
     projectOperations.project(key).forUpdate().add(permission).add(permission).update();
 
     Config config = projectOperations.project(key).getConfig();
-    assertThat(config).sections().containsExactly("access");
+    assertThat(config).sections().contains("access");
     assertThat(config).subsections("access").containsExactly("refs/foo");
     assertThat(config)
         .subsectionValues("access", "refs/foo")
-        .containsExactly(
-            "abandon", "group global:Registered-Users",
-            "abandon", "group global:Registered-Users");
+        // Duplicated permission was recorded only once
+        .containsExactly("abandon", "group global:Registered-Users");
 
     projectOperations.project(key).forUpdate().add(permission).update();
     config = projectOperations.project(key).getConfig();
-    assertThat(config).sections().containsExactly("access");
+    assertThat(config).sections().containsExactly("access", "submit");
     assertThat(config).subsections("access").containsExactly("refs/foo");
     assertThat(config)
         .subsectionValues("access", "refs/foo")
-        .containsExactly(
-            "abandon", "group global:Registered-Users",
-            "abandon", "group global:Registered-Users",
-            "abandon", "group global:Registered-Users");
+        // Duplicated permission in request was dropped
+        .containsExactly("abandon", "group global:Registered-Users");
   }
 
   @Test
@@ -365,7 +366,7 @@
         .update();
 
     Config config = projectOperations.project(key).getConfig();
-    assertThat(config).sections().containsExactly("access");
+    assertThat(config).sections().containsExactly("access", "submit");
     assertThat(config).subsections("access").containsExactly("refs/foo");
     assertThat(config)
         .subsectionValues("access", "refs/foo")
@@ -382,7 +383,7 @@
         .update();
 
     Config config = projectOperations.project(key).getConfig();
-    assertThat(config).sections().containsExactly("access");
+    assertThat(config).sections().containsExactly("access", "submit");
     assertThat(config).subsections("access").containsExactly("refs/foo");
     assertThat(config)
         .subsectionValues("access", "refs/foo")
@@ -400,7 +401,7 @@
         .update();
 
     Config config = projectOperations.project(key).getConfig();
-    assertThat(config).sections().containsExactly("access");
+    assertThat(config).sections().containsExactly("access", "submit");
     assertThat(config).subsections("access").containsExactly("refs/foo");
     assertThat(config)
         .subsectionValues("access", "refs/foo")
@@ -415,7 +416,7 @@
         .update();
 
     config = projectOperations.project(key).getConfig();
-    assertThat(config).sections().containsExactly("access");
+    assertThat(config).sections().containsExactly("access", "submit");
     assertThat(config).subsections("access").containsExactly("refs/foo");
     assertThat(config)
         .subsectionValues("access", "refs/foo")
@@ -437,7 +438,7 @@
         .update();
 
     Config config = projectOperations.project(key).getConfig();
-    assertThat(config).sections().containsExactly("access");
+    assertThat(config).sections().containsExactly("access", "submit");
     assertThat(config).subsections("access").containsExactly("refs/foo");
     assertThat(config)
         .subsectionValues("access", "refs/foo")
@@ -445,6 +446,73 @@
   }
 
   @Test
+  public void addAllowLabelRemovalPermission() throws Exception {
+    Project.NameKey key = projectOperations.newProject().create();
+    projectOperations
+        .project(key)
+        .forUpdate()
+        .add(allowLabelRemoval("Code-Review").ref("refs/foo").group(REGISTERED_USERS).range(-1, 2))
+        .update();
+
+    Config config = projectOperations.project(key).getConfig();
+    assertThat(config).sections().containsExactly("access", "submit");
+    assertThat(config).subsections("access").containsExactly("refs/foo");
+    assertThat(config)
+        .subsectionValues("access", "refs/foo")
+        .containsExactly("removeLabel-Code-Review", "-1..+2 group global:Registered-Users");
+  }
+
+  @Test
+  public void addBlockLabelRemovalPermission() throws Exception {
+    Project.NameKey key = projectOperations.newProject().create();
+    projectOperations
+        .project(key)
+        .forUpdate()
+        .add(blockLabelRemoval("Code-Review").ref("refs/foo").group(REGISTERED_USERS).range(-1, 2))
+        .update();
+
+    Config config = projectOperations.project(key).getConfig();
+    assertThat(config).sections().containsExactly("access", "submit");
+    assertThat(config).subsections("access").containsExactly("refs/foo");
+    assertThat(config)
+        .subsectionValues("access", "refs/foo")
+        .containsExactly("removeLabel-Code-Review", "block -1..+2 group global:Registered-Users");
+  }
+
+  @Test
+  public void addAllowExclusiveLabelRemovalPermission() throws Exception {
+    Project.NameKey key = projectOperations.newProject().create();
+    projectOperations
+        .project(key)
+        .forUpdate()
+        .add(allowLabelRemoval("Code-Review").ref("refs/foo").group(REGISTERED_USERS).range(-1, 2))
+        .setExclusiveGroup(labelRemovalPermissionKey("Code-Review").ref("refs/foo"), true)
+        .update();
+
+    Config config = projectOperations.project(key).getConfig();
+    assertThat(config).sections().containsExactly("access", "submit");
+    assertThat(config).subsections("access").containsExactly("refs/foo");
+    assertThat(config)
+        .subsectionValues("access", "refs/foo")
+        .containsExactly(
+            "removeLabel-Code-Review", "-1..+2 group global:Registered-Users",
+            "exclusiveGroupPermissions", "removeLabel-Code-Review");
+
+    projectOperations
+        .project(key)
+        .forUpdate()
+        .setExclusiveGroup(labelRemovalPermissionKey("Code-Review").ref("refs/foo"), false)
+        .update();
+
+    config = projectOperations.project(key).getConfig();
+    assertThat(config).sections().containsExactly("access", "submit");
+    assertThat(config).subsections("access").containsExactly("refs/foo");
+    assertThat(config)
+        .subsectionValues("access", "refs/foo")
+        .containsExactly("removeLabel-Code-Review", "-1..+2 group global:Registered-Users");
+  }
+
+  @Test
   public void addAllowCapability() throws Exception {
     Config config = projectOperations.project(allProjects).getConfig();
     assertThat(config)
@@ -542,6 +610,31 @@
   }
 
   @Test
+  public void removeLabelRemovalPermission() throws Exception {
+    Project.NameKey key = projectOperations.newProject().create();
+    projectOperations
+        .project(key)
+        .forUpdate()
+        .add(allowLabelRemoval("Code-Review").ref("refs/foo").group(REGISTERED_USERS).range(-1, 2))
+        .add(allowLabelRemoval("Code-Review").ref("refs/foo").group(PROJECT_OWNERS).range(-2, 1))
+        .update();
+    assertThat(projectOperations.project(key).getConfig())
+        .subsectionValues("access", "refs/foo")
+        .containsExactly(
+            "removeLabel-Code-Review", "-1..+2 group global:Registered-Users",
+            "removeLabel-Code-Review", "-2..+1 group global:Project-Owners");
+
+    projectOperations
+        .project(key)
+        .forUpdate()
+        .remove(labelRemovalPermissionKey("Code-Review").ref("refs/foo").group(REGISTERED_USERS))
+        .update();
+    assertThat(projectOperations.project(key).getConfig())
+        .subsectionValues("access", "refs/foo")
+        .containsExactly("removeLabel-Code-Review", "-2..+1 group global:Project-Owners");
+  }
+
+  @Test
   public void removeCapability() throws Exception {
     projectOperations
         .allProjectsForUpdate()
diff --git a/javatests/com/google/gerrit/common/data/BUILD b/javatests/com/google/gerrit/common/data/BUILD
index f2b7d63..154fd89 100644
--- a/javatests/com/google/gerrit/common/data/BUILD
+++ b/javatests/com/google/gerrit/common/data/BUILD
@@ -4,6 +4,7 @@
     name = "data_tests",
     srcs = glob(["*.java"]),
     deps = [
+        "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/common:server",
         "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/testing:gerrit-test-util",
diff --git a/javatests/com/google/gerrit/common/data/GroupReferenceTest.java b/javatests/com/google/gerrit/common/data/GroupReferenceTest.java
index 477f9d2..ba4b586 100644
--- a/javatests/com/google/gerrit/common/data/GroupReferenceTest.java
+++ b/javatests/com/google/gerrit/common/data/GroupReferenceTest.java
@@ -17,6 +17,7 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.AccountGroup.UUID;
 import com.google.gerrit.entities.GroupDescription;
@@ -33,6 +34,7 @@
             new GroupDescription.Basic() {
 
               @Override
+              @Nullable
               public String getUrl() {
                 return null;
               }
@@ -48,6 +50,7 @@
               }
 
               @Override
+              @Nullable
               public String getEmailAddress() {
                 return null;
               }
diff --git a/javatests/com/google/gerrit/entities/PermissionTest.java b/javatests/com/google/gerrit/entities/PermissionTest.java
index 3175671..d25d833 100644
--- a/javatests/com/google/gerrit/entities/PermissionTest.java
+++ b/javatests/com/google/gerrit/entities/PermissionTest.java
@@ -36,6 +36,7 @@
 
     assertThat(Permission.isPermission(Permission.LABEL + LabelId.CODE_REVIEW)).isTrue();
     assertThat(Permission.isPermission(Permission.LABEL_AS + LabelId.CODE_REVIEW)).isTrue();
+    assertThat(Permission.isPermission(Permission.REMOVE_LABEL + LabelId.CODE_REVIEW)).isTrue();
     assertThat(Permission.isPermission(LabelId.CODE_REVIEW)).isFalse();
   }
 
@@ -56,6 +57,7 @@
 
     assertThat(Permission.isLabel(Permission.LABEL + LabelId.CODE_REVIEW)).isTrue();
     assertThat(Permission.isLabel(Permission.LABEL_AS + LabelId.CODE_REVIEW)).isFalse();
+    assertThat(Permission.isLabel(Permission.REMOVE_LABEL + LabelId.CODE_REVIEW)).isFalse();
     assertThat(Permission.isLabel(LabelId.CODE_REVIEW)).isFalse();
   }
 
@@ -66,10 +68,22 @@
 
     assertThat(Permission.isLabelAs(Permission.LABEL + LabelId.CODE_REVIEW)).isFalse();
     assertThat(Permission.isLabelAs(Permission.LABEL_AS + LabelId.CODE_REVIEW)).isTrue();
+    assertThat(Permission.isLabelAs(Permission.REMOVE_LABEL + LabelId.CODE_REVIEW)).isFalse();
     assertThat(Permission.isLabelAs(LabelId.CODE_REVIEW)).isFalse();
   }
 
   @Test
+  public void isRemoveLabel() {
+    assertThat(Permission.isRemoveLabel(Permission.ABANDON)).isFalse();
+    assertThat(Permission.isRemoveLabel("no-permission")).isFalse();
+
+    assertThat(Permission.isRemoveLabel(Permission.LABEL + LabelId.CODE_REVIEW)).isFalse();
+    assertThat(Permission.isRemoveLabel(Permission.LABEL_AS + LabelId.CODE_REVIEW)).isFalse();
+    assertThat(Permission.isRemoveLabel(Permission.REMOVE_LABEL + LabelId.CODE_REVIEW)).isTrue();
+    assertThat(Permission.isRemoveLabel(LabelId.CODE_REVIEW)).isFalse();
+  }
+
+  @Test
   public void forLabel() {
     assertThat(Permission.forLabel(LabelId.CODE_REVIEW))
         .isEqualTo(Permission.LABEL + LabelId.CODE_REVIEW);
@@ -82,11 +96,19 @@
   }
 
   @Test
+  public void forRemoveLabel() {
+    assertThat(Permission.forRemoveLabel(LabelId.CODE_REVIEW))
+        .isEqualTo(Permission.REMOVE_LABEL + LabelId.CODE_REVIEW);
+  }
+
+  @Test
   public void extractLabel() {
     assertThat(Permission.extractLabel(Permission.LABEL + LabelId.CODE_REVIEW))
         .isEqualTo(LabelId.CODE_REVIEW);
     assertThat(Permission.extractLabel(Permission.LABEL_AS + LabelId.CODE_REVIEW))
         .isEqualTo(LabelId.CODE_REVIEW);
+    assertThat(Permission.extractLabel(Permission.REMOVE_LABEL + LabelId.CODE_REVIEW))
+        .isEqualTo(LabelId.CODE_REVIEW);
     assertThat(Permission.extractLabel(LabelId.CODE_REVIEW)).isNull();
     assertThat(Permission.extractLabel(Permission.ABANDON)).isNull();
   }
@@ -103,6 +125,10 @@
             Permission.canBeOnAllProjects(
                 AccessSection.ALL, Permission.LABEL_AS + LabelId.CODE_REVIEW))
         .isTrue();
+    assertThat(
+            Permission.canBeOnAllProjects(
+                AccessSection.ALL, Permission.REMOVE_LABEL + LabelId.CODE_REVIEW))
+        .isTrue();
 
     assertThat(Permission.canBeOnAllProjects("refs/heads/*", Permission.ABANDON)).isTrue();
     assertThat(Permission.canBeOnAllProjects("refs/heads/*", Permission.OWNER)).isTrue();
@@ -113,6 +139,10 @@
             Permission.canBeOnAllProjects(
                 "refs/heads/*", Permission.LABEL_AS + LabelId.CODE_REVIEW))
         .isTrue();
+    assertThat(
+            Permission.canBeOnAllProjects(
+                "refs/heads/*", Permission.REMOVE_LABEL + LabelId.CODE_REVIEW))
+        .isTrue();
   }
 
   @Test
@@ -126,6 +156,8 @@
         .isEqualTo(LabelId.CODE_REVIEW);
     assertThat(Permission.create(Permission.LABEL_AS + LabelId.CODE_REVIEW).getLabel())
         .isEqualTo(LabelId.CODE_REVIEW);
+    assertThat(Permission.create(Permission.REMOVE_LABEL + LabelId.CODE_REVIEW).getLabel())
+        .isEqualTo(LabelId.CODE_REVIEW);
     assertThat(Permission.create(LabelId.CODE_REVIEW).getLabel()).isNull();
     assertThat(Permission.create(Permission.ABANDON).getLabel()).isNull();
   }
diff --git a/javatests/com/google/gerrit/entities/converter/ChangeProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/ChangeProtoConverterTest.java
index bd4b2b1..bbf10bd 100644
--- a/javatests/com/google/gerrit/entities/converter/ChangeProtoConverterTest.java
+++ b/javatests/com/google/gerrit/entities/converter/ChangeProtoConverterTest.java
@@ -48,7 +48,6 @@
         PatchSet.id(Change.id(14), 23), "subject XYZ", "original subject ABC");
     change.setTopic("my topic");
     change.setSubmissionId("submission ID 234");
-    change.setAssignee(Account.id(100001));
     change.setPrivate(true);
     change.setWorkInProgress(true);
     change.setReviewStarted(true);
@@ -73,7 +72,6 @@
             .setTopic("my topic")
             .setOriginalSubject("original subject ABC")
             .setSubmissionId("submission ID 234")
-            .setAssignee(Entities.Account_Id.newBuilder().setId(100001))
             .setIsPrivate(true)
             .setWorkInProgress(true)
             .setReviewStarted(true)
@@ -205,7 +203,6 @@
         PatchSet.id(Change.id(14), 23), "subject XYZ", "original subject ABC");
     change.setTopic("my topic");
     change.setSubmissionId("submission ID 234");
-    change.setAssignee(Account.id(100001));
     change.setPrivate(true);
     change.setWorkInProgress(true);
     change.setReviewStarted(true);
@@ -289,7 +286,6 @@
                 .put("topic", String.class)
                 .put("originalSubject", String.class)
                 .put("submissionId", String.class)
-                .put("assignee", Account.Id.class)
                 .put("isPrivate", boolean.class)
                 .put("workInProgress", boolean.class)
                 .put("reviewStarted", boolean.class)
@@ -313,7 +309,6 @@
     assertThat(change.getTopic()).isEqualTo(expectedChange.getTopic());
     assertThat(change.getOriginalSubject()).isEqualTo(expectedChange.getOriginalSubject());
     assertThat(change.getSubmissionId()).isEqualTo(expectedChange.getSubmissionId());
-    assertThat(change.getAssignee()).isEqualTo(expectedChange.getAssignee());
     assertThat(change.isPrivate()).isEqualTo(expectedChange.isPrivate());
     assertThat(change.isWorkInProgress()).isEqualTo(expectedChange.isWorkInProgress());
     assertThat(change.hasReviewStarted()).isEqualTo(expectedChange.hasReviewStarted());
diff --git a/javatests/com/google/gerrit/entities/converter/PatchSetProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/PatchSetProtoConverterTest.java
index 3a534e9..447b625 100644
--- a/javatests/com/google/gerrit/entities/converter/PatchSetProtoConverterTest.java
+++ b/javatests/com/google/gerrit/entities/converter/PatchSetProtoConverterTest.java
@@ -42,6 +42,7 @@
             .id(PatchSet.id(Change.id(103), 73))
             .commitId(ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"))
             .uploader(Account.id(452))
+            .realUploader(Account.id(687))
             .createdOn(Instant.ofEpochMilli(930349320L))
             .groups(ImmutableList.of("group1", " group2"))
             .pushCertificate("my push certificate")
@@ -59,6 +60,7 @@
             .setCommitId(
                 Entities.ObjectId.newBuilder().setName("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"))
             .setUploaderAccountId(Entities.Account_Id.newBuilder().setId(452))
+            .setRealUploaderAccountId(Entities.Account_Id.newBuilder().setId(687))
             .setCreatedOn(930349320L)
             .setGroups("group1, group2")
             .setPushCertificate("my push certificate")
@@ -74,6 +76,7 @@
             .id(PatchSet.id(Change.id(103), 73))
             .commitId(ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"))
             .uploader(Account.id(452))
+            .realUploader(Account.id(687))
             .createdOn(Instant.ofEpochMilli(930349320L))
             .build();
 
@@ -88,6 +91,7 @@
             .setCommitId(
                 Entities.ObjectId.newBuilder().setName("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"))
             .setUploaderAccountId(Entities.Account_Id.newBuilder().setId(452))
+            .setRealUploaderAccountId(Entities.Account_Id.newBuilder().setId(687))
             .setCreatedOn(930349320L)
             .build();
     assertThat(proto).isEqualTo(expectedProto);
@@ -100,6 +104,7 @@
             .id(PatchSet.id(Change.id(103), 73))
             .commitId(ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"))
             .uploader(Account.id(452))
+            .realUploader(Account.id(687))
             .createdOn(Instant.ofEpochMilli(930349320L))
             .groups(ImmutableList.of("group1", " group2"))
             .pushCertificate("my push certificate")
@@ -118,6 +123,7 @@
             .id(PatchSet.id(Change.id(103), 73))
             .commitId(ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"))
             .uploader(Account.id(452))
+            .realUploader(Account.id(687))
             .createdOn(Instant.ofEpochMilli(930349320L))
             .build();
 
@@ -143,6 +149,30 @@
                 .id(PatchSet.id(Change.id(103), 73))
                 .commitId(ObjectId.fromString("0000000000000000000000000000000000000000"))
                 .uploader(Account.id(0))
+                .realUploader(Account.id(0))
+                .createdOn(Instant.EPOCH)
+                .build());
+  }
+
+  @Test
+  public void realUploaderIsSetToUploaderIfMissingFromProto() {
+    Entities.PatchSet proto =
+        Entities.PatchSet.newBuilder()
+            .setId(
+                Entities.PatchSet_Id.newBuilder()
+                    .setChangeId(Entities.Change_Id.newBuilder().setId(103))
+                    .setId(73))
+            .setUploaderAccountId(Entities.Account_Id.newBuilder().setId(452))
+            .build();
+
+    PatchSet convertedPatchSet = patchSetProtoConverter.fromProto(proto);
+    Truth.assertThat(convertedPatchSet)
+        .isEqualTo(
+            PatchSet.builder()
+                .id(PatchSet.id(Change.id(103), 73))
+                .commitId(ObjectId.fromString("0000000000000000000000000000000000000000"))
+                .uploader(Account.id(452))
+                .realUploader(Account.id(452))
                 .createdOn(Instant.EPOCH)
                 .build());
   }
@@ -156,6 +186,7 @@
                 .put("id", PatchSet.Id.class)
                 .put("commitId", ObjectId.class)
                 .put("uploader", Account.Id.class)
+                .put("realUploader", Account.Id.class)
                 .put("createdOn", Instant.class)
                 .put("groups", new TypeLiteral<ImmutableList<String>>() {}.getType())
                 .put("pushCertificate", new TypeLiteral<Optional<String>>() {}.getType())
diff --git a/javatests/com/google/gerrit/extensions/BUILD b/javatests/com/google/gerrit/extensions/BUILD
index 2202a11..1bb39c8 100644
--- a/javatests/com/google/gerrit/extensions/BUILD
+++ b/javatests/com/google/gerrit/extensions/BUILD
@@ -5,6 +5,7 @@
     size = "small",
     srcs = glob(["**/*.java"]),
     deps = [
+        "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/extensions/common/testing:common-test-util",
         "//lib:guava",
diff --git a/javatests/com/google/gerrit/extensions/common/ChangeInfoDifferTest.java b/javatests/com/google/gerrit/extensions/common/ChangeInfoDifferTest.java
index 024e35e..3704969 100644
--- a/javatests/com/google/gerrit/extensions/common/ChangeInfoDifferTest.java
+++ b/javatests/com/google/gerrit/extensions/common/ChangeInfoDifferTest.java
@@ -18,6 +18,7 @@
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.client.ReviewerState;
 import java.lang.reflect.Field;
 import java.lang.reflect.ParameterizedType;
@@ -47,6 +48,7 @@
     assertThat(diff.added().messages).isNull();
     assertThat(diff.added().reviewers).isNull();
     assertThat(diff.added().hashtags).isNull();
+    assertThat(diff.added().removableLabels).isNull();
     assertThat(diff.removed()._number).isNull();
     assertThat(diff.removed().branch).isNull();
     assertThat(diff.removed().project).isNull();
@@ -55,6 +57,7 @@
     assertThat(diff.removed().messages).isNull();
     assertThat(diff.removed().reviewers).isNull();
     assertThat(diff.removed().hashtags).isNull();
+    assertThat(diff.removed().removableLabels).isNull();
   }
 
   @Test
@@ -91,61 +94,6 @@
   }
 
   @Test
-  public void getDiff_givenEqualAssignees_returnsNullAssignee() {
-    ChangeInfo oldChangeInfo =
-        createChangeInfoWithAccount(new AccountInfo("name", "mail@mail.com"));
-    ChangeInfo newChangeInfo =
-        createChangeInfoWithAccount(
-            new AccountInfo(oldChangeInfo.assignee.name, oldChangeInfo.assignee.email));
-
-    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
-
-    assertThat(diff.added().assignee).isNull();
-    assertThat(diff.removed().assignee).isNull();
-  }
-
-  @Test
-  public void getDiff_givenNewAssignee_returnsAssignee() {
-    ChangeInfo oldChangeInfo = new ChangeInfo();
-    ChangeInfo newChangeInfo =
-        createChangeInfoWithAccount(new AccountInfo("name", "mail@mail.com"));
-
-    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
-
-    assertThat(diff.added().assignee).isEqualTo(newChangeInfo.assignee);
-    assertThat(diff.removed().assignee).isNull();
-  }
-
-  @Test
-  public void getDiff_withRemovedAssignee_returnsAssignee() {
-    ChangeInfo oldChangeInfo =
-        createChangeInfoWithAccount(new AccountInfo("name", "mail@mail.com"));
-    ChangeInfo newChangeInfo = new ChangeInfo();
-
-    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
-
-    assertThat(diff.added().assignee).isNull();
-    assertThat(diff.removed().assignee).isEqualTo(oldChangeInfo.assignee);
-  }
-
-  @Test
-  public void getDiff_givenAssigneeWithNewName_returnsNameButNotEmail() {
-    ChangeInfo oldChangeInfo =
-        createChangeInfoWithAccount(new AccountInfo("old name", "mail@mail.com"));
-    ChangeInfo newChangeInfo =
-        createChangeInfoWithAccount(new AccountInfo("new name", oldChangeInfo.assignee.email));
-
-    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
-
-    assertThat(diff.added().assignee).isNotNull();
-    assertThat(diff.added().assignee.name).isEqualTo(newChangeInfo.assignee.name);
-    assertThat(diff.added().assignee.email).isNull();
-    assertThat(diff.removed().assignee).isNotNull();
-    assertThat(diff.removed().assignee.name).isEqualTo(oldChangeInfo.assignee.name);
-    assertThat(diff.removed().assignee.email).isNull();
-  }
-
-  @Test
   public void getDiff_whenHashtagsChanged_returnsHashtags() {
     String removedHashtag = "removed";
     String addedHashtag = "added";
@@ -291,6 +239,7 @@
     assertThat(diff.added().revisions.get(REVISION).uploader.name).isNull();
     assertThat(diff.added().revisions.get(REVISION).uploader.email)
         .isEqualTo(newRevision.uploader.email);
+    assertThat(diff.added().revisions.get(REVISION).realUploader).isNull();
     assertThat(diff.removed().revisions).isNotNull();
     assertThat(diff.removed().revisions).hasSize(1);
     assertThat(diff.removed().revisions).containsKey(REVISION);
@@ -298,6 +247,37 @@
     assertThat(diff.removed().revisions.get(REVISION).uploader.name).isNull();
     assertThat(diff.removed().revisions.get(REVISION).uploader.email)
         .isEqualTo(oldRevision.uploader.email);
+    assertThat(diff.removed().revisions.get(REVISION).realUploader).isNull();
+  }
+
+  @Test
+  public void getDiff_whenOneModifiedRevisionUploader_returnsModificationsToRevisionRealUploader() {
+    RevisionInfo oldRevision = new RevisionInfo(new AccountInfo("uploader", "uploader@mail.com"));
+    oldRevision.realUploader = new AccountInfo("real-uploader", "real-uploader@mail.com");
+    RevisionInfo newRevision = new RevisionInfo(oldRevision.uploader);
+    newRevision.realUploader =
+        new AccountInfo(oldRevision.realUploader.name, oldRevision.realUploader.email + "2");
+    ChangeInfo oldChangeInfo = new ChangeInfo(ImmutableMap.of(REVISION, oldRevision));
+    ChangeInfo newChangeInfo = new ChangeInfo(ImmutableMap.of(REVISION, newRevision));
+
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+    assertThat(diff.added().revisions).isNotNull();
+    assertThat(diff.added().revisions).hasSize(1);
+    assertThat(diff.added().revisions).containsKey(REVISION);
+    assertThat(diff.added().revisions.get(REVISION).uploader).isNull();
+    assertThat(diff.added().revisions.get(REVISION).realUploader).isNotNull();
+    assertThat(diff.added().revisions.get(REVISION).realUploader.name).isNull();
+    assertThat(diff.added().revisions.get(REVISION).realUploader.email)
+        .isEqualTo(newRevision.realUploader.email);
+    assertThat(diff.removed().revisions).isNotNull();
+    assertThat(diff.removed().revisions).hasSize(1);
+    assertThat(diff.removed().revisions).containsKey(REVISION);
+    assertThat(diff.removed().revisions.get(REVISION).uploader).isNull();
+    assertThat(diff.removed().revisions.get(REVISION).realUploader).isNotNull();
+    assertThat(diff.removed().revisions.get(REVISION).realUploader.name).isNull();
+    assertThat(diff.removed().revisions.get(REVISION).realUploader.email)
+        .isEqualTo(oldRevision.realUploader.email);
   }
 
   @Test
@@ -314,6 +294,295 @@
   }
 
   @Test
+  public void getDiff_removableLabelsEmpty_returnsNullRemovableLabels() {
+    ChangeInfo oldChangeInfo = new ChangeInfo();
+    ChangeInfo newChangeInfo = new ChangeInfo();
+    oldChangeInfo.removableLabels = ImmutableMap.of();
+    newChangeInfo.removableLabels = ImmutableMap.of();
+
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+    assertThat(diff.added().removableLabels).isNull();
+    assertThat(diff.removed().removableLabels).isNull();
+  }
+
+  @Test
+  public void getDiff_removableLabelsNullAndEmpty_returnsEmptyRemovableLabels() {
+    ChangeInfo oldChangeInfo = new ChangeInfo();
+    ChangeInfo newChangeInfo = new ChangeInfo();
+    newChangeInfo.removableLabels = ImmutableMap.of();
+
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+    assertThat(diff.added().removableLabels).isEmpty();
+    assertThat(diff.removed().removableLabels).isNull();
+  }
+
+  @Test
+  public void getDiff_removableLabelsEmptyAndNull_returnsEmptyRemovableLabels() {
+    ChangeInfo oldChangeInfo = new ChangeInfo();
+    ChangeInfo newChangeInfo = new ChangeInfo();
+    oldChangeInfo.removableLabels = ImmutableMap.of();
+
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+    assertThat(diff.added().removableLabels).isNull();
+    assertThat(diff.removed().removableLabels).isEmpty();
+  }
+
+  @Test
+  public void getDiff_removableLabelsLabelAdded() {
+    ChangeInfo oldChangeInfo = new ChangeInfo();
+    ChangeInfo newChangeInfo = new ChangeInfo();
+    AccountInfo acc1 = new AccountInfo();
+    acc1.name = "Cow";
+    AccountInfo acc2 = new AccountInfo();
+    acc2.name = "Pig";
+    AccountInfo acc3 = new AccountInfo();
+    acc3.name = "Cat";
+    AccountInfo acc4 = new AccountInfo();
+    acc4.name = "Dog";
+
+    oldChangeInfo.removableLabels =
+        ImmutableMap.of(
+            "Code-Review",
+            ImmutableMap.of("+1", ImmutableList.of(acc1), "-1", ImmutableList.of(acc2, acc3)));
+    newChangeInfo.removableLabels =
+        ImmutableMap.of(
+            "Code-Review",
+            ImmutableMap.of("+1", ImmutableList.of(acc1), "-1", ImmutableList.of(acc2, acc3)),
+            "Verified",
+            ImmutableMap.of("-1", ImmutableList.of(acc4)));
+
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+    assertThat(diff.added().removableLabels)
+        .containsExactlyEntriesIn(
+            ImmutableMap.of("Verified", ImmutableMap.of("-1", ImmutableList.of(acc4))));
+    assertThat(diff.removed().removableLabels).isNull();
+  }
+
+  @Test
+  public void getDiff_removableLabelsLabelRemoved() {
+    ChangeInfo oldChangeInfo = new ChangeInfo();
+    ChangeInfo newChangeInfo = new ChangeInfo();
+    AccountInfo acc1 = new AccountInfo();
+    acc1.name = "Cow";
+    AccountInfo acc2 = new AccountInfo();
+    acc2.name = "Pig";
+    AccountInfo acc3 = new AccountInfo();
+    acc3.name = "Cat";
+    AccountInfo acc4 = new AccountInfo();
+    acc4.name = "Dog";
+
+    oldChangeInfo.removableLabels =
+        ImmutableMap.of(
+            "Code-Review",
+            ImmutableMap.of("+1", ImmutableList.of(acc1), "-1", ImmutableList.of(acc2, acc3)),
+            "Verified",
+            ImmutableMap.of("-1", ImmutableList.of(acc4)));
+    newChangeInfo.removableLabels =
+        ImmutableMap.of(
+            "Code-Review",
+            ImmutableMap.of("+1", ImmutableList.of(acc1), "-1", ImmutableList.of(acc2, acc3)));
+
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+    assertThat(diff.added().removableLabels).isNull();
+    assertThat(diff.removed().removableLabels)
+        .containsExactlyEntriesIn(
+            ImmutableMap.of("Verified", ImmutableMap.of("-1", ImmutableList.of(acc4))));
+  }
+
+  @Test
+  public void getDiff_removableLabelsVoteAdded() {
+    ChangeInfo oldChangeInfo = new ChangeInfo();
+    ChangeInfo newChangeInfo = new ChangeInfo();
+    AccountInfo acc1 = new AccountInfo();
+    acc1.name = "acc1";
+    AccountInfo acc2 = new AccountInfo();
+    acc2.name = "acc2";
+    AccountInfo acc3 = new AccountInfo();
+    acc3.name = "acc3";
+
+    oldChangeInfo.removableLabels =
+        ImmutableMap.of("Code-Review", ImmutableMap.of("+1", ImmutableList.of(acc1)));
+    newChangeInfo.removableLabels =
+        ImmutableMap.of(
+            "Code-Review",
+            ImmutableMap.of("+1", ImmutableList.of(acc1), "-1", ImmutableList.of(acc2, acc3)));
+
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+    assertThat(diff.added().removableLabels)
+        .containsExactlyEntriesIn(
+            ImmutableMap.of("Code-Review", ImmutableMap.of("-1", ImmutableList.of(acc2, acc3))));
+    assertThat(diff.removed().removableLabels).isNull();
+  }
+
+  @Test
+  public void getDiff_removableLabelsVoteRemoved() {
+    ChangeInfo oldChangeInfo = new ChangeInfo();
+    ChangeInfo newChangeInfo = new ChangeInfo();
+    AccountInfo acc1 = new AccountInfo();
+    acc1.name = "acc1";
+    AccountInfo acc2 = new AccountInfo();
+    acc2.name = "acc2";
+    AccountInfo acc3 = new AccountInfo();
+    acc3.name = "acc3";
+
+    oldChangeInfo.removableLabels =
+        ImmutableMap.of(
+            "Code-Review",
+            ImmutableMap.of("+1", ImmutableList.of(acc1), "-1", ImmutableList.of(acc2, acc3)));
+    newChangeInfo.removableLabels =
+        ImmutableMap.of("Code-Review", ImmutableMap.of("+1", ImmutableList.of(acc1)));
+
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+    assertThat(diff.added().removableLabels).isNull();
+    assertThat(diff.removed().removableLabels)
+        .containsExactlyEntriesIn(
+            ImmutableMap.of("Code-Review", ImmutableMap.of("-1", ImmutableList.of(acc2, acc3))));
+  }
+
+  @Test
+  public void getDiff_removableLabelsAccountAdded() {
+    ChangeInfo oldChangeInfo = new ChangeInfo();
+    ChangeInfo newChangeInfo = new ChangeInfo();
+    AccountInfo acc1 = new AccountInfo();
+    acc1.name = "acc1";
+    AccountInfo acc2 = new AccountInfo();
+    acc2.name = "acc2";
+
+    oldChangeInfo.removableLabels =
+        ImmutableMap.of("Code-Review", ImmutableMap.of("+1", ImmutableList.of(acc1)));
+    newChangeInfo.removableLabels =
+        ImmutableMap.of("Code-Review", ImmutableMap.of("+1", ImmutableList.of(acc1, acc2)));
+
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+    assertThat(diff.added().removableLabels)
+        .containsExactlyEntriesIn(
+            ImmutableMap.of("Code-Review", ImmutableMap.of("+1", ImmutableList.of(acc2))));
+    assertThat(diff.removed().removableLabels).isNull();
+  }
+
+  @Test
+  public void getDiff_removableLabelsAccountRemoved() {
+    ChangeInfo oldChangeInfo = new ChangeInfo();
+    ChangeInfo newChangeInfo = new ChangeInfo();
+    AccountInfo acc1 = new AccountInfo();
+    acc1.name = "acc1";
+    AccountInfo acc2 = new AccountInfo();
+    acc2.name = "acc2";
+
+    oldChangeInfo.removableLabels =
+        ImmutableMap.of("Code-Review", ImmutableMap.of("+1", ImmutableList.of(acc1)));
+    newChangeInfo.removableLabels =
+        ImmutableMap.of("Code-Review", ImmutableMap.of("+1", ImmutableList.of(acc1, acc2)));
+
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+    assertThat(diff.added().removableLabels)
+        .containsExactlyEntriesIn(
+            ImmutableMap.of("Code-Review", ImmutableMap.of("+1", ImmutableList.of(acc2))));
+    assertThat(diff.removed().removableLabels).isNull();
+  }
+
+  @Test
+  public void getDiff_removableLabelsAccountChanged() {
+    ChangeInfo oldChangeInfo = new ChangeInfo();
+    ChangeInfo newChangeInfo = new ChangeInfo();
+    AccountInfo acc1 = new AccountInfo();
+    acc1.name = "acc1";
+    AccountInfo acc2 = new AccountInfo();
+    acc2.name = "acc2";
+
+    oldChangeInfo.removableLabels =
+        ImmutableMap.of("Code-Review", ImmutableMap.of("+1", ImmutableList.of(acc1)));
+    newChangeInfo.removableLabels =
+        ImmutableMap.of("Code-Review", ImmutableMap.of("+1", ImmutableList.of(acc2)));
+
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+    assertThat(diff.added().removableLabels)
+        .containsExactlyEntriesIn(
+            ImmutableMap.of("Code-Review", ImmutableMap.of("+1", ImmutableList.of(acc2))));
+    assertThat(diff.removed().removableLabels)
+        .containsExactlyEntriesIn(
+            ImmutableMap.of("Code-Review", ImmutableMap.of("+1", ImmutableList.of(acc1))));
+  }
+
+  @Test
+  public void getDiff_removableLabelsScoreChanged() {
+    ChangeInfo oldChangeInfo = new ChangeInfo();
+    ChangeInfo newChangeInfo = new ChangeInfo();
+    AccountInfo acc1 = new AccountInfo();
+    acc1.name = "acc1";
+
+    oldChangeInfo.removableLabels =
+        ImmutableMap.of("Code-Review", ImmutableMap.of("+1", ImmutableList.of(acc1)));
+    newChangeInfo.removableLabels =
+        ImmutableMap.of("Code-Review", ImmutableMap.of("-1", ImmutableList.of(acc1)));
+
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+    assertThat(diff.added().removableLabels)
+        .containsExactlyEntriesIn(
+            ImmutableMap.of("Code-Review", ImmutableMap.of("-1", ImmutableList.of(acc1))));
+    assertThat(diff.removed().removableLabels)
+        .containsExactlyEntriesIn(
+            ImmutableMap.of("Code-Review", ImmutableMap.of("+1", ImmutableList.of(acc1))));
+  }
+
+  @Test
+  public void getDiff_removableLabelsLabelChanged() {
+    ChangeInfo oldChangeInfo = new ChangeInfo();
+    ChangeInfo newChangeInfo = new ChangeInfo();
+    AccountInfo acc1 = new AccountInfo();
+    acc1.name = "acc1";
+
+    oldChangeInfo.removableLabels =
+        ImmutableMap.of("Code-Review", ImmutableMap.of("+1", ImmutableList.of(acc1)));
+    newChangeInfo.removableLabels =
+        ImmutableMap.of("Verified", ImmutableMap.of("+1", ImmutableList.of(acc1)));
+
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+    assertThat(diff.added().removableLabels)
+        .containsExactlyEntriesIn(
+            ImmutableMap.of("Verified", ImmutableMap.of("+1", ImmutableList.of(acc1))));
+    assertThat(diff.removed().removableLabels)
+        .containsExactlyEntriesIn(
+            ImmutableMap.of("Code-Review", ImmutableMap.of("+1", ImmutableList.of(acc1))));
+  }
+
+  @Test
+  public void getDiff_removableLabelsLabelScoreAndAccountChanged() {
+    ChangeInfo oldChangeInfo = new ChangeInfo();
+    ChangeInfo newChangeInfo = new ChangeInfo();
+    AccountInfo acc1 = new AccountInfo();
+    acc1.name = "acc1";
+    AccountInfo acc2 = new AccountInfo();
+    acc2.name = "acc2";
+
+    oldChangeInfo.removableLabels =
+        ImmutableMap.of("Code-Review", ImmutableMap.of("+1", ImmutableList.of(acc1)));
+    newChangeInfo.removableLabels =
+        ImmutableMap.of("Verified", ImmutableMap.of("-1", ImmutableList.of(acc2)));
+
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+    assertThat(diff.added().removableLabels)
+        .containsExactlyEntriesIn(
+            ImmutableMap.of("Verified", ImmutableMap.of("-1", ImmutableList.of(acc2))));
+    assertThat(diff.removed().removableLabels)
+        .containsExactlyEntriesIn(
+            ImmutableMap.of("Code-Review", ImmutableMap.of("+1", ImmutableList.of(acc1))));
+  }
+
+  @Test
   public void getDiff_assertCanConstructAllChangeInfoReferences() throws Exception {
     buildObjectWithFullFields(ChangeInfo.class);
   }
@@ -344,6 +613,7 @@
     assertThat(diff.removed().reviewers).isNull();
   }
 
+  @Nullable
   private static Object buildObjectWithFullFields(Class<?> c) throws Exception {
     if (c == null) {
       return null;
@@ -365,6 +635,7 @@
     return toPopulate;
   }
 
+  @Nullable
   private static Class<?> getParameterizedType(Field field) {
     if (!Collection.class.isAssignableFrom(field.getType())) {
       return null;
@@ -382,12 +653,6 @@
     return changeInfo;
   }
 
-  private static ChangeInfo createChangeInfoWithAccount(AccountInfo accountInfo) {
-    ChangeInfo changeInfo = new ChangeInfo();
-    changeInfo.assignee = accountInfo;
-    return changeInfo;
-  }
-
   private static ChangeInfo createChangeInfoWithHashtags(String... hashtags) {
     ChangeInfo changeInfo = new ChangeInfo();
     changeInfo.hashtags = ImmutableList.copyOf(hashtags);
diff --git a/javatests/com/google/gerrit/extensions/restapi/IdStringTest.java b/javatests/com/google/gerrit/extensions/restapi/IdStringTest.java
new file mode 100644
index 0000000..3a92864
--- /dev/null
+++ b/javatests/com/google/gerrit/extensions/restapi/IdStringTest.java
@@ -0,0 +1,28 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.restapi;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import org.junit.Test;
+
+/** Unit tests for {@link IdString}. */
+public class IdStringTest {
+  @Test
+  public void decodeStringWithPercentageThatIsNotFollowedByTwoHexadecimalDigits() throws Exception {
+    String s = "<%=FOO%>";
+    assertThat(IdString.fromUrl(s).get()).isEqualTo(s);
+  }
+}
diff --git a/javatests/com/google/gerrit/gpg/PublicKeyStoreTest.java b/javatests/com/google/gerrit/gpg/PublicKeyStoreTest.java
index 3727d38..a01807a 100644
--- a/javatests/com/google/gerrit/gpg/PublicKeyStoreTest.java
+++ b/javatests/com/google/gerrit/gpg/PublicKeyStoreTest.java
@@ -33,6 +33,7 @@
 import java.util.Arrays;
 import java.util.Iterator;
 import java.util.List;
+import java.util.Locale;
 import java.util.Set;
 import java.util.TreeSet;
 import org.bouncycastle.openpgp.PGPPublicKey;
@@ -80,7 +81,7 @@
     PGPPublicKey key = validKeyWithoutExpiration().getPublicKey();
     String objId = keyObjectId(key.getKeyID()).name();
     assertEquals("ed0625dc46328a8c000000000000000000000000", objId);
-    assertEquals(keyIdToString(key.getKeyID()).toLowerCase(), objId.substring(8, 16));
+    assertEquals(keyIdToString(key.getKeyID()).toLowerCase(Locale.US), objId.substring(8, 16));
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/httpd/BUILD b/javatests/com/google/gerrit/httpd/BUILD
index 121cbc4..a69d60f 100644
--- a/javatests/com/google/gerrit/httpd/BUILD
+++ b/javatests/com/google/gerrit/httpd/BUILD
@@ -21,6 +21,7 @@
         "//lib/bouncycastle:bcprov",
         "//lib/guice",
         "//lib/guice:guice-servlet",
+        "//lib/jsoup",
         "//lib/mockito",
         "//lib/truth",
         "//lib/truth:truth-java8-extension",
diff --git a/javatests/com/google/gerrit/httpd/ProjectBasicAuthFilterTest.java b/javatests/com/google/gerrit/httpd/ProjectBasicAuthFilterTest.java
index 71695f3..04f9827 100644
--- a/javatests/com/google/gerrit/httpd/ProjectBasicAuthFilterTest.java
+++ b/javatests/com/google/gerrit/httpd/ProjectBasicAuthFilterTest.java
@@ -45,6 +45,7 @@
 import java.nio.charset.StandardCharsets;
 import java.time.Instant;
 import java.util.Base64;
+import java.util.Collection;
 import java.util.Optional;
 import javax.servlet.FilterChain;
 import javax.servlet.ServletException;
@@ -72,8 +73,6 @@
 
   @Mock private AccountCache accountCache;
 
-  @Mock private AccountState accountState;
-
   @Mock private AccountManager accountManager;
 
   @Mock private AuthConfig authConfig;
@@ -105,12 +104,8 @@
     authRequestFactory = new AuthRequest.Factory(extIdKeyFactory);
     pwdVerifier = new PasswordVerifier(extIdKeyFactory, authConfig);
 
-    Account account = Account.builder(Account.id(1000000), Instant.now()).build();
     authSuccessful =
         new AuthResult(AUTH_ACCOUNT_ID, extIdKeyFactory.create("username", AUTH_USER), false);
-    doReturn(Optional.of(accountState)).when(accountCache).getByUsername(AUTH_USER);
-    doReturn(Optional.of(accountState)).when(accountCache).get(AUTH_ACCOUNT_ID);
-    doReturn(account).when(accountState).account();
     doReturn(authSuccessful).when(accountManager).authenticate(any());
 
     doReturn(new WebSessionManager.Key(AUTH_COOKIE_VALUE)).when(webSessionManager).createKey(any());
@@ -123,6 +118,7 @@
 
   @Test
   public void shouldAllowAnonymousRequest() throws Exception {
+    initAccount();
     initMockedWebSession();
     res.setStatus(HttpServletResponse.SC_OK);
 
@@ -143,6 +139,7 @@
 
   @Test
   public void shouldRequestAuthenticationForBasicAuthRequest() throws Exception {
+    initAccount();
     initMockedWebSession();
     req.addHeader("Authorization", "Basic " + AUTH_USER_B64);
     res.setStatus(HttpServletResponse.SC_OK);
@@ -165,6 +162,7 @@
 
   @Test
   public void shouldAuthenticateSucessfullyAgainstRealmAndReturnCookie() throws Exception {
+    initAccount();
     initWebSessionWithoutCookie();
     requestBasicAuth(req);
     res.setStatus(HttpServletResponse.SC_OK);
@@ -191,9 +189,10 @@
 
   @Test
   public void shouldValidateUserPasswordAndNotReturnCookie() throws Exception {
+    ExternalId extId = createUsernamePasswordExternalId();
+    initAccount(ImmutableSet.of(extId));
     initWebSessionWithoutCookie();
     requestBasicAuth(req);
-    initMockedUsernamePasswordExternalId();
     doReturn(GitBasicAuthPolicy.HTTP).when(authConfig).getGitBasicAuthPolicy();
     res.setStatus(HttpServletResponse.SC_OK);
 
@@ -217,6 +216,7 @@
 
   @Test
   public void shouldNotReauthenticateForGitPostRequest() throws Exception {
+    initAccount();
     req.setPathInfo("/a/project.git/git-upload-pack");
     req.setMethod("POST");
     req.addHeader("Content-Type", "application/x-git-upload-pack-request");
@@ -229,6 +229,7 @@
 
   @Test
   public void shouldReauthenticateForRegularRequestEvenIfAlreadySignedIn() throws Exception {
+    initAccount();
     doReturn(GitBasicAuthPolicy.LDAP).when(authConfig).getGitBasicAuthPolicy();
     doFilterForRequestWhenAlreadySignedIn();
 
@@ -239,6 +240,7 @@
 
   @Test
   public void shouldReauthenticateEvenIfHasExistingCookie() throws Exception {
+    initAccount();
     initWebSessionWithCookie("GerritAccount=" + AUTH_COOKIE_VALUE);
     res.setStatus(HttpServletResponse.SC_OK);
     requestBasicAuth(req);
@@ -262,6 +264,7 @@
 
   @Test
   public void shouldFailedAuthenticationAgainstRealm() throws Exception {
+    initAccount();
     initMockedWebSession();
     requestBasicAuth(req);
 
@@ -285,6 +288,17 @@
     assertThat(res.getStatus()).isEqualTo(HttpServletResponse.SC_UNAUTHORIZED);
   }
 
+  private void initAccount() throws Exception {
+    initAccount(ImmutableSet.of());
+  }
+
+  private void initAccount(Collection<ExternalId> extIds) throws Exception {
+    Account account = Account.builder(Account.id(1000000), Instant.now()).build();
+    AccountState accountState = AccountState.forAccount(account, extIds);
+    doReturn(Optional.of(accountState)).when(accountCache).getByUsername(AUTH_USER);
+    doReturn(Optional.of(accountState)).when(accountCache).get(AUTH_ACCOUNT_ID);
+  }
+
   private void doFilterForRequestWhenAlreadySignedIn()
       throws IOException, ServletException, AccountException {
     initMockedWebSession();
@@ -322,14 +336,12 @@
     doReturn(webSession).when(webSessionItem).get();
   }
 
-  private void initMockedUsernamePasswordExternalId() {
-    ExternalId extId =
-        extIdFactory.createWithPassword(
-            extIdKeyFactory.create(ExternalId.SCHEME_USERNAME, AUTH_USER),
-            AUTH_ACCOUNT_ID,
-            null,
-            AUTH_PASSWORD);
-    doReturn(ImmutableSet.builder().add(extId).build()).when(accountState).externalIds();
+  private ExternalId createUsernamePasswordExternalId() {
+    return extIdFactory.createWithPassword(
+        extIdKeyFactory.create(ExternalId.SCHEME_USERNAME, AUTH_USER),
+        AUTH_ACCOUNT_ID,
+        null,
+        AUTH_PASSWORD);
   }
 
   private void requestBasicAuth(FakeHttpServletRequest fakeReq) {
diff --git a/javatests/com/google/gerrit/httpd/raw/DocServletTest.java b/javatests/com/google/gerrit/httpd/raw/DocServletTest.java
new file mode 100644
index 0000000..2f09aa2
--- /dev/null
+++ b/javatests/com/google/gerrit/httpd/raw/DocServletTest.java
@@ -0,0 +1,167 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.httpd.raw;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.server.experiments.ExperimentFeaturesConstants.GERRIT_BACKEND_FEATURE_ATTACH_NONCE_TO_DOCUMENTATION;
+import static org.mockito.Mockito.when;
+
+import com.google.common.base.CharMatcher;
+import com.google.common.cache.CacheBuilder;
+import com.google.common.jimfs.Configuration;
+import com.google.common.jimfs.Jimfs;
+import com.google.gerrit.server.experiments.ExperimentFeatures;
+import com.google.gerrit.util.http.testutil.FakeHttpServletRequest;
+import com.google.gerrit.util.http.testutil.FakeHttpServletResponse;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.FileSystem;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import org.jsoup.Jsoup;
+import org.jsoup.nodes.Document;
+import org.jsoup.nodes.Element;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnit;
+import org.mockito.junit.MockitoRule;
+
+@RunWith(JUnit4.class)
+public class DocServletTest {
+
+  @Rule public final MockitoRule mockito = MockitoJUnit.rule();
+
+  @Mock private ExperimentFeatures experimentFeatures;
+  private FileSystem fs = Jimfs.newFileSystem(Configuration.unix());
+  private DocServlet docServlet;
+
+  @Before
+  public void setUp() throws Exception {
+    when(experimentFeatures.isFeatureEnabled(GERRIT_BACKEND_FEATURE_ATTACH_NONCE_TO_DOCUMENTATION))
+        .thenReturn(true);
+
+    docServlet =
+        new DocServlet(
+            CacheBuilder.newBuilder().maximumSize(1).build(), false, experimentFeatures) {
+          private static final long serialVersionUID = 1L;
+
+          @Override
+          protected Path getResourcePath(String pathInfo) throws IOException {
+            return fs.getPath("/" + CharMatcher.is('/').trimLeadingFrom(pathInfo));
+          }
+        };
+
+    Files.createDirectories(fs.getPath(DOC_PATH).getParent());
+    Files.write(fs.getPath(DOC_PATH), HTML_RESPONSE.getBytes(StandardCharsets.UTF_8));
+    Files.write(
+        fs.getPath(DOC_PATH_NO_SCRIPT), HTML_RESPONSE_NO_SCRIPT.getBytes(StandardCharsets.UTF_8));
+    Files.write(fs.getPath(NON_HTML_FILE_PATH), NON_HTML_FILE);
+  }
+
+  @Test
+  public void noNonce_unchangedResponse() throws Exception {
+    FakeHttpServletRequest request = new FakeHttpServletRequest().setPathInfo(DOC_PATH);
+    FakeHttpServletResponse response = new FakeHttpServletResponse();
+
+    docServlet.doGet(request, response);
+
+    assertThat(response.getActualBody()).isEqualTo(HTML_RESPONSE.getBytes(StandardCharsets.UTF_8));
+  }
+
+  @Test
+  public void experimentDisabled_unchangedResponse() throws Exception {
+    when(experimentFeatures.isFeatureEnabled(GERRIT_BACKEND_FEATURE_ATTACH_NONCE_TO_DOCUMENTATION))
+        .thenReturn(false);
+    FakeHttpServletRequest request = new FakeHttpServletRequest().setPathInfo(DOC_PATH);
+    request.setAttribute("nonce", NONCE);
+    FakeHttpServletResponse response = new FakeHttpServletResponse();
+
+    docServlet.doGet(request, response);
+
+    assertThat(response.getActualBody()).isEqualTo(HTML_RESPONSE.getBytes(StandardCharsets.UTF_8));
+  }
+
+  @Test
+  public void nonHtmlResponse_unchangedResponse() throws Exception {
+    FakeHttpServletRequest request = new FakeHttpServletRequest().setPathInfo(NON_HTML_FILE_PATH);
+    request.setAttribute("nonce", NONCE);
+    FakeHttpServletResponse response = new FakeHttpServletResponse();
+
+    docServlet.doGet(request, response);
+
+    assertThat(response.getActualBody()).isEqualTo(NON_HTML_FILE);
+  }
+
+  @Test
+  public void responseWithoutScripts_equivalentResponse() throws Exception {
+    FakeHttpServletRequest request = new FakeHttpServletRequest().setPathInfo(DOC_PATH_NO_SCRIPT);
+    request.setAttribute("nonce", NONCE);
+    FakeHttpServletResponse response = new FakeHttpServletResponse();
+
+    docServlet.doGet(request, response);
+
+    // Normally file is not guaranteed to not get reformatted, but in the simple example like we use
+    // here we can check byte-wise equality.
+    assertThat(response.getActualBody())
+        .isEqualTo(HTML_RESPONSE_NO_SCRIPT.getBytes(StandardCharsets.UTF_8));
+  }
+
+  @Test
+  public void htmlResponse_nonceAttached() throws Exception {
+    FakeHttpServletRequest request = new FakeHttpServletRequest().setPathInfo(DOC_PATH);
+    request.setAttribute("nonce", NONCE);
+    FakeHttpServletResponse response = new FakeHttpServletResponse();
+
+    docServlet.doGet(request, response);
+
+    Document doc = Jsoup.parse(response.getActualBodyString());
+    for (Element el : doc.getElementsByTag("script")) {
+      assertThat(el.attributes().get("nonce")).isEqualTo(NONCE);
+    }
+  }
+
+  @Test
+  public void htmlResponse_noCacheHeaderSet() throws Exception {
+    FakeHttpServletRequest request = new FakeHttpServletRequest().setPathInfo(DOC_PATH);
+    request.setAttribute("nonce", NONCE);
+    FakeHttpServletResponse response = new FakeHttpServletResponse();
+
+    docServlet.doGet(request, response);
+
+    assertThat(response.getHeader("Cache-Control"))
+        .isEqualTo("no-cache, no-store, max-age=0, must-revalidate");
+  }
+
+  private static final String NONCE = "1234abcde";
+  private static final String HTML_RESPONSE =
+      "<!DOCTYPE html>"
+          + "<html lang=\"en\">"
+          + "<head>"
+          + "  <title>Gerrit Code Review - Searching Changes</title>"
+          + "  <link rel=\"stylesheet\" href=\"./asciidoctor.css\">"
+          + "  <script src=\"./prettify.min.js\"></script>"
+          + "  <script>document.addEventListener('DOMContentLoaded', prettyPrint)</script>"
+          + "</head><body></body></html>";
+  private static final String DOC_PATH = "/Documentation/page1.html";
+  private static final String HTML_RESPONSE_NO_SCRIPT =
+      "<html><head></head><body><div>Hello</div></body></html>";
+  private static final String DOC_PATH_NO_SCRIPT = "/Documentation/page_no_script.html";
+  private static final byte[] NON_HTML_FILE = "<script></script>".getBytes(StandardCharsets.UTF_8);
+  private static final String NON_HTML_FILE_PATH = "/foo";
+}
diff --git a/javatests/com/google/gerrit/httpd/raw/IndexPreloadingUtilTest.java b/javatests/com/google/gerrit/httpd/raw/IndexPreloadingUtilTest.java
index e1cccf8..9f8f494 100644
--- a/javatests/com/google/gerrit/httpd/raw/IndexPreloadingUtilTest.java
+++ b/javatests/com/google/gerrit/httpd/raw/IndexPreloadingUtilTest.java
@@ -52,6 +52,7 @@
   @Test
   public void preloadOnlyForSelfDashboard() throws Exception {
     assertThat(parseRequestedPage("/dashboard/self")).isEqualTo(RequestedPage.DASHBOARD);
+    assertThat(parseRequestedPage("/profile/self")).isEqualTo(RequestedPage.PROFILE);
     assertThat(parseRequestedPage("/dashboard/1085901"))
         .isEqualTo(RequestedPage.PAGE_WITHOUT_PRELOADING);
     assertThat(parseRequestedPage("/dashboard/gerrit"))
diff --git a/javatests/com/google/gerrit/httpd/raw/IndexServletTest.java b/javatests/com/google/gerrit/httpd/raw/IndexServletTest.java
index f65e823..06ea8b6 100644
--- a/javatests/com/google/gerrit/httpd/raw/IndexServletTest.java
+++ b/javatests/com/google/gerrit/httpd/raw/IndexServletTest.java
@@ -60,15 +60,13 @@
     String testCdnPath = "bar-cdn";
     String testFaviconURL = "zaz-url";
 
-    // Pick any known experiment enabled by default;
-    String disabledDefault = ExperimentFeaturesConstants.UI_FEATURE_PATCHSET_COMMENTS;
-    assertThat(ExperimentFeaturesConstants.DEFAULT_ENABLED_FEATURES).contains(disabledDefault);
+    assertThat(ExperimentFeaturesConstants.DEFAULT_ENABLED_FEATURES).isEmpty();
 
     org.eclipse.jgit.lib.Config serverConfig = new org.eclipse.jgit.lib.Config();
     serverConfig.setStringList(
         "experiments", null, "enabled", ImmutableList.of("NewFeature", "DisabledFeature"));
     serverConfig.setStringList(
-        "experiments", null, "disabled", ImmutableList.of("DisabledFeature", disabledDefault));
+        "experiments", null, "disabled", ImmutableList.of("DisabledFeature"));
     ExperimentFeatures experimentFeatures = new ConfigExperimentFeatures(serverConfig);
     IndexServlet servlet =
         new IndexServlet(
@@ -97,7 +95,6 @@
                 + "\\x5b\\x5d\\x7d');");
     ImmutableSet<String> enabledDefaults =
         ExperimentFeaturesConstants.DEFAULT_ENABLED_FEATURES.stream()
-            .filter(e -> !e.equals(disabledDefault))
             .collect(ImmutableSet.toImmutableSet());
     List<String> expectedEnabled = new ArrayList<>();
     expectedEnabled.add("NewFeature");
diff --git a/javatests/com/google/gerrit/httpd/raw/ResourceServletTest.java b/javatests/com/google/gerrit/httpd/raw/ResourceServletTest.java
index 36641fe..8a2de7d 100644
--- a/javatests/com/google/gerrit/httpd/raw/ResourceServletTest.java
+++ b/javatests/com/google/gerrit/httpd/raw/ResourceServletTest.java
@@ -40,6 +40,7 @@
 import java.time.LocalDateTime;
 import java.time.Month;
 import java.time.ZoneOffset;
+import java.util.Locale;
 import java.util.concurrent.atomic.AtomicLong;
 import java.util.zip.GZIPInputStream;
 import org.junit.Before;
@@ -331,7 +332,7 @@
   }
 
   private static void assertCacheable(FakeHttpServletResponse res, boolean revalidate) {
-    String header = res.getHeader("Cache-Control").toLowerCase();
+    String header = res.getHeader("Cache-Control").toLowerCase(Locale.US);
     assertThat(header).contains("public");
     if (revalidate) {
       assertThat(header).contains("must-revalidate");
diff --git a/javatests/com/google/gerrit/index/IndexUpgradeValidatorTest.java b/javatests/com/google/gerrit/index/IndexUpgradeValidatorTest.java
index c2caff8..af6f28a 100644
--- a/javatests/com/google/gerrit/index/IndexUpgradeValidatorTest.java
+++ b/javatests/com/google/gerrit/index/IndexUpgradeValidatorTest.java
@@ -18,10 +18,10 @@
 import static com.google.gerrit.index.SchemaUtil.schema;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.index.SchemaFieldDefs.Getter;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.query.change.ChangeQueryBuilder;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.junit.runners.JUnit4;
@@ -34,12 +34,41 @@
   // SchemaFields.
   @Test
   public void valid() {
-    IndexUpgradeValidator.assertValid(schema(1, ChangeField.ID), schema(2, ChangeField.ID));
     IndexUpgradeValidator.assertValid(
-        schema(1, ChangeField.ID), schema(2, ChangeField.ID, ChangeField.OWNER));
+        schema(
+            1,
+            ImmutableList.<IndexedField<ChangeData, ?>>of(ChangeField.CHANGE_ID_FIELD),
+            ImmutableList.<IndexedField<ChangeData, ?>.SearchSpec>of(ChangeField.CHANGE_ID_SPEC)),
+        schema(
+            2,
+            ImmutableList.<IndexedField<ChangeData, ?>>of(ChangeField.CHANGE_ID_FIELD),
+            ImmutableList.<IndexedField<ChangeData, ?>.SearchSpec>of(ChangeField.CHANGE_ID_SPEC)));
     IndexUpgradeValidator.assertValid(
-        schema(1, ChangeField.ID),
-        schema(2, ChangeField.ID, ChangeField.OWNER, ChangeField.COMMITTER));
+        schema(
+            1,
+            ImmutableList.<IndexedField<ChangeData, ?>>of(ChangeField.CHANGE_ID_FIELD),
+            ImmutableList.<IndexedField<ChangeData, ?>.SearchSpec>of(ChangeField.CHANGE_ID_SPEC)),
+        schema(
+            2,
+            ImmutableList.<IndexedField<ChangeData, ?>>of(
+                ChangeField.OWNER_FIELD, ChangeField.CHANGE_ID_FIELD),
+            ImmutableList.<IndexedField<ChangeData, ?>.SearchSpec>of(
+                ChangeField.OWNER_SPEC, ChangeField.CHANGE_ID_SPEC)));
+    IndexUpgradeValidator.assertValid(
+        schema(
+            1,
+            ImmutableList.<IndexedField<ChangeData, ?>>of(ChangeField.CHANGE_ID_FIELD),
+            ImmutableList.<IndexedField<ChangeData, ?>.SearchSpec>of(ChangeField.CHANGE_ID_SPEC)),
+        schema(
+            2,
+            ImmutableList.<IndexedField<ChangeData, ?>>of(
+                ChangeField.CHANGE_ID_FIELD,
+                ChangeField.OWNER_FIELD,
+                ChangeField.COMMITTER_PARTS_FIELD),
+            ImmutableList.<IndexedField<ChangeData, ?>.SearchSpec>of(
+                ChangeField.CHANGE_ID_SPEC,
+                ChangeField.OWNER_SPEC,
+                ChangeField.COMMITTER_PARTS_SPEC)));
   }
 
   @Test
@@ -49,7 +78,16 @@
             AssertionError.class,
             () ->
                 IndexUpgradeValidator.assertValid(
-                    schema(1, ChangeField.ID), schema(2, ChangeField.OWNER)));
+                    schema(
+                        1,
+                        ImmutableList.<IndexedField<ChangeData, ?>>of(ChangeField.CHANGE_ID_FIELD),
+                        ImmutableList.<IndexedField<ChangeData, ?>.SearchSpec>of(
+                            ChangeField.CHANGE_ID_SPEC)),
+                    schema(
+                        2,
+                        ImmutableList.<IndexedField<ChangeData, ?>>of(ChangeField.OWNER_FIELD),
+                        ImmutableList.<IndexedField<ChangeData, ?>.SearchSpec>of(
+                            ChangeField.OWNER_SPEC))));
     assertThat(e)
         .hasMessageThat()
         .contains("Schema upgrade to version 2 may either add or remove fields, but not both");
@@ -58,32 +96,42 @@
   @Test
   public void invalid_modify() {
     // Change value type from String to Integer.
-    FieldDef<ChangeData, Integer> ID_MODIFIED =
-        new FieldDef.Builder<>(FieldType.INTEGER, ChangeQueryBuilder.FIELD_CHANGE_ID)
-            .build(cd -> 42);
+    IndexedField<ChangeData, Integer> ID_MODIFIED =
+        IndexedField.<ChangeData>integerBuilder(ChangeField.CHANGE_ID_FIELD.name()).build(cd -> 42);
     AssertionError e =
         assertThrows(
             AssertionError.class,
             () ->
                 IndexUpgradeValidator.assertValid(
-                    schema(1, ChangeField.ID), schema(2, ID_MODIFIED)));
+                    schema(
+                        1,
+                        ImmutableList.<IndexedField<ChangeData, ?>>of(ChangeField.CHANGE_ID_FIELD),
+                        ImmutableList.<IndexedField<ChangeData, ?>.SearchSpec>of(
+                            ChangeField.CHANGE_ID_SPEC)),
+                    schema(
+                        2,
+                        ImmutableList.<IndexedField<ChangeData, ?>>of(ID_MODIFIED),
+                        ImmutableList.<IndexedField<ChangeData, ?>.SearchSpec>of())));
     assertThat(e).hasMessageThat().contains("Fields may not be modified");
-    assertThat(e).hasMessageThat().contains(ChangeQueryBuilder.FIELD_CHANGE_ID);
+    assertThat(e).hasMessageThat().contains(ChangeField.CHANGE_ID_FIELD.name());
   }
 
   @Test
   public void invalid_modify_referenceEquality() {
     // Comparison uses Object.equals(), i.e. reference equality.
     Getter<ChangeData, String> getter = cd -> cd.change().getKey().get();
-    FieldDef<ChangeData, String> ID_1 =
-        new FieldDef.Builder<>(FieldType.PREFIX, ChangeQueryBuilder.FIELD_CHANGE_ID).build(getter);
-    FieldDef<ChangeData, String> ID_2 =
-        new FieldDef.Builder<>(FieldType.PREFIX, ChangeQueryBuilder.FIELD_CHANGE_ID).build(getter);
+    IndexedField<ChangeData, String> ID_1 =
+        IndexedField.<ChangeData>stringBuilder(ChangeField.CHANGE_ID_FIELD.name()).build(getter);
+    IndexedField<ChangeData, String> ID_2 =
+        IndexedField.<ChangeData>stringBuilder(ChangeField.CHANGE_ID_FIELD.name()).build(getter);
     AssertionError e =
         assertThrows(
             AssertionError.class,
-            () -> IndexUpgradeValidator.assertValid(schema(1, ID_1), schema(2, ID_2)));
+            () ->
+                IndexUpgradeValidator.assertValid(
+                    schema(1, ImmutableList.of(ID_1), ImmutableList.of()),
+                    schema(2, ImmutableList.of(ID_2), ImmutableList.of())));
     assertThat(e).hasMessageThat().contains("Fields may not be modified");
-    assertThat(e).hasMessageThat().contains(ChangeQueryBuilder.FIELD_CHANGE_ID);
+    assertThat(e).hasMessageThat().contains(ChangeField.CHANGE_ID_FIELD.name());
   }
 }
diff --git a/javatests/com/google/gerrit/index/SchemaUtilTest.java b/javatests/com/google/gerrit/index/SchemaUtilTest.java
index a92ee0c..4f67f8e 100644
--- a/javatests/com/google/gerrit/index/SchemaUtilTest.java
+++ b/javatests/com/google/gerrit/index/SchemaUtilTest.java
@@ -15,12 +15,12 @@
 package com.google.gerrit.index;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.gerrit.index.FieldDef.exact;
 import static com.google.gerrit.index.SchemaUtil.getNameParts;
 import static com.google.gerrit.index.SchemaUtil.getPersonParts;
 import static com.google.gerrit.index.SchemaUtil.schema;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
+import com.google.common.collect.ImmutableList;
 import java.util.Map;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.junit.Test;
@@ -30,20 +30,14 @@
 @RunWith(JUnit4.class)
 public class SchemaUtilTest {
 
-  private static final FieldDef<String, String> TEST_DEF =
-      exact("test_id").stored().build(id -> id);
-
-  private static final FieldDef<String, String> OTHER_TEST_DEF =
-      exact("other_test_id").stored().build(id -> id);
-
   private static final IndexedField<String, String> TEST_FIELD =
       IndexedField.<String>stringBuilder("TestId").build(a -> a);
 
   private static final IndexedField<String, String> TEST_FIELD_DUPLICATE_NAME =
-      IndexedField.<String>stringBuilder(TEST_DEF.getName()).build(a -> a);
+      IndexedField.<String>stringBuilder("TestId").build(a -> a);
 
   private static final IndexedField<String, String>.SearchSpec TEST_FIELD_SPEC =
-      TEST_FIELD.exact(TEST_DEF.getName());
+      TEST_FIELD.exact("test_id");
 
   static class TestSchemas {
 
@@ -71,8 +65,10 @@
 
   @Test
   public void schemaVersion_incrementedOnVersionUpgrades() {
-    Schema<String> initialSchemaVersion = schema(/* version= */ 1);
-    Schema<String> schemaVersionUpgrade = schema(initialSchemaVersion);
+    Schema<String> initialSchemaVersion =
+        schema(/* version= */ 1, ImmutableList.of(), ImmutableList.of());
+    Schema<String> schemaVersionUpgrade =
+        schema(initialSchemaVersion, ImmutableList.of(), ImmutableList.of());
     assertThat(initialSchemaVersion.getVersion()).isEqualTo(1);
     assertThat(schemaVersionUpgrade.getVersion()).isEqualTo(2);
   }
@@ -108,21 +104,6 @@
   }
 
   @Test
-  public void canAddFieldSpecAndFieldDef() {
-    Schema<String> schema0 =
-        new Schema.Builder<String>()
-            .version(0)
-            .addIndexedFields(TEST_FIELD)
-            .addSearchSpecs(TEST_FIELD_SPEC)
-            .add(OTHER_TEST_DEF)
-            .build();
-
-    assertThat(schema0.hasField(TEST_FIELD_SPEC)).isTrue();
-    assertThat(schema0.hasField(OTHER_TEST_DEF)).isTrue();
-    assertThat(schema0.getIndexFields().values()).contains(TEST_FIELD);
-  }
-
-  @Test
   public void canRemoveIndexedField() {
     Schema<String> schema0 =
         new Schema.Builder<String>()
@@ -157,23 +138,6 @@
   }
 
   @Test
-  public void canRemoveFieldDef() {
-    Schema<String> schema0 =
-        new Schema.Builder<String>()
-            .version(0)
-            .addIndexedFields(TEST_FIELD)
-            .addSearchSpecs(TEST_FIELD_SPEC)
-            .add(OTHER_TEST_DEF)
-            .build();
-
-    Schema<String> schema1 =
-        new Schema.Builder<String>().add(schema0).remove(OTHER_TEST_DEF).build();
-    assertThat(schema1.hasField(TEST_FIELD_SPEC)).isTrue();
-    assertThat(schema1.hasField(OTHER_TEST_DEF)).isFalse();
-    assertThat(schema1.getIndexFields().values()).contains(TEST_FIELD);
-  }
-
-  @Test
   public void addSearchWithoutStoredField_disallowed() {
     IllegalArgumentException thrown =
         assertThrows(
@@ -199,6 +163,20 @@
   }
 
   @Test
+  public void addDuplicateIndexField_byName_disallowed() {
+    IllegalArgumentException thrown =
+        assertThrows(
+            IllegalArgumentException.class,
+            () ->
+                new Schema.Builder<String>()
+                    .version(0)
+                    .addIndexedFields(TEST_FIELD)
+                    .addIndexedFields(TEST_FIELD_DUPLICATE_NAME)
+                    .build());
+    assertThat(thrown).hasMessageThat().contains("Multiple entries with same key: TestId");
+  }
+
+  @Test
   public void addDuplicateSearchSpec_disallowed() {
     IllegalArgumentException thrown =
         assertThrows(
@@ -214,37 +192,6 @@
   }
 
   @Test
-  public void addFieldDefWithDuplicateSearchName_disallowed() {
-    IllegalArgumentException thrown =
-        assertThrows(
-            IllegalArgumentException.class,
-            () ->
-                new Schema.Builder<String>()
-                    .version(0)
-                    .addIndexedFields(TEST_FIELD)
-                    .addSearchSpecs(TEST_FIELD_SPEC)
-                    .add(TEST_DEF)
-                    .build());
-    assertThat(thrown).hasMessageThat().contains("Multiple entries with same key: test_id");
-  }
-
-  @Test
-  public void addFieldDefWithDuplicateFieldName_disallowed() {
-    IllegalArgumentException thrown =
-        assertThrows(
-            IllegalArgumentException.class,
-            () ->
-                new Schema.Builder<String>()
-                    .version(0)
-                    .addIndexedFields(TEST_FIELD_DUPLICATE_NAME)
-                    .add(TEST_DEF)
-                    .build());
-    assertThat(thrown)
-        .hasMessageThat()
-        .isEqualTo("DuplicateKeys found [test_id], indexFields:[test_id], schemaFields: [test_id]");
-  }
-
-  @Test
   public void removeFieldWithExistingSearchSpec_disallowed() {
     Schema<String> schema0 =
         new Schema.Builder<String>()
diff --git a/javatests/com/google/gerrit/index/query/AndSourceTest.java b/javatests/com/google/gerrit/index/query/AndSourceTest.java
index 8b95bff..068ae8c 100644
--- a/javatests/com/google/gerrit/index/query/AndSourceTest.java
+++ b/javatests/com/google/gerrit/index/query/AndSourceTest.java
@@ -29,7 +29,7 @@
     TestDataSourcePredicate p1 = new TestDataSourcePredicate("predicate1", "foo", 10, 10);
     TestDataSourcePredicate p2 = new TestDataSourcePredicate("predicate2", "foo", 1, 10);
     AndSource<String> andSource = new AndSource<>(Lists.newArrayList(p1, p2), null);
-    andSource.match("bar");
+    assertFalse(andSource.match("bar"));
     assertFalse(p1.ranMatch);
     assertTrue(p2.ranMatch);
   }
diff --git a/javatests/com/google/gerrit/json/BUILD b/javatests/com/google/gerrit/json/BUILD
index 575f575..a242b0e 100644
--- a/javatests/com/google/gerrit/json/BUILD
+++ b/javatests/com/google/gerrit/json/BUILD
@@ -5,7 +5,6 @@
     srcs = glob(["*.java"]),
     deps = [
         "//java/com/google/gerrit/json",
-        "//java/com/google/gerrit/server/util/time",
         "//java/com/google/gerrit/testing:gerrit-test-util",
         "//lib:gson",
         "//lib:guava",
diff --git a/javatests/com/google/gerrit/server/BUILD b/javatests/com/google/gerrit/server/BUILD
index a586c0e..4821f20 100644
--- a/javatests/com/google/gerrit/server/BUILD
+++ b/javatests/com/google/gerrit/server/BUILD
@@ -69,6 +69,7 @@
         "//java/com/google/gerrit/sshd",
         "//java/com/google/gerrit/testing:assertable-executor",
         "//java/com/google/gerrit/testing:gerrit-test-util",
+        "//java/com/google/gerrit/testing:test-ref-update-context",
         "//java/com/google/gerrit/truth",
         "//lib:gson",
         "//lib:guava",
diff --git a/javatests/com/google/gerrit/server/IdentifiedUserTest.java b/javatests/com/google/gerrit/server/IdentifiedUserTest.java
index 855a0bc..ce045f7 100644
--- a/javatests/com/google/gerrit/server/IdentifiedUserTest.java
+++ b/javatests/com/google/gerrit/server/IdentifiedUserTest.java
@@ -37,6 +37,7 @@
 import com.google.inject.Injector;
 import java.util.Arrays;
 import java.util.HashSet;
+import java.util.Locale;
 import java.util.Set;
 import org.eclipse.jgit.lib.Config;
 import org.junit.Before;
@@ -92,6 +93,7 @@
             bind(AccountCache.class).toInstance(accountCache);
             bind(GroupBackend.class).to(SystemGroupBackend.class).in(SINGLETON);
             bind(Realm.class).toInstance(mockRealm);
+            install(new DefaultRefLogIdentityProvider.Module());
           }
         };
 
@@ -113,14 +115,14 @@
   @Test
   public void emailsExistence() {
     assertThat(identifiedUser.hasEmailAddress(TEST_CASES[0])).isTrue();
-    assertThat(identifiedUser.hasEmailAddress(TEST_CASES[1].toLowerCase())).isTrue();
+    assertThat(identifiedUser.hasEmailAddress(TEST_CASES[1].toLowerCase(Locale.US))).isTrue();
     assertThat(identifiedUser.hasEmailAddress(TEST_CASES[1])).isTrue();
-    assertThat(identifiedUser.hasEmailAddress(TEST_CASES[1].toUpperCase())).isTrue();
+    assertThat(identifiedUser.hasEmailAddress(TEST_CASES[1].toUpperCase(Locale.US))).isTrue();
     /* assert again to test cached email address by IdentifiedUser.validEmails */
     assertThat(identifiedUser.hasEmailAddress(TEST_CASES[1])).isTrue();
 
     assertThat(identifiedUser.hasEmailAddress(TEST_CASES[2])).isTrue();
-    assertThat(identifiedUser.hasEmailAddress(TEST_CASES[2].toLowerCase())).isTrue();
+    assertThat(identifiedUser.hasEmailAddress(TEST_CASES[2].toLowerCase(Locale.US))).isTrue();
 
     assertThat(identifiedUser.hasEmailAddress("non-exist@email.com")).isFalse();
     /* assert again to test cached email address by IdentifiedUser.invalidEmails */
diff --git a/javatests/com/google/gerrit/server/account/AccountResolverTest.java b/javatests/com/google/gerrit/server/account/AccountResolverTest.java
index 3658834..34f746a 100644
--- a/javatests/com/google/gerrit/server/account/AccountResolverTest.java
+++ b/javatests/com/google/gerrit/server/account/AccountResolverTest.java
@@ -23,6 +23,8 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.server.AnonymousUser;
+import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.AccountResolver.Result;
 import com.google.gerrit.server.account.AccountResolver.Searcher;
 import com.google.gerrit.server.account.AccountResolver.StringSearcher;
@@ -35,11 +37,12 @@
 import org.junit.Test;
 
 public class AccountResolverTest {
+  private final CurrentUser user = new AnonymousUser();
+
   private static class TestSearcher extends StringSearcher {
     private final String pattern;
     private final boolean shortCircuit;
     private final ImmutableList<AccountState> accounts;
-    private boolean assumeVisible;
     private boolean filterInactive;
 
     private TestSearcher(String pattern, boolean shortCircuit, AccountState... accounts) {
@@ -64,15 +67,6 @@
     }
 
     @Override
-    public boolean callerMayAssumeCandidatesAreVisible() {
-      return assumeVisible;
-    }
-
-    void setCallerMayAssumeCandidatesAreVisible() {
-      this.assumeVisible = true;
-    }
-
-    @Override
     public boolean callerShouldFilterOutInactiveCandidates() {
       return filterInactive;
     }
@@ -135,17 +129,6 @@
   }
 
   @Test
-  public void skipVisibilityCheck() throws Exception {
-    TestSearcher searcher = new TestSearcher("foo", false, newAccount(1), newAccount(2));
-    ImmutableList<Searcher<?>> searchers = ImmutableList.of(searcher);
-
-    assertThat(search("foo", searchers, only(2)).asIdSet()).containsExactlyElementsIn(ids(2));
-
-    searcher.setCallerMayAssumeCandidatesAreVisible();
-    assertThat(search("foo", searchers, only(2)).asIdSet()).containsExactlyElementsIn(ids(1, 2));
-  }
-
-  @Test
   public void dontFilterInactive() throws Exception {
     ImmutableList<Searcher<?>> searchers =
         ImmutableList.of(
@@ -282,7 +265,7 @@
     AccountResolver resolver = newAccountResolver();
     assertThat(
             new UnresolvableAccountException(
-                resolver.new Result("foo", ImmutableList.of(), ImmutableList.of())))
+                resolver.new Result("foo", ImmutableList.of(), ImmutableList.of(), user)))
         .hasMessageThat()
         .isEqualTo("Account 'foo' not found");
   }
@@ -292,7 +275,7 @@
     AccountResolver resolver = newAccountResolver();
     UnresolvableAccountException e =
         new UnresolvableAccountException(
-            resolver.new Result("self", ImmutableList.of(), ImmutableList.of()));
+            resolver.new Result("self", ImmutableList.of(), ImmutableList.of(), user));
     assertThat(e.isSelf()).isTrue();
     assertThat(e).hasMessageThat().isEqualTo("Resolving account 'self' requires login");
   }
@@ -302,7 +285,7 @@
     AccountResolver resolver = newAccountResolver();
     UnresolvableAccountException e =
         new UnresolvableAccountException(
-            resolver.new Result("me", ImmutableList.of(), ImmutableList.of()));
+            resolver.new Result("me", ImmutableList.of(), ImmutableList.of(), user));
     assertThat(e.isSelf()).isTrue();
     assertThat(e).hasMessageThat().isEqualTo("Resolving account 'me' requires login");
   }
@@ -314,7 +297,10 @@
             new UnresolvableAccountException(
                 resolver
                 .new Result(
-                    "foo", ImmutableList.of(newAccount(3), newAccount(1)), ImmutableList.of())))
+                    "foo",
+                    ImmutableList.of(newAccount(3), newAccount(1)),
+                    ImmutableList.of(),
+                    user)))
         .hasMessageThat()
         .isEqualTo(
             "Account 'foo' is ambiguous (at most 3 shown):\n1: Anonymous Name (1)\n3: Anonymous Name (3)");
@@ -329,7 +315,8 @@
                 .new Result(
                     "foo",
                     ImmutableList.of(),
-                    ImmutableList.of(newInactiveAccount(3), newInactiveAccount(1)))))
+                    ImmutableList.of(newInactiveAccount(3), newInactiveAccount(1)),
+                    user)))
         .hasMessageThat()
         .isEqualTo(
             "Account 'foo' only matches inactive accounts. To use an inactive account, retry"
@@ -352,10 +339,11 @@
       Supplier<Predicate<AccountState>> visibilitySupplier,
       Predicate<AccountState> activityPredicate)
       throws Exception {
-    return newAccountResolver().searchImpl(input, searchers, visibilitySupplier, activityPredicate);
+    return newAccountResolver()
+        .searchImpl(input, searchers, user, visibilitySupplier, activityPredicate);
   }
 
-  private static AccountResolver newAccountResolver() {
+  private AccountResolver newAccountResolver() {
     return new AccountResolver(null, null, null, null, null, null, null, null, "Anonymous Name");
   }
 
diff --git a/javatests/com/google/gerrit/server/cache/BUILD b/javatests/com/google/gerrit/server/cache/BUILD
index cbe64a5..cd6a6b4 100644
--- a/javatests/com/google/gerrit/server/cache/BUILD
+++ b/javatests/com/google/gerrit/server/cache/BUILD
@@ -8,11 +8,9 @@
         "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/testing:gerrit-test-util",
-        "//javatests/com/google/gerrit/util/http/testutil",
         "//lib:junit",
         "//lib/truth",
         "//lib/truth:truth-java8-extension",
-        "@servlet-api//jar",
     ],
 )
 
diff --git a/javatests/com/google/gerrit/server/cache/mem/BUILD b/javatests/com/google/gerrit/server/cache/mem/BUILD
index 5ae4b73..baa6ff8 100644
--- a/javatests/com/google/gerrit/server/cache/mem/BUILD
+++ b/javatests/com/google/gerrit/server/cache/mem/BUILD
@@ -4,6 +4,8 @@
     name = "tests",
     srcs = glob(["*Test.java"]),
     deps = [
+        "//java/com/google/gerrit/common:annotations",
+        "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/metrics",
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/server/cache/mem",
diff --git a/javatests/com/google/gerrit/server/cache/mem/DefaultMemoryCacheFactoryTest.java b/javatests/com/google/gerrit/server/cache/mem/DefaultMemoryCacheFactoryTest.java
index 0771afa..e7dbbfe 100644
--- a/javatests/com/google/gerrit/server/cache/mem/DefaultMemoryCacheFactoryTest.java
+++ b/javatests/com/google/gerrit/server/cache/mem/DefaultMemoryCacheFactoryTest.java
@@ -22,10 +22,14 @@
 import com.google.common.cache.RemovalNotification;
 import com.google.common.cache.Weigher;
 import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.metrics.DisabledMetricMaker;
 import com.google.gerrit.server.cache.CacheDef;
 import com.google.gerrit.server.cache.ForwardingRemovalListener;
 import com.google.gerrit.server.git.WorkQueue;
+import com.google.gerrit.server.plugincontext.PluginContext;
+import com.google.gerrit.server.plugincontext.PluginMapContext;
 import com.google.gerrit.server.util.IdGenerator;
 import com.google.inject.Guice;
 import com.google.inject.TypeLiteral;
@@ -74,7 +78,13 @@
   @Before
   public void setUp() {
     IdGenerator idGenerator = Guice.createInjector().getInstance(IdGenerator.class);
-    workQueue = new WorkQueue(idGenerator, 10, new DisabledMetricMaker());
+    workQueue =
+        new WorkQueue(
+            idGenerator,
+            10,
+            new DisabledMetricMaker(),
+            new PluginMapContext<>(
+                DynamicMap.emptyMap(), PluginContext.PluginMetrics.DISABLED_INSTANCE));
     memoryCacheConfig = new Config();
     memoryCacheConfigDirectExecutor = new Config();
     memoryCacheConfigDirectExecutor.setInt("cache", null, "threads", 0);
@@ -273,11 +283,13 @@
       }
 
       @Override
+      @Nullable
       public TypeLiteral<Integer> keyType() {
         return null;
       }
 
       @Override
+      @Nullable
       public TypeLiteral<Integer> valueType() {
         return null;
       }
@@ -288,26 +300,31 @@
       }
 
       @Override
+      @Nullable
       public Duration expireAfterWrite() {
         return null;
       }
 
       @Override
+      @Nullable
       public Duration expireFromMemoryAfterAccess() {
         return null;
       }
 
       @Override
+      @Nullable
       public Duration refreshAfterWrite() {
         return null;
       }
 
       @Override
+      @Nullable
       public Weigher<Integer, Integer> weigher() {
         return null;
       }
 
       @Override
+      @Nullable
       public CacheLoader<Integer, Integer> loader() {
         return null;
       }
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/CachedProjectConfigSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/CachedProjectConfigSerializerTest.java
index c7e09dc..bf8a071 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/entities/CachedProjectConfigSerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/CachedProjectConfigSerializerTest.java
@@ -52,7 +52,7 @@
           .addNotifySection(NotifyConfigSerializerTest.ALL_VALUES_SET)
           .addLabelSection(LabelTypeSerializerTest.ALL_VALUES_SET)
           .addSubscribeSection(SubscribeSectionSerializerTest.ALL_VALUES_SET)
-          .addCommentLinkSection(StoredCommentLinkInfoSerializerTest.HTML_ONLY)
+          .addCommentLinkSection(StoredCommentLinkInfoSerializerTest.LINK_ONLY)
           .setRevision(Optional.of(ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef")))
           .setRulesId(Optional.of(ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef")))
           .setExtensionPanelSections(ImmutableMap.of("key1", ImmutableList.of("val1", "val2")))
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/FileDiffOutputSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/FileDiffOutputSerializerTest.java
index c5e8574..00272112 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/entities/FileDiffOutputSerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/FileDiffOutputSerializerTest.java
@@ -18,6 +18,7 @@
 
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.entities.Patch.ChangeType;
+import com.google.gerrit.entities.Patch.FileMode;
 import com.google.gerrit.entities.Patch.PatchType;
 import com.google.gerrit.server.patch.ComparisonType;
 import com.google.gerrit.server.patch.filediff.Edit;
@@ -42,6 +43,8 @@
             .comparisonType(ComparisonType.againstOtherPatchSet())
             .oldPath(Optional.of("old_file_path.txt"))
             .newPath(Optional.empty())
+            .oldMode(Optional.of(FileMode.REGULAR_FILE))
+            .newMode(Optional.of(FileMode.SYMLINK))
             .changeType(ChangeType.DELETED)
             .patchType(Optional.of(PatchType.UNIFIED))
             .size(23)
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/ProjectSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/ProjectSerializerTest.java
index 29fd5ed..1f725f8 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/entities/ProjectSerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/ProjectSerializerTest.java
@@ -40,6 +40,9 @@
           .setBooleanConfig(
               BooleanProjectConfig.CREATE_NEW_CHANGE_FOR_ALL_NOT_IN_TARGET,
               InheritableBoolean.INHERIT)
+          .setBooleanConfig(
+              BooleanProjectConfig.SKIP_ADDING_AUTHOR_AND_COMMITTER_AS_REVIEWERS,
+              InheritableBoolean.TRUE)
           .build();
 
   @Test
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/StoredCommentLinkInfoSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/StoredCommentLinkInfoSerializerTest.java
index e293493..aa6cfef 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/entities/StoredCommentLinkInfoSerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/StoredCommentLinkInfoSerializerTest.java
@@ -22,27 +22,16 @@
 import org.junit.Test;
 
 public class StoredCommentLinkInfoSerializerTest {
-  static final StoredCommentLinkInfo HTML_ONLY =
+  static final StoredCommentLinkInfo LINK_ONLY =
       StoredCommentLinkInfo.builder("name")
           .setEnabled(true)
-          .setHtml("<p>html")
+          .setLink("a.com/b.html")
           .setMatch("*")
           .build();
 
   @Test
-  public void htmlOnly_roundTrip() {
-    assertThat(deserialize(serialize(HTML_ONLY))).isEqualTo(HTML_ONLY);
-  }
-
-  @Test
   public void linkOnly_roundTrip() {
-    StoredCommentLinkInfo autoValue =
-        StoredCommentLinkInfo.builder("name")
-            .setEnabled(true)
-            .setLink("<p>html")
-            .setMatch("*")
-            .build();
-    assertThat(deserialize(serialize(autoValue))).isEqualTo(autoValue);
+    assertThat(deserialize(serialize(LINK_ONLY))).isEqualTo(LINK_ONLY);
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/server/events/EventDeserializerTest.java b/javatests/com/google/gerrit/server/events/EventDeserializerTest.java
index 601445d..05d6df7 100644
--- a/javatests/com/google/gerrit/server/events/EventDeserializerTest.java
+++ b/javatests/com/google/gerrit/server/events/EventDeserializerTest.java
@@ -69,22 +69,6 @@
   }
 
   @Test
-  public void assigneeChangedEvent() {
-    Change change = newChange();
-    AssigneeChangedEvent orig = new AssigneeChangedEvent(change);
-    orig.change = asChangeAttribute(change);
-    orig.changer = newAccount("changer");
-    orig.oldAssignee = newAccount("oldAssignee");
-
-    AssigneeChangedEvent e = roundTrip(orig);
-
-    assertThat(e).isNotNull();
-    assertSameChangeEvent(e, orig);
-    assertSameAccount(e.changer, orig.changer);
-    assertSameAccount(e.oldAssignee, orig.oldAssignee);
-  }
-
-  @Test
   public void changeDeletedEvent() {
     Change change = newChange();
     ChangeDeletedEvent orig = new ChangeDeletedEvent(change);
diff --git a/javatests/com/google/gerrit/server/events/EventJsonTest.java b/javatests/com/google/gerrit/server/events/EventJsonTest.java
index c2b67c3..3f8519e 100644
--- a/javatests/com/google/gerrit/server/events/EventJsonTest.java
+++ b/javatests/com/google/gerrit/server/events/EventJsonTest.java
@@ -153,51 +153,6 @@
   }
 
   @Test
-  public void assigneeChangedEvent() {
-    Change change = newChange();
-    AssigneeChangedEvent event = new AssigneeChangedEvent(change);
-    event.change = asChangeAttribute(change);
-    event.changer = newAccount("changer");
-    event.oldAssignee = newAccount("oldAssignee");
-
-    assertThatJsonMap(event)
-        .isEqualTo(
-            ImmutableMap.builder()
-                .put(
-                    "changer",
-                    ImmutableMap.builder()
-                        .put("name", event.changer.get().name)
-                        .put("email", event.changer.get().email)
-                        .put("username", event.changer.get().username)
-                        .build())
-                .put(
-                    "oldAssignee",
-                    ImmutableMap.builder()
-                        .put("name", event.oldAssignee.get().name)
-                        .put("email", event.oldAssignee.get().email)
-                        .put("username", event.oldAssignee.get().username)
-                        .build())
-                .put(
-                    "change",
-                    ImmutableMap.builder()
-                        .put("project", PROJECT)
-                        .put("branch", BRANCH)
-                        .put("id", CHANGE_ID)
-                        .put("number", CHANGE_NUM_DOUBLE)
-                        .put("url", URL)
-                        .put("commitMessage", COMMIT_MESSAGE)
-                        .put("createdOn", TS1)
-                        .put("status", NEW.name())
-                        .build())
-                .put("project", PROJECT)
-                .put("refName", REF)
-                .put("changeKey", map("id", CHANGE_ID))
-                .put("type", "assignee-changed")
-                .put("eventCreatedOn", TS2)
-                .build());
-  }
-
-  @Test
   public void changeDeletedEvent() {
     Change change = newChange();
     ChangeDeletedEvent event = new ChangeDeletedEvent(change);
diff --git a/javatests/com/google/gerrit/server/extensions/events/GitReferenceUpdatedTest.java b/javatests/com/google/gerrit/server/extensions/events/GitReferenceUpdatedTest.java
index 9143dd5f..8ff6825 100644
--- a/javatests/com/google/gerrit/server/extensions/events/GitReferenceUpdatedTest.java
+++ b/javatests/com/google/gerrit/server/extensions/events/GitReferenceUpdatedTest.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.events.GitBatchRefUpdateListener;
 import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
@@ -25,6 +26,7 @@
 import com.google.gerrit.server.plugincontext.PluginContext.PluginMetrics;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
 import java.io.IOException;
+import java.time.Instant;
 import org.eclipse.jgit.internal.storage.dfs.DfsRepositoryDescription;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.lib.BatchRefUpdate;
@@ -46,10 +48,12 @@
   private DynamicSet<GitReferenceUpdatedListener> refUpdatedListeners;
   private DynamicSet<GitBatchRefUpdateListener> batchRefUpdateListeners;
 
+  private final AccountState updater =
+      AccountState.forAccount(Account.builder(Account.id(1), Instant.now()).build());
+
   @Mock GitReferenceUpdatedListener refUpdatedListener;
   @Mock GitBatchRefUpdateListener batchRefUpdateListener;
   @Mock EventUtil util;
-  @Mock AccountState updater;
   @Captor ArgumentCaptor<GitBatchRefUpdateEvent> eventCaptor;
 
   @Before
diff --git a/javatests/com/google/gerrit/server/fixes/fixCalculator/FixCalculatorVariousTest.java b/javatests/com/google/gerrit/server/fixes/fixCalculator/FixCalculatorVariousTest.java
index 42a80c3..782c7d7 100644
--- a/javatests/com/google/gerrit/server/fixes/fixCalculator/FixCalculatorVariousTest.java
+++ b/javatests/com/google/gerrit/server/fixes/fixCalculator/FixCalculatorVariousTest.java
@@ -41,7 +41,7 @@
         new FixReplacement(
             "AnyPath", new Range(startLine, startChar, endLine, endChar), replacement);
     return FixCalculator.calculateFix(
-        new Text(content.getBytes(UTF_8)), ImmutableList.of(fixReplacement));
+        new Text(content.getBytes(UTF_8)), ImmutableList.of(fixReplacement), false);
   }
 
   @Test
@@ -117,7 +117,8 @@
     FixReplacement insert = new FixReplacement("path", new Range(2, 5, 2, 5), "DEFG");
     FixReplacement delete = new FixReplacement("path", new Range(2, 7, 2, 9), "");
     FixResult result =
-        FixCalculator.calculateFix(multilineContent, ImmutableList.of(replace, delete, insert));
+        FixCalculator.calculateFix(
+            multilineContent, ImmutableList.of(replace, delete, insert), false);
     assertThat(result)
         .text()
         .isEqualTo("First line\nSABConDEFGd ne\nThird line\nFourth line\nFifth line\n");
@@ -136,7 +137,8 @@
     FixReplacement insert = new FixReplacement("path", new Range(3, 5, 3, 5), "DEFG");
     FixReplacement delete = new FixReplacement("path", new Range(4, 7, 4, 9), "");
     FixResult result =
-        FixCalculator.calculateFix(multilineContent, ImmutableList.of(replace, insert, delete));
+        FixCalculator.calculateFix(
+            multilineContent, ImmutableList.of(replace, insert, delete), false);
     assertThat(result)
         .text()
         .isEqualTo("First line\nSABCond line\nThirdDEFG line\nFourth ne\nFifth line\n");
@@ -150,12 +152,28 @@
   }
 
   @Test
+  public void intraline() throws Exception {
+    FixReplacement replace = new FixReplacement("path", new Range(2, 0, 2, 11), "Second ABC line");
+    FixResult result =
+        FixCalculator.calculateFix(multilineContent, ImmutableList.of(replace), true);
+    assertThat(result)
+        .text()
+        .isEqualTo("First line\nSecond ABC line\nThird line\nFourth line\nFifth line\n");
+    assertThat(result).edits().hasSize(1);
+    Edit edit = result.edits.get(0);
+    assertThat(edit).isReplace(1, 1, 1, 1);
+    assertThat(edit).internalEdits().hasSize(1);
+    assertThat(edit).internalEdits().element(0).isInsert(7, 7, 4);
+  }
+
+  @Test
   public void severalChangesInNonConsecutiveLines() throws Exception {
     FixReplacement replace = new FixReplacement("path", new Range(1, 1, 1, 3), "ABC");
     FixReplacement insert = new FixReplacement("path", new Range(3, 5, 3, 5), "DEFG");
     FixReplacement delete = new FixReplacement("path", new Range(5, 9, 6, 0), "");
     FixResult result =
-        FixCalculator.calculateFix(multilineContent, ImmutableList.of(replace, insert, delete));
+        FixCalculator.calculateFix(
+            multilineContent, ImmutableList.of(replace, insert, delete), false);
     assertThat(result)
         .text()
         .isEqualTo("FABCst line\nSecond line\nThirdDEFG line\nFourth line\nFifth lin");
@@ -195,7 +213,8 @@
                 singleLineInsert,
                 singleLineReplace,
                 multiLineInsert,
-                singleLineDelete));
+                singleLineDelete),
+            false);
     assertThat(result)
         .text()
         .isEqualTo(
@@ -237,7 +256,7 @@
         new FixReplacement("path", new Range(2, 7, 3, 5), "content");
     FixResult result =
         FixCalculator.calculateFix(
-            multilineContent, ImmutableList.of(firstReplace, consecutiveReplace));
+            multilineContent, ImmutableList.of(firstReplace, consecutiveReplace), false);
     assertThat(result).text().isEqualTo("First modified content line\nFourth line\nFifth line\n");
     assertThat(result).edits().hasSize(1);
     Edit edit = result.edits.get(0);
@@ -259,7 +278,7 @@
         ResourceConflictException.class,
         () ->
             FixCalculator.calculateFix(
-                multilineContent, ImmutableList.of(firstReplace, secondReplace)));
+                multilineContent, ImmutableList.of(firstReplace, secondReplace), false));
   }
 
   @Test
@@ -272,6 +291,6 @@
         ResourceConflictException.class,
         () ->
             FixCalculator.calculateFix(
-                multilineContent, ImmutableList.of(firstReplace, secondReplace)));
+                multilineContent, ImmutableList.of(firstReplace, secondReplace), false));
   }
 }
diff --git a/javatests/com/google/gerrit/server/git/DeleteZombieCommentsRefsTest.java b/javatests/com/google/gerrit/server/git/DeleteZombieCommentsRefsTest.java
index 6bdf80f..0112f88 100644
--- a/javatests/com/google/gerrit/server/git/DeleteZombieCommentsRefsTest.java
+++ b/javatests/com/google/gerrit/server/git/DeleteZombieCommentsRefsTest.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.TestActionRefUpdateContext.testRefAction;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.gerrit.entities.Account;
@@ -166,7 +167,7 @@
           new ReceiveCommand(ObjectId.zeroId(), commitId, refName, ReceiveCommand.Type.CREATE));
       refNames.add(refName);
     }
-    RefUpdateUtil.executeChecked(bru, usersRepo);
+    testRefAction(() -> RefUpdateUtil.executeChecked(bru, usersRepo));
     return refNames;
   }
 
@@ -201,7 +202,7 @@
     RefUpdate update = repo.updateRef(refName);
     update.setNewObjectId(commitId);
     update.setForceUpdate(true);
-    update.update();
+    testRefAction(() -> update.update());
     return repo.exactRef(refName);
   }
 
diff --git a/javatests/com/google/gerrit/server/group/db/AbstractGroupTest.java b/javatests/com/google/gerrit/server/group/db/AbstractGroupTest.java
index bea5eaa..65eb5b0 100644
--- a/javatests/com/google/gerrit/server/group/db/AbstractGroupTest.java
+++ b/javatests/com/google/gerrit/server/group/db/AbstractGroupTest.java
@@ -74,6 +74,7 @@
     allUsersRepo.close();
   }
 
+  @Nullable
   protected Instant getTipTimestamp(AccountGroup.UUID uuid) throws Exception {
     try (RevWalk rw = new RevWalk(allUsersRepo)) {
       Ref ref = allUsersRepo.exactRef(RefNames.refsGroups(uuid));
diff --git a/javatests/com/google/gerrit/server/group/db/AuditLogReaderTest.java b/javatests/com/google/gerrit/server/group/db/AuditLogReaderTest.java
index c06c623..6d90309 100644
--- a/javatests/com/google/gerrit/server/group/db/AuditLogReaderTest.java
+++ b/javatests/com/google/gerrit/server/group/db/AuditLogReaderTest.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.group.db;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.TestActionRefUpdateContext.testRefAction;
 
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Sets;
@@ -239,34 +240,40 @@
 
   private InternalGroup createGroup(
       int next, String groupName, PersonIdent authorIdent, Account.Id authorId) throws Exception {
-    InternalGroupCreation groupCreation =
-        InternalGroupCreation.builder()
-            .setGroupUUID(GroupUuid.make(groupName, serverIdent))
-            .setNameKey(AccountGroup.nameKey(groupName))
-            .setId(AccountGroup.id(next))
-            .build();
-    GroupDelta groupDelta =
-        authorIdent.equals(serverIdent)
-            ? GroupDelta.builder().setDescription("Groups").build()
-            : GroupDelta.builder()
-                .setDescription("Groups")
-                .setMemberModification(members -> ImmutableSet.of(authorId))
-                .build();
+    return testRefAction(
+        () -> {
+          InternalGroupCreation groupCreation =
+              InternalGroupCreation.builder()
+                  .setGroupUUID(GroupUuid.make(groupName, serverIdent))
+                  .setNameKey(AccountGroup.nameKey(groupName))
+                  .setId(AccountGroup.id(next))
+                  .build();
+          GroupDelta groupDelta =
+              authorIdent.equals(serverIdent)
+                  ? GroupDelta.builder().setDescription("Groups").build()
+                  : GroupDelta.builder()
+                      .setDescription("Groups")
+                      .setMemberModification(members -> ImmutableSet.of(authorId))
+                      .build();
 
-    GroupConfig groupConfig =
-        GroupConfig.createForNewGroup(allUsersName, allUsersRepo, groupCreation);
-    groupConfig.setGroupDelta(groupDelta, getAuditLogFormatter());
+          GroupConfig groupConfig =
+              GroupConfig.createForNewGroup(allUsersName, allUsersRepo, groupCreation);
+          groupConfig.setGroupDelta(groupDelta, getAuditLogFormatter());
 
-    groupConfig.commit(createMetaDataUpdate(authorIdent));
-    return groupConfig
-        .getLoadedGroup()
-        .orElseThrow(() -> new IllegalStateException("create group failed"));
+          groupConfig.commit(createMetaDataUpdate(authorIdent));
+          return groupConfig
+              .getLoadedGroup()
+              .orElseThrow(() -> new IllegalStateException("create group failed"));
+        });
   }
 
   private void updateGroup(AccountGroup.UUID uuid, GroupDelta groupDelta) throws Exception {
-    GroupConfig groupConfig = GroupConfig.loadForGroup(allUsersName, allUsersRepo, uuid);
-    groupConfig.setGroupDelta(groupDelta, getAuditLogFormatter());
-    groupConfig.commit(createMetaDataUpdate(userIdent));
+    testRefAction(
+        () -> {
+          GroupConfig groupConfig = GroupConfig.loadForGroup(allUsersName, allUsersRepo, uuid);
+          groupConfig.setGroupDelta(groupDelta, getAuditLogFormatter());
+          groupConfig.commit(createMetaDataUpdate(userIdent));
+        });
   }
 
   private void addMembers(AccountGroup.UUID groupUuid, Set<Account.Id> ids) throws Exception {
diff --git a/javatests/com/google/gerrit/server/group/db/BUILD b/javatests/com/google/gerrit/server/group/db/BUILD
index 9f9f459..47550bb 100644
--- a/javatests/com/google/gerrit/server/group/db/BUILD
+++ b/javatests/com/google/gerrit/server/group/db/BUILD
@@ -17,6 +17,7 @@
         "//java/com/google/gerrit/server/group/testing",
         "//java/com/google/gerrit/server/util/time",
         "//java/com/google/gerrit/testing:gerrit-test-util",
+        "//java/com/google/gerrit/testing:test-ref-update-context",
         "//java/com/google/gerrit/truth",
         "//lib:guava",
         "//lib:jgit",
diff --git a/javatests/com/google/gerrit/server/index/IndexedFieldTest.java b/javatests/com/google/gerrit/server/index/IndexedFieldTest.java
index 6a62ed1..5029334 100644
--- a/javatests/com/google/gerrit/server/index/IndexedFieldTest.java
+++ b/javatests/com/google/gerrit/server/index/IndexedFieldTest.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.index.testing.TestIndexedFields.EXACT_STRING_FIELD_SPEC;
 import static com.google.gerrit.index.testing.TestIndexedFields.INTEGER_FIELD;
 import static com.google.gerrit.index.testing.TestIndexedFields.INTEGER_FIELD_SPEC;
 import static com.google.gerrit.index.testing.TestIndexedFields.INTEGER_RANGE_FIELD_SPEC;
@@ -31,6 +32,7 @@
 import static com.google.gerrit.index.testing.TestIndexedFields.ITERABLE_STRING_FIELD_SPEC;
 import static com.google.gerrit.index.testing.TestIndexedFields.LONG_FIELD_SPEC;
 import static com.google.gerrit.index.testing.TestIndexedFields.LONG_RANGE_FIELD_SPEC;
+import static com.google.gerrit.index.testing.TestIndexedFields.PREFIX_STRING_FIELD_SPEC;
 import static com.google.gerrit.index.testing.TestIndexedFields.STORED_BYTE_FIELD;
 import static com.google.gerrit.index.testing.TestIndexedFields.STORED_BYTE_SPEC;
 import static com.google.gerrit.index.testing.TestIndexedFields.STORED_PROTO_FIELD;
@@ -59,7 +61,6 @@
 import org.junit.runner.RunWith;
 
 /** Tests for {@link com.google.gerrit.index.IndexedField} */
-@SuppressWarnings("serial")
 @RunWith(Theories.class)
 public class IndexedFieldTest {
 
@@ -78,6 +79,8 @@
               .put(ITERABLE_LONG_RANGE_FIELD_SPEC, ImmutableList.of(123456L, 654321L))
               .put(TIMESTAMP_FIELD_SPEC, new Timestamp(1234567L))
               .put(STRING_FIELD_SPEC, "123456")
+              .put(PREFIX_STRING_FIELD_SPEC, "123456")
+              .put(EXACT_STRING_FIELD_SPEC, "123456")
               .put(ITERABLE_STRING_FIELD_SPEC, ImmutableList.of("123456"))
               .put(
                   ITERABLE_STORED_BYTE_SPEC,
diff --git a/javatests/com/google/gerrit/server/index/account/AccountFieldTest.java b/javatests/com/google/gerrit/server/index/account/AccountFieldTest.java
index 65eb3b8..a40afe8 100644
--- a/javatests/com/google/gerrit/server/index/account/AccountFieldTest.java
+++ b/javatests/com/google/gerrit/server/index/account/AccountFieldTest.java
@@ -40,8 +40,7 @@
     String metaId = "0e39795bb25dc914118224995c53c5c36923a461";
     account.setMetaId(metaId);
     Iterable<byte[]> refStates =
-        (Iterable<byte[]>)
-            AccountField.REF_STATE_SPEC.get(AccountState.forAccount(account.build()));
+        AccountField.REF_STATE_SPEC.get(AccountState.forAccount(account.build()));
     List<String> values = toStrings(refStates);
     String expectedValue =
         allUsersName.get() + ":" + RefNames.refsUsers(account.id()) + ":" + metaId;
diff --git a/javatests/com/google/gerrit/server/index/change/ChangeFieldTest.java b/javatests/com/google/gerrit/server/index/change/ChangeFieldTest.java
index e35941c..59b354c 100644
--- a/javatests/com/google/gerrit/server/index/change/ChangeFieldTest.java
+++ b/javatests/com/google/gerrit/server/index/change/ChangeFieldTest.java
@@ -159,7 +159,7 @@
     Project.NameKey project = Project.nameKey("project");
     ChangeData cd =
         ChangeData.createForTest(project, Change.id(1), 1, ObjectId.zeroId(), null, null, null);
-    assertThat(ChangeField.ADDED.setIfPossible(cd, new FakeStoredValue(null))).isTrue();
+    assertThat(ChangeField.ADDED_LINES_SPEC.setIfPossible(cd, new FakeStoredValue(null))).isTrue();
   }
 
   @Test
@@ -167,7 +167,8 @@
     Project.NameKey project = Project.nameKey("project");
     ChangeData cd =
         ChangeData.createForTest(project, Change.id(1), 1, ObjectId.zeroId(), null, null, null);
-    assertThat(ChangeField.DELETED.setIfPossible(cd, new FakeStoredValue(null))).isTrue();
+    assertThat(ChangeField.DELETED_LINES_SPEC.setIfPossible(cd, new FakeStoredValue(null)))
+        .isTrue();
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/server/index/change/FakeChangeIndex.java b/javatests/com/google/gerrit/server/index/change/FakeChangeIndex.java
index f70c97a..6e3514e 100644
--- a/javatests/com/google/gerrit/server/index/change/FakeChangeIndex.java
+++ b/javatests/com/google/gerrit/server/index/change/FakeChangeIndex.java
@@ -16,7 +16,9 @@
 
 import static com.google.gerrit.index.SchemaUtil.schema;
 
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.index.IndexedField;
 import com.google.gerrit.index.QueryOptions;
 import com.google.gerrit.index.Schema;
 import com.google.gerrit.index.query.FieldBundle;
@@ -29,10 +31,19 @@
 
 @Ignore
 public class FakeChangeIndex implements ChangeIndex {
-  static final Schema<ChangeData> V1 = schema(1, ChangeField.STATUS);
+  static final Schema<ChangeData> V1 =
+      schema(
+          1,
+          ImmutableList.<IndexedField<ChangeData, ?>>of(ChangeField.STATUS_FIELD),
+          ImmutableList.<IndexedField<ChangeData, ?>.SearchSpec>of(ChangeField.STATUS_SPEC));
 
   static final Schema<ChangeData> V2 =
-      schema(2, ChangeField.STATUS, ChangeField.PATH, ChangeField.UPDATED);
+      schema(
+          2,
+          ImmutableList.<IndexedField<ChangeData, ?>>of(
+              ChangeField.PATH_FIELD, ChangeField.STATUS_FIELD, ChangeField.UPDATED_FIELD),
+          ImmutableList.<IndexedField<ChangeData, ?>.SearchSpec>of(
+              ChangeField.PATH_SPEC, ChangeField.STATUS_SPEC, ChangeField.UPDATED_SPEC));
 
   private static class Source implements ChangeDataSource {
     private final Predicate<ChangeData> p;
@@ -84,6 +95,11 @@
   }
 
   @Override
+  public void deleteByValue(ChangeData value) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
   public void delete(Change.Id id) {
     throw new UnsupportedOperationException();
   }
diff --git a/javatests/com/google/gerrit/server/mail/send/NotificationEmailTest.java b/javatests/com/google/gerrit/server/mail/send/BranchEmailUtilsTest.java
similarity index 88%
rename from javatests/com/google/gerrit/server/mail/send/NotificationEmailTest.java
rename to javatests/com/google/gerrit/server/mail/send/BranchEmailUtilsTest.java
index b87c4a1..3c60b79 100644
--- a/javatests/com/google/gerrit/server/mail/send/NotificationEmailTest.java
+++ b/javatests/com/google/gerrit/server/mail/send/BranchEmailUtilsTest.java
@@ -15,12 +15,12 @@
 package com.google.gerrit.server.mail.send;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.gerrit.server.mail.send.NotificationEmail.getInstanceAndProjectName;
-import static com.google.gerrit.server.mail.send.NotificationEmail.getShortProjectName;
+import static com.google.gerrit.server.mail.send.BranchEmailUtils.getInstanceAndProjectName;
+import static com.google.gerrit.server.mail.send.BranchEmailUtils.getShortProjectName;
 
 import org.junit.Test;
 
-public class NotificationEmailTest {
+public class BranchEmailUtilsTest {
   @Test
   public void instanceAndProjectName() throws Exception {
     assertThat(getInstanceAndProjectName("test", "/my/api")).isEqualTo("test/api");
diff --git a/javatests/com/google/gerrit/server/mail/send/MailSoySauceLoaderTest.java b/javatests/com/google/gerrit/server/mail/send/MailSoySauceLoaderTest.java
index fbeabe1..7f893f1 100644
--- a/javatests/com/google/gerrit/server/mail/send/MailSoySauceLoaderTest.java
+++ b/javatests/com/google/gerrit/server/mail/send/MailSoySauceLoaderTest.java
@@ -20,7 +20,6 @@
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.plugincontext.PluginContext.PluginMetrics;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
-import com.google.template.soy.shared.SoyAstCache;
 import java.nio.file.Paths;
 import org.junit.Before;
 import org.junit.Test;
@@ -40,9 +39,7 @@
   public void soyCompilation() {
     MailSoySauceLoader loader =
         new MailSoySauceLoader(
-            sitePaths,
-            new SoyAstCache(),
-            new PluginSetContext<>(set, PluginMetrics.DISABLED_INSTANCE));
+            sitePaths, new PluginSetContext<>(set, PluginMetrics.DISABLED_INSTANCE));
     assertThat(loader.load()).isNotNull(); // should not throw
   }
 }
diff --git a/javatests/com/google/gerrit/server/mail/send/MailSoySauceModuleTest.java b/javatests/com/google/gerrit/server/mail/send/MailSoySauceModuleTest.java
index 3ce60b8..5a6db42 100644
--- a/javatests/com/google/gerrit/server/mail/send/MailSoySauceModuleTest.java
+++ b/javatests/com/google/gerrit/server/mail/send/MailSoySauceModuleTest.java
@@ -25,6 +25,7 @@
 import com.google.gerrit.server.cache.mem.DefaultMemoryCacheModule;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.git.WorkQueue;
 import com.google.inject.AbstractModule;
 import com.google.inject.Guice;
 import com.google.inject.Injector;
@@ -54,6 +55,7 @@
                 bind(SitePaths.class).toInstance(sitePaths);
                 bind(Config.class).annotatedWith(GerritServerConfig.class).toInstance(new Config());
                 bind(MetricMaker.class).to(DisabledMetricMaker.class);
+                install(new WorkQueue.WorkQueueModule());
                 install(new DefaultMemoryCacheModule());
               }
             });
diff --git a/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java b/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java
index 069a1de..20e441b 100644
--- a/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java
+++ b/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.notedb;
 
+import static com.google.gerrit.testing.TestActionRefUpdateContext.testRefAction;
 import static com.google.inject.Scopes.SINGLETON;
 import static java.util.concurrent.TimeUnit.SECONDS;
 
@@ -30,6 +31,7 @@
 import com.google.gerrit.metrics.DisabledMetricMaker;
 import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.DefaultRefLogIdentityProvider;
 import com.google.gerrit.server.FanOutExecutor;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
@@ -75,6 +77,7 @@
 import com.google.inject.TypeLiteral;
 import java.time.Instant;
 import java.time.ZoneId;
+import java.util.Optional;
 import java.util.concurrent.ExecutorService;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
@@ -180,6 +183,7 @@
 
             install(new DefaultUrlFormatterModule());
             install(NoteDbModule.forTest());
+            install(new DefaultRefLogIdentityProvider.Module());
             bind(AllUsersName.class).toProvider(AllUsersNameProvider.class);
             bind(String.class).annotatedWith(GerritServerId.class).toInstance(serverId);
             bind(new TypeLiteral<ImmutableList<String>>() {})
@@ -245,13 +249,16 @@
   }
 
   protected Change newChange(Injector injector, boolean workInProgress) throws Exception {
-    Change c = TestChanges.newChange(project, changeOwner.getAccountId());
-    ChangeUpdate u = newUpdate(injector, c, changeOwner, false);
-    u.setChangeId(c.getKey().get());
-    u.setBranch(c.getDest().branch());
-    u.setWorkInProgress(workInProgress);
-    u.commit();
-    return c;
+    return testRefAction(
+        () -> {
+          Change c = TestChanges.newChange(project, changeOwner.getAccountId());
+          ChangeUpdate u = newUpdate(injector, c, changeOwner, false);
+          u.setChangeId(c.getKey().get());
+          u.setBranch(c.getDest().branch());
+          u.setWorkInProgress(workInProgress);
+          u.commit();
+          return c;
+        });
   }
 
   protected Change newWorkInProgressChange() throws Exception {
@@ -277,7 +284,7 @@
 
   protected ChangeUpdate newUpdate(
       Injector injector, Change c, CurrentUser user, boolean shouldExist) throws Exception {
-    ChangeUpdate update = TestChanges.newUpdate(injector, c, user, shouldExist);
+    ChangeUpdate update = TestChanges.newUpdate(injector, c, Optional.of(user), shouldExist);
     update.setPatchSetId(c.currentPatchSetId());
     update.setAllowWriteToNewRef(true);
     return update;
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesParserTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesParserTest.java
index 546bb18..23c5704 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesParserTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesParserTest.java
@@ -167,9 +167,11 @@
             + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
             + "Patch-set: 1\n"
             + "Label: Label1=+1, 577fb248e474018276351785930358ec0450e9f7\n"
-            + "Label: Label1=+1, 577fb248e474018276351785930358ec0450e9f7 Gerrit User 2 <2@gerrit>\n"
+            + "Label: Label1=+1, 577fb248e474018276351785930358ec0450e9f7 Gerrit User 2"
+            + " <2@gerrit>\n"
             + "Label: Label1=0, 577fb248e474018276351785930358ec0450e9f7 Gerrit User 2 <2@gerrit>\n"
-            + "Label: Label1=0, 577fb248e474018276351785930358ec0450e9f7 Gerrit User 3 (name,with, comma) <3@gerrit>\n"
+            + "Label: Label1=0, 577fb248e474018276351785930358ec0450e9f7 Gerrit User 3 (name,with,"
+            + " comma) <3@gerrit>\n"
             + "Subject: This is a test change\n");
 
     assertParseSucceeds(
@@ -185,14 +187,24 @@
     assertParseFails("Update change\n\nPatch-set: 1\nLabel: Label1=+1, \n");
     assertParseFails("Update change\n\nPatch-set: 1\nLabel: Label1=+1,\n");
     assertParseFails(
-        "Update change\n\nPatch-set: 1\nLabel: Label1=-1,  577fb248e474018276351785930358ec0450e9f7 Gerrit User 2 <2@gerrit>\n");
+        "Update change\n\n"
+            + "Patch-set: 1\n"
+            + "Label: Label1=-1,  577fb248e474018276351785930358ec0450e9f7 Gerrit User 2"
+            + " <2@gerrit>\n");
     assertParseFails(
-        "Update change\n\nPatch-set: 1\nLabel: Label1=-1,  577fb248e474018276351785930358ec0450e9f7\n");
+        "Update change\n\n"
+            + "Patch-set: 1\n"
+            + "Label: Label1=-1,  577fb248e474018276351785930358ec0450e9f7\n");
     // UUID for removals is not supported.
     assertParseFails(
-        "Update change\n\nPatch-set: 1\nLabel: -Label1, 577fb248e474018276351785930358ec0450e9f7\n");
+        "Update change\n\n"
+            + "Patch-set: 1\n"
+            + "Label: -Label1, 577fb248e474018276351785930358ec0450e9f7\n");
     assertParseFails(
-        "Update change\n\nPatch-set: 1\nLabel: -Label1, 577fb248e474018276351785930358ec0450e9f7 Other Account <2@gerrit>\n");
+        "Update change\n\n"
+            + "Patch-set: 1\n"
+            + "Label: -Label1, 577fb248e474018276351785930358ec0450e9f7 Other Account"
+            + " <2@gerrit>\n");
   }
 
   @Test
@@ -210,7 +222,8 @@
             + "Copied-Label: -Label1 Account <1@gerrit>,Other Account <2@gerrit>\\n"
             + "Copied-Label: -Label1 Account <1@gerrit>\n"
             + "Copied-Label: Label1=+1 Gerrit User 1 (name,with, comma) <1@gerrit>\n"
-            + "Copied-Label: Label2=+1 Gerrit User 1 (name,with, comma) <1@gerrit>,Gerrit User 2 (name,with, comma) <2@gerrit>\n"
+            + "Copied-Label: Label2=+1 Gerrit User 1 (name,with, comma) <1@gerrit>,Gerrit User 2"
+            + " (name,with, comma) <2@gerrit>\n"
             + "Subject: This is a test change\n");
 
     assertParseFails("Update change\n\nPatch-set: 1\nCopied-Label: Label1=X\n");
@@ -238,14 +251,22 @@
             + "Branch: refs/heads/master\n"
             + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
             + "Patch-set: 1\n"
-            + "Copied-Label: Label2=+1, 577fb248e474018276351785930358ec0450e9f7 Gerrit User 1 <1@gerrit>\n"
-            + "Copied-Label: Label1=+1, 577fb248e474018276351785930358ec0450e9f7 Gerrit User 1 <1@gerrit>,Gerrit User 2 <2@gerrit>\n"
-            + "Copied-Label: Label3=+1, 577fb248e474018276351785930358ec0450e9f7 Gerrit User 1 <1@gerrit>,Gerrit User 2 <2@gerrit> :\"tag\"\n"
-            + "Copied-Label: Label4=+1, 577fb248e474018276351785930358ec0450e9f7 Gerrit User 1 <1@gerrit> :\"tag with characters %^#@^( *::!\"\n"
-            + "Copied-Label: Label4=+1, 577fb248e474018276351785930358ec0450e9f7 Gerrit User 1 <1@gerrit> :\"tag with uuid delimiter , \"\n"
-            + "Copied-Label: Label4=+1, 577fb248e474018276351785930358ec0450e9f7 Gerrit User 1 <1@gerrit>,Gerrit User 2 <2@gerrit> :\"tag with characters %^#@^( *::!\"\n"
-            + "Copied-Label: Label4=+1, 577fb248e474018276351785930358ec0450e9f7 Gerrit User 1 <1@gerrit>,Gerrit User 2 <2@gerrit> :\"tag with uuid delimiter , \"\n"
-            + "Copied-Label: Label4=+1, 577fb248e474018276351785930358ec0450e9f7 Gerrit User 1 (name,with, comma) <2@gerrit>,Gerrit User 3 (name,with, comma) <3@gerrit>\n"
+            + "Copied-Label: Label2=+1, 577fb248e474018276351785930358ec0450e9f7 Gerrit User 1"
+            + " <1@gerrit>\n"
+            + "Copied-Label: Label1=+1, 577fb248e474018276351785930358ec0450e9f7 Gerrit User 1"
+            + " <1@gerrit>,Gerrit User 2 <2@gerrit>\n"
+            + "Copied-Label: Label3=+1, 577fb248e474018276351785930358ec0450e9f7 Gerrit User 1"
+            + " <1@gerrit>,Gerrit User 2 <2@gerrit> :\"tag\"\n"
+            + "Copied-Label: Label4=+1, 577fb248e474018276351785930358ec0450e9f7 Gerrit User 1"
+            + " <1@gerrit> :\"tag with characters %^#@^( *::!\"\n"
+            + "Copied-Label: Label4=+1, 577fb248e474018276351785930358ec0450e9f7 Gerrit User 1"
+            + " <1@gerrit> :\"tag with uuid delimiter , \"\n"
+            + "Copied-Label: Label4=+1, 577fb248e474018276351785930358ec0450e9f7 Gerrit User 1"
+            + " <1@gerrit>,Gerrit User 2 <2@gerrit> :\"tag with characters %^#@^( *::!\"\n"
+            + "Copied-Label: Label4=+1, 577fb248e474018276351785930358ec0450e9f7 Gerrit User 1"
+            + " <1@gerrit>,Gerrit User 2 <2@gerrit> :\"tag with uuid delimiter , \"\n"
+            + "Copied-Label: Label4=+1, 577fb248e474018276351785930358ec0450e9f7 Gerrit User 1"
+            + " (name,with, comma) <2@gerrit>,Gerrit User 3 (name,with, comma) <3@gerrit>\n"
             + "Subject: This is a test change\n");
 
     assertParseSucceeds(
@@ -255,23 +276,34 @@
             + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
             + "Patch-set: 1\n"
             + "Copied-Label: Label2=+1, non-SHA1_UUID Gerrit User 1 <1@gerrit>\n"
-            + "Copied-Label: Label1=+1, non-SHA1_UUID Gerrit User 1 <1@gerrit>,Gerrit User 2 <2@gerrit>\n"
-            + "Copied-Label: Label3=+1, non-SHA1_UUID Gerrit User 1 <1@gerrit>,Gerrit User 2 <2@gerrit> :\"tag\"\n"
-            + "Copied-Label: Label4=+1, non-SHA1_UUID Gerrit User 1 <1@gerrit> :\"tag with characters %^#@^( *::!\"\n"
-            + "Copied-Label: Label4=+1, non-SHA1_UUID Gerrit User 1 <1@gerrit> :\"tag with uuid delimiter , \"\n"
-            + "Copied-Label: Label4=+1, non-SHA1_UUID Gerrit User 1 <1@gerrit>,Gerrit User 2 <2@gerrit> :\"tag with characters %^#@^( *::!\"\n"
-            + "Copied-Label: Label4=+1, non-SHA1_UUID Gerrit User 1 <1@gerrit>,Gerrit User 2 <2@gerrit> :\"tag with uuid delimiter , \"\n"
+            + "Copied-Label: Label1=+1, non-SHA1_UUID Gerrit User 1 <1@gerrit>,Gerrit User 2"
+            + " <2@gerrit>\n"
+            + "Copied-Label: Label3=+1, non-SHA1_UUID Gerrit User 1 <1@gerrit>,Gerrit User 2"
+            + " <2@gerrit> :\"tag\"\n"
+            + "Copied-Label: Label4=+1, non-SHA1_UUID Gerrit User 1 <1@gerrit> :\"tag with"
+            + " characters %^#@^( *::!\"\n"
+            + "Copied-Label: Label4=+1, non-SHA1_UUID Gerrit User 1 <1@gerrit> :\"tag with uuid"
+            + " delimiter , \"\n"
+            + "Copied-Label: Label4=+1, non-SHA1_UUID Gerrit User 1 <1@gerrit>,Gerrit User 2"
+            + " <2@gerrit> :\"tag with characters %^#@^( *::!\"\n"
+            + "Copied-Label: Label4=+1, non-SHA1_UUID Gerrit User 1 <1@gerrit>,Gerrit User 2"
+            + " <2@gerrit> :\"tag with uuid delimiter , \"\n"
             + "Subject: This is a test change\n");
 
     assertParseFails("Update change\n\nPatch-set: 1\nCopied-Label: Label1=+1,\n");
     assertParseFails("Update change\n\nPatch-set: 1\nCopied-Label: Label1=+1,\n");
     assertParseFails("Update change\n\nPatch-set: 1\nCopied-Label: Label1=+1 ,\n");
     assertParseFails(
-        "Copied-Label: Label1=+1,  577fb248e474018276351785930358ec0450e9f7 Gerrit User 1 <1@gerrit>,Gerrit User 2 <2@gerrit>\n\n");
+        "Copied-Label: Label1=+1,  577fb248e474018276351785930358ec0450e9f7 Gerrit User 1"
+            + " <1@gerrit>,Gerrit User 2 <2@gerrit>\n\n");
     assertParseFails(
-        "Update change\n\nPatch-set: 1\nCopied-Label: Label1=+1, 577fb248e474018276351785930358ec0450e9f7");
+        "Update change\n\n"
+            + "Patch-set: 1\n"
+            + "Copied-Label: Label1=+1, 577fb248e474018276351785930358ec0450e9f7");
     assertParseFails(
-        "Update change\n\nPatch-set: 1\nCopied-Label: Label1=+1, 577fb248e474018276351785930358ec0450e9f7 :\"tag\"\n");
+        "Update change\n\n"
+            + "Patch-set: 1\n"
+            + "Copied-Label: Label1=+1, 577fb248e474018276351785930358ec0450e9f7 :\"tag\"\n");
 
     // UUID for removals is not supported.
     assertParseFails(
@@ -355,26 +387,6 @@
   }
 
   @Test
-  public void parseAssignee() throws Exception {
-    assertParseSucceeds(
-        "Update change\n"
-            + "\n"
-            + "Branch: refs/heads/master\n"
-            + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
-            + "Patch-set: 1\n"
-            + "Assignee: Change Owner <1@gerrit>\n"
-            + "Subject: This is a test change\n");
-    assertParseSucceeds(
-        "Update change\n"
-            + "\n"
-            + "Branch: refs/heads/master\n"
-            + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
-            + "Patch-set: 2\n"
-            + "Assignee:\n"
-            + "Subject: This is a test change\n");
-  }
-
-  @Test
   public void parseTopic() throws Exception {
     assertParseSucceeds(
         "Update change\n"
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
index 976ffc8..9a29230 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
@@ -45,12 +45,10 @@
 import com.google.gerrit.entities.converter.PatchSetProtoConverter;
 import com.google.gerrit.proto.Entities;
 import com.google.gerrit.proto.Protos;
-import com.google.gerrit.server.AssigneeStatusUpdate;
 import com.google.gerrit.server.ReviewerByEmailSet;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.ReviewerStatusUpdate;
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto;
-import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto.AssigneeStatusUpdateProto;
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto.AttentionSetUpdateProto;
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto.ChangeColumnsProto;
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto.ReviewerByEmailSetEntryProto;
@@ -342,22 +340,24 @@
             .id(PatchSet.id(ID, 1))
             .commitId(ObjectId.fromString("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"))
             .uploader(Account.id(2000))
+            .realUploader(Account.id(2001))
             .createdOn(cols.createdOn())
             .build();
     Entities.PatchSet ps1Proto = PatchSetProtoConverter.INSTANCE.toProto(ps1);
     ByteString ps1Bytes = Protos.toByteString(ps1Proto);
-    assertThat(ps1Bytes.size()).isEqualTo(66);
+    assertThat(ps1Bytes.size()).isEqualTo(71);
 
     PatchSet ps2 =
         PatchSet.builder()
             .id(PatchSet.id(ID, 2))
             .commitId(ObjectId.fromString("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"))
             .uploader(Account.id(3000))
+            .realUploader(Account.id(3001))
             .createdOn(cols.lastUpdatedOn())
             .build();
     Entities.PatchSet ps2Proto = PatchSetProtoConverter.INSTANCE.toProto(ps2);
     ByteString ps2Bytes = Protos.toByteString(ps2Proto);
-    assertThat(ps2Bytes.size()).isEqualTo(66);
+    assertThat(ps2Bytes.size()).isEqualTo(71);
     assertThat(ps2Bytes).isNotEqualTo(ps1Bytes);
 
     assertRoundTrip(
@@ -804,37 +804,6 @@
   }
 
   @Test
-  public void serializeAssigneeUpdates() throws Exception {
-    assertRoundTrip(
-        newBuilder()
-            .assigneeUpdates(
-                ImmutableList.of(
-                    AssigneeStatusUpdate.create(
-                        Instant.ofEpochMilli(1212L),
-                        Account.id(1000),
-                        Optional.of(Account.id(2001))),
-                    AssigneeStatusUpdate.create(
-                        Instant.ofEpochMilli(3434L), Account.id(1000), Optional.empty())))
-            .build(),
-        ChangeNotesStateProto.newBuilder()
-            .setMetaId(SHA_BYTES)
-            .setChangeId(ID.get())
-            .setColumns(colsProto)
-            .addAssigneeUpdate(
-                AssigneeStatusUpdateProto.newBuilder()
-                    .setTimestampMillis(1212L)
-                    .setUpdatedBy(1000)
-                    .setCurrentAssignee(2001)
-                    .setHasCurrentAssignee(true))
-            .addAssigneeUpdate(
-                AssigneeStatusUpdateProto.newBuilder()
-                    .setTimestampMillis(3434L)
-                    .setUpdatedBy(1000)
-                    .setHasCurrentAssignee(false))
-            .build());
-  }
-
-  @Test
   public void serializeSubmitRecords() throws Exception {
     SubmitRecord sr1 = new SubmitRecord();
     sr1.status = SubmitRecord.Status.OK;
@@ -969,9 +938,6 @@
                 .put(
                     "allAttentionSetUpdates",
                     new TypeLiteral<ImmutableList<AttentionSetUpdate>>() {}.getType())
-                .put(
-                    "assigneeUpdates",
-                    new TypeLiteral<ImmutableList<AssigneeStatusUpdate>>() {}.getType())
                 .put("submitRecords", new TypeLiteral<ImmutableList<SubmitRecord>>() {}.getType())
                 .put("changeMessages", new TypeLiteral<ImmutableList<ChangeMessage>>() {}.getType())
                 .put(
@@ -1018,6 +984,7 @@
                 .put("id", PatchSet.Id.class)
                 .put("commitId", ObjectId.class)
                 .put("uploader", Account.Id.class)
+                .put("realUploader", Account.Id.class)
                 .put("createdOn", Instant.class)
                 .put("groups", new TypeLiteral<ImmutableList<String>>() {}.getType())
                 .put("pushCertificate", new TypeLiteral<Optional<String>>() {}.getType())
@@ -1085,19 +1052,6 @@
   }
 
   @Test
-  public void assigneeStatusUpdateMethods() throws Exception {
-    assertThatSerializedClass(AssigneeStatusUpdate.class)
-        .hasAutoValueMethods(
-            ImmutableMap.of(
-                "date",
-                Instant.class,
-                "updatedBy",
-                Account.Id.class,
-                "currentAssignee",
-                new TypeLiteral<Optional<Account.Id>>() {}.getType()));
-  }
-
-  @Test
   public void submitRecordFields() throws Exception {
     assertThatSerializedClass(SubmitRecord.class)
         .hasFields(
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
index c723725..50ff860 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
@@ -25,6 +25,7 @@
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.REMOVED;
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static com.google.gerrit.testing.TestActionRefUpdateContext.testRefAction;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.Comparator.comparing;
 import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
@@ -37,6 +38,7 @@
 import com.google.common.collect.Iterables;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Lists;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Address;
 import com.google.gerrit.entities.AttentionSetUpdate;
@@ -52,7 +54,6 @@
 import com.google.gerrit.entities.SubmissionId;
 import com.google.gerrit.entities.SubmitRecord;
 import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.server.AssigneeStatusUpdate;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.ReviewerSet;
@@ -824,7 +825,8 @@
         ImmutableList.of(", ", ":\"", ",", "!@#$%^\0&*):\" \n: \r\"#$@,. :");
     for (String strangeTag : strangeTags) {
       Change c = newChange();
-      CurrentUser otherUserAsOwner = userFactory.runAs(null, changeOwner.getAccountId(), otherUser);
+      CurrentUser otherUserAsOwner =
+          userFactory.runAs(/* remotePeer= */ null, changeOwner.getAccountId(), otherUser);
       ChangeUpdate update = newUpdate(c, otherUserAsOwner);
       update.putApproval(LabelId.CODE_REVIEW, (short) 2);
       update.setTag(strangeTag);
@@ -1112,7 +1114,7 @@
     ChangeUpdate update = newUpdate(c, changeOwner);
     update.putReviewer(changeOwner.getAccount().id(), REVIEWER);
     update.putReviewer(otherUser.getAccount().id(), CC);
-    update.commit();
+    testRefAction(() -> update.commit());
 
     ChangeNotes notes = newNotes(c);
     Instant ts = update.getWhen();
@@ -1472,91 +1474,6 @@
   }
 
   @Test
-  public void assigneeCommit() throws Exception {
-    Change c = newChange();
-    ChangeUpdate update = newUpdate(c, changeOwner);
-    update.setAssignee(otherUserId);
-    ObjectId result = update.commit();
-    assertThat(result).isNotNull();
-    try (RevWalk rw = new RevWalk(repo)) {
-      RevCommit commit = rw.parseCommit(update.getResult());
-      rw.parseBody(commit);
-      String strIdent = "Gerrit User " + otherUserId + " <" + otherUserId + "@" + serverId + ">";
-      assertThat(commit.getFullMessage()).contains("Assignee: " + strIdent);
-    }
-  }
-
-  @Test
-  public void assigneeChangeNotes() throws Exception {
-    Change c = newChange();
-    ChangeUpdate update = newUpdate(c, changeOwner);
-    update.setAssignee(otherUserId);
-    update.commit();
-
-    ChangeNotes notes = newNotes(c);
-    assertThat(notes.getChange().getAssignee()).isEqualTo(otherUserId);
-
-    update = newUpdate(c, changeOwner);
-    update.setAssignee(changeOwner.getAccountId());
-    update.commit();
-
-    notes = newNotes(c);
-    assertThat(notes.getChange().getAssignee()).isEqualTo(changeOwner.getAccountId());
-  }
-
-  @Test
-  public void pastAssigneesChangeNotes() throws Exception {
-    Change c = newChange();
-    ChangeUpdate update = newUpdate(c, changeOwner);
-    update.setAssignee(otherUserId);
-    update.commit();
-
-    update = newUpdate(c, changeOwner);
-    update.setAssignee(changeOwner.getAccountId());
-    update.commit();
-
-    update = newUpdate(c, changeOwner);
-    update.setAssignee(otherUserId);
-    update.commit();
-
-    update = newUpdate(c, changeOwner);
-    update.removeAssignee();
-    update.commit();
-
-    ChangeNotes notes = newNotes(c);
-    assertThat(notes.getPastAssignees()).hasSize(2);
-  }
-
-  @Test
-  public void assigneeStatusUpdateChangeNotes() throws Exception {
-    Change c = newChange();
-    ChangeUpdate update = newUpdate(c, otherUser);
-    update.setAssignee(otherUserId);
-    update.commit();
-
-    update = newUpdate(c, changeOwner);
-    update.removeAssignee();
-    update.commit();
-
-    update = newUpdate(c, changeOwner);
-    update.setAssignee(changeOwner.getAccountId());
-    update.commit();
-
-    update = newUpdate(c, changeOwner);
-    update.setAssignee(otherUserId);
-    update.commit();
-
-    ChangeNotes notes = newNotes(c);
-    ImmutableList<AssigneeStatusUpdate> statusUpdates = notes.getAssigneeUpdates();
-    assertThat(statusUpdates).hasSize(4);
-    assertThat(statusUpdates.get(3).updatedBy()).isEqualTo(otherUserId);
-    assertThat(statusUpdates.get(3).currentAssignee()).hasValue(otherUserId);
-    assertThat(statusUpdates.get(2).currentAssignee()).isEmpty();
-    assertThat(statusUpdates.get(1).currentAssignee()).hasValue(changeOwner.getAccountId());
-    assertThat(statusUpdates.get(0).currentAssignee()).hasValue(otherUserId);
-  }
-
-  @Test
   public void hashtagCommit() throws Exception {
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, changeOwner);
@@ -2020,7 +1937,7 @@
     try (NoteDbUpdateManager updateManager = updateManagerFactory.create(project)) {
       updateManager.add(update1);
       updateManager.add(update2);
-      updateManager.execute();
+      testRefAction(() -> updateManager.execute());
     }
 
     ChangeNotes notes = newNotes(c);
@@ -2069,7 +1986,7 @@
       update2.putApproval(LabelId.CODE_REVIEW, (short) 2);
       updateManager.add(update2);
 
-      updateManager.execute();
+      testRefAction(() -> updateManager.execute());
     }
 
     ChangeNotes notes = newNotes(c);
@@ -2129,7 +2046,7 @@
     try (NoteDbUpdateManager updateManager = updateManagerFactory.create(project)) {
       updateManager.add(update1);
       updateManager.add(update2);
-      updateManager.execute();
+      testRefAction(() -> updateManager.execute());
     }
 
     Ref ref1 = repo.exactRef(update1.getRefName());
@@ -3444,7 +3361,7 @@
     draftUpdate.putComment(comment2);
     try (NoteDbUpdateManager manager = updateManagerFactory.create(c.getProject())) {
       manager.add(draftUpdate);
-      manager.execute();
+      testRefAction(() -> manager.execute());
     }
 
     // Looking at drafts directly shows the zombie comment.
@@ -3508,7 +3425,7 @@
     try (NoteDbUpdateManager manager = updateManagerFactory.create(project)) {
       manager.add(update1);
       manager.add(update2);
-      manager.execute();
+      testRefAction(() -> manager.execute());
     }
 
     ChangeNotes notes = newNotes(c);
@@ -3948,6 +3865,7 @@
     return new String(rw.getObjectReader().open(dataId, OBJ_BLOB).getCachedBytes(), UTF_8);
   }
 
+  @Nullable
   private ObjectId exactRefAllUsers(String refName) throws Exception {
     try (Repository allUsersRepo = repoManager.openRepository(allUsers)) {
       Ref ref = allUsersRepo.exactRef(refName);
diff --git a/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java b/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java
index b53de89..25f2f98 100644
--- a/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java
+++ b/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java
@@ -354,7 +354,8 @@
   @Test
   public void realUser() throws Exception {
     Change c = newChange();
-    CurrentUser ownerAsOtherUser = userFactory.runAs(null, otherUserId, changeOwner);
+    CurrentUser ownerAsOtherUser =
+        userFactory.runAs(/* remotePeer= */ null, otherUserId, changeOwner);
     ChangeUpdate update = newUpdate(c, ownerAsOtherUser);
     update.setChangeMessage("Message on behalf of other user");
     update.commit();
diff --git a/javatests/com/google/gerrit/server/notedb/CommitRewriterTest.java b/javatests/com/google/gerrit/server/notedb/CommitRewriterTest.java
index 5e6803e..d13ccdd 100644
--- a/javatests/com/google/gerrit/server/notedb/CommitRewriterTest.java
+++ b/javatests/com/google/gerrit/server/notedb/CommitRewriterTest.java
@@ -21,6 +21,7 @@
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.CC;
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.REMOVED;
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
+import static com.google.gerrit.testing.TestActionRefUpdateContext.testRefAction;
 import static java.util.Objects.requireNonNull;
 
 import com.google.common.collect.ImmutableList;
@@ -90,7 +91,7 @@
       bru.addCommand(new ReceiveCommand(ref.getObjectId(), ObjectId.zeroId(), ref.getName()));
     }
 
-    RefUpdateUtil.executeChecked(bru, repo);
+    testRefAction(() -> RefUpdateUtil.executeChecked(bru, repo));
   }
 
   @Test
@@ -214,37 +215,37 @@
   @Test
   public void maxRefsToUpdate_coversAllInvalid_inMultipleBatches() throws Exception {
     testMaxRefsToUpdate(
-        /*numberOfInvalidChanges=*/ 11,
-        /*numberOfValidChanges=*/ 9,
-        /*maxRefsToUpdate=*/ 12,
-        /*maxRefsInBatch=*/ 2);
+        /* numberOfInvalidChanges= */ 11,
+        /* numberOfValidChanges= */ 9,
+        /* maxRefsToUpdate= */ 12,
+        /* maxRefsInBatch= */ 2);
   }
 
   @Test
   public void maxRefsToUpdate_coversAllInvalid_inSingleBatch() throws Exception {
     testMaxRefsToUpdate(
-        /*numberOfInvalidChanges=*/ 11,
-        /*numberOfValidChanges=*/ 9,
-        /*maxRefsToUpdate=*/ 12,
-        /*maxRefsInBatch=*/ 12);
+        /* numberOfInvalidChanges= */ 11,
+        /* numberOfValidChanges= */ 9,
+        /* maxRefsToUpdate= */ 12,
+        /* maxRefsInBatch= */ 12);
   }
 
   @Test
   public void moreInvalidRefs_thenMaxRefsToUpdate_inMultipleBatches() throws Exception {
     testMaxRefsToUpdate(
-        /*numberOfInvalidChanges=*/ 11,
-        /*numberOfValidChanges=*/ 9,
-        /*maxRefsToUpdate=*/ 10,
-        /*maxRefsInBatch=*/ 2);
+        /* numberOfInvalidChanges= */ 11,
+        /* numberOfValidChanges= */ 9,
+        /* maxRefsToUpdate= */ 10,
+        /* maxRefsInBatch= */ 2);
   }
 
   @Test
   public void moreInvalidRefs_thenMaxRefsToUpdate_inSingleBatch() throws Exception {
     testMaxRefsToUpdate(
-        /*numberOfInvalidChanges=*/ 11,
-        /*numberOfValidChanges=*/ 9,
-        /*maxRefsToUpdate=*/ 10,
-        /*maxRefsInBatch=*/ 10);
+        /* numberOfInvalidChanges= */ 11,
+        /* numberOfValidChanges= */ 9,
+        /* maxRefsToUpdate= */ 10,
+        /* maxRefsInBatch= */ 10);
   }
 
   private void testMaxRefsToUpdate(
@@ -333,7 +334,7 @@
     RevCommit invalidUpdateCommit =
         writeUpdate(
             RefNames.changeMetaRef(c.getId()),
-            getChangeUpdateBody(c, /*changeMessage=*/ null),
+            getChangeUpdateBody(c, /* changeMessage= */ null),
             invalidAuthorIdent);
     ChangeUpdate validUpdate = newUpdate(c, changeOwner);
     validUpdate.setChangeMessage("verification from jenkins");
@@ -399,7 +400,9 @@
 
     IdentifiedUser impersonatedChangeOwner =
         this.userFactory.runAs(
-            null, changeOwner.getAccountId(), requireNonNull(otherUser).getRealUser());
+            /* remotePeer= */ null,
+            changeOwner.getAccountId(),
+            requireNonNull(otherUser).getRealUser());
     ChangeUpdate impersonatedChangeMessageUpdate = newUpdate(c, impersonatedChangeOwner);
     impersonatedChangeMessageUpdate.setChangeMessage("Other comment on behalf of");
     impersonatedChangeMessageUpdate.commit();
@@ -470,7 +473,8 @@
                     // valid change message that should not be overwritten
                     getChangeUpdateBody(
                         c,
-                        "Removed cc <GERRIT_ACCOUNT_2> with the following votes:\n\n * Code-Review+2 by <GERRIT_ACCOUNT_2>",
+                        "Removed cc <GERRIT_ACCOUNT_2> with the following votes:\n\n"
+                            + " * Code-Review+2 by <GERRIT_ACCOUNT_2>",
                         "CC: " + reviewerIdentToFix),
                     getAuthorIdent(otherUser.getAccount())))
             .add(
@@ -675,7 +679,7 @@
                     RefNames.changeMetaRef(c.getId()),
                     getChangeUpdateBody(
                         c,
-                        /*changeMessage=*/ null,
+                        /* changeMessage= */ null,
                         "Label: -Verified " + approverIdentToFix,
                         "Label: Custom-Label-1=-1 " + approverIdentToFix,
                         "Label: Verified=+1",
@@ -688,7 +692,7 @@
                     RefNames.changeMetaRef(c.getId()),
                     getChangeUpdateBody(
                         c,
-                        /*changeMessage=*/ null,
+                        /* changeMessage= */ null,
                         "Label: -Verified " + changeOwnerIdentToFix,
                         "Label: Custom-Label-1=+1"),
                     getAuthorIdent(otherUser.getAccount())))
@@ -810,7 +814,7 @@
                     RefNames.changeMetaRef(c.getId()),
                     getChangeUpdateBody(
                         c,
-                        /*changeMessage=*/ "Removed Code-Review+2 by " + otherUser.getNameEmail(),
+                        /* changeMessage= */ "Removed Code-Review+2 by " + otherUser.getNameEmail(),
                         "Label: -Code-Review " + approverIdentToFix),
                     getAuthorIdent(changeOwner.getAccount())))
             .add(
@@ -818,7 +822,8 @@
                     RefNames.changeMetaRef(c.getId()),
                     getChangeUpdateBody(
                         c,
-                        /*changeMessage=*/ "Removed Custom-Label-1 by " + otherUser.getNameEmail(),
+                        /* changeMessage= */ "Removed Custom-Label-1 by "
+                            + otherUser.getNameEmail(),
                         "Label: -Custom-Label " + getValidIdentAsString(otherUser.getAccount())),
                     getAuthorIdent(changeOwner.getAccount())))
             .add(
@@ -826,7 +831,7 @@
                     RefNames.changeMetaRef(c.getId()),
                     getChangeUpdateBody(
                         c,
-                        /*changeMessage=*/ "Removed Verified+2 by " + changeOwner.getNameEmail(),
+                        /* changeMessage= */ "Removed Verified+2 by " + changeOwner.getNameEmail(),
                         "Label: -Verified"),
                     getAuthorIdent(changeOwner.getAccount())))
             .build();
@@ -926,7 +931,7 @@
         RefNames.changeMetaRef(c.getId()),
         getChangeUpdateBody(
             c,
-            /*changeMessage=*/ "Removed Verified+2 by " + otherUser.getNameEmail(),
+            /* changeMessage= */ "Removed Verified+2 by " + otherUser.getNameEmail(),
             "Label: -Verified"),
         invalidAuthorIdent);
 
@@ -959,19 +964,19 @@
         RefNames.changeMetaRef(c.getId()),
 
         // Even though footer is missing, accounts are matched among the account in change updates.
-        getChangeUpdateBody(c, /*changeMessage=*/ "Removed Verified-1 by Other Account (0002)"),
+        getChangeUpdateBody(c, /* changeMessage= */ "Removed Verified-1 by Other Account (0002)"),
         getAuthorIdent(changeOwner.getAccount()));
 
     writeUpdate(
         RefNames.changeMetaRef(c.getId()),
         getChangeUpdateBody(
-            c, /*changeMessage=*/ "Removed Verified+2 by " + changeOwner.getNameEmail()),
+            c, /* changeMessage= */ "Removed Verified+2 by " + changeOwner.getNameEmail()),
         getAuthorIdent(changeOwner.getAccount()));
 
     // No rewrite for default
     writeUpdate(
         RefNames.changeMetaRef(c.getId()),
-        getChangeUpdateBody(c, /*changeMessage=*/ "Removed Verified+2 by Gerrit Account"),
+        getChangeUpdateBody(c, /* changeMessage= */ "Removed Verified+2 by Gerrit Account"),
         getAuthorIdent(changeOwner.getAccount()));
 
     RunOptions options = new RunOptions();
@@ -1004,7 +1009,8 @@
     writeUpdate(
         RefNames.changeMetaRef(c.getId()),
         getChangeUpdateBody(
-            c, /*changeMessage=*/ "Removed Verified+2 by Renamed Change Owner <change@owner.com>"),
+            c,
+            /* changeMessage= */ "Removed Verified+2 by Renamed Change Owner <change@owner.com>"),
         getAuthorIdent(changeOwner.getAccount()));
 
     RunOptions options = new RunOptions();
@@ -1033,7 +1039,7 @@
     approvalUpdate.commit();
     writeUpdate(
         RefNames.changeMetaRef(c.getId()),
-        getChangeUpdateBody(c, /*changeMessage=*/ "Removed Verified+2 by Change Owner"),
+        getChangeUpdateBody(c, /* changeMessage= */ "Removed Verified+2 by Change Owner"),
         getAuthorIdent(changeOwner.getAccount()));
 
     RunOptions options = new RunOptions();
@@ -1070,17 +1076,17 @@
     writeUpdate(
         RefNames.changeMetaRef(c.getId()),
         getChangeUpdateBody(
-            c, /*changeMessage=*/ "Removed Verified+2 by Change Owner <other@test.com>"),
+            c, /* changeMessage= */ "Removed Verified+2 by Change Owner <other@test.com>"),
         getAuthorIdent(changeOwner.getAccount()));
     writeUpdate(
         RefNames.changeMetaRef(c.getId()),
         getChangeUpdateBody(
-            c, /*changeMessage=*/ "Removed Verified+2 by Change Owner <change@owner.com>"),
+            c, /* changeMessage= */ "Removed Verified+2 by Change Owner <change@owner.com>"),
         getAuthorIdent(changeOwner.getAccount()));
     writeUpdate(
         RefNames.changeMetaRef(c.getId()),
         getChangeUpdateBody(
-            c, /*changeMessage=*/ "Removed Verified-1 by Change Owner <other@test.com>"),
+            c, /* changeMessage= */ "Removed Verified-1 by Change Owner <other@test.com>"),
         getAuthorIdent(changeOwner.getAccount()));
 
     RunOptions options = new RunOptions();
@@ -1119,7 +1125,7 @@
         // Even though footer is missing, accounts are matched among the account in change updates.
         getChangeUpdateBody(
             c,
-            /*changeMessage=*/ "Removed the following votes:\n"
+            /* changeMessage= */ "Removed the following votes:\n"
                 + String.format("* Verified-1 by %s\n", otherUser.getNameEmail())),
         getAuthorIdent(changeOwner.getAccount()));
 
@@ -1127,7 +1133,7 @@
         RefNames.changeMetaRef(c.getId()),
         getChangeUpdateBody(
             c,
-            /*changeMessage=*/ "Removed the following votes:\n"
+            /* changeMessage= */ "Removed the following votes:\n"
                 + String.format("* Verified+2 by %s\n", changeOwner.getNameEmail())
                 + String.format("* Verified-1 by %s\n", changeOwner.getNameEmail())
                 + String.format("* Code-Review by %s\n", otherUser.getNameEmail())),
@@ -1138,7 +1144,7 @@
         RefNames.changeMetaRef(c.getId()),
         getChangeUpdateBody(
             c,
-            /*changeMessage=*/ "Removed the following votes:\n"
+            /* changeMessage= */ "Removed the following votes:\n"
                 + "* Verified+2 by Gerrit Account\n"
                 + "* Verified-1 by <GERRIT_ACCOUNT_2>\n"),
         getAuthorIdent(changeOwner.getAccount()));
@@ -1198,7 +1204,7 @@
             RefNames.changeMetaRef(c.getId()),
             getChangeUpdateBody(
                 c,
-                /*changeMessage=*/ null,
+                /* changeMessage= */ null,
                 // Only 'person_ident' fix is required
                 "Attention: "
                     + gson.toJson(
@@ -1352,27 +1358,51 @@
     assertThat(commitHistoryDiff.get(0))
         .isEqualTo(
             "@@ -8 +8 @@\n"
-                + "-Attention: {\"person_ident\":\"Gerrit User 2 \\u003c2@gerrit\\u003e\",\"operation\":\"ADD\",\"reason\":\"Added by Other Account using the hovercard menu\"}\n"
-                + "+Attention: {\"person_ident\":\"Gerrit User 2 \\u003c2@gerrit\\u003e\",\"operation\":\"ADD\",\"reason\":\"Added by someone using the hovercard menu\"}\n");
+                + "-Attention: {\"person_ident\":\"Gerrit User 2"
+                + " \\u003c2@gerrit\\u003e\",\"operation\":\"ADD\",\"reason\":\"Added by Other"
+                + " Account using the hovercard menu\"}\n"
+                + "+Attention: {\"person_ident\":\"Gerrit User 2"
+                + " \\u003c2@gerrit\\u003e\",\"operation\":\"ADD\",\"reason\":\"Added by someone"
+                + " using the hovercard menu\"}\n");
     assertThat(Arrays.asList(commitHistoryDiff.get(1).split("\n")))
         .containsExactly(
             "@@ -7,2 +7,2 @@",
-            "-Attention: {\"person_ident\":\"Gerrit User 1 \\u003c1@gerrit\\u003e\",\"operation\":\"ADD\",\"reason\":\"Other Account replied on the change\"}",
-            "-Attention: {\"person_ident\":\"Gerrit User 2 \\u003c2@gerrit\\u003e\",\"operation\":\"REMOVE\",\"reason\":\"Removed by Other Account using the hovercard menu\"}",
-            "+Attention: {\"person_ident\":\"Gerrit User 1 \\u003c1@gerrit\\u003e\",\"operation\":\"ADD\",\"reason\":\"Someone replied on the change\"}",
-            "+Attention: {\"person_ident\":\"Gerrit User 2 \\u003c2@gerrit\\u003e\",\"operation\":\"REMOVE\",\"reason\":\"Removed by someone using the hovercard menu\"}");
+            "-Attention: {\"person_ident\":\"Gerrit User 1"
+                + " \\u003c1@gerrit\\u003e\",\"operation\":\"ADD\",\"reason\":\"Other Account"
+                + " replied on the change\"}",
+            "-Attention: {\"person_ident\":\"Gerrit User 2"
+                + " \\u003c2@gerrit\\u003e\",\"operation\":\"REMOVE\",\"reason\":\"Removed by Other"
+                + " Account using the hovercard menu\"}",
+            "+Attention: {\"person_ident\":\"Gerrit User 1"
+                + " \\u003c1@gerrit\\u003e\",\"operation\":\"ADD\",\"reason\":\"Someone replied on"
+                + " the change\"}",
+            "+Attention: {\"person_ident\":\"Gerrit User 2"
+                + " \\u003c2@gerrit\\u003e\",\"operation\":\"REMOVE\",\"reason\":\"Removed by"
+                + " someone using the hovercard menu\"}");
     assertThat(Arrays.asList(commitHistoryDiff.get(2).split("\n")))
         .containsExactly(
             "@@ -7,2 +7,2 @@",
-            "-Attention: {\"person_ident\":\"Other Account \\u003c2@gerrit\\u003e\",\"operation\":\"ADD\",\"reason\":\"Added by someone using the hovercard menu\"}",
-            "-Attention: {\"person_ident\":\"Change Owner \\u003c1@gerrit\\u003e\",\"operation\":\"REMOVE\",\"reason\":\"Other Account replied on the change\"}",
-            "+Attention: {\"person_ident\":\"Gerrit User 2 \\u003c2@gerrit\\u003e\",\"operation\":\"ADD\",\"reason\":\"Added by someone using the hovercard menu\"}",
-            "+Attention: {\"person_ident\":\"Gerrit User 1 \\u003c1@gerrit\\u003e\",\"operation\":\"REMOVE\",\"reason\":\"Someone replied on the change\"}");
+            "-Attention: {\"person_ident\":\"Other Account"
+                + " \\u003c2@gerrit\\u003e\",\"operation\":\"ADD\",\"reason\":\"Added by someone"
+                + " using the hovercard menu\"}",
+            "-Attention: {\"person_ident\":\"Change Owner"
+                + " \\u003c1@gerrit\\u003e\",\"operation\":\"REMOVE\",\"reason\":\"Other Account"
+                + " replied on the change\"}",
+            "+Attention: {\"person_ident\":\"Gerrit User 2"
+                + " \\u003c2@gerrit\\u003e\",\"operation\":\"ADD\",\"reason\":\"Added by someone"
+                + " using the hovercard menu\"}",
+            "+Attention: {\"person_ident\":\"Gerrit User 1"
+                + " \\u003c1@gerrit\\u003e\",\"operation\":\"REMOVE\",\"reason\":\"Someone replied"
+                + " on the change\"}");
     assertThat(commitHistoryDiff.get(3))
         .isEqualTo(
             "@@ -7 +7 @@\n"
-                + "-Attention: {\"person_ident\":\"Gerrit User 1 \\u003c1@gerrit\\u003e\",\"operation\":\"REMOVE\",\"reason\":\"Removed by Other Account by clicking the attention icon\"}\n"
-                + "+Attention: {\"person_ident\":\"Gerrit User 1 \\u003c1@gerrit\\u003e\",\"operation\":\"REMOVE\",\"reason\":\"Removed by someone by clicking the attention icon\"}\n");
+                + "-Attention: {\"person_ident\":\"Gerrit User 1"
+                + " \\u003c1@gerrit\\u003e\",\"operation\":\"REMOVE\",\"reason\":\"Removed by Other"
+                + " Account by clicking the attention icon\"}\n"
+                + "+Attention: {\"person_ident\":\"Gerrit User 1"
+                + " \\u003c1@gerrit\\u003e\",\"operation\":\"REMOVE\",\"reason\":\"Removed by"
+                + " someone by clicking the attention icon\"}\n");
     BackfillResult secondRunResult = rewriter.backfillProject(project, repo, options);
     assertThat(secondRunResult.fixedRefDiff.keySet()).isEmpty();
     assertThat(secondRunResult.refsFailedToFix).isEmpty();
@@ -1481,13 +1511,15 @@
     commitsToFix.add(invalidCherryPickedMessageUpdate.commit());
     ChangeUpdate invalidRebasedMessageUpdate = newUpdate(c, changeOwner);
     invalidRebasedMessageUpdate.setChangeMessage(
-        "Change has been successfully rebased and submitted as e40dc1a50dc7f457a37579e2755374f3e1a5413b by "
+        "Change has been successfully rebased and submitted as"
+            + " e40dc1a50dc7f457a37579e2755374f3e1a5413b by "
             + changeOwner.getName());
 
     commitsToFix.add(invalidRebasedMessageUpdate.commit());
     ChangeUpdate validSubmitMessageUpdate = newUpdate(c, changeOwner);
     validSubmitMessageUpdate.setChangeMessage(
-        "Change has been successfully rebased and submitted as e40dc1a50dc7f457a37579e2755374f3e1a5413b");
+        "Change has been successfully rebased and submitted as"
+            + " e40dc1a50dc7f457a37579e2755374f3e1a5413b");
     validSubmitMessageUpdate.commit();
 
     Ref metaRefBeforeRewrite = repo.exactRef(RefNames.changeMetaRef(c.getId()));
@@ -1510,15 +1542,21 @@
     assertThat(changeMessages(notesBeforeRewrite))
         .containsExactly(
             "Change has been successfully merged by Change Owner",
-            "Change has been successfully cherry-picked as e40dc1a50dc7f457a37579e2755374f3e1a5413b by Change Owner",
-            "Change has been successfully rebased and submitted as e40dc1a50dc7f457a37579e2755374f3e1a5413b by Change Owner",
-            "Change has been successfully rebased and submitted as e40dc1a50dc7f457a37579e2755374f3e1a5413b");
+            "Change has been successfully cherry-picked as e40dc1a50dc7f457a37579e2755374f3e1a5413b"
+                + " by Change Owner",
+            "Change has been successfully rebased and submitted as"
+                + " e40dc1a50dc7f457a37579e2755374f3e1a5413b by Change Owner",
+            "Change has been successfully rebased and submitted as"
+                + " e40dc1a50dc7f457a37579e2755374f3e1a5413b");
     assertThat(changeMessages(notesAfterRewrite))
         .containsExactly(
             "Change has been successfully merged",
-            "Change has been successfully cherry-picked as e40dc1a50dc7f457a37579e2755374f3e1a5413b",
-            "Change has been successfully rebased and submitted as e40dc1a50dc7f457a37579e2755374f3e1a5413b",
-            "Change has been successfully rebased and submitted as e40dc1a50dc7f457a37579e2755374f3e1a5413b");
+            "Change has been successfully cherry-picked as"
+                + " e40dc1a50dc7f457a37579e2755374f3e1a5413b",
+            "Change has been successfully rebased and submitted as"
+                + " e40dc1a50dc7f457a37579e2755374f3e1a5413b",
+            "Change has been successfully rebased and submitted as"
+                + " e40dc1a50dc7f457a37579e2755374f3e1a5413b");
 
     Ref metaRefAfterRewrite = repo.exactRef(RefNames.changeMetaRef(c.getId()));
     assertThat(metaRefAfterRewrite.getObjectId()).isNotEqualTo(metaRefBeforeRewrite.getObjectId());
@@ -1534,11 +1572,15 @@
                 + "-Change has been successfully merged by Change Owner\n"
                 + "+Change has been successfully merged\n",
             "@@ -6 +6 @@\n"
-                + "-Change has been successfully cherry-picked as e40dc1a50dc7f457a37579e2755374f3e1a5413b by Change Owner\n"
-                + "+Change has been successfully cherry-picked as e40dc1a50dc7f457a37579e2755374f3e1a5413b\n",
+                + "-Change has been successfully cherry-picked as"
+                + " e40dc1a50dc7f457a37579e2755374f3e1a5413b by Change Owner\n"
+                + "+Change has been successfully cherry-picked as"
+                + " e40dc1a50dc7f457a37579e2755374f3e1a5413b\n",
             "@@ -6 +6 @@\n"
-                + "-Change has been successfully rebased and submitted as e40dc1a50dc7f457a37579e2755374f3e1a5413b by Change Owner\n"
-                + "+Change has been successfully rebased and submitted as e40dc1a50dc7f457a37579e2755374f3e1a5413b\n");
+                + "-Change has been successfully rebased and submitted as"
+                + " e40dc1a50dc7f457a37579e2755374f3e1a5413b by Change Owner\n"
+                + "+Change has been successfully rebased and submitted as"
+                + " e40dc1a50dc7f457a37579e2755374f3e1a5413b\n");
     BackfillResult secondRunResult = rewriter.backfillProject(project, repo, options);
     assertThat(secondRunResult.fixedRefDiff.keySet()).isEmpty();
     assertThat(secondRunResult.refsFailedToFix).isEmpty();
@@ -1608,7 +1650,7 @@
             RefNames.changeMetaRef(c.getId()),
             getChangeUpdateBody(
                 c,
-                /*changeMessage=*/ null,
+                /* changeMessage= */ null,
                 "Label: SUBM=+1",
                 "Submission-id: 5271-1496917120975-10a10df9",
                 "Submitted-with: NOT_READY",
@@ -1874,59 +1916,72 @@
     ChangeUpdate invalidOnReviewUpdate = newUpdate(c, changeOwner);
     invalidOnReviewUpdate.setChangeMessage(
         "Patch Set 1: Any-Label+2 Other-Label+2 Code-Review+2\n\n"
-            + "By voting Code-Review+2 the following files are now code-owner approved by Change Owner:\n"
+            + "By voting Code-Review+2 the following files are now code-owner approved by Change"
+            + " Owner:\n"
             + "   * file1.java\n"
             + "   * file2.ts\n"
-            + "By voting Any-Label+2 the code-owners submit requirement is overridden by Change Owner\n"
-            + "By voting Other-Label+2 the code-owners submit requirement is still overridden by Change Owner\n");
+            + "By voting Any-Label+2 the code-owners submit requirement is overridden by Change"
+            + " Owner\n"
+            + "By voting Other-Label+2 the code-owners submit requirement is still overridden by"
+            + " Change Owner\n");
     commitsToFix.add(invalidOnReviewUpdate.commit());
 
     ChangeUpdate invalidOnReviewUpdateAnyOrder = newUpdate(c, changeOwner);
     invalidOnReviewUpdateAnyOrder.setChangeMessage(
         "Patch Set 1: Any-Label+2 Other-Label+2 Code-Review+2\n\n"
-            + "By voting Any-Label+2 the code-owners submit requirement is overridden by Change Owner\n"
-            + "By voting Other-Label+2 the code-owners submit requirement is still overridden by Change Owner\n"
-            + "By voting Code-Review+2 the following files are now code-owner approved by Change Owner:\n"
+            + "By voting Any-Label+2 the code-owners submit requirement is overridden by Change"
+            + " Owner\n"
+            + "By voting Other-Label+2 the code-owners submit requirement is still overridden by"
+            + " Change Owner\n"
+            + "By voting Code-Review+2 the following files are now code-owner approved by Change"
+            + " Owner:\n"
             + "   * file1.java\n"
             + "   * file2.ts\n");
     commitsToFix.add(invalidOnReviewUpdateAnyOrder.commit());
     ChangeUpdate invalidOnApprovalUpdate = newUpdate(c, otherUser);
     invalidOnApprovalUpdate.setChangeMessage(
         "Patch Set 1: -Code-Review\n\n"
-            + "By removing the Code-Review+2 vote the following files are no longer explicitly code-owner approved by Other Account:\n"
+            + "By removing the Code-Review+2 vote the following files are no longer explicitly"
+            + " code-owner approved by Other Account:\n"
             + "   * file1.java\n"
             + "   * file2.ts\n"
-            + "\nThe listed files are still implicitly approved by Other Account.\n");
+            + "\n"
+            + "The listed files are still implicitly approved by Other Account.\n");
     commitsToFix.add(invalidOnApprovalUpdate.commit());
 
     ChangeUpdate invalidOnOverrideUpdate = newUpdate(c, changeOwner);
     invalidOnOverrideUpdate.setChangeMessage(
         "Patch Set 1: -Owners-Override\n\n"
             + "(1 comment)\n\n"
-            + "By removing the Owners-Override+1 vote the code-owners submit requirement is no longer overridden by Change Owner\n");
+            + "By removing the Owners-Override+1 vote the code-owners submit requirement is no"
+            + " longer overridden by Change Owner\n");
 
     commitsToFix.add(invalidOnOverrideUpdate.commit());
 
     ChangeUpdate partiallyValidOnReviewUpdate = newUpdate(c, changeOwner);
     partiallyValidOnReviewUpdate.setChangeMessage(
         "Patch Set 1: Any-Label+2 Code-Review+2\n\n"
-            + "By voting Code-Review+2 the following files are now code-owner approved by <GERRIT_ACCOUNT_1>:\n"
+            + "By voting Code-Review+2 the following files are now code-owner approved by"
+            + " <GERRIT_ACCOUNT_1>:\n"
             + "   * file1.java\n"
             + "   * file2.ts\n"
-            + "By voting Any-Label+2 the code-owners submit requirement is overridden by Change Owner\n");
+            + "By voting Any-Label+2 the code-owners submit requirement is overridden by Change"
+            + " Owner\n");
     commitsToFix.add(partiallyValidOnReviewUpdate.commit());
 
     ChangeUpdate validOnApprovalUpdate = newUpdate(c, changeOwner);
     validOnApprovalUpdate.setChangeMessage(
         "Patch Set 1: Code-Review-2\n\n"
-            + "By voting Code-Review-2 the following files are no longer explicitly code-owner approved by <GERRIT_ACCOUNT_1>:\n"
+            + "By voting Code-Review-2 the following files are no longer explicitly code-owner"
+            + " approved by <GERRIT_ACCOUNT_1>:\n"
             + "   * file4.java\n");
     validOnApprovalUpdate.commit();
 
     ChangeUpdate validOnOverrideUpdate = newUpdate(c, changeOwner);
     validOnOverrideUpdate.setChangeMessage(
         "Patch Set 1: Owners-Override+1\n\n"
-            + "By voting Owners-Override+1 the code-owners submit requirement is still overridden by <GERRIT_ACCOUNT_1>\n");
+            + "By voting Owners-Override+1 the code-owners submit requirement is still overridden"
+            + " by <GERRIT_ACCOUNT_1>\n");
     validOnOverrideUpdate.commit();
 
     Ref metaRefBeforeRewrite = repo.exactRef(RefNames.changeMetaRef(c.getId()));
@@ -1950,39 +2005,52 @@
     assertThat(changeMessages(notesAfterRewrite))
         .containsExactly(
             "Patch Set 1: Any-Label+2 Other-Label+2 Code-Review+2\n\n"
-                + "By voting Code-Review+2 the following files are now code-owner approved by <GERRIT_ACCOUNT_1>:\n"
+                + "By voting Code-Review+2 the following files are now code-owner approved by"
+                + " <GERRIT_ACCOUNT_1>:\n"
                 + "   * file1.java\n"
                 + "   * file2.ts\n"
-                + "By voting Any-Label+2 the code-owners submit requirement is overridden by <GERRIT_ACCOUNT_1>\n"
-                + "By voting Other-Label+2 the code-owners submit requirement is still overridden by <GERRIT_ACCOUNT_1>\n",
+                + "By voting Any-Label+2 the code-owners submit requirement is overridden by"
+                + " <GERRIT_ACCOUNT_1>\n"
+                + "By voting Other-Label+2 the code-owners submit requirement is still overridden"
+                + " by <GERRIT_ACCOUNT_1>\n",
             "Patch Set 1: Any-Label+2 Other-Label+2 Code-Review+2\n\n"
-                + "By voting Any-Label+2 the code-owners submit requirement is overridden by <GERRIT_ACCOUNT_1>\n"
-                + "By voting Other-Label+2 the code-owners submit requirement is still overridden by <GERRIT_ACCOUNT_1>\n"
-                + "By voting Code-Review+2 the following files are now code-owner approved by <GERRIT_ACCOUNT_1>:\n"
+                + "By voting Any-Label+2 the code-owners submit requirement is overridden by"
+                + " <GERRIT_ACCOUNT_1>\n"
+                + "By voting Other-Label+2 the code-owners submit requirement is still overridden"
+                + " by <GERRIT_ACCOUNT_1>\n"
+                + "By voting Code-Review+2 the following files are now code-owner approved by"
+                + " <GERRIT_ACCOUNT_1>:\n"
                 + "   * file1.java\n"
                 + "   * file2.ts\n",
             "Patch Set 1: -Code-Review\n"
                 + "\n"
-                + "By removing the Code-Review+2 vote the following files are no longer explicitly code-owner approved by <GERRIT_ACCOUNT_2>:\n"
+                + "By removing the Code-Review+2 vote the following files are no longer explicitly"
+                + " code-owner approved by <GERRIT_ACCOUNT_2>:\n"
                 + "   * file1.java\n"
                 + "   * file2.ts\n"
-                + "\nThe listed files are still implicitly approved by <GERRIT_ACCOUNT_2>.\n",
+                + "\n"
+                + "The listed files are still implicitly approved by <GERRIT_ACCOUNT_2>.\n",
             "Patch Set 1: -Owners-Override\n"
                 + "\n"
                 + "(1 comment)\n"
                 + "\n"
-                + "By removing the Owners-Override+1 vote the code-owners submit requirement is no longer overridden by <GERRIT_ACCOUNT_1>\n",
+                + "By removing the Owners-Override+1 vote the code-owners submit requirement is no"
+                + " longer overridden by <GERRIT_ACCOUNT_1>\n",
             "Patch Set 1: Any-Label+2 Code-Review+2\n\n"
-                + "By voting Code-Review+2 the following files are now code-owner approved by <GERRIT_ACCOUNT_1>:\n"
+                + "By voting Code-Review+2 the following files are now code-owner approved by"
+                + " <GERRIT_ACCOUNT_1>:\n"
                 + "   * file1.java\n"
                 + "   * file2.ts\n"
-                + "By voting Any-Label+2 the code-owners submit requirement is overridden by <GERRIT_ACCOUNT_1>\n",
+                + "By voting Any-Label+2 the code-owners submit requirement is overridden by"
+                + " <GERRIT_ACCOUNT_1>\n",
             "Patch Set 1: Code-Review-2\n\n"
-                + "By voting Code-Review-2 the following files are no longer explicitly code-owner approved by <GERRIT_ACCOUNT_1>:\n"
+                + "By voting Code-Review-2 the following files are no longer explicitly code-owner"
+                + " approved by <GERRIT_ACCOUNT_1>:\n"
                 + "   * file4.java\n",
             "Patch Set 1: Owners-Override+1\n"
                 + "\n"
-                + "By voting Owners-Override+1 the code-owners submit requirement is still overridden by <GERRIT_ACCOUNT_1>\n");
+                + "By voting Owners-Override+1 the code-owners submit requirement is still"
+                + " overridden by <GERRIT_ACCOUNT_1>\n");
 
     Ref metaRefAfterRewrite = repo.exactRef(RefNames.changeMetaRef(c.getId()));
     assertThat(metaRefAfterRewrite.getObjectId()).isNotEqualTo(metaRefBeforeRewrite.getObjectId());
@@ -1995,32 +2063,50 @@
     assertThat(commitHistoryDiff)
         .containsExactly(
             "@@ -8 +8 @@\n"
-                + "-By voting Code-Review+2 the following files are now code-owner approved by Change Owner:\n"
-                + "+By voting Code-Review+2 the following files are now code-owner approved by <GERRIT_ACCOUNT_1>:\n"
+                + "-By voting Code-Review+2 the following files are now code-owner approved by"
+                + " Change Owner:\n"
+                + "+By voting Code-Review+2 the following files are now code-owner approved by"
+                + " <GERRIT_ACCOUNT_1>:\n"
                 + "@@ -11,2 +11,2 @@\n"
-                + "-By voting Any-Label+2 the code-owners submit requirement is overridden by Change Owner\n"
-                + "-By voting Other-Label+2 the code-owners submit requirement is still overridden by Change Owner\n"
-                + "+By voting Any-Label+2 the code-owners submit requirement is overridden by <GERRIT_ACCOUNT_1>\n"
-                + "+By voting Other-Label+2 the code-owners submit requirement is still overridden by <GERRIT_ACCOUNT_1>\n",
+                + "-By voting Any-Label+2 the code-owners submit requirement is overridden by"
+                + " Change Owner\n"
+                + "-By voting Other-Label+2 the code-owners submit requirement is still overridden"
+                + " by Change Owner\n"
+                + "+By voting Any-Label+2 the code-owners submit requirement is overridden by"
+                + " <GERRIT_ACCOUNT_1>\n"
+                + "+By voting Other-Label+2 the code-owners submit requirement is still overridden"
+                + " by <GERRIT_ACCOUNT_1>\n",
             "@@ -8,3 +8,3 @@\n"
-                + "-By voting Any-Label+2 the code-owners submit requirement is overridden by Change Owner\n"
-                + "-By voting Other-Label+2 the code-owners submit requirement is still overridden by Change Owner\n"
-                + "-By voting Code-Review+2 the following files are now code-owner approved by Change Owner:\n"
-                + "+By voting Any-Label+2 the code-owners submit requirement is overridden by <GERRIT_ACCOUNT_1>\n"
-                + "+By voting Other-Label+2 the code-owners submit requirement is still overridden by <GERRIT_ACCOUNT_1>\n"
-                + "+By voting Code-Review+2 the following files are now code-owner approved by <GERRIT_ACCOUNT_1>:\n",
+                + "-By voting Any-Label+2 the code-owners submit requirement is overridden by"
+                + " Change Owner\n"
+                + "-By voting Other-Label+2 the code-owners submit requirement is still overridden"
+                + " by Change Owner\n"
+                + "-By voting Code-Review+2 the following files are now code-owner approved by"
+                + " Change Owner:\n"
+                + "+By voting Any-Label+2 the code-owners submit requirement is overridden by"
+                + " <GERRIT_ACCOUNT_1>\n"
+                + "+By voting Other-Label+2 the code-owners submit requirement is still overridden"
+                + " by <GERRIT_ACCOUNT_1>\n"
+                + "+By voting Code-Review+2 the following files are now code-owner approved by"
+                + " <GERRIT_ACCOUNT_1>:\n",
             "@@ -8 +8 @@\n"
-                + "-By removing the Code-Review+2 vote the following files are no longer explicitly code-owner approved by Other Account:\n"
-                + "+By removing the Code-Review+2 vote the following files are no longer explicitly code-owner approved by <GERRIT_ACCOUNT_2>:\n"
+                + "-By removing the Code-Review+2 vote the following files are no longer explicitly"
+                + " code-owner approved by Other Account:\n"
+                + "+By removing the Code-Review+2 vote the following files are no longer explicitly"
+                + " code-owner approved by <GERRIT_ACCOUNT_2>:\n"
                 + "@@ -12 +12 @@\n"
                 + "-The listed files are still implicitly approved by Other Account.\n"
                 + "+The listed files are still implicitly approved by <GERRIT_ACCOUNT_2>.\n",
             "@@ -10 +10 @@\n"
-                + "-By removing the Owners-Override+1 vote the code-owners submit requirement is no longer overridden by Change Owner\n"
-                + "+By removing the Owners-Override+1 vote the code-owners submit requirement is no longer overridden by <GERRIT_ACCOUNT_1>\n",
+                + "-By removing the Owners-Override+1 vote the code-owners submit requirement is no"
+                + " longer overridden by Change Owner\n"
+                + "+By removing the Owners-Override+1 vote the code-owners submit requirement is no"
+                + " longer overridden by <GERRIT_ACCOUNT_1>\n",
             "@@ -11 +11 @@\n"
-                + "-By voting Any-Label+2 the code-owners submit requirement is overridden by Change Owner\n"
-                + "+By voting Any-Label+2 the code-owners submit requirement is overridden by <GERRIT_ACCOUNT_1>\n");
+                + "-By voting Any-Label+2 the code-owners submit requirement is overridden by"
+                + " Change Owner\n"
+                + "+By voting Any-Label+2 the code-owners submit requirement is overridden by"
+                + " <GERRIT_ACCOUNT_1>\n");
     BackfillResult secondRunResult = rewriter.backfillProject(project, repo, options);
     assertThat(secondRunResult.fixedRefDiff.keySet()).isEmpty();
     assertThat(secondRunResult.refsFailedToFix).isEmpty();
@@ -2037,34 +2123,30 @@
             getChangeUpdateBody(c, "Assignee added", "Assignee: " + assigneeIdentToFix),
             getAuthorIdent(changeOwner.getAccount()));
 
-    ChangeUpdate changeAssigneeUpdate = newUpdate(c, changeOwner);
-    changeAssigneeUpdate.setAssignee(otherUserId);
-    changeAssigneeUpdate.commit();
-
-    ChangeUpdate removeAssigneeUpdate = newUpdate(c, changeOwner);
-    removeAssigneeUpdate.removeAssignee();
-    removeAssigneeUpdate.commit();
+    // Valid commits
+    writeUpdate(
+        RefNames.changeMetaRef(c.getId()),
+        getChangeUpdateBody(
+            c,
+            "Assignee added: <GERRIT_ACCOUNT_2>",
+            "Assignee: " + getValidIdentAsString(otherUser.getAccount())),
+        getAuthorIdent(changeOwner.getAccount()));
+    writeUpdate(
+        RefNames.changeMetaRef(c.getId()),
+        getChangeUpdateBody(c, "Assignee deleted: <GERRIT_ACCOUNT_2>", "Assignee:"),
+        getAuthorIdent(changeOwner.getAccount()));
 
     Ref metaRefBeforeRewrite = repo.exactRef(RefNames.changeMetaRef(c.getId()));
 
     ImmutableList<RevCommit> commitsBeforeRewrite = logMetaRef(repo, metaRefBeforeRewrite);
 
     int invalidCommitIndex = commitsBeforeRewrite.indexOf(invalidUpdateCommit);
-    ChangeNotes notesBeforeRewrite = newNotes(c);
 
     RunOptions options = new RunOptions();
     options.dryRun = false;
     BackfillResult result = rewriter.backfillProject(project, repo, options);
     assertThat(result.fixedRefDiff.keySet()).containsExactly(RefNames.changeMetaRef(c.getId()));
 
-    ChangeNotes notesAfterRewrite = newNotes(c);
-    assertThat(notesBeforeRewrite.getPastAssignees())
-        .containsExactly(changeOwner.getAccountId(), otherUser.getAccountId());
-    assertThat(notesBeforeRewrite.getChange().getAssignee()).isNull();
-    assertThat(notesAfterRewrite.getPastAssignees())
-        .containsExactly(changeOwner.getAccountId(), otherUser.getAccountId());
-    assertThat(notesAfterRewrite.getChange().getAssignee()).isNull();
-
     Ref metaRefAfterRewrite = repo.exactRef(RefNames.changeMetaRef(c.getId()));
     assertThat(metaRefAfterRewrite.getObjectId()).isNotEqualTo(metaRefBeforeRewrite.getObjectId());
 
@@ -2143,18 +2225,13 @@
     assertThat(result.fixedRefDiff.keySet()).containsExactly(RefNames.changeMetaRef(c.getId()));
 
     ChangeNotes notesAfterRewrite = newNotes(c);
-    assertThat(notesBeforeRewrite.getPastAssignees())
-        .containsExactly(changeOwner.getAccountId(), otherUser.getAccountId());
-    assertThat(notesBeforeRewrite.getChange().getAssignee()).isNull();
     assertThat(changeMessages(notesBeforeRewrite))
         .containsExactly(
             "Assignee added: Change Owner <change@owner.com>",
-            "Assignee changed from: Change Owner <change@owner.com> to: Other Account <other@account.com>",
+            "Assignee changed from: Change Owner <change@owner.com> to: Other Account"
+                + " <other@account.com>",
             "Assignee deleted: Other Account <other@account.com>");
 
-    assertThat(notesAfterRewrite.getPastAssignees())
-        .containsExactly(changeOwner.getAccountId(), otherUser.getAccountId());
-    assertThat(notesAfterRewrite.getChange().getAssignee()).isNull();
     assertThat(changeMessages(notesAfterRewrite))
         .containsExactly(
             "Assignee added: " + AccountTemplateUtil.getAccountTemplate(changeOwner.getAccountId()),
@@ -2179,7 +2256,8 @@
                 + "-Assignee added: Change Owner <change@owner.com>\n"
                 + "+Assignee added: <GERRIT_ACCOUNT_1>\n",
             "@@ -6 +6 @@\n"
-                + "-Assignee changed from: Change Owner <change@owner.com> to: Other Account <other@account.com>\n"
+                + "-Assignee changed from: Change Owner <change@owner.com> to: Other Account"
+                + " <other@account.com>\n"
                 + "+Assignee changed from: <GERRIT_ACCOUNT_1> to: <GERRIT_ACCOUNT_2>\n",
             "@@ -6 +6 @@\n"
                 + "-Assignee deleted: Other Account <other@account.com>\n"
@@ -2242,7 +2320,8 @@
                 + "-Assignee added: Change Owner\n"
                 + "+Assignee added: <GERRIT_ACCOUNT_1>\n",
             "@@ -6 +6 @@\n"
-                + "-Assignee changed from: Change Owner <change@owner.com> to: Other Account <other@account.com>\n"
+                + "-Assignee changed from: Change Owner <change@owner.com> to: Other Account"
+                + " <other@account.com>\n"
                 + "+Assignee changed from: <GERRIT_ACCOUNT_1> to: <GERRIT_ACCOUNT_2>\n",
             "@@ -6 +6 @@\n"
                 + "-Assignee deleted: Other Account\n"
@@ -2278,17 +2357,12 @@
     ImmutableList<RevCommit> commitsBeforeRewrite = logMetaRef(repo, metaRefBeforeRewrite);
 
     int invalidCommitIndex = commitsBeforeRewrite.indexOf(invalidUpdateCommit);
-    ChangeNotes notesBeforeRewrite = newNotes(c);
 
     RunOptions options = new RunOptions();
     options.dryRun = false;
     BackfillResult result = rewriter.backfillProject(project, repo, options);
     assertThat(result.fixedRefDiff.keySet()).containsExactly(RefNames.changeMetaRef(c.getId()));
 
-    ChangeNotes notesAfterRewrite = newNotes(c);
-    assertThat(notesBeforeRewrite.getChange().getAssignee()).isEqualTo(otherUserId);
-    assertThat(notesAfterRewrite.getChange().getAssignee()).isEqualTo(otherUserId);
-
     Ref metaRefAfterRewrite = repo.exactRef(RefNames.changeMetaRef(c.getId()));
     assertThat(metaRefAfterRewrite.getObjectId()).isNotEqualTo(metaRefBeforeRewrite.getObjectId());
 
diff --git a/javatests/com/google/gerrit/server/notedb/RepoSequenceTest.java b/javatests/com/google/gerrit/server/notedb/RepoSequenceTest.java
index 0c9f731..1b2d906 100644
--- a/javatests/com/google/gerrit/server/notedb/RepoSequenceTest.java
+++ b/javatests/com/google/gerrit/server/notedb/RepoSequenceTest.java
@@ -17,6 +17,7 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static com.google.gerrit.testing.TestActionRefUpdateContext.testRefAction;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
 
@@ -385,7 +386,9 @@
       ins.flush();
       RefUpdate ru = repo.updateRef(refName);
       ru.setNewObjectId(newId);
-      assertThat(ru.forceUpdate()).isAnyOf(RefUpdate.Result.NEW, RefUpdate.Result.FORCED);
+      testRefAction(
+          () ->
+              assertThat(ru.forceUpdate()).isAnyOf(RefUpdate.Result.NEW, RefUpdate.Result.FORCED));
       return newId;
     } catch (IOException e) {
       throw new RuntimeException(e);
diff --git a/javatests/com/google/gerrit/server/project/ProjectConfigTest.java b/javatests/com/google/gerrit/server/project/ProjectConfigTest.java
index ef92139..3b7ad1e 100644
--- a/javatests/com/google/gerrit/server/project/ProjectConfigTest.java
+++ b/javatests/com/google/gerrit/server/project/ProjectConfigTest.java
@@ -537,14 +537,14 @@
     StoredCommentLinkInfo cm =
         StoredCommentLinkInfo.builder("Test")
             .setMatch("abc.*")
-            .setHtml("<a>link</a>")
+            .setLink("link")
             .setEnabled(true)
             .setOverrideOnly(false)
             .build();
     cfg.addCommentLinkSection(cm);
     rev = commit(cfg);
     assertThat(text(rev, "project.config"))
-        .isEqualTo("[commentlink \"Test\"]\n\tmatch = abc.*\n\thtml = <a>link</a>\n");
+        .isEqualTo("[commentlink \"Test\"]\n\tmatch = abc.*\n\tlink = link\n");
   }
 
   @Test
@@ -722,7 +722,7 @@
   }
 
   @Test
-  public void readCommentLinksNoHtmlOrLinkButEnabled() throws Exception {
+  public void readCommentLinksNoLinkButEnabled() throws Exception {
     RevCommit rev =
         tr.commit().add("project.config", "[commentlink \"bugzilla\"]\n \tenabled = true").create();
     ProjectConfig cfg = read(rev);
@@ -732,7 +732,7 @@
   }
 
   @Test
-  public void readCommentLinksNoHtmlOrLinkAndDisabled() throws Exception {
+  public void readCommentLinksNoLinkAndDisabled() throws Exception {
     RevCommit rev =
         tr.commit()
             .add("project.config", "[commentlink \"bugzilla\"]\n \tenabled = false")
@@ -744,7 +744,7 @@
   }
 
   @Test
-  public void readCommentLinksNoHtmlOrLinkAndMissingEnabled() throws Exception {
+  public void readCommentLinksMissingEnabled() throws Exception {
     RevCommit rev =
         tr.commit()
             .add(
@@ -792,26 +792,7 @@
   }
 
   @Test
-  public void readCommentLinkRawHtml() throws Exception {
-    RevCommit rev =
-        tr.commit()
-            .add(
-                "project.config",
-                "[commentlink \"bugzilla\"]\n"
-                    + "\tmatch = \"(bugs#?)(d+)\"\n"
-                    + "\thtml = http://bugs.example.com/show_bug.cgi?id=$2")
-            .create();
-    ProjectConfig cfg = read(rev);
-    assertThat(cfg.getCommentLinkSections()).isEmpty();
-    assertThat(cfg.getValidationErrors())
-        .containsExactly(
-            ValidationError.create(
-                "project.config: Error in pattern \"(bugs#?)(d+)\" in commentlink.bugzilla.match: "
-                    + "Raw html replacement not allowed"));
-  }
-
-  @Test
-  public void readCommentLinkMatchButNoHtmlOrLink() throws Exception {
+  public void readCommentLinkMatchButNoLink() throws Exception {
     RevCommit rev =
         tr.commit()
             .add("project.config", "[commentlink \"bugzilla\"]\n" + "\tmatch = \"(bugs#?)(d+)\"\n")
@@ -822,7 +803,7 @@
         .containsExactly(
             ValidationError.create(
                 "project.config: Error in pattern \"(bugs#?)(d+)\" in commentlink.bugzilla.match: "
-                    + "commentlink.bugzilla must have either link or html"));
+                    + "commentlink.bugzilla must have link specified"));
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java b/javatests/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java
index b0e705b..aea4b95 100644
--- a/javatests/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java
+++ b/javatests/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java
@@ -17,6 +17,7 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.common.truth.Truth8.assertThat;
+import static com.google.common.truth.TruthJUnit.assume;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.util.stream.Collectors.toList;
 import static org.junit.Assert.fail;
@@ -24,6 +25,7 @@
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Streams;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.GerritApi;
@@ -32,10 +34,12 @@
 import com.google.gerrit.extensions.api.access.PermissionRuleInfo;
 import com.google.gerrit.extensions.api.access.ProjectAccessInput;
 import com.google.gerrit.extensions.api.accounts.Accounts.QueryRequest;
+import com.google.gerrit.extensions.api.changes.ReviewerInput;
 import com.google.gerrit.extensions.api.groups.GroupInput;
 import com.google.gerrit.extensions.api.projects.ProjectInput;
 import com.google.gerrit.extensions.client.ListAccountsOption;
 import com.google.gerrit.extensions.client.ProjectWatchInfo;
+import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.common.AccountExternalIdInfo;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
@@ -90,6 +94,7 @@
 import java.util.HashMap;
 import java.util.Iterator;
 import java.util.List;
+import java.util.Locale;
 import java.util.Optional;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.Repository;
@@ -238,7 +243,7 @@
 
     assertQuery(user5.email, user5);
     assertQuery("email:" + user5.email, user5);
-    assertQuery("email:" + user5.email.toUpperCase(), user5);
+    assertQuery("email:" + user5.email.toUpperCase(Locale.US), user5);
   }
 
   @Test
@@ -277,6 +282,7 @@
 
   @Test
   public void byUsername() throws Exception {
+    assume().that(hasIndexByUsername()).isTrue();
     AccountInfo user1 = newAccount("myuser");
 
     assertQuery("notexisting");
@@ -284,7 +290,7 @@
 
     assertQuery(user1.username, user1);
     assertQuery("username:" + user1.username, user1);
-    assertQuery("username:" + user1.username.toUpperCase(), user1);
+    assertQuery("username:" + user1.username.toUpperCase(Locale.US), user1);
   }
 
   @Test
@@ -387,6 +393,46 @@
   }
 
   @Test
+  public void byCanSee_privateChange() throws Exception {
+    String domain = name("test.com");
+    AccountInfo user1 = newAccountWithEmail("account1", "account1@" + domain);
+    AccountInfo user2 = newAccountWithEmail("account2", "account2@" + domain);
+    AccountInfo user3 = newAccountWithEmail("account3", "account3@" + domain);
+    AccountInfo user4 = newAccountWithEmail("account4", "account4@" + domain);
+
+    Project.NameKey p = createProject(name("p"));
+
+    // Create the change as User1
+    requestContext.setContext(newRequestContext(Account.id(user1._accountId)));
+    ChangeInfo c = createPrivateChange(p);
+    assertThat(c.owner).isEqualTo(user1);
+
+    // Add user2 as a reviewer, user3 as a CC, and leave user4 dangling.
+    addReviewer(c.changeId, user2.email, ReviewerState.REVIEWER);
+    addReviewer(c.changeId, user3.email, ReviewerState.CC);
+
+    // Request as the owner
+    requestContext.setContext(newRequestContext(Account.id(user1._accountId)));
+    assertQuery("cansee:" + c.changeId, user1, user2, user3);
+
+    // Request as the reviewer
+    requestContext.setContext(newRequestContext(Account.id(user2._accountId)));
+    assertQuery("cansee:" + c.changeId, user1, user2, user3);
+
+    // Request as the CC
+    requestContext.setContext(newRequestContext(Account.id(user3._accountId)));
+    assertQuery("cansee:" + c.changeId, user1, user2, user3);
+
+    // Request as an account not in {owner, reviewer, CC}
+    requestContext.setContext(newRequestContext(Account.id(user4._accountId)));
+    BadRequestException exception =
+        assertThrows(BadRequestException.class, () -> newQuery("cansee:" + c.changeId).get());
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo(String.format("change %s not found", c.changeId));
+  }
+
+  @Test
   public void byWatchedProject() throws Exception {
     Project.NameKey p = createProject(name("p"));
     Project.NameKey p2 = createProject(name("p2"));
@@ -517,7 +563,7 @@
   public void withDetails() throws Exception {
     AccountInfo user1 = newAccount("myuser", "My User", "my.user@example.com", true);
 
-    List<AccountInfo> result = assertQuery(user1.username, user1);
+    List<AccountInfo> result = assertQuery(getDefaultSearch(user1), user1);
     AccountInfo ai = result.get(0);
     assertThat(ai._accountId).isEqualTo(user1._accountId);
     assertThat(ai.name).isNull();
@@ -525,7 +571,9 @@
     assertThat(ai.email).isNull();
     assertThat(ai.avatars).isNull();
 
-    result = assertQuery(newQuery(user1.username).withOption(ListAccountsOption.DETAILS), user1);
+    result =
+        assertQuery(
+            newQuery(getDefaultSearch(user1)).withOption(ListAccountsOption.DETAILS), user1);
     ai = result.get(0);
     assertThat(ai._accountId).isEqualTo(user1._accountId);
     assertThat(ai.name).isEqualTo(user1.name);
@@ -540,25 +588,29 @@
     String[] secondaryEmails = new String[] {"bar@example.com", "foo@example.com"};
     addEmails(user1, secondaryEmails);
 
-    List<AccountInfo> result = assertQuery(user1.username, user1);
+    List<AccountInfo> result = assertQuery(getDefaultSearch(user1), user1);
     assertThat(result.get(0).secondaryEmails).isNull();
 
-    result = assertQuery(newQuery(user1.username).withSuggest(true), user1);
-    assertThat(result.get(0).secondaryEmails)
-        .containsExactlyElementsIn(Arrays.asList(secondaryEmails))
-        .inOrder();
-
-    result = assertQuery(newQuery(user1.username).withOption(ListAccountsOption.DETAILS), user1);
-    assertThat(result.get(0).secondaryEmails).isNull();
-
-    result = assertQuery(newQuery(user1.username).withOption(ListAccountsOption.ALL_EMAILS), user1);
+    result = assertQuery(newQuery(getDefaultSearch(user1)).withSuggest(true), user1);
     assertThat(result.get(0).secondaryEmails)
         .containsExactlyElementsIn(Arrays.asList(secondaryEmails))
         .inOrder();
 
     result =
         assertQuery(
-            newQuery(user1.username)
+            newQuery(getDefaultSearch(user1)).withOption(ListAccountsOption.DETAILS), user1);
+    assertThat(result.get(0).secondaryEmails).isNull();
+
+    result =
+        assertQuery(
+            newQuery(getDefaultSearch(user1)).withOption(ListAccountsOption.ALL_EMAILS), user1);
+    assertThat(result.get(0).secondaryEmails)
+        .containsExactlyElementsIn(Arrays.asList(secondaryEmails))
+        .inOrder();
+
+    result =
+        assertQuery(
+            newQuery(getDefaultSearch(user1))
                 .withOptions(ListAccountsOption.DETAILS, ListAccountsOption.ALL_EMAILS),
             user1);
     assertThat(result.get(0).secondaryEmails)
@@ -576,21 +628,22 @@
 
     requestContext.setContext(newRequestContext(Account.id(user._accountId)));
 
-    List<AccountInfo> result = newQuery(otherUser.username).withSuggest(true).get();
+    List<AccountInfo> result = newQuery(getDefaultSearch(otherUser)).withSuggest(true).get();
     assertThat(result.get(0).secondaryEmails).isNull();
     assertThrows(
         AuthException.class,
-        () -> newQuery(otherUser.username).withOption(ListAccountsOption.ALL_EMAILS).get());
+        () ->
+            newQuery(getDefaultSearch(otherUser)).withOption(ListAccountsOption.ALL_EMAILS).get());
   }
 
   @Test
   public void asAnonymous() throws Exception {
-    AccountInfo user1 = newAccount("user1");
+    AccountInfo user1 = newAccount("user1", "user1@gerrit.com", /*active=*/ true);
 
     setAnonymous();
     assertQuery("9999999");
     assertQuery("self");
-    assertQuery("username:" + user1.username, user1);
+    assertQuery("email:" + user1.email, user1);
   }
 
   // reindex permissions are tested by {@link AccountIT#reindexPermissions}
@@ -631,7 +684,12 @@
             .getRaw(
                 Account.id(userInfo._accountId),
                 QueryOptions.create(
-                    IndexConfig.fromConfig(config).build(), 0, 1, schema.getStoredFields()));
+                    config != null
+                        ? IndexConfig.fromConfig(config).build()
+                        : IndexConfig.createDefault(),
+                    0,
+                    1,
+                    schema.getStoredFields()));
 
     assertThat(rawFields).isPresent();
     if (schema.hasField(AccountField.ID_FIELD_SPEC)) {
@@ -649,6 +707,11 @@
       assertThat(extId).isPresent();
       blobs.add(new ByteArrayWrapper(extId.get().toByteArray()));
     }
+
+    // Some installations do not store EXTERNAL_ID_STATE_SPEC
+    if (!schema.hasField(AccountField.EXTERNAL_ID_STATE_SPEC)) {
+      return;
+    }
     Iterable<byte[]> externalIdStates =
         rawFields.get().<Iterable<byte[]>>getValue(AccountField.EXTERNAL_ID_STATE_SPEC);
     assertThat(externalIdStates).hasSize(blobs.size());
@@ -656,6 +719,21 @@
         .containsExactlyElementsIn(blobs);
   }
 
+  private String getDefaultSearch(AccountInfo user) {
+    return hasIndexByUsername() ? user.username : user.name;
+  }
+
+  /**
+   * Returns 'true' is {@link AccountField#USERNAME_FIELD} is indexed.
+   *
+   * <p>Some installations do not index {@link AccountField#USERNAME_FIELD}, since they do not use
+   * {@link ExternalId#SCHEME_USERNAME}
+   */
+  private boolean hasIndexByUsername() {
+    Schema<AccountState> schema = indexes.getSearchIndex().getSchema();
+    return schema.hasField(AccountField.USERNAME_SPEC);
+  }
+
   protected AccountInfo newAccount(String username) throws Exception {
     return newAccountWithEmail(username, null);
   }
@@ -709,6 +787,15 @@
     gApi.projects().name(project.get()).access(in);
   }
 
+  protected ChangeInfo createPrivateChange(Project.NameKey project) throws RestApiException {
+    ChangeInput in = new ChangeInput();
+    in.subject = "A change";
+    in.project = project.get();
+    in.branch = "master";
+    in.isPrivate = true;
+    return gApi.changes().create(in).get();
+  }
+
   protected ChangeInfo createChange(Project.NameKey project) throws RestApiException {
     ChangeInput in = new ChangeInput();
     in.subject = "A change";
@@ -717,6 +804,14 @@
     return gApi.changes().create(in).get();
   }
 
+  protected void addReviewer(String changeId, String email, ReviewerState state)
+      throws RestApiException {
+    ReviewerInput reviewerInput = new ReviewerInput();
+    reviewerInput.reviewer = email;
+    reviewerInput.state = state;
+    gApi.changes().id(changeId).addReviewer(reviewerInput);
+  }
+
   protected GroupInfo createGroup(String name, AccountInfo... members) throws RestApiException {
     GroupInput in = new GroupInput();
     in.name = name;
@@ -742,6 +837,7 @@
     return "\"" + s + "\"";
   }
 
+  @Nullable
   protected String name(String name) {
     if (name == null) {
       return null;
diff --git a/javatests/com/google/gerrit/server/query/account/BUILD b/javatests/com/google/gerrit/server/query/account/BUILD
index c255f5d..c781d8b 100644
--- a/javatests/com/google/gerrit/server/query/account/BUILD
+++ b/javatests/com/google/gerrit/server/query/account/BUILD
@@ -13,6 +13,7 @@
         "//prolog:gerrit-prolog-common",
     ],
     deps = [
+        "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/index",
diff --git a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
index f6e7ede..5d83949 100644
--- a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
+++ b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
@@ -26,6 +26,7 @@
 import static com.google.gerrit.server.project.testing.TestLabels.label;
 import static com.google.gerrit.server.project.testing.TestLabels.value;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static com.google.gerrit.testing.TestActionRefUpdateContext.testRefAction;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.concurrent.TimeUnit.HOURS;
 import static java.util.concurrent.TimeUnit.MILLISECONDS;
@@ -46,6 +47,7 @@
 import com.google.common.collect.Multimaps;
 import com.google.common.collect.Streams;
 import com.google.common.truth.ThrowableSubject;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.acceptance.ExtensionRegistry;
 import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
 import com.google.gerrit.acceptance.FakeSubmitRule;
@@ -69,7 +71,6 @@
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.GerritApi;
-import com.google.gerrit.extensions.api.changes.AssigneeInput;
 import com.google.gerrit.extensions.api.changes.AttentionSetInput;
 import com.google.gerrit.extensions.api.changes.ChangeApi;
 import com.google.gerrit.extensions.api.changes.Changes.QueryRequest;
@@ -113,6 +114,7 @@
 import com.google.gerrit.server.account.AuthRequest;
 import com.google.gerrit.server.account.ListGroupMembership;
 import com.google.gerrit.server.account.VersionedAccountQueries;
+import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.account.externalids.ExternalIdFactory;
 import com.google.gerrit.server.change.ChangeInserter;
 import com.google.gerrit.server.change.ChangeTriplet;
@@ -120,6 +122,7 @@
 import com.google.gerrit.server.change.PatchSetInserter;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.group.testing.TestGroupBackend;
 import com.google.gerrit.server.index.change.ChangeField;
@@ -127,6 +130,7 @@
 import com.google.gerrit.server.index.change.ChangeIndexer;
 import com.google.gerrit.server.index.change.IndexedChangeQuery;
 import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.notedb.Sequences;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectConfig;
@@ -138,8 +142,7 @@
 import com.google.gerrit.server.util.ThreadLocalRequestContext;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.testing.GerritServerTests;
-import com.google.gerrit.testing.InMemoryRepositoryManager;
-import com.google.gerrit.testing.InMemoryRepositoryManager.Repo;
+import com.google.gerrit.testing.TestChanges;
 import com.google.gerrit.testing.TestTimeUtil;
 import com.google.inject.Inject;
 import com.google.inject.Injector;
@@ -153,6 +156,7 @@
 import java.util.Iterator;
 import java.util.List;
 import java.util.Map;
+import java.util.Optional;
 import java.util.concurrent.TimeUnit;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
@@ -162,7 +166,7 @@
 import org.eclipse.jgit.lib.ObjectReader;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.util.SystemReader;
@@ -188,7 +192,7 @@
   @Inject protected ChangeIndexer indexer;
   @Inject protected ExtensionRegistry extensionRegistry;
   @Inject protected IndexConfig indexConfig;
-  @Inject protected InMemoryRepositoryManager repoManager;
+  @Inject protected GitRepositoryManager repoManager;
   @Inject protected Provider<AnonymousUser> anonymousUserProvider;
   @Inject protected Provider<InternalChangeQuery> queryProvider;
   @Inject protected ChangeNotes.Factory notesFactory;
@@ -212,11 +216,20 @@
 
   protected Injector injector;
   protected LifecycleManager lifecycle;
+
+  /**
+   * Index tests should not use username in query assert, since some backends do not use {@link
+   * ExternalId#SCHEME_USERNAME}
+   */
   protected Account.Id userId;
+
   protected CurrentUser user;
+  protected Account userAccount;
 
   private String systemTimeZone;
 
+  protected TestRepository<Repository> repo;
+
   protected abstract Injector createInjector();
 
   @Before
@@ -232,6 +245,10 @@
 
   @After
   public void cleanUp() {
+    if (repo != null) {
+      repo.close();
+      repo = null;
+    }
     lifecycle.stop();
   }
 
@@ -258,8 +275,9 @@
     return () -> requestUser;
   }
 
-  protected void resetUser() {
+  protected void resetUser() throws ConfigInvalidException, IOException {
     user = userFactory.create(userId);
+    userAccount = accounts.get(userId).get().account();
     requestContext.setContext(newRequestContext(userId));
   }
 
@@ -295,9 +313,9 @@
 
   @Test
   public void byId() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChange(repo));
-    Change change2 = insert(repo, newChange(repo));
+    repo = createAndOpenProject("repo");
+    Change change1 = insert("repo", newChange(repo));
+    Change change2 = insert("repo", newChange(repo));
 
     assertQuery("12345");
     assertQuery(change1.getId().get(), change1);
@@ -306,8 +324,8 @@
 
   @Test
   public void byKey() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change = insert(repo, newChange(repo));
+    repo = createAndOpenProject("repo");
+    Change change = insert("repo", newChange(repo));
     String key = change.getKey().get();
 
     assertQuery("I0000000000000000000000000000000000000000");
@@ -319,8 +337,8 @@
 
   @Test
   public void byTriplet() throws Exception {
-    TestRepository<Repo> repo = createProject("iabcde");
-    Change change = insert(repo, newChangeForBranch(repo, "branch"));
+    repo = createAndOpenProject("iabcde");
+    Change change = insert("iabcde", newChangeForBranch(repo, "branch"));
     String k = change.getKey().get();
 
     assertQuery("iabcde~branch~" + k, change);
@@ -342,11 +360,11 @@
 
   @Test
   public void byStatus() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     ChangeInserter ins1 = newChangeWithStatus(repo, Change.Status.NEW);
-    Change change1 = insert(repo, ins1);
+    Change change1 = insert("repo", ins1);
     ChangeInserter ins2 = newChangeWithStatus(repo, Change.Status.MERGED);
-    Change change2 = insert(repo, ins2);
+    Change change2 = insert("repo", ins2);
 
     assertQuery("status:new", change1);
     assertQuery("status:NEW", change1);
@@ -361,11 +379,11 @@
 
   @Test
   public void byStatusOr() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     ChangeInserter ins1 = newChangeWithStatus(repo, Change.Status.NEW);
-    Change change1 = insert(repo, ins1);
+    Change change1 = insert("repo", ins1);
     ChangeInserter ins2 = newChangeWithStatus(repo, Change.Status.MERGED);
-    Change change2 = insert(repo, ins2);
+    Change change2 = insert("repo", ins2);
 
     assertQuery("status:new OR status:merged", change2, change1);
     assertQuery("status:new or status:merged", change2, change1);
@@ -373,10 +391,10 @@
 
   @Test
   public void byStatusOpen() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     ChangeInserter ins1 = newChangeWithStatus(repo, Change.Status.NEW);
-    Change change1 = insert(repo, ins1);
-    insert(repo, newChangeWithStatus(repo, Change.Status.MERGED));
+    Change change1 = insert("repo", ins1);
+    insert("repo", newChangeWithStatus(repo, Change.Status.MERGED));
 
     Change[] expected = new Change[] {change1};
     assertQuery("status:open", expected);
@@ -395,12 +413,12 @@
 
   @Test
   public void byStatusClosed() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     ChangeInserter ins1 = newChangeWithStatus(repo, Change.Status.MERGED);
-    Change change1 = insert(repo, ins1);
+    Change change1 = insert("repo", ins1);
     ChangeInserter ins2 = newChangeWithStatus(repo, Change.Status.ABANDONED);
-    Change change2 = insert(repo, ins2);
-    insert(repo, newChangeWithStatus(repo, Change.Status.NEW));
+    Change change2 = insert("repo", ins2);
+    insert("repo", newChangeWithStatus(repo, Change.Status.NEW));
 
     Change[] expected = new Change[] {change2, change1};
     assertQuery("status:closed", expected);
@@ -416,12 +434,12 @@
 
   @Test
   public void byStatusAbandoned() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     ChangeInserter ins1 = newChangeWithStatus(repo, Change.Status.MERGED);
-    insert(repo, ins1);
+    insert("repo", ins1);
     ChangeInserter ins2 = newChangeWithStatus(repo, Change.Status.ABANDONED);
-    Change change1 = insert(repo, ins2);
-    insert(repo, newChangeWithStatus(repo, Change.Status.NEW));
+    Change change1 = insert("repo", ins2);
+    insert("repo", newChangeWithStatus(repo, Change.Status.NEW));
 
     assertQuery("status:abandoned", change1);
     assertQuery("status:ABANDONED", change1);
@@ -430,10 +448,10 @@
 
   @Test
   public void byStatusPrefix() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     ChangeInserter ins1 = newChangeWithStatus(repo, Change.Status.NEW);
-    Change change1 = insert(repo, ins1);
-    insert(repo, newChangeWithStatus(repo, Change.Status.MERGED));
+    Change change1 = insert("repo", ins1);
+    Change change2 = insert("repo", newChangeWithStatus(repo, Change.Status.MERGED));
 
     assertQuery("status:n", change1);
     assertQuery("status:ne", change1);
@@ -441,6 +459,7 @@
     assertQuery("status:N", change1);
     assertQuery("status:nE", change1);
     assertQuery("status:neW", change1);
+    assertQuery("status:m", change2);
     Exception thrown = assertThrows(BadRequestException.class, () -> assertQuery("status:newx"));
     assertThat(thrown).hasMessageThat().isEqualTo("Unrecognized value: newx");
     thrown = assertThrows(BadRequestException.class, () -> assertQuery("status:nx"));
@@ -449,11 +468,11 @@
 
   @Test
   public void byPrivate() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChange(repo), userId);
+    repo = createAndOpenProject("repo");
+    Change change1 = insert("repo", newChange(repo), userId);
     Account.Id user2 =
         accountManager.authenticate(authRequestFactory.createForUser("anotheruser")).getAccountId();
-    Change change2 = insert(repo, newChange(repo), user2);
+    Change change2 = insert("repo", newChange(repo), user2);
 
     // No private changes.
     assertQuery("is:open", change2, change1);
@@ -473,8 +492,8 @@
 
   @Test
   public void byWip() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChange(repo), userId);
+    repo = createAndOpenProject("repo");
+    Change change1 = insert("repo", newChange(repo), userId);
 
     assertQuery("is:open", change1);
     assertQuery("is:wip");
@@ -491,8 +510,8 @@
   @Test
   public void excludeWipChangeFromReviewersDashboards() throws Exception {
     Account.Id user1 = createAccount("user1");
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChangeWorkInProgress(repo), userId);
+    repo = createAndOpenProject("repo");
+    Change change1 = insert("repo", newChangeWorkInProgress(repo), userId);
 
     assertQuery("is:wip", change1);
     assertQuery("reviewer:" + user1);
@@ -508,8 +527,8 @@
 
   @Test
   public void byStarted() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChangeWorkInProgress(repo));
+    repo = createAndOpenProject("repo");
+    Change change1 = insert("repo", newChangeWorkInProgress(repo));
 
     assertQuery("is:started");
 
@@ -544,11 +563,11 @@
   @Test
   public void restorePendingReviewers() throws Exception {
     Project.NameKey project = Project.nameKey("repo");
-    TestRepository<Repo> repo = createProject(project.get());
+    repo = createAndOpenProject(project.get());
     ConfigInput conf = new ConfigInput();
     conf.enableReviewerByEmail = InheritableBoolean.TRUE;
     gApi.projects().name(project.get()).config(conf);
-    Change change1 = insert(repo, newChangeWorkInProgress(repo));
+    Change change1 = insert("repo", newChangeWorkInProgress(repo));
     Account.Id user1 = createAccount("user1");
     Account.Id user2 = createAccount("user2");
     String email1 = "email1@example.com";
@@ -601,9 +620,9 @@
 
   @Test
   public void byCommit() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     ChangeInserter ins = newChange(repo);
-    Change change = insert(repo, ins);
+    Change change = insert("repo", ins);
     String sha = ins.getCommitId().name();
 
     assertQuery("0000000000000000000000000000000000000000");
@@ -617,11 +636,11 @@
 
   @Test
   public void byOwner() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChange(repo), userId);
+    repo = createAndOpenProject("repo");
+    Change change1 = insert("repo", newChange(repo), userId);
     Account.Id user2 =
         accountManager.authenticate(authRequestFactory.createForUser("anotheruser")).getAccountId();
-    Change change2 = insert(repo, newChange(repo), user2);
+    Change change2 = insert("repo", newChange(repo), user2);
 
     assertQuery("is:owner", change1);
     assertQuery("owner:" + userId.get(), change1);
@@ -633,16 +652,17 @@
 
   @Test
   public void byUploader() throws Exception {
-    assume().that(getSchema().hasField(ChangeField.UPLOADER)).isTrue();
-    Account.Id user2 =
-        accountManager.authenticate(authRequestFactory.createForUser("anotheruser")).getAccountId();
-    CurrentUser user2CurrentUser = userFactory.create(user2);
+    assume().that(getSchema().hasField(ChangeField.UPLOADER_SPEC)).isTrue();
 
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChange(repo), userId);
+    repo = createAndOpenProject("repo");
+    Change change1 = insert("repo", newChange(repo), userId);
     assertQuery("is:uploader", change1);
     assertQuery("uploader:" + userId.get(), change1);
-    change1 = newPatchSet(repo, change1, user2CurrentUser);
+
+    Account.Id user2 = createAccount("anotheruser");
+    CurrentUser user2CurrentUser = userFactory.create(user2);
+
+    change1 = newPatchSet("repo", change1, user2CurrentUser, /* message= */ Optional.empty());
     // Uploader has changed
     assertQuery("uploader:" + userId.get());
     assertQuery("uploader:" + user2.get(), change1);
@@ -660,11 +680,21 @@
   }
 
   @Test
+  public void byAuthorExact_byAlias() throws Exception {
+    byAuthorOrCommitterExact("a:");
+  }
+
+  @Test
   public void byAuthorFullText() throws Exception {
     byAuthorOrCommitterFullText("author:");
   }
 
   @Test
+  public void byAuthorFullText_byAlias() throws Exception {
+    byAuthorOrCommitterFullText("a:");
+  }
+
+  @Test
   public void byCommitterExact() throws Exception {
     byAuthorOrCommitterExact("committer:");
   }
@@ -675,7 +705,7 @@
   }
 
   private void byAuthorOrCommitterExact(String searchOperator) throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    createProject("repo");
     PersonIdent johnDoe = new PersonIdent("John Doe", "john.doe@example.com");
     PersonIdent john = new PersonIdent("John", "john@example.com");
     PersonIdent doeSmith = new PersonIdent("Doe Smith", "doe_smith@example.com");
@@ -683,10 +713,10 @@
     PersonIdent myself = new PersonIdent("I Am", ua.preferredEmail());
     PersonIdent selfName = new PersonIdent("My Self", "my.self@example.com");
 
-    Change change1 = createChange(repo, johnDoe);
-    Change change2 = createChange(repo, john);
-    Change change3 = createChange(repo, doeSmith);
-    createChange(repo, selfName);
+    Change change1 = createChange("repo", johnDoe);
+    Change change2 = createChange("repo", john);
+    Change change3 = createChange("repo", doeSmith);
+    createChange("repo", selfName);
 
     // Only email address.
     assertQuery(searchOperator + "john.doe@example.com", change1);
@@ -711,19 +741,19 @@
     assertQuery(searchOperator + "self");
 
     // ':self' matches a change created with the current user's email address
-    Change change5 = createChange(repo, myself);
+    Change change5 = createChange("repo", myself);
     assertQuery(searchOperator + "me", change5);
     assertQuery(searchOperator + "self", change5);
   }
 
   private void byAuthorOrCommitterFullText(String searchOperator) throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    createProject("repo");
     PersonIdent johnDoe = new PersonIdent("John Doe", "john.doe@example.com");
     PersonIdent john = new PersonIdent("John", "john@example.com");
     PersonIdent doeSmith = new PersonIdent("Doe Smith", "doe_smith@example.com");
-    Change change1 = createChange(repo, johnDoe);
-    Change change2 = createChange(repo, john);
-    Change change3 = createChange(repo, doeSmith);
+    Change change1 = createChange("repo", johnDoe);
+    Change change2 = createChange("repo", john);
+    Change change3 = createChange("repo", doeSmith);
 
     // By exact name.
     assertQuery(searchOperator + "\"John Doe\"", change1);
@@ -744,20 +774,25 @@
     assertThat(thrown).hasMessageThat().contains("invalid value");
   }
 
-  protected Change createChange(TestRepository<Repo> repo, PersonIdent person) throws Exception {
-    RevCommit commit =
-        repo.parseBody(repo.commit().message("message").author(person).committer(person).create());
-    return insert(repo, newChangeForCommit(repo, commit), null);
+  @CanIgnoreReturnValue
+  protected Change createChange(String repoName, PersonIdent person) throws Exception {
+    try (TestRepository<Repository> repo =
+        new TestRepository<>(repoManager.openRepository(Project.nameKey(repoName)))) {
+      RevCommit commit =
+          repo.parseBody(
+              repo.commit().message("message").author(person).committer(person).create());
+      return insert("repo", newChangeForCommit(repo, commit), null);
+    }
   }
 
   @Test
   public void byOwnerIn() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChange(repo), userId);
+    repo = createAndOpenProject("repo");
+    Change change1 = insert("repo", newChange(repo), userId);
     Account.Id user2 =
         accountManager.authenticate(authRequestFactory.createForUser("anotheruser")).getAccountId();
-    Change change2 = insert(repo, newChange(repo), user2);
-    Change change3 = insert(repo, newChange(repo), user2);
+    Change change2 = insert("repo", newChange(repo), user2);
+    Change change3 = insert("repo", newChange(repo), user2);
     gApi.changes().id(change3.getId().get()).current().review(ReviewInput.approve());
     gApi.changes().id(change3.getId().get()).current().submit();
 
@@ -771,7 +806,10 @@
       testGroupBackend.setMembershipsOf(
           user2, new ListGroupMembership(ImmutableList.of(externalGroup.getGroupUUID())));
 
-      assertQuery("ownerin:\"" + "testbackend:" + externalGroup.getName() + "\"", change3, change2);
+      assertQuery(
+          "ownerin:\"" + TestGroupBackend.PREFIX + externalGroup.getName() + "\"",
+          change3,
+          change2);
 
       String nameOfGroupThatContainsExternalGroupAsSubgroup = "test-group-1";
       AccountGroup.UUID uuidOfGroupThatContainsExternalGroupAsSubgroup =
@@ -801,15 +839,16 @@
 
   @Test
   public void byUploaderIn() throws Exception {
-    assume().that(getSchema().hasField(ChangeField.UPLOADER)).isTrue();
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChange(repo), userId);
+    assume().that(getSchema().hasField(ChangeField.UPLOADER_SPEC)).isTrue();
+    repo = createAndOpenProject("repo");
+    Change change1 = insert("repo", newChange(repo), userId);
+
     assertQuery("uploaderin:Administrators", change1);
 
-    Account.Id user2 =
-        accountManager.authenticate(authRequestFactory.createForUser("anotheruser")).getAccountId();
+    Account.Id user2 = createAccount("anotheruser");
     CurrentUser user2CurrentUser = userFactory.create(user2);
-    newPatchSet(repo, change1, user2CurrentUser);
+    change1 = newPatchSet("repo", change1, user2CurrentUser, /* message= */ Optional.empty());
+
     assertQuery("uploaderin:Administrators");
     assertQuery("uploaderin:\"Registered Users\"", change1);
 
@@ -818,7 +857,8 @@
       testGroupBackend.setMembershipsOf(
           user2, new ListGroupMembership(ImmutableList.of(externalGroup.getGroupUUID())));
 
-      assertQuery("uploaderin:\"" + "testbackend:" + externalGroup.getName() + "\"", change1);
+      assertQuery(
+          "uploaderin:\"" + TestGroupBackend.PREFIX + externalGroup.getName() + "\"", change1);
 
       String nameOfGroupThatContainsExternalGroupAsSubgroup = "test-group-1";
       AccountGroup.UUID uuidOfGroupThatContainsExternalGroupAsSubgroup =
@@ -846,10 +886,10 @@
 
   @Test
   public void byProject() throws Exception {
-    TestRepository<Repo> repo1 = createProject("repo1");
-    TestRepository<Repo> repo2 = createProject("repo2");
-    Change change1 = insert(repo1, newChange(repo1));
-    Change change2 = insert(repo2, newChange(repo2));
+    createProject("repo1");
+    createProject("repo2");
+    Change change1 = insert("repo1", newChange("repo1"));
+    Change change2 = insert("repo2", newChange("repo2"));
 
     assertQuery("project:foo");
     assertQuery("project:repo");
@@ -859,16 +899,16 @@
 
   @Test
   public void byProjectWithHidden() throws Exception {
-    TestRepository<Repo> hiddenProject = createProject("hiddenProject");
-    insert(hiddenProject, newChange(hiddenProject));
+    createProject("hiddenProject");
+    insert("hiddenProject", newChange("hiddenProject"));
     projectOperations
         .project(Project.nameKey("hiddenProject"))
         .forUpdate()
         .add(block(Permission.READ).ref("refs/*").group(REGISTERED_USERS))
         .update();
 
-    TestRepository<Repo> visibleProject = createProject("visibleProject");
-    Change visibleChange = insert(visibleProject, newChange(visibleProject));
+    createProject("visibleProject");
+    Change visibleChange = insert("visibleProject", newChange("visibleProject"));
     assertQuery("project:visibleProject", visibleChange);
     assertQuery("project:hiddenProject");
     assertQuery("project:visibleProject OR project:hiddenProject", visibleChange);
@@ -876,13 +916,13 @@
 
   @Test
   public void byParentOf() throws Exception {
-    TestRepository<Repo> repo1 = createProject("repo1");
-    RevCommit commit1 = repo1.parseBody(repo1.commit().message("message").create());
-    Change change1 = insert(repo1, newChangeForCommit(repo1, commit1));
-    RevCommit commit2 = repo1.parseBody(repo1.commit(commit1));
-    Change change2 = insert(repo1, newChangeForCommit(repo1, commit2));
-    RevCommit commit3 = repo1.parseBody(repo1.commit(commit1, commit2));
-    Change change3 = insert(repo1, newChangeForCommit(repo1, commit3));
+    repo = createAndOpenProject("repo1");
+    RevCommit commit1 = repo.parseBody(repo.commit().message("message").create());
+    Change change1 = insert("repo1", newChangeForCommit(repo, commit1));
+    RevCommit commit2 = repo.parseBody(repo.commit(commit1));
+    Change change2 = insert("repo1", newChangeForCommit(repo, commit2));
+    RevCommit commit3 = repo.parseBody(repo.commit(commit1, commit2));
+    Change change3 = insert("repo1", newChangeForCommit(repo, commit3));
 
     assertQuery("parentof:" + change1.getId().get());
     assertQuery("parentof:" + change1.getKey().get());
@@ -894,10 +934,10 @@
 
   @Test
   public void byParentProject() throws Exception {
-    TestRepository<Repo> repo1 = createProject("repo1");
-    TestRepository<Repo> repo2 = createProject("repo2", "repo1");
-    Change change1 = insert(repo1, newChange(repo1));
-    Change change2 = insert(repo2, newChange(repo2));
+    createProject("repo1");
+    createProject("repo2", "repo1");
+    Change change1 = insert("repo1", newChange("repo1"));
+    Change change2 = insert("repo2", newChange("repo2"));
 
     assertQuery("parentproject:repo1", change2, change1);
     assertQuery("parentproject:repo2", change2);
@@ -905,10 +945,10 @@
 
   @Test
   public void byProjectPrefix() throws Exception {
-    TestRepository<Repo> repo1 = createProject("repo1");
-    TestRepository<Repo> repo2 = createProject("repo2");
-    Change change1 = insert(repo1, newChange(repo1));
-    Change change2 = insert(repo2, newChange(repo2));
+    createProject("repo1");
+    createProject("repo2");
+    Change change1 = insert("repo1", newChange("repo1"));
+    Change change2 = insert("repo2", newChange("repo2"));
 
     assertQuery("projects:foo");
     assertQuery("projects:repo1", change1);
@@ -918,10 +958,10 @@
 
   @Test
   public void byRepository() throws Exception {
-    TestRepository<Repo> repo1 = createProject("repo1");
-    TestRepository<Repo> repo2 = createProject("repo2");
-    Change change1 = insert(repo1, newChange(repo1));
-    Change change2 = insert(repo2, newChange(repo2));
+    createProject("repo1");
+    createProject("repo2");
+    Change change1 = insert("repo1", newChange("repo1"));
+    Change change2 = insert("repo2", newChange("repo2"));
 
     assertQuery("repository:foo");
     assertQuery("repository:repo");
@@ -931,10 +971,10 @@
 
   @Test
   public void byParentRepository() throws Exception {
-    TestRepository<Repo> repo1 = createProject("repo1");
-    TestRepository<Repo> repo2 = createProject("repo2", "repo1");
-    Change change1 = insert(repo1, newChange(repo1));
-    Change change2 = insert(repo2, newChange(repo2));
+    createProject("repo1");
+    createProject("repo2", "repo1");
+    Change change1 = insert("repo1", newChange("repo1"));
+    Change change2 = insert("repo2", newChange("repo2"));
 
     assertQuery("parentrepository:repo1", change2, change1);
     assertQuery("parentrepository:repo2", change2);
@@ -942,10 +982,10 @@
 
   @Test
   public void byRepositoryPrefix() throws Exception {
-    TestRepository<Repo> repo1 = createProject("repo1");
-    TestRepository<Repo> repo2 = createProject("repo2");
-    Change change1 = insert(repo1, newChange(repo1));
-    Change change2 = insert(repo2, newChange(repo2));
+    createProject("repo1");
+    createProject("repo2");
+    Change change1 = insert("repo1", newChange("repo1"));
+    Change change2 = insert("repo2", newChange("repo2"));
 
     assertQuery("repositories:foo");
     assertQuery("repositories:repo1", change1);
@@ -955,10 +995,10 @@
 
   @Test
   public void byRepo() throws Exception {
-    TestRepository<Repo> repo1 = createProject("repo1");
-    TestRepository<Repo> repo2 = createProject("repo2");
-    Change change1 = insert(repo1, newChange(repo1));
-    Change change2 = insert(repo2, newChange(repo2));
+    createProject("repo1");
+    createProject("repo2");
+    Change change1 = insert("repo1", newChange("repo1"));
+    Change change2 = insert("repo2", newChange("repo2"));
 
     assertQuery("repo:foo");
     assertQuery("repo:repo");
@@ -968,10 +1008,10 @@
 
   @Test
   public void byParentRepo() throws Exception {
-    TestRepository<Repo> repo1 = createProject("repo1");
-    TestRepository<Repo> repo2 = createProject("repo2", "repo1");
-    Change change1 = insert(repo1, newChange(repo1));
-    Change change2 = insert(repo2, newChange(repo2));
+    createProject("repo1");
+    createProject("repo2", "repo1");
+    Change change1 = insert("repo1", newChange("repo1"));
+    Change change2 = insert("repo2", newChange("repo2"));
 
     assertQuery("parentrepo:repo1", change2, change1);
     assertQuery("parentrepo:repo2", change2);
@@ -979,10 +1019,10 @@
 
   @Test
   public void byRepoPrefix() throws Exception {
-    TestRepository<Repo> repo1 = createProject("repo1");
-    TestRepository<Repo> repo2 = createProject("repo2");
-    Change change1 = insert(repo1, newChange(repo1));
-    Change change2 = insert(repo2, newChange(repo2));
+    createProject("repo1");
+    createProject("repo2");
+    Change change1 = insert("repo1", newChange("repo1"));
+    Change change2 = insert("repo2", newChange("repo2"));
 
     assertQuery("repos:foo");
     assertQuery("repos:repo1", change1);
@@ -992,9 +1032,9 @@
 
   @Test
   public void byBranchAndRef() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChangeForBranch(repo, "master"));
-    Change change2 = insert(repo, newChangeForBranch(repo, "branch"));
+    repo = createAndOpenProject("repo");
+    Change change1 = insert("repo", newChangeForBranch(repo, "master"));
+    Change change2 = insert("repo", newChangeForBranch(repo, "branch"));
 
     assertQuery("branch:foo");
     assertQuery("branch:master", change1);
@@ -1011,26 +1051,26 @@
   @Test
   public void byTopic() throws Exception {
 
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     ChangeInserter ins1 = newChangeWithTopic(repo, "feature1");
-    Change change1 = insert(repo, ins1);
+    Change change1 = insert("repo", ins1);
 
     ChangeInserter ins2 = newChangeWithTopic(repo, "feature2");
-    Change change2 = insert(repo, ins2);
+    Change change2 = insert("repo", ins2);
 
     ChangeInserter ins3 = newChangeWithTopic(repo, "Cherrypick-feature2");
-    Change change3 = insert(repo, ins3);
+    Change change3 = insert("repo", ins3);
 
     ChangeInserter ins4 = newChangeWithTopic(repo, "feature2-fixup");
-    Change change4 = insert(repo, ins4);
+    Change change4 = insert("repo", ins4);
 
     ChangeInserter ins5 = newChangeWithTopic(repo, "https://gerrit.local");
-    Change change5 = insert(repo, ins5);
+    Change change5 = insert("repo", ins5);
 
     ChangeInserter ins6 = newChangeWithTopic(repo, "git_gerrit_training");
-    Change change6 = insert(repo, ins6);
+    Change change6 = insert("repo", ins6);
 
-    Change change_no_topic = insert(repo, newChange(repo));
+    Change changeNoTopic = insert("repo", newChange(repo));
 
     assertQuery("intopic:foo");
     assertQuery("intopic:feature1", change1);
@@ -1039,8 +1079,8 @@
     assertQuery("intopic:feature2", change4, change3, change2);
     assertQuery("intopic:fixup", change4);
     assertQuery("intopic:gerrit", change6, change5);
-    assertQuery("topic:\"\"", change_no_topic);
-    assertQuery("intopic:\"\"", change_no_topic);
+    assertQuery("topic:\"\"", changeNoTopic);
+    assertQuery("intopic:\"\"", changeNoTopic);
 
     assume().that(getSchema().hasField(ChangeField.PREFIX_TOPIC)).isTrue();
     assertQuery("prefixtopic:feature", change4, change2, change1);
@@ -1050,16 +1090,16 @@
 
   @Test
   public void byTopicRegex() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
 
     ChangeInserter ins1 = newChangeWithTopic(repo, "feature1");
-    Change change1 = insert(repo, ins1);
+    Change change1 = insert("repo", ins1);
 
     ChangeInserter ins2 = newChangeWithTopic(repo, "Cherrypick-feature1");
-    Change change2 = insert(repo, ins2);
+    Change change2 = insert("repo", ins2);
 
     ChangeInserter ins3 = newChangeWithTopic(repo, "feature1-fixup");
-    Change change3 = insert(repo, ins3);
+    Change change3 = insert("repo", ins3);
 
     assertQuery("intopic:^feature1.*", change3, change1);
     assertQuery("intopic:{^.*feature1$}", change2, change1);
@@ -1067,13 +1107,13 @@
 
   @Test
   public void byMessageExact() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     RevCommit commit1 = repo.parseBody(repo.commit().message("one").create());
-    Change change1 = insert(repo, newChangeForCommit(repo, commit1));
+    Change change1 = insert("repo", newChangeForCommit(repo, commit1));
     RevCommit commit2 = repo.parseBody(repo.commit().message("two").create());
-    Change change2 = insert(repo, newChangeForCommit(repo, commit2));
+    Change change2 = insert("repo", newChangeForCommit(repo, commit2));
     RevCommit commit3 = repo.parseBody(repo.commit().message("A great \"fix\" to my bug").create());
-    Change change3 = insert(repo, newChangeForCommit(repo, commit3));
+    Change change3 = insert("repo", newChangeForCommit(repo, commit3));
 
     assertQuery("message:foo");
     assertQuery("message:one", change1);
@@ -1084,16 +1124,16 @@
   @Test
   public void byMessageRegEx() throws Exception {
     assume().that(getSchema().hasField(ChangeField.COMMIT_MESSAGE_EXACT)).isTrue();
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     RevCommit commit1 = repo.parseBody(repo.commit().message("aaaabcc").create());
-    Change change1 = insert(repo, newChangeForCommit(repo, commit1));
+    Change change1 = insert("repo", newChangeForCommit(repo, commit1));
     RevCommit commit2 = repo.parseBody(repo.commit().message("aaaacc").create());
-    Change change2 = insert(repo, newChangeForCommit(repo, commit2));
+    Change change2 = insert("repo", newChangeForCommit(repo, commit2));
     RevCommit commit3 = repo.parseBody(repo.commit().message("Title\n\nHELLO WORLD").create());
-    Change change3 = insert(repo, newChangeForCommit(repo, commit3));
+    Change change3 = insert("repo", newChangeForCommit(repo, commit3));
     RevCommit commit4 =
         repo.parseBody(repo.commit().message("Title\n\nfoobar hello WORLD").create());
-    Change change4 = insert(repo, newChangeForCommit(repo, commit4));
+    Change change4 = insert("repo", newChangeForCommit(repo, commit4));
 
     assertQuery("message:\"^aaaa(b|c)*\"", change2, change1);
     assertQuery("message:\"^aaaa(c)*c.*\"", change2);
@@ -1103,12 +1143,109 @@
   }
 
   @Test
+  public void bySubject() throws Exception {
+    assume().that(getSchema().hasField(ChangeField.SUBJECT_SPEC)).isTrue();
+    repo = createAndOpenProject("repo");
+    RevCommit commit1 =
+        repo.parseBody(
+            repo.commit()
+                .message(
+                    "First commit with test subject\n\n"
+                        + "Message body\n\n"
+                        + "Change-Id: I986c6a013dd5b3a2e8a0271c04deac2c9752b920")
+                .create());
+    Change change1 = insert("repo", newChangeForCommit(repo, commit1));
+    RevCommit commit2 =
+        repo.parseBody(
+            repo.commit()
+                .message(
+                    "Second commit with test subject\n\n"
+                        + "Message body for another commit\n\n"
+                        + "Change-Id: I986c6a013dd5b3a2e8a0271c04deac2c9752b921")
+                .create());
+    Change change2 = insert("repo", newChangeForCommit(repo, commit2));
+    RevCommit commit3 =
+        repo.parseBody(
+            repo.commit()
+                .message(
+                    "Third commit with test subject\n\n"
+                        + "Last message body\n\n"
+                        + "Change-Id: I986c6a013dd5b3a2e8a0271c04deac2c9752b921")
+                .create());
+    Change change3 = insert("repo", newChangeForCommit(repo, commit3));
+
+    assertQuery("subject:First", change1);
+    assertQuery("subject:Second", change2);
+    assertQuery("subject:Third", change3);
+    assertQuery("subject:\"commit with test subject\"", change3, change2, change1);
+    assertQuery("subject:\"Message body\"");
+    assertQuery("subject:body");
+    change1 =
+        newPatchSet(
+            "repo",
+            change1,
+            user,
+            Optional.of("Rework of commit with test subject\n\n" + "Message body\n\n"));
+    assertQuery("subject:Rework", change1);
+    assertQuery("subject:First");
+    assertQuery("subject:\"commit with test subject\"", change1, change3, change2);
+  }
+
+  @Test
+  public void bySubjectPrefix() throws Exception {
+    assume().that(getSchema().hasField(ChangeField.PREFIX_SUBJECT_SPEC)).isTrue();
+    repo = createAndOpenProject("repo");
+    RevCommit commit1 =
+        repo.parseBody(
+            repo.commit()
+                .message(
+                    "[FOO123] First commit with test subject\n\n"
+                        + "Message body\n\n"
+                        + "Change-Id: I986c6a013dd5b3a2e8a0271c04deac2c9752b920")
+                .create());
+    Change change1 = insert("repo", newChangeForCommit(repo, commit1));
+    RevCommit commit2 =
+        repo.parseBody(
+            repo.commit()
+                .message(
+                    "[BAR45] Second commit with test subject\n\n"
+                        + "Message body for another commit\n\n"
+                        + "Change-Id: I986c6a013dd5b3a2e8a0271c04deac2c9752b921")
+                .create());
+    Change change2 = insert("repo", newChangeForCommit(repo, commit2));
+    RevCommit commit3 =
+        repo.parseBody(
+            repo.commit()
+                .message(
+                    "[FOO99] Third commit with test subject\n\n"
+                        + "Last message body\n\n"
+                        + "Change-Id: I986c6a013dd5b3a2e8a0271c04deac2c9752b921")
+                .create());
+    Change change3 = insert("repo", newChangeForCommit(repo, commit3));
+
+    assertQuery("prefixsubject:\"[FOO\"", change3, change1);
+    assertQuery("prefixsubject:\"[BAR\"", change2);
+    assertQuery("prefixsubject:\"[FOO1\"", change1);
+    assertQuery("prefixsubject:\"[FOO123]\"", change1);
+    assertQuery("prefixsubject:\"[\"", change3, change2, change1);
+    assertQuery("prefixsubject:FOO");
+    change1 =
+        newPatchSet(
+            "repo",
+            change1,
+            user,
+            Optional.of("[BAR123] Rework of commit with test subject\n\n" + "Message body\n\n"));
+    assertQuery("prefixsubject:\"[FOO\"", change3);
+    assertQuery("prefixsubject:\"[BAR\"", change1, change2);
+  }
+
+  @Test
   public void fullTextWithNumbers() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     RevCommit commit1 = repo.parseBody(repo.commit().message("12345 67890").create());
-    Change change1 = insert(repo, newChangeForCommit(repo, commit1));
+    Change change1 = insert("repo", newChangeForCommit(repo, commit1));
     RevCommit commit2 = repo.parseBody(repo.commit().message("12346 67891").create());
-    Change change2 = insert(repo, newChangeForCommit(repo, commit2));
+    Change change2 = insert("repo", newChangeForCommit(repo, commit2));
 
     assertQuery("message:1234");
     assertQuery("message:12345", change1);
@@ -1117,13 +1254,13 @@
 
   @Test
   public void fullTextMultipleTerms() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     RevCommit commit1 = repo.parseBody(repo.commit().message("Signed-off: owner").create());
-    Change change1 = insert(repo, newChangeForCommit(repo, commit1));
+    Change change1 = insert("repo", newChangeForCommit(repo, commit1));
     RevCommit commit2 = repo.parseBody(repo.commit().message("Signed by owner").create());
-    Change change2 = insert(repo, newChangeForCommit(repo, commit2));
+    Change change2 = insert("repo", newChangeForCommit(repo, commit2));
     RevCommit commit3 = repo.parseBody(repo.commit().message("This change is off").create());
-    Change change3 = insert(repo, newChangeForCommit(repo, commit3));
+    Change change3 = insert("repo", newChangeForCommit(repo, commit3));
 
     assertQuery("message:\"Signed-off: owner\"", change1);
     assertQuery("message:\"Signed\"", change2, change1);
@@ -1132,11 +1269,11 @@
 
   @Test
   public void byMessageMixedCase() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     RevCommit commit1 = repo.parseBody(repo.commit().message("Hello gerrit").create());
-    Change change1 = insert(repo, newChangeForCommit(repo, commit1));
+    Change change1 = insert("repo", newChangeForCommit(repo, commit1));
     RevCommit commit2 = repo.parseBody(repo.commit().message("Hello Gerrit").create());
-    Change change2 = insert(repo, newChangeForCommit(repo, commit2));
+    Change change2 = insert("repo", newChangeForCommit(repo, commit2));
 
     assertQuery("message:gerrit", change2, change1);
     assertQuery("message:Gerrit", change2, change1);
@@ -1144,16 +1281,16 @@
 
   @Test
   public void byMessageSubstring() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     RevCommit commit1 = repo.parseBody(repo.commit().message("https://gerrit.local").create());
-    Change change1 = insert(repo, newChangeForCommit(repo, commit1));
+    Change change1 = insert("repo", newChangeForCommit(repo, commit1));
     assertQuery("message:gerrit", change1);
   }
 
   @Test
   public void byLabel() throws Exception {
-    accountManager.authenticate(authRequestFactory.createForUser("anotheruser"));
-    TestRepository<Repo> repo = createProject("repo");
+    Account.Id anotherUser = createAccount("anotheruser");
+    repo = createAndOpenProject("repo");
     ChangeInserter ins = newChange(repo);
     ChangeInserter ins2 = newChange(repo);
     ChangeInserter ins3 = newChange(repo);
@@ -1161,24 +1298,24 @@
     ChangeInserter ins5 = newChange(repo);
     ChangeInserter ins6 = newChange(repo);
 
-    Change reviewMinus2Change = insert(repo, ins);
+    Change reviewMinus2Change = insert("repo", ins);
     gApi.changes().id(reviewMinus2Change.getId().get()).current().review(ReviewInput.reject());
 
-    Change reviewMinus1Change = insert(repo, ins2);
+    Change reviewMinus1Change = insert("repo", ins2);
     gApi.changes().id(reviewMinus1Change.getId().get()).current().review(ReviewInput.dislike());
 
-    Change noLabelChange = insert(repo, ins3);
+    Change noLabelChange = insert("repo", ins3);
 
-    Change reviewPlus1Change = insert(repo, ins4);
+    Change reviewPlus1Change = insert("repo", ins4);
     gApi.changes().id(reviewPlus1Change.getId().get()).current().review(ReviewInput.recommend());
 
-    Change reviewTwoPlus1Change = insert(repo, ins5);
+    Change reviewTwoPlus1Change = insert("repo", ins5);
     gApi.changes().id(reviewTwoPlus1Change.getId().get()).current().review(ReviewInput.recommend());
     requestContext.setContext(newRequestContext(createAccount("user1")));
     gApi.changes().id(reviewTwoPlus1Change.getId().get()).current().review(ReviewInput.recommend());
     requestContext.setContext(newRequestContext(userId));
 
-    Change reviewPlus2Change = insert(repo, ins6);
+    Change reviewPlus2Change = insert("repo", ins6);
     gApi.changes().id(reviewPlus2Change.getId().get()).current().review(ReviewInput.approve());
 
     Map<String, Short> m =
@@ -1242,9 +1379,15 @@
     assertQuery("label:Code-Review<=-2", reviewMinus2Change);
     assertQuery("label:Code-Review<-2");
 
-    assertQuery("label:Code-Review=+1,anotheruser");
-    assertQuery("label:Code-Review=+1,user", reviewTwoPlus1Change, reviewPlus1Change);
-    assertQuery("label:Code-Review=+1,user=user", reviewTwoPlus1Change, reviewPlus1Change);
+    assertQuery("label:Code-Review=+1," + anotherUser);
+    assertQuery(
+        String.format("label:Code-Review=+1,%s", userAccount.preferredEmail()),
+        reviewTwoPlus1Change,
+        reviewPlus1Change);
+    assertQuery(
+        String.format("label:Code-Review=+1,user=%s", userAccount.preferredEmail()),
+        reviewTwoPlus1Change,
+        reviewPlus1Change);
     assertQuery("label:Code-Review=+1,Administrators", reviewTwoPlus1Change, reviewPlus1Change);
     assertQuery(
         "label:Code-Review=+1,group=Administrators", reviewTwoPlus1Change, reviewPlus1Change);
@@ -1307,13 +1450,21 @@
     // "count" and "group" args cannot be used simultaneously.
     assertThrows(
         BadRequestException.class, () -> assertQuery("label:Code-Review=+1,group=gerrit,count=2"));
+
+    // "non_contributor arg for the label operator is not allowed in change queries
+    thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> assertQuery("label:Code-Review=+2,user=non_contributor"));
+    assertThat(thrown)
+        .hasMessageThat()
+        .isEqualTo("non_contributor arg is not allowed in change queries");
   }
 
   @Test
   public void byLabelMulti() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Project.NameKey project =
-        Project.nameKey(repo.getRepository().getDescription().getRepositoryName());
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project.get());
 
     LabelType verified =
         label(LabelId.VERIFIED, value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
@@ -1340,25 +1491,25 @@
     ChangeInserter ins5 = newChange(repo);
 
     // CR+1
-    Change reviewCRplus1 = insert(repo, ins);
+    Change reviewCRplus1 = insert(project.get(), ins);
     gApi.changes().id(reviewCRplus1.getId().get()).current().review(ReviewInput.recommend());
 
     // CR+2
-    Change reviewCRplus2 = insert(repo, ins2);
+    Change reviewCRplus2 = insert(project.get(), ins2);
     gApi.changes().id(reviewCRplus2.getId().get()).current().review(ReviewInput.approve());
 
     // CR+1 VR+1
-    Change reviewCRplus1VRplus1 = insert(repo, ins3);
+    Change reviewCRplus1VRplus1 = insert(project.get(), ins3);
     gApi.changes().id(reviewCRplus1VRplus1.getId().get()).current().review(ReviewInput.recommend());
     gApi.changes().id(reviewCRplus1VRplus1.getId().get()).current().review(reviewVerified);
 
     // CR+2 VR+1
-    Change reviewCRplus2VRplus1 = insert(repo, ins4);
+    Change reviewCRplus2VRplus1 = insert(project.get(), ins4);
     gApi.changes().id(reviewCRplus2VRplus1.getId().get()).current().review(ReviewInput.approve());
     gApi.changes().id(reviewCRplus2VRplus1.getId().get()).current().review(reviewVerified);
 
     // VR+1
-    Change reviewVRplus1 = insert(repo, ins5);
+    Change reviewVRplus1 = insert(project.get(), ins5);
     gApi.changes().id(reviewVRplus1.getId().get()).current().review(reviewVerified);
 
     assertQuery("label:Code-Review=+1", reviewCRplus1VRplus1, reviewCRplus1);
@@ -1377,28 +1528,28 @@
 
   @Test
   public void byLabelNotOwner() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     ChangeInserter ins = newChange(repo);
     Account.Id user1 = createAccount("user1");
 
-    Change reviewPlus1Change = insert(repo, ins);
+    Change reviewPlus1Change = insert("repo", ins);
 
     // post a review with user1
     requestContext.setContext(newRequestContext(user1));
     gApi.changes().id(reviewPlus1Change.getId().get()).current().review(ReviewInput.recommend());
 
-    assertQuery("label:Code-Review=+1,user=user1", reviewPlus1Change);
+    assertQuery("label:Code-Review=+1,user=" + user1, reviewPlus1Change);
     assertQuery("label:Code-Review=+1,owner");
   }
 
   @Test
   public void byLabelNonUploader() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     ChangeInserter ins = newChange(repo);
     Account.Id user1 = createAccount("user1");
 
     // create a change with "user"
-    Change reviewPlus1Change = insert(repo, ins);
+    Change reviewPlus1Change = insert("repo", ins);
 
     // add a +1 vote with "user". Query doesn't match since voter is the uploader.
     gApi.changes().id(reviewPlus1Change.getId().get()).current().review(ReviewInput.recommend());
@@ -1437,8 +1588,8 @@
   @Test
   public void byLabelGroup() throws Exception {
     Account.Id user1 = createAccount("user1");
-    createAccount("user2");
-    TestRepository<Repo> repo = createProject("repo");
+    Account.Id user2 = createAccount("user2");
+    repo = createAndOpenProject("repo");
 
     // create group and add users
     String g1 = createGroup("group1", "Administrators");
@@ -1447,7 +1598,7 @@
     gApi.groups().id(g2).addMembers("user2");
 
     // create a change
-    Change change1 = insert(repo, newChange(repo), user1);
+    Change change1 = insert("repo", newChange(repo), user1);
 
     // post a review with user1
     requestContext.setContext(newRequestContext(user1));
@@ -1460,8 +1611,8 @@
     requestContext.setContext(newRequestContext(userId));
     assertQuery("label:Code-Review=+1,group1", change1);
     assertQuery("label:Code-Review=+1,group=group1", change1);
-    assertQuery("label:Code-Review=+1,user=user1", change1);
-    assertQuery("label:Code-Review=+1,user=user2");
+    assertQuery("label:Code-Review=+1,user=" + user1, change1);
+    assertQuery("label:Code-Review=+1,user=" + user2);
     assertQuery("label:Code-Review=+1,group=group2");
   }
 
@@ -1469,7 +1620,7 @@
   public void byLabelExternalGroup() throws Exception {
     Account.Id user1 = createAccount("user1");
     Account.Id user2 = createAccount("user2");
-    TestRepository<InMemoryRepositoryManager.Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
 
     // create group and add users
     AccountGroup.UUID external_group1 = AccountGroup.uuid("testbackend:group1");
@@ -1494,8 +1645,8 @@
         .addSubgroup(uuidOfGroupThatContainsExternalGroupAsSubgroup)
         .create();
 
-    Change change1 = insert(repo, newChange(repo), user1);
-    Change change2 = insert(repo, newChange(repo), user1);
+    Change change1 = insert("repo", newChange(repo), user1);
+    Change change2 = insert("repo", newChange(repo), user1);
 
     // post a review with user1 and other_user
     requestContext.setContext(newRequestContext(user1));
@@ -1515,8 +1666,8 @@
         "label:Code-Review=+1,group=" + nameOfGroupThatContainsExternalGroupAsSubgroup, change1);
     assertQuery(
         "label:Code-Review=+1,group=" + nameOfGroupThatContainsExternalGroupAsSubSubgroup, change1);
-    assertQuery("label:Code-Review=+1,user=user1", change1);
-    assertQuery("label:Code-Review=+1,user=user2");
+    assertQuery("label:Code-Review=+1,user=" + user1, change1);
+    assertQuery("label:Code-Review=+1,user=" + user2);
     assertQuery("label:Code-Review=+1,group=" + external_group2.get());
 
     // Negated operator tests
@@ -1527,18 +1678,18 @@
     assertQuery(
         "-label:Code-Review=+1,group=" + nameOfGroupThatContainsExternalGroupAsSubSubgroup,
         change2);
-    assertQuery("-label:Code-Review=+1,user=user1", change2);
+    assertQuery("-label:Code-Review=+1,user=" + user1, change2);
     assertQuery("-label:Code-Review=+1,group=" + external_group2.get(), change2, change1);
-    assertQuery("-label:Code-Review=+1,user=user2", change2, change1);
+    assertQuery("-label:Code-Review=+1,user=" + user2, change2, change1);
   }
 
   @Test
   public void limit() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     Change last = null;
     int n = 5;
     for (int i = 0; i < n; i++) {
-      last = insert(repo, newChange(repo));
+      last = insert("repo", newChange(repo));
     }
 
     for (int i = 1; i <= n + 2; i++) {
@@ -1563,10 +1714,10 @@
 
   @Test
   public void start() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     List<Change> changes = new ArrayList<>();
     for (int i = 0; i < 2; i++) {
-      changes.add(insert(repo, newChange(repo)));
+      changes.add(insert("repo", newChange(repo)));
     }
 
     assertQuery("status:new", changes.get(1), changes.get(0));
@@ -1583,10 +1734,10 @@
 
   @Test
   public void startWithLimit() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     List<Change> changes = new ArrayList<>();
     for (int i = 0; i < 3; i++) {
-      changes.add(insert(repo, newChange(repo)));
+      changes.add(insert("repo", newChange(repo)));
     }
 
     assertQuery("status:new limit:2", changes.get(2), changes.get(1));
@@ -1597,8 +1748,8 @@
 
   @Test
   public void maxPages() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change = insert(repo, newChange(repo));
+    repo = createAndOpenProject("repo");
+    Change change = insert("repo", newChange(repo));
 
     QueryRequest query = newQuery("status:new").withLimit(10);
     assertQuery(query, change);
@@ -1613,12 +1764,12 @@
   @Test
   public void updateOrder() throws Exception {
     resetTimeWithClockStep(2, MINUTES);
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     List<ChangeInserter> inserters = new ArrayList<>();
     List<Change> changes = new ArrayList<>();
     for (int i = 0; i < 5; i++) {
       inserters.add(newChange(repo));
-      changes.add(insert(repo, inserters.get(i)));
+      changes.add(insert("repo", inserters.get(i)));
     }
 
     for (int i : ImmutableList.of(2, 0, 1, 4, 3)) {
@@ -1640,10 +1791,10 @@
   @Test
   public void updatedOrder() throws Exception {
     resetTimeWithClockStep(1, SECONDS);
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     ChangeInserter ins1 = newChange(repo);
-    Change change1 = insert(repo, ins1);
-    Change change2 = insert(repo, newChange(repo));
+    Change change1 = insert("repo", ins1);
+    Change change2 = insert("repo", newChange(repo));
 
     assertThat(lastUpdatedMs(change1)).isLessThan(lastUpdatedMs(change2));
     assertQuery("status:new", change2, change1);
@@ -1661,12 +1812,12 @@
 
   @Test
   public void filterOutMoreThanOnePageOfResults() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change = insert(repo, newChange(repo), userId);
+    repo = createAndOpenProject("repo");
+    Change change = insert("repo", newChange(repo), userId);
     Account.Id user2 =
         accountManager.authenticate(authRequestFactory.createForUser("anotheruser")).getAccountId();
     for (int i = 0; i < 5; i++) {
-      insert(repo, newChange(repo), user2);
+      insert("repo", newChange(repo), user2);
     }
 
     assertQuery("status:new ownerin:Administrators", change);
@@ -1675,11 +1826,11 @@
 
   @Test
   public void filterOutAllResults() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     Account.Id user2 =
         accountManager.authenticate(authRequestFactory.createForUser("anotheruser")).getAccountId();
     for (int i = 0; i < 5; i++) {
-      insert(repo, newChange(repo), user2);
+      insert("repo", newChange(repo), user2);
     }
 
     assertQuery("status:new ownerin:Administrators");
@@ -1688,8 +1839,8 @@
 
   @Test
   public void byFileExact() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change = insert(repo, newChangeWithFiles(repo, "dir/file1", "dir/file2"));
+    repo = createAndOpenProject("repo");
+    Change change = insert("repo", newChangeWithFiles(repo, "dir/file1", "dir/file2"));
 
     assertQuery("file:file");
     assertQuery("file:dir", change);
@@ -1701,8 +1852,8 @@
 
   @Test
   public void byFileRegex() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change = insert(repo, newChangeWithFiles(repo, "dir/file1", "dir/file2"));
+    repo = createAndOpenProject("repo");
+    Change change = insert("repo", newChangeWithFiles(repo, "dir/file1", "dir/file2"));
 
     assertQuery("file:.*file.*");
     assertQuery("file:^file.*"); // Whole path only.
@@ -1711,8 +1862,8 @@
 
   @Test
   public void byPathExact() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change = insert(repo, newChangeWithFiles(repo, "dir/file1", "dir/file2"));
+    repo = createAndOpenProject("repo");
+    Change change = insert("repo", newChangeWithFiles(repo, "dir/file1", "dir/file2"));
 
     assertQuery("path:file");
     assertQuery("path:dir");
@@ -1724,8 +1875,8 @@
 
   @Test
   public void byPathRegex() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change = insert(repo, newChangeWithFiles(repo, "dir/file1", "dir/file2"));
+    repo = createAndOpenProject("repo");
+    Change change = insert("repo", newChangeWithFiles(repo, "dir/file1", "dir/file2"));
 
     assertQuery("path:.*file.*");
     assertQuery("path:^dir.file.*", change);
@@ -1733,12 +1884,12 @@
 
   @Test
   public void byExtension() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChangeWithFiles(repo, "foo.h", "foo.cc"));
-    Change change2 = insert(repo, newChangeWithFiles(repo, "bar.H", "bar.CC"));
-    Change change3 = insert(repo, newChangeWithFiles(repo, "dir/baz.h", "dir/baz.cc"));
-    Change change4 = insert(repo, newChangeWithFiles(repo, "Quux.java", "foo"));
-    Change change5 = insert(repo, newChangeWithFiles(repo, "foo"));
+    repo = createAndOpenProject("repo");
+    Change change1 = insert("repo", newChangeWithFiles(repo, "foo.h", "foo.cc"));
+    Change change2 = insert("repo", newChangeWithFiles(repo, "bar.H", "bar.CC"));
+    Change change3 = insert("repo", newChangeWithFiles(repo, "dir/baz.h", "dir/baz.cc"));
+    Change change4 = insert("repo", newChangeWithFiles(repo, "Quux.java", "foo"));
+    Change change5 = insert("repo", newChangeWithFiles(repo, "foo"));
 
     assertQuery("extension:java", change4);
     assertQuery("ext:java", change4);
@@ -1754,14 +1905,14 @@
 
   @Test
   public void byOnlyExtensions() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChangeWithFiles(repo, "foo.h", "foo.cc", "bar.cc"));
-    Change change2 = insert(repo, newChangeWithFiles(repo, "bar.H", "bar.CC", "foo.H"));
-    Change change3 = insert(repo, newChangeWithFiles(repo, "foo.CC", "bar.cc"));
-    Change change4 = insert(repo, newChangeWithFiles(repo, "dir/baz.h", "dir/baz.cc"));
-    Change change5 = insert(repo, newChangeWithFiles(repo, "Quux.java"));
-    Change change6 = insert(repo, newChangeWithFiles(repo, "foo.txt", "foo"));
-    Change change7 = insert(repo, newChangeWithFiles(repo, "foo"));
+    repo = createAndOpenProject("repo");
+    Change change1 = insert("repo", newChangeWithFiles(repo, "foo.h", "foo.cc", "bar.cc"));
+    Change change2 = insert("repo", newChangeWithFiles(repo, "bar.H", "bar.CC", "foo.H"));
+    Change change3 = insert("repo", newChangeWithFiles(repo, "foo.CC", "bar.cc"));
+    Change change4 = insert("repo", newChangeWithFiles(repo, "dir/baz.h", "dir/baz.cc"));
+    Change change5 = insert("repo", newChangeWithFiles(repo, "Quux.java"));
+    Change change6 = insert("repo", newChangeWithFiles(repo, "foo.txt", "foo"));
+    Change change7 = insert("repo", newChangeWithFiles(repo, "foo"));
 
     // case doesn't matter
     assertQuery("onlyextensions:cc,h", change4, change2, change1);
@@ -1801,23 +1952,23 @@
 
   @Test
   public void byFooter() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     RevCommit commit1 = repo.parseBody(repo.commit().message("Test\n\nfoo: bar").create());
-    Change change1 = insert(repo, newChangeForCommit(repo, commit1));
+    Change change1 = insert("repo", newChangeForCommit(repo, commit1));
     RevCommit commit2 = repo.parseBody(repo.commit().message("Test\n\nfoo: baz").create());
-    Change change2 = insert(repo, newChangeForCommit(repo, commit2));
+    Change change2 = insert("repo", newChangeForCommit(repo, commit2));
     RevCommit commit3 = repo.parseBody(repo.commit().message("Test\n\nfoo: bar\nfoo:baz").create());
-    Change change3 = insert(repo, newChangeForCommit(repo, commit3));
+    Change change3 = insert("repo", newChangeForCommit(repo, commit3));
     RevCommit commit4 = repo.parseBody(repo.commit().message("Test\n\nfoo: bar=baz").create());
-    Change change4 = insert(repo, newChangeForCommit(repo, commit4));
+    Change change4 = insert("repo", newChangeForCommit(repo, commit4));
 
     // create a changes with lines that look like footers, but which are not
     RevCommit commit5 =
         repo.parseBody(
             repo.commit().message("Test\n\nfoo: bar\n\nfoo=bar").insertChangeId().create());
-    Change change5 = insert(repo, newChangeForCommit(repo, commit5));
+    Change change5 = insert("repo", newChangeForCommit(repo, commit5));
     RevCommit commit6 = repo.parseBody(repo.commit().message("Test\n\na=b: c").create());
-    insert(repo, newChangeForCommit(repo, commit6));
+    insert("repo", newChangeForCommit(repo, commit6));
 
     // matching by 'key=value' works
     assertQuery("footer:foo=bar", change3, change1);
@@ -1851,15 +2002,15 @@
   @Test
   public void byFooterName() throws Exception {
     assume().that(getSchema().hasField(ChangeField.FOOTER_NAME)).isTrue();
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     RevCommit commit1 = repo.parseBody(repo.commit().message("Test\n\nfoo: bar").create());
-    Change change1 = insert(repo, newChangeForCommit(repo, commit1));
+    Change change1 = insert("repo", newChangeForCommit(repo, commit1));
     RevCommit commit2 = repo.parseBody(repo.commit().message("Test\n\nBaR: baz").create());
-    Change change2 = insert(repo, newChangeForCommit(repo, commit2));
+    Change change2 = insert("repo", newChangeForCommit(repo, commit2));
 
     // create a changes with lines that look like footers, but which are not
     RevCommit commit6 = repo.parseBody(repo.commit().message("Test\n\na=b: c").create());
-    insert(repo, newChangeForCommit(repo, commit6));
+    insert("repo", newChangeForCommit(repo, commit6));
 
     // matching by 'key=value' works
     assertQuery("hasfooter:foo", change1);
@@ -1871,14 +2022,14 @@
 
   @Test
   public void byDirectory() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChangeWithFiles(repo, "src/foo.h", "src/foo.cc"));
-    Change change2 = insert(repo, newChangeWithFiles(repo, "src/java/foo.java", "src/js/bar.js"));
+    repo = createAndOpenProject("repo");
+    Change change1 = insert("repo", newChangeWithFiles(repo, "src/foo.h", "src/foo.cc"));
+    Change change2 = insert("repo", newChangeWithFiles(repo, "src/java/foo.java", "src/js/bar.js"));
     Change change3 =
-        insert(repo, newChangeWithFiles(repo, "documentation/training/slides/README.txt"));
-    Change change4 = insert(repo, newChangeWithFiles(repo, "a.txt"));
-    Change change5 = insert(repo, newChangeWithFiles(repo, "a/b/c/d/e/foo.txt"));
-    Change change6 = insert(repo, newChangeWithFiles(repo, "all/caps/DIRECTORY/file.txt"));
+        insert("repo", newChangeWithFiles(repo, "documentation/training/slides/README.txt"));
+    Change change4 = insert("repo", newChangeWithFiles(repo, "a.txt"));
+    Change change5 = insert("repo", newChangeWithFiles(repo, "a/b/c/d/e/foo.txt"));
+    Change change6 = insert("repo", newChangeWithFiles(repo, "all/caps/DIRECTORY/file.txt"));
 
     // matching by directory prefix works
     assertQuery("directory:src", change2, change1);
@@ -1939,10 +2090,10 @@
 
   @Test
   public void byDirectoryRegex() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChangeWithFiles(repo, "src/java/foo.java", "src/js/bar.js"));
+    repo = createAndOpenProject("repo");
+    Change change1 = insert("repo", newChangeWithFiles(repo, "src/java/foo.java", "src/js/bar.js"));
     Change change2 =
-        insert(repo, newChangeWithFiles(repo, "documentation/training/slides/README.txt"));
+        insert("repo", newChangeWithFiles(repo, "documentation/training/slides/README.txt"));
 
     // match by regexp
     assertQuery("directory:^.*va.*", change1);
@@ -1952,9 +2103,9 @@
 
   @Test
   public void byComment() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     ChangeInserter ins = newChange(repo);
-    Change change = insert(repo, ins);
+    Change change = insert("repo", ins);
 
     ReviewInput input = new ReviewInput();
     input.message = "toplevel";
@@ -1982,11 +2133,11 @@
   public void byAge() throws Exception {
     long thirtyHoursInMs = MILLISECONDS.convert(30, HOURS);
     resetTimeWithClockStep(thirtyHoursInMs, MILLISECONDS);
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     long startMs = TestTimeUtil.START.toEpochMilli();
-    Change change1 = insert(repo, newChange(repo), null, Instant.ofEpochMilli(startMs));
+    Change change1 = insert("repo", newChange(repo), null, Instant.ofEpochMilli(startMs));
     Change change2 =
-        insert(repo, newChange(repo), null, Instant.ofEpochMilli(startMs + thirtyHoursInMs));
+        insert("repo", newChange(repo), null, Instant.ofEpochMilli(startMs + thirtyHoursInMs));
 
     // Stop time so age queries use the same endpoint.
     TestTimeUtil.setClockStep(0, MILLISECONDS);
@@ -2023,11 +2174,11 @@
   public void byBeforeUntil() throws Exception {
     long thirtyHoursInMs = MILLISECONDS.convert(30, HOURS);
     resetTimeWithClockStep(thirtyHoursInMs, MILLISECONDS);
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     long startMs = TestTimeUtil.START.toEpochMilli();
-    Change change1 = insert(repo, newChange(repo), null, Instant.ofEpochMilli(startMs));
+    Change change1 = insert("repo", newChange(repo), null, Instant.ofEpochMilli(startMs));
     Change change2 =
-        insert(repo, newChange(repo), null, Instant.ofEpochMilli(startMs + thirtyHoursInMs));
+        insert("repo", newChange(repo), null, Instant.ofEpochMilli(startMs + thirtyHoursInMs));
     TestTimeUtil.setClockStep(0, MILLISECONDS);
 
     // Change1 was last updated on 2009-09-30 21:00:00 -0000
@@ -2075,11 +2226,11 @@
   public void byAfterSince() throws Exception {
     long thirtyHoursInMs = MILLISECONDS.convert(30, HOURS);
     resetTimeWithClockStep(thirtyHoursInMs, MILLISECONDS);
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     long startMs = TestTimeUtil.START.toEpochMilli();
-    Change change1 = insert(repo, newChange(repo), null, Instant.ofEpochMilli(startMs));
+    Change change1 = insert("repo", newChange(repo), null, Instant.ofEpochMilli(startMs));
     Change change2 =
-        insert(repo, newChange(repo), null, Instant.ofEpochMilli(startMs + thirtyHoursInMs));
+        insert("repo", newChange(repo), null, Instant.ofEpochMilli(startMs + thirtyHoursInMs));
     TestTimeUtil.setClockStep(0, MILLISECONDS);
 
     // Change1 was last updated on 2009-09-30 21:00:00 -0000
@@ -2114,17 +2265,17 @@
 
   @Test
   public void byMergedBefore() throws Exception {
-    assume().that(getSchema().hasField(ChangeField.MERGED_ON)).isTrue();
+    assume().that(getSchema().hasField(ChangeField.MERGED_ON_SPEC)).isTrue();
     long thirtyHoursInMs = MILLISECONDS.convert(30, HOURS);
 
     // Stop the clock, will set time to specific test values.
     resetTimeWithClockStep(0, MILLISECONDS);
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     long startMs = TestTimeUtil.START.toEpochMilli();
     TestTimeUtil.setClock(new Timestamp(startMs));
-    Change change1 = insert(repo, newChange(repo));
-    Change change2 = insert(repo, newChange(repo));
-    Change change3 = insert(repo, newChange(repo));
+    Change change1 = insert("repo", newChange(repo));
+    Change change2 = insert("repo", newChange(repo));
+    Change change3 = insert("repo", newChange(repo));
 
     TestTimeUtil.setClock(new Timestamp(startMs + thirtyHoursInMs));
     submit(change3);
@@ -2174,17 +2325,17 @@
 
   @Test
   public void byMergedAfter() throws Exception {
-    assume().that(getSchema().hasField(ChangeField.MERGED_ON)).isTrue();
+    assume().that(getSchema().hasField(ChangeField.MERGED_ON_SPEC)).isTrue();
     long thirtyHoursInMs = MILLISECONDS.convert(30, HOURS);
 
     // Stop the clock, will set time to specific test values.
     resetTimeWithClockStep(0, MILLISECONDS);
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     long startMs = TestTimeUtil.START.toEpochMilli();
     TestTimeUtil.setClock(new Timestamp(startMs));
-    Change change1 = insert(repo, newChange(repo));
-    Change change2 = insert(repo, newChange(repo));
-    Change change3 = insert(repo, newChange(repo));
+    Change change1 = insert("repo", newChange(repo));
+    Change change2 = insert("repo", newChange(repo));
+    Change change3 = insert("repo", newChange(repo));
     assertThat(TimeUtil.nowMs()).isEqualTo(startMs);
 
     TestTimeUtil.setClock(new Timestamp(startMs + thirtyHoursInMs));
@@ -2244,18 +2395,18 @@
 
   @Test
   public void updatedThenMergedOrder() throws Exception {
-    assume().that(getSchema().hasField(ChangeField.MERGED_ON)).isTrue();
+    assume().that(getSchema().hasField(ChangeField.MERGED_ON_SPEC)).isTrue();
     long thirtyHoursInMs = MILLISECONDS.convert(30, HOURS);
 
     // Stop the clock, will set time to specific test values.
     resetTimeWithClockStep(0, MILLISECONDS);
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     long startMs = TestTimeUtil.START.toEpochMilli();
     TestTimeUtil.setClock(new Timestamp(startMs));
 
-    Change change1 = insert(repo, newChange(repo));
-    Change change2 = insert(repo, newChange(repo));
-    Change change3 = insert(repo, newChange(repo));
+    Change change1 = insert("repo", newChange(repo));
+    Change change2 = insert("repo", newChange(repo));
+    Change change3 = insert("repo", newChange(repo));
 
     TestTimeUtil.setClock(new Timestamp(startMs + thirtyHoursInMs));
     submit(change2);
@@ -2282,15 +2433,15 @@
 
   @Test
   public void bySize() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
 
     // added = 3, deleted = 0, delta = 3
     RevCommit commit1 = repo.parseBody(repo.commit().add("file1", "foo\n\foo\nfoo").create());
     // added = 0, deleted = 2, delta = 2
     RevCommit commit2 = repo.parseBody(repo.commit().parent(commit1).add("file1", "foo").create());
 
-    Change change1 = insert(repo, newChangeForCommit(repo, commit1));
-    Change change2 = insert(repo, newChangeForCommit(repo, commit2));
+    Change change1 = insert("repo", newChangeForCommit(repo, commit1));
+    Change change2 = insert("repo", newChangeForCommit(repo, commit2));
 
     assertQuery("added:>4");
     assertQuery("-added:<=4");
@@ -2338,9 +2489,9 @@
   }
 
   private List<Change> setUpHashtagChanges() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChange(repo));
-    Change change2 = insert(repo, newChange(repo));
+    repo = createAndOpenProject("repo");
+    Change change1 = insert("repo", newChange(repo));
+    Change change2 = insert("repo", newChange(repo));
 
     addHashtags(change1.getId(), "foo", "aaa-bbb-ccc");
     addHashtags(change2.getId(), "foo", "bar", "a tag", "ACamelCaseTag");
@@ -2387,10 +2538,10 @@
 
   @Test
   public void byHashtagRegex() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChange(repo));
-    Change change2 = insert(repo, newChange(repo));
-    Change change3 = insert(repo, newChange(repo));
+    repo = createAndOpenProject("repo");
+    Change change1 = insert("repo", newChange(repo));
+    Change change2 = insert("repo", newChange(repo));
+    Change change3 = insert("repo", newChange(repo));
     addHashtags(change1.getId(), "feature1");
     addHashtags(change1.getId(), "trending");
     addHashtags(change2.getId(), "Cherrypick-feature1");
@@ -2403,27 +2554,27 @@
 
   @Test
   public void byDefault() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
 
-    Change change1 = insert(repo, newChange(repo));
+    Change change1 = insert("repo", newChange(repo));
 
     RevCommit commit2 = repo.parseBody(repo.commit().message("foosubject").create());
-    Change change2 = insert(repo, newChangeForCommit(repo, commit2));
+    Change change2 = insert("repo", newChangeForCommit(repo, commit2));
 
     RevCommit commit3 = repo.parseBody(repo.commit().add("Foo.java", "foo contents").create());
-    Change change3 = insert(repo, newChangeForCommit(repo, commit3));
+    Change change3 = insert("repo", newChangeForCommit(repo, commit3));
 
     ChangeInserter ins4 = newChange(repo);
-    Change change4 = insert(repo, ins4);
+    Change change4 = insert("repo", ins4);
     ReviewInput ri4 = new ReviewInput();
     ri4.message = "toplevel";
     ri4.labels = ImmutableMap.of("Code-Review", (short) 1);
     gApi.changes().id(change4.getId().get()).current().review(ri4);
 
     ChangeInserter ins5 = newChangeWithTopic(repo, "feature5");
-    Change change5 = insert(repo, ins5);
+    Change change5 = insert("repo", ins5);
 
-    Change change6 = insert(repo, newChangeForBranch(repo, "branch6"));
+    Change change6 = insert("repo", newChangeForBranch(repo, "branch6"));
 
     assertQuery(change1.getId().get(), change1);
     assertQuery(ChangeTriplet.format(change1), change1);
@@ -2444,18 +2595,18 @@
 
   @Test
   public void byDefaultWithCommitPrefix() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     RevCommit commit = repo.parseBody(repo.commit().message("message").create());
-    Change change = insert(repo, newChangeForCommit(repo, commit));
+    Change change = insert("repo", newChangeForCommit(repo, commit));
 
     assertQuery(commit.getId().getName().substring(0, 6), change);
   }
 
   @Test
   public void visible() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChange(repo));
-    Change change2 = insert(repo, newChangePrivate(repo));
+    repo = createAndOpenProject("repo");
+    Change change1 = insert("repo", newChange(repo));
+    Change change2 = insert("repo", newChangePrivate(repo));
 
     String q = "project:repo";
 
@@ -2509,8 +2660,8 @@
 
     // Switch to user3
     requestContext.setContext(newRequestContext(user3));
-    Change change3 = insert(repo, newChange(repo), user3);
-    Change change4 = insert(repo, newChangePrivate(repo), user3);
+    Change change3 = insert("repo", newChange(repo), user3);
+    Change change4 = insert("repo", newChangePrivate(repo), user3);
 
     // User3 can see both their changes and the first user's change
     assertQuery(q + " visibleto:" + user3.get(), change4, change3, change1);
@@ -2550,9 +2701,9 @@
 
   @Test
   public void visibleToSelf() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChange(repo));
-    Change change2 = insert(repo, newChange(repo));
+    repo = createAndOpenProject("repo");
+    Change change1 = insert("repo", newChange(repo));
+    Change change2 = insert("repo", newChange(repo));
 
     gApi.changes().id(change2.getChangeId()).setPrivate(true, "private");
 
@@ -2568,16 +2719,12 @@
 
   @Test
   public void byCommentBy() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChange(repo));
-    Change change2 = insert(repo, newChange(repo));
 
-    int user2 =
-        accountManager
-            .authenticate(authRequestFactory.createForUser("anotheruser"))
-            .getAccountId()
-            .get();
+    repo = createAndOpenProject("repo");
+    Change change1 = insert("repo", newChange(repo));
+    Change change2 = insert("repo", newChange(repo));
 
+    Account.Id user2 = createAccount("anotheruser");
     ReviewInput input = new ReviewInput();
     input.message = "toplevel";
     ReviewInput.CommentInput comment = new ReviewInput.CommentInput();
@@ -2598,8 +2745,8 @@
   public void bySubmitRuleResult() throws Exception {
     try (Registration registration =
         extensionRegistry.newRegistration().add(new FakeSubmitRule())) {
-      TestRepository<Repo> repo = createProject("repo");
-      Change change = insert(repo, newChange(repo));
+      repo = createAndOpenProject("repo");
+      Change change = insert("repo", newChange(repo));
       // The fake submit rule exports its ruleName as "FakeSubmitRule"
       assertQuery("rule:FakeSubmitRule");
 
@@ -2618,17 +2765,17 @@
   public void byNonExistingSubmitRule_returnsEmpty() throws Exception {
     try (Registration registration =
         extensionRegistry.newRegistration().add(new FakeSubmitRule())) {
-      TestRepository<Repo> repo = createProject("repo");
-      insert(repo, newChange(repo));
+      repo = createAndOpenProject("repo");
+      insert("repo", newChange(repo));
       assertQuery("rule:non-existent-rule");
     }
   }
 
   @Test
   public void byHasDraft() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChange(repo));
-    Change change2 = insert(repo, newChange(repo));
+    repo = createAndOpenProject("repo");
+    Change change1 = insert("repo", newChange(repo));
+    Change change2 = insert("repo", newChange(repo));
 
     assertQuery("has:draft");
 
@@ -2660,8 +2807,8 @@
    */
   public void byHasDraftExcludesZombieDrafts() throws Exception {
     Project.NameKey project = Project.nameKey("repo");
-    TestRepository<Repo> repo = createProject(project.get());
-    Change change = insert(repo, newChange(repo));
+    repo = createAndOpenProject(project.get());
+    Change change = insert("repo", newChange(repo));
     Change.Id id = change.getId();
 
     DraftInput in = new DraftInput();
@@ -2673,7 +2820,7 @@
     assertQuery("has:draft", change);
     assertQuery("commentby:" + userId);
 
-    try (TestRepository<Repo> allUsers =
+    try (TestRepository<Repository> allUsers =
         new TestRepository<>(repoManager.openRepository(allUsersName))) {
       Ref draftsRef = allUsers.getRepository().exactRef(RefNames.refsDraftComments(id, userId));
       assertThat(draftsRef).isNotNull();
@@ -2697,15 +2844,15 @@
 
   @Test
   public void byHasDraftWithManyDrafts() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     Change[] changesWithDrafts = new Change[30];
 
     // unrelated change not shown in the result.
-    insert(repo, newChange(repo));
+    insert("repo", newChange(repo));
 
     for (int i = 0; i < changesWithDrafts.length; i++) {
       // put the changes in reverse order since this is the order we receive them from the index.
-      changesWithDrafts[changesWithDrafts.length - 1 - i] = insert(repo, newChange(repo));
+      changesWithDrafts[changesWithDrafts.length - 1 - i] = insert("repo", newChange(repo));
       DraftInput in = new DraftInput();
       in.line = 1;
       in.message = "nit: trailing whitespace";
@@ -2725,10 +2872,10 @@
 
   @Test
   public void byStarredBy() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChange(repo));
-    Change change2 = insert(repo, newChange(repo));
-    insert(repo, newChange(repo));
+    repo = createAndOpenProject("repo");
+    Change change1 = insert("repo", newChange(repo));
+    Change change2 = insert("repo", newChange(repo));
+    insert("repo", newChange(repo));
 
     gApi.accounts().self().starChange(change1.getId().toString());
     gApi.accounts().self().starChange(change2.getId().toString());
@@ -2745,8 +2892,8 @@
   @Test
   public void byStar_withStarOptionSet() throws Exception {
     // When star option is set, the 'starred' field is set in the change infos in response.
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChangeWithStatus(repo, Change.Status.MERGED));
+    repo = createAndOpenProject("repo");
+    Change change1 = insert("repo", newChangeWithStatus(repo, Change.Status.MERGED));
 
     Account.Id user2 =
         accountManager.authenticate(authRequestFactory.createForUser("anotheruser")).getAccountId();
@@ -2767,8 +2914,8 @@
   @Test
   public void byStar_withStarOptionNotSet() throws Exception {
     // When star option is not set, the 'starred' field is not set in the change infos in response.
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChangeWithStatus(repo, Change.Status.MERGED));
+    repo = createAndOpenProject("repo");
+    Change change1 = insert("repo", newChangeWithStatus(repo, Change.Status.MERGED));
 
     Account.Id user2 =
         accountManager.authenticate(authRequestFactory.createForUser("anotheruser")).getAccountId();
@@ -2788,8 +2935,8 @@
   @Test
   public void byStar_withStarOptionSet_notPopulatedForAnonymousUsers() throws Exception {
     // Create a random change and star it as some user
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChangeWithStatus(repo, Change.Status.NEW));
+    repo = createAndOpenProject("repo");
+    Change change1 = insert("repo", newChangeWithStatus(repo, Change.Status.NEW));
     Account.Id user2 =
         accountManager.authenticate(authRequestFactory.createForUser("anotheruser")).getAccountId();
     requestContext.setContext(newRequestContext(user2));
@@ -2805,11 +2952,11 @@
 
   @Test
   public void byStarWithManyStars() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     Change[] changesWithDrafts = new Change[30];
     for (int i = 0; i < changesWithDrafts.length; i++) {
       // put the changes in reverse order since this is the order we receive them from the index.
-      changesWithDrafts[changesWithDrafts.length - 1 - i] = insert(repo, newChange(repo));
+      changesWithDrafts[changesWithDrafts.length - 1 - i] = insert("repo", newChange(repo));
 
       // star the change
       gApi.accounts()
@@ -2823,12 +2970,12 @@
 
   @Test
   public void byFrom() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChange(repo));
+    repo = createAndOpenProject("repo");
+    Change change1 = insert("repo", newChange(repo));
 
     Account.Id user2 =
         accountManager.authenticate(authRequestFactory.createForUser("anotheruser")).getAccountId();
-    Change change2 = insert(repo, newChange(repo), user2);
+    Change change2 = insert("repo", newChange(repo), user2);
 
     ReviewInput input = new ReviewInput();
     input.message = "toplevel";
@@ -2844,7 +2991,7 @@
 
   @Test
   public void conflicts() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     RevCommit commit1 =
         repo.parseBody(
             repo.commit()
@@ -2856,10 +3003,10 @@
     RevCommit commit3 =
         repo.parseBody(repo.commit().add("dir/file2", "contents2 different").create());
     RevCommit commit4 = repo.parseBody(repo.commit().add("file4", "contents4").create());
-    Change change1 = insert(repo, newChangeForCommit(repo, commit1));
-    Change change2 = insert(repo, newChangeForCommit(repo, commit2));
-    Change change3 = insert(repo, newChangeForCommit(repo, commit3));
-    Change change4 = insert(repo, newChangeForCommit(repo, commit4));
+    Change change1 = insert("repo", newChangeForCommit(repo, commit1));
+    Change change2 = insert("repo", newChangeForCommit(repo, commit2));
+    Change change3 = insert("repo", newChangeForCommit(repo, commit3));
+    Change change4 = insert("repo", newChangeForCommit(repo, commit4));
 
     assertQuery("conflicts:" + change1.getId().get(), change3);
     assertQuery("conflicts:" + change2.getId().get());
@@ -2872,11 +3019,12 @@
       name = "change.mergeabilityComputationBehavior",
       value = "API_REF_UPDATED_AND_CHANGE_REINDEX")
   public void mergeable() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    assume().that(getSchema().hasField(ChangeField.MERGEABLE_SPEC)).isTrue();
+    repo = createAndOpenProject("repo");
     RevCommit commit1 = repo.parseBody(repo.commit().add("file1", "contents1").create());
     RevCommit commit2 = repo.parseBody(repo.commit().add("file1", "contents2").create());
-    Change change1 = insert(repo, newChangeForCommit(repo, commit1));
-    Change change2 = insert(repo, newChangeForCommit(repo, commit2));
+    Change change1 = insert("repo", newChangeForCommit(repo, commit1));
+    Change change2 = insert("repo", newChangeForCommit(repo, commit2));
 
     assertQuery("conflicts:" + change1.getId().get(), change2);
     assertQuery("conflicts:" + change2.getId().get(), change1);
@@ -2899,10 +3047,10 @@
 
   @Test
   public void cherrypick() throws Exception {
-    assume().that(getSchema().hasField(ChangeField.CHERRY_PICK)).isTrue();
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChange(repo));
-    Change change2 = insert(repo, newCherryPickChange(repo, "foo", change1.currentPatchSetId()));
+    assume().that(getSchema().hasField(ChangeField.CHERRY_PICK_SPEC)).isTrue();
+    repo = createAndOpenProject("repo");
+    Change change1 = insert("repo", newChange(repo));
+    Change change2 = insert("repo", newCherryPickChange(repo, "foo", change1.currentPatchSetId()));
 
     assertQuery("is:cherrypick", change2);
     assertQuery("-is:cherrypick", change1);
@@ -2910,15 +3058,15 @@
 
   @Test
   public void merge() throws Exception {
-    assume().that(getSchema().hasField(ChangeField.MERGE)).isTrue();
-    TestRepository<Repo> repo = createProject("repo");
+    assume().that(getSchema().hasField(ChangeField.MERGE_SPEC)).isTrue();
+    repo = createAndOpenProject("repo");
     RevCommit commit1 = repo.parseBody(repo.commit().add("file1", "contents1").create());
     RevCommit commit2 = repo.parseBody(repo.commit().add("file1", "contents2").create());
     RevCommit commit3 =
         repo.parseBody(repo.commit().parent(commit2).add("file1", "contents3").create());
-    Change change1 = insert(repo, newChangeForCommit(repo, commit1));
-    Change change2 = insert(repo, newChangeForCommit(repo, commit2));
-    Change change3 = insert(repo, newChangeForCommit(repo, commit3));
+    Change change1 = insert("repo", newChangeForCommit(repo, commit1));
+    Change change2 = insert("repo", newChangeForCommit(repo, commit2));
+    Change change3 = insert("repo", newChangeForCommit(repo, commit3));
     RevCommit mergeCommit =
         repo.branch("master")
             .commit()
@@ -2927,7 +3075,7 @@
             .parent(commit3)
             .insertChangeId()
             .create();
-    Change mergeChange = insert(repo, newChangeForCommit(repo, mergeCommit));
+    Change mergeChange = insert("repo", newChangeForCommit(repo, mergeCommit));
 
     assertQuery("status:open is:merge", mergeChange);
     assertQuery("status:open -is:merge", change3, change2, change1);
@@ -2937,10 +3085,10 @@
   @Test
   public void reviewedBy() throws Exception {
     resetTimeWithClockStep(2, MINUTES);
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChange(repo));
-    Change change2 = insert(repo, newChange(repo));
-    Change change3 = insert(repo, newChange(repo));
+    repo = createAndOpenProject("repo");
+    Change change1 = insert("repo", newChange(repo));
+    Change change2 = insert("repo", newChange(repo));
+    Change change3 = insert("repo", newChange(repo));
 
     gApi.changes().id(change1.getId().get()).current().review(new ReviewInput().message("comment"));
 
@@ -2951,7 +3099,7 @@
     gApi.changes().id(change2.getId().get()).current().review(new ReviewInput().message("comment"));
 
     PatchSet.Id ps3_1 = change3.currentPatchSetId();
-    change3 = newPatchSet(repo, change3, user);
+    change3 = newPatchSet("repo", change3, user, /* message= */ Optional.empty());
     assertThat(change3.currentPatchSetId()).isNotEqualTo(ps3_1);
     // Response to previous patch set still counts as reviewing.
     gApi.changes()
@@ -2978,11 +3126,11 @@
   @Test
   public void reviewerAndCc() throws Exception {
     Account.Id user1 = createAccount("user1");
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChange(repo));
-    Change change2 = insert(repo, newChange(repo));
-    Change change3 = insert(repo, newChange(repo));
-    insert(repo, newChange(repo));
+    repo = createAndOpenProject("repo");
+    Change change1 = insert("repo", newChange(repo));
+    Change change2 = insert("repo", newChange(repo));
+    Change change3 = insert("repo", newChange(repo));
+    insert("repo", newChange(repo));
 
     ReviewerInput rin = new ReviewerInput();
     rin.reviewer = user1.toString();
@@ -3009,11 +3157,11 @@
 
   @Test
   public void byReviewed() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     Account.Id otherUser =
         accountManager.authenticate(authRequestFactory.createForUser("anotheruser")).getAccountId();
-    Change change1 = insert(repo, newChange(repo));
-    Change change2 = insert(repo, newChange(repo));
+    Change change1 = insert("repo", newChange(repo));
+    Change change2 = insert("repo", newChange(repo));
 
     assertQuery("is:reviewed");
     assertQuery("status:reviewed");
@@ -3037,11 +3185,11 @@
         accountManager.authenticate(authRequestFactory.createForUser("user2")).getAccountId();
     Account.Id user3 =
         accountManager.authenticate(authRequestFactory.createForUser("user3")).getAccountId();
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
 
-    Change change1 = insert(repo, newChange(repo));
-    Change change2 = insert(repo, newChange(repo));
-    Change change3 = insert(repo, newChange(repo));
+    Change change1 = insert("repo", newChange(repo));
+    Change change2 = insert("repo", newChange(repo));
+    Change change3 = insert("repo", newChange(repo));
 
     ReviewerInput rin = new ReviewerInput();
     rin.reviewer = user1.toString();
@@ -3081,7 +3229,7 @@
   @Test
   public void reviewerAndCcByEmail() throws Exception {
     Project.NameKey project = Project.nameKey("repo");
-    TestRepository<Repo> repo = createProject(project.get());
+    repo = createAndOpenProject(project.get());
     ConfigInput conf = new ConfigInput();
     conf.enableReviewerByEmail = InheritableBoolean.TRUE;
     gApi.projects().name(project.get()).config(conf);
@@ -3089,9 +3237,9 @@
     String userByEmail = "un.registered@reviewer.com";
     String userByEmailWithName = "John Doe <" + userByEmail + ">";
 
-    Change change1 = insert(repo, newChange(repo));
-    Change change2 = insert(repo, newChange(repo));
-    insert(repo, newChange(repo));
+    Change change1 = insert("repo", newChange(repo));
+    Change change2 = insert("repo", newChange(repo));
+    insert("repo", newChange(repo));
 
     ReviewerInput rin = new ReviewerInput();
     rin.reviewer = userByEmailWithName;
@@ -3114,16 +3262,16 @@
   @Test
   public void reviewerAndCcByEmailWithQueryForDifferentUser() throws Exception {
     Project.NameKey project = Project.nameKey("repo");
-    TestRepository<Repo> repo = createProject(project.get());
+    repo = createAndOpenProject(project.get());
     ConfigInput conf = new ConfigInput();
     conf.enableReviewerByEmail = InheritableBoolean.TRUE;
     gApi.projects().name(project.get()).config(conf);
 
     String userByEmail = "John Doe <un.registered@reviewer.com>";
 
-    Change change1 = insert(repo, newChange(repo));
-    Change change2 = insert(repo, newChange(repo));
-    insert(repo, newChange(repo));
+    Change change1 = insert("repo", newChange(repo));
+    Change change2 = insert("repo", newChange(repo));
+    insert("repo", newChange(repo));
 
     ReviewerInput rin = new ReviewerInput();
     rin.reviewer = userByEmail;
@@ -3142,9 +3290,9 @@
   @Test
   public void submitRecords() throws Exception {
     Account.Id user1 = createAccount("user1");
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChange(repo));
-    Change change2 = insert(repo, newChange(repo));
+    repo = createAndOpenProject("repo");
+    Change change1 = insert("repo", newChange(repo));
+    Change change2 = insert("repo", newChange(repo));
 
     gApi.changes().id(change1.getId().get()).current().review(ReviewInput.approve());
     requestContext.setContext(newRequestContext(user1));
@@ -3155,7 +3303,7 @@
     assertQuery("-is:submittable", change2);
 
     assertQuery("label:CodE-RevieW=ok", change1);
-    assertQuery("label:CodE-RevieW=ok,user=user", change1);
+    assertQuery("label:CodE-RevieW=ok,user=" + userAccount.preferredEmail(), change1);
     assertQuery("label:CodE-RevieW=ok,Administrators", change1);
     assertQuery("label:CodE-RevieW=ok,group=Administrators", change1);
     assertQuery("label:CodE-RevieW=ok,owner", change1);
@@ -3174,10 +3322,10 @@
   public void hasEdit() throws Exception {
     Account.Id user1 = createAccount("user1");
     Account.Id user2 = createAccount("user2");
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChange(repo));
+    repo = createAndOpenProject("repo");
+    Change change1 = insert("repo", newChange(repo));
     String changeId1 = change1.getKey().get();
-    Change change2 = insert(repo, newChange(repo));
+    Change change2 = insert("repo", newChange(repo));
     String changeId2 = change2.getKey().get();
 
     requestContext.setContext(newRequestContext(user1));
@@ -3198,10 +3346,10 @@
 
   @Test
   public void byUnresolved() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChange(repo));
-    Change change2 = insert(repo, newChange(repo));
-    Change change3 = insert(repo, newChange(repo));
+    repo = createAndOpenProject("repo");
+    Change change1 = insert("repo", newChange(repo));
+    Change change2 = insert("repo", newChange(repo));
+    Change change3 = insert("repo", newChange(repo));
 
     // Change1 has one resolved comment (unresolvedcount = 0)
     // Change2 has one unresolved comment (unresolvedcount = 1)
@@ -3229,13 +3377,13 @@
 
   @Test
   public void byCommitsOnBranchNotMerged() throws Exception {
-    TestRepository<Repo> tr = createProject("repo");
-    testByCommitsOnBranchNotMerged(tr, ImmutableSet.of());
+    createProject("repo");
+    testByCommitsOnBranchNotMerged("repo", ImmutableSet.of());
   }
 
   @Test
   public void byCommitsOnBranchNotMergedSkipsMissingChanges() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     ObjectId missing =
         repo.branch(PatchSet.id(Change.id(987654), 1).toRefName())
             .commit()
@@ -3243,72 +3391,75 @@
             .insertChangeId()
             .create()
             .copy();
-    testByCommitsOnBranchNotMerged(repo, ImmutableSet.of(missing));
+    testByCommitsOnBranchNotMerged("repo", ImmutableSet.of(missing));
   }
 
-  private void testByCommitsOnBranchNotMerged(TestRepository<Repo> repo, Collection<ObjectId> extra)
+  private void testByCommitsOnBranchNotMerged(String repo, Collection<ObjectId> extra)
       throws Exception {
     int n = 10;
     List<String> shas = new ArrayList<>(n + extra.size());
     extra.forEach(i -> shas.add(i.name()));
     List<Integer> expectedIds = new ArrayList<>(n);
     BranchNameKey dest = null;
-    for (int i = 0; i < n; i++) {
-      ChangeInserter ins = newChange(repo);
-      insert(repo, ins);
-      if (dest == null) {
-        dest = ins.getChange().getDest();
+    try (TestRepository<Repository> repository =
+        new TestRepository<>(repoManager.openRepository(Project.nameKey(repo)))) {
+      for (int i = 0; i < n; i++) {
+        ChangeInserter ins = newChange(repository);
+        insert("repo", ins);
+        if (dest == null) {
+          dest = ins.getChange().getDest();
+        }
+        shas.add(ins.getCommitId().name());
+        expectedIds.add(ins.getChange().getId().get());
       }
-      shas.add(ins.getCommitId().name());
-      expectedIds.add(ins.getChange().getId().get());
     }
-
-    for (int i = 1; i <= 11; i++) {
-      Iterable<ChangeData> cds =
-          queryProvider.get().byCommitsOnBranchNotMerged(repo.getRepository(), dest, shas, i);
-      Iterable<Integer> ids = FluentIterable.from(cds).transform(in -> in.getId().get());
-      String name = "limit " + i;
-      assertWithMessage(name).that(ids).hasSize(n);
-      assertWithMessage(name).that(ids).containsExactlyElementsIn(expectedIds);
+    try (Repository repository = repoManager.openRepository(Project.nameKey(repo))) {
+      for (int i = 1; i <= 11; i++) {
+        Iterable<ChangeData> cds =
+            queryProvider.get().byCommitsOnBranchNotMerged(repository, dest, shas, i);
+        Iterable<Integer> ids = FluentIterable.from(cds).transform(in -> in.getId().get());
+        String name = "limit " + i;
+        assertWithMessage(name).that(ids).hasSize(n);
+        assertWithMessage(name).that(ids).containsExactlyElementsIn(expectedIds);
+      }
     }
   }
 
   @Test
   public void reindexIfStale() throws Exception {
-    Account.Id user = createAccount("user");
     Project.NameKey project = Project.nameKey("repo");
-    TestRepository<Repo> repo = createProject(project.get());
-    Change change = insert(repo, newChange(repo));
+    repo = createAndOpenProject(project.get());
+    Change change = insert("repo", newChange(repo));
     String changeId = change.getKey().get();
-    ChangeNotes notes = notesFactory.create(change.getProject(), change.getId());
-    PatchSet ps = psUtil.get(notes, change.currentPatchSetId());
 
-    requestContext.setContext(newRequestContext(user));
-    gApi.changes().id(changeId).edit().create();
-    assertQuery("has:edit", change);
+    Account.Id anotherUser = createAccount("another-user");
+    requestContext.setContext(newRequestContext(anotherUser));
+    gApi.changes().id(changeId).addReviewer(anotherUser.toString());
+
+    assertQuery("reviewer:self", change);
     assertThat(indexer.reindexIfStale(project, change.getId()).get()).isFalse();
 
-    // Delete edit ref behind index's back.
-    RefUpdate ru = repo.getRepository().updateRef(RefNames.refsEdit(user, change.getId(), ps.id()));
-    ru.setForceUpdate(true);
-    assertThat(ru.delete()).isEqualTo(RefUpdate.Result.FORCED);
+    // Remove reviewer behind index's back.
+    ChangeUpdate update = newUpdate(change);
+    update.removeReviewer(anotherUser);
+    update.commit();
 
     // Index is stale.
-    assertQuery("has:edit", change);
+    assertQuery("reviewer:self", change);
     assertThat(indexer.reindexIfStale(project, change.getId()).get()).isTrue();
-    assertQuery("has:edit");
+    assertQuery("reviewer:self");
   }
 
   @Test
   public void watched() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    ChangeInserter ins1 = newChangeWithStatus(repo, Change.Status.NEW);
-    Change change1 = insert(repo, ins1);
+    createProject("repo");
+    ChangeInserter ins1 = newChangeWithStatus("repo", Change.Status.NEW);
+    Change change1 = insert("repo", ins1);
 
-    TestRepository<Repo> repo2 = createProject("repo2");
+    createProject("repo2");
 
-    ChangeInserter ins2 = newChangeWithStatus(repo2, Change.Status.NEW);
-    insert(repo2, ins2);
+    ChangeInserter ins2 = newChangeWithStatus("repo2", Change.Status.NEW);
+    insert("repo2", ins2);
 
     assertQuery("is:watched");
 
@@ -3328,17 +3479,17 @@
 
   @Test
   public void trackingid() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     RevCommit commit1 =
         repo.parseBody(repo.commit().message("Change one\n\nBug:QUERY123").create());
-    Change change1 = insert(repo, newChangeForCommit(repo, commit1));
+    Change change1 = insert("repo", newChangeForCommit(repo, commit1));
     RevCommit commit2 =
         repo.parseBody(repo.commit().message("Change two\n\nIssue: Issue 16038\n").create());
-    Change change2 = insert(repo, newChangeForCommit(repo, commit2));
+    Change change2 = insert("repo", newChangeForCommit(repo, commit2));
 
     RevCommit commit3 =
         repo.parseBody(repo.commit().message("Change two\n\nGoogle-Bug-Id: b/16039\n").create());
-    Change change3 = insert(repo, newChangeForCommit(repo, commit3));
+    Change change3 = insert("repo", newChangeForCommit(repo, commit3));
 
     assertQuery("tr:QUERY123", change1);
     assertQuery("bug:QUERY123", change1);
@@ -3364,9 +3515,9 @@
 
   @Test
   public void revertOf() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     // Create two commits and revert second commit (initial commit can't be reverted)
-    Change initial = insert(repo, newChange(repo));
+    Change initial = insert("repo", newChange(repo));
     gApi.changes().id(initial.getChangeId()).current().review(ReviewInput.approve());
     gApi.changes().id(initial.getChangeId()).current().submit();
 
@@ -3381,10 +3532,10 @@
 
   @Test
   public void submissionId() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change = insert(repo, newChange(repo));
+    repo = createAndOpenProject("repo");
+    Change change = insert("repo", newChange(repo));
     // create irrelevant change
-    insert(repo, newChange(repo));
+    insert("repo", newChange(repo));
     gApi.changes().id(change.getChangeId()).current().review(ReviewInput.approve());
     gApi.changes().id(change.getChangeId()).current().submit();
     String submissionId = gApi.changes().id(change.getChangeId()).get().submissionId;
@@ -3402,7 +3553,6 @@
     private boolean wip;
     private boolean abandoned;
     @Nullable private Account.Id mergedBy;
-    @Nullable private Account.Id assigneeId;
 
     @Nullable Change.Id id;
 
@@ -3414,11 +3564,6 @@
       deleteDraftCommentBy = new ArrayList<>();
     }
 
-    DashboardChangeState assignTo(Account.Id assigneeId) {
-      this.assigneeId = assigneeId;
-      return this;
-    }
-
     DashboardChangeState wip() {
       wip = true;
       return this;
@@ -3454,16 +3599,11 @@
       return this;
     }
 
-    DashboardChangeState create(TestRepository<Repo> repo) throws Exception {
+    DashboardChangeState create(TestRepository<Repository> repo) throws Exception {
       requestContext.setContext(newRequestContext(ownerId));
-      Change change = insert(repo, newChange(repo), ownerId);
+      Change change = insert("repo", newChange(repo), ownerId);
       id = change.getId();
       ChangeApi cApi = gApi.changes().id(change.getChangeId());
-      if (assigneeId != null) {
-        AssigneeInput in = new AssigneeInput();
-        in.assignee = "" + assigneeId;
-        cApi.setAssignee(in);
-      }
       if (wip) {
         cApi.setWorkInProgress();
       }
@@ -3524,7 +3664,7 @@
 
   @Test
   public void dashboardHasUnpublishedDrafts() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     Account.Id otherAccountId = createAccount("other");
     DashboardChangeState hasUnpublishedDraft =
         new DashboardChangeState(otherAccountId).draftCommentBy(user.getAccountId()).create(repo);
@@ -3541,35 +3681,8 @@
   }
 
   @Test
-  public void dashboardAssignedReviews() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Account.Id otherAccountId = createAccount("other");
-    DashboardChangeState otherOpenWip =
-        new DashboardChangeState(otherAccountId).wip().assignTo(user.getAccountId()).create(repo);
-    DashboardChangeState selfOpenWip =
-        new DashboardChangeState(user.getAccountId())
-            .wip()
-            .assignTo(user.getAccountId())
-            .create(repo);
-
-    // Create changes that should not be returned by query.
-    new DashboardChangeState(user.getAccountId()).assignTo(user.getAccountId()).abandon();
-    new DashboardChangeState(user.getAccountId())
-        .assignTo(user.getAccountId())
-        .mergeBy(user.getAccountId());
-
-    assertDashboardQuery(
-        "self", IndexPreloadingUtil.DASHBOARD_ASSIGNED_QUERY, selfOpenWip, otherOpenWip);
-
-    // Viewing another user's dashboard.
-    requestContext.setContext(newRequestContext(otherAccountId));
-    assertDashboardQuery(
-        user.getUserName().get(), IndexPreloadingUtil.DASHBOARD_ASSIGNED_QUERY, otherOpenWip);
-  }
-
-  @Test
   public void dashboardWorkInProgressReviews() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     DashboardChangeState ownedOpenWip =
         new DashboardChangeState(user.getAccountId()).wip().create(repo);
 
@@ -3584,7 +3697,7 @@
 
   @Test
   public void dashboardOutgoingReviews() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     Account.Id otherAccountId = createAccount("other");
     DashboardChangeState ownedOpenReviewable =
         new DashboardChangeState(user.getAccountId()).create(repo);
@@ -3599,23 +3712,18 @@
     // Viewing another user's dashboard.
     requestContext.setContext(newRequestContext(otherAccountId));
     assertDashboardQuery(
-        user.getUserName().get(),
-        IndexPreloadingUtil.DASHBOARD_OUTGOING_QUERY,
-        ownedOpenReviewable);
+        userId.toString(), IndexPreloadingUtil.DASHBOARD_OUTGOING_QUERY, ownedOpenReviewable);
   }
 
   @Test
   public void dashboardIncomingReviews() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     Account.Id otherAccountId = createAccount("other");
     DashboardChangeState reviewingReviewable =
         new DashboardChangeState(otherAccountId).addReviewer(user.getAccountId()).create(repo);
-    DashboardChangeState assignedReviewable =
-        new DashboardChangeState(otherAccountId).assignTo(user.getAccountId()).create(repo);
 
     // Create changes that should not be returned by any queries in this test.
     new DashboardChangeState(otherAccountId).wip().addReviewer(user.getAccountId()).create(repo);
-    new DashboardChangeState(otherAccountId).wip().assignTo(user.getAccountId()).create(repo);
     new DashboardChangeState(otherAccountId).addReviewer(otherAccountId).create(repo);
     new DashboardChangeState(otherAccountId)
         .addReviewer(user.getAccountId())
@@ -3623,24 +3731,17 @@
         .create(repo);
 
     // Viewing one's own dashboard.
-    assertDashboardQuery(
-        "self",
-        IndexPreloadingUtil.DASHBOARD_INCOMING_QUERY,
-        assignedReviewable,
-        reviewingReviewable);
+    assertDashboardQuery("self", IndexPreloadingUtil.DASHBOARD_INCOMING_QUERY, reviewingReviewable);
 
     // Viewing another user's dashboard.
     requestContext.setContext(newRequestContext(otherAccountId));
     assertDashboardQuery(
-        user.getUserName().get(),
-        IndexPreloadingUtil.DASHBOARD_INCOMING_QUERY,
-        assignedReviewable,
-        reviewingReviewable);
+        userId.toString(), IndexPreloadingUtil.DASHBOARD_INCOMING_QUERY, reviewingReviewable);
   }
 
   @Test
   public void dashboardRecentlyClosedReviews() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     Account.Id otherAccountId = createAccount("other");
     DashboardChangeState mergedOwned =
         new DashboardChangeState(user.getAccountId()).mergeBy(user.getAccountId()).create(repo);
@@ -3654,11 +3755,6 @@
             .addCc(user.getAccountId())
             .mergeBy(user.getAccountId())
             .create(repo);
-    DashboardChangeState mergedAssigned =
-        new DashboardChangeState(otherAccountId)
-            .assignTo(user.getAccountId())
-            .mergeBy(user.getAccountId())
-            .create(repo);
     DashboardChangeState abandonedOwned =
         new DashboardChangeState(user.getAccountId()).abandon().create(repo);
     DashboardChangeState abandonedOwnedWip =
@@ -3668,17 +3764,6 @@
             .addReviewer(user.getAccountId())
             .abandon()
             .create(repo);
-    DashboardChangeState abandonedAssigned =
-        new DashboardChangeState(otherAccountId)
-            .assignTo(user.getAccountId())
-            .abandon()
-            .create(repo);
-    DashboardChangeState abandonedAssignedWip =
-        new DashboardChangeState(otherAccountId)
-            .assignTo(user.getAccountId())
-            .wip()
-            .abandon()
-            .create(repo);
 
     // Create changes that should not be returned by any queries in this test.
     new DashboardChangeState(otherAccountId)
@@ -3691,11 +3776,9 @@
     assertDashboardQuery(
         "self",
         IndexPreloadingUtil.DASHBOARD_RECENTLY_CLOSED_QUERY,
-        abandonedAssigned,
         abandonedReviewing,
         abandonedOwnedWip,
         abandonedOwned,
-        mergedAssigned,
         mergedCced,
         mergedReviewing,
         mergedOwned);
@@ -3703,13 +3786,10 @@
     // Viewing another user's dashboard.
     requestContext.setContext(newRequestContext(otherAccountId));
     assertDashboardQuery(
-        user.getUserName().get(),
+        userId.toString(),
         IndexPreloadingUtil.DASHBOARD_RECENTLY_CLOSED_QUERY,
-        abandonedAssignedWip,
-        abandonedAssigned,
         abandonedReviewing,
         abandonedOwned,
-        mergedAssigned,
         mergedCced,
         mergedReviewing,
         mergedOwned);
@@ -3719,9 +3799,9 @@
   public void attentionSetIndexed() throws Exception {
     assume().that(getSchema().hasField(ChangeField.ATTENTION_SET_USERS)).isTrue();
     assume().that(getSchema().hasField(ChangeField.ATTENTION_SET_USERS_COUNT)).isTrue();
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChange(repo));
-    Change change2 = insert(repo, newChange(repo));
+    repo = createAndOpenProject("repo");
+    Change change1 = insert("repo", newChange(repo));
+    Change change2 = insert("repo", newChange(repo));
 
     AttentionSetInput input = new AttentionSetInput(userId.toString(), "some reason");
     gApi.changes().id(change1.getChangeId()).addToAttentionSet(input);
@@ -3730,7 +3810,7 @@
     assertQuery("-is:attention", change2);
     assertQuery("has:attention", change1);
     assertQuery("-has:attention", change2);
-    assertQuery("attention:" + user.getUserName().get(), change1);
+    assertQuery("attention:" + userAccount.preferredEmail(), change1);
     assertQuery("-attention:" + userId.toString(), change2);
 
     gApi.changes()
@@ -3743,8 +3823,8 @@
   @Test
   public void attentionSetStored() throws Exception {
     assume().that(getSchema().hasField(ChangeField.ATTENTION_SET_USERS)).isTrue();
-    TestRepository<Repo> repo = createProject("repo");
-    Change change = insert(repo, newChange(repo));
+    repo = createAndOpenProject("repo");
+    Change change = insert("repo", newChange(repo));
 
     AttentionSetInput input = new AttentionSetInput(userId.toString(), "reason 1");
     gApi.changes().id(change.getChangeId()).addToAttentionSet(input);
@@ -3770,31 +3850,13 @@
     assertThat(changeInfo.attentionSet.get(user2Id.get()).reason).isEqualTo("reason 2");
   }
 
-  @Test
-  public void assignee() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChange(repo));
-    Change change2 = insert(repo, newChange(repo));
-
-    AssigneeInput input = new AssigneeInput();
-    input.assignee = user.getUserName().get();
-    gApi.changes().id(change1.getChangeId()).setAssignee(input);
-
-    assertQuery("is:assigned", change1);
-    assertQuery("-is:assigned", change2);
-    assertQuery("is:unassigned", change2);
-    assertQuery("-is:unassigned", change1);
-    assertQuery("assignee:" + user.getUserName().get(), change1);
-    assertQuery("-assignee:" + user.getUserName().get(), change2);
-  }
-
   @GerritConfig(name = "accounts.visibility", value = "NONE")
   @Test
   public void userDestination() throws Exception {
-    TestRepository<Repo> repo1 = createProject("repo1");
-    Change change1 = insert(repo1, newChange(repo1));
-    TestRepository<Repo> repo2 = createProject("repo2");
-    Change change2 = insert(repo2, newChange(repo2));
+    createProject("repo1");
+    Change change1 = insert("repo1", newChange("repo1"));
+    createProject("repo2");
+    Change change2 = insert("repo2", newChange("repo2"));
 
     assertThatQueryException("destination:foo")
         .hasMessageThat()
@@ -3808,7 +3870,7 @@
     String destination4 = "refs/heads/master\trepo3";
     String destination5 = "refs/heads/other\trepo1";
 
-    try (TestRepository<Repo> allUsers =
+    try (TestRepository<Repository> allUsers =
         new TestRepository<>(repoManager.openRepository(allUsersName))) {
       String refsUsers = RefNames.refsUsers(userId);
       allUsers.branch(refsUsers).commit().add("destinations/destination1", destination1).create();
@@ -3859,26 +3921,25 @@
     assertThatQueryException("destination:destination3,user=" + anotherUserId)
         .hasMessageThat()
         .isEqualTo("Unknown named destination: destination3");
-    assertThatQueryException("destination:destination3,user=test")
+    assertThatQueryException("destination:destination3,user=non-existent")
         .hasMessageThat()
-        .isEqualTo("Account 'test' not found");
+        .isEqualTo("Account 'non-existent' not found");
 
     requestContext.setContext(newRequestContext(anotherUserId));
-    // account 1000000 is not visible to 'anotheruser' as they are not an admin
+    // account userId is not visible to 'anotheruser' as they are not an admin
     assertThatQueryException("destination:destination3,user=" + userId)
         .hasMessageThat()
-        .isEqualTo("Account '1000000' not found");
+        .isEqualTo(String.format("Account '%s' not found", userId));
   }
 
   @GerritConfig(name = "accounts.visibility", value = "NONE")
   @Test
   public void userQuery() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChange(repo));
-    Change change2 = insert(repo, newChangeForBranch(repo, "stable"));
+    repo = createAndOpenProject("repo");
+    Change change1 = insert("repo", newChange(repo));
+    Change change2 = insert("repo", newChangeForBranch(repo, "stable"));
 
-    Account.Id anotherUserId =
-        accountManager.authenticate(authRequestFactory.createForUser("anotheruser")).getAccountId();
+    Account.Id anotherUserId = createAccount("anotheruser");
     String queryListText =
         "query1\tproject:repo\n"
             + "query2\tproject:repo status:open\n"
@@ -3890,7 +3951,7 @@
             + "query7\tproject:repo branch:stable\n"
             + "query8\tproject:repo branch:other";
 
-    try (TestRepository<Repo> allUsers =
+    try (TestRepository<Repository> allUsers =
             new TestRepository<>(repoManager.openRepository(allUsersName));
         MetaDataUpdate md = metaDataUpdateFactory.create(allUsersName);
         MetaDataUpdate anotherMd = metaDataUpdateFactory.create(allUsersName)) {
@@ -3904,19 +3965,20 @@
       anotherQueries.commit(anotherMd);
     }
 
+    assertThat(gApi.accounts().self().get()._accountId).isEqualTo(userId.get());
     assertThatQueryException("query:foo").hasMessageThat().isEqualTo("Unknown named query: foo");
     assertThatQueryException("query:query1,user=" + anotherUserId)
         .hasMessageThat()
         .isEqualTo("Unknown named query: query1");
-    assertThatQueryException("query:query1,user=test")
+    assertThatQueryException("query:query1,user=non-existent")
         .hasMessageThat()
-        .isEqualTo("Account 'test' not found");
+        .isEqualTo("Account 'non-existent' not found");
 
     requestContext.setContext(newRequestContext(anotherUserId));
     // account 1000000 is not visible to 'anotheruser' as they are not an admin
     assertThatQueryException("query:query1,user=" + userId)
         .hasMessageThat()
-        .isEqualTo("Account '1000000' not found");
+        .isEqualTo(String.format("Account '%s' not found", userId));
     requestContext.setContext(newRequestContext(userId));
 
     assertQuery("query:query1", change2, change1);
@@ -3934,17 +3996,9 @@
   }
 
   @Test
-  public void byOwnerInvalidQuery() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    insert(repo, newChange(repo), userId);
-    String nameEmail = user.asIdentifiedUser().getNameEmail();
-    assertQuery("owner: \"" + nameEmail + "\"\\");
-  }
-
-  @Test
   public void byDeletedChange() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change = insert(repo, newChange(repo));
+    repo = createAndOpenProject("repo");
+    Change change = insert("repo", newChange(repo));
 
     String query = "change:" + change.getId();
     assertQuery(query, change);
@@ -3955,8 +4009,8 @@
 
   @Test
   public void byUrlEncodedProject() throws Exception {
-    TestRepository<Repo> repo = createProject("repo+foo");
-    Change change = insert(repo, newChange(repo));
+    repo = createAndOpenProject("repo+foo");
+    Change change = insert("repo+foo", newChange(repo));
     assertQuery("project:repo+foo", change);
   }
 
@@ -3988,10 +4042,10 @@
 
   @Test
   public void isPureRevert() throws Exception {
-    assume().that(getSchema().hasField(ChangeField.IS_PURE_REVERT)).isTrue();
-    TestRepository<Repo> repo = createProject("repo");
+    assume().that(getSchema().hasField(ChangeField.IS_PURE_REVERT_SPEC)).isTrue();
+    repo = createAndOpenProject("repo");
     // Create two commits and revert second commit (initial commit can't be reverted)
-    Change initial = insert(repo, newChange(repo));
+    Change initial = insert("repo", newChange(repo));
     gApi.changes().id(initial.getChangeId()).current().review(ReviewInput.approve());
     gApi.changes().id(initial.getChangeId()).current().submit();
 
@@ -4015,7 +4069,7 @@
 
   @Test
   public void selfFailsForAnonymousUser() throws Exception {
-    for (String query : ImmutableList.of("assignee:self", "has:star", "is:starred")) {
+    for (String query : ImmutableList.of("has:star", "is:starred")) {
       assertQuery(query);
       RequestContext oldContext = requestContext.setContext(anonymousUserProvider::get);
 
@@ -4035,8 +4089,8 @@
     Account.Id user2 =
         accountManager.authenticate(authRequestFactory.createForUser("anotheruser")).getAccountId();
 
-    TestRepository<Repo> repo = createProject("repo");
-    Change change = insert(repo, newChange(repo));
+    repo = createAndOpenProject("repo");
+    Change change = insert("repo", newChange(repo));
     gApi.changes().id(change.getId().get()).addReviewer(user2.toString());
 
     RequestContext adminContext = requestContext.setContext(newRequestContext(user2));
@@ -4051,8 +4105,8 @@
 
   @Test
   public void none() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change = insert(repo, newChange(repo));
+    repo = createAndOpenProject("repo");
+    Change change = insert("repo", newChange(repo));
 
     assertQuery(ChangeIndexPredicate.none());
 
@@ -4072,27 +4126,24 @@
   @Test
   @GerritConfig(name = "change.mergeabilityComputationBehavior", value = "NEVER")
   public void mergeableFailsWhenNotIndexed() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    assume().that(getSchema().hasField(ChangeField.MERGE_SPEC)).isTrue();
+    repo = createAndOpenProject("repo");
     RevCommit commit1 = repo.parseBody(repo.commit().add("file1", "contents1").create());
-    insert(repo, newChangeForCommit(repo, commit1));
+    insert("repo", newChangeForCommit(repo, commit1));
 
     Throwable thrown = assertThrows(Throwable.class, () -> assertQuery("status:open is:mergeable"));
     assertThat(thrown.getCause()).isInstanceOf(QueryParseException.class);
     assertThat(thrown)
         .hasMessageThat()
-        .contains("'is:mergeable' operator is not supported by server");
+        .contains("'is:mergeable' operator is not supported on this gerrit host");
   }
 
-  protected ChangeInserter newChange(TestRepository<Repo> repo) throws Exception {
-    return newChange(repo, null, null, null, null, null, false, false);
-  }
-
-  protected ChangeInserter newChangeForCommit(TestRepository<Repo> repo, RevCommit commit)
+  protected ChangeInserter newChangeForCommit(TestRepository<Repository> repo, RevCommit commit)
       throws Exception {
     return newChange(repo, commit, null, null, null, null, false, false);
   }
 
-  protected ChangeInserter newChangeWithFiles(TestRepository<Repo> repo, String... paths)
+  protected ChangeInserter newChangeWithFiles(TestRepository<Repository> repo, String... paths)
       throws Exception {
     TestRepository<?>.CommitBuilder b = repo.commit().message("Change with files");
     for (String path : paths) {
@@ -4101,36 +4152,67 @@
     return newChangeForCommit(repo, repo.parseBody(b.create()));
   }
 
-  protected ChangeInserter newChangeForBranch(TestRepository<Repo> repo, String branch)
+  protected ChangeInserter newChangeForBranch(TestRepository<Repository> repo, String branch)
       throws Exception {
     return newChange(repo, null, branch, null, null, null, false, false);
   }
 
-  protected ChangeInserter newChangeWithStatus(TestRepository<Repo> repo, Change.Status status)
-      throws Exception {
+  protected ChangeInserter newChangeWithStatus(
+      TestRepository<Repository> repo, Change.Status status) throws Exception {
     return newChange(repo, null, null, status, null, null, false, false);
   }
 
-  protected ChangeInserter newChangeWithTopic(TestRepository<Repo> repo, String topic)
+  protected ChangeInserter newChangeWithStatus(String repoName, Change.Status status)
+      throws Exception {
+    return newChange(repoName, null, null, status, null, null, false, false);
+  }
+
+  protected ChangeInserter newChangeWithTopic(TestRepository<Repository> repo, String topic)
       throws Exception {
     return newChange(repo, null, null, null, topic, null, false, false);
   }
 
-  protected ChangeInserter newChangeWorkInProgress(TestRepository<Repo> repo) throws Exception {
+  protected ChangeInserter newChangeWorkInProgress(TestRepository<Repository> repo)
+      throws Exception {
     return newChange(repo, null, null, null, null, null, true, false);
   }
 
-  protected ChangeInserter newChangePrivate(TestRepository<Repo> repo) throws Exception {
+  protected ChangeInserter newChangePrivate(TestRepository<Repository> repo) throws Exception {
     return newChange(repo, null, null, null, null, null, false, true);
   }
 
   protected ChangeInserter newCherryPickChange(
-      TestRepository<Repo> repo, String branch, PatchSet.Id cherryPickOf) throws Exception {
+      TestRepository<Repository> repo, String branch, PatchSet.Id cherryPickOf) throws Exception {
     return newChange(repo, null, branch, null, null, cherryPickOf, false, true);
   }
 
+  protected ChangeInserter newChange(String repoName) throws Exception {
+    return newChange(repoName, null, null, null, null, null, false, false);
+  }
+
   protected ChangeInserter newChange(
-      TestRepository<Repo> repo,
+      String repoName,
+      @Nullable RevCommit commit,
+      @Nullable String branch,
+      @Nullable Change.Status status,
+      @Nullable String topic,
+      @Nullable PatchSet.Id cherryPickOf,
+      boolean workInProgress,
+      boolean isPrivate)
+      throws Exception {
+    try (TestRepository<Repository> repo =
+        new TestRepository<>(repoManager.openRepository(Project.nameKey(repoName)))) {
+      return newChange(
+          repo, commit, branch, status, topic, cherryPickOf, workInProgress, isPrivate);
+    }
+  }
+
+  protected ChangeInserter newChange(TestRepository<Repository> repo) throws Exception {
+    return newChange(repo, null, null, null, null, null, false, false);
+  }
+
+  protected ChangeInserter newChange(
+      TestRepository<Repository> repo,
       @Nullable RevCommit commit,
       @Nullable String branch,
       @Nullable Change.Status status,
@@ -4140,7 +4222,7 @@
       boolean isPrivate)
       throws Exception {
     if (commit == null) {
-      commit = repo.parseBody(repo.commit().message("message").create());
+      commit = repo.parseBody(repo.commit().message("initial message").create());
     }
 
     branch = MoreObjects.firstNonNull(branch, "refs/heads/master");
@@ -4149,65 +4231,78 @@
     }
 
     Change.Id id = Change.id(seq.nextChangeId());
-    ChangeInserter ins =
-        changeFactory
-            .create(id, commit, branch)
-            .setValidate(false)
-            .setStatus(status)
-            .setTopic(topic)
-            .setWorkInProgress(workInProgress)
-            .setPrivate(isPrivate)
-            .setCherryPickOf(cherryPickOf);
-    return ins;
+    return changeFactory
+        .create(id, commit, branch)
+        .setValidate(false)
+        .setStatus(status)
+        .setTopic(topic)
+        .setWorkInProgress(workInProgress)
+        .setPrivate(isPrivate)
+        .setCherryPickOf(cherryPickOf);
   }
 
-  protected Change insert(TestRepository<Repo> repo, ChangeInserter ins) throws Exception {
-    return insert(repo, ins, null, TimeUtil.now());
-  }
-
-  protected Change insert(TestRepository<Repo> repo, ChangeInserter ins, @Nullable Account.Id owner)
+  @CanIgnoreReturnValue
+  protected Change insert(String repoName, ChangeInserter ins, @Nullable Account.Id owner)
       throws Exception {
-    return insert(repo, ins, owner, TimeUtil.now());
+    return insert(repoName, ins, owner, TimeUtil.now());
   }
 
+  @CanIgnoreReturnValue
+  protected Change insert(String repoName, ChangeInserter ins) throws Exception {
+    return insert(repoName, ins, null, TimeUtil.now());
+  }
+
+  @CanIgnoreReturnValue
   protected Change insert(
-      TestRepository<Repo> repo, ChangeInserter ins, @Nullable Account.Id owner, Instant createdOn)
+      String repoName, ChangeInserter ins, @Nullable Account.Id owner, Instant createdOn)
       throws Exception {
-    Project.NameKey project =
-        Project.nameKey(repo.getRepository().getDescription().getRepositoryName());
+    Project.NameKey project = Project.nameKey(repoName);
     Account.Id ownerId = owner != null ? owner : userId;
     IdentifiedUser user = userFactory.create(ownerId);
-    try (BatchUpdate bu = updateFactory.create(project, user, createdOn)) {
-      bu.insertChange(ins);
-      bu.execute();
-      return ins.getChange();
-    }
+    return testRefAction(
+        () -> {
+          try (BatchUpdate bu = updateFactory.create(project, user, createdOn)) {
+            bu.insertChange(ins);
+            bu.execute();
+            return ins.getChange();
+          }
+        });
   }
 
-  protected Change newPatchSet(TestRepository<Repo> repo, Change c, CurrentUser user)
-      throws Exception {
-    // Add a new file so the patch set is not a trivial rebase, to avoid default
-    // Code-Review label copying.
-    int n = c.currentPatchSetId().get() + 1;
-    RevCommit commit =
-        repo.parseBody(repo.commit().message("message").add("file" + n, "contents " + n).create());
+  protected Change newPatchSet(
+      String repoName, Change c, CurrentUser user, Optional<String> message) throws Exception {
+    try (TestRepository<Repository> repo =
+        new TestRepository<>(repoManager.openRepository(Project.nameKey(repoName)))) {
+      // Add a new file so the patch set is not a trivial rebase, to avoid default
+      // Code-Review label copying.
+      int n = c.currentPatchSetId().get() + 1;
+      RevCommit commit =
+          repo.parseBody(
+              repo.commit()
+                  .message(message.orElse("updated message"))
+                  .add("file" + n, "contents " + n)
+                  .create());
 
-    PatchSetInserter inserter =
-        patchSetFactory
-            .create(changeNotesFactory.createChecked(c), PatchSet.id(c.getId(), n), commit)
-            .setFireRevisionCreated(false)
-            .setValidate(false);
-    try (BatchUpdate bu = updateFactory.create(c.getProject(), user, TimeUtil.now());
-        ObjectInserter oi = repo.getRepository().newObjectInserter();
-        ObjectReader reader = oi.newReader();
-        RevWalk rw = new RevWalk(reader)) {
-      bu.setRepository(repo.getRepository(), rw, oi);
-      bu.setNotify(NotifyResolver.Result.none());
-      bu.addOp(c.getId(), inserter);
-      bu.execute();
+      PatchSetInserter inserter =
+          patchSetFactory
+              .create(changeNotesFactory.createChecked(c), PatchSet.id(c.getId(), n), commit)
+              .setFireRevisionCreated(false)
+              .setValidate(false);
+      testRefAction(
+          () -> {
+            try (BatchUpdate bu = updateFactory.create(c.getProject(), user, TimeUtil.now());
+                ObjectInserter oi = repo.getRepository().newObjectInserter();
+                ObjectReader reader = oi.newReader();
+                RevWalk rw = new RevWalk(reader)) {
+              bu.setRepository(repo.getRepository(), rw, oi);
+              bu.setNotify(NotifyResolver.Result.none());
+              bu.addOp(c.getId(), inserter);
+              bu.execute();
+            }
+          });
+
+      return inserter.getChange();
     }
-
-    return inserter.getChange();
   }
 
   protected ThrowableSubject assertThatQueryException(Object query) throws Exception {
@@ -4232,17 +4327,27 @@
     }
   }
 
-  protected TestRepository<Repo> createProject(String name) throws Exception {
-    gApi.projects().create(name).get();
+  @CanIgnoreReturnValue
+  protected TestRepository<Repository> createAndOpenProject(String name) throws Exception {
+    createProject(name);
     return new TestRepository<>(repoManager.openRepository(Project.nameKey(name)));
   }
 
-  protected TestRepository<Repo> createProject(String name, String parent) throws Exception {
+  protected TestRepository<Repository> createAndOpenProject(String name, String parent)
+      throws Exception {
+    createProject(name, parent);
+    return new TestRepository<>(repoManager.openRepository(Project.nameKey(name)));
+  }
+
+  protected void createProject(String name) throws Exception {
+    gApi.projects().create(name).get();
+  }
+
+  protected void createProject(String name, String parent) throws Exception {
     ProjectInput input = new ProjectInput();
     input.name = name;
     input.parent = parent;
     gApi.projects().create(input).get();
-    return new TestRepository<>(repoManager.openRepository(Project.nameKey(name)));
   }
 
   protected QueryRequest newQuery(Object query) {
@@ -4454,6 +4559,14 @@
     return indexes.getSearchIndex().getSchema();
   }
 
+  protected ChangeUpdate newUpdate(Change c) throws Exception {
+    ChangeUpdate update =
+        TestChanges.newUpdate(injector, c, Optional.empty(), /* shouldExist= */ true);
+    update.setPatchSetId(c.currentPatchSetId());
+    update.setAllowWriteToNewRef(true);
+    return update;
+  }
+
   PaginationType getCurrentPaginationType() {
     return config.getEnum("index", null, "paginationType", PaginationType.OFFSET);
   }
diff --git a/javatests/com/google/gerrit/server/query/change/ChangeDataTest.java b/javatests/com/google/gerrit/server/query/change/ChangeDataTest.java
index 5124021..0ce00eb 100644
--- a/javatests/com/google/gerrit/server/query/change/ChangeDataTest.java
+++ b/javatests/com/google/gerrit/server/query/change/ChangeDataTest.java
@@ -77,6 +77,7 @@
         .id(PatchSet.id(changeId, num))
         .commitId(ObjectId.zeroId())
         .uploader(Account.id(1234))
+        .realUploader(Account.id(5678))
         .createdOn(TimeUtil.now())
         .build();
   }
diff --git a/javatests/com/google/gerrit/server/query/change/FakeQueryChangesTest.java b/javatests/com/google/gerrit/server/query/change/FakeQueryChangesTest.java
index b1faf03..72fc6d2 100644
--- a/javatests/com/google/gerrit/server/query/change/FakeQueryChangesTest.java
+++ b/javatests/com/google/gerrit/server/query/change/FakeQueryChangesTest.java
@@ -35,14 +35,13 @@
 import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.gerrit.server.index.change.ChangeIndexCollection;
 import com.google.gerrit.testing.InMemoryModule;
-import com.google.gerrit.testing.InMemoryRepositoryManager;
-import com.google.gerrit.testing.InMemoryRepositoryManager.Repo;
 import com.google.inject.Guice;
 import com.google.inject.Inject;
 import com.google.inject.Injector;
 import java.util.List;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.Repository;
 import org.junit.Test;
 
 /**
@@ -64,47 +63,51 @@
 
   @Test
   @UseClockStep
-  @SuppressWarnings("unchecked")
   public void stopQueryIfNoMoreResults() throws Exception {
     // create 2 visible changes
-    TestRepository<InMemoryRepositoryManager.Repo> testRepo = createProject("repo");
-    insert(testRepo, newChange(testRepo));
-    insert(testRepo, newChange(testRepo));
+    try (TestRepository<Repository> testRepo = createAndOpenProject("repo")) {
+      insert("repo", newChange(testRepo));
+      insert("repo", newChange(testRepo));
+    }
 
     // create 2 invisible changes
-    TestRepository<Repo> hiddenProject = createProject("hiddenProject");
-    insert(hiddenProject, newChange(hiddenProject));
-    insert(hiddenProject, newChange(hiddenProject));
-    projectOperations
-        .project(Project.nameKey("hiddenProject"))
-        .forUpdate()
-        .add(block(Permission.READ).ref("refs/*").group(REGISTERED_USERS))
-        .update();
+    try (TestRepository<Repository> hiddenProject = createAndOpenProject("hiddenProject")) {
+      insert("hiddenProject", newChange(hiddenProject));
+      insert("hiddenProject", newChange(hiddenProject));
+      projectOperations
+          .project(Project.nameKey("hiddenProject"))
+          .forUpdate()
+          .add(block(Permission.READ).ref("refs/*").group(REGISTERED_USERS))
+          .update();
+    }
 
-    AbstractFakeIndex idx = (AbstractFakeIndex) changeIndexCollection.getSearchIndex();
+    AbstractFakeIndex<?, ?, ?> idx =
+        (AbstractFakeIndex<?, ?, ?>) changeIndexCollection.getSearchIndex();
     newQuery("status:new").withLimit(5).get();
     assertThatSearchQueryWasNotPaginated(idx.getQueryCount());
   }
 
   @Test
   @UseClockStep
-  @SuppressWarnings("unchecked")
   public void noLimitQueryPaginates() throws Exception {
     assumeFalse(PaginationType.NONE == getCurrentPaginationType());
 
-    TestRepository<InMemoryRepositoryManager.Repo> testRepo = createProject("repo");
-    // create 4 changes
-    insert(testRepo, newChange(testRepo));
-    insert(testRepo, newChange(testRepo));
-    insert(testRepo, newChange(testRepo));
-    insert(testRepo, newChange(testRepo));
+    try (TestRepository<Repository> testRepo = createAndOpenProject("repo")) {
+      insert("repo", newChange(testRepo));
+      insert("repo", newChange(testRepo));
+      insert("repo", newChange(testRepo));
+      insert("repo", newChange(testRepo));
+    }
     // Set queryLimit to 2
     projectOperations
         .project(allProjects)
         .forUpdate()
         .add(allowCapability(QUERY_LIMIT).group(REGISTERED_USERS).range(0, 2))
         .update();
-    AbstractFakeIndex idx = (AbstractFakeIndex) changeIndexCollection.getSearchIndex();
+
+    AbstractFakeIndex<?, ?, ?> idx =
+        (AbstractFakeIndex<?, ?, ?>) changeIndexCollection.getSearchIndex();
+
     // 2 index searches are expected. The first index search will run with size 3 (i.e.
     // the configured query-limit+1), and then we will paginate to get the remaining
     // changes with the second index search.
@@ -180,17 +183,16 @@
 
   @Test
   @UseClockStep
-  @SuppressWarnings("unchecked")
   public void internalQueriesPaginate() throws Exception {
     assumeFalse(PaginationType.NONE == getCurrentPaginationType());
     final int LIMIT = 2;
 
-    TestRepository<Repo> testRepo = createProject("repo");
-    // create 4 changes
-    insert(testRepo, newChange(testRepo));
-    insert(testRepo, newChange(testRepo));
-    insert(testRepo, newChange(testRepo));
-    insert(testRepo, newChange(testRepo));
+    try (TestRepository<Repository> testRepo = createAndOpenProject("repo")) {
+      insert("repo", newChange(testRepo));
+      insert("repo", newChange(testRepo));
+      insert("repo", newChange(testRepo));
+      insert("repo", newChange(testRepo));
+    }
     // Set queryLimit to 2
     projectOperations
         .project(allProjects)
@@ -230,12 +232,12 @@
   }
 
   private AbstractFakeIndex setupRepoWithFourChanges() throws Exception {
-    TestRepository<Repo> testRepo = createProject("repo");
-    // create 4 changes
-    insert(testRepo, newChange(testRepo));
-    insert(testRepo, newChange(testRepo));
-    insert(testRepo, newChange(testRepo));
-    insert(testRepo, newChange(testRepo));
+    try (TestRepository<Repository> testRepo = createAndOpenProject("repo")) {
+      insert("repo", newChange(testRepo));
+      insert("repo", newChange(testRepo));
+      insert("repo", newChange(testRepo));
+      insert("repo", newChange(testRepo));
+    }
 
     // Set queryLimit to 2
     projectOperations
diff --git a/javatests/com/google/gerrit/server/query/change/LuceneQueryChangesTest.java b/javatests/com/google/gerrit/server/query/change/LuceneQueryChangesTest.java
index 9717bfb..7f383f9 100644
--- a/javatests/com/google/gerrit/server/query/change/LuceneQueryChangesTest.java
+++ b/javatests/com/google/gerrit/server/query/change/LuceneQueryChangesTest.java
@@ -24,11 +24,9 @@
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.testing.InMemoryModule;
-import com.google.gerrit.testing.InMemoryRepositoryManager.Repo;
 import com.google.inject.Guice;
 import com.google.inject.Inject;
 import com.google.inject.Injector;
-import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.junit.Test;
@@ -45,11 +43,11 @@
 
   @Test
   public void fullTextWithSpecialChars() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     RevCommit commit1 = repo.parseBody(repo.commit().message("foo_bar_foo").create());
-    Change change1 = insert(repo, newChangeForCommit(repo, commit1));
+    Change change1 = insert("repo", newChangeForCommit(repo, commit1));
     RevCommit commit2 = repo.parseBody(repo.commit().message("one.two.three").create());
-    Change change2 = insert(repo, newChangeForCommit(repo, commit2));
+    Change change2 = insert("repo", newChangeForCommit(repo, commit2));
 
     assertQuery("message:foo_ba");
     assertQuery("message:bar", change1);
@@ -61,32 +59,25 @@
   }
 
   @Test
-  @Override
-  public void byOwnerInvalidQuery() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChange(repo), userId);
-    String nameEmail = user.asIdentifiedUser().getNameEmail();
-
+  public void invalidQuery() throws Exception {
     BadRequestException thrown =
-        assertThrows(
-            BadRequestException.class,
-            () -> assertQuery("owner: \"" + nameEmail + "\"\\", change1));
+        assertThrows(BadRequestException.class, () -> newQuery("\\").get());
     assertThat(thrown).hasMessageThat().contains("Cannot create full-text query with value: \\");
   }
 
   @Test
   public void openAndClosedChanges() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
 
     // create 3 closed changes
-    Change change1 = insert(repo, newChangeWithStatus(repo, Change.Status.MERGED));
-    Change change2 = insert(repo, newChangeWithStatus(repo, Change.Status.MERGED));
-    Change change3 = insert(repo, newChangeWithStatus(repo, Change.Status.MERGED));
+    Change change1 = insert("repo", newChangeWithStatus(repo, Change.Status.MERGED));
+    Change change2 = insert("repo", newChangeWithStatus(repo, Change.Status.MERGED));
+    Change change3 = insert("repo", newChangeWithStatus(repo, Change.Status.MERGED));
 
     // create 3 new changes
-    Change change4 = insert(repo, newChangeWithStatus(repo, Change.Status.NEW));
-    Change change5 = insert(repo, newChangeWithStatus(repo, Change.Status.NEW));
-    Change change6 = insert(repo, newChangeWithStatus(repo, Change.Status.NEW));
+    Change change4 = insert("repo", newChangeWithStatus(repo, Change.Status.NEW));
+    Change change5 = insert("repo", newChangeWithStatus(repo, Change.Status.NEW));
+    Change change6 = insert("repo", newChangeWithStatus(repo, Change.Status.NEW));
 
     // Set queryLimit to 1
     projectOperations
diff --git a/javatests/com/google/gerrit/server/query/group/AbstractQueryGroupsTest.java b/javatests/com/google/gerrit/server/query/group/AbstractQueryGroupsTest.java
index 1ca4571..12bafd5 100644
--- a/javatests/com/google/gerrit/server/query/group/AbstractQueryGroupsTest.java
+++ b/javatests/com/google/gerrit/server/query/group/AbstractQueryGroupsTest.java
@@ -23,6 +23,7 @@
 import static org.junit.Assert.fail;
 
 import com.google.common.base.CharMatcher;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.InternalGroup;
@@ -382,7 +383,9 @@
             .getRaw(
                 uuid,
                 QueryOptions.create(
-                    IndexConfig.fromConfig(config).build(),
+                    config != null
+                        ? IndexConfig.fromConfig(config).build()
+                        : IndexConfig.createDefault(),
                     0,
                     10,
                     indexes.getSearchIndex().getSchema().getStoredFields()));
@@ -398,9 +401,8 @@
     String query = "uuid:" + uuid;
     assertQuery(query, group);
 
-    for (GroupIndex index : groupIndexes.getWriteIndexes()) {
-      index.delete(uuid);
-    }
+    deleteGroup(uuid);
+
     assertQuery(query);
   }
 
@@ -438,6 +440,10 @@
     return createGroupWithDescription(name, null, members);
   }
 
+  protected GroupInfo createGroup(GroupInput in) throws Exception {
+    return gApi.groups().create(in).get();
+  }
+
   protected GroupInfo createGroupWithDescription(
       String name, String description, AccountInfo... members) throws Exception {
     GroupInput in = new GroupInput();
@@ -445,21 +451,27 @@
     in.description = description;
     in.members =
         Arrays.asList(members).stream().map(a -> String.valueOf(a._accountId)).collect(toList());
-    return gApi.groups().create(in).get();
+    return createGroup(in);
   }
 
   protected GroupInfo createGroupWithOwner(String name, GroupInfo ownerGroup) throws Exception {
     GroupInput in = new GroupInput();
     in.name = name;
     in.ownerId = ownerGroup.id;
-    return gApi.groups().create(in).get();
+    return createGroup(in);
   }
 
   protected GroupInfo createGroupThatIsVisibleToAll(String name) throws Exception {
     GroupInput in = new GroupInput();
     in.name = name;
     in.visibleToAll = true;
-    return gApi.groups().create(in).get();
+    return createGroup(in);
+  }
+
+  protected void deleteGroup(AccountGroup.UUID uuid) throws Exception {
+    for (GroupIndex index : groupIndexes.getWriteIndexes()) {
+      index.delete(uuid);
+    }
   }
 
   protected GroupInfo getGroup(AccountGroup.UUID uuid) throws Exception {
@@ -558,6 +570,7 @@
     return groups.stream().map(g -> g.id).sorted().collect(toList());
   }
 
+  @Nullable
   protected String name(String name) {
     if (name == null) {
       return null;
diff --git a/javatests/com/google/gerrit/server/query/group/BUILD b/javatests/com/google/gerrit/server/query/group/BUILD
index 0094bd6..550cb41 100644
--- a/javatests/com/google/gerrit/server/query/group/BUILD
+++ b/javatests/com/google/gerrit/server/query/group/BUILD
@@ -13,6 +13,7 @@
     visibility = ["//visibility:public"],
     runtime_deps = ["//java/com/google/gerrit/lucene"],
     deps = [
+        "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/index",
diff --git a/javatests/com/google/gerrit/server/query/project/AbstractQueryProjectsTest.java b/javatests/com/google/gerrit/server/query/project/AbstractQueryProjectsTest.java
index c06fcde..b119104 100644
--- a/javatests/com/google/gerrit/server/query/project/AbstractQueryProjectsTest.java
+++ b/javatests/com/google/gerrit/server/query/project/AbstractQueryProjectsTest.java
@@ -24,6 +24,7 @@
 import com.google.common.base.CharMatcher;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.GerritApi;
@@ -469,6 +470,7 @@
     return projects.stream().map(p -> p.name).collect(toList());
   }
 
+  @Nullable
   protected String name(String name) {
     if (name == null) {
       return null;
diff --git a/javatests/com/google/gerrit/server/query/project/BUILD b/javatests/com/google/gerrit/server/query/project/BUILD
index f476ae6..53f9d9d 100644
--- a/javatests/com/google/gerrit/server/query/project/BUILD
+++ b/javatests/com/google/gerrit/server/query/project/BUILD
@@ -10,6 +10,7 @@
     visibility = ["//visibility:public"],
     runtime_deps = ["//java/com/google/gerrit/lucene"],
     deps = [
+        "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/index",
diff --git a/javatests/com/google/gerrit/server/restapi/change/CommentPorterTest.java b/javatests/com/google/gerrit/server/restapi/change/CommentPorterTest.java
index 4c8750a..131bd05 100644
--- a/javatests/com/google/gerrit/server/restapi/change/CommentPorterTest.java
+++ b/javatests/com/google/gerrit/server/restapi/change/CommentPorterTest.java
@@ -239,6 +239,7 @@
         .id(id)
         .commitId(dummyObjectId)
         .uploader(Account.id(123))
+        .realUploader(Account.id(456))
         .createdOn(Instant.ofEpochMilli(12345))
         .build();
   }
diff --git a/javatests/com/google/gerrit/server/schema/NoteDbSchemaVersionCheckTest.java b/javatests/com/google/gerrit/server/schema/NoteDbSchemaVersionCheckTest.java
index a5fd4a2..d2ccaa9 100644
--- a/javatests/com/google/gerrit/server/schema/NoteDbSchemaVersionCheckTest.java
+++ b/javatests/com/google/gerrit/server/schema/NoteDbSchemaVersionCheckTest.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static com.google.gerrit.testing.TestActionRefUpdateContext.testRefAction;
 
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.config.SitePaths;
@@ -38,7 +39,7 @@
     GitRepositoryManager repoManager = new InMemoryRepositoryManager();
     repoManager.createRepository(allProjectsName);
     versionManager = new NoteDbSchemaVersionManager(allProjectsName, repoManager);
-    versionManager.init();
+    testRefAction(() -> versionManager.init());
 
     sitePaths = new SitePaths(Paths.get("/tmp/foo"));
   }
@@ -51,7 +52,7 @@
 
   @Test
   public void shouldFailIfCurrentVersionIsOneMoreThanExpected() throws IOException {
-    versionManager.increment(NoteDbSchemaVersions.LATEST);
+    testRefAction(() -> versionManager.increment(NoteDbSchemaVersions.LATEST));
 
     ProvisionException e =
         assertThrows(
@@ -69,7 +70,7 @@
           throws IOException {
     Config gerritConfig = new Config();
     gerritConfig.setBoolean("gerrit", null, "experimentalRollingUpgrade", true);
-    versionManager.increment(NoteDbSchemaVersions.LATEST);
+    testRefAction(() -> versionManager.increment(NoteDbSchemaVersions.LATEST));
 
     NoteDbSchemaVersionCheck versionCheck =
         new NoteDbSchemaVersionCheck(versionManager, sitePaths, gerritConfig);
diff --git a/javatests/com/google/gerrit/server/schema/NoteDbSchemaVersionManagerTest.java b/javatests/com/google/gerrit/server/schema/NoteDbSchemaVersionManagerTest.java
index 38e19f7..3a1ea12 100644
--- a/javatests/com/google/gerrit/server/schema/NoteDbSchemaVersionManagerTest.java
+++ b/javatests/com/google/gerrit/server/schema/NoteDbSchemaVersionManagerTest.java
@@ -17,6 +17,7 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.entities.RefNames.REFS_VERSION;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static com.google.gerrit.testing.TestActionRefUpdateContext.testRefAction;
 
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.server.config.AllProjectsName;
@@ -62,14 +63,14 @@
 
   @Test
   public void incrementFromMissing() throws Exception {
-    manager.increment(123);
+    testRefAction(() -> manager.increment(123));
     assertThat(manager.read()).isEqualTo(124);
   }
 
   @Test
   public void increment() throws Exception {
     tr.update(REFS_VERSION, tr.blob("123"));
-    manager.increment(123);
+    testRefAction(() -> manager.increment(123));
     assertThat(manager.read()).isEqualTo(124);
   }
 
diff --git a/javatests/com/google/gerrit/server/submit/BUILD b/javatests/com/google/gerrit/server/submit/BUILD
index 7425bc8..01acb72 100644
--- a/javatests/com/google/gerrit/server/submit/BUILD
+++ b/javatests/com/google/gerrit/server/submit/BUILD
@@ -11,6 +11,7 @@
         "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/testing:gerrit-test-util",
+        "//java/com/google/gerrit/testing:test-ref-update-context",
         "//lib:jgit",
         "//lib/mockito",
         "//lib/truth",
diff --git a/javatests/com/google/gerrit/server/submit/SubmoduleCommitsTest.java b/javatests/com/google/gerrit/server/submit/SubmoduleCommitsTest.java
index 313e697..a391c03 100644
--- a/javatests/com/google/gerrit/server/submit/SubmoduleCommitsTest.java
+++ b/javatests/com/google/gerrit/server/submit/SubmoduleCommitsTest.java
@@ -17,6 +17,7 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.common.truth.Truth8.assertThat;
+import static com.google.gerrit.testing.TestActionRefUpdateContext.testRefAction;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.Mockito.when;
 
@@ -197,7 +198,7 @@
     RefUpdate ru = serverRepo.updateRef(refName);
     ru.setExpectedOldObjectId(oldCommitId);
     ru.setNewObjectId(newCommitId);
-    assertThat(ru.update()).isEqualTo(RefUpdate.Result.FAST_FORWARD);
+    testRefAction(() -> assertThat(ru.update()).isEqualTo(RefUpdate.Result.FAST_FORWARD));
     return rw.parseCommit(newCommitId);
   }
 
diff --git a/javatests/com/google/gerrit/server/update/BUILD b/javatests/com/google/gerrit/server/update/BUILD
index 6d96c10..345681d 100644
--- a/javatests/com/google/gerrit/server/update/BUILD
+++ b/javatests/com/google/gerrit/server/update/BUILD
@@ -15,6 +15,7 @@
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/server/util/time",
         "//java/com/google/gerrit/testing:gerrit-test-util",
+        "//java/com/google/gerrit/testing:test-ref-update-context",
         "//lib:guava",
         "//lib:jgit",
         "//lib:jgit-junit",
diff --git a/javatests/com/google/gerrit/server/update/BatchUpdateTest.java b/javatests/com/google/gerrit/server/update/BatchUpdateTest.java
index fcb680f..ff1f6a3 100644
--- a/javatests/com/google/gerrit/server/update/BatchUpdateTest.java
+++ b/javatests/com/google/gerrit/server/update/BatchUpdateTest.java
@@ -18,6 +18,7 @@
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static com.google.gerrit.testing.TestActionRefUpdateContext.openTestRefUpdateContext;
 import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
 
@@ -37,6 +38,7 @@
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.InternalUser;
 import com.google.gerrit.server.account.AccountManager;
 import com.google.gerrit.server.account.AuthRequest;
@@ -47,19 +49,25 @@
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.notedb.ReviewerStateInternal;
 import com.google.gerrit.server.notedb.Sequences;
 import com.google.gerrit.server.patch.DiffSummary;
 import com.google.gerrit.server.patch.DiffSummaryKey;
+import com.google.gerrit.server.update.context.RefUpdateContext;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.testing.InMemoryTestEnvironment;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.name.Named;
+import java.util.ArrayList;
+import java.util.List;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.After;
 import org.junit.Before;
 import org.junit.Rule;
 import org.junit.Test;
@@ -96,6 +104,7 @@
   @Inject private DynamicSet<AttentionSetListener> attentionSetListeners;
   @Inject private AccountManager accountManager;
   @Inject private AuthRequest.Factory authRequestFactory;
+  @Inject private IdentifiedUser.GenericFactory userFactory;
   @Inject private InternalUser.Factory internalUserFactory;
   @Inject private AbandonOp.Factory abandonOpFactory;
 
@@ -110,12 +119,21 @@
   private Project.NameKey project;
   private TestRepository<Repository> repo;
 
+  private RefUpdateContext testRefUpdateContext;
+
   @Before
   public void setUp() throws Exception {
     project = Project.nameKey("test");
 
     Repository inMemoryRepo = repoManager.createRepository(project);
     repo = new TestRepository<>(inMemoryRepo);
+    // All tests here are low level. Open context here to avoid repeated code in multiple tests.
+    testRefUpdateContext = openTestRefUpdateContext();
+  }
+
+  @After
+  public void tearDown() {
+    testRefUpdateContext.close();
   }
 
   @Test
@@ -133,7 +151,6 @@
           });
       bu.execute();
     }
-
     assertThat(repo.getRepository().exactRef("refs/heads/master").getObjectId())
         .isEqualTo(branchCommit.getId());
   }
@@ -379,7 +396,8 @@
 
     int cacheSizeBefore = diffSummaryCache.asMap().size();
 
-    // We don't want to depend on the test helper used above so we perform an explicit commit here.
+    // We don't want to depend on the test helper used above so we perform an explicit commit
+    // here.
     try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.now())) {
       ObjectId commitId =
           repo.amend(notes.getCurrentPatchSet().commitId())
@@ -400,6 +418,165 @@
     assertThat(diffSummaryCache.asMap()).hasSize(cacheSizeBefore + 1);
   }
 
+  @Test
+  public void executeOpsWithDifferentUsers() throws Exception {
+    Change.Id changeId = createChange();
+
+    ObjectId oldHead = getMetaId(changeId);
+
+    CurrentUser defaultUser = user.get();
+    IdentifiedUser user1 =
+        userFactory.create(
+            accountManager.authenticate(authRequestFactory.createForUser("user1")).getAccountId());
+    IdentifiedUser user2 =
+        userFactory.create(
+            accountManager.authenticate(authRequestFactory.createForUser("user2")).getAccountId());
+
+    TestOp testOp1 = new TestOp().addReviewer(defaultUser.getAccountId());
+    TestOp testOp2 = new TestOp().addReviewer(user1.getAccountId());
+    TestOp testOp3 = new TestOp().addReviewer(user2.getAccountId());
+
+    try (BatchUpdate bu = batchUpdateFactory.create(project, defaultUser, TimeUtil.now())) {
+      bu.addOp(changeId, user1, testOp1);
+      bu.addOp(changeId, user2, testOp2);
+      bu.addOp(changeId, testOp3);
+      bu.execute();
+
+      PersonIdent refLogIdent = bu.getRefLogIdent().get();
+      assertThat(refLogIdent.getName())
+          .isEqualTo(
+              String.format(
+                  "account-%s|account-%s|account-%s",
+                  defaultUser.asIdentifiedUser().getAccountId(),
+                  user1.asIdentifiedUser().getAccountId(),
+                  user2.asIdentifiedUser().getAccountId()));
+      assertThat(refLogIdent.getEmailAddress())
+          .isEqualTo(String.format("%s@unknown", refLogIdent.getName()));
+    }
+
+    assertThat(testOp1.updateRepoUser).isEqualTo(user1);
+    assertThat(testOp1.updateChangeUser).isEqualTo(user1);
+    assertThat(testOp1.postUpdateUser).isEqualTo(user1);
+
+    assertThat(testOp2.updateRepoUser).isEqualTo(user2);
+    assertThat(testOp2.updateChangeUser).isEqualTo(user2);
+    assertThat(testOp2.postUpdateUser).isEqualTo(user2);
+
+    assertThat(testOp3.updateRepoUser).isEqualTo(defaultUser);
+    assertThat(testOp3.updateChangeUser).isEqualTo(defaultUser);
+    assertThat(testOp3.postUpdateUser).isEqualTo(defaultUser);
+
+    // Verify that we got one meta commit per op.
+    RevCommit metaCommitForTestOp3 = repo.getRepository().parseCommit(getMetaId(changeId));
+    assertThat(metaCommitForTestOp3.getAuthorIdent().getEmailAddress())
+        .isEqualTo(String.format("%s@gerrit", defaultUser.getAccountId()));
+    assertThat(metaCommitForTestOp3.getFullMessage())
+        .startsWith(
+            "Update patch set 1\n"
+                + "\n"
+                + "Patch-set: 1\n"
+                + String.format(
+                    "Reviewer: Gerrit User %s <%s@gerrit>\n",
+                    user2.getAccountId(), user2.getAccountId())
+                + "Attention:");
+
+    RevCommit metaCommitForTestOp2 =
+        repo.getRepository().parseCommit(metaCommitForTestOp3.getParent(0));
+    assertThat(metaCommitForTestOp2.getAuthorIdent().getEmailAddress())
+        .isEqualTo(String.format("%s@gerrit", user2.getAccountId()));
+    assertThat(metaCommitForTestOp2.getFullMessage())
+        .startsWith(
+            "Update patch set 1\n"
+                + "\n"
+                + "Patch-set: 1\n"
+                + String.format(
+                    "Reviewer: Gerrit User %s <%s@gerrit>\n",
+                    user1.getAccountId(), user1.getAccountId())
+                + "Attention:");
+
+    RevCommit metaCommitForTestOp1 =
+        repo.getRepository().parseCommit(metaCommitForTestOp2.getParent(0));
+    assertThat(metaCommitForTestOp1.getAuthorIdent().getEmailAddress())
+        .isEqualTo(String.format("%s@gerrit", user1.getAccountId()));
+    assertThat(metaCommitForTestOp1.getFullMessage())
+        .isEqualTo(
+            "Update patch set 1\n"
+                + "\n"
+                + "Patch-set: 1\n"
+                + String.format(
+                    "Reviewer: Gerrit User %s <%s@gerrit>\n",
+                    defaultUser.getAccountId(), defaultUser.getAccountId()));
+
+    assertThat(metaCommitForTestOp1.getParent(0)).isEqualTo(oldHead);
+  }
+
+  @Test
+  public void executeOpsWithSameUser() throws Exception {
+    Change.Id changeId = createChange();
+
+    ObjectId oldHead = getMetaId(changeId);
+
+    CurrentUser defaultUser = user.get();
+    IdentifiedUser user1 =
+        userFactory.create(
+            accountManager.authenticate(authRequestFactory.createForUser("user1")).getAccountId());
+    IdentifiedUser user2 =
+        userFactory.create(
+            accountManager.authenticate(authRequestFactory.createForUser("user2")).getAccountId());
+
+    TestOp testOp1 = new TestOp().addReviewer(user1.getAccountId());
+    TestOp testOp2 = new TestOp().addReviewer(user2.getAccountId());
+
+    try (BatchUpdate bu = batchUpdateFactory.create(project, defaultUser, TimeUtil.now())) {
+      bu.addOp(changeId, defaultUser, testOp1);
+      bu.addOp(changeId, testOp2);
+      bu.execute();
+
+      PersonIdent refLogIdent = bu.getRefLogIdent().get();
+      PersonIdent defaultUserRefLogIdent = defaultUser.asIdentifiedUser().newRefLogIdent();
+      assertThat(refLogIdent.getName()).isEqualTo(defaultUserRefLogIdent.getName());
+      assertThat(refLogIdent.getEmailAddress()).isEqualTo(defaultUserRefLogIdent.getEmailAddress());
+    }
+
+    assertThat(testOp1.updateRepoUser).isEqualTo(defaultUser);
+    assertThat(testOp1.updateChangeUser).isEqualTo(defaultUser);
+    assertThat(testOp1.postUpdateUser).isEqualTo(defaultUser);
+
+    assertThat(testOp2.updateRepoUser).isEqualTo(defaultUser);
+    assertThat(testOp2.updateChangeUser).isEqualTo(defaultUser);
+    assertThat(testOp2.postUpdateUser).isEqualTo(defaultUser);
+
+    // Verify that we got a single meta commit (updates of both ops squashed into one commit).
+    RevCommit metaCommit = repo.getRepository().parseCommit(getMetaId(changeId));
+    assertThat(metaCommit.getAuthorIdent().getEmailAddress())
+        .isEqualTo(String.format("%s@gerrit", defaultUser.getAccountId()));
+    assertThat(metaCommit.getFullMessage())
+        .startsWith(
+            "Update patch set 1\n"
+                + "\n"
+                + "Patch-set: 1\n"
+                + String.format(
+                    "Reviewer: Gerrit User %s <%s@gerrit>\n",
+                    user1.getAccountId(), user1.getAccountId())
+                + String.format(
+                    "Reviewer: Gerrit User %s <%s@gerrit>\n",
+                    user2.getAccountId(), user2.getAccountId())
+                + "Attention:");
+
+    assertThat(metaCommit.getParent(0)).isEqualTo(oldHead);
+  }
+
+  private Change.Id createChange() throws Exception {
+    Change.Id id = Change.id(sequences.nextChangeId());
+    try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.now())) {
+      bu.insertChange(
+          changeInserterFactory.create(
+              id, repo.commit().message("Change").insertChangeId().create(), "refs/heads/master"));
+      bu.execute();
+    }
+    return id;
+  }
+
   private Change.Id createChangeWithUpdates(int totalUpdates) throws Exception {
     checkArgument(totalUpdates > 0);
     checkArgument(totalUpdates <= MAX_UPDATES);
@@ -494,4 +671,38 @@
       return true;
     }
   }
+
+  private static class TestOp implements BatchUpdateOp {
+    CurrentUser updateRepoUser;
+    CurrentUser updateChangeUser;
+    CurrentUser postUpdateUser;
+
+    private List<Account.Id> reviewersToAdd = new ArrayList<>();
+
+    TestOp addReviewer(Account.Id accountId) {
+      reviewersToAdd.add(accountId);
+      return this;
+    }
+
+    @Override
+    public void updateRepo(RepoContext ctx) {
+      updateRepoUser = ctx.getUser();
+    }
+
+    @Override
+    public boolean updateChange(ChangeContext ctx) {
+      updateChangeUser = ctx.getUser();
+
+      reviewersToAdd.forEach(
+          accountId ->
+              ctx.getUpdate(ctx.getChange().currentPatchSetId())
+                  .putReviewer(accountId, ReviewerStateInternal.REVIEWER));
+      return true;
+    }
+
+    @Override
+    public void postUpdate(PostUpdateContext ctx) {
+      postUpdateUser = ctx.getUser();
+    }
+  }
 }
diff --git a/javatests/com/google/gerrit/server/update/RepoViewTest.java b/javatests/com/google/gerrit/server/update/RepoViewTest.java
index b37e302..b118c9f 100644
--- a/javatests/com/google/gerrit/server/update/RepoViewTest.java
+++ b/javatests/com/google/gerrit/server/update/RepoViewTest.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth8.assertThat;
+import static com.google.gerrit.testing.TestActionRefUpdateContext.testRefAction;
 import static org.eclipse.jgit.lib.Constants.R_HEADS;
 
 import com.google.gerrit.entities.Project;
@@ -43,8 +44,14 @@
     InMemoryRepositoryManager repoManager = new InMemoryRepositoryManager();
     Project.NameKey project = Project.nameKey("project");
     repo = repoManager.createRepository(project);
-    tr = new TestRepository<>(repo);
-    tr.branch(MASTER).commit().create();
+    tr =
+        testRefAction(
+            () -> {
+              TestRepository<?> testRepo = new TestRepository<>(repo);
+              testRepo.branch(MASTER).commit().create();
+              return testRepo;
+            });
+
     view = new RepoView(repoManager, project);
   }
 
@@ -75,8 +82,11 @@
     assertThat(view.getRef(MASTER)).hasValue(oldMaster);
     assertThat(view.getRef(BRANCH)).isEmpty();
 
-    tr.branch(MASTER).commit().create();
-    tr.branch(BRANCH).commit().create();
+    testRefAction(
+        () -> {
+          tr.branch(MASTER).commit().create();
+          tr.branch(BRANCH).commit().create();
+        });
     assertThat(repo.exactRef(MASTER).getObjectId()).isNotEqualTo(oldMaster);
     assertThat(repo.exactRef(BRANCH)).isNotNull();
     assertThat(view.getRef(MASTER)).hasValue(oldMaster);
@@ -88,7 +98,7 @@
     ObjectId oldMaster = repo.exactRef(MASTER).getObjectId();
     assertThat(view.getRefs(R_HEADS)).containsExactly("master", oldMaster);
 
-    ObjectId newBranch = tr.branch(BRANCH).commit().create();
+    ObjectId newBranch = testRefAction(() -> tr.branch(BRANCH).commit().create());
     assertThat(view.getRefs(R_HEADS)).containsExactly("master", oldMaster, "branch", newBranch);
   }
 
@@ -99,17 +109,17 @@
     assertThat(view.getRef(MASTER)).hasValue(master1);
 
     // Doesn't reflect new value for master.
-    ObjectId master2 = tr.branch(MASTER).commit().create();
+    ObjectId master2 = testRefAction(() -> tr.branch(MASTER).commit().create());
     assertThat(repo.exactRef(MASTER).getObjectId()).isEqualTo(master2);
     assertThat(view.getRefs(R_HEADS)).containsExactly("master", master1);
 
     // Branch wasn't previously cached, so does reflect new value.
-    ObjectId branch1 = tr.branch(BRANCH).commit().create();
+    ObjectId branch1 = testRefAction(() -> tr.branch(BRANCH).commit().create());
     assertThat(view.getRefs(R_HEADS)).containsExactly("master", master1, "branch", branch1);
 
     // Looking up branch causes it to be cached.
     assertThat(view.getRef(BRANCH)).hasValue(branch1);
-    ObjectId branch2 = tr.branch(BRANCH).commit().create();
+    ObjectId branch2 = testRefAction(() -> tr.branch(BRANCH).commit().create());
     assertThat(repo.exactRef(BRANCH).getObjectId()).isEqualTo(branch2);
     assertThat(view.getRefs(R_HEADS)).containsExactly("master", master1, "branch", branch1);
   }
diff --git a/javatests/com/google/gerrit/server/update/context/BUILD b/javatests/com/google/gerrit/server/update/context/BUILD
new file mode 100644
index 0000000..e580595
--- /dev/null
+++ b/javatests/com/google/gerrit/server/update/context/BUILD
@@ -0,0 +1,14 @@
+load("//tools/bzl:junit.bzl", "junit_tests")
+
+junit_tests(
+    name = "update_context_tests",
+    size = "small",
+    srcs = glob(["*.java"]),
+    deps = [
+        "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/testing:gerrit-test-util",
+        "//java/com/google/gerrit/testing:test-ref-update-context",
+        "//lib/truth",
+        "//lib/truth:truth-java8-extension",
+    ],
+)
diff --git a/javatests/com/google/gerrit/server/update/context/RefUpdateContextTest.java b/javatests/com/google/gerrit/server/update/context/RefUpdateContextTest.java
new file mode 100644
index 0000000..178d67d
--- /dev/null
+++ b/javatests/com/google/gerrit/server/update/context/RefUpdateContextTest.java
@@ -0,0 +1,93 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.update.context;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.GROUPS_UPDATE;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.INIT_REPO;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.common.collect.ImmutableList;
+import org.junit.After;
+import org.junit.Test;
+
+public class RefUpdateContextTest {
+  @After
+  public void tearDown() {
+    // Each test should close all opened context to avoid interference with other tests.
+    assertThat(RefUpdateContext.getOpenedContexts()).isEmpty();
+  }
+
+  @Test
+  public void contextNotOpen() {
+    assertThat(RefUpdateContext.getOpenedContexts()).isEmpty();
+    assertThat(RefUpdateContext.hasOpen(INIT_REPO)).isFalse();
+    assertThat(RefUpdateContext.hasOpen(GROUPS_UPDATE)).isFalse();
+  }
+
+  @Test
+  public void singleContext_openedAndClosedCorrectly() {
+    try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+      ImmutableList<RefUpdateContext> openedContexts = RefUpdateContext.getOpenedContexts();
+      assertThat(openedContexts).hasSize(1);
+      assertThat(openedContexts.get(0).getUpdateType()).isEqualTo(CHANGE_MODIFICATION);
+      assertThat(RefUpdateContext.hasOpen(CHANGE_MODIFICATION)).isTrue();
+      assertThat(RefUpdateContext.hasOpen(INIT_REPO)).isFalse();
+    }
+
+    assertThat(RefUpdateContext.getOpenedContexts()).isEmpty();
+    assertThat(RefUpdateContext.hasOpen(CHANGE_MODIFICATION)).isFalse();
+  }
+
+  @Test
+  public void nestedContext_openedAndClosedCorrectly() {
+    try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+      try (RefUpdateContext nestedCtx = RefUpdateContext.open(INIT_REPO)) {
+        ImmutableList<RefUpdateContext> nestedOpenedContexts = RefUpdateContext.getOpenedContexts();
+        assertThat(nestedOpenedContexts).hasSize(2);
+        assertThat(nestedOpenedContexts.get(0).getUpdateType()).isEqualTo(CHANGE_MODIFICATION);
+        assertThat(nestedOpenedContexts.get(1).getUpdateType()).isEqualTo(INIT_REPO);
+        assertThat(RefUpdateContext.hasOpen(CHANGE_MODIFICATION)).isTrue();
+        assertThat(RefUpdateContext.hasOpen(INIT_REPO)).isTrue();
+        assertThat(RefUpdateContext.hasOpen(GROUPS_UPDATE)).isFalse();
+      }
+      ImmutableList<RefUpdateContext> openedContexts = RefUpdateContext.getOpenedContexts();
+      assertThat(openedContexts).hasSize(1);
+      assertThat(openedContexts.get(0).getUpdateType()).isEqualTo(CHANGE_MODIFICATION);
+      assertThat(RefUpdateContext.hasOpen(CHANGE_MODIFICATION)).isTrue();
+      assertThat(RefUpdateContext.hasOpen(INIT_REPO)).isFalse();
+      assertThat(RefUpdateContext.hasOpen(GROUPS_UPDATE)).isFalse();
+    }
+
+    assertThat(RefUpdateContext.getOpenedContexts()).isEmpty();
+    assertThat(RefUpdateContext.hasOpen(CHANGE_MODIFICATION)).isFalse();
+    assertThat(RefUpdateContext.hasOpen(INIT_REPO)).isFalse();
+    assertThat(RefUpdateContext.hasOpen(GROUPS_UPDATE)).isFalse();
+  }
+
+  @Test
+  public void incorrectCloseOrder_exceptionThrown() {
+    try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+      try (RefUpdateContext nestedCtx = RefUpdateContext.open(INIT_REPO)) {
+        assertThrows(Exception.class, () -> ctx.close());
+        ImmutableList<RefUpdateContext> openedContexts = RefUpdateContext.getOpenedContexts();
+        assertThat(openedContexts).hasSize(2);
+        assertThat(RefUpdateContext.hasOpen(CHANGE_MODIFICATION)).isTrue();
+        assertThat(RefUpdateContext.hasOpen(INIT_REPO)).isTrue();
+      }
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/util/http/testutil/BUILD b/javatests/com/google/gerrit/util/http/testutil/BUILD
index 3a67d45..3b4817b 100644
--- a/javatests/com/google/gerrit/util/http/testutil/BUILD
+++ b/javatests/com/google/gerrit/util/http/testutil/BUILD
@@ -6,6 +6,7 @@
     srcs = glob(["**/*.java"]),
     visibility = ["//visibility:public"],
     deps = [
+        "//java/com/google/gerrit/common:annotations",
         "//lib:guava",
         "//lib:jgit",
         "//lib:servlet-api",
diff --git a/javatests/com/google/gerrit/util/http/testutil/FakeHttpServletRequest.java b/javatests/com/google/gerrit/util/http/testutil/FakeHttpServletRequest.java
index ebdf2d9..0347177 100644
--- a/javatests/com/google/gerrit/util/http/testutil/FakeHttpServletRequest.java
+++ b/javatests/com/google/gerrit/util/http/testutil/FakeHttpServletRequest.java
@@ -25,6 +25,7 @@
 import com.google.common.collect.LinkedListMultimap;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Maps;
+import com.google.gerrit.common.Nullable;
 import java.io.BufferedReader;
 import java.io.UnsupportedEncodingException;
 import java.net.URLDecoder;
@@ -105,6 +106,7 @@
     return -1;
   }
 
+  @Nullable
   @Override
   public String getContentType() {
     List<String> contentType = headers.get("Content-Type");
diff --git a/javatests/com/google/gerrit/util/http/testutil/FakeHttpServletResponse.java b/javatests/com/google/gerrit/util/http/testutil/FakeHttpServletResponse.java
index f39b875..18714ac 100644
--- a/javatests/com/google/gerrit/util/http/testutil/FakeHttpServletResponse.java
+++ b/javatests/com/google/gerrit/util/http/testutil/FakeHttpServletResponse.java
@@ -171,7 +171,7 @@
 
   @Override
   public void addHeader(String name, String value) {
-    headers.put(name.toLowerCase(), value);
+    headers.put(name.toLowerCase(Locale.US), value);
   }
 
   @Override
@@ -181,7 +181,7 @@
 
   @Override
   public boolean containsHeader(String name) {
-    return headers.containsKey(name.toLowerCase());
+    return headers.containsKey(name.toLowerCase(Locale.US));
   }
 
   @Override
@@ -232,13 +232,13 @@
 
   @Override
   public void setHeader(String name, String value) {
-    headers.removeAll(name.toLowerCase());
+    headers.removeAll(name.toLowerCase(Locale.US));
     addHeader(name, value);
   }
 
   @Override
   public void setIntHeader(String name, int value) {
-    headers.removeAll(name.toLowerCase());
+    headers.removeAll(name.toLowerCase(Locale.US));
     addIntHeader(name, value);
   }
 
@@ -262,7 +262,7 @@
 
   @Override
   public String getHeader(String name) {
-    return Iterables.getFirst(headers.get(requireNonNull(name.toLowerCase())), null);
+    return Iterables.getFirst(headers.get(requireNonNull(name.toLowerCase(Locale.US))), null);
   }
 
   @Override
@@ -272,7 +272,7 @@
 
   @Override
   public Collection<String> getHeaders(String name) {
-    return headers.get(requireNonNull(name.toLowerCase()));
+    return headers.get(requireNonNull(name.toLowerCase(Locale.US)));
   }
 
   public byte[] getActualBody() {
diff --git a/modules/jgit b/modules/jgit
index 5ae8d28..74fa245 160000
--- a/modules/jgit
+++ b/modules/jgit
@@ -1 +1 @@
-Subproject commit 5ae8d28faaf6168921f673c89a4e6d601ffad78d
+Subproject commit 74fa245b3c3ccf13afcbec7911c7c8459e48527d
diff --git a/package.json b/package.json
index 745a34e..362b9dc 100644
--- a/package.json
+++ b/package.json
@@ -33,14 +33,16 @@
     "typescript": "^4.7.2"
   },
   "scripts": {
+    "setup": "yarn && yarn --cwd=polygerrit-ui && yarn --cwd=polygerrit-ui/app",
     "clean": "git clean -fdx && bazel clean --expunge",
-    "compile:local": "tsc --project ./polygerrit-ui/app/tsconfig.json",
-    "compile:watch": "npm run compile:local -- --preserveWatchOutput --watch",
+    "compile": "tsc --project ./polygerrit-ui/app/tsconfig.json",
+    "compile:watch": "npm run compile -- --preserveWatchOutput --watch",
     "start": "run-p -rl compile:watch start:server",
     "start:server": "web-dev-server",
     "test": "yarn --cwd=polygerrit-ui test",
     "test:screenshot": "yarn --cwd=polygerrit-ui test:screenshot",
     "test:screenshot-update": "yarn --cwd=polygerrit-ui test:screenshot-update",
+    "test:browsers": "yarn --cwd=polygerrit-ui test:browsers",
     "test:coverage": "yarn --cwd=polygerrit-ui test:coverage",
     "test:watch": "yarn --cwd=polygerrit-ui test:watch",
     "test:single": "yarn --cwd=polygerrit-ui test:single",
@@ -48,7 +50,8 @@
     "safe_bazelisk": "if which bazelisk >/dev/null; then bazel_bin=bazelisk; else bazel_bin=bazel; fi && $bazel_bin",
     "eslint": "npm run safe_bazelisk test polygerrit-ui/app:lint_test",
     "eslintfix": "npm run safe_bazelisk run polygerrit-ui/app:lint_bin -- -- --fix $(pwd)/polygerrit-ui/app",
-    "litlint": "npm run safe_bazelisk run polygerrit-ui/app:lit_analysis"
+    "litlint": "npm run safe_bazelisk run polygerrit-ui/app:lit_analysis",
+    "lint": "eslint -c polygerrit-ui/app/.eslintrc.js --ignore-path polygerrit-ui/app/.eslintignore polygerrit-ui/app"
   },
   "repository": {
     "type": "git",
diff --git a/plugins/BUILD b/plugins/BUILD
index 32efa3e..39560c5 100644
--- a/plugins/BUILD
+++ b/plugins/BUILD
@@ -6,7 +6,7 @@
     "CORE_PLUGINS",
     "CUSTOM_PLUGINS",
 )
-load("@npm//@bazel/typescript:index.bzl", "ts_config")
+load("@build_bazel_rules_nodejs//:index.bzl", "nodejs_test")
 
 package(default_visibility = ["//visibility:public"])
 
@@ -169,3 +169,32 @@
     pkgs = ["com.google.gerrit"],
     title = "Gerrit Review Plugin API Documentation",
 )
+
+# This is a generic test target for TypeScript plugins.
+#
+# `nodejs_test` needs to run in the directory where the `package.json` and
+# `node_modules` are, so unfortunately we cannot move this target into the
+# BUILD files of individual plugins. On the other hand one common target
+# for all plugins also has the advantage of being re-usable.
+#
+# For making this work for a specific plugin you have make the source files
+# of the plugin available as a `filegroup` and add it to the `data` attribute.
+# And you have to specify the `PLUGIN_DIR` in the `env` attribute.
+nodejs_test(
+    name = "web-test-runner",
+    size = "large",
+    chdir = package_name(),
+    data = [
+        ":package.json",
+        ":web-test-runner.config.mjs",
+        # This is an example of how you could reference your plugin sources:
+        # "//plugins/codemirror-editor/web:codemirror-test-sources",
+        "@plugins_npm//:node_modules",
+    ],
+    entry_point = "@plugins_npm//:node_modules/@web/test-runner/dist/bin.js",
+    env = {"PLUGIN_DIR": "codemirror-editor"},
+    tags = [
+        "local",
+        "manual",
+    ],
+)
diff --git a/plugins/codemirror-editor b/plugins/codemirror-editor
index 3af12c5..be8e04b 160000
--- a/plugins/codemirror-editor
+++ b/plugins/codemirror-editor
@@ -1 +1 @@
-Subproject commit 3af12c5a5e65861830b42bd07933e275c33b9159
+Subproject commit be8e04b1a6de091a63c9bc79b56508f2ad56a830
diff --git a/plugins/delete-project b/plugins/delete-project
index b183ee5..b080ed4 160000
--- a/plugins/delete-project
+++ b/plugins/delete-project
@@ -1 +1 @@
-Subproject commit b183ee5230273670f3235cc5b3cf32562ccfb7ee
+Subproject commit b080ed4630104cee0078f6be3561600ed1c3647a
diff --git a/plugins/gitiles b/plugins/gitiles
index 24529d2..20f65c2 160000
--- a/plugins/gitiles
+++ b/plugins/gitiles
@@ -1 +1 @@
-Subproject commit 24529d232268ac51fd6850770f70dc0fcd732dd8
+Subproject commit 20f65c2067b9190d1c85fbf61e5d72edf4493724
diff --git a/plugins/package.json b/plugins/package.json
index 79bb7665..504fc17 100644
--- a/plugins/package.json
+++ b/plugins/package.json
@@ -3,12 +3,38 @@
   "description": "Gerrit Code Review - frontend plugin dependencies, each plugin may depend on a subset of these",
   "browser": true,
   "dependencies": {
-    "@gerritcodereview/typescript-api": "3.7.0",
+    "@gerritcodereview/typescript-api": "3.8.0",
     "@polymer/decorators": "^3.0.0",
     "@polymer/polymer": "^3.4.1",
     "@open-wc/testing": "^3.1.6",
+    "@web/dev-server-esbuild": "^0.3.2",
+    "@web/test-runner": "^0.14.0",
+    "@codemirror/autocomplete": "^6.5.1",
+    "@codemirror/commands": "^6.2.3",
+    "@codemirror/legacy-modes": "^6.3.2",
+    "@codemirror/lang-cpp": "^6.0.2",
+    "@codemirror/lang-css": "^6.2.0",
+    "@codemirror/lang-html": "^6.4.3",
+    "@codemirror/lang-java": "^6.0.1",
+    "@codemirror/lang-javascript": "^6.1.7",
+    "@codemirror/lang-json": "^6.0.1",
+    "@codemirror/lang-less": "^6.0.0",
+    "@codemirror/lang-markdown": "^6.1.1",
+    "@codemirror/lang-php": "^6.0.1",
+    "@codemirror/lang-python": "^6.1.2",
+    "@codemirror/lang-rust": "^6.0.1",
+    "@codemirror/lang-sass": "^6.0.1",
+    "@codemirror/lang-sql": "^6.4.1",
+    "@codemirror/lang-xml": "^6.0.2",
+    "@codemirror/language": "^6.6.0",
+    "@codemirror/language-data": "^6.3.0",
+    "@codemirror/lint": "^6.2.1",
+    "@codemirror/search": "^6.4.0",
+    "@codemirror/state": "^6.2.0",
+    "@codemirror/view": "^6.10.0",
     "lit": "^2.2.3",
-    "rxjs": "^6.6.7"
+    "rxjs": "^6.6.7",
+    "sinon": "^13.0.0"
   },
   "license": "Apache-2.0",
   "private": true
diff --git a/plugins/replication b/plugins/replication
index 47ee3da..8fd3c27 160000
--- a/plugins/replication
+++ b/plugins/replication
@@ -1 +1 @@
-Subproject commit 47ee3dab0dd96900e85662adf0d5f48a33d17733
+Subproject commit 8fd3c271ce0a21480e3d04da5ad2112efea3bedf
diff --git a/plugins/reviewnotes b/plugins/reviewnotes
index 4198fe8..9321303 160000
--- a/plugins/reviewnotes
+++ b/plugins/reviewnotes
@@ -1 +1 @@
-Subproject commit 4198fe8df1c1b86d812f32da63e891b1c2fc6f3e
+Subproject commit 9321303265fcab2ff7f764a444f8c23915747638
diff --git a/plugins/singleusergroup b/plugins/singleusergroup
index 3239ce3..084a372 160000
--- a/plugins/singleusergroup
+++ b/plugins/singleusergroup
@@ -1 +1 @@
-Subproject commit 3239ce3a471f5aa9edd8f6f702bee655ea81f77d
+Subproject commit 084a37253dc94ac52cfaa1c9d516fcb8b0318b31
diff --git a/plugins/yarn.lock b/plugins/yarn.lock
index e012bd1..3f53453 100644
--- a/plugins/yarn.lock
+++ b/plugins/yarn.lock
@@ -10,9 +10,9 @@
     "@babel/highlight" "^7.18.6"
 
 "@babel/helper-validator-identifier@^7.18.6":
-  version "7.18.6"
-  resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.18.6.tgz#9c97e30d31b2b8c72a1d08984f2ca9b574d7a076"
-  integrity sha512-MmetCkz9ej86nJQV+sFCxoGGrUbU3q02kgLciwkrt9QqEB7cP39oKEY0PakknEO0Gu20SskMRi+AYZ3b1TpN9g==
+  version "7.19.1"
+  resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.19.1.tgz#7eea834cf32901ffdc1a7ee555e2f9c27e249ca2"
+  integrity sha512-awrNfaMtnHUr653GgGEs++LlAvW6w+DcPrOliSMXWCKo597CwL5Acf/wWdNkf/tfEQE3mjkeD1YOVZOUV/od1w==
 
 "@babel/highlight@^7.18.6":
   version "7.18.6"
@@ -23,6 +23,284 @@
     chalk "^2.0.0"
     js-tokens "^4.0.0"
 
+"@codemirror/autocomplete@^6.0.0", "@codemirror/autocomplete@^6.3.2", "@codemirror/autocomplete@^6.5.1":
+  version "6.5.1"
+  resolved "https://registry.yarnpkg.com/@codemirror/autocomplete/-/autocomplete-6.5.1.tgz#539cfff291dbffd3841cba078b222cea28ff7eda"
+  integrity sha512-/Sv9yJmqyILbZ26U4LBHnAtbikuVxWUp+rQ8BXuRGtxZfbfKOY/WPbsUtvSP2h0ZUZMlkxV/hqbKRFzowlA6xw==
+  dependencies:
+    "@codemirror/language" "^6.0.0"
+    "@codemirror/state" "^6.0.0"
+    "@codemirror/view" "^6.6.0"
+    "@lezer/common" "^1.0.0"
+
+"@codemirror/commands@^6.2.3":
+  version "6.2.3"
+  resolved "https://registry.yarnpkg.com/@codemirror/commands/-/commands-6.2.3.tgz#ec476fd588f7a4333f54584d4783dd3862befe3b"
+  integrity sha512-9uf0g9m2wZyrIim1SavcxMdwsu8wc/y5uSw6JRUBYIGWrN+RY4vSru/BqB+MyNWqx4C2uRhQ/Kh7Pw8lAyT3qQ==
+  dependencies:
+    "@codemirror/language" "^6.0.0"
+    "@codemirror/state" "^6.2.0"
+    "@codemirror/view" "^6.0.0"
+    "@lezer/common" "^1.0.0"
+
+"@codemirror/lang-angular@^0.1.0":
+  version "0.1.0"
+  resolved "https://registry.yarnpkg.com/@codemirror/lang-angular/-/lang-angular-0.1.0.tgz#1054747c8196357a2aee2b9c36f0f6de9a6ffef9"
+  integrity sha512-vTjoHjzJmLrrMFmf/tojwp+O0P+R9mgWtjjaKDNDoY58PzOPg7ldMEBqIzABBc+/2mYPD85SG7O5byfBxc83eA==
+  dependencies:
+    "@codemirror/lang-html" "^6.0.0"
+    "@codemirror/lang-javascript" "^6.1.2"
+    "@codemirror/language" "^6.0.0"
+    "@lezer/common" "^1.0.0"
+    "@lezer/highlight" "^1.0.0"
+
+"@codemirror/lang-cpp@^6.0.0", "@codemirror/lang-cpp@^6.0.2":
+  version "6.0.2"
+  resolved "https://registry.yarnpkg.com/@codemirror/lang-cpp/-/lang-cpp-6.0.2.tgz#076c98340c3beabde016d7d83e08eebe17254ef9"
+  integrity sha512-6oYEYUKHvrnacXxWxYa6t4puTlbN3dgV662BDfSH8+MfjQjVmP697/KYTDOqpxgerkvoNm7q5wlFMBeX8ZMocg==
+  dependencies:
+    "@codemirror/language" "^6.0.0"
+    "@lezer/cpp" "^1.0.0"
+
+"@codemirror/lang-css@^6.0.0", "@codemirror/lang-css@^6.1.1", "@codemirror/lang-css@^6.2.0":
+  version "6.2.0"
+  resolved "https://registry.yarnpkg.com/@codemirror/lang-css/-/lang-css-6.2.0.tgz#f84f9da392099432445c75e32fdac63ae572315f"
+  integrity sha512-oyIdJM29AyRPM3+PPq1I2oIk8NpUfEN3kAM05XWDDs6o3gSneIKaVJifT2P+fqONLou2uIgXynFyMUDQvo/szA==
+  dependencies:
+    "@codemirror/autocomplete" "^6.0.0"
+    "@codemirror/language" "^6.0.0"
+    "@codemirror/state" "^6.0.0"
+    "@lezer/common" "^1.0.2"
+    "@lezer/css" "^1.0.0"
+
+"@codemirror/lang-html@^6.0.0", "@codemirror/lang-html@^6.4.3":
+  version "6.4.3"
+  resolved "https://registry.yarnpkg.com/@codemirror/lang-html/-/lang-html-6.4.3.tgz#dec78f76d9d0261cbe9f2a3a247a1b546327f700"
+  integrity sha512-VKzQXEC8nL69Jg2hvAFPBwOdZNvL8tMFOrdFwWpU+wc6a6KEkndJ/19R5xSaglNX6v2bttm8uIEFYxdQDcIZVQ==
+  dependencies:
+    "@codemirror/autocomplete" "^6.0.0"
+    "@codemirror/lang-css" "^6.0.0"
+    "@codemirror/lang-javascript" "^6.0.0"
+    "@codemirror/language" "^6.4.0"
+    "@codemirror/state" "^6.0.0"
+    "@codemirror/view" "^6.2.2"
+    "@lezer/common" "^1.0.0"
+    "@lezer/css" "^1.1.0"
+    "@lezer/html" "^1.3.0"
+
+"@codemirror/lang-java@^6.0.0", "@codemirror/lang-java@^6.0.1":
+  version "6.0.1"
+  resolved "https://registry.yarnpkg.com/@codemirror/lang-java/-/lang-java-6.0.1.tgz#03bd06334da7c8feb9dff6db01ac6d85bd2e48bb"
+  integrity sha512-OOnmhH67h97jHzCuFaIEspbmsT98fNdhVhmA3zCxW0cn7l8rChDhZtwiwJ/JOKXgfm4J+ELxQihxaI7bj7mJRg==
+  dependencies:
+    "@codemirror/language" "^6.0.0"
+    "@lezer/java" "^1.0.0"
+
+"@codemirror/lang-javascript@^6.0.0", "@codemirror/lang-javascript@^6.1.2", "@codemirror/lang-javascript@^6.1.7":
+  version "6.1.7"
+  resolved "https://registry.yarnpkg.com/@codemirror/lang-javascript/-/lang-javascript-6.1.7.tgz#e39fb9757b1cf47de432e4244d18ca5284a73a58"
+  integrity sha512-KXKqxlZ4W6t5I7i2ScmITUD3f/F5Cllk3kj0De9P9mFeYVfhOVOWuDLgYiLpk357u7Xh4dhqjJAnsNPPoTLghQ==
+  dependencies:
+    "@codemirror/autocomplete" "^6.0.0"
+    "@codemirror/language" "^6.6.0"
+    "@codemirror/lint" "^6.0.0"
+    "@codemirror/state" "^6.0.0"
+    "@codemirror/view" "^6.0.0"
+    "@lezer/common" "^1.0.0"
+    "@lezer/javascript" "^1.0.0"
+
+"@codemirror/lang-json@^6.0.0", "@codemirror/lang-json@^6.0.1":
+  version "6.0.1"
+  resolved "https://registry.yarnpkg.com/@codemirror/lang-json/-/lang-json-6.0.1.tgz#0a0be701a5619c4b0f8991f9b5e95fe33f462330"
+  integrity sha512-+T1flHdgpqDDlJZ2Lkil/rLiRy684WMLc74xUnjJH48GQdfJo/pudlTRreZmKwzP8/tGdKf83wlbAdOCzlJOGQ==
+  dependencies:
+    "@codemirror/language" "^6.0.0"
+    "@lezer/json" "^1.0.0"
+
+"@codemirror/lang-less@^6.0.0":
+  version "6.0.0"
+  resolved "https://registry.yarnpkg.com/@codemirror/lang-less/-/lang-less-6.0.0.tgz#47ac36242f45bcc211dbcbce11e10f3b249519c9"
+  integrity sha512-hQVj+AxcUW/LybRkwaOope8K8+U6bjWH91t0tW8MMok33Y65xo+Wx0t1BaXi3Iuo6CgJ4tW7Rz09cfNwloIdNA==
+  dependencies:
+    "@codemirror/lang-css" "^6.2.0"
+    "@codemirror/language" "^6.0.0"
+    "@lezer/highlight" "^1.0.0"
+    "@lezer/lr" "^1.0.0"
+
+"@codemirror/lang-markdown@^6.0.0", "@codemirror/lang-markdown@^6.1.1":
+  version "6.1.1"
+  resolved "https://registry.yarnpkg.com/@codemirror/lang-markdown/-/lang-markdown-6.1.1.tgz#ff3cdd339c277f6a02d08eb12f1090977873e771"
+  integrity sha512-n87Ms6Y5UYb1UkFu8sRzTLfq/yyF1y2AYiWvaVdbBQi5WDj1tFk5N+AKA+WC0Jcjc1VxvrCCM0iizjdYYi9sFQ==
+  dependencies:
+    "@codemirror/lang-html" "^6.0.0"
+    "@codemirror/language" "^6.3.0"
+    "@codemirror/state" "^6.0.0"
+    "@codemirror/view" "^6.0.0"
+    "@lezer/common" "^1.0.0"
+    "@lezer/markdown" "^1.0.0"
+
+"@codemirror/lang-php@^6.0.0", "@codemirror/lang-php@^6.0.1":
+  version "6.0.1"
+  resolved "https://registry.yarnpkg.com/@codemirror/lang-php/-/lang-php-6.0.1.tgz#fa34cc75562178325861a5731f79bd621f57ffaa"
+  integrity sha512-ublojMdw/PNWa7qdN5TMsjmqkNuTBD3k6ndZ4Z0S25SBAiweFGyY68AS3xNcIOlb6DDFDvKlinLQ40vSLqf8xA==
+  dependencies:
+    "@codemirror/lang-html" "^6.0.0"
+    "@codemirror/language" "^6.0.0"
+    "@codemirror/state" "^6.0.0"
+    "@lezer/common" "^1.0.0"
+    "@lezer/php" "^1.0.0"
+
+"@codemirror/lang-python@^6.0.0", "@codemirror/lang-python@^6.1.2":
+  version "6.1.2"
+  resolved "https://registry.yarnpkg.com/@codemirror/lang-python/-/lang-python-6.1.2.tgz#cabb57529679981f170491833dbf798576e7ab18"
+  integrity sha512-nbQfifLBZstpt6Oo4XxA2LOzlSp4b/7Bc5cmodG1R+Cs5PLLCTUvsMNWDnziiCfTOG/SW1rVzXq/GbIr6WXlcw==
+  dependencies:
+    "@codemirror/autocomplete" "^6.3.2"
+    "@codemirror/language" "^6.0.0"
+    "@lezer/python" "^1.0.0"
+
+"@codemirror/lang-rust@^6.0.0", "@codemirror/lang-rust@^6.0.1":
+  version "6.0.1"
+  resolved "https://registry.yarnpkg.com/@codemirror/lang-rust/-/lang-rust-6.0.1.tgz#d6829fc7baa39a15bcd174a41a9e0a1bf7cf6ba8"
+  integrity sha512-344EMWFBzWArHWdZn/NcgkwMvZIWUR1GEBdwG8FEp++6o6vT6KL9V7vGs2ONsKxxFUPXKI0SPcWhyYyl2zPYxQ==
+  dependencies:
+    "@codemirror/language" "^6.0.0"
+    "@lezer/rust" "^1.0.0"
+
+"@codemirror/lang-sass@^6.0.0", "@codemirror/lang-sass@^6.0.1":
+  version "6.0.1"
+  resolved "https://registry.yarnpkg.com/@codemirror/lang-sass/-/lang-sass-6.0.1.tgz#e390f427c8601175f155046e142371c3c4fb718c"
+  integrity sha512-USy9zqtdLYxSuqq0s4peMoQi+BDzyOyO7chUzli+X2xVCjmBhc3CsWQ4kkDU0NYtCHHFQRkcFO8770eaOwZqfw==
+  dependencies:
+    "@codemirror/lang-css" "^6.1.1"
+    "@codemirror/language" "^6.0.0"
+    "@codemirror/state" "^6.0.0"
+    "@lezer/common" "^1.0.2"
+    "@lezer/sass" "^1.0.0"
+
+"@codemirror/lang-sql@^6.0.0", "@codemirror/lang-sql@^6.4.1":
+  version "6.4.1"
+  resolved "https://registry.yarnpkg.com/@codemirror/lang-sql/-/lang-sql-6.4.1.tgz#e680fe8c12e5902a29fd952207bf454ae02b3bdc"
+  integrity sha512-PFB56L+A0WGY35uRya+Trt5g19V9k2V9X3c55xoFW4RgiATr/yLqWsbbnEsdxuMn5tLpuikp7Kmj9smRsqBXAg==
+  dependencies:
+    "@codemirror/autocomplete" "^6.0.0"
+    "@codemirror/language" "^6.0.0"
+    "@codemirror/state" "^6.0.0"
+    "@lezer/highlight" "^1.0.0"
+    "@lezer/lr" "^1.0.0"
+
+"@codemirror/lang-vue@^0.1.1":
+  version "0.1.1"
+  resolved "https://registry.yarnpkg.com/@codemirror/lang-vue/-/lang-vue-0.1.1.tgz#79567fb3be3f411354cd135af59d67f956cdb042"
+  integrity sha512-GIfc/MemCFKUdNSYGTFZDN8XsD2z0DUY7DgrK34on0dzdZ/CawZbi+SADYfVzWoPPdxngHzLhqlR5pSOqyPCvA==
+  dependencies:
+    "@codemirror/lang-html" "^6.0.0"
+    "@codemirror/lang-javascript" "^6.1.2"
+    "@codemirror/language" "^6.0.0"
+    "@lezer/common" "^1.0.0"
+    "@lezer/highlight" "^1.0.0"
+    "@lezer/lr" "^1.3.1"
+
+"@codemirror/lang-wast@^6.0.0":
+  version "6.0.1"
+  resolved "https://registry.yarnpkg.com/@codemirror/lang-wast/-/lang-wast-6.0.1.tgz#c15bec84548a5e9b0a43fa69fb63631d087d6047"
+  integrity sha512-sQLsqhRjl2MWG3rxZysX+2XAyed48KhLBHLgq9xcKxIJu3npH/G+BIXW5NM5mHeDUjG0jcGh9BcjP0NfMStuzA==
+  dependencies:
+    "@codemirror/language" "^6.0.0"
+    "@lezer/highlight" "^1.0.0"
+    "@lezer/lr" "^1.0.0"
+
+"@codemirror/lang-xml@^6.0.0", "@codemirror/lang-xml@^6.0.2":
+  version "6.0.2"
+  resolved "https://registry.yarnpkg.com/@codemirror/lang-xml/-/lang-xml-6.0.2.tgz#66f75390bf8013fd8645db9cdd0b1d177e0777a4"
+  integrity sha512-JQYZjHL2LAfpiZI2/qZ/qzDuSqmGKMwyApYmEUUCTxLM4MWS7sATUEfIguZQr9Zjx/7gcdnewb039smF6nC2zw==
+  dependencies:
+    "@codemirror/autocomplete" "^6.0.0"
+    "@codemirror/language" "^6.4.0"
+    "@codemirror/state" "^6.0.0"
+    "@lezer/common" "^1.0.0"
+    "@lezer/xml" "^1.0.0"
+
+"@codemirror/language-data@^6.3.0":
+  version "6.3.0"
+  resolved "https://registry.yarnpkg.com/@codemirror/language-data/-/language-data-6.3.0.tgz#058365fc2e857eb48810ed92134ee469d9a9bba6"
+  integrity sha512-D9tOZS38mK59jDs1Flqe8GgCdUAYI339SqBdwHJZwxgyXHsBc8RIhAlz2oXWGpvZeP/kVHy9LVfoBFgO02mx7w==
+  dependencies:
+    "@codemirror/lang-angular" "^0.1.0"
+    "@codemirror/lang-cpp" "^6.0.0"
+    "@codemirror/lang-css" "^6.0.0"
+    "@codemirror/lang-html" "^6.0.0"
+    "@codemirror/lang-java" "^6.0.0"
+    "@codemirror/lang-javascript" "^6.0.0"
+    "@codemirror/lang-json" "^6.0.0"
+    "@codemirror/lang-markdown" "^6.0.0"
+    "@codemirror/lang-php" "^6.0.0"
+    "@codemirror/lang-python" "^6.0.0"
+    "@codemirror/lang-rust" "^6.0.0"
+    "@codemirror/lang-sass" "^6.0.0"
+    "@codemirror/lang-sql" "^6.0.0"
+    "@codemirror/lang-vue" "^0.1.1"
+    "@codemirror/lang-wast" "^6.0.0"
+    "@codemirror/lang-xml" "^6.0.0"
+    "@codemirror/language" "^6.0.0"
+    "@codemirror/legacy-modes" "^6.1.0"
+
+"@codemirror/language@^6.0.0", "@codemirror/language@^6.3.0", "@codemirror/language@^6.4.0", "@codemirror/language@^6.6.0":
+  version "6.6.0"
+  resolved "https://registry.yarnpkg.com/@codemirror/language/-/language-6.6.0.tgz#2204407174a38a68053715c19e28ad61f491779f"
+  integrity sha512-cwUd6lzt3MfNYOobdjf14ZkLbJcnv4WtndYaoBkbor/vF+rCNguMPK0IRtvZJG4dsWiaWPcK8x1VijhvSxnstg==
+  dependencies:
+    "@codemirror/state" "^6.0.0"
+    "@codemirror/view" "^6.0.0"
+    "@lezer/common" "^1.0.0"
+    "@lezer/highlight" "^1.0.0"
+    "@lezer/lr" "^1.0.0"
+    style-mod "^4.0.0"
+
+"@codemirror/legacy-modes@^6.1.0", "@codemirror/legacy-modes@^6.3.2":
+  version "6.3.2"
+  resolved "https://registry.yarnpkg.com/@codemirror/legacy-modes/-/legacy-modes-6.3.2.tgz#d5616b453f38866717437b51c16bde1ae3f011ec"
+  integrity sha512-ki5sqNKWzKi5AKvpVE6Cna4Q+SgxYuYVLAZFSsMjGBWx5qSVa+D+xipix65GS3f2syTfAD9pXKMX4i4p49eneQ==
+  dependencies:
+    "@codemirror/language" "^6.0.0"
+
+"@codemirror/lint@^6.0.0", "@codemirror/lint@^6.2.1":
+  version "6.2.1"
+  resolved "https://registry.yarnpkg.com/@codemirror/lint/-/lint-6.2.1.tgz#654581d8cc293c315ecfa5c9d61d78c52bbd9ccd"
+  integrity sha512-y1muai5U/uUPAGRyHMx9mHuHLypPcHWxzlZGknp/U5Mdb5Ol8Q5ZLp67UqyTbNFJJ3unVxZ8iX3g1fMN79S1JQ==
+  dependencies:
+    "@codemirror/state" "^6.0.0"
+    "@codemirror/view" "^6.0.0"
+    crelt "^1.0.5"
+
+"@codemirror/search@^6.4.0":
+  version "6.4.0"
+  resolved "https://registry.yarnpkg.com/@codemirror/search/-/search-6.4.0.tgz#2b256a9e0eaa9317fb48e3cc81eb2735360a59b4"
+  integrity sha512-zMDgaBXah+nMLK2dHz9GdCnGbQu+oaGRXS1qviqNZkvOCv/whp5XZFyoikLp/23PM9RBcbuKUUISUmQHM1eRHw==
+  dependencies:
+    "@codemirror/state" "^6.0.0"
+    "@codemirror/view" "^6.0.0"
+    crelt "^1.0.5"
+
+"@codemirror/state@^6.0.0", "@codemirror/state@^6.1.4", "@codemirror/state@^6.2.0":
+  version "6.2.0"
+  resolved "https://registry.yarnpkg.com/@codemirror/state/-/state-6.2.0.tgz#a0fb08403ced8c2a68d1d0acee926bd20be922f2"
+  integrity sha512-69QXtcrsc3RYtOtd+GsvczJ319udtBf1PTrr2KbLWM/e2CXUPnh0Nz9AUo8WfhSQ7GeL8dPVNUmhQVgpmuaNGA==
+
+"@codemirror/view@^6.0.0", "@codemirror/view@^6.10.0", "@codemirror/view@^6.2.2", "@codemirror/view@^6.6.0":
+  version "6.10.0"
+  resolved "https://registry.yarnpkg.com/@codemirror/view/-/view-6.10.0.tgz#40bb39f391955db8960337a9e80fd7564f8915e2"
+  integrity sha512-Oea3rvE4JQLMmLsy2b54yxXQJgJM9xKpUQIpF/LGgKUTH2lA06GAmEtKKWn5OUnbW3jrH1hHeUd8DJEgePMOeQ==
+  dependencies:
+    "@codemirror/state" "^6.1.4"
+    style-mod "^4.0.0"
+    w3c-keyname "^2.2.4"
+
+"@esbuild/linux-loong64@0.14.54":
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.14.54.tgz#de2a4be678bd4d0d1ffbb86e6de779cde5999028"
+  integrity sha512-bZBrLAIX1kpWelV0XemxBZllyRmM6vgFQQG2GdNb+r3Fkp0FOh1NJSvekXDs7jq70k4euu1cryLMfU+mTXlEpw==
+
 "@esm-bundle/chai@^4.3.4-fix.0":
   version "4.3.4-fix.0"
   resolved "https://registry.yarnpkg.com/@esm-bundle/chai/-/chai-4.3.4-fix.0.tgz#3084cff7eb46d741749f47f3a48dbbdcbaf30a92"
@@ -30,20 +308,143 @@
   dependencies:
     "@types/chai" "^4.2.12"
 
-"@gerritcodereview/typescript-api@3.7.0":
-  version "3.7.0"
-  resolved "https://registry.yarnpkg.com/@gerritcodereview/typescript-api/-/typescript-api-3.7.0.tgz#ae3886b5c4ddc6a02659a11d577e1df0b6158727"
-  integrity sha512-8zeZClN1gur+Isrn02bRXJ0wUjYvK99jQxg36ZbDelrGDglXKddf8QQkZmaH9sYIRcCFDLlh5+ZlRUTcXTuDVA==
+"@gerritcodereview/typescript-api@3.8.0":
+  version "3.8.0"
+  resolved "https://registry.yarnpkg.com/@gerritcodereview/typescript-api/-/typescript-api-3.8.0.tgz#2e418b814d7451c40365b2dc4f88e9965ece0769"
+  integrity sha512-wUkIWUx99Rj1vxRYQISxyzN0nplqu7t5sRDyJ8R3yNNkvALQAMC6Whj63qzCsZsymVFzC5up3y+ZVxaeh7b+xA==
 
-"@lit/reactive-element@^1.0.0", "@lit/reactive-element@^1.4.0":
-  version "1.4.1"
-  resolved "https://registry.yarnpkg.com/@lit/reactive-element/-/reactive-element-1.4.1.tgz#3f587eec5708692135bc9e94cf396130604979f3"
-  integrity sha512-qDv4851VFSaBWzpS02cXHclo40jsbAjRXnebNXpm0uVg32kCneZPo9RYVQtrTNICtZ+1wAYHu1ZtxWSWMbKrBw==
+"@lezer/common@^1.0.0", "@lezer/common@^1.0.2":
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/@lezer/common/-/common-1.0.2.tgz#8fb9b86bdaa2ece57e7d59e5ffbcb37d71815087"
+  integrity sha512-SVgiGtMnMnW3ActR8SXgsDhw7a0w0ChHSYAyAUxxrOiJ1OqYWEKk/xJd84tTSPo1mo6DXLObAJALNnd0Hrv7Ng==
 
-"@lit/reactive-element@^1.3.0":
-  version "1.3.2"
-  resolved "https://registry.yarnpkg.com/@lit/reactive-element/-/reactive-element-1.3.2.tgz#43e470537b6ec2c23510c07812616d5aa27a17cd"
-  integrity sha512-A2e18XzPMrIh35nhIdE4uoqRzoIpEU5vZYuQN4S3Ee1zkGdYC27DP12pewbw/RLgPHzaE4kx/YqxMzebOpm0dA==
+"@lezer/cpp@^1.0.0":
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/@lezer/cpp/-/cpp-1.1.0.tgz#5aaecac684437925d650252d7e9d97acf8f8f095"
+  integrity sha512-zUHrjNFuY/DOZCkOBJ6qItQIkcopHM/Zv/QOE0a4XNG3HDNahxTNu5fQYl8dIuKCpxCqRdMl5cEwl5zekFc7BA==
+  dependencies:
+    "@lezer/highlight" "^1.0.0"
+    "@lezer/lr" "^1.0.0"
+
+"@lezer/css@^1.0.0", "@lezer/css@^1.1.0":
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/@lezer/css/-/css-1.1.1.tgz#c36dcb0789317cb80c3740767dd3b85e071ad082"
+  integrity sha512-mSjx+unLLapEqdOYDejnGBokB5+AiJKZVclmud0MKQOKx3DLJ5b5VTCstgDDknR6iIV4gVrN6euzsCnj0A2gQA==
+  dependencies:
+    "@lezer/highlight" "^1.0.0"
+    "@lezer/lr" "^1.0.0"
+
+"@lezer/highlight@^1.0.0", "@lezer/highlight@^1.1.3":
+  version "1.1.4"
+  resolved "https://registry.yarnpkg.com/@lezer/highlight/-/highlight-1.1.4.tgz#98ed821e89f72981b7ba590474e6ee86c8185619"
+  integrity sha512-IECkFmw2l7sFcYXrV8iT9GeY4W0fU4CxX0WMwhmhMIVjoDdD1Hr6q3G2NqVtLg/yVe5n7i4menG3tJ2r4eCrPQ==
+  dependencies:
+    "@lezer/common" "^1.0.0"
+
+"@lezer/html@^1.3.0":
+  version "1.3.4"
+  resolved "https://registry.yarnpkg.com/@lezer/html/-/html-1.3.4.tgz#7a5c5498dae6c93aee3de208bfb01aa3a0a932e3"
+  integrity sha512-HdJYMVZcT4YsMo7lW3ipL4NoyS2T67kMPuSVS5TgLGqmaCjEU/D6xv7zsa1ktvTK5lwk7zzF1e3eU6gBZIPm5g==
+  dependencies:
+    "@lezer/common" "^1.0.0"
+    "@lezer/highlight" "^1.0.0"
+    "@lezer/lr" "^1.0.0"
+
+"@lezer/java@^1.0.0":
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/@lezer/java/-/java-1.0.3.tgz#393e333fcdb64f7308e0ce120005b0065668e1d2"
+  integrity sha512-kKN17wmgP1cgHb8juR4pwVSPMKkDMzY/lAPbBsZ1fpXwbk2sg3N1kIrf0q+LefxgrANaQb/eNO7+m2QPruTFng==
+  dependencies:
+    "@lezer/highlight" "^1.0.0"
+    "@lezer/lr" "^1.0.0"
+
+"@lezer/javascript@^1.0.0":
+  version "1.4.3"
+  resolved "https://registry.yarnpkg.com/@lezer/javascript/-/javascript-1.4.3.tgz#f59e764a0578184c6fb86abb5279a9679777c3ba"
+  integrity sha512-k7Eo9z9B1supZ5cCD4ilQv/RZVN30eUQL+gGbr6ybrEY3avBAL5MDiYi2aa23Aj0A79ry4rJRvPAwE2TM8bd+A==
+  dependencies:
+    "@lezer/highlight" "^1.1.3"
+    "@lezer/lr" "^1.3.0"
+
+"@lezer/json@^1.0.0":
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/@lezer/json/-/json-1.0.0.tgz#848ad9c2c3e812518eb02897edd5a7f649e9c160"
+  integrity sha512-zbAuUY09RBzCoCA3lJ1+ypKw5WSNvLqGMtasdW6HvVOqZoCpPr8eWrsGnOVWGKGn8Rh21FnrKRVlJXrGAVUqRw==
+  dependencies:
+    "@lezer/highlight" "^1.0.0"
+    "@lezer/lr" "^1.0.0"
+
+"@lezer/lr@^1.0.0", "@lezer/lr@^1.1.0", "@lezer/lr@^1.3.0", "@lezer/lr@^1.3.1":
+  version "1.3.4"
+  resolved "https://registry.yarnpkg.com/@lezer/lr/-/lr-1.3.4.tgz#8795bf2ba4f69b998e8fb4b5a7c57ea68753474c"
+  integrity sha512-7o+e4og/QoC/6btozDPJqnzBhUaD1fMfmvnEKQO1wRRiTse1WxaJ3OMEXZJnkgT6HCcTVOctSoXK9jGJw2oe9g==
+  dependencies:
+    "@lezer/common" "^1.0.0"
+
+"@lezer/markdown@^1.0.0":
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/@lezer/markdown/-/markdown-1.0.2.tgz#8c804a9f6fe1ccca4a20acd2fd9fbe0fae1ae178"
+  integrity sha512-8CY0OoZ6V5EzPjSPeJ4KLVbtXdLBd8V6sRCooN5kHnO28ytreEGTyrtU/zUwo/XLRzGr/e1g44KlzKi3yWGB5A==
+  dependencies:
+    "@lezer/common" "^1.0.0"
+    "@lezer/highlight" "^1.0.0"
+
+"@lezer/php@^1.0.0":
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/@lezer/php/-/php-1.0.1.tgz#4496b58c980ca710c0433fd743d27e9964fd74ea"
+  integrity sha512-aqdCQJOXJ66De22vzdwnuC502hIaG9EnPK2rSi+ebXyUd+j7GAX1mRjWZOVOmf3GST1YUfUCu6WXDiEgDGOVwA==
+  dependencies:
+    "@lezer/highlight" "^1.0.0"
+    "@lezer/lr" "^1.1.0"
+
+"@lezer/python@^1.0.0":
+  version "1.1.4"
+  resolved "https://registry.yarnpkg.com/@lezer/python/-/python-1.1.4.tgz#6ef58ff965286150fea9f2db776944a1d69cd9b9"
+  integrity sha512-x82XgYxqqX0Yiw7uIemQJ3z2QyQme5BYpectkPfNg99OQrakqfwqVolqEVIrsj4QO9rVDLFZZ49J0Vbne7UbAA==
+  dependencies:
+    "@lezer/highlight" "^1.0.0"
+    "@lezer/lr" "^1.0.0"
+
+"@lezer/rust@^1.0.0":
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/@lezer/rust/-/rust-1.0.0.tgz#939f3e7b0376ebe13f4ac336ed7d59ca2c8adf52"
+  integrity sha512-IpGAxIjNxYmX9ra6GfQTSPegdCAWNeq23WNmrsMMQI7YNSvKtYxO4TX5rgZUmbhEucWn0KTBMeDEPXg99YKtTA==
+  dependencies:
+    "@lezer/highlight" "^1.0.0"
+    "@lezer/lr" "^1.0.0"
+
+"@lezer/sass@^1.0.0":
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/@lezer/sass/-/sass-1.0.1.tgz#c0ec3ece28b04e92437a75ac4a806367e5cb6fd4"
+  integrity sha512-S/aYAzABzMqWLfKKqV89pCWME4yjZYC6xzD02l44wbmb0sHxmN9/8aE4GULrKFzFaGazHdXcGEbPZ4zzB6yqwQ==
+  dependencies:
+    "@lezer/highlight" "^1.0.0"
+    "@lezer/lr" "^1.0.0"
+
+"@lezer/xml@^1.0.0":
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/@lezer/xml/-/xml-1.0.1.tgz#c4c738a407db610f0e9c59d0e9b16607cd029591"
+  integrity sha512-jMDXrV953sDAUEMI25VNrI9dz94Ai96FfeglytFINhhwQ867HKlCE2jt3AwZTCT7M528WxdDWv/Ty8e9wizwmQ==
+  dependencies:
+    "@lezer/highlight" "^1.0.0"
+    "@lezer/lr" "^1.0.0"
+
+"@lit-labs/ssr-dom-shim@^1.0.0":
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.0.0.tgz#427e19a2765681fd83411cd72c55ba80a01e0523"
+  integrity sha512-ic93MBXfApIFTrup4a70M/+ddD8xdt2zxxj9sRwHQzhS9ag/syqkD8JPdTXsc1gUy2K8TTirhlCqyTEM/sifNw==
+
+"@lit/reactive-element@^1.0.0", "@lit/reactive-element@^1.3.0", "@lit/reactive-element@^1.6.0":
+  version "1.6.1"
+  resolved "https://registry.yarnpkg.com/@lit/reactive-element/-/reactive-element-1.6.1.tgz#0d958b6d479d0e3db5fc1132ecc4fa84be3f0b93"
+  integrity sha512-va15kYZr7KZNNPZdxONGQzpUr+4sxVu7V/VG7a8mRfPPXUyhEYj5RzXCQmGrlP3tAh0L3HHm5AjBMFYRqlM9SA==
+  dependencies:
+    "@lit-labs/ssr-dom-shim" "^1.0.0"
+
+"@mdn/browser-compat-data@^4.0.0":
+  version "4.2.1"
+  resolved "https://registry.yarnpkg.com/@mdn/browser-compat-data/-/browser-compat-data-4.2.1.tgz#1fead437f3957ceebe2e8c3f46beccdb9bc575b8"
+  integrity sha512-EWUguj2kd7ldmrF9F+vI5hUOralPd+sdsUnYbRy33vZTuZkduC1shE9TtEMEjAQwyfyMb4ole5KtjF8MsnQOlA==
 
 "@nodelib/fs.scandir@2.1.5":
   version "2.1.5"
@@ -80,9 +481,9 @@
   integrity sha512-ukowSvzpZQDUH0Y3znJTsY88HkiGk3Khc0WGpIPhap1xlerieYi27QBg6wx/nTurpWfU6XXXsx9ocxDYCdtw0Q==
 
 "@open-wc/scoped-elements@^2.1.3":
-  version "2.1.3"
-  resolved "https://registry.yarnpkg.com/@open-wc/scoped-elements/-/scoped-elements-2.1.3.tgz#c4f06fa16091c6ebf2a69b3f40afc03821f42535"
-  integrity sha512-WoQD5T8Me9obek+iyjgrAMw9wxZZg4ytIteIN1i9LXW2KohezUp0LTOlWgBajWJo0/bpjUKiODX73cMYL2i3hw==
+  version "2.1.4"
+  resolved "https://registry.yarnpkg.com/@open-wc/scoped-elements/-/scoped-elements-2.1.4.tgz#8064abaa69bc2fb67695115c077aabedc9333b68"
+  integrity sha512-KX/bOkcDG9kbBDSmgsbpp40ZjEWxpWNrNRZZVSO0KqBygMfvfiEeVfP16uJp9YyWHi/PVZ/C0aUEgf8Pg1Eq7A==
   dependencies:
     "@lit/reactive-element" "^1.0.0"
     "@open-wc/dedupe-mixin" "^1.3.0"
@@ -100,24 +501,24 @@
     "@types/chai" "^4.3.1"
     "@web/test-runner-commands" "^0.6.1"
 
-"@open-wc/testing-helpers@^2.1.2":
-  version "2.1.3"
-  resolved "https://registry.yarnpkg.com/@open-wc/testing-helpers/-/testing-helpers-2.1.3.tgz#85a133ac8637ed1d880d523b07650788eab4a128"
-  integrity sha512-hQujGaWncmWLx/974jq5yf2jydBNNTwnkISw2wLGiYgX34+3R6/ns301Oi9S3Il96Kzd8B7avdExp/gDgqcF5w==
+"@open-wc/testing-helpers@^2.1.4":
+  version "2.1.4"
+  resolved "https://registry.yarnpkg.com/@open-wc/testing-helpers/-/testing-helpers-2.1.4.tgz#4b439442ecb1ea3fbcbb1ef76e8717574d78dc97"
+  integrity sha512-iZJxxKI9jRgnPczm8p2jpuvBZ3DHYSLrBmhDfzs7ol8vXMNt+HluzM1j1TSU95MFVGnfaspvvt9fMbXKA7cNcA==
   dependencies:
     "@open-wc/scoped-elements" "^2.1.3"
     lit "^2.0.0"
     lit-html "^2.0.0"
 
 "@open-wc/testing@^3.1.6":
-  version "3.1.6"
-  resolved "https://registry.yarnpkg.com/@open-wc/testing/-/testing-3.1.6.tgz#89f71710e5530d74f0c478b0a9239d68dcdb9f5e"
-  integrity sha512-MIf9cBtac4/UBE5a+R5cXiRhOGfzetsV+ZPFc188AfkPDPbmffHqjrRoCyk4B/qS6fLEulSBMLSaQ+6ze971gQ==
+  version "3.1.7"
+  resolved "https://registry.yarnpkg.com/@open-wc/testing/-/testing-3.1.7.tgz#65200c759626d510fda103c3cb4ede6202b1b88b"
+  integrity sha512-HCS2LuY6hXtEwjqmad+eanId5H7E+3mUi9Z3rjAhH+1DCJ53lUnjzWF1lbCYbREqrdCpmzZvW1t5R3e9gJZSCA==
   dependencies:
     "@esm-bundle/chai" "^4.3.4-fix.0"
     "@open-wc/chai-dom-equals" "^0.12.36"
     "@open-wc/semantic-dom-diff" "^0.19.7"
-    "@open-wc/testing-helpers" "^2.1.2"
+    "@open-wc/testing-helpers" "^2.1.4"
     "@types/chai" "^4.2.11"
     "@types/chai-dom" "^0.0.12"
     "@types/sinon-chai" "^3.2.3"
@@ -131,12 +532,75 @@
     "@polymer/polymer" "^3.0.5"
 
 "@polymer/polymer@^3.0.5", "@polymer/polymer@^3.4.1":
-  version "3.4.1"
-  resolved "https://registry.yarnpkg.com/@polymer/polymer/-/polymer-3.4.1.tgz#333bef25711f8411bb5624fb3eba8212ef8bee96"
-  integrity sha512-KPWnhDZibtqKrUz7enIPOiO4ZQoJNOuLwqrhV2MXzIt3VVnUVJVG5ORz4Z2sgO+UZ+/UZnPD0jqY+jmw/+a9mQ==
+  version "3.5.1"
+  resolved "https://registry.yarnpkg.com/@polymer/polymer/-/polymer-3.5.1.tgz#4b5234e43b8876441022bcb91313ab3c4a29f0c8"
+  integrity sha512-JlAHuy+1qIC6hL1ojEUfIVD58fzTpJAoCxFwV5yr0mYTXV1H8bz5zy0+rC963Cgr9iNXQ4T9ncSjC2fkF9BQfw==
   dependencies:
     "@webcomponents/shadycss" "^1.9.1"
 
+"@rollup/plugin-node-resolve@^13.0.4":
+  version "13.3.0"
+  resolved "https://registry.yarnpkg.com/@rollup/plugin-node-resolve/-/plugin-node-resolve-13.3.0.tgz#da1c5c5ce8316cef96a2f823d111c1e4e498801c"
+  integrity sha512-Lus8rbUo1eEcnS4yTFKLZrVumLPY+YayBdWXgFSHYhTT2iJbMhoaaBL3xl5NCdeRytErGr8tZ0L71BMRmnlwSw==
+  dependencies:
+    "@rollup/pluginutils" "^3.1.0"
+    "@types/resolve" "1.17.1"
+    deepmerge "^4.2.2"
+    is-builtin-module "^3.1.0"
+    is-module "^1.0.0"
+    resolve "^1.19.0"
+
+"@rollup/pluginutils@^3.1.0":
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/@rollup/pluginutils/-/pluginutils-3.1.0.tgz#706b4524ee6dc8b103b3c995533e5ad680c02b9b"
+  integrity sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg==
+  dependencies:
+    "@types/estree" "0.0.39"
+    estree-walker "^1.0.1"
+    picomatch "^2.2.2"
+
+"@sinonjs/commons@^1.6.0", "@sinonjs/commons@^1.7.0", "@sinonjs/commons@^1.8.3":
+  version "1.8.6"
+  resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-1.8.6.tgz#80c516a4dc264c2a69115e7578d62581ff455ed9"
+  integrity sha512-Ky+XkAkqPZSm3NLBeUng77EBQl3cmeJhITaGHdYH8kjVB+aun3S4XBRti2zt17mtt0mIUDiNxYeoJm6drVvBJQ==
+  dependencies:
+    type-detect "4.0.8"
+
+"@sinonjs/commons@^2.0.0":
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-2.0.0.tgz#fd4ca5b063554307e8327b4564bd56d3b73924a3"
+  integrity sha512-uLa0j859mMrg2slwQYdO/AkrOfmH+X6LTVmNTS9CqexuE2IvVORIkSpJLqePAbEnKJ77aMmCwr1NUZ57120Xcg==
+  dependencies:
+    type-detect "4.0.8"
+
+"@sinonjs/fake-timers@^10.0.2":
+  version "10.0.2"
+  resolved "https://registry.yarnpkg.com/@sinonjs/fake-timers/-/fake-timers-10.0.2.tgz#d10549ed1f423d80639c528b6c7f5a1017747d0c"
+  integrity sha512-SwUDyjWnah1AaNl7kxsa7cfLhlTYoiyhDAIgyh+El30YvXs/o7OLXpYH88Zdhyx9JExKrmHDJ+10bwIcY80Jmw==
+  dependencies:
+    "@sinonjs/commons" "^2.0.0"
+
+"@sinonjs/fake-timers@^9.1.2":
+  version "9.1.2"
+  resolved "https://registry.yarnpkg.com/@sinonjs/fake-timers/-/fake-timers-9.1.2.tgz#4eaab737fab77332ab132d396a3c0d364bd0ea8c"
+  integrity sha512-BPS4ynJW/o92PUR4wgriz2Ud5gpST5vz6GQfMixEDK0Z8ZCUv2M7SkBLykH56T++Xs+8ln9zTGbOvNGIe02/jw==
+  dependencies:
+    "@sinonjs/commons" "^1.7.0"
+
+"@sinonjs/samsam@^6.1.1":
+  version "6.1.3"
+  resolved "https://registry.yarnpkg.com/@sinonjs/samsam/-/samsam-6.1.3.tgz#4e30bcd4700336363302a7d72cbec9b9ab87b104"
+  integrity sha512-nhOb2dWPeb1sd3IQXL/dVPnKHDOAFfvichtBf4xV00/rU1QbPCQqKMbvIheIjqwVjh7qIgf2AHTHi391yMOMpQ==
+  dependencies:
+    "@sinonjs/commons" "^1.6.0"
+    lodash.get "^4.4.2"
+    type-detect "^4.0.8"
+
+"@sinonjs/text-encoding@^0.7.1":
+  version "0.7.2"
+  resolved "https://registry.yarnpkg.com/@sinonjs/text-encoding/-/text-encoding-0.7.2.tgz#5981a8db18b56ba38ef0efb7d995b12aa7b51918"
+  integrity sha512-sXXKG+uL9IrKqViTtao2Ws6dy0znu9sOaP1di/jKGW1M6VssO8vlpXCQcpZ+jisQ1tTFAC5Jo/EOzFbggBagFQ==
+
 "@types/accepts@*":
   version "1.3.5"
   resolved "https://registry.yarnpkg.com/@types/accepts/-/accepts-1.3.5.tgz#c34bec115cfc746e04fe5a059df4ce7e7b391575"
@@ -165,9 +629,9 @@
     "@types/chai" "*"
 
 "@types/chai@*", "@types/chai@^4.1.7", "@types/chai@^4.2.11", "@types/chai@^4.2.12", "@types/chai@^4.3.1":
-  version "4.3.3"
-  resolved "https://registry.yarnpkg.com/@types/chai/-/chai-4.3.3.tgz#3c90752792660c4b562ad73b3fbd68bf3bc7ae07"
-  integrity sha512-hC7OMnszpxhZPduX+m+nrx+uFoLkWOMiR4oa/AZF3MuSETYTZmFfJAHqZEM8MVlvfG7BEUcgvtwoCTxBp6hm3g==
+  version "4.3.4"
+  resolved "https://registry.yarnpkg.com/@types/chai/-/chai-4.3.4.tgz#e913e8175db8307d78b4e8fa690408ba6b65dee4"
+  integrity sha512-KnRanxnpfpjUTqTCXslZSEdLfXExwgNxYPdiO2WGUj8+HDjFi8R3k5RVKPeSCzLjCcshCAtVO2QBbVuAV4kTnw==
 
 "@types/co-body@^6.1.0":
   version "6.1.0"
@@ -177,6 +641,11 @@
     "@types/node" "*"
     "@types/qs" "*"
 
+"@types/command-line-args@^5.0.0":
+  version "5.2.0"
+  resolved "https://registry.yarnpkg.com/@types/command-line-args/-/command-line-args-5.2.0.tgz#adbb77980a1cc376bb208e3f4142e907410430f6"
+  integrity sha512-UuKzKpJJ/Ief6ufIaIzr3A/0XnluX7RvFgwkV89Yzvm77wCh1kFaFmqN8XEnGcN62EuHdedQjEMb8mYxFLGPyA==
+
 "@types/connect@*":
   version "3.4.35"
   resolved "https://registry.yarnpkg.com/@types/connect/-/connect-3.4.35.tgz#5fcf6ae445e4021d1fc2219a4873cc73a3bb2ad1"
@@ -209,22 +678,27 @@
   resolved "https://registry.yarnpkg.com/@types/debounce/-/debounce-1.2.1.tgz#79b65710bc8b6d44094d286aecf38e44f9627852"
   integrity sha512-epMsEE85fi4lfmJUH/89/iV/LI+F5CvNIvmgs5g5jYFPfhO2S/ae8WSsLOKWdwtoaZw9Q2IhJ4tQ5tFCcS/4HA==
 
-"@types/express-serve-static-core@^4.17.18":
-  version "4.17.31"
-  resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-4.17.31.tgz#a1139efeab4e7323834bb0226e62ac019f474b2f"
-  integrity sha512-DxMhY+NAsTwMMFHBTtJFNp5qiHKJ7TeqOo23zVEM9alT1Ml27Q3xcTH0xwxn7Q0BbMcVEJOs/7aQtUWupUQN3Q==
+"@types/estree@0.0.39":
+  version "0.0.39"
+  resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.39.tgz#e177e699ee1b8c22d23174caaa7422644389509f"
+  integrity sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==
+
+"@types/express-serve-static-core@^4.17.33":
+  version "4.17.33"
+  resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-4.17.33.tgz#de35d30a9d637dc1450ad18dd583d75d5733d543"
+  integrity sha512-TPBqmR/HRYI3eC2E5hmiivIzv+bidAfXofM+sbonAGvyDhySGw9/PQZFt2BLOrjUUR++4eJVpx6KnLQK1Fk9tA==
   dependencies:
     "@types/node" "*"
     "@types/qs" "*"
     "@types/range-parser" "*"
 
 "@types/express@*":
-  version "4.17.14"
-  resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.14.tgz#143ea0557249bc1b3b54f15db4c81c3d4eb3569c"
-  integrity sha512-TEbt+vaPFQ+xpxFLFssxUDXj5cWCxZJjIcB7Yg0k0GMHGtgtQgpvx/MUQUeAkNbA9AAGrwkAsoeItdTgS7FMyg==
+  version "4.17.17"
+  resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.17.tgz#01d5437f6ef9cfa8668e616e13c2f2ac9a491ae4"
+  integrity sha512-Q4FmmuLGBG58btUnfS1c1r/NQdlp3DMfGDGig8WhfpA2YRUtEkxAjkZb0yvplJGYdF1fsQ81iMDcH24sSCNC/Q==
   dependencies:
     "@types/body-parser" "*"
-    "@types/express-serve-static-core" "^4.17.18"
+    "@types/express-serve-static-core" "^4.17.33"
     "@types/qs" "*"
     "@types/serve-static" "*"
 
@@ -234,11 +708,11 @@
   integrity sha512-FyAOrDuQmBi8/or3ns4rwPno7/9tJTijVW6aQQjK02+kOQ8zmoNg2XJtAuQhvQcy1ASJq38wirX5//9J1EqoUA==
 
 "@types/http-errors@*":
-  version "1.8.2"
-  resolved "https://registry.yarnpkg.com/@types/http-errors/-/http-errors-1.8.2.tgz#7315b4c4c54f82d13fa61c228ec5c2ea5cc9e0e1"
-  integrity sha512-EqX+YQxINb+MeXaIqYDASb6U6FCHbWjkj4a1CKDBks3d/QiB2+PqBLyO72vLDgAO1wUI4O+9gweRcQK11bTL/w==
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/@types/http-errors/-/http-errors-2.0.1.tgz#20172f9578b225f6c7da63446f56d4ce108d5a65"
+  integrity sha512-/K3ds8TRAfBvi5vfjuz8y6+GiAYBZ0x4tXv1Av6CWBWn0IlADc+ZX9pMq7oU0fNQPnBwIZl3rmeLp6SBApbxSQ==
 
-"@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.3":
+"@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.1", "@types/istanbul-lib-coverage@^2.0.3":
   version "2.0.4"
   resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.4.tgz#8467d4b3c087805d63580480890791277ce35c44"
   integrity sha512-z/QT1XN4K4KYuslS23k62yDIDLwLFkzxOuMplDtObz0+y7VqJCaO2o+SPwHCvLFZh7xazvvoor2tA/hPz9ee7g==
@@ -288,10 +762,15 @@
   resolved "https://registry.yarnpkg.com/@types/mime/-/mime-3.0.1.tgz#5f8f2bca0a5863cb69bc0b0acd88c96cb1d4ae10"
   integrity sha512-Y4XFY5VJAuw0FgAqPNd6NNoV44jbq9Bz2L7Rh/J6jLTiHBSBJa9fxqQIvkIld4GsoDOcCbvzOUAbLPsSKKg+uA==
 
+"@types/mocha@^8.2.0":
+  version "8.2.3"
+  resolved "https://registry.yarnpkg.com/@types/mocha/-/mocha-8.2.3.tgz#bbeb55fbc73f28ea6de601fbfa4613f58d785323"
+  integrity sha512-ekGvFhFgrc2zYQoX4JeZPmVzZxw6Dtllga7iGHzfbYIYkAMUx/sAFP2GdFpLff+vdHXu5fl7WX9AT+TtqYcsyw==
+
 "@types/node@*":
-  version "18.7.18"
-  resolved "https://registry.yarnpkg.com/@types/node/-/node-18.7.18.tgz#633184f55c322e4fb08612307c274ee6d5ed3154"
-  integrity sha512-m+6nTEOadJZuTPkKR/SYK3A2d7FZrgElol9UP1Kae90VVU4a6mxnPuLiIW1m4Cq4gZ/nWb9GrdVXJCoCazDAbg==
+  version "18.14.2"
+  resolved "https://registry.yarnpkg.com/@types/node/-/node-18.14.2.tgz#c076ed1d7b6095078ad3cf21dfeea951842778b1"
+  integrity sha512-1uEQxww3DaghA0RxqHx0O0ppVlo43pJhepY51OxuQIKHpjbnYLA7vcdwioNPzIqmC2u3I/dmylcqjlh0e7AyUA==
 
 "@types/parse5@^6.0.1":
   version "6.0.3"
@@ -308,18 +787,25 @@
   resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.4.tgz#cd667bcfdd025213aafb7ca5915a932590acdcdc"
   integrity sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==
 
+"@types/resolve@1.17.1":
+  version "1.17.1"
+  resolved "https://registry.yarnpkg.com/@types/resolve/-/resolve-1.17.1.tgz#3afd6ad8967c77e4376c598a82ddd58f46ec45d6"
+  integrity sha512-yy7HuzQhj0dhGpD8RLXSZWEkLsV9ibvxvi6EiJ3bkqLAO1RGo0WbkWQiwpRlSFymTJRz0d3k5LM3kkx8ArDbLw==
+  dependencies:
+    "@types/node" "*"
+
 "@types/serve-static@*":
-  version "1.15.0"
-  resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-1.15.0.tgz#c7930ff61afb334e121a9da780aac0d9b8f34155"
-  integrity sha512-z5xyF6uh8CbjAu9760KDKsH2FcDxZ2tFCsA4HIMWE6IkiYMXfVoa+4f9KX+FN0ZLsaMw1WNG2ETLA6N+/YA+cg==
+  version "1.15.1"
+  resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-1.15.1.tgz#86b1753f0be4f9a1bee68d459fcda5be4ea52b5d"
+  integrity sha512-NUo5XNiAdULrJENtJXZZ3fHtfMolzZwczzBbnAeBbqBwG+LaG6YaJtuwzwGSQZ2wsCrxjEhNNjAkKigy3n8teQ==
   dependencies:
     "@types/mime" "*"
     "@types/node" "*"
 
 "@types/sinon-chai@^3.2.3":
-  version "3.2.8"
-  resolved "https://registry.yarnpkg.com/@types/sinon-chai/-/sinon-chai-3.2.8.tgz#5871d09ab50d671d8e6dd72e9073f8e738ac61dc"
-  integrity sha512-d4ImIQbT/rKMG8+AXpmcan5T2/PNeSjrYhvkwet6z0p8kzYtfgA32xzOBlbU0yqJfq+/0Ml805iFoODO0LP5/g==
+  version "3.2.9"
+  resolved "https://registry.yarnpkg.com/@types/sinon-chai/-/sinon-chai-3.2.9.tgz#71feb938574bbadcb176c68e5ff1a6014c5e69d4"
+  integrity sha512-/19t63pFYU0ikrdbXKBWj9PCdnKyTd0Qkz0X91Ta081cYsq90OxYdcWwK/dwEoDa6dtXgj2HJfmzgq+QZTHdmQ==
   dependencies:
     "@types/chai" "*"
     "@types/sinon" "*"
@@ -337,9 +823,9 @@
   integrity sha512-9GcLXF0/v3t80caGs5p2rRfkB+a8VBGLJZVih6CNFkx8IZ994wiKKLSRs9nuFwk1HevWs/1mnUmkApGrSGsShA==
 
 "@types/trusted-types@^2.0.2":
-  version "2.0.2"
-  resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-2.0.2.tgz#fc25ad9943bcac11cceb8168db4f275e0e72e756"
-  integrity sha512-F5DIZ36YVLE+PN+Zwws4kJogq47hNgX3Nx6WyDJ3kcplxyke3XIzB8uK5n/Lpm1HBsbGzd6nmGehL8cPekP+Tg==
+  version "2.0.3"
+  resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-2.0.3.tgz#a136f83b0758698df454e328759dbd3d44555311"
+  integrity sha512-NfQ4gyz38SL8sDNrSixxU2Os1a5xcdFxipAFxYEuLUlvU2uDwS4NUpsImcf1//SlWItCVMMLiylsxbmNMToV/g==
 
 "@types/ws@^7.4.0":
   version "7.4.7"
@@ -348,14 +834,28 @@
   dependencies:
     "@types/node" "*"
 
-"@web/browser-logs@^0.2.1":
+"@types/yauzl@^2.9.1":
+  version "2.10.0"
+  resolved "https://registry.yarnpkg.com/@types/yauzl/-/yauzl-2.10.0.tgz#b3248295276cf8c6f153ebe6a9aba0c988cb2599"
+  integrity sha512-Cn6WYCm0tXv8p6k+A8PvbDG763EDpBoTzHdA+Q/MF6H3sapGjCm9NzoaJncJS9tUKSuCoDs9XHxYYsQDgxR6kw==
+  dependencies:
+    "@types/node" "*"
+
+"@web/browser-logs@^0.2.1", "@web/browser-logs@^0.2.2":
   version "0.2.5"
   resolved "https://registry.yarnpkg.com/@web/browser-logs/-/browser-logs-0.2.5.tgz#0895efb641eacb0fbc1138c6092bd18c01df2734"
   integrity sha512-Qxo1wY/L7yILQqg0jjAaueh+tzdORXnZtxQgWH23SsTCunz9iq9FvsZa8Q5XlpjnZ3vLIsFEuEsCMqFeohJnEg==
   dependencies:
     errorstacks "^2.2.0"
 
-"@web/dev-server-core@^0.3.18":
+"@web/config-loader@^0.1.3":
+  version "0.1.3"
+  resolved "https://registry.yarnpkg.com/@web/config-loader/-/config-loader-0.1.3.tgz#8325ea54f75ef2ee7166783e64e66936db25bff7"
+  integrity sha512-XVKH79pk4d3EHRhofete8eAnqto1e8mCRAqPV00KLNFzCWSe8sWmLnqKCqkPNARC6nksMaGrATnA5sPDRllMpQ==
+  dependencies:
+    semver "^7.3.4"
+
+"@web/dev-server-core@^0.3.18", "@web/dev-server-core@^0.3.19":
   version "0.3.19"
   resolved "https://registry.yarnpkg.com/@web/dev-server-core/-/dev-server-core-0.3.19.tgz#b61f9a0b92351371347a758b30ba19e683c72e94"
   integrity sha512-Q/Xt4RMVebLWvALofz1C0KvP8qHbzU1EmdIA2Y1WMPJwiFJFhPxdr75p9YxK32P2t0hGs6aqqS5zE0HW9wYzYA==
@@ -379,6 +879,49 @@
     picomatch "^2.2.2"
     ws "^7.4.2"
 
+"@web/dev-server-esbuild@^0.3.2":
+  version "0.3.3"
+  resolved "https://registry.yarnpkg.com/@web/dev-server-esbuild/-/dev-server-esbuild-0.3.3.tgz#e82af2e5acec0e645b920400be9601601b3921c5"
+  integrity sha512-hB9C8X9NsFWUG2XKT3W+Xcw3IZ/VObf4LNbK14BTjApnNyZfV6hVhSlJfvhgOoJ4DxsImfhIB5+gMRKOG9NmBw==
+  dependencies:
+    "@mdn/browser-compat-data" "^4.0.0"
+    "@web/dev-server-core" "^0.3.19"
+    esbuild "^0.12 || ^0.13 || ^0.14"
+    parse5 "^6.0.1"
+    ua-parser-js "^1.0.2"
+
+"@web/dev-server-rollup@^0.3.19":
+  version "0.3.21"
+  resolved "https://registry.yarnpkg.com/@web/dev-server-rollup/-/dev-server-rollup-0.3.21.tgz#edeecc599970fcc03f6a53fd7c5fdaf01178e88a"
+  integrity sha512-138t+vMFkegRip6Rtlz68Bo5rl984C9c2rLg3dWl9JEEJSQcWgA3iEwXYh4xTc52WjXnM3/LpboAjTYQOMyfrA==
+  dependencies:
+    "@rollup/plugin-node-resolve" "^13.0.4"
+    "@web/dev-server-core" "^0.3.19"
+    nanocolors "^0.2.1"
+    parse5 "^6.0.1"
+    rollup "^2.67.0"
+    whatwg-url "^11.0.0"
+
+"@web/dev-server@^0.1.35":
+  version "0.1.35"
+  resolved "https://registry.yarnpkg.com/@web/dev-server/-/dev-server-0.1.35.tgz#d845822d7c3c7749adf03f7abac4a69e2a4490cc"
+  integrity sha512-E7TSTSFdGPzhkiE3kIVt8i49gsiAYpJIZHzs1vJmVfdt8U4rsmhE+5roezxZo0hkEw4mNsqj9zCc4Dzqy/IFHg==
+  dependencies:
+    "@babel/code-frame" "^7.12.11"
+    "@types/command-line-args" "^5.0.0"
+    "@web/config-loader" "^0.1.3"
+    "@web/dev-server-core" "^0.3.19"
+    "@web/dev-server-rollup" "^0.3.19"
+    camelcase "^6.2.0"
+    command-line-args "^5.1.1"
+    command-line-usage "^6.1.1"
+    debounce "^1.2.0"
+    deepmerge "^4.2.2"
+    ip "^1.1.5"
+    nanocolors "^0.2.1"
+    open "^8.0.2"
+    portfinder "^1.0.32"
+
 "@web/parse5-utils@^1.2.0":
   version "1.3.0"
   resolved "https://registry.yarnpkg.com/@web/parse5-utils/-/parse5-utils-1.3.0.tgz#e2e9e98b31a4ca948309f74891bda8d77399f6bd"
@@ -387,7 +930,17 @@
     "@types/parse5" "^6.0.1"
     parse5 "^6.0.1"
 
-"@web/test-runner-commands@^0.6.1":
+"@web/test-runner-chrome@^0.10.7":
+  version "0.10.7"
+  resolved "https://registry.yarnpkg.com/@web/test-runner-chrome/-/test-runner-chrome-0.10.7.tgz#2dc35da47aa8b98c59f9e229a70ea3f443303e0c"
+  integrity sha512-DKJVHhHh3e/b6/erfKOy0a4kGfZ47qMoQRgROxi9T4F9lavEY3E5/MQ7hapHFM2lBF4vDrm+EWjtBdOL8o42tw==
+  dependencies:
+    "@web/test-runner-core" "^0.10.20"
+    "@web/test-runner-coverage-v8" "^0.4.8"
+    chrome-launcher "^0.15.0"
+    puppeteer-core "^13.1.3"
+
+"@web/test-runner-commands@^0.6.1", "@web/test-runner-commands@^0.6.3":
   version "0.6.5"
   resolved "https://registry.yarnpkg.com/@web/test-runner-commands/-/test-runner-commands-0.6.5.tgz#69a2a06b52fd9d329f9cf1e172cd8fb1d5ffc521"
   integrity sha512-W+wLg10jEAJY9N6tNWqG1daKmAzxGmTbO/H9fFfcgOgdxdn+hHiR4r2/x1iylKbFLujHUQlnjNQeu2d6eDPFqg==
@@ -395,7 +948,7 @@
     "@web/test-runner-core" "^0.10.27"
     mkdirp "^1.0.4"
 
-"@web/test-runner-core@^0.10.27":
+"@web/test-runner-core@^0.10.20", "@web/test-runner-core@^0.10.27":
   version "0.10.27"
   resolved "https://registry.yarnpkg.com/@web/test-runner-core/-/test-runner-core-0.10.27.tgz#8d1430f2364fb36b3ac15b9b43034fae9d94e177"
   integrity sha512-ClV/hSxs4wDm/ANFfQOdRRFb/c0sYywC1QfUXG/nS4vTp3nnt7x7mjydtMGGLmvK9f6Zkubkc1aa+7ryfmVwNA==
@@ -427,10 +980,50 @@
     picomatch "^2.2.2"
     source-map "^0.7.3"
 
+"@web/test-runner-coverage-v8@^0.4.8":
+  version "0.4.9"
+  resolved "https://registry.yarnpkg.com/@web/test-runner-coverage-v8/-/test-runner-coverage-v8-0.4.9.tgz#334d80cd19fc68c08ec3339b1b1d2725078b51a2"
+  integrity sha512-y9LWL4uY25+fKQTljwr0XTYjeWIwU4h8eYidVuLoW3n1CdFkaddv+smrGzzF5j8XY+Mp6TmV9NdxjvMWqVkDdw==
+  dependencies:
+    "@web/test-runner-core" "^0.10.20"
+    istanbul-lib-coverage "^3.0.0"
+    picomatch "^2.2.2"
+    v8-to-istanbul "^8.0.0"
+
+"@web/test-runner-mocha@^0.7.5":
+  version "0.7.5"
+  resolved "https://registry.yarnpkg.com/@web/test-runner-mocha/-/test-runner-mocha-0.7.5.tgz#696f8cb7f5118a72bd7ac5778367ae3bd3fb92cd"
+  integrity sha512-12/OBq6efPCAvJpcz3XJs2OO5nHe7GtBibZ8Il1a0QtsGpRmuJ4/m1EF0Fj9f6KHg7JdpGo18A37oE+5hXjHwg==
+  dependencies:
+    "@types/mocha" "^8.2.0"
+    "@web/test-runner-core" "^0.10.20"
+
+"@web/test-runner@^0.14.0":
+  version "0.14.1"
+  resolved "https://registry.yarnpkg.com/@web/test-runner/-/test-runner-0.14.1.tgz#a637e45c9b6ce7860ab780b5ac82dbfa1ed824f9"
+  integrity sha512-S2/Xp/bZBJdbWeTQxhs45cO9Khwqx99X+rrx8l0uDR0Ju/+kX+yC3RpjnOY1ooKD3rjkoEAE82soZTZNz+aKIg==
+  dependencies:
+    "@web/browser-logs" "^0.2.2"
+    "@web/config-loader" "^0.1.3"
+    "@web/dev-server" "^0.1.35"
+    "@web/test-runner-chrome" "^0.10.7"
+    "@web/test-runner-commands" "^0.6.3"
+    "@web/test-runner-core" "^0.10.27"
+    "@web/test-runner-mocha" "^0.7.5"
+    camelcase "^6.2.0"
+    command-line-args "^5.1.1"
+    command-line-usage "^6.1.1"
+    convert-source-map "^1.7.0"
+    diff "^5.0.0"
+    globby "^11.0.1"
+    nanocolors "^0.2.1"
+    portfinder "^1.0.32"
+    source-map "^0.7.3"
+
 "@webcomponents/shadycss@^1.9.1":
-  version "1.11.0"
-  resolved "https://registry.yarnpkg.com/@webcomponents/shadycss/-/shadycss-1.11.0.tgz#73e289996c002d8be694cd3be0e83c46ad25e7e0"
-  integrity sha512-L5O/+UPum8erOleNjKq6k58GVl3fNsEQdSOyh0EUhNmi7tHUyRuCJy1uqJiWydWcLARE5IPsMoPYMZmUGrz1JA==
+  version "1.11.1"
+  resolved "https://registry.yarnpkg.com/@webcomponents/shadycss/-/shadycss-1.11.1.tgz#add19d5e0db4a014e143d2278921347dcd8f0a55"
+  integrity sha512-qSok/oMynEgS99wFY5fKT6cR1y64i01RkHGYOspkh2JQsLSM8pjciER+gu3fqTx589y/7LoSuyB5G9Rh7dyXaQ==
 
 accepts@^1.3.5:
   version "1.3.8"
@@ -440,6 +1033,13 @@
     mime-types "~2.1.34"
     negotiator "0.6.3"
 
+agent-base@6:
+  version "6.0.2"
+  resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-6.0.2.tgz#49fff58577cfee3f37176feab4c22e00f86d7f77"
+  integrity sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==
+  dependencies:
+    debug "4"
+
 ansi-escapes@^4.3.0:
   version "4.3.2"
   resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-4.3.2.tgz#6b2291d1db7d98b6521d5f1efa42d0f3a9feb65e"
@@ -467,13 +1067,23 @@
     color-convert "^2.0.1"
 
 anymatch@~3.1.2:
-  version "3.1.2"
-  resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.2.tgz#c0557c096af32f106198f4f4e2a383537e378716"
-  integrity sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg==
+  version "3.1.3"
+  resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.3.tgz#790c58b19ba1720a84205b57c618d5ad8524973e"
+  integrity sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==
   dependencies:
     normalize-path "^3.0.0"
     picomatch "^2.0.4"
 
+array-back@^3.0.1, array-back@^3.1.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/array-back/-/array-back-3.1.0.tgz#b8859d7a508871c9a7b2cf42f99428f65e96bfb0"
+  integrity sha512-TkuxA4UCOvxuDK6NZYXCalszEzj+TLszyASooky+i742l9TqsOdYCMJJupxRic61hwquNtppB3hgcuq9SVSH1Q==
+
+array-back@^4.0.1, array-back@^4.0.2:
+  version "4.0.2"
+  resolved "https://registry.yarnpkg.com/array-back/-/array-back-4.0.2.tgz#8004e999a6274586beeb27342168652fdb89fa1e"
+  integrity sha512-NbdMezxqf94cnNfWLL7V/im0Ub+Anbb0IoZhvzie8+4HJ4nMQuzHuy49FkGYCJK2yAloZ3meiB6AVMClbrI1vg==
+
 array-union@^2.1.0:
   version "2.1.0"
   resolved "https://registry.yarnpkg.com/array-union/-/array-union-2.1.0.tgz#b798420adbeb1de828d84acd8a2e23d3efe85e8d"
@@ -484,16 +1094,50 @@
   resolved "https://registry.yarnpkg.com/astral-regex/-/astral-regex-2.0.0.tgz#483143c567aeed4785759c0865786dc77d7d2e31"
   integrity sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==
 
+async@^2.6.4:
+  version "2.6.4"
+  resolved "https://registry.yarnpkg.com/async/-/async-2.6.4.tgz#706b7ff6084664cd7eae713f6f965433b5504221"
+  integrity sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==
+  dependencies:
+    lodash "^4.17.14"
+
 axe-core@^4.3.3:
-  version "4.4.3"
-  resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.4.3.tgz#11c74d23d5013c0fa5d183796729bc3482bd2f6f"
-  integrity sha512-32+ub6kkdhhWick/UjvEwRchgoetXqTK14INLqbGm5U2TzBkBNF3nQtLYm8ovxSkQWArjEQvftCKryjZaATu3w==
+  version "4.6.3"
+  resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.6.3.tgz#fc0db6fdb65cc7a80ccf85286d91d64ababa3ece"
+  integrity sha512-/BQzOX780JhsxDnPpH4ZiyrJAzcd8AfzFPkv+89veFSr1rcMjuq2JDCwypKaPeB6ljHp9KjXhPpjgCvQlWYuqg==
+
+balanced-match@^1.0.0:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee"
+  integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==
+
+base64-js@^1.3.1:
+  version "1.5.1"
+  resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a"
+  integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
 
 binary-extensions@^2.0.0:
   version "2.2.0"
   resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d"
   integrity sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==
 
+bl@^4.0.3:
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/bl/-/bl-4.1.0.tgz#451535264182bec2fbbc83a62ab98cf11d9f7b3a"
+  integrity sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==
+  dependencies:
+    buffer "^5.5.0"
+    inherits "^2.0.4"
+    readable-stream "^3.4.0"
+
+brace-expansion@^1.1.7:
+  version "1.1.11"
+  resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd"
+  integrity sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==
+  dependencies:
+    balanced-match "^1.0.0"
+    concat-map "0.0.1"
+
 braces@^3.0.2, braces@~3.0.2:
   version "3.0.2"
   resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107"
@@ -501,6 +1145,24 @@
   dependencies:
     fill-range "^7.0.1"
 
+buffer-crc32@~0.2.3:
+  version "0.2.13"
+  resolved "https://registry.yarnpkg.com/buffer-crc32/-/buffer-crc32-0.2.13.tgz#0d333e3f00eac50aa1454abd30ef8c2a5d9a7242"
+  integrity sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==
+
+buffer@^5.2.1, buffer@^5.5.0:
+  version "5.7.1"
+  resolved "https://registry.yarnpkg.com/buffer/-/buffer-5.7.1.tgz#ba62e7c13133053582197160851a8f648e99eed0"
+  integrity sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==
+  dependencies:
+    base64-js "^1.3.1"
+    ieee754 "^1.1.13"
+
+builtin-modules@^3.3.0:
+  version "3.3.0"
+  resolved "https://registry.yarnpkg.com/builtin-modules/-/builtin-modules-3.3.0.tgz#cae62812b89801e9656336e46223e030386be7b6"
+  integrity sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==
+
 bytes@3.1.2:
   version "3.1.2"
   resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5"
@@ -522,6 +1184,11 @@
     function-bind "^1.1.1"
     get-intrinsic "^1.0.2"
 
+camelcase@^6.2.0:
+  version "6.3.0"
+  resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.3.0.tgz#5685b95eb209ac9c0c177467778c9c84df58ba9a"
+  integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==
+
 chai-a11y-axe@^1.3.2:
   version "1.4.0"
   resolved "https://registry.yarnpkg.com/chai-a11y-axe/-/chai-a11y-axe-1.4.0.tgz#e584af967727a8656e27c32e845f5db21f2bf2e0"
@@ -529,7 +1196,7 @@
   dependencies:
     axe-core "^4.3.3"
 
-chalk@^2.0.0:
+chalk@^2.0.0, chalk@^2.4.2:
   version "2.4.2"
   resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424"
   integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==
@@ -553,6 +1220,21 @@
   optionalDependencies:
     fsevents "~2.3.2"
 
+chownr@^1.1.1:
+  version "1.1.4"
+  resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.1.4.tgz#6fc9d7b42d32a583596337666e7d08084da2cc6b"
+  integrity sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==
+
+chrome-launcher@^0.15.0:
+  version "0.15.1"
+  resolved "https://registry.yarnpkg.com/chrome-launcher/-/chrome-launcher-0.15.1.tgz#0a0208037063641e2b3613b7e42b0fcb3fa2d399"
+  integrity sha512-UugC8u59/w2AyX5sHLZUHoxBAiSiunUhZa3zZwMH6zPVis0C3dDKiRWyUGIo14tTbZHGVviWxv3PQWZ7taZ4fg==
+  dependencies:
+    "@types/node" "*"
+    escape-string-regexp "^4.0.0"
+    is-wsl "^2.2.0"
+    lighthouse-logger "^1.0.0"
+
 cli-cursor@^3.1.0:
   version "3.1.0"
   resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-3.1.0.tgz#264305a7ae490d1d03bf0c9ba7c925d1753af307"
@@ -604,6 +1286,31 @@
   resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2"
   integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==
 
+command-line-args@^5.1.1:
+  version "5.2.1"
+  resolved "https://registry.yarnpkg.com/command-line-args/-/command-line-args-5.2.1.tgz#c44c32e437a57d7c51157696893c5909e9cec42e"
+  integrity sha512-H4UfQhZyakIjC74I9d34fGYDwk3XpSr17QhEd0Q3I9Xq1CETHo4Hcuo87WyWHpAF1aSLjLRf5lD9ZGX2qStUvg==
+  dependencies:
+    array-back "^3.1.0"
+    find-replace "^3.0.0"
+    lodash.camelcase "^4.3.0"
+    typical "^4.0.0"
+
+command-line-usage@^6.1.1:
+  version "6.1.3"
+  resolved "https://registry.yarnpkg.com/command-line-usage/-/command-line-usage-6.1.3.tgz#428fa5acde6a838779dfa30e44686f4b6761d957"
+  integrity sha512-sH5ZSPr+7UStsloltmDh7Ce5fb8XPlHyoPzTpyyMuYCtervL65+ubVZ6Q61cFtFl62UyJlc8/JwERRbAFPUqgw==
+  dependencies:
+    array-back "^4.0.2"
+    chalk "^2.4.2"
+    table-layout "^1.0.2"
+    typical "^5.2.0"
+
+concat-map@0.0.1:
+  version "0.0.1"
+  resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b"
+  integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==
+
 content-disposition@~0.5.2:
   version "0.5.4"
   resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.4.tgz#8b82b4efac82512a02bb0b1dcec9d2c5e8eb5bfe"
@@ -612,16 +1319,14 @@
     safe-buffer "5.2.1"
 
 content-type@^1.0.4:
-  version "1.0.4"
-  resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.4.tgz#e138cc75e040c727b1966fe5e5f8c9aee256fe3b"
-  integrity sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==
+  version "1.0.5"
+  resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.5.tgz#8b773162656d1d1086784c8f23a54ce6d73d7918"
+  integrity sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==
 
-convert-source-map@^1.7.0:
-  version "1.8.0"
-  resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.8.0.tgz#f3373c32d21b4d780dd8004514684fb791ca4369"
-  integrity sha512-+OQdjP49zViI/6i7nIJpA8rAl4sV/JdPfU9nZs3VqOwGIgizICvuN2ru6fMd+4llL0tar18UYJXfZ/TWtmhUjA==
-  dependencies:
-    safe-buffer "~5.1.1"
+convert-source-map@^1.6.0, convert-source-map@^1.7.0:
+  version "1.9.0"
+  resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.9.0.tgz#7faae62353fb4213366d0ca98358d22e8368b05f"
+  integrity sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==
 
 cookies@~0.8.0:
   version "0.8.0"
@@ -631,30 +1336,59 @@
     depd "~2.0.0"
     keygrip "~1.1.0"
 
+crelt@^1.0.5:
+  version "1.0.5"
+  resolved "https://registry.yarnpkg.com/crelt/-/crelt-1.0.5.tgz#57c0d52af8c859e354bace1883eb2e1eb182bb94"
+  integrity sha512-+BO9wPPi+DWTDcNYhr/W90myha8ptzftZT+LwcmUbbok0rcP/fequmFYCw8NMoH7pkAZQzU78b3kYrlua5a9eA==
+
+cross-fetch@3.1.5:
+  version "3.1.5"
+  resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-3.1.5.tgz#e1389f44d9e7ba767907f7af8454787952ab534f"
+  integrity sha512-lvb1SBsI0Z7GDwmuid+mU3kWVBwTVUbe7S0H52yaaAdQOXq2YktTCZdlAcNKFzE6QtRz0snpw9bNiPeOIkkQvw==
+  dependencies:
+    node-fetch "2.6.7"
+
 debounce@^1.2.0:
   version "1.2.1"
   resolved "https://registry.yarnpkg.com/debounce/-/debounce-1.2.1.tgz#38881d8f4166a5c5848020c11827b834bcb3e0a5"
   integrity sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==
 
-debug@^3.1.0:
-  version "3.2.7"
-  resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.7.tgz#72580b7e9145fb39b6676f9c5e5fb100b934179a"
-  integrity sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==
-  dependencies:
-    ms "^2.1.1"
-
-debug@^4.1.1, debug@^4.3.2:
+debug@4, debug@4.3.4, debug@^4.1.1, debug@^4.3.2:
   version "4.3.4"
   resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865"
   integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==
   dependencies:
     ms "2.1.2"
 
+debug@^2.6.9:
+  version "2.6.9"
+  resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f"
+  integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==
+  dependencies:
+    ms "2.0.0"
+
+debug@^3.1.0, debug@^3.2.7:
+  version "3.2.7"
+  resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.7.tgz#72580b7e9145fb39b6676f9c5e5fb100b934179a"
+  integrity sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==
+  dependencies:
+    ms "^2.1.1"
+
 deep-equal@~1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-1.0.1.tgz#f5d260292b660e084eff4cdbc9f08ad3247448b5"
   integrity sha512-bHtC0iYvWhyaTzvV3CZgPeZQqCOBGyGsVV7v4eevpdkLHfiSrXUdBG+qAuSz4RI70sszvjQ1QSZ98An1yNwpSw==
 
+deep-extend@~0.6.0:
+  version "0.6.0"
+  resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.6.0.tgz#c4fa7c95404a17a9c3e8ca7e1537312b736330ac"
+  integrity sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==
+
+deepmerge@^4.2.2:
+  version "4.3.0"
+  resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.3.0.tgz#65491893ec47756d44719ae520e0e2609233b59b"
+  integrity sha512-z2wJZXrmeHdvYJp/Ux55wIjqo81G5Bp4c+oELTW+7ar6SogWHajt5a9gO3s3IDaGSAXjDk0vlQKN3rms8ab3og==
+
 define-lazy-prop@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz#3f7ae421129bcaaac9bc74905c98a0009ec9ee7f"
@@ -685,6 +1419,16 @@
   resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.2.0.tgz#4803735509ad8be552934c67df614f94e66fa015"
   integrity sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==
 
+devtools-protocol@0.0.981744:
+  version "0.0.981744"
+  resolved "https://registry.yarnpkg.com/devtools-protocol/-/devtools-protocol-0.0.981744.tgz#9960da0370284577d46c28979a0b32651022bacf"
+  integrity sha512-0cuGS8+jhR67Fy7qG3i3Pc7Aw494sb9yG9QgpG97SFVWwolgYjlhJg7n+UaHxOQT30d1TYu/EYe9k01ivLErIg==
+
+diff@^5.0.0:
+  version "5.1.0"
+  resolved "https://registry.yarnpkg.com/diff/-/diff-5.1.0.tgz#bc52d298c5ea8df9194800224445ed43ffc87e40"
+  integrity sha512-D+mk+qE8VC/PAUrlAU34N+VfXev0ghe5ywmpqrawphmVZc1bEfn56uo9qpyGp1p4xpzOHkSW4ztBd6L7Xx4ACw==
+
 dir-glob@^3.0.1:
   version "3.0.1"
   resolved "https://registry.yarnpkg.com/dir-glob/-/dir-glob-3.0.1.tgz#56dbf73d992a4a93ba1584f4534063fd2e41717f"
@@ -707,15 +1451,149 @@
   resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59"
   integrity sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==
 
+end-of-stream@^1.1.0, end-of-stream@^1.4.1:
+  version "1.4.4"
+  resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.4.tgz#5ae64a5f45057baf3626ec14da0ca5e4b2431eb0"
+  integrity sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==
+  dependencies:
+    once "^1.4.0"
+
 errorstacks@^2.2.0:
   version "2.4.0"
   resolved "https://registry.yarnpkg.com/errorstacks/-/errorstacks-2.4.0.tgz#2155674dd9e741aacda3f3b8b967d9c40a4a0baf"
   integrity sha512-5ecWhU5gt0a5G05nmQcgCxP5HperSMxLDzvWlT5U+ZSKkuDK0rJ3dbCQny6/vSCIXjwrhwSecXBbw1alr295hQ==
 
 es-module-lexer@^1.0.0:
-  version "1.0.3"
-  resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-1.0.3.tgz#f0d8d35b36d13024110000d5e6fadc8eeaeb66b8"
-  integrity sha512-iC67eXHToclrlVhQfpRawDiF8D8sQxNxmbqw5oebegOaJkyx/w9C/k57/5e6yJR2zIByRt9OXdqX50DV2t6ZKw==
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-1.2.0.tgz#812264973b613195ba214f69a84e05b0f4241a67"
+  integrity sha512-2BMfqBDeVCcOlLaL1ZAfp+D868SczNpKArrTM3dhpd7dK/OVlogzY15qpUngt+LMTq5UC/csb9vVQAgupucSbA==
+
+esbuild-android-64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-android-64/-/esbuild-android-64-0.14.54.tgz#505f41832884313bbaffb27704b8bcaa2d8616be"
+  integrity sha512-Tz2++Aqqz0rJ7kYBfz+iqyE3QMycD4vk7LBRyWaAVFgFtQ/O8EJOnVmTOiDWYZ/uYzB4kvP+bqejYdVKzE5lAQ==
+
+esbuild-android-arm64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-android-arm64/-/esbuild-android-arm64-0.14.54.tgz#8ce69d7caba49646e009968fe5754a21a9871771"
+  integrity sha512-F9E+/QDi9sSkLaClO8SOV6etqPd+5DgJje1F9lOWoNncDdOBL2YF59IhsWATSt0TLZbYCf3pNlTHvVV5VfHdvg==
+
+esbuild-darwin-64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-darwin-64/-/esbuild-darwin-64-0.14.54.tgz#24ba67b9a8cb890a3c08d9018f887cc221cdda25"
+  integrity sha512-jtdKWV3nBviOd5v4hOpkVmpxsBy90CGzebpbO9beiqUYVMBtSc0AL9zGftFuBon7PNDcdvNCEuQqw2x0wP9yug==
+
+esbuild-darwin-arm64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-darwin-arm64/-/esbuild-darwin-arm64-0.14.54.tgz#3f7cdb78888ee05e488d250a2bdaab1fa671bf73"
+  integrity sha512-OPafJHD2oUPyvJMrsCvDGkRrVCar5aVyHfWGQzY1dWnzErjrDuSETxwA2HSsyg2jORLY8yBfzc1MIpUkXlctmw==
+
+esbuild-freebsd-64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-freebsd-64/-/esbuild-freebsd-64-0.14.54.tgz#09250f997a56ed4650f3e1979c905ffc40bbe94d"
+  integrity sha512-OKwd4gmwHqOTp4mOGZKe/XUlbDJ4Q9TjX0hMPIDBUWWu/kwhBAudJdBoxnjNf9ocIB6GN6CPowYpR/hRCbSYAg==
+
+esbuild-freebsd-arm64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-freebsd-arm64/-/esbuild-freebsd-arm64-0.14.54.tgz#bafb46ed04fc5f97cbdb016d86947a79579f8e48"
+  integrity sha512-sFwueGr7OvIFiQT6WeG0jRLjkjdqWWSrfbVwZp8iMP+8UHEHRBvlaxL6IuKNDwAozNUmbb8nIMXa7oAOARGs1Q==
+
+esbuild-linux-32@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-linux-32/-/esbuild-linux-32-0.14.54.tgz#e2a8c4a8efdc355405325033fcebeb941f781fe5"
+  integrity sha512-1ZuY+JDI//WmklKlBgJnglpUL1owm2OX+8E1syCD6UAxcMM/XoWd76OHSjl/0MR0LisSAXDqgjT3uJqT67O3qw==
+
+esbuild-linux-64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-linux-64/-/esbuild-linux-64-0.14.54.tgz#de5fdba1c95666cf72369f52b40b03be71226652"
+  integrity sha512-EgjAgH5HwTbtNsTqQOXWApBaPVdDn7XcK+/PtJwZLT1UmpLoznPd8c5CxqsH2dQK3j05YsB3L17T8vE7cp4cCg==
+
+esbuild-linux-arm64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-linux-arm64/-/esbuild-linux-arm64-0.14.54.tgz#dae4cd42ae9787468b6a5c158da4c84e83b0ce8b"
+  integrity sha512-WL71L+0Rwv+Gv/HTmxTEmpv0UgmxYa5ftZILVi2QmZBgX3q7+tDeOQNqGtdXSdsL8TQi1vIaVFHUPDe0O0kdig==
+
+esbuild-linux-arm@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-linux-arm/-/esbuild-linux-arm-0.14.54.tgz#a2c1dff6d0f21dbe8fc6998a122675533ddfcd59"
+  integrity sha512-qqz/SjemQhVMTnvcLGoLOdFpCYbz4v4fUo+TfsWG+1aOu70/80RV6bgNpR2JCrppV2moUQkww+6bWxXRL9YMGw==
+
+esbuild-linux-mips64le@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-linux-mips64le/-/esbuild-linux-mips64le-0.14.54.tgz#d9918e9e4cb972f8d6dae8e8655bf9ee131eda34"
+  integrity sha512-qTHGQB8D1etd0u1+sB6p0ikLKRVuCWhYQhAHRPkO+OF3I/iSlTKNNS0Lh2Oc0g0UFGguaFZZiPJdJey3AGpAlw==
+
+esbuild-linux-ppc64le@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-linux-ppc64le/-/esbuild-linux-ppc64le-0.14.54.tgz#3f9a0f6d41073fb1a640680845c7de52995f137e"
+  integrity sha512-j3OMlzHiqwZBDPRCDFKcx595XVfOfOnv68Ax3U4UKZ3MTYQB5Yz3X1mn5GnodEVYzhtZgxEBidLWeIs8FDSfrQ==
+
+esbuild-linux-riscv64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-linux-riscv64/-/esbuild-linux-riscv64-0.14.54.tgz#618853c028178a61837bc799d2013d4695e451c8"
+  integrity sha512-y7Vt7Wl9dkOGZjxQZnDAqqn+XOqFD7IMWiewY5SPlNlzMX39ocPQlOaoxvT4FllA5viyV26/QzHtvTjVNOxHZg==
+
+esbuild-linux-s390x@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-linux-s390x/-/esbuild-linux-s390x-0.14.54.tgz#d1885c4c5a76bbb5a0fe182e2c8c60eb9e29f2a6"
+  integrity sha512-zaHpW9dziAsi7lRcyV4r8dhfG1qBidQWUXweUjnw+lliChJqQr+6XD71K41oEIC3Mx1KStovEmlzm+MkGZHnHA==
+
+esbuild-netbsd-64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-netbsd-64/-/esbuild-netbsd-64-0.14.54.tgz#69ae917a2ff241b7df1dbf22baf04bd330349e81"
+  integrity sha512-PR01lmIMnfJTgeU9VJTDY9ZerDWVFIUzAtJuDHwwceppW7cQWjBBqP48NdeRtoP04/AtO9a7w3viI+PIDr6d+w==
+
+esbuild-openbsd-64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-openbsd-64/-/esbuild-openbsd-64-0.14.54.tgz#db4c8495287a350a6790de22edea247a57c5d47b"
+  integrity sha512-Qyk7ikT2o7Wu76UsvvDS5q0amJvmRzDyVlL0qf5VLsLchjCa1+IAvd8kTBgUxD7VBUUVgItLkk609ZHUc1oCaw==
+
+esbuild-sunos-64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-sunos-64/-/esbuild-sunos-64-0.14.54.tgz#54287ee3da73d3844b721c21bc80c1dc7e1bf7da"
+  integrity sha512-28GZ24KmMSeKi5ueWzMcco6EBHStL3B6ubM7M51RmPwXQGLe0teBGJocmWhgwccA1GeFXqxzILIxXpHbl9Q/Kw==
+
+esbuild-windows-32@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-windows-32/-/esbuild-windows-32-0.14.54.tgz#f8aaf9a5667630b40f0fb3aa37bf01bbd340ce31"
+  integrity sha512-T+rdZW19ql9MjS7pixmZYVObd9G7kcaZo+sETqNH4RCkuuYSuv9AGHUVnPoP9hhuE1WM1ZimHz1CIBHBboLU7w==
+
+esbuild-windows-64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-windows-64/-/esbuild-windows-64-0.14.54.tgz#bf54b51bd3e9b0f1886ffdb224a4176031ea0af4"
+  integrity sha512-AoHTRBUuYwXtZhjXZbA1pGfTo8cJo3vZIcWGLiUcTNgHpJJMC1rVA44ZereBHMJtotyN71S8Qw0npiCIkW96cQ==
+
+esbuild-windows-arm64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-windows-arm64/-/esbuild-windows-arm64-0.14.54.tgz#937d15675a15e4b0e4fafdbaa3a01a776a2be982"
+  integrity sha512-M0kuUvXhot1zOISQGXwWn6YtS+Y/1RT9WrVIOywZnJHo3jCDyewAc79aKNQWFCQm+xNHVTq9h8dZKvygoXQQRg==
+
+"esbuild@^0.12 || ^0.13 || ^0.14":
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.14.54.tgz#8b44dcf2b0f1a66fc22459943dccf477535e9aa2"
+  integrity sha512-Cy9llcy8DvET5uznocPyqL3BFRrFXSVqbgpMJ9Wz8oVjZlh/zUSNbPRbov0VX7VxN2JH1Oa0uNxZ7eLRb62pJA==
+  optionalDependencies:
+    "@esbuild/linux-loong64" "0.14.54"
+    esbuild-android-64 "0.14.54"
+    esbuild-android-arm64 "0.14.54"
+    esbuild-darwin-64 "0.14.54"
+    esbuild-darwin-arm64 "0.14.54"
+    esbuild-freebsd-64 "0.14.54"
+    esbuild-freebsd-arm64 "0.14.54"
+    esbuild-linux-32 "0.14.54"
+    esbuild-linux-64 "0.14.54"
+    esbuild-linux-arm "0.14.54"
+    esbuild-linux-arm64 "0.14.54"
+    esbuild-linux-mips64le "0.14.54"
+    esbuild-linux-ppc64le "0.14.54"
+    esbuild-linux-riscv64 "0.14.54"
+    esbuild-linux-s390x "0.14.54"
+    esbuild-netbsd-64 "0.14.54"
+    esbuild-openbsd-64 "0.14.54"
+    esbuild-sunos-64 "0.14.54"
+    esbuild-windows-32 "0.14.54"
+    esbuild-windows-64 "0.14.54"
+    esbuild-windows-arm64 "0.14.54"
 
 escape-html@^1.0.3:
   version "1.0.3"
@@ -727,11 +1605,32 @@
   resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4"
   integrity sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==
 
+escape-string-regexp@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34"
+  integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==
+
+estree-walker@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-1.0.1.tgz#31bc5d612c96b704106b477e6dd5d8aa138cb700"
+  integrity sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg==
+
 etag@^1.8.1:
   version "1.8.1"
   resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887"
   integrity sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==
 
+extract-zip@2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/extract-zip/-/extract-zip-2.0.1.tgz#663dca56fe46df890d5f131ef4a06d22bb8ba13a"
+  integrity sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==
+  dependencies:
+    debug "^4.1.1"
+    get-stream "^5.1.0"
+    yauzl "^2.10.0"
+  optionalDependencies:
+    "@types/yauzl" "^2.9.1"
+
 fast-glob@^3.2.9:
   version "3.2.12"
   resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.2.12.tgz#7f39ec99c2e6ab030337142da9e0c18f37afae80"
@@ -744,12 +1643,19 @@
     micromatch "^4.0.4"
 
 fastq@^1.6.0:
-  version "1.13.0"
-  resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.13.0.tgz#616760f88a7526bdfc596b7cab8c18938c36b98c"
-  integrity sha512-YpkpUnK8od0o1hmeSc7UUs/eB/vIPWJYjKck2QKIzAf71Vm1AAQ3EbuZB3g2JIy+pg+ERD0vqI79KyZiB2e2Nw==
+  version "1.15.0"
+  resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.15.0.tgz#d04d07c6a2a68fe4599fea8d2e103a937fae6b3a"
+  integrity sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw==
   dependencies:
     reusify "^1.0.4"
 
+fd-slicer@~1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/fd-slicer/-/fd-slicer-1.1.0.tgz#25c7c89cb1f9077f8891bbe61d8f390eae256f1e"
+  integrity sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==
+  dependencies:
+    pend "~1.2.0"
+
 fill-range@^7.0.1:
   version "7.0.1"
   resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.0.1.tgz#1919a6a7c75fe38b2c7c77e5198535da9acdda40"
@@ -757,11 +1663,36 @@
   dependencies:
     to-regex-range "^5.0.1"
 
+find-replace@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/find-replace/-/find-replace-3.0.0.tgz#3e7e23d3b05167a76f770c9fbd5258b0def68c38"
+  integrity sha512-6Tb2myMioCAgv5kfvP5/PkZZ/ntTpVK39fHY7WkWBgvbeE+VHd/tZuZ4mrC+bxh4cfOZeYKVPaJIZtZXV7GNCQ==
+  dependencies:
+    array-back "^3.0.1"
+
+find-up@^4.0.0:
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/find-up/-/find-up-4.1.0.tgz#97afe7d6cdc0bc5928584b7c8d7b16e8a9aa5d19"
+  integrity sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==
+  dependencies:
+    locate-path "^5.0.0"
+    path-exists "^4.0.0"
+
 fresh@~0.5.2:
   version "0.5.2"
   resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7"
   integrity sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==
 
+fs-constants@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/fs-constants/-/fs-constants-1.0.0.tgz#6be0de9be998ce16af8afc24497b9ee9b7ccd9ad"
+  integrity sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==
+
+fs.realpath@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f"
+  integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==
+
 fsevents@~2.3.2:
   version "2.3.2"
   resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a"
@@ -773,14 +1704,21 @@
   integrity sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==
 
 get-intrinsic@^1.0.2:
-  version "1.1.3"
-  resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.1.3.tgz#063c84329ad93e83893c7f4f243ef63ffa351385"
-  integrity sha512-QJVz1Tj7MS099PevUG5jvnt9tSkXN8K14dxQlikJuPt4uD9hHAHjLyLBiLR5zELelBdD9QNRAXZzsJx0WaDL9A==
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.2.0.tgz#7ad1dc0535f3a2904bba075772763e5051f6d05f"
+  integrity sha512-L049y6nFOuom5wGyRc3/gdTLO94dySVKRACj1RmJZBQXlbTMhtNIgkWkUHq+jYmZvKf14EW1EoJnnjbmoHij0Q==
   dependencies:
     function-bind "^1.1.1"
     has "^1.0.3"
     has-symbols "^1.0.3"
 
+get-stream@^5.1.0:
+  version "5.2.0"
+  resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-5.2.0.tgz#4966a1795ee5ace65e706c4b7beb71257d6e22d3"
+  integrity sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==
+  dependencies:
+    pump "^3.0.0"
+
 get-stream@^6.0.0:
   version "6.0.1"
   resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-6.0.1.tgz#a262d8eef67aced57c2852ad6167526a43cbf7b7"
@@ -793,6 +1731,18 @@
   dependencies:
     is-glob "^4.0.1"
 
+glob@^7.1.3:
+  version "7.2.3"
+  resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b"
+  integrity sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==
+  dependencies:
+    fs.realpath "^1.0.0"
+    inflight "^1.0.4"
+    inherits "2"
+    minimatch "^3.1.1"
+    once "^1.3.0"
+    path-is-absolute "^1.0.0"
+
 globby@^11.0.1:
   version "11.1.0"
   resolved "https://registry.yarnpkg.com/globby/-/globby-11.1.0.tgz#bd4be98bb042f83d796f7e3811991fbe82a0d34b"
@@ -879,6 +1829,14 @@
     setprototypeof "1.1.0"
     statuses ">= 1.4.0 < 2"
 
+https-proxy-agent@5.0.1:
+  version "5.0.1"
+  resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz#c59ef224a04fe8b754f3db0063a25ea30d0005d6"
+  integrity sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==
+  dependencies:
+    agent-base "6"
+    debug "4"
+
 iconv-lite@0.4.24:
   version "0.4.24"
   resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b"
@@ -886,26 +1844,39 @@
   dependencies:
     safer-buffer ">= 2.1.2 < 3"
 
+ieee754@^1.1.13:
+  version "1.2.1"
+  resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352"
+  integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==
+
 ignore@^5.2.0:
-  version "5.2.0"
-  resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.2.0.tgz#6d3bac8fa7fe0d45d9f9be7bac2fc279577e345a"
-  integrity sha512-CmxgYGiEPCLhfLnpPp1MoRmifwEIOgjcHXxOBjv7mY96c+eWScsOP9c112ZyLdWHi0FxHjI+4uVhKYp/gcdRmQ==
+  version "5.2.4"
+  resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.2.4.tgz#a291c0c6178ff1b960befe47fcdec301674a6324"
+  integrity sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==
 
 inflation@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/inflation/-/inflation-2.0.0.tgz#8b417e47c28f925a45133d914ca1fd389107f30f"
   integrity sha512-m3xv4hJYR2oXw4o4Y5l6P5P16WYmazYof+el6Al3f+YlggGj6qT9kImBAnzDelRALnP5d3h4jGBPKzYCizjZZw==
 
+inflight@^1.0.4:
+  version "1.0.6"
+  resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9"
+  integrity sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==
+  dependencies:
+    once "^1.3.0"
+    wrappy "1"
+
+inherits@2, inherits@2.0.4, inherits@^2.0.3, inherits@^2.0.4:
+  version "2.0.4"
+  resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
+  integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
+
 inherits@2.0.3:
   version "2.0.3"
   resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de"
   integrity sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==
 
-inherits@2.0.4:
-  version "2.0.4"
-  resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
-  integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
-
 ip@^1.1.5:
   version "1.1.8"
   resolved "https://registry.yarnpkg.com/ip/-/ip-1.1.8.tgz#ae05948f6b075435ed3307acce04629da8cdbf48"
@@ -918,6 +1889,20 @@
   dependencies:
     binary-extensions "^2.0.0"
 
+is-builtin-module@^3.1.0:
+  version "3.2.1"
+  resolved "https://registry.yarnpkg.com/is-builtin-module/-/is-builtin-module-3.2.1.tgz#f03271717d8654cfcaf07ab0463faa3571581169"
+  integrity sha512-BSLE3HnV2syZ0FK0iMA/yUGplUeMmNz4AW5fnTunbCIqZi4vG3WjJT9FHMy5D69xmAYBHXQhJdALdpwVxV501A==
+  dependencies:
+    builtin-modules "^3.3.0"
+
+is-core-module@^2.9.0:
+  version "2.11.0"
+  resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.11.0.tgz#ad4cb3e3863e814523c96f3f58d26cc570ff0144"
+  integrity sha512-RRjxlvLDkD1YJwDbroBHMb+cukurkDWNyHx7D3oNB5x9rb5ogcksMC5wHCadcXoo67gVr/+3GFySh3134zi6rw==
+  dependencies:
+    has "^1.0.3"
+
 is-docker@^2.0.0, is-docker@^2.1.1:
   version "2.2.1"
   resolved "https://registry.yarnpkg.com/is-docker/-/is-docker-2.2.1.tgz#33eeabe23cfe86f14bde4408a02c0cfb853acdaa"
@@ -947,6 +1932,11 @@
   dependencies:
     is-extglob "^2.1.1"
 
+is-module@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/is-module/-/is-module-1.0.0.tgz#3258fb69f78c14d5b815d664336b4cffb6441591"
+  integrity sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==
+
 is-number@^7.0.0:
   version "7.0.0"
   resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b"
@@ -964,6 +1954,11 @@
   dependencies:
     is-docker "^2.0.0"
 
+isarray@0.0.1:
+  version "0.0.1"
+  resolved "https://registry.yarnpkg.com/isarray/-/isarray-0.0.1.tgz#8a18acfca9a8f4177e09abfc6038939b05d1eedf"
+  integrity sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==
+
 isbinaryfile@^4.0.6:
   version "4.0.10"
   resolved "https://registry.yarnpkg.com/isbinaryfile/-/isbinaryfile-4.0.10.tgz#0c5b5e30c2557a2f06febd37b7322946aaee42b3"
@@ -996,6 +1991,11 @@
   resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"
   integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==
 
+just-extend@^4.0.2:
+  version "4.2.1"
+  resolved "https://registry.yarnpkg.com/just-extend/-/just-extend-4.2.1.tgz#ef5e589afb61e5d66b24eca749409a8939a8c744"
+  integrity sha512-g3UB796vUFIY90VIv/WX3L2c8CS2MdWUww3CNrYmqza1Fg0DURc2K/O4YrnklBdQarSJ/y8JnJYDGc+1iumQjg==
+
 keygrip@~1.1.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/keygrip/-/keygrip-1.1.0.tgz#871b1681d5e159c62a445b0c74b615e0917e7226"
@@ -1041,9 +2041,9 @@
     koa-send "^5.0.0"
 
 koa@^2.13.0:
-  version "2.13.4"
-  resolved "https://registry.yarnpkg.com/koa/-/koa-2.13.4.tgz#ee5b0cb39e0b8069c38d115139c774833d32462e"
-  integrity sha512-43zkIKubNbnrULWlHdN5h1g3SEKXOEzoAlRsHOTFpnlDu8JlAOZSMJBLULusuXRequboiwJcj5vtYXKB3k7+2g==
+  version "2.14.1"
+  resolved "https://registry.yarnpkg.com/koa/-/koa-2.14.1.tgz#defb9589297d8eb1859936e777f3feecfc26925c"
+  integrity sha512-USJFyZgi2l0wDgqkfD27gL4YGno7TfUkcmOe6UOLFOVuN+J7FwnNu4Dydl4CUQzraM1lBAiGed0M9OVJoT0Kqw==
   dependencies:
     accepts "^1.3.5"
     cache-content-type "^1.0.0"
@@ -1069,45 +2069,59 @@
     type-is "^1.6.16"
     vary "^1.1.2"
 
+lighthouse-logger@^1.0.0:
+  version "1.3.0"
+  resolved "https://registry.yarnpkg.com/lighthouse-logger/-/lighthouse-logger-1.3.0.tgz#ba6303e739307c4eee18f08249524e7dafd510db"
+  integrity sha512-BbqAKApLb9ywUli+0a+PcV04SyJ/N1q/8qgCNe6U97KbPCS1BTksEuHFLYdvc8DltuhfxIUBqDZsC0bBGtl3lA==
+  dependencies:
+    debug "^2.6.9"
+    marky "^1.2.2"
+
 lit-element@^3.2.0:
-  version "3.2.0"
-  resolved "https://registry.yarnpkg.com/lit-element/-/lit-element-3.2.0.tgz#9c981c55dfd9a8f124dc863edb62cc529d434db7"
-  integrity sha512-HbE7yt2SnUtg5DCrWt028oaU4D5F4k/1cntAFHTkzY8ZIa8N0Wmu92PxSxucsQSOXlODFrICkQ5x/tEshKi13g==
+  version "3.2.2"
+  resolved "https://registry.yarnpkg.com/lit-element/-/lit-element-3.2.2.tgz#d148ab6bf4c53a33f707a5168e087725499e5f2b"
+  integrity sha512-6ZgxBR9KNroqKb6+htkyBwD90XGRiqKDHVrW/Eh0EZ+l+iC+u+v+w3/BA5NGi4nizAVHGYvQBHUDuSmLjPp7NQ==
   dependencies:
     "@lit/reactive-element" "^1.3.0"
     lit-html "^2.2.0"
 
-lit-html@^2.0.0, lit-html@^2.3.0:
-  version "2.3.1"
-  resolved "https://registry.yarnpkg.com/lit-html/-/lit-html-2.3.1.tgz#56f15104ea75c0a702904893e3409d0e89e2a2b9"
-  integrity sha512-FyKH6LTW6aBdkfNhNSHyZTnLgJSTe5hMk7HFtc/+DcN1w74C215q8B+Cfxc2OuIEpBNcEKxgF64qL8as30FDHA==
+lit-html@^2.0.0, lit-html@^2.2.0, lit-html@^2.6.0:
+  version "2.6.1"
+  resolved "https://registry.yarnpkg.com/lit-html/-/lit-html-2.6.1.tgz#eb29f0b0c2ab54ea77379db11fc011b0c71f1cda"
+  integrity sha512-Z3iw+E+3KKFn9t2YKNjsXNEu/LRLI98mtH/C6lnFg7kvaqPIzPn124Yd4eT/43lyqrejpc5Wb6BHq3fdv4S8Rw==
   dependencies:
     "@types/trusted-types" "^2.0.2"
 
-lit-html@^2.2.0:
-  version "2.2.6"
-  resolved "https://registry.yarnpkg.com/lit-html/-/lit-html-2.2.6.tgz#e70679605420a34c4f3cbd0c483b2fb1fff781df"
-  integrity sha512-xOKsPmq/RAKJ6dUeOxhmOYFjcjf0Q7aSdfBJgdJkOfCUnkmmJPxNrlZpRBeVe1Gg50oYWMlgm6ccAE/SpJgSdw==
+lit@^2.0.0, lit@^2.2.3:
+  version "2.6.1"
+  resolved "https://registry.yarnpkg.com/lit/-/lit-2.6.1.tgz#5951a2098b9bde5b328c73b55c15fdc0eefd96d7"
+  integrity sha512-DT87LD64f8acR7uVp7kZfhLRrHkfC/N4BVzAtnw9Yg8087mbBJ//qedwdwX0kzDbxgPccWRW6mFwGbRQIxy0pw==
   dependencies:
-    "@types/trusted-types" "^2.0.2"
-
-lit@^2.0.0:
-  version "2.3.1"
-  resolved "https://registry.yarnpkg.com/lit/-/lit-2.3.1.tgz#2cf1c2042da1e44c7a7cc72dff2d72303fd26f48"
-  integrity sha512-TejktDR4mqG3qB32Y8Lm5Lye3c8SUehqz7qRsxe1PqGYL6me2Ef+jeQAEqh20BnnGncv4Yxy2njEIT0kzK1WCw==
-  dependencies:
-    "@lit/reactive-element" "^1.4.0"
+    "@lit/reactive-element" "^1.6.0"
     lit-element "^3.2.0"
-    lit-html "^2.3.0"
+    lit-html "^2.6.0"
 
-lit@^2.2.3:
-  version "2.2.6"
-  resolved "https://registry.yarnpkg.com/lit/-/lit-2.2.6.tgz#4ef223e88517c000b0c01baf2e3535e61a75a5b5"
-  integrity sha512-K2vkeGABfSJSfkhqHy86ujchJs3NR9nW1bEEiV+bXDkbiQ60Tv5GUausYN2mXigZn8lC1qXuc46ArQRKYmumZw==
+locate-path@^5.0.0:
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-5.0.0.tgz#1afba396afd676a6d42504d0a67a3a7eb9f62aa0"
+  integrity sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==
   dependencies:
-    "@lit/reactive-element" "^1.3.0"
-    lit-element "^3.2.0"
-    lit-html "^2.2.0"
+    p-locate "^4.1.0"
+
+lodash.camelcase@^4.3.0:
+  version "4.3.0"
+  resolved "https://registry.yarnpkg.com/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz#b28aa6288a2b9fc651035c7711f65ab6190331a6"
+  integrity sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==
+
+lodash.get@^4.4.2:
+  version "4.4.2"
+  resolved "https://registry.yarnpkg.com/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99"
+  integrity sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==
+
+lodash@^4.17.14:
+  version "4.17.21"
+  resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
+  integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
 
 log-update@^4.0.0:
   version "4.0.0"
@@ -1133,6 +2147,11 @@
   dependencies:
     semver "^6.0.0"
 
+marky@^1.2.2:
+  version "1.2.5"
+  resolved "https://registry.yarnpkg.com/marky/-/marky-1.2.5.tgz#55796b688cbd72390d2d399eaaf1832c9413e3c0"
+  integrity sha512-q9JtQJKjpsVxCRVgQ+WapguSbKC3SQ5HEzFGPAJMStgh3QjCawp00UKv3MTTAArTmGmmPUvllHZoNbZ3gs0I+Q==
+
 media-typer@0.3.0:
   version "0.3.0"
   resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748"
@@ -1168,11 +2187,40 @@
   resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b"
   integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==
 
+minimatch@^3.1.1:
+  version "3.1.2"
+  resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b"
+  integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==
+  dependencies:
+    brace-expansion "^1.1.7"
+
+minimist@^1.2.6:
+  version "1.2.8"
+  resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c"
+  integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==
+
+mkdirp-classic@^0.5.2:
+  version "0.5.3"
+  resolved "https://registry.yarnpkg.com/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz#fa10c9115cc6d8865be221ba47ee9bed78601113"
+  integrity sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==
+
+mkdirp@^0.5.6:
+  version "0.5.6"
+  resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.6.tgz#7def03d2432dcae4ba1d611445c48396062255f6"
+  integrity sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==
+  dependencies:
+    minimist "^1.2.6"
+
 mkdirp@^1.0.4:
   version "1.0.4"
   resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e"
   integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==
 
+ms@2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8"
+  integrity sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==
+
 ms@2.1.2:
   version "2.1.2"
   resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009"
@@ -1198,15 +2246,33 @@
   resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.3.tgz#58e323a72fedc0d6f9cd4d31fe49f51479590ccd"
   integrity sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==
 
+nise@^5.1.1:
+  version "5.1.4"
+  resolved "https://registry.yarnpkg.com/nise/-/nise-5.1.4.tgz#491ce7e7307d4ec546f5a659b2efe94a18b4bbc0"
+  integrity sha512-8+Ib8rRJ4L0o3kfmyVCL7gzrohyDe0cMFTBa2d364yIrEGMEoetznKJx899YxjybU6bL9SQkYPSBBs1gyYs8Xg==
+  dependencies:
+    "@sinonjs/commons" "^2.0.0"
+    "@sinonjs/fake-timers" "^10.0.2"
+    "@sinonjs/text-encoding" "^0.7.1"
+    just-extend "^4.0.2"
+    path-to-regexp "^1.7.0"
+
+node-fetch@2.6.7:
+  version "2.6.7"
+  resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.7.tgz#24de9fba827e3b4ae44dc8b20256a379160052ad"
+  integrity sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==
+  dependencies:
+    whatwg-url "^5.0.0"
+
 normalize-path@^3.0.0, normalize-path@~3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65"
   integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==
 
 object-inspect@^1.9.0:
-  version "1.12.2"
-  resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.12.2.tgz#c0641f26394532f28ab8d796ab954e43c009a8ea"
-  integrity sha512-z+cPxW0QGUp0mcqcsgQyLVRDoXFQbXOwBaqyF7VIgI4TWNQsDHrBpUQslRmIfAoYWdYzs6UlKJtB2XJpTaNSpQ==
+  version "1.12.3"
+  resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.12.3.tgz#ba62dffd67ee256c8c086dfae69e016cd1f198b9"
+  integrity sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g==
 
 on-finished@^2.3.0:
   version "2.4.1"
@@ -1215,6 +2281,13 @@
   dependencies:
     ee-first "1.1.1"
 
+once@^1.3.0, once@^1.3.1, once@^1.4.0:
+  version "1.4.0"
+  resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1"
+  integrity sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==
+  dependencies:
+    wrappy "1"
+
 onetime@^5.1.0:
   version "5.1.2"
   resolved "https://registry.yarnpkg.com/onetime/-/onetime-5.1.2.tgz#d0e96ebb56b07476df1dd9c4806e5237985ca45e"
@@ -1228,14 +2301,33 @@
   integrity sha512-Fvw+Jemq5fjjyWz6CpKx6w9s7xxqo3+JCyM0WXWeCSOboZ8ABkyvP8ID4CZuChA/wxSx+XSJmdOm8rGVyJ1hdQ==
 
 open@^8.0.2:
-  version "8.4.0"
-  resolved "https://registry.yarnpkg.com/open/-/open-8.4.0.tgz#345321ae18f8138f82565a910fdc6b39e8c244f8"
-  integrity sha512-XgFPPM+B28FtCCgSb9I+s9szOC1vZRSwgWsRUA5ylIxRTgKozqjOCrVOqGsYABPYK5qnfqClxZTFBa8PKt2v6Q==
+  version "8.4.2"
+  resolved "https://registry.yarnpkg.com/open/-/open-8.4.2.tgz#5b5ffe2a8f793dcd2aad73e550cb87b59cb084f9"
+  integrity sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==
   dependencies:
     define-lazy-prop "^2.0.0"
     is-docker "^2.1.1"
     is-wsl "^2.2.0"
 
+p-limit@^2.2.0:
+  version "2.3.0"
+  resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-2.3.0.tgz#3dd33c647a214fdfffd835933eb086da0dc21db1"
+  integrity sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==
+  dependencies:
+    p-try "^2.0.0"
+
+p-locate@^4.1.0:
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-4.1.0.tgz#a3428bb7088b3a60292f66919278b7c297ad4f07"
+  integrity sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==
+  dependencies:
+    p-limit "^2.2.0"
+
+p-try@^2.0.0:
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6"
+  integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==
+
 parse5@^6.0.1:
   version "6.0.1"
   resolved "https://registry.yarnpkg.com/parse5/-/parse5-6.0.1.tgz#e1a1c085c569b3dc08321184f19a39cc27f7c30b"
@@ -1246,21 +2338,100 @@
   resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4"
   integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==
 
-path-is-absolute@1.0.1:
+path-exists@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3"
+  integrity sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==
+
+path-is-absolute@1.0.1, path-is-absolute@^1.0.0:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f"
   integrity sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==
 
+path-parse@^1.0.7:
+  version "1.0.7"
+  resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735"
+  integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==
+
+path-to-regexp@^1.7.0:
+  version "1.8.0"
+  resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-1.8.0.tgz#887b3ba9d84393e87a0a0b9f4cb756198b53548a"
+  integrity sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA==
+  dependencies:
+    isarray "0.0.1"
+
 path-type@^4.0.0:
   version "4.0.0"
   resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b"
   integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==
 
+pend@~1.2.0:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/pend/-/pend-1.2.0.tgz#7a57eb550a6783f9115331fcf4663d5c8e007a50"
+  integrity sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==
+
 picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.2.2, picomatch@^2.3.1:
   version "2.3.1"
   resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42"
   integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==
 
+pkg-dir@4.2.0:
+  version "4.2.0"
+  resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-4.2.0.tgz#f099133df7ede422e81d1d8448270eeb3e4261f3"
+  integrity sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==
+  dependencies:
+    find-up "^4.0.0"
+
+portfinder@^1.0.32:
+  version "1.0.32"
+  resolved "https://registry.yarnpkg.com/portfinder/-/portfinder-1.0.32.tgz#2fe1b9e58389712429dc2bea5beb2146146c7f81"
+  integrity sha512-on2ZJVVDXRADWE6jnQaX0ioEylzgBpQk8r55NE4wjXW1ZxO+BgDlY6DXwj20i0V8eB4SenDQ00WEaxfiIQPcxg==
+  dependencies:
+    async "^2.6.4"
+    debug "^3.2.7"
+    mkdirp "^0.5.6"
+
+progress@2.0.3:
+  version "2.0.3"
+  resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8"
+  integrity sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==
+
+proxy-from-env@1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2"
+  integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==
+
+pump@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/pump/-/pump-3.0.0.tgz#b4a2116815bde2f4e1ea602354e8c75565107a64"
+  integrity sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==
+  dependencies:
+    end-of-stream "^1.1.0"
+    once "^1.3.1"
+
+punycode@^2.1.1:
+  version "2.3.0"
+  resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.0.tgz#f67fa67c94da8f4d0cfff981aee4118064199b8f"
+  integrity sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==
+
+puppeteer-core@^13.1.3:
+  version "13.7.0"
+  resolved "https://registry.yarnpkg.com/puppeteer-core/-/puppeteer-core-13.7.0.tgz#3344bee3994163f49120a55ddcd144a40575ba5b"
+  integrity sha512-rXja4vcnAzFAP1OVLq/5dWNfwBGuzcOARJ6qGV7oAZhnLmVRU8G5MsdeQEAOy332ZhkIOnn9jp15R89LKHyp2Q==
+  dependencies:
+    cross-fetch "3.1.5"
+    debug "4.3.4"
+    devtools-protocol "0.0.981744"
+    extract-zip "2.0.1"
+    https-proxy-agent "5.0.1"
+    pkg-dir "4.2.0"
+    progress "2.0.3"
+    proxy-from-env "1.1.0"
+    rimraf "3.0.2"
+    tar-fs "2.1.1"
+    unbzip2-stream "1.4.3"
+    ws "8.5.0"
+
 qs@^6.5.2:
   version "6.11.0"
   resolved "https://registry.yarnpkg.com/qs/-/qs-6.11.0.tgz#fd0d963446f7a65e1367e01abd85429453f0c37a"
@@ -1274,15 +2445,24 @@
   integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==
 
 raw-body@^2.3.3:
-  version "2.5.1"
-  resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.5.1.tgz#fe1b1628b181b700215e5fd42389f98b71392857"
-  integrity sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig==
+  version "2.5.2"
+  resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.5.2.tgz#99febd83b90e08975087e8f1f9419a149366b68a"
+  integrity sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==
   dependencies:
     bytes "3.1.2"
     http-errors "2.0.0"
     iconv-lite "0.4.24"
     unpipe "1.0.0"
 
+readable-stream@^3.1.1, readable-stream@^3.4.0:
+  version "3.6.1"
+  resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.1.tgz#f9f9b5f536920253b3d26e7660e7da4ccff9bb62"
+  integrity sha512-+rQmrWMYGA90yenhTYsLWAsLsqVC8osOw6PKE1HDYiO0gdPeKe/xDHNzIAIn4C91YQ6oenEhfYqqc1883qHbjQ==
+  dependencies:
+    inherits "^2.0.3"
+    string_decoder "^1.1.1"
+    util-deprecate "^1.0.1"
+
 readdirp@~3.6.0:
   version "3.6.0"
   resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.6.0.tgz#74a370bd857116e245b29cc97340cd431a02a6c7"
@@ -1290,6 +2470,11 @@
   dependencies:
     picomatch "^2.2.1"
 
+reduce-flatten@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/reduce-flatten/-/reduce-flatten-2.0.0.tgz#734fd84e65f375d7ca4465c69798c25c9d10ae27"
+  integrity sha512-EJ4UNY/U1t2P/2k6oqotuX2Cc3T6nxJwsM0N0asT7dhrtH1ltUxDn4NalSYmPE2rCkVpcf/X6R0wDwcFpzhd4w==
+
 resolve-path@^1.4.0:
   version "1.4.0"
   resolved "https://registry.yarnpkg.com/resolve-path/-/resolve-path-1.4.0.tgz#c4bda9f5efb2fce65247873ab36bb4d834fe16f7"
@@ -1298,6 +2483,15 @@
     http-errors "~1.6.2"
     path-is-absolute "1.0.1"
 
+resolve@^1.19.0:
+  version "1.22.1"
+  resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.1.tgz#27cb2ebb53f91abb49470a928bba7558066ac177"
+  integrity sha512-nBpuuYuY5jFsli/JIs1oldw6fOQCBioohqWZg/2hiaOybXOft4lonv85uDOKXdf8rhyK159cxU5cDcK/NKk8zw==
+  dependencies:
+    is-core-module "^2.9.0"
+    path-parse "^1.0.7"
+    supports-preserve-symlinks-flag "^1.0.0"
+
 restore-cursor@^3.1.0:
   version "3.1.0"
   resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-3.1.0.tgz#39f67c54b3a7a58cea5236d95cf0034239631f7e"
@@ -1311,6 +2505,20 @@
   resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.0.4.tgz#90da382b1e126efc02146e90845a88db12925d76"
   integrity sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==
 
+rimraf@3.0.2:
+  version "3.0.2"
+  resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a"
+  integrity sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==
+  dependencies:
+    glob "^7.1.3"
+
+rollup@^2.67.0:
+  version "2.79.1"
+  resolved "https://registry.yarnpkg.com/rollup/-/rollup-2.79.1.tgz#bedee8faef7c9f93a2647ac0108748f497f081c7"
+  integrity sha512-uKxbd0IhMZOhjAiD5oAFp7BqvkA4Dv47qpOCtaNvng4HBwdbWtdOh8f5nZNuk2rp51PMGk3bzfWu5oayNEuYnw==
+  optionalDependencies:
+    fsevents "~2.3.2"
+
 run-parallel@^1.1.9:
   version "1.2.0"
   resolved "https://registry.yarnpkg.com/run-parallel/-/run-parallel-1.2.0.tgz#66d1368da7bdf921eb9d95bd1a9229e7f21a43ee"
@@ -1325,16 +2533,11 @@
   dependencies:
     tslib "^1.9.0"
 
-safe-buffer@5.2.1:
+safe-buffer@5.2.1, safe-buffer@~5.2.0:
   version "5.2.1"
   resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6"
   integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==
 
-safe-buffer@~5.1.1:
-  version "5.1.2"
-  resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d"
-  integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==
-
 "safer-buffer@>= 2.1.2 < 3":
   version "2.1.2"
   resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a"
@@ -1345,6 +2548,13 @@
   resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d"
   integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==
 
+semver@^7.3.4:
+  version "7.3.8"
+  resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.8.tgz#07a78feafb3f7b32347d725e33de7e2a2df67798"
+  integrity sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==
+  dependencies:
+    lru-cache "^6.0.0"
+
 setprototypeof@1.1.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.1.0.tgz#d0bd85536887b6fe7c0d818cb962d9d91c54e656"
@@ -1369,6 +2579,18 @@
   resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.7.tgz#a9a1767f8af84155114eaabd73f99273c8f59ad9"
   integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==
 
+sinon@^13.0.0:
+  version "13.0.2"
+  resolved "https://registry.yarnpkg.com/sinon/-/sinon-13.0.2.tgz#c6a8ddd655dc1415bbdc5ebf0e5b287806850c3a"
+  integrity sha512-KvOrztAVqzSJWMDoxM4vM+GPys1df2VBoXm+YciyB/OLMamfS3VXh3oGh5WtrAGSzrgczNWFFY22oKb7Fi5eeA==
+  dependencies:
+    "@sinonjs/commons" "^1.8.3"
+    "@sinonjs/fake-timers" "^9.1.2"
+    "@sinonjs/samsam" "^6.1.1"
+    diff "^5.0.0"
+    nise "^5.1.1"
+    supports-color "^7.2.0"
+
 slash@^3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634"
@@ -1407,6 +2629,13 @@
     is-fullwidth-code-point "^3.0.0"
     strip-ansi "^6.0.1"
 
+string_decoder@^1.1.1:
+  version "1.3.0"
+  resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.3.0.tgz#42f114594a46cf1a8e30b0a84f56c78c3edac21e"
+  integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==
+  dependencies:
+    safe-buffer "~5.2.0"
+
 strip-ansi@^6.0.0, strip-ansi@^6.0.1:
   version "6.0.1"
   resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
@@ -1414,6 +2643,11 @@
   dependencies:
     ansi-regex "^5.0.1"
 
+style-mod@^4.0.0:
+  version "4.0.3"
+  resolved "https://registry.yarnpkg.com/style-mod/-/style-mod-4.0.3.tgz#136c4abc905f82a866a18b39df4dc08ec762b1ad"
+  integrity sha512-78Jv8kYJdjbvRwwijtCevYADfsI0lGzYJe4mMFdceO8l75DFFDoqBhR1jVDicDRRaX4//g1u9wKeo+ztc2h1Rw==
+
 supports-color@^5.3.0:
   version "5.5.0"
   resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f"
@@ -1421,13 +2655,54 @@
   dependencies:
     has-flag "^3.0.0"
 
-supports-color@^7.1.0:
+supports-color@^7.1.0, supports-color@^7.2.0:
   version "7.2.0"
   resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da"
   integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==
   dependencies:
     has-flag "^4.0.0"
 
+supports-preserve-symlinks-flag@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09"
+  integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==
+
+table-layout@^1.0.2:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/table-layout/-/table-layout-1.0.2.tgz#c4038a1853b0136d63365a734b6931cf4fad4a04"
+  integrity sha512-qd/R7n5rQTRFi+Zf2sk5XVVd9UQl6ZkduPFC3S7WEGJAmetDTjY3qPN50eSKzwuzEyQKy5TN2TiZdkIjos2L6A==
+  dependencies:
+    array-back "^4.0.1"
+    deep-extend "~0.6.0"
+    typical "^5.2.0"
+    wordwrapjs "^4.0.0"
+
+tar-fs@2.1.1:
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/tar-fs/-/tar-fs-2.1.1.tgz#489a15ab85f1f0befabb370b7de4f9eb5cbe8784"
+  integrity sha512-V0r2Y9scmbDRLCNex/+hYzvp/zyYjvFbHPNgVTKfQvVrb6guiE/fxP+XblDNR011utopbkex2nM4dHNV6GDsng==
+  dependencies:
+    chownr "^1.1.1"
+    mkdirp-classic "^0.5.2"
+    pump "^3.0.0"
+    tar-stream "^2.1.4"
+
+tar-stream@^2.1.4:
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-2.2.0.tgz#acad84c284136b060dc3faa64474aa9aebd77287"
+  integrity sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==
+  dependencies:
+    bl "^4.0.3"
+    end-of-stream "^1.4.1"
+    fs-constants "^1.0.0"
+    inherits "^2.0.3"
+    readable-stream "^3.1.1"
+
+through@^2.3.8:
+  version "2.3.8"
+  resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5"
+  integrity sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==
+
 to-regex-range@^5.0.1:
   version "5.0.1"
   resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-5.0.1.tgz#1648c44aae7c8d988a326018ed72f5b4dd0392e4"
@@ -1440,6 +2715,18 @@
   resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35"
   integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==
 
+tr46@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/tr46/-/tr46-3.0.0.tgz#555c4e297a950617e8eeddef633c87d4d9d6cbf9"
+  integrity sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==
+  dependencies:
+    punycode "^2.1.1"
+
+tr46@~0.0.3:
+  version "0.0.3"
+  resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a"
+  integrity sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==
+
 tslib@^1.9.0:
   version "1.14.1"
   resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00"
@@ -1450,6 +2737,11 @@
   resolved "https://registry.yarnpkg.com/tsscmp/-/tsscmp-1.0.6.tgz#85b99583ac3589ec4bfef825b5000aa911d605eb"
   integrity sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==
 
+type-detect@4.0.8, type-detect@^4.0.8:
+  version "4.0.8"
+  resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.0.8.tgz#7646fb5f18871cfbb7749e69bd39a6388eb7450c"
+  integrity sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==
+
 type-fest@^0.21.3:
   version "0.21.3"
   resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.21.3.tgz#d260a24b0198436e133fa26a524a6d65fa3b2e37"
@@ -1463,16 +2755,92 @@
     media-typer "0.3.0"
     mime-types "~2.1.24"
 
+typical@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/typical/-/typical-4.0.0.tgz#cbeaff3b9d7ae1e2bbfaf5a4e6f11eccfde94fc4"
+  integrity sha512-VAH4IvQ7BDFYglMd7BPRDfLgxZZX4O4TFcRDA6EN5X7erNJJq+McIEp8np9aVtxrCJ6qx4GTYVfOWNjcqwZgRw==
+
+typical@^5.2.0:
+  version "5.2.0"
+  resolved "https://registry.yarnpkg.com/typical/-/typical-5.2.0.tgz#4daaac4f2b5315460804f0acf6cb69c52bb93066"
+  integrity sha512-dvdQgNDNJo+8B2uBQoqdb11eUCE1JQXhvjC/CZtgvZseVd5TYMXnq0+vuUemXbd/Se29cTaUuPX3YIc2xgbvIg==
+
+ua-parser-js@^1.0.2:
+  version "1.0.33"
+  resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-1.0.33.tgz#f21f01233e90e7ed0f059ceab46eb190ff17f8f4"
+  integrity sha512-RqshF7TPTE0XLYAqmjlu5cLLuGdKrNu9O1KLA/qp39QtbZwuzwv1dT46DZSopoUMsYgXpB3Cv8a03FI8b74oFQ==
+
+unbzip2-stream@1.4.3:
+  version "1.4.3"
+  resolved "https://registry.yarnpkg.com/unbzip2-stream/-/unbzip2-stream-1.4.3.tgz#b0da04c4371311df771cdc215e87f2130991ace7"
+  integrity sha512-mlExGW4w71ebDJviH16lQLtZS32VKqsSfk80GCfUlwT/4/hNRFsoscrF/c++9xinkMzECL1uL9DDwXqFWkruPg==
+  dependencies:
+    buffer "^5.2.1"
+    through "^2.3.8"
+
 unpipe@1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec"
   integrity sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==
 
+util-deprecate@^1.0.1:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
+  integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==
+
+v8-to-istanbul@^8.0.0:
+  version "8.1.1"
+  resolved "https://registry.yarnpkg.com/v8-to-istanbul/-/v8-to-istanbul-8.1.1.tgz#77b752fd3975e31bbcef938f85e9bd1c7a8d60ed"
+  integrity sha512-FGtKtv3xIpR6BYhvgH8MI/y78oT7d8Au3ww4QIxymrCtZEh5b8gCw2siywE+puhEmuWKDtmfrvF5UlB298ut3w==
+  dependencies:
+    "@types/istanbul-lib-coverage" "^2.0.1"
+    convert-source-map "^1.6.0"
+    source-map "^0.7.3"
+
 vary@^1.1.2:
   version "1.1.2"
   resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc"
   integrity sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==
 
+w3c-keyname@^2.2.4:
+  version "2.2.6"
+  resolved "https://registry.yarnpkg.com/w3c-keyname/-/w3c-keyname-2.2.6.tgz#8412046116bc16c5d73d4e612053ea10a189c85f"
+  integrity sha512-f+fciywl1SJEniZHD6H+kUO8gOnwIr7f4ijKA6+ZvJFjeGi1r4PDLl53Ayud9O/rk64RqgoQine0feoeOU0kXg==
+
+webidl-conversions@^3.0.0:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871"
+  integrity sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==
+
+webidl-conversions@^7.0.0:
+  version "7.0.0"
+  resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-7.0.0.tgz#256b4e1882be7debbf01d05f0aa2039778ea080a"
+  integrity sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==
+
+whatwg-url@^11.0.0:
+  version "11.0.0"
+  resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-11.0.0.tgz#0a849eebb5faf2119b901bb76fd795c2848d4018"
+  integrity sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==
+  dependencies:
+    tr46 "^3.0.0"
+    webidl-conversions "^7.0.0"
+
+whatwg-url@^5.0.0:
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-5.0.0.tgz#966454e8765462e37644d3626f6742ce8b70965d"
+  integrity sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==
+  dependencies:
+    tr46 "~0.0.3"
+    webidl-conversions "^3.0.0"
+
+wordwrapjs@^4.0.0:
+  version "4.0.1"
+  resolved "https://registry.yarnpkg.com/wordwrapjs/-/wordwrapjs-4.0.1.tgz#d9790bccfb110a0fc7836b5ebce0937b37a8b98f"
+  integrity sha512-kKlNACbvHrkpIw6oPeYDSmdCTu2hdMHoyXLTcUKala++lx5Y+wjJ/e474Jqv5abnVmwxw08DiTuHmw69lJGksA==
+  dependencies:
+    reduce-flatten "^2.0.0"
+    typical "^5.2.0"
+
 wrap-ansi@^6.2.0:
   version "6.2.0"
   resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz#e9393ba07102e6c91a3b221478f0257cd2856e53"
@@ -1482,6 +2850,16 @@
     string-width "^4.1.0"
     strip-ansi "^6.0.0"
 
+wrappy@1:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f"
+  integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==
+
+ws@8.5.0:
+  version "8.5.0"
+  resolved "https://registry.yarnpkg.com/ws/-/ws-8.5.0.tgz#bfb4be96600757fe5382de12c670dab984a1ed4f"
+  integrity sha512-BWX0SWVgLPzYwF8lTzEy1egjhS4S4OEAHfsO8o65WOVsrnSRGaSiUaa9e0ggGlkMTtBlmOpEXiie9RUcBO86qg==
+
 ws@^7.4.2:
   version "7.5.9"
   resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.9.tgz#54fa7db29f4c7cec68b1ddd3a89de099942bb591"
@@ -1492,6 +2870,14 @@
   resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72"
   integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==
 
+yauzl@^2.10.0:
+  version "2.10.0"
+  resolved "https://registry.yarnpkg.com/yauzl/-/yauzl-2.10.0.tgz#c7eb17c93e112cb1086fa6d8e51fb0667b79a5f9"
+  integrity sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==
+  dependencies:
+    buffer-crc32 "~0.2.3"
+    fd-slicer "~1.1.0"
+
 ylru@^1.2.0:
   version "1.3.2"
   resolved "https://registry.yarnpkg.com/ylru/-/ylru-1.3.2.tgz#0de48017473275a4cbdfc83a1eaf67c01af8a785"
diff --git a/polygerrit-ui/FE_Style_Guide.md b/polygerrit-ui/FE_Style_Guide.md
index 6673cdf..d2b865b 100644
--- a/polygerrit-ui/FE_Style_Guide.md
+++ b/polygerrit-ui/FE_Style_Guide.md
@@ -125,10 +125,6 @@
 
 Do not use getAppContext() anywhere else in a class.
 
-**Note:** This rule doesn't apply for HTML/Polymer elements classes. A browser creates instances of such classes
-implicitly and calls the constructor without parameters. See
-[Assign required services in a HTML/Polymer element constructor](#assign-dependencies-in-html-element-constructor)
-
 **Good:**
 ```Javascript
 export class UserService {
@@ -160,90 +156,3 @@
 }
 
 ```
-
-## <a name="assign-dependencies-in-html-element-constructor"></a>Assign required services in a HTML/Polymer element constructor
-If a class is a custom HTML/Polymer element, the class must assign all required services in the constructor.
-A browser creates instances of such classes implicitly, so it is impossible to pass anything as a parameter to
-the element's class constructor.
-
-Do not use appContext anywhere except the constructor of the class.
-
-**Note for legacy elements:** If a polymer element extends a LegacyElementMixin and overrides the `created()` method,
-move all code from this method to a constructor right after the call to a `super()`
-([example](#assign-dependencies-legacy-element-example)). The `created()`
-method is [deprecated](https://polymer-library.polymer-project.org/2.0/docs/about_20#lifecycle-changes) and is called
-when a super (i.e. base) class constructor is called. If you are unsure about moving the code from the `created` method
-to the class constructor, consult with the source code:
-[`LegacyElementMixin._initializeProperties`](https://github.com/Polymer/polymer/blob/v3.4.0/lib/legacy/legacy-element-mixin.js#L318)
-and
-[`PropertiesChanged.constructor`](https://github.com/Polymer/polymer/blob/v3.4.0/lib/mixins/properties-changed.js#L177)
-
-
-
-**Good:**
-```Javascript
-import {appContext} from `.../services/app-context.js`;
-
-export class MyCustomElement extends ...{
-    constructor() {
-        super(); //This is mandatory to call parent constructor
-        this._userModel = appContext.userModel;
-    }
-    //...
-    _getUserName() {
-        return this._userModel.activeUserName();
-    }
-}
-```
-
-**Bad:**
-```Javascript
-import {appContext} from `.../services/app-context.js`;
-
-export class MyCustomElement extends ...{
-    created() {
-        // Incorrect: assign all dependencies in the constructor
-        this._userModel = appContext.userModel;
-    }
-    //...
-    _getUserName() {
-        // Incorrect: use appContext outside of a constructor
-        return appContext.userModel.activeUserName();
-    }
-}
-```
-
-<a name="assign-dependencies-legacy-element-example"></a>
-**Legacy element:**
-
-Before:
-```Javascript
-export class MyCustomElement extends ...LegacyElementMixin(...) {
-    constructor() {
-        super();
-        someAction();
-    }
-    created() {
-        super();
-        createdAction1();
-        createdAction2();
-    }
-}
-```
-
-After:
-```Javascript
-export class MyCustomElement extends ...LegacyElementMixin(...) {
-    constructor() {
-        super();
-        // Assign services here
-        this._userModel = appContext.userModel;
-        // Code from the created method - put it before existing actions in constructor
-        createdAction1();
-        createdAction2();
-        // Original constructor code
-        someAction();
-    }
-    // created method is removed
-}
-```
diff --git a/polygerrit-ui/README.md b/polygerrit-ui/README.md
index d599230..0848925 100644
--- a/polygerrit-ui/README.md
+++ b/polygerrit-ui/README.md
@@ -24,11 +24,13 @@
 
 Follow the instructions
 [here](https://gerrit-review.googlesource.com/Documentation/dev-bazel.html#_installation)
-to get and install Bazel.
+to get and install Bazel. The `npm install -g @bazel/bazelisk` method is
+probably easiest since you will have npm as part of Nodejs.
 
 ## Installing [Node.js](https://nodejs.org/en/download/) and npm packages
 
-The minimum nodejs version supported is 10.x+.
+The minimum nodejs version supported is 10.x+. We recommend at least the latest
+LTS (v16 as of October 2022).
 
 ```sh
 # Debian experimental
@@ -80,11 +82,12 @@
 
 ## Setup typescript support in the IDE
 
-Modern IDE should automatically handle typescript settings from the
-`polygerrit-ui/app/tsconfig.json` files. IDE places compiled files in the
-`.ts-out/pg` directory at the root of gerrit workspace and you can configure IDE
-to exclude the whole .ts-out directory. To do it in the IntelliJ IDEA click on
-this directory and select "Mark Directory As > Excluded" in the context menu.
+Modern IDEs should automatically handle typescript settings from the
+`polygerrit-ui/app/tsconfig.json` files. The `tsc` compiler places compiled
+files in the `.ts-out/pg` directory at the root of gerrit workspace and you can
+configure the IDE to exclude the whole .ts-out directory. To do it in the
+IntelliJ IDEA click on this directory and select "Mark Directory As > Excluded"
+in the context menu.
 
 However, if you receive some errors from IDE, you can try to configure IDE
 manually. For example, if IntelliJ IDEA shows
@@ -92,22 +95,27 @@
 options `--project polygerrit-ui/app/tsconfig.json` in the IDE settings.
 
 
-## Serving files locally
+## Developing locally
 
-#### Web Dev Server
+The preferred method for development is to serve the web files locally using the
+Web Dev Server and then view a running gerrit instance (local or otherwise) to
+replace its web client with the local one using the Gerrit FE Dev Helper
+extension.
 
-To test the local frontend against production data or a local test site execute:
+### Web Dev Server
+
+The [Web Dev Server](https://modern-web.dev/docs/dev-server/overview/) serves
+the compiled web files and dependencies unbundled over localhost. Start it using
+this command:
 
 ```sh
 yarn start
 ```
 
-This command starts the [Web Dev Server](https://modern-web.dev/docs/dev-server/overview/).
 To inject plugins or other files, we use the [Gerrit FE Dev Helper](https://chrome.google.com/webstore/detail/gerrit-fe-dev-helper/jimgomcnodkialnpmienbomamgomglkd) Chrome extension.
 
 If any issues occured, please refer to the Troubleshooting section at the bottom or contact the team!
 
-## Running locally against production data
 
 ### Chrome extension: Gerrit FE Dev Helper
 
@@ -120,7 +128,7 @@
 
 To use this extension, just follow its [readme here](https://gerrit.googlesource.com/gerrit-fe-dev-helper/+/master/README.md).
 
-## Running locally against a Gerrit test site
+### Running locally against a Gerrit test site
 
 Set up a local test site once:
 
@@ -144,62 +152,49 @@
     --dev-cdn http://localhost:8081
 ```
 
+The Web Dev Server is currently not serving fonts or other static assets. Follow
+[Issue 16341](https://bugs.chromium.org/p/gerrit/issues/detail?id=16341) for
+fixing this issue.
+
 *NOTE* You can use any other cdn here, for example: https://cdn.googlesource.com/polygerrit_ui/678.0
 
 ## Running Tests
 
 For daily development you typically only want to run and debug individual tests.
-There are several ways to run tests.
+Our tests run using the
+[Web Test Runner](https://modern-web.dev/docs/test-runner/overview/). There are
+several ways to trigger tests:
 
-* Run all tests:
+* Run all tests once:
 ```sh
 yarn test
 ```
 
-* Run all tests under bazel:
+* Run all tests and then watches for changes. Change a file will trigger all
+tests affected by the changes.
+```sh
+yarn test:watch
+```
+
+* Run all tests once under bazel:
 ```sh
 ./polygerrit-ui/app/run_test.sh
 ```
 
-* Run a single test file:
+* Run a single test file and rerun on any changes affecting it:
 ```
-yarn test:single "**/async-foreach-behavior_test.js"
+yarn test:single "**/gr-comment_test.ts"
 ```
 
 Compiling code:
 ```sh
 # Compile frontend once to check for type errors:
-yarn compile:local
+yarn compile
 
 # Watch mode:
-## Terminal 1:
 yarn compile:watch
-## Terminal 2, test & watch a file for example:
-yarn test:single "**/async-foreach-behavior_test.js"
 ```
 
-### Generated file overview
-
-A generated file starts with imports followed by a static content with
-different type definitions. You can skip this part - it doesn't contains
-anything usefule.
-
-After the static content there is a class definition. Example:
-```typescript
-export class GrCreateGroupDialogCheck extends GrCreateGroupDialog {
-  templateCheck() {
-    // Converted template
-    // Each HTML element from the template is wrapped into own block.
-  }
-}
-```
-
-The converted template usually quite straightforward, but in some cases
-additional functions are added. For example, `<element x=[[y.a]]>` converts into
-`el.x = y!.a` if y is a simple type. However, if y has a union type, like - `y:A|B`,
-then the generated code looks like `el.x=__f(y)!.a` (`y!.a` may result in a TS error
-if `a` is defined only in one type of a union).
-
 ## Style guide
 
 We follow the [Google JavaScript Style Guide](https://google.github.io/styleguide/javascriptguide.xml)
diff --git a/polygerrit-ui/app/.eslintrc.js b/polygerrit-ui/app/.eslintrc.js
index c519465..e18a3af 100644
--- a/polygerrit-ui/app/.eslintrc.js
+++ b/polygerrit-ui/app/.eslintrc.js
@@ -327,6 +327,8 @@
           'error',
           {argsIgnorePattern: '^_'},
         ],
+        // https://github.com/mysticatea/eslint-plugin-node/blob/master/docs/rules/no-unsupported-features/es-builtins.md
+        'node/no-unsupported-features/es-builtins': 'off',
         // https://github.com/mysticatea/eslint-plugin-node/blob/master/docs/rules/no-unsupported-features/node-builtins.md
         'node/no-unsupported-features/node-builtins': 'off',
         // Disable no-invalid-this for ts files, because it incorrectly reports
@@ -399,19 +401,6 @@
       },
     },
     {
-      files: ['test/functional/**/*.js'],
-      // Settings for functional tests. These scripts are node scripts.
-      // Turn off "no-undef" to allow any global variable
-      env: {
-        browser: false,
-        node: true,
-        es6: false,
-      },
-      rules: {
-        'no-undef': 'off',
-      },
-    },
-    {
       files: ['*_html.js', 'gr-icons.js', '*-theme.js', '*-styles.js'],
       rules: {
         'max-len': 'off',
diff --git a/polygerrit-ui/app/BUILD b/polygerrit-ui/app/BUILD
index 2807a6d..925820c 100644
--- a/polygerrit-ui/app/BUILD
+++ b/polygerrit-ui/app/BUILD
@@ -203,7 +203,12 @@
         "--rules.no-property-visibility-mismatch off",
         "--rules.no-incompatible-property-type off",
         "--rules.no-incompatible-type-binding off",
-        "--rules.no-unknown-attribute error",
+        # TODO: We would actually like to change this to `error`, but we also
+        # want to allow certain attributes, for example `aria-description`. This
+        # would be possible, if we would run the lit-analyzer as a ts plugin.
+        # In tsconfig.json there is an option `globalAttributes` that we could
+        # use. But that is not available when running lit-analyzer as cli.
+        "--rules.no-unknown-attribute warn",
     ],
 )
 
diff --git a/polygerrit-ui/app/api/annotation.ts b/polygerrit-ui/app/api/annotation.ts
index c670c58..c394ef7 100644
--- a/polygerrit-ui/app/api/annotation.ts
+++ b/polygerrit-ui/app/api/annotation.ts
@@ -3,7 +3,7 @@
  * Copyright 2020 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-import {CoverageRange, Side} from './diff';
+import {CoverageRange} from './diff';
 import {ChangeInfo} from './rest-api';
 
 /**
@@ -28,18 +28,5 @@
    * providers are not supported. A second call will just overwrite the
    * provider of the first call.
    */
-  setCoverageProvider(coverageProvider: CoverageProvider): AnnotationPluginApi;
-
-  /**
-   * For plugins notifying Gerrit about new annotations being ready to be
-   * applied for a certain range. Gerrit will then re-render the relevant lines
-   * of the diff and call back to the layer annotation function that was
-   * registered in addLayer().
-   *
-   * @param path The file path whose listeners should be notified.
-   * @param start The line where the update starts.
-   * @param end The line where the update ends.
-   * @param side The side of the update ('left' or 'right').
-   */
-  notify(path: string, start: number, end: number, side: Side): void;
+  setCoverageProvider(coverageProvider: CoverageProvider): void;
 }
diff --git a/polygerrit-ui/app/api/checks.ts b/polygerrit-ui/app/api/checks.ts
index e631642..f66c373 100644
--- a/polygerrit-ui/app/api/checks.ts
+++ b/polygerrit-ui/app/api/checks.ts
@@ -3,8 +3,7 @@
  * Copyright 2020 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-import {CommentRange} from './core';
-import {ChangeInfo} from './rest-api';
+import {ChangeInfo, CommentRange} from './rest-api';
 
 export declare interface ChecksPluginApi {
   /**
diff --git a/polygerrit-ui/app/api/core.ts b/polygerrit-ui/app/api/core.ts
index c44edfb..d3d268a 100644
--- a/polygerrit-ui/app/api/core.ts
+++ b/polygerrit-ui/app/api/core.ts
@@ -9,33 +9,6 @@
  */
 
 /**
- * The CommentRange entity describes the range of an inline comment.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#comment-range
- *
- * The range includes all characters from the start position, specified by
- * start_line and start_character, to the end position, specified by end_line
- * and end_character. The start position is inclusive and the end position is
- * exclusive.
- *
- * So, a range over part of a line will have start_line equal to end_line;
- * however a range with end_line set to 5 and end_character equal to 0 will not
- * include any characters on line 5.
- */
-export declare interface CommentRange {
-  /** The start line number of the range. (1-based) */
-  start_line: number;
-
-  /** The character position in the start line. (0-based) */
-  start_character: number;
-
-  /** The end line number of the range. (1-based) */
-  end_line: number;
-
-  /** The character position in the end line. (0-based) */
-  end_character: number;
-}
-
-/**
  * Return type for cursor moves, that indicate whether a move was possible.
  */
 export enum CursorMoveResult {
diff --git a/polygerrit-ui/app/api/diff.ts b/polygerrit-ui/app/api/diff.ts
index 683638e..4bf253d 100644
--- a/polygerrit-ui/app/api/diff.ts
+++ b/polygerrit-ui/app/api/diff.ts
@@ -9,7 +9,8 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 
-import {CommentRange, CursorMoveResult} from './core';
+import {CursorMoveResult} from './core';
+import {CommentRange} from './rest-api';
 
 /**
  * Diff type in preferences
@@ -201,7 +202,7 @@
   syntax_highlighting?: boolean;
   tab_size: number;
   font_size: number;
-  // TODO: Missing documentation
+  // Hides the FILE and LOST diff rows. Default is TRUE.
   show_file_comment_button?: boolean;
   line_wrapping?: boolean;
 }
@@ -242,13 +243,14 @@
   image_diff_prefs?: ImageDiffPreferences;
   responsive_mode?: DiffResponsiveMode;
   num_lines_rendered_at_once?: number;
-  /**
-   * If enabled, then a new (experimental) diff rendering is used that is
-   * based on Lit components and multiple rendering passes. This is planned to
-   * be a temporary setting until the experiment is concluded.
-   */
-  use_lit_components?: boolean;
   show_sign_col?: boolean;
+  /**
+   * The default view mode is SIDE_BY_SIDE.
+   *
+   * Note that gr-diff also still supports setting viewMode as a dedicated
+   * property on <gr-diff>. TODO: Migrate usages to RenderPreferences.
+   */
+  view_mode?: DiffViewMode;
 }
 
 /**
@@ -329,6 +331,12 @@
   lineNum: LineNumber;
 }
 
+export declare interface LineSelectedEventDetail {
+  number: LineNumber;
+  side: Side;
+  path?: string;
+}
+
 // TODO: Currently unused and not fired.
 export declare interface RenderProgressEventDetail {
   linesRendered: number;
diff --git a/polygerrit-ui/app/api/embed.ts b/polygerrit-ui/app/api/embed.ts
index de2e1bf..af481fd 100644
--- a/polygerrit-ui/app/api/embed.ts
+++ b/polygerrit-ui/app/api/embed.ts
@@ -24,7 +24,8 @@
       TokenHighlightLayer: {
         new (
           container: HTMLElement,
-          listener?: TokenHighlightListener
+          listener?: TokenHighlightListener,
+          getTokenQueryContainer?: () => HTMLElement
         ): DiffLayer;
       };
     };
diff --git a/polygerrit-ui/app/api/gerrit.ts b/polygerrit-ui/app/api/gerrit.ts
index a5f7731..404907a 100644
--- a/polygerrit-ui/app/api/gerrit.ts
+++ b/polygerrit-ui/app/api/gerrit.ts
@@ -17,7 +17,7 @@
 export declare interface Gerrit {
   install(
     callback: (plugin: PluginApi) => void,
-    opt_version?: string,
+    version?: string,
     src?: string
   ): void;
   styles: Styles;
diff --git a/polygerrit-ui/app/api/package.json b/polygerrit-ui/app/api/package.json
index 79c8bb6..7df755b 100644
--- a/polygerrit-ui/app/api/package.json
+++ b/polygerrit-ui/app/api/package.json
@@ -1,9 +1,9 @@
 {
   "name": "@gerritcodereview/typescript-api",
-  "version": "3.7.0",
+  "version": "3.8.0",
   "description": "Gerrit Code Review - TypeScript API",
   "homepage": "https://www.gerritcodereview.com/",
   "browser": true,
   "dependencies": {},
   "license": "Apache-2.0"
-}
+}
\ No newline at end of file
diff --git a/polygerrit-ui/app/api/plugin.ts b/polygerrit-ui/app/api/plugin.ts
index b9c065f..d3d012d 100644
--- a/polygerrit-ui/app/api/plugin.ts
+++ b/polygerrit-ui/app/api/plugin.ts
@@ -14,6 +14,7 @@
 import {ChangeActionsPluginApi} from './change-actions';
 import {RestPluginApi} from './rest';
 import {HookApi, RegisterOptions} from './hook';
+import {StylePluginApi} from './styles';
 
 export enum TargetElement {
   CHANGE_ACTIONS = 'changeactions',
@@ -22,19 +23,15 @@
 
 // Note: for new events, naming convention should be: `a-b`
 export enum EventType {
-  HISTORY = 'history',
   LABEL_CHANGE = 'labelchange',
   SHOW_CHANGE = 'showchange',
   SUBMIT_CHANGE = 'submitchange',
   SHOW_REVISION_ACTIONS = 'show-revision-actions',
   COMMIT_MSG_EDIT = 'commitmsgedit',
-  COMMENT = 'comment',
   REVERT = 'revert',
   REVERT_SUBMISSION = 'revert_submission',
   POST_REVERT = 'postrevert',
-  ANNOTATE_DIFF = 'annotatediff',
   ADMIN_MENU_LINKS = 'admin-menu-links',
-  HIGHLIGHTJS_LOADED = 'highlightjs-loaded',
 }
 
 export declare interface PluginApi {
@@ -81,10 +78,9 @@
     moduleName?: string,
     options?: RegisterOptions
   ): HookApi<T>;
-  // DEPRECATED: Just add <style> elements to `document.head`.
-  registerStyleModule(endpoint: string, moduleName: string): void;
   reporting(): ReportingPluginApi;
   restApi(): RestPluginApi;
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
   screen(screenName: string, moduleName?: string): any;
+  styleApi(): StylePluginApi;
 }
diff --git a/polygerrit-ui/app/api/rest-api.ts b/polygerrit-ui/app/api/rest-api.ts
index 0ed4e0d..993f24d 100644
--- a/polygerrit-ui/app/api/rest-api.ts
+++ b/polygerrit-ui/app/api/rest-api.ts
@@ -142,9 +142,9 @@
 }
 
 /**
- * The state of the projects
+ * The state of the repository
  */
-export enum ProjectState {
+export enum RepoState {
   ACTIVE = 'ACTIVE',
   READ_ONLY = 'READ_ONLY',
   HIDDEN = 'HIDDEN',
@@ -452,12 +452,11 @@
  */
 export declare interface CommentLinkInfo {
   match: string;
-  link?: string;
+  link: string;
   prefix?: string;
   suffix?: string;
   text?: string;
   enabled?: boolean;
-  html?: string;
 }
 
 export declare interface CommentLinks {
@@ -489,7 +488,7 @@
 
 /**
  * The ConfigInfo entity contains information about the effective
- * project configuration.
+ * repository configuration.
  * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#config-info
  */
 export declare interface ConfigInfo {
@@ -508,7 +507,7 @@
   default_submit_type: SubmitTypeInfo;
   submit_type: SubmitType;
   match_author_to_committer_date?: InheritedBooleanInfo;
-  state?: ProjectState;
+  state?: RepoState;
   commentlinks: CommentLinks;
   plugin_config?: PluginNameToPluginParametersMap;
   actions?: {[viewName: string]: ActionInfo};
@@ -516,6 +515,33 @@
   enable_reviewer_by_email: InheritedBooleanInfo;
 }
 
+/**
+ * The CommentRange entity describes the range of an inline comment.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#comment-range
+ *
+ * The range includes all characters from the start position, specified by
+ * start_line and start_character, to the end position, specified by end_line
+ * and end_character. The start position is inclusive and the end position is
+ * exclusive.
+ *
+ * So, a range over part of a line will have start_line equal to end_line;
+ * however a range with end_line set to 5 and end_character equal to 0 will not
+ * include any characters on line 5.
+ */
+export declare interface CommentRange {
+  /** The start line number of the range. (1-based) */
+  start_line: number;
+
+  /** The character position in the start line. (0-based) */
+  start_character: number;
+
+  /** The end line number of the range. (1-based) */
+  end_line: number;
+
+  /** The character position in the end line. (0-based) */
+  end_character: number;
+}
+
 export declare interface ConfigListParameterInfo
   extends ConfigParameterInfoBase {
   type: ConfigParameterInfoType.LIST;
@@ -619,6 +645,8 @@
   lines_deleted?: number;
   size_delta?: number; // in bytes
   size?: number; // in bytes
+  old_mode?: number;
+  new_mode?: number;
 }
 
 /**
@@ -668,7 +696,6 @@
   name: string;
   email: EmailAddress;
   date: Timestamp;
-  tz: TimezoneOffset;
 }
 
 export type GroupId = BrandType<string, '_groupId'>;
@@ -740,7 +767,7 @@
 export type LabelNameToLabelTypeInfoMap = {[labelName: string]: LabelTypeInfo};
 
 /**
- * The LabelTypeInfo entity contains metadata about the labels that a project
+ * The LabelTypeInfo entity contains metadata about the labels that a repository
  * has.
  * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#label-type-info
  */
@@ -756,7 +783,7 @@
 
 /**
  * The MaxObjectSizeLimitInfo entity contains information about the max object
- * size limit of a project.
+ * size limit of a repository.
  * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#max-object-size-limit-info
  */
 export declare interface MaxObjectSizeLimitInfo {
@@ -835,23 +862,23 @@
 }
 
 /**
- * The ProjectInfo entity contains information about a project
+ * The ProjectInfo entity contains information about a repository
  * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#project-info
  */
 export declare interface ProjectInfo {
   id: UrlEncodedRepoName;
-  // name is not set if returned in a map where the project name is used as
+  // name is not set if returned in a map where the repo name is used as
   // map key
   name?: RepoName;
-  // ?-<n> if the parent project is not visible (<n> is a number which
-  // is increased for each non-visible project).
+  // ?-<n> if the parent repository is not visible (<n> is a number which
+  // is increased for each non-visible repository).
   parent?: RepoName;
   description?: string;
-  state?: ProjectState;
+  state?: RepoState;
   branches?: {[branchName: string]: CommitId};
-  // labels is filled for Create Project and Get Project calls.
+  // labels is filled for Create Repo and Get Repo calls.
   labels?: LabelNameToLabelTypeInfoMap;
-  // Links to the project in external sites
+  // Links to the repository in external sites
   web_links?: WebLinkInfo[];
 }
 
@@ -1005,8 +1032,8 @@
 // where "'ffffffffff'" represents nanoseconds.
 
 /**
- * Information about the default submittype of a project, taking into account
- * project inheritance.
+ * Information about the default submittype of a repository, taking into account
+ * repository inheritance.
  * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#submit-type-info
  */
 export declare interface SubmitTypeInfo {
@@ -1027,8 +1054,6 @@
 export type Timestamp = BrandType<string, '_timestamp'>;
 // The timezone offset from UTC in minutes
 
-export type TimezoneOffset = BrandType<number, '_timezoneOffset'>;
-
 export type TopicName = BrandType<string, '_topicName'>;
 
 export type TrackingId = BrandType<string, '_trackingId'>;
@@ -1067,14 +1092,14 @@
  * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#web-link-info
  */
 export declare interface WebLinkInfo {
-  /** The link name. */
+  /** The text to be linkified. */
   name: string;
+  /** Tooltip to show when hovering over the link. */
+  tooltip?: string;
   /** The link URL. */
   url: string;
   /** URL to the icon of the link. */
   image_url?: string;
-  /* Value of the "target" attribute for anchor elements. */
-  target?: string;
 }
 
 /**
@@ -1169,7 +1194,7 @@
   /**
    * The label is required for submission, but is impossible to complete.
    * The likely cause is access has not been granted correctly by the
-   * project owner or site administrator.
+   * repository owner or site administrator.
    */
   IMPOSSIBLE = 'IMPOSSIBLE',
   OPTIONAL = 'OPTIONAL',
diff --git a/polygerrit-ui/app/api/rest.ts b/polygerrit-ui/app/api/rest.ts
index 283e029..a09f711 100644
--- a/polygerrit-ui/app/api/rest.ts
+++ b/polygerrit-ui/app/api/rest.ts
@@ -15,7 +15,10 @@
   PUT = 'PUT',
 }
 
-export type ErrorCallback = (response?: Response | null, err?: Error) => void;
+export type ErrorCallback = (
+  response?: Response | null,
+  err?: Error
+) => Promise<void> | void;
 
 export declare interface RestPluginApi {
   getLoggedIn(): Promise<boolean>;
diff --git a/polygerrit-ui/app/api/styles.ts b/polygerrit-ui/app/api/styles.ts
index 1e1f60a..6ca8496 100644
--- a/polygerrit-ui/app/api/styles.ts
+++ b/polygerrit-ui/app/api/styles.ts
@@ -22,6 +22,7 @@
   toString(): string;
 }
 
+/** Accessible via `window.Gerrit.styles`. */
 export declare interface Styles {
   font: Style;
   form: Style;
@@ -30,4 +31,24 @@
   spinner: Style;
   subPage: Style;
   table: Style;
+  modal: Style;
+}
+
+/** Accessible via `window.Gerrit.install(plugin => {plugin.styleApi()})`. */
+export declare interface StylePluginApi {
+  /**
+   * Convenience method for adding a CSS rule to a <style> element in <head>.
+   *
+   * Note that you can only insert one rule per call. See `insertRule()`
+   * documentation of `CSSStyleSheet`:
+   * https://developer.mozilla.org/en-US/docs/Web/API/CSSStyleSheet/insertRule
+   *
+   * @param rule the css rule, e.g.:
+   *        ```
+   *          html.darkTheme {
+   *            --header-background-color: blue;
+   *          }
+   *        ```
+   */
+  insertCSSRule(rule: string): void;
 }
diff --git a/polygerrit-ui/app/constants/constants.ts b/polygerrit-ui/app/constants/constants.ts
index bb7b313..b9ed56b 100644
--- a/polygerrit-ui/app/constants/constants.ts
+++ b/polygerrit-ui/app/constants/constants.ts
@@ -22,7 +22,7 @@
   InheritedBooleanInfoConfiguredValue,
   MergeabilityComputationBehavior,
   ProblemInfoStatus,
-  ProjectState,
+  RepoState,
   RequirementStatus,
   ReviewerState,
   RevisionKind,
@@ -41,7 +41,7 @@
   InheritedBooleanInfoConfiguredValue,
   MergeabilityComputationBehavior,
   ProblemInfoStatus,
-  ProjectState,
+  RepoState,
   RequirementStatus,
   ReviewerState,
   RevisionKind,
@@ -258,6 +258,8 @@
   NONE = 'NONE',
 }
 
+// These defaults should match the defaults in
+// java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
 export function createDefaultPreferences(): PreferencesInfo {
   return {
     changes_per_page: 25,
@@ -265,8 +267,8 @@
     size_bar_in_change_table: true,
     my: [],
     theme: AppTheme.AUTO,
-    date_format: DateFormat.EURO,
-    time_format: TimeFormat.HHMM_24,
+    date_format: DateFormat.STD,
+    time_format: TimeFormat.HHMM_12,
     change_table: [],
     email_strategy: EmailStrategy.ATTENTION_SET_ONLY,
     default_base_for_merges: DefaultBase.AUTO_MERGE,
@@ -319,6 +321,4 @@
 
 export const RELOAD_DASHBOARD_INTERVAL_MS = 10 * 1000;
 
-export const SHOWN_ITEMS_COUNT = 25;
-
 export const WAITING = 'Waiting';
diff --git a/polygerrit-ui/app/constants/reporting.ts b/polygerrit-ui/app/constants/reporting.ts
index 0e00d07..ad59edd 100644
--- a/polygerrit-ui/app/constants/reporting.ts
+++ b/polygerrit-ui/app/constants/reporting.ts
@@ -14,6 +14,8 @@
   PLUGINS_INSTALLED = 'Plugins installed',
   PLUGINS_FAILED = 'Some plugins failed to load',
   USER_REFERRED_FROM = 'User referred from',
+  NOTIFICATION_PERMISSION = 'Notification Permission',
+  SERVICE_WORKER_UPDATE = 'Service worker update',
 }
 
 export enum Execution {
@@ -91,6 +93,8 @@
   FID = 'FID',
   // WebVitals - Largest Contentful Paint (LCP): measures loading performance.
   LCP = 'LCP',
+  // WebVitals - Interaction to Next Paint (INP): measures responsiveness
+  INP = 'INP',
 }
 
 export enum Interaction {
@@ -120,32 +124,7 @@
   CHECKS_RUNS_PANEL_TOGGLE = 'checks-runs-panel-toggle',
   CHECKS_RUNS_SELECTED_TRIGGERED = 'checks-runs-selected-triggered',
   CHECKS_STATS = 'checks-stats',
-  // The following interactions are logged for investigating a spurious bug of
-  // auto-closing draft comments.
-  COMMENTS_AUTOCLOSE_FIRST_UPDATE = 'comments-autoclose-first-update',
-  COMMENTS_AUTOCLOSE_EDITING_FALSE_SAVE = 'comments-autoclose-editing-false-save',
-  COMMENTS_AUTOCLOSE_EDITING_DISCONNECTED = 'comments-autoclose-editing-disconnected',
-  COMMENTS_AUTOCLOSE_EDITING_THREAD_DISCONNECTED = 'comments-autoclose-editing-thread-disconnected',
-  COMMENTS_AUTOCLOSE_CHECKS_UPDATED = 'comments-autoclose-checks-updated',
-  COMMENTS_AUTOCLOSE_THREADS_UPDATED = 'comments-autoclose-threads-updated',
-  COMMENTS_AUTOCLOSE_COMMENT_REMOVED = 'comments-autoclose-comment-removed',
-  COMMENTS_AUTOCLOSE_MESSAGES_LIST_CREATED = 'comments-autoclose-messages-list-created',
-  COMMENTS_AUTOCLOSE_MESSAGES_LIST_UPDATED = 'comments-autoclose-messages-list-updated',
-  COMMENTS_AUTOCLOSE_THREAD_LIST_CREATED = 'comments-autoclose-thread-list-created',
-  COMMENTS_AUTOCLOSE_THREAD_LIST_UPDATED = 'comments-autoclose-thread-list-updated',
-  // The following interactions are logged for investigating a spurious bug of
-  // auto-closing diffs.
-  DIFF_AUTOCLOSE_DIFF_UNDEFINED = 'diff-autoclose-diff-undefined',
-  DIFF_AUTOCLOSE_DIFF_ONGOING = 'diff-autoclose-diff-ongoing',
-  DIFF_AUTOCLOSE_RELOAD_ON_WHITESPACE = 'diff-autoclose-reload-on-whitespace',
-  DIFF_AUTOCLOSE_RELOAD_ON_SYNTAX = 'diff-autoclose-reload-on-syntax',
-  DIFF_AUTOCLOSE_RELOAD_FILELIST_PREFS = 'diff-autoclose-reload-filelist-prefs',
-  DIFF_AUTOCLOSE_DIFF_HOST_CREATED = 'diff-autoclose-diff-host-created',
-  DIFF_AUTOCLOSE_DIFF_HOST_NOT_RENDERING = 'diff-autoclose-diff-not-rendering',
-  DIFF_AUTOCLOSE_FILE_LIST_UPDATED = 'diff-autoclose-file-list-updated',
-  DIFF_AUTOCLOSE_SHOWN_FILES_CHANGED = 'diff-autoclose-shown-files-changed',
-  // The following interaction is logged for reporting and counting a suspected
-  // Chrome bug that leads to html`` misbehavior.
-  AUTOCLOSE_HTML_PATCHED = 'autoclose-html-patched',
   CHANGE_ACTION_FIRED = 'change-action-fired',
+  BUTTON_CLICK = 'button-click',
+  LINK_CLICK = 'link-click',
 }
diff --git a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.ts b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.ts
index d5a83a7..e15c240 100644
--- a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.ts
+++ b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.ts
@@ -16,7 +16,7 @@
 import {
   EditablePermissionInfo,
   PermissionAccessSection,
-  EditableProjectAccessGroups,
+  EditableRepoAccessGroups,
 } from '../gr-repo-access/gr-repo-access-interfaces';
 import {
   CapabilityInfoMap,
@@ -24,7 +24,7 @@
   LabelNameToLabelTypeInfoMap,
   RepoName,
 } from '../../../types/common';
-import {fire, fireEvent} from '../../../utils/event-util';
+import {fire} from '../../../utils/event-util';
 import {IronInputElement} from '@polymer/iron-input/iron-input';
 import {fontStyles} from '../../../styles/gr-font-styles';
 import {formStyles} from '../../../styles/gr-form-styles';
@@ -34,25 +34,11 @@
 import {BindValueChangeEvent, ValueChangedEvent} from '../../../types/events';
 import {assertIsDefined, queryAndAssert} from '../../../utils/common-util';
 
-/**
- * Fired when the section has been modified or removed.
- *
- * @event access-modified
- */
-
-/**
- * Fired when a section that was previously added was removed.
- *
- * @event added-section-removed
- */
-
 const GLOBAL_NAME = 'GLOBAL_CAPABILITIES';
 
 // The name that gets automatically input when a new reference is added.
 const NEW_NAME = 'refs/heads/*';
 const REFS_NAME = 'refs/';
-const ON_BEHALF_OF = '(On Behalf Of)';
-const LABEL = 'Label';
 
 @customElement('gr-access-section')
 export class GrAccessSection extends LitElement {
@@ -68,7 +54,7 @@
   section?: PermissionAccessSection;
 
   @property({type: Object})
-  groups?: EditableProjectAccessGroups;
+  groups?: EditableRepoAccessGroups;
 
   @property({type: Object})
   labels?: LabelNameToLabelTypeInfoMap;
@@ -300,7 +286,7 @@
       // For a new section, this is not fired because new permissions and
       // rules have to be added in order to save, modifying the ref is not
       // enough.
-      fireEvent(this, 'access-modified');
+      fire(this, 'access-modified', {});
     }
     this.section.value.updatedId = this.section.id;
     this.requestUpdate();
@@ -372,14 +358,14 @@
       labelOptions.push({
         id: 'label-' + labelName,
         value: {
-          name: `${LABEL} ${labelName}`,
+          name: `Label ${labelName}`,
           id: 'label-' + labelName,
         },
       });
       labelOptions.push({
         id: 'labelAs-' + labelName,
         value: {
-          name: `${LABEL} ${labelName} ${ON_BEHALF_OF}`,
+          name: `Label ${labelName} (On Behalf Of)`,
           id: 'labelAs-' + labelName,
         },
       });
@@ -396,11 +382,13 @@
     } else if (AccessPermissions[permission.id]) {
       return AccessPermissions[permission.id]?.name;
     } else if (permission.value.label) {
-      let behalfOf = '';
       if (permission.id.startsWith('labelAs-')) {
-        behalfOf = ON_BEHALF_OF;
+        return `Label ${permission.value.label} (On Behalf Of)`;
+      } else if (permission.id.startsWith('removeLabel-')) {
+        return `Remove Label ${permission.value.label}`;
+      } else {
+        return `Label ${permission.value.label}`;
       }
-      return `${LABEL} ${permission.value.label}${behalfOf}`;
     }
     return undefined;
   }
@@ -432,11 +420,11 @@
       return;
     }
     if (this.section.value.added) {
-      fireEvent(this, 'added-section-removed');
+      fire(this, 'added-section-removed', {});
     }
     this.deleted = true;
     this.section.value.deleted = true;
-    fireEvent(this, 'access-modified');
+    fire(this, 'access-modified', {});
   }
 
   _handleUndoRemove() {
@@ -533,6 +521,10 @@
 
 declare global {
   interface HTMLElementEventMap {
+    /** Fired when the section has been modified or removed. */
+    'access-modified': CustomEvent<{}>;
+    /** Fired when a section that was previously added was removed. */
+    'added-section-removed': CustomEvent<{}>;
     'section-changed': ValueChangedEvent<PermissionAccessSection>;
   }
   interface HTMLElementTagNameMap {
diff --git a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_test.ts b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_test.ts
index 593a1ed..2c397e0 100644
--- a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_test.ts
@@ -355,7 +355,20 @@
 
       assert.equal(
         element.computePermissionName(permission),
-        'Label Code-Review(On Behalf Of)'
+        'Label Code-Review (On Behalf Of)'
+      );
+
+      permission = {
+        id: 'removeLabel-Code-Review' as GitRef,
+        value: {
+          label: 'Code-Review',
+          rules: {},
+        },
+      };
+
+      assert.equal(
+        element.computePermissionName(permission),
+        'Remove Label Code-Review'
       );
     });
 
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.ts b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.ts
index 5d32d32..b30619e 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.ts
+++ b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.ts
@@ -5,21 +5,24 @@
  */
 import '../../shared/gr-dialog/gr-dialog';
 import '../../shared/gr-list-view/gr-list-view';
-import '../../shared/gr-overlay/gr-overlay';
 import '../gr-create-group-dialog/gr-create-group-dialog';
-import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
 import {GroupId, GroupInfo, GroupName} from '../../../types/common';
 import {GrCreateGroupDialog} from '../gr-create-group-dialog/gr-create-group-dialog';
 import {fireTitleChange} from '../../../utils/event-util';
 import {getAppContext} from '../../../services/app-context';
-import {SHOWN_ITEMS_COUNT} from '../../../constants/constants';
 import {tableStyles} from '../../../styles/gr-table-styles';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, PropertyValues, css, html} from 'lit';
 import {customElement, query, property, state} from 'lit/decorators.js';
 import {assertIsDefined} from '../../../utils/common-util';
-import {AdminViewState} from '../../../models/views/admin';
+import {
+  AdminChildView,
+  AdminViewState,
+  createAdminUrl,
+} from '../../../models/views/admin';
 import {createGroupUrl} from '../../../models/views/group';
+import {whenVisible} from '../../../utils/dom-util';
+import {modalStyles} from '../../../styles/gr-modal-styles';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -29,9 +32,7 @@
 
 @customElement('gr-admin-group-list')
 export class GrAdminGroupList extends LitElement {
-  readonly path = '/admin/groups';
-
-  @query('#createOverlay') private createOverlay?: GrOverlay;
+  @query('#createModal') private createModal?: HTMLDialogElement;
 
   @query('#createNewModal') private createNewModal?: GrCreateGroupDialog;
 
@@ -41,34 +42,33 @@
   /**
    * Offset of currently visible query results.
    */
-  @state() private offset = 0;
+  @state() offset = 0;
 
-  @state() private hasNewGroupName = false;
+  @state() hasNewGroupName = false;
 
-  @state() private createNewCapability = false;
+  @state() createNewCapability = false;
 
-  // private but used in test
   @state() groups: GroupInfo[] = [];
 
-  @state() private groupsPerPage = 25;
+  @state() groupsPerPage = 25;
 
-  // private but used in test
   @state() loading = true;
 
-  @state() private filter = '';
+  @state() filter = '';
 
   private readonly restApiService = getAppContext().restApiService;
 
   override connectedCallback() {
     super.connectedCallback();
     this.getCreateGroupCapability();
-    fireTitleChange(this, 'Groups');
+    fireTitleChange('Groups');
   }
 
   static override get styles() {
     return [
       tableStyles,
       sharedStyles,
+      modalStyles,
       css`
         gr-list-view {
           --generic-list-description-width: 70%;
@@ -86,7 +86,7 @@
         .itemsPerPage=${this.groupsPerPage}
         .loading=${this.loading}
         .offset=${this.offset}
-        .path=${this.path}
+        .path=${createAdminUrl({adminView: AdminChildView.GROUPS})}
         @create-clicked=${() => this.handleCreateClicked()}
       >
         <table id="list" class="genericList">
@@ -105,12 +105,12 @@
           </tbody>
           <tbody class=${this.loading ? 'loading' : ''}>
             ${this.groups
-              .slice(0, SHOWN_ITEMS_COUNT)
+              .slice(0, this.groupsPerPage)
               .map(group => this.renderGroupList(group))}
           </tbody>
         </table>
       </gr-list-view>
-      <gr-overlay id="createOverlay" with-backdrop>
+      <dialog id="createModal" tabindex="-1">
         <gr-dialog
           id="createDialog"
           class="confirmDialog"
@@ -128,7 +128,7 @@
             ></gr-create-group-dialog>
           </div>
         </gr-dialog>
-      </gr-overlay>
+      </dialog>
     `;
   }
 
@@ -156,7 +156,7 @@
   paramsChanged() {
     this.filter = this.params?.filter ?? '';
     this.offset = Number(this.params?.offset ?? 0);
-    this.maybeOpenCreateOverlay(this.params);
+    this.maybeOpenCreateModal(this.params);
 
     return this.getGroups(this.filter, this.groupsPerPage, this.offset);
   }
@@ -166,18 +166,14 @@
    *
    * private but used in test
    */
-  maybeOpenCreateOverlay(params?: AdminViewState) {
+  async maybeOpenCreateModal(params?: AdminViewState) {
     if (params?.openCreateModal) {
-      assertIsDefined(this.createOverlay, 'createOverlay');
-      this.createOverlay.open();
+      await this.updateComplete;
+      if (!this.createModal?.open) this.createModal?.showModal();
     }
   }
 
-  /**
-   * Generates groups link (/admin/groups/<uuid>)
-   *
-   * private but used in test
-   */
+  // private but used in test
   computeGroupUrl(encodedId: string) {
     const groupId = decodeURIComponent(encodedId) as GroupId;
     return createGroupUrl({groupId});
@@ -229,14 +225,15 @@
 
   // private but used in test
   handleCloseCreate() {
-    assertIsDefined(this.createOverlay, 'createOverlay');
-    this.createOverlay.close();
+    assertIsDefined(this.createModal, 'createModal');
+    this.createModal.close();
   }
 
   // private but used in test
   handleCreateClicked() {
-    assertIsDefined(this.createOverlay, 'createOverlay');
-    this.createOverlay.open().then(() => {
+    assertIsDefined(this.createModal, 'createModal');
+    this.createModal.showModal();
+    whenVisible(this.createModal, () => {
       assertIsDefined(this.createNewModal, 'createNewModal');
       this.createNewModal.focus();
     });
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_test.ts b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_test.ts
index e484489..fe5aa22 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_test.ts
@@ -15,8 +15,6 @@
 import {GerritView} from '../../../services/router/router-model';
 import {GrListView} from '../../shared/gr-list-view/gr-list-view';
 import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
-import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
-import {SHOWN_ITEMS_COUNT} from '../../../constants/constants';
 import {fixture, html, assert} from '@open-wc/testing';
 import {AdminChildView, AdminViewState} from '../../../models/views/admin';
 
@@ -83,13 +81,7 @@
             <tbody class="loading"></tbody>
           </table>
         </gr-list-view>
-        <gr-overlay
-          aria-hidden="true"
-          id="createOverlay"
-          style="outline: none; display: none;"
-          tabindex="-1"
-          with-backdrop=""
-        >
+        <dialog id="createModal" tabindex="-1">
           <gr-dialog
             class="confirmDialog"
             confirm-label="Create"
@@ -104,7 +96,7 @@
               </gr-create-group-dialog>
             </div>
           </gr-dialog>
-        </gr-overlay>
+        </dialog>
       `
     );
   });
@@ -124,21 +116,23 @@
     });
 
     test('groups', () => {
-      assert.equal(element.groups.slice(0, SHOWN_ITEMS_COUNT).length, 25);
+      const table = queryAndAssert(element, 'table');
+      const rows = table.querySelectorAll('tr.table');
+      assert.equal(rows.length, element.groupsPerPage);
     });
 
-    test('maybeOpenCreateOverlay', () => {
-      const overlayOpen = sinon.stub(
-        queryAndAssert<GrOverlay>(element, '#createOverlay'),
-        'open'
+    test('maybeOpenCreateModal', async () => {
+      const modalOpen = sinon.stub(
+        queryAndAssert<HTMLDialogElement>(element, '#createModal'),
+        'showModal'
       );
-      element.maybeOpenCreateOverlay();
-      assert.isFalse(overlayOpen.called);
-      element.maybeOpenCreateOverlay(undefined);
-      assert.isFalse(overlayOpen.called);
+      await element.maybeOpenCreateModal();
+      assert.isFalse(modalOpen.called);
+      await element.maybeOpenCreateModal(undefined);
+      assert.isFalse(modalOpen.called);
       value.openCreateModal = true;
-      element.maybeOpenCreateOverlay(value);
-      assert.isTrue(overlayOpen.called);
+      await element.maybeOpenCreateModal(value);
+      assert.isTrue(modalOpen.called);
     });
   });
 
@@ -152,7 +146,9 @@
     });
 
     test('groups', () => {
-      assert.equal(element.groups.slice(0, SHOWN_ITEMS_COUNT).length, 25);
+      const table = queryAndAssert(element, 'table');
+      const rows = table.querySelectorAll('tr.table');
+      assert.equal(rows.length, element.groupsPerPage);
     });
   });
 
@@ -205,9 +201,10 @@
     });
 
     test('handleCreateClicked opens modal', () => {
-      const openStub = sinon
-        .stub(queryAndAssert<GrOverlay>(element, '#createOverlay'), 'open')
-        .returns(Promise.resolve());
+      const openStub = sinon.stub(
+        queryAndAssert<HTMLDialogElement>(element, '#createModal'),
+        'showModal'
+      );
       element.handleCreateClicked();
       assert.isTrue(openStub.called);
     });
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.ts b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.ts
index 088002c..8cadd23 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.ts
+++ b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.ts
@@ -17,15 +17,7 @@
 import '../gr-repo-dashboards/gr-repo-dashboards';
 import '../gr-repo-detail-list/gr-repo-detail-list';
 import '../gr-repo-list/gr-repo-list';
-import {getBaseUrl} from '../../../utils/url-util';
 import {navigationToken} from '../../core/gr-navigation/gr-navigation';
-import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
-import {
-  AdminNavLinksOption,
-  getAdminLinks,
-  NavLink,
-  SubsectionInterface,
-} from '../../../utils/admin-nav-util';
 import {
   AccountDetailInfo,
   GroupId,
@@ -34,7 +26,10 @@
 } from '../../../types/common';
 import {GroupNameChangedDetail} from '../gr-group/gr-group';
 import {getAppContext} from '../../../services/app-context';
-import {GerritView} from '../../../services/router/router-model';
+import {
+  GerritView,
+  routerModelToken,
+} from '../../../services/router/router-model';
 import {menuPageStyles} from '../../../styles/gr-menu-page-styles';
 import {pageNavStyles} from '../../../styles/gr-page-nav-styles';
 import {sharedStyles} from '../../../styles/shared-styles';
@@ -46,6 +41,10 @@
   AdminChildView,
   adminViewModelToken,
   AdminViewState,
+  AdminNavLinksOption,
+  getAdminLinks,
+  NavLink,
+  SubsectionInterface,
 } from '../../../models/views/admin';
 import {
   GroupDetailView,
@@ -59,6 +58,7 @@
 } from '../../../models/views/repo';
 import {resolve} from '../../../models/dependency';
 import {subscribe} from '../../lit/subscription-controller';
+import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 
 const INTERNAL_GROUP_REGEX = /^[\da-f]{40}$/;
 
@@ -110,22 +110,23 @@
   private reloading = false;
 
   // private but used in the tests
-  readonly jsAPI = getAppContext().jsApiService;
-
   private readonly restApiService = getAppContext().restApiService;
 
+  private readonly getPluginLoader = resolve(this, pluginLoaderToken);
+
   private readonly getAdminViewModel = resolve(this, adminViewModelToken);
 
   private readonly getGroupViewModel = resolve(this, groupViewModelToken);
 
   private readonly getRepoViewModel = resolve(this, repoViewModelToken);
 
-  private readonly routerModel = getAppContext().routerModel;
+  private readonly getRouterModel = resolve(this, routerModelToken);
 
   private readonly getNavigation = resolve(this, navigationToken);
 
   constructor() {
     super();
+    this.addEventListener('reload', () => window.location.reload());
     subscribe(
       this,
       () => this.getAdminViewModel().state$,
@@ -152,7 +153,7 @@
     );
     subscribe(
       this,
-      () => this.routerModel.routerView$,
+      () => this.getRouterModel().routerView$,
       view => {
         this.view = view;
         if (this.needsReload()) this.reload();
@@ -415,7 +416,10 @@
 
     return html`
       <div class="main breadcrumbs">
-        <gr-repo-commands .repo=${this.repoViewState.repo}></gr-repo-commands>
+        <gr-repo-commands
+          .repo=${this.repoViewState.repo}
+          .createEdit=${this.repoViewState.createEdit}
+        ></gr-repo-commands>
       </div>
     `;
   }
@@ -457,7 +461,7 @@
       const promises: [Promise<AccountDetailInfo | undefined>, Promise<void>] =
         [
           this.restApiService.getAccount(),
-          getPluginLoader().awaitPluginsLoaded(),
+          this.getPluginLoader().awaitPluginsLoaded(),
         ];
       const result = await Promise.all(promises);
       this.account = result[0];
@@ -487,7 +491,7 @@
             }
             return capabilities;
           }),
-        () => this.jsAPI.getAdminMenuLinks(),
+        () => this.getPluginLoader().jsApiService.getAdminMenuLinks(),
         options
       );
       this.filteredLinks = res.links;
@@ -593,12 +597,7 @@
 
   // private but used in test
   computeLinkURL(link?: NavLink | SubsectionInterface) {
-    if (!link || typeof link.url === 'undefined') return '';
-
-    if ((link as NavLink).target || !(link as NavLink).noBaseUrl) {
-      return link.url;
-    }
-    return `//${window.location.host}${getBaseUrl()}${link.url}`;
+    return link?.url || '';
   }
 
   private computeSelectedClass(
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.ts b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.ts
index d65d171..1d456c9 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.ts
@@ -6,8 +6,7 @@
 import '../../../test/common-test-setup';
 import './gr-admin-view';
 import {AdminSubsectionLink, GrAdminView} from './gr-admin-view';
-import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
-import {stubBaseUrl, stubElement, stubRestApi} from '../../../test/test-utils';
+import {stubElement, stubRestApi} from '../../../test/test-utils';
 import {GerritView} from '../../../services/router/router-model';
 import {query, queryAll, queryAndAssert} from '../../../test/test-utils';
 import {GrRepoList} from '../gr-repo-list/gr-repo-list';
@@ -20,6 +19,10 @@
 import {RepoDetailView} from '../../../models/views/repo';
 import {testResolver} from '../../../test/common-test-setup';
 import {navigationToken} from '../../core/gr-navigation/gr-navigation';
+import {
+  PluginLoader,
+  pluginLoaderToken,
+} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 
 function createAdminCapabilities() {
   return {
@@ -31,40 +34,22 @@
 
 suite('gr-admin-view tests', () => {
   let element: GrAdminView;
+  let pluginLoader: PluginLoader;
 
   setup(async () => {
     element = await fixture(html`<gr-admin-view></gr-admin-view>`);
     stubRestApi('getProjectConfig').returns(Promise.resolve(undefined));
     const pluginsLoaded = Promise.resolve();
-    sinon.stub(getPluginLoader(), 'awaitPluginsLoaded').returns(pluginsLoaded);
+    pluginLoader = testResolver(pluginLoaderToken);
+    sinon.stub(pluginLoader, 'awaitPluginsLoaded').returns(pluginsLoaded);
     await pluginsLoaded;
     await element.updateComplete;
   });
 
   test('link URLs', () => {
-    assert.equal(
-      element.computeLinkURL({name: '', url: '/test', noBaseUrl: true}),
-      '//' + window.location.host + '/test'
-    );
-
-    stubBaseUrl('/foo');
-    assert.equal(
-      element.computeLinkURL({name: '', url: '/test', noBaseUrl: true}),
-      '//' + window.location.host + '/foo/test'
-    );
-    assert.equal(
-      element.computeLinkURL({name: '', url: '/test', noBaseUrl: false}),
-      '/test'
-    );
-    assert.equal(
-      element.computeLinkURL({
-        name: '',
-        url: '/test',
-        target: '_blank',
-        noBaseUrl: false,
-      }),
-      '/test'
-    );
+    assert.equal(element.computeLinkURL({name: '', url: '/test'}), '/test');
+    assert.equal(element.computeLinkURL({name: '', url: ''}), '');
+    assert.equal(element.computeLinkURL(undefined), '');
   });
 
   test('current page gets selected and is displayed', async () => {
@@ -73,7 +58,6 @@
         name: 'Repositories',
         url: '/admin/repos',
         view: 'gr-repo-list' as GerritView,
-        noBaseUrl: false,
       },
     ];
 
@@ -131,8 +115,12 @@
 
   test('filteredLinks from plugin', () => {
     stubRestApi('getAccount').returns(Promise.resolve(undefined));
-    sinon.stub(element.jsAPI, 'getAdminMenuLinks').returns([
-      {capability: null, text: 'internal link text', url: '/internal/link/url'},
+    sinon.stub(pluginLoader.jsApiService, 'getAdminMenuLinks').returns([
+      {
+        capability: null,
+        text: 'internal link text',
+        url: '/internal/link/url',
+      },
       {
         capability: null,
         text: 'external link text',
@@ -145,7 +133,6 @@
         capability: undefined,
         url: '/internal/link/url',
         name: 'internal link text',
-        noBaseUrl: true,
         view: undefined,
         viewableToAll: true,
         target: null,
@@ -154,7 +141,6 @@
         capability: undefined,
         url: 'http://external/link/url',
         name: 'external link text',
-        noBaseUrl: false,
         view: undefined,
         viewableToAll: true,
         target: '_blank',
@@ -336,7 +322,6 @@
     const expectedFilteredLinks = [
       {
         name: 'Repositories',
-        noBaseUrl: true,
         url: '/admin/repos',
         view: 'gr-repo-list' as GerritView,
         viewableToAll: true,
@@ -386,7 +371,6 @@
       {
         name: 'Groups',
         section: 'Groups',
-        noBaseUrl: true,
         url: '/admin/groups',
         view: 'gr-admin-group-list' as GerritView,
       },
@@ -394,7 +378,6 @@
         name: 'Plugins',
         capability: 'viewPlugins',
         section: 'Plugins',
-        noBaseUrl: true,
         url: '/admin/plugins',
         view: 'gr-plugin-list' as GerritView,
       },
@@ -531,29 +514,17 @@
           <gr-page-nav class="navStyles">
             <ul class="sectionContent">
               <li class="sectionTitle selected">
-                <a
-                  class="title"
-                  href="//localhost:9876/admin/repos"
-                  rel="noopener"
-                >
+                <a class="title" href="/admin/repos" rel="noopener">
                   Repositories
                 </a>
               </li>
               <li class="sectionTitle">
-                <a
-                  class="title"
-                  href="//localhost:9876/admin/groups"
-                  rel="noopener"
-                >
+                <a class="title" href="/admin/groups" rel="noopener">
                   Groups
                 </a>
               </li>
               <li class="sectionTitle">
-                <a
-                  class="title"
-                  href="//localhost:9876/admin/plugins"
-                  rel="noopener"
-                >
+                <a class="title" href="/admin/plugins" rel="noopener">
                   Plugins
                 </a>
               </li>
diff --git a/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.ts b/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.ts
index 42ec988..61fe0c4 100644
--- a/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.ts
+++ b/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.ts
@@ -7,6 +7,7 @@
 import {sharedStyles} from '../../../styles/shared-styles';
 import {css, html, LitElement} from 'lit';
 import {customElement, property} from 'lit/decorators.js';
+import {fireNoBubble} from '../../../utils/event-util';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -68,22 +69,12 @@
   _handleConfirmTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
-    this.dispatchEvent(
-      new CustomEvent('confirm', {
-        composed: true,
-        bubbles: false,
-      })
-    );
+    fireNoBubble(this, 'confirm', {});
   }
 
   _handleCancelTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
-    this.dispatchEvent(
-      new CustomEvent('cancel', {
-        composed: true,
-        bubbles: false,
-      })
-    );
+    fireNoBubble(this, 'cancel', {});
   }
 }
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 cee0fa4..b3f1e96 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
@@ -23,12 +23,13 @@
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, PropertyValues, css, html} from 'lit';
 import {customElement, property, query, state} from 'lit/decorators.js';
-import {BindValueChangeEvent} from '../../../types/events';
-import {fireEvent} from '../../../utils/event-util';
+import {BindValueChangeEvent, ValueChangedEvent} from '../../../types/events';
+import {fire} from '../../../utils/event-util';
 import {subscribe} from '../../lit/subscription-controller';
 import {configModelToken} from '../../../models/config/config-model';
 import {resolve} from '../../../models/dependency';
 import {createChangeUrl} from '../../../models/views/change';
+import {throwingErrorCallback} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
 
 const SUGGESTIONS_LIMIT = 15;
 const REF_PREFIX = 'refs/heads/';
@@ -37,6 +38,9 @@
   interface HTMLElementTagNameMap {
     'gr-create-change-dialog': GrCreateChangeDialog;
   }
+  interface HTMLElementEventMap {
+    'can-create-change': CustomEvent<{}>;
+  }
 }
 
 @customElement('gr-create-change-dialog')
@@ -124,7 +128,7 @@
               .text=${this.branch}
               .query=${this.query}
               placeholder="Destination branch"
-              @text-changed=${(e: CustomEvent) => {
+              @text-changed=${(e: ValueChangedEvent<BranchName>) => {
                 this.branch = e.detail.value;
               }}
             >
@@ -206,7 +210,7 @@
   }
 
   private allowCreate() {
-    fireEvent(this, 'can-create-change');
+    fire(this, 'can-create-change', {});
   }
 
   handleCreateChange(): Promise<void> {
@@ -241,7 +245,13 @@
       input = input.substring(REF_PREFIX.length);
     }
     return this.restApiService
-      .getRepoBranches(input, this.repoName, SUGGESTIONS_LIMIT)
+      .getRepoBranches(
+        input,
+        this.repoName,
+        SUGGESTIONS_LIMIT,
+        /* offset=*/ undefined,
+        throwingErrorCallback
+      )
       .then(response => {
         if (!response) return [];
         const branches: Array<{name: BranchName}> = [];
diff --git a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-file-edit-dialog.ts b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-file-edit-dialog.ts
new file mode 100644
index 0000000..0cfbaa4
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-file-edit-dialog.ts
@@ -0,0 +1,170 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
+import {
+  RepoName,
+  BranchName,
+  ChangeInfo,
+  PatchSetNumber,
+} from '../../../types/common';
+import {getAppContext} from '../../../services/app-context';
+import {LitElement, html, nothing} from 'lit';
+import {customElement, property, query, state} from 'lit/decorators.js';
+import {resolve} from '../../../models/dependency';
+import {createEditUrl} from '../../../models/views/change';
+import {modalStyles} from '../../../styles/gr-modal-styles';
+import {assertIsDefined} from '../../../utils/common-util';
+import {when} from 'lit/directives/when.js';
+import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-create-file-edit-dialog': GrCreateFileEditDialog;
+  }
+}
+
+@customElement('gr-create-file-edit-dialog')
+export class GrCreateFileEditDialog extends LitElement {
+  @query('dialog')
+  modal?: HTMLDialogElement;
+
+  @query('gr-dialog')
+  grDialog?: GrDialog;
+
+  @property({type: String})
+  repo?: RepoName;
+
+  @property({type: String})
+  branch?: BranchName;
+
+  @property({type: String})
+  path?: string;
+
+  /**
+   * If this is set, then we show this message replacing all other content.
+   */
+  @state()
+  errorMessage?: string;
+
+  /**
+   * Triggers showing the dialog and kicks off creating a change.
+   */
+  @state()
+  active = false;
+
+  /**
+   * Indicates whether the REST API call for creating a change is in progress.
+   */
+  @state()
+  loading = false;
+
+  private readonly restApiService = getAppContext().restApiService;
+
+  private readonly getNavigation = resolve(this, navigationToken);
+
+  static override get styles() {
+    return [modalStyles];
+  }
+
+  override render() {
+    if (!this.active) return nothing;
+    return html`
+      <dialog tabindex="-1">
+        <gr-dialog
+          disabled
+          ?loading=${this.loading}
+          .loadingLabel=${'Creating change ...'}
+          @cancel=${() => this.deactivate()}
+          .confirmLabel=${this.loading ? 'Please wait ...' : 'Failed'}
+          .cancelLabel=${'Cancel'}
+        >
+          <div slot="header">
+            <span class="main-heading">Create Change from URL</span>
+          </div>
+          <div slot="main">
+            ${when(
+              this.errorMessage,
+              () => this.renderError(),
+              () => this.renderCreating()
+            )}
+          </div>
+        </gr-dialog>
+      </dialog>
+    `;
+  }
+
+  async activate() {
+    this.active = true;
+    this.createChange();
+    await this.updateComplete;
+    if (this.active && this.modal?.open === false) this.modal.showModal();
+  }
+
+  deactivate() {
+    this.active = false;
+    this.modal?.close();
+  }
+
+  private renderCreating() {
+    return html`
+      <div>
+        <span>
+          Creating a change in repository <b>${this.repo}</b> on branch
+          <b>${this.branch}</b>.
+        </span>
+      </div>
+      <div>
+        <span>
+          The page will then redirect to the file editor for
+          <b>${this.path}</b>
+          in the newly created change.
+        </span>
+      </div>
+    `;
+  }
+
+  private renderError() {
+    return html`<div>Error: ${this.errorMessage}</div>`;
+  }
+
+  private createChange() {
+    if (!this.repo || !this.branch || !this.path) {
+      this.errorMessage = 'repo, branch and path must be set';
+      return;
+    }
+    if (this.loading || this.errorMessage) return;
+    this.loading = true;
+    this.restApiService
+      .createChange(this.repo, this.branch, `Edit ${this.path}`)
+      .then(change => {
+        if (!this.active) return;
+        if (change) {
+          this.loading = false;
+          this.redirectToFileEdit(change);
+          this.deactivate();
+        } else {
+          this.errorMessage = 'Creating the change failed.';
+        }
+      })
+      .catch(() => {
+        this.errorMessage = 'Creating the change failed.';
+      })
+      .finally(() => {
+        this.loading = false;
+      });
+  }
+
+  private redirectToFileEdit(change: ChangeInfo) {
+    assertIsDefined(this.path, 'path');
+    const url = createEditUrl({
+      changeNum: change._number,
+      repo: change.project,
+      patchNum: 1 as PatchSetNumber,
+      editView: {path: this.path},
+    });
+    this.getNavigation().setUrl(url);
+  }
+}
diff --git a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-file-edit-dialog_test.ts b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-file-edit-dialog_test.ts
new file mode 100644
index 0000000..e2da5d7
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-file-edit-dialog_test.ts
@@ -0,0 +1,103 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-create-file-edit-dialog';
+import {createChange} from '../../../test/test-data-generators';
+import {fixture, html, assert} from '@open-wc/testing';
+import {GrCreateFileEditDialog} from './gr-create-file-edit-dialog';
+import {stubRestApi, waitUntilCalled} from '../../../test/test-utils';
+import {BranchName, RepoName} from '../../../api/rest-api';
+import {SinonStubbedMember} from 'sinon';
+import {testResolver} from '../../../test/common-test-setup';
+import {
+  NavigationService,
+  navigationToken,
+} from '../../core/gr-navigation/gr-navigation';
+import {RestApiService} from '../../../services/gr-rest-api/gr-rest-api';
+
+suite('gr-create-file-edit-dialog', () => {
+  let element: GrCreateFileEditDialog;
+  let createChangeStub: SinonStubbedMember<RestApiService['createChange']>;
+  let setUrlStub: SinonStubbedMember<NavigationService['setUrl']>;
+
+  setup(async () => {
+    createChangeStub = stubRestApi('createChange').resolves(createChange());
+    setUrlStub = sinon.stub(testResolver(navigationToken), 'setUrl');
+
+    element = await fixture(
+      html`<gr-create-file-edit-dialog></gr-create-file-edit-dialog>`
+    );
+    element.repo = 'test-repo' as RepoName;
+    element.branch = 'test-branch' as BranchName;
+    element.path = 'test-path';
+    await element.updateComplete;
+  });
+
+  test('render', async () => {
+    element.activate();
+    await element.updateComplete;
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <dialog tabindex="-1">
+          <gr-dialog disabled loading>
+            <div slot="header">
+              <span class="main-heading"> Create Change from URL </span>
+            </div>
+            <div slot="main">
+              <div>
+                <span>
+                  Creating a change in repository
+                  <b> test-repo </b>
+                  on branch
+                  <b> test-branch </b>
+                  .
+                </span>
+              </div>
+              <div>
+                <span>
+                  The page will then redirect to the file editor for
+                  <b> test-path </b> in the newly created change.
+                </span>
+              </div>
+            </div>
+          </gr-dialog>
+        </dialog>
+      `
+    );
+  });
+
+  test('render error', async () => {
+    element.activate();
+    element.errorMessage = 'Failed.';
+    await element.updateComplete;
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <dialog tabindex="-1">
+          <gr-dialog disabled loading>
+            <div slot="header">
+              <span class="main-heading"> Create Change from URL </span>
+            </div>
+            <div slot="main">
+              <div>Error: Failed.</div>
+            </div>
+          </gr-dialog>
+        </dialog>
+      `
+    );
+  });
+
+  test('creates change', async () => {
+    element.activate();
+    await element.updateComplete;
+
+    assert.isTrue(createChangeStub.calledOnce);
+    await waitUntilCalled(setUrlStub, 'setUrl');
+    await element.updateComplete;
+    assert.shadowDom.equal(element, '');
+  });
+});
diff --git a/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.ts b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.ts
index 4808d00..7428727 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.ts
+++ b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.ts
@@ -6,8 +6,6 @@
 import '@polymer/iron-input/iron-input';
 import '../../../styles/gr-form-styles';
 import '../../../styles/shared-styles';
-import {encodeURL, getBaseUrl} from '../../../utils/url-util';
-import {page} from '../../../utils/page-wrapper-utils';
 import {GroupName} from '../../../types/common';
 import {getAppContext} from '../../../services/app-context';
 import {formStyles} from '../../../styles/gr-form-styles';
@@ -15,12 +13,18 @@
 import {LitElement, PropertyValues, css, html} from 'lit';
 import {customElement, query, property} from 'lit/decorators.js';
 import {BindValueChangeEvent} from '../../../types/events';
-import {fireEvent} from '../../../utils/event-util';
+import {fire} from '../../../utils/event-util';
+import {createGroupUrl} from '../../../models/views/group';
+import {resolve} from '../../../models/dependency';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
 
 declare global {
   interface HTMLElementTagNameMap {
     'gr-create-group-dialog': GrCreateGroupDialog;
   }
+  interface HTMLElementEventMap {
+    'has-new-group-name': CustomEvent<{}>;
+  }
 }
 
 @customElement('gr-create-group-dialog')
@@ -32,6 +36,8 @@
 
   private readonly restApiService = getAppContext().restApiService;
 
+  private readonly getNavigation = resolve(this, navigationToken);
+
   static override get styles() {
     return [
       formStyles,
@@ -72,11 +78,7 @@
   }
 
   private updateGroupName() {
-    fireEvent(this, 'has-new-group-name');
-  }
-
-  private computeGroupUrl(groupId: string) {
-    return getBaseUrl() + '/admin/groups/' + encodeURL(groupId, true);
+    fire(this, 'has-new-group-name', {});
   }
 
   override focus() {
@@ -89,7 +91,7 @@
       if (groupRegistered.status !== 201) return;
       return this.restApiService.getGroupConfig(name).then(group => {
         if (!group) return;
-        page.show(this.computeGroupUrl(String(group.group_id!)));
+        this.getNavigation().setUrl(createGroupUrl({groupId: group.id}));
       });
     });
   }
diff --git a/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog_test.ts b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog_test.ts
index 2a0b539..c5fbde3 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog_test.ts
@@ -6,7 +6,6 @@
 import '../../../test/common-test-setup';
 import './gr-create-group-dialog';
 import {GrCreateGroupDialog} from './gr-create-group-dialog';
-import {page} from '../../../utils/page-wrapper-utils';
 import {
   mockPromise,
   queryAndAssert,
@@ -15,6 +14,8 @@
 import {IronInputElement} from '@polymer/iron-input';
 import {GroupId} from '../../../types/common';
 import {fixture, html, assert} from '@open-wc/testing';
+import {testResolver} from '../../../test/common-test-setup';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
 
 suite('gr-create-group-dialog tests', () => {
   let element: GrCreateGroupDialog;
@@ -68,9 +69,9 @@
       Promise.resolve({id: 'testId551' as GroupId, group_id: 551})
     );
 
-    const showStub = sinon.stub(page, 'show');
+    const setUrlStub = sinon.stub(testResolver(navigationToken), 'setUrl');
     await element.handleCreateGroup();
-    assert.isTrue(showStub.calledWith('/admin/groups/551'));
+    assert.isTrue(setUrlStub.calledWith('/admin/groups/testId551'));
   });
 
   test('test for unsuccessful group creation', async () => {
@@ -81,8 +82,8 @@
       Promise.resolve({id: 'testId551' as GroupId, group_id: 551})
     );
 
-    const showStub = sinon.stub(page, 'show');
+    const setUrlStub = sinon.stub(testResolver(navigationToken), 'setUrl');
     await element.handleCreateGroup();
-    assert.isFalse(showStub.called);
+    assert.isFalse(setUrlStub.called);
   });
 });
diff --git a/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog.ts b/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog.ts
index 889a859..12f36ec 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog.ts
+++ b/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog.ts
@@ -6,8 +6,6 @@
 import '@polymer/iron-input/iron-input';
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-select/gr-select';
-import {encodeURL, getBaseUrl} from '../../../utils/url-util';
-import {page} from '../../../utils/page-wrapper-utils';
 import {BranchName, RepoName} from '../../../types/common';
 import {getAppContext} from '../../../services/app-context';
 import {formStyles} from '../../../styles/gr-form-styles';
@@ -15,13 +13,16 @@
 import {LitElement, PropertyValues, css, html} from 'lit';
 import {customElement, property, state} from 'lit/decorators.js';
 import {BindValueChangeEvent} from '../../../types/events';
-import {fireEvent} from '../../../utils/event-util';
+import {fireAlert, fire, fireReload} from '../../../utils/event-util';
 import {RepoDetailView} from '../../../models/views/repo';
 
 declare global {
   interface HTMLElementTagNameMap {
     'gr-create-pointer-dialog': GrCreatePointerDialog;
   }
+  interface HTMLElementEventMap {
+    'update-item-name': CustomEvent<{}>;
+  }
 }
 
 @customElement('gr-create-pointer-dialog')
@@ -115,7 +116,7 @@
   }
 
   private updateItemName() {
-    fireEvent(this, 'update-item-name');
+    fire(this, 'update-item-name', {});
   }
 
   handleCreateItem() {
@@ -126,13 +127,13 @@
       throw new Error('itemName name is not set');
     }
     const USE_HEAD = this.itemRevision ? this.itemRevision : 'HEAD';
-    const url = `${getBaseUrl()}/admin/repos/${encodeURL(this.repoName, true)}`;
     if (this.itemDetail === RepoDetailView.BRANCHES) {
       return this.restApiService
         .createRepoBranch(this.repoName, this.itemName, {revision: USE_HEAD})
         .then(itemRegistered => {
           if (itemRegistered.status === 201) {
-            page.show(`${url},branches`);
+            fireAlert(this, 'Branch created successfully. Reloading...');
+            fireReload(this);
           }
         });
     } else if (this.itemDetail === RepoDetailView.TAGS) {
@@ -143,7 +144,8 @@
         })
         .then(itemRegistered => {
           if (itemRegistered.status === 201) {
-            page.show(`${url},tags`);
+            fireAlert(this, 'Tag created successfully. Reloading...');
+            fireReload(this);
           }
         });
     }
diff --git a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.ts b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.ts
index 158419e..1a70f2b 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.ts
+++ b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.ts
@@ -7,8 +7,6 @@
 import '../../shared/gr-autocomplete/gr-autocomplete';
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-select/gr-select';
-import {encodeURL, getBaseUrl} from '../../../utils/url-util';
-import {page} from '../../../utils/page-wrapper-utils';
 import {
   BranchName,
   GroupId,
@@ -22,22 +20,25 @@
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, css, html} from 'lit';
 import {customElement, query, property, state} from 'lit/decorators.js';
-import {fireEvent} from '../../../utils/event-util';
+import {fire} from '../../../utils/event-util';
+import {throwingErrorCallback} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
+import {createRepoUrl} from '../../../models/views/repo';
+import {resolve} from '../../../models/dependency';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
+import {ValueChangedEvent} from '../../../types/events';
 
 declare global {
   interface HTMLElementTagNameMap {
     'gr-create-repo-dialog': GrCreateRepoDialog;
   }
+  interface HTMLElementEventMap {
+    /** Fired when repostiory name is entered. */
+    'new-repo-name': CustomEvent<{}>;
+  }
 }
 
 @customElement('gr-create-repo-dialog')
 export class GrCreateRepoDialog extends LitElement {
-  /**
-   * Fired when repostiory name is entered.
-   *
-   * @event new-repo-name
-   */
-
   @query('input')
   input?: HTMLInputElement;
 
@@ -70,6 +71,8 @@
 
   private readonly restApiService = getAppContext().restApiService;
 
+  private readonly getNavigation = resolve(this, navigationToken);
+
   constructor() {
     super();
     this.query = (input: string) => this.getRepoSuggestions(input);
@@ -182,10 +185,6 @@
     `;
   }
 
-  _computeRepoUrl(repoName: string) {
-    return getBaseUrl() + '/admin/repos/' + encodeURL(repoName, true);
-  }
-
   override focus() {
     this.input?.focus();
   }
@@ -198,23 +197,32 @@
     );
     if (repoRegistered.status === 201) {
       this.repoCreated = true;
-      page.show(this._computeRepoUrl(this.repoConfig.name));
+      this.getNavigation().setUrl(createRepoUrl({repo: this.repoConfig.name}));
     }
     return repoRegistered;
   }
 
   private async getRepoSuggestions(input: string) {
-    const response = await this.restApiService.getSuggestedProjects(input);
+    const response = await this.restApiService.getSuggestedRepos(
+      input,
+      /* n=*/ undefined,
+      throwingErrorCallback
+    );
 
     const repos = [];
-    for (const [name, project] of Object.entries(response ?? {})) {
-      repos.push({name, value: project.id});
+    for (const [name, repo] of Object.entries(response ?? {})) {
+      repos.push({name, value: repo.id});
     }
     return repos;
   }
 
   private async getGroupSuggestions(input: string) {
-    const response = await this.restApiService.getSuggestedGroups(input);
+    const response = await this.restApiService.getSuggestedGroups(
+      input,
+      /* project=*/ undefined,
+      /* n=*/ undefined,
+      throwingErrorCallback
+    );
 
     const groups = [];
     for (const [name, group] of Object.entries(response ?? {})) {
@@ -223,39 +231,41 @@
     return groups;
   }
 
-  private handleRightsTextChanged(e: CustomEvent) {
+  private handleRightsTextChanged(e: ValueChangedEvent) {
     this.repoConfig.parent = e.detail.value as RepoName;
     this.requestUpdate();
   }
 
-  private handleOwnerTextChanged(e: CustomEvent) {
+  private handleOwnerTextChanged(e: ValueChangedEvent) {
     this.repoOwner = e.detail.value;
   }
 
-  private handleOwnerValueChanged(e: CustomEvent) {
+  private handleOwnerValueChanged(e: ValueChangedEvent) {
     this.repoOwnerId = e.detail.value as GroupId;
   }
 
-  private handleNameBindValueChanged(e: CustomEvent) {
+  private handleNameBindValueChanged(e: ValueChangedEvent) {
     this.repoConfig.name = e.detail.value as RepoName;
     // nameChanged needs to be set before the event is fired,
     // because when the event is fired, gr-repo-list gets
     // the nameChanged value.
     this.nameChanged = !!e.detail.value;
-    fireEvent(this, 'new-repo-name');
+    fire(this, 'new-repo-name', {});
     this.requestUpdate();
   }
 
-  private handleBranchNameBindValueChanged(e: CustomEvent) {
+  private handleBranchNameBindValueChanged(e: ValueChangedEvent) {
     this.defaultBranch = e.detail.value as BranchName;
   }
 
-  private handleCreateEmptyCommitBindValueChanged(e: CustomEvent) {
+  private handleCreateEmptyCommitBindValueChanged(
+    e: ValueChangedEvent<boolean>
+  ) {
     this.repoConfig.create_empty_commit = e.detail.value;
     this.requestUpdate();
   }
 
-  private handlePermissionsOnlyBindValueChanged(e: CustomEvent) {
+  private handlePermissionsOnlyBindValueChanged(e: ValueChangedEvent<boolean>) {
     this.repoConfig.permissions_only = e.detail.value;
     this.requestUpdate();
   }
diff --git a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.ts b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.ts
index e0c0d30..f4a7bf3 100644
--- a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.ts
+++ b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.ts
@@ -40,7 +40,7 @@
 
   override connectedCallback() {
     super.connectedCallback();
-    fireTitleChange(this, 'Audit Log');
+    fireTitleChange('Audit Log');
   }
 
   static override get styles() {
diff --git a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.ts b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.ts
index c716d65..cffde7a 100644
--- a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.ts
+++ b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.ts
@@ -3,14 +3,11 @@
  * Copyright 2017 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
 import '../../shared/gr-account-label/gr-account-label';
 import '../../shared/gr-autocomplete/gr-autocomplete';
 import '../../shared/gr-button/gr-button';
-import '../../shared/gr-overlay/gr-overlay';
 import '../gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog';
 import {getBaseUrl} from '../../../utils/url-util';
-import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
 import {
   GroupId,
   AccountId,
@@ -18,6 +15,7 @@
   GroupInfo,
   GroupName,
   ServerInfo,
+  NumericChangeId,
 } from '../../../types/common';
 import {
   AutocompleteQuery,
@@ -40,15 +38,19 @@
 import {LitElement, css, html} from 'lit';
 import {customElement, property, query, state} from 'lit/decorators.js';
 import {ifDefined} from 'lit/directives/if-defined.js';
-import {getAccountSuggestions} from '../../../utils/account-util';
 import {subscribe} from '../../lit/subscription-controller';
 import {configModelToken} from '../../../models/config/config-model';
 import {resolve} from '../../../models/dependency';
+import {modalStyles} from '../../../styles/gr-modal-styles';
+import {throwingErrorCallback} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
+import {ValueChangedEvent} from '../../../types/events';
+import {getAccountDisplayName} from '../../../utils/display-name-util';
 
 const SAVING_ERROR_TEXT =
   'Group may not exist, or you may not have ' + 'permission to add it';
 
 const URL_REGEX = '^(?:[a-z]+:)?//';
+const SUGGESTIONS_LIMIT = 15;
 
 export enum ItemType {
   MEMBER = 'member',
@@ -63,7 +65,7 @@
 
 @customElement('gr-group-members')
 export class GrGroupMembers extends LitElement {
-  @query('#overlay') protected overlay!: GrOverlay;
+  @query('#modal') protected modal!: HTMLDialogElement;
 
   @property({type: String})
   groupId?: GroupId;
@@ -119,7 +121,7 @@
       }
     );
     this.queryMembers = input =>
-      getAccountSuggestions(input, this.restApiService, this.serverConfig);
+      this.getAccountSuggestions(input, this.serverConfig);
     this.queryIncludedGroup = input => this.getGroupSuggestions(input);
   }
 
@@ -127,7 +129,7 @@
     super.connectedCallback();
     this.loadGroupDetails();
 
-    fireTitleChange(this, 'Members');
+    fireTitleChange('Members');
   }
 
   static override get styles() {
@@ -137,6 +139,7 @@
       sharedStyles,
       subpageStyles,
       tableStyles,
+      modalStyles,
       css`
         .input {
           width: 15em;
@@ -258,7 +261,7 @@
           </div>
         </div>
       </div>
-      <gr-overlay id="overlay" with-backdrop>
+      <dialog id="modal" tabindex="-1">
         <gr-confirm-delete-item-dialog
           class="confirmDialog"
           .item=${this.itemName}
@@ -266,10 +269,37 @@
           @confirm=${this.handleDeleteConfirm}
           @cancel=${this.handleConfirmDialogCancel}
         ></gr-confirm-delete-item-dialog>
-      </gr-overlay>
+      </dialog>
     `;
   }
 
+  getAccountSuggestions(
+    input: string,
+    config?: ServerInfo,
+    canSee?: NumericChangeId,
+    filterActive = false
+  ) {
+    return this.restApiService
+      .getSuggestedAccounts(
+        input,
+        SUGGESTIONS_LIMIT,
+        canSee,
+        filterActive,
+        throwingErrorCallback
+      )
+      .then(accounts => {
+        if (!accounts) return [];
+        const accountSuggestions = [];
+        for (const account of accounts) {
+          accountSuggestions.push({
+            name: getAccountDisplayName(config, account),
+            value: account._account_id?.toString(),
+          });
+        }
+        return accountSuggestions;
+      });
+  }
+
   private renderGroupMember(member: AccountInfo, index: number) {
     return html`
       <tr>
@@ -411,7 +441,7 @@
     if (!this.groupName) {
       return Promise.reject(new Error('group name undefined'));
     }
-    this.overlay.close();
+    this.modal.close();
     if (this.itemType === ItemType.MEMBER) {
       return this.restApiService
         .deleteGroupMember(this.groupName, this.itemId! as AccountId)
@@ -457,7 +487,7 @@
   }
 
   private handleConfirmDialogCancel() {
-    this.overlay.close();
+    this.modal.close();
   }
 
   private handleDeleteMember(e: Event) {
@@ -472,7 +502,7 @@
     this.itemName = item;
     this.itemId = keys._account_id;
     this.itemType = ItemType.MEMBER;
-    this.overlay.open();
+    this.modal.showModal();
   }
 
   /* private but used in test */
@@ -490,7 +520,7 @@
           if (errResponse) {
             if (errResponse.status === 404) {
               fireAlert(this, SAVING_ERROR_TEXT);
-              return errResponse;
+              return;
             }
             throw Error(errResponse.statusText);
           }
@@ -525,36 +555,43 @@
     this.itemName = item;
     this.itemId = id;
     this.itemType = ItemType.INCLUDED_GROUP;
-    this.overlay.open();
+    this.modal.showModal();
   }
 
   /* private but used in test */
   getGroupSuggestions(input: string) {
-    return this.restApiService.getSuggestedGroups(input).then(response => {
-      const groups: AutocompleteSuggestion[] = [];
-      for (const [name, group] of Object.entries(response ?? {})) {
-        groups.push({name, value: decodeURIComponent(group.id)});
-      }
-      return groups;
-    });
+    return this.restApiService
+      .getSuggestedGroups(
+        input,
+        /* project=*/ undefined,
+        /* n=*/ undefined,
+        throwingErrorCallback
+      )
+      .then(response => {
+        const groups: AutocompleteSuggestion[] = [];
+        for (const [name, group] of Object.entries(response ?? {})) {
+          groups.push({name, value: decodeURIComponent(group.id)});
+        }
+        return groups;
+      });
   }
 
-  private handleGroupMemberTextChanged(e: CustomEvent) {
+  private handleGroupMemberTextChanged(e: ValueChangedEvent) {
     if (this.loading) return;
     this.groupMemberSearchName = e.detail.value;
   }
 
-  private handleGroupMemberValueChanged(e: CustomEvent) {
+  private handleGroupMemberValueChanged(e: ValueChangedEvent<number>) {
     if (this.loading) return;
     this.groupMemberSearchId = e.detail.value;
   }
 
-  private handleIncludedGroupTextChanged(e: CustomEvent) {
+  private handleIncludedGroupTextChanged(e: ValueChangedEvent) {
     if (this.loading) return;
     this.includedGroupSearchName = e.detail.value;
   }
 
-  private handleIncludedGroupValueChanged(e: CustomEvent) {
+  private handleIncludedGroupValueChanged(e: ValueChangedEvent) {
     if (this.loading) return;
     this.includedGroupSearchId = e.detail.value;
   }
diff --git a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.ts b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.ts
index 6c65dd6..a3c7bbd 100644
--- a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.ts
@@ -25,9 +25,7 @@
 } from '../../../types/common';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {GrAutocomplete} from '../../shared/gr-autocomplete/gr-autocomplete';
-import {EventType, PageErrorEvent} from '../../../types/events';
-import {getAccountSuggestions} from '../../../utils/account-util';
-import {getAppContext} from '../../../services/app-context';
+import {PageErrorEvent} from '../../../types/events';
 import {fixture, html, assert} from '@open-wc/testing';
 import {createServerInfo} from '../../../test/test-data-generators';
 
@@ -346,16 +344,10 @@
             </div>
           </div>
         </div>
-        <gr-overlay
-          aria-hidden="true"
-          id="overlay"
-          style="outline: none; display: none;"
-          tabindex="-1"
-          with-backdrop=""
-        >
+        <dialog id="modal" tabindex="-1">
           <gr-confirm-delete-item-dialog class="confirmDialog">
           </gr-confirm-delete-item-dialog>
-        </gr-overlay>
+        </dialog>
       `
     );
   });
@@ -452,7 +444,7 @@
 
     const memberName = 'bad-name';
     const alertStub = sinon.stub();
-    element.addEventListener(EventType.SHOW_ALERT, alertStub);
+    element.addEventListener('show-alert', alertStub);
     const errorResponse = {...new Response(), status: 404, ok: false};
     stubRestApi('saveIncludedGroup').callsFake((_, _non, errFn) => {
       if (errFn !== undefined) {
@@ -498,18 +490,16 @@
   });
 
   test('getAccountSuggestions empty', async () => {
-    const accounts = await getAccountSuggestions(
+    const accounts = await element.getAccountSuggestions(
       'nonexistent',
-      getAppContext().restApiService,
       createServerInfo()
     );
     assert.equal(accounts.length, 0);
   });
 
   test('getAccountSuggestions non-empty', async () => {
-    const accounts = await getAccountSuggestions(
+    const accounts = await element.getAccountSuggestions(
       'test-',
-      getAppContext().restApiService,
       createServerInfo()
     );
     assert.equal(accounts.length, 3);
@@ -540,12 +530,18 @@
     deleteBtns[0].click();
     assert.equal(element.itemId, 1000097 as AccountId);
     assert.equal(element.itemName, 'jane');
+    queryAndAssert<HTMLDialogElement>(element, 'dialog').close();
+
     deleteBtns[1].click();
     assert.equal(element.itemId, 1000096 as AccountId);
     assert.equal(element.itemName, 'Test User');
+    queryAndAssert<HTMLDialogElement>(element, 'dialog').close();
+
     deleteBtns[2].click();
     assert.equal(element.itemId, 1000095 as AccountId);
     assert.equal(element.itemName, 'Gerrit');
+    queryAndAssert<HTMLDialogElement>(element, 'dialog').close();
+
     deleteBtns[3].click();
     assert.equal(element.itemId, 1000098 as AccountId);
     assert.equal(element.itemName, '1000098');
@@ -559,9 +555,13 @@
     deleteBtns[0].click();
     assert.equal(element.itemId, 'testId' as GroupId);
     assert.equal(element.itemName, 'testName');
+    queryAndAssert<HTMLDialogElement>(element, 'dialog').close();
+
     deleteBtns[1].click();
     assert.equal(element.itemId, 'testId2' as GroupId);
     assert.equal(element.itemName, 'testName2');
+    queryAndAssert<HTMLDialogElement>(element, 'dialog').close();
+
     deleteBtns[2].click();
     assert.equal(element.itemId, 'testId3' as GroupId);
     assert.equal(element.itemName, 'testName3');
diff --git a/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts b/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts
index e65b16b..223a700 100644
--- a/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts
+++ b/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts
@@ -13,17 +13,18 @@
   AutocompleteQuery,
 } from '../../shared/gr-autocomplete/gr-autocomplete';
 import {GroupId, GroupInfo, GroupName} from '../../../types/common';
-import {firePageError, fireTitleChange} from '../../../utils/event-util';
+import {fire, firePageError, fireTitleChange} from '../../../utils/event-util';
 import {getAppContext} from '../../../services/app-context';
 import {ErrorCallback} from '../../../api/rest';
 import {convertToString} from '../../../utils/string-util';
-import {BindValueChangeEvent} from '../../../types/events';
+import {BindValueChangeEvent, ValueChangedEvent} from '../../../types/events';
 import {fontStyles} from '../../../styles/gr-font-styles';
 import {formStyles} from '../../../styles/gr-form-styles';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {subpageStyles} from '../../../styles/gr-subpage-styles';
 import {LitElement, PropertyValues, css, html} from 'lit';
 import {customElement, property, state} from 'lit/decorators.js';
+import {throwingErrorCallback} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
 
 const INTERNAL_GROUP_REGEX = /^[\da-f]{40}$/;
 
@@ -47,16 +48,13 @@
   interface HTMLElementTagNameMap {
     'gr-group': GrGroup;
   }
+  interface HTMLElementEventMap {
+    'name-changed': CustomEvent<GroupNameChangedDetail>;
+  }
 }
 
 @customElement('gr-group')
 export class GrGroup extends LitElement {
-  /**
-   * Fired when the group name changes.
-   *
-   * @event name-changed
-   */
-
   private readonly query: AutocompleteQuery;
 
   @property({type: String})
@@ -344,7 +342,7 @@
     this.groupConfig = config;
     this.originalOptionsVisibleToAll = config?.options?.visible_to_all;
 
-    fireTitleChange(this, config.name);
+    fireTitleChange(config.name);
 
     await Promise.all(promises);
     this.loading = false;
@@ -372,13 +370,7 @@
         name: groupName,
         external: !this.groupIsInternal,
       };
-      this.dispatchEvent(
-        new CustomEvent('name-changed', {
-          detail,
-          composed: true,
-          bubbles: true,
-        })
-      );
+      fire(this, 'name-changed', detail);
       this.requestUpdate();
     }
 
@@ -427,13 +419,20 @@
   }
 
   private getGroupSuggestions(input: string) {
-    return this.restApiService.getSuggestedGroups(input).then(response => {
-      const groups: AutocompleteSuggestion[] = [];
-      for (const [name, group] of Object.entries(response ?? {})) {
-        groups.push({name, value: decodeURIComponent(group.id)});
-      }
-      return groups;
-    });
+    return this.restApiService
+      .getSuggestedGroups(
+        input,
+        /* project=*/ undefined,
+        /* n=*/ undefined,
+        throwingErrorCallback
+      )
+      .then(response => {
+        const groups: AutocompleteSuggestion[] = [];
+        for (const [name, group] of Object.entries(response ?? {})) {
+          groups.push({name, value: decodeURIComponent(group.id)});
+        }
+        return groups;
+      });
   }
 
   // private but used in test
@@ -447,25 +446,25 @@
     return id.match(INTERNAL_GROUP_REGEX) ? id : decodeURIComponent(id);
   }
 
-  private handleNameTextChanged(e: CustomEvent) {
+  private handleNameTextChanged(e: ValueChangedEvent) {
     if (!this.groupConfig || this.loading) return;
     this.groupConfig.name = e.detail.value as GroupName;
     this.requestUpdate();
   }
 
-  private handleOwnerTextChanged(e: CustomEvent) {
+  private handleOwnerTextChanged(e: ValueChangedEvent) {
     if (!this.groupConfig || this.loading) return;
     this.groupConfig.owner = e.detail.value;
     this.requestUpdate();
   }
 
-  private handleOwnerValueChanged(e: CustomEvent) {
+  private handleOwnerValueChanged(e: ValueChangedEvent) {
     if (this.loading) return;
     this.groupConfigOwner = e.detail.value;
     this.requestUpdate();
   }
 
-  private handleDescriptionTextChanged(e: CustomEvent) {
+  private handleDescriptionTextChanged(e: ValueChangedEvent) {
     if (!this.groupConfig || this.loading) return;
     this.groupConfig.description = e.detail.value;
     this.requestUpdate();
diff --git a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.ts b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.ts
index 35e50ad..7f03d1d 100644
--- a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.ts
+++ b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.ts
@@ -26,21 +26,24 @@
   AutocompleteQuery,
   GrAutocomplete,
   AutocompleteSuggestion,
-  AutocompleteCommitEvent,
 } from '../../shared/gr-autocomplete/gr-autocomplete';
 import {
   EditablePermissionInfo,
   EditablePermissionRuleInfo,
-  EditableProjectAccessGroups,
+  EditableRepoAccessGroups,
 } from '../gr-repo-access/gr-repo-access-interfaces';
 import {getAppContext} from '../../../services/app-context';
-import {fire, fireEvent} from '../../../utils/event-util';
+import {fire} from '../../../utils/event-util';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {paperStyles} from '../../../styles/gr-paper-styles';
 import {formStyles} from '../../../styles/gr-form-styles';
 import {menuPageStyles} from '../../../styles/gr-menu-page-styles';
 import {when} from 'lit/directives/when.js';
-import {ValueChangedEvent} from '../../../types/events';
+import {
+  AutocompleteCommitEvent,
+  ValueChangedEvent,
+} from '../../../types/events';
+import {throwingErrorCallback} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
 
 const MAX_AUTOCOMPLETE_RESULTS = 20;
 
@@ -63,16 +66,6 @@
   value: GroupInfo;
 }
 
-/**
- * Fired when the permission has been modified or removed.
- *
- * @event access-modified
- */
-/**
- * Fired when a permission that was previously added was removed.
- *
- * @event added-permission-removed
- */
 @customElement('gr-permission')
 export class GrPermission extends LitElement {
   @property({type: String})
@@ -88,7 +81,7 @@
   permission?: PermissionArrayItem<EditablePermissionInfo>;
 
   @property({type: Object})
-  groups?: EditableProjectAccessGroups;
+  groups?: EditableRepoAccessGroups;
 
   @property({type: String})
   section?: GitRef;
@@ -360,7 +353,7 @@
     this.permission.value.modified = true;
     this.permission.value.exclusive = (e.target as HTMLInputElement).checked;
     // Allows overall access page to know a change has been made.
-    fireEvent(this, 'access-modified');
+    fire(this, 'access-modified', {});
   }
 
   handleRemovePermission() {
@@ -368,11 +361,11 @@
       return;
     }
     if (this.permission.value.added) {
-      fireEvent(this, 'added-permission-removed');
+      fire(this, 'added-permission-removed', {});
     }
     this.deleted = true;
     this.permission.value.deleted = true;
-    fireEvent(this, 'access-modified');
+    fire(this, 'access-modified', {});
   }
 
   private handleRulesChanged() {
@@ -458,7 +451,7 @@
   }
 
   computeGroupName(
-    groups: EditableProjectAccessGroups | undefined,
+    groups: EditableRepoAccessGroups | undefined,
     groupId: GitRef
   ) {
     return groups && groups[groupId] && groups[groupId].name
@@ -471,7 +464,8 @@
       .getSuggestedGroups(
         this.groupFilter || '',
         this.repo,
-        MAX_AUTOCOMPLETE_RESULTS
+        MAX_AUTOCOMPLETE_RESULTS,
+        throwingErrorCallback
       )
       .then(response => {
         const groups: GroupSuggestion[] = [];
@@ -540,7 +534,7 @@
     const value = this.rules[this.rules.length - 1].value;
     value!.added = true;
     this.permission.value.rules[groupId] = value!;
-    fireEvent(this, 'access-modified');
+    fire(this, 'access-modified', {});
     this.requestUpdate();
   }
 
@@ -563,6 +557,11 @@
     e.preventDefault();
   }
 
+  // TODO: Do not use generic `CustomEvent`.
+  // There is something fishy going on here though.
+  // `e.detail.value` is of type `Rule`, but `splice()` expects a `number`.
+  // Did not look closer, but this seems to be broken. Should `e.detail.value`
+  // be replaced by `1` maybe??
   private handleRuleChanged(e: CustomEvent, index: number) {
     this.rules!.splice(index, e.detail.value);
     this.handleRulesChanged();
@@ -572,6 +571,8 @@
 
 declare global {
   interface HTMLElementEventMap {
+    /** Fired when a permission that was previously added was removed. */
+    'added-permission-removed': CustomEvent<{}>;
     'permission-changed': ValueChangedEvent<
       PermissionArrayItem<EditablePermissionInfo>
     >;
diff --git a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_test.ts b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_test.ts
index b6bc3ed..46f6ac4 100644
--- a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_test.ts
@@ -9,15 +9,13 @@
 import {query, stubRestApi, waitEventLoop} from '../../../test/test-utils';
 import {GitRef, GroupId, GroupName} from '../../../types/common';
 import {PermissionAction} from '../../../constants/constants';
-import {
-  AutocompleteCommitEventDetail,
-  GrAutocomplete,
-} from '../../shared/gr-autocomplete/gr-autocomplete';
+import {GrAutocomplete} from '../../shared/gr-autocomplete/gr-autocomplete';
 import {queryAndAssert} from '../../../test/test-utils';
 import {GrRuleEditor} from '../gr-rule-editor/gr-rule-editor';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {fixture, html, assert} from '@open-wc/testing';
 import {PaperToggleButtonElement} from '@polymer/paper-toggle-button';
+import {AutocompleteCommitEventDetail} from '../../../types/events';
 
 suite('gr-permission tests', () => {
   let element: GrPermission;
diff --git a/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor.ts b/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor.ts
index 54a83ee..5afaa9d 100644
--- a/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor.ts
+++ b/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor.ts
@@ -15,21 +15,19 @@
 import {LitElement, html, css} from 'lit';
 import {customElement, property, state} from 'lit/decorators.js';
 import {BindValueChangeEvent} from '../../../types/events';
+import {fireNoBubbleNoCompose} from '../../../utils/event-util';
 
 declare global {
   interface HTMLElementTagNameMap {
     'gr-plugin-config-array-editor': GrPluginConfigArrayEditor;
   }
+  interface HTMLElementEventMap {
+    'plugin-config-option-changed': CustomEvent<PluginConfigOptionsChangedEventDetail>;
+  }
 }
 
 @customElement('gr-plugin-config-array-editor')
 export class GrPluginConfigArrayEditor extends LitElement {
-  /**
-   * Fired when the plugin config option changes.
-   *
-   * @event plugin-config-option-changed
-   */
-
   // private but used in test
   @state() newValue = '';
 
@@ -175,9 +173,7 @@
       info: {...info, values},
       notifyPath: `${_key}.values`,
     };
-    this.dispatchEvent(
-      new CustomEvent('plugin-config-option-changed', {detail})
-    );
+    fireNoBubbleNoCompose(this, 'plugin-config-option-changed', detail);
   }
 
   private handleBindValueChangedNewValue(e: BindValueChangeEvent) {
diff --git a/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.ts b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.ts
index 383b4a7..852f907 100644
--- a/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.ts
+++ b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.ts
@@ -9,12 +9,15 @@
 import {getAppContext} from '../../../services/app-context';
 import {ErrorCallback} from '../../../api/rest';
 import {encodeURL, getBaseUrl} from '../../../utils/url-util';
-import {SHOWN_ITEMS_COUNT} from '../../../constants/constants';
 import {tableStyles} from '../../../styles/gr-table-styles';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, PropertyValues, css, html} from 'lit';
 import {customElement, property, state} from 'lit/decorators.js';
-import {AdminViewState} from '../../../models/views/admin';
+import {
+  AdminChildView,
+  AdminViewState,
+  createAdminUrl,
+} from '../../../models/views/admin';
 
 // Exported for tests
 export interface PluginInfoWithName extends PluginInfo {
@@ -23,8 +26,6 @@
 
 @customElement('gr-plugin-list')
 export class GrPluginList extends LitElement {
-  readonly path = '/admin/plugins';
-
   /**
    * URL params passed from the router.
    */
@@ -34,23 +35,21 @@
   /**
    * Offset of currently visible query results.
    */
-  @state() private offset = 0;
+  @state() offset = 0;
 
-  // private but used in test
   @state() plugins?: PluginInfoWithName[];
 
-  @state() private pluginsPerPage = 25;
+  @state() pluginsPerPage = 25;
 
-  // private but used in test
   @state() loading = true;
 
-  @state() private filter = '';
+  @state() filter = '';
 
   private readonly restApiService = getAppContext().restApiService;
 
   override connectedCallback() {
     super.connectedCallback();
-    fireTitleChange(this, 'Plugins');
+    fireTitleChange('Plugins');
   }
 
   static override get styles() {
@@ -73,7 +72,7 @@
         .items=${this.plugins}
         .loading=${this.loading}
         .offset=${this.offset}
-        .path=${this.path}
+        .path=${createAdminUrl({adminView: AdminChildView.PLUGINS})}
       >
         <table id="list" class="genericList">
           <tbody>
@@ -107,7 +106,7 @@
     return html`
       <tbody>
         ${this.plugins
-          ?.slice(0, SHOWN_ITEMS_COUNT)
+          ?.slice(0, this.pluginsPerPage)
           .map(plugin => this.renderPluginList(plugin))}
       </tbody>
     `;
@@ -176,7 +175,7 @@
   }
 
   private computePluginUrl(id: string) {
-    return getBaseUrl() + '/' + encodeURL(id, true);
+    return getBaseUrl() + '/' + encodeURL(id);
   }
 }
 
diff --git a/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_test.ts b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_test.ts
index 65495b4..7c41120 100644
--- a/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_test.ts
@@ -17,7 +17,6 @@
 import {PluginInfo} from '../../../types/common';
 import {GerritView} from '../../../services/router/router-model';
 import {PageErrorEvent} from '../../../types/events';
-import {SHOWN_ITEMS_COUNT} from '../../../constants/constants';
 import {fixture, html, assert} from '@open-wc/testing';
 import {AdminChildView, AdminViewState} from '../../../models/views/admin';
 
@@ -334,7 +333,9 @@
     });
 
     test('plugins', () => {
-      assert.equal(element.plugins!.slice(0, SHOWN_ITEMS_COUNT).length, 25);
+      const table = queryAndAssert(element, 'table');
+      const rows = table.querySelectorAll('tr.table');
+      assert.equal(rows.length, element.pluginsPerPage);
     });
   });
 
@@ -348,7 +349,9 @@
     });
 
     test('plugins', () => {
-      assert.equal(element.plugins!.slice(0, SHOWN_ITEMS_COUNT).length, 25);
+      const table = queryAndAssert(element, 'table');
+      const rows = table.querySelectorAll('tr.table');
+      assert.equal(rows.length, element.pluginsPerPage);
     });
   });
 
@@ -387,7 +390,7 @@
     test('fires page-error', async () => {
       const response = {status: 404} as Response;
       stubRestApi('getPlugins').callsFake(
-        (_filter, _pluginsPerPage, _opt_offset, errFn) => {
+        (_filter, _pluginsPerPage, _offset, errFn) => {
           if (errFn !== undefined) {
             errFn(response);
           }
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access-interfaces.ts b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access-interfaces.ts
index c7bd36fe..e9396b9 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access-interfaces.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access-interfaces.ts
@@ -66,6 +66,6 @@
 export interface NewlyAddedGroupInfo {
   name: string;
 }
-export type EditableProjectAccessGroups = {
+export type EditableRepoAccessGroups = {
   [uuid: string]: GroupInfo | NewlyAddedGroupInfo;
 };
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.ts b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.ts
index 21ab184..2809d6e 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
@@ -4,7 +4,7 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import '../gr-access-section/gr-access-section';
-import {encodeURL, getBaseUrl, singleDecodeURL} from '../../../utils/url-util';
+import {singleDecodeURL} from '../../../utils/url-util';
 import {navigationToken} from '../../core/gr-navigation/gr-navigation';
 import {toSortedPermissionsArray} from '../../../utils/access-util';
 import {
@@ -15,7 +15,7 @@
   ProjectAccessInput,
   GitRef,
   UrlEncodedRepoName,
-  ProjectAccessGroups,
+  RepoAccessGroups,
 } from '../../../types/common';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {GrAccessSection} from '../gr-access-section/gr-access-section';
@@ -39,10 +39,15 @@
 import {LitElement, PropertyValues, css, html} from 'lit';
 import {customElement, property, query, state} from 'lit/decorators.js';
 import {assertIsDefined} from '../../../utils/common-util';
-import {ValueChangedEvent} from '../../../types/events';
-import {ifDefined} from 'lit/directives/if-defined.js';
+import {
+  AutocompleteCommitEvent,
+  ValueChangedEvent,
+} from '../../../types/events';
 import {resolve} from '../../../models/dependency';
 import {createChangeUrl} from '../../../models/views/change';
+import {throwingErrorCallback} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
+import {createRepoUrl, RepoDetailView} from '../../../models/views/repo';
+import '../../shared/gr-weblink/gr-weblink';
 
 const NOTHING_TO_SAVE = 'No changes to save.';
 
@@ -79,7 +84,7 @@
   @state() capabilities?: CapabilityInfoMap;
 
   // private but used in test
-  @state() groups?: ProjectAccessGroups;
+  @state() groups?: RepoAccessGroups;
 
   // private but used in test
   @state() inheritsFrom?: ProjectInfo;
@@ -141,7 +146,7 @@
           min-height: 2em;
           align-items: center;
         }
-        .weblink {
+        gr-weblink {
           margin-right: var(--spacing-xs);
         }
         gr-access-section {
@@ -192,7 +197,7 @@
               id="editInheritFromInput"
               .text=${this.inheritFromFilter}
               .query=${this.query}
-              @commit=${(e: ValueChangedEvent) => {
+              @commit=${(e: AutocompleteCommitEvent) => {
                 this.handleUpdateInheritFrom(e);
               }}
               @bind-value-changed=${(e: ValueChangedEvent) => {
@@ -205,7 +210,9 @@
           </h3>
           <div class="weblinks ${this.weblinks?.length ? 'show' : ''}">
             History:
-            ${this.weblinks?.map(webLink => this.renderWebLinks(webLink))}
+            ${this.weblinks?.map(
+              info => html`<gr-weblink .info=${info}></gr-weblink>`
+            )}
           </div>
           ${this.sections?.map((section, index) =>
             this.renderPermissionSections(section, index)
@@ -249,19 +256,6 @@
     `;
   }
 
-  private renderWebLinks(webLink: WebLinkInfo) {
-    return html`
-      <a
-        class="weblink"
-        href=${webLink.url}
-        rel="noopener"
-        target=${ifDefined(webLink.target)}
-      >
-        ${webLink.name}
-      </a>
-    `;
-  }
-
   private renderPermissionSections(
     section: PermissionAccessSection,
     index: number
@@ -318,7 +312,7 @@
 
     this.editing = false;
 
-    // Always reset sections when a project changes.
+    // Always reset sections when a repo changes.
     this.sections = [];
     const sectionsPromises = this.restApiService
       .getRepoAccessRights(repo, errFn)
@@ -386,7 +380,7 @@
   }
 
   // private but used in test
-  handleUpdateInheritFrom(e: ValueChangedEvent) {
+  handleUpdateInheritFrom(e: AutocompleteCommitEvent) {
     this.inheritsFrom = {
       ...(this.inheritsFrom ?? {}),
       id: e.detail.value as UrlEncodedRepoName,
@@ -397,19 +391,24 @@
 
   private getInheritFromSuggestions(): Promise<AutocompleteSuggestion[]> {
     return this.restApiService
-      .getRepos(this.inheritFromFilter, MAX_AUTOCOMPLETE_RESULTS)
+      .getRepos(
+        this.inheritFromFilter,
+        MAX_AUTOCOMPLETE_RESULTS,
+        /* offset=*/ undefined,
+        throwingErrorCallback
+      )
       .then(response => {
-        const projects: AutocompleteSuggestion[] = [];
+        const repos: AutocompleteSuggestion[] = [];
         if (!response) {
-          return projects;
+          return repos;
         }
         for (const item of response) {
-          projects.push({
+          repos.push({
             name: item.name,
             value: item.id,
           });
         }
-        return projects;
+        return repos;
       });
   }
 
@@ -720,10 +719,10 @@
 
   computeParentHref() {
     if (!this.inheritsFrom?.name) return '';
-    return `${getBaseUrl()}/admin/repos/${encodeURL(
-      this.inheritsFrom.name,
-      true
-    )},access`;
+    return createRepoUrl({
+      repo: this.inheritsFrom.name,
+      detail: RepoDetailView.ACCESS,
+    });
   }
 
   private handleEditInheritFromTextChanged(e: ValueChangedEvent) {
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_test.ts b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_test.ts
index 85d5c21..467857d 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_test.ts
@@ -22,12 +22,9 @@
   UrlEncodedRepoName,
 } from '../../../types/common';
 import {PermissionAction} from '../../../constants/constants';
-import {PageErrorEvent} from '../../../types/events';
+import {AutocompleteCommitEvent, PageErrorEvent} from '../../../types/events';
 import {GrButton} from '../../shared/gr-button/gr-button';
-import {
-  AutocompleteCommitEvent,
-  GrAutocomplete,
-} from '../../shared/gr-autocomplete/gr-autocomplete';
+import {GrAutocomplete} from '../../shared/gr-autocomplete/gr-autocomplete';
 import {GrAccessSection} from '../gr-access-section/gr-access-section';
 import {GrPermission} from '../gr-permission/gr-permission';
 import {createChange} from '../../../test/test-data-generators';
@@ -303,7 +300,7 @@
     };
     await element.updateComplete;
 
-    // When there is a parent project, the link should be displayed.
+    // When there is a parent repo, the link should be displayed.
     assert.notEqual(
       getComputedStyle(
         queryAndAssert<HTMLHeadingElement>(element, '#inheritsFrom')
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.ts b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.ts
index 9867aa5..553de0e 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.ts
@@ -3,13 +3,12 @@
  * Copyright 2017 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
 import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
 import '../../plugins/gr-endpoint-param/gr-endpoint-param';
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-dialog/gr-dialog';
-import '../../shared/gr-overlay/gr-overlay';
 import '../gr-create-change-dialog/gr-create-change-dialog';
+import '../gr-create-change-dialog/gr-create-file-edit-dialog';
 import {navigationToken} from '../../core/gr-navigation/gr-navigation';
 import {
   BranchName,
@@ -17,7 +16,6 @@
   RevisionPatchSetNum,
   RepoName,
 } from '../../../types/common';
-import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
 import {GrCreateChangeDialog} from '../gr-create-change-dialog/gr-create-change-dialog';
 import {
   fireAlert,
@@ -33,8 +31,10 @@
 import {LitElement, PropertyValues, css, html} from 'lit';
 import {customElement, query, property, state} from 'lit/decorators.js';
 import {assertIsDefined} from '../../../utils/common-util';
-import {createEditUrl} from '../../../models/views/edit';
+import {createEditUrl} from '../../../models/views/change';
 import {resolve} from '../../../models/dependency';
+import {modalStyles} from '../../../styles/gr-modal-styles';
+import {GrCreateFileEditDialog} from '../gr-create-change-dialog/gr-create-file-edit-dialog';
 
 const GC_MESSAGE = 'Garbage collection completed successfully.';
 const CONFIG_BRANCH = 'refs/meta/config' as BranchName;
@@ -52,15 +52,24 @@
 
 @customElement('gr-repo-commands')
 export class GrRepoCommands extends LitElement {
-  @query('#createChangeOverlay')
-  private readonly createChangeOverlay?: GrOverlay;
+  @query('#createChangeModal')
+  private readonly createChangeModal?: HTMLDialogElement;
 
   @query('#createNewChangeModal')
   private readonly createNewChangeModal?: GrCreateChangeDialog;
 
+  @query('#createFileEditDialog')
+  private readonly createFileEditDialog?: GrCreateFileEditDialog;
+
   @property({type: String})
   repo?: RepoName;
 
+  @property({type: Object})
+  createEdit?: {
+    branch: BranchName;
+    path: string;
+  };
+
   @state() private loading = true;
 
   @state() private repoConfig?: ConfigInfo;
@@ -77,9 +86,12 @@
 
   private readonly getNavigation = resolve(this, navigationToken);
 
+  /** Make sure that this dialog is only activated once. */
+  private createFileEditDialogWasActivated = false;
+
   override connectedCallback() {
     super.connectedCallback();
-    fireTitleChange(this, 'Repo Commands');
+    fireTitleChange('Repo Commands');
   }
 
   static override get styles() {
@@ -88,6 +100,7 @@
       formStyles,
       subpageStyles,
       sharedStyles,
+      modalStyles,
       css`
         #form h2,
         h3 {
@@ -156,7 +169,7 @@
           </div>
         </div>
       </div>
-      <gr-overlay id="createChangeOverlay" with-backdrop>
+      <dialog id="createChangeModal" tabindex="-1">
         <gr-dialog
           id="createChangeDialog"
           confirm-label="Create"
@@ -180,7 +193,13 @@
             ></gr-create-change-dialog>
           </div>
         </gr-dialog>
-      </gr-overlay>
+      </dialog>
+      <gr-create-file-edit-dialog
+        id="createFileEditDialog"
+        .repo=${this.repo}
+        .branch=${this.createEdit?.branch}
+        .path=${this.createEdit?.path}
+      ></gr-create-file-edit-dialog>
     `;
   }
 
@@ -200,6 +219,15 @@
     `;
   }
 
+  override updated(changedProperties: PropertyValues) {
+    if (changedProperties.has('createEdit')) {
+      if (!this.createFileEditDialogWasActivated) {
+        this.createFileEditDialog?.activate();
+        this.createFileEditDialogWasActivated = true;
+      }
+    }
+  }
+
   override willUpdate(changedProperties: PropertyValues) {
     if (changedProperties.has('repo')) {
       this.loadRepo();
@@ -242,8 +270,8 @@
 
   // private but used in test
   createNewChange() {
-    assertIsDefined(this.createChangeOverlay, 'createChangeOverlay');
-    this.createChangeOverlay.open();
+    assertIsDefined(this.createChangeModal, 'createChangeModal');
+    this.createChangeModal.showModal();
   }
 
   // private but used in test
@@ -258,8 +286,8 @@
 
   // private but used in test
   handleCloseCreateChange() {
-    assertIsDefined(this.createChangeOverlay, 'createChangeOverlay');
-    this.createChangeOverlay.close();
+    assertIsDefined(this.createChangeModal, 'createChangeModal');
+    this.createChangeModal.close();
   }
 
   /**
@@ -291,9 +319,9 @@
         this.getNavigation().setUrl(
           createEditUrl({
             changeNum: change._number,
-            project: change.project,
-            path: CONFIG_PATH,
+            repo: change.project,
             patchNum: INITIAL_PATCHSET,
+            editView: {path: CONFIG_PATH},
           })
         );
       })
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_test.ts b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_test.ts
index 77caf5e..af2831a 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_test.ts
@@ -12,9 +12,8 @@
   queryAndAssert,
   stubRestApi,
 } from '../../../test/test-utils';
-import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
 import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
-import {EventType, PageErrorEvent} from '../../../types/events';
+import {PageErrorEvent} from '../../../types/events';
 import {RepoName} from '../../../types/common';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {fixture, html, assert} from '@open-wc/testing';
@@ -81,13 +80,7 @@
             </div>
           </div>
         </div>
-        <gr-overlay
-          aria-hidden="true"
-          id="createChangeOverlay"
-          style="outline: none; display: none;"
-          tabindex="-1"
-          with-backdrop=""
-        >
+        <dialog id="createChangeModal" tabindex="-1">
           <gr-dialog
             confirm-label="Create"
             disabled=""
@@ -100,7 +93,9 @@
               </gr-create-change-dialog>
             </div>
           </gr-dialog>
-        </gr-overlay>
+        </dialog>
+        <gr-create-file-edit-dialog id="createFileEditDialog">
+        </gr-create-file-edit-dialog>
       `,
       {ignoreTags: ['p']}
     );
@@ -109,8 +104,8 @@
   suite('create new change dialog', () => {
     test('createNewChange opens modal', () => {
       const openStub = sinon.stub(
-        queryAndAssert<GrOverlay>(element, '#createChangeOverlay'),
-        'open'
+        queryAndAssert<HTMLDialogElement>(element, '#createChangeModal'),
+        'showModal'
       );
       element.createNewChange();
       assert.isTrue(openStub.called);
@@ -152,7 +147,7 @@
       handleSpy = sinon.spy(element, 'handleEditRepoConfig');
       alertStub = sinon.stub();
       element.repo = 'test' as RepoName;
-      element.addEventListener(EventType.SHOW_ALERT, alertStub);
+      element.addEventListener('show-alert', alertStub);
     });
 
     test('successful creation of change', async () => {
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.ts b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.ts
index 86d4bc5..70403fd 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.ts
@@ -9,11 +9,9 @@
 import '../../shared/gr-date-formatter/gr-date-formatter';
 import '../../shared/gr-dialog/gr-dialog';
 import '../../shared/gr-list-view/gr-list-view';
-import '../../shared/gr-overlay/gr-overlay';
+import '../../shared/gr-weblink/gr-weblink';
 import '../gr-create-pointer-dialog/gr-create-pointer-dialog';
 import '../gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog';
-import {encodeURL} from '../../../utils/url-util';
-import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
 import {GrCreatePointerDialog} from '../gr-create-pointer-dialog/gr-create-pointer-dialog';
 import {
   BranchInfo,
@@ -27,24 +25,28 @@
 import {firePageError} from '../../../utils/event-util';
 import {getAppContext} from '../../../services/app-context';
 import {ErrorCallback} from '../../../api/rest';
-import {SHOWN_ITEMS_COUNT} from '../../../constants/constants';
 import {formStyles} from '../../../styles/gr-form-styles';
 import {tableStyles} from '../../../styles/gr-table-styles';
 import {sharedStyles} from '../../../styles/shared-styles';
-import {LitElement, PropertyValues, css, html} from 'lit';
+import {LitElement, PropertyValues, css, html, nothing} from 'lit';
 import {customElement, query, property, state} from 'lit/decorators.js';
 import {BindValueChangeEvent} from '../../../types/events';
 import {assertIsDefined} from '../../../utils/common-util';
 import {ifDefined} from 'lit/directives/if-defined.js';
-import {RepoDetailView, RepoViewState} from '../../../models/views/repo';
+import {
+  createRepoUrl,
+  RepoDetailView,
+  RepoViewState,
+} from '../../../models/views/repo';
+import {modalStyles} from '../../../styles/gr-modal-styles';
 
 const PGP_START = '-----BEGIN PGP SIGNATURE-----';
 
 @customElement('gr-repo-detail-list')
 export class GrRepoDetailList extends LitElement {
-  @query('#overlay') private readonly overlay?: GrOverlay;
+  @query('#modal') private readonly modal?: HTMLDialogElement;
 
-  @query('#createOverlay') private readonly createOverlay?: GrOverlay;
+  @query('#createModal') private readonly createModal?: HTMLDialogElement;
 
   @query('#createNewModal')
   private readonly createNewModal?: GrCreatePointerDialog;
@@ -52,36 +54,30 @@
   @property({type: Object})
   params?: RepoViewState;
 
-  // private but used in test
   @state() detailType?: RepoDetailView.BRANCHES | RepoDetailView.TAGS;
 
-  // private but used in test
   @state() isOwner = false;
 
-  @state() private loggedIn = false;
+  @state() loggedIn = false;
 
-  @state() private offset = 0;
+  @state() offset = 0;
 
-  // private but used in test
   @state() repo?: RepoName;
 
-  // private but used in test
   @state() items?: BranchInfo[] | TagInfo[];
 
-  @state() private readonly itemsPerPage = 25;
+  @state() readonly itemsPerPage = 25;
 
-  @state() private loading = true;
+  @state() loading = true;
 
-  @state() private filter?: string;
+  @state() filter?: string;
 
-  @state() private refName?: GitRef;
+  @state() refName?: GitRef;
 
-  @state() private newItemName = false;
+  @state() newItemName = false;
 
-  // private but used in test
   @state() isEditing = false;
 
-  // private but used in test
   @state() revisedRef?: GitRef;
 
   private readonly restApiService = getAppContext().restApiService;
@@ -91,6 +87,7 @@
       formStyles,
       tableStyles,
       sharedStyles,
+      modalStyles,
       css`
         .tags td.name {
           min-width: 25em;
@@ -139,6 +136,8 @@
   }
 
   override render() {
+    if (!this.repo) return nothing;
+    if (!this.detailType) return nothing;
     return html`
       <gr-list-view
         .createNew=${this.loggedIn}
@@ -147,7 +146,7 @@
         .items=${this.items}
         .loading=${this.loading}
         .offset=${this.offset}
-        .path=${this.getPath(this.repo, this.detailType)}
+        .path=${this.getPath()}
         @create-clicked=${() => {
           this.handleCreateClicked();
         }}
@@ -185,11 +184,11 @@
           </tbody>
           <tbody class=${this.loading ? 'loading' : ''}>
             ${this.items
-              ?.slice(0, SHOWN_ITEMS_COUNT)
+              ?.slice(0, this.itemsPerPage)
               .map((item, index) => this.renderItemList(item, index))}
           </tbody>
         </table>
-        <gr-overlay id="overlay" with-backdrop>
+        <dialog id="modal" tabindex="-1">
           <gr-confirm-delete-item-dialog
             class="confirmDialog"
             .item=${this.refName}
@@ -199,9 +198,9 @@
               this.handleConfirmDialogCancel();
             }}
           ></gr-confirm-delete-item-dialog>
-        </gr-overlay>
+        </dialog>
       </gr-list-view>
-      <gr-overlay id="createOverlay" with-backdrop>
+      <dialog id="createModal" tabindex="-1">
         <gr-dialog
           id="createDialog"
           ?disabled=${!this.newItemName}
@@ -228,7 +227,7 @@
             ></gr-create-pointer-dialog>
           </div>
         </gr-dialog>
-      </gr-overlay>
+      </dialog>
     `;
   }
 
@@ -337,12 +336,8 @@
     `;
   }
 
-  private renderWeblink(link: WebLinkInfo) {
-    return html`
-      <a href=${link.url} class="webLink" rel="noopener" target="_blank">
-        (${link.name})
-      </a>
-    `;
+  private renderWeblink(info: WebLinkInfo) {
+    return html`<gr-weblink imageAndText .info=${info}></gr-weblink>`;
   }
 
   override willUpdate(changedProperties: PropertyValues) {
@@ -441,8 +436,10 @@
     return Promise.reject(new Error('unknown detail type'));
   }
 
-  private getPath(repo?: RepoName, detailType?: RepoDetailView) {
-    return `/admin/repos/${encodeURL(repo ?? '', false)},${detailType}`;
+  private getPath() {
+    if (!this.repo) return '';
+    if (!this.detailType) return '';
+    return createRepoUrl({repo: this.repo, detail: this.detailType});
   }
 
   private computeWeblink(repo: ProjectInfo | BranchInfo | TagInfo) {
@@ -531,8 +528,8 @@
   }
 
   private handleDeleteItemConfirm() {
-    assertIsDefined(this.overlay, 'overlay');
-    this.overlay.close();
+    assertIsDefined(this.modal, 'modal');
+    this.modal.close();
     if (!this.repo || !this.refName) {
       return Promise.reject(new Error('undefined repo or refName'));
     }
@@ -569,20 +566,20 @@
   }
 
   private handleConfirmDialogCancel() {
-    assertIsDefined(this.overlay, 'overlay');
-    this.overlay.close();
+    assertIsDefined(this.modal, 'modal');
+    this.modal.close();
   }
 
   private handleDeleteItem(index: number) {
     if (!this.items) return;
-    assertIsDefined(this.overlay, 'overlay');
+    assertIsDefined(this.modal, 'modal');
     const name = this.stripRefs(
       this.items[index].ref,
       this.detailType
     ) as GitRef;
     if (!name) return;
     this.refName = name;
-    this.overlay.open();
+    this.modal.showModal();
   }
 
   // private but used in test
@@ -594,14 +591,14 @@
 
   // private but used in test
   handleCloseCreate() {
-    assertIsDefined(this.createOverlay, 'createOverlay');
-    this.createOverlay.close();
+    assertIsDefined(this.createModal, 'createModal');
+    this.createModal.close();
   }
 
   // private but used in test
   handleCreateClicked() {
-    assertIsDefined(this.createOverlay, 'createOverlay');
-    this.createOverlay.open();
+    assertIsDefined(this.createModal, 'createModal');
+    this.createModal.showModal();
   }
 
   private handleUpdateItemName() {
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_test.ts b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_test.ts
index 49a82e5..5e25b33 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_test.ts
@@ -6,7 +6,6 @@
 import '../../../test/common-test-setup';
 import './gr-repo-detail-list';
 import {GrRepoDetailList} from './gr-repo-detail-list';
-import {page} from '../../../utils/page-wrapper-utils';
 import {
   addListenerForTest,
   mockPromise,
@@ -20,22 +19,21 @@
   GitRef,
   GroupId,
   GroupName,
-  ProjectAccessGroups,
-  ProjectAccessInfoMap,
+  RepoAccessGroups,
+  RepoAccessInfoMap,
   RepoName,
   TagInfo,
   Timestamp,
-  TimezoneOffset,
 } from '../../../types/common';
 import {GerritView} from '../../../services/router/router-model';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {PageErrorEvent} from '../../../types/events';
-import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
 import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
 import {GrListView} from '../../shared/gr-list-view/gr-list-view';
-import {SHOWN_ITEMS_COUNT} from '../../../constants/constants';
 import {fixture, html, assert} from '@open-wc/testing';
 import {RepoDetailView} from '../../../models/views/repo';
+import {testResolver} from '../../../test/common-test-setup';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
 
 function branchGenerator(counter: number) {
   return {
@@ -74,7 +72,6 @@
       name: 'Test User',
       email: 'test.user@gmail.com' as EmailAddress,
       date: '2017-09-19 14:54:00.000000000' as Timestamp,
-      tz: 540 as TimezoneOffset,
     },
   };
 }
@@ -97,7 +94,7 @@
         html`<gr-repo-detail-list></gr-repo-detail-list>`
       );
       element.detailType = RepoDetailView.BRANCHES;
-      sinon.stub(page, 'show');
+      sinon.stub(testResolver(navigationToken), 'setUrl');
     });
 
     suite('list of repo branches', () => {
@@ -254,14 +251,7 @@
                     <td class="hideItem message"></td>
                     <td class="hideItem tagger"></td>
                     <td class="repositoryBrowser">
-                      <a
-                        class="webLink"
-                        href="https://git.example.org/branch/test;refs/heads/test0"
-                        rel="noopener"
-                        target="_blank"
-                      >
-                        (diffusion)
-                      </a>
+                      <gr-weblink imageAndText></gr-weblink>
                     </td>
                     <td class="delete">
                       <gr-button
@@ -330,14 +320,7 @@
                     <td class="hideItem message"></td>
                     <td class="hideItem tagger"></td>
                     <td class="repositoryBrowser">
-                      <a
-                        class="webLink"
-                        href="https://git.example.org/branch/test;refs/heads/test1"
-                        rel="noopener"
-                        target="_blank"
-                      >
-                        (diffusion)
-                      </a>
+                      <gr-weblink imageAndText></gr-weblink>
                     </td>
                     <td class="delete">
                       <gr-button
@@ -406,14 +389,7 @@
                     <td class="hideItem message"></td>
                     <td class="hideItem tagger"></td>
                     <td class="repositoryBrowser">
-                      <a
-                        class="webLink"
-                        href="https://git.example.org/branch/test;refs/heads/test2"
-                        rel="noopener"
-                        target="_blank"
-                      >
-                        (diffusion)
-                      </a>
+                      <gr-weblink imageAndText></gr-weblink>
                     </td>
                     <td class="delete">
                       <gr-button
@@ -482,14 +458,7 @@
                     <td class="hideItem message"></td>
                     <td class="hideItem tagger"></td>
                     <td class="repositoryBrowser">
-                      <a
-                        class="webLink"
-                        href="https://git.example.org/branch/test;refs/heads/test3"
-                        rel="noopener"
-                        target="_blank"
-                      >
-                        (diffusion)
-                      </a>
+                      <gr-weblink imageAndText></gr-weblink>
                     </td>
                     <td class="delete">
                       <gr-button
@@ -558,14 +527,7 @@
                     <td class="hideItem message"></td>
                     <td class="hideItem tagger"></td>
                     <td class="repositoryBrowser">
-                      <a
-                        class="webLink"
-                        href="https://git.example.org/branch/test;refs/heads/test4"
-                        rel="noopener"
-                        target="_blank"
-                      >
-                        (diffusion)
-                      </a>
+                      <gr-weblink imageAndText></gr-weblink>
                     </td>
                     <td class="delete">
                       <gr-button
@@ -634,14 +596,7 @@
                     <td class="hideItem message"></td>
                     <td class="hideItem tagger"></td>
                     <td class="repositoryBrowser">
-                      <a
-                        class="webLink"
-                        href="https://git.example.org/branch/test;refs/heads/test5"
-                        rel="noopener"
-                        target="_blank"
-                      >
-                        (diffusion)
-                      </a>
+                      <gr-weblink imageAndText></gr-weblink>
                     </td>
                     <td class="delete">
                       <gr-button
@@ -710,14 +665,7 @@
                     <td class="hideItem message"></td>
                     <td class="hideItem tagger"></td>
                     <td class="repositoryBrowser">
-                      <a
-                        class="webLink"
-                        href="https://git.example.org/branch/test;refs/heads/test6"
-                        rel="noopener"
-                        target="_blank"
-                      >
-                        (diffusion)
-                      </a>
+                      <gr-weblink imageAndText></gr-weblink>
                     </td>
                     <td class="delete">
                       <gr-button
@@ -786,14 +734,7 @@
                     <td class="hideItem message"></td>
                     <td class="hideItem tagger"></td>
                     <td class="repositoryBrowser">
-                      <a
-                        class="webLink"
-                        href="https://git.example.org/branch/test;refs/heads/test7"
-                        rel="noopener"
-                        target="_blank"
-                      >
-                        (diffusion)
-                      </a>
+                      <gr-weblink imageAndText></gr-weblink>
                     </td>
                     <td class="delete">
                       <gr-button
@@ -862,14 +803,7 @@
                     <td class="hideItem message"></td>
                     <td class="hideItem tagger"></td>
                     <td class="repositoryBrowser">
-                      <a
-                        class="webLink"
-                        href="https://git.example.org/branch/test;refs/heads/test8"
-                        rel="noopener"
-                        target="_blank"
-                      >
-                        (diffusion)
-                      </a>
+                      <gr-weblink imageAndText></gr-weblink>
                     </td>
                     <td class="delete">
                       <gr-button
@@ -938,14 +872,7 @@
                     <td class="hideItem message"></td>
                     <td class="hideItem tagger"></td>
                     <td class="repositoryBrowser">
-                      <a
-                        class="webLink"
-                        href="https://git.example.org/branch/test;refs/heads/test9"
-                        rel="noopener"
-                        target="_blank"
-                      >
-                        (diffusion)
-                      </a>
+                      <gr-weblink imageAndText></gr-weblink>
                     </td>
                     <td class="delete">
                       <gr-button
@@ -1014,14 +941,7 @@
                     <td class="hideItem message"></td>
                     <td class="hideItem tagger"></td>
                     <td class="repositoryBrowser">
-                      <a
-                        class="webLink"
-                        href="https://git.example.org/branch/test;refs/heads/test10"
-                        rel="noopener"
-                        target="_blank"
-                      >
-                        (diffusion)
-                      </a>
+                      <gr-weblink imageAndText></gr-weblink>
                     </td>
                     <td class="delete">
                       <gr-button
@@ -1090,14 +1010,7 @@
                     <td class="hideItem message"></td>
                     <td class="hideItem tagger"></td>
                     <td class="repositoryBrowser">
-                      <a
-                        class="webLink"
-                        href="https://git.example.org/branch/test;refs/heads/test11"
-                        rel="noopener"
-                        target="_blank"
-                      >
-                        (diffusion)
-                      </a>
+                      <gr-weblink imageAndText></gr-weblink>
                     </td>
                     <td class="delete">
                       <gr-button
@@ -1166,14 +1079,7 @@
                     <td class="hideItem message"></td>
                     <td class="hideItem tagger"></td>
                     <td class="repositoryBrowser">
-                      <a
-                        class="webLink"
-                        href="https://git.example.org/branch/test;refs/heads/test12"
-                        rel="noopener"
-                        target="_blank"
-                      >
-                        (diffusion)
-                      </a>
+                      <gr-weblink imageAndText></gr-weblink>
                     </td>
                     <td class="delete">
                       <gr-button
@@ -1242,14 +1148,7 @@
                     <td class="hideItem message"></td>
                     <td class="hideItem tagger"></td>
                     <td class="repositoryBrowser">
-                      <a
-                        class="webLink"
-                        href="https://git.example.org/branch/test;refs/heads/test13"
-                        rel="noopener"
-                        target="_blank"
-                      >
-                        (diffusion)
-                      </a>
+                      <gr-weblink imageAndText></gr-weblink>
                     </td>
                     <td class="delete">
                       <gr-button
@@ -1318,14 +1217,7 @@
                     <td class="hideItem message"></td>
                     <td class="hideItem tagger"></td>
                     <td class="repositoryBrowser">
-                      <a
-                        class="webLink"
-                        href="https://git.example.org/branch/test;refs/heads/test14"
-                        rel="noopener"
-                        target="_blank"
-                      >
-                        (diffusion)
-                      </a>
+                      <gr-weblink imageAndText></gr-weblink>
                     </td>
                     <td class="delete">
                       <gr-button
@@ -1394,14 +1286,7 @@
                     <td class="hideItem message"></td>
                     <td class="hideItem tagger"></td>
                     <td class="repositoryBrowser">
-                      <a
-                        class="webLink"
-                        href="https://git.example.org/branch/test;refs/heads/test15"
-                        rel="noopener"
-                        target="_blank"
-                      >
-                        (diffusion)
-                      </a>
+                      <gr-weblink imageAndText></gr-weblink>
                     </td>
                     <td class="delete">
                       <gr-button
@@ -1470,14 +1355,7 @@
                     <td class="hideItem message"></td>
                     <td class="hideItem tagger"></td>
                     <td class="repositoryBrowser">
-                      <a
-                        class="webLink"
-                        href="https://git.example.org/branch/test;refs/heads/test16"
-                        rel="noopener"
-                        target="_blank"
-                      >
-                        (diffusion)
-                      </a>
+                      <gr-weblink imageAndText></gr-weblink>
                     </td>
                     <td class="delete">
                       <gr-button
@@ -1546,14 +1424,7 @@
                     <td class="hideItem message"></td>
                     <td class="hideItem tagger"></td>
                     <td class="repositoryBrowser">
-                      <a
-                        class="webLink"
-                        href="https://git.example.org/branch/test;refs/heads/test17"
-                        rel="noopener"
-                        target="_blank"
-                      >
-                        (diffusion)
-                      </a>
+                      <gr-weblink imageAndText></gr-weblink>
                     </td>
                     <td class="delete">
                       <gr-button
@@ -1622,14 +1493,7 @@
                     <td class="hideItem message"></td>
                     <td class="hideItem tagger"></td>
                     <td class="repositoryBrowser">
-                      <a
-                        class="webLink"
-                        href="https://git.example.org/branch/test;refs/heads/test18"
-                        rel="noopener"
-                        target="_blank"
-                      >
-                        (diffusion)
-                      </a>
+                      <gr-weblink imageAndText></gr-weblink>
                     </td>
                     <td class="delete">
                       <gr-button
@@ -1698,14 +1562,7 @@
                     <td class="hideItem message"></td>
                     <td class="hideItem tagger"></td>
                     <td class="repositoryBrowser">
-                      <a
-                        class="webLink"
-                        href="https://git.example.org/branch/test;refs/heads/test19"
-                        rel="noopener"
-                        target="_blank"
-                      >
-                        (diffusion)
-                      </a>
+                      <gr-weblink imageAndText></gr-weblink>
                     </td>
                     <td class="delete">
                       <gr-button
@@ -1774,14 +1631,7 @@
                     <td class="hideItem message"></td>
                     <td class="hideItem tagger"></td>
                     <td class="repositoryBrowser">
-                      <a
-                        class="webLink"
-                        href="https://git.example.org/branch/test;refs/heads/test20"
-                        rel="noopener"
-                        target="_blank"
-                      >
-                        (diffusion)
-                      </a>
+                      <gr-weblink imageAndText></gr-weblink>
                     </td>
                     <td class="delete">
                       <gr-button
@@ -1850,14 +1700,7 @@
                     <td class="hideItem message"></td>
                     <td class="hideItem tagger"></td>
                     <td class="repositoryBrowser">
-                      <a
-                        class="webLink"
-                        href="https://git.example.org/branch/test;refs/heads/test21"
-                        rel="noopener"
-                        target="_blank"
-                      >
-                        (diffusion)
-                      </a>
+                      <gr-weblink imageAndText></gr-weblink>
                     </td>
                     <td class="delete">
                       <gr-button
@@ -1926,14 +1769,7 @@
                     <td class="hideItem message"></td>
                     <td class="hideItem tagger"></td>
                     <td class="repositoryBrowser">
-                      <a
-                        class="webLink"
-                        href="https://git.example.org/branch/test;refs/heads/test22"
-                        rel="noopener"
-                        target="_blank"
-                      >
-                        (diffusion)
-                      </a>
+                      <gr-weblink imageAndText></gr-weblink>
                     </td>
                     <td class="delete">
                       <gr-button
@@ -2002,14 +1838,7 @@
                     <td class="hideItem message"></td>
                     <td class="hideItem tagger"></td>
                     <td class="repositoryBrowser">
-                      <a
-                        class="webLink"
-                        href="https://git.example.org/branch/test;refs/heads/test23"
-                        rel="noopener"
-                        target="_blank"
-                      >
-                        (diffusion)
-                      </a>
+                      <gr-weblink imageAndText></gr-weblink>
                     </td>
                     <td class="delete">
                       <gr-button
@@ -2026,24 +1855,12 @@
                   </tr>
                 </tbody>
               </table>
-              <gr-overlay
-                aria-hidden="true"
-                id="overlay"
-                style="outline: none; display: none;"
-                tabindex="-1"
-                with-backdrop=""
-              >
+              <dialog id="modal" tabindex="-1">
                 <gr-confirm-delete-item-dialog class="confirmDialog">
                 </gr-confirm-delete-item-dialog>
-              </gr-overlay>
+              </dialog>
             </gr-list-view>
-            <gr-overlay
-              aria-hidden="true"
-              id="createOverlay"
-              style="outline: none; display: none;"
-              tabindex="-1"
-              with-backdrop=""
-            >
+            <dialog id="createModal" tabindex="-1">
               <gr-dialog
                 confirm-label="Create"
                 disabled=""
@@ -2056,7 +1873,7 @@
                   </gr-create-pointer-dialog>
                 </div>
               </gr-dialog>
-            </gr-overlay>
+            </dialog>
           `
         );
       });
@@ -2103,10 +1920,10 @@
                   url: 'test',
                   name: 'test' as GroupName,
                 },
-              } as ProjectAccessGroups,
+              } as RepoAccessGroups,
               config_web_links: [{name: 'gitiles', url: 'test'}],
             },
-          } as ProjectAccessInfoMap)
+          } as RepoAccessInfoMap)
         );
         await element.determineIfOwner('test' as RepoName);
         assert.equal(element.isOwner, false);
@@ -2157,10 +1974,10 @@
                   url: 'test',
                   name: 'test' as GroupName,
                 },
-              } as ProjectAccessGroups,
+              } as RepoAccessGroups,
               config_web_links: [{name: 'gitiles', url: 'test'}],
             },
-          } as ProjectAccessInfoMap)
+          } as RepoAccessInfoMap)
         );
         const handleSaveRevisionStub = sinon.stub(
           element,
@@ -2316,7 +2133,7 @@
       test('fires page-error', async () => {
         const response = {status: 404} as Response;
         stubRestApi('getRepoBranches').callsFake(
-          (_filter, _repo, _reposBranchesPerPage, _opt_offset, errFn) => {
+          (_filter, _repo, _reposBranchesPerPage, _offset, errFn) => {
             if (errFn !== undefined) {
               errFn(response);
             }
@@ -2352,7 +2169,7 @@
         html`<gr-repo-detail-list></gr-repo-detail-list>`
       );
       element.detailType = RepoDetailView.TAGS;
-      sinon.stub(page, 'show');
+      sinon.stub(testResolver(navigationToken), 'setUrl');
     });
 
     suite('list of repo tags', () => {
@@ -2383,7 +2200,6 @@
           name: 'Test User',
           email: 'test.user@gmail.com' as EmailAddress,
           date: '2017-09-19 14:54:00.000000000' as Timestamp,
-          tz: 540 as TimezoneOffset,
         };
 
         assert.deepEqual((element.items as TagInfo[])![2].tagger, tagger);
@@ -2404,7 +2220,9 @@
       });
 
       test('items', () => {
-        assert.equal(element.items!.slice(0, SHOWN_ITEMS_COUNT)!.length, 25);
+        const table = queryAndAssert(element, 'table');
+        const rows = table.querySelectorAll('tr.table');
+        assert.equal(rows.length, element.itemsPerPage);
       });
     });
 
@@ -2424,7 +2242,9 @@
       });
 
       test('items', () => {
-        assert.equal(element.items!.slice(0, SHOWN_ITEMS_COUNT)!.length, 25);
+        const table = queryAndAssert(element, 'table');
+        const rows = table.querySelectorAll('tr.table');
+        assert.equal(rows.length, element.itemsPerPage);
       });
     });
 
@@ -2447,6 +2267,18 @@
     });
 
     suite('create new', () => {
+      setup(async () => {
+        stubRestApi('getRepoBranches').resolves(createBranchesList(3));
+
+        element.params = {
+          view: GerritView.REPO,
+          repo: 'test' as RepoName,
+          detail: RepoDetailView.BRANCHES,
+        };
+        await element.paramsChanged();
+        await element.updateComplete;
+      });
+
       test('handleCreateClicked called when create-click fired', () => {
         const handleCreateClickedStub = sinon.stub(
           element,
@@ -2462,10 +2294,10 @@
       });
 
       test('handleCreateClicked opens modal', () => {
-        queryAndAssert<GrOverlay>(element, '#createOverlay');
+        queryAndAssert<HTMLDialogElement>(element, '#createModal');
         const openStub = sinon.stub(
-          queryAndAssert<GrOverlay>(element, '#createOverlay'),
-          'open'
+          queryAndAssert<HTMLDialogElement>(element, '#createModal'),
+          'showModal'
         );
         element.handleCreateClicked();
         assert.isTrue(openStub.called);
@@ -2498,7 +2330,7 @@
       test('fires page-error', async () => {
         const response = {status: 404} as Response;
         stubRestApi('getRepoTags').callsFake(
-          (_filter, _repo, _reposTagsPerPage, _opt_offset, errFn) => {
+          (_filter, _repo, _reposTagsPerPage, _offset, errFn) => {
             if (errFn !== undefined) {
               errFn(response);
             }
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.ts b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.ts
index 14fbe92..5318500 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.ts
@@ -5,25 +5,25 @@
  */
 import '../../shared/gr-dialog/gr-dialog';
 import '../../shared/gr-list-view/gr-list-view';
-import '../../shared/gr-overlay/gr-overlay';
+import '../../shared/gr-weblink/gr-weblink';
 import '../gr-create-repo-dialog/gr-create-repo-dialog';
-import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
-import {
-  RepoName,
-  ProjectInfoWithName,
-  WebLinkInfo,
-} from '../../../types/common';
+import {ProjectInfoWithName, WebLinkInfo} from '../../../types/common';
 import {GrCreateRepoDialog} from '../gr-create-repo-dialog/gr-create-repo-dialog';
-import {ProjectState, SHOWN_ITEMS_COUNT} from '../../../constants/constants';
+import {RepoState} from '../../../constants/constants';
 import {fireTitleChange} from '../../../utils/event-util';
 import {getAppContext} from '../../../services/app-context';
-import {encodeURL, getBaseUrl} from '../../../utils/url-util';
 import {tableStyles} from '../../../styles/gr-table-styles';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, PropertyValues, css, html} from 'lit';
 import {customElement, property, query, state} from 'lit/decorators.js';
-import {AdminViewState} from '../../../models/views/admin';
+import {
+  AdminChildView,
+  AdminViewState,
+  createAdminUrl,
+} from '../../../models/views/admin';
 import {createSearchUrl} from '../../../models/views/search';
+import {modalStyles} from '../../../styles/gr-modal-styles';
+import {createRepoUrl} from '../../../models/views/repo';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -33,32 +33,25 @@
 
 @customElement('gr-repo-list')
 export class GrRepoList extends LitElement {
-  readonly path = '/admin/repos';
-
-  @query('#createOverlay') private createOverlay?: GrOverlay;
+  @query('#createModal') private createModal?: HTMLDialogElement;
 
   @query('#createNewModal') private createNewModal?: GrCreateRepoDialog;
 
   @property({type: Object})
   params?: AdminViewState;
 
-  // private but used in test
   @state() offset = 0;
 
-  @state() private newRepoName = false;
+  @state() newRepoName = false;
 
-  @state() private createNewCapability = false;
+  @state() createNewCapability = false;
 
-  // private but used in test
   @state() repos: ProjectInfoWithName[] = [];
 
-  // private but used in test
   @state() reposPerPage = 25;
 
-  // private but used in test
   @state() loading = true;
 
-  // private but used in test
   @state() filter = '';
 
   private readonly restApiService = getAppContext().restApiService;
@@ -66,14 +59,15 @@
   override async connectedCallback() {
     super.connectedCallback();
     await this.getCreateRepoCapability();
-    fireTitleChange(this, 'Repos');
-    this.maybeOpenCreateOverlay(this.params);
+    fireTitleChange('Repos');
+    this.maybeOpenCreateModal(this.params);
   }
 
   static override get styles() {
     return [
       tableStyles,
       sharedStyles,
+      modalStyles,
       css`
         .genericList tr td:last-of-type {
           text-align: left;
@@ -103,7 +97,7 @@
         .items=${this.repos}
         .loading=${this.loading}
         .offset=${this.offset}
-        .path=${this.path}
+        .path=${createAdminUrl({adminView: AdminChildView.REPOS})}
         @create-clicked=${() => this.handleCreateClicked()}
       >
         <table id="list" class="genericList">
@@ -127,7 +121,7 @@
           </tbody>
         </table>
       </gr-list-view>
-      <gr-overlay id="createOverlay" with-backdrop>
+      <dialog id="createModal" tabindex="-1">
         <gr-dialog
           id="createDialog"
           class="confirmDialog"
@@ -144,12 +138,12 @@
             ></gr-create-repo-dialog>
           </div>
         </gr-dialog>
-      </gr-overlay>
+      </dialog>
     `;
   }
 
   private renderRepoList() {
-    const shownRepos = this.repos.slice(0, SHOWN_ITEMS_COUNT);
+    const shownRepos = this.repos.slice(0, this.reposPerPage);
     return shownRepos.map(item => this.renderRepo(item));
   }
 
@@ -157,14 +151,14 @@
     return html`
       <tr class="table">
         <td class="name">
-          <a href=${this.computeRepoUrl(item.name)}>${item.name}</a>
+          <a href=${createRepoUrl({repo: item.name})}>${item.name}</a>
         </td>
         <td class="repositoryBrowser">${this.renderWebLinks(item)}</td>
         <td class="changesLink">
-          <a href=${this.computeChangesLink(item.name)}>view all</a>
+          <a href=${createSearchUrl({repo: item.name})}>view all</a>
         </td>
         <td class="readOnly">
-          ${item.state === ProjectState.READ_ONLY ? 'Y' : ''}
+          ${item.state === RepoState.READ_ONLY ? 'Y' : ''}
         </td>
         <td class="description">${item.description}</td>
       </tr>
@@ -176,12 +170,8 @@
     return webLinks.map(link => this.renderWebLink(link));
   }
 
-  private renderWebLink(link: WebLinkInfo) {
-    return html`
-      <a href=${link.url} class="webLink" rel="noopener" target="_blank">
-        ${link.name}
-      </a>
-    `;
+  private renderWebLink(info: WebLinkInfo) {
+    return html`<gr-weblink imageAndText .info=${info}></gr-weblink>`;
   }
 
   override willUpdate(changedProperties: PropertyValues) {
@@ -204,20 +194,12 @@
    *
    * private but used in test
    */
-  maybeOpenCreateOverlay(params?: AdminViewState) {
+  maybeOpenCreateModal(params?: AdminViewState) {
     if (params?.openCreateModal) {
-      this.createOverlay?.open();
+      this.createModal?.showModal();
     }
   }
 
-  private computeRepoUrl(name: string) {
-    return `${getBaseUrl()}${this.path}/${encodeURL(name, true)}`;
-  }
-
-  private computeChangesLink(name: string) {
-    return createSearchUrl({project: name as RepoName});
-  }
-
   private async getCreateRepoCapability() {
     const account = await this.restApiService.getAccount();
 
@@ -268,14 +250,13 @@
 
   // private but used in test
   handleCloseCreate() {
-    this.createOverlay?.close();
+    this.createModal?.close();
   }
 
   // private but used in test
   handleCreateClicked() {
-    this.createOverlay?.open().then(() => {
-      this.createNewModal?.focus();
-    });
+    this.createModal?.showModal();
+    this.createNewModal?.focus();
   }
 
   // private but used in test
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.ts b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.ts
index 0694dfe..36674f5 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.ts
@@ -6,7 +6,6 @@
 import '../../../test/common-test-setup';
 import './gr-repo-list';
 import {GrRepoList} from './gr-repo-list';
-import {page} from '../../../utils/page-wrapper-utils';
 import {
   mockPromise,
   queryAndAssert,
@@ -17,19 +16,20 @@
   ProjectInfoWithName,
   RepoName,
 } from '../../../types/common';
-import {ProjectState, SHOWN_ITEMS_COUNT} from '../../../constants/constants';
+import {RepoState} from '../../../api/rest-api';
 import {GerritView} from '../../../services/router/router-model';
-import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
 import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
 import {GrListView} from '../../shared/gr-list-view/gr-list-view';
 import {fixture, html, assert} from '@open-wc/testing';
 import {AdminChildView, AdminViewState} from '../../../models/views/admin';
+import {testResolver} from '../../../test/common-test-setup';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
 
 function createRepo(name: string, counter: number) {
   return {
     id: `${name}${counter}` as UrlEncodedRepoName,
     name: `${name}` as RepoName,
-    state: 'ACTIVE' as ProjectState,
+    state: 'ACTIVE' as RepoState,
     web_links: [
       {
         name: 'diffusion',
@@ -52,7 +52,7 @@
   let repos: ProjectInfoWithName[];
 
   setup(async () => {
-    sinon.stub(page, 'show');
+    sinon.stub(testResolver(navigationToken), 'setUrl');
     element = await fixture(html`<gr-repo-list></gr-repo-list>`);
   });
 
@@ -90,14 +90,7 @@
                     <a href="/admin/repos/test"> test </a>
                   </td>
                   <td class="repositoryBrowser">
-                    <a
-                      class="webLink"
-                      href="https://phabricator.example.org/r/project/test0"
-                      rel="noopener"
-                      target="_blank"
-                    >
-                      diffusion
-                    </a>
+                    <gr-weblink imageAndText></gr-weblink>
                   </td>
                   <td class="changesLink">
                     <a href="/q/project:test"> view all </a>
@@ -110,14 +103,7 @@
                     <a href="/admin/repos/test"> test </a>
                   </td>
                   <td class="repositoryBrowser">
-                    <a
-                      class="webLink"
-                      href="https://phabricator.example.org/r/project/test1"
-                      rel="noopener"
-                      target="_blank"
-                    >
-                      diffusion
-                    </a>
+                    <gr-weblink imageAndText></gr-weblink>
                   </td>
                   <td class="changesLink">
                     <a href="/q/project:test"> view all </a>
@@ -130,14 +116,7 @@
                     <a href="/admin/repos/test"> test </a>
                   </td>
                   <td class="repositoryBrowser">
-                    <a
-                      class="webLink"
-                      href="https://phabricator.example.org/r/project/test2"
-                      rel="noopener"
-                      target="_blank"
-                    >
-                      diffusion
-                    </a>
+                    <gr-weblink imageAndText></gr-weblink>
                   </td>
                   <td class="changesLink">
                     <a href="/q/project:test"> view all </a>
@@ -150,14 +129,7 @@
                     <a href="/admin/repos/test"> test </a>
                   </td>
                   <td class="repositoryBrowser">
-                    <a
-                      class="webLink"
-                      href="https://phabricator.example.org/r/project/test3"
-                      rel="noopener"
-                      target="_blank"
-                    >
-                      diffusion
-                    </a>
+                    <gr-weblink imageAndText></gr-weblink>
                   </td>
                   <td class="changesLink">
                     <a href="/q/project:test"> view all </a>
@@ -170,14 +142,7 @@
                     <a href="/admin/repos/test"> test </a>
                   </td>
                   <td class="repositoryBrowser">
-                    <a
-                      class="webLink"
-                      href="https://phabricator.example.org/r/project/test4"
-                      rel="noopener"
-                      target="_blank"
-                    >
-                      diffusion
-                    </a>
+                    <gr-weblink imageAndText></gr-weblink>
                   </td>
                   <td class="changesLink">
                     <a href="/q/project:test"> view all </a>
@@ -190,14 +155,7 @@
                     <a href="/admin/repos/test"> test </a>
                   </td>
                   <td class="repositoryBrowser">
-                    <a
-                      class="webLink"
-                      href="https://phabricator.example.org/r/project/test5"
-                      rel="noopener"
-                      target="_blank"
-                    >
-                      diffusion
-                    </a>
+                    <gr-weblink imageAndText></gr-weblink>
                   </td>
                   <td class="changesLink">
                     <a href="/q/project:test"> view all </a>
@@ -210,14 +168,7 @@
                     <a href="/admin/repos/test"> test </a>
                   </td>
                   <td class="repositoryBrowser">
-                    <a
-                      class="webLink"
-                      href="https://phabricator.example.org/r/project/test6"
-                      rel="noopener"
-                      target="_blank"
-                    >
-                      diffusion
-                    </a>
+                    <gr-weblink imageAndText></gr-weblink>
                   </td>
                   <td class="changesLink">
                     <a href="/q/project:test"> view all </a>
@@ -230,14 +181,7 @@
                     <a href="/admin/repos/test"> test </a>
                   </td>
                   <td class="repositoryBrowser">
-                    <a
-                      class="webLink"
-                      href="https://phabricator.example.org/r/project/test7"
-                      rel="noopener"
-                      target="_blank"
-                    >
-                      diffusion
-                    </a>
+                    <gr-weblink imageAndText></gr-weblink>
                   </td>
                   <td class="changesLink">
                     <a href="/q/project:test"> view all </a>
@@ -250,14 +194,7 @@
                     <a href="/admin/repos/test"> test </a>
                   </td>
                   <td class="repositoryBrowser">
-                    <a
-                      class="webLink"
-                      href="https://phabricator.example.org/r/project/test8"
-                      rel="noopener"
-                      target="_blank"
-                    >
-                      diffusion
-                    </a>
+                    <gr-weblink imageAndText></gr-weblink>
                   </td>
                   <td class="changesLink">
                     <a href="/q/project:test"> view all </a>
@@ -270,14 +207,7 @@
                     <a href="/admin/repos/test"> test </a>
                   </td>
                   <td class="repositoryBrowser">
-                    <a
-                      class="webLink"
-                      href="https://phabricator.example.org/r/project/test9"
-                      rel="noopener"
-                      target="_blank"
-                    >
-                      diffusion
-                    </a>
+                    <gr-weblink imageAndText></gr-weblink>
                   </td>
                   <td class="changesLink">
                     <a href="/q/project:test"> view all </a>
@@ -290,14 +220,7 @@
                     <a href="/admin/repos/test"> test </a>
                   </td>
                   <td class="repositoryBrowser">
-                    <a
-                      class="webLink"
-                      href="https://phabricator.example.org/r/project/test10"
-                      rel="noopener"
-                      target="_blank"
-                    >
-                      diffusion
-                    </a>
+                    <gr-weblink imageAndText></gr-weblink>
                   </td>
                   <td class="changesLink">
                     <a href="/q/project:test"> view all </a>
@@ -310,14 +233,7 @@
                     <a href="/admin/repos/test"> test </a>
                   </td>
                   <td class="repositoryBrowser">
-                    <a
-                      class="webLink"
-                      href="https://phabricator.example.org/r/project/test11"
-                      rel="noopener"
-                      target="_blank"
-                    >
-                      diffusion
-                    </a>
+                    <gr-weblink imageAndText></gr-weblink>
                   </td>
                   <td class="changesLink">
                     <a href="/q/project:test"> view all </a>
@@ -330,14 +246,7 @@
                     <a href="/admin/repos/test"> test </a>
                   </td>
                   <td class="repositoryBrowser">
-                    <a
-                      class="webLink"
-                      href="https://phabricator.example.org/r/project/test12"
-                      rel="noopener"
-                      target="_blank"
-                    >
-                      diffusion
-                    </a>
+                    <gr-weblink imageAndText></gr-weblink>
                   </td>
                   <td class="changesLink">
                     <a href="/q/project:test"> view all </a>
@@ -350,14 +259,7 @@
                     <a href="/admin/repos/test"> test </a>
                   </td>
                   <td class="repositoryBrowser">
-                    <a
-                      class="webLink"
-                      href="https://phabricator.example.org/r/project/test13"
-                      rel="noopener"
-                      target="_blank"
-                    >
-                      diffusion
-                    </a>
+                    <gr-weblink imageAndText></gr-weblink>
                   </td>
                   <td class="changesLink">
                     <a href="/q/project:test"> view all </a>
@@ -370,14 +272,7 @@
                     <a href="/admin/repos/test"> test </a>
                   </td>
                   <td class="repositoryBrowser">
-                    <a
-                      class="webLink"
-                      href="https://phabricator.example.org/r/project/test14"
-                      rel="noopener"
-                      target="_blank"
-                    >
-                      diffusion
-                    </a>
+                    <gr-weblink imageAndText></gr-weblink>
                   </td>
                   <td class="changesLink">
                     <a href="/q/project:test"> view all </a>
@@ -390,14 +285,7 @@
                     <a href="/admin/repos/test"> test </a>
                   </td>
                   <td class="repositoryBrowser">
-                    <a
-                      class="webLink"
-                      href="https://phabricator.example.org/r/project/test15"
-                      rel="noopener"
-                      target="_blank"
-                    >
-                      diffusion
-                    </a>
+                    <gr-weblink imageAndText></gr-weblink>
                   </td>
                   <td class="changesLink">
                     <a href="/q/project:test"> view all </a>
@@ -410,14 +298,7 @@
                     <a href="/admin/repos/test"> test </a>
                   </td>
                   <td class="repositoryBrowser">
-                    <a
-                      class="webLink"
-                      href="https://phabricator.example.org/r/project/test16"
-                      rel="noopener"
-                      target="_blank"
-                    >
-                      diffusion
-                    </a>
+                    <gr-weblink imageAndText></gr-weblink>
                   </td>
                   <td class="changesLink">
                     <a href="/q/project:test"> view all </a>
@@ -430,14 +311,7 @@
                     <a href="/admin/repos/test"> test </a>
                   </td>
                   <td class="repositoryBrowser">
-                    <a
-                      class="webLink"
-                      href="https://phabricator.example.org/r/project/test17"
-                      rel="noopener"
-                      target="_blank"
-                    >
-                      diffusion
-                    </a>
+                    <gr-weblink imageAndText></gr-weblink>
                   </td>
                   <td class="changesLink">
                     <a href="/q/project:test"> view all </a>
@@ -450,14 +324,7 @@
                     <a href="/admin/repos/test"> test </a>
                   </td>
                   <td class="repositoryBrowser">
-                    <a
-                      class="webLink"
-                      href="https://phabricator.example.org/r/project/test18"
-                      rel="noopener"
-                      target="_blank"
-                    >
-                      diffusion
-                    </a>
+                    <gr-weblink imageAndText></gr-weblink>
                   </td>
                   <td class="changesLink">
                     <a href="/q/project:test"> view all </a>
@@ -470,14 +337,7 @@
                     <a href="/admin/repos/test"> test </a>
                   </td>
                   <td class="repositoryBrowser">
-                    <a
-                      class="webLink"
-                      href="https://phabricator.example.org/r/project/test19"
-                      rel="noopener"
-                      target="_blank"
-                    >
-                      diffusion
-                    </a>
+                    <gr-weblink imageAndText></gr-weblink>
                   </td>
                   <td class="changesLink">
                     <a href="/q/project:test"> view all </a>
@@ -490,14 +350,7 @@
                     <a href="/admin/repos/test"> test </a>
                   </td>
                   <td class="repositoryBrowser">
-                    <a
-                      class="webLink"
-                      href="https://phabricator.example.org/r/project/test20"
-                      rel="noopener"
-                      target="_blank"
-                    >
-                      diffusion
-                    </a>
+                    <gr-weblink imageAndText></gr-weblink>
                   </td>
                   <td class="changesLink">
                     <a href="/q/project:test"> view all </a>
@@ -510,14 +363,7 @@
                     <a href="/admin/repos/test"> test </a>
                   </td>
                   <td class="repositoryBrowser">
-                    <a
-                      class="webLink"
-                      href="https://phabricator.example.org/r/project/test21"
-                      rel="noopener"
-                      target="_blank"
-                    >
-                      diffusion
-                    </a>
+                    <gr-weblink imageAndText></gr-weblink>
                   </td>
                   <td class="changesLink">
                     <a href="/q/project:test"> view all </a>
@@ -530,14 +376,7 @@
                     <a href="/admin/repos/test"> test </a>
                   </td>
                   <td class="repositoryBrowser">
-                    <a
-                      class="webLink"
-                      href="https://phabricator.example.org/r/project/test22"
-                      rel="noopener"
-                      target="_blank"
-                    >
-                      diffusion
-                    </a>
+                    <gr-weblink imageAndText></gr-weblink>
                   </td>
                   <td class="changesLink">
                     <a href="/q/project:test"> view all </a>
@@ -550,14 +389,7 @@
                     <a href="/admin/repos/test"> test </a>
                   </td>
                   <td class="repositoryBrowser">
-                    <a
-                      class="webLink"
-                      href="https://phabricator.example.org/r/project/test23"
-                      rel="noopener"
-                      target="_blank"
-                    >
-                      diffusion
-                    </a>
+                    <gr-weblink imageAndText></gr-weblink>
                   </td>
                   <td class="changesLink">
                     <a href="/q/project:test"> view all </a>
@@ -570,14 +402,7 @@
                     <a href="/admin/repos/test"> test </a>
                   </td>
                   <td class="repositoryBrowser">
-                    <a
-                      class="webLink"
-                      href="https://phabricator.example.org/r/project/test24"
-                      rel="noopener"
-                      target="_blank"
-                    >
-                      diffusion
-                    </a>
+                    <gr-weblink imageAndText></gr-weblink>
                   </td>
                   <td class="changesLink">
                     <a href="/q/project:test"> view all </a>
@@ -588,13 +413,7 @@
               </tbody>
             </table>
           </gr-list-view>
-          <gr-overlay
-            aria-hidden="true"
-            id="createOverlay"
-            style="outline: none; display: none;"
-            tabindex="-1"
-            with-backdrop=""
-          >
+          <dialog id="createModal" tabindex="-1">
             <gr-dialog
               class="confirmDialog"
               confirm-label="Create"
@@ -608,7 +427,7 @@
                 </gr-create-repo-dialog>
               </div>
             </gr-dialog>
-          </gr-overlay>
+          </dialog>
         `
       );
     });
@@ -621,25 +440,27 @@
     });
 
     test('shownRepos', () => {
-      assert.equal(element.repos.slice(0, SHOWN_ITEMS_COUNT).length, 25);
+      const table = queryAndAssert(element, 'table');
+      const rows = table.querySelectorAll('tr.table');
+      assert.equal(rows.length, element.reposPerPage);
     });
 
-    test('maybeOpenCreateOverlay', () => {
-      const overlayOpen = sinon.stub(
-        queryAndAssert<GrOverlay>(element, '#createOverlay'),
-        'open'
+    test('maybeOpenCreateModal', () => {
+      const modalOpen = sinon.stub(
+        queryAndAssert<HTMLDialogElement>(element, '#createModal'),
+        'showModal'
       );
-      element.maybeOpenCreateOverlay();
-      assert.isFalse(overlayOpen.called);
-      element.maybeOpenCreateOverlay(undefined);
-      assert.isFalse(overlayOpen.called);
+      element.maybeOpenCreateModal();
+      assert.isFalse(modalOpen.called);
+      element.maybeOpenCreateModal(undefined);
+      assert.isFalse(modalOpen.called);
       const params: AdminViewState = {
         view: GerritView.ADMIN,
         adminView: AdminChildView.REPOS,
         openCreateModal: true,
       };
-      element.maybeOpenCreateOverlay(params);
-      assert.isTrue(overlayOpen.called);
+      element.maybeOpenCreateModal(params);
+      assert.isTrue(modalOpen.called);
     });
   });
 
@@ -652,7 +473,9 @@
     });
 
     test('shownRepos', () => {
-      assert.equal(element.repos.slice(0, SHOWN_ITEMS_COUNT).length, 25);
+      const table = queryAndAssert(element, 'table');
+      const rows = table.querySelectorAll('tr.table');
+      assert.equal(rows.length, element.reposPerPage);
     });
   });
 
@@ -760,9 +583,10 @@
     });
 
     test('handleCreateClicked opens modal', () => {
-      const openStub = sinon
-        .stub(queryAndAssert<GrOverlay>(element, '#createOverlay'), 'open')
-        .returns(Promise.resolve());
+      const openStub = sinon.stub(
+        queryAndAssert<HTMLDialogElement>(element, '#createModal'),
+        'showModal'
+      );
       element.handleCreateClicked();
       assert.isTrue(openStub.called);
     });
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config.ts b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config.ts
index ad87d86..fabf93d 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config.ts
@@ -25,8 +25,7 @@
   PluginOption,
 } from './gr-repo-plugin-config-types';
 import {paperStyles} from '../../../styles/gr-paper-styles';
-
-const PLUGIN_CONFIG_CHANGED_EVENT_NAME = 'plugin-config-changed';
+import {fire} from '../../../utils/event-util';
 
 export interface ConfigChangeInfo {
   _key: string; // parameterName of PluginParameterToConfigParameterInfoMap
@@ -256,14 +255,7 @@
       name,
       config: {...config, [_key]: info},
     };
-
-    this.dispatchEvent(
-      new CustomEvent(PLUGIN_CONFIG_CHANGED_EVENT_NAME, {
-        detail,
-        bubbles: true,
-        composed: true,
-      })
-    );
+    fire(this, 'plugin-config-changed', detail);
   }
 
   /**
@@ -278,4 +270,7 @@
   interface HTMLElementTagNameMap {
     'gr-repo-plugin-config': GrRepoPluginConfig;
   }
+  interface HTMLElementEventMap {
+    'plugin-config-changed': CustomEvent<PluginConfigChangeDetail>;
+  }
 }
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 391aa55..4bbd533 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
@@ -22,7 +22,7 @@
 } from '../../../types/common';
 import {
   InheritedBooleanInfoConfiguredValue,
-  ProjectState,
+  RepoState,
   SubmitType,
 } from '../../../constants/constants';
 import {assertIsDefined, hasOwnProperty} from '../../../utils/common-util';
@@ -41,11 +41,13 @@
 import {when} from 'lit/directives/when.js';
 import {subscribe} from '../../lit/subscription-controller';
 import {createSearchUrl} from '../../../models/views/search';
+import {userModelToken} from '../../../models/user/user-model';
+import {resolve} from '../../../models/dependency';
 
 const STATES = {
-  active: {value: ProjectState.ACTIVE, label: 'Active'},
-  readOnly: {value: ProjectState.READ_ONLY, label: 'Read Only'},
-  hidden: {value: ProjectState.HIDDEN, label: 'Hidden'},
+  active: {value: RepoState.ACTIVE, label: 'Active'},
+  readOnly: {value: RepoState.READ_ONLY, label: 'Read Only'},
+  hidden: {value: RepoState.HIDDEN, label: 'Hidden'},
 };
 
 const SUBMIT_TYPES = {
@@ -111,7 +113,7 @@
 
   @state() private pluginConfigChanged = false;
 
-  private readonly userModel = getAppContext().userModel;
+  private readonly getUserModel = resolve(this, userModelToken);
 
   private readonly restApiService = getAppContext().restApiService;
 
@@ -119,7 +121,7 @@
     super();
     subscribe(
       this,
-      () => this.userModel.preferences$,
+      () => this.getUserModel().preferences$,
       prefs => {
         if (prefs?.download_scheme) {
           // Note (issue 5180): normalize the download scheme with lower-case.
@@ -132,7 +134,7 @@
   override connectedCallback() {
     super.connectedCallback();
 
-    fireTitleChange(this, `${this.repo}`);
+    fireTitleChange(`${this.repo}`);
   }
 
   static override get styles() {
@@ -1096,7 +1098,7 @@
 
   private computeChangesUrl(name?: RepoName) {
     if (!name) return '';
-    return createSearchUrl({project: name});
+    return createSearchUrl({repo: name});
   }
 
   // private but used in test
@@ -1130,7 +1132,7 @@
     if (this.repoConfig.state === e.detail.value) return;
     this.repoConfig = {
       ...this.repoConfig,
-      state: e.detail.value as ProjectState,
+      state: e.detail.value as RepoState,
     };
     this.requestUpdate();
   }
diff --git a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.ts b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.ts
index ef99a34..4deb99a 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.ts
@@ -25,14 +25,14 @@
   InheritedBooleanInfo,
   MaxObjectSizeLimitInfo,
   PluginParameterToConfigParameterInfoMap,
-  ProjectAccessGroups,
-  ProjectAccessInfoMap,
+  RepoAccessGroups,
+  RepoAccessInfoMap,
   RepoName,
 } from '../../../types/common';
 import {
   ConfigParameterInfoType,
   InheritedBooleanInfoConfiguredValue,
-  ProjectState,
+  RepoState,
   SubmitType,
 } from '../../../constants/constants';
 import {
@@ -52,7 +52,7 @@
   let repoStub: sinon.SinonStub;
 
   const repoConf: ConfigInfo = {
-    description: 'Access inherited by all other projects.',
+    description: 'Access inherited by all other repositories.',
     use_contributor_agreements: {
       value: false,
       configured_value: InheritedBooleanInfoConfiguredValue.FALSE,
@@ -537,10 +537,10 @@
               url: 'test',
               name: 'test' as GroupName,
             },
-          } as ProjectAccessGroups,
+          } as RepoAccessGroups,
           config_web_links: [{name: 'gitiles', url: 'test'}],
         },
-      } as ProjectAccessInfoMap)
+      } as RepoAccessInfoMap)
     );
     await element.loadRepo();
     assert.isTrue(element.readOnly);
@@ -655,10 +655,10 @@
                 url: 'test',
                 name: 'test' as GroupName,
               },
-            } as ProjectAccessGroups,
+            } as RepoAccessGroups,
             config_web_links: [{name: 'gitiles', url: 'test'}],
           },
-        } as ProjectAccessInfoMap)
+        } as RepoAccessInfoMap)
       );
     });
 
@@ -674,10 +674,10 @@
 
     test('state gets set correctly', async () => {
       await element.loadRepo();
-      assert.equal(element.repoConfig!.state, ProjectState.ACTIVE);
+      assert.equal(element.repoConfig!.state, RepoState.ACTIVE);
       assert.equal(
         queryAndAssert<GrSelect>(element, '#stateSelect').bindValue,
-        ProjectState.ACTIVE
+        RepoState.ACTIVE
       );
     });
 
@@ -710,7 +710,7 @@
         reject_empty_commit: InheritedBooleanInfoConfiguredValue.TRUE,
         max_object_size_limit: '10' as MaxObjectSizeLimitInfo,
         submit_type: SubmitType.FAST_FORWARD_ONLY,
-        state: ProjectState.READ_ONLY,
+        state: RepoState.READ_ONLY,
         enable_reviewer_by_email: InheritedBooleanInfoConfiguredValue.TRUE,
       };
 
diff --git a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.ts b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.ts
index 975dd3b..4e41dfe 100644
--- a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.ts
+++ b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.ts
@@ -8,28 +8,16 @@
 import '../../shared/gr-select/gr-select';
 import {encodeURL, getBaseUrl} from '../../../utils/url-util';
 import {AccessPermissionId} from '../../../utils/access-util';
-import {fireEvent} from '../../../utils/event-util';
+import {fire} from '../../../utils/event-util';
 import {formStyles} from '../../../styles/gr-form-styles';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, PropertyValues, html, css} from 'lit';
 import {customElement, property, state} from 'lit/decorators.js';
-import {BindValueChangeEvent} from '../../../types/events';
+import {BindValueChangeEvent, ValueChangedEvent} from '../../../types/events';
 import {ifDefined} from 'lit/directives/if-defined.js';
 import {EditablePermissionRuleInfo} from '../gr-repo-access/gr-repo-access-interfaces';
 import {PermissionAction} from '../../../constants/constants';
 
-/**
- * Fired when the rule has been modified or removed.
- *
- * @event access-modified
- */
-
-/**
- * Fired when a rule that was previously added was removed.
- *
- * @event added-rule-removed
- */
-
 const PRIORITY_OPTIONS = [PermissionAction.BATCH, PermissionAction.INTERACTIVE];
 
 const Action = {
@@ -81,6 +69,11 @@
   interface HTMLElementTagNameMap {
     'gr-rule-editor': GrRuleEditor;
   }
+  interface HTMLElementEventMap {
+    /** Fired when a rule that was previously added was removed. */
+    'added-rule-removed': CustomEvent<{}>;
+    'rule-changed': ValueChangedEvent<Rule | undefined>;
+  }
 }
 
 @customElement('gr-rule-editor')
@@ -343,7 +336,7 @@
   // private but used in test
   computeGroupPath(groupId?: string) {
     if (!groupId) return;
-    return `${getBaseUrl()}/admin/groups/${encodeURL(groupId, true)}`;
+    return `${getBaseUrl()}/admin/groups/${encodeURL(groupId)}`;
   }
 
   // private but used in test
@@ -431,14 +424,14 @@
   private handleRemoveRule() {
     if (!this.rule?.value) return;
     if (this.rule.value.added) {
-      fireEvent(this, 'added-rule-removed');
+      fire(this, 'added-rule-removed', {});
     }
     this.deleted = true;
     this.rule.value.deleted = true;
 
     this.handleRuleChange();
 
-    fireEvent(this, 'access-modified');
+    fire(this, 'access-modified', {});
   }
 
   private handleUndoRemove() {
@@ -476,7 +469,7 @@
     this.handleRuleChange();
 
     // Allows overall access page to know a change has been made.
-    fireEvent(this, 'access-modified');
+    fire(this, 'access-modified', {});
   }
 
   // private but used in test
@@ -537,13 +530,6 @@
 
   private handleRuleChange() {
     this.requestUpdate('rule');
-
-    this.dispatchEvent(
-      new CustomEvent('rule-changed', {
-        detail: {value: this.rule},
-        composed: true,
-        bubbles: true,
-      })
-    );
+    fire(this, 'rule-changed', {value: this.rule});
   }
 }
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 318a33b..a49a95e 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
@@ -14,7 +14,7 @@
 import '../gr-change-list-bulk-vote-flow/gr-change-list-bulk-vote-flow';
 import '../gr-change-list-topic-flow/gr-change-list-topic-flow';
 import '../gr-change-list-hashtag-flow/gr-change-list-hashtag-flow';
-
+import '../gr-change-list-bulk-abandon-flow/gr-change-list-bulk-abandon-flow';
 /**
  * An action bar for the top of a <gr-change-list-section> element. Assumes it
  * will be used inside a <tr> element.
@@ -77,6 +77,7 @@
             <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 8badced..4b7f0a3 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
@@ -19,7 +19,7 @@
 } from '../../../test/test-utils';
 import {ChangeInfo, NumericChangeId} from '../../../types/common';
 import './gr-change-list-action-bar';
-import type {GrChangeListActionBar} from './gr-change-list-action-bar';
+import {GrChangeListActionBar} from './gr-change-list-action-bar';
 
 const change1 = {...createChange(), _number: 1 as NumericChangeId, actions: {}};
 const change2 = {...createChange(), _number: 2 as NumericChangeId, actions: {}};
@@ -68,6 +68,7 @@
               <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 1582b0a..2ca7f36 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
@@ -9,10 +9,10 @@
 import {bulkActionsModelToken} from '../../../models/bulk-actions/bulk-actions-model';
 import {NumericChangeId, ChangeInfo, ChangeStatus} from '../../../api/rest-api';
 import {subscribe} from '../../lit/subscription-controller';
-import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
 import {ProgressStatus} from '../../../constants/constants';
 import '../../shared/gr-dialog/gr-dialog';
 import {fireAlert, fireReload} from '../../../utils/event-util';
+import {modalStyles} from '../../../styles/gr-modal-styles';
 
 @customElement('gr-change-list-bulk-abandon-flow')
 export class GrChangeListBulkAbandonFlow extends LitElement {
@@ -22,10 +22,11 @@
 
   @state() progress: Map<NumericChangeId, ProgressStatus> = new Map();
 
-  @query('#actionOverlay') actionOverlay!: GrOverlay;
+  @query('#actionModal') actionModal!: HTMLDialogElement;
 
   static override get styles() {
     return [
+      modalStyles,
       css`
         section {
           padding: var(--spacing-l);
@@ -49,13 +50,13 @@
         id="abandon"
         flatten
         .disabled=${!this.isEnabled()}
-        @click=${() => this.actionOverlay.open()}
+        @click=${() => this.actionModal.showModal()}
         >Abandon</gr-button
       >
-      <gr-overlay id="actionOverlay" with-backdrop="">
+      <dialog id="actionModal" tabindex="-1">
         <gr-dialog
           .disableCancel=${!this.isCancelEnabled()}
-          .disabled=${!this.isConfirmEnabled()}
+          .disabled=${this.isDisabled()}
           @confirm=${() => this.handleConfirm()}
           @cancel=${() => this.handleClose()}
           .cancelLabel=${'Close'}
@@ -86,7 +87,7 @@
             </table>
           </div>
         </gr-dialog>
-      </gr-overlay>
+      </dialog>
     `;
   }
 
@@ -103,13 +104,13 @@
     );
   }
 
-  private isConfirmEnabled() {
+  private isDisabled() {
     // Action is allowed if none of the changes have any bulk action performed
     // on them. In case an error happens then we keep the button disabled.
     for (const status of this.progress.values()) {
-      if (status !== ProgressStatus.NOT_STARTED) return false;
+      if (status !== ProgressStatus.NOT_STARTED) return true;
     }
-    return true;
+    return false;
   }
 
   private isCancelEnabled() {
@@ -144,9 +145,9 @@
   }
 
   private handleClose() {
-    this.actionOverlay.close();
+    this.actionModal.close();
     fireAlert(this, 'Reloading page..');
-    fireReload(this, true);
+    fireReload(this);
   }
 }
 
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-abandon-flow/gr-change-list-bulk-abandon-flow_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-abandon-flow/gr-change-list-bulk-abandon-flow_test.ts
index 4fc2cd8..df7a6ce 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-abandon-flow/gr-change-list-bulk-abandon-flow_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-abandon-flow/gr-change-list-bulk-abandon-flow_test.ts
@@ -90,13 +90,7 @@
         >
           Abandon
         </gr-button>
-        <gr-overlay
-          aria-hidden="true"
-          id="actionOverlay"
-          style="outline: none; display: none;"
-          tabindex="-1"
-          with-backdrop=""
-        >
+        <dialog id="actionModal" tabindex="-1">
           <gr-dialog role="dialog">
             <div slot="header">1 changes to abandon</div>
             <div slot="main">
@@ -116,7 +110,7 @@
               </table>
             </div>
           </gr-dialog>
-        </gr-overlay>
+        </dialog>
       `
     );
   });
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 5728529..6d7045a 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
@@ -5,7 +5,6 @@
  */
 import {customElement, query, state} from 'lit/decorators.js';
 import {LitElement, html, css, nothing} from 'lit';
-import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
 import {resolve} from '../../../models/dependency';
 import {bulkActionsModelToken} from '../../../models/bulk-actions/bulk-actions-model';
 import {subscribe} from '../../lit/subscription-controller';
@@ -39,12 +38,14 @@
 import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
 import {Interaction} from '../../../constants/reporting';
 import {createChangeUrl} from '../../../models/views/change';
+import {userModelToken} from '../../../models/user/user-model';
+import {modalStyles} from '../../../styles/gr-modal-styles';
 
 @customElement('gr-change-list-bulk-vote-flow')
 export class GrChangeListBulkVoteFlow extends LitElement {
   private readonly getBulkActionsModel = resolve(this, bulkActionsModelToken);
 
-  private readonly userModel = getAppContext().userModel;
+  private readonly getUserModel = resolve(this, userModelToken);
 
   private readonly reportingService = getAppContext().reportingService;
 
@@ -52,7 +53,7 @@
 
   @state() progressByChange: Map<NumericChangeId, ProgressStatus> = new Map();
 
-  @query('#actionOverlay') actionOverlay!: GrOverlay;
+  @query('#actionModal') actionModal!: HTMLDialogElement;
 
   @query('gr-dialog') dialog?: GrDialog;
 
@@ -61,6 +62,7 @@
   static override get styles() {
     return [
       fontStyles,
+      modalStyles,
       css`
         gr-dialog {
           width: 840px;
@@ -141,7 +143,7 @@
     );
     subscribe(
       this,
-      () => this.userModel.account$,
+      () => this.getUserModel().account$,
       account => (this.account = account)
     );
   }
@@ -153,13 +155,15 @@
       permittedLabels
     ).filter(label => !triggerLabels.some(l => l.name === label.name));
     return html`
-      <gr-button id="voteFlowButton" flatten @click=${this.openOverlay}
+      <gr-button id="voteFlowButton" flatten @click=${this.openModal}
         >Vote</gr-button
       >
-      <gr-overlay id="actionOverlay" with-backdrop="">
+      <dialog id="actionModal" tabindex="-1">
         <gr-dialog
           .disableCancel=${!this.isCancelEnabled()}
-          .disabled=${!this.isConfirmEnabled()}
+          .disabled=${this.isDisabled(
+            triggerLabels.length + nonTriggerLabels.length
+          )}
           ?loading=${this.isLoading()}
           .loadingLabel=${'Voting in progress...'}
           @confirm=${() => this.handleConfirm()}
@@ -185,7 +189,7 @@
             ${this.renderErrors()}
           </div>
         </gr-dialog>
-      </gr-overlay>
+      </dialog>
     `;
   }
 
@@ -223,12 +227,8 @@
     }
   }
 
-  private async openOverlay() {
-    await this.actionOverlay.open();
-    this.actionOverlay.setFocusStops({
-      start: queryAndAssert(this.dialog, 'header'),
-      end: queryAndAssert(this.dialog, 'footer'),
-    });
+  private openModal() {
+    this.actionModal.showModal();
   }
 
   private renderErrors() {
@@ -291,11 +291,12 @@
     return getOverallStatus(this.progressByChange) === ProgressStatus.RUNNING;
   }
 
-  private isConfirmEnabled() {
+  private isDisabled(permittedLabelsCount: number) {
     // Action is allowed if none of the changes have any bulk action performed
     // on them. In case an error happens then we keep the button disabled.
-    return (
-      getOverallStatus(this.progressByChange) === ProgressStatus.NOT_STARTED
+    return !(
+      getOverallStatus(this.progressByChange) === ProgressStatus.NOT_STARTED &&
+      permittedLabelsCount > 0
     );
   }
 
@@ -304,10 +305,10 @@
   }
 
   private handleClose() {
-    this.actionOverlay.close();
+    this.actionModal.close();
     if (getOverallStatus(this.progressByChange) === ProgressStatus.NOT_STARTED)
       return;
-    fireReload(this, true);
+    fireReload(this);
   }
 
   private async handleConfirm() {
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 0ca3976..8a5bf47 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
@@ -147,12 +147,9 @@
       >
         Vote
       </gr-button>
-      <gr-overlay
-        aria-hidden="true"
-        id="actionOverlay"
-        style="outline: none; display: none;"
+      <dialog
+        id="actionModal"
         tabindex="-1"
-        with-backdrop=""
       >
         <gr-dialog role="dialog">
           <div slot="header">
@@ -197,7 +194,7 @@
             </div>
           </div>
         </gr-dialog>
-      </gr-overlay> `
+      </dialog> `
     );
   });
 
@@ -238,12 +235,9 @@
       >
         Vote
       </gr-button>
-      <gr-overlay
-        aria-hidden="true"
-        id="actionOverlay"
-        style="outline: none; display: none;"
+      <dialog
+        id="actionModal"
         tabindex="-1"
-        with-backdrop=""
       >
         <gr-dialog role="dialog">
           <div slot="header">
@@ -292,7 +286,7 @@
             </div>
           </div>
         </gr-dialog>
-      </gr-overlay> `
+      </dialog> `
     );
   });
 
@@ -313,17 +307,18 @@
     );
 
     // No common label with change1 so button is disabled
-    change2.labels = {
+    const c2 = {...change2}; // create copy so other tests are not affected
+    c2.labels = {
       x: {value: null} as LabelInfo,
       y: {value: null} as LabelInfo,
       z: {value: null} as LabelInfo,
     };
-    change2.submit_requirements = [
+    c2.submit_requirements = [
       createSubmitRequirementResultInfo('label:x=MAX'),
       createSubmitRequirementResultInfo('label:y=MAX'),
       createSubmitRequirementResultInfo('label:z=MAX'),
     ];
-    changes.push({...change2});
+    changes.push({...c2});
     getChangesStub.restore();
     getChangesStub.returns(Promise.resolve(changes));
     model.sync(changes);
@@ -367,10 +362,7 @@
     const saveChangeReview = mockPromise<Response>();
     stubRestApi('saveChangeReview').returns(saveChangeReview);
 
-    const stopsStub = sinon.stub(element.actionOverlay, 'setFocusStops');
-
     queryAndAssert<GrButton>(element, '#voteFlowButton').click();
-    await waitUntil(() => stopsStub.called);
 
     await element.updateComplete;
 
@@ -493,6 +485,45 @@
       assert.equal(dispatchEventStub.lastCall.args[0].type, 'reload');
     });
 
+    test('button is disabled if no votes are possible', async () => {
+      const c2 = {...change2}; // create copy so other tests are not affected
+      c2.labels = {
+        x: {value: null} as LabelInfo,
+        y: {value: null} as LabelInfo,
+        z: {value: null} as LabelInfo,
+      };
+      c2.submit_requirements = [
+        createSubmitRequirementResultInfo('label:x=MAX'),
+        createSubmitRequirementResultInfo('label:y=MAX'),
+        createSubmitRequirementResultInfo('label:z=MAX'),
+      ];
+
+      const changes: ChangeInfo[] = [change1, c2];
+      getChangesStub.returns(Promise.resolve(changes));
+
+      stubRestApi('saveChangeReview').callsFake(
+        (_changeNum, _patchNum, _review, errFn) =>
+          Promise.resolve(new Response()).then(res => {
+            errFn && errFn();
+            return res;
+          })
+      );
+
+      model.sync(changes);
+      await waitUntilObserved(
+        model.loadingState$,
+        state => state === LoadingState.LOADED
+      );
+      await selectChange(change1);
+      await selectChange(c2);
+      await element.updateComplete;
+
+      assert.isTrue(
+        queryAndAssert<GrButton>(query(element, 'gr-dialog'), '#confirm')
+          .disabled
+      );
+    });
+
     test('closing dialog does not trigger reload if no request made', async () => {
       const changes: ChangeInfo[] = [change1, change2];
       getChangesStub.returns(Promise.resolve(changes));
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
index 2b3c3df4..ea61bfc 100644
--- 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
@@ -15,7 +15,7 @@
 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 {isDefined} from '../../../types/types';
 import {unique} from '../../../utils/common-util';
 import {AutocompleteSuggestion} from '../../shared/gr-autocomplete/gr-autocomplete';
 import {when} from 'lit/directives/when.js';
@@ -27,6 +27,7 @@
 import {fireAlert} from '../../../utils/event-util';
 import {pluralize} from '../../../utils/string-util';
 import {Interaction} from '../../../constants/reporting';
+import {throwingErrorCallback} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
 
 @customElement('gr-change-list-hashtag-flow')
 export class GrChangeListHashtagFlow extends LitElement {
@@ -158,7 +159,7 @@
         .horizontalAlign=${'auto'}
         .verticalAlign=${'auto'}
         .verticalOffset=${24}
-        @opened-changed=${(e: CustomEvent) =>
+        @opened-changed=${(e: ValueChangedEvent<boolean>) =>
           (this.isDropdownOpen = e.detail.value)}
       >
         ${when(
@@ -215,7 +216,7 @@
   private renderExistingHashtags() {
     const hashtags = this.selectedChanges
       .flatMap(change => change.hashtags ?? [])
-      .filter(notUndefined)
+      .filter(isDefined)
       .filter(unique);
     return html`
       <div class="chips">
@@ -298,11 +299,12 @@
     query: string
   ): Promise<AutocompleteSuggestion[]> {
     const suggestions = await this.restApiService.getChangesWithSimilarHashtag(
-      query
+      query,
+      throwingErrorCallback
     );
     this.existingHashtagSuggestions = (suggestions ?? [])
       .flatMap(change => change.hashtags ?? [])
-      .filter(notUndefined)
+      .filter(isDefined)
       .filter(unique);
     return this.existingHashtagSuggestions.map(hashtag => {
       return {name: hashtag, value: hashtag};
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
index f7a2531..af997a9 100644
--- 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
@@ -29,11 +29,10 @@
   waitUntilObserved,
 } from '../../../test/test-utils';
 import {ChangeInfo, NumericChangeId, Hashtag} from '../../../types/common';
-import {EventType} from '../../../types/events';
 import {GrAutocomplete} from '../../shared/gr-autocomplete/gr-autocomplete';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import './gr-change-list-hashtag-flow';
-import type {GrChangeListHashtagFlow} from './gr-change-list-hashtag-flow';
+import {GrChangeListHashtagFlow} from './gr-change-list-hashtag-flow';
 
 suite('gr-change-list-hashtag-flow tests', () => {
   let element: GrChangeListHashtagFlow;
@@ -303,7 +302,7 @@
 
     test('add hashtag from selected change', async () => {
       const alertStub = sinon.stub();
-      element.addEventListener(EventType.SHOW_ALERT, alertStub);
+      element.addEventListener('show-alert', alertStub);
       // selects "hashtag1"
       queryAll<HTMLButtonElement>(element, 'button.chip')[0].click();
       await element.updateComplete;
@@ -377,7 +376,7 @@
 
     test('add multiple hashtag from selected change', async () => {
       const alertStub = sinon.stub();
-      element.addEventListener(EventType.SHOW_ALERT, alertStub);
+      element.addEventListener('show-alert', alertStub);
       // selects "hashtag1"
       queryAll<HTMLButtonElement>(element, 'button.chip')[0].click();
       await element.updateComplete;
@@ -425,7 +424,7 @@
 
     test('add existing hashtag not on selected changes', async () => {
       const alertStub = sinon.stub();
-      element.addEventListener(EventType.SHOW_ALERT, alertStub);
+      element.addEventListener('show-alert', alertStub);
 
       const getHashtagsStub = stubRestApi(
         'getChangesWithSimilarHashtag'
@@ -481,7 +480,7 @@
 
     test('add new hashtag', async () => {
       const alertStub = sinon.stub();
-      element.addEventListener(EventType.SHOW_ALERT, alertStub);
+      element.addEventListener('show-alert', alertStub);
 
       const getHashtagsStub = stubRestApi(
         'getChangesWithSimilarHashtag'
@@ -586,7 +585,7 @@
 
     test('cannot add existing hashtag already on selected changes', async () => {
       const alertStub = sinon.stub();
-      element.addEventListener(EventType.SHOW_ALERT, alertStub);
+      element.addEventListener('show-alert', alertStub);
       // selects "sharedHashtag"
       queryAll<HTMLButtonElement>(element, 'button.chip')[1].click();
       await element.updateComplete;
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 2245a09..19207bc 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
@@ -17,18 +17,16 @@
 import '../../shared/gr-tooltip-content/gr-tooltip-content';
 import {navigationToken} from '../../core/gr-navigation/gr-navigation';
 import {getDisplayName} from '../../../utils/display-name-util';
-import {getPluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints';
-import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 import {getAppContext} from '../../../services/app-context';
 import {truncatePath} from '../../../utils/path-list-util';
 import {changeStatuses} from '../../../utils/change-util';
 import {isSelf, isServiceUser} from '../../../utils/account-util';
-import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
 import {
   ChangeInfo,
   ServerInfo,
   AccountInfo,
   Timestamp,
+  NumericChangeId,
 } from '../../../types/common';
 import {hasOwnProperty, assertIsDefined} from '../../../utils/common-util';
 import {changeListStyles} from '../../../styles/gr-change-list-styles';
@@ -44,6 +42,8 @@
 import {classMap} from 'lit/directives/class-map.js';
 import {createSearchUrl} from '../../../models/views/search';
 import {createChangeUrl} from '../../../models/views/change';
+import {userModelToken} from '../../../models/user/user-model';
+import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 
 enum ChangeSize {
   XS = 10,
@@ -115,16 +115,16 @@
 
   @state() private dynamicCellEndpoints?: string[];
 
-  // Private but used in test.
-  reporting: ReportingService = getAppContext().reportingService;
+  private readonly reporting = getAppContext().reportingService;
 
-  // Private but used in test.
-  userModel = getAppContext().userModel;
+  private readonly getPluginLoader = resolve(this, pluginLoaderToken);
 
   private readonly getBulkActionsModel = resolve(this, bulkActionsModelToken);
 
   private readonly getNavigation = resolve(this, navigationToken);
 
+  private readonly getUserModel = resolve(this, userModelToken);
+
   @state() private isLoggedIn = false;
 
   constructor() {
@@ -133,25 +133,25 @@
       this,
       () => this.getBulkActionsModel().selectedChangeNums$,
       selectedChangeNums => {
-        if (!this.change) return;
-        this.checked = selectedChangeNums.includes(this.change._number);
+        this.updateCheckedState(selectedChangeNums);
       }
     );
     subscribe(
       this,
-      () => this.userModel.loggedIn$,
+      () => this.getUserModel().loggedIn$,
       isLoggedIn => (this.isLoggedIn = isLoggedIn)
     );
   }
 
   override connectedCallback() {
     super.connectedCallback();
-    getPluginLoader()
+    this.getPluginLoader()
       .awaitPluginsLoaded()
       .then(() => {
-        this.dynamicCellEndpoints = getPluginEndpoints().getDynamicEndpoints(
-          'change-list-item-cell'
-        );
+        this.dynamicCellEndpoints =
+          this.getPluginLoader().pluginEndPoints.getDynamicEndpoints(
+            'change-list-item-cell'
+          );
       });
     this.addEventListener('click', this.onItemClick);
   }
@@ -166,6 +166,20 @@
     if (this.selected && changedProperties.has('selected')) {
       this.focus();
     }
+
+    if (changedProperties.has('change')) {
+      this.updateCheckedState(
+        this.getBulkActionsModel().getState().selectedChangeNums
+      );
+    }
+  }
+
+  private updateCheckedState(selectedChangeNums: NumericChangeId[]) {
+    if (!this.change) {
+      this.checked = false;
+      return;
+    }
+    this.checked = selectedChangeNums.includes(this.change._number);
   }
 
   static override get styles() {
@@ -682,14 +696,14 @@
 
   private computeRepoUrl() {
     if (!this.change) return '';
-    return createSearchUrl({project: this.change.project, statuses: ['open']});
+    return createSearchUrl({repo: this.change.project, statuses: ['open']});
   }
 
   private computeRepoBranchURL() {
     if (!this.change) return '';
     return createSearchUrl({
       branch: this.change.branch,
-      project: this.change.project,
+      repo: this.change.project,
     });
   }
 
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 a4c4a94..5e31cc8 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
@@ -44,6 +44,7 @@
   bulkActionsModelToken,
   BulkActionsModel,
 } from '../../../models/bulk-actions/bulk-actions-model';
+import {UserModel, userModelToken} from '../../../models/user/user-model';
 import {createTestAppContext} from '../../../test/test-app-context-init';
 import {ColumnNames} from '../../../constants/constants';
 import {testResolver} from '../../../test/common-test-setup';
@@ -59,11 +60,13 @@
 
   let element: GrChangeListItem;
   let bulkActionsModel: BulkActionsModel;
+  let userModel: UserModel;
 
   setup(async () => {
     bulkActionsModel = new BulkActionsModel(
       createTestAppContext().restApiService
     );
+    userModel = testResolver(userModelToken);
     element = (
       await fixture<DIProviderElement>(
         wrapInProvider(
@@ -104,7 +107,7 @@
     test('bulk actions checkboxes', async () => {
       element.change = {...createChange(), _number: 1 as NumericChangeId};
       bulkActionsModel.sync([element.change]);
-      element.userModel.setAccount({
+      userModel.setAccount({
         ...createAccountWithEmail('abc@def.com'),
         registered_on: '2015-03-12 18:32:08.000000000' as Timestamp,
       });
@@ -137,7 +140,7 @@
       element.globalIndex = 5;
       element.change = {...createChange(), _number: 1 as NumericChangeId};
       bulkActionsModel.sync([element.change]);
-      element.userModel.setAccount({
+      userModel.setAccount({
         ...createAccountWithEmail('abc@def.com'),
         registered_on: '2015-03-12 18:32:08.000000000' as Timestamp,
       });
@@ -154,7 +157,7 @@
     });
 
     test('checkbox state updates with model updates', async () => {
-      element.userModel.setAccount({
+      userModel.setAccount({
         ...createAccountWithEmail('abc@def.com'),
         registered_on: '2015-03-12 18:32:08.000000000' as Timestamp,
       });
@@ -164,10 +167,6 @@
       element.change = {...createChange(), _number: 1 as NumericChangeId};
       bulkActionsModel.sync([element.change]);
       bulkActionsModel.addSelectedChangeNum(element.change._number);
-      await waitUntilObserved(
-        bulkActionsModel.selectedChangeNums$,
-        s => s.length === 1
-      );
       await element.updateComplete;
 
       const checkbox = queryAndAssert<HTMLInputElement>(
@@ -177,10 +176,35 @@
       assert.isTrue(checkbox.checked);
 
       bulkActionsModel.removeSelectedChangeNum(element.change._number);
-      await waitUntilObserved(
-        bulkActionsModel.selectedChangeNums$,
-        s => s.length === 0
+      await element.updateComplete;
+
+      assert.isFalse(checkbox.checked);
+    });
+
+    test('checkbox state updates with change id update', async () => {
+      userModel.setAccount({
+        ...createAccountWithEmail('abc@def.com'),
+        registered_on: '2015-03-12 18:32:08.000000000' as Timestamp,
+      });
+      element.requestUpdate();
+      await element.updateComplete;
+
+      const changes = [
+        {...createChange(), _number: 1 as NumericChangeId},
+        {...createChange(), _number: 2 as NumericChangeId},
+      ];
+      element.change = changes[0];
+      bulkActionsModel.sync(changes);
+      bulkActionsModel.addSelectedChangeNum(element.change._number);
+      await element.updateComplete;
+
+      const checkbox = queryAndAssert<HTMLInputElement>(
+        element,
+        '.selection > .selectionLabel > input'
       );
+      assert.isTrue(checkbox.checked);
+
+      element.change = changes[1];
       await element.updateComplete;
 
       assert.isFalse(checkbox.checked);
@@ -352,15 +376,17 @@
   });
 
   test('renders', async () => {
-    element.userModel.setAccount({
+    const change = createChange();
+    bulkActionsModel.sync([change]);
+    bulkActionsModel.addSelectedChangeNum(change._number);
+    userModel.setAccount({
       ...createAccountWithEmail('abc@def.com'),
       registered_on: '2015-03-12 18:32:08.000000000' as Timestamp,
     });
     element.showNumber = true;
     element.account = createAccountWithId(1);
     element.config = createServerInfo();
-    element.change = createChange();
-    element.checked = true;
+    element.change = change;
     await element.updateComplete;
     assert.isTrue(element.hasAttribute('checked'));
 
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 46d15af..f72d1ef 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
@@ -18,16 +18,14 @@
   SuggestedReviewerGroupInfo,
 } from '../../../types/common';
 import {subscribe} from '../../lit/subscription-controller';
-import '../../shared/gr-overlay/gr-overlay';
 import '../../shared/gr-dialog/gr-dialog';
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-icon/gr-icon';
-import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
 import {getAppContext} from '../../../services/app-context';
 import {
   GrReviewerSuggestionsProvider,
   ReviewerSuggestionsProvider,
-} from '../../../scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider';
+} from '../../../services/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider';
 import '../../shared/gr-account-list/gr-account-list';
 import {getOverallStatus} from '../../../utils/bulk-flow-util';
 import {allSettled} from '../../../utils/async-util';
@@ -35,12 +33,14 @@
 import {getDisplayName} from '../../../utils/display-name-util';
 import {GrAccountList} from '../../shared/gr-account-list/gr-account-list';
 import {getReplyByReason} from '../../../utils/attention-set-util';
-import {intersection, queryAndAssert} from '../../../utils/common-util';
+import {intersection} from '../../../utils/common-util';
 import {AccountInput, accountKey, getUserId} from '../../../utils/account-util';
 import {ValueChangedEvent} from '../../../types/events';
 import {fireAlert, fireReload} from '../../../utils/event-util';
 import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
 import {Interaction} from '../../../constants/reporting';
+import {userModelToken} from '../../../models/user/user-model';
+import {modalStyles} from '../../../styles/gr-modal-styles';
 
 @customElement('gr-change-list-reviewer-flow')
 export class GrChangeListReviewerFlow extends LitElement {
@@ -77,24 +77,26 @@
     [ReviewerState.CC, null],
   ]);
 
-  @query('gr-overlay#flow') private overlay?: GrOverlay;
+  @query('dialog#flow') private modal?: HTMLDialogElement;
 
   @query('gr-account-list#reviewer-list') private reviewerList?: GrAccountList;
 
   @query('gr-account-list#cc-list') private ccList?: GrAccountList;
 
-  @query('gr-overlay#confirm-reviewer')
-  private reviewerConfirmOverlay?: GrOverlay;
+  @query('dialog#confirm-reviewer')
+  private reviewerConfirmModal?: HTMLDialogElement;
 
-  @query('gr-overlay#confirm-cc') private ccConfirmOverlay?: GrOverlay;
+  @query('dialog#confirm-cc') private ccConfirmModal?: HTMLDialogElement;
 
   @query('gr-dialog') dialog?: GrDialog;
 
   private readonly reportingService = getAppContext().reportingService;
 
-  private getBulkActionsModel = resolve(this, bulkActionsModelToken);
+  private readonly getBulkActionsModel = resolve(this, bulkActionsModelToken);
 
-  private getConfigModel = resolve(this, configModelToken);
+  private readonly getConfigModel = resolve(this, configModelToken);
+
+  private readonly getUserModel = resolve(this, userModelToken);
 
   private restApiService = getAppContext().restApiService;
 
@@ -104,6 +106,7 @@
 
   static override get styles() {
     return [
+      modalStyles,
       css`
         gr-dialog {
           width: 60em;
@@ -140,8 +143,8 @@
           color: var(--orange-800);
           font-size: 18px;
         }
-        gr-overlay#confirm-cc,
-        gr-overlay#confirm-reviewer {
+        dialog#confirm-cc,
+        dialog#confirm-reviewer {
           padding: var(--spacing-l);
           text-align: center;
         }
@@ -166,12 +169,12 @@
     );
     subscribe(
       this,
-      () => getAppContext().userModel.loggedIn$,
+      () => this.getUserModel().loggedIn$,
       isLoggedIn => (this.isLoggedIn = isLoggedIn)
     );
     subscribe(
       this,
-      () => getAppContext().userModel.account$,
+      () => this.getUserModel().account$,
       account => (this.account = account)
     );
   }
@@ -185,9 +188,9 @@
         @click=${() => this.openOverlay()}
         >add reviewer/cc</gr-button
       >
-      <gr-overlay id="flow" with-backdrop>
+      <dialog id="flow" tabindex="-1">
         ${this.isOverlayOpen ? this.renderDialog() : nothing}
-      </gr-overlay>
+      </dialog>
     `;
   }
 
@@ -259,9 +262,10 @@
     const suggestion =
       this.groupPendingConfirmationByReviewerState.get(reviewerState);
     return html`
-      <gr-overlay
+      <dialog
+        tabindex="-1"
         id=${id}
-        @iron-overlay-canceled=${() => this.cancelPendingGroup(reviewerState)}
+        @close=${() => this.cancelPendingGroup(reviewerState)}
       >
         <div class="confirmation-text">
           Group
@@ -281,7 +285,7 @@
             >No</gr-button
           >
         </div>
-      </gr-overlay>
+      </dialog>
     `;
   }
 
@@ -375,16 +379,12 @@
     this.resetFlow();
     this.isOverlayOpen = true;
     // Must await the overlay opening because the dialog is lazily rendered.
-    await this.overlay?.open();
-    this.overlay?.setFocusStops({
-      start: queryAndAssert(this.dialog, 'header'),
-      end: queryAndAssert(this.dialog, 'footer'),
-    });
+    await this.modal?.showModal();
   }
 
   private closeOverlay() {
     this.isOverlayOpen = false;
-    this.overlay?.close();
+    this.modal?.close();
   }
 
   private resetFlow() {
@@ -451,23 +451,23 @@
     this.requestUpdate();
     await this.updateComplete;
 
-    const overlay =
+    const modal =
       reviewerState === ReviewerState.CC
-        ? this.ccConfirmOverlay
-        : this.reviewerConfirmOverlay;
+        ? this.ccConfirmModal
+        : this.reviewerConfirmModal;
     if (ev.detail.value === null) {
-      overlay?.close();
+      modal?.close();
     } else {
-      await overlay?.open();
+      await modal?.showModal();
     }
   }
 
   private cancelPendingGroup(reviewerState: ReviewerState) {
-    const overlay =
+    const modal =
       reviewerState === ReviewerState.CC
-        ? this.ccConfirmOverlay
-        : this.reviewerConfirmOverlay;
-    overlay?.close();
+        ? this.ccConfirmModal
+        : this.reviewerConfirmModal;
+    modal?.close();
     this.groupPendingConfirmationByReviewerState.set(reviewerState, null);
     this.requestUpdate();
   }
@@ -488,10 +488,10 @@
         this.saveReviewers();
         break;
       case ProgressStatus.SUCCESSFUL:
-        this.overlay?.close();
+        this.modal?.close();
         break;
       case ProgressStatus.FAILED:
-        this.overlay?.close();
+        this.modal?.close();
         break;
     }
   }
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 a96085c..d2f5fa2 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
@@ -40,9 +40,8 @@
 import {GrAccountList} from '../../shared/gr-account-list/gr-account-list';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
-import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
 import './gr-change-list-reviewer-flow';
-import type {GrChangeListReviewerFlow} from './gr-change-list-reviewer-flow';
+import {GrChangeListReviewerFlow} from './gr-change-list-reviewer-flow';
 
 const accounts: AccountInfo[] = [
   createAccountWithIdNameAndEmail(0),
@@ -122,13 +121,7 @@
           tabindex="0"
           >add reviewer/cc</gr-button
         >
-        <gr-overlay
-          id="flow"
-          aria-hidden="true"
-          with-backdrop=""
-          tabindex="-1"
-          style="outline: none; display: none;"
-        ></gr-overlay>
+        <dialog id="flow" tabindex="-1"></dialog>
       `
     );
   });
@@ -148,18 +141,21 @@
   });
 
   test('overlay hidden before flow button clicked', async () => {
-    const overlay = queryAndAssert<GrOverlay>(element, 'gr-overlay');
-    assert.isFalse(overlay.opened);
+    const dialog = queryAndAssert<HTMLDialogElement>(element, 'dialog');
+    const openStub = sinon.stub(dialog, 'showModal');
+    assert.isFalse(openStub.called);
   });
 
   test('flow button click shows overlay', async () => {
     const button = queryAndAssert<GrButton>(element, 'gr-button#start-flow');
+    const dialog = queryAndAssert<HTMLDialogElement>(element, 'dialog');
+    const openStub = sinon.stub(dialog, 'showModal');
 
     button.click();
+
     await element.updateComplete;
 
-    const overlay = queryAndAssert<GrOverlay>(element, 'gr-overlay');
-    assert.isTrue(overlay.opened);
+    assert.isTrue(openStub.called);
   });
 
   suite('dialog flow', () => {
@@ -202,23 +198,14 @@
             tabindex="0"
             >add reviewer/cc</gr-button
           >
-          <gr-overlay
-            id="flow"
-            with-backdrop=""
-            tabindex="-1"
-            style="outline: none; display: none;"
-          >
+          <dialog id="flow" open="" tabindex="-1">
             <gr-dialog role="dialog">
               <div slot="header">Add reviewer / CC</div>
               <div slot="main">
                 <div class="grid">
                   <span>Reviewers</span>
                   <gr-account-list id="reviewer-list"></gr-account-list>
-                  <gr-overlay
-                    aria-hidden="true"
-                    id="confirm-reviewer"
-                    style="outline: none; display: none;"
-                  >
+                  <dialog id="confirm-reviewer" tabindex="-1">
                     <div class="confirmation-text">
                       Group
                       <span class="groupName"></span>
@@ -244,14 +231,10 @@
                         No
                       </gr-button>
                     </div>
-                  </gr-overlay>
+                  </dialog>
                   <span>CC</span>
                   <gr-account-list id="cc-list"></gr-account-list>
-                  <gr-overlay
-                    aria-hidden="true"
-                    id="confirm-cc"
-                    style="outline: none; display: none;"
-                  >
+                  <dialog id="confirm-cc" tabindex="-1">
                     <div class="confirmation-text">
                       Group
                       <span class="groupName"></span>
@@ -277,11 +260,12 @@
                         No
                       </gr-button>
                     </div>
-                  </gr-overlay>
+                  </dialog>
                 </div>
               </div>
             </gr-dialog>
-          </gr-overlay>
+            <div id="gr-hovercard-container"></div>
+          </dialog>
         `
       );
     });
@@ -645,14 +629,14 @@
             tabindex="0"
             >add reviewer/cc</gr-button
           >
-          <gr-overlay id="flow" with-backdrop="" tabindex="-1">
+          <dialog id="flow" open="" tabindex="-1">
             <gr-dialog role="dialog">
               <div slot="header">Add reviewer / CC</div>
               <div slot="main">
                 <div class="grid">
                   <span>Reviewers</span>
                   <gr-account-list id="reviewer-list"></gr-account-list>
-                  <gr-overlay aria-hidden="true" id="confirm-reviewer">
+                  <dialog tabindex="-1" id="confirm-reviewer">
                     <div class="confirmation-text">
                       Group
                       <span class="groupName"></span>
@@ -676,10 +660,10 @@
                         No
                       </gr-button>
                     </div>
-                  </gr-overlay>
+                  </dialog>
                   <span>CC</span>
                   <gr-account-list id="cc-list"></gr-account-list>
-                  <gr-overlay aria-hidden="true" id="confirm-cc">
+                  <dialog tabindex="-1" id="confirm-cc">
                     <div class="confirmation-text">
                       Group
                       <span class="groupName"></span>
@@ -703,7 +687,7 @@
                         No
                       </gr-button>
                     </div>
-                  </gr-overlay>
+                  </dialog>
                 </div>
                 <div class="warning">
                   <gr-icon icon="warning" filled role="img" aria-label="Warning"
@@ -721,11 +705,13 @@
                 </div>
               </div>
             </gr-dialog>
-          </gr-overlay>
+            <div id="gr-hovercard-container">
+            </div>
+          </dialog>
         `,
         {
-          // gr-overlay sizing seems to vary between local & CI
-          ignoreAttributes: [{tags: ['gr-overlay'], attributes: ['style']}],
+          // dialog sizing seems to vary between local & CI
+          ignoreAttributes: [{tags: ['dialog'], attributes: ['style']}],
         }
       );
     });
@@ -759,10 +745,10 @@
           >
             add reviewer/cc
           </gr-button>
-          <gr-overlay
+          <dialog
             id="flow"
             tabindex="-1"
-            with-backdrop=""
+            open=""
           >
             <gr-dialog role="dialog">
               <div slot="header">Add reviewer / CC</div>
@@ -770,7 +756,7 @@
                 <div class="grid">
                   <span> Reviewers </span>
                   <gr-account-list id="reviewer-list"> </gr-account-list>
-                  <gr-overlay aria-hidden="true" id="confirm-reviewer">
+                  <dialog tabindex="-1" id="confirm-reviewer">
                     <div class="confirmation-text">
                       Group
                       <span class="groupName"> </span>
@@ -796,10 +782,10 @@
                         No
                       </gr-button>
                     </div>
-                  </gr-overlay>
+                  </dialog>
                   <span> CC </span>
                   <gr-account-list id="cc-list"> </gr-account-list>
-                  <gr-overlay aria-hidden="true" id="confirm-cc">
+                  <dialog tabindex="-1" id="confirm-cc">
                     <div class="confirmation-text">
                       Group
                       <span class="groupName"> </span>
@@ -825,7 +811,7 @@
                         No
                       </gr-button>
                     </div>
-                  </gr-overlay>
+                  </dialog>
                 </div>
                 <div class="error">
                   <gr-icon icon="error" filled role="img" aria-label="Error"></gr-icon>
@@ -833,11 +819,13 @@
                 </div>
               </div>
             </gr-dialog>
-          </gr-overlay>
+            <div id="gr-hovercard-container">
+            </div>
+          </dialog>
         `,
         {
-          // gr-overlay sizing seems to vary between local & CI
-          ignoreAttributes: [{tags: ['gr-overlay'], attributes: ['style']}],
+          // dialog sizing seems to vary between local & CI
+          ignoreAttributes: [{tags: ['dialog'], attributes: ['style']}],
         }
       );
     });
@@ -866,10 +854,7 @@
       await reviewerList.updateComplete;
       await element.updateComplete;
 
-      const confirmDialog = queryAndAssert(
-        element,
-        'gr-overlay#confirm-reviewer'
-      );
+      const confirmDialog = queryAndAssert(element, 'dialog#confirm-reviewer');
       await waitUntil(
         () =>
           getComputedStyle(confirmDialog).getPropertyValue('display') !== 'none'
@@ -906,12 +891,10 @@
       ).click();
       await element.updateComplete;
 
-      const confirmDialog = queryAndAssert(
-        element,
-        'gr-overlay#confirm-reviewer'
-      );
-      assert.isTrue(
-        getComputedStyle(confirmDialog).getPropertyValue('display') === 'none'
+      const confirmDialog = queryAndAssert(element, 'dialog#confirm-reviewer');
+      await waitUntil(
+        () =>
+          getComputedStyle(confirmDialog).getPropertyValue('display') === 'none'
       );
 
       assert.deepEqual(reviewerList.accounts[1], {
@@ -947,10 +930,7 @@
       // triggers an update of ReviewerFlow
       await reviewerList.updateComplete;
       await element.updateComplete;
-      const confirmDialog = queryAndAssert(
-        element,
-        'gr-overlay#confirm-reviewer'
-      );
+      const confirmDialog = queryAndAssert(element, 'dialog#confirm-reviewer');
       assert.isTrue(
         getComputedStyle(confirmDialog).getPropertyValue('display') === 'none'
       );
@@ -990,10 +970,7 @@
       ).click();
       await element.updateComplete;
 
-      const confirmDialog = queryAndAssert(
-        element,
-        'gr-overlay#confirm-reviewer'
-      );
+      const confirmDialog = queryAndAssert(element, 'dialog#confirm-reviewer');
       assert.isTrue(
         getComputedStyle(confirmDialog).getPropertyValue('display') === 'none'
       );
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 86adc70..61b276e 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
@@ -15,12 +15,13 @@
 import {sharedStyles} from '../../../styles/shared-styles';
 import {Metadata} from '../../../utils/change-metadata-util';
 import {WAITING} from '../../../constants/constants';
-import {provide} from '../../../models/dependency';
+import {provide, resolve} from '../../../models/dependency';
 import {
   bulkActionsModelToken,
   BulkActionsModel,
 } from '../../../models/bulk-actions/bulk-actions-model';
 import {createSearchUrl} from '../../../models/views/search';
+import {userModelToken} from '../../../models/user/user-model';
 import {subscribe} from '../../lit/subscription-controller';
 import {classMap} from 'lit/directives/class-map.js';
 
@@ -101,8 +102,7 @@
     getAppContext().restApiService
   );
 
-  // Private but used in test.
-  userModel = getAppContext().userModel;
+  private readonly getUserModel = resolve(this, userModelToken);
 
   private isLoggedIn = false;
 
@@ -160,7 +160,7 @@
     );
     subscribe(
       this,
-      () => this.userModel.loggedIn$,
+      () => this.getUserModel().loggedIn$,
       isLoggedIn => (this.isLoggedIn = isLoggedIn)
     );
   }
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 eec8b1a..63552c7 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
@@ -28,11 +28,15 @@
 import {ChangeListSection} from '../gr-change-list/gr-change-list';
 import {fixture, html, assert} from '@open-wc/testing';
 import {ColumnNames} from '../../../constants/constants';
+import {testResolver} from '../../../test/common-test-setup';
+import {UserModel, userModelToken} from '../../../models/user/user-model';
 
 suite('gr-change-list section', () => {
   let element: GrChangeListSection;
+  let userModel: UserModel;
 
   setup(async () => {
+    userModel = testResolver(userModelToken);
     const changeSection: ChangeListSection = {
       name: 'test',
       query: 'test',
@@ -194,7 +198,7 @@
         ],
         emptyStateSlotName: 'test',
       };
-      element.userModel.setAccount({
+      userModel.setAccount({
         ...createAccountWithEmail('abc@def.com'),
         registered_on: '2015-03-12 18:32:08.000000000' as Timestamp,
       });
@@ -240,7 +244,7 @@
         ],
         emptyStateSlotName: 'test',
       };
-      element.userModel.setAccount({
+      userModel.setAccount({
         ...createAccountWithEmail('abc@def.com'),
         registered_on: '2015-03-12 18:32:08.000000000' as Timestamp,
       });
@@ -300,7 +304,7 @@
       ],
       emptyStateSlotName: 'test',
     };
-    element.userModel.setAccount(undefined);
+    userModel.setAccount(undefined);
     await element.updateComplete;
     const rows = queryAll(element, 'gr-change-list-item');
     assert.lengthOf(rows, 2);
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
index ac1ba23..4a01412 100644
--- 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
@@ -15,7 +15,7 @@
 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 {isDefined} from '../../../types/types';
 import {unique} from '../../../utils/common-util';
 import {AutocompleteSuggestion} from '../../shared/gr-autocomplete/gr-autocomplete';
 import {when} from 'lit/directives/when.js';
@@ -28,6 +28,7 @@
 import {fireAlert} from '../../../utils/event-util';
 import {pluralize} from '../../../utils/string-util';
 import {Interaction} from '../../../constants/reporting';
+import {throwingErrorCallback} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
 
 @customElement('gr-change-list-topic-flow')
 export class GrChangeListTopicFlow extends LitElement {
@@ -158,7 +159,7 @@
         .horizontalAlign=${'auto'}
         .verticalAlign=${'auto'}
         .verticalOffset=${24}
-        @opened-changed=${(e: CustomEvent) =>
+        @opened-changed=${(e: ValueChangedEvent<boolean>) =>
           (this.isDropdownOpen = e.detail.value)}
       >
         ${when(
@@ -190,7 +191,7 @@
   private renderExistingTopicsMode() {
     const topics = this.selectedChanges
       .map(change => change.topic)
-      .filter(notUndefined)
+      .filter(isDefined)
       .filter(unique);
     const removeDisabled =
       this.selectedExistingTopics.size === 0 ||
@@ -343,11 +344,12 @@
     query: string
   ): Promise<AutocompleteSuggestion[]> {
     const suggestions = await this.restApiService.getChangesWithSimilarTopic(
-      query
+      query,
+      throwingErrorCallback
     );
     this.existingTopicSuggestions = (suggestions ?? [])
       .map(change => change.topic)
-      .filter(notUndefined)
+      .filter(isDefined)
       .filter(unique);
     return this.existingTopicSuggestions.map(topic => {
       return {name: topic, value: topic};
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
index 9125cfd..489d8ee 100644
--- 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
@@ -29,11 +29,10 @@
   waitUntilObserved,
 } from '../../../test/test-utils';
 import {ChangeInfo, NumericChangeId, TopicName} from '../../../types/common';
-import {EventType} from '../../../types/events';
 import {GrAutocomplete} from '../../shared/gr-autocomplete/gr-autocomplete';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import './gr-change-list-topic-flow';
-import type {GrChangeListTopicFlow} from './gr-change-list-topic-flow';
+import {GrChangeListTopicFlow} from './gr-change-list-topic-flow';
 
 suite('gr-change-list-topic-flow tests', () => {
   let element: GrChangeListTopicFlow;
@@ -326,7 +325,7 @@
 
     test('remove single topic', async () => {
       const alertStub = sinon.stub();
-      element.addEventListener(EventType.SHOW_ALERT, alertStub);
+      element.addEventListener('show-alert', alertStub);
       queryAll<HTMLButtonElement>(element, 'button.chip')[0].click();
       await element.updateComplete;
       queryAndAssert<GrButton>(element, '#remove-topics-button').click();
@@ -387,7 +386,7 @@
 
     test('shows error when remove topic fails', async () => {
       const alertStub = sinon.stub();
-      element.addEventListener(EventType.SHOW_ALERT, alertStub);
+      element.addEventListener('show-alert', alertStub);
       queryAll<HTMLButtonElement>(element, 'button.chip')[0].click();
       await element.updateComplete;
       queryAndAssert<GrButton>(element, '#remove-topics-button').click();
@@ -435,7 +434,7 @@
 
     test('applies topic to all changes', async () => {
       const alertStub = sinon.stub();
-      element.addEventListener(EventType.SHOW_ALERT, alertStub);
+      element.addEventListener('show-alert', alertStub);
 
       queryAll<HTMLButtonElement>(element, 'button.chip')[0].click();
       await element.updateComplete;
@@ -589,7 +588,7 @@
 
     test('create new topic', async () => {
       const alertStub = sinon.stub();
-      element.addEventListener(EventType.SHOW_ALERT, alertStub);
+      element.addEventListener('show-alert', alertStub);
       const getTopicsStub = stubRestApi('getChangesWithSimilarTopic').resolves(
         []
       );
@@ -639,7 +638,7 @@
 
     test('shows error when create topic fails', async () => {
       const alertStub = sinon.stub();
-      element.addEventListener(EventType.SHOW_ALERT, alertStub);
+      element.addEventListener('show-alert', alertStub);
       const getTopicsStub = stubRestApi('getChangesWithSimilarTopic').resolves(
         []
       );
@@ -682,7 +681,7 @@
         {...createChange(), topic: 'foo' as TopicName},
       ]);
       const alertStub = sinon.stub();
-      element.addEventListener(EventType.SHOW_ALERT, alertStub);
+      element.addEventListener('show-alert', alertStub);
       const autocomplete = queryAndAssert<GrAutocomplete>(
         element,
         'gr-autocomplete'
@@ -732,7 +731,7 @@
         {...createChange(), topic: 'foo' as TopicName},
       ]);
       const alertStub = sinon.stub();
-      element.addEventListener(EventType.SHOW_ALERT, alertStub);
+      element.addEventListener('show-alert', alertStub);
       const autocomplete = queryAndAssert<GrAutocomplete>(
         element,
         'gr-autocomplete'
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts
index 7abd7c0..96b01e1 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts
@@ -6,7 +6,6 @@
 import '../gr-change-list/gr-change-list';
 import '../gr-repo-header/gr-repo-header';
 import '../gr-user-header/gr-user-header';
-import {page} from '../../../utils/page-wrapper-utils';
 import {
   AccountDetailInfo,
   AccountId,
@@ -16,7 +15,7 @@
   RepoName,
 } from '../../../types/common';
 import {ChangeStarToggleStarDetail} from '../../shared/gr-change-star/gr-change-star';
-import {fireAlert, fireEvent, fireTitleChange} from '../../../utils/event-util';
+import {fireAlert, fire, fireTitleChange} from '../../../utils/event-util';
 import {getAppContext} from '../../../services/app-context';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, PropertyValues, html, css, nothing} from 'lit';
@@ -27,17 +26,13 @@
 } from '../../../models/views/search';
 import {resolve} from '../../../models/dependency';
 import {subscribe} from '../../lit/subscription-controller';
+import {userModelToken} from '../../../models/user/user-model';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
 
 const LIMIT_OPERATOR_PATTERN = /\blimit:(\d+)/i;
 
 @customElement('gr-change-list-view')
 export class GrChangeListView extends LitElement {
-  /**
-   * Fired when the title of the page should change.
-   *
-   * @event title-change
-   */
-
   @query('#prevArrow') protected prevArrow?: HTMLAnchorElement;
 
   @query('#nextArrow') protected nextArrow?: HTMLAnchorElement;
@@ -76,10 +71,12 @@
 
   private reporting = getAppContext().reportingService;
 
-  private userModel = getAppContext().userModel;
+  private readonly getUserModel = resolve(this, userModelToken);
 
   private readonly getViewModel = resolve(this, searchViewModelToken);
 
+  private readonly getNavigation = resolve(this, navigationToken);
+
   constructor() {
     super();
     this.addEventListener('next-page', () => this.handleNextPage());
@@ -117,22 +114,22 @@
     );
     subscribe(
       this,
-      () => this.userModel.account$,
+      () => this.getUserModel().account$,
       x => (this.account = x)
     );
     subscribe(
       this,
-      () => this.userModel.loggedIn$,
+      () => this.getUserModel().loggedIn$,
       x => (this.loggedIn = x)
     );
     subscribe(
       this,
-      () => this.userModel.preferenceChangesPerPage$,
+      () => this.getUserModel().preferenceChangesPerPage$,
       x => (this.changesPerPage = x)
     );
     subscribe(
       this,
-      () => this.userModel.preferences$,
+      () => this.getUserModel().preferences$,
       x => (this.preferences = x)
     );
   }
@@ -255,7 +252,7 @@
 
   override updated(changedProperties: PropertyValues) {
     if (changedProperties.has('query')) {
-      fireTitleChange(this, this.query);
+      fireTitleChange(this.query);
     }
   }
 
@@ -280,13 +277,13 @@
   // private but used in test
   handleNextPage() {
     if (!this.nextArrow || !this.changesPerPage) return;
-    page.show(this.computeNavLink(1));
+    this.getNavigation().setUrl(this.computeNavLink(1));
   }
 
   // private but used in test
   handlePreviousPage() {
     if (!this.prevArrow || !this.changesPerPage) return;
-    page.show(this.computeNavLink(-1));
+    this.getNavigation().setUrl(this.computeNavLink(-1));
   }
 
   // private but used in test
@@ -313,7 +310,7 @@
       e.detail.change._number,
       e.detail.starred
     );
-    fireEvent(this, 'hide-alert');
+    fire(this, 'hide-alert', {});
   }
 }
 
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.ts
index f4bd8bd..decc253 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.ts
@@ -6,7 +6,6 @@
 import '../../../test/common-test-setup';
 import './gr-change-list-view';
 import {GrChangeListView} from './gr-change-list-view';
-import {page} from '../../../utils/page-wrapper-utils';
 import {query, queryAndAssert} from '../../../test/test-utils';
 import {createChange} from '../../../test/test-data-generators';
 import {ChangeInfo} from '../../../api/rest-api';
@@ -14,6 +13,8 @@
 import {GrChangeList} from '../gr-change-list/gr-change-list';
 import {GrChangeListSection} from '../gr-change-list-section/gr-change-list-section';
 import {GrChangeListItem} from '../gr-change-list-item/gr-change-list-item';
+import {testResolver} from '../../../test/common-test-setup';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
 
 suite('gr-change-list-view tests', () => {
   let element: GrChangeListView;
@@ -158,7 +159,7 @@
   });
 
   test('handleNextPage', async () => {
-    const showStub = sinon.stub(page, 'show');
+    const setUrlStub = sinon.stub(testResolver(navigationToken), 'setUrl');
     element.changes = Array(25)
       .fill(0)
       .map(_ => createChange());
@@ -166,7 +167,7 @@
     element.loading = false;
     await element.updateComplete;
     element.handleNextPage();
-    assert.isFalse(showStub.called);
+    assert.isFalse(setUrlStub.called);
 
     element.changes = Array(25)
       .fill(0)
@@ -174,11 +175,11 @@
     element.loading = false;
     await element.updateComplete;
     element.handleNextPage();
-    assert.isTrue(showStub.called);
+    assert.isTrue(setUrlStub.called);
   });
 
   test('handlePreviousPage', async () => {
-    const showStub = sinon.stub(page, 'show');
+    const setUrlStub = sinon.stub(testResolver(navigationToken), 'setUrl');
     element.offset = 0;
     element.changes = Array(25)
       .fill(0)
@@ -187,11 +188,11 @@
     element.loading = false;
     await element.updateComplete;
     element.handlePreviousPage();
-    assert.isFalse(showStub.called);
+    assert.isFalse(setUrlStub.called);
 
     element.offset = 25;
     await element.updateComplete;
     element.handlePreviousPage();
-    assert.isTrue(showStub.called);
+    assert.isTrue(setUrlStub.called);
   });
 });
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts
index 945ff6e..117abd6 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
@@ -10,8 +10,6 @@
 import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
 import {getAppContext} from '../../../services/app-context';
 import {navigationToken} from '../../core/gr-navigation/gr-navigation';
-import {getPluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints';
-import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 import {GrCursorManager} from '../../shared/gr-cursor-manager/gr-cursor-manager';
 import {
   AccountInfo,
@@ -19,7 +17,7 @@
   ServerInfo,
   PreferencesInput,
 } from '../../../types/common';
-import {fire, fireEvent, fireReload} from '../../../utils/event-util';
+import {fire, fireReload} from '../../../utils/event-util';
 import {ColumnNames, ScrollMode} from '../../../constants/constants';
 import {getRequirements} from '../../../utils/label-util';
 import {Key} from '../../../utils/dom-util';
@@ -36,6 +34,7 @@
 import {ValueChangedEvent} from '../../../types/events';
 import {resolve} from '../../../models/dependency';
 import {createChangeUrl} from '../../../models/views/change';
+import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 
 export interface ChangeListSection {
   countLabel?: string;
@@ -78,18 +77,6 @@
 @customElement('gr-change-list')
 export class GrChangeList extends LitElement {
   /**
-   * Fired when next page key shortcut was pressed.
-   *
-   * @event next-page
-   */
-
-  /**
-   * Fired when previous page key shortcut was pressed.
-   *
-   * @event previous-page
-   */
-
-  /**
    * The logged-in user's account, or an empty object if no user is logged
    * in.
    */
@@ -134,9 +121,6 @@
   // private but used in test
   @state() config?: ServerInfo;
 
-  // Private but used in test.
-  userModel = getAppContext().userModel;
-
   private readonly flagsService = getAppContext().flagsService;
 
   private readonly restApiService = getAppContext().restApiService;
@@ -145,6 +129,8 @@
 
   private readonly shortcuts = new ShortcutController(this);
 
+  private readonly getPluginLoader = resolve(this, pluginLoaderToken);
+
   private readonly getNavigation = resolve(this, navigationToken);
 
   private cursor = new GrCursorManager();
@@ -179,11 +165,13 @@
     this.restApiService.getConfig().then(config => {
       this.config = config;
     });
-    getPluginLoader()
+    this.getPluginLoader()
       .awaitPluginsLoaded()
       .then(() => {
         this.dynamicHeaderEndpoints =
-          getPluginEndpoints().getDynamicEndpoints('change-list-header');
+          this.getPluginLoader().pluginEndPoints.getDynamicEndpoints(
+            'change-list-header'
+          );
       });
   }
 
@@ -241,7 +229,7 @@
   }
 
   private calculateStartIndices(sections: ChangeListSection[]): number[] {
-    const startIndices: number[] = new Array(sections.length).fill(0);
+    const startIndices = Array.from<number>({length: sections.length}).fill(0);
     for (let i = 1; i < sections.length; ++i) {
       startIndices[i] = startIndices[i - 1] + sections[i - 1].results.length;
     }
@@ -415,11 +403,11 @@
   }
 
   private nextPage() {
-    fireEvent(this, 'next-page');
+    fire(this, 'next-page', {});
   }
 
   private prevPage() {
-    fireEvent(this, 'previous-page');
+    fire(this, 'previous-page', {});
   }
 
   private refreshChangeList() {
@@ -484,5 +472,9 @@
   }
   interface HTMLElementEventMap {
     'selected-index-changed': ValueChangedEvent<number>;
+    /** Fired when next page key shortcut was pressed. */
+    'next-page': CustomEvent<{}>;
+    /** Fired when previous page key shortcut was pressed. */
+    'previous-page': CustomEvent<{}>;
   }
 }
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 7511b57..e201ab4 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
@@ -34,12 +34,15 @@
 import {html} from 'lit';
 import {testResolver} from '../../../test/common-test-setup';
 import {Timestamp} from '../../../api/rest-api';
+import {UserModel, userModelToken} from '../../../models/user/user-model';
 
 suite('gr-change-list basic tests', () => {
   let element: GrChangeList;
+  let userModel: UserModel;
 
   setup(async () => {
     element = await fixture(html`<gr-change-list></gr-change-list>`);
+    userModel = testResolver(userModelToken);
   });
 
   test('renders', async () => {
@@ -138,7 +141,10 @@
   });
 
   test('computeRelativeIndex', () => {
-    element.sections = [{results: new Array(1)}, {results: new Array(2)}];
+    element.sections = [
+      {results: Array.from({length: 1})},
+      {results: Array.from({length: 2})},
+    ];
 
     let selectedChangeIndex = 0;
     assert.equal(
@@ -225,7 +231,10 @@
 
   test('keyboard shortcuts', async () => {
     sinon.stub(element, 'computeLabelNames');
-    element.sections = [{results: new Array(1)}, {results: new Array(2)}];
+    element.sections = [
+      {results: Array.from({length: 1})},
+      {results: Array.from({length: 2})},
+    ];
     element.selectedIndex = 0;
     element.preferences = createDefaultPreferences();
     element.config = createServerInfo();
@@ -287,7 +296,7 @@
   });
 
   test('toggle checkbox keyboard shortcut', async () => {
-    element.userModel.setAccount({
+    userModel.setAccount({
       ...createAccountWithEmail('abc@def.com'),
       registered_on: '2015-03-12 18:32:08.000000000' as Timestamp,
     });
@@ -297,7 +306,10 @@
       queryAndAssert<HTMLInputElement>(query(item, '.selection'), 'input');
 
     sinon.stub(element, 'computeLabelNames');
-    element.sections = [{results: new Array(1)}, {results: new Array(2)}];
+    element.sections = [
+      {results: Array.from({length: 1})},
+      {results: Array.from({length: 2})},
+    ];
     element.selectedIndex = 0;
     element.preferences = createDefaultPreferences();
     element.config = createServerInfo();
@@ -521,7 +533,6 @@
 
   test('obsolete column in preferences not visible', () => {
     assert.isTrue(element.isColumnEnabled('Subject'));
-    assert.isFalse(element.isColumnEnabled('Assignee'));
   });
 
   test('loggedIn and showNumber', async () => {
@@ -543,7 +554,7 @@
       ],
     };
     element.config = createServerInfo();
-    element.userModel.setAccount(undefined);
+    userModel.setAccount(undefined);
     await element.updateComplete;
     const section = query<GrChangeListSection>(
       element,
@@ -557,7 +568,7 @@
     assert.isNotOk(query(query(section, 'gr-change-list-item'), '.star'));
     assert.isNotOk(query(query(section, 'gr-change-list-item'), '.number'));
 
-    element.userModel.setAccount({
+    userModel.setAccount({
       ...createAccountWithEmail('abc@def.com'),
       registered_on: '2015-03-12 18:32:08.000000000' as Timestamp,
     });
diff --git a/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help.ts b/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help.ts
index 9c53fea..d9be9ca 100644
--- a/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help.ts
@@ -4,7 +4,7 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import '../../shared/gr-button/gr-button';
-import {fireEvent} from '../../../utils/event-util';
+import {fire} from '../../../utils/event-util';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, css, html} from 'lit';
 import {customElement} from 'lit/decorators.js';
@@ -14,6 +14,10 @@
   interface HTMLElementTagNameMap {
     'gr-create-change-help': GrCreateChangeHelp;
   }
+  interface HTMLElementEventMap {
+    /** Fired when the "Create change" button is tapped. */
+    'create-tap': CustomEvent<{}>;
+  }
 }
 
 @customElement('gr-create-change-help')
@@ -87,11 +91,8 @@
     `;
   }
 
-  /**
-   * Fired when the "Create change" button is tapped.
-   */
   _handleCreateTap(e: Event) {
     e.preventDefault();
-    fireEvent(this, 'create-tap');
+    fire(this, 'create-tap', {});
   }
 }
diff --git a/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog.ts b/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog.ts
index 567c508..cf5c26d 100644
--- a/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog.ts
@@ -4,12 +4,11 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import '../../shared/gr-dialog/gr-dialog';
-import '../../shared/gr-overlay/gr-overlay';
 import '../../shared/gr-shell-command/gr-shell-command';
-import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, css, html} from 'lit';
 import {customElement, property, query} from 'lit/decorators.js';
+import {modalStyles} from '../../../styles/gr-modal-styles';
 
 enum Commands {
   CREATE = 'git commit',
@@ -25,8 +24,8 @@
 
 @customElement('gr-create-commands-dialog')
 export class GrCreateCommandsDialog extends LitElement {
-  @query('#commandsOverlay')
-  commandsOverlay?: GrOverlay;
+  @query('#commandsModal')
+  commandsModal?: HTMLDialogElement;
 
   @property({type: String})
   branch?: string;
@@ -34,6 +33,7 @@
   static override get styles() {
     return [
       sharedStyles,
+      modalStyles,
       css`
         ol {
           list-style: decimal;
@@ -50,13 +50,13 @@
   }
 
   override render() {
-    return html` <gr-overlay id="commandsOverlay" with-backdrop="">
+    return html` <dialog id="commandsModal" tabindex="-1">
       <gr-dialog
         id="commandsDialog"
         confirm-label="Done"
         cancel-label=""
         confirm-on-enter=""
-        @confirm=${() => this.commandsOverlay?.close()}
+        @confirm=${() => this.commandsModal?.close()}
       >
         <div class="header" slot="header">Create change commands</div>
         <div class="main" slot="main">
@@ -90,10 +90,10 @@
           </ol>
         </div>
       </gr-dialog>
-    </gr-overlay>`;
+    </dialog>`;
   }
 
   open() {
-    this.commandsOverlay?.open();
+    this.commandsModal?.showModal();
   }
 }
diff --git a/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog_test.ts b/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog_test.ts
index 96ec9eb..3252e3d 100644
--- a/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog_test.ts
@@ -27,13 +27,7 @@
     assert.shadowDom.equal(
       element,
       /* prettier-ignore */ /* HTML */ `
-      <gr-overlay
-        aria-hidden="true"
-        id="commandsOverlay"
-        style="outline: none; display: none;"
-        tabindex="-1"
-        with-backdrop=""
-      >
+      <dialog id="commandsModal" tabindex="-1">
         <gr-dialog
           cancel-label=""
           confirm-label="Done"
@@ -71,7 +65,7 @@
             </ol>
           </div>
         </gr-dialog>
-      </gr-overlay>
+      </dialog>
     `
     );
   });
diff --git a/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog.ts b/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog.ts
index 983a0d9..16220ba 100644
--- a/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog.ts
@@ -4,15 +4,15 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import '../../shared/gr-dialog/gr-dialog';
-import '../../shared/gr-overlay/gr-overlay';
 import '../../shared/gr-repo-branch-picker/gr-repo-branch-picker';
-import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
 import {RepoName, BranchName} from '../../../types/common';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, html} from 'lit';
 import {customElement, state, query} from 'lit/decorators.js';
 import {assertIsDefined} from '../../../utils/common-util';
 import {BindValueChangeEvent} from '../../../types/events';
+import {modalStyles} from '../../../styles/gr-modal-styles';
+import {fireNoBubbleNoCompose} from '../../../utils/event-util';
 
 export interface CreateDestinationConfirmDetail {
   repo?: RepoName;
@@ -28,25 +28,25 @@
    * @event confirm
    */
 
-  @query('#createOverlay') private createOverlay?: GrOverlay;
+  @query('#createModal') private createModal?: HTMLDialogElement;
 
   @state() private repo?: RepoName;
 
   @state() private branch?: BranchName;
 
   static override get styles() {
-    return [sharedStyles];
+    return [sharedStyles, modalStyles];
   }
 
   override render() {
     return html`
-      <gr-overlay id="createOverlay" with-backdrop>
+      <dialog id="createModal" tabindex="-1">
         <gr-dialog
           confirm-label="View commands"
           @confirm=${this.pickerConfirm}
           @cancel=${() => {
-            assertIsDefined(this.createOverlay, 'createOverlay');
-            this.createOverlay.close();
+            assertIsDefined(this.createModal, 'createModal');
+            this.createModal.close();
           }}
           ?disabled=${!(this.repo && this.branch)}
         >
@@ -67,20 +67,20 @@
             </p>
           </div>
         </gr-dialog>
-      </gr-overlay>
+      </dialog>
     `;
   }
 
   open() {
-    assertIsDefined(this.createOverlay, 'createOverlay');
+    assertIsDefined(this.createModal, 'createModal');
     this.repo = '' as RepoName;
     this.branch = '' as BranchName;
-    this.createOverlay.open();
+    this.createModal.showModal();
   }
 
   private pickerConfirm = (e: Event) => {
-    assertIsDefined(this.createOverlay, 'createOverlay');
-    this.createOverlay.close();
+    assertIsDefined(this.createModal, 'createModal');
+    this.createModal.close();
     const detail: CreateDestinationConfirmDetail = {
       repo: this.repo,
       branch: this.branch,
@@ -89,7 +89,7 @@
     // 'confirm' event here, so let's stop propagation of the bare event.
     e.preventDefault();
     e.stopPropagation();
-    this.dispatchEvent(new CustomEvent('confirm', {detail, bubbles: false}));
+    fireNoBubbleNoCompose(this, 'confirm-destination', detail);
   };
 }
 
@@ -97,4 +97,7 @@
   interface HTMLElementTagNameMap {
     'gr-create-destination-dialog': GrCreateDestinationDialog;
   }
+  interface HTMLElementEventMap {
+    'confirm-destination': CustomEvent<CreateDestinationConfirmDetail>;
+  }
 }
diff --git a/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog_test.ts b/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog_test.ts
index 44b3183..cb27aae 100644
--- a/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog_test.ts
@@ -21,13 +21,7 @@
     assert.shadowDom.equal(
       element,
       /* HTML */ `
-        <gr-overlay
-          aria-hidden="true"
-          id="createOverlay"
-          style="outline: none; display: none;"
-          tabindex="-1"
-          with-backdrop=""
-        >
+        <dialog id="createModal" tabindex="-1">
           <gr-dialog confirm-label="View commands" disabled="" role="dialog">
             <div class="header" slot="header">Create change</div>
             <div class="main" slot="main">
@@ -37,7 +31,7 @@
               </p>
             </div>
           </gr-dialog>
-        </gr-overlay>
+        </dialog>
       `
     );
   });
diff --git a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.ts b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.ts
index f11fe1c4..a870835 100644
--- a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.ts
@@ -6,7 +6,6 @@
 import '../gr-change-list/gr-change-list';
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-dialog/gr-dialog';
-import '../../shared/gr-overlay/gr-overlay';
 import '../gr-create-commands-dialog/gr-create-commands-dialog';
 import '../gr-create-change-help/gr-create-change-help';
 import '../gr-create-destination-dialog/gr-create-destination-dialog';
@@ -27,11 +26,10 @@
   CreateDestinationConfirmDetail,
   GrCreateDestinationDialog,
 } from '../gr-create-destination-dialog/gr-create-destination-dialog';
-import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
 import {ChangeStarToggleStarDetail} from '../../shared/gr-change-star/gr-change-star';
 import {
   fireAlert,
-  fireEvent,
+  fire,
   firePageError,
   fireTitleChange,
 } from '../../../utils/event-util';
@@ -57,6 +55,9 @@
   UserDashboard,
   YOUR_TURN,
 } from '../../../utils/dashboard-util';
+import {userModelToken} from '../../../models/user/user-model';
+import {Timing} from '../../../constants/reporting';
+import {modalStyles} from '../../../styles/gr-modal-styles';
 
 const PROJECT_PLACEHOLDER_PATTERN = /\${project}/g;
 
@@ -67,12 +68,6 @@
 
 @customElement('gr-dashboard-view')
 export class GrDashboardView extends LitElement {
-  /**
-   * Fired when the title of the page should change.
-   *
-   * @event title-change
-   */
-
   @query('#confirmDeleteDialog') protected confirmDeleteDialog?: GrDialog;
 
   @query('#commandsDialog') protected commandsDialog?: GrCreateCommandsDialog;
@@ -80,7 +75,8 @@
   @query('#destinationDialog')
   protected destinationDialog?: GrCreateDestinationDialog;
 
-  @query('#confirmDeleteOverlay') protected confirmDeleteOverlay?: GrOverlay;
+  @query('#confirmDeleteModal')
+  protected confirmDeleteModal?: HTMLDialogElement;
 
   @property({type: Object})
   account?: AccountDetailInfo;
@@ -107,19 +103,29 @@
 
   private readonly restApiService = getAppContext().restApiService;
 
-  private readonly userModel = getAppContext().userModel;
+  private readonly getUserModel = resolve(this, userModelToken);
 
   private readonly getViewModel = resolve(this, dashboardViewModelToken);
 
   private lastVisibleTimestampMs = 0;
 
+  /**
+   * For `DASHBOARD_DISPLAYED` timing we can only rely on the router to have
+   * reset the timer properly when the dashboard loads for the first time.
+   * Later we won't have a guarantee that the timer was just reset. So we will
+   * just reset the timer at the beginning of `reload()`. The dashboard view
+   * is cached anyway, so there is unlikely a lot of time that has passed
+   * initiating the reload and the reload() method being executed.
+   */
+  private firstTimeLoad = true;
+
   private readonly shortcuts = new ShortcutController(this);
 
   constructor() {
     super();
     subscribe(
       this,
-      () => this.userModel.account$,
+      () => this.getUserModel().account$,
       x => (this.account = x)
     );
     subscribe(
@@ -167,6 +173,7 @@
     return [
       a11yStyles,
       sharedStyles,
+      modalStyles,
       css`
         :host {
           display: block;
@@ -208,7 +215,7 @@
     if (!this.viewState) return nothing;
     return html`
       ${this.renderBanner()} ${this.renderContent()}
-      <gr-overlay id="confirmDeleteOverlay" with-backdrop>
+      <dialog id="confirmDeleteModal" tabindex="-1">
         <gr-dialog
           id="confirmDeleteDialog"
           confirm-label="Delete"
@@ -216,7 +223,7 @@
             this.handleConfirmDelete();
           }}
           @cancel=${() => {
-            this.closeConfirmDeleteOverlay();
+            this.closeConfirmDeleteModal();
           }}
         >
           <div class="header" slot="header">Delete comments</div>
@@ -225,10 +232,12 @@
             changes? This action cannot be undone.
           </div>
         </gr-dialog>
-      </gr-overlay>
+      </dialog>
       <gr-create-destination-dialog
         id="destinationDialog"
-        @confirm=${(e: CustomEvent<CreateDestinationConfirmDetail>) => {
+        @confirm-destination=${(
+          e: CustomEvent<CreateDestinationConfirmDetail>
+        ) => {
           this.handleDestinationConfirm(e);
         }}
       ></gr-create-destination-dialog>
@@ -329,8 +338,8 @@
   }
 
   // private but used in test
-  getProjectDashboard(
-    project: RepoName,
+  getRepositoryDashboard(
+    repo: RepoName,
     dashboard?: DashboardId
   ): Promise<UserDashboard | undefined> {
     const errFn = (response?: Response | null) => {
@@ -338,7 +347,7 @@
     };
     assertIsDefined(dashboard, 'project dashboard must have id');
     return this.restApiService
-      .getDashboard(project, dashboard, errFn)
+      .getDashboard(repo, dashboard, errFn)
       .then(response => {
         if (!response) {
           return;
@@ -351,7 +360,7 @@
               name: section.name,
               query: (section.query + suffix).replace(
                 PROJECT_PLACEHOLDER_PATTERN,
-                project
+                repo
               ),
             };
           }),
@@ -374,11 +383,18 @@
    */
   reload() {
     if (!this.viewState) return Promise.resolve();
+
+    // See `firstTimeLoad` comment above.
+    if (!this.firstTimeLoad) {
+      this.reporting.time(Timing.DASHBOARD_DISPLAYED);
+    }
+    this.firstTimeLoad = false;
+
     this.loading = true;
     const {project, dashboard, title, user, sections} = this.viewState;
 
     const dashboardPromise: Promise<UserDashboard | undefined> = project
-      ? this.getProjectDashboard(project, dashboard)
+      ? this.getRepositoryDashboard(project, dashboard)
       : Promise.resolve(
           getUserDashboard(user, sections, title || this.computeTitle(user))
         );
@@ -388,7 +404,7 @@
     return dashboardPromise
       .then(res => {
         if (res && res.title) {
-          fireTitleChange(this, res.title);
+          fireTitleChange(res.title);
         }
         return this.fetchDashboardChanges(res, checkForNewUser);
       })
@@ -397,7 +413,7 @@
         this.reporting.dashboardDisplayed();
       })
       .catch(err => {
-        fireTitleChange(this, title || this.computeTitle(user));
+        fireTitleChange(title || this.computeTitle(user));
         this.reporting.error('Dashboard reload', err);
       })
       .finally(() => {
@@ -517,7 +533,7 @@
       e.detail.change._number,
       e.detail.starred
     );
-    fireEvent(this, 'hide-alert');
+    fire(this, 'hide-alert', {});
     if (e.detail.starred) {
       this.reporting.reportInteraction('change-starred-from-dashboard');
     }
@@ -564,8 +580,8 @@
 
   // private but used in test
   handleOpenDeleteDialog() {
-    assertIsDefined(this.confirmDeleteOverlay, 'confirmDeleteOverlay');
-    this.confirmDeleteOverlay.open();
+    assertIsDefined(this.confirmDeleteModal, 'confirmDeleteModal');
+    this.confirmDeleteModal.showModal();
   }
 
   // private but used in test
@@ -573,14 +589,14 @@
     assertIsDefined(this.confirmDeleteDialog, 'confirmDeleteDialog');
     this.confirmDeleteDialog.disabled = true;
     return this.restApiService.deleteDraftComments('-is:open').then(() => {
-      this.closeConfirmDeleteOverlay();
+      this.closeConfirmDeleteModal();
       this.reload();
     });
   }
 
-  private closeConfirmDeleteOverlay() {
-    assertIsDefined(this.confirmDeleteOverlay, 'confirmDeleteOverlay');
-    this.confirmDeleteOverlay.close();
+  private closeConfirmDeleteModal() {
+    assertIsDefined(this.confirmDeleteModal, 'confirmDeleteModal');
+    this.confirmDeleteModal.close();
   }
 
   private computeDraftsLink() {
diff --git a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.ts b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.ts
index e7aaa21..84a3139 100644
--- a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.ts
@@ -29,7 +29,6 @@
   RepoName,
   Timestamp,
 } from '../../../types/common';
-import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
 import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
 import {GrCreateChangeHelp} from '../gr-create-change-help/gr-create-change-help';
 import {PageErrorEvent} from '../../../types/events';
@@ -90,12 +89,9 @@
             </div>
           </gr-change-list>
         </div>
-        <gr-overlay
-          aria-hidden="true"
-          id="confirmDeleteOverlay"
-          style="outline: none; display: none;"
+        <dialog
+          id="confirmDeleteModal"
           tabindex="-1"
-          with-backdrop=""
         >
           <gr-dialog
             confirm-label="Delete"
@@ -108,7 +104,7 @@
             changes? This action cannot be undone.
             </div>
           </gr-dialog>
-        </gr-overlay>
+        </dialog>
         <gr-create-destination-dialog id="destinationDialog">
         </gr-create-destination-dialog>
         <gr-create-commands-dialog id="commandsDialog">
@@ -266,11 +262,14 @@
       );
 
       // Open confirmation dialog and tap confirm button.
-      await queryAndAssert<GrOverlay>(element, '#confirmDeleteOverlay').open();
-      queryAndAssert<GrDialog>(
+      const modal = queryAndAssert<HTMLDialogElement>(
         element,
-        '#confirmDeleteDialog'
-      ).confirmButton!.click();
+        '#confirmDeleteModal'
+      );
+      modal.showModal();
+      const dialog = queryAndAssert<GrDialog>(modal, '#confirmDeleteDialog');
+      await waitUntil(() => !!dialog.confirmButton);
+      dialog.confirmButton!.click();
       await element.updateComplete;
       assert.isTrue(deleteStub.calledWithExactly('-is:open'));
       assert.isTrue(
@@ -397,7 +396,7 @@
           ],
         })
       );
-      const dashboard = await element.getProjectDashboard(
+      const dashboard = await element.getRepositoryDashboard(
         'project' as RepoName,
         '' as DashboardId
       );
@@ -429,7 +428,7 @@
           ],
         })
       );
-      const dashboard = await element.getProjectDashboard(
+      const dashboard = await element.getRepositoryDashboard(
         'project' as RepoName,
         '' as DashboardId
       );
diff --git a/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header.ts b/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header.ts
index e27274b..b743467 100644
--- a/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header.ts
@@ -12,6 +12,7 @@
 import {LitElement, css, html, PropertyValues} from 'lit';
 import {customElement, property} from 'lit/decorators.js';
 import {createRepoUrl} from '../../../models/views/repo';
+import '../../shared/gr-weblink/gr-weblink';
 
 @customElement('gr-repo-header')
 export class GrRepoHeader extends LitElement {
@@ -50,7 +51,7 @@
     return html`<div>
       <span class="browse">Browse:</span>
       ${webLinks.map(
-        link => html`<a target="_blank" href=${link.url}>${link.name}</a> `
+        info => html`<gr-weblink imageAndText .info=${info}></gr-weblink>`
       )}
     </div> `;
   }
diff --git a/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header.ts b/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header.ts
index 57f9ee6..3f99416 100644
--- a/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header.ts
@@ -117,10 +117,12 @@
       return;
     }
 
-    this.restApiService.getAccountDetails(userId).then(details => {
-      this._accountDetails = details ?? undefined;
-      this._status = details?.status ?? '';
-    });
+    this.restApiService
+      .getAccountDetails(userId, () => {})
+      .then(details => {
+        this._accountDetails = details ?? undefined;
+        this._status = details?.status ?? '';
+      });
   }
 
   _computeDetail(
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
index 506e086..18fcce1 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
@@ -8,7 +8,6 @@
 import '../../shared/gr-dialog/gr-dialog';
 import '../../shared/gr-dropdown/gr-dropdown';
 import '../../shared/gr-icon/gr-icon';
-import '../../shared/gr-overlay/gr-overlay';
 import '../gr-confirm-abandon-dialog/gr-confirm-abandon-dialog';
 import '../gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog';
 import '../gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog';
@@ -18,7 +17,6 @@
 import '../gr-confirm-submit-dialog/gr-confirm-submit-dialog';
 import '../../../styles/shared-styles';
 import {navigationToken} from '../../core/gr-navigation/gr-navigation';
-import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 import {getAppContext} from '../../../services/app-context';
 import {
   CURRENT,
@@ -36,12 +34,13 @@
   HttpMethod,
   NotifyType,
 } from '../../../constants/constants';
-import {EventType as PluginEventType, TargetElement} from '../../../api/plugin';
+import {TargetElement} from '../../../api/plugin';
 import {
   AccountInfo,
   ActionInfo,
   ActionNameToActionInfoMap,
   BranchName,
+  ChangeActionDialog,
   ChangeInfo,
   ChangeViewChangeInfo,
   CherryPickInput,
@@ -57,7 +56,6 @@
   ReviewInput,
 } from '../../../types/common';
 import {GrConfirmAbandonDialog} from '../gr-confirm-abandon-dialog/gr-confirm-abandon-dialog';
-import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
 import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
 import {GrCreateChangeDialog} from '../../admin/gr-create-change-dialog/gr-create-change-dialog';
 import {GrConfirmSubmitDialog} from '../gr-confirm-submit-dialog/gr-confirm-submit-dialog';
@@ -81,15 +79,14 @@
 import {
   fire,
   fireAlert,
-  fireEvent,
-  fireReload,
+  fireError,
+  fireNoBubbleNoCompose,
 } from '../../../utils/event-util';
 import {
   getApprovalInfo,
   getVotingRange,
   StandardLabels,
 } from '../../../utils/label-util';
-import {EventType, ShowAlertEventDetail} from '../../../types/events';
 import {
   ActionPriority,
   ActionType,
@@ -105,11 +102,16 @@
 import {LitElement, PropertyValues, css, html, nothing} from 'lit';
 import {customElement, property, query, state} from 'lit/decorators.js';
 import {ifDefined} from 'lit/directives/if-defined.js';
-import {assertIsDefined, queryAll} from '../../../utils/common-util';
+import {assertIsDefined, queryAll, uuid} from '../../../utils/common-util';
 import {Interaction} from '../../../constants/reporting';
 import {rootUrl} from '../../../utils/url-util';
 import {createSearchUrl} from '../../../models/views/search';
 import {createChangeUrl} from '../../../models/views/change';
+import {storageServiceToken} from '../../../services/storage/gr-storage_impl';
+import {ShowRevisionActionsDetail} from '../../shared/gr-js-api-interface/gr-js-api-types';
+import {whenVisible} from '../../../utils/dom-util';
+import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
+import {modalStyles} from '../../../styles/gr-modal-styles';
 import {subscribe} from '../../lit/subscription-controller';
 
 const ERR_BRANCH_EMPTY = 'The destination branch can’t be empty.';
@@ -318,11 +320,6 @@
   priority: ActionPriority;
 }
 
-interface ChangeActionDialog extends HTMLElement {
-  resetFocus?(): void;
-  init?(): void;
-}
-
 @customElement('gr-change-actions')
 export class GrChangeActions
   extends LitElement
@@ -340,21 +337,9 @@
    * @event custom-tap - naming pattern: <action key>-tap
    */
 
-  /**
-   * Fires to show an alert when a send is attempted on the non-latest patch.
-   *
-   * @event show-alert
-   */
-
-  /**
-   * Fires when a change action fails.
-   *
-   * @event show-error
-   */
-
   @query('#mainContent') mainContent?: Element;
 
-  @query('#overlay') overlay?: GrOverlay;
+  @query('#actionsModal') actionsModal?: HTMLDialogElement;
 
   @query('#confirmRebase') confirmRebase?: GrConfirmRebaseDialog;
 
@@ -392,13 +377,6 @@
 
   RevisionActions = RevisionActions;
 
-  private readonly reporting = getAppContext().reportingService;
-
-  // Accessed in tests
-  readonly jsAPI = getAppContext().jsApiService;
-
-  private readonly getChangeModel = resolve(this, changeModelToken);
-
   @property({type: Object})
   change?: ChangeViewChangeInfo;
 
@@ -414,9 +392,6 @@
   @property({type: Boolean})
   disableEdit = false;
 
-  @property({type: Boolean})
-  _hasKnownChainState = false;
-
   // private but used in test
   @state() _hideQuickApproveAction = false;
 
@@ -432,9 +407,6 @@
   @property({type: String})
   commitNum?: CommitId;
 
-  @property({type: Boolean})
-  hasParent?: boolean;
-
   @state() latestPatchNum?: PatchSetNumber;
 
   @property({type: String})
@@ -457,6 +429,8 @@
   // private but used in test
   @state() actionLoadingMessage = '';
 
+  @state() private inProgressActionKeys = new Set<string>();
+
   // _computeAllActions always returns an array
   // private but used in test
   @state() allActionValues: UIActionInfo[] = [];
@@ -545,18 +519,18 @@
 
   private readonly restApiService = getAppContext().restApiService;
 
-  private readonly storage = getAppContext().storageService;
+  private readonly reporting = getAppContext().reportingService;
+
+  private readonly getPluginLoader = resolve(this, pluginLoaderToken);
+
+  private readonly getChangeModel = resolve(this, changeModelToken);
+
+  private readonly getStorage = resolve(this, storageServiceToken);
 
   private readonly getNavigation = resolve(this, navigationToken);
 
   constructor() {
     super();
-    this.addEventListener('fullscreen-overlay-opened', () =>
-      this.handleHideBackgroundContent()
-    );
-    this.addEventListener('fullscreen-overlay-closed', () =>
-      this.handleShowBackgroundContent()
-    );
     subscribe(
       this,
       () => this.getChangeModel().latestPatchNum$,
@@ -576,13 +550,17 @@
 
   override connectedCallback() {
     super.connectedCallback();
-    this.jsAPI.addElement(TargetElement.CHANGE_ACTIONS, this);
+    this.getPluginLoader().jsApiService.addElement(
+      TargetElement.CHANGE_ACTIONS,
+      this
+    );
     this.handleLoadingComplete();
   }
 
   static override get styles() {
     return [
       sharedStyles,
+      modalStyles,
       css`
         :host {
           display: flex;
@@ -689,15 +667,16 @@
           <span id="moreMessage">More</span>
         </gr-dropdown>
       </div>
-      <gr-overlay id="overlay" with-backdrop="">
+      <dialog id="actionsModal" tabindex="-1">
         <gr-confirm-rebase-dialog
           id="confirmRebase"
           class="confirmDialog"
-          .changeNumber=${this.change?._number}
-          @confirm=${this.handleRebaseConfirm}
+          @confirm-rebase=${this.handleRebaseConfirm}
           @cancel=${this.handleConfirmDialogCancel}
+          .disableActions=${this.inProgressActionKeys.has(
+            RevisionActions.REBASE
+          )}
           .branch=${this.change?.branch}
-          .hasParent=${this.hasParent}
           .rebaseOnCurrent=${this.revisionRebaseAction
             ? !!this.revisionRebaseAction.enabled
             : null}
@@ -728,7 +707,7 @@
         <gr-confirm-revert-dialog
           id="confirmRevertDialog"
           class="confirmDialog"
-          @confirm=${this.handleRevertDialogConfirm}
+          @confirm-revert=${this.handleRevertDialogConfirm}
           @cancel=${this.handleConfirmDialogCancel}
         ></gr-confirm-revert-dialog>
         <gr-confirm-abandon-dialog
@@ -788,7 +767,7 @@
             Do you really want to delete the edit?
           </div>
         </gr-dialog>
-      </gr-overlay>
+      </dialog>
     `;
   }
 
@@ -822,10 +801,6 @@
   }
 
   override willUpdate(changedProperties: PropertyValues) {
-    if (changedProperties.has('hasParent')) {
-      this.computeChainState();
-    }
-
     if (changedProperties.has('change')) {
       this.reload();
       this.actions = this.change?.actions ?? {};
@@ -903,17 +878,14 @@
   }
 
   private handleLoadingComplete() {
-    getPluginLoader()
+    this.getPluginLoader()
       .awaitPluginsLoaded()
       .then(() => (this.loading = false));
   }
 
   // private but used in test
-  sendShowRevisionActions(detail: {
-    change: ChangeInfo;
-    revisionActions: ActionNameToActionInfoMap;
-  }) {
-    this.jsAPI.handleEvent(PluginEventType.SHOW_REVISION_ACTIONS, detail);
+  sendShowRevisionActions(detail: ShowRevisionActionsDetail) {
+    this.getPluginLoader().jsApiService.handleShowRevisionActions(detail);
   }
 
   addActionButton(type: ActionType, label: string) {
@@ -924,8 +896,7 @@
       enabled: true,
       label,
       __type: type,
-      __key:
-        ADDITIONAL_ACTION_KEY_PREFIX + Math.random().toString(36).substr(2),
+      __key: ADDITIONAL_ACTION_KEY_PREFIX + uuid(),
     };
     this.additionalActions.push(action);
     this.requestUpdate('additionalActions');
@@ -1033,23 +1004,17 @@
   }
 
   private actionsChanged() {
-    this.hidden =
-      Object.keys(this.actions).length === 0 &&
-      Object.keys(this.revisionActions).length === 0 &&
-      this.additionalActions.length === 0;
     this.actionLoadingMessage = '';
     this.disabledMenuActions = [];
 
-    if (Object.keys(this.revisionActions).length !== 0) {
-      if (!this.revisionActions.download) {
-        this.revisionActions = {
-          ...this.revisionActions,
-          download: DOWNLOAD_ACTION,
-        };
-        fire(this, 'revision-actions-changed', {
-          value: this.revisionActions,
-        });
-      }
+    if (!this.revisionActions.download) {
+      this.revisionActions = {
+        ...this.revisionActions,
+        download: DOWNLOAD_ACTION,
+      };
+      fire(this, 'revision-actions-changed', {
+        value: this.revisionActions,
+      });
     }
     if (
       !this.actions.includedIn &&
@@ -1355,7 +1320,7 @@
     if (!this.change) {
       return false;
     }
-    return this.jsAPI.canSubmitChange(
+    return this.getPluginLoader().jsApiService.canSubmitChange(
       this.change,
       this.getRevision(this.change, this.latestPatchNum)
     );
@@ -1374,7 +1339,6 @@
   showRevertDialog() {
     const change = this.change;
     if (!change) return;
-    // The search is still broken if there is a " in the topic.
     const query = `submissionid: "${change.submission_id}"`;
     /* A chromium plugin expects that the modifyRevertMsg hook will only
     be called after the revert button is pressed, hence we populate the
@@ -1388,7 +1352,11 @@
         return;
       }
       assertIsDefined(this.confirmRevertDialog, 'confirmRevertDialog');
-      this.confirmRevertDialog.populate(change, this.commitMessage, changes);
+      this.confirmRevertDialog.populate(
+        change,
+        this.commitMessage,
+        changes.length
+      );
       this.showActionDialog(this.confirmRevertDialog);
     });
   }
@@ -1556,22 +1524,10 @@
     return key === '/' ? key : `/${key}`;
   }
 
-  /**
-   * _hasKnownChainState set to true true if hasParent is defined (can be
-   * either true or false). set to false otherwise.
-   *
-   * private but used in test
-   */
-  computeChainState() {
-    this._hasKnownChainState = true;
-  }
-
-  // private but used in test
-  calculateDisabled(action: UIActionInfo) {
-    if (action.__key === 'rebase') {
-      // Rebase button is only disabled when change has no parent(s).
-      return this._hasKnownChainState === false;
-    }
+  private calculateDisabled(action: UIActionInfo) {
+    // TODO(b/270972983): Remove this special casing once the backend is more
+    // aggressive about setting`enabled:true`.
+    if (action.__key === 'rebase') return false;
     return !action.enabled;
   }
 
@@ -1585,27 +1541,29 @@
     for (const dialogEl of dialogEls) {
       (dialogEl as HTMLElement).hidden = true;
     }
-    assertIsDefined(this.overlay, 'overlay');
-    this.overlay.close();
+    assertIsDefined(this.actionsModal, 'actionsModal');
+    this.actionsModal.close();
   }
 
   // private but used in test
   handleRebaseConfirm(e: CustomEvent<ConfirmRebaseEventDetail>) {
     assertIsDefined(this.confirmRebase, 'confirmRebase');
-    assertIsDefined(this.overlay, 'overlay');
-    const el = this.confirmRebase;
+    assertIsDefined(this.actionsModal, 'actionsModal');
     const payload = {
       base: e.detail.base,
       allow_conflicts: e.detail.allowConflicts,
+      on_behalf_of_uploader: e.detail.onBehalfOfUploader,
     };
-    this.overlay.close();
-    el.hidden = true;
+    const rebaseChain = !!e.detail.rebaseChain;
     this.fireAction(
-      '/rebase',
+      rebaseChain ? '/rebase:chain' : '/rebase',
       assertUIActionInfo(this.revisionActions.rebase),
-      true,
+      rebaseChain ? false : true,
       payload,
-      {allow_conflicts: payload.allow_conflicts}
+      {
+        allow_conflicts: payload.allow_conflicts,
+        on_behalf_of_uploader: payload.on_behalf_of_uploader,
+      }
     );
   }
 
@@ -1621,7 +1579,7 @@
 
   private handleCherryPickRestApi(conflicts: boolean) {
     assertIsDefined(this.confirmCherrypick, 'confirmCherrypick');
-    assertIsDefined(this.overlay, 'overlay');
+    assertIsDefined(this.actionsModal, 'actionsModal');
     const el = this.confirmCherrypick;
     if (!el.branch) {
       fireAlert(this, ERR_BRANCH_EMPTY);
@@ -1631,7 +1589,7 @@
       fireAlert(this, ERR_COMMIT_EMPTY);
       return;
     }
-    this.overlay.close();
+    this.actionsModal.close();
     el.hidden = true;
     this.fireAction(
       '/cherrypick',
@@ -1649,13 +1607,13 @@
   // private but used in test
   handleMoveConfirm() {
     assertIsDefined(this.confirmMove, 'confirmMove');
-    assertIsDefined(this.overlay, 'overlay');
+    assertIsDefined(this.actionsModal, 'actionsModal');
     const el = this.confirmMove;
     if (!el.branch) {
       fireAlert(this, ERR_BRANCH_EMPTY);
       return;
     }
-    this.overlay.close();
+    this.actionsModal.close();
     el.hidden = true;
     this.fireAction('/move', assertUIActionInfo(this.actions.move), false, {
       destination_branch: el.branch,
@@ -1665,11 +1623,11 @@
 
   private handleRevertDialogConfirm(e: CustomEvent<ConfirmRevertEventDetail>) {
     assertIsDefined(this.confirmRevertDialog, 'confirmRevertDialog');
-    assertIsDefined(this.overlay, 'overlay');
+    assertIsDefined(this.actionsModal, 'actionsModal');
     const revertType = e.detail.revertType;
     const message = e.detail.message;
     const el = this.confirmRevertDialog;
-    this.overlay.close();
+    this.actionsModal.close();
     el.hidden = true;
     switch (revertType) {
       case RevertType.REVERT_SINGLE_CHANGE:
@@ -1701,9 +1659,9 @@
   // private but used in test
   handleAbandonDialogConfirm() {
     assertIsDefined(this.confirmAbandonDialog, 'confirmAbandonDialog');
-    assertIsDefined(this.overlay, 'overlay');
+    assertIsDefined(this.actionsModal, 'actionsModal');
     const el = this.confirmAbandonDialog;
-    this.overlay.close();
+    this.actionsModal.close();
     el.hidden = true;
     this.fireAction(
       '/abandon',
@@ -1722,8 +1680,8 @@
   }
 
   private handleCloseCreateFollowUpChange() {
-    assertIsDefined(this.overlay, 'overlay');
-    this.overlay.close();
+    assertIsDefined(this.actionsModal, 'actionsModal');
+    this.actionsModal.close();
   }
 
   private handleDeleteConfirm() {
@@ -1740,7 +1698,7 @@
 
     // We need to make sure that all cached version of a change
     // edit are deleted.
-    this.storage.eraseEditableContentItemsForChangeEdit(this.changeNum);
+    this.getStorage().eraseEditableContentItemsForChangeEdit(this.changeNum);
 
     this.fireAction(
       '/edit',
@@ -1769,7 +1727,9 @@
   }
 
   // private but used in test
-  setLoadingOnButtonWithKey(type: string, key: string) {
+  setLoadingOnButtonWithKey(action: UIActionInfo) {
+    const key = action.__key;
+    this.inProgressActionKeys.add(key);
     this.actionLoadingMessage = this.computeLoadingLabel(key);
     let buttonKey = key;
     // TODO(dhruvsri): clean this up later
@@ -1780,12 +1740,14 @@
     }
 
     // If the action appears in the overflow menu.
-    if (this.getActionOverflowIndex(type, buttonKey) !== -1) {
+    if (this.getActionOverflowIndex(action.__type, buttonKey) !== -1) {
       this.disabledMenuActions.push(buttonKey === '/' ? 'delete' : buttonKey);
       this.requestUpdate('disabledMenuActions');
       return () => {
+        this.inProgressActionKeys.delete(key);
         this.actionLoadingMessage = '';
         this.disabledMenuActions = [];
+        this.requestUpdate();
       };
     }
 
@@ -1799,9 +1761,11 @@
     buttonEl.setAttribute('loading', 'true');
     buttonEl.disabled = true;
     return () => {
+      this.inProgressActionKeys.delete(action.__key);
       this.actionLoadingMessage = '';
       buttonEl.removeAttribute('loading');
       buttonEl.disabled = false;
+      this.requestUpdate();
     };
   }
 
@@ -1813,10 +1777,7 @@
     payload?: RequestPayload,
     toReport?: Object
   ) {
-    const cleanupFn = this.setLoadingOnButtonWithKey(
-      action.__type,
-      action.__key
-    );
+    const cleanupFn = this.setLoadingOnButtonWithKey(action);
     this.reporting.reportInteraction(Interaction.CHANGE_ACTION_FIRED, {
       endpoint,
       toReport,
@@ -1837,8 +1798,9 @@
     this.hideAllDialogs();
     if (dialog.init) dialog.init();
     dialog.hidden = false;
-    assertIsDefined(this.overlay, 'overlay');
-    this.overlay.open().then(() => {
+    assertIsDefined(this.actionsModal, 'actionsModal');
+    this.actionsModal.showModal();
+    whenVisible(dialog, () => {
       if (dialog.resetFocus) {
         dialog.resetFocus();
       }
@@ -1849,7 +1811,9 @@
   // https://issues.gerritcodereview.com/issues/40004936 is resolved.
   // private but used in test
   setReviewOnRevert(newChangeId: NumericChangeId) {
-    const review = this.jsAPI.getReviewPostRevert(this.change);
+    const review = this.getPluginLoader().jsApiService.getReviewPostRevert(
+      this.change
+    );
     if (!review) {
       return Promise.resolve(undefined);
     }
@@ -1861,6 +1825,7 @@
     if (!response) {
       return;
     }
+    // response is guaranteed to be ok (due to semantics of rest-api methods)
     return this.restApiService.getResponseObject(response).then(obj => {
       switch (action.__key) {
         case ChangeActions.REVERT: {
@@ -1894,7 +1859,10 @@
         case ChangeActions.REBASE_EDIT:
         case ChangeActions.REBASE:
         case ChangeActions.SUBMIT:
-          fireReload(this, true);
+          // Hide rebase dialog only if the action succeeds
+          this.actionsModal?.close();
+          this.hideAllDialogs();
+          this.getChangeModel().navigateToChangeResetReload();
           break;
         case ChangeActions.REVERT_SUBMISSION: {
           const revertSubmistionInfo = obj as unknown as RevertSubmissionInfo;
@@ -1906,12 +1874,11 @@
           /* If there is only 1 change then gerrit will automatically
             redirect to that change */
           const topic = revertSubmistionInfo.revert_changes[0].topic;
-          const query = `topic:${topic}`;
-          if (topic) this.getNavigation().setUrl(createSearchUrl({query}));
+          this.getNavigation().setUrl(createSearchUrl({topic}));
           break;
         }
         default:
-          fireReload(this, true);
+          this.getChangeModel().navigateToChangeResetReload();
           break;
       }
     });
@@ -1925,13 +1892,7 @@
   ) {
     if (!response) {
       return Promise.resolve(() => {
-        this.dispatchEvent(
-          new CustomEvent('show-error', {
-            detail: {message: `Could not perform action '${action.__key}'`},
-            composed: true,
-            bubbles: true,
-          })
-        );
+        fireError(this, `Could not perform action '${action.__key}'`);
       });
     }
     if (action && action.__key === RevisionActions.CHERRYPICK) {
@@ -1949,13 +1910,7 @@
       }
     }
     return response.text().then(errText => {
-      this.dispatchEvent(
-        new CustomEvent('show-error', {
-          detail: {message: `Could not perform action: ${errText}`},
-          composed: true,
-          bubbles: true,
-        })
-      );
+      fireError(this, `Could not perform action: ${errText}`);
       if (!errText.startsWith('Change is already up to date')) {
         throw Error(errText);
       }
@@ -1986,19 +1941,13 @@
       .fetchChangeUpdates(change)
       .then(result => {
         if (!result.isLatest) {
-          this.dispatchEvent(
-            new CustomEvent<ShowAlertEventDetail>(EventType.SHOW_ALERT, {
-              detail: {
-                message:
-                  'Cannot set label: a newer patch has been ' +
-                  'uploaded to this change.',
-                action: 'Reload',
-                callback: () => fireReload(this, true),
-              },
-              composed: true,
-              bubbles: true,
-            })
-          );
+          fire(this, 'show-alert', {
+            message:
+              'Cannot set label: a newer patch has been ' +
+              'uploaded to this change.',
+            action: 'Reload',
+            callback: () => this.getChangeModel().navigateToChangeResetReload(),
+          });
 
           // Because this is not a network error, call the cleanup function
           // but not the error handler.
@@ -2060,12 +2009,12 @@
 
   // private but used in test
   handleDownloadTap() {
-    fireEvent(this, 'download-tap');
+    fire(this, 'download-tap', {});
   }
 
   // private but used in test
   handleIncludedInTap() {
-    fireEvent(this, 'included-tap');
+    fire(this, 'included-tap', {});
   }
 
   // private but used in test
@@ -2097,7 +2046,7 @@
 
     // We need to make sure that all cached version of a change
     // edit are deleted.
-    this.storage.eraseEditableContentItemsForChangeEdit(this.changeNum);
+    this.getStorage().eraseEditableContentItemsForChangeEdit(this.changeNum);
 
     this.fireAction(
       '/edit:publish',
@@ -2118,18 +2067,6 @@
     );
   }
 
-  // private but used in test
-  handleHideBackgroundContent() {
-    assertIsDefined(this.mainContent, 'mainContent');
-    this.mainContent.classList.add('overlayOpen');
-  }
-
-  // private but used in test
-  handleShowBackgroundContent() {
-    assertIsDefined(this.mainContent, 'mainContent');
-    this.mainContent.classList.remove('overlayOpen');
-  }
-
   /**
    * Merge sources of change actions into a single ordered array of action
    * values.
@@ -2266,17 +2203,21 @@
   }
 
   private handleEditTap() {
-    this.dispatchEvent(new CustomEvent('edit-tap', {bubbles: false}));
+    fireNoBubbleNoCompose(this, 'edit-tap', {});
   }
 
   private handleStopEditTap() {
-    this.dispatchEvent(new CustomEvent('stop-edit-tap', {bubbles: false}));
+    fireNoBubbleNoCompose(this, 'stop-edit-tap', {});
   }
 }
 
 declare global {
   interface HTMLElementEventMap {
+    'download-tap': CustomEvent<{}>;
+    'edit-tap': CustomEvent<{}>;
+    'included-tap': CustomEvent<{}>;
     'revision-actions-changed': CustomEvent<{value: ActionNameToActionInfoMap}>;
+    'stop-edit-tap': CustomEvent<{}>;
   }
   interface HTMLElementTagNameMap {
     'gr-change-actions': GrChangeActions;
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.ts b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.ts
index 7c04f7d..946191b 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.ts
@@ -6,7 +6,6 @@
 import '../../../test/common-test-setup';
 import './gr-change-actions';
 import {navigationToken} from '../../core/gr-navigation/gr-navigation';
-import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 import {
   createAccountWithId,
   createApproval,
@@ -22,7 +21,6 @@
   query,
   queryAll,
   queryAndAssert,
-  spyStorage,
   stubReporting,
   stubRestApi,
 } from '../../../test/test-utils';
@@ -42,27 +40,33 @@
   TopicName,
 } from '../../../types/common';
 import {ActionType} from '../../../api/change-actions';
-import {SinonFakeTimers} from 'sinon';
+import {SinonFakeTimers, SinonStubbedMember} from 'sinon';
 import {IronAutogrowTextareaElement} from '@polymer/iron-autogrow-textarea';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
 import {UIActionInfo} from '../../shared/gr-js-api-interface/gr-change-actions-js-api';
-import {getAppContext} from '../../../services/app-context';
 import {fixture, html, assert} from '@open-wc/testing';
 import {GrConfirmCherrypickDialog} from '../gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog';
 import {GrDropdown} from '../../shared/gr-dropdown/gr-dropdown';
-import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
 import {GrConfirmSubmitDialog} from '../gr-confirm-submit-dialog/gr-confirm-submit-dialog';
 import {GrConfirmRebaseDialog} from '../gr-confirm-rebase-dialog/gr-confirm-rebase-dialog';
 import {GrConfirmMoveDialog} from '../gr-confirm-move-dialog/gr-confirm-move-dialog';
 import {GrConfirmAbandonDialog} from '../gr-confirm-abandon-dialog/gr-confirm-abandon-dialog';
 import {GrConfirmRevertDialog} from '../gr-confirm-revert-dialog/gr-confirm-revert-dialog';
-import {EventType} from '../../../types/events';
 import {testResolver} from '../../../test/common-test-setup';
+import {storageServiceToken} from '../../../services/storage/gr-storage_impl';
+import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
+import {
+  ChangeModel,
+  changeModelToken,
+} from '../../../models/change/change-model';
 
 // TODO(dhruvsri): remove use of _populateRevertMessage as it's private
 suite('gr-change-actions tests', () => {
   let element: GrChangeActions;
+  let navigateResetStub: SinonStubbedMember<
+    ChangeModel['navigateToChangeResetReload']
+  >;
 
   suite('basic tests', () => {
     setup(async () => {
@@ -119,7 +123,7 @@
       });
 
       sinon
-        .stub(getPluginLoader(), 'awaitPluginsLoaded')
+        .stub(testResolver(pluginLoaderToken), 'awaitPluginsLoaded')
         .returns(Promise.resolve());
 
       element = await fixture<GrChangeActions>(html`
@@ -142,6 +146,10 @@
         _account_id: 123 as AccountId,
       };
       stubRestApi('getRepoBranches').returns(Promise.resolve([]));
+      navigateResetStub = sinon.stub(
+        testResolver(changeModelToken),
+        'navigateToChangeResetReload'
+      );
 
       await element.updateComplete;
       await element.reload();
@@ -180,14 +188,13 @@
                 title="Rebase onto tip of branch or parent change"
               >
                 <gr-button
-                  aria-disabled="true"
+                  aria-disabled="false"
                   class="rebase"
                   data-action-key="rebase"
                   data-label="Rebase"
-                  disabled=""
                   link=""
                   role="button"
-                  tabindex="-1"
+                  tabindex="0"
                 >
                   <gr-icon icon="rebase"> </gr-icon>
                   Rebase
@@ -207,13 +214,7 @@
               <span id="moreMessage"> More </span>
             </gr-dropdown>
           </div>
-          <gr-overlay
-            aria-hidden="true"
-            id="overlay"
-            style="outline: none; display: none;"
-            tabindex="-1"
-            with-backdrop=""
-          >
+          <dialog id="actionsModal" tabindex="-1">
             <gr-confirm-rebase-dialog class="confirmDialog" id="confirmRebase">
             </gr-confirm-rebase-dialog>
             <gr-confirm-cherrypick-dialog
@@ -279,7 +280,7 @@
                 Do you really want to delete the edit?
               </div>
             </gr-dialog>
-          </gr-overlay>
+          </dialog>
         `
       );
     });
@@ -479,9 +480,6 @@
       stubRestApi('getFromProjectLookup').returns(
         Promise.resolve('test' as RepoName)
       );
-      sinon
-        .stub(queryAndAssert<GrOverlay>(element, '#overlay'), 'open')
-        .returns(Promise.resolve());
       element.change = {
         ...createChangeViewChange(),
         revisions: {
@@ -518,9 +516,6 @@
       stubRestApi('getFromProjectLookup').returns(
         Promise.resolve('test' as RepoName)
       );
-      sinon
-        .stub(queryAndAssert<GrOverlay>(element, '#overlay'), 'open')
-        .returns(Promise.resolve());
       element.change = {
         ...createChangeViewChange(),
         revisions: {
@@ -585,34 +580,6 @@
       assert.equal(fireActionStub.callCount, 0);
     });
 
-    test('chain state', async () => {
-      assert.equal(element._hasKnownChainState, false);
-      element.hasParent = true;
-      await element.updateComplete;
-      assert.equal(element._hasKnownChainState, true);
-    });
-
-    test('calculateDisabled', () => {
-      const action = {
-        __key: 'rebase',
-        enabled: true,
-        __type: ActionType.CHANGE,
-        label: 'l',
-      };
-      element._hasKnownChainState = false;
-      assert.equal(element.calculateDisabled(action), true);
-
-      action.__key = 'delete';
-      assert.equal(element.calculateDisabled(action), false);
-
-      action.__key = 'rebase';
-      element._hasKnownChainState = true;
-      assert.equal(element.calculateDisabled(action), false);
-
-      action.enabled = false;
-      assert.equal(element.calculateDisabled(action), false);
-    });
-
     test('rebase change', async () => {
       const fireActionStub = sinon.stub(element, 'fireAction');
       const fetchChangesStub = sinon
@@ -621,7 +588,6 @@
           'fetchRecentChanges'
         )
         .returns(Promise.resolve([]));
-      element._hasKnownChainState = true;
       await element.updateComplete;
       queryAndAssert<GrButton>(
         element,
@@ -638,25 +604,30 @@
       };
       assert.isTrue(fetchChangesStub.called);
       element.handleRebaseConfirm(
-        new CustomEvent('', {detail: {base: '1234', allowConflicts: false}})
+        new CustomEvent('', {
+          detail: {
+            base: '1234',
+            allowConflicts: false,
+            rebaseChain: false,
+            onBehalfOfUploader: true,
+          },
+        })
       );
       assert.deepEqual(fireActionStub.lastCall.args, [
         '/rebase',
         assertUIActionInfo(rebaseAction),
         true,
-        {base: '1234', allow_conflicts: false},
-        {allow_conflicts: false},
+        {base: '1234', allow_conflicts: false, on_behalf_of_uploader: true},
+        {allow_conflicts: false, on_behalf_of_uploader: true},
       ]);
     });
 
     test('rebase change fires reload event', async () => {
-      const eventStub = sinon.stub(element, 'dispatchEvent');
       await element.handleResponse(
         {__key: 'rebase', __type: ActionType.CHANGE, label: 'l'},
         new Response()
       );
-      assert.isTrue(eventStub.called);
-      assert.equal(eventStub.lastCall.args[0].type, 'reload');
+      assert.isTrue(navigateResetStub.called);
     });
 
     test("rebase dialog gets recent changes each time it's opened", async () => {
@@ -666,7 +637,6 @@
           'fetchRecentChanges'
         )
         .returns(Promise.resolve([]));
-      element._hasKnownChainState = true;
       await element.updateComplete;
       const rebaseButton = queryAndAssert<GrButton>(
         element,
@@ -691,7 +661,6 @@
     });
 
     test('two dialogs are not shown at the same time', async () => {
-      element._hasKnownChainState = true;
       await element.updateComplete;
       queryAndAssert<GrButton>(
         element,
@@ -713,42 +682,15 @@
       );
     });
 
-    test('fullscreen-overlay-opened hides content', () => {
-      const spy = sinon.spy(element, 'handleHideBackgroundContent');
-      queryAndAssert<GrOverlay>(element, '#overlay').dispatchEvent(
-        new CustomEvent('fullscreen-overlay-opened', {
-          composed: true,
-          bubbles: true,
-        })
-      );
-      assert.isTrue(spy.called);
-      assert.isTrue(
-        queryAndAssert<Element>(element, '#mainContent').classList.contains(
-          'overlayOpen'
-        )
-      );
-    });
-
-    test('fullscreen-overlay-closed shows content', () => {
-      const spy = sinon.spy(element, 'handleShowBackgroundContent');
-      queryAndAssert<GrOverlay>(element, '#overlay').dispatchEvent(
-        new CustomEvent('fullscreen-overlay-closed', {
-          composed: true,
-          bubbles: true,
-        })
-      );
-      assert.isTrue(spy.called);
-      assert.isFalse(
-        queryAndAssert<Element>(element, '#mainContent').classList.contains(
-          'overlayOpen'
-        )
-      );
-    });
-
     test('setReviewOnRevert', () => {
       const review = {labels: {Foo: 1, 'Bar-Baz': -2}};
       const changeId = 1234 as NumericChangeId;
-      sinon.stub(element.jsAPI, 'getReviewPostRevert').returns(review);
+      sinon
+        .stub(
+          testResolver(pluginLoaderToken).jsApiService,
+          'getReviewPostRevert'
+        )
+        .returns(review);
       const saveStub = stubRestApi('saveChangeReview').returns(
         Promise.resolve(new Response())
       );
@@ -812,7 +754,7 @@
         element.editPatchsetLoaded = true;
         await element.updateComplete;
 
-        const storage = getAppContext().storageService;
+        const storage = testResolver(storageServiceToken);
         storage.setEditableContentItem(
           'c42_ps2_index.php',
           '<?php\necho 42_ps_2'
@@ -835,7 +777,8 @@
         assert.isOk(storage.getEditableContentItem('c42_ps2_index.php')!);
         assert.isNotOk(storage.getEditableContentItem('c50_psedit_index.php')!);
 
-        const eraseEditableContentItemsForChangeEditSpy = spyStorage(
+        const eraseEditableContentItemsForChangeEditSpy = sinon.spy(
+          storage,
           'eraseEditableContentItemsForChangeEdit'
         );
         sinon.stub(element, 'fireAction');
@@ -1308,18 +1251,28 @@
       await keyTapped;
     });
 
-    test('setLoadingOnButtonWithKey top-level', () => {
+    test('setLoadingOnButtonWithKey top-level', async () => {
       const key = 'rebase';
-      const type = 'revision';
-      const cleanup = element.setLoadingOnButtonWithKey(type, key);
+      const type = ActionType.REVISION;
+      const cleanup = element.setLoadingOnButtonWithKey({
+        __type: type,
+        __key: key,
+        label: 'label',
+      });
       assert.equal(element.actionLoadingMessage, 'Rebasing...');
 
       const button = queryAndAssert<GrButton>(
         element,
         '[data-action-key="' + key + '"]'
       );
+      const dialog = queryAndAssert<GrConfirmRebaseDialog>(
+        element,
+        'gr-confirm-rebase-dialog'
+      );
       assert.isTrue(button.hasAttribute('loading'));
       assert.isTrue(button.disabled);
+      await dialog.updateComplete;
+      assert.isTrue(dialog.disableActions);
 
       assert.isOk(cleanup);
       assert.isFunction(cleanup);
@@ -1328,12 +1281,18 @@
       assert.isFalse(button.hasAttribute('loading'));
       assert.isFalse(button.disabled);
       assert.isNotOk(element.actionLoadingMessage);
+      await dialog.updateComplete;
+      assert.isFalse(dialog.disableActions);
     });
 
     test('setLoadingOnButtonWithKey overflow menu', () => {
       const key = 'cherrypick';
-      const type = 'revision';
-      const cleanup = element.setLoadingOnButtonWithKey(type, key);
+      const type = ActionType.REVISION;
+      const cleanup = element.setLoadingOnButtonWithKey({
+        __type: type,
+        __key: key,
+        label: 'label',
+      });
       assert.equal(element.actionLoadingMessage, 'Cherry-picking...');
       assert.include(element.disabledMenuActions, 'cherrypick');
       assert.isFunction(cleanup);
@@ -1461,6 +1420,39 @@
         await element.reload();
       });
 
+      test('revert change payload', async () => {
+        await element.updateComplete;
+        queryAndAssert<GrButton>(
+          element,
+          'gr-button[data-action-key="revert"]'
+        ).click();
+        const revertAction = {
+          __key: 'revert',
+          __type: 'change',
+          __primary: false,
+          method: HttpMethod.POST,
+          label: 'Revert',
+          title: 'Revert the change',
+          enabled: true,
+        };
+        queryAndAssert(element, 'gr-confirm-revert-dialog').dispatchEvent(
+          new CustomEvent('confirm-revert', {
+            detail: {
+              message: 'foo message',
+              revertType: 1,
+            },
+          })
+        );
+        assert.deepEqual(fireActionStub.lastCall.args, [
+          '/revert',
+          assertUIActionInfo(revertAction),
+          false,
+          {
+            message: 'foo message',
+          },
+        ]);
+      });
+
       test('revert change with plugin hook', async () => {
         const newRevertMsg = 'Modified revert msg';
         sinon
@@ -1588,13 +1580,8 @@
             'Revert submission 199 0' +
             '\n\n' +
             'Reason for revert: <INSERT REASONING HERE>' +
-            '\n' +
-            'Reverted Changes:' +
-            '\n' +
-            '1234567890:random' +
-            '\n' +
-            '23456:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa...' +
-            '\n';
+            '\n\n' +
+            'Reverted changes: /q/submissionid:199+0\n';
           assert.equal(confirmRevertDialog.message, expectedMsg);
           const radioInputs = queryAll<HTMLInputElement>(
             confirmRevertDialog,
@@ -1654,13 +1641,8 @@
             'Revert submission 199 0' +
             '\n\n' +
             'Reason for revert: <INSERT REASONING HERE>' +
-            '\n' +
-            'Reverted Changes:' +
-            '\n' +
-            '1234567890:random' +
-            '\n' +
-            '23456:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa...' +
-            '\n';
+            '\n\n' +
+            'Reverted changes: /q/submissionid:199+0\n';
           const singleChangeMsg =
             'Revert "random commit message"\n\nThis reverts ' +
             'commit 2000.\n\nReason' +
@@ -2438,7 +2420,7 @@
         onShowError = sinon.stub();
         element.addEventListener('show-error', onShowError);
         onShowAlert = sinon.stub();
-        element.addEventListener(EventType.SHOW_ALERT, onShowAlert);
+        element.addEventListener('show-alert', onShowAlert);
       });
 
       suite('happy path', () => {
@@ -2533,7 +2515,7 @@
               new Response()
             );
             assert.isTrue(setUrlStub.called);
-            assert.equal(setUrlStub.lastCall.args[0], '/q/topic:T');
+            assert.equal(setUrlStub.lastCall.args[0], '/q/topic:"T"');
           });
         });
 
@@ -2572,7 +2554,7 @@
             );
             assert.isFalse(showActionDialogStub.called);
             assert.isTrue(setUrlStub.called);
-            assert.equal(setUrlStub.lastCall.args[0], '/q/topic:T');
+            assert.equal(setUrlStub.lastCall.args[0], '/q/topic:"T"');
           });
         });
 
@@ -2684,7 +2666,7 @@
       stubRestApi('send').returns(Promise.reject(new Error('error')));
 
       sinon
-        .stub(getPluginLoader(), 'awaitPluginsLoaded')
+        .stub(testResolver(pluginLoaderToken), 'awaitPluginsLoaded')
         .returns(Promise.resolve());
 
       element = await fixture<GrChangeActions>(html`
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 228e7ce..b7851a6 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
@@ -9,7 +9,6 @@
 import '../../../styles/gr-change-view-integration-shared-styles';
 import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
 import '../../plugins/gr-endpoint-param/gr-endpoint-param';
-import '../../plugins/gr-external-style/gr-external-style';
 import '../../shared/gr-account-chip/gr-account-chip';
 import '../../shared/gr-date-formatter/gr-date-formatter';
 import '../../shared/gr-editable-label/gr-editable-label';
@@ -17,6 +16,7 @@
 import '../../shared/gr-limited-text/gr-limited-text';
 import '../../shared/gr-linked-chip/gr-linked-chip';
 import '../../shared/gr-tooltip-content/gr-tooltip-content';
+import '../../shared/gr-weblink/gr-weblink';
 import '../gr-submit-requirements/gr-submit-requirements';
 import '../gr-commit-info/gr-commit-info';
 import '../gr-reviewer-list/gr-reviewer-list';
@@ -48,6 +48,7 @@
   RepoName,
   RevisionInfo,
   ServerInfo,
+  WebLinkInfo,
 } from '../../../types/common';
 import {assertIsDefined, assertNever, unique} from '../../../utils/common-util';
 import {GrEditableLabel} from '../../shared/gr-editable-label/gr-editable-label';
@@ -58,10 +59,10 @@
   isSectionSet,
   DisplayRules,
 } from '../../../utils/change-metadata-util';
-import {fireAlert, fireEvent, fireReload} from '../../../utils/event-util';
+import {fireAlert, fire, fireReload} from '../../../utils/event-util';
 import {
   EditRevisionInfo,
-  notUndefined,
+  isDefined,
   ParsedChangeInfo,
 } from '../../../types/types';
 import {
@@ -77,10 +78,10 @@
 import {fontStyles} from '../../../styles/gr-font-styles';
 import {changeMetadataStyles} from '../../../styles/gr-change-metadata-shared-styles';
 import {when} from 'lit/directives/when.js';
-import {ifDefined} from 'lit/directives/if-defined.js';
 import {createSearchUrl} from '../../../models/views/search';
 import {createChangeUrl} from '../../../models/views/change';
-import {GeneratedWebLink, getChangeWeblinks} from '../../../utils/weblink-util';
+import {getChangeWeblinks} from '../../../utils/weblink-util';
+import {throwingErrorCallback} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
 
 const HASHTAG_ADD_MESSAGE = 'Add Hashtag';
 
@@ -125,6 +126,7 @@
 
   @property({type: Object}) revision?: RevisionInfo | EditRevisionInfo;
 
+  // TODO: Just use `revision.commit` instead.
   @property({type: Object}) commitInfo?: CommitInfoWithRequiredCommit;
 
   @property({type: Object}) serverConfig?: ServerInfo;
@@ -182,7 +184,7 @@
       gr-editable-label {
         max-width: 9em;
       }
-      .webLink {
+      gr-weblink {
         display: block;
       }
       gr-account-chip[disabled],
@@ -279,7 +281,7 @@
       ${this.renderNonOwner(ChangeRole.AUTHOR)}
       ${this.renderNonOwner(ChangeRole.COMMITTER)} ${this.renderReviewers()}
       ${this.renderCCs()} ${this.renderProjectBranch()} ${this.renderParent()}
-      ${this.renderMergedAs()} ${this.renderShowReverCreatedAs()}
+      ${this.renderMergedAs()} ${this.renderShowRevertCreatedAs()}
       ${this.renderTopic()} ${this.renderCherryPickOf()}
       ${this.renderStrategy()} ${this.renderHashTags()}
       ${this.renderSubmitRequirements()} ${this.renderWeblinks()}
@@ -462,7 +464,14 @@
       this.computeShowRepoBranchTogether(),
       () =>
         html`<section class=${this.computeDisplayState(Metadata.REPO_BRANCH)}>
-          <span class="title">Repo | Branch</span>
+          <span class="title">
+            <gr-tooltip-content
+              has-tooltip
+              title="Repository and branch that the change will be merged into if submitted."
+            >
+              Repo | Branch
+            </gr-tooltip-content>
+          </span>
           <span class="value">
             <a href=${this.computeProjectUrl(change.project)}
               >${change.project}</a
@@ -474,10 +483,17 @@
           </span>
         </section>`,
 
-      () => html` <section
+      () => html`<section
           class=${this.computeDisplayState(Metadata.REPO_BRANCH)}
         >
-          <span class="title">Repo</span>
+          <span class="title">
+            <gr-tooltip-content
+              has-tooltip
+              title="Repository that the change will be merged into if submitted."
+            >
+              Repo
+            </gr-tooltip-content>
+          </span>
           <span class="value">
             <a href=${this.computeProjectUrl(change.project)}>
               <gr-limited-text
@@ -488,7 +504,14 @@
           </span>
         </section>
         <section class=${this.computeDisplayState(Metadata.REPO_BRANCH)}>
-          <span class="title">Branch</span>
+          <span class="title">
+            <gr-tooltip-content
+              has-tooltip
+              title="Branch that the change will be merged into if submitted."
+            >
+              Branch
+            </gr-tooltip-content>
+          </span>
           <span class="value">
             <a href=${this.computeBranchUrl(change.project, change.branch)}>
               <gr-limited-text
@@ -540,7 +563,7 @@
     </section>`;
   }
 
-  private renderShowReverCreatedAs() {
+  private renderShowRevertCreatedAs() {
     if (!this.showRevertCreatedAs()) return nothing;
 
     return html`<section
@@ -682,16 +705,7 @@
     return html`<section id="webLinks">
       <span class="title">Links</span>
       <span class="value">
-        ${webLinks.map(
-          link => html`<a
-            href=${ifDefined(link.url)}
-            class="webLink"
-            rel="noopener"
-            target="_blank"
-          >
-            ${link.name}
-          </a>`
-        )}
+        ${webLinks.map(info => html`<gr-weblink .info=${info}></gr-weblink>`)}
       </span>
     </section>`;
   }
@@ -720,7 +734,7 @@
   }
 
   // private but used in test
-  computeWebLinks(): GeneratedWebLink[] {
+  computeWebLinks(): WebLinkInfo[] {
     return getChangeWeblinks(this.commitInfo?.web_links, this.serverConfig);
   }
 
@@ -748,7 +762,7 @@
     } finally {
       this.settingTopic = false;
     }
-    fireEvent(this, 'hide-alert');
+    fire(this, 'hide-alert', {});
     fireReload(this);
   }
 
@@ -781,7 +795,7 @@
     await this.restApiService.setChangeHashtag(this.change._number, {
       add: [newHashtag as Hashtag],
     });
-    fireEvent(this, 'hide-alert');
+    fire(this, 'hide-alert', {});
     fireReload(this);
   }
 
@@ -891,14 +905,14 @@
 
   private computeProjectUrl(project?: RepoName) {
     if (!project) return '';
-    return createSearchUrl({project});
+    return createSearchUrl({repo: project});
   }
 
   private computeBranchUrl(project?: RepoName, branch?: BranchName) {
     if (!project || !branch || !this.change || !this.change.status) return '';
     return createSearchUrl({
       branch,
-      project,
+      repo: project,
       statuses:
         this.change.status === ChangeStatus.NEW
           ? ['open']
@@ -916,7 +930,7 @@
     }
     return createChangeUrl({
       changeNum: change,
-      project,
+      repo: project,
       usp: 'metadata',
       patchNum: patchset,
     });
@@ -926,7 +940,7 @@
     return createSearchUrl({hashtag, statuses: ['open', 'merged']});
   }
 
-  private async handleTopicRemoved(e: CustomEvent) {
+  private async handleTopicRemoved(e: Event) {
     assertIsDefined(this.change, 'change');
     const target = e.composedPath()[0] as GrLinkedChip;
     target.disabled = true;
@@ -936,12 +950,12 @@
     } finally {
       target.disabled = false;
     }
-    fireEvent(this, 'hide-alert');
+    fire(this, 'hide-alert', {});
     fireReload(this);
   }
 
   // private but used in test
-  async handleHashtagRemoved(e: CustomEvent) {
+  async handleHashtagRemoved(e: Event) {
     e.preventDefault();
     assertIsDefined(this.change, 'change');
     const target = e.target as GrLinkedChip;
@@ -954,7 +968,7 @@
     } finally {
       target.disabled = false;
     }
-    fireEvent(this, 'hide-alert');
+    fire(this, 'hide-alert', {});
     fireReload(this);
   }
 
@@ -1120,11 +1134,11 @@
     input: string
   ): Promise<AutocompleteSuggestion[]> {
     return this.restApiService
-      .getChangesWithSimilarTopic(input)
+      .getChangesWithSimilarTopic(input, throwingErrorCallback)
       .then(response =>
         (response ?? [])
           .map(change => change.topic)
-          .filter(notUndefined)
+          .filter(isDefined)
           .filter(unique)
           .map(topic => {
             return {name: topic, value: topic};
@@ -1136,11 +1150,11 @@
     input: string
   ): Promise<AutocompleteSuggestion[]> {
     return this.restApiService
-      .getChangesWithSimilarHashtag(input)
+      .getChangesWithSimilarHashtag(input, throwingErrorCallback)
       .then(response =>
         (response ?? [])
           .flatMap(change => change.hashtags ?? [])
-          .filter(notUndefined)
+          .filter(isDefined)
           .filter(unique)
           .map(hashtag => {
             return {name: hashtag, value: hashtag};
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 038c34a..c46fe24 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
@@ -5,7 +5,7 @@
  */
 import '../../../test/common-test-setup';
 import './gr-change-metadata';
-import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
+
 import {ChangeRole, GrChangeMetadata} from './gr-change-metadata';
 import {
   createServerInfo,
@@ -46,7 +46,6 @@
 import {GrEndpointDecorator} from '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
 import {
   queryAndAssert,
-  resetPlugins,
   stubRestApi,
   waitUntilCalled,
 } from '../../../test/test-utils';
@@ -55,7 +54,8 @@
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {nothing} from 'lit';
 import {fixture, html, assert} from '@open-wc/testing';
-import {EventType} from '../../../types/events';
+import {testResolver} from '../../../test/common-test-setup';
+import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 
 suite('gr-change-metadata tests', () => {
   let element: GrChangeMetadata;
@@ -164,7 +164,12 @@
       </section>
       <section>
           <span class="title">
-            Repo | Branch
+            <gr-tooltip-content
+              has-tooltip=""
+              title="Repository and branch that the change will be merged into if submitted."
+            >
+              Repo | Branch
+            </gr-tooltip-content>
           </span>
           <span class="value">
             <a href="/q/project:test-project">
@@ -865,7 +870,7 @@
         Promise.resolve(newTopic)
       );
       const alertStub = sinon.stub();
-      element.addEventListener(EventType.SHOW_ALERT, alertStub);
+      element.addEventListener('show-alert', alertStub);
 
       element.handleTopicChanged(new CustomEvent('test', {detail: newTopic}));
 
@@ -886,7 +891,7 @@
         Promise.resolve(newTopic)
       );
       const alertStub = sinon.stub();
-      element.addEventListener(EventType.SHOW_ALERT, alertStub);
+      element.addEventListener('show-alert', alertStub);
       await element.updateComplete;
       const chip = queryAndAssert<GrLinkedChip>(element, 'gr-linked-chip');
       const remove = queryAndAssert<GrButton>(chip, '#remove');
@@ -910,7 +915,7 @@
         Promise.resolve(newHashtag)
       );
       const alertStub = sinon.stub();
-      element.addEventListener(EventType.SHOW_ALERT, alertStub);
+      element.addEventListener('show-alert', alertStub);
       element.handleHashtagChanged(
         new CustomEvent('test', {detail: 'new hashtag'})
       );
@@ -949,17 +954,12 @@
 
   suite('plugin endpoints', () => {
     setup(async () => {
-      resetPlugins();
       element = await fixture(html`<gr-change-metadata></gr-change-metadata>`);
       element.change = createParsedChange();
       element.revision = createRevision();
       await element.updateComplete;
     });
 
-    teardown(() => {
-      resetPlugins();
-    });
-
     test('endpoint params', async () => {
       interface MetadataGrEndpointDecorator extends GrEndpointDecorator {
         plugin: PluginApi;
@@ -978,7 +978,7 @@
       const hookEl = (await plugin!
         .hook('change-metadata-item')
         .getLastAttached()) as MetadataGrEndpointDecorator;
-      getPluginLoader().loadPlugins([]);
+      testResolver(pluginLoaderToken).loadPlugins([]);
       await element.updateComplete;
       assert.strictEqual(hookEl.plugin, plugin!);
       assert.strictEqual(hookEl.change, element.change);
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 73653bb..f6a40a2 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
@@ -4,8 +4,7 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import './gr-checks-chip';
-import './gr-summary-chip';
-import '../../shared/gr-avatar/gr-avatar-stack';
+import '../gr-comments-summary/gr-comments-summary';
 import '../../shared/gr-icon/gr-icon';
 import '../../checks/gr-checks-action';
 import {LitElement, css, html, nothing} from 'lit';
@@ -29,34 +28,21 @@
   isRunningOrScheduled,
   isRunningScheduledOrCompleted,
 } from '../../../models/checks/checks-util';
-import {
-  CommentThread,
-  getFirstComment,
-  getMentionedThreads,
-  hasHumanReply,
-  isResolved,
-  isRobotThread,
-  isUnresolved,
-} from '../../../utils/comment-util';
-import {pluralize} from '../../../utils/string-util';
-import {AccountInfo} from '../../../types/common';
-import {notUndefined} from '../../../types/types';
+import {getMentionedThreads, isUnresolved} from '../../../utils/comment-util';
+import {AccountInfo, CommentThread, DropdownLink} from '../../../types/common';
 import {Tab} from '../../../constants/constants';
-import {ChecksTabState, CommentTabState} from '../../../types/events';
+import {ChecksTabState} from '../../../types/events';
 import {spinnerStyles} from '../../../styles/gr-spinner-styles';
 import {modifierPressed} from '../../../utils/dom-util';
-import {DropdownLink} from '../../shared/gr-dropdown/gr-dropdown';
 import {commentsModelToken} from '../../../models/comments/comments-model';
 import {resolve} from '../../../models/dependency';
 import {checksModelToken} from '../../../models/checks/checks-model';
 import {changeModelToken} from '../../../models/change/change-model';
 import {Interaction} from '../../../constants/reporting';
 import {roleDetails} from '../../../utils/change-util';
-
-import {SummaryChipStyles} from './gr-summary-chip';
 import {when} from 'lit/directives/when.js';
-import {KnownExperimentId} from '../../../services/flags/flags';
 import {combineLatest} from 'rxjs';
+import {userModelToken} from '../../../models/user/user-model';
 
 function handleSpaceOrEnter(e: KeyboardEvent, handler: () => void) {
   if (modifierPressed(e)) return;
@@ -70,11 +56,16 @@
 const DETAILS_QUOTA: Map<RunStatus | Category, number> = new Map();
 DETAILS_QUOTA.set(Category.ERROR, 7);
 DETAILS_QUOTA.set(Category.WARNING, 2);
+DETAILS_QUOTA.set(Category.INFO, 2);
+DETAILS_QUOTA.set(Category.SUCCESS, 2);
 DETAILS_QUOTA.set(RunStatus.RUNNING, 2);
 
 @customElement('gr-change-summary')
 export class GrChangeSummary extends LitElement {
   @state()
+  commentsLoading = true;
+
+  @state()
   commentThreads?: CommentThread[];
 
   @state()
@@ -109,11 +100,9 @@
 
   private readonly showAllChips = new Map<RunStatus | Category, boolean>();
 
-  // private but used in tests
-  readonly getCommentsModel = resolve(this, commentsModelToken);
+  private readonly getCommentsModel = resolve(this, commentsModelToken);
 
-  // private but used in tests
-  readonly userModel = getAppContext().userModel;
+  private readonly getUserModel = resolve(this, userModelToken);
 
   private readonly getChecksModel = resolve(this, checksModelToken);
 
@@ -121,8 +110,6 @@
 
   private readonly reporting = getAppContext().reportingService;
 
-  private readonly flagsService = getAppContext().flagsService;
-
   constructor() {
     super();
     subscribe(
@@ -167,32 +154,35 @@
     );
     subscribe(
       this,
-      () => this.getCommentsModel().threads$,
+      () => this.getCommentsModel().threadsSaved$,
       x => (this.commentThreads = x)
     );
     subscribe(
       this,
-      () => this.userModel.account$,
+      () => this.getUserModel().account$,
       x => (this.selfAccount = x)
     );
-    if (this.flagsService.isEnabled(KnownExperimentId.MENTION_USERS)) {
-      subscribe(
-        this,
-        () =>
-          combineLatest([
-            this.userModel.account$,
-            this.getCommentsModel().threads$,
-          ]),
-        ([selfAccount, threads]) => {
-          if (!selfAccount || !selfAccount.email) return;
-          const unresolvedThreadsMentioningSelf = getMentionedThreads(
-            threads,
-            selfAccount
-          ).filter(isUnresolved);
-          this.mentionCount = unresolvedThreadsMentioningSelf.length;
-        }
-      );
-    }
+    subscribe(
+      this,
+      () => this.getCommentsModel().commentsLoading$,
+      x => (this.commentsLoading = x)
+    );
+    subscribe(
+      this,
+      () =>
+        combineLatest([
+          this.getUserModel().account$,
+          this.getCommentsModel().threadsSaved$,
+        ]),
+      ([selfAccount, threads]) => {
+        if (!selfAccount || !selfAccount.email) return;
+        const unresolvedThreadsMentioningSelf = getMentionedThreads(
+          threads,
+          selfAccount
+        ).filter(isUnresolved);
+        this.mentionCount = unresolvedThreadsMentioningSelf.length;
+      }
+    );
   }
 
   static override get styles() {
@@ -270,14 +260,6 @@
           padding-bottom: var(--spacing-s);
           line-height: calc(var(--line-height-normal) + var(--spacing-s));
         }
-        gr-avatar-stack {
-          --avatar-size: var(--line-height-small, 16px);
-          --stack-border-color: var(--warning-background);
-        }
-        .unresolvedIcon {
-          font-size: var(--line-height-small);
-          color: var(--warning-foreground);
-        }
         /* The basics of .loadingSpin are defined in shared styles. */
         .loadingSpin {
           width: calc(var(--line-height-normal) - 2px);
@@ -423,8 +405,27 @@
       if (hasResultsOf(run, category)) return true;
       return category === Category.SUCCESS && hasCompletedWithoutResults(run);
     });
+    const hasRunning = this.runs.some(isRunningOrScheduled);
+    const hasWarning = this.runs.some(run =>
+      hasResultsOf(run, Category.WARNING)
+    );
+    const hasError = this.runs.some(run => hasResultsOf(run, Category.ERROR));
     const count = (run: CheckRun) => getResultsOf(run, category);
-    if (category === Category.SUCCESS || category === Category.INFO) {
+
+    // Sometimes INFO and SUCCESS results should not consume much UI space and
+    // not grab any attention, e.g. when there are errors. Then let's
+    // aggressively collapse them into one small chip. But if INFO and SUCCESS
+    // is all we have, then make use of the one line we have and show expanded
+    // chips.
+    if (
+      category === Category.SUCCESS &&
+      (hasRunning || hasError || hasWarning || runs.length > 3)
+    ) {
+      return this.renderChecksChipsCollapsed(runs, category, count);
+    } else if (
+      category === Category.INFO &&
+      (hasRunning || hasError || runs.length > 3)
+    ) {
       return this.renderChecksChipsCollapsed(runs, category, count);
     }
     return this.renderChecksChipsExpanded(runs, category);
@@ -531,29 +532,23 @@
   }
 
   override render() {
-    const commentThreads =
-      this.commentThreads?.filter(t => !isRobotThread(t) || hasHumanReply(t)) ??
-      [];
-    const countResolvedComments = commentThreads.filter(isResolved).length;
-    const unresolvedThreads = commentThreads.filter(isUnresolved);
-    const countUnresolvedComments = unresolvedThreads.length;
-    const unresolvedAuthors = this.getAccounts(unresolvedThreads);
     return html`
       <div>
         <table>
           <tr>
             <td class="key">Comments</td>
             <td class="value">
-              ${this.renderZeroState(
-                countResolvedComments,
-                countUnresolvedComments
+              ${when(
+                this.commentsLoading,
+                () => html`<span class="loadingSpin"></span>`
               )}
-              ${this.renderDraftChip()} ${this.renderMentionChip()}
-              ${this.renderUnresolvedCommentsChip(
-                countUnresolvedComments,
-                unresolvedAuthors
-              )}
-              ${this.renderResolvedCommentsChip(countResolvedComments)}
+              <gr-comments-summary
+                .commentThreads=${this.commentThreads}
+                .draftCount=${this.draftCount}
+                .mentionCount=${this.mentionCount}
+                showCommentCategoryName
+                clickableChips
+              ></gr-comments-summary>
             </td>
           </tr>
           ${this.renderChecksSummary()}
@@ -562,78 +557,6 @@
     `;
   }
 
-  private renderZeroState(
-    countResolvedComments: number,
-    countUnresolvedComments: number
-  ) {
-    if (
-      !!countResolvedComments ||
-      !!this.draftCount ||
-      !!countUnresolvedComments
-    )
-      return nothing;
-    return html`<span class="zeroState"> No comments</span>`;
-  }
-
-  private renderMentionChip() {
-    if (!this.flagsService.isEnabled(KnownExperimentId.MENTION_USERS))
-      return nothing;
-    if (!this.mentionCount) return nothing;
-    return html` <gr-summary-chip
-      class="mentionSummary"
-      styleType=${SummaryChipStyles.WARNING}
-      category=${CommentTabState.MENTIONS}
-      icon="alternate_email"
-    >
-      ${pluralize(this.mentionCount, 'mention')}</gr-summary-chip
-    >`;
-  }
-
-  private renderDraftChip() {
-    if (!this.draftCount) return nothing;
-    return html` <gr-summary-chip
-      styleType=${SummaryChipStyles.INFO}
-      category=${CommentTabState.DRAFTS}
-      icon="rate_review"
-      iconFilled
-    >
-      ${pluralize(this.draftCount, 'draft')}</gr-summary-chip
-    >`;
-  }
-
-  private renderUnresolvedCommentsChip(
-    countUnresolvedComments: number,
-    unresolvedAuthors: AccountInfo[]
-  ) {
-    if (!countUnresolvedComments) return nothing;
-    return html` <gr-summary-chip
-      styleType=${SummaryChipStyles.WARNING}
-      category=${CommentTabState.UNRESOLVED}
-      ?hidden=${!countUnresolvedComments}
-    >
-      <gr-avatar-stack .accounts=${unresolvedAuthors} imageSize="32">
-        <gr-icon
-          slot="fallback"
-          icon="chat_bubble"
-          filled
-          class="unresolvedIcon"
-        >
-        </gr-icon>
-      </gr-avatar-stack>
-      ${countUnresolvedComments} unresolved</gr-summary-chip
-    >`;
-  }
-
-  private renderResolvedCommentsChip(countResolvedComments: number) {
-    if (!countResolvedComments) return nothing;
-    return html` <gr-summary-chip
-      styleType=${SummaryChipStyles.CHECK}
-      category=${CommentTabState.SHOW_ALL}
-      icon="mark_chat_read"
-      >${countResolvedComments} resolved</gr-summary-chip
-    >`;
-  }
-
   private renderChecksSummary() {
     const hasNonRunningChip = this.runs.some(
       run => hasCompletedWithoutResults(run) || hasResults(run)
@@ -665,13 +588,6 @@
       </td>
     </tr>`;
   }
-
-  getAccounts(commentThreads: CommentThread[]): AccountInfo[] {
-    return commentThreads
-      .map(getFirstComment)
-      .map(comment => comment?.author ?? this.selfAccount)
-      .filter(notUndefined);
-  }
 }
 
 declare global {
diff --git a/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary_test.ts b/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary_test.ts
index 9584637..c3d9774 100644
--- a/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary_test.ts
@@ -6,21 +6,36 @@
 import '../../../test/common-test-setup';
 import {fixture, html, assert} from '@open-wc/testing';
 import {GrChangeSummary} from './gr-change-summary';
-import {queryAndAssert} from '../../../utils/common-util';
+import {queryAll, queryAndAssert} from '../../../utils/common-util';
 import {fakeRun0} from '../../../models/checks/checks-fakes';
 import {
   createAccountWithEmail,
+  createCheckResult,
   createComment,
   createCommentThread,
   createDraft,
+  createRun,
 } from '../../../test/test-data-generators';
-import {stubFlags} from '../../../test/test-utils';
 import {Timestamp} from '../../../api/rest-api';
+import {testResolver} from '../../../test/common-test-setup';
+import {UserModel, userModelToken} from '../../../models/user/user-model';
+import {
+  CommentsModel,
+  commentsModelToken,
+} from '../../../models/comments/comments-model';
+import {GrChecksChip} from './gr-checks-chip';
+import {CheckRun} from '../../../models/checks/checks-model';
+import {Category, RunStatus} from '../../../api/checks';
 
 suite('gr-change-summary test', () => {
   let element: GrChangeSummary;
+  let commentsModel: CommentsModel;
+  let userModel: UserModel;
+
   setup(async () => {
     element = await fixture(html`<gr-change-summary></gr-change-summary>`);
+    commentsModel = testResolver(commentsModelToken);
+    userModel = testResolver(userModelToken);
   });
 
   test('is defined', () => {
@@ -29,12 +44,13 @@
   });
 
   test('renders', async () => {
-    element.getCommentsModel().setState({
+    commentsModel.setState({
       drafts: {
         a: [createDraft(), createDraft(), createDraft()],
       },
       discardedDrafts: [],
     });
+    element.commentsLoading = false;
     element.commentThreads = [
       createCommentThread([createComment()]),
       createCommentThread([{...createComment(), unresolved: true}]),
@@ -48,32 +64,10 @@
             <tr>
               <td class="key">Comments</td>
               <td class="value">
-                <gr-summary-chip
-                  category="drafts"
-                  icon="rate_review"
-                  iconFilled
-                  styletype="info"
-                >
-                  3 drafts
-                </gr-summary-chip>
-                <gr-summary-chip category="unresolved" styletype="warning">
-                  <gr-avatar-stack imageSize="32">
-                    <gr-icon
-                      class="unresolvedIcon"
-                      filled
-                      icon="chat_bubble"
-                      slot="fallback"
-                    ></gr-icon>
-                  </gr-avatar-stack>
-                  1 unresolved
-                </gr-summary-chip>
-                <gr-summary-chip
-                  category="show all"
-                  icon="mark_chat_read"
-                  styletype="check"
-                >
-                  1 resolved
-                </gr-summary-chip>
+                <gr-comments-summary
+                  clickablechips=""
+                  showcommentcategoryname=""
+                ></gr-comments-summary>
               </td>
             </tr>
           </tbody>
@@ -106,13 +100,75 @@
     );
   });
 
-  test('renders mentions summary', async () => {
-    stubFlags('isEnabled').returns(true);
-    // recreate element so that flag protected subscriptions are added
-    element = await fixture(html`<gr-change-summary></gr-change-summary>`);
-    await element.updateComplete;
+  suite('checks summary', () => {
+    const checkSummary = async (runs: CheckRun[], texts: string[]) => {
+      element.runs = runs;
+      element.showChecksSummary = true;
+      await element.updateComplete;
+      const chips = queryAll<GrChecksChip>(element, 'gr-checks-chip') ?? [];
+      assert.deepEqual(
+        [...chips].map(c => `${c.statusOrCategory} ${c.text}`),
+        texts
+      );
+    };
 
-    element.getCommentsModel().setState({
+    test('single success', async () => {
+      checkSummary([createRun()], ['SUCCESS test-name']);
+    });
+
+    test('single running', async () => {
+      checkSummary(
+        [createRun({status: RunStatus.RUNNING})],
+        ['RUNNING test-name']
+      );
+    });
+
+    test('single info', async () => {
+      checkSummary(
+        [
+          createRun({
+            status: RunStatus.COMPLETED,
+            results: [createCheckResult({category: Category.INFO})],
+          }),
+        ],
+        ['INFO test-name']
+      );
+    });
+
+    test('single of each collapses INFO and SUCCESS', async () => {
+      checkSummary(
+        [
+          createRun({status: RunStatus.RUNNING}),
+          createRun({
+            status: RunStatus.COMPLETED,
+            results: [createCheckResult({category: Category.SUCCESS})],
+          }),
+          createRun({
+            status: RunStatus.COMPLETED,
+            results: [createCheckResult({category: Category.INFO})],
+          }),
+          createRun({
+            status: RunStatus.COMPLETED,
+            results: [createCheckResult({category: Category.WARNING})],
+          }),
+          createRun({
+            status: RunStatus.COMPLETED,
+            results: [createCheckResult({category: Category.ERROR})],
+          }),
+        ],
+        [
+          'ERROR test-name',
+          'WARNING test-name',
+          'INFO 1',
+          'SUCCESS 1',
+          'RUNNING test-name',
+        ]
+      );
+    });
+  });
+
+  test('renders mentions summary', async () => {
+    commentsModel.setState({
       drafts: {
         a: [
           {
@@ -139,12 +195,13 @@
       },
       discardedDrafts: [],
     });
-    element.userModel.setAccount({
+    userModel.setAccount({
       ...createAccountWithEmail('abc@def.com'),
       registered_on: '2015-03-12 18:32:08.000000000' as Timestamp,
     });
     await element.updateComplete;
-    const mentionSummary = queryAndAssert(element, '.mentionSummary');
+    const commentsSummary = queryAndAssert(element, 'gr-comments-summary');
+    const mentionSummary = queryAndAssert(commentsSummary, '.mentionSummary');
     // Only count occurrences in unresolved threads
     // Resolved threads are ignored hence mention chip count is 2
     assert.dom.equal(
diff --git a/polygerrit-ui/app/elements/change/gr-change-summary/gr-summary-chip.ts b/polygerrit-ui/app/elements/change/gr-change-summary/gr-summary-chip.ts
index 34423b7..5588f40 100644
--- a/polygerrit-ui/app/elements/change/gr-change-summary/gr-summary-chip.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-summary/gr-summary-chip.ts
@@ -34,6 +34,9 @@
   @property()
   category?: CommentTabState;
 
+  @property({type: Boolean})
+  clickable?: Boolean;
+
   private readonly reporting = getAppContext().reportingService;
 
   static override get styles() {
@@ -63,7 +66,7 @@
           border-color: var(--info-foreground);
           background: var(--info-background);
         }
-        .summaryChip.info:hover {
+        button.summaryChip.info:hover {
           background: var(--info-background-hover);
           box-shadow: var(--elevation-level-1);
         }
@@ -77,7 +80,7 @@
           border-color: var(--warning-foreground);
           background: var(--warning-background);
         }
-        .summaryChip.warning:hover {
+        button.summaryChip.warning:hover {
           background: var(--warning-background-hover);
           box-shadow: var(--elevation-level-1);
         }
@@ -91,7 +94,7 @@
           border-color: var(--gray-foreground);
           background: var(--gray-background);
         }
-        .summaryChip.check:hover {
+        button.summaryChip.check:hover {
           background: var(--gray-background-hover);
           box-shadow: var(--elevation-level-1);
         }
@@ -107,11 +110,19 @@
 
   override render() {
     const chipClass = `summaryChip font-small ${this.styleType}`;
-    return html`<button class=${chipClass} @click=${this.handleClick}>
-      ${this.icon &&
+    if (this.clickable) {
+      return html`<button class=${chipClass} @click=${this.handleClick}>
+        ${this.renderIconAndSlot()}
+      </button>`;
+    } else {
+      return html`<span class=${chipClass}>${this.renderIconAndSlot()}</span>`;
+    }
+  }
+
+  renderIconAndSlot() {
+    return html` ${this.icon &&
       html`<gr-icon ?filled=${this.iconFilled} icon=${this.icon}></gr-icon>`}
-      <slot></slot>
-    </button>`;
+      <slot></slot>`;
   }
 
   private handleClick(e: MouseEvent) {
diff --git a/polygerrit-ui/app/elements/change/gr-change-summary/gr-summary-chip_test.ts b/polygerrit-ui/app/elements/change/gr-change-summary/gr-summary-chip_test.ts
index 9b25591..31f64c9 100644
--- a/polygerrit-ui/app/elements/change/gr-change-summary/gr-summary-chip_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-summary/gr-summary-chip_test.ts
@@ -12,8 +12,9 @@
   let element: GrSummaryChip;
   setup(async () => {
     element = await fixture(html`<gr-summary-chip
-      styleType=${SummaryChipStyles.WARNING}
-      category=${CommentTabState.DRAFTS}
+      .styleType=${SummaryChipStyles.WARNING}
+      .category=${CommentTabState.DRAFTS}
+      clickable
     ></gr-summary-chip>`);
   });
   test('is defined', () => {
@@ -29,4 +30,17 @@
       </button>`
     );
   });
+
+  test('renders as not clickable', async () => {
+    const element = await fixture(html`<gr-summary-chip
+      .styleType=${SummaryChipStyles.CHECK}
+      .category=${CommentTabState.SHOW_ALL}
+    ></gr-summary-chip>`);
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `<span class="check font-small summaryChip">
+        <slot> </slot>
+      </span>`
+    );
+  });
 });
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 64bdab9..fc8bcb9 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
@@ -16,7 +16,6 @@
 import '../../shared/gr-change-status/gr-change-status';
 import '../../shared/gr-editable-content/gr-editable-content';
 import '../../shared/gr-formatted-text/gr-formatted-text';
-import '../../shared/gr-overlay/gr-overlay';
 import '../../shared/gr-tooltip-content/gr-tooltip-content';
 import '../gr-change-actions/gr-change-actions';
 import '../gr-change-summary/gr-change-summary';
@@ -33,92 +32,68 @@
 import '../gr-thread-list/gr-thread-list';
 import '../../checks/gr-checks-tab';
 import {ChangeStarToggleStarDetail} from '../../shared/gr-change-star/gr-change-star';
-import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
 import {GrEditConstants} from '../../edit/gr-edit-constants';
 import {pluralize} from '../../../utils/string-util';
-import {querySelectorAll, whenVisible} from '../../../utils/dom-util';
+import {untilRendered, whenVisible} from '../../../utils/dom-util';
 import {navigationToken} from '../../core/gr-navigation/gr-navigation';
-import {getPluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints';
-import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
-import {RevisionInfo as RevisionInfoClass} from '../../shared/revision-info/revision-info';
-import {
-  ChangeStatus,
-  DefaultBase,
-  Tab,
-  DiffViewMode,
-} from '../../../constants/constants';
+import {ChangeStatus, Tab, DiffViewMode} from '../../../constants/constants';
 import {getAppContext} from '../../../services/app-context';
 import {
   computeAllPatchSets,
   computeLatestPatchNum,
-  findEdit,
-  findEditParentRevision,
   PatchSet,
 } from '../../../utils/patch-set-util';
 import {
-  changeIsAbandoned,
-  changeIsMerged,
-  changeIsOpen,
   changeStatuses,
   isInvolved,
   roleDetails,
 } from '../../../utils/change-util';
-import {EventType as PluginEventType} from '../../../api/plugin';
 import {customElement, property, query, state} from 'lit/decorators.js';
 import {GrApplyFixDialog} from '../../diff/gr-apply-fix-dialog/gr-apply-fix-dialog';
 import {GrFileListHeader} from '../gr-file-list-header/gr-file-list-header';
 import {GrEditableContent} from '../../shared/gr-editable-content/gr-editable-content';
-import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
-import {GrRelatedChangesList} from '../gr-related-changes-list/gr-related-changes-list';
 import {GrChangeStar} from '../../shared/gr-change-star/gr-change-star';
 import {GrChangeActions} from '../gr-change-actions/gr-change-actions';
 import {
   AccountDetailInfo,
   ActionNameToActionInfoMap,
   BasePatchSetNum,
-  ChangeId,
   ChangeInfo,
-  CommitId,
-  CommitInfo,
+  CommentThread,
   ConfigInfo,
   DetailedLabelInfo,
   EDIT,
   LabelNameToInfoMap,
   NumericChangeId,
   PARENT,
-  PatchRange,
   PatchSetNum,
-  PatchSetNumber,
-  PreferencesInfo,
   QuickLabelInfo,
-  RelatedChangeAndCommitInfo,
-  RelatedChangesInfo,
   RevisionInfo,
   RevisionPatchSetNum,
   ServerInfo,
   UrlEncodedCommentId,
+  isRobot,
 } from '../../../types/common';
 import {FocusTarget, GrReplyDialog} from '../gr-reply-dialog/gr-reply-dialog';
 import {GrIncludedInDialog} from '../gr-included-in-dialog/gr-included-in-dialog';
 import {GrDownloadDialog} from '../gr-download-dialog/gr-download-dialog';
 import {GrChangeMetadata} from '../gr-change-metadata/gr-change-metadata';
-import {assertIsDefined, assert, queryAll} from '../../../utils/common-util';
-import {GrEditControls} from '../../edit/gr-edit-controls/gr-edit-controls';
 import {
-  CommentThread,
-  isRobot,
-  isUnresolved,
-  DraftInfo,
-} from '../../../utils/comment-util';
+  assertIsDefined,
+  assert,
+  queryAll,
+  queryAndAssert,
+} from '../../../utils/common-util';
+import {GrEditControls} from '../../edit/gr-edit-controls/gr-edit-controls';
+import {isUnresolved} from '../../../utils/comment-util';
 import {PaperTabsElement} from '@polymer/paper-tabs/paper-tabs';
 import {GrFileList} from '../gr-file-list/gr-file-list';
 import {EditRevisionInfo, ParsedChangeInfo} from '../../../types/types';
 import {
-  CloseFixPreviewEvent,
   EditableContentSaveEvent,
-  EventType,
+  FileActionTapEvent,
   OpenFixPreviewEvent,
-  ShowAlertEventDetail,
+  ShowReplyDialogEvent,
   SwitchTabEvent,
   TabState,
   ValueChangedEvent,
@@ -129,20 +104,17 @@
 import {
   fireAlert,
   fireDialogChange,
-  fireEvent,
+  fire,
   fireReload,
-  fireTitleChange,
 } from '../../../utils/event-util';
-import {GerritView} from '../../../services/router/router-model';
 import {
   debounce,
   DelayedTask,
   throttleWrap,
   until,
+  waitUntil,
 } from '../../../utils/async-util';
-import {Interaction, Timing, Execution} from '../../../constants/reporting';
-import {ChangeStates} from '../../shared/gr-change-status/gr-change-status';
-import {getRevertCreatedChangeIds} from '../../../utils/message-util';
+import {Interaction} from '../../../constants/reporting';
 import {
   getAddedByReason,
   getRemovedByReason,
@@ -158,7 +130,7 @@
 import {resolve} from '../../../models/dependency';
 import {checksModelToken} from '../../../models/checks/checks-model';
 import {changeModelToken} from '../../../models/change/change-model';
-import {css, html, LitElement, nothing, PropertyValues} from 'lit';
+import {css, html, LitElement, nothing} from 'lit';
 import {a11yStyles} from '../../../styles/gr-a11y-styles';
 import {paperStyles} from '../../../styles/gr-paper-styles';
 import {sharedStyles} from '../../../styles/shared-styles';
@@ -168,31 +140,26 @@
 import {FilesExpandedState} from '../gr-file-list-constants';
 import {subscribe} from '../../lit/subscription-controller';
 import {configModelToken} from '../../../models/config/config-model';
-import {filesModelToken} from '../../../models/change/files-model';
 import {prependOrigin} from '../../../utils/url-util';
 import {CopyLink, GrCopyLinks} from '../gr-copy-links/gr-copy-links';
 import {
+  ChangeChildView,
   changeViewModelToken,
   ChangeViewState,
   createChangeUrl,
+  createEditUrl,
 } from '../../../models/views/change';
 import {rootUrl} from '../../../utils/url-util';
-import {createEditUrl} from '../../../models/views/edit';
-
-const CHANGE_ID_ERROR = {
-  MISMATCH: 'mismatch',
-  MISSING: 'missing',
-};
-const CHANGE_ID_REGEX_PATTERN =
-  /^(Change-Id:\s|Link:.*\/id\/)(I[0-9a-f]{8,40})/gm;
+import {userModelToken} from '../../../models/user/user-model';
+import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
+import {modalStyles} from '../../../styles/gr-modal-styles';
+import {relatedChangesModelToken} from '../../../models/change/related-changes-model';
 
 const MIN_LINES_FOR_COMMIT_COLLAPSE = 18;
 
 const REVIEWERS_REGEX = /^(R|CC)=/gm;
 const MIN_CHECK_INTERVAL_SECS = 0;
 
-const REPLY_REFIT_DEBOUNCE_INTERVAL_MS = 500;
-
 const ACCIDENTAL_STARRING_LIMIT_MS = 10 * 1000;
 
 const TRAILING_WHITESPACE_REGEX = /[ \t]+$/gm;
@@ -210,17 +177,9 @@
 // Making the tab names more unique in case a plugin adds one with same name
 const ROBOT_COMMENTS_LIMIT = 10;
 
-export type ChangeViewPatchRange = Partial<PatchRange>;
-
 @customElement('gr-change-view')
 export class GrChangeView extends LitElement {
   /**
-   * Fired when the title of the page should change.
-   *
-   * @event title-change
-   */
-
-  /**
    * Fired if an error occurs when fetching the change data.
    *
    * @event page-error
@@ -240,15 +199,15 @@
 
   @query('#commitMessageEditor') commitMessageEditor?: GrEditableContent;
 
-  @query('#includedInOverlay') includedInOverlay?: GrOverlay;
+  @query('#includedInModal') includedInModal?: HTMLDialogElement;
 
   @query('#includedInDialog') includedInDialog?: GrIncludedInDialog;
 
-  @query('#downloadOverlay') downloadOverlay?: GrOverlay;
+  @query('#downloadModal') downloadModal?: HTMLDialogElement;
 
   @query('#downloadDialog') downloadDialog?: GrDownloadDialog;
 
-  @query('#replyOverlay') replyOverlay?: GrOverlay;
+  @query('#replyModal') replyModal?: HTMLDialogElement;
 
   @query('#replyDialog') replyDialog?: GrReplyDialog;
 
@@ -276,35 +235,18 @@
 
   @query('gr-copy-links') private copyLinksDropdown?: GrCopyLinks;
 
-  private _viewState?: ChangeViewState;
-
-  @property({type: Object})
-  get viewState() {
-    return this._viewState;
-  }
-
-  set viewState(viewState: ChangeViewState | undefined) {
-    if (this._viewState === viewState) return;
-    const oldViewState = this._viewState;
-    this._viewState = viewState;
-    this.viewStateChanged();
-    this.requestUpdate('viewState', oldViewState);
-  }
+  @state()
+  viewState?: ChangeViewState;
 
   @property({type: String})
   backPage?: string;
 
   @state()
-  private hasParent?: boolean;
-
-  // Private but used in tests.
-  @state()
   commentThreads?: CommentThread[];
 
   // Don't use, use serverConfig instead.
   private _serverConfig?: ServerInfo;
 
-  // Private but used in tests.
   @state()
   get serverConfig() {
     return this._serverConfig;
@@ -321,10 +263,6 @@
   @state()
   private account?: AccountDetailInfo;
 
-  // Private but used in tests.
-  @state()
-  prefs?: PreferencesInfo;
-
   canStartReview() {
     return !!(
       this.change &&
@@ -352,50 +290,19 @@
 
   // Private but used in tests.
   @state()
-  commitInfo?: CommitInfo;
-
-  // Private but used in tests.
-  @state()
   changeNum?: NumericChangeId;
 
-  // Private but used in tests.
-  @state()
-  diffDrafts?: {[path: string]: DraftInfo[]} = {};
-
   @state()
   private editingCommitMessage = false;
 
   @state()
-  private latestCommitMessage: string | null = '';
+  private latestCommitMessage = '';
 
-  // Use patchRange getter/setter.
-  private _patchRange?: ChangeViewPatchRange;
+  @state() basePatchNum: BasePatchSetNum = PARENT;
 
-  // Private but used in tests.
-  @state()
-  get patchRange() {
-    return this._patchRange;
-  }
+  @state() patchNum?: RevisionPatchSetNum;
 
-  set patchRange(patchRange: ChangeViewPatchRange | undefined) {
-    if (this._patchRange === patchRange) return;
-    const oldPatchRange = this._patchRange;
-    this._patchRange = patchRange;
-    this.patchNumChanged();
-    this.requestUpdate('patchRange', oldPatchRange);
-  }
-
-  // Private but used in tests.
-  @state()
-  selectedRevision?: RevisionInfo | EditRevisionInfo;
-
-  @state()
-  get changeIdCommitMessageError() {
-    return this.computeChangeIdCommitMessageError(
-      this.latestCommitMessage,
-      this.change
-    );
-  }
+  @state() revision?: RevisionInfo | EditRevisionInfo;
 
   /**
    * <gr-change-actions> populates this via two-way data binding.
@@ -423,30 +330,14 @@
 
   // Private but used in tests.
   @state()
-  initialLoadComplete = false;
-
-  // Private but used in tests.
-  @state()
   replyDisabled = true;
 
-  // Private but used in tests.
-  @state()
-  changeStatuses: ChangeStates[] = [];
-
   @state()
   private updateCheckTimerHandle?: number | null;
 
   // Private but used in tests.
-  getEditMode() {
-    if (!this.patchRange || !this.viewState) {
-      return false;
-    }
-
-    if (this.viewState.edit) {
-      return true;
-    }
-
-    return this.patchRange.patchNum === EDIT;
+  getEditMode(): boolean {
+    return !!this.viewState?.edit || this.patchNum === EDIT;
   }
 
   isSubmitEnabled(): boolean {
@@ -457,9 +348,7 @@
     );
   }
 
-  // Private but used in tests.
-  @state()
-  mergeable: boolean | null = null;
+  @state() mergeable?: boolean;
 
   /**
    * Plugins can provide (multiple) tabs. For each plugin tab we render an
@@ -508,7 +397,7 @@
   private showRobotCommentsButton = false;
 
   @state()
-  private draftCount = 0;
+  draftCount = 0;
 
   private throttledToggleChangeStar?: (e: KeyboardEvent) => void;
 
@@ -526,45 +415,41 @@
   private tabState?: TabState;
 
   @state()
-  private revertedChange?: ChangeInfo;
+  private revertingChange?: ChangeInfo;
 
   // Private but used in tests.
   @state()
   scrollCommentId?: UrlEncodedCommentId;
 
-  /** Just reflects the `opened` prop of the overlay. */
+  /** Reflects the `opened` state of the reply dialog. */
   @state()
-  private replyOverlayOpened = false;
+  replyModalOpened = false;
 
   // Accessed in tests.
   readonly reporting = getAppContext().reportingService;
 
-  readonly jsAPI = getAppContext().jsApiService;
-
   private readonly getChecksModel = resolve(this, checksModelToken);
 
   readonly restApiService = getAppContext().restApiService;
 
-  // Private but used in tests.
-  readonly userModel = getAppContext().userModel;
+  private readonly getPluginLoader = resolve(this, pluginLoaderToken);
 
-  // Private but used in tests.
-  readonly getChangeModel = resolve(this, changeModelToken);
+  private readonly getUserModel = resolve(this, userModelToken);
 
-  private readonly routerModel = getAppContext().routerModel;
+  private readonly getChangeModel = resolve(this, changeModelToken);
 
-  // Private but used in tests.
-  readonly getCommentsModel = resolve(this, commentsModelToken);
+  private readonly getCommentsModel = resolve(this, commentsModelToken);
 
   private readonly getConfigModel = resolve(this, configModelToken);
 
-  private readonly getFilesModel = resolve(this, filesModelToken);
-
   private readonly getViewModel = resolve(this, changeViewModelToken);
 
-  private readonly getShortcutsService = resolve(this, shortcutsServiceToken);
+  private readonly getRelatedChangesModel = resolve(
+    this,
+    relatedChangesModelToken
+  );
 
-  private replyRefitTask?: DelayedTask;
+  private readonly getShortcutsService = resolve(this, shortcutsServiceToken);
 
   private scrollTask?: DelayedTask;
 
@@ -588,7 +473,7 @@
 
   /** Simply reflects the router-model value. */
   // visible for testing
-  routerPatchNum?: RevisionPatchSetNum;
+  viewModelPatchNum?: RevisionPatchSetNum;
 
   private readonly shortcutsController = new ShortcutController(this);
 
@@ -602,18 +487,6 @@
   }
 
   private setupListeners() {
-    this.addEventListener(
-      // When an overlay is opened in a mobile viewport, the overlay has a full
-      // screen view. When it has a full screen view, we do not want the
-      // background to be scrollable. This will eliminate background scroll by
-      // hiding most of the contents on the screen upon opening, and showing
-      // again upon closing.
-      'fullscreen-overlay-opened',
-      () => this.handleHideBackgroundContent()
-    );
-    this.addEventListener('fullscreen-overlay-closed', () =>
-      this.handleShowBackgroundContent()
-    );
     this.addEventListener('open-reply-dialog', () => this.openReplyDialog());
     this.addEventListener('change-message-deleted', () => fireReload(this));
     this.addEventListener('editable-content-save', e =>
@@ -623,15 +496,7 @@
       this.handleCommitMessageCancel()
     );
     this.addEventListener('open-fix-preview', e => this.onOpenFixPreview(e));
-    this.addEventListener('close-fix-preview', e => this.onCloseFixPreview(e));
-
-    this.addEventListener(EventType.SHOW_TAB, e => this.setActiveTab(e));
-    this.addEventListener('reload', e => {
-      this.loadData(
-        /* isLocationChange= */ false,
-        /* clearPatchset= */ e.detail && e.detail.clearPatchset
-      );
-    });
+    this.addEventListener('show-tab', e => this.setActiveTab(e));
   }
 
   private setupShortcuts() {
@@ -639,7 +504,7 @@
     this.shortcutsController.addAbstract(Shortcut.EMOJI_DROPDOWN, () => {}); // docOnly
     this.shortcutsController.addAbstract(Shortcut.MENTIONS_DROPDOWN, () => {}); // docOnly
     this.shortcutsController.addAbstract(Shortcut.REFRESH_CHANGE, () =>
-      fireReload(this, true)
+      this.getChangeModel().navigateToChangeResetReload()
     );
     this.shortcutsController.addAbstract(Shortcut.OPEN_REPLY_DIALOG, () =>
       this.handleOpenReplyDialog()
@@ -709,7 +574,21 @@
     subscribe(
       this,
       () => this.getViewModel().tab$,
-      t => (this.activeTab = t ?? Tab.FILES)
+      t => (this.activeTab = t)
+    );
+    subscribe(
+      this,
+      () => this.getViewModel().commentId$,
+      commentId => (this.scrollCommentId = commentId)
+    );
+    subscribe(
+      this,
+      () => this.getViewModel().openReplyDialog$,
+      openReplyDialog => {
+        // Here we are relying on `this.loggedIn` being set *before*
+        // `openReplyDialog`, but that is fine for this feature.
+        if (openReplyDialog && this.loggedIn) this.handleOpenReplyDialog();
+      }
     );
     subscribe(
       this,
@@ -727,28 +606,26 @@
     );
     subscribe(
       this,
-      () => this.routerModel.routerView$,
-      view => {
-        this.isViewCurrent = view === GerritView.CHANGE;
+      () => this.getViewModel().childView$,
+      childView => {
+        this.isViewCurrent = childView === ChangeChildView.OVERVIEW;
+        // When coming back from ChangeChildView.DIFF we want to restore the
+        // scroll position to what it was before leaving the OVERVIEW page.
+        if (this.isViewCurrent) {
+          document.documentElement.scrollTop = this.scrollPosition ?? 0;
+        }
       }
     );
     subscribe(
       this,
-      () => this.routerModel.routerPatchNum$,
+      () => this.getViewModel().patchNum$,
       patchNum => {
-        this.routerPatchNum = patchNum;
+        this.viewModelPatchNum = patchNum;
       }
     );
     subscribe(
       this,
-      () => this.getCommentsModel().drafts$,
-      drafts => {
-        this.diffDrafts = {...drafts};
-      }
-    );
-    subscribe(
-      this,
-      () => this.userModel.preferenceDiffViewMode$,
+      () => this.getUserModel().preferenceDiffViewMode$,
       diffViewMode => {
         this.diffViewMode = diffViewMode;
       }
@@ -762,7 +639,7 @@
     );
     subscribe(
       this,
-      () => this.getCommentsModel().threads$,
+      () => this.getCommentsModel().threadsSaved$,
       threads => {
         this.commentThreads = threads;
       }
@@ -778,14 +655,57 @@
     );
     subscribe(
       this,
-      () => this.userModel.account$,
+      () => this.getChangeModel().changeNum$,
+      changeNum => {
+        // The change view is tied to a specific change number, so don't update
+        // changeNum to undefined and only set it once.
+        if (changeNum && !this.changeNum) this.changeNum = changeNum;
+      }
+    );
+    subscribe(
+      this,
+      () => this.getChangeModel().patchNum$,
+      patchNum => (this.patchNum = patchNum)
+    );
+    subscribe(
+      this,
+      () => this.getChangeModel().basePatchNum$,
+      basePatchNum => (this.basePatchNum = basePatchNum)
+    );
+    subscribe(
+      this,
+      () => this.getChangeModel().mergeable$,
+      mergeable => (this.mergeable = mergeable)
+    );
+    subscribe(
+      this,
+      () => this.getChangeModel().revision$,
+      revision => (this.revision = revision)
+    );
+    subscribe(
+      this,
+      () => this.getChangeModel().changeLoadingStatus$,
+      status => (this.loading = status !== LoadingStatus.LOADED)
+    );
+    subscribe(
+      this,
+      () => this.getChangeModel().latestRevision$,
+      revision => {
+        this.latestCommitMessage = this.prepareCommitMsgForLinkify(
+          revision?.commit?.message ?? ''
+        );
+      }
+    );
+    subscribe(
+      this,
+      () => this.getUserModel().account$,
       account => {
         this.account = account;
       }
     );
     subscribe(
       this,
-      () => this.userModel.loggedIn$,
+      () => this.getUserModel().loggedIn$,
       loggedIn => {
         this.loggedIn = loggedIn;
       }
@@ -805,6 +725,13 @@
         this.projectConfig = config;
       }
     );
+    subscribe(
+      this,
+      () => this.getRelatedChangesModel().revertingChange$,
+      revertingChange => {
+        this.revertingChange = revertingChange;
+      }
+    );
   }
 
   override connectedCallback() {
@@ -819,6 +746,8 @@
   }
 
   override firstUpdated() {
+    this.maybeScrollToMessage(window.location.hash);
+    this.maybeShowRevertDialog();
     // _onTabSizingChanged is called when iron-items-changed event is fired
     // from iron-selectable but that is called before the element is present
     // in view which whereas the method requires paper tabs already be visible
@@ -837,13 +766,17 @@
     if (!this.isFirstConnection) return;
     this.isFirstConnection = false;
 
-    getPluginLoader()
+    this.getPluginLoader()
       .awaitPluginsLoaded()
       .then(() => {
         this.pluginTabsHeaderEndpoints =
-          getPluginEndpoints().getDynamicEndpoints('change-view-tab-header');
+          this.getPluginLoader().pluginEndPoints.getDynamicEndpoints(
+            'change-view-tab-header'
+          );
         this.pluginTabsContentEndpoints =
-          getPluginEndpoints().getDynamicEndpoints('change-view-tab-content');
+          this.getPluginLoader().pluginEndPoints.getDynamicEndpoints(
+            'change-view-tab-content'
+          );
         if (
           this.pluginTabsContentEndpoints.length !==
           this.pluginTabsHeaderEndpoints.length
@@ -853,8 +786,7 @@
             new Error('Mismatch of headers and content.')
           );
         }
-      })
-      .then(() => this.initActiveTab());
+      });
 
     this.throttledToggleChangeStar = throttleWrap<KeyboardEvent>(_ =>
       this.handleToggleChangeStar()
@@ -867,7 +799,6 @@
       this.handleVisibilityChange
     );
     document.removeEventListener('scroll', this.handleScroll);
-    this.replyRefitTask?.cancel();
     this.scrollTask?.cancel();
 
     if (this.updateCheckTimerHandle) {
@@ -877,21 +808,12 @@
     super.disconnectedCallback();
   }
 
-  protected override willUpdate(changedProperties: PropertyValues): void {
-    if (
-      changedProperties.has('change') ||
-      changedProperties.has('mergeable') ||
-      changedProperties.has('currentRevisionActions')
-    ) {
-      this.changeStatuses = this.computeChangeStatusChips();
-    }
-  }
-
   static override get styles() {
     return [
       a11yStyles,
       paperStyles,
       sharedStyles,
+      modalStyles,
       css`
         .container:not(.loading) {
           background-color: var(--background-color-tertiary);
@@ -906,7 +828,6 @@
           border-bottom: 1px solid var(--border-color);
           display: flex;
           padding: var(--spacing-s) var(--spacing-l);
-          z-index: 99; /* Less than gr-overlay's backdrop */
         }
         .header.editMode {
           background-color: var(--edit-mode-background-color);
@@ -968,11 +889,6 @@
           background-color: var(--background-color-secondary);
           padding-right: var(--spacing-m);
         }
-        .changeId {
-          color: var(--deemphasized-text-color);
-          font-family: var(--font-family);
-          margin-top: var(--spacing-l);
-        }
         section {
           background-color: var(--view-background-color);
           box-shadow: var(--elevation-level-1);
@@ -1084,7 +1000,7 @@
         gr-thread-list {
           min-height: 250px;
         }
-        #includedInOverlay {
+        #includedInModal {
           width: 65em;
         }
         #uploadHelpOverlay {
@@ -1102,7 +1018,7 @@
           .relatedChanges {
             padding: 0;
           }
-          #relatedChanges {
+          .relatedChanges gr-related-changes-list {
             padding-top: var(--spacing-l);
           }
           #commitAndRelated {
@@ -1175,19 +1091,11 @@
             flex: initial;
             margin: 0;
           }
-          /* Change actions are the only thing thant need to remain visible due
-            to the fact that they may have the currently visible overlay open. */
-          #mainContent.overlayOpen .hideOnMobileOverlay {
-            display: none;
-          }
           gr-reply-dialog {
             height: 100vh;
             min-width: initial;
             width: 100vw;
           }
-          #replyOverlay {
-            z-index: var(--reply-overlay-z-index);
-          }
         }
         .patch-set-dropdown {
           margin: var(--spacing-m) 0 0 var(--spacing-m);
@@ -1231,33 +1139,24 @@
         .change=${this.change}
         .changeNum=${this.changeNum}
       ></gr-apply-fix-dialog>
-      <gr-overlay id="downloadOverlay" with-backdrop="">
+      <dialog id="downloadModal" tabindex="-1">
         <gr-download-dialog
           id="downloadDialog"
           .change=${this.change}
           .config=${this.serverConfig?.download}
           @close=${this.handleDownloadDialogClose}
         ></gr-download-dialog>
-      </gr-overlay>
-      <gr-overlay id="includedInOverlay" with-backdrop="">
+      </dialog>
+      <dialog id="includedInModal" tabindex="-1">
         <gr-included-in-dialog
           id="includedInDialog"
           .changeNum=${this.changeNum}
           @close=${this.handleIncludedInDialogClose}
         ></gr-included-in-dialog>
-      </gr-overlay>
-      <gr-overlay
-        id="replyOverlay"
-        class="scrollable"
-        no-cancel-on-outside-click=""
-        no-cancel-on-esc-key=""
-        scroll-action="lock"
-        with-backdrop=""
-        @iron-overlay-canceled=${this.onReplyOverlayCanceled}
-        @opened-changed=${this.onReplyOverlayOpenedChanged}
-      >
+      </dialog>
+      <dialog id="replyModal" @close=${this.onReplyModalCanceled}>
         ${when(
-          this.replyOverlayOpened && this.loggedIn,
+          this.replyModalOpened && this.loggedIn,
           () => html`
             <gr-reply-dialog
               id="replyDialog"
@@ -1266,13 +1165,11 @@
               .canBeStarted=${this.canStartReview()}
               @send=${this.handleReplySent}
               @cancel=${this.handleReplyCancel}
-              @autogrow=${this.handleReplyAutogrow}
-              @send-disabled-changed=${this.resetReplyOverlayFocusStops}
             >
             </gr-reply-dialog>
           `
         )}
-      </gr-overlay>
+      </dialog>
     `;
   }
 
@@ -1290,13 +1187,14 @@
   }
 
   private renderHeaderTitle() {
-    const resolveWeblinks = this.commitInfo?.resolve_conflicts_web_links ?? [];
+    const changeStatuses = this.computeChangeStatusChips();
+    const resolveWeblinks =
+      this.revision?.commit?.resolve_conflicts_web_links ?? [];
     return html` <div class="headerTitle">
       <div class="changeStatuses">
-        ${this.changeStatuses.map(
+        ${changeStatuses.map(
           status => html` <gr-change-status
-            .change=${this.change}
-            .revertedChange=${this.revertedChange}
+            .revertedChange=${this.revertingChange}
             .status=${status}
             .resolveWeblinks=${resolveWeblinks}
           ></gr-change-status>`
@@ -1307,7 +1205,14 @@
         flatten
         down-arrow
         class="showCopyLinkDialogButton"
-        @click=${() => this.copyLinksDropdown?.toggleDropdown()}
+        @click=${(e: MouseEvent) => {
+          // We don't want to handle clicks on the star or the <a> link.
+          // Calling `stopPropagation()` from the click handler of <a> is not an
+          // option, because then the click does not reach the top-level gr-page
+          // click handler and would result is a full page reload.
+          if ((e.target as HTMLElement)?.nodeName !== 'GR-BUTTON') return;
+          this.copyLinksDropdown?.toggleDropdown();
+        }}
         ><gr-change-star
           id="changeStar"
           .change=${this.change}
@@ -1319,7 +1224,6 @@
           class="changeNumber"
           aria-label=${`Change ${this.change?._number}`}
           href=${ifDefined(this.computeChangeUrl(true))}
-          @click=${(e: MouseEvent) => e.stopPropagation()}
           >${this.change?._number}</a
         >
       </gr-button>
@@ -1389,11 +1293,10 @@
         id="actions"
         .change=${this.change}
         .disableEdit=${false}
-        .hasParent=${this.hasParent}
         .account=${this.account}
         .changeNum=${this.changeNum}
         .changeStatus=${this.change?.status}
-        .commitNum=${this.commitInfo?.commit}
+        .commitNum=${this.revision?.commit?.commit}
         .commitMessage=${this.latestCommitMessage}
         .editMode=${this.getEditMode()}
         .privateByDefault=${this.projectConfig?.private_by_default}
@@ -1415,14 +1318,14 @@
       this.getEditMode()
     );
     return html` <div class="changeInfo">
-      <div class="changeInfo-column changeMetadata hideOnMobileOverlay">
+      <div class="changeInfo-column changeMetadata">
         <gr-change-metadata
           id="metadata"
           .change=${this.change}
-          .revertedChange=${this.revertedChange}
+          .revertedChange=${this.revertingChange}
           .account=${this.account}
-          .revision=${this.selectedRevision}
-          .commitInfo=${this.commitInfo}
+          .revision=${this.revision}
+          .commitInfo=${this.revision?.commit}
           .serverConfig=${this.serverConfig}
           .parentIsCurrent=${this.isParentCurrent()}
           .repoConfig=${this.projectConfig}
@@ -1431,7 +1334,7 @@
         </gr-change-metadata>
       </div>
       <div id="mainChangeInfo" class="changeInfo-column mainChangeInfo">
-        <div id="commitAndRelated" class="hideOnMobileOverlay">
+        <div id="commitAndRelated">
           <div class="commitContainer">
             <h3 class="assistive-tech-only">Commit Message</h3>
             <div>
@@ -1462,42 +1365,22 @@
                 remove-zero-width-space=""
               >
                 <gr-formatted-text
-                  .content=${this.latestCommitMessage ?? ''}
+                  .content=${this.latestCommitMessage}
                   .markdown=${false}
                 ></gr-formatted-text>
               </gr-editable-content>
-              <div class="changeId" ?hidden=${!this.changeIdCommitMessageError}>
-                <hr />
-                Change-Id:
-                <span
-                  class=${this.computeChangeIdClass(
-                    this.changeIdCommitMessageError
-                  )}
-                  title=${this.computeTitleAttributeWarning(
-                    this.changeIdCommitMessageError
-                  )}
-                  >${this.change?.change_id}</span
-                >
-              </div>
             </div>
             <h3 class="assistive-tech-only">Comments and Checks Summary</h3>
             <gr-change-summary></gr-change-summary>
             <gr-endpoint-decorator name="commit-container">
               <gr-endpoint-param name="change" .value=${this.change}>
               </gr-endpoint-param>
-              <gr-endpoint-param
-                name="revision"
-                .value=${this.selectedRevision}
-              >
+              <gr-endpoint-param name="revision" .value=${this.revision}>
               </gr-endpoint-param>
             </gr-endpoint-decorator>
           </div>
           <div class="relatedChanges">
-            <gr-related-changes-list
-              id="relatedChanges"
-              .change=${this.change}
-              .mergeable=${this.mergeable}
-            ></gr-related-changes-list>
+            <gr-related-changes-list></gr-related-changes-list>
           </div>
           <div class="emptySpace"></div>
         </div>
@@ -1540,10 +1423,7 @@
               <gr-endpoint-decorator name=${tabHeader}>
                 <gr-endpoint-param name="change" .value=${this.change}>
                 </gr-endpoint-param>
-                <gr-endpoint-param
-                  name="revision"
-                  .value=${this.selectedRevision}
-                >
+                <gr-endpoint-param name="revision" .value=${this.revision}>
                 </gr-endpoint-param>
               </gr-endpoint-decorator>
             </paper-tab>
@@ -1579,7 +1459,7 @@
           .account=${this.account}
           .change=${this.change}
           .changeNum=${this.changeNum}
-          .commitInfo=${this.commitInfo}
+          .commitInfo=${this.revision?.commit}
           .changeUrl=${this.computeChangeUrl()}
           .editMode=${this.getEditMode()}
           .loggedIn=${this.loggedIn}
@@ -1593,7 +1473,6 @@
         </gr-file-list-header>
         <gr-file-list
           id="fileList"
-          class="hideOnMobileOverlay"
           .change=${this.change}
           .changeNum=${this.changeNum}
           .editMode=${this.getEditMode()}
@@ -1675,7 +1554,7 @@
       <gr-endpoint-decorator .name=${pluginTabContentEndpoint}>
         <gr-endpoint-param name="change" .value=${this.change}>
         </gr-endpoint-param>
-        <gr-endpoint-param name="revision" .value=${this.selectedRevision}></gr-endpoint-param>
+        <gr-endpoint-param name="revision" .value=${this.revision}></gr-endpoint-param>
         </gr-endpoint-param>
       </gr-endpoint-decorator>
     `;
@@ -1686,7 +1565,7 @@
       <gr-endpoint-decorator name="change-view-integration">
         <gr-endpoint-param name="change" .value=${this.change}>
         </gr-endpoint-param>
-        <gr-endpoint-param name="revision" .value=${this.selectedRevision}>
+        <gr-endpoint-param name="revision" .value=${this.revision}>
         </gr-endpoint-param>
       </gr-endpoint-decorator>
 
@@ -1698,17 +1577,26 @@
       <section class="changeLog">
         <h2 class="assistive-tech-only">Change Log</h2>
         <gr-messages-list
-          class="hideOnMobileOverlay"
           .labels=${this.change?.labels}
           .messages=${this.change?.messages}
-          .reviewerUpdates=${this.change?.reviewer_updates}
+          .reviewerUpdates=${this.change?.reviewer_updates ?? []}
           @message-anchor-tap=${this.handleMessageAnchorTap}
-          @reply=${this.handleMessageReply}
         ></gr-messages-list>
       </section>
     `;
   }
 
+  override updated() {
+    const tabs = [...queryAll<HTMLElement>(this.tabs!, 'paper-tab')];
+    const tabIndex = tabs.findIndex(t => t.dataset['name'] === this.activeTab);
+
+    if (tabIndex !== -1 && this.tabs!.selected !== tabIndex) {
+      this.tabs!.selected = tabIndex;
+    }
+    this.reportChangeDisplayed();
+    this.reportFullyLoaded();
+  }
+
   private readonly handleScroll = () => {
     if (!this.isViewCurrent) return;
     this.scrollTask = debounce(
@@ -1723,16 +1611,12 @@
     this.applyFixDialog.open(e);
   }
 
-  private onCloseFixPreview(e: CloseFixPreviewEvent) {
-    if (e.detail.fixApplied) fireReload(this);
-  }
-
   // Private but used in tests.
   handleToggleDiffMode() {
     if (this.diffViewMode === DiffViewMode.SIDE_BY_SIDE) {
-      this.userModel.updatePreferences({diff_view: DiffViewMode.UNIFIED});
+      this.getUserModel().updatePreferences({diff_view: DiffViewMode.UNIFIED});
     } else {
-      this.userModel.updatePreferences({
+      this.getUserModel().updatePreferences({
         diff_view: DiffViewMode.SIDE_BY_SIDE,
       });
     }
@@ -1754,22 +1638,10 @@
   }
 
   setActiveTab(e: SwitchTabEvent) {
-    if (!this.tabs) return;
-    const tabs = [...queryAll<HTMLElement>(this.tabs, 'paper-tab')];
-    if (!tabs) return;
-
     const tab = e.detail.tab;
-    const tabIndex = tabs.findIndex(t => t.dataset['name'] === tab);
-    assert(tabIndex !== -1, `tab ${tab} not found`);
-
-    if (this.tabs.selected !== tabIndex) {
-      this.tabs.selected = tabIndex;
-    }
-
     this.getViewModel().updateState({tab});
-
     if (e.detail.tabState) this.tabState = e.detail.tabState;
-    if (e.detail.scrollIntoView) this.tabs.scrollIntoView({block: 'center'});
+    if (e.detail.scrollIntoView) this.tabs!.scrollIntoView({block: 'center'});
   }
 
   /**
@@ -1809,7 +1681,8 @@
   }
 
   private handleContentChanged(e: ValueChangedEvent) {
-    this.latestCommitMessage = e.detail.value;
+    // optimistic update
+    this.latestCommitMessage = e.detail.value ?? '';
   }
 
   // Private but used in tests.
@@ -1821,7 +1694,10 @@
     // Trim trailing whitespace from each line.
     const message = e.detail.content.replace(TRAILING_WHITESPACE_REGEX, '');
 
-    this.jsAPI.handleCommitMessage(this.change, message);
+    this.getPluginLoader().jsApiService.handleCommitMessage(
+      this.change,
+      message
+    );
 
     this.commitMessageEditor.disabled = true;
     this.restApiService
@@ -1833,9 +1709,8 @@
           return;
         }
 
-        this.latestCommitMessage = this.prepareCommitMsgForLinkify(message);
         this.editingCommitMessage = false;
-        fireReload(this, true);
+        this.getChangeModel().navigateToChangeResetReload();
       })
       .catch(() => {
         assertIsDefined(this.commitMessageEditor);
@@ -1848,19 +1723,12 @@
   }
 
   private computeChangeStatusChips() {
-    if (!this.change) {
-      return [];
-    }
-
-    // Show no chips until mergeability is loaded.
-    if (this.mergeable === null) {
-      return [];
-    }
+    if (!this.change || this.mergeable === undefined) return [];
 
     const options = {
-      includeDerived: true,
-      mergeable: !!this.mergeable,
+      mergeable: this.mergeable,
       submitEnabled: !!this.isSubmitEnabled(),
+      revertingChangeStatus: this.revertingChange?.status,
     };
     return changeStatuses(this.change as ChangeInfo, options);
   }
@@ -1974,13 +1842,10 @@
     this.openReplyDialog(FocusTarget.ANY);
   }
 
-  private onReplyOverlayCanceled() {
+  private onReplyModalCanceled() {
     fireDialogChange(this, {canceled: true});
     this.changeViewAriaHidden = false;
-  }
-
-  private onReplyOverlayOpenedChanged(e: ValueChangedEvent<boolean>) {
-    this.replyOverlayOpened = e.detail.value;
+    this.replyModalOpened = false;
   }
 
   private handleOpenDiffPrefs() {
@@ -1990,92 +1855,60 @@
 
   private handleOpenIncludedInDialog() {
     assertIsDefined(this.includedInDialog);
-    assertIsDefined(this.includedInOverlay);
-    this.includedInDialog.loadData().then(() => {
-      assertIsDefined(this.includedInOverlay);
-      flush();
-      this.includedInOverlay.refit();
-    });
-    this.includedInOverlay.open();
+    assertIsDefined(this.includedInModal);
+    this.includedInDialog.loadData();
+    this.includedInModal.showModal();
   }
 
   private handleIncludedInDialogClose() {
-    assertIsDefined(this.includedInOverlay);
-    this.includedInOverlay.close();
+    assertIsDefined(this.includedInModal);
+    this.includedInModal.close();
   }
 
   // Private but used in tests
   handleOpenDownloadDialog() {
-    assertIsDefined(this.downloadOverlay);
-    this.downloadOverlay.open().then(() => {
-      assertIsDefined(this.downloadOverlay);
+    assertIsDefined(this.downloadModal);
+    this.downloadModal.showModal();
+    whenVisible(this.downloadModal, () => {
+      assertIsDefined(this.downloadModal);
       assertIsDefined(this.downloadDialog);
-      this.downloadOverlay.setFocusStops(this.downloadDialog.getFocusStops());
       this.downloadDialog.focus();
+      const downloadCommands = queryAndAssert(
+        this.downloadDialog,
+        'gr-download-commands'
+      );
+      const paperTabs = queryAndAssert<PaperTabsElement>(
+        downloadCommands,
+        'paper-tabs'
+      );
+      // Paper Tabs normally listen to 'iron-resize' event to call this method.
+      // After migrating to Dialog element, this event is no longer fired
+      // which means this method is not called which ends up styling the
+      // selected paper tab with an underline.
+      paperTabs._onTabSizingChanged();
     });
   }
 
   private handleDownloadDialogClose() {
-    assertIsDefined(this.downloadOverlay);
-    this.downloadOverlay.close();
-  }
-
-  // Private but used in tests.
-  handleMessageReply(e: CustomEvent<{message: {message: string}}>) {
-    const msg: string = e.detail.message.message;
-    const quoteStr =
-      msg
-        .split('\n')
-        .map(line => '> ' + line)
-        .join('\n') + '\n\n';
-    this.openReplyDialog(FocusTarget.BODY, quoteStr);
-  }
-
-  // Private but used in tests.
-  handleHideBackgroundContent() {
-    assertIsDefined(this.mainContent);
-    this.mainContent.classList.add('overlayOpen');
-  }
-
-  // Private but used in tests.
-  handleShowBackgroundContent() {
-    assertIsDefined(this.mainContent);
-    this.mainContent.classList.remove('overlayOpen');
+    assertIsDefined(this.downloadModal);
+    this.downloadModal.close();
   }
 
   // Private but used in tests.
   handleReplySent() {
-    this.addEventListener(
-      'change-details-loaded',
-      () => {
-        this.reporting.timeEnd(Timing.SEND_REPLY);
-      },
-      {once: true}
-    );
-    assertIsDefined(this.replyOverlay);
-    this.replyOverlay.cancel();
-    fireReload(this);
+    assertIsDefined(this.replyModal);
+    this.replyModal.close();
+    this.getChangeModel().navigateToChangeResetReload();
   }
 
   private handleReplyCancel() {
-    assertIsDefined(this.replyOverlay);
-    this.replyOverlay.cancel();
-  }
-
-  private handleReplyAutogrow() {
-    // If the textarea resizes, we need to re-fit the overlay.
-    this.replyRefitTask = debounce(
-      this.replyRefitTask,
-      () => {
-        assertIsDefined(this.replyOverlay);
-        this.replyOverlay.refit();
-      },
-      REPLY_REFIT_DEBOUNCE_INTERVAL_MS
-    );
+    assertIsDefined(this.replyModal);
+    this.replyModal.close();
+    this.onReplyModalCanceled();
   }
 
   // Private but used in tests.
-  handleShowReplyDialog(e: CustomEvent<{value: {ccsOnly: boolean}}>) {
+  handleShowReplyDialog(e: ShowReplyDialogEvent) {
     let target = FocusTarget.REVIEWERS;
     if (e.detail.value && e.detail.value.ccsOnly) {
       target = FocusTarget.CCS;
@@ -2116,179 +1949,14 @@
   }
 
   // Private but used in tests.
-  hasPatchRangeChanged(viewState: ChangeViewState) {
-    if (!this.patchRange) return false;
-    if (this.patchRange.basePatchNum !== viewState.basePatchNum) return true;
-    return this.hasPatchNumChanged(viewState);
-  }
-
-  // Private but used in tests.
-  hasPatchNumChanged(viewState: ChangeViewState) {
-    if (!this.patchRange) return false;
-    if (viewState.patchNum !== undefined) {
-      return this.patchRange.patchNum !== viewState.patchNum;
-    } else {
-      // value.patchNum === undefined specifies the latest patchset
-      return (
-        this.patchRange.patchNum !== computeLatestPatchNum(this.allPatchSets)
-      );
-    }
-  }
-
-  // Private but used in tests.
-  viewStateChanged() {
-    if (this.viewState === undefined) {
-      this.initialLoadComplete = false;
-      querySelectorAll(this, 'gr-overlay').forEach(overlay =>
-        (overlay as GrOverlay).close()
-      );
-      return;
-    }
-
-    if (this.isChangeObsolete()) {
-      // Tell the app element that we are not going to handle the new change
-      // number and that they have to create a new change view.
-      fireEvent(this, EventType.RECREATE_CHANGE_VIEW);
-      return;
-    }
-
-    if (this.viewState.changeNum && this.viewState.project) {
-      this.restApiService.setInProjectLookup(
-        this.viewState.changeNum,
-        this.viewState.project
-      );
-    }
-
-    if (this.viewState.basePatchNum === undefined)
-      this.viewState.basePatchNum = PARENT;
-
-    const patchChanged = this.hasPatchRangeChanged(this.viewState);
-    let patchNumChanged = this.hasPatchNumChanged(this.viewState);
-
-    this.patchRange = {
-      patchNum: this.viewState.patchNum,
-      basePatchNum: this.viewState.basePatchNum,
-    };
-    this.scrollCommentId = this.viewState.commentId;
-
-    const patchKnown =
-      !this.patchRange.patchNum ||
-      (this.allPatchSets ?? []).some(
-        ps => ps.num === this.patchRange!.patchNum
-      );
-    // _allPatchsets does not know value.patchNum so force a reload.
-    const forceReload = this.viewState.forceReload || !patchKnown;
-
-    // If changeNum is defined that means the change has already been
-    // rendered once before so a full reload is not required.
-    if (this.changeNum !== undefined && !forceReload) {
-      if (!this.patchRange.patchNum) {
-        this.patchRange = {
-          ...this.patchRange,
-          patchNum: computeLatestPatchNum(this.allPatchSets),
-        };
-        patchNumChanged = true;
-      }
-      if (patchChanged) {
-        // We need to collapse all diffs when viewState changes so that a non
-        // existing diff is not requested. See Issue 125270 for more details.
-        this.fileList?.resetFileState();
-        this.fileList?.collapseAllDiffs();
-        this.reloadPatchNumDependentResources(patchNumChanged);
-      }
-
-      // If there is no change in patchset or changeNum, such as when user goes
-      // to the diff view and then comes back to change page then there is no
-      // need to reload anything and we render the change view component as is.
-      document.documentElement.scrollTop = this.scrollPosition ?? 0;
-      this.reporting.reportInteraction('change-view-re-rendered');
-      this.updateTitle(this.change);
-      // We still need to check if post load tasks need to be done such as when
-      // user wants to open the reply dialog when in the diff page, the change
-      // page should open the reply dialog
-      this.performPostLoadTasks();
-      return;
-    }
-
-    // We need to collapse all diffs when viewState changes so that a non
-    // existing diff is not requested. See Issue 125270 for more details.
-    this.updateComplete.then(() => {
-      assertIsDefined(this.fileList);
-      this.fileList?.collapseAllDiffs();
-      this.fileList?.resetFileState();
-    });
-
-    // If the change was loaded before, then we are firing a 'reload' event
-    // instead of calling `loadData()` directly for two reasons:
-    // 1. We want to avoid code such as `this.initialLoadComplete = false` that
-    //    is only relevant for the initial load of a change.
-    // 2. We have to somehow trigger the change-model reloading. Otherwise
-    //    this.change is not updated.
-    if (this.changeNum) {
-      if (!this._patchRange?.patchNum) {
-        this._patchRange = {
-          basePatchNum: PARENT,
-          patchNum: computeLatestPatchNum(this.allPatchSets),
-        };
-      }
-      fireReload(this);
-      return;
-    }
-
-    this.initialLoadComplete = false;
-    this.changeNum = this.viewState.changeNum;
-    this.loadData(true).then(() => {
-      this.performPostLoadTasks();
-    });
-
-    getPluginLoader()
-      .awaitPluginsLoaded()
-      .then(() => {
-        this.initActiveTab();
-      });
-  }
-
-  private initActiveTab() {
-    let tab = Tab.FILES;
-    if (this.viewState?.tab) {
-      tab = this.viewState?.tab as Tab;
-    } else if (this.viewState?.commentId) {
-      tab = Tab.COMMENT_THREADS;
-    }
-    this.setActiveTab(new CustomEvent(EventType.SHOW_TAB, {detail: {tab}}));
-  }
-
-  // Private but used in tests.
-  sendShowChangeEvent() {
-    assertIsDefined(this.patchRange, 'patchRange');
-    this.jsAPI.handleEvent(PluginEventType.SHOW_CHANGE, {
-      change: this.change,
-      patchNum: this.patchRange.patchNum,
-      info: {mergeable: this.mergeable},
-    });
-  }
-
-  private performPostLoadTasks() {
-    this.maybeShowReplyDialog();
-    this.maybeShowRevertDialog();
-
-    this.sendShowChangeEvent();
-
-    this.updateComplete.then(() => {
-      this.maybeScrollToMessage(window.location.hash);
-      this.initialLoadComplete = true;
-    });
-  }
-
-  // Private but used in tests.
   handleMessageAnchorTap(e: CustomEvent<{id: string}>) {
     assertIsDefined(this.change, 'change');
-    assertIsDefined(this.patchRange, 'patchRange');
+    assertIsDefined(this.patchNum, 'patchNum');
     const hash = PREFIX + e.detail.id;
     const url = createChangeUrl({
       change: this.change,
-      patchNum: this.patchRange.patchNum,
-      basePatchNum: this.patchRange.basePatchNum,
+      patchNum: this.patchNum,
+      basePatchNum: this.basePatchNum,
       edit: this.getEditMode(),
       messageHash: hash,
     });
@@ -2296,9 +1964,10 @@
   }
 
   // Private but used in tests.
-  maybeScrollToMessage(hash: string) {
-    if (hash.startsWith(PREFIX) && this.messagesList) {
-      this.messagesList.scrollToMessage(hash.substr(PREFIX.length));
+  async maybeScrollToMessage(hash: string) {
+    if (hash.startsWith(PREFIX)) {
+      await waitUntil(() => !!this.messagesList);
+      await this.messagesList!.scrollToMessage(hash.substr(PREFIX.length));
     }
   }
 
@@ -2321,39 +1990,18 @@
   }
 
   // Private but used in tests.
-  maybeShowRevertDialog() {
-    getPluginLoader()
-      .awaitPluginsLoaded()
-      .then(() => {
-        if (
-          !this.loggedIn ||
-          !this.change ||
-          this.change.status !== ChangeStatus.MERGED
-        ) {
-          // Do not display dialog if not logged-in or the change is not
-          // merged.
-          return;
-        }
-        if (this._getUrlParameter('revert')) {
-          assertIsDefined(this.actions);
-          this.actions.showRevertDialog();
-        }
-      });
-  }
+  async maybeShowRevertDialog() {
+    if (!this._getUrlParameter('revert')) return;
 
-  private maybeShowReplyDialog() {
-    if (!this.loggedIn) return;
-    if (this.viewState?.openReplyDialog) {
-      this.openReplyDialog(FocusTarget.ANY);
+    await this.getPluginLoader().awaitPluginsLoaded();
+    await waitUntil(() => !!this.actions);
+    await waitUntil(() => !!this.change);
+
+    if (this.change?.status === ChangeStatus.MERGED && this.loggedIn) {
+      this.actions!.showRevertDialog();
     }
   }
 
-  private updateTitle(change?: ChangeInfo | ParsedChangeInfo) {
-    if (!change) return;
-    const title = change.subject + ' (' + change.change_id.substr(0, 9) + ')';
-    fireTitleChange(this, title);
-  }
-
   // Private but used in tests.
   changeChanged(oldChange: ParsedChangeInfo | undefined) {
     this.allPatchSets = computeAllPatchSets(this.change);
@@ -2367,60 +2015,11 @@
       this.currentRobotCommentsPatchSet =
         this.change.revisions[this.change.current_revision]._number;
     }
-    if (!this.change || !this.patchRange || !this.allPatchSets) {
-      return;
-    }
-
-    // We get the parent first so we keep the original value for basePatchNum
-    // and not the updated value.
-    const parent = this.getBasePatchNum();
-
-    this.patchRange = {
-      ...this.patchRange,
-      basePatchNum: parent,
-      patchNum:
-        this.patchRange.patchNum || computeLatestPatchNum(this.allPatchSets),
-    };
-    this.updateTitle(this.change);
   }
 
   /**
-   * Gets base patch number, if it is a parent try and decide from
-   * preference whether to default to `auto merge`, `Parent 1` or `PARENT`.
-   * Private but used in tests.
+   * This is the URL equivalent of changeModel.navigateToChangeResetReload().
    */
-  getBasePatchNum() {
-    if (
-      this.patchRange &&
-      this.patchRange.basePatchNum &&
-      this.patchRange.basePatchNum !== PARENT
-    ) {
-      return this.patchRange.basePatchNum;
-    }
-
-    const revisionInfo = this.getRevisionInfo();
-    if (!revisionInfo) return PARENT;
-
-    // TODO: It is a bit unclear why `1` is used here instead of
-    // `patchRange.patchNum`. Maybe that is a bug? Maybe if one patchset
-    // is a merge commit, then all patchsets are merge commits??
-    const isMerge = revisionInfo.isMergeCommit(1 as PatchSetNumber);
-    const preferFirst =
-      this.prefs &&
-      this.prefs.default_base_for_merges === DefaultBase.FIRST_PARENT;
-
-    // TODO: I think checking `!patchRange.patchNum` here is a bug and means
-    // that the feature is actually broken at the moment. Looking at the
-    // `changeChanged` method, `patchRange.patchNum` is set before
-    // `getBasePatchNum` is called, so it is unlikely that this method will
-    // ever return -1.
-    if (isMerge && preferFirst && !this.patchRange?.patchNum) {
-      this.reporting.reportExecution(Execution.PREFER_MERGE_FIRST_PARENT);
-      return -1 as BasePatchSetNum;
-    }
-    return PARENT;
-  }
-
   private computeChangeUrl(forceReload?: boolean) {
     if (!this.change) return undefined;
     return createChangeUrl({
@@ -2429,81 +2028,18 @@
     });
   }
 
-  // private but used in test
-  computeChangeIdClass(displayChangeId?: string | null) {
-    if (displayChangeId) {
-      return displayChangeId === CHANGE_ID_ERROR.MISMATCH ? 'warning' : '';
-    }
-    return '';
-  }
-
-  computeTitleAttributeWarning(displayChangeId?: string | null) {
-    if (!displayChangeId) {
-      return undefined;
-    }
-    if (displayChangeId === CHANGE_ID_ERROR.MISMATCH) {
-      return 'Change-Id mismatch';
-    } else if (displayChangeId === CHANGE_ID_ERROR.MISSING) {
-      return 'No Change-Id in commit message';
-    }
-    return undefined;
-  }
-
-  computeChangeIdCommitMessageError(
-    commitMessage: string | null,
-    change?: ParsedChangeInfo
-  ) {
-    if (change === undefined) {
-      return undefined;
-    }
-
-    if (!commitMessage) {
-      return CHANGE_ID_ERROR.MISSING;
-    }
-
-    // Find the last match in the commit message:
-    let changeId;
-    let changeIdArr;
-
-    while ((changeIdArr = CHANGE_ID_REGEX_PATTERN.exec(commitMessage))) {
-      changeId = changeIdArr[2];
-    }
-
-    if (changeId) {
-      // A change-id is detected in the commit message.
-
-      if (changeId === change.change_id) {
-        // The change-id found matches the real change-id.
-        return null;
-      }
-      // The change-id found does not match the change-id.
-      return CHANGE_ID_ERROR.MISMATCH;
-    }
-    // There is no change-id in the commit message.
-    return CHANGE_ID_ERROR.MISSING;
-  }
-
   // Private but used in tests.
   computeReplyButtonLabel() {
-    if (this.diffDrafts === undefined) {
-      return 'Reply';
-    }
-
-    const draftCount = Object.keys(this.diffDrafts).reduce(
-      (count, file) => count + this.diffDrafts![file].length,
-      0
-    );
-
     let label = this.canStartReview() ? 'Start Review' : 'Reply';
-    if (draftCount > 0) {
-      label += ` (${draftCount})`;
+    if (this.draftCount > 0) {
+      label += ` (${this.draftCount})`;
     }
     return label;
   }
 
   private handleOpenReplyDialog() {
     if (!this.loggedIn) {
-      fireEvent(this, 'show-auth-required');
+      fire(this, 'show-auth-required', {});
       return;
     }
     this.openReplyDialog(FocusTarget.ANY);
@@ -2533,7 +2069,7 @@
           reason
         )
         .then(() => {
-          fireEvent(this, 'hide-alert');
+          fire(this, 'hide-alert', {});
         });
     } else {
       const reason = getAddedByReason(this.account, this.serverConfig);
@@ -2550,7 +2086,7 @@
           reason
         )
         .then(() => {
-          fireEvent(this, 'hide-alert');
+          fire(this, 'hide-alert', {});
         });
     }
     this.change = newChange;
@@ -2559,29 +2095,30 @@
   // Private but used in tests.
   handleDiffAgainstBase() {
     assertIsDefined(this.change, 'change');
-    assertIsDefined(this.patchRange, 'patchRange');
-    if (this.patchRange.basePatchNum === PARENT) {
+    assertIsDefined(this.patchNum, 'patchNum');
+    if (this.basePatchNum === PARENT) {
       fireAlert(this, 'Base is already selected.');
       return;
     }
     this.getNavigation().setUrl(
-      createChangeUrl({change: this.change, patchNum: this.patchRange.patchNum})
+      createChangeUrl({change: this.change, patchNum: this.patchNum})
     );
   }
 
   // Private but used in tests.
   handleDiffBaseAgainstLeft() {
+    if (this.viewState?.childView !== ChangeChildView.OVERVIEW) return;
     assertIsDefined(this.change, 'change');
-    assertIsDefined(this.patchRange, 'patchRange');
+    assertIsDefined(this.patchNum, 'patchNum');
 
-    if (this.patchRange.basePatchNum === PARENT) {
+    if (this.basePatchNum === PARENT) {
       fireAlert(this, 'Left is already base.');
       return;
     }
     this.getNavigation().setUrl(
       createChangeUrl({
         change: this.change,
-        patchNum: this.patchRange.basePatchNum as RevisionPatchSetNum,
+        patchNum: this.basePatchNum as RevisionPatchSetNum,
       })
     );
   }
@@ -2589,9 +2126,9 @@
   // Private but used in tests.
   handleDiffAgainstLatest() {
     assertIsDefined(this.change, 'change');
-    assertIsDefined(this.patchRange, 'patchRange');
+    assertIsDefined(this.patchNum, 'patchNum');
     const latestPatchNum = computeLatestPatchNum(this.allPatchSets);
-    if (this.patchRange.patchNum === latestPatchNum) {
+    if (this.patchNum === latestPatchNum) {
       fireAlert(this, 'Latest is already selected.');
       return;
     }
@@ -2599,7 +2136,7 @@
       createChangeUrl({
         change: this.change,
         patchNum: latestPatchNum,
-        basePatchNum: this.patchRange.basePatchNum,
+        basePatchNum: this.basePatchNum,
       })
     );
   }
@@ -2607,9 +2144,9 @@
   // Private but used in tests.
   handleDiffRightAgainstLatest() {
     assertIsDefined(this.change, 'change');
-    assertIsDefined(this.patchRange, 'patchRange');
+    assertIsDefined(this.patchNum, 'patchNum');
     const latestPatchNum = computeLatestPatchNum(this.allPatchSets);
-    if (this.patchRange.patchNum === latestPatchNum) {
+    if (this.patchNum === latestPatchNum) {
       fireAlert(this, 'Right is already latest.');
       return;
     }
@@ -2617,7 +2154,7 @@
       createChangeUrl({
         change: this.change,
         patchNum: latestPatchNum,
-        basePatchNum: this.patchRange.patchNum as BasePatchSetNum,
+        basePatchNum: this.patchNum as BasePatchSetNum,
       })
     );
   }
@@ -2625,12 +2162,9 @@
   // Private but used in tests.
   handleDiffBaseAgainstLatest() {
     assertIsDefined(this.change, 'change');
-    assertIsDefined(this.patchRange, 'patchRange');
+    assertIsDefined(this.patchNum, 'patchNum');
     const latestPatchNum = computeLatestPatchNum(this.allPatchSets);
-    if (
-      this.patchRange.patchNum === latestPatchNum &&
-      this.patchRange.basePatchNum === PARENT
-    ) {
+    if (this.patchNum === latestPatchNum && this.basePatchNum === PARENT) {
       fireAlert(this, 'Already diffing base against latest.');
       return;
     }
@@ -2701,23 +2235,19 @@
       return;
     }
     this.handleLabelRemoved(oldLabels, newLabels);
-    this.jsAPI.handleEvent(PluginEventType.LABEL_CHANGE, {
+    this.getPluginLoader().jsApiService.handleLabelChange({
       change: this.change,
     });
   }
 
-  openReplyDialog(focusTarget?: FocusTarget, quote?: string) {
+  openReplyDialog(focusTarget?: FocusTarget) {
     if (!this.change) return;
-    assertIsDefined(this.replyOverlay);
-    const overlay = this.replyOverlay;
-    overlay.open().finally(() => {
-      // the following code should be executed no matter open succeed or not
-      const dialog = this.replyDialog;
-      assertIsDefined(dialog, 'reply dialog');
-      this.resetReplyOverlayFocusStops();
-      dialog.open(focusTarget, quote);
-      const observer = new ResizeObserver(() => overlay.center());
-      observer.observe(dialog);
+    this.replyModalOpened = true;
+    assertIsDefined(this.replyModal);
+    this.replyModal.showModal();
+    whenVisible(this.replyModal, () => {
+      assertIsDefined(this.replyDialog, 'replyDialog');
+      this.replyDialog.open(focusTarget);
     });
     fireDialogChange(this, {opened: true});
     this.changeViewAriaHidden = true;
@@ -2728,174 +2258,11 @@
     // TODO(wyatta) switch linkify sequence, see issue 5526.
     // This is a zero-with space. It is added to prevent the linkify library
     // from including R= or CC= as part of the email address.
+    // TODO: Is this comment referring to the ba-linkify library that we are
+    // not using anymore? If so, then remove this hack.
     return msg.replace(REVIEWERS_REGEX, '$1=\u200B');
   }
 
-  /**
-   * Utility function to make the necessary modifications to a change in the
-   * case an edit exists.
-   * Private but used in tests.
-   */
-  processEdit(change: ParsedChangeInfo) {
-    const revisions = Object.values(change.revisions || {});
-    const editRev = findEdit(revisions);
-    const editParentRev = findEditParentRevision(revisions);
-    if (
-      !editRev &&
-      this.patchRange?.patchNum === EDIT &&
-      changeIsOpen(change)
-    ) {
-      fireAlert(this, 'Change edit not found. Please create a change edit.');
-      fireReload(this, true);
-      return;
-    }
-
-    if (
-      !editRev &&
-      (changeIsMerged(change) || changeIsAbandoned(change)) &&
-      this.getEditMode()
-    ) {
-      fireAlert(
-        this,
-        'Change edits cannot be created if change is merged or abandoned. Redirecting to non edit mode.'
-      );
-      fireReload(this, true);
-      return;
-    }
-
-    if (!editRev) return;
-    assertIsDefined(this.patchRange, 'patchRange');
-    assertIsDefined(editRev.commit.commit, 'editRev.commit.commit');
-    assertIsDefined(editParentRev, 'editParentRev');
-
-    const latestPsNum = computeLatestPatchNum(computeAllPatchSets(change));
-    // If the change was loaded without a specific patchset, then this normally
-    // means that the *latest* patchset should be loaded. But if there is an
-    // active edit, then automatically switch to that edit as the current
-    // patchset.
-    // TODO: This goes together with `change.current_revision` being set, which
-    // is under change-model control. `patchRange.patchNum` should eventually
-    // also be model managed, so we can reconcile these two code snippets into
-    // one location.
-    if (!this.routerPatchNum && latestPsNum === editParentRev._number) {
-      this.patchRange = {...this.patchRange, patchNum: EDIT};
-      // The file list is not reactive (yet) with regards to patch range
-      // changes, so we have to actively trigger it.
-      this.reloadPatchNumDependentResources();
-    }
-  }
-
-  computeRevertSubmitted(change?: ChangeInfo | ParsedChangeInfo) {
-    if (!change?.messages) return;
-    Promise.all(
-      getRevertCreatedChangeIds(change.messages).map(changeId =>
-        this.restApiService.getChange(changeId)
-      )
-    ).then(changes => {
-      // if a change is deleted then getChanges returns null for that changeId
-      changes = changes.filter(
-        change => change && change.status !== ChangeStatus.ABANDONED
-      );
-      if (!changes.length) return;
-      const submittedRevert = changes.find(
-        change => change?.status === ChangeStatus.MERGED
-      );
-      if (!this.changeStatuses) return;
-      if (submittedRevert) {
-        this.revertedChange = submittedRevert;
-        this.changeStatuses = this.changeStatuses.concat([
-          ChangeStates.REVERT_SUBMITTED,
-        ]);
-      } else {
-        if (changes[0]) this.revertedChange = changes[0];
-        this.changeStatuses = this.changeStatuses.concat([
-          ChangeStates.REVERT_CREATED,
-        ]);
-      }
-    });
-  }
-
-  private async untilModelLoaded() {
-    // NOTE: Wait until this page is connected before determining whether the
-    // model is loaded.  This can happen when viewState changes when setting up
-    // this view. It's unclear whether this issue is related to Polymer
-    // specifically.
-    if (!this.isConnected) {
-      await until(this.connected$, connected => connected);
-    }
-    await until(
-      this.getChangeModel().changeLoadingStatus$,
-      status => status === LoadingStatus.LOADED
-    );
-  }
-
-  /**
-   * Process edits
-   * Check if a revert of this change has been submitted
-   * Calculate selected revision
-   */
-  // private but used in tests
-  async performPostChangeLoadTasks() {
-    assertIsDefined(this.changeNum, 'changeNum');
-
-    const prefCompletes = this.restApiService.getPreferences();
-    await this.untilModelLoaded();
-
-    this.prefs = await prefCompletes;
-
-    if (!this.change) return false;
-
-    this.processEdit(this.change);
-    // Issue 4190: Coalesce missing topics to null.
-    // TODO(TS): code needs second thought,
-    // it might be that nulls were assigned to trigger some bindings
-    if (!this.change.topic) {
-      this.change.topic = null as unknown as undefined;
-    }
-    if (!this.change.reviewer_updates) {
-      this.change.reviewer_updates = null as unknown as undefined;
-    }
-    const latestRevisionSha = this.getLatestRevisionSHA(this.change);
-    if (!latestRevisionSha)
-      throw new Error('Could not find latest Revision Sha');
-    const currentRevision = this.change.revisions[latestRevisionSha];
-    if (currentRevision.commit && currentRevision.commit.message) {
-      this.latestCommitMessage = this.prepareCommitMsgForLinkify(
-        currentRevision.commit.message
-      );
-    } else {
-      this.latestCommitMessage = null;
-    }
-
-    this.computeRevertSubmitted(this.change);
-    if (
-      !this.patchRange ||
-      !this.patchRange.patchNum ||
-      this.patchRange.patchNum === currentRevision._number
-    ) {
-      // CommitInfo.commit is optional, and may need patching.
-      if (currentRevision.commit && !currentRevision.commit.commit) {
-        currentRevision.commit.commit = latestRevisionSha as CommitId;
-      }
-      this.commitInfo = currentRevision.commit;
-      this.selectedRevision = currentRevision;
-      // TODO: Fetch and process files.
-    } else {
-      if (!this.change?.revisions || !this.patchRange) return false;
-      this.selectedRevision = Object.values(this.change.revisions).find(
-        revision => {
-          // edit patchset is a special one
-          const thePatchNum = this.patchRange!.patchNum;
-          if (thePatchNum === EDIT) {
-            return revision._number === thePatchNum;
-          }
-          return revision._number === Number(`${thePatchNum}`);
-        }
-      );
-    }
-    return true;
-  }
-
   private isParentCurrent() {
     const revisionActions = this.currentRevisionActions;
     if (revisionActions && revisionActions.rebase) {
@@ -2905,243 +2272,39 @@
     }
   }
 
-  // Private but used in tests.
-  getLatestCommitMessage() {
-    assertIsDefined(this.changeNum, 'changeNum');
-    const lastpatchNum = computeLatestPatchNum(this.allPatchSets);
-    if (lastpatchNum === undefined)
-      throw new Error('missing lastPatchNum property');
-    return this.restApiService
-      .getChangeCommitInfo(this.changeNum, lastpatchNum)
-      .then(commitInfo => {
-        if (!commitInfo) return;
-        this.latestCommitMessage = this.prepareCommitMsgForLinkify(
-          commitInfo.message
-        );
-      });
+  private async reportChangeDisplayed() {
+    await waitUntil(() => !!this.metadata);
+    await untilRendered(this.metadata!);
+    if (this.activeTab === Tab.FILES) {
+      await waitUntil(() => !!this.fileList);
+      await untilRendered(this.fileList!);
+    }
+    await waitUntil(() => !!this.messagesList);
+    await untilRendered(this.messagesList!);
+    // We are ending the timer after each change view update, because ending a
+    // timer that was not started is a no-op. :-)
+    if (this.change && this.isConnected && !this.isChangeObsolete()) {
+      this.reporting.changeDisplayed(roleDetails(this.change, this.account));
+    }
   }
 
-  // Private but used in tests.
-  getLatestRevisionSHA(change: ChangeInfo | ParsedChangeInfo) {
-    if (change.current_revision) return change.current_revision;
-    // current_revision may not be present in the case where the latest rev is
-    // a draft and the user doesn’t have permission to view that rev.
-    let latestRev = null;
-    let latestPatchNum = -1 as PatchSetNum;
-    for (const [rev, revInfo] of Object.entries(change.revisions ?? {})) {
-      if (revInfo._number > latestPatchNum) {
-        latestRev = rev;
-        latestPatchNum = revInfo._number;
-      }
+  private async reportFullyLoaded() {
+    await waitUntil(() => !!this.metadata);
+    await untilRendered(this.metadata!);
+    if (this.activeTab === Tab.FILES) {
+      await waitUntil(() => !!this.fileList);
+      await untilRendered(this.fileList!);
     }
-    return latestRev;
-  }
-
-  // visible for testing
-  loadAndSetCommitInfo() {
-    assertIsDefined(this.changeNum, 'changeNum');
-    assertIsDefined(this.patchRange?.patchNum, 'patchRange.patchNum');
-    return this.restApiService
-      .getChangeCommitInfo(this.changeNum, this.patchRange.patchNum)
-      .then(commitInfo => {
-        this.commitInfo = commitInfo;
-      });
-  }
-
-  /**
-   * Reload the change.
-   *
-   * @param isLocationChange Reloads the related changes
-   * when true and ends reporting events that started on location change.
-   * @param clearPatchset Reloads the change ignoring any patchset
-   * choice made.
-   * @return A promise that resolves when the core data has loaded.
-   * Some non-core data loading may still be in-flight when the core data
-   * promise resolves.
-   */
-  loadData(isLocationChange?: boolean, clearPatchset?: boolean) {
-    if (this.isChangeObsolete()) return Promise.resolve();
-    if (clearPatchset && this.change) {
-      this.getNavigation().setUrl(
-        createChangeUrl({change: this.change, forceReload: true})
-      );
-      return Promise.resolve();
+    await waitUntil(() => !!this.messagesList);
+    await untilRendered(this.messagesList!);
+    await waitUntil(() => this.mergeable !== undefined);
+    await until(this.getCommentsModel().comments$, c => c !== undefined);
+    await until(this.getCommentsModel().drafts$, c => c !== undefined);
+    // We are ending the timer after each change view update, because ending a
+    // timer that was not started is a no-op. :-)
+    if (this.change && this.isConnected && !this.isChangeObsolete()) {
+      this.reporting.changeFullyLoaded();
     }
-    this.loading = true;
-    this.reporting.time(Timing.CHANGE_RELOAD);
-    this.reporting.time(Timing.CHANGE_DATA);
-
-    // Array to house all promises related to data requests.
-    const allDataPromises: Promise<unknown>[] = [];
-
-    // Resolves when the change detail and the edit patch set (if available)
-    // are loaded.
-    const detailCompletes = this.untilModelLoaded();
-    allDataPromises.push(detailCompletes);
-
-    // Resolves when the loading flag is set to false, meaning that some
-    // change content may start appearing.
-    const loadingFlagSet = detailCompletes.then(() => {
-      this.loading = false;
-      this.performPostChangeLoadTasks();
-    });
-
-    let coreDataPromise;
-
-    // If the patch number is specified
-    if (this.patchRange && this.patchRange.patchNum) {
-      // Because a specific patchset is specified, reload the resources that
-      // are keyed by patch number or patch range.
-      const patchResourcesLoaded = this.reloadPatchNumDependentResources();
-      allDataPromises.push(patchResourcesLoaded);
-
-      // Promise resolves when the change detail and patch dependent resources
-      // have loaded.
-      coreDataPromise = Promise.all([patchResourcesLoaded, loadingFlagSet]);
-    } else {
-      const latestCommitMessageLoaded = loadingFlagSet.then(() => {
-        // If the latest commit message is known, there is nothing to do.
-        if (this.latestCommitMessage) {
-          return Promise.resolve();
-        }
-        return this.getLatestCommitMessage();
-      });
-      allDataPromises.push(latestCommitMessageLoaded);
-
-      coreDataPromise = loadingFlagSet;
-    }
-    const mergeabilityLoaded = coreDataPromise.then(() =>
-      this.getMergeability()
-    );
-    allDataPromises.push(mergeabilityLoaded);
-
-    coreDataPromise.then(() => {
-      fireEvent(this, 'change-details-loaded');
-      this.reporting.timeEnd(Timing.CHANGE_RELOAD);
-      if (isLocationChange) {
-        this.reporting.changeDisplayed(roleDetails(this.change, this.account));
-      }
-    });
-
-    if (isLocationChange) {
-      this.editingCommitMessage = false;
-    }
-    const relatedChangesLoaded = coreDataPromise.then(() => {
-      let relatedChangesPromise:
-        | Promise<RelatedChangesInfo | undefined>
-        | undefined;
-      const patchNum = computeLatestPatchNum(this.allPatchSets);
-      if (this.change && patchNum) {
-        relatedChangesPromise = this.restApiService
-          .getRelatedChanges(this.change._number, patchNum)
-          .then(response => {
-            if (this.change && response) {
-              this.hasParent = this.calculateHasParent(
-                this.change.change_id,
-                response.changes
-              );
-            }
-            return response;
-          });
-      }
-      return this.getRelatedChangesList()?.reload(relatedChangesPromise);
-    });
-    allDataPromises.push(relatedChangesLoaded);
-    allDataPromises.push(this.filesLoaded());
-
-    Promise.all(allDataPromises).then(() => {
-      // Loading of commments data is no longer part of this reporting
-      this.reporting.timeEnd(Timing.CHANGE_DATA);
-      if (isLocationChange) {
-        this.reporting.changeFullyLoaded();
-      }
-    });
-
-    return coreDataPromise;
-  }
-
-  private async filesLoaded() {
-    if (!this.isConnected) await until(this.connected$, connected => connected);
-    await until(this.getFilesModel().files$, f => f.length > 0);
-  }
-
-  /**
-   * Determines whether or not the given change has a parent change. If there
-   * is a relation chain, and the change id is not the last item of the
-   * relation chain, there is a parent.
-   *
-   * Private but used in tests.
-   */
-  calculateHasParent(
-    currentChangeId: ChangeId,
-    relatedChanges: RelatedChangeAndCommitInfo[]
-  ) {
-    return (
-      relatedChanges.length > 0 &&
-      relatedChanges[relatedChanges.length - 1].change_id !== currentChangeId
-    );
-  }
-
-  /**
-   * Kicks off requests for resources that rely on the patch range
-   * (`this.patchRange`) being defined.
-   */
-  reloadPatchNumDependentResources(patchNumChanged?: boolean) {
-    assertIsDefined(this.changeNum, 'changeNum');
-    if (!this.patchRange?.patchNum) throw new Error('missing patchNum');
-    const promises = [this.loadAndSetCommitInfo()];
-    if (patchNumChanged) {
-      promises.push(
-        this.getCommentsModel().reloadPortedComments(
-          this.changeNum,
-          this.patchRange?.patchNum
-        )
-      );
-      promises.push(
-        this.getCommentsModel().reloadPortedDrafts(
-          this.changeNum,
-          this.patchRange?.patchNum
-        )
-      );
-    }
-    return Promise.all(promises);
-  }
-
-  // Private but used in tests
-  getMergeability(): Promise<void> {
-    if (!this.change) {
-      this.mergeable = null;
-      return Promise.resolve();
-    }
-    // If the change is closed, it is not mergeable. Note: already merged
-    // changes are obviously not mergeable, but the mergeability API will not
-    // answer for abandoned changes.
-    if (
-      this.change.status === ChangeStatus.MERGED ||
-      this.change.status === ChangeStatus.ABANDONED
-    ) {
-      this.mergeable = false;
-      return Promise.resolve();
-    }
-
-    if (!this.changeNum) {
-      return Promise.reject(new Error('missing required changeNum property'));
-    }
-
-    // If mergeable bit was already returned in detail REST endpoint, use it.
-    if (this.change.mergeable !== undefined) {
-      this.mergeable = this.change.mergeable;
-      return Promise.resolve();
-    }
-
-    this.mergeable = null;
-    return this.restApiService
-      .getMergeable(this.changeNum)
-      .then(mergableInfo => {
-        if (mergableInfo) {
-          this.mergeable = mergableInfo.mergeable;
-        }
-      });
   }
 
   /**
@@ -3157,13 +2320,11 @@
     );
   }
 
-  private computeCommitCollapsible() {
-    if (!this.latestCommitMessage) {
-      return false;
-    }
+  private computeCommitCollapsible(): boolean {
     return (
+      !!this.latestCommitMessage &&
       this.latestCommitMessage.split('\n').length >=
-      MIN_LINES_FOR_COMMIT_COLLAPSE
+        MIN_LINES_FOR_COMMIT_COLLAPSE
     );
   }
 
@@ -3218,20 +2379,14 @@
           }
 
           this.cancelUpdateCheckTimer();
-          this.dispatchEvent(
-            new CustomEvent<ShowAlertEventDetail>(EventType.SHOW_ALERT, {
-              detail: {
-                message: toastMessage,
-                // Persist this alert.
-                dismissOnNavigation: true,
-                showDismiss: true,
-                action: 'Reload',
-                callback: () => fireReload(this, true),
-              },
-              composed: true,
-              bubbles: true,
-            })
-          );
+          fire(this, 'show-alert', {
+            message: toastMessage,
+            // Persist this alert.
+            dismissOnNavigation: true,
+            showDismiss: true,
+            action: 'Reload',
+            callback: () => this.getChangeModel().navigateToChangeResetReload(),
+          });
         });
     }, this.serverConfig.change.update_delay * 1000);
   }
@@ -3260,7 +2415,7 @@
     return classes.join(' ');
   }
 
-  private handleFileActionTap(e: CustomEvent<{path: string; action: string}>) {
+  private handleFileActionTap(e: FileActionTapEvent) {
     e.preventDefault();
     assertIsDefined(this.fileListHeader);
     const controls =
@@ -3269,7 +2424,7 @@
       );
     if (!controls) throw new Error('Missing edit controls');
     assertIsDefined(this.change, 'change');
-    assertIsDefined(this.patchRange, 'patchRange');
+    assertIsDefined(this.patchNum, 'patchNum');
 
     const path = e.detail.path;
     switch (e.detail.action) {
@@ -3277,13 +2432,13 @@
         controls.openDeleteDialog(path);
         break;
       case GrEditConstants.Actions.OPEN.id:
-        assertIsDefined(this.patchRange.patchNum, 'patchset number');
+        assertIsDefined(this.patchNum, 'patchset number');
         this.getNavigation().setUrl(
           createEditUrl({
             changeNum: this.change._number,
-            project: this.change.project,
-            path,
-            patchNum: this.patchRange.patchNum,
+            repo: this.change.project,
+            patchNum: this.patchNum,
+            editView: {path},
           })
         );
         break;
@@ -3296,21 +2451,6 @@
     }
   }
 
-  private patchNumChanged() {
-    if (!this.selectedRevision || !this.patchRange?.patchNum) {
-      return;
-    }
-    assertIsDefined(this.change, 'change');
-
-    if (this.patchRange.patchNum === this.selectedRevision._number) {
-      return;
-    }
-    if (!this.change.revisions) return;
-    this.selectedRevision = Object.values(this.change.revisions).find(
-      revision => revision._number === this.patchRange!.patchNum
-    );
-  }
-
   /**
    * If an edit exists already, load it. Otherwise, toggle edit mode via the
    * navigation API.
@@ -3331,7 +2471,7 @@
     this.getNavigation().setUrl(
       createChangeUrl({
         change: this.change,
-        patchNum: this.routerPatchNum,
+        patchNum: this.viewModelPatchNum,
         edit: true,
         forceReload: true,
       })
@@ -3340,24 +2480,16 @@
 
   private handleStopEditTap() {
     assertIsDefined(this.change, 'change');
-    assertIsDefined(this.patchRange, 'patchRange');
+    assertIsDefined(this.patchNum, 'patchNum');
     this.getNavigation().setUrl(
       createChangeUrl({
         change: this.change,
-        patchNum: this.patchRange.patchNum,
+        patchNum: this.patchNum,
         forceReload: true,
       })
     );
   }
 
-  private resetReplyOverlayFocusStops() {
-    const dialog = this.replyDialog;
-    const focusStops = dialog?.getFocusStops();
-    if (!focusStops) return;
-    assertIsDefined(this.replyOverlay);
-    this.replyOverlay.setFocusStops(focusStops);
-  }
-
   // Private but used in tests.
   async handleToggleStar(e: CustomEvent<ChangeStarToggleStarDetail>) {
     if (e.detail.starred) {
@@ -3379,18 +2511,7 @@
       e.detail.change._number,
       e.detail.starred
     );
-    fireEvent(this, 'hide-alert');
-  }
-
-  private getRevisionInfo(): RevisionInfoClass | undefined {
-    if (this.change === undefined) return undefined;
-    return new RevisionInfoClass(this.change);
-  }
-
-  getRelatedChangesList() {
-    return this.shadowRoot!.querySelector<GrRelatedChangesList>(
-      '#relatedChanges'
-    );
+    fire(this, 'hide-alert', {});
   }
 
   createTitle(shortcutName: Shortcut, section: ShortcutSection) {
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
index 99b9277..560006b 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
@@ -10,18 +10,13 @@
 import {
   ChangeStatus,
   CommentSide,
-  DefaultBase,
   DiffViewMode,
-  HttpMethod,
-  MessageTag,
   createDefaultPreferences,
   Tab,
 } from '../../../constants/constants';
 import {GrEditConstants} from '../../edit/gr-edit-constants';
-import {_testOnly_resetEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints';
 import {navigationToken} from '../../core/gr-navigation/gr-navigation';
-import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
-import {EventType, PluginApi} from '../../../api/plugin';
+import {PluginApi} from '../../../api/plugin';
 import {
   mockPromise,
   pressKey,
@@ -29,83 +24,68 @@
   stubBaseUrl,
   stubFlags,
   stubRestApi,
-  stubUsers,
-  waitEventLoop,
-  waitQueryAndAssert,
   waitUntil,
+  waitUntilVisible,
 } from '../../../test/test-utils';
 import {
   createChangeViewState,
-  createApproval,
-  createChange,
   createChangeMessages,
-  createCommit,
-  createMergeable,
-  createPreferences,
   createRevision,
   createRevisions,
   createServerInfo,
   createUserConfig,
   TEST_NUMERIC_CHANGE_ID,
   TEST_PROJECT_NAME,
-  createEditRevision,
-  createAccountWithIdNameAndEmail,
   createChangeViewChange,
-  createRelatedChangeAndCommitInfo,
   createAccountDetailWithId,
   createParsedChange,
-  createDraft,
 } from '../../../test/test-data-generators';
 import {GrChangeView} from './gr-change-view';
 import {
   AccountId,
-  ApprovalInfo,
   BasePatchSetNum,
-  ChangeId,
-  ChangeInfo,
   CommitId,
   EDIT,
   NumericChangeId,
   PARENT,
-  RelatedChangeAndCommitInfo,
-  ReviewInputTag,
-  RevisionInfo,
   RevisionPatchSetNum,
   RobotId,
   RobotCommentInfo,
   Timestamp,
   UrlEncodedCommentId,
-  DetailedLabelInfo,
   RepoName,
-  QuickLabelInfo,
+  CommentThread,
+  SavingState,
 } from '../../../types/common';
 import {GrEditControls} from '../../edit/gr-edit-controls/gr-edit-controls';
-import {SinonFakeTimers, SinonStubbedMember} from 'sinon';
-import {RestApiService} from '../../../services/gr-rest-api/gr-rest-api';
-import {CommentThread} from '../../../utils/comment-util';
+import {SinonFakeTimers} from 'sinon';
 import {GerritView} from '../../../services/router/router-model';
 import {ParsedChangeInfo} from '../../../types/types';
-import {GrRelatedChangesList} from '../gr-related-changes-list/gr-related-changes-list';
-import {ChangeStates} from '../../shared/gr-change-status/gr-change-status';
-import {LoadingStatus} from '../../../models/change/change-model';
-import {FocusTarget, GrReplyDialog} from '../gr-reply-dialog/gr-reply-dialog';
-import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
+import {
+  ChangeModel,
+  changeModelToken,
+  LoadingStatus,
+} from '../../../models/change/change-model';
+import {FocusTarget} from '../gr-reply-dialog/gr-reply-dialog';
 import {GrChangeStar} from '../../shared/gr-change-star/gr-change-star';
 import {GrThreadList} from '../gr-thread-list/gr-thread-list';
 import {assertIsDefined} from '../../../utils/common-util';
-import {DEFAULT_NUM_FILES_SHOWN} from '../gr-file-list/gr-file-list';
 import {fixture, html, assert} from '@open-wc/testing';
-import {deepClone} from '../../../utils/deep-util';
 import {Modifier} from '../../../utils/dom-util';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {GrCopyLinks} from '../gr-copy-links/gr-copy-links';
-import {ChangeViewState} from '../../../models/views/change';
+import {ChangeChildView, ChangeViewState} from '../../../models/views/change';
 import {rootUrl} from '../../../utils/url-util';
 import {testResolver} from '../../../test/common-test-setup';
+import {UserModel, userModelToken} from '../../../models/user/user-model';
+import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
+import {commentsModelToken} from '../../../models/comments/comments-model';
 
 suite('gr-change-view tests', () => {
   let element: GrChangeView;
   let setUrlStub: sinon.SinonStub;
+  let userModel: UserModel;
+  let changeModel: ChangeModel;
 
   const ROBOT_COMMENTS_LIMIT = 10;
 
@@ -150,7 +130,7 @@
           updated: '2018-02-13 22:48:48.018000000' as Timestamp,
           message: 'draft',
           unresolved: false,
-          __draft: true,
+          savingState: SavingState.OK,
           patch_set: 2 as RevisionPatchSetNum,
         },
       ],
@@ -253,7 +233,7 @@
           updated: '2018-02-15 22:48:48.018000000' as Timestamp,
           message: 'resolved draft',
           unresolved: false,
-          __draft: true,
+          savingState: SavingState.OK,
           patch_set: 2 as RevisionPatchSetNum,
         },
       ],
@@ -328,8 +308,6 @@
   ];
 
   setup(async () => {
-    // Since pluginEndpoints are global, must reset state.
-    _testOnly_resetEndpoints();
     setUrlStub = sinon.stub(testResolver(navigationToken), 'setUrl');
 
     stubRestApi('getConfig').returns(
@@ -348,7 +326,6 @@
     stubRestApi('getDiffRobotComments').returns(Promise.resolve({}));
     stubRestApi('getDiffDrafts').returns(Promise.resolve({}));
 
-    getPluginLoader().loadPlugins([]);
     window.Gerrit.install(
       plugin => {
         plugin.registerDynamicCustomComponent(
@@ -368,13 +345,16 @@
     );
     element.viewState = {
       view: GerritView.CHANGE,
+      childView: ChangeChildView.OVERVIEW,
       changeNum: TEST_NUMERIC_CHANGE_ID,
-      project: 'gerrit' as RepoName,
+      repo: 'gerrit' as RepoName,
     };
     await element.updateComplete.then(() => {
       assertIsDefined(element.actions);
       sinon.stub(element.actions, 'reload').returns(Promise.resolve());
     });
+    userModel = testResolver(userModelToken);
+    changeModel = testResolver(changeModelToken);
   });
 
   teardown(async () => {
@@ -411,16 +391,16 @@
                 </gr-copy-clipboard>
               </div>
               <div class="commitActions">
-                <gr-change-actions hidden="" id="actions"> </gr-change-actions>
+                <gr-change-actions id="actions"> </gr-change-actions>
               </div>
             </div>
             <h2 class="assistive-tech-only">Change metadata</h2>
             <div class="changeInfo">
-              <div class="changeInfo-column changeMetadata hideOnMobileOverlay">
+              <div class="changeInfo-column changeMetadata">
                 <gr-change-metadata id="metadata"> </gr-change-metadata>
               </div>
               <div class="changeInfo-column mainChangeInfo" id="mainChangeInfo">
-                <div class="hideOnMobileOverlay" id="commitAndRelated">
+                <div id="commitAndRelated">
                   <div class="commitContainer">
                     <h3 class="assistive-tech-only">Commit Message</h3>
                     <div>
@@ -443,11 +423,6 @@
                       >
                         <gr-formatted-text></gr-formatted-text>
                       </gr-editable-content>
-                      <div class="changeId" hidden="">
-                        <hr />
-                        Change-Id:
-                        <span class="" title=""></span>
-                      </div>
                     </div>
                     <h3 class="assistive-tech-only">
                       Comments and Checks Summary
@@ -459,8 +434,7 @@
                     </gr-endpoint-decorator>
                   </div>
                   <div class="relatedChanges">
-                    <gr-related-changes-list id="relatedChanges">
-                    </gr-related-changes-list>
+                    <gr-related-changes-list></gr-related-changes-list>
                   </div>
                   <div class="emptySpace"></div>
                 </div>
@@ -507,8 +481,7 @@
           <section class="tabContent">
             <div>
               <gr-file-list-header id="fileListHeader"> </gr-file-list-header>
-              <gr-file-list class="hideOnMobileOverlay" id="fileList">
-              </gr-file-list>
+              <gr-file-list id="fileList"> </gr-file-list>
             </div>
           </section>
           <gr-endpoint-decorator name="change-view-integration">
@@ -529,51 +502,25 @@
           </paper-tabs>
           <section class="changeLog">
             <h2 class="assistive-tech-only">Change Log</h2>
-            <gr-messages-list class="hideOnMobileOverlay"> </gr-messages-list>
+            <gr-messages-list> </gr-messages-list>
           </section>
         </div>
         <gr-apply-fix-dialog id="applyFixDialog"> </gr-apply-fix-dialog>
-        <gr-overlay
-          aria-hidden="true"
-          id="downloadOverlay"
-          style="outline: none; display: none;"
-          tabindex="-1"
-          with-backdrop=""
-        >
+        <dialog id="downloadModal" tabindex="-1">
           <gr-download-dialog id="downloadDialog" role="dialog">
           </gr-download-dialog>
-        </gr-overlay>
-        <gr-overlay
-          aria-hidden="true"
-          id="includedInOverlay"
-          style="outline: none; display: none;"
-          tabindex="-1"
-          with-backdrop=""
-        >
+        </dialog>
+        <dialog id="includedInModal" tabindex="-1">
           <gr-included-in-dialog id="includedInDialog"> </gr-included-in-dialog>
-        </gr-overlay>
-        <gr-overlay
-          aria-hidden="true"
-          class="scrollable"
-          id="replyOverlay"
-          no-cancel-on-esc-key=""
-          no-cancel-on-outside-click=""
-          scroll-action="lock"
-          style="outline: none; display: none;"
-          tabindex="-1"
-          with-backdrop=""
-        >
-        </gr-overlay>
+        </dialog>
+        <dialog id="replyModal"></dialog>
       `
     );
   });
 
   test('handleMessageAnchorTap', async () => {
     element.changeNum = 1 as NumericChangeId;
-    element.patchRange = {
-      basePatchNum: PARENT,
-      patchNum: 1 as RevisionPatchSetNum,
-    };
+    element.patchNum = 1 as RevisionPatchSetNum;
     element.change = createChangeViewChange();
     await element.updateComplete;
     const replaceStateStub = sinon.stub(history, 'replaceState');
@@ -589,10 +536,8 @@
       ...createChangeViewChange(),
       revisions: createRevisions(10),
     };
-    element.patchRange = {
-      patchNum: 3 as RevisionPatchSetNum,
-      basePatchNum: 1 as BasePatchSetNum,
-    };
+    element.basePatchNum = 1 as BasePatchSetNum;
+    element.patchNum = 3 as RevisionPatchSetNum;
     element.handleDiffAgainstBase();
     assert.isTrue(setUrlStub.called);
     assert.equal(setUrlStub.lastCall.firstArg, '/c/test-project/+/42/3');
@@ -603,10 +548,8 @@
       ...createChangeViewChange(),
       revisions: createRevisions(10),
     };
-    element.patchRange = {
-      basePatchNum: 1 as BasePatchSetNum,
-      patchNum: 3 as RevisionPatchSetNum,
-    };
+    element.basePatchNum = 1 as BasePatchSetNum;
+    element.patchNum = 3 as RevisionPatchSetNum;
     element.handleDiffAgainstLatest();
     assert.isTrue(setUrlStub.called);
     assert.equal(setUrlStub.lastCall.firstArg, '/c/test-project/+/42/1..10');
@@ -617,10 +560,8 @@
       ...createChangeViewChange(),
       revisions: createRevisions(10),
     };
-    element.patchRange = {
-      patchNum: 3 as RevisionPatchSetNum,
-      basePatchNum: 1 as BasePatchSetNum,
-    };
+    element.basePatchNum = 1 as BasePatchSetNum;
+    element.patchNum = 3 as RevisionPatchSetNum;
     element.handleDiffBaseAgainstLeft();
     assert.isTrue(setUrlStub.called);
     assert.equal(setUrlStub.lastCall.firstArg, '/c/test-project/+/42/1');
@@ -631,10 +572,8 @@
       ...createChangeViewChange(),
       revisions: createRevisions(10),
     };
-    element.patchRange = {
-      basePatchNum: 1 as BasePatchSetNum,
-      patchNum: 3 as RevisionPatchSetNum,
-    };
+    element.basePatchNum = 1 as BasePatchSetNum;
+    element.patchNum = 3 as RevisionPatchSetNum;
     element.handleDiffRightAgainstLatest();
     assert.isTrue(setUrlStub.called);
     assert.equal(setUrlStub.lastCall.firstArg, '/c/test-project/+/42/3..10');
@@ -645,10 +584,8 @@
       ...createChangeViewChange(),
       revisions: createRevisions(10),
     };
-    element.patchRange = {
-      basePatchNum: 1 as BasePatchSetNum,
-      patchNum: 3 as RevisionPatchSetNum,
-    };
+    element.basePatchNum = 1 as BasePatchSetNum;
+    element.patchNum = 3 as RevisionPatchSetNum;
     element.handleDiffBaseAgainstLatest();
     assert.isTrue(setUrlStub.called);
     assert.equal(setUrlStub.lastCall.firstArg, '/c/test-project/+/42/10');
@@ -666,10 +603,8 @@
     const removeFromAttentionSetStub = stubRestApi(
       'removeFromAttentionSet'
     ).returns(Promise.resolve(new Response()));
-    element.patchRange = {
-      basePatchNum: 1 as BasePatchSetNum,
-      patchNum: 3 as RevisionPatchSetNum,
-    };
+    element.basePatchNum = 1 as BasePatchSetNum;
+    element.patchNum = 3 as RevisionPatchSetNum;
     await element.updateComplete;
     assert.isNotOk(element.change.attention_set);
     element.handleToggleAttentionSet();
@@ -724,19 +659,6 @@
       assert.equal(element.activeTab, 'change-view-tab-header-url');
     });
 
-    test('param change should switch primary tab correctly', async () => {
-      assert.equal(element.activeTab, Tab.FILES);
-      // view is required
-      element.changeNum = undefined;
-      element.viewState = {
-        ...createChangeViewState(),
-        ...element.viewState,
-        tab: Tab.COMMENT_THREADS,
-      };
-      await element.updateComplete;
-      assert.equal(element.activeTab, Tab.COMMENT_THREADS);
-    });
-
     test('invalid param change should not switch primary tab', async () => {
       assert.equal(element.activeTab, Tab.FILES);
       // view is required
@@ -818,107 +740,59 @@
     });
 
     test('A fires an error event when not logged in', async () => {
-      element.userModel.setAccount(undefined);
+      userModel.setAccount(undefined);
       const loggedInErrorSpy = sinon.spy();
       element.addEventListener('show-auth-required', loggedInErrorSpy);
       pressKey(element, 'a');
       await element.updateComplete;
-      assertIsDefined(element.replyOverlay);
-      assert.isFalse(element.replyOverlay.opened);
+      assertIsDefined(element.replyModal);
+      assert.isFalse(element.replyModalOpened);
       assert.isTrue(loggedInErrorSpy.called);
     });
 
     test('shift A does not open reply overlay', async () => {
       pressKey(element, 'a', Modifier.SHIFT_KEY);
       await element.updateComplete;
-      assertIsDefined(element.replyOverlay);
-      assert.isFalse(element.replyOverlay.opened);
+      assertIsDefined(element.replyModal);
+      assert.isFalse(element.replyModalOpened);
     });
 
     test('A toggles overlay when logged in', async () => {
-      element.change = {
+      // restore clock so that setTimeout in waitUntil() works as expected
+      clock.restore();
+      stubRestApi('getChangeDetail').returns(
+        Promise.resolve(createParsedChange())
+      );
+      const change = {
         ...createChangeViewChange(),
         revisions: createRevisions(1),
         messages: createChangeMessages(1),
       };
-      element.change.labels = {};
+      change.labels = {};
+      element.change = change;
+
+      changeModel.setState({
+        loadingStatus: LoadingStatus.LOADED,
+        change,
+      });
+
       await element.updateComplete;
 
       const openSpy = sinon.spy(element, 'openReplyDialog');
 
       pressKey(element, 'a');
       await element.updateComplete;
-      assertIsDefined(element.replyOverlay);
-      assert.isTrue(element.replyOverlay.opened);
-      element.replyOverlay.close();
-      assert.isFalse(element.replyOverlay.opened);
+      assertIsDefined(element.replyModal);
+      assert.isTrue(element.replyModalOpened);
+      sinon.spy(element.replyDialog!, 'open');
+      await waitUntilVisible(element.replyDialog!);
+      element.replyModal.close();
       assert(
         openSpy.lastCall.calledWithExactly(FocusTarget.ANY),
         'openReplyDialog should have been passed ANY'
       );
       assert.equal(openSpy.callCount, 1);
-    });
-
-    test('fullscreen-overlay-opened hides content', async () => {
-      element.loggedIn = true;
-      element.loading = false;
-      element.change = {
-        ...createChangeViewChange(),
-        labels: {},
-        actions: {
-          abandon: {
-            enabled: true,
-            label: 'Abandon',
-            method: HttpMethod.POST,
-            title: 'Abandon',
-          },
-        },
-      };
-      await element.updateComplete;
-      const handlerSpy = sinon.spy(element, 'handleHideBackgroundContent');
-      const overlay = queryAndAssert<GrOverlay>(element, '#replyOverlay');
-      overlay.dispatchEvent(
-        new CustomEvent('fullscreen-overlay-opened', {
-          composed: true,
-          bubbles: true,
-        })
-      );
-      await element.updateComplete;
-      assert.isTrue(handlerSpy.called);
-      assertIsDefined(element.mainContent);
-      assertIsDefined(element.actions);
-      assert.isTrue(element.mainContent.classList.contains('overlayOpen'));
-      assert.equal(getComputedStyle(element.actions).display, 'flex');
-    });
-
-    test('fullscreen-overlay-closed shows content', async () => {
-      element.loggedIn = true;
-      element.loading = false;
-      element.change = {
-        ...createChangeViewChange(),
-        labels: {},
-        actions: {
-          abandon: {
-            enabled: true,
-            label: 'Abandon',
-            method: HttpMethod.POST,
-            title: 'Abandon',
-          },
-        },
-      };
-      await element.updateComplete;
-      const handlerSpy = sinon.spy(element, 'handleShowBackgroundContent');
-      const overlay = queryAndAssert<GrOverlay>(element, '#replyOverlay');
-      overlay.dispatchEvent(
-        new CustomEvent('fullscreen-overlay-closed', {
-          composed: true,
-          bubbles: true,
-        })
-      );
-      await element.updateComplete;
-      assert.isTrue(handlerSpy.called);
-      assertIsDefined(element.mainContent);
-      assert.isFalse(element.mainContent.classList.contains('overlayOpen'));
+      await waitUntil(() => !element.replyModalOpened);
     });
 
     test('expand all messages when expand-diffs fired', () => {
@@ -968,10 +842,8 @@
     });
 
     test('d should open download overlay', () => {
-      assertIsDefined(element.downloadOverlay);
-      const stub = sinon
-        .stub(element.downloadOverlay, 'open')
-        .returns(Promise.resolve());
+      assertIsDefined(element.downloadModal);
+      const stub = sinon.stub(element.downloadModal, 'showModal');
       pressKey(element, 'd');
       assert.isTrue(stub.called);
     });
@@ -991,14 +863,14 @@
     });
 
     test('m should toggle diff mode', async () => {
-      const updatePreferencesStub = stubUsers('updatePreferences');
+      const updatePreferencesStub = sinon.stub(userModel, 'updatePreferences');
       await element.updateComplete;
 
       const prefs = {
         ...createDefaultPreferences(),
         diff_view: DiffViewMode.SIDE_BY_SIDE,
       };
-      element.userModel.setPreferences(prefs);
+      userModel.setPreferences(prefs);
       element.handleToggleDiffMode();
       assert.isTrue(
         updatePreferencesStub.calledWith({diff_view: DiffViewMode.UNIFIED})
@@ -1008,7 +880,7 @@
         ...createDefaultPreferences(),
         diff_view: DiffViewMode.UNIFIED,
       };
-      element.userModel.setPreferences(newPrefs);
+      userModel.setPreferences(newPrefs);
       await element.updateComplete;
       element.handleToggleDiffMode();
       assert.isTrue(
@@ -1020,10 +892,8 @@
   suite('thread list and change log tabs', () => {
     setup(() => {
       element.changeNum = TEST_NUMERIC_CHANGE_ID;
-      element.patchRange = {
-        basePatchNum: PARENT,
-        patchNum: 1 as RevisionPatchSetNum,
-      };
+      element.basePatchNum = PARENT;
+      element.patchNum = 1 as RevisionPatchSetNum;
       element.change = {
         ...createChangeViewChange(),
         revisions: {
@@ -1043,12 +913,6 @@
           },
         },
       };
-      const relatedChanges = element.shadowRoot!.querySelector(
-        '#relatedChanges'
-      ) as GrRelatedChangesList;
-      sinon.stub(relatedChanges, 'reload');
-      sinon.stub(element, 'loadData').returns(Promise.resolve());
-      sinon.spy(element, 'viewStateChanged');
       element.viewState = createChangeViewState();
     });
   });
@@ -1228,183 +1092,6 @@
     );
   });
 
-  test('changeStatuses', async () => {
-    element.loading = false;
-    element.change = {
-      ...createChangeViewChange(),
-      revisions: {
-        rev2: createRevision(2),
-        rev1: createRevision(1),
-        rev13: createRevision(13),
-        rev3: createRevision(3),
-      },
-      current_revision: 'rev3' as CommitId,
-      status: ChangeStatus.MERGED,
-      labels: {
-        test: {
-          all: [],
-          default_value: 0,
-          values: {},
-          approved: {},
-        },
-      },
-    };
-    element.mergeable = true;
-    await element.updateComplete;
-    const expectedStatuses = [ChangeStates.MERGED];
-    assert.deepEqual(element.changeStatuses, expectedStatuses);
-    const statusChips =
-      element.shadowRoot!.querySelectorAll('gr-change-status');
-    assert.equal(statusChips.length, 1);
-  });
-
-  suite('ChangeStatus revert', () => {
-    test('do not show any chip if no revert created', async () => {
-      const change = {
-        ...createParsedChange(),
-        messages: createChangeMessages(2),
-      };
-      const getChangeStub = stubRestApi('getChange');
-      getChangeStub.onFirstCall().returns(
-        Promise.resolve({
-          ...createChange(),
-        })
-      );
-      getChangeStub.onSecondCall().returns(
-        Promise.resolve({
-          ...createChange(),
-        })
-      );
-      element.change = change;
-      element.mergeable = true;
-      element.currentRevisionActions = {submit: {enabled: true}};
-      assert.isTrue(element.isSubmitEnabled());
-      await element.updateComplete;
-      element.computeRevertSubmitted(element.change);
-      await element.updateComplete;
-      assert.isFalse(
-        element.changeStatuses?.includes(ChangeStates.REVERT_SUBMITTED)
-      );
-      assert.isFalse(
-        element.changeStatuses?.includes(ChangeStates.REVERT_CREATED)
-      );
-    });
-
-    test('do not show any chip if all reverts are abandoned', async () => {
-      const change = {
-        ...createParsedChange(),
-        messages: createChangeMessages(2),
-      };
-      change.messages[0].message = 'Created a revert of this change as 12345';
-      change.messages[0].tag = MessageTag.TAG_REVERT as ReviewInputTag;
-
-      change.messages[1].message = 'Created a revert of this change as 23456';
-      change.messages[1].tag = MessageTag.TAG_REVERT as ReviewInputTag;
-
-      const getChangeStub = stubRestApi('getChange');
-      getChangeStub.onFirstCall().returns(
-        Promise.resolve({
-          ...createChange(),
-          status: ChangeStatus.ABANDONED,
-        })
-      );
-      getChangeStub.onSecondCall().returns(
-        Promise.resolve({
-          ...createChange(),
-          status: ChangeStatus.ABANDONED,
-        })
-      );
-      element.change = change;
-      element.mergeable = true;
-      element.currentRevisionActions = {submit: {enabled: true}};
-      assert.isTrue(element.isSubmitEnabled());
-      await element.updateComplete;
-      element.computeRevertSubmitted(element.change);
-      await element.updateComplete;
-      assert.isFalse(
-        element.changeStatuses?.includes(ChangeStates.REVERT_SUBMITTED)
-      );
-      assert.isFalse(
-        element.changeStatuses?.includes(ChangeStates.REVERT_CREATED)
-      );
-    });
-
-    test('show revert created if no revert is merged', async () => {
-      const change = {
-        ...createParsedChange(),
-        messages: createChangeMessages(2),
-      };
-      change.messages[0].message = 'Created a revert of this change as 12345';
-      change.messages[0].tag = MessageTag.TAG_REVERT as ReviewInputTag;
-
-      change.messages[1].message = 'Created a revert of this change as 23456';
-      change.messages[1].tag = MessageTag.TAG_REVERT as ReviewInputTag;
-
-      const getChangeStub = stubRestApi('getChange');
-      getChangeStub.onFirstCall().returns(
-        Promise.resolve({
-          ...createChange(),
-        })
-      );
-      getChangeStub.onSecondCall().returns(
-        Promise.resolve({
-          ...createChange(),
-        })
-      );
-      element.change = change;
-      element.mergeable = true;
-      element.currentRevisionActions = {submit: {enabled: true}};
-      assert.isTrue(element.isSubmitEnabled());
-      await element.updateComplete;
-      element.computeRevertSubmitted(element.change);
-      // Wait for promises to settle.
-      await waitEventLoop();
-      await element.updateComplete;
-      assert.isFalse(
-        element.changeStatuses?.includes(ChangeStates.REVERT_SUBMITTED)
-      );
-      assert.isTrue(
-        element.changeStatuses?.includes(ChangeStates.REVERT_CREATED)
-      );
-    });
-
-    test('show revert submitted if revert is merged', async () => {
-      const change = {
-        ...createParsedChange(),
-        messages: createChangeMessages(2),
-      };
-      change.messages[0].message = 'Created a revert of this change as 12345';
-      change.messages[0].tag = MessageTag.TAG_REVERT as ReviewInputTag;
-      const getChangeStub = stubRestApi('getChange');
-      getChangeStub.onFirstCall().returns(
-        Promise.resolve({
-          ...createChange(),
-          status: ChangeStatus.MERGED,
-        })
-      );
-      getChangeStub.onSecondCall().returns(
-        Promise.resolve({
-          ...createChange(),
-        })
-      );
-      element.change = change;
-      element.mergeable = true;
-      element.currentRevisionActions = {submit: {enabled: true}};
-      assert.isTrue(element.isSubmitEnabled());
-      await element.updateComplete;
-      element.computeRevertSubmitted(element.change);
-      // Wait for promises to settle.
-      await waitEventLoop();
-      await element.updateComplete;
-      assert.isFalse(
-        element.changeStatuses?.includes(ChangeStates.REVERT_CREATED)
-      );
-      assert.isTrue(
-        element.changeStatuses?.includes(ChangeStates.REVERT_SUBMITTED)
-      );
-    });
-  });
-
   test('diff preferences open when open-diff-prefs is fired', async () => {
     await element.updateComplete;
     assertIsDefined(element.fileList);
@@ -1442,66 +1129,6 @@
     assert.isTrue(element.isSubmitEnabled());
   });
 
-  test('reload is called when an approved label is removed', async () => {
-    const vote: ApprovalInfo = {
-      ...createApproval(),
-      _account_id: 1 as AccountId,
-      name: 'bojack',
-      value: 1,
-    };
-    element.changeNum = TEST_NUMERIC_CHANGE_ID;
-    element.patchRange = {
-      basePatchNum: PARENT,
-      patchNum: 1 as RevisionPatchSetNum,
-    };
-    const change = {
-      ...createParsedChange(),
-      owner: createAccountWithIdNameAndEmail(),
-      revisions: {
-        rev2: createRevision(2),
-        rev1: createRevision(1),
-        rev13: createRevision(13),
-        rev3: createRevision(3),
-      },
-      current_revision: 'rev3' as CommitId,
-      status: ChangeStatus.NEW,
-      labels: {
-        test: {
-          all: [vote],
-          default_value: 0,
-          values: {},
-          approved: {},
-        },
-      },
-    };
-    element.change = change;
-    await element.updateComplete;
-    const reloadStub = sinon.stub(element, 'loadData');
-    const newChange = {...element.change};
-    (newChange.labels!.test! as DetailedLabelInfo).all = [];
-    element.change = deepClone(newChange);
-    await element.updateComplete;
-    assert.isFalse(reloadStub.called);
-
-    assert.isDefined(element.change);
-    const testLabels: DetailedLabelInfo & QuickLabelInfo =
-      newChange.labels!.test;
-    assertIsDefined(testLabels);
-    testLabels.all!.push(vote);
-    testLabels.all!.push(vote);
-    testLabels.approved = vote;
-    element.change = deepClone(newChange);
-    await element.updateComplete;
-    assert.isFalse(reloadStub.called);
-
-    assert.isDefined(element.change);
-    (newChange.labels!.test! as DetailedLabelInfo).all = [];
-    element.change = deepClone(newChange);
-    await element.updateComplete;
-    assert.isTrue(reloadStub.called);
-    assert.isTrue(reloadStub.calledOnce);
-  });
-
   test('reply button has updated count when there are drafts', () => {
     const getLabel = (canReview: boolean) => {
       element.change!.actions!.ready = {enabled: canReview};
@@ -1509,205 +1136,19 @@
     };
     element.change = createParsedChange();
     element.change.actions = {};
-    element.diffDrafts = undefined;
-    assert.equal(getLabel(false), 'Reply');
-    assert.equal(getLabel(true), 'Reply');
-
-    element.diffDrafts = {};
+    element.draftCount = 0;
     assert.equal(getLabel(false), 'Reply');
     assert.equal(getLabel(true), 'Start Review');
 
-    element.diffDrafts = {
-      'file1.txt': [createDraft()],
-      'file2.txt': [createDraft(), createDraft()],
-    };
+    element.draftCount = 0;
+    assert.equal(getLabel(false), 'Reply');
+    assert.equal(getLabel(true), 'Start Review');
+
+    element.draftCount = 3;
     assert.equal(getLabel(false), 'Reply (3)');
     assert.equal(getLabel(true), 'Start Review (3)');
   });
 
-  test('change num change', async () => {
-    const change = {
-      ...createChangeViewChange(),
-      labels: {},
-    } as ParsedChangeInfo;
-    element.changeNum = undefined;
-    element.patchRange = {
-      basePatchNum: PARENT,
-      patchNum: 2 as RevisionPatchSetNum,
-    };
-    element.change = change;
-    assertIsDefined(element.fileList);
-    assert.equal(element.fileList.numFilesShown, DEFAULT_NUM_FILES_SHOWN);
-    element.fileList.numFilesShown = 150;
-    element.fileList.selectedIndex = 15;
-    await element.updateComplete;
-
-    element.changeNum = 2 as NumericChangeId;
-    element.viewState = {
-      ...createChangeViewState(),
-      changeNum: 2 as NumericChangeId,
-    };
-    await element.updateComplete;
-    assert.equal(element.fileList.numFilesShown, DEFAULT_NUM_FILES_SHOWN);
-    assert.equal(element.fileList.selectedIndex, 0);
-  });
-
-  test('don’t reload entire page when patchRange changes', async () => {
-    const reloadStub = sinon
-      .stub(element, 'loadData')
-      .callsFake(() => Promise.resolve());
-    const reloadPatchDependentStub = sinon
-      .stub(element, 'reloadPatchNumDependentResources')
-      .callsFake(() => Promise.resolve([undefined, undefined, undefined]));
-    assertIsDefined(element.fileList);
-    await element.fileList.updateComplete;
-    const collapseStub = sinon.stub(element.fileList, 'collapseAllDiffs');
-    const value: ChangeViewState = {
-      ...createChangeViewState(),
-      view: GerritView.CHANGE,
-      patchNum: 1 as RevisionPatchSetNum,
-    };
-    element.changeNum = undefined;
-    element.viewState = value;
-    await element.updateComplete;
-    assert.isTrue(reloadStub.calledOnce);
-
-    element.initialLoadComplete = true;
-    element.fileList.selectedIndex = 15;
-    element.change = {
-      ...createChangeViewChange(),
-      revisions: {
-        rev1: createRevision(1),
-        rev2: createRevision(2),
-      },
-    };
-
-    value.basePatchNum = 1 as BasePatchSetNum;
-    value.patchNum = 2 as RevisionPatchSetNum;
-    element.viewState = {...value};
-    await element.updateComplete;
-    await waitEventLoop();
-    assert.equal(element.fileList.selectedIndex, 0);
-    assert.isFalse(reloadStub.calledTwice);
-    assert.isTrue(reloadPatchDependentStub.calledOnce);
-    assert.isTrue(collapseStub.calledTwice);
-  });
-
-  test('reload ported comments when patchNum changes', async () => {
-    assertIsDefined(element.fileList);
-    sinon.stub(element, 'loadData').callsFake(() => Promise.resolve());
-    sinon.stub(element, 'loadAndSetCommitInfo');
-    await element.updateComplete;
-    const reloadPortedCommentsStub = sinon.stub(
-      element.getCommentsModel(),
-      'reloadPortedComments'
-    );
-    const reloadPortedDraftsStub = sinon.stub(
-      element.getCommentsModel(),
-      'reloadPortedDrafts'
-    );
-    sinon.stub(element.fileList, 'collapseAllDiffs');
-
-    const value: ChangeViewState = {
-      ...createChangeViewState(),
-      view: GerritView.CHANGE,
-      patchNum: 1 as RevisionPatchSetNum,
-    };
-    element.viewState = value;
-    await element.updateComplete;
-
-    element.initialLoadComplete = true;
-    element.change = {
-      ...createChangeViewChange(),
-      revisions: {
-        rev1: createRevision(1),
-        rev2: createRevision(2),
-      },
-    };
-
-    value.basePatchNum = 1 as BasePatchSetNum;
-    value.patchNum = 2 as RevisionPatchSetNum;
-    element.viewState = {...value};
-    await element.updateComplete;
-    assert.isTrue(reloadPortedCommentsStub.calledOnce);
-    assert.isTrue(reloadPortedDraftsStub.calledOnce);
-  });
-
-  test('do not reload entire page when patchRange doesnt change', async () => {
-    assertIsDefined(element.fileList);
-    const reloadStub = sinon
-      .stub(element, 'loadData')
-      .callsFake(() => Promise.resolve());
-    const collapseStub = sinon.stub(element.fileList, 'collapseAllDiffs');
-    const value: ChangeViewState = createChangeViewState();
-    element.viewState = value;
-    // change already loaded
-    assert.isOk(element.changeNum);
-    await element.updateComplete;
-    assert.isFalse(reloadStub.calledOnce);
-    element.initialLoadComplete = true;
-    element.viewState = {...value};
-    await element.updateComplete;
-    assert.isFalse(reloadStub.calledTwice);
-    assert.isFalse(collapseStub.calledTwice);
-  });
-
-  test('forceReload updates the change', async () => {
-    assertIsDefined(element.fileList);
-    const getChangeStub = stubRestApi('getChangeDetail').returns(
-      Promise.resolve(createParsedChange())
-    );
-    const loadDataStub = sinon
-      .stub(element, 'loadData')
-      .callsFake(() => Promise.resolve());
-    const collapseStub = sinon.stub(element.fileList, 'collapseAllDiffs');
-    element.viewState = {...createChangeViewState(), forceReload: true};
-    await element.updateComplete;
-    assert.isTrue(getChangeStub.called);
-    assert.isTrue(loadDataStub.called);
-    assert.isTrue(collapseStub.called);
-    // patchNum is set by changeChanged, so this verifies that change was set.
-    assert.isOk(element.patchRange?.patchNum);
-  });
-
-  test('do not handle new change numbers', async () => {
-    const recreateSpy = sinon.spy();
-    element.addEventListener('recreate-change-view', recreateSpy);
-
-    const value: ChangeViewState = createChangeViewState();
-    element.viewState = value;
-    await element.updateComplete;
-    assert.isFalse(recreateSpy.calledOnce);
-
-    value.changeNum = 555111333 as NumericChangeId;
-    element.viewState = {...value};
-    await element.updateComplete;
-    assert.isTrue(recreateSpy.calledOnce);
-  });
-
-  test('related changes are updated when loadData is called', async () => {
-    await element.updateComplete;
-    const relatedChanges = element.shadowRoot!.querySelector(
-      '#relatedChanges'
-    ) as GrRelatedChangesList;
-    const reloadStub = sinon.stub(relatedChanges, 'reload');
-    stubRestApi('getMergeable').returns(
-      Promise.resolve({...createMergeable(), mergeable: true})
-    );
-
-    element.viewState = createChangeViewState();
-    element.getChangeModel().setState({
-      loadingStatus: LoadingStatus.LOADED,
-      change: {
-        ...createChangeViewChange(),
-      },
-    });
-
-    await element.loadData(true);
-    assert.isFalse(setUrlStub.called);
-    assert.isTrue(reloadStub.called);
-  });
-
   test('computeCopyTextForTitle', () => {
     element.change = {
       ...createChangeViewChange(),
@@ -1725,26 +1166,6 @@
     );
   });
 
-  test('get latest revision', () => {
-    let change: ChangeInfo = {
-      ...createChange(),
-      revisions: {
-        rev1: createRevision(1),
-        rev3: createRevision(3),
-      },
-      current_revision: 'rev3' as CommitId,
-    };
-    assert.equal(element.getLatestRevisionSHA(change), 'rev3');
-    change = {
-      ...createChange(),
-      revisions: {
-        rev1: createRevision(1),
-      },
-      current_revision: undefined,
-    };
-    assert.equal(element.getLatestRevisionSHA(change), 'rev1');
-  });
-
   test('show commit message edit button', () => {
     const change = createParsedChange();
     const mergedChanged: ParsedChangeInfo = {
@@ -1767,6 +1188,7 @@
   });
 
   test('handleCommitMessageSave trims trailing whitespace', async () => {
+    element.changeNum = TEST_NUMERIC_CHANGE_ID;
     element.change = createChangeViewChange();
     // Response code is 500, because we want to avoid window reloading
     const putStub = stubRestApi('putChangeCommitMessage').returns(
@@ -1786,182 +1208,6 @@
     element.handleCommitMessageSave(mockEvent('\n\n\n\n\n\n\n\n'));
     assert.equal(putStub.lastCall.args[1], '\n\n\n\n\n\n\n\n');
   });
-  test('computeChangeIdCommitMessageError', () => {
-    let commitMessage = 'Change-Id: I4ce18b2395bca69d7a9aa48bf4554faa56282483';
-    let change: ParsedChangeInfo = {
-      ...createChangeViewChange(),
-      change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282483' as ChangeId,
-    };
-    assert.equal(
-      element.computeChangeIdCommitMessageError(commitMessage, change),
-      null
-    );
-
-    change = {
-      ...createChangeViewChange(),
-      change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282484' as ChangeId,
-    };
-    assert.equal(
-      element.computeChangeIdCommitMessageError(commitMessage, change),
-      'mismatch'
-    );
-
-    commitMessage = 'This is the greatest change.';
-    assert.equal(
-      element.computeChangeIdCommitMessageError(commitMessage, change),
-      'missing'
-    );
-  });
-
-  test('multiple change Ids in commit message picks last', () => {
-    const commitMessage = [
-      'Change-Id: I4ce18b2395bca69d7a9aa48bf4554faa56282484',
-      'Change-Id: I4ce18b2395bca69d7a9aa48bf4554faa56282483',
-    ].join('\n');
-    let change: ParsedChangeInfo = {
-      ...createChangeViewChange(),
-      change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282483' as ChangeId,
-    };
-    assert.equal(
-      element.computeChangeIdCommitMessageError(commitMessage, change),
-      null
-    );
-    change = {
-      ...createChangeViewChange(),
-      change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282484' as ChangeId,
-    };
-    assert.equal(
-      element.computeChangeIdCommitMessageError(commitMessage, change),
-      'mismatch'
-    );
-  });
-
-  test('does not count change Id that starts mid line', () => {
-    const commitMessage = [
-      'Change-Id: I4ce18b2395bca69d7a9aa48bf4554faa56282484',
-      'Change-Id: I4ce18b2395bca69d7a9aa48bf4554faa56282483',
-    ].join(' and ');
-    let change: ParsedChangeInfo = {
-      ...createChangeViewChange(),
-      change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282484' as ChangeId,
-    };
-    assert.equal(
-      element.computeChangeIdCommitMessageError(commitMessage, change),
-      null
-    );
-    change = {
-      ...createChangeViewChange(),
-      change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282483' as ChangeId,
-    };
-    assert.equal(
-      element.computeChangeIdCommitMessageError(commitMessage, change),
-      'mismatch'
-    );
-  });
-
-  test('computeTitleAttributeWarning', () => {
-    let changeIdCommitMessageError = 'missing';
-    assert.equal(
-      element.computeTitleAttributeWarning(changeIdCommitMessageError),
-      'No Change-Id in commit message'
-    );
-
-    changeIdCommitMessageError = 'mismatch';
-    assert.equal(
-      element.computeTitleAttributeWarning(changeIdCommitMessageError),
-      'Change-Id mismatch'
-    );
-  });
-
-  test('computeChangeIdClass', () => {
-    let changeIdCommitMessageError = 'missing';
-    assert.equal(element.computeChangeIdClass(changeIdCommitMessageError), '');
-
-    changeIdCommitMessageError = 'mismatch';
-    assert.equal(
-      element.computeChangeIdClass(changeIdCommitMessageError),
-      'warning'
-    );
-  });
-
-  test('topic is coalesced to null', async () => {
-    sinon.stub(element, 'changeChanged');
-    element.getChangeModel().setState({
-      loadingStatus: LoadingStatus.LOADED,
-      change: {
-        ...createChangeViewChange(),
-        labels: {},
-        current_revision: 'foo' as CommitId,
-        revisions: {foo: createRevision()},
-      },
-    });
-
-    await element.performPostChangeLoadTasks();
-    assert.isNull(element.change!.topic);
-  });
-
-  test('commit sha is populated from getChangeDetail', async () => {
-    element.getChangeModel().setState({
-      loadingStatus: LoadingStatus.LOADED,
-      change: {
-        ...createChangeViewChange(),
-        labels: {},
-        current_revision: 'foo' as CommitId,
-        revisions: {foo: createRevision()},
-      },
-    });
-
-    await element.performPostChangeLoadTasks();
-    assert.equal('foo', element.commitInfo!.commit);
-  });
-
-  test('getBasePatchNum', async () => {
-    element.change = {
-      ...createChangeViewChange(),
-      revisions: {
-        '98da160735fb81604b4c40e93c368f380539dd0e': createRevision(),
-      },
-    };
-    element.patchRange = {
-      basePatchNum: PARENT,
-    };
-    await element.updateComplete;
-    assert.equal(element.getBasePatchNum(), PARENT);
-
-    element.prefs = {
-      ...createPreferences(),
-      default_base_for_merges: DefaultBase.FIRST_PARENT,
-    };
-
-    element.change = {
-      ...createChangeViewChange(),
-      revisions: {
-        '98da160735fb81604b4c40e93c368f380539dd0e': {
-          ...createRevision(1),
-          commit: {
-            ...createCommit(),
-            parents: [
-              {
-                commit: '6e12bdf1176eb4ab24d8491ba3b6d0704409cde8' as CommitId,
-                subject: 'test',
-              },
-              {
-                commit: '22f7db4754b5d9816fc581f3d9a6c0ef8429c841' as CommitId,
-                subject: 'test3',
-              },
-            ],
-          },
-        },
-      },
-    };
-    await element.updateComplete;
-    assert.equal(element.getBasePatchNum(), -1 as BasePatchSetNum);
-
-    element.patchRange.basePatchNum = PARENT;
-    element.patchRange.patchNum = 1 as RevisionPatchSetNum;
-    await element.updateComplete;
-    assert.equal(element.getBasePatchNum(), PARENT);
-  });
 
   test('openReplyDialog called with `ANY` when coming from tap event', async () => {
     await element.updateComplete;
@@ -1975,29 +1221,11 @@
     assert.equal(openStub.callCount, 1);
   });
 
-  test(
-    'openReplyDialog called with `BODY` when coming from message reply' +
-      'event',
-    async () => {
-      await element.updateComplete;
-      const openStub = sinon.stub(element, 'openReplyDialog');
-      element.messagesList!.dispatchEvent(
-        new CustomEvent('reply', {
-          detail: {message: {message: 'text'}},
-          composed: true,
-          bubbles: true,
-        })
-      );
-      assert.isTrue(openStub.calledOnce);
-      assert.equal(openStub.lastCall.args[0], FocusTarget.BODY);
-    }
-  );
-
   test('reply dialog focus can be controlled', () => {
     const openStub = sinon.stub(element, 'openReplyDialog');
 
     const e = new CustomEvent('show-reply-dialog', {
-      detail: {value: {ccsOnly: false}},
+      detail: {value: {reviewersOnly: true, ccsOnly: false}},
     });
     element.handleShowReplyDialog(e);
     assert(
@@ -2006,7 +1234,7 @@
     );
     assert.equal(openStub.callCount, 1);
 
-    e.detail.value = {ccsOnly: true};
+    e.detail.value = {reviewersOnly: false, ccsOnly: true};
     element.handleShowReplyDialog(e);
     assert(
       openStub.lastCall.calledWithExactly(FocusTarget.CCS),
@@ -2031,13 +1259,11 @@
 
   test('revert dialog opened with revert param', async () => {
     const awaitPluginsLoadedStub = sinon
-      .stub(getPluginLoader(), 'awaitPluginsLoaded')
+      .stub(testResolver(pluginLoaderToken), 'awaitPluginsLoaded')
       .callsFake(() => Promise.resolve());
 
-    element.patchRange = {
-      basePatchNum: PARENT,
-      patchNum: 2 as RevisionPatchSetNum,
-    };
+    element.basePatchNum = PARENT;
+    element.patchNum = 2 as RevisionPatchSetNum;
     element.change = {
       ...createChangeViewChange(),
       revisions: {
@@ -2091,21 +1317,6 @@
       await element.updateComplete;
       assert.isTrue(openReplyDialogStub.calledOnce);
     });
-
-    test('reply from comment adds quote text', async () => {
-      const e = new CustomEvent('', {
-        detail: {message: {message: 'quote text'}},
-      });
-      element.handleMessageReply(e);
-      const dialog = await waitQueryAndAssert<GrReplyDialog>(
-        element,
-        '#replyDialog'
-      );
-      const openSpy = sinon.spy(dialog, 'open');
-      await element.updateComplete;
-      await waitUntil(() => openSpy.called && !!openSpy.lastCall.args[1]);
-      assert.equal(openSpy.lastCall.args[1], '> quote text\n\n');
-    });
   });
 
   test('header class computation', () => {
@@ -2116,14 +1327,18 @@
   });
 
   test('maybeScrollToMessage', async () => {
+    element.change = {
+      ...createChangeViewChange(),
+      messages: createChangeMessages(1),
+    };
     await element.updateComplete;
     const scrollStub = sinon.stub(element.messagesList!, 'scrollToMessage');
 
-    element.maybeScrollToMessage('');
+    await element.maybeScrollToMessage('');
     assert.isFalse(scrollStub.called);
-    element.maybeScrollToMessage('message');
+    await element.maybeScrollToMessage('message');
     assert.isFalse(scrollStub.called);
-    element.maybeScrollToMessage('#message-TEST');
+    await element.maybeScrollToMessage('#message-TEST');
     assert.isTrue(scrollStub.called);
     assert.equal(scrollStub.lastCall.args[0], 'TEST');
   });
@@ -2131,6 +1346,8 @@
   test('computeEditMode', async () => {
     const callCompute = async (viewState: ChangeViewState) => {
       element.viewState = viewState;
+      element.patchNum = viewState.patchNum;
+      element.basePatchNum = viewState.basePatchNum ?? PARENT;
       await element.updateComplete;
       return element.getEditMode();
     };
@@ -2158,46 +1375,8 @@
     );
   });
 
-  test('processEdit', () => {
-    element.patchRange = {};
-    const change: ParsedChangeInfo = {
-      ...createChangeViewChange(),
-      current_revision: 'foo' as CommitId,
-      revisions: {
-        foo: {...createRevision()},
-      },
-    };
-
-    // With no edit, nothing happens.
-    element.processEdit(change);
-    assert.equal(element.patchRange.patchNum, undefined);
-
-    change.revisions['bar'] = {
-      _number: EDIT,
-      basePatchNum: 1 as BasePatchSetNum,
-      commit: {
-        ...createCommit(),
-        commit: 'bar' as CommitId,
-      },
-      fetch: {},
-    };
-
-    // When edit is set, but not patchNum, then switch to edit ps.
-    element.processEdit(change);
-    assert.equal(element.patchRange.patchNum, EDIT);
-
-    // When edit is set, but patchNum as well, then keep patchNum.
-    element.patchRange.patchNum = 5 as RevisionPatchSetNum;
-    element.routerPatchNum = 5 as RevisionPatchSetNum;
-    element.processEdit(change);
-    assert.equal(element.patchRange.patchNum, 5 as RevisionPatchSetNum);
-  });
-
   test('file-action-tap handling', async () => {
-    element.patchRange = {
-      basePatchNum: PARENT,
-      patchNum: 1 as RevisionPatchSetNum,
-    };
+    element.patchNum = 1 as RevisionPatchSetNum;
     element.change = {
       ...createChangeViewChange(),
     };
@@ -2268,107 +1447,6 @@
     assert.isTrue(setUrlStub.called);
   });
 
-  test('selectedRevision updates when patchNum is changed', async () => {
-    const revision1: RevisionInfo = createRevision(1);
-    const revision2: RevisionInfo = createRevision(2);
-    element.getChangeModel().setState({
-      loadingStatus: LoadingStatus.LOADED,
-      change: {
-        ...createChangeViewChange(),
-        revisions: {
-          aaa: revision1,
-          bbb: revision2,
-        },
-        labels: {},
-        actions: {},
-        current_revision: 'bbb' as CommitId,
-      },
-    });
-    element.userModel.setPreferences(createPreferences());
-
-    element.patchRange = {patchNum: 2 as RevisionPatchSetNum};
-    await element.performPostChangeLoadTasks();
-    assert.strictEqual(element.selectedRevision, revision2);
-
-    element.patchRange = {patchNum: 1 as RevisionPatchSetNum};
-    await element.updateComplete;
-    assert.strictEqual(element.selectedRevision, revision1);
-  });
-
-  test('selectedRevision is assigned when patchNum is edit', async () => {
-    const revision1 = createRevision(1);
-    const revision2 = createRevision(2);
-    const revision3 = createEditRevision();
-    element.getChangeModel().setState({
-      loadingStatus: LoadingStatus.LOADED,
-      change: {
-        ...createChangeViewChange(),
-        revisions: {
-          aaa: revision1,
-          bbb: revision2,
-          ccc: revision3,
-        },
-        labels: {},
-        actions: {},
-        current_revision: 'ccc' as CommitId,
-      },
-    });
-    stubRestApi('getPreferences').returns(Promise.resolve(createPreferences()));
-
-    element.patchRange = {patchNum: EDIT};
-    await element.performPostChangeLoadTasks();
-    assert.strictEqual(element.selectedRevision, revision3);
-  });
-
-  test('sendShowChangeEvent', () => {
-    const change = {...createChangeViewChange(), labels: {}};
-    element.change = {...change};
-    element.patchRange = {patchNum: 4 as RevisionPatchSetNum};
-    element.mergeable = true;
-    const showStub = sinon.stub(element.jsAPI, 'handleEvent');
-    element.sendShowChangeEvent();
-    assert.isTrue(showStub.calledOnce);
-    assert.equal(showStub.lastCall.args[0], EventType.SHOW_CHANGE);
-    assert.deepEqual(showStub.lastCall.args[1], {
-      change,
-      patchNum: 4,
-      info: {mergeable: true},
-    });
-  });
-
-  test('patch range changed', () => {
-    element.patchRange = undefined;
-    element.change = createChangeViewChange();
-    element.change.revisions = createRevisions(4);
-    element.change.current_revision = '1' as CommitId;
-    element.change = {...element.change};
-
-    const viewState = createChangeViewState();
-
-    assert.isFalse(element.hasPatchRangeChanged(viewState));
-    assert.isFalse(element.hasPatchNumChanged(viewState));
-
-    viewState.basePatchNum = PARENT;
-    // undefined means navigate to latest patchset
-    viewState.patchNum = undefined;
-
-    element.patchRange = {
-      patchNum: 2 as RevisionPatchSetNum,
-      basePatchNum: PARENT,
-    };
-
-    assert.isTrue(element.hasPatchRangeChanged(viewState));
-    assert.isTrue(element.hasPatchNumChanged(viewState));
-
-    element.patchRange = {
-      patchNum: 4 as RevisionPatchSetNum,
-      basePatchNum: PARENT,
-    };
-
-    assert.isFalse(element.hasPatchRangeChanged(viewState));
-    assert.isFalse(element.hasPatchNumChanged(viewState));
-  });
-
   suite('handleEditTap', () => {
     let fireEdit: () => void;
 
@@ -2401,7 +1479,7 @@
       const newChange = {...element.change};
       newChange.revisions.rev2 = createRevision(2);
       element.change = newChange;
-      element.routerPatchNum = 1 as RevisionPatchSetNum;
+      element.viewModelPatchNum = 1 as RevisionPatchSetNum;
       await element.updateComplete;
 
       fireEdit();
@@ -2417,7 +1495,7 @@
       const newChange = {...element.change};
       newChange.revisions.rev2 = createRevision(2);
       element.change = newChange;
-      element.patchRange = {patchNum: 2 as RevisionPatchSetNum};
+      element.patchNum = 2 as RevisionPatchSetNum;
       await element.updateComplete;
 
       fireEdit();
@@ -2438,7 +1516,7 @@
     assertIsDefined(element.actions);
     sinon.stub(element.metadata, 'computeLabelNames');
 
-    element.patchRange = {patchNum: 1 as RevisionPatchSetNum};
+    element.patchNum = 1 as RevisionPatchSetNum;
     element.actions.dispatchEvent(
       new CustomEvent('stop-edit-tap', {bubbles: false})
     );
@@ -2453,7 +1531,7 @@
   suite('plugin endpoints', () => {
     test('endpoint params', async () => {
       element.change = {...createChangeViewChange(), labels: {}};
-      element.selectedRevision = createRevision();
+      element.revision = createRevision();
       const promise = mockPromise();
       window.Gerrit.install(
         promise.resolve,
@@ -2467,43 +1545,7 @@
         .getLastAttached();
       assert.strictEqual((hookEl as any).plugin, plugin);
       assert.strictEqual((hookEl as any).change, element.change);
-      assert.strictEqual((hookEl as any).revision, element.selectedRevision);
-    });
-  });
-
-  suite('getMergeability', () => {
-    let getMergeableStub: SinonStubbedMember<RestApiService['getMergeable']>;
-    setup(() => {
-      element.change = {...createChangeViewChange(), labels: {}};
-      getMergeableStub = stubRestApi('getMergeable').returns(
-        Promise.resolve({...createMergeable(), mergeable: true})
-      );
-    });
-
-    test('merged change', () => {
-      element.mergeable = null;
-      element.change!.status = ChangeStatus.MERGED;
-      return element.getMergeability().then(() => {
-        assert.isFalse(element.mergeable);
-        assert.isFalse(getMergeableStub.called);
-      });
-    });
-
-    test('abandoned change', () => {
-      element.mergeable = null;
-      element.change!.status = ChangeStatus.ABANDONED;
-      return element.getMergeability().then(() => {
-        assert.isFalse(element.mergeable);
-        assert.isFalse(getMergeableStub.called);
-      });
-    });
-
-    test('open change', () => {
-      element.mergeable = null;
-      return element.getMergeability().then(() => {
-        assert.isTrue(element.mergeable);
-        assert.isTrue(getMergeableStub.called);
-      });
+      assert.strictEqual((hookEl as any).revision, element.revision);
     });
   });
 
@@ -2525,18 +1567,8 @@
 
   suite('gr-reporting tests', () => {
     setup(() => {
-      element.patchRange = {
-        basePatchNum: PARENT,
-        patchNum: 1 as RevisionPatchSetNum,
-      };
-      sinon
-        .stub(element, 'performPostChangeLoadTasks')
-        .returns(Promise.resolve(false));
-      sinon.stub(element, 'getMergeability').returns(Promise.resolve());
-      sinon.stub(element, 'getLatestCommitMessage').returns(Promise.resolve());
-      sinon
-        .stub(element, 'reloadPatchNumDependentResources')
-        .returns(Promise.resolve([undefined, undefined, undefined]));
+      element.basePatchNum = PARENT;
+      element.patchNum = 1 as RevisionPatchSetNum;
     });
 
     test("don't report changeDisplayed on reply", async () => {
@@ -2554,7 +1586,8 @@
       assert.isFalse(changeFullyLoadedStub.called);
     });
 
-    test('report changeDisplayed on viewStateChanged', async () => {
+    test('report changeDisplayed and changeFullyLoaded', async () => {
+      const commentsModel = testResolver(commentsModelToken);
       stubRestApi('getChangeOrEditFiles').resolves({
         'a-file.js': {},
       });
@@ -2571,9 +1604,9 @@
       element.viewState = {
         ...createChangeViewState(),
         changeNum: TEST_NUMERIC_CHANGE_ID,
-        project: TEST_PROJECT_NAME,
+        repo: TEST_PROJECT_NAME,
       };
-      element.getChangeModel().setState({
+      changeModel.setState({
         loadingStatus: LoadingStatus.LOADED,
         change: {
           ...createChangeViewChange(),
@@ -2582,32 +1615,23 @@
           revisions: {foo: createRevision()},
         },
       });
-      await element.updateComplete;
-      await waitEventLoop();
+
+      await waitUntil(() => changeDisplayStub.called);
       assert.isTrue(changeDisplayStub.called);
+      assert.isFalse(changeFullyLoadedStub.called);
+
+      element.mergeable = true;
+      commentsModel.setState({
+        comments: {},
+        drafts: {},
+        discardedDrafts: [],
+      });
+
+      await waitUntil(() => changeFullyLoadedStub.called);
       assert.isTrue(changeFullyLoadedStub.called);
     });
   });
 
-  test('calculateHasParent', () => {
-    const changeId = '123' as ChangeId;
-    const relatedChanges: RelatedChangeAndCommitInfo[] = [];
-
-    assert.equal(element.calculateHasParent(changeId, relatedChanges), false);
-
-    relatedChanges.push({
-      ...createRelatedChangeAndCommitInfo(),
-      change_id: '123' as ChangeId,
-    });
-    assert.equal(element.calculateHasParent(changeId, relatedChanges), false);
-
-    relatedChanges.push({
-      ...createRelatedChangeAndCommitInfo(),
-      change_id: '234' as ChangeId,
-    });
-    assert.equal(element.calculateHasParent(changeId, relatedChanges), true);
-  });
-
   test('renders sha in copy links', async () => {
     stubFlags('isEnabled').returns(true);
     const sha = '123' as CommitId;
diff --git a/polygerrit-ui/app/elements/change/gr-comments-summary/gr-comments-summary.ts b/polygerrit-ui/app/elements/change/gr-comments-summary/gr-comments-summary.ts
new file mode 100644
index 0000000..bbd6003
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-comments-summary/gr-comments-summary.ts
@@ -0,0 +1,231 @@
+/**
+ * @license
+ * Copyright 2023 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../gr-change-summary/gr-summary-chip';
+import '../../shared/gr-avatar/gr-avatar-stack';
+import '../../shared/gr-icon/gr-icon';
+import {LitElement, css, html, nothing} from 'lit';
+import {customElement, property, state} from 'lit/decorators.js';
+import {
+  getFirstComment,
+  hasHumanReply,
+  isResolved,
+  isRobotThread,
+  isUnresolved,
+} from '../../../utils/comment-util';
+import {pluralize} from '../../../utils/string-util';
+import {AccountInfo, CommentThread} from '../../../types/common';
+import {isDefined} from '../../../types/types';
+import {CommentTabState} from '../../../types/events';
+import {SummaryChipStyles} from '../gr-change-summary/gr-summary-chip';
+import {subscribe} from '../../lit/subscription-controller';
+import {resolve} from '../../../models/dependency';
+import {userModelToken} from '../../../models/user/user-model';
+
+@customElement('gr-comments-summary')
+export class GrCommentsSummary extends LitElement {
+  @property({type: Object})
+  commentThreads?: CommentThread[];
+
+  @property({type: Number})
+  draftCount = 0;
+
+  @property({type: Number})
+  mentionCount = 0;
+
+  @property({type: Boolean})
+  showCommentCategoryName = false;
+
+  @property({type: Boolean})
+  clickableChips = false;
+
+  @property({type: Boolean})
+  emptyWhenNoComments = false;
+
+  @property({type: Boolean})
+  showAvatarForResolved = false;
+
+  @state()
+  selfAccount?: AccountInfo;
+
+  private readonly getUserModel = resolve(this, userModelToken);
+
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.getUserModel().account$,
+      x => (this.selfAccount = x)
+    );
+  }
+
+  static override get styles() {
+    return [
+      css`
+        .zeroState {
+          color: var(--deemphasized-text-color);
+        }
+        gr-avatar-stack {
+          --avatar-size: var(--line-height-small, 16px);
+          --stack-border-color: var(--warning-background);
+        }
+        .unresolvedIcon {
+          font-size: var(--line-height-small);
+          color: var(--warning-foreground);
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    const commentThreads =
+      this.commentThreads?.filter(t => !isRobotThread(t) || hasHumanReply(t)) ??
+      [];
+    const countResolvedComments = commentThreads.filter(isResolved).length;
+    const unresolvedThreads = commentThreads.filter(isUnresolved);
+    const countUnresolvedComments = unresolvedThreads.length;
+    const unresolvedAuthors = this.getAccounts(unresolvedThreads);
+    const resolveAuthors = this.showAvatarForResolved
+      ? this.getAccounts(commentThreads.filter(isResolved))
+      : undefined;
+    return html`
+      ${this.renderZeroState(countResolvedComments, countUnresolvedComments)}
+      ${this.renderDraftChip()} ${this.renderMentionChip()}
+      ${this.renderUnresolvedCommentsChip(
+        countUnresolvedComments,
+        unresolvedAuthors
+      )}
+      ${this.renderResolvedCommentsChip(countResolvedComments, resolveAuthors)}
+    `;
+  }
+
+  private renderZeroState(
+    countResolvedComments: number,
+    countUnresolvedComments: number
+  ) {
+    if (
+      this.emptyWhenNoComments ||
+      !!countResolvedComments ||
+      !!this.draftCount ||
+      !!countUnresolvedComments
+    )
+      return nothing;
+    return html`<span class="zeroState"> No comments</span>`;
+  }
+
+  private renderMentionChip() {
+    if (!this.mentionCount) return nothing;
+    return html` <gr-summary-chip
+      class="mentionSummary"
+      styleType=${SummaryChipStyles.WARNING}
+      category=${CommentTabState.MENTIONS}
+      icon="alternate_email"
+      .clickable=${this.clickableChips}
+    >
+      ${pluralize(this.mentionCount, 'mention')}</gr-summary-chip
+    >`;
+  }
+
+  private renderDraftChip() {
+    if (!this.draftCount) return nothing;
+    return html` <gr-summary-chip
+      styleType=${SummaryChipStyles.INFO}
+      category=${CommentTabState.DRAFTS}
+      icon="rate_review"
+      iconFilled
+      .clickable=${this.clickableChips}
+      title=${this.showCommentCategoryName
+        ? nothing
+        : pluralize(this.draftCount, 'draft')}
+    >
+      ${this.showCommentCategoryName
+        ? pluralize(this.draftCount, 'draft')
+        : this.draftCount}</gr-summary-chip
+    >`;
+  }
+
+  private renderUnresolvedCommentsChip(
+    countUnresolvedComments: number,
+    unresolvedAuthors: AccountInfo[]
+  ) {
+    if (!countUnresolvedComments) return nothing;
+    return html` <gr-summary-chip
+      styleType=${SummaryChipStyles.WARNING}
+      category=${CommentTabState.UNRESOLVED}
+      ?hidden=${!countUnresolvedComments}
+      .clickable=${this.clickableChips}
+      title=${this.showCommentCategoryName
+        ? nothing
+        : `${countUnresolvedComments} unresolved`}
+    >
+      <gr-avatar-stack .accounts=${unresolvedAuthors} imageSize="32">
+        <gr-icon
+          slot="fallback"
+          icon="chat_bubble"
+          filled
+          class="unresolvedIcon"
+        >
+        </gr-icon>
+      </gr-avatar-stack>
+      ${this.showCommentCategoryName
+        ? `${countUnresolvedComments} unresolved`
+        : `${countUnresolvedComments}`}</gr-summary-chip
+    >`;
+  }
+
+  private renderResolvedCommentsChip(
+    countResolvedComments: number,
+    resolvedAuthors?: AccountInfo[]
+  ) {
+    if (!countResolvedComments) return nothing;
+    if (resolvedAuthors) {
+      return html` <gr-summary-chip
+        styleType=${SummaryChipStyles.CHECK}
+        category=${CommentTabState.SHOW_ALL}
+        .clickable=${this.clickableChips}
+        title=${this.showCommentCategoryName
+          ? nothing
+          : `${countResolvedComments} resolved`}
+        icon="mark_chat_read"
+        ><gr-avatar-stack .accounts=${resolvedAuthors} imageSize="32">
+          <gr-icon
+            slot="fallback"
+            icon="chat_bubble"
+            filled
+            class="unresolvedIcon"
+          >
+          </gr-icon> </gr-avatar-stack
+        >${this.showCommentCategoryName
+          ? `${countResolvedComments} resolved`
+          : `${countResolvedComments}`}</gr-summary-chip
+      >`;
+    }
+    return html` <gr-summary-chip
+      styleType=${SummaryChipStyles.CHECK}
+      category=${CommentTabState.SHOW_ALL}
+      .clickable=${this.clickableChips}
+      icon="mark_chat_read"
+      title=${this.showCommentCategoryName
+        ? nothing
+        : `${countResolvedComments} resolved`}
+      >${this.showCommentCategoryName
+        ? `${countResolvedComments} resolved`
+        : `${countResolvedComments}`}</gr-summary-chip
+    >`;
+  }
+
+  getAccounts(commentThreads: CommentThread[]): AccountInfo[] {
+    return commentThreads
+      .map(getFirstComment)
+      .map(comment => comment?.author ?? this.selfAccount)
+      .filter(isDefined);
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-comments-summary': GrCommentsSummary;
+  }
+}
diff --git a/polygerrit-ui/app/elements/change/gr-comments-summary/gr-comments-summary_test.ts b/polygerrit-ui/app/elements/change/gr-comments-summary/gr-comments-summary_test.ts
new file mode 100644
index 0000000..d8eb246
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-comments-summary/gr-comments-summary_test.ts
@@ -0,0 +1,68 @@
+/**
+ * @license
+ * Copyright 2023 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import {fixture, html, assert} from '@open-wc/testing';
+import {GrCommentsSummary} from './gr-comments-summary';
+import {
+  createComment,
+  createCommentThread,
+} from '../../../test/test-data-generators';
+
+suite('gr-comments-summary test', () => {
+  let element: GrCommentsSummary;
+
+  setup(async () => {
+    element = await fixture(
+      html`<gr-comments-summary
+        showCommentCategoryName
+        clickableChips
+      ></gr-comments-summary>`
+    );
+  });
+
+  test('is defined', () => {
+    const el = document.createElement('gr-comments-summary');
+    assert.instanceOf(el, GrCommentsSummary);
+  });
+
+  test('renders', async () => {
+    element.commentThreads = [
+      createCommentThread([createComment()]),
+      createCommentThread([{...createComment(), unresolved: true}]),
+    ];
+    element.draftCount = 3;
+    await element.updateComplete;
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `<gr-summary-chip
+          category="drafts"
+          icon="rate_review"
+          iconFilled
+          styletype="info"
+        >
+          3 drafts
+        </gr-summary-chip>
+        <gr-summary-chip category="unresolved" styletype="warning">
+          <gr-avatar-stack imageSize="32">
+            <gr-icon
+              class="unresolvedIcon"
+              filled
+              icon="chat_bubble"
+              slot="fallback"
+            ></gr-icon>
+          </gr-avatar-stack>
+          1 unresolved
+        </gr-summary-chip>
+        <gr-summary-chip
+          category="show all"
+          icon="mark_chat_read"
+          styletype="check"
+        >
+          1 resolved
+        </gr-summary-chip>`
+    );
+  });
+});
diff --git a/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info.ts b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info.ts
index d6a5327..4e60228 100644
--- a/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info.ts
+++ b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info.ts
@@ -4,7 +4,13 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import '../../shared/gr-copy-clipboard/gr-copy-clipboard';
-import {CommitInfo, ServerInfo} from '../../../types/common';
+import '../../shared/gr-weblink/gr-weblink';
+import {
+  CommitId,
+  CommitInfo,
+  ServerInfo,
+  WebLinkInfo,
+} from '../../../types/common';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, css, html, nothing} from 'lit';
 import {customElement, property, state} from 'lit/decorators.js';
@@ -12,7 +18,7 @@
 import {resolve} from '../../../models/dependency';
 import {configModelToken} from '../../../models/config/config-model';
 import {createSearchUrl} from '../../../models/views/search';
-import {getPatchSetWeblink} from '../../../utils/weblink-util';
+import {getBrowseCommitWeblink} from '../../../utils/weblink-util';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -55,9 +61,7 @@
     const commit = this.commitInfo?.commit;
     if (!commit) return nothing;
     return html` <div class="container">
-      <a target="_blank" rel="noopener" href=${this.computeCommitLink()}
-        >${this.getWeblink()?.name ?? ''}</a
-      >
+      <gr-weblink imageAndText .info=${this.getWeblink(commit)}></gr-weblink>
       <gr-copy-clipboard
         hastooltip
         .buttonTitle=${'Copy full SHA to clipboard'}
@@ -68,19 +72,19 @@
     </div>`;
   }
 
-  getWeblink() {
-    return getPatchSetWeblink(
-      this.commitInfo?.commit,
+  /**
+   * Looks up the primary patchset weblink, but replaces its name by the
+   * shortened commit hash. And falls back to a search query, if no weblink
+   * is configured.
+   */
+  getWeblink(commit: CommitId): WebLinkInfo | undefined {
+    if (!commit) return undefined;
+    const name = commit.slice(0, 7);
+    const primaryLink = getBrowseCommitWeblink(
       this.commitInfo?.web_links,
       this.serverConfig
     );
-  }
-
-  computeCommitLink() {
-    const weblink = this.getWeblink();
-    if (weblink?.url) return weblink.url;
-
-    const hash = weblink?.name;
-    return hash ? createSearchUrl({query: hash}) : '';
+    if (primaryLink) return {...primaryLink, name};
+    return {name, url: createSearchUrl({query: name})};
   }
 }
diff --git a/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info_test.ts b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info_test.ts
index d992ffe..6481c26 100644
--- a/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info_test.ts
@@ -12,6 +12,7 @@
 } from '../../../test/test-data-generators';
 import {CommitId} from '../../../types/common';
 import {fixture, html, assert} from '@open-wc/testing';
+import {queryAndAssert} from '../../../utils/common-util';
 
 suite('gr-commit-info tests', () => {
   let element: GrCommitInfo;
@@ -40,11 +41,22 @@
       element,
       /* HTML */ `
         <div class="container">
-          <a href="link-url" rel="noopener" target="_blank">sha4567</a>
+          <gr-weblink imageandtext=""> </gr-weblink>
           <gr-copy-clipboard hastooltip="" hideinput=""> </gr-copy-clipboard>
         </div>
       `
     );
+    const weblink = queryAndAssert(element, 'gr-weblink');
+    assert.shadowDom.equal(
+      weblink,
+      /* HTML */ `
+        <a href="link-url" rel="noopener" target="_blank">
+          <gr-tooltip-content>
+            <span> sha4567 </span>
+          </gr-tooltip-content>
+        </a>
+      `
+    );
   });
 
   test('web link fall back to search query', async () => {
@@ -58,10 +70,21 @@
       element,
       /* HTML */ `
         <div class="container">
-          <a href="/q/sha4567" rel="noopener" target="_blank">sha4567</a>
+          <gr-weblink imageandtext=""> </gr-weblink>
           <gr-copy-clipboard hastooltip="" hideinput=""> </gr-copy-clipboard>
         </div>
       `
     );
+    const weblink = queryAndAssert(element, 'gr-weblink');
+    assert.shadowDom.equal(
+      weblink,
+      /* HTML */ `
+        <a href="/q/sha4567" rel="noopener" target="_blank">
+          <gr-tooltip-content>
+            <span> sha4567 </span>
+          </gr-tooltip-content>
+        </a>
+      `
+    );
   });
 });
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.ts
index ab15ae6..2129918 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.ts
@@ -13,6 +13,8 @@
 import {assertIsDefined} from '../../../utils/common-util';
 import {BindValueChangeEvent} from '../../../types/events';
 import {ShortcutController} from '../../lit/shortcut-controller';
+import {ChangeActionDialog} from '../../../types/common';
+import {fireNoBubble} from '../../../utils/event-util';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -21,7 +23,10 @@
 }
 
 @customElement('gr-confirm-abandon-dialog')
-export class GrConfirmAbandonDialog extends LitElement {
+export class GrConfirmAbandonDialog
+  extends LitElement
+  implements ChangeActionDialog
+{
   /**
    * Fired when the confirm button is pressed.
    *
@@ -128,25 +133,14 @@
 
   // private but used in test
   confirm() {
-    this.dispatchEvent(
-      new CustomEvent('confirm', {
-        detail: {reason: this.message},
-        composed: true,
-        bubbles: false,
-      })
-    );
+    fireNoBubble(this, 'confirm', {});
   }
 
   // private but used in test
   handleCancelTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
-    this.dispatchEvent(
-      new CustomEvent('cancel', {
-        composed: true,
-        bubbles: false,
-      })
-    );
+    fireNoBubble(this, 'cancel', {});
   }
 
   private handleBindValueChanged(e: BindValueChangeEvent) {
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog.ts
index 15f3e21..7723327 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog.ts
@@ -6,10 +6,15 @@
 import {css, html, LitElement} from 'lit';
 import {customElement} from 'lit/decorators.js';
 import {sharedStyles} from '../../../styles/shared-styles';
+import {ChangeActionDialog} from '../../../types/common';
+import {fireNoBubble} from '../../../utils/event-util';
 import '../../shared/gr-dialog/gr-dialog';
 
 @customElement('gr-confirm-cherrypick-conflict-dialog')
-export class GrConfirmCherrypickConflictDialog extends LitElement {
+export class GrConfirmCherrypickConflictDialog
+  extends LitElement
+  implements ChangeActionDialog
+{
   /**
    * Fired when the confirm button is pressed.
    *
@@ -62,23 +67,13 @@
   handleConfirmTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
-    this.dispatchEvent(
-      new CustomEvent('confirm', {
-        composed: true,
-        bubbles: false,
-      })
-    );
+    fireNoBubble(this, 'confirm', {});
   }
 
   handleCancelTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
-    this.dispatchEvent(
-      new CustomEvent('cancel', {
-        composed: true,
-        bubbles: false,
-      })
-    );
+    fireNoBubble(this, 'cancel', {});
   }
 }
 
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.ts
index 259154f..891209e 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.ts
@@ -16,6 +16,8 @@
   RepoName,
   CommitId,
   ChangeInfoId,
+  TopicName,
+  ChangeActionDialog,
 } from '../../../types/common';
 import {customElement, property, query, state} from 'lit/decorators.js';
 import {
@@ -28,7 +30,7 @@
   ChangeStatus,
   ProgressStatus,
 } from '../../../constants/constants';
-import {fireEvent} from '../../../utils/event-util';
+import {fire, fireNoBubble} from '../../../utils/event-util';
 import {css, html, LitElement, PropertyValues} from 'lit';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {choose} from 'lit/directives/choose.js';
@@ -36,6 +38,8 @@
 import {BindValueChangeEvent} from '../../../types/events';
 import {resolve} from '../../../models/dependency';
 import {createSearchUrl} from '../../../models/views/search';
+import {throwingErrorCallback} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
+import {uuid} from '../../../utils/common-util';
 
 const SUGGESTIONS_LIMIT = 15;
 const CHANGE_SUBJECT_LIMIT = 50;
@@ -58,7 +62,10 @@
 }
 
 @customElement('gr-confirm-cherrypick-dialog')
-export class GrConfirmCherrypickDialog extends LitElement {
+export class GrConfirmCherrypickDialog
+  extends LitElement
+  implements ChangeActionDialog
+{
   /**
    * Fired when the confirm button is pressed.
    *
@@ -497,12 +504,12 @@
 
   private handlecherryPickSingleChangeClicked() {
     this.cherryPickType = CherryPickType.SINGLE_CHANGE;
-    fireEvent(this, 'iron-resize');
+    fire(this, 'iron-resize', {});
   }
 
   private handlecherryPickTopicClicked() {
     this.cherryPickType = CherryPickType.TOPIC;
-    fireEvent(this, 'iron-resize');
+    fire(this, 'iron-resize', {});
   }
 
   private computeMessage() {
@@ -526,8 +533,7 @@
   }
 
   private generateRandomCherryPickTopic(change: ChangeInfo) {
-    const randomString = Math.random().toString(36).substr(2, 10);
-    const message = `cherrypick-${change.topic}-${randomString}`;
+    const message = `cherrypick-${change.topic}-${uuid()}`;
     return message;
   }
 
@@ -582,8 +588,9 @@
           if (!failedOrPending) {
             // This needs some more work, as the new topic may not always be
             // created, instead we may end up creating a new patchset */
-            const query = `topic: "${topic}"`;
-            this.getNavigation().setUrl(createSearchUrl({query}));
+            this.getNavigation().setUrl(
+              createSearchUrl({topic: topic as TopicName})
+            );
           }
         });
     });
@@ -598,23 +605,13 @@
       return;
     }
     // Cherry pick single change
-    this.dispatchEvent(
-      new CustomEvent('confirm', {
-        composed: true,
-        bubbles: false,
-      })
-    );
+    fireNoBubble(this, 'confirm', {});
   }
 
   private handleCancelTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
-    this.dispatchEvent(
-      new CustomEvent('cancel', {
-        composed: true,
-        bubbles: false,
-      })
-    );
+    fireNoBubble(this, 'cancel', {});
   }
 
   resetFocus() {
@@ -629,7 +626,13 @@
       input = input.substring('refs/heads/'.length);
     }
     return this.restApiService
-      .getRepoBranches(input, this.project, SUGGESTIONS_LIMIT)
+      .getRepoBranches(
+        input,
+        this.project,
+        SUGGESTIONS_LIMIT,
+        /* offset=*/ undefined,
+        throwingErrorCallback
+      )
       .then(response => {
         if (!response) return [];
         const branches: Array<{name: BranchName}> = [];
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.ts
index 7adc2ca..6f82e8c 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.ts
@@ -6,7 +6,7 @@
 import {css, html, LitElement} from 'lit';
 import {customElement, property} from 'lit/decorators.js';
 import {sharedStyles} from '../../../styles/shared-styles';
-import {BranchName, RepoName} from '../../../types/common';
+import {BranchName, ChangeActionDialog, RepoName} from '../../../types/common';
 import {getAppContext} from '../../../services/app-context';
 import '../../shared/gr-autocomplete/gr-autocomplete';
 import '../../shared/gr-dialog/gr-dialog';
@@ -14,11 +14,16 @@
 import {Key, Modifier} from '../../../utils/dom-util';
 import {ValueChangedEvent} from '../../../types/events';
 import {ShortcutController} from '../../lit/shortcut-controller';
+import {throwingErrorCallback} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
+import {fireNoBubble} from '../../../utils/event-util';
 
 const SUGGESTIONS_LIMIT = 15;
 
 @customElement('gr-confirm-move-dialog')
-export class GrConfirmMoveDialog extends LitElement {
+export class GrConfirmMoveDialog
+  extends LitElement
+  implements ChangeActionDialog
+{
   /**
    * Fired when the confirm button is pressed.
    *
@@ -138,23 +143,13 @@
   private handleConfirmTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
-    this.dispatchEvent(
-      new CustomEvent('confirm', {
-        composed: true,
-        bubbles: false,
-      })
-    );
+    fireNoBubble(this, 'confirm', {});
   }
 
   private handleCancelTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
-    this.dispatchEvent(
-      new CustomEvent('cancel', {
-        composed: true,
-        bubbles: false,
-      })
-    );
+    fireNoBubble(this, 'cancel', {});
   }
 
   private getProjectBranchesSuggestions(input: string) {
@@ -163,7 +158,13 @@
       input = input.substring('refs/heads/'.length);
     }
     return this.restApiService
-      .getRepoBranches(input, this.project, SUGGESTIONS_LIMIT)
+      .getRepoBranches(
+        input,
+        this.project,
+        SUGGESTIONS_LIMIT,
+        /* offest=*/ undefined,
+        throwingErrorCallback
+      )
       .then(response => {
         if (!response) return [];
         const branches: Array<{name: BranchName}> = [];
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.ts
index 14cd5e8..6ad416e 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.ts
@@ -3,9 +3,17 @@
  * Copyright 2016 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import '../../shared/gr-account-chip/gr-account-chip';
 import {css, html, LitElement, PropertyValues} from 'lit';
 import {customElement, property, query, state} from 'lit/decorators.js';
-import {NumericChangeId, BranchName} from '../../../types/common';
+import {when} from 'lit/directives/when.js';
+import {
+  NumericChangeId,
+  BranchName,
+  ChangeActionDialog,
+  AccountDetailInfo,
+  AccountInfo,
+} from '../../../types/common';
 import '../../shared/gr-dialog/gr-dialog';
 import '../../shared/gr-autocomplete/gr-autocomplete';
 import {
@@ -16,6 +24,13 @@
 import {getAppContext} from '../../../services/app-context';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {ValueChangedEvent} from '../../../types/events';
+import {throwingErrorCallback} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
+import {fireNoBubbleNoCompose} from '../../../utils/event-util';
+import {resolve} from '../../../models/dependency';
+import {changeModelToken} from '../../../models/change/change-model';
+import {userModelToken} from '../../../models/user/user-model';
+import {relatedChangesModelToken} from '../../../models/change/related-changes-model';
+import {subscribe} from '../../lit/subscription-controller';
 
 export interface RebaseChange {
   name: string;
@@ -25,10 +40,15 @@
 export interface ConfirmRebaseEventDetail {
   base: string | null;
   allowConflicts: boolean;
+  rebaseChain: boolean;
+  onBehalfOfUploader: boolean;
 }
 
 @customElement('gr-confirm-rebase-dialog')
-export class GrConfirmRebaseDialog extends LitElement {
+export class GrConfirmRebaseDialog
+  extends LitElement
+  implements ChangeActionDialog
+{
   /**
    * Fired when the confirm button is pressed.
    *
@@ -44,44 +64,91 @@
   @property({type: String})
   branch?: BranchName;
 
-  @property({type: Number})
-  changeNumber?: NumericChangeId;
-
-  @property({type: Boolean})
-  hasParent?: boolean;
-
   @property({type: Boolean})
   rebaseOnCurrent?: boolean;
 
+  @property({type: Boolean})
+  disableActions = false;
+
+  @state()
+  changeNum?: NumericChangeId;
+
+  @state()
+  hasParent?: boolean;
+
   @state()
   text = '';
 
   @state()
+  shouldRebaseChain = false;
+
+  @state()
   private query: AutocompleteQuery;
 
   @state()
   recentChanges?: RebaseChange[];
 
+  @state()
+  allowConflicts = false;
+
   @query('#rebaseOnParentInput')
-  private rebaseOnParentInput!: HTMLInputElement;
+  private rebaseOnParentInput?: HTMLInputElement;
 
   @query('#rebaseOnTipInput')
-  private rebaseOnTipInput!: HTMLInputElement;
+  private rebaseOnTipInput?: HTMLInputElement;
 
   @query('#rebaseOnOtherInput')
-  rebaseOnOtherInput!: HTMLInputElement;
+  rebaseOnOtherInput?: HTMLInputElement;
 
   @query('#rebaseAllowConflicts')
-  private rebaseAllowConflicts!: HTMLInputElement;
+  private rebaseAllowConflicts?: HTMLInputElement;
+
+  @query('#rebaseChain')
+  private rebaseChain?: HTMLInputElement;
 
   @query('#parentInput')
   parentInput!: GrAutocomplete;
 
+  @state()
+  account?: AccountDetailInfo;
+
+  @state()
+  uploader?: AccountInfo;
+
   private readonly restApiService = getAppContext().restApiService;
 
+  private readonly getChangeModel = resolve(this, changeModelToken);
+
+  private readonly getUserModel = resolve(this, userModelToken);
+
+  private readonly getRelatedChangesModel = resolve(
+    this,
+    relatedChangesModelToken
+  );
+
   constructor() {
     super();
     this.query = input => this.getChangeSuggestions(input);
+    subscribe(
+      this,
+      () => this.getUserModel().account$,
+      x => (this.account = x)
+    );
+    subscribe(
+      this,
+      () => this.getChangeModel().latestUploader$,
+      x => (this.uploader = x)
+    );
+    subscribe(
+      this,
+      () => this.getChangeModel().changeNum$,
+      x => (this.changeNum = x)
+    );
+    subscribe(
+      this,
+      () => this.getRelatedChangesModel().hasParent$,
+      x => (this.hasParent = x)
+    );
   }
 
   override willUpdate(changedProperties: PropertyValues): void {
@@ -115,12 +182,15 @@
         display: block;
         width: 100%;
       }
-      .rebaseAllowConflicts {
+      .rebaseCheckbox {
         margin-top: var(--spacing-m);
       }
       .rebaseOption {
         margin: var(--spacing-m) 0;
       }
+      .rebaseOnBehalfMsg {
+        margin-top: var(--spacing-m);
+      }
     `,
   ];
 
@@ -129,6 +199,7 @@
       <gr-dialog
         id="confirmDialog"
         confirm-label="Rebase"
+        .disabled=${this.disableActions}
         @confirm=${this.handleConfirmTap}
         @cancel=${this.handleCancelTap}
       >
@@ -144,6 +215,9 @@
               Rebase on parent change
             </label>
           </div>
+          <div class="message" ?hidden=${this.hasParent !== undefined}>
+            Still loading parent information ...
+          </div>
           <div
             id="parentUpToDateMsg"
             class="message"
@@ -164,7 +238,7 @@
             />
             <label id="rebaseOnTipLabel" for="rebaseOnTipInput">
               Rebase on top of the ${this.branch} branch<span
-                ?hidden=${!this.hasParent}
+                ?hidden=${!this.hasParent || this.shouldRebaseChain}
               >
                 (breaks relation chain)
               </span>
@@ -186,14 +260,15 @@
             />
             <label id="rebaseOnOtherLabel" for="rebaseOnOtherInput">
               Rebase on a specific change, ref, or commit
-              <span ?hidden=${!this.hasParent}> (breaks relation chain) </span>
+              <span ?hidden=${!this.hasParent || this.shouldRebaseChain}>
+                (breaks relation chain)
+              </span>
             </label>
           </div>
           <div class="parentRevisionContainer">
             <gr-autocomplete
               id="parentInput"
               .query=${this.query}
-              no-debounce
               .text=${this.text}
               @text-changed=${(e: ValueChangedEvent) =>
                 (this.text = e.detail.value)}
@@ -203,12 +278,50 @@
             >
             </gr-autocomplete>
           </div>
-          <div class="rebaseAllowConflicts">
-            <input id="rebaseAllowConflicts" type="checkbox" />
+          <div class="rebaseCheckbox">
+            <input
+              id="rebaseAllowConflicts"
+              type="checkbox"
+              @change=${() => {
+                this.allowConflicts = !!this.rebaseAllowConflicts?.checked;
+              }}
+            />
             <label for="rebaseAllowConflicts"
               >Allow rebase with conflicts</label
             >
           </div>
+          ${when(
+            !this.isCurrentUserEqualToLatestUploader() && this.allowConflicts,
+            () =>
+              html`<span class="message"
+                >Rebase cannot be done on behalf of the uploader when allowing
+                conflicts.</span
+              >`
+          )}
+          ${when(
+            this.hasParent,
+            () =>
+              html`<div class="rebaseCheckbox">
+                <input
+                  id="rebaseChain"
+                  type="checkbox"
+                  @change=${() => {
+                    this.shouldRebaseChain = !!this.rebaseChain?.checked;
+                  }}
+                />
+                <label for="rebaseChain">Rebase all ancestors</label>
+              </div>`
+          )}
+          ${when(
+            !this.isCurrentUserEqualToLatestUploader(),
+            () => html`<div class="rebaseOnBehalfMsg">Rebase will be done on behalf of${
+              !this.allowConflicts ? ' the uploader:' : ''
+            } <gr-account-chip
+                .account=${this.allowConflicts ? this.account : this.uploader}
+                .hideHovercard=${true}
+              ></gr-account-chip
+              ><span></div>`
+          )}
         </div>
       </gr-dialog>
     `;
@@ -222,7 +335,13 @@
   // last time it was run.
   fetchRecentChanges() {
     return this.restApiService
-      .getChanges(undefined, 'is:open -age:90d')
+      .getChanges(
+        undefined,
+        'is:open -age:90d',
+        /* offset=*/ undefined,
+        /* options=*/ undefined,
+        throwingErrorCallback
+      )
       .then(response => {
         if (!response) return [];
         const changes: RebaseChange[] = [];
@@ -237,6 +356,11 @@
       });
   }
 
+  isCurrentUserEqualToLatestUploader() {
+    if (!this.account || !this.uploader) return true;
+    return this.account._account_id === this.uploader._account_id;
+  }
+
   getRecentChanges() {
     if (this.recentChanges) {
       return Promise.resolve(this.recentChanges);
@@ -256,8 +380,7 @@
   ): AutocompleteSuggestion[] {
     return changes
       .filter(
-        change =>
-          change.name.includes(input) && change.value !== this.changeNumber
+        change => change.name.includes(input) && change.value !== this.changeNum
       )
       .map(
         change =>
@@ -288,10 +411,10 @@
    * should be rebased on top of its current parent.
    */
   getSelectedBase() {
-    if (this.rebaseOnParentInput.checked) {
+    if (this.rebaseOnParentInput?.checked) {
       return null;
     }
-    if (this.rebaseOnTipInput.checked) {
+    if (this.rebaseOnTipInput?.checked) {
       return '';
     }
     if (!this.text) {
@@ -307,16 +430,23 @@
     e.stopPropagation();
     const detail: ConfirmRebaseEventDetail = {
       base: this.getSelectedBase(),
-      allowConflicts: this.rebaseAllowConflicts.checked,
+      allowConflicts: !!this.rebaseAllowConflicts?.checked,
+      rebaseChain: !!this.rebaseChain?.checked,
+      onBehalfOfUploader: this.rebaseOnBehalfOfUploader(),
     };
-    this.dispatchEvent(new CustomEvent('confirm', {detail}));
+    fireNoBubbleNoCompose(this, 'confirm-rebase', detail);
     this.text = '';
   }
 
+  private rebaseOnBehalfOfUploader() {
+    if (this.allowConflicts) return false;
+    return true;
+  }
+
   private handleCancelTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
-    this.dispatchEvent(new CustomEvent('cancel'));
+    fireNoBubbleNoCompose(this, 'cancel', {});
     this.text = '';
   }
 
@@ -325,7 +455,7 @@
   }
 
   private handleEnterChangeNumberClick() {
-    this.rebaseOnOtherInput.checked = true;
+    if (this.rebaseOnOtherInput) this.rebaseOnOtherInput.checked = true;
   }
 
   /**
@@ -339,11 +469,11 @@
     }
 
     if (this.displayParentOption()) {
-      this.rebaseOnParentInput.checked = true;
+      if (this.rebaseOnParentInput) this.rebaseOnParentInput.checked = true;
     } else if (this.displayTipOption()) {
-      this.rebaseOnTipInput.checked = true;
+      if (this.rebaseOnTipInput) this.rebaseOnTipInput.checked = true;
     } else {
-      this.rebaseOnOtherInput.checked = true;
+      if (this.rebaseOnOtherInput) this.rebaseOnOtherInput.checked = true;
     }
   }
 }
@@ -352,4 +482,7 @@
   interface HTMLElementTagNameMap {
     'gr-confirm-rebase-dialog': GrConfirmRebaseDialog;
   }
+  interface HTMLElementEventMap {
+    'confirm-rebase': CustomEvent<ConfirmRebaseEventDetail>;
+  }
 }
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.ts b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.ts
index eba4bfe..24f8a34 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.ts
@@ -12,15 +12,31 @@
   stubRestApi,
   waitUntil,
 } from '../../../test/test-utils';
-import {NumericChangeId, BranchName} from '../../../types/common';
-import {createChangeViewChange} from '../../../test/test-data-generators';
+import {NumericChangeId, BranchName, Timestamp} from '../../../types/common';
+import {
+  createAccountWithEmail,
+  createChangeViewChange,
+} from '../../../test/test-data-generators';
 import {fixture, html, assert} from '@open-wc/testing';
 import {Key} from '../../../utils/dom-util';
+import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
+import {testResolver} from '../../../test/common-test-setup';
+import {userModelToken} from '../../../models/user/user-model';
+import {
+  changeModelToken,
+  LoadingStatus,
+} from '../../../models/change/change-model';
+import {GrAccountChip} from '../../shared/gr-account-chip/gr-account-chip';
 
 suite('gr-confirm-rebase-dialog tests', () => {
   let element: GrConfirmRebaseDialog;
 
   setup(async () => {
+    const userModel = testResolver(userModelToken);
+    userModel.setAccount({
+      ...createAccountWithEmail('abc@def.com'),
+      registered_on: '2015-03-12 18:32:08.000000000' as Timestamp,
+    });
     element = await fixture(
       html`<gr-confirm-rebase-dialog></gr-confirm-rebase-dialog>`
     );
@@ -28,6 +44,7 @@
 
   test('render', async () => {
     element.branch = 'test' as BranchName;
+    element.hasParent = false;
     await element.updateComplete;
     assert.shadowDom.equal(
       element,
@@ -44,6 +61,9 @@
               Rebase on parent change
             </label>
           </div>
+          <div class="message" hidden="">
+            Still loading parent information ...
+          </div>
           <div class="message" hidden="" id="parentUpToDateMsg">
             This change is up to date with its parent.
           </div>
@@ -73,12 +93,11 @@
             <gr-autocomplete
               allow-non-suggested-values=""
               id="parentInput"
-              no-debounce=""
               placeholder="Change number, ref, or commit hash"
             >
             </gr-autocomplete>
           </div>
-          <div class="rebaseAllowConflicts">
+          <div class="rebaseCheckbox">
             <input id="rebaseAllowConflicts" type="checkbox" />
             <label for="rebaseAllowConflicts">
               Allow rebase with conflicts
@@ -89,6 +108,70 @@
     );
   });
 
+  suite('on behalf of uploader', () => {
+    let changeModel;
+    const change = {
+      ...createChangeViewChange(),
+    };
+    setup(async () => {
+      element.branch = 'test' as BranchName;
+      await element.updateComplete;
+      changeModel = testResolver(changeModelToken);
+      changeModel.setState({
+        loadingStatus: LoadingStatus.LOADED,
+        change,
+      });
+    });
+    test('for reviewer it shows message about on behalf', () => {
+      const rebaseOnBehalfMsg = queryAndAssert(element, '.rebaseOnBehalfMsg');
+      assert.dom.equal(
+        rebaseOnBehalfMsg,
+        /* HTML */ `<div class="rebaseOnBehalfMsg">
+          Rebase will be done on behalf of the uploader:
+          <gr-account-chip> </gr-account-chip> <span> </span>
+        </div>`
+      );
+      const accountChip: GrAccountChip = queryAndAssert(
+        rebaseOnBehalfMsg,
+        'gr-account-chip'
+      );
+      assert.equal(
+        accountChip.account!,
+        change?.revisions[change.current_revision]?.uploader
+      );
+    });
+    test('allowConflicts', async () => {
+      element.allowConflicts = true;
+      await element.updateComplete;
+      const rebaseOnBehalfMsg = queryAndAssert(element, '.rebaseOnBehalfMsg');
+      assert.dom.equal(
+        rebaseOnBehalfMsg,
+        /* HTML */ `<div class="rebaseOnBehalfMsg">
+          Rebase will be done on behalf of
+          <gr-account-chip> </gr-account-chip> <span> </span>
+        </div>`
+      );
+      const accountChip: GrAccountChip = queryAndAssert(
+        rebaseOnBehalfMsg,
+        'gr-account-chip'
+      );
+      assert.equal(accountChip.account, element.account);
+    });
+  });
+
+  test('disableActions property disables dialog confirm', async () => {
+    element.disableActions = false;
+    await element.updateComplete;
+
+    const dialog = queryAndAssert<GrDialog>(element, 'gr-dialog');
+    assert.isFalse(dialog.disabled);
+
+    element.disableActions = true;
+    await element.updateComplete;
+
+    assert.isTrue(dialog.disabled);
+  });
+
   test('controls with parent and rebase on current available', async () => {
     element.rebaseOnCurrent = true;
     element.hasParent = true;
@@ -160,7 +243,7 @@
     element.hasParent = false;
     await element.updateComplete;
 
-    assert.isTrue(element.rebaseOnOtherInput.checked);
+    assert.isTrue(element.rebaseOnOtherInput?.checked);
     assert.isTrue(
       queryAndAssert(element, '#rebaseOnParent').hasAttribute('hidden')
     );
@@ -281,7 +364,7 @@
       assert.equal(element.filterChanges('awesome', recentChanges).length, 3);
       assert.equal(element.filterChanges('third', recentChanges).length, 1);
 
-      element.changeNumber = 123 as NumericChangeId;
+      element.changeNum = 123 as NumericChangeId;
       await element.updateComplete;
 
       assert.equal(element.filterChanges('123', recentChanges).length, 0);
@@ -291,7 +374,6 @@
 
     test('input text change triggers function', async () => {
       const recentChangesSpy = sinon.spy(element, 'getRecentChanges');
-      element.parentInput.noDebounce = true;
       pressKey(
         queryAndAssert(queryAndAssert(element, '#parentInput'), '#input'),
         Key.ENTER
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 5f4835a..fedc377 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
@@ -8,14 +8,15 @@
 import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
 import {LitElement, html, css, nothing} from 'lit';
 import {customElement, state} from 'lit/decorators.js';
-import {ChangeInfo, CommitId} from '../../../types/common';
+import {ChangeActionDialog, ChangeInfo, CommitId} from '../../../types/common';
 import {fire, fireAlert} from '../../../utils/event-util';
-import {getAppContext} from '../../../services/app-context';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {BindValueChangeEvent} from '../../../types/events';
+import {resolve} from '../../../models/dependency';
+import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
+import {createSearchUrl} from '../../../models/views/search';
 
 const ERR_COMMIT_NOT_FOUND = 'Unable to find the commit hash of this change.';
-const CHANGE_SUBJECT_LIMIT = 50;
 const INSERT_REASON_STRING = '<INSERT REASONING HERE>';
 
 // TODO(dhruvsri): clean up repeated definitions after moving to js modules
@@ -29,23 +30,11 @@
   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 {
+export class GrConfirmRevertDialog
+  extends LitElement
+  implements ChangeActionDialog
+{
   /* The revert message updated by the user
       The default value is set by the dialog */
   @state()
@@ -57,8 +46,9 @@
   @state()
   private showRevertSubmission = false;
 
+  // Value supplied by populate(). Non-private for access in tests.
   @state()
-  private changesCount?: number;
+  changesCount?: number;
 
   @state()
   showErrorMessage = false;
@@ -73,6 +63,8 @@
   @state()
   private revertMessages: string[] = [];
 
+  private readonly getPluginLoader = resolve(this, pluginLoaderToken);
+
   static override styles = [
     sharedStyles,
     css`
@@ -170,8 +162,6 @@
     `;
   }
 
-  private readonly jsAPI = getAppContext().jsApiService;
-
   private computeIfSingleRevert() {
     return this.revertType === RevertType.REVERT_SINGLE_CHANGE;
   }
@@ -181,18 +171,22 @@
   }
 
   modifyRevertMsg(change: ChangeInfo, commitMessage: string, message: string) {
-    return this.jsAPI.modifyRevertMsg(change, message, commitMessage);
+    return this.getPluginLoader().jsApiService.modifyRevertMsg(
+      change,
+      message,
+      commitMessage
+    );
   }
 
-  populate(change: ChangeInfo, commitMessage: string, changes: ChangeInfo[]) {
-    this.changesCount = changes.length;
+  populate(change: ChangeInfo, commitMessage: string, changesCount: number) {
+    this.changesCount = changesCount;
     // The option to revert a single change is always available
     this.populateRevertSingleChangeMessage(
       change,
       commitMessage,
       change.current_revision
     );
-    this.populateRevertSubmissionMessage(change, changes, commitMessage);
+    this.populateRevertSubmissionMessage(change, commitMessage);
   }
 
   populateRevertSingleChangeMessage(
@@ -220,44 +214,34 @@
     this.originalRevertMessages[this.revertType] = this.message;
   }
 
-  private getTrimmedChangeSubject(subject: string) {
-    if (!subject) return '';
-    if (subject.length < CHANGE_SUBJECT_LIMIT) return subject;
-    return subject.substring(0, CHANGE_SUBJECT_LIMIT) + '...';
-  }
-
   private modifyRevertSubmissionMsg(
     change: ChangeInfo,
     msg: string,
     commitMessage: string
   ) {
-    return this.jsAPI.modifyRevertSubmissionMsg(change, msg, commitMessage);
+    return this.getPluginLoader().jsApiService.modifyRevertSubmissionMsg(
+      change,
+      msg,
+      commitMessage
+    );
   }
 
-  populateRevertSubmissionMessage(
-    change: ChangeInfo,
-    changes: ChangeInfo[],
-    commitMessage: string
-  ) {
+  populateRevertSubmissionMessage(change: ChangeInfo, commitMessage: string) {
     // Follow the same convention of the revert
     const commitHash = change.current_revision;
     if (!commitHash) {
       fireAlert(this, ERR_COMMIT_NOT_FOUND);
       return;
     }
-    if (!changes || changes.length <= 1) return;
-    const revertTitle = `Revert submission ${change.submission_id}`;
-    let message =
-      revertTitle +
+    if (this.changesCount! <= 1) return;
+    const message =
+      `Revert submission ${change.submission_id}` +
       '\n\n' +
       'Reason for revert: <INSERT ' +
-      'REASONING HERE>\n';
-    message += 'Reverted Changes:\n';
-    changes.forEach(change => {
-      message +=
-        `${change.change_id.substring(0, 10)}:` +
-        `${this.getTrimmedChangeSubject(change.subject)}\n`;
-    });
+      'REASONING HERE>\n\n' +
+      'Reverted changes: ' +
+      createSearchUrl({query: `submissionid:${change.submission_id}`}) +
+      '\n';
     this.message = this.modifyRevertSubmissionMsg(
       change,
       message,
@@ -303,16 +287,13 @@
       revertType: this.revertType,
       message: this.message,
     };
-    fire(this, 'confirm', detail);
+    fire(this, 'confirm-revert', detail);
   }
 
   private handleCancelTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
-    const detail: ConfirmRevertEventDetail = {
-      revertType: this.revertType,
-    };
-    fire(this, 'cancel', detail);
+    fire(this, 'cancel', {});
   }
 }
 
@@ -320,4 +301,7 @@
   interface HTMLElementTagNameMap {
     'gr-confirm-revert-dialog': GrConfirmRevertDialog;
   }
+  interface HTMLElementEventMap {
+    'confirm-revert': CustomEvent<ConfirmRevertEventDetail>;
+  }
 }
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.ts b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.ts
index 59416da..904285f 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.ts
@@ -6,8 +6,7 @@
 import {fixture, html, assert} from '@open-wc/testing';
 import '../../../test/common-test-setup';
 import {createChange} from '../../../test/test-data-generators';
-import {CommitId} from '../../../types/common';
-import {EventType} from '../../../types/events';
+import {ChangeSubmissionId, CommitId} from '../../../types/common';
 import './gr-confirm-revert-dialog';
 import {GrConfirmRevertDialog} from './gr-confirm-revert-dialog';
 
@@ -47,7 +46,7 @@
   test('no match', () => {
     assert.isNotOk(element.message);
     const alertStub = sinon.stub();
-    element.addEventListener(EventType.SHOW_ALERT, alertStub);
+    element.addEventListener('show-alert', alertStub);
     element.populateRevertSingleChangeMessage(
       createChange(),
       'not a commitHash in sight',
@@ -111,4 +110,22 @@
       'Reason for revert: <INSERT REASONING HERE>\n';
     assert.equal(element.message, expected);
   });
+
+  test('revert submission', () => {
+    element.changesCount = 3;
+    element.populateRevertSubmissionMessage(
+      {
+        ...createChange(),
+        submission_id: '5545' as ChangeSubmissionId,
+        current_revision: 'abcd123' as CommitId,
+      },
+      'one line commit\n\nChange-Id: abcdefg\n'
+    );
+
+    const expected =
+      'Revert submission 5545\n\n' +
+      'Reason for revert: <INSERT REASONING HERE>\n\n' +
+      'Reverted changes: /q/submissionid:5545\n';
+    assert.equal(element.message, expected);
+  });
 });
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.ts
index cf66ecf..44e237b 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
@@ -8,10 +8,15 @@
 import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
 import '../../plugins/gr-endpoint-param/gr-endpoint-param';
 import '../gr-thread-list/gr-thread-list';
-import {ActionInfo, EDIT} from '../../../types/common';
+import {
+  ActionInfo,
+  ChangeActionDialog,
+  CommentThread,
+  EDIT,
+} from '../../../types/common';
 import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
 import {pluralize} from '../../../utils/string-util';
-import {CommentThread, isUnresolved} from '../../../utils/comment-util';
+import {isUnresolved} from '../../../utils/comment-util';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, css, html} from 'lit';
 import {customElement, property, query, state} from 'lit/decorators.js';
@@ -21,9 +26,13 @@
 import {commentsModelToken} from '../../../models/comments/comments-model';
 import {changeModelToken} from '../../../models/change/change-model';
 import {resolve} from '../../../models/dependency';
+import {fireNoBubbleNoCompose} from '../../../utils/event-util';
 
 @customElement('gr-confirm-submit-dialog')
-export class GrConfirmSubmitDialog extends LitElement {
+export class GrConfirmSubmitDialog
+  extends LitElement
+  implements ChangeActionDialog
+{
   @query('#dialog')
   dialog?: GrDialog;
 
@@ -90,7 +99,7 @@
     );
     subscribe(
       this,
-      () => this.getCommentsModel().threads$,
+      () => this.getCommentsModel().threadsSaved$,
       x => (this.unresolvedThreads = x.filter(isUnresolved))
     );
   }
@@ -127,7 +136,7 @@
     return html`
       <gr-icon icon="warning" filled class="warningBeforeSubmit"></gr-icon>
       Your unpublished edit will not be submitted. Did you forget to click
-      <b>PUBLISH</b>
+      <b>PUBLISH</b> after pressing <b>EDIT</b>?
     `;
   }
 
@@ -190,13 +199,13 @@
   private handleConfirmTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
-    this.dispatchEvent(new CustomEvent('confirm', {bubbles: false}));
+    fireNoBubbleNoCompose(this, 'confirm', {});
   }
 
   private handleCancelTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
-    this.dispatchEvent(new CustomEvent('cancel', {bubbles: false}));
+    fireNoBubbleNoCompose(this, 'cancel', {});
   }
 }
 
diff --git a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.ts b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.ts
index 2e84143..11dc890 100644
--- a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.ts
@@ -8,19 +8,13 @@
 import {ChangeInfo, DownloadInfo, PatchSetNum} from '../../../types/common';
 import {GrDownloadCommands} from '../../shared/gr-download-commands/gr-download-commands';
 import {GrButton} from '../../shared/gr-button/gr-button';
-import {
-  copyToClipbard,
-  hasOwnProperty,
-  queryAndAssert,
-} from '../../../utils/common-util';
-import {GrOverlayStops} from '../../shared/gr-overlay/gr-overlay';
-import {fireEvent} from '../../../utils/event-util';
+import {copyToClipbard, hasOwnProperty} from '../../../utils/common-util';
+import {fire} from '../../../utils/event-util';
 import {fontStyles} from '../../../styles/gr-font-styles';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, PropertyValues, html, css} from 'lit';
 import {customElement, property, state, query} from 'lit/decorators.js';
 import {assertIsDefined} from '../../../utils/common-util';
-import {PaperTabsElement} from '@polymer/paper-tabs/paper-tabs';
 import {BindValueChangeEvent} from '../../../types/events';
 import {ShortcutController} from '../../lit/shortcut-controller';
 import {subscribe} from '../../lit/subscription-controller';
@@ -239,7 +233,7 @@
       commands[index].command,
       `${commands[index].title} command`
     );
-    fireEvent(this, 'close');
+    fire(this, 'close', {});
   }
 
   override focus() {
@@ -252,19 +246,6 @@
     }
   }
 
-  getFocusStops(): GrOverlayStops {
-    assertIsDefined(this.downloadCommands, 'downloadCommands');
-    assertIsDefined(this.closeButton, 'closeButton');
-    const downloadTabs = queryAndAssert<PaperTabsElement>(
-      this.downloadCommands,
-      '#downloadTabs'
-    );
-    return {
-      start: downloadTabs,
-      end: this.closeButton,
-    };
-  }
-
   private computeDownloadCommands() {
     let commandObj;
     if (!this.change || !this.selectedScheme) return [];
@@ -342,7 +323,7 @@
   private handleCloseTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
-    fireEvent(this, 'close');
+    fire(this, 'close', {});
   }
 
   private schemesChanged() {
diff --git a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.ts b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.ts
index 5debc0c..fd3ddac 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.ts
@@ -25,8 +25,8 @@
 import {DiffPreferencesInfo} from '../../../types/diff';
 import {GrDiffModeSelector} from '../../../embed/diff/gr-diff-mode-selector/gr-diff-mode-selector';
 import {GrButton} from '../../shared/gr-button/gr-button';
-import {fireEvent} from '../../../utils/event-util';
-import {css, html, LitElement} from 'lit';
+import {fire, fireNoBubbleNoCompose} from '../../../utils/event-util';
+import {css, html, LitElement, nothing} from 'lit';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {when} from 'lit/directives/when.js';
 import {ifDefined} from 'lit/directives/if-defined.js';
@@ -36,30 +36,16 @@
   shortcutsServiceToken,
 } from '../../../services/shortcuts/shortcuts-service';
 import {resolve} from '../../../models/dependency';
-import {getAppContext} from '../../../services/app-context';
 import {subscribe} from '../../lit/subscription-controller';
 import {configModelToken} from '../../../models/config/config-model';
 import {createChangeUrl} from '../../../models/views/change';
+import {userModelToken} from '../../../models/user/user-model';
 import {changeModelToken} from '../../../models/change/change-model';
+import {PatchRangeChangeEvent} from '../../diff/gr-patch-range-select/gr-patch-range-select';
+import {classMap} from 'lit/directives/class-map.js';
 
 @customElement('gr-file-list-header')
 export class GrFileListHeader extends LitElement {
-  /**
-   * @event expand-diffs
-   */
-
-  /**
-   * @event collapse-diffs
-   */
-
-  /**
-   * @event open-diff-prefs
-   */
-
-  /**
-   * @event open-download-dialog
-   */
-
   @property({type: Object})
   account: AccountInfo | undefined;
 
@@ -113,7 +99,7 @@
   // 'hide diffs' buttons still be functional.
   private readonly maxFilesForBulkActions = 225;
 
-  private readonly userModel = getAppContext().userModel;
+  private readonly getUserModel = resolve(this, userModelToken);
 
   private readonly getNavigation = resolve(this, navigationToken);
 
@@ -123,7 +109,7 @@
     super();
     subscribe(
       this,
-      () => this.userModel.diffPreferences$,
+      () => this.getUserModel().diffPreferences$,
       diffPreferences => {
         if (!diffPreferences) return;
         this.diffPrefs = diffPreferences;
@@ -176,15 +162,6 @@
         display: flex;
         flex-wrap: wrap;
       }
-      .patchInfo-header .container.latestPatchContainer {
-        display: none;
-      }
-      .patchInfoOldPatchSet .container.latestPatchContainer {
-        display: initial;
-      }
-      .editMode.patchInfoOldPatchSet .container.latestPatchContainer {
-        display: none;
-      }
       .latestPatchContainer a {
         text-decoration: none;
       }
@@ -233,16 +210,7 @@
         margin: 0;
         --gr-button-padding: 2px 4px;
       }
-      .editMode .hideOnEdit {
-        display: none;
-      }
-      .showOnEdit {
-        display: none;
-      }
-      .editMode .showOnEdit {
-        display: initial;
-      }
-      .editMode .showOnEdit.flexContainer {
+      .flexContainer {
         align-items: center;
         display: flex;
       }
@@ -269,15 +237,14 @@
     if (!this.change || !this.diffPrefs) {
       return;
     }
-    const editModeClass = this.computeEditModeClass(this.editMode);
-    const patchInfoClass = this.computePatchInfoClass();
     const expandedClass = this.computeExpandedClass(this.filesExpanded);
-    const prefsButtonHidden = this.computePrefsButtonHidden(
-      this.diffPrefs,
-      this.loggedIn
-    );
     return html`
-      <div class="patchInfo-header ${editModeClass} ${patchInfoClass}">
+      <div
+        class=${classMap({
+          'patchInfo-header': true,
+          patchInfoOldPatchSet: this.patchNum !== this.latestPatchNum,
+        })}
+      >
         <div class="patchInfo-left">
           <div class="patchInfoContent">
             <gr-patch-range-select
@@ -287,17 +254,14 @@
             </gr-patch-range-select>
             <span class="separator"></span>
             <gr-commit-info .commitInfo=${this.commitInfo}></gr-commit-info>
-            <span class="container latestPatchContainer">
-              <span class="separator"></span>
-              <a href=${ifDefined(this.changeUrl)}>Go to latest patch set</a>
-            </span>
+            ${this.renderLatestPatchContainer()}
           </div>
         </div>
         <div class="rightControls ${expandedClass}">
           ${when(
             this.editMode,
             () => html`
-              <span class="showOnEdit flexContainer">
+              <span class="flexContainer">
                 <gr-edit-controls
                   id="editControls"
                   .patchNum=${this.patchNum}
@@ -307,28 +271,20 @@
               </span>
             `
           )}
-          <div class="fileViewActions">
-            <span class="fileViewActionsLabel">Diff view:</span>
-            <gr-diff-mode-selector
-              id="modeSelect"
-              .saveOnChange=${this.loggedIn ?? false}
-            ></gr-diff-mode-selector>
-            <span
-              id="diffPrefsContainer"
-              class="hideOnEdit"
-              ?hidden=${prefsButtonHidden}
-            >
-              <gr-tooltip-content has-tooltip title="Diff preferences">
-                <gr-button
-                  link
-                  class="prefsButton desktop"
-                  @click=${this.handlePrefsTap}
-                  ><gr-icon icon="settings" filled></gr-icon
-                ></gr-button>
-              </gr-tooltip-content>
-            </span>
-            <span class="separator"></span>
-          </div>
+          ${when(
+            this.loggedIn && this.diffPrefs,
+            () => html`
+              <div class="fileViewActions">
+                <span class="fileViewActionsLabel">Diff view:</span>
+                <gr-diff-mode-selector
+                  id="modeSelect"
+                  .saveOnChange=${true}
+                ></gr-diff-mode-selector>
+                ${this.renderDiffPrefsContainer()}
+                <span class="separator"></span>
+              </div>
+            `
+          )}
           <span class="downloadContainer desktop">
             <gr-tooltip-content
               has-tooltip
@@ -380,12 +336,38 @@
     `;
   }
 
+  private renderLatestPatchContainer() {
+    if (this.editMode || this.patchNum === this.latestPatchNum) return nothing;
+    return html`
+      <span class="container latestPatchContainer">
+        <span class="separator"></span>
+        <a href=${ifDefined(this.changeUrl)}>Go to latest patch set</a>
+      </span>
+    `;
+  }
+
+  private renderDiffPrefsContainer() {
+    if (this.editMode) return nothing;
+    return html`
+      <span id="diffPrefsContainer">
+        <gr-tooltip-content has-tooltip title="Diff preferences">
+          <gr-button
+            link
+            class="prefsButton desktop"
+            @click=${this.handleDiffPrefsTap}
+            ><gr-icon icon="settings" filled></gr-icon
+          ></gr-button>
+        </gr-tooltip-content>
+      </span>
+    `;
+  }
+
   private expandAllDiffs() {
-    fireEvent(this, 'expand-diffs');
+    fire(this, 'expand-diffs', {});
   }
 
   private collapseAllDiffs() {
-    fireEvent(this, 'collapse-diffs');
+    fire(this, 'collapse-diffs', {});
   }
 
   private computeExpandedClass(filesExpanded?: FilesExpandedState) {
@@ -400,13 +382,6 @@
     return classes.join(' ');
   }
 
-  private computePrefsButtonHidden(
-    prefs: DiffPreferencesInfo,
-    loggedIn?: boolean
-  ) {
-    return !loggedIn || !prefs;
-  }
-
   private fileListActionsVisible(
     shownFileCount: number,
     maxFilesForBulkActions: number
@@ -414,7 +389,7 @@
     return shownFileCount <= maxFilesForBulkActions;
   }
 
-  handlePatchChange(e: CustomEvent) {
+  handlePatchChange(e: PatchRangeChangeEvent) {
     const {basePatchNum, patchNum} = e.detail;
     if (
       (basePatchNum === this.basePatchNum && patchNum === this.patchNum) ||
@@ -427,28 +402,15 @@
     );
   }
 
-  private handlePrefsTap(e: Event) {
+  private handleDiffPrefsTap(e: Event) {
     e.preventDefault();
-    fireEvent(this, 'open-diff-prefs');
+    fire(this, 'open-diff-prefs', {});
   }
 
   private handleDownloadTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
-    this.dispatchEvent(
-      new CustomEvent('open-download-dialog', {bubbles: false})
-    );
-  }
-
-  private computeEditModeClass(editMode?: boolean) {
-    return editMode ? 'editMode' : '';
-  }
-
-  computePatchInfoClass() {
-    if (this.patchNum === this.latestPatchNum) {
-      return '';
-    }
-    return 'patchInfoOldPatchSet';
+    fireNoBubbleNoCompose(this, 'open-download-dialog', {});
   }
 
   private createTitle(shortcutName: Shortcut, section: ShortcutSection) {
@@ -460,4 +422,10 @@
   interface HTMLElementTagNameMap {
     'gr-file-list-header': GrFileListHeader;
   }
+  interface HTMLElementEventMap {
+    'collapse-diffs': CustomEvent<{}>;
+    'expand-diffs': CustomEvent<{}>;
+    'open-diff-prefs': CustomEvent<{}>;
+    'open-download-dialog': CustomEvent<{}>;
+  }
 }
diff --git a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_test.ts b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_test.ts
index f2121b3..e2c3304 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_test.ts
@@ -51,6 +51,7 @@
         .shownFileCount=${3}
       ></gr-file-list-header>`
     );
+    element.loggedIn = true;
     element.diffPrefs = createDefaultDiffPrefs();
     await element.updateComplete;
   });
@@ -65,17 +66,13 @@
               <gr-patch-range-select id="rangeSelect"> </gr-patch-range-select>
               <span class="separator"> </span>
               <gr-commit-info> </gr-commit-info>
-              <span class="container latestPatchContainer">
-                <span class="separator"> </span>
-                <a> Go to latest patch set </a>
-              </span>
             </div>
           </div>
           <div class="rightControls">
             <div class="fileViewActions">
               <span class="fileViewActionsLabel"> Diff view: </span>
               <gr-diff-mode-selector id="modeSelect"> </gr-diff-mode-selector>
-              <span class="hideOnEdit" hidden="" id="diffPrefsContainer">
+              <span id="diffPrefsContainer">
                 <gr-tooltip-content has-tooltip="" title="Diff preferences">
                   <gr-button
                     aria-disabled="false"
@@ -108,7 +105,7 @@
             </span>
             <gr-tooltip-content
               has-tooltip=""
-              title="Show/hide all inline diffs (shortcut: I)"
+              title="Show/hide all inline diffs (shortcut: Shift+i)"
             >
               <gr-button
                 aria-disabled="false"
@@ -122,7 +119,7 @@
             </gr-tooltip-content>
             <gr-tooltip-content
               has-tooltip=""
-              title="Show/hide all inline diffs (shortcut: I)"
+              title="Show/hide all inline diffs (shortcut: Shift+i)"
             >
               <gr-button
                 aria-disabled="false"
@@ -141,17 +138,13 @@
   });
 
   test('Diff preferences hidden when no prefs', async () => {
-    assert.isTrue(
-      queryAndAssert<HTMLElement>(element, '#diffPrefsContainer').hidden
-    );
+    assert.isOk(query<HTMLElement>(element, '#diffPrefsContainer'));
 
-    element.diffPrefs = createDefaultDiffPrefs();
+    element.diffPrefs = undefined;
     element.loggedIn = true;
     await element.updateComplete;
 
-    assert.isFalse(
-      queryAndAssert<HTMLElement>(element, '#diffPrefsContainer').hidden
-    );
+    assert.isNotOk(query<HTMLElement>(element, '#diffPrefsContainer'));
   });
 
   test('expandAllDiffs called when expand button clicked', async () => {
@@ -251,17 +244,20 @@
     assert.equal(setUrlStub.lastCall.firstArg, '/c/test-project/+/42/1..3');
   });
 
-  test('class is applied to file list on old patch set', () => {
+  test('class is applied to file list on old patch set', async () => {
     element.latestPatchNum = 4 as PatchSetNumber;
 
     element.patchNum = 1 as PatchSetNumber;
-    assert.equal(element.computePatchInfoClass(), 'patchInfoOldPatchSet');
+    await element.updateComplete;
+    assert.isTrue(Boolean(query(element, '.patchInfoOldPatchSet')));
 
     element.patchNum = 2 as PatchSetNumber;
-    assert.equal(element.computePatchInfoClass(), 'patchInfoOldPatchSet');
+    await element.updateComplete;
+    assert.isTrue(Boolean(query(element, '.patchInfoOldPatchSet')));
 
     element.patchNum = 4 as PatchSetNumber;
-    assert.equal(element.computePatchInfoClass(), '');
+    await element.updateComplete;
+    assert.isFalse(Boolean(query(element, '.patchInfoOldPatchSet')));
   });
 
   suite('editMode behavior', () => {
@@ -273,17 +269,11 @@
     test('patch specific elements', async () => {
       element.editMode = true;
       await element.updateComplete;
-
-      assert.isFalse(
-        isVisible(queryAndAssert<HTMLElement>(element, '#diffPrefsContainer'))
-      );
+      assert.isFalse(Boolean(query(element, '#diffPrefsContainer')));
 
       element.editMode = false;
       await element.updateComplete;
-
-      assert.isTrue(
-        isVisible(queryAndAssert<HTMLElement>(element, '#diffPrefsContainer'))
-      );
+      assert.isTrue(Boolean(query(element, '#diffPrefsContainer')));
     });
 
     test('edit-controls visibility', async () => {
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
index 9813845..f9568f4 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
@@ -21,8 +21,6 @@
 import {FilesExpandedState} from '../gr-file-list-constants';
 import {diffFilePaths, pluralize} from '../../../utils/string-util';
 import {navigationToken} from '../../core/gr-navigation/gr-navigation';
-import {getPluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints';
-import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 import {getAppContext} from '../../../services/app-context';
 import {
   DiffViewMode,
@@ -53,7 +51,7 @@
 import {GrCursorManager} from '../../shared/gr-cursor-manager/gr-cursor-manager';
 import {ChangeComments} from '../../diff/gr-comment-api/gr-comment-api';
 import {ParsedChangeInfo, PatchSetFile} from '../../../types/types';
-import {Interaction, Timing} from '../../../constants/reporting';
+import {Timing} from '../../../constants/reporting';
 import {RevisionInfo} from '../../shared/revision-info/revision-info';
 import {select} from '../../../utils/observable-util';
 import {resolve} from '../../../models/dependency';
@@ -62,7 +60,14 @@
 import {changeModelToken} from '../../../models/change/change-model';
 import {filesModelToken} from '../../../models/change/files-model';
 import {ShortcutController} from '../../lit/shortcut-controller';
-import {css, html, LitElement, nothing, PropertyValues} from 'lit';
+import {
+  css,
+  html,
+  LitElement,
+  nothing,
+  PropertyValues,
+  TemplateResult,
+} from 'lit';
 import {Shortcut} from '../../../services/shortcuts/shortcuts-config';
 import {fire} from '../../../utils/event-util';
 import {a11yStyles} from '../../../styles/gr-a11y-styles';
@@ -73,10 +78,14 @@
 import {classMap} from 'lit/directives/class-map.js';
 import {incrementalRepeat} from '../../lit/incremental-repeat';
 import {ifDefined} from 'lit/directives/if-defined.js';
-import {HtmlPatched} from '../../../utils/lit-util';
-import {createDiffUrl} from '../../../models/views/diff';
-import {createEditUrl} from '../../../models/views/edit';
-import {createChangeUrl} from '../../../models/views/change';
+import {
+  createDiffUrl,
+  createEditUrl,
+  createChangeUrl,
+} from '../../../models/views/change';
+import {userModelToken} from '../../../models/user/user-model';
+import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
+import {FileMode, fileModeToString} from '../../../utils/file-util';
 
 export const DEFAULT_NUM_FILES_SHOWN = 200;
 
@@ -164,11 +173,6 @@
 }
 @customElement('gr-file-list')
 export class GrFileList extends LitElement {
-  /**
-   * @event files-expanded-changed
-   * @event files-shown-changed
-   * @event diff-prefs-changed
-   */
   @query('#diffPreferencesDialog')
   diffPreferencesDialog?: GrDiffPreferencesDialog;
 
@@ -259,10 +263,6 @@
 
   // Private but used in tests.
   @state()
-  displayLine?: boolean;
-
-  // Private but used in tests.
-  @state()
   showSizeBars = true;
 
   // For merge commits vs Auto Merge, an extra file row is shown detailing the
@@ -296,7 +296,9 @@
 
   private readonly restApiService = getAppContext().restApiService;
 
-  private readonly userModel = getAppContext().userModel;
+  private readonly getPluginLoader = resolve(this, pluginLoaderToken);
+
+  private readonly getUserModel = resolve(this, userModelToken);
 
   private readonly getChangeModel = resolve(this, changeModelToken);
 
@@ -306,13 +308,6 @@
 
   private readonly getBrowserModel = resolve(this, browserModelToken);
 
-  private readonly patched = new HtmlPatched(key => {
-    this.reporting.reportInteraction(Interaction.AUTOCLOSE_HTML_PATCHED, {
-      component: this.tagName,
-      key: key.substring(0, 300),
-    });
-  });
-
   shortcutsController = new ShortcutController(this);
 
   private readonly getNavigation = resolve(this, navigationToken);
@@ -600,6 +595,16 @@
           top: 2px;
           display: block;
         }
+        .file-mode-warning {
+          font-size: 16px;
+          position: relative;
+          top: 2px;
+          color: var(--warning-foreground);
+        }
+        .file-mode-content {
+          display: inline-block;
+          color: var(--deemphasized-text-color);
+        }
 
         @media screen and (max-width: 1200px) {
           gr-endpoint-decorator.extra-col {
@@ -722,9 +727,6 @@
     this.shortcutsController.addAbstract(Shortcut.TOGGLE_LEFT_PANE, _ =>
       this.handleToggleLeftPane()
     );
-    this.shortcutsController.addGlobal({key: Key.ESC}, _ =>
-      this.handleEscKey()
-    );
     this.shortcutsController.addAbstract(
       Shortcut.EXPAND_ALL_COMMENT_THREADS,
       _ => {}
@@ -749,7 +751,7 @@
     );
     subscribe(
       this,
-      () => this.getFilesModel().filesWithUnmodified$,
+      () => this.getFilesModel().filesIncludingUnmodified$,
       files => {
         this.files = [...files];
       }
@@ -777,7 +779,7 @@
     );
     subscribe(
       this,
-      () => this.userModel.diffPreferences$,
+      () => this.getUserModel().diffPreferences$,
       diffPreferences => {
         this.diffPrefs = diffPreferences;
       }
@@ -786,7 +788,7 @@
       this,
       () =>
         select(
-          this.userModel.preferences$,
+          this.getUserModel().preferences$,
           prefs => !!prefs?.size_bar_in_change_table
         ),
       sizeBarInChangeTable => {
@@ -795,7 +797,7 @@
     );
     subscribe(
       this,
-      () => this.userModel.loggedIn$,
+      () => this.getUserModel().loggedIn$,
       loggedIn => {
         this.loggedIn = loggedIn;
       }
@@ -821,6 +823,13 @@
 
   override willUpdate(changedProperties: PropertyValues): void {
     if (
+      changedProperties.has('patchNum') ||
+      changedProperties.has('basePatchNum')
+    ) {
+      this.resetFileState();
+      this.collapseAllDiffs();
+    }
+    if (
       changedProperties.has('diffPrefs') ||
       changedProperties.has('diffViewMode')
     ) {
@@ -843,26 +852,29 @@
   override connectedCallback() {
     super.connectedCallback();
 
-    getPluginLoader()
+    this.getPluginLoader()
       .awaitPluginsLoaded()
       .then(() => {
-        this.dynamicHeaderEndpoints = getPluginEndpoints().getDynamicEndpoints(
-          'change-view-file-list-header'
-        );
-        this.dynamicContentEndpoints = getPluginEndpoints().getDynamicEndpoints(
-          'change-view-file-list-content'
-        );
+        this.dynamicHeaderEndpoints =
+          this.getPluginLoader().pluginEndPoints.getDynamicEndpoints(
+            'change-view-file-list-header'
+          );
+        this.dynamicContentEndpoints =
+          this.getPluginLoader().pluginEndPoints.getDynamicEndpoints(
+            'change-view-file-list-content'
+          );
         this.dynamicPrependedHeaderEndpoints =
-          getPluginEndpoints().getDynamicEndpoints(
+          this.getPluginLoader().pluginEndPoints.getDynamicEndpoints(
             'change-view-file-list-header-prepend'
           );
         this.dynamicPrependedContentEndpoints =
-          getPluginEndpoints().getDynamicEndpoints(
+          this.getPluginLoader().pluginEndPoints.getDynamicEndpoints(
             'change-view-file-list-content-prepend'
           );
-        this.dynamicSummaryEndpoints = getPluginEndpoints().getDynamicEndpoints(
-          'change-view-file-list-summary'
-        );
+        this.dynamicSummaryEndpoints =
+          this.getPluginLoader().pluginEndPoints.getDynamicEndpoints(
+            'change-view-file-list-summary'
+          );
 
         if (
           this.dynamicHeaderEndpoints.length !==
@@ -953,7 +965,10 @@
       <div class="path" role="columnheader">File</div>
       <div class="comments desktop" role="columnheader">Comments</div>
       <div class="comments mobile" role="columnheader" title="Comments">C</div>
-      <div class="sizeBars desktop" role="columnheader">Size</div>
+      ${when(
+        this.showSizeBars,
+        () => html`<div class="sizeBars desktop" role="columnheader">Size</div>`
+      )}
       <div class="header-stats" role="columnheader">Delta</div>
       <!-- endpoint: change-view-file-list-header -->
       ${when(showDynamicColumns, () => this.renderDynamicHeaderEndpoints())}
@@ -999,25 +1014,12 @@
     );
   }
 
-  // for DIFF_AUTOCLOSE logging purposes only
-  private shownFilesOld: NormalizedFileInfo[] = this.shownFiles;
-
   private renderShownFiles() {
     const showDynamicColumns = this.computeShowDynamicColumns();
     const showPrependedDynamicColumns =
       this.computeShowPrependedDynamicColumns();
     const sizeBarLayout = this.computeSizeBarLayout();
 
-    // for DIFF_AUTOCLOSE logging purposes only
-    if (
-      this.shownFilesOld.length > 0 &&
-      this.shownFiles !== this.shownFilesOld
-    ) {
-      this.reporting.reportInteraction(
-        Interaction.DIFF_AUTOCLOSE_SHOWN_FILES_CHANGED
-      );
-    }
-    this.shownFilesOld = this.shownFiles;
     return incrementalRepeat({
       values: this.shownFiles,
       mapFn: (f, i) =>
@@ -1049,6 +1051,7 @@
         data-file=${JSON.stringify(patchSetFile)}
         tabindex="-1"
         role="row"
+        aria-label=${file.__path}
       >
         <!-- endpoint: change-view-file-list-content-prepend -->
         ${when(showPrependedDynamicColumns, () =>
@@ -1067,11 +1070,10 @@
       </div>
       ${when(
         this.isFileExpanded(file.__path),
-        () => this.patched.html`
+        () => html`
           <gr-diff-host
             ?noAutoRender=${true}
             ?showLoadFailure=${true}
-            .displayLine=${this.displayLine}
             .changeNum=${this.changeNum}
             .change=${this.change}
             .patchRange=${this.patchRange}
@@ -1120,10 +1122,14 @@
     </div>`;
   }
 
-  private renderDivWithTooltip(content: string, tooltip: string) {
+  private renderDivWithTooltip(
+    content: TemplateResult | string,
+    tooltip: string,
+    cssClass = 'content'
+  ) {
     return html`
       <gr-tooltip-content title=${tooltip} has-tooltip>
-        <div class="content">${content}</div>
+        <div class=${cssClass}>${content}</div>
       </gr-tooltip-content>
     `;
   }
@@ -1163,12 +1169,18 @@
 
   private renderFileStatusLeft(path?: string) {
     if (this.filesLeftBase.length === 0) return nothing;
+    const arrow = html`
+      <gr-icon
+        icon="arrow_right_alt"
+        class="file-status-arrow"
+        aria-label="then"
+      ></gr-icon>
+    `;
     // no path means "header row"
     const psNum = this.basePatchNum;
     if (!path) {
       return html`
-        ${this.renderDivWithTooltip(`${psNum}`, `Patchset ${psNum}`)}
-        <gr-icon icon="arrow_right_alt" class="file-status-arrow"></gr-icon>
+        ${this.renderDivWithTooltip(`${psNum}`, `Patchset ${psNum}`)} ${arrow}
       `;
     }
     if (isMagicPath(path)) return nothing;
@@ -1185,7 +1197,7 @@
         .status=${status}
         .labelPostfix=${postfix}
       ></gr-file-status>
-      <gr-icon icon="arrow_right_alt" class="file-status-arrow"></gr-icon>
+      ${arrow}
     `;
   }
 
@@ -1202,6 +1214,7 @@
           >
             ${computeTruncatedPath(file.__path)}
           </span>
+          ${this.renderFileMode(file)}
           <gr-copy-clipboard
             ?hideInput=${true}
             .text=${file.__path}
@@ -1223,6 +1236,34 @@
     `;
   }
 
+  private renderFileMode(file: NormalizedFileInfo) {
+    const {old_mode, new_mode} = file;
+
+    // For added, modified or deleted regular files we do not want to render
+    // anything. Only if a file changed from something else to regular, then let
+    // the user know.
+    if (new_mode === undefined) return nothing;
+    let newModeStr = fileModeToString(new_mode, false);
+    if (new_mode === FileMode.REGULAR_FILE) {
+      if (old_mode === undefined) return nothing;
+      if (old_mode === FileMode.REGULAR_FILE) return nothing;
+      newModeStr = `non-${fileModeToString(old_mode, false)}`;
+    }
+
+    const changed = old_mode !== undefined && old_mode !== new_mode;
+    const icon = changed
+      ? html`<gr-icon icon="warning" class="file-mode-warning"></gr-icon> `
+      : '';
+    const action = changed
+      ? `changed from ${fileModeToString(old_mode)} to`
+      : 'is';
+    return this.renderDivWithTooltip(
+      html`${icon}(${newModeStr})`,
+      `file mode ${action} ${fileModeToString(new_mode)}`,
+      'file-mode-content'
+    );
+  }
+
   private renderStyledPath(filePath: string, previousFilePath?: string) {
     const {matchingFolders, newFolders, fileName} = diffFilePaths(
       filePath,
@@ -1242,8 +1283,7 @@
   private renderFileComments(file: NormalizedFileInfo) {
     return html` <div role="gridcell">
       <div class="comments desktop">
-        <span class="drafts">${this.computeDraftsString(file)}</span>
-        <span>${this.computeCommentsString(file)}</span>
+        <span>${this.renderCommentsChips(file)}</span>
         <span class="noCommentsScreenReaderText">
           <!-- Screen readers read the following content only if 2 other
           spans in the parent div is empty. The content is not visible on
@@ -1277,10 +1317,7 @@
           For example, without a nested div screen readers pronounce the
           "Commit message" row content with incorrect column headers.
         -->
-      <div
-        class=${this.computeSizeBarsClass(file.__path)}
-        aria-label="A bar that represents the addition and deletion ratio for the current file"
-      >
+      <div class=${this.computeSizeBarsClass(file.__path)} aria-hidden="true">
         <svg width="61" height="8">
           <rect
             x=${this.computeBarAdditionX(file, sizeBarLayout)}
@@ -1312,7 +1349,7 @@
         <span
           class="added"
           tabindex="0"
-          aria-label=${`${file.lines_inserted} lines added`}
+          aria-label=${`${file.lines_inserted} added`}
           ?hidden=${file.binary}
         >
           +${file.lines_inserted}
@@ -1320,7 +1357,7 @@
         <span
           class="removed"
           tabindex="0"
-          aria-label=${`${file.lines_deleted} lines removed`}
+          aria-label=${`${file.lines_deleted} removed`}
           ?hidden=${file.binary}
         >
           -${file.lines_deleted}
@@ -1410,6 +1447,7 @@
   }
 
   private renderShowHide(file: NormalizedFileInfo) {
+    const expanded = this.isFileExpanded(file.__path);
     return html` <div class="show-hide" role="gridcell">
       <!-- Do not use input type="checkbox" with hidden input and
             visible label here. Screen readers don't read/interract
@@ -1422,7 +1460,10 @@
         role="switch"
         tabindex="0"
         aria-checked=${this.isFileExpandedStr(file.__path)}
-        aria-label="Expand file"
+        aria-label=${expanded ? 'collapse' : 'expand'}
+        aria-description=${expanded
+          ? 'Collapse diff of this file'
+          : 'Expand diff of this file'}
         @click=${this.expandedClick}
         @keydown=${this.expandedClick}
       >
@@ -1432,7 +1473,7 @@
           class="show-hide-icon"
           tabindex="-1"
           id="icon"
-          icon=${this.computeShowHideIcon(file.__path)}
+          icon=${expanded ? 'expand_less' : 'expand_more'}
         ></gr-icon>
       </span>
     </div>`;
@@ -1597,22 +1638,31 @@
     </div>`;
   }
 
+  renderCommentsChips(file?: NormalizedFileInfo) {
+    if (!this.changeComments || !this.patchRange || !file?.__path) {
+      return nothing;
+    }
+    const commentThreads = this.changeComments?.computeCommentsThreads(
+      this.patchRange,
+      file.__path,
+      file
+    );
+    const draftCount = this.changeComments?.computeDraftCountForFile(
+      this.patchRange,
+      file
+    );
+    return html`<gr-comments-summary
+      .commentThreads=${commentThreads}
+      .draftCount=${draftCount}
+      emptyWhenNoComments
+    ></gr-comments-summary>`;
+  }
+
   protected override firstUpdated(): void {
     this.detectChromiteButler();
     this.reporting.fileListDisplayed();
   }
 
-  protected override updated(): void {
-    // for DIFF_AUTOCLOSE logging purposes only
-    const ids = this.diffs.map(d => d.uid);
-    if (ids.length > 0) {
-      this.reporting.reportInteraction(
-        Interaction.DIFF_AUTOCLOSE_FILE_LIST_UPDATED,
-        {l: ids.length, ids: ids.slice(0, 10)}
-      );
-    }
-  }
-
   // TODO: Move into files-model.
   // visible for testing
   async updateCleanlyMergedPaths() {
@@ -1731,10 +1781,6 @@
     if (!this.diffs.length) {
       return;
     }
-    this.reporting.reportInteraction(
-      Interaction.DIFF_AUTOCLOSE_RELOAD_FILELIST_PREFS
-    );
-
     // Re-render all expanded diffs sequentially.
     this.renderInOrder(this.expandedFiles, this.diffs);
   }
@@ -1821,14 +1867,14 @@
       return '';
     }
     const commentThreadCount =
-      this.changeComments.computeCommentThreadCount({
+      this.changeComments.computeCommentThreads({
         patchNum: this.patchRange.basePatchNum,
         path: file.__path,
-      }) +
-      this.changeComments.computeCommentThreadCount({
+      }).length +
+      this.changeComments.computeCommentThreads({
         patchNum: this.patchRange.patchNum,
         path: file.__path,
-      });
+      }).length;
     return commentThreadCount === 0 ? '' : `${commentThreadCount}c`;
   }
 
@@ -1984,7 +2030,6 @@
     e.stopPropagation();
     if (this.filesExpanded === FilesExpandedState.ALL) {
       this.diffCursor?.moveDown();
-      this.displayLine = true;
     } else {
       this.fileCursor.next({circular: true});
       this.selectedIndex = this.fileCursor.index;
@@ -2004,7 +2049,6 @@
     e.stopPropagation();
     if (this.filesExpanded === FilesExpandedState.ALL) {
       this.diffCursor?.moveUp();
-      this.displayLine = true;
     } else {
       this.fileCursor.previous({circular: true});
       this.selectedIndex = this.fileCursor.index;
@@ -2075,9 +2119,9 @@
     this.getNavigation().setUrl(
       createDiffUrl({
         change: this.change,
-        path: diff.path,
         patchNum: this.patchRange.patchNum,
         basePatchNum: this.patchRange.basePatchNum,
+        diffView: {path: diff.path},
       })
     );
   }
@@ -2096,9 +2140,9 @@
     this.getNavigation().setUrl(
       createDiffUrl({
         change: this.change,
-        path: this.files[this.fileCursor.index].__path,
         patchNum: this.patchRange.patchNum,
         basePatchNum: this.patchRange.basePatchNum,
+        diffView: {path: this.files[this.fileCursor.index].__path},
       })
     );
   }
@@ -2129,17 +2173,17 @@
     if (this.editMode && path !== SpecialFilePath.MERGE_LIST) {
       return createEditUrl({
         changeNum: this.change._number,
-        project: this.change.project,
-        path,
+        repo: this.change.project,
         patchNum: this.patchRange.patchNum,
+        editView: {path},
       });
     }
     return createDiffUrl({
       changeNum: this.change._number,
-      project: this.change.project,
-      path,
+      repo: this.change.project,
       patchNum: this.patchRange.patchNum,
       basePatchNum: this.patchRange.basePatchNum,
+      diffView: {path},
     });
   }
 
@@ -2190,10 +2234,6 @@
     return this.isFileExpanded(path) ? 'expanded' : '';
   }
 
-  private computeShowHideIcon(path: string | undefined) {
-    return this.isFileExpanded(path) ? 'expand_less' : 'expand_more';
-  }
-
   private computeShowNumCleanlyMerged(): boolean {
     return this.cleanlyMergedPaths.length > 0;
   }
@@ -2218,13 +2258,7 @@
     const previousNumFilesShown = this.shownFiles ? this.shownFiles.length : 0;
 
     const filesShown = this.files.slice(0, this.numFilesShown);
-    this.dispatchEvent(
-      new CustomEvent('files-shown-changed', {
-        detail: {length: filesShown.length},
-        composed: true,
-        bubbles: true,
-      })
-    );
+    fire(this, 'files-shown-changed', {length: filesShown.length});
 
     // Start the timer for the rendering work here because this is where the
     // shownFiles property is being set, and shownFiles is used in the
@@ -2460,11 +2494,6 @@
     return undefined;
   }
 
-  // Private but used in tests.
-  handleEscKey() {
-    this.displayLine = false;
-  }
-
   /**
    * Compute size bar layout values from the file list.
    * Private but used in tests.
@@ -2616,7 +2645,7 @@
   }
 
   private handleReloadingDiffPreference() {
-    this.userModel.getDiffPreferences();
+    this.getUserModel().getDiffPreferences();
   }
 
   private getOldPath(file: NormalizedFileInfo) {
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 529c05e..6033a25 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
@@ -56,6 +56,7 @@
 import {fixture, html, assert} from '@open-wc/testing';
 import {Modifier} from '../../../utils/dom-util';
 import {testResolver} from '../../../test/common-test-setup';
+import {FileMode} from '../../../utils/file-util';
 
 suite('gr-diff a11y test', () => {
   test('audit', async () => {
@@ -68,7 +69,7 @@
   fileInfo: FileInfo = {}
 ): NormalizedFileInfo[] {
   const files = Array(count).fill({});
-  return files.map((_, idx) => normalize(fileInfo, `'/file${idx}`));
+  return files.map((_, idx) => normalize(fileInfo, `path/file${idx}`));
 }
 
 suite('gr-file-list tests', () => {
@@ -82,9 +83,6 @@
       stubRestApi('getDiffRobotComments').returns(Promise.resolve({}));
       stubRestApi('getDiffDrafts').returns(Promise.resolve({}));
       stubRestApi('getAccountCapabilities').returns(Promise.resolve({}));
-      stubElement('gr-date-formatter', 'loadTimeFormat').callsFake(() =>
-        Promise.resolve()
-      );
       stubElement('gr-diff-host', 'reload').callsFake(() => Promise.resolve());
       stubElement('gr-diff-host', 'prefetchDiff').callsFake(() => {});
 
@@ -110,6 +108,7 @@
         .stub(element, '_saveReviewedState')
         .callsFake(() => Promise.resolve());
       await element.updateComplete;
+      element.showSizeBars = true;
       // Wait for expandedFilesChanged to complete.
       await waitEventLoop();
     });
@@ -171,26 +170,33 @@
         fileRows?.[0],
         /* HTML */ `<div
           class="file-row row"
-          data-file='{"path":"&apos;/file0"}'
+          data-file='{"path":"path/file0"}'
           role="row"
           tabindex="-1"
+          aria-label="path/file0"
         >
           <div class="status" role="gridcell">
             <gr-file-status></gr-file-status>
           </div>
           <span class="path" role="gridcell">
             <a class="pathLink">
-              <span class="fullFileName" title="'/file0">
-                <span class="newFilePath"> '/ </span>
+              <span class="fullFileName" title="path/file0">
+                <span class="newFilePath"> path/ </span>
                 <span class="fileName"> file0 </span>
               </span>
-              <span class="truncatedFileName" title="'/file0"> …/file0 </span>
+              <span class="truncatedFileName" title="path/file0">
+                …/file0
+              </span>
               <gr-copy-clipboard hideinput=""> </gr-copy-clipboard>
             </a>
           </span>
           <div role="gridcell">
             <div class="comments desktop">
-              <span class="drafts"> </span> <span> </span>
+              <span
+                ><gr-comments-summary
+                  emptywhennocomments=""
+                ></gr-comments-summary
+              ></span>
               <span class="noCommentsScreenReaderText"> No comments </span>
             </div>
             <div class="comments mobile">
@@ -199,17 +205,12 @@
             </div>
           </div>
           <div class="desktop" role="gridcell">
-            <div
-              aria-label="A bar that represents the addition and deletion ratio for the current file"
-              class="hide sizeBars"
-            ></div>
+            <div aria-hidden="true" class="sizeBars"></div>
           </div>
           <div class="stats" role="gridcell">
             <div>
-              <span aria-label="9 lines added" class="added" tabindex="0">
-                +9
-              </span>
-              <span aria-label="0 lines removed" class="removed" tabindex="0">
+              <span aria-label="9 added" class="added" tabindex="0"> +9 </span>
+              <span aria-label="0 removed" class="removed" tabindex="0">
                 -0
               </span>
               <span hidden=""> +/-0 B </span>
@@ -241,10 +242,11 @@
           <div class="show-hide" role="gridcell">
             <span
               aria-checked="false"
-              aria-label="Expand file"
+              aria-label="expand"
+              aria-description="Expand diff of this file"
               class="show-hide"
               data-expand="true"
-              data-path="'/file0"
+              data-path="path/file0"
               role="switch"
               tabindex="0"
             >
@@ -270,11 +272,13 @@
         /* HTML */ `
           <span class="path" role="gridcell">
             <a class="pathLink">
-              <span class="fullFileName" title="'/file0">
-                <span class="newFilePath"> '/ </span>
+              <span class="fullFileName" title="path/file0">
+                <span class="newFilePath"> path/ </span>
                 <span class="fileName"> file0 </span>
               </span>
-              <span class="truncatedFileName" title="'/file0"> …/file0 </span>
+              <span class="truncatedFileName" title="path/file0">
+                …/file0
+              </span>
               <gr-copy-clipboard hideinput=""> </gr-copy-clipboard>
             </a>
           </span>
@@ -286,11 +290,13 @@
         /* HTML */ `
           <span class="path" role="gridcell">
             <a class="pathLink">
-              <span class="fullFileName" title="'/file1">
-                <span class="matchingFilePath"> '/ </span>
+              <span class="fullFileName" title="path/file1">
+                <span class="matchingFilePath"> path/ </span>
                 <span class="fileName"> file1 </span>
               </span>
-              <span class="truncatedFileName" title="'/file1"> …/file1 </span>
+              <span class="truncatedFileName" title="path/file1">
+                …/file1
+              </span>
               <gr-copy-clipboard hideinput=""> </gr-copy-clipboard>
             </a>
           </span>
@@ -309,13 +315,55 @@
         /* HTML */ `
           <div class="extended status" role="gridcell">
             <gr-file-status></gr-file-status>
-            <gr-icon class="file-status-arrow" icon="arrow_right_alt"></gr-icon>
+            <gr-icon
+              aria-label="then"
+              class="file-status-arrow"
+              icon="arrow_right_alt"
+            ></gr-icon>
             <gr-file-status></gr-file-status>
           </div>
         `
       );
     });
 
+    test('renders file mode', async () => {
+      element.files = createFiles(1, {
+        old_mode: FileMode.REGULAR_FILE,
+        new_mode: FileMode.EXECUTABLE_FILE,
+      });
+      await element.updateComplete;
+      const fileRows = queryAll<HTMLDivElement>(element, '.file-row');
+      const fileMode = queryAndAssert(
+        fileRows?.[0],
+        '.path gr-tooltip-content'
+      );
+      assert.dom.equal(
+        fileMode,
+        /* HTML */ `
+          <gr-tooltip-content
+            has-tooltip=""
+            title="file mode changed from regular (100644) to executable (100755)"
+          >
+            <div class="file-mode-content">
+              <gr-icon class="file-mode-warning" icon="warning"> </gr-icon>
+              (executable)
+            </div>
+          </gr-tooltip-content>
+        `
+      );
+    });
+
+    test('renders file mode, but not for regular files', async () => {
+      element.files = createFiles(3, {
+        old_mode: FileMode.REGULAR_FILE,
+        new_mode: FileMode.REGULAR_FILE,
+      });
+      await element.updateComplete;
+      const fileRows = queryAll<HTMLDivElement>(element, '.file-row');
+      const fileMode = query(fileRows?.[0], '.path gr-tooltip-content');
+      assert.notOk(fileMode);
+    });
+
     test('renders file status column header', async () => {
       element.files = createFiles(1, {lines_inserted: 9});
       element.filesLeftBase = createFiles(1, {lines_inserted: 9});
@@ -330,7 +378,11 @@
             <gr-tooltip-content has-tooltip="" title="Patchset 1">
               <div class="content">1</div>
             </gr-tooltip-content>
-            <gr-icon class="file-status-arrow" icon="arrow_right_alt"></gr-icon>
+            <gr-icon
+              aria-label="then"
+              class="file-status-arrow"
+              icon="arrow_right_alt"
+            ></gr-icon>
             <gr-tooltip-content has-tooltip="" title="Patchset 2">
               <div class="content">2</div>
             </gr-tooltip-content>
@@ -2030,9 +2082,6 @@
       stubRestApi('getDiffComments').returns(Promise.resolve({}));
       stubRestApi('getDiffRobotComments').returns(Promise.resolve({}));
       stubRestApi('getDiffDrafts').returns(Promise.resolve({}));
-      stubElement('gr-date-formatter', 'loadTimeFormat').callsFake(() =>
-        Promise.resolve()
-      );
       stubRestApi('getDiff').callsFake(() => Promise.resolve(createDiff()));
       stubElement('gr-diff-host', 'prefetchDiff').callsFake(() => {});
 
@@ -2262,22 +2311,6 @@
       assert.isTrue(setUrlStub.calledOnce);
     });
 
-    test('displayLine', () => {
-      element.filesExpanded = FilesExpandedState.ALL;
-
-      element.displayLine = false;
-      element.handleCursorNext(new KeyboardEvent('keydown'));
-      assert.isTrue(element.displayLine);
-
-      element.displayLine = false;
-      element.handleCursorPrev(new KeyboardEvent('keydown'));
-      assert.isTrue(element.displayLine);
-
-      element.displayLine = true;
-      element.handleEscKey();
-      assert.isFalse(element.displayLine);
-    });
-
     suite('editMode behavior', () => {
       test('reviewed checkbox', async () => {
         reviewFileStub.restore();
diff --git a/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.ts b/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.ts
index fcfe209..33dfe82 100644
--- a/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.ts
@@ -12,6 +12,7 @@
 import {LitElement, PropertyValues, html, css} from 'lit';
 import {customElement, property, state} from 'lit/decorators.js';
 import {BindValueChangeEvent} from '../../../types/events';
+import {fireNoBubble} from '../../../utils/event-util';
 
 interface DisplayGroup {
   title: string;
@@ -197,12 +198,7 @@
   private handleCloseTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
-    this.dispatchEvent(
-      new CustomEvent('close', {
-        composed: true,
-        bubbles: false,
-      })
-    );
+    fireNoBubble(this, 'close', {});
   }
 }
 
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 50c5caf..13662982 100644
--- a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.ts
+++ b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.ts
@@ -18,21 +18,20 @@
 import {assertIsDefined, hasOwnProperty} from '../../../utils/common-util';
 import {Label} from '../../../utils/label-util';
 import {LabelNameToValuesMap} from '../../../api/rest-api';
+import {fire} from '../../../utils/event-util';
+import {LabelsChangedDetail} from '../../../api/change-reply';
 
 declare global {
   interface HTMLElementTagNameMap {
     'gr-label-score-row': GrLabelScoreRow;
   }
+  interface HTMLElementEventMap {
+    'labels-changed': CustomEvent<LabelsChangedDetail>;
+  }
 }
 
 @customElement('gr-label-score-row')
 export class GrLabelScoreRow extends LitElement {
-  /**
-   * Fired when any label is changed.
-   *
-   * @event labels-changed
-   */
-
   @query('#labelSelector')
   labelSelector?: IronSelectorElement;
 
@@ -169,7 +168,7 @@
   // Render blank cells so that all same value votes are aligned
   private renderBlankItems(position: string) {
     const blankItemCount = this.computeBlankItemsCount(position);
-    return new Array(blankItemCount)
+    return Array.from({length: blankItemCount})
       .fill('')
       .map(
         () => html`
@@ -365,13 +364,7 @@
     this.selectedValueText = selectedItem.getAttribute('title') || '';
     const name = selectedItem.dataset['name'];
     const value = selectedItem.dataset['value'];
-    this.dispatchEvent(
-      new CustomEvent('labels-changed', {
-        detail: {name, value},
-        bubbles: true,
-        composed: true,
-      })
-    );
+    if (name && value) fire(this, 'labels-changed', {name, value});
   };
 
   private computePermittedLabelValues() {
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 850ae09..3a83fa1 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
@@ -5,7 +5,7 @@
  */
 import '../gr-label-score-row/gr-label-score-row';
 import '../../../styles/shared-styles';
-import {LitElement, css, html} from 'lit';
+import {LitElement, css, html, nothing} from 'lit';
 import {customElement, property} from 'lit/decorators.js';
 import {
   ChangeInfo,
@@ -49,10 +49,8 @@
         .abandonedMessage {
           font-style: italic;
           text-align: center;
-          width: 100%;
         }
         .permissionMessage {
-          width: 100%;
           color: var(--deemphasized-text-color);
           padding-left: var(--label-score-padding-left, 0);
         }
@@ -109,8 +107,7 @@
         label => !this.permittedLabels || this.permittedLabels[label.name]
       ).length === 0
     ) {
-      return html`<h3 class="heading-4">Trigger Votes</h3>
-        <div class="permissionMessage">You don't have permission to vote</div>`;
+      return nothing;
     }
     return html`<h3 class="heading-4">Trigger Votes</h3>
       ${this.renderLabels(labels)}`;
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 9799880..09ca036 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
@@ -8,12 +8,12 @@
 import {customElement, property} from 'lit/decorators.js';
 import {ChangeInfo} from '../../../api/rest-api';
 import {
-  ChangeMessage,
   LabelExtreme,
   PATCH_SET_PREFIX_PATTERN,
 } from '../../../utils/comment-util';
 import {hasOwnProperty} from '../../../utils/common-util';
 import {getTriggerVotes} from '../../../utils/label-util';
+import {ChangeMessage} from '../../../types/common';
 
 const VOTE_RESET_TEXT = '0 (vote reset)';
 
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message.ts b/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
index a4da747..4abfeff 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
@@ -24,10 +24,10 @@
   AccountInfo,
   BasePatchSetNum,
   LabelNameToInfoMap,
+  CommentThread,
+  ChangeMessage,
 } from '../../../types/common';
 import {
-  ChangeMessage,
-  CommentThread,
   isFormattedReviewerUpdate,
   LabelExtreme,
   PATCH_SET_PREFIX_PATTERN,
@@ -48,6 +48,8 @@
 import {FormattedReviewerUpdateInfo} from '../../../types/types';
 import {resolve} from '../../../models/dependency';
 import {createChangeUrl} from '../../../models/views/change';
+import {fire} from '../../../utils/event-util';
+import {ChangeMessageDeletedEventDetail} from '../../../types/events';
 
 const UPLOADED_NEW_PATCHSET_PATTERN = /Uploaded patch set (\d+)./;
 const MERGED_PATCHSET_PATTERN = /(\d+) is the latest approved patch-set/;
@@ -55,6 +57,10 @@
   interface HTMLElementTagNameMap {
     'gr-message': GrMessage;
   }
+  interface HTMLElementEventMap {
+    'message-anchor-tap': CustomEvent<MessageAnchorTapDetail>;
+    'change-message-deleted': CustomEvent<ChangeMessageDeletedEventDetail>;
+  }
 }
 
 export interface MessageAnchorTapDetail {
@@ -70,12 +76,6 @@
    */
 
   /**
-   * Fired when the message's timestamp is tapped.
-   *
-   * @event message-anchor-tap
-   */
-
-  /**
    * Fired when a change message is deleted.
    *
    * @event change-message-deleted
@@ -123,9 +123,6 @@
 
   private readonly getNavigation = resolve(this, navigationToken);
 
-  // for COMMENTS_AUTOCLOSE logging purposes only
-  readonly uid = performance.now().toString(36) + Math.random().toString(36);
-
   constructor() {
     super();
     this.addEventListener('click', e => this.handleClick(e));
@@ -205,15 +202,11 @@
           margin: 0 -4px;
         }
         .collapsed gr-thread-list,
-        .collapsed .replyBtn,
         .collapsed .deleteBtn,
         .collapsed .hideOnCollapsed,
         .hideOnOpen {
           display: none;
         }
-        .replyBtn {
-          margin-right: var(--spacing-m);
-        }
         .collapsed .hideOnOpen {
           display: block;
         }
@@ -440,24 +433,18 @@
   }
 
   private renderActionContainer() {
-    if (!this.computeShowReplyButton()) return nothing;
+    if (!this.isAdmin || !this.loggedIn || this.computeIsAutomated()) {
+      return nothing;
+    }
     return html` <div class="replyActionContainer">
-      <gr-button class="replyBtn" link="" @click=${this.handleReplyTap}>
-        Reply
+      <gr-button
+        ?disabled=${this.isDeletingChangeMsg}
+        class="deleteBtn"
+        link=""
+        @click=${this.handleDeleteMessage}
+      >
+        Delete
       </gr-button>
-      ${when(
-        this.isAdmin,
-        () => html`
-          <gr-button
-            ?disabled=${this.isDeletingChangeMsg}
-            class="deleteBtn"
-            link=""
-            @click=${this.handleDeleteMessage}
-          >
-            Delete
-          </gr-button>
-        `
-      )}
     </div>`;
   }
 
@@ -695,16 +682,6 @@
     );
   }
 
-  // private but used in tests.
-  computeShowReplyButton() {
-    return (
-      !!this.message &&
-      !!this.message.message &&
-      this.loggedIn &&
-      !this.computeIsAutomated()
-    );
-  }
-
   private handleClick(e: Event) {
     if (!this.message || this.message?.expanded) {
       return;
@@ -746,29 +723,13 @@
 
   private handleAnchorClick(e: Event) {
     e.preventDefault();
+    assertIsDefined(this.message, 'message');
     // The element which triggers handleAnchorClick is rendered only if
     // message.id defined: the element is wrapped in dom-if if="[[message.id]]"
     const detail: MessageAnchorTapDetail = {
-      id: this.message!.id,
+      id: this.message.id,
     };
-    this.dispatchEvent(
-      new CustomEvent('message-anchor-tap', {
-        bubbles: true,
-        composed: true,
-        detail,
-      })
-    );
-  }
-
-  private handleReplyTap(e: Event) {
-    e.preventDefault();
-    this.dispatchEvent(
-      new CustomEvent('reply', {
-        detail: {message: this.message},
-        composed: true,
-        bubbles: true,
-      })
-    );
+    fire(this, 'message-anchor-tap', detail);
   }
 
   private handleDeleteMessage(e: Event) {
@@ -779,13 +740,10 @@
       .deleteChangeCommitMessage(this.changeNum, this.message.id)
       .then(() => {
         this.isDeletingChangeMsg = false;
-        this.dispatchEvent(
-          new CustomEvent('change-message-deleted', {
-            detail: {message: this.message},
-            composed: true,
-            bubbles: true,
-          })
-        );
+        // TODO: Fix the type casting. Might actually be a bug.
+        fire(this, 'change-message-deleted', {
+          message: this.message as ChangeMessage,
+        });
       });
   }
 
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message_test.ts b/polygerrit-ui/app/elements/change/gr-message/gr-message_test.ts
index 34292d6..1ed2729 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message_test.ts
@@ -5,7 +5,10 @@
  */
 import '../../../test/common-test-setup';
 import './gr-message';
-import {navigationToken} from '../../core/gr-navigation/gr-navigation';
+import {
+  NavigationService,
+  navigationToken,
+} from '../../core/gr-navigation/gr-navigation';
 import {
   createAccountWithIdNameAndEmail,
   createChange,
@@ -20,7 +23,6 @@
   query,
   queryAndAssert,
   stubRestApi,
-  waitEventLoop,
 } from '../../../test/test-utils';
 import {GrMessage} from './gr-message';
 import {
@@ -32,14 +34,12 @@
   ReviewInputTag,
   Timestamp,
   UrlEncodedCommentId,
+  SavingState,
 } from '../../../types/common';
-import {
-  ChangeMessageDeletedEventDetail,
-  ReplyEventDetail,
-} from '../../../types/events';
+import {ChangeMessageDeletedEventDetail} from '../../../types/events';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {CommentSide} from '../../../constants/constants';
-import {SinonStub} from 'sinon';
+import {SinonStubbedMember} from 'sinon';
 import {html} from 'lit';
 import {fixture, assert} from '@open-wc/testing';
 import {testResolver} from '../../../test/common-test-setup';
@@ -53,32 +53,6 @@
       element = await fixture<GrMessage>(html`<gr-message></gr-message>`);
     });
 
-    test('reply event', async () => {
-      element.message = {
-        ...createChangeMessage(),
-        id: '47c43261_55aa2c41' as ChangeMessageId,
-        author: {
-          _account_id: 1115495 as AccountId,
-          name: 'Andrew Bonventre',
-          email: 'andybons@chromium.org' as EmailAddress,
-        },
-        date: '2016-01-12 20:24:49.448000000' as Timestamp,
-        message: 'Uploaded patch set 1.',
-        _revision_number: 1 as RevisionPatchSetNum,
-        expanded: true,
-      };
-
-      const promise = mockPromise();
-      element.addEventListener('reply', (e: CustomEvent<ReplyEventDetail>) => {
-        assert.deepEqual(e.detail.message, element.message);
-        promise.resolve();
-      });
-      await waitEventLoop();
-      assert.isOk(query<HTMLElement>(element, '.replyActionContainer'));
-      queryAndAssert<GrButton>(element, '.replyBtn').click();
-      await promise;
-    });
-
     test('can see delete button', async () => {
       element.message = {
         ...createChangeMessage(),
@@ -368,18 +342,6 @@
       assert.shadowDom.equal(element, rendered);
     });
 
-    test('reply button hidden unless logged in', () => {
-      element.message = {
-        ...createChangeMessage(),
-        message: 'Uploaded patch set 1.',
-        expanded: false,
-      };
-      element.loggedIn = false;
-      assert.isFalse(element.computeShowReplyButton());
-      element.loggedIn = true;
-      assert.isTrue(element.computeShowReplyButton());
-    });
-
     test('_computeShowOnBehalfOf', () => {
       const message = {
         ...createChangeMessage(),
@@ -423,7 +385,7 @@
     });
 
     suite('uploaded patchset X message navigates to X - 1 vs  X', () => {
-      let setUrlStub: SinonStub;
+      let setUrlStub: SinonStubbedMember<NavigationService['setUrl']>;
       setup(() => {
         element.change = {...createChange(), revisions: createRevisions(4)};
         setUrlStub = sinon.stub(testResolver(navigationToken), 'setUrl');
@@ -814,7 +776,7 @@
               message: 'n',
               unresolved: false,
               path: '/PATCHSET_LEVEL',
-              __draft: true,
+              savingState: SavingState.OK,
             },
           ],
           patchNum: 1 as RevisionPatchSetNum,
@@ -830,59 +792,4 @@
       );
     });
   });
-
-  suite('when logged in but not admin', () => {
-    setup(async () => {
-      stubRestApi('getIsAdmin').returns(Promise.resolve(false));
-      element = await fixture<GrMessage>(html`<gr-message></gr-message>`);
-    });
-
-    test('can see reply but not delete button', async () => {
-      element.message = {
-        ...createChangeMessage(),
-        id: '47c43261_55aa2c41' as ChangeMessageId,
-        author: {
-          _account_id: 1115495 as AccountId,
-          name: 'Andrew Bonventre',
-          email: 'andybons@chromium.org' as EmailAddress,
-        },
-        date: '2016-01-12 20:24:49.448000000' as Timestamp,
-        message: 'Uploaded patch set 1.',
-        _revision_number: 1 as RevisionPatchSetNum,
-        expanded: true,
-      };
-      await element.updateComplete;
-
-      assert.isOk(query<HTMLElement>(element, '.replyActionContainer'));
-      assert.isNotOk(query<HTMLElement>(element, '.deleteBtn'));
-    });
-
-    test('reply button shown when message is updated', async () => {
-      element.message = undefined;
-      await element.updateComplete;
-
-      let replyEl = query(element, '.replyActionContainer');
-      // We don't even expect the button to show up in the DOM when the message
-      // is undefined.
-      assert.isNotOk(replyEl);
-
-      element.message = {
-        ...createChangeMessage(),
-        id: '47c43261_55aa2c41' as ChangeMessageId,
-        author: {
-          _account_id: 1115495 as AccountId,
-          name: 'Andrew Bonventre',
-          email: 'andybons@chromium.org' as EmailAddress,
-        },
-        date: '2016-01-12 20:24:49.448000000' as Timestamp,
-        message: 'not empty',
-        _revision_number: 1 as RevisionPatchSetNum,
-        expanded: true,
-      };
-      await element.updateComplete;
-
-      replyEl = queryAndAssert(element, '.replyActionContainer');
-      assert.isOk(replyEl);
-    });
-  });
 });
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts
index 5417127..d5da9c9 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts
@@ -15,12 +15,15 @@
   ChangeId,
   ChangeMessageId,
   ChangeMessageInfo,
+  CommentThread,
   LabelNameToInfoMap,
   NumericChangeId,
   PatchSetNum,
   VotingRangeInfo,
+  isRobot,
+  EDIT,
+  PARENT,
 } from '../../../types/common';
-import {CommentThread, isRobot} from '../../../utils/comment-util';
 import {GrMessage, MessageAnchorTapDetail} from '../gr-message/gr-message';
 import {getVotingRange} from '../../../utils/label-util';
 import {
@@ -43,7 +46,7 @@
   shortcutsServiceToken,
 } from '../../../services/shortcuts/shortcuts-service';
 import {GrFormattedText} from '../../shared/gr-formatted-text/gr-formatted-text';
-import {Interaction} from '../../../constants/reporting';
+import {waitUntil} from '../../../utils/async-util';
 
 /**
  * The content of the enum is also used in the UI for the button text.
@@ -154,13 +157,25 @@
   message: CombinedMessage,
   allMessages: CombinedMessage[]
 ): PatchSetNum | undefined {
-  if (message._revision_number && message._revision_number > 0)
+  if (
+    message._revision_number !== undefined &&
+    message._revision_number !== 0 &&
+    message._revision_number !== PARENT &&
+    message._revision_number !== EDIT
+  ) {
     return message._revision_number;
+  }
   let revision: PatchSetNum = 0 as PatchSetNum;
   for (const m of allMessages) {
     if (m.date > message.date) break;
-    if (m._revision_number && m._revision_number > revision)
+    if (
+      m._revision_number !== undefined &&
+      m._revision_number !== 0 &&
+      m._revision_number !== PARENT &&
+      m._revision_number !== EDIT
+    ) {
       revision = m._revision_number;
+    }
   }
   return revision > 0 ? revision : undefined;
 }
@@ -322,8 +337,7 @@
   @state()
   private combinedMessages: CombinedMessage[] = [];
 
-  // Private but used in tests.
-  readonly getCommentsModel = resolve(this, commentsModelToken);
+  private readonly getCommentsModel = resolve(this, commentsModelToken);
 
   private readonly changeModel = resolve(this, changeModelToken);
 
@@ -335,7 +349,7 @@
     super();
     subscribe(
       this,
-      () => this.getCommentsModel().threads$,
+      () => this.getCommentsModel().threadsSaved$,
       x => {
         this.commentThreads = x;
       }
@@ -354,21 +368,6 @@
         this.changeNum = x;
       }
     );
-    // for COMMENTS_AUTOCLOSE logging purposes only
-    this.reporting.reportInteraction(
-      Interaction.COMMENTS_AUTOCLOSE_MESSAGES_LIST_CREATED
-    );
-  }
-
-  override updated(): void {
-    // for COMMENTS_AUTOCLOSE logging purposes only
-    const messages = this.shadowRoot!.querySelectorAll('gr-message');
-    if (messages.length > 0) {
-      this.reporting.reportInteraction(
-        Interaction.COMMENTS_AUTOCLOSE_MESSAGES_LIST_UPDATED,
-        {uid: messages[0].uid}
-      );
-    }
   }
 
   override willUpdate(changedProperties: PropertyValues): void {
@@ -441,6 +440,9 @@
   }
 
   async scrollToMessage(messageID: string) {
+    await waitUntil(() => this.messages && this.messages.length > 0);
+    await this.updateComplete;
+
     const selector = `[data-message-id="${messageID}"]`;
     const el = this.shadowRoot!.querySelector(selector) as
       | GrMessage
@@ -465,15 +467,7 @@
     await el.updateComplete;
     await query<GrFormattedText>(el, 'gr-formatted-text.message')
       ?.updateComplete;
-    let top = el.offsetTop;
-    for (
-      let offsetParent = el.offsetParent as HTMLElement | null;
-      offsetParent;
-      offsetParent = offsetParent.offsetParent as HTMLElement | null
-    ) {
-      top += offsetParent.offsetTop;
-    }
-    window.scrollTo(0, top);
+    el.scrollIntoView();
     this.highlightEl(el);
   }
 
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 2e62718..df04f68 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
@@ -27,11 +27,13 @@
   Timestamp,
   UrlEncodedCommentId,
 } from '../../../types/common';
-import {assertIsDefined} from '../../../utils/common-util';
+import {assertIsDefined, uuid} from '../../../utils/common-util';
 import {html} from 'lit';
 import {fixture, assert} from '@open-wc/testing';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {PaperToggleButtonElement} from '@polymer/paper-toggle-button';
+import {testResolver} from '../../../test/common-test-setup';
+import {commentsModelToken} from '../../../models/comments/comments-model';
 
 const author = {
   _account_id: 42 as AccountId,
@@ -51,17 +53,17 @@
   };
 };
 
-const randomMessage = function (opt_params?: ChangeMessageInfo) {
-  const params = opt_params || ({} as ChangeMessageInfo);
+const randomMessage = function (params?: ChangeMessageInfo) {
+  params = params || ({} as ChangeMessageInfo);
   const author1 = {
     _account_id: 1115495 as AccountId,
     name: 'Andrew Bonventre',
     email: 'andybons@chromium.org' as EmailAddress,
   };
   return {
-    id: (params.id || Math.random().toString()) as ChangeMessageId,
+    id: (params.id || uuid()) as ChangeMessageId,
     date: (params.date || '2016-01-12 20:28:33.038000') as Timestamp,
-    message: params.message || Math.random().toString(),
+    message: params.message || uuid(),
     _revision_number: (params._revision_number || 1) as PatchSetNum,
     author: params.author || author1,
     tag: params.tag,
@@ -69,7 +71,7 @@
 };
 
 function generateRandomMessages(count: number) {
-  return new Array(count)
+  return Array.from({length: count})
     .fill(undefined)
     .map(() => randomMessage()) as ChangeMessageInfo[];
 }
@@ -136,7 +138,9 @@
       element = await fixture<GrMessagesList>(
         html`<gr-messages-list></gr-messages-list>`
       );
-      await element.getCommentsModel().reloadComments(0 as NumericChangeId);
+      await testResolver(commentsModelToken).reloadComments(
+        0 as NumericChangeId
+      );
       element.messages = messages;
       await element.updateComplete;
     });
@@ -191,9 +195,9 @@
       }
     });
 
-    test('expand/collapse from external keypress', () => {
+    test('expand/collapse from external keypress', async () => {
       // Start with one expanded message. -> not all collapsed
-      element.scrollToMessage(messages[1].id);
+      await element.scrollToMessage(messages[1].id);
       assert.isFalse(
         [...getMessages()].filter(m => m.message?.expanded).length === 0
       );
@@ -229,7 +233,6 @@
         message.message = {...message.message, expanded: false};
       }
 
-      const scrollToStub = sinon.stub(window, 'scrollTo');
       const highlightStub = sinon.stub(element, 'highlightEl');
 
       await element.scrollToMessage('invalid');
@@ -243,6 +246,11 @@
       }
 
       const messageID = messages[1].id;
+
+      const selector = `[data-message-id="${messageID}"]`;
+      const el = queryAndAssert<GrMessage>(element, selector);
+      const scrollToStub = sinon.stub(el, 'scrollIntoView');
+
       await element.scrollToMessage(messageID);
       assert.isTrue(
         queryAndAssert<GrMessage>(element, `[data-message-id="${messageID}"]`)
@@ -254,14 +262,18 @@
     });
 
     test('scroll to message offscreen', async () => {
-      const scrollToStub = sinon.stub(window, 'scrollTo');
       const highlightStub = sinon.stub(element, 'highlightEl');
       element.messages = generateRandomMessages(25);
       await element.updateComplete;
-      assert.isFalse(scrollToStub.called);
       assert.isFalse(highlightStub.called);
 
       const messageID = element.messages[1].id;
+      const selector = `[data-message-id="${messageID}"]`;
+      const el = queryAndAssert<GrMessage>(element, selector);
+      const scrollToStub = sinon.stub(el, 'scrollIntoView');
+
+      assert.isFalse(scrollToStub.called);
+
       await element.scrollToMessage(messageID);
       assert.isTrue(scrollToStub.calledOnce);
       assert.isTrue(highlightStub.calledOnce);
@@ -317,10 +329,11 @@
       await element.updateComplete;
       const messageElements = getMessages();
       // threads
-      assert.equal(messageElements[0].message!.commentThreads.length, 3);
+      assertIsDefined(messageElements[0].message, 'message');
+      assert.equal(messageElements[0].message.commentThreads.length, 3);
       // first thread contains 1 comment
       assert.equal(
-        messageElements[0].message!.commentThreads[0].comments.length,
+        messageElements[0].message.commentThreads[0].comments.length,
         1
       );
     });
@@ -512,7 +525,8 @@
       await element.updateComplete;
       const messageEls = getMessages();
       assert.equal(messageEls.length, 1);
-      assert.equal(messageEls[0].message!.message, messages[0].message);
+      assertIsDefined(messageEls[0].message, 'message');
+      assert.equal(messageEls[0].message.message, messages[0].message);
     });
   });
 
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-change.ts b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-change.ts
index 69fd142..90b05f6 100644
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-change.ts
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-change.ts
@@ -69,8 +69,8 @@
         .notCurrent {
           color: var(--warning-foreground);
         }
-        .indirectAncestor {
-          color: var(--indirect-ancestor-text-color);
+        .indirectRelation {
+          color: var(--indirect-relation-text-color);
         }
         .submittableCheck {
           padding-left: var(--spacing-s);
@@ -99,7 +99,7 @@
   override render() {
     const change = this.change;
     if (!change) throw new Error('Missing change');
-    const linkClass = this._computeLinkClass(change);
+    const linkClass = this.computeLinkClass(change);
     return html`
       <div class="changeContainer">
         <a
@@ -118,16 +118,16 @@
               >✓</span
             >`
           : ''}
-        ${this.showChangeStatus && !isChangeInfo(change)
-          ? html`<span class=${this._computeChangeStatusClass(change)}>
-              (${this._computeChangeStatus(change)})
+        ${this.showChangeStatus
+          ? html`<span class=${this.computeChangeStatusClass(change)}>
+              (${this.computeChangeStatus(change)})
             </span>`
           : ''}
       </div>
     `;
   }
 
-  _computeLinkClass(change: ChangeInfo | RelatedChangeAndCommitInfo) {
+  private computeLinkClass(change: ChangeInfo | RelatedChangeAndCommitInfo) {
     const statuses = [];
     if (change.status === ChangeStatus.ABANDONED) {
       statuses.push('strikethrough');
@@ -138,12 +138,17 @@
     return statuses.join(' ');
   }
 
-  _computeChangeStatusClass(change: RelatedChangeAndCommitInfo) {
+  private computeChangeStatusClass(
+    change: RelatedChangeAndCommitInfo | ChangeInfo
+  ) {
     const classes = ['status'];
-    if (change._revision_number !== change._current_revision_number) {
+    if (
+      !isChangeInfo(change) &&
+      change._revision_number !== change._current_revision_number
+    ) {
       classes.push('notCurrent');
-    } else if (this._isIndirectAncestor(change)) {
-      classes.push('indirectAncestor');
+    } else if (!isChangeInfo(change) && this.isIndirectRelation(change)) {
+      classes.push('indirectRelation');
     } else if (change.submittable) {
       classes.push('submittable');
     } else if (change.status === ChangeStatus.NEW) {
@@ -152,24 +157,27 @@
     return classes.join(' ');
   }
 
-  _computeChangeStatus(change: RelatedChangeAndCommitInfo) {
+  private computeChangeStatus(change: RelatedChangeAndCommitInfo | ChangeInfo) {
     switch (change.status) {
       case ChangeStatus.MERGED:
         return 'Merged';
       case ChangeStatus.ABANDONED:
         return 'Abandoned';
     }
-    if (change._revision_number !== change._current_revision_number) {
+    if (
+      !isChangeInfo(change) &&
+      change._revision_number !== change._current_revision_number
+    ) {
       return 'Not current';
-    } else if (this._isIndirectAncestor(change)) {
-      return 'Indirect ancestor';
+    } else if (!isChangeInfo(change) && this.isIndirectRelation(change)) {
+      return 'Indirect relation';
     } else if (change.submittable) {
       return 'Submittable';
     }
     return '';
   }
 
-  _isIndirectAncestor(change: RelatedChangeAndCommitInfo) {
+  private isIndirectRelation(change: RelatedChangeAndCommitInfo) {
     return (
       this.connectedRevisions &&
       !this.connectedRevisions.includes(change.commit.commit)
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.ts b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.ts
index 5d6ab63..2ba0cf8 100644
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.ts
@@ -11,31 +11,26 @@
 import '../../shared/gr-icon/gr-icon';
 import {classMap} from 'lit/directives/class-map.js';
 import {LitElement, css, html, TemplateResult} from 'lit';
-import {customElement, property, state} from 'lit/decorators.js';
+import {customElement, state} from 'lit/decorators.js';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {
   ChangeInfo,
   CommitId,
   PatchSetNumber,
   RelatedChangeAndCommitInfo,
-  RelatedChangesInfo,
   RevisionPatchSetNum,
   SubmittedTogetherInfo,
 } from '../../../types/common';
-import {getAppContext} from '../../../services/app-context';
 import {ParsedChangeInfo} from '../../../types/types';
 import {truncatePath} from '../../../utils/path-list-util';
 import {pluralize} from '../../../utils/string-util';
-import {
-  changeIsOpen,
-  getChangeNumber,
-  getRevisionKey,
-} from '../../../utils/change-util';
+import {getChangeNumber, getRevisionKey} from '../../../utils/change-util';
 import {DEFALT_NUM_CHANGES_WHEN_COLLAPSED} from './gr-related-collapse';
 import {createChangeUrl} from '../../../models/views/change';
 import {subscribe} from '../../lit/subscription-controller';
 import {resolve} from '../../../models/dependency';
 import {changeModelToken} from '../../../models/change/change-model';
+import {relatedChangesModelToken} from '../../../models/change/related-changes-model';
 
 export interface ChangeMarkersInList {
   showCurrentChangeArrow: boolean;
@@ -54,12 +49,9 @@
 
 @customElement('gr-related-changes-list')
 export class GrRelatedChangesList extends LitElement {
-  @property({type: Object})
+  @state()
   change?: ParsedChangeInfo;
 
-  @property({type: Boolean})
-  mergeable?: boolean;
-
   @state()
   latestPatchNum?: PatchSetNumber;
 
@@ -81,17 +73,50 @@
   @state()
   sameTopicChanges: ChangeInfo[] = [];
 
-  private readonly restApiService = getAppContext().restApiService;
-
   private readonly getChangeModel = resolve(this, changeModelToken);
 
+  private readonly getRelatedChangesModel = resolve(
+    this,
+    relatedChangesModelToken
+  );
+
   constructor() {
     super();
     subscribe(
       this,
+      () => this.getChangeModel().change$,
+      x => (this.change = x)
+    );
+    subscribe(
+      this,
       () => this.getChangeModel().latestPatchNum$,
       x => (this.latestPatchNum = x)
     );
+    subscribe(
+      this,
+      () => this.getRelatedChangesModel().relatedChanges$,
+      x => (this.relatedChanges = x ?? [])
+    );
+    subscribe(
+      this,
+      () => this.getRelatedChangesModel().submittedTogether$,
+      x => (this.submittedTogether = x)
+    );
+    subscribe(
+      this,
+      () => this.getRelatedChangesModel().cherryPicks$,
+      x => (this.cherryPickChanges = x ?? [])
+    );
+    subscribe(
+      this,
+      () => this.getRelatedChangesModel().conflictingChanges$,
+      x => (this.conflictingChanges = x ?? [])
+    );
+    subscribe(
+      this,
+      () => this.getRelatedChangesModel().sameTopicChanges$,
+      x => (this.sameTopicChanges = x ?? [])
+    );
   }
 
   static override get styles() {
@@ -239,7 +264,7 @@
                 .href=${change?._change_number
                   ? createChangeUrl({
                       changeNum: change._change_number,
-                      project: change.project,
+                      repo: change.project,
                       usp: 'related-change',
                       patchNum: change._revision_number as RevisionPatchSetNum,
                     })
@@ -292,7 +317,7 @@
             >
               ${this.renderMarkers(
                 submittedTogetherMarkersPredicate(index)
-              )}${this.renderSubmittedTogetherLine(change, true)}
+              )}${this.renderSubmittedTogetherLine(change)}
             </div>`
         )}
       </gr-related-collapse>
@@ -302,17 +327,14 @@
     </section>`;
   }
 
-  private renderSubmittedTogetherLine(
-    change: ChangeInfo,
-    showSubmittabilityCheck: boolean
-  ) {
+  private renderSubmittedTogetherLine(change: ChangeInfo) {
     const truncatedRepo = truncatePath(change.project, 2);
     return html`
       <gr-related-change
         .label=${this.renderChangeTitle(change)}
         .change=${change}
         .href=${createChangeUrl({change, usp: 'submitted-together'})}
-        ?show-submittable-check=${showSubmittabilityCheck}
+        show-submittable-check
         >${change.subject}</gr-related-change
       >
       <span class="repo" .title=${change.project}>${truncatedRepo}</span
@@ -351,7 +373,7 @@
             >
               ${this.renderMarkers(
                 sameTopicMarkersPredicate(index)
-              )}${this.renderSubmittedTogetherLine(change, false)}
+              )}${this.renderSubmittedTogetherLine(change)}
             </div>`
         )}
       </gr-related-collapse>
@@ -432,6 +454,7 @@
               )}<gr-related-change
                 .change=${change}
                 .href=${createChangeUrl({change, usp: 'cherry-pick'})}
+                show-change-status
                 >${change.branch}: ${change.subject}</gr-related-change
               >
             </div>`
@@ -580,72 +603,6 @@
     return html`<span class="marker space"></span>`;
   }
 
-  reload(getRelatedChanges?: Promise<RelatedChangesInfo | undefined>) {
-    const change = this.change;
-    if (!change) return Promise.reject(new Error('change missing'));
-    if (!this.latestPatchNum)
-      return Promise.reject(new Error('latestPatchNum missing'));
-    if (!getRelatedChanges) {
-      getRelatedChanges = this.restApiService.getRelatedChanges(
-        change._number,
-        this.latestPatchNum
-      );
-    }
-    const promises: Array<Promise<void>> = [
-      getRelatedChanges.then(response => {
-        if (!response) {
-          throw new Error('getRelatedChanges returned undefined response');
-        }
-        this.relatedChanges = response?.changes ?? [];
-      }),
-      this.restApiService
-        .getChangesSubmittedTogether(change._number)
-        .then(response => {
-          this.submittedTogether = response;
-        }),
-      this.restApiService
-        .getChangeCherryPicks(change.project, change.change_id, change.branch)
-        .then(response => {
-          this.cherryPickChanges = response || [];
-        }),
-    ];
-
-    // Get conflicts if change is open and is mergeable.
-    // Mergeable is output of restApiServict.getMergeable from gr-change-view
-    if (changeIsOpen(change) && this.mergeable) {
-      promises.push(
-        this.restApiService
-          .getChangeConflicts(change._number)
-          .then(response => {
-            this.conflictingChanges = response ?? [];
-          })
-      );
-    }
-    if (change.topic) {
-      const changeTopic = change.topic;
-      promises.push(
-        this.restApiService.getConfig().then(config => {
-          if (config && !config.change.submit_whole_topic) {
-            return this.restApiService
-              .getChangesWithSameTopic(changeTopic, {
-                openChangesOnly: true,
-                changeToExclude: change._number,
-              })
-              .then(response => {
-                if (changeTopic === this.change?.topic) {
-                  this.sameTopicChanges = response ?? [];
-                }
-              });
-          }
-          this.sameTopicChanges = [];
-          return Promise.resolve();
-        })
-      );
-    }
-
-    return Promise.all(promises);
-  }
-
   /**
    * Do the given objects describe the same change? Compares the changes by
    * their numbers.
@@ -681,9 +638,7 @@
     while (pos >= 0) {
       const commit: CommitId = commits[pos].commit;
       connected.push(commit);
-      // TODO(TS): Ensure that both (commit and changeRevision) are string and use === instead
-      // eslint-disable-next-line eqeqeq
-      if (commit == changeRevision) {
+      if (commit === changeRevision) {
         break;
       }
       pos--;
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.ts b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.ts
index c6921b2..24a8217 100644
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.ts
@@ -4,11 +4,9 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import {fixture, html, assert} from '@open-wc/testing';
-import {SinonStubbedMember} from 'sinon';
 import {PluginApi} from '../../../api/plugin';
-import {ChangeStatus} from '../../../constants/constants';
-import {RestApiService} from '../../../services/gr-rest-api/gr-rest-api';
 import '../../../test/common-test-setup';
+import {testResolver} from '../../../test/common-test-setup';
 import {
   createChange,
   createCommitInfoWithRequiredCommit,
@@ -18,13 +16,7 @@
   createRevision,
   createSubmittedTogetherInfo,
 } from '../../../test/test-data-generators';
-import {
-  query,
-  queryAndAssert,
-  resetPlugins,
-  stubRestApi,
-  waitEventLoop,
-} from '../../../test/test-utils';
+import {query, queryAndAssert, waitEventLoop} from '../../../test/test-utils';
 import {
   ChangeId,
   ChangeInfo,
@@ -38,7 +30,7 @@
 import {ParsedChangeInfo} from '../../../types/types';
 import {getChangeNumber} from '../../../utils/change-util';
 import {GrEndpointDecorator} from '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
-import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
+import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 import './gr-related-changes-list';
 import {
   ChangeMarkersInList,
@@ -196,16 +188,10 @@
     });
 
     test('render', async () => {
-      stubRestApi('getRelatedChanges').returns(
-        Promise.resolve(relatedChangeInfo)
-      );
-      stubRestApi('getChangesSubmittedTogether').returns(
-        Promise.resolve(submittedTogether)
-      );
-      stubRestApi('getChangeCherryPicks').returns(
-        Promise.resolve([createChange()])
-      );
-      await element.reload();
+      element.relatedChanges = relatedChangeInfo.changes;
+      element.submittedTogether = submittedTogether;
+      element.cherryPickChanges = [createChange()];
+      await element.updateComplete;
 
       assert.shadowDom.equal(
         element,
@@ -249,7 +235,7 @@
               <gr-related-collapse title="Cherry picks">
                 <div class="relatedChangeLine show-when-collapsed">
                   <span class="marker space"> </span>
-                  <gr-related-change>
+                  <gr-related-change show-change-status="">
                     test-branch: Test subject
                   </gr-related-change>
                 </div>
@@ -262,10 +248,9 @@
     });
 
     test('first list', async () => {
-      stubRestApi('getRelatedChanges').returns(
-        Promise.resolve(relatedChangeInfo)
-      );
-      await element.reload();
+      element.relatedChanges = relatedChangeInfo.changes;
+      await element.updateComplete;
+
       const section = queryAndAssert<HTMLElement>(element, '#relatedChanges');
       const relatedChanges = queryAndAssert<GrRelatedCollapse>(
         section,
@@ -275,13 +260,10 @@
     });
 
     test('first empty second non-empty', async () => {
-      stubRestApi('getRelatedChanges').returns(
-        Promise.resolve(createRelatedChangesInfo())
-      );
-      stubRestApi('getChangesSubmittedTogether').returns(
-        Promise.resolve(submittedTogether)
-      );
-      await element.reload();
+      element.relatedChanges = createRelatedChangesInfo().changes;
+      element.submittedTogether = submittedTogether;
+      await element.updateComplete;
+
       const relatedChanges = query<HTMLElement>(element, '#relatedChanges');
       assert.notExists(relatedChanges);
       const submittedTogetherSection = queryAndAssert<GrRelatedCollapse>(
@@ -292,16 +274,10 @@
     });
 
     test('first non-empty second empty third non-empty', async () => {
-      stubRestApi('getRelatedChanges').returns(
-        Promise.resolve(relatedChangeInfo)
-      );
-      stubRestApi('getChangesSubmittedTogether').returns(
-        Promise.resolve(createSubmittedTogetherInfo())
-      );
-      stubRestApi('getChangeCherryPicks').returns(
-        Promise.resolve([createChange()])
-      );
-      await element.reload();
+      element.relatedChanges = relatedChangeInfo.changes;
+      element.submittedTogether = createSubmittedTogetherInfo();
+      element.cherryPickChanges = [createChange()];
+      await element.updateComplete;
 
       const relatedChanges = queryAndAssert<GrRelatedCollapse>(
         queryAndAssert<HTMLElement>(element, '#relatedChanges'),
@@ -364,67 +340,6 @@
     assert.equal(getChangeNumber(change2), 1);
   });
 
-  suite('get conflicts tests', () => {
-    let element: GrRelatedChangesList;
-    let conflictsStub: SinonStubbedMember<RestApiService['getChangeConflicts']>;
-
-    setup(async () => {
-      element = await fixture(
-        html`<gr-related-changes-list></gr-related-changes-list>`
-      );
-      conflictsStub = stubRestApi('getChangeConflicts').returns(
-        Promise.resolve(undefined)
-      );
-    });
-
-    test('request conflicts if open and mergeable', () => {
-      element.latestPatchNum = 7 as PatchSetNumber;
-      element.change = {
-        ...createParsedChange(),
-        change_id: '123' as ChangeId,
-        status: ChangeStatus.NEW,
-      };
-      element.mergeable = true;
-      element.reload();
-      assert.isTrue(conflictsStub.called);
-    });
-
-    test('does not request conflicts if closed and mergeable', () => {
-      element.latestPatchNum = 7 as PatchSetNumber;
-      element.change = {
-        ...createParsedChange(),
-        change_id: '123' as ChangeId,
-        status: ChangeStatus.NEW,
-      };
-      element.reload();
-      assert.isFalse(conflictsStub.called);
-    });
-
-    test('does not request conflicts if open and not mergeable', () => {
-      element.latestPatchNum = 7 as PatchSetNumber;
-      element.change = {
-        ...createParsedChange(),
-        change_id: '123' as ChangeId,
-        status: ChangeStatus.NEW,
-      };
-      element.mergeable = false;
-      element.reload();
-      assert.isFalse(conflictsStub.called);
-    });
-
-    test('doesnt request conflicts if closed and not mergeable', () => {
-      element.latestPatchNum = 7 as PatchSetNumber;
-      element.change = {
-        ...createParsedChange(),
-        change_id: '123' as ChangeId,
-        status: ChangeStatus.NEW,
-      };
-      element.mergeable = false;
-      element.reload();
-      assert.isFalse(conflictsStub.called);
-    });
-  });
-
   test('connected revisions', () => {
     const change: ParsedChangeInfo = {
       ...createParsedChange(),
@@ -646,16 +561,11 @@
     let element: GrRelatedChangesList;
 
     setup(async () => {
-      resetPlugins();
       element = await fixture(
         html`<gr-related-changes-list></gr-related-changes-list>`
       );
     });
 
-    teardown(() => {
-      resetPlugins();
-    });
-
     test('endpoint params', async () => {
       element.change = {...createParsedChange(), labels: {}};
       interface RelatedChangesListGrEndpointDecorator
@@ -676,7 +586,7 @@
         '0.1',
         'http://some/plugins/url1.js'
       );
-      getPluginLoader().loadPlugins([]);
+      testResolver(pluginLoaderToken).loadPlugins([]);
       await waitEventLoop();
       assert.strictEqual(hookEl!.plugin, plugin!);
       assert.strictEqual(hookEl!.change, element.change);
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.ts
index 9fb856d..4f951f3 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.ts
@@ -7,11 +7,11 @@
 import './gr-reply-dialog';
 import {
   queryAndAssert,
-  resetPlugins,
   stubRestApi,
   waitEventLoop,
+  waitUntil,
 } from '../../../test/test-utils';
-import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
+
 import {GrReplyDialog} from './gr-reply-dialog';
 import {fixture, html, assert} from '@open-wc/testing';
 import {
@@ -22,6 +22,11 @@
 } from '../../../types/common';
 import {createChange} from '../../../test/test-data-generators';
 import {GrButton} from '../../shared/gr-button/gr-button';
+import {testResolver} from '../../../test/common-test-setup';
+import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
+import {GrComment} from '../../shared/gr-comment/gr-comment';
+import {createNewPatchsetLevel} from '../../../utils/comment-util';
+import {commentsModelToken} from '../../../models/comments/comments-model';
 
 suite('gr-reply-dialog-it tests', () => {
   let element: GrReplyDialog;
@@ -59,6 +64,9 @@
       'Code-Review': ['-1', ' 0', '+1'],
       Verified: ['-1', ' 0', '+1'],
     };
+    testResolver(commentsModelToken).addNewDraft(
+      createNewPatchsetLevel(latestPatchNum, '', false)
+    );
   };
 
   setup(async () => {
@@ -80,10 +88,6 @@
     await element.updateComplete;
   });
 
-  teardown(() => {
-    resetPlugins();
-  });
-
   test('submit blocked when invalid email is supplied to ccs', async () => {
     const sendStub = sinon.stub(element, 'send').returns(Promise.resolve());
 
@@ -99,11 +103,15 @@
   });
 
   test('lgtm plugin', async () => {
-    resetPlugins();
+    const attachStub = sinon.stub();
+    const callbackStub = sinon.stub();
     window.Gerrit.install(
       plugin => {
         const replyApi = plugin.changeReply();
+        const hook = plugin.hook('reply-text');
+        hook.onAttached(attachStub);
         replyApi.addReplyTextChangedCallback(text => {
+          callbackStub(text);
           const label = 'Code-Review';
           const labelValue = replyApi.getLabelValue(label);
           if (labelValue && labelValue === ' 0' && text.indexOf('LGTM') === 0) {
@@ -116,17 +124,29 @@
     );
     element = await fixture(html`<gr-reply-dialog></gr-reply-dialog>`);
     setupElement(element);
-    getPluginLoader().loadPlugins([]);
-    await getPluginLoader().awaitPluginsLoaded();
-    await waitEventLoop();
-    await waitEventLoop();
+    const pluginLoader = testResolver(pluginLoaderToken);
+    pluginLoader.loadPlugins([]);
+    // This may seem a bit weird, but we have to somehow make sure that the
+    // event listener is actually installed, and apparently a `gr-comment` is
+    // attached twice inside the 'reply-text' endpoint. Could not find a better
+    // way to make sure that the callback is ready to receive events.
+    await waitUntil(() => attachStub.callCount === 2);
+
+    const comment = queryAndAssert<GrComment>(
+      element,
+      'gr-comment#patchsetLevelComment'
+    );
+    comment.messageText = 'LGTM';
+
+    await waitUntil(() => callbackStub.calledWith('LGTM'));
+
     const labelScoreRows = queryAndAssert(
       element.getLabelScores(),
       'gr-label-score-row[name="Code-Review"]'
     );
     const selectedBtn = queryAndAssert(
       labelScoreRows,
-      'gr-button[data-value="+1"]'
+      'gr-button[data-value="+1"].iron-selected'
     );
     assert.isOk(selectedBtn);
   });
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
index e77593b..e582607 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
@@ -10,12 +10,11 @@
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-icon/gr-icon';
 import '../../shared/gr-formatted-text/gr-formatted-text';
-import '../../shared/gr-overlay/gr-overlay';
 import '../../shared/gr-account-list/gr-account-list';
 import '../gr-label-scores/gr-label-scores';
 import '../gr-thread-list/gr-thread-list';
 import '../../../styles/shared-styles';
-import {GrReviewerSuggestionsProvider} from '../../../scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider';
+import {GrReviewerSuggestionsProvider} from '../../../services/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider';
 import {getAppContext} from '../../../services/app-context';
 import {
   ChangeStatus,
@@ -35,19 +34,16 @@
   removeServiceUsers,
   toReviewInput,
 } from '../../../utils/account-util';
-import {IronA11yAnnouncer} from '@polymer/iron-a11y-announcer/iron-a11y-announcer';
 import {TargetElement} from '../../../api/plugin';
-import {
-  FixIronA11yAnnouncer,
-  notUndefined,
-  ParsedChangeInfo,
-} from '../../../types/types';
+import {isDefined, ParsedChangeInfo} from '../../../types/types';
 import {GrAccountList} from '../../shared/gr-account-list/gr-account-list';
 import {
   AccountId,
   AccountInfo,
   AttentionSetInput,
   ChangeInfo,
+  CommentThread,
+  DraftInfo,
   GroupInfo,
   isAccount,
   isDetailedLabelInfo,
@@ -61,6 +57,7 @@
   SuggestedReviewerGroupInfo,
   Suggestion,
   UserId,
+  isDraft,
 } from '../../../types/common';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {GrLabelScores} from '../gr-label-scores/gr-label-scores';
@@ -73,16 +70,11 @@
   queryAndAssert,
 } from '../../../utils/common-util';
 import {
-  CommentThread,
-  DraftInfo,
   getFirstComment,
-  isDraft,
   isPatchsetLevel,
   isUnresolved,
-  UnsavedInfo,
 } from '../../../utils/comment-util';
 import {GrAccountChip} from '../../shared/gr-account-chip/gr-account-chip';
-import {GrOverlay, GrOverlayStops} from '../../shared/gr-overlay/gr-overlay';
 import {
   getApprovalInfo,
   getMaxAccounts,
@@ -91,9 +83,10 @@
 import {pluralize} from '../../../utils/string-util';
 import {
   fireAlert,
-  fireEvent,
+  fireError,
+  fire,
+  fireNoBubble,
   fireIronAnnounce,
-  fireReload,
   fireServerError,
 } from '../../../utils/event-util';
 import {ErrorCallback} from '../../../api/rest';
@@ -111,24 +104,32 @@
   LabelNameToValuesMap,
   PatchSetNumber,
 } from '../../../api/rest-api';
-import {css, html, PropertyValues, LitElement} from 'lit';
+import {css, html, PropertyValues, LitElement, nothing} from 'lit';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {when} from 'lit/directives/when.js';
 import {classMap} from 'lit/directives/class-map.js';
-import {ValueChangedEvent} from '../../../types/events';
+import {
+  AddReviewerEvent,
+  RemoveReviewerEvent,
+  ValueChangedEvent,
+} from '../../../types/events';
 import {customElement, property, state, query} from 'lit/decorators.js';
 import {subscribe} from '../../lit/subscription-controller';
 import {configModelToken} from '../../../models/config/config-model';
-import {hasHumanReviewer, isOwner} from '../../../utils/change-util';
-import {KnownExperimentId} from '../../../services/flags/flags';
+import {hasHumanReviewer} from '../../../utils/change-util';
 import {commentsModelToken} from '../../../models/comments/comments-model';
 import {
   CommentEditingChangedDetail,
   GrComment,
 } from '../../shared/gr-comment/gr-comment';
 import {ShortcutController} from '../../lit/shortcut-controller';
-import {Key, Modifier} from '../../../utils/dom-util';
+import {Key, Modifier, whenVisible} from '../../../utils/dom-util';
 import {GrThreadList} from '../gr-thread-list/gr-thread-list';
+import {userModelToken} from '../../../models/user/user-model';
+import {accountsModelToken} from '../../../models/accounts-model/accounts-model';
+import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
+import {modalStyles} from '../../../styles/gr-modal-styles';
+import {ironAnnouncerRequestAvailability} from '../../polymer-util';
 
 export enum FocusTarget {
   ANY = 'any',
@@ -164,58 +165,13 @@
 
 @customElement('gr-reply-dialog')
 export class GrReplyDialog extends LitElement {
-  /**
-   * Fired when a reply is successfully sent.
-   *
-   * @event send
-   */
-
-  /**
-   * Fired when the user presses the cancel button.
-   *
-   * @event cancel
-   */
-
-  /**
-   * Fired when the main textarea's value changes, which may have triggered
-   * a change in size for the dialog.
-   *
-   * @event autogrow
-   */
-
-  /**
-   * Fires to show an alert when a send is attempted on the non-latest patch.
-   *
-   * @event show-alert
-   */
-
-  /**
-   * Fires when the reply dialog believes that the server side diff drafts
-   * have been updated and need to be refreshed.
-   *
-   * @event comment-refresh
-   */
-
-  /**
-   * Fires when the state of the send button (enabled/disabled) changes.
-   *
-   * @event send-disabled-changed
-   */
-
-  /**
-   * Fired to reload the change page.
-   *
-   * @event reload
-   */
-
   FocusTarget = FocusTarget;
 
   private readonly reporting = getAppContext().reportingService;
 
   private readonly getChangeModel = resolve(this, changeModelToken);
 
-  // Private but used in tests.
-  readonly getCommentsModel = resolve(this, commentsModelToken);
+  private readonly getCommentsModel = resolve(this, commentsModelToken);
 
   // TODO: update type to only ParsedChangeInfo
   @property({type: Object})
@@ -236,6 +192,8 @@
   @property({type: Object})
   projectConfig?: ConfigInfo;
 
+  @query('#patchsetLevelComment') patchsetLevelGrComment?: GrComment;
+
   @query('#reviewers') reviewersList?: GrAccountList;
 
   @query('#ccs') ccsList?: GrAccountList;
@@ -246,8 +204,8 @@
 
   @query('#labelScores') labelScores?: GrLabelScores;
 
-  @query('#reviewerConfirmationOverlay')
-  reviewerConfirmationOverlay?: GrOverlay;
+  @query('#reviewerConfirmationModal')
+  reviewerConfirmationModal?: HTMLDialogElement;
 
   @state() latestPatchNum?: PatchSetNumber;
 
@@ -338,9 +296,6 @@
   reviewerPendingConfirmation: SuggestedReviewerGroupInfo | null = null;
 
   @state()
-  sendButtonLabel?: string;
-
-  @state()
   savingComments = false;
 
   @state()
@@ -373,24 +328,24 @@
   newAttentionSet: Set<UserId> = new Set();
 
   @state()
-  sendDisabled?: boolean;
-
-  @state()
   patchsetLevelDraftIsResolved = true;
 
   @state()
-  patchsetLevelComment?: UnsavedInfo | DraftInfo;
+  patchsetLevelComment?: DraftInfo;
+
+  @state()
+  isOwner = false;
 
   private readonly restApiService: RestApiService =
     getAppContext().restApiService;
 
-  private readonly jsAPI = getAppContext().jsApiService;
-
-  private readonly flagsService = getAppContext().flagsService;
+  private readonly getPluginLoader = resolve(this, pluginLoaderToken);
 
   private readonly getConfigModel = resolve(this, configModelToken);
 
-  private readonly accountsModel = getAppContext().accountsModel;
+  private readonly getAccountsModel = resolve(this, accountsModelToken);
+
+  private readonly getUserModel = resolve(this, userModelToken);
 
   storeTask?: DelayedTask;
 
@@ -400,6 +355,7 @@
 
   static override styles = [
     sharedStyles,
+    modalStyles,
     css`
       :host {
         background-color: var(--dialog-background-color);
@@ -464,7 +420,7 @@
         flex-wrap: wrap;
         flex: 1;
       }
-      #reviewerConfirmationOverlay {
+      #reviewerConfirmationModal {
         padding: var(--spacing-l);
         text-align: center;
       }
@@ -586,7 +542,7 @@
         border: 1px solid var(--border-color);
         border-radius: var(--border-radius);
         margin-top: var(--spacing-m);
-        background-color: var(--assignee-highlight-color);
+        background-color: var(--line-item-highlight-color);
       }
       .attentionTip div gr-icon {
         margin-right: var(--spacing-s);
@@ -602,6 +558,16 @@
       .patchsetLevelContainer.unresolved {
         background-color: var(--unresolved-comment-background-color);
       }
+      .privateVisiblityInfo {
+        display: flex;
+        justify-content: center;
+        background-color: var(--info-background);
+        padding: var(--spacing-s) 0;
+      }
+      .privateVisiblityInfo gr-icon {
+        margin-right: var(--spacing-m);
+        color: var(--info-foreground);
+      }
     `,
   ];
 
@@ -610,7 +576,6 @@
     this.filterReviewerSuggestion =
       this.filterReviewerSuggestionGenerator(false);
     this.filterCCSuggestion = this.filterReviewerSuggestionGenerator(true);
-    this.jsAPI.addElement(TargetElement.REPLY_DIALOG, this);
 
     this.shortcuts.addLocal({key: Key.ESC}, () => this.cancel());
     this.shortcuts.addLocal(
@@ -624,7 +589,7 @@
 
     subscribe(
       this,
-      () => getAppContext().userModel.loggedIn$,
+      () => this.getUserModel().loggedIn$,
       isLoggedIn => (this.isLoggedIn = isLoggedIn)
     );
     subscribe(
@@ -646,6 +611,11 @@
     );
     subscribe(
       this,
+      () => this.getChangeModel().isOwner$,
+      x => (this.isOwner = x)
+    );
+    subscribe(
+      this,
       () => this.getCommentsModel().mentionedUsersInDrafts$,
       x => {
         this.mentionedUsers = x;
@@ -657,9 +627,6 @@
       this,
       () => this.getCommentsModel().mentionedUsersInUnresolvedDrafts$,
       x => {
-        if (!this.flagsService.isEnabled(KnownExperimentId.MENTION_USERS)) {
-          return;
-        }
         this.mentionedUsersInUnresolvedDrafts = x.filter(
           v => !this.isAlreadyReviewerOrCC(v)
         );
@@ -672,7 +639,7 @@
     );
     subscribe(
       this,
-      () => this.getCommentsModel().draftThreads$,
+      () => this.getCommentsModel().draftThreadsSaved$,
       threads =>
         (this.draftCommentThreads = threads.filter(
           t => !(isDraft(getFirstComment(t)) && isPatchsetLevel(t))
@@ -682,9 +649,13 @@
 
   override connectedCallback() {
     super.connectedCallback();
-    (
-      IronA11yAnnouncer as unknown as FixIronA11yAnnouncer
-    ).requestAvailability();
+    ironAnnouncerRequestAvailability();
+
+    this.getPluginLoader().jsApiService.addElement(
+      TargetElement.REPLY_DIALOG,
+      this
+    );
+
     this.restApiService.getAccount().then(account => {
       if (account) this.account = account;
     });
@@ -709,17 +680,19 @@
     // Plugins on reply-reviewers endpoint can take advantage of these
     // events to add / remove reviewers
 
-    this.addEventListener('add-reviewer', e => {
+    this.addEventListener('add-reviewer', (e: AddReviewerEvent) => {
+      const reviewer = e.detail.reviewer;
       // 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,
+        account: reviewer,
         count: 1,
       });
     });
 
-    this.addEventListener('remove-reviewer', e => {
-      this.reviewersList?.removeAccount((e as CustomEvent).detail.reviewer);
+    this.addEventListener('remove-reviewer', (e: RemoveReviewerEvent) => {
+      const reviewer = e.detail.reviewer;
+      this.reviewersList?.removeAccount(reviewer);
     });
   }
 
@@ -736,16 +709,6 @@
     }
     if (changedProperties.has('canBeStarted')) {
       this.computeMessagePlaceholder();
-      this.computeSendButtonLabel();
-    }
-    if (changedProperties.has('reviewFormatting')) {
-      this.handleHeightChanged();
-    }
-    if (changedProperties.has('draftCommentThreads')) {
-      this.handleHeightChanged();
-    }
-    if (changedProperties.has('sendDisabled')) {
-      this.sendDisabledChanged();
     }
     if (changedProperties.has('attentionExpanded')) {
       this.onAttentionExpandedChange();
@@ -773,7 +736,6 @@
 
   override render() {
     if (!this.change) return;
-    this.sendDisabled = this.computeSendButtonDisabled();
     return html`
       <div tabindex="-1">
         <section class="peopleContainer">
@@ -788,6 +750,7 @@
             <gr-endpoint-slot name="below"></gr-endpoint-slot>
           </gr-endpoint-decorator>
           ${this.renderCCList()} ${this.renderReviewConfirmation()}
+          ${this.renderPrivateVisiblityInfo()}
         </section>
         <section class="labelsContainer">${this.renderLabels()}</section>
         <section class="newReplyDialog textareaContainer">
@@ -864,9 +827,10 @@
 
   private renderReviewConfirmation() {
     return html`
-      <gr-overlay
-        id="reviewerConfirmationOverlay"
-        @iron-overlay-canceled=${this.cancelPendingReviewer}
+      <dialog
+        tabindex="-1"
+        id="reviewerConfirmationModal"
+        @close=${this.cancelPendingReviewer}
       >
         <div class="reviewerConfirmation">
           Group
@@ -885,7 +849,23 @@
           <gr-button @click=${this.confirmPendingReviewer}>Yes</gr-button>
           <gr-button @click=${this.cancelPendingReviewer}>No</gr-button>
         </div>
-      </gr-overlay>
+      </dialog>
+    `;
+  }
+
+  private renderPrivateVisiblityInfo() {
+    const addedAccounts = [
+      ...(this.reviewersList?.additions() ?? []),
+      ...(this.ccsList?.additions() ?? []),
+    ];
+    if (!this.change?.is_private || !addedAccounts.length) return nothing;
+    return html`
+      <div class="privateVisiblityInfo">
+        <gr-icon icon="info"></gr-icon>
+        <div>
+          Adding a reviewer/CC will make this private change visible to them
+        </div>
+      </div>
     `;
   }
 
@@ -909,20 +889,8 @@
     `;
   }
 
-  // TODO: move to comment-util
-  private createDraft(): UnsavedInfo {
-    return {
-      patch_set: this.latestPatchNum,
-      message: this.patchsetLevelDraftMessage,
-      unresolved: !this.patchsetLevelDraftIsResolved,
-      path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
-      __unsaved: true,
-    };
-  }
-
   private renderPatchsetLevelComment() {
-    if (!this.patchsetLevelComment)
-      this.patchsetLevelComment = this.createDraft();
+    if (!this.patchsetLevelComment) return nothing;
     return html`
       <gr-comment
         id="patchsetLevelComment"
@@ -933,6 +901,8 @@
         }}
         @comment-text-changed=${(e: ValueChangedEvent<string>) => {
           this.patchsetLevelDraftMessage = e.detail.value;
+          // See `addReplyTextChangedCallback` in `ChangeReplyPluginApi`.
+          fire(e.currentTarget as HTMLElement, 'value-changed', e.detail);
         }}
         .messagePlaceholder=${this.messagePlaceholder}
         hide-header
@@ -962,7 +932,8 @@
   }
 
   private renderDraftsSection() {
-    if (this.computeHideDraftList(this.draftCommentThreads)) return;
+    const threads = this.draftCommentThreads;
+    if (!threads || threads.length === 0) return;
     return html`
       <section class="draftsContainer">
         <div class="includeComments">
@@ -973,17 +944,13 @@
             ?checked=${this.includeComments}
           />
           <label for="includeComments"
-            >Publish ${this.computeDraftsTitle(this.draftCommentThreads)}</label
+            >Publish ${this.computeDraftsTitle(threads)}</label
           >
         </div>
         ${when(
           this.includeComments,
           () => html`
-            <gr-thread-list
-              id="commentList"
-              .threads=${this.draftCommentThreads}
-              hide-dropdown
-            >
+            <gr-thread-list id="commentList" .threads=${threads} hide-dropdown>
             </gr-thread-list>
           `
         )}
@@ -1032,7 +999,7 @@
               <gr-button
                 class="edit-attention-button"
                 @click=${this.handleAttentionModify}
-                ?disabled=${this.sendDisabled}
+                ?disabled=${this.isSendDisabled()}
                 link
                 position-below
                 data-label="Edit"
@@ -1231,10 +1198,12 @@
             <gr-button
               id="sendButton"
               primary
-              ?disabled=${this.sendDisabled}
+              ?disabled=${this.isSendDisabled()}
               class="action send"
-              @click=${this.sendTapHandler}
-              >${this.sendButtonLabel}
+              @click=${this.sendClickHandler}
+              >${this.canBeStarted
+                ? ButtonLabels.SEND + ' and ' + ButtonLabels.START_REVIEW
+                : ButtonLabels.SEND}
             </gr-button>
           </gr-tooltip-content>
         </div>
@@ -1248,7 +1217,7 @@
    * change view for initializing the dialog after opening the overlay. Maybe it
    * should be called `onOpened()` or `initialize()`?
    */
-  open(focusTarget?: FocusTarget, quote?: string) {
+  open(focusTarget?: FocusTarget) {
     assertIsDefined(this.change, 'change');
     this.knownLatestState = LatestPatchState.CHECKING;
     this.getChangeModel()
@@ -1260,14 +1229,10 @@
       });
 
     this.focusOn(focusTarget);
-    if (quote?.length) {
-      // If a reply quote has been provided, use it.
-      this.patchsetLevelDraftMessage = quote;
-    }
     if (this.restApiService.hasPendingDiffDrafts()) {
       this.savingComments = true;
       this.restApiService.awaitPendingDiffDrafts().then(() => {
-        fireEvent(this, 'comment-refresh');
+        fire(this, 'comment-refresh', {});
         this.savingComments = false;
       });
     }
@@ -1284,15 +1249,6 @@
     this.focusOn(FocusTarget.ANY);
   }
 
-  getFocusStops(): GrOverlayStops | undefined {
-    const end = this.sendDisabled ? this.cancelButton : this.sendButton;
-    if (!this.reviewersList?.focusStart || !end) return undefined;
-    return {
-      start: this.reviewersList.focusStart,
-      end,
-    };
-  }
-
   private handleIncludeCommentsChanged(e: Event) {
     if (!(e.target instanceof HTMLInputElement)) return;
     this.includeComments = e.target.checked;
@@ -1378,7 +1334,9 @@
     );
   }
 
+  // visible for testing
   async send(includeComments: boolean, startReview: boolean) {
+    // The change model will end this timing when the change was reloaded.
     this.reporting.time(Timing.SEND_REPLY);
     const labels = this.getLabelScores().getLabelValues();
 
@@ -1391,6 +1349,14 @@
 
     if (startReview) {
       reviewInput.ready = true;
+    } else if (this.change?.work_in_progress) {
+      const addedAccounts = [
+        ...(this.reviewersList?.additions() ?? []),
+        ...(this.ccsList?.additions() ?? []),
+      ];
+      if (addedAccounts.length > 0) {
+        fireAlert(this, 'Reviewers are not notified for WIP changes');
+      }
     }
 
     this.disabled = true;
@@ -1406,27 +1372,24 @@
     )
       .filter(user => !this.currentAttentionSet.has(user))
       .map(user => allAccounts.find(a => getUserId(a) === user))
-      .filter(notUndefined);
+      .filter(isDefined);
 
     const newAttentionSetUsers = (
       await Promise.all(
-        newAttentionSetAdditions.map(a => this.accountsModel.fillDetails(a))
+        newAttentionSetAdditions.map(a =>
+          this.getAccountsModel().fillDetails(a)
+        )
       )
-    ).filter(notUndefined);
+    ).filter(isDefined);
 
     for (const user of newAttentionSetUsers) {
-      let reason;
-      if (this.flagsService.isEnabled(KnownExperimentId.MENTION_USERS)) {
-        reason =
-          getMentionedReason(
-            this.draftCommentThreads,
-            this.account,
-            user,
-            this.serverConfig
-          ) ?? '';
-      } else {
-        reason = getReplyByReason(this.account, this.serverConfig);
-      }
+      const reason =
+        getMentionedReason(
+          this.draftCommentThreads,
+          this.account,
+          user,
+          this.serverConfig
+        ) ?? '';
       reviewInput.add_to_attention_set.push({user: getUserId(user), reason});
     }
     reviewInput.remove_from_attention_set = [];
@@ -1441,11 +1404,7 @@
       reviewInput.remove_from_attention_set
     );
 
-    const patchsetLevelComment = queryAndAssert<GrComment>(
-      this,
-      '#patchsetLevelComment'
-    );
-    await patchsetLevelComment.save();
+    await this.patchsetLevelGrComment?.save();
 
     assertIsDefined(this.change, 'change');
     reviewInput.reviewers = this.computeReviewers();
@@ -1465,12 +1424,7 @@
 
         this.patchsetLevelDraftMessage = '';
         this.includeComments = true;
-        this.dispatchEvent(
-          new CustomEvent('send', {
-            composed: true,
-            bubbles: false,
-          })
-        );
+        fireNoBubble(this, 'send', {});
         fireIronAnnounce(this, 'Reply sent');
         return;
       })
@@ -1489,26 +1443,25 @@
     if (!section || section === FocusTarget.ANY) {
       section = this.chooseFocusTarget();
     }
-    if (section === FocusTarget.REVIEWERS) {
-      const reviewerEntry = this.reviewersList?.focusStart;
-      setTimeout(() => reviewerEntry?.focus());
-    } else if (section === FocusTarget.CCS) {
-      const ccEntry = this.ccsList?.focusStart;
-      setTimeout(() => ccEntry?.focus());
-    }
+    whenVisible(this, () => {
+      if (section === FocusTarget.REVIEWERS) {
+        const reviewerEntry = this.reviewersList?.focusStart;
+        reviewerEntry?.focus();
+      } else if (section === FocusTarget.CCS) {
+        const ccEntry = this.ccsList?.focusStart;
+        ccEntry?.focus();
+      } else {
+        this.patchsetLevelGrComment?.focus();
+      }
+    });
   }
 
   chooseFocusTarget() {
-    if (!isOwner(this.change, this.account)) return FocusTarget.BODY;
+    if (!this.isOwner) return FocusTarget.BODY;
     if (hasHumanReviewer(this.change)) return FocusTarget.BODY;
     return FocusTarget.REVIEWERS;
   }
 
-  isOwner(account?: AccountInfo, change?: ParsedChangeInfo | ChangeInfo) {
-    if (!account || !change || !change.owner) return false;
-    return account._account_id === change.owner._account_id;
-  }
-
   handle400Error(r?: Response | null) {
     if (!r) throw new Error('Response is empty.');
     let response: Response = r;
@@ -1547,10 +1500,6 @@
     });
   }
 
-  computeHideDraftList(draftCommentThreads?: CommentThread[]) {
-    return !draftCommentThreads || draftCommentThreads.length === 0;
-  }
-
   computeDraftsTitle(draftCommentThreads?: CommentThread[]) {
     const total = draftCommentThreads ? draftCommentThreads.length : 0;
     return pluralize(total, 'Draft');
@@ -1580,11 +1529,11 @@
   onAttentionExpandedChange() {
     // If the attention-detail section is expanded without dispatching this
     // event, then the dialog may expand beyond the screen's bottom border.
-    fireEvent(this, 'iron-resize');
+    fire(this, 'iron-resize', {});
   }
 
-  computeAttentionButtonTitle(sendDisabled?: boolean) {
-    return sendDisabled
+  computeAttentionButtonTitle() {
+    return this.isSendDisabled()
       ? 'Modify the attention set by adding a comment or use the account ' +
           'hovercard in the change page.'
       : 'Edit attention set changes';
@@ -1632,7 +1581,6 @@
       ? this.draftCommentThreads
       : [];
     const hasVote = !!this.labelsChanged;
-    const isOwner = this.isOwner(this.account, this.change);
     const isUploader = this.uploader?._account_id === this.account._account_id;
 
     this.attentionCcsCount = removeServiceUsers(this.ccs).length;
@@ -1666,7 +1614,7 @@
         .filter(
           r =>
             isAccountNewlyAdded(r, ReviewerState.REVIEWER, this.change) ||
-            (this.canBeStarted && isOwner)
+            (this.canBeStarted && this.isOwner)
         )
         .filter(notIsReviewerAndHasDraftOrLabel)
         .forEach(r => newAttention.add((r as AccountInfo)._account_id!));
@@ -1675,7 +1623,7 @@
         if (this.uploader?._account_id && !isUploader) {
           newAttention.add(this.uploader._account_id);
         }
-        if (this.change.owner?._account_id && !isOwner) {
+        if (this.change.owner?._account_id && !this.isOwner) {
           newAttention.add(this.change.owner._account_id);
         }
       }
@@ -1703,18 +1651,11 @@
   }
 
   computeShowAttentionTip() {
-    if (
-      !this.account ||
-      !this.change?.owner ||
-      !this.currentAttentionSet ||
-      !this.newAttentionSet
-    )
-      return false;
-    const isOwner = this.account._account_id === this.change.owner._account_id;
+    if (!this.currentAttentionSet || !this.newAttentionSet) return false;
     const addedIds = [...this.newAttentionSet].filter(
       id => !this.currentAttentionSet.has(id)
     );
-    return isOwner && addedIds.length > 2;
+    return this.isOwner && addedIds.length > 2;
   }
 
   computeCommentAccounts(threads: CommentThread[]) {
@@ -1736,13 +1677,15 @@
   }
 
   computeShowNoAttentionUpdate() {
-    return this.sendDisabled || this.computeNewAttentionAccounts().length === 0;
+    return (
+      this.isSendDisabled() || this.computeNewAttentionAccounts().length === 0
+    );
   }
 
   computeDoNotUpdateMessage() {
     if (!this.currentAttentionSet || !this.newAttentionSet) return '';
     if (
-      this.sendDisabled ||
+      this.isSendDisabled() ||
       areSetsEqual(this.currentAttentionSet, this.newAttentionSet)
     ) {
       return 'No changes to the attention set.';
@@ -1849,53 +1792,36 @@
   async cancel() {
     assertIsDefined(this.change, 'change');
     if (!this.change?.owner) throw new Error('missing required owner property');
-    this.dispatchEvent(
-      new CustomEvent('cancel', {
-        composed: true,
-        bubbles: false,
-      })
-    );
-    const patchsetLevelComment = queryAndAssert<GrComment>(
-      this,
-      '#patchsetLevelComment'
-    );
-    await patchsetLevelComment.save();
+    fireNoBubble(this, 'cancel', {});
+    await this.patchsetLevelGrComment?.save();
     this.rebuildReviewerArrays();
   }
 
-  saveClickHandler(e: Event) {
+  private saveClickHandler(e: Event) {
     e.preventDefault();
-    if (!this.ccsList?.submitEntryText()) {
-      // Do not proceed with the save if there is an invalid email entry in
-      // the text field of the CC entry.
-      return;
+    this.submit(false);
+  }
+
+  private sendClickHandler(e: Event) {
+    e.preventDefault();
+    this.submit(this.canBeStarted);
+  }
+
+  private submit(startReview?: boolean) {
+    if (startReview === undefined) {
+      startReview = this.isOwner && this.canBeStarted;
     }
-    this.send(this.includeComments, false);
-  }
-
-  sendTapHandler(e: Event) {
-    e.preventDefault();
-    this.submit();
-  }
-
-  submit() {
     if (!this.ccsList?.submitEntryText()) {
       // Do not proceed with the send if there is an invalid email entry in
       // the text field of the CC entry.
       return;
     }
-    if (this.sendDisabled) {
+    if (this.isSendDisabled()) {
       fireAlert(this, EMPTY_REPLY_MESSAGE);
       return;
     }
-    return this.send(this.includeComments, this.canBeStarted).catch(err => {
-      this.dispatchEvent(
-        new CustomEvent('show-error', {
-          bubbles: true,
-          composed: true,
-          detail: {message: `Error submitting review ${err}`},
-        })
-      );
+    return this.send(this.includeComments, startReview).catch(err => {
+      fireError(this, `Error submitting review ${err}`);
     });
   }
 
@@ -1912,15 +1838,16 @@
 
   pendingConfirmationUpdated(reviewer: RawAccountInput | null) {
     if (reviewer === null) {
-      this.reviewerConfirmationOverlay?.close();
+      this.reviewerConfirmationModal?.close();
     } else {
       this.pendingConfirmationDetails =
         this.ccPendingConfirmation || this.reviewerPendingConfirmation;
-      this.reviewerConfirmationOverlay?.open();
+      this.reviewerConfirmationModal?.showModal();
     }
   }
 
   confirmPendingReviewer() {
+    this.reviewerConfirmationModal?.close();
     if (this.ccPendingConfirmation) {
       this.ccsList?.confirmGroup(this.ccPendingConfirmation.group);
       this.focusOn(FocusTarget.CCS);
@@ -1938,6 +1865,7 @@
   }
 
   cancelPendingReviewer() {
+    this.reviewerConfirmationModal?.close();
     this.ccPendingConfirmation = null;
     this.reviewerPendingConfirmation = null;
 
@@ -1968,10 +1896,6 @@
     );
   }
 
-  handleHeightChanged() {
-    fireEvent(this, 'autogrow');
-  }
-
   getLabelScores(): GrLabelScores {
     return this.labelScores || queryAndAssert(this, 'gr-label-scores');
   }
@@ -2004,16 +1928,10 @@
   }
 
   _reload() {
-    fireReload(this, true);
+    this.getChangeModel().navigateToChangeResetReload();
     this.cancel();
   }
 
-  computeSendButtonLabel() {
-    this.sendButtonLabel = this.canBeStarted
-      ? ButtonLabels.SEND + ' and ' + ButtonLabels.START_REVIEW
-      : ButtonLabels.SEND;
-  }
-
   computeSendButtonTooltip(canBeStarted?: boolean, commentEditing?: boolean) {
     if (commentEditing) {
       return ButtonTooltips.DISABLED_COMMENT_EDITING;
@@ -2025,7 +1943,8 @@
     return savingComments ? 'saving' : '';
   }
 
-  computeSendButtonDisabled() {
+  // visible for testing
+  isSendDisabled() {
     if (
       this.canBeStarted === undefined ||
       this.patchsetLevelDraftMessage === undefined ||
@@ -2073,10 +1992,6 @@
     this.pluginMessage = message;
   }
 
-  sendDisabledChanged() {
-    this.dispatchEvent(new CustomEvent('send-disabled-changed'));
-  }
-
   getReviewerSuggestionsProvider(change?: ChangeInfo | ParsedChangeInfo) {
     if (!change) return;
     const provider = new GrReviewerSuggestionsProvider(
@@ -2130,4 +2045,19 @@
   interface HTMLElementTagNameMap {
     'gr-reply-dialog': GrReplyDialog;
   }
+  interface HTMLElementEventMap {
+    /** Fired when the user presses the cancel button. */
+    // prettier-ignore
+    'cancel': CustomEvent<{}>;
+    /**
+     * Fires when the reply dialog believes that the server side diff drafts
+     * have been updated and need to be refreshed.
+     */
+    'comment-refresh': CustomEvent<{}>;
+    /** Fired when a reply is successfully sent. */
+    // prettier-ignore
+    'send': CustomEvent<{}>;
+    /** Fires when the state of the send button (enabled/disabled) changes. */
+    'send-disabled-changed': CustomEvent<{}>;
+  }
 }
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts
index 9eca525f..c4a978c 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts
@@ -13,10 +13,14 @@
   query,
   queryAll,
   queryAndAssert,
-  stubFlags,
   stubRestApi,
+  waitUntilVisible,
 } from '../../../test/test-utils';
-import {ChangeStatus, ReviewerState} from '../../../constants/constants';
+import {
+  ChangeStatus,
+  DraftsAction,
+  ReviewerState,
+} from '../../../constants/constants';
 import {JSON_PREFIX} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
 import {StandardLabels} from '../../../utils/label-util';
 import {
@@ -33,6 +37,7 @@
 import {
   AccountId,
   AccountInfo,
+  CommentThread,
   CommitId,
   DetailedLabelInfo,
   EmailAddress,
@@ -49,19 +54,22 @@
   UrlEncodedCommentId,
   UserId,
 } from '../../../types/common';
-import {CommentThread} from '../../../utils/comment-util';
 import {GrAccountList} from '../../shared/gr-account-list/gr-account-list';
 import {GrLabelScoreRow} from '../gr-label-score-row/gr-label-score-row';
 import {GrLabelScores} from '../gr-label-scores/gr-label-scores';
-import {GrThreadList} from '../gr-thread-list/gr-thread-list';
-import {GrAutocomplete} from '../../shared/gr-autocomplete/gr-autocomplete';
 import {fixture, html, waitUntil, assert} from '@open-wc/testing';
 import {accountKey} from '../../../utils/account-util';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {GrAccountLabel} from '../../shared/gr-account-label/gr-account-label';
-import {KnownExperimentId} from '../../../services/flags/flags';
 import {Key, Modifier} from '../../../utils/dom-util';
 import {GrComment} from '../../shared/gr-comment/gr-comment';
+import {testResolver} from '../../../test/common-test-setup';
+import {
+  CommentsModel,
+  commentsModelToken,
+} from '../../../models/comments/comments-model';
+import {isOwner} from '../../../utils/change-util';
+import {createNewPatchsetLevel} from '../../../utils/comment-util';
 
 function cloneableResponse(status: number, text: string) {
   return {
@@ -87,6 +95,7 @@
   let element: GrReplyDialog;
   let changeNum: NumericChangeId;
   let latestPatchNum: PatchSetNumber;
+  let commentsModel: CommentsModel;
 
   let lastId = 1;
   const makeAccount = function () {
@@ -145,6 +154,10 @@
       Verified: ['-1', ' 0', '+1'],
     };
     element.draftCommentThreads = [];
+    commentsModel = testResolver(commentsModelToken);
+    commentsModel.addNewDraft(
+      createNewPatchsetLevel(latestPatchNum, '', false)
+    );
 
     await element.updateComplete;
   });
@@ -172,9 +185,9 @@
     );
   }
 
-  function interceptSaveReview() {
+  function interceptSaveReview(): Promise<ReviewInput> {
     let resolver: (review: ReviewInput) => void;
-    const promise = new Promise(resolve => {
+    const promise = new Promise<ReviewInput>(resolve => {
       resolver = resolve;
     });
     stubSaveReview((review: ReviewInput) => {
@@ -203,11 +216,7 @@
               <div class="peopleListLabel">CC</div>
               <gr-account-list allow-any-input="" id="ccs"> </gr-account-list>
             </div>
-            <gr-overlay
-              aria-hidden="true"
-              id="reviewerConfirmationOverlay"
-              style="outline: none; display: none;"
-            >
+            <dialog tabindex="-1" id="reviewerConfirmationModal">
               <div class="reviewerConfirmation">
                 Group
                 <span class="groupName"> </span>
@@ -225,7 +234,7 @@
                   No
                 </gr-button>
               </div>
-            </gr-overlay>
+            </dialog>
           </section>
           <section class="labelsContainer">
             <gr-endpoint-decorator name="reply-label-scores">
@@ -256,7 +265,7 @@
                     <span> No changes to the attention set. </span>
                     <gr-tooltip-content
                       has-tooltip=""
-                      title="Edit attention set changes"
+                      title="Modify the attention set by adding a comment or use the account hovercard in the change page."
                     >
                       <gr-button
                         aria-disabled="true"
@@ -323,6 +332,123 @@
     );
   });
 
+  test('renders private change info when reviewer is added', async () => {
+    element.change!.is_private = true;
+    element.requestUpdate();
+    await element.updateComplete;
+    const peopleContainer = queryAndAssert<HTMLDivElement>(
+      element,
+      '.peopleContainer'
+    );
+
+    // Info is rendered only if reviewer is added
+    assert.dom.equal(
+      peopleContainer,
+      `
+      <section class="peopleContainer">
+        <gr-endpoint-decorator name="reply-reviewers">
+          <gr-endpoint-param name="change"> </gr-endpoint-param>
+          <gr-endpoint-param name="reviewers"> </gr-endpoint-param>
+          <div class="peopleList">
+            <div class="peopleListLabel">Reviewers</div>
+            <gr-account-list id="reviewers"> </gr-account-list>
+            <gr-endpoint-slot name="right"> </gr-endpoint-slot>
+          </div>
+          <gr-endpoint-slot name="below"> </gr-endpoint-slot>
+        </gr-endpoint-decorator>
+        <div class="peopleList">
+          <div class="peopleListLabel">CC</div>
+          <gr-account-list allow-any-input="" id="ccs"> </gr-account-list>
+        </div>
+        <dialog
+          tabindex="-1"
+          id="reviewerConfirmationModal"
+        >
+          <div class="reviewerConfirmation">
+            Group
+            <span class="groupName"> </span>
+            has
+            <span class="groupSize"> </span>
+            members.
+            <br />
+            Are you sure you want to add them all?
+          </div>
+          <div class="reviewerConfirmationButtons">
+            <gr-button aria-disabled="false" role="button" tabindex="0">
+              Yes
+            </gr-button>
+            <gr-button aria-disabled="false" role="button" tabindex="0">
+              No
+            </gr-button>
+          </div>
+        </dialog>
+      </section>
+    `
+    );
+
+    const account = createAccountWithId(22);
+    element.reviewersList!.accounts = [];
+    element.reviewersList!.addAccountItem({account, count: 1});
+    element.reviewersList!.dispatchEvent(
+      new CustomEvent('account-added', {
+        detail: {account},
+      })
+    );
+    element.requestUpdate();
+    await element.updateComplete;
+
+    assert.dom.equal(
+      peopleContainer,
+      `
+      <section class="peopleContainer">
+        <gr-endpoint-decorator name="reply-reviewers">
+          <gr-endpoint-param name="change"> </gr-endpoint-param>
+          <gr-endpoint-param name="reviewers"> </gr-endpoint-param>
+          <div class="peopleList">
+            <div class="peopleListLabel">Reviewers</div>
+            <gr-account-list id="reviewers"> </gr-account-list>
+            <gr-endpoint-slot name="right"> </gr-endpoint-slot>
+          </div>
+          <gr-endpoint-slot name="below"> </gr-endpoint-slot>
+        </gr-endpoint-decorator>
+        <div class="peopleList">
+          <div class="peopleListLabel">CC</div>
+          <gr-account-list allow-any-input="" id="ccs"> </gr-account-list>
+        </div>
+        <dialog
+          tabindex="-1"
+          id="reviewerConfirmationModal"
+        >
+          <div class="reviewerConfirmation">
+            Group
+            <span class="groupName"> </span>
+            has
+            <span class="groupSize"> </span>
+            members.
+            <br />
+            Are you sure you want to add them all?
+          </div>
+          <div class="reviewerConfirmationButtons">
+            <gr-button aria-disabled="false" role="button" tabindex="0">
+              Yes
+            </gr-button>
+            <gr-button aria-disabled="false" role="button" tabindex="0">
+              No
+            </gr-button>
+          </div>
+        </dialog>
+        <div class="privateVisiblityInfo">
+          <gr-icon icon="info">
+          </gr-icon>
+          <div>
+            Adding a reviewer/CC will make this private change visible to them
+          </div>
+        </div>
+      </section>
+    `
+    );
+  });
+
   test('default to publishing draft comments with reply', async () => {
     // Async tick is needed because iron-selector content is distributed and
     // distributed content requires an observer to be set up.
@@ -341,21 +467,21 @@
 
     const review = await saveReviewPromise;
     assert.deepEqual(review, {
-      drafts: 'PUBLISH_ALL_REVISIONS',
+      drafts: DraftsAction.PUBLISH_ALL_REVISIONS,
       labels: {
         'Code-Review': 0,
         Verified: 0,
       },
       reviewers: [],
       add_to_attention_set: [
-        {reason: '<GERRIT_ACCOUNT_1> replied on the change', user: 999},
+        {
+          reason: '<GERRIT_ACCOUNT_1> replied on the change',
+          user: 999 as UserId,
+        },
       ],
       remove_from_attention_set: [],
       ignore_automatic_attention_set_rules: true,
     });
-    assert.isFalse(
-      queryAndAssert<GrThreadList>(element, '#commentList').hidden
-    );
   });
 
   test('modified attention set', async () => {
@@ -374,13 +500,16 @@
     const review = await saveReviewPromise;
 
     assert.deepEqual(review, {
-      drafts: 'PUBLISH_ALL_REVISIONS',
+      drafts: DraftsAction.PUBLISH_ALL_REVISIONS,
       labels: {
         'Code-Review': 0,
         Verified: 0,
       },
       add_to_attention_set: [
-        {reason: '<GERRIT_ACCOUNT_123> replied on the change', user: 314},
+        {
+          reason: '<GERRIT_ACCOUNT_123> replied on the change',
+          user: 314 as UserId,
+        },
       ],
       reviewers: [],
       ready: true,
@@ -405,14 +534,17 @@
     const review = await saveReviewPromise;
 
     assert.deepEqual(review, {
-      drafts: 'PUBLISH_ALL_REVISIONS',
+      drafts: DraftsAction.PUBLISH_ALL_REVISIONS,
       labels: {
         'Code-Review': 0,
         Verified: 0,
       },
       add_to_attention_set: [
         // Name coming from createUserConfig in test-data-generator
-        {reason: 'Name of user not set replied on the change', user: 314},
+        {
+          reason: 'Name of user not set replied on the change',
+          user: 314 as UserId,
+        },
       ],
       reviewers: [],
       ready: true,
@@ -480,6 +612,7 @@
     element._ccs = [];
     element.draftCommentThreads = draftThreads;
     element.includeComments = includeComments;
+    element.isOwner = isOwner(change, element.account);
 
     await element.updateComplete;
 
@@ -949,6 +1082,7 @@
     // If the change is "work in progress" and the owner sends a reply, then
     // add all reviewers.
     element.canBeStarted = true;
+    element.isOwner = isOwner(element.change, element.account);
     element.computeNewAttention();
     await element.updateComplete;
     assert.sameMembers(
@@ -958,6 +1092,7 @@
 
     // ... but not when someone else replies.
     element.account = {_account_id: 4 as AccountId};
+    element.isOwner = isOwner(element.change, element.account);
     element.computeNewAttention();
     assert.sameMembers([...element.newAttentionSet], []);
   });
@@ -1077,14 +1212,17 @@
     await waitUntil(() => element.disabled === false);
     assert.equal(element.patchsetLevelDraftMessage.length, 0);
     assert.deepEqual(review, {
-      drafts: 'PUBLISH_ALL_REVISIONS',
+      drafts: DraftsAction.PUBLISH_ALL_REVISIONS,
       labels: {
         'Code-Review': -1,
         Verified: -1,
       },
       reviewers: [],
       add_to_attention_set: [
-        {user: 999, reason: '<GERRIT_ACCOUNT_1> replied on the change'},
+        {
+          user: 999 as UserId,
+          reason: '<GERRIT_ACCOUNT_1> replied on the change',
+        },
       ],
       remove_from_attention_set: [],
       ignore_automatic_attention_set_rules: true,
@@ -1113,14 +1251,17 @@
     const review = await saveReviewPromise;
     await element.updateComplete;
     assert.deepEqual(review, {
-      drafts: 'KEEP',
+      drafts: DraftsAction.KEEP,
       labels: {
         'Code-Review': 0,
         Verified: 0,
       },
       reviewers: [],
       add_to_attention_set: [
-        {reason: '<GERRIT_ACCOUNT_1> replied on the change', user: 999},
+        {
+          reason: '<GERRIT_ACCOUNT_1> replied on the change',
+          user: 999 as UserId,
+        },
       ],
       remove_from_attention_set: [],
       ignore_automatic_attention_set_rules: true,
@@ -1163,40 +1304,6 @@
     });
   });
 
-  function getActiveElement() {
-    return document.activeElement;
-  }
-
-  function overlayObserver(mode: string) {
-    return new Promise(resolve => {
-      function listener() {
-        element.removeEventListener('iron-overlay-' + mode, listener);
-        resolve(mode);
-      }
-      element.addEventListener('iron-overlay-' + mode, listener);
-    });
-  }
-
-  function isFocusInsideElement(element: Element) {
-    // In Polymer 2 focused element either <paper-input> or nested
-    // native input <input> element depending on the current focus
-    // in browser window.
-    // For example, the focus is changed if the developer console
-    // get a focus.
-    let activeElement = getActiveElement();
-    while (activeElement) {
-      if (activeElement === element) {
-        return true;
-      }
-      if (activeElement.parentElement) {
-        activeElement = activeElement.parentElement;
-      } else {
-        activeElement = (activeElement.getRootNode() as ShadowRoot).host;
-      }
-    }
-    return false;
-  }
-
   async function testConfirmationDialog(cc?: boolean) {
     const yesButton = queryAndAssert<GrButton>(
       element,
@@ -1211,11 +1318,9 @@
     element.reviewerPendingConfirmation = null;
     await element.updateComplete;
     assert.isFalse(
-      isVisible(queryAndAssert(element, 'reviewerConfirmationOverlay'))
+      isVisible(queryAndAssert(element, '#reviewerConfirmationModal'))
     );
 
-    // Cause the confirmation dialog to display.
-    let observer = overlayObserver('opened');
     const group = {
       id: 'id' as GroupId,
       name: 'name' as GroupName,
@@ -1224,13 +1329,13 @@
       element.ccPendingConfirmation = {
         group,
         confirm: false,
-        count: 1,
+        count: 10,
       };
     } else {
       element.reviewerPendingConfirmation = {
         group,
         confirm: false,
-        count: 1,
+        count: 10,
       };
     }
     await element.updateComplete;
@@ -1247,40 +1352,35 @@
       );
     }
 
-    await observer;
     assert.isTrue(
-      isVisible(queryAndAssert(element, 'reviewerConfirmationOverlay'))
+      isVisible(queryAndAssert(element, '#reviewerConfirmationModal'))
     );
-    observer = overlayObserver('closed');
     const expected = 'Group name has 10 members';
     assert.notEqual(
       queryAndAssert<HTMLElement>(
         element,
-        'reviewerConfirmationOverlay'
+        '#reviewerConfirmationModal'
       ).innerText.indexOf(expected),
       -1
     );
-    noButton.click(); // close the overlay
-
-    await observer;
-    assert.isFalse(
-      isVisible(queryAndAssert(element, 'reviewerConfirmationOverlay'))
+    noButton.click(); // close the dialog
+    await waitUntil(
+      () => !isVisible(queryAndAssert(element, '#reviewerConfirmationModal'))
     );
 
+    // TODO(dhruvsri): figure out why focus is not on the input element
     // We should be focused on account entry input.
-    const reviewersEntry = queryAndAssert<GrAccountList>(element, '#reviewers');
-    assert.isTrue(
-      isFocusInsideElement(
-        queryAndAssert<GrAutocomplete>(reviewersEntry.entry, '#input').input!
-      )
-    );
+    // const reviewersEntry = queryAndAssert<GrAccountList>(element, '#reviewers');
+    // assert.isTrue(
+    //   isFocusInsideElement(
+    //     queryAndAssert<GrAutocomplete>(reviewersEntry.entry, '#input').input!
+    //   )
+    // );
 
     // No reviewer/CC should have been added.
     assert.equal(element.ccsList?.additions().length, 0);
     assert.equal(element.reviewersList?.additions().length, 0);
 
-    // Reopen confirmation dialog.
-    observer = overlayObserver('opened');
     if (cc) {
       element.ccPendingConfirmation = {
         group,
@@ -1294,46 +1394,47 @@
         count: 1,
       };
     }
+    await element.updateComplete;
 
-    await observer;
     assert.isTrue(
-      isVisible(queryAndAssert(element, 'reviewerConfirmationOverlay'))
+      isVisible(queryAndAssert(element, '#reviewerConfirmationModal'))
     );
-    observer = overlayObserver('closed');
-    yesButton.click(); // Confirm the group.
 
-    await observer;
-    assert.isFalse(
-      isVisible(queryAndAssert(element, 'reviewerConfirmationOverlay'))
+    yesButton.click(); // Confirm the group.
+    await waitUntil(
+      () => !isVisible(queryAndAssert(element, '#reviewerConfirmationModal'))
     );
     const additions = cc
       ? element.ccsList?.additions()
       : element.reviewersList?.additions();
     assert.deepEqual(additions, [
       {
+        confirmed: true,
+        id: 'id' as GroupId,
         name: 'name' as GroupName,
       },
     ]);
 
     // We should be focused on account entry input.
-    if (cc) {
-      const ccsEntry = queryAndAssert<GrAccountList>(element, '#ccs');
-      assert.isTrue(
-        isFocusInsideElement(
-          queryAndAssert<GrAutocomplete>(ccsEntry.entry, '#input').input!
-        )
-      );
-    } else {
-      const reviewersEntry = queryAndAssert<GrAccountList>(
-        element,
-        '#reviewers'
-      );
-      assert.isTrue(
-        isFocusInsideElement(
-          queryAndAssert<GrAutocomplete>(reviewersEntry.entry, '#input').input!
-        )
-      );
-    }
+    // TODO(dhruvsri): figure out why focus is not on the input element
+    // if (cc) {
+    //   const ccsEntry = queryAndAssert<GrAccountList>(element, '#ccs');
+    //   assert.isTrue(
+    //     isFocusInsideElement(
+    //       queryAndAssert<GrAutocomplete>(ccsEntry.entry, '#input').input!
+    //     )
+    //   );
+    // } else {
+    //   const reviewersEntry = queryAndAssert<GrAccountList>(
+    //     element,
+    //     '#reviewers'
+    //   );
+    //   assert.isTrue(
+    //     isFocusInsideElement(
+    //       queryAndAssert<GrAutocomplete>(reviewersEntry.entry, '#input').input!
+    //     )
+    //   );
+    // }
   }
 
   test('cc confirmation', async () => {
@@ -1344,6 +1445,70 @@
     testConfirmationDialog(false);
   });
 
+  suite('reviewer toast for WIP changes', () => {
+    let fireStub: sinon.SinonStub;
+    setup(() => {
+      fireStub = sinon.stub(element, 'dispatchEvent');
+    });
+
+    test('toast not fired if change is already active', async () => {
+      element.change = {
+        ...createChange(),
+        status: ChangeStatus.NEW,
+      };
+      element.send(false, false);
+
+      await waitUntil(() => fireStub.called);
+
+      const events = fireStub.args.map(arg => arg[0].type || '');
+      assert.isFalse(events.includes('show-alert'));
+    });
+
+    test('toast is not fired if change is WIP and becomes active', async () => {
+      const account = createAccountWithId(22);
+      element.reviewersList!.accounts = [];
+      element.reviewersList!.addAccountItem({account, count: 1});
+      element.reviewersList!.dispatchEvent(
+        new CustomEvent('account-added', {
+          detail: {account},
+        })
+      );
+      element.change = {
+        ...createChange(),
+        status: ChangeStatus.NEW,
+        work_in_progress: true,
+      };
+      element.send(false, true);
+
+      await waitUntil(() => fireStub.called);
+
+      const events = fireStub.args.map(arg => arg[0].type || '');
+      assert.isFalse(events.includes('show-alert'));
+    });
+
+    test('toast is fired if change is WIP and becomes active and reviewer added', async () => {
+      const account = createAccountWithId(22);
+      element.reviewersList!.accounts = [];
+      element.reviewersList!.addAccountItem({account, count: 1});
+      element.reviewersList!.dispatchEvent(
+        new CustomEvent('account-added', {
+          detail: {account},
+        })
+      );
+      element.change = {
+        ...createChange(),
+        status: ChangeStatus.NEW,
+        work_in_progress: true,
+      };
+      element.send(false, false);
+
+      await waitUntil(() => fireStub.called);
+
+      const events = fireStub.args.map(arg => arg[0].type || '');
+      assert.isTrue(events.includes('show-alert'));
+    });
+  });
+
   test('reviewersMutated when account-text-change is fired from ccs', () => {
     assert.isFalse(element.reviewersMutated);
     assert.isTrue(queryAndAssert<GrAccountList>(element, '#ccs').allowAnyInput);
@@ -1444,14 +1609,10 @@
 
   test('focusOn', async () => {
     await element.updateComplete;
-    const clock = sinon.useFakeTimers();
     const chooseFocusTargetSpy = sinon.spy(element, 'chooseFocusTarget');
     element.focusOn();
-    // element.focus() is called after a setTimeout(). The focusOn() method
-    // does not trigger any changes in the element hence element.updateComplete
-    // resolves immediately and cannot be used here, hence tick the clock here
-    // explicitly instead
-    clock.tick(1);
+    await waitUntilVisible(element); // let whenVisible resolve
+
     assert.equal(chooseFocusTargetSpy.callCount, 1);
     assert.equal(element?.shadowRoot?.activeElement?.tagName, 'GR-COMMENT');
     assert.equal(
@@ -1460,7 +1621,8 @@
     );
 
     element.focusOn(element.FocusTarget.ANY);
-    clock.tick(1);
+    await waitUntilVisible(element); // let whenVisible resolve
+
     assert.equal(chooseFocusTargetSpy.callCount, 2);
     assert.equal(element?.shadowRoot?.activeElement?.tagName, 'GR-COMMENT');
     assert.equal(
@@ -1469,7 +1631,8 @@
     );
 
     element.focusOn(element.FocusTarget.BODY);
-    clock.tick(1);
+    await waitUntilVisible(element); // let whenVisible resolve
+
     assert.equal(chooseFocusTargetSpy.callCount, 2);
     assert.equal(element?.shadowRoot?.activeElement?.tagName, 'GR-COMMENT');
     assert.equal(
@@ -1478,30 +1641,28 @@
     );
 
     element.focusOn(element.FocusTarget.REVIEWERS);
-    clock.tick(1);
+    await waitUntilVisible(element); // let whenVisible resolve
+
     assert.equal(chooseFocusTargetSpy.callCount, 2);
-    assert.equal(
-      element?.shadowRoot?.activeElement?.tagName,
-      'GR-ACCOUNT-LIST'
+    await waitUntil(
+      () => element?.shadowRoot?.activeElement?.tagName === 'GR-ACCOUNT-LIST'
     );
     assert.equal(element?.shadowRoot?.activeElement?.id, 'reviewers');
 
     element.focusOn(element.FocusTarget.CCS);
-    clock.tick(1);
     assert.equal(chooseFocusTargetSpy.callCount, 2);
     assert.equal(
       element?.shadowRoot?.activeElement?.tagName,
       'GR-ACCOUNT-LIST'
     );
-    assert.equal(element?.shadowRoot?.activeElement?.id, 'ccs');
-    clock.restore();
+    await waitUntil(() => element?.shadowRoot?.activeElement?.id === 'ccs');
   });
 
   test('chooseFocusTarget', () => {
-    element.account = undefined;
+    element.isOwner = false;
     assert.equal(element.chooseFocusTarget(), element.FocusTarget.BODY);
 
-    element.account = element.change!.owner;
+    element.isOwner = true;
     assert.equal(element.chooseFocusTarget(), element.FocusTarget.REVIEWERS);
 
     element.change!.reviewers.REVIEWER = [createAccountWithId(314)];
@@ -1748,7 +1909,7 @@
 
     // Remove and add to other field.
     reviewers.dispatchEvent(
-      new CustomEvent('remove', {
+      new CustomEvent('remove-account', {
         detail: {account: reviewer1},
         composed: true,
         bubbles: true,
@@ -1765,14 +1926,14 @@
       })
     );
     ccs.dispatchEvent(
-      new CustomEvent('remove', {
+      new CustomEvent('remove-account', {
         detail: {account: cc1},
         composed: true,
         bubbles: true,
       })
     );
     ccs.dispatchEvent(
-      new CustomEvent('remove', {
+      new CustomEvent('remove-account', {
         detail: {account: cc3},
         composed: true,
         bubbles: true,
@@ -1836,13 +1997,13 @@
 
     const mapReviewer = function (
       reviewer: AccountInfo,
-      opt_state?: ReviewerState
+      state?: ReviewerState
     ) {
       const result: ReviewerInput = {
         reviewer: reviewer._account_id as AccountId,
       };
-      if (opt_state) {
-        result.state = opt_state;
+      if (state) {
+        result.state = state;
       }
       return result;
     };
@@ -1949,16 +2110,26 @@
     pressKey(element, Key.ENTER);
   });
 
-  test('emit send on ctrl+enter key', async () => {
-    // required so that "Send" button is enabled
+  test('send and start review on ctrl+enter for owner', async () => {
     element.canBeStarted = true;
+    element.isOwner = true;
     await element.updateComplete;
 
-    stubSaveReview(() => undefined);
-    const promise = mockPromise();
-    element.addEventListener('send', () => promise.resolve());
+    const savePromise = interceptSaveReview();
     pressKey(element, Key.ENTER, Modifier.CTRL_KEY);
-    await promise;
+    const reviewInput = await savePromise;
+    assert.isTrue(reviewInput.ready);
+  });
+
+  test('save on ctrl+enter for reviewer', async () => {
+    element.canBeStarted = true;
+    element.isOwner = false;
+    await element.updateComplete;
+
+    const savePromise = interceptSaveReview();
+    pressKey(element, Key.ENTER, Modifier.CTRL_KEY);
+    const reviewInput = await savePromise;
+    assert.isUndefined(reviewInput.ready);
   });
 
   test('computeMessagePlaceholder', async () => {
@@ -1974,14 +2145,14 @@
     );
   });
 
-  test('computeSendButtonLabel', async () => {
+  test('sendButton text', async () => {
     element.canBeStarted = false;
     await element.updateComplete;
-    assert.equal(element.sendButtonLabel, 'Send');
+    assert.equal(element.sendButton?.innerText, 'SEND');
 
     element.canBeStarted = true;
     await element.updateComplete;
-    assert.equal(element.sendButtonLabel, 'Send and Start review');
+    assert.equal(element.sendButton?.innerText, 'SEND AND START REVIEW');
   });
 
   test('handle400Error reviewers and CCs', async () => {
@@ -2018,17 +2189,6 @@
     await promise;
   });
 
-  test('fires height change when the drafts comments load', async () => {
-    // Flush DOM operations before binding to the autogrow event so we don't
-    // catch the events fired from the initial layout.
-    await element.updateComplete;
-    const autoGrowHandler = sinon.stub();
-    element.addEventListener('autogrow', autoGrowHandler);
-    element.draftCommentThreads = [];
-    await element.updateComplete;
-    assert.isTrue(autoGrowHandler.called);
-  });
-
   suite('start review and save buttons', () => {
     let sendStub: sinon.SinonStub;
 
@@ -2112,7 +2272,7 @@
     });
   });
 
-  test('computeSendButtonDisabled_canBeStarted', () => {
+  test('isSendDisabled_canBeStarted', () => {
     // Mock canBeStarted
     element.canBeStarted = true;
     element.draftCommentThreads = [];
@@ -2123,10 +2283,10 @@
     element.disabled = false;
     element.commentEditing = false;
     element.account = makeAccount();
-    assert.isFalse(element.computeSendButtonDisabled());
+    assert.isFalse(element.isSendDisabled());
   });
 
-  test('computeSendButtonDisabled_allFalse', () => {
+  test('isSendDisabled_allFalse', () => {
     // Mock everything false
     element.canBeStarted = false;
     element.draftCommentThreads = [];
@@ -2137,10 +2297,10 @@
     element.disabled = false;
     element.commentEditing = false;
     element.account = makeAccount();
-    assert.isTrue(element.computeSendButtonDisabled());
+    assert.isTrue(element.isSendDisabled());
   });
 
-  test('computeSendButtonDisabled_draftCommentsSend', () => {
+  test('isSendDisabled_draftCommentsSend', () => {
     // Mock nonempty comment draft array; with sending comments.
     element.canBeStarted = false;
     element.draftCommentThreads = [{...createCommentThread([createComment()])}];
@@ -2151,10 +2311,10 @@
     element.disabled = false;
     element.commentEditing = false;
     element.account = makeAccount();
-    assert.isFalse(element.computeSendButtonDisabled());
+    assert.isFalse(element.isSendDisabled());
   });
 
-  test('computeSendButtonDisabled_draftCommentsDoNotSend', () => {
+  test('isSendDisabled_draftCommentsDoNotSend', () => {
     // Mock nonempty comment draft array; without sending comments.
     element.canBeStarted = false;
     element.draftCommentThreads = [{...createCommentThread([createComment()])}];
@@ -2166,10 +2326,10 @@
     element.commentEditing = false;
     element.account = makeAccount();
 
-    assert.isTrue(element.computeSendButtonDisabled());
+    assert.isTrue(element.isSendDisabled());
   });
 
-  test('computeSendButtonDisabled_changeMessage', () => {
+  test('isSendDisabled_changeMessage', () => {
     // Mock nonempty change message.
     element.canBeStarted = false;
     element.draftCommentThreads = [{...createCommentThread([createComment()])}];
@@ -2181,10 +2341,10 @@
     element.commentEditing = false;
     element.account = makeAccount();
 
-    assert.isFalse(element.computeSendButtonDisabled());
+    assert.isFalse(element.isSendDisabled());
   });
 
-  test('computeSendButtonDisabledreviewersChanged', () => {
+  test('isSendDisabledreviewersChanged', () => {
     // Mock reviewers mutated.
     element.canBeStarted = false;
     element.draftCommentThreads = [{...createCommentThread([createComment()])}];
@@ -2196,10 +2356,10 @@
     element.commentEditing = false;
     element.account = makeAccount();
 
-    assert.isFalse(element.computeSendButtonDisabled());
+    assert.isFalse(element.isSendDisabled());
   });
 
-  test('computeSendButtonDisabled_labelsChanged', () => {
+  test('isSendDisabled_labelsChanged', () => {
     // Mock labels changed.
     element.canBeStarted = false;
     element.draftCommentThreads = [{...createCommentThread([createComment()])}];
@@ -2211,10 +2371,10 @@
     element.commentEditing = false;
     element.account = makeAccount();
 
-    assert.isFalse(element.computeSendButtonDisabled());
+    assert.isFalse(element.isSendDisabled());
   });
 
-  test('computeSendButtonDisabled_dialogDisabled', () => {
+  test('isSendDisabled_dialogDisabled', () => {
     // Whole dialog is disabled.
     element.canBeStarted = false;
     element.draftCommentThreads = [{...createCommentThread([createComment()])}];
@@ -2226,10 +2386,10 @@
     element.commentEditing = false;
     element.account = makeAccount();
 
-    assert.isTrue(element.computeSendButtonDisabled());
+    assert.isTrue(element.isSendDisabled());
   });
 
-  test('computeSendButtonDisabled_existingVote', async () => {
+  test('isSendDisabled_existingVote', async () => {
     const account = createAccountWithId();
     (
       element.change!.labels![StandardLabels.CODE_REVIEW]! as DetailedLabelInfo
@@ -2245,7 +2405,7 @@
     element.account = account;
 
     // User has already voted.
-    assert.isFalse(element.computeSendButtonDisabled());
+    assert.isFalse(element.isSendDisabled());
   });
 
   test('_submit blocked when no mutations exist', async () => {
@@ -2301,19 +2461,19 @@
     });
 
     test('send button updates state as text is typed in patchset comment', async () => {
-      assert.isTrue(element.computeSendButtonDisabled());
+      assert.isTrue(element.isSendDisabled());
 
       queryAndAssert<GrComment>(element, '#patchsetLevelComment').messageText =
         'hello';
       await waitUntil(() => element.patchsetLevelDraftMessage === 'hello');
 
-      assert.isFalse(element.computeSendButtonDisabled());
+      assert.isFalse(element.isSendDisabled());
 
       queryAndAssert<GrComment>(element, '#patchsetLevelComment').messageText =
         '';
       await waitUntil(() => element.patchsetLevelDraftMessage === '');
 
-      assert.isTrue(element.computeSendButtonDisabled());
+      assert.isTrue(element.isSendDisabled());
     });
 
     test('sending patchset level comment', async () => {
@@ -2341,14 +2501,17 @@
       assert.deepEqual(autoSaveStub.callCount, 1);
 
       assert.deepEqual(review, {
-        drafts: 'PUBLISH_ALL_REVISIONS',
+        drafts: DraftsAction.PUBLISH_ALL_REVISIONS,
         labels: {
           'Code-Review': 0,
           Verified: 0,
         },
         reviewers: [],
         add_to_attention_set: [
-          {reason: '<GERRIT_ACCOUNT_1> replied on the change', user: 999},
+          {
+            reason: '<GERRIT_ACCOUNT_1> replied on the change',
+            user: 999 as UserId,
+          },
         ],
         remove_from_attention_set: [],
         ignore_automatic_attention_set_rules: true,
@@ -2381,7 +2544,7 @@
 
     test('replies to patchset level comments are not filtered out', async () => {
       const draft = {...createDraft(), in_reply_to: '1' as UrlEncodedCommentId};
-      element.getCommentsModel().setState({
+      commentsModel.setState({
         drafts: {
           'abc.txt': [draft],
         },
@@ -2398,9 +2561,6 @@
 
   suite('mention users', () => {
     setup(async () => {
-      stubFlags('isEnabled')
-        .withArgs(KnownExperimentId.MENTION_USERS)
-        .returns(true);
       element.account = createAccountWithId(1);
       element.requestUpdate();
       await element.updateComplete;
@@ -2417,7 +2577,7 @@
         ...createDraft(),
         message: 'hey @abcd@def take a look at this',
       };
-      element.getCommentsModel().setState({
+      commentsModel.setState({
         comments: {},
         robotComments: {},
         drafts: {
@@ -2452,7 +2612,7 @@
         message: 'hey @abcd@def.com take a look at this',
         unresolved: true,
       };
-      element.getCommentsModel().setState({
+      commentsModel.setState({
         comments: {},
         robotComments: {},
         drafts: {
@@ -2492,7 +2652,7 @@
         message: 'hey @abcd@def.com take a look at this',
         unresolved: true,
       };
-      element.getCommentsModel().setState({
+      commentsModel.setState({
         comments: {},
         robotComments: {},
         drafts: {
@@ -2545,7 +2705,7 @@
       };
       stubRestApi('getAccountDetails').returns(Promise.resolve(account));
 
-      element.getCommentsModel().setState({
+      commentsModel.setState({
         comments: {},
         robotComments: {},
         drafts: {
@@ -2582,7 +2742,7 @@
         registered_on: '2015-03-12 18:32:08.000000000' as Timestamp,
       };
       stubRestApi('getAccountDetails').returns(Promise.resolve(account));
-      element.getCommentsModel().setState({
+      commentsModel.setState({
         comments: {},
         robotComments: {},
         drafts: {
@@ -2616,36 +2776,6 @@
     });
   });
 
-  test('getFocusStops', async () => {
-    // Setting draftCommentThreads to an empty object causes _sendDisabled to be
-    // computed to false.
-    element.draftCommentThreads = [];
-    await element.updateComplete;
-
-    assert.equal(
-      element.getFocusStops()!.end,
-      queryAndAssert<GrButton>(element, '#cancelButton')
-    );
-    element.draftCommentThreads = [
-      {
-        ...createCommentThread([
-          {
-            ...createDraft(),
-            path: 'test',
-            line: 1,
-            patch_set: 1 as RevisionPatchSetNum,
-          },
-        ]),
-      },
-    ];
-    await element.updateComplete;
-
-    assert.equal(
-      element.getFocusStops()!.end,
-      queryAndAssert<GrButton>(element, '#sendButton')
-    );
-  });
-
   test('setPluginMessage', async () => {
     element.setPluginMessage('foo');
     await element.updateComplete;
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 9408b82..db19329 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
@@ -23,15 +23,11 @@
 import {sharedStyles} from '../../../styles/shared-styles';
 import {css} from 'lit';
 import {nothing} from 'lit';
+import {fire} from '../../../utils/event-util';
+import {ShowReplyDialogEvent} from '../../../types/events';
 
 @customElement('gr-reviewer-list')
 export class GrReviewerList extends LitElement {
-  /**
-   * Fired when the "Add reviewer..." button is tapped.
-   *
-   * @event show-reply-dialog
-   */
-
   @property({type: Object}) change?: ChangeInfo;
 
   @property({type: Object}) account?: AccountDetailInfo;
@@ -203,22 +199,10 @@
   handleAddTap(e: Event) {
     e.preventDefault();
     const value = {
-      reviewersOnly: false,
-      ccsOnly: false,
+      reviewersOnly: this.reviewersOnly,
+      ccsOnly: this.ccsOnly,
     };
-    if (this.reviewersOnly) {
-      value.reviewersOnly = true;
-    }
-    if (this.ccsOnly) {
-      value.ccsOnly = true;
-    }
-    this.dispatchEvent(
-      new CustomEvent('show-reply-dialog', {
-        detail: {value},
-        composed: true,
-        bubbles: true,
-      })
-    );
+    fire(this, 'show-reply-dialog', {value});
   }
 }
 
@@ -226,4 +210,8 @@
   interface HTMLElementTagNameMap {
     'gr-reviewer-list': GrReviewerList;
   }
+  interface HTMLElementEventMap {
+    /** Fired when the "Add reviewer..." button is tapped. */
+    'show-reply-dialog': ShowReplyDialogEvent;
+  }
 }
diff --git a/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard.ts b/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard.ts
index 66ad38c..98f65c0 100644
--- a/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard.ts
+++ b/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard.ts
@@ -10,13 +10,12 @@
 import {
   AccountInfo,
   ChangeStatus,
-  isDetailedLabelInfo,
   SubmitRequirementExpressionInfo,
   SubmitRequirementResultInfo,
   SubmitRequirementStatus,
 } from '../../../api/rest-api';
 import {
-  canVote,
+  canReviewerVote,
   extractAssociatedLabels,
   getApprovalInfo,
   hasVotes,
@@ -204,7 +203,7 @@
       if (requirementLabels.includes(label)) {
         const labelInfo = allLabels[label];
         const canSomeoneVote = (this.change?.reviewers['REVIEWER'] ?? []).some(
-          reviewer => canVote(labelInfo, reviewer)
+          reviewer => canReviewerVote(labelInfo, reviewer)
         );
         if (hasVotes(labelInfo) || canSomeoneVote) {
           labels.push(label);
@@ -280,15 +279,17 @@
     labelName: string,
     type: 'override' | 'submittability'
   ) {
+    if (!this.account) return;
+    const votes = this.change?.permitted_labels?.[labelName];
+    if (!votes || votes.length < 1) return;
+    const maxVote = Number(votes[votes.length - 1]);
+    if (maxVote <= 0) return;
+
     const labels = this.change?.labels ?? {};
     const labelInfo = labels[labelName];
-    if (!labelInfo || !isDetailedLabelInfo(labelInfo)) return;
-    if (!this.account || !canVote(labelInfo, this.account)) return;
-
     const approvalInfo = getApprovalInfo(labelInfo, this.account);
-    const maxVote = approvalInfo?.permitted_voting_range?.max;
-    if (!maxVote || maxVote <= 0) return;
     if (approvalInfo?.value === maxVote) return; // Already voted maxVote
+
     return html` <div class="button quickApprove">
       <gr-button
         link=""
@@ -326,7 +327,7 @@
         review
       )
       .then(() => {
-        fireReload(this, true);
+        fireReload(this);
       });
   }
 
diff --git a/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard_test.ts b/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard_test.ts
index 4e78e8a..18f8e4c 100644
--- a/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard_test.ts
@@ -253,6 +253,9 @@
     const change: ParsedChangeInfo = {
       ...createParsedChange(),
       status: ChangeStatus.NEW,
+      permitted_labels: {
+        Verified: ['-1', ' 0', '+1', '+2'],
+      },
       labels: {
         Verified: {
           ...createDetailedLabelInfo(),
@@ -351,6 +354,9 @@
       const change: ParsedChangeInfo = {
         ...createParsedChange(),
         status: ChangeStatus.NEW,
+        permitted_labels: {
+          'Build-Cop': ['-1', ' 0', '+1', '+2'],
+        },
         labels: {
           'Build-Cop': {
             ...createDetailedLabelInfo(),
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 fa12eaa..212394c 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
@@ -16,8 +16,8 @@
   createSubmitRequirementExpressionInfo,
   createSubmitRequirementResultInfo,
   createNonApplicableSubmitRequirementResultInfo,
-  createRunResult,
   createCheckResult,
+  createRun,
 } from '../../../test/test-data-generators';
 import {
   SubmitRequirementResultInfo,
@@ -163,7 +163,7 @@
     test('checks', async () => {
       element.runs = [
         {
-          ...createRunResult(),
+          ...createRun(),
           labelName: 'Verified',
           results: [createCheckResult()],
         },
@@ -184,7 +184,7 @@
     test('running checks', async () => {
       element.runs = [
         {
-          ...createRunResult(),
+          ...createRun(),
           status: RunStatus.RUNNING,
           labelName: 'Verified',
           results: [createCheckResult()],
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 27b5097..75845f6 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
@@ -10,16 +10,16 @@
 import {
   AccountDetailInfo,
   AccountInfo,
+  CommentThread,
   NumericChangeId,
   UrlEncodedCommentId,
+  isDraft,
 } from '../../../types/common';
 import {ChangeMessageId} from '../../../api/rest-api';
 import {
-  CommentThread,
   getCommentAuthors,
   getMentionedThreads,
   hasHumanReply,
-  isDraft,
   isDraftThread,
   isMentionedThread,
   isRobotThread,
@@ -28,7 +28,11 @@
 } from '../../../utils/comment-util';
 import {pluralize} from '../../../utils/string-util';
 import {assertIsDefined} from '../../../utils/common-util';
-import {CommentTabState, TabState} from '../../../types/events';
+import {
+  CommentTabState,
+  TabState,
+  ValueChangedEvent,
+} from '../../../types/events';
 import {DropdownItem} from '../../shared/gr-dropdown-list/gr-dropdown-list';
 import {GrAccountChip} from '../../shared/gr-account-chip/gr-account-chip';
 import {css, html, LitElement, PropertyValues} from 'lit';
@@ -38,12 +42,10 @@
 import {ParsedChangeInfo} from '../../../types/types';
 import {repeat} from 'lit/directives/repeat.js';
 import {GrCommentThread} from '../../shared/gr-comment-thread/gr-comment-thread';
-import {getAppContext} from '../../../services/app-context';
 import {resolve} from '../../../models/dependency';
 import {changeModelToken} from '../../../models/change/change-model';
-import {Interaction} from '../../../constants/reporting';
-import {KnownExperimentId} from '../../../services/flags/flags';
-import {HtmlPatched} from '../../../utils/lit-util';
+import {userModelToken} from '../../../models/user/user-model';
+import {specialFilePathCompare} from '../../../utils/path-list-util';
 
 enum SortDropdownState {
   TIMESTAMP = 'Latest timestamp',
@@ -91,7 +93,7 @@
     if (c2.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS) {
       return 1;
     }
-    return c1.path.localeCompare(c2.path);
+    return specialFilePathCompare(c1.path, c2.path);
   }
 
   // Convert 'FILE' and 'LOST' to undefined.
@@ -201,18 +203,7 @@
 
   private readonly getChangeModel = resolve(this, changeModelToken);
 
-  private readonly reporting = getAppContext().reportingService;
-
-  private readonly flagsService = getAppContext().flagsService;
-
-  private readonly userModel = getAppContext().userModel;
-
-  private readonly patched = new HtmlPatched(key => {
-    this.reporting.reportInteraction(Interaction.AUTOCLOSE_HTML_PATCHED, {
-      component: this.tagName,
-      key: key.substring(0, 300),
-    });
-  });
+  private readonly getUserModel = resolve(this, userModelToken);
 
   constructor() {
     super();
@@ -228,13 +219,9 @@
     );
     subscribe(
       this,
-      () => this.userModel.account$,
+      () => this.getUserModel().account$,
       x => (this.account = x)
     );
-    // for COMMENTS_AUTOCLOSE logging purposes only
-    this.reporting.reportInteraction(
-      Interaction.COMMENTS_AUTOCLOSE_THREAD_LIST_CREATED
-    );
   }
 
   override willUpdate(changed: PropertyValues) {
@@ -338,17 +325,6 @@
     ];
   }
 
-  override updated(): void {
-    // for COMMENTS_AUTOCLOSE logging purposes only
-    const threads = this.shadowRoot!.querySelectorAll('gr-comment-thread');
-    if (threads.length > 0) {
-      this.reporting.reportInteraction(
-        Interaction.COMMENTS_AUTOCLOSE_THREAD_LIST_UPDATED,
-        {uid: threads[0].uid}
-      );
-    }
-  }
-
   override render() {
     return html`
       ${this.renderDropdown()}
@@ -366,7 +342,7 @@
         <gr-dropdown-list
           id="sortDropdown"
           .value=${this.sortDropdownValue}
-          @value-change=${(e: CustomEvent) =>
+          @value-change=${(e: ValueChangedEvent<SortDropdownState>) =>
             (this.sortDropdownValue = e.detail.value)}
           .items=${this.getSortDropdownEntries()}
         >
@@ -422,16 +398,16 @@
           index === 0 || threads[index - 1].path !== threads[index].path;
         const separator =
           index !== 0 && isFirst
-            ? this.patched.html`<div class="thread-separator"></div>`
+            ? html`<div class="thread-separator"></div>`
             : undefined;
         const commentThread = this.renderCommentThread(thread, isFirst);
-        return this.patched.html`${separator}${commentThread}`;
+        return html`${separator}${commentThread}`;
       }
     );
   }
 
   private renderCommentThread(thread: CommentThread, isFirst: boolean) {
-    return this.patched.html`
+    return html`
       <gr-comment-thread
         .thread=${thread}
         show-file-path
@@ -493,14 +469,10 @@
       value: CommentTabState.UNRESOLVED,
     });
     if (this.account) {
-      if (this.flagsService.isEnabled(KnownExperimentId.MENTION_USERS)) {
-        items.push({
-          text: `Mentions (${
-            getMentionedThreads(threads, this.account).length
-          })`,
-          value: CommentTabState.MENTIONS,
-        });
-      }
+      items.push({
+        text: `Mentions (${getMentionedThreads(threads, this.account).length})`,
+        value: CommentTabState.MENTIONS,
+      });
       items.push({
         text: `Drafts (${threads.filter(isDraftThread).length})`,
         value: CommentTabState.DRAFTS,
@@ -526,7 +498,7 @@
   }
 
   // private, but visible for testing
-  handleCommentsDropdownValueChange(e: CustomEvent) {
+  handleCommentsDropdownValueChange(e: ValueChangedEvent<CommentTabState>) {
     const value = e.detail.value;
     switch (value) {
       case CommentTabState.UNRESOLVED:
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.ts b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.ts
index 16fa813..a4357bb 100644
--- a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.ts
@@ -32,12 +32,15 @@
   RobotId,
   UrlEncodedCommentId,
   RevisionPatchSetNum,
+  CommentThread,
+  isDraft,
+  SavingState,
 } from '../../../types/common';
-import {CommentThread, isDraft} from '../../../utils/comment-util';
 import {query, queryAndAssert} from '../../../utils/common-util';
 import {GrAccountLabel} from '../../shared/gr-account-label/gr-account-label';
 import {GrDropdownList} from '../../shared/gr-dropdown-list/gr-dropdown-list';
 import {fixture, html, assert} from '@open-wc/testing';
+import {GrCommentThread} from '../../shared/gr-comment-thread/gr-comment-thread';
 
 suite('gr-thread-list tests', () => {
   let element: GrThreadList;
@@ -73,7 +76,7 @@
             updated: '2015-12-01 15:16:15.000000000' as Timestamp,
             message: 'draft',
             unresolved: true,
-            __draft: true,
+            savingState: SavingState.OK,
             patch_set: '2' as RevisionPatchSetNum,
           },
         ],
@@ -160,7 +163,7 @@
             updated: '2015-12-05 15:16:15.000000000' as Timestamp,
             message: 'resolved draft',
             unresolved: false,
-            __draft: true,
+            savingState: SavingState.OK,
             patch_set: '2' as RevisionPatchSetNum,
           },
         ],
@@ -290,6 +293,40 @@
       assert.sameOrderedMembers(actual, expected);
     });
 
+    test('respects special cases for ordering', async () => {
+      element.sortDropdownValue = __testOnly_SortDropdownState.FILES;
+      element.threads = [
+        {
+          ...createThread(createComment({path: '/app/test.cc'})),
+          path: '/app/test.cc',
+        },
+        {
+          ...createThread(createComment({path: '/app/test.h'})),
+          path: '/app/test.h',
+        },
+        {
+          ...createThread(
+            createComment({path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS})
+          ),
+          path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
+        },
+      ];
+      await element.updateComplete;
+
+      const paths = Array.from(
+        queryAll<GrCommentThread>(element, 'gr-comment-thread')
+      ).map(threadElement => threadElement.thread?.path);
+
+      // Patchset comment is always first, then we have a special case where .h
+      // files should appear above other files of the same name regardless of
+      // their alphabetical ordering.
+      assert.sameOrderedMembers(paths, [
+        SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
+        '/app/test.h',
+        '/app/test.cc',
+      ]);
+    });
+
     test('sort all threads by timestamp', () => {
       element.sortDropdownValue = __testOnly_SortDropdownState.TIMESTAMP;
       assert.equal(element.getDisplayedThreads().length, 9);
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-results.ts b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
index 848948f..65165c1 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-results.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
@@ -28,7 +28,7 @@
   Tag,
 } from '../../api/checks';
 import {sharedStyles} from '../../styles/shared-styles';
-import {CheckRun, RunResult} from '../../models/checks/checks-model';
+import {CheckRun, RunResult, runResult} from '../../models/checks/checks-model';
 import {
   ALL_ATTEMPTS,
   AttemptChoice,
@@ -54,14 +54,17 @@
 import {charsOnly} from '../../utils/string-util';
 import {isAttemptSelected, matches} from './gr-checks-util';
 import {ChecksTabState, ValueChangedEvent} from '../../types/events';
-import {LabelNameToInfoMap, PatchSetNumber} from '../../types/common';
+import {
+  DropdownLink,
+  LabelNameToInfoMap,
+  PatchSetNumber,
+} from '../../types/common';
 import {spinnerStyles} from '../../styles/gr-spinner-styles';
 import {
   getLabelStatus,
   getRepresentativeValue,
   valueString,
 } from '../../utils/label-util';
-import {DropdownLink} from '../shared/gr-dropdown/gr-dropdown';
 import {subscribe} from '../lit/subscription-controller';
 import {fontStyles} from '../../styles/gr-font-styles';
 import {fire} from '../../utils/event-util';
@@ -72,12 +75,9 @@
 import {changeModelToken} from '../../models/change/change-model';
 import {getAppContext} from '../../services/app-context';
 import {when} from 'lit/directives/when.js';
-import {KnownExperimentId} from '../../services/flags/flags';
-import {HtmlPatched} from '../../utils/lit-util';
 import {DropdownItem} from '../shared/gr-dropdown-list/gr-dropdown-list';
 import './gr-checks-attempt';
-import {createDiffUrl} from '../../models/views/diff';
-import {changeViewModelToken} from '../../models/views/change';
+import {createDiffUrl, changeViewModelToken} from '../../models/views/change';
 
 /**
  * Firing this event sets the regular expression of the results filter.
@@ -125,8 +125,6 @@
 
   private readonly reporting = getAppContext().reportingService;
 
-  private readonly flags = getAppContext().flagsService;
-
   constructor() {
     super();
     subscribe(
@@ -326,10 +324,18 @@
 
   override updated(changedProperties: PropertyValues) {
     if (changedProperties.has('result')) {
-      this.isExpandable = !!this.result?.summary && !!this.result?.message;
+      this.isExpandable = this.computeIsExpandable();
     }
   }
 
+  private computeIsExpandable() {
+    const hasSummary = !!this.result?.summary;
+    const hasMessage = !!this.result?.message;
+    const hasMultipleLinks = (this.result?.links ?? []).length > 1;
+    const hasPointers = (this.result?.codePointers ?? []).length > 0;
+    return hasSummary && (hasMessage || hasMultipleLinks || hasPointers);
+  }
+
   override focus() {
     if (this.nameEl) this.nameEl.focus();
   }
@@ -536,10 +542,8 @@
 
   private renderActions() {
     const actions = [...(this.result?.actions ?? [])];
-    if (this.flags.isEnabled(KnownExperimentId.CHECKS_FIXES)) {
-      const fixAction = createFixAction(this, this.result);
-      if (fixAction) actions.unshift(fixAction);
-    }
+    const fixAction = createFixAction(this, this.result);
+    if (fixAction) actions.unshift(fixAction);
     if (actions.length === 0) return;
     const overflowItems = actions.slice(2).map(action => {
       return {...action, id: action.name};
@@ -711,10 +715,9 @@
         tooltip: `${path}${rangeText}`,
         url: createDiffUrl({
           changeNum: change._number,
-          project: change.project,
-          path,
+          repo: change.project,
           patchNum: patchset,
-          lineNum: line,
+          diffView: {path, lineNum: line},
         }),
         primary: true,
       };
@@ -817,13 +820,6 @@
 
   private readonly reporting = getAppContext().reportingService;
 
-  private readonly patched = new HtmlPatched(key => {
-    this.reporting.reportInteraction(Interaction.AUTOCLOSE_HTML_PATCHED, {
-      component: this.tagName,
-      key: key.substring(0, 300),
-    });
-  });
-
   constructor() {
     super();
     subscribe(
@@ -1501,7 +1497,7 @@
           ${repeat(
             filtered,
             result => result.internalResultId,
-            (result?: RunResult) => this.patched.html`
+            (result?: RunResult) => html`
               <gr-result-row
                 class=${charsOnly(result!.checkName)}
                 .result=${result}
@@ -1533,26 +1529,23 @@
     this.requestUpdate();
   }
 
-  computeRunResults(category: Category, run: CheckRun) {
+  computeRunResults(category: Category, run: CheckRun): RunResult[] {
     if (category === Category.SUCCESS && hasCompletedWithoutResults(run)) {
       return [this.computeSuccessfulRunResult(run)];
     }
     return (
       run.results
         ?.filter(result => result.category === category)
-        .map(result => {
-          return {...run, ...result};
-        }) ?? []
+        .map(result => runResult(run, result)) ?? []
     );
   }
 
   computeSuccessfulRunResult(run: CheckRun): RunResult {
-    const adaptedRun: RunResult = {
+    const adaptedRun: RunResult = runResult(run, {
       internalResultId: run.internalRunId + '-0',
       category: Category.SUCCESS,
       summary: run.statusDescription ?? '',
-      ...run,
-    };
+    });
     if (!run.statusDescription) {
       const start = run.scheduledTimestamp ?? run.startedTimestamp;
       const end = run.finishedTimestamp;
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-results_test.ts b/polygerrit-ui/app/elements/checks/gr-checks-results_test.ts
index 934e958..385bde7 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-results_test.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-results_test.ts
@@ -117,7 +117,7 @@
           aria-checked="false"
           aria-label="Expand result row"
           class="show-hide"
-          hidden=""
+          hidden
           role="switch"
           tabindex="0"
         >
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-runs.ts b/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
index 128a9b0a..8ba9895 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
@@ -634,7 +634,7 @@
     return Object.entries(this.errorMessages).map(([plugin, message]) => {
       const msg = this.collapsed
         ? 'Error'
-        : `Error while fetching results for ${plugin}:<br />${message}`;
+        : html`Error while fetching results for ${plugin}:<br />${message}`;
       return html`
         <div class="error">
           <div class="left">
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-runs_test.ts b/polygerrit-ui/app/elements/checks/gr-checks-runs_test.ts
index 4bd3446..a858e4d 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-runs_test.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-runs_test.ts
@@ -22,6 +22,7 @@
     );
     const getChecksModel = resolve(element, checksModelToken);
     setAllFakeRuns(getChecksModel());
+    element.errorMessages = {'test-plugin-name': 'test-error-message'};
     await element.updateComplete;
   });
 
@@ -57,6 +58,17 @@
             </gr-button>
           </gr-tooltip-content>
         </h2>
+        <div class="error">
+          <div class="left">
+            <gr-icon filled="" icon="error"> </gr-icon>
+          </div>
+          <div class="right">
+            <div class="message">
+              Error while fetching results for test-plugin-name: <br />
+              test-error-message
+            </div>
+          </div>
+        </div>
         <input
           id="filterInput"
           placeholder="Filter runs by regular expression"
@@ -121,6 +133,14 @@
             </gr-button>
           </gr-tooltip-content>
         </h2>
+        <div class="error">
+          <div class="left">
+            <gr-icon filled="" icon="error"> </gr-icon>
+          </div>
+          <div class="right">
+            <div class="message">Error</div>
+          </div>
+        </div>
         <input
           hidden
           id="filterInput"
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-util.ts b/polygerrit-ui/app/elements/checks/gr-checks-util.ts
index c7477c4..f1a3fb9 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-util.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-util.ts
@@ -9,6 +9,7 @@
   AttemptChoice,
   LATEST_ATTEMPT,
 } from '../../models/checks/checks-util';
+import {fire} from '../../utils/event-util';
 
 export interface RunSelectedEventDetail {
   checkName?: string;
@@ -23,13 +24,7 @@
 }
 
 export function fireRunSelected(target: EventTarget, checkName: string) {
-  target.dispatchEvent(
-    new CustomEvent('run-selected', {
-      detail: {reset: false, checkName},
-      composed: true,
-      bubbles: true,
-    })
-  );
+  fire(target, 'run-selected', {checkName});
 }
 
 export function isAttemptSelected(
diff --git a/polygerrit-ui/app/elements/checks/gr-diff-check-result.ts b/polygerrit-ui/app/elements/checks/gr-diff-check-result.ts
index 0aca71f..efc6efe 100644
--- a/polygerrit-ui/app/elements/checks/gr-diff-check-result.ts
+++ b/polygerrit-ui/app/elements/checks/gr-diff-check-result.ts
@@ -8,13 +8,21 @@
 import {LitElement, css, html, PropertyValues, nothing} from 'lit';
 import {customElement, property, state} from 'lit/decorators.js';
 import {RunResult} from '../../models/checks/checks-model';
-import {createFixAction, iconFor} from '../../models/checks/checks-util';
+import {
+  createFixAction,
+  createPleaseFixComment,
+  iconFor,
+} from '../../models/checks/checks-util';
 import {modifierPressed} from '../../utils/dom-util';
 import './gr-checks-results';
 import './gr-hovercard-run';
 import {fontStyles} from '../../styles/gr-font-styles';
-import {KnownExperimentId} from '../../services/flags/flags';
-import {getAppContext} from '../../services/app-context';
+import {Action} from '../../api/checks';
+import {assertIsDefined} from '../../utils/common-util';
+import {resolve} from '../../models/dependency';
+import {commentsModelToken} from '../../models/comments/comments-model';
+import {subscribe} from '../lit/subscription-controller';
+import {changeModelToken} from '../../models/change/change-model';
 
 @customElement('gr-diff-check-result')
 export class GrDiffCheckResult extends LitElement {
@@ -34,7 +42,12 @@
   @state()
   isExpandable = false;
 
-  private readonly flags = getAppContext().flagsService;
+  @state()
+  isOwner = false;
+
+  private readonly getChangeModel = resolve(this, changeModelToken);
+
+  private readonly getCommentsModel = resolve(this, commentsModelToken);
 
   static override get styles() {
     return [
@@ -118,6 +131,15 @@
     ];
   }
 
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.getChangeModel().isOwner$,
+      x => (this.isOwner = x)
+    );
+  }
+
   override render() {
     if (!this.result) return;
     const cat = this.result.category.toLowerCase();
@@ -186,15 +208,39 @@
 
   private renderActions() {
     if (!this.isExpanded) return nothing;
-    return html`<div class="actions">${this.renderFixButton()}</div>`;
+    return html`<div class="actions">
+      ${this.renderPleaseFixButton()}${this.renderShowFixButton()}
+    </div>`;
   }
 
-  private renderFixButton() {
-    if (!this.flags.isEnabled(KnownExperimentId.CHECKS_FIXES)) return nothing;
+  private renderPleaseFixButton() {
+    if (this.isOwner) return nothing;
+    const action: Action = {
+      name: 'Please Fix',
+      callback: () => {
+        assertIsDefined(this.result, 'result');
+        this.getCommentsModel().saveDraft(createPleaseFixComment(this.result));
+        return undefined;
+      },
+    };
+    return html`
+      <gr-checks-action
+        id="please-fix"
+        context="diff-fix"
+        .action=${action}
+      ></gr-checks-action>
+    `;
+  }
+
+  private renderShowFixButton() {
     const action = createFixAction(this, this.result);
     if (!action) return nothing;
     return html`
-      <gr-checks-action context="diff-fix" .action=${action}></gr-checks-action>
+      <gr-checks-action
+        id="show-fix"
+        context="diff-fix"
+        .action=${action}
+      ></gr-checks-action>
     `;
   }
 
diff --git a/polygerrit-ui/app/elements/checks/gr-diff-check-result_test.ts b/polygerrit-ui/app/elements/checks/gr-diff-check-result_test.ts
index 3892c9a..0377e0e 100644
--- a/polygerrit-ui/app/elements/checks/gr-diff-check-result_test.ts
+++ b/polygerrit-ui/app/elements/checks/gr-diff-check-result_test.ts
@@ -7,6 +7,7 @@
 import {fakeRun1} from '../../models/checks/checks-fakes';
 import {RunResult} from '../../models/checks/checks-model';
 import '../../test/common-test-setup';
+import {queryAndAssert} from '../../utils/common-util';
 import './gr-diff-check-result';
 import {GrDiffCheckResult} from './gr-diff-check-result';
 
@@ -50,4 +51,30 @@
     `
     );
   });
+
+  test('renders expanded', async () => {
+    element.result = {...fakeRun1, ...fakeRun1.results?.[2]} as RunResult;
+    element.isExpanded = true;
+    await element.updateComplete;
+
+    const details = queryAndAssert(element, 'div.details');
+    assert.dom.equal(
+      details,
+      /* HTML */ `
+        <div class="details">
+          <gr-result-expanded hidecodepointers=""></gr-result-expanded>
+          <div class="actions">
+            <gr-checks-action
+              id="please-fix"
+              context="diff-fix"
+            ></gr-checks-action>
+            <gr-checks-action
+              id="show-fix"
+              context="diff-fix"
+            ></gr-checks-action>
+          </div>
+        </div>
+      `
+    );
+  });
 });
diff --git a/polygerrit-ui/app/elements/checks/gr-hovercard-run.ts b/polygerrit-ui/app/elements/checks/gr-hovercard-run.ts
index 9aa837d..e78d131 100644
--- a/polygerrit-ui/app/elements/checks/gr-hovercard-run.ts
+++ b/polygerrit-ui/app/elements/checks/gr-hovercard-run.ts
@@ -38,7 +38,6 @@
       css`
         #container {
           min-width: 356px;
-          max-width: 356px;
           padding: var(--spacing-xl) 0 var(--spacing-m) 0;
         }
         .row {
diff --git a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.ts b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.ts
index b46d2b9..2916f75 100644
--- a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.ts
+++ b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.ts
@@ -6,13 +6,10 @@
 import '../../shared/gr-dropdown/gr-dropdown';
 import '../../shared/gr-avatar/gr-avatar';
 import {getUserName} from '../../../utils/display-name-util';
-import {AccountInfo, ServerInfo} from '../../../types/common';
+import {AccountInfo, DropdownLink, ServerInfo} from '../../../types/common';
 import {getAppContext} from '../../../services/app-context';
-import {fireEvent} from '../../../utils/event-util';
-import {
-  DropdownContent,
-  DropdownLink,
-} from '../../shared/gr-dropdown/gr-dropdown';
+import {fire} from '../../../utils/event-util';
+import {DropdownContent} from '../../shared/gr-dropdown/gr-dropdown';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, css, html} from 'lit';
 import {customElement, property} from 'lit/decorators.js';
@@ -23,6 +20,9 @@
   interface HTMLElementTagNameMap {
     'gr-account-dropdown': GrAccountDropdown;
   }
+  interface HTMLElementEventMap {
+    'show-keyboard-shortcuts': CustomEvent<{}>;
+  }
 }
 
 @customElement('gr-account-dropdown')
@@ -136,7 +136,7 @@
   }
 
   _handleShortcutsTap() {
-    fireEvent(this, 'show-keyboard-shortcuts');
+    fire(this, 'show-keyboard-shortcuts', {});
   }
 
   private readonly handleLocationChange = () => {
diff --git a/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog.ts b/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog.ts
index ab65231..ca0480c9 100644
--- a/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog.ts
+++ b/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog.ts
@@ -7,11 +7,16 @@
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, html, css} from 'lit';
 import {customElement, property} from 'lit/decorators.js';
+import {fireNoBubbleNoCompose} from '../../../utils/event-util';
 
 declare global {
   interface HTMLElementTagNameMap {
     'gr-error-dialog': GrErrorDialog;
   }
+  interface HTMLElementEventMap {
+    // prettier-ignore
+    'dismiss': CustomEvent<{}>;
+  }
 }
 
 @customElement('gr-error-dialog')
@@ -86,6 +91,6 @@
   }
 
   private handleConfirm() {
-    this.dispatchEvent(new CustomEvent('dismiss'));
+    fireNoBubbleNoCompose(this, 'dismiss', {});
   }
 }
diff --git a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.ts b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.ts
index 539acfa..c8a1c39 100644
--- a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.ts
+++ b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.ts
@@ -3,21 +3,16 @@
  * Copyright 2016 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-/* Import to get Gerrit interface */
-/* TODO(taoalpha): decouple gr-gerrit from gr-js-api-interface */
 import '../gr-error-dialog/gr-error-dialog';
 import '../../shared/gr-alert/gr-alert';
-import '../../shared/gr-overlay/gr-overlay';
 import {getBaseUrl} from '../../../utils/url-util';
 import {getAppContext} from '../../../services/app-context';
-import {IronA11yAnnouncer} from '@polymer/iron-a11y-announcer/iron-a11y-announcer';
-import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
 import {GrErrorDialog} from '../gr-error-dialog/gr-error-dialog';
 import {GrAlert} from '../../shared/gr-alert/gr-alert';
-import {ErrorType, FixIronA11yAnnouncer} from '../../../types/types';
+import {ErrorType} from '../../../types/types';
 import {AccountId} from '../../../types/common';
 import {
-  EventType,
+  AuthErrorEvent,
   NetworkErrorEvent,
   ServerErrorEvent,
   ShowAlertEventDetail,
@@ -28,6 +23,10 @@
 import {fireIronAnnounce} from '../../../utils/event-util';
 import {LitElement, html} from 'lit';
 import {customElement, property, query, state} from 'lit/decorators.js';
+import {authServiceToken} from '../../../services/gr-auth/gr-auth';
+import {resolve} from '../../../models/dependency';
+import {modalStyles} from '../../../styles/gr-modal-styles';
+import {ironAnnouncerRequestAvailability} from '../../polymer-util';
 
 const HIDE_ALERT_TIMEOUT_MS = 10 * 1000;
 const CHECK_SIGN_IN_INTERVAL_MS = 60 * 1000;
@@ -100,11 +99,11 @@
 
   @state() refreshingCredentials = false;
 
-  @query('#noInteractionOverlay') noInteractionOverlay!: GrOverlay;
+  @query('#signInModal') signInModal!: HTMLDialogElement;
 
   @query('#errorDialog') errorDialog!: GrErrorDialog;
 
-  @query('#errorOverlay') errorOverlay!: GrOverlay;
+  @query('#errorModal') errorModal!: HTMLDialogElement;
 
   /**
    * The time (in milliseconds) since the most recent credential check.
@@ -119,11 +118,7 @@
 
   private readonly reporting = getAppContext().reportingService;
 
-  private readonly _authService = getAppContext().authService;
-
-  private readonly eventEmitter = getAppContext().eventEmitter;
-
-  private authErrorHandlerDeregistrationHook?: Function;
+  private readonly getAuthService = resolve(this, authServiceToken);
 
   private readonly restApiService = getAppContext().restApiService;
 
@@ -131,37 +126,23 @@
 
   override connectedCallback() {
     super.connectedCallback();
-    document.addEventListener(EventType.SERVER_ERROR, this.handleServerError);
-    document.addEventListener(EventType.NETWORK_ERROR, this.handleNetworkError);
-    document.addEventListener(EventType.SHOW_ALERT, this.handleShowAlert);
+    document.addEventListener('server-error', this.handleServerError);
+    document.addEventListener('network-error', this.handleNetworkError);
+    document.addEventListener('show-alert', this.handleShowAlert);
     document.addEventListener('hide-alert', this.hideAlert);
     document.addEventListener('show-error', this.handleShowErrorDialog);
     document.addEventListener('visibilitychange', this.handleVisibilityChange);
     document.addEventListener('show-auth-required', this.handleAuthRequired);
+    document.addEventListener('auth-error', this.handleAuthError);
 
-    this.authErrorHandlerDeregistrationHook = this.eventEmitter.on(
-      'auth-error',
-      event => {
-        this.handleAuthError(event.message, event.action);
-      }
-    );
-
-    (
-      IronA11yAnnouncer as unknown as FixIronA11yAnnouncer
-    ).requestAvailability();
+    ironAnnouncerRequestAvailability();
   }
 
   override disconnectedCallback() {
     this.clearHideAlertHandle();
-    document.removeEventListener(
-      EventType.SERVER_ERROR,
-      this.handleServerError
-    );
-    document.removeEventListener(
-      EventType.NETWORK_ERROR,
-      this.handleNetworkError
-    );
-    document.removeEventListener(EventType.SHOW_ALERT, this.handleShowAlert);
+    document.removeEventListener('server-error', this.handleServerError);
+    document.removeEventListener('network-error', this.handleNetworkError);
+    document.removeEventListener('show-alert', this.handleShowAlert);
     document.removeEventListener('hide-alert', this.hideAlert);
     document.removeEventListener('show-error', this.handleShowErrorDialog);
     document.removeEventListener(
@@ -171,30 +152,45 @@
     document.removeEventListener('show-auth-required', this.handleAuthRequired);
     this.checkLoggedInTask?.cancel();
 
-    if (this.authErrorHandlerDeregistrationHook) {
-      this.authErrorHandlerDeregistrationHook();
-    }
+    document.removeEventListener('auth-error', this.handleAuthError);
     super.disconnectedCallback();
   }
 
+  static override get styles() {
+    return [modalStyles];
+  }
+
   override render() {
     return html`
-      <gr-overlay with-backdrop="" id="errorOverlay">
+      <dialog id="errorModal" tabindex="-1">
         <gr-error-dialog
           id="errorDialog"
-          @dismiss=${() => this.errorOverlay.close()}
+          @dismiss=${() => this.errorModal.close()}
           .loginUrl=${this.loginUrl}
           .loginText=${this.loginText}
         ></gr-error-dialog>
-      </gr-overlay>
-      <gr-overlay
-        id="noInteractionOverlay"
-        with-backdrop=""
-        always-on-top=""
-        no-cancel-on-esc-key=""
-        no-cancel-on-outside-click=""
+      </dialog>
+      <dialog
+        id="signInModal"
+        @keydown=${(e: KeyboardEvent) => {
+          if (e.key === 'Escape') {
+            e.preventDefault();
+            e.stopPropagation();
+          }
+        }}
+        tabindex="-1"
       >
-      </gr-overlay>
+        <gr-dialog
+          id="signInDialog"
+          confirm-label="Sign In"
+          @confirm=${() => {
+            this.createLoginPopup();
+          }}
+          cancel-label=""
+        >
+          <div class="header" slot="header">Refresh Credentials</div>
+        </gr-dialog>
+      </dialog>
     `;
   }
 
@@ -209,11 +205,10 @@
     );
   };
 
-  private handleAuthError(msg: string, action: string) {
-    this.noInteractionOverlay.open().then(() => {
-      this.showAuthErrorAlert(msg, action);
-    });
-  }
+  private handleAuthError = (event: AuthErrorEvent) => {
+    this.signInModal.showModal();
+    this.showAuthErrorAlert(event.detail.message, event.detail.action);
+  };
 
   private readonly handleServerError = (e: ServerErrorEvent) => {
     const {request, response} = e.detail;
@@ -222,7 +217,7 @@
       const {status, statusText} = response;
       if (
         response.status === 403 &&
-        !this._authService.isAuthed &&
+        !this.getAuthService().isAuthed &&
         errorText === AUTHENTICATION_REQUIRED
       ) {
         // if not authed previously, this is trying to access auth required APIs
@@ -230,13 +225,13 @@
         this.handleAuthRequired();
       } else if (
         response.status === 403 &&
-        this._authService.isAuthed &&
+        this.getAuthService().isAuthed &&
         errorText === AUTHENTICATION_REQUIRED
       ) {
         // The app was logged at one point and is now getting auth errors.
         // This indicates the auth token may no longer valid.
         // Re-check on auth
-        this._authService.clearCache();
+        this.getAuthService().clearCache();
         this.restApiService.getLoggedIn();
       } else if (!this.shouldSuppressError(errorText)) {
         const trace =
@@ -358,7 +353,7 @@
     el.show(text, actionText, actionCallback);
     this.alertElement = el;
     fireIronAnnounce(this, `Alert: ${text}`);
-    this.reporting.reportInteraction(EventType.SHOW_ALERT, {text});
+    this.reporting.reportInteraction('show-alert', {text});
   }
 
   private readonly hideAlert = () => {
@@ -449,7 +444,7 @@
 
     // force to refetch account info
     this.restApiService.invalidateAccountsCache();
-    this._authService.clearCache();
+    this.getAuthService().clearCache();
 
     this.restApiService.getLoggedIn().then(isLoggedIn => {
       if (!this.refreshingCredentials) return;
@@ -508,10 +503,10 @@
     this.refreshingCredentials = false;
     this.hideAlert();
     this._showAlert('Credentials refreshed.');
-    this.noInteractionOverlay.close();
+    this.signInModal.close();
 
     // Clear the cache for auth
-    this._authService.clearCache();
+    this.getAuthService().clearCache();
   }
 
   private readonly handleWindowFocus = () => {
@@ -527,7 +522,10 @@
     this.reporting.reportErrorDialog(message);
     this.errorDialog.text = message;
     this.errorDialog.showSignInButton = !!options && !!options.showSignInButton;
-    this.errorOverlay.open();
+    if (this.errorModal.hasAttribute('open')) {
+      this.errorModal.close();
+    }
+    this.errorModal.showModal();
   }
 }
 
diff --git a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.ts b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.ts
index e0de507..fff69ef 100644
--- a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.ts
+++ b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.ts
@@ -11,7 +11,6 @@
   __testOnly_ErrorType,
 } from './gr-error-manager';
 import {
-  stubAuth,
   stubReporting,
   stubRestApi,
   waitEventLoop,
@@ -25,7 +24,8 @@
 import {waitUntil} from '../../../test/test-utils';
 import {fixture, assert} from '@open-wc/testing';
 import {html} from 'lit';
-import {EventType} from '../../../types/events';
+import {testResolver} from '../../../test/common-test-setup';
+import {authServiceToken} from '../../../services/gr-auth/gr-auth';
 
 suite('gr-error-manager tests', () => {
   let element: GrErrorManager;
@@ -37,9 +37,9 @@
     let appContext: AppContext;
 
     setup(async () => {
-      fetchStub = stubAuth('fetch').returns(
-        Promise.resolve({...new Response(), ok: true, status: 204})
-      );
+      fetchStub = sinon
+        .stub(testResolver(authServiceToken), 'fetch')
+        .returns(Promise.resolve({...new Response(), ok: true, status: 204}));
       appContext = getAppContext();
       getLoggedInStub = stubRestApi('getLoggedIn').callsFake(() =>
         appContext.authService.authCheck()
@@ -65,26 +65,19 @@
       assert.shadowDom.equal(
         element,
         /* HTML */ `
-          <gr-overlay
-            aria-hidden="true"
-            id="errorOverlay"
-            style="outline: none; display: none;"
-            tabindex="-1"
-            with-backdrop=""
-          >
+          <dialog id="errorModal" tabindex="-1">
             <gr-error-dialog id="errorDialog"> </gr-error-dialog>
-          </gr-overlay>
-          <gr-overlay
-            always-on-top=""
-            aria-hidden="true"
-            id="noInteractionOverlay"
-            no-cancel-on-esc-key=""
-            no-cancel-on-outside-click=""
-            style="outline: none; display: none;"
-            tabindex="-1"
-            with-backdrop=""
-          >
-          </gr-overlay>
+          </dialog>
+          <dialog id="signInModal" tabindex="-1">
+            <gr-dialog
+              id="signInDialog"
+              confirm-label="Sign In"
+              role="dialog"
+              cancel-label=""
+            >
+              <div class="header" slot="header">Refresh Credentials</div>
+            </gr-dialog>
+          </dialog>
         `
       );
     });
@@ -354,17 +347,11 @@
       assert.include(toast.shadowRoot.textContent, 'Credentials expired.');
       assert.include(toast.shadowRoot.textContent, 'Refresh credentials');
 
-      // noInteractionOverlay
-      const noInteractionOverlay = element.noInteractionOverlay;
-      assert.isOk(noInteractionOverlay);
-      const noInteractionOverlayCloseSpy = sinon.spy(
-        noInteractionOverlay,
-        'close'
-      );
-      assert.equal(
-        noInteractionOverlay.backdropElement.getAttribute('opened'),
-        ''
-      );
+      // signInModal
+      const signInModal = element.signInModal;
+      assert.isOk(signInModal);
+      const signInModalCloseSpy = sinon.spy(signInModal, 'close');
+      assert.isTrue(signInModal.hasAttribute('open'));
       assert.isFalse(windowOpen.called);
       toast.shadowRoot.querySelector('gr-button.action')!.click();
       assert.isTrue(windowOpen.called);
@@ -392,7 +379,7 @@
       assert.include(toast.shadowRoot.textContent, 'Credentials refreshed');
 
       // close overlay
-      assert.isTrue(noInteractionOverlayCloseSpy.called);
+      assert.isTrue(signInModalCloseSpy.called);
     });
 
     test('auth toast should dismiss existing toast', async () => {
@@ -403,7 +390,7 @@
 
       // fake an alert
       element.dispatchEvent(
-        new CustomEvent(EventType.SHOW_ALERT, {
+        new CustomEvent('show-alert', {
           detail: {message: 'test reload', action: 'reload'},
           composed: true,
           bubbles: true,
@@ -451,7 +438,7 @@
 
       // fake an alert
       element.dispatchEvent(
-        new CustomEvent(EventType.SHOW_ALERT, {
+        new CustomEvent('show-alert', {
           detail: {message: 'test reload', action: 'reload'},
           composed: true,
           bubbles: true,
@@ -464,7 +451,7 @@
 
       // new alert
       element.dispatchEvent(
-        new CustomEvent(EventType.SHOW_ALERT, {
+        new CustomEvent('show-alert', {
           detail: {message: 'second-test', action: 'reload'},
           composed: true,
           bubbles: true,
@@ -510,7 +497,7 @@
 
       // fake an alert
       element.dispatchEvent(
-        new CustomEvent(EventType.SHOW_ALERT, {
+        new CustomEvent('show-alert', {
           detail: {
             message: 'test-alert',
             action: 'reload',
@@ -531,7 +518,7 @@
       const alertObj = {message: 'foo'};
       const showAlertStub = sinon.stub(element, '_showAlert');
       element.dispatchEvent(
-        new CustomEvent(EventType.SHOW_ALERT, {
+        new CustomEvent('show-alert', {
           detail: alertObj,
           composed: true,
           bubbles: true,
@@ -593,8 +580,8 @@
     });
 
     test('show-error', async () => {
-      const openStub = sinon.stub(element.errorOverlay, 'open');
-      const closeStub = sinon.stub(element.errorOverlay, 'close');
+      const openStub = sinon.stub(element.errorModal, 'showModal');
+      const closeStub = sinon.stub(element.errorModal, 'close');
       const reportStub = stubReporting('reportErrorDialog');
 
       const message = 'test message';
diff --git a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.ts b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.ts
index 25173b4..a359072 100644
--- a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.ts
+++ b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.ts
@@ -16,6 +16,7 @@
   ShortcutViewListener,
 } from '../../../services/shortcuts/shortcuts-service';
 import {resolve} from '../../../models/dependency';
+import {fireNoBubble} from '../../../utils/event-util';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -61,7 +62,6 @@
           display: block;
           max-height: 100vh;
           min-width: 60vw;
-          overflow-y: auto;
         }
         main {
           display: flex;
@@ -163,12 +163,7 @@
   private handleCloseTap(e: MouseEvent) {
     e.preventDefault();
     e.stopPropagation();
-    this.dispatchEvent(
-      new CustomEvent('close', {
-        composed: true,
-        bubbles: false,
-      })
-    );
+    fireNoBubble(this, 'close', {});
   }
 
   onDirectoryUpdated(directory?: Map<ShortcutSection, SectionView>) {
diff --git a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.ts b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.ts
index 46e17d2..2c6de04 100644
--- a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.ts
+++ b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.ts
@@ -10,25 +10,26 @@
 import '../../shared/gr-icon/gr-icon';
 import '../gr-account-dropdown/gr-account-dropdown';
 import '../gr-smart-search/gr-smart-search';
-import {getBaseUrl, getDocsBaseUrl} from '../../../utils/url-util';
-import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
-import {getAdminLinks, NavLink} from '../../../utils/admin-nav-util';
+import {getBaseUrl} from '../../../utils/url-util';
+import {getAdminLinks, NavLink} from '../../../models/views/admin';
 import {
   AccountDetailInfo,
+  DropdownLink,
   RequireProperties,
   ServerInfo,
   TopMenuEntryInfo,
   TopMenuItemInfo,
 } from '../../../types/common';
 import {AuthType} from '../../../constants/constants';
-import {DropdownLink} from '../../shared/gr-dropdown/gr-dropdown';
 import {getAppContext} from '../../../services/app-context';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, PropertyValues, html, css} from 'lit';
 import {customElement, property, state} from 'lit/decorators.js';
-import {fireEvent} from '../../../utils/event-util';
+import {fire} from '../../../utils/event-util';
 import {resolve} from '../../../models/dependency';
 import {configModelToken} from '../../../models/config/config-model';
+import {userModelToken} from '../../../models/user/user-model';
+import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 
 type MainHeaderLink = RequireProperties<DropdownLink, 'url' | 'name'>;
 
@@ -96,13 +97,13 @@
   interface HTMLElementTagNameMap {
     'gr-main-header': GrMainHeader;
   }
+  interface HTMLElementEventMap {
+    'mobile-search': CustomEvent<{}>;
+  }
 }
 
 @customElement('gr-main-header')
 export class GrMainHeader extends LitElement {
-  @property({type: String})
-  searchQuery = '';
-
   @property({type: Boolean, reflect: true})
   loggedIn?: boolean;
 
@@ -139,15 +140,13 @@
   // private but used in test
   @state() feedbackURL = '';
 
-  @state() private serverConfig?: ServerInfo;
-
   private readonly restApiService = getAppContext().restApiService;
 
-  private readonly jsAPI = getAppContext().jsApiService;
+  private readonly getPluginLoader = resolve(this, pluginLoaderToken);
 
-  private readonly userModel = getAppContext().userModel;
+  private readonly getUserModel = resolve(this, userModelToken);
 
-  private readonly configModel = resolve(this, configModelToken);
+  private readonly getConfigModel = resolve(this, configModelToken);
 
   private subscriptions: Subscription[] = [];
 
@@ -156,8 +155,8 @@
     this.loadAccount();
 
     this.subscriptions.push(
-      this.userModel.preferences$
-        .pipe(
+      this.getUserModel()
+        .preferences$.pipe(
           map(preferences => preferences?.my ?? []),
           distinctUntilChanged()
         )
@@ -166,12 +165,11 @@
         })
     );
     this.subscriptions.push(
-      this.configModel().serverConfig$.subscribe(config => {
+      this.getConfigModel().serverConfig$.subscribe(config => {
         if (!config) return;
-        this.serverConfig = config;
         this.retrieveFeedbackURL(config);
         this.retrieveRegisterURL(config);
-        getDocsBaseUrl(config, this.restApiService).then(docBaseUrl => {
+        this.restApiService.getDocsBaseUrl(config).then(docBaseUrl => {
           this.docBaseUrl = docBaseUrl;
         });
       })
@@ -206,18 +204,22 @@
           text-decoration: underline;
         }
         .titleText::before {
+          --icon-width: var(--header-icon-width, var(--header-icon-size, 0));
+          --icon-height: var(--header-icon-height, var(--header-icon-size, 0));
           background-image: var(--header-icon);
-          background-size: var(--header-icon-size) var(--header-icon-size);
+          background-size: var(--icon-width) var(--icon-height);
           background-repeat: no-repeat;
           content: '';
           display: inline-block;
-          height: var(--header-icon-size);
-          margin-right: calc(var(--header-icon-size) / 4);
+          height: var(--icon-height);
+          /* If size or height are set, then use 'spacing-m', 0px otherwise. */
+          margin-right: clamp(0px, var(--icon-height), var(--spacing-m));
           vertical-align: text-bottom;
-          width: var(--header-icon-size);
+          width: var(--icon-width);
         }
         .titleText::after {
           content: var(--header-title-content);
+          white-space: nowrap;
         }
         ul {
           list-style: none;
@@ -370,15 +372,10 @@
         class="hideOnMobile"
         name="header-small-banner"
       ></gr-endpoint-decorator>
-      <gr-smart-search
-        id="search"
-        label="Search for changes"
-        .searchQuery=${this.searchQuery}
-        .serverConfig=${this.serverConfig}
-      ></gr-smart-search>
+      <gr-smart-search id="search"></gr-smart-search>
       <gr-endpoint-decorator
         class="hideOnMobile"
-        name="header-browse-source"
+        name="header-top-right"
       ></gr-endpoint-decorator>
       <gr-endpoint-decorator class="feedbackButton" name="header-feedback">
         ${this.renderFeedback()}
@@ -575,7 +572,7 @@
     return Promise.all([
       this.restApiService.getAccount(),
       this.restApiService.getTopMenus(),
-      getPluginLoader().awaitPluginsLoaded(),
+      this.getPluginLoader().awaitPluginsLoaded(),
     ]).then(result => {
       const account = result[0];
       this.account = account;
@@ -592,7 +589,7 @@
             }
             return capabilities;
           }),
-        () => this.jsAPI.getAdminMenuLinks()
+        () => this.getPluginLoader().jsApiService.getAdminMenuLinks()
       ).then(res => {
         this.adminLinks = res.links;
       });
@@ -639,6 +636,6 @@
   private onMobileSearchTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
-    fireEvent(this, 'mobile-search');
+    fire(this, 'mobile-search', {});
   }
 }
diff --git a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.ts b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.ts
index 7eb19f0..955846f 100644
--- a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.ts
+++ b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.ts
@@ -17,7 +17,7 @@
   createGerritInfo,
   createServerInfo,
 } from '../../../test/test-data-generators';
-import {NavLink} from '../../../utils/admin-nav-util';
+import {NavLink} from '../../../models/views/admin';
 import {ServerInfo, TopMenuItemInfo} from '../../../types/common';
 import {AuthType} from '../../../constants/constants';
 import {fixture, html, assert} from '@open-wc/testing';
@@ -61,12 +61,8 @@
               name="header-small-banner"
             >
             </gr-endpoint-decorator>
-            <gr-smart-search id="search" label="Search for changes">
-            </gr-smart-search>
-            <gr-endpoint-decorator
-              class="hideOnMobile"
-              name="header-browse-source"
-            >
+            <gr-smart-search id="search"> </gr-smart-search>
+            <gr-endpoint-decorator class="hideOnMobile" name="header-top-right">
             </gr-endpoint-decorator>
             <gr-endpoint-decorator
               class="feedbackButton"
@@ -162,7 +158,6 @@
       {
         name: 'Repos',
         url: '/repos',
-        noBaseUrl: true,
         view: undefined,
       },
     ];
@@ -236,7 +231,6 @@
       {
         name: 'Repos',
         url: '/repos',
-        noBaseUrl: true,
         view: undefined,
       },
     ];
@@ -283,7 +277,6 @@
       {
         name: 'Repos',
         url: '/repos',
-        noBaseUrl: true,
         view: undefined,
       },
     ];
@@ -335,7 +328,6 @@
       {
         name: 'Repos',
         url: '/repos',
-        noBaseUrl: true,
         view: undefined,
       },
     ];
@@ -497,7 +489,6 @@
       {
         name: 'Repos',
         url: '/repos',
-        noBaseUrl: true,
         view: undefined,
       },
     ];
diff --git a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts
index 4af24cc..94241ea 100644
--- a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts
+++ b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts
@@ -26,4 +26,22 @@
    * page.redirect() eventually just calls `window.history.replaceState()`.
    */
   replaceUrl(url: string): void;
+
+  /**
+   * You can ask the router to block all navigation to other pages for a while,
+   * e.g. while there are unsaved comments. You must make sure to call
+   * `releaseNavigation()` with the same string shortly after to unblock the
+   * router.
+   *
+   * The provided reason must be non-empty.
+   */
+  blockNavigation(reason: string): void;
+
+  /**
+   * See `blockNavigation()`.
+   *
+   * This API is not counting. If you block navigation with the same reason
+   * multiple times, then one release call will unblock.
+   */
+  releaseNavigation(reason: string): void;
 }
diff --git a/polygerrit-ui/app/elements/core/gr-notifications-prompt/gr-notifications-prompt.ts b/polygerrit-ui/app/elements/core/gr-notifications-prompt/gr-notifications-prompt.ts
new file mode 100644
index 0000000..ab95711
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-notifications-prompt/gr-notifications-prompt.ts
@@ -0,0 +1,138 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {LitElement, html, css, nothing} from 'lit';
+import {customElement, state} from 'lit/decorators.js';
+import {resolve} from '../../../models/dependency';
+import {serviceWorkerInstallerToken} from '../../../services/service-worker-installer';
+import {subscribe} from '../../lit/subscription-controller';
+import {fontStyles} from '../../../styles/gr-font-styles';
+import {navigationToken} from '../gr-navigation/gr-navigation';
+import {createSettingsUrl} from '../../../models/views/settings';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-icon/gr-icon';
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-notifications-prompt': GrNotificationsPrompt;
+  }
+}
+
+@customElement('gr-notifications-prompt')
+export class GrNotificationsPrompt extends LitElement {
+  @state() private hideNotificationsPrompt = false;
+
+  @state() private shouldShowPrompt = false;
+
+  private readonly serviceWorkerInstaller = resolve(
+    this,
+    serviceWorkerInstallerToken
+  );
+
+  private readonly getNavigation = resolve(this, navigationToken);
+
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.serviceWorkerInstaller().shouldShowPrompt$,
+      shouldShowPrompt => {
+        this.shouldShowPrompt = !!shouldShowPrompt;
+      }
+    );
+  }
+
+  static override get styles() {
+    return [
+      fontStyles,
+      css`
+        #notificationsPrompt {
+          position: absolute;
+          right: 30px;
+          top: 50px;
+          z-index: 150; /* Less than gr-hovercard's, higher than rest */
+          display: flex;
+          background-color: var(--background-color-primary);
+          padding: var(--spacing-l);
+          border: 1px solid var(--border-color);
+          border-radius: var(--border-radius);
+          box-shadow: var(--elevation-level-5);
+        }
+        h3 {
+          margin: 0;
+          padding: 0;
+        }
+        .icon {
+          flex: 0 0 30px;
+        }
+        .content {
+          width: 300px;
+        }
+        div.section {
+          margin: 0 var(--spacing-xl) var(--spacing-m) var(--spacing-xl);
+          display: flex;
+          align-items: center;
+        }
+        div.sectionIcon {
+          flex: 0 0 30px;
+        }
+        .message {
+          margin: var(--spacing-m) 0;
+        }
+        div.sectionIcon gr-icon {
+          position: relative;
+        }
+        b {
+          font-weight: var(--font-weight-bold);
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    if (this.hideNotificationsPrompt) return nothing;
+    if (!this.shouldShowPrompt) return nothing;
+    return html`<div id="notificationsPrompt" role="dialog">
+      <div class="icon">
+        <gr-icon icon="info"></gr-icon>
+      </div>
+      <div class="content">
+        <h3 class="heading-3">Missing your turn notifications?</h3>
+        <div class="message">
+          Get notified whenever it's your turn on a change. Gerrit needs
+          permission to send notifications. To turn on notifications, click
+          <b>Continue</b> and then <b>Allow</b> when prompted by your browser.
+        </div>
+        <div class="buttons">
+          <gr-button
+            primary=""
+            @click=${() => {
+              this.hideNotificationsPrompt = true;
+              this.serviceWorkerInstaller().requestPermission();
+            }}
+            >Continue</gr-button
+          >
+          <gr-button
+            @click=${() => {
+              this.hideNotificationsPrompt = true;
+              this.getNavigation().setUrl(createSettingsUrl());
+            }}
+            >Disable in settings</gr-button
+          >
+        </div>
+      </div>
+      <div class="icon">
+        <gr-button
+          @click=${() => {
+            this.hideNotificationsPrompt = true;
+          }}
+          link
+        >
+          <gr-icon icon="close"></gr-icon>
+        </gr-button>
+      </div>
+    </div>`;
+  }
+}
diff --git a/polygerrit-ui/app/elements/core/gr-notifications-prompt/gr-notifications-prompt_test.ts b/polygerrit-ui/app/elements/core/gr-notifications-prompt/gr-notifications-prompt_test.ts
new file mode 100644
index 0000000..d06b405
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-notifications-prompt/gr-notifications-prompt_test.ts
@@ -0,0 +1,92 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-notifications-prompt';
+import {GrNotificationsPrompt} from './gr-notifications-prompt';
+import {fixture, html, assert} from '@open-wc/testing';
+import {getAppContext} from '../../../services/app-context';
+import {testResolver} from '../../../test/common-test-setup';
+import {
+  ServiceWorkerInstaller,
+  serviceWorkerInstallerToken,
+} from '../../../services/service-worker-installer';
+import {waitUntilObserved} from '../../../test/test-utils';
+import {createDefaultPreferences} from '../../../constants/constants';
+import {userModelToken} from '../../../models/user/user-model';
+
+suite('gr-notifications-prompt tests', () => {
+  let element: GrNotificationsPrompt;
+  let serviceWorkerInstaller: ServiceWorkerInstaller;
+
+  setup(async () => {
+    sinon
+      .stub(window.navigator.serviceWorker, 'register')
+      .returns(Promise.resolve({} as ServiceWorkerRegistration));
+    const flagsService = getAppContext().flagsService;
+    sinon.stub(flagsService, 'isEnabled').returns(true);
+    const userModel = testResolver(userModelToken);
+    const prefs = {
+      ...createDefaultPreferences(),
+      allow_browser_notifications: true,
+    };
+    userModel.setPreferences(prefs);
+    await waitUntilObserved(
+      userModel.preferences$,
+      pref => pref.allow_browser_notifications === true
+    );
+    await waitUntilObserved(
+      userModel.preferences$,
+      pref => pref.allow_browser_notifications === true
+    );
+    serviceWorkerInstaller = testResolver(serviceWorkerInstallerToken);
+    // Since we cannot stub Notification.permission, we stub shouldShowPrompt.
+    sinon.stub(serviceWorkerInstaller, 'shouldShowPrompt').returns(true);
+    element = await fixture(
+      html`<gr-notifications-prompt></gr-notifications-prompt>`
+    );
+    await waitUntilObserved(
+      serviceWorkerInstaller.shouldShowPrompt$,
+      shouldShowPrompt => shouldShowPrompt === true
+    );
+    await element.updateComplete;
+  });
+
+  test('renders', () => {
+    assert.shadowDom.equal(
+      element, // cannot format with HTML because test will not pass.
+      `<div id="notificationsPrompt" role="dialog">
+        <div class="icon"><gr-icon icon="info"> </gr-icon></div>
+        <div class="content">
+          <h3 class="heading-3">Missing your turn notifications?</h3>
+          <div class="message">
+            Get notified whenever it's your turn on a change. Gerrit needs
+          permission to send notifications. To turn on notifications, click
+            <b> Continue </b> and then <b> Allow </b>
+            when prompted by your browser.
+          </div>
+          <div class="buttons">
+            <gr-button
+              aria-disabled="false"
+              primary=""
+              role="button"
+              tabindex="0"
+            >
+              Continue
+            </gr-button>
+            <gr-button aria-disabled="false" role="button" tabindex="0">
+              Disable in settings
+            </gr-button>
+          </div>
+        </div>
+        <div class="icon">
+          <gr-button aria-disabled="false" link="" role="button" tabindex="0">
+            <gr-icon icon="close"> </gr-icon>
+          </gr-button>
+        </div>
+      </div>`
+    );
+  });
+});
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-page.ts b/polygerrit-ui/app/elements/core/gr-router/gr-page.ts
new file mode 100644
index 0000000..1d2a272
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-page.ts
@@ -0,0 +1,376 @@
+/**
+ * @license
+ * Copyright 2023 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+/**
+ * This file was originally a copy of https://github.com/visionmedia/page.js.
+ * It was converted to TypeScript and stripped off lots of code that we don't
+ * need in Gerrit. Thus we reproduce the original LICENSE in js_licenses.txt.
+ */
+
+/**
+ * This is what registered routes have to provide, see `registerRoute()` and
+ * `registerExitRoute()`.
+ * `context` provides information about the matched parameters in the URL.
+ * Then you can decide to handle the route exclusively (not calling `next()`),
+ * or to pass it on to other registered routes. Normally you would not call
+ * `next()`, because your regex matching the URL was specific enough.
+ */
+export type PageCallback = (
+  context: PageContext,
+  next: PageNextCallback
+) => void;
+
+/** See comment on `PageCallback` above. */
+export type PageNextCallback = () => void;
+
+/** Options for starting the router. */
+export interface PageOptions {
+  /**
+   * Should the router inspect the current URL and dispatch it when the router
+   * is started? Default is `true`, but can be turned off for testing.
+   */
+  dispatch: boolean;
+
+  /**
+   * The base path of the application. For Gerrit this must be set to
+   * getBaseUrl().
+   */
+  base: string;
+}
+
+/**
+ * The browser `History` API allows `pushState()` to contain an arbitrary state
+ * object. Our router only sets `path` on the state and inspects it when
+ * handling `popstate` events. This interface is internal only.
+ */
+interface PageState {
+  path?: string;
+}
+
+const clickEvent = document.ontouchstart ? 'touchstart' : 'click';
+
+export class Page {
+  /**
+   * When a new URL is dispatched all these routes are called one after another.
+   * If a route decides that it wants to handle a URL, then it does not call
+   * next().
+   */
+  private entryRoutes: PageCallback[] = [];
+
+  /**
+   * Before a new URL is dispatched exit routes for the previous URL are called.
+   * They can clean up some state for example. But they could also prevent the
+   * user from navigating away (from within the app), if they don't call next().
+   */
+  private exitRoutes: PageCallback[] = [];
+
+  /**
+   * The path that is currently being dispatched. This is used, so that we can
+   * check whether a context is still valid, i.e. ctx.path === currentPath.
+   */
+  private currentPath = '';
+
+  /**
+   * The base path of the application. For Gerrit this must be set to
+   * getBaseUrl(). For example https://gerrit.wikimedia.org/ uses r/ as its
+   * base path.
+   */
+  private base = '';
+
+  /**
+   * Is set at the beginning of start() and stop(), so that you cannot start
+   * the routing twice.
+   */
+  private running = false;
+
+  /**
+   * Keeping around the previous context for being able to call exit routes
+   * after creating a new context.
+   */
+  private prevPageContext?: PageContext;
+
+  /**
+   * We don't want to handle popstate events before the document is loaded.
+   */
+  private documentLoaded = false;
+
+  start(options: PageOptions = {dispatch: true, base: ''}) {
+    if (this.running) return;
+    this.running = true;
+    this.base = options.base;
+
+    window.document.addEventListener(clickEvent, this.clickHandler);
+    window.addEventListener('load', this.loadHandler);
+    window.addEventListener('popstate', this.popStateHandler);
+    if (document.readyState === 'complete') this.documentLoaded = true;
+
+    if (options.dispatch) {
+      const loc = window.location;
+      this.replace(loc.pathname + loc.search + loc.hash);
+    }
+  }
+
+  stop() {
+    if (!this.running) return;
+    this.currentPath = '';
+    this.running = false;
+
+    window.document.removeEventListener(clickEvent, this.clickHandler);
+    window.removeEventListener('popstate', this.popStateHandler);
+    window.removeEventListener('load', this.loadHandler);
+  }
+
+  show(path: string, push = true) {
+    const ctx = new PageContext(path, {}, this.base);
+    const prev = this.prevPageContext;
+    this.prevPageContext = ctx;
+    this.currentPath = ctx.path;
+    this.dispatch(ctx, prev);
+    if (push && !ctx.preventPush) ctx.pushState();
+  }
+
+  redirect(to: string) {
+    setTimeout(() => this.replace(to), 0);
+  }
+
+  replace(path: string, state: PageState = {}, dispatch = true) {
+    const ctx = new PageContext(path, state, this.base);
+    const prev = this.prevPageContext;
+    this.prevPageContext = ctx;
+    this.currentPath = ctx.path;
+    ctx.replaceState(); // replace before dispatching, which may redirect
+    if (dispatch) this.dispatch(ctx, prev);
+  }
+
+  dispatch(ctx: PageContext, prev?: PageContext) {
+    let j = 0;
+    const nextExit = () => {
+      const fn = this.exitRoutes[j++];
+      // First call the exit routes of the previous context. Then proceed
+      // to the entry routes for the new context.
+      if (!fn) {
+        nextEnter();
+        return;
+      }
+      fn(prev!, nextExit);
+    };
+
+    let i = 0;
+    const nextEnter = () => {
+      const fn = this.entryRoutes[i++];
+
+      // Concurrency protection. The context is not valid anymore.
+      // Stop calling any further route handlers.
+      if (ctx.path !== this.currentPath) {
+        ctx.preventPush = true;
+        return;
+      }
+
+      // You must register a route that handles everything (.*) and does not
+      // call next().
+      if (!fn) throw new Error('No route has handled the URL.');
+
+      fn(ctx, nextEnter);
+    };
+
+    if (prev) {
+      nextExit();
+    } else {
+      nextEnter();
+    }
+  }
+
+  registerRoute(re: RegExp, fn: PageCallback) {
+    this.entryRoutes.push(createRoute(re, fn));
+  }
+
+  registerExitRoute(re: RegExp, fn: PageCallback) {
+    this.exitRoutes.push(createRoute(re, fn));
+  }
+
+  loadHandler = () => {
+    setTimeout(() => (this.documentLoaded = true), 0);
+  };
+
+  clickHandler = (e: MouseEvent | TouchEvent) => {
+    if ((e as MouseEvent).button !== 0) return;
+    if (e.metaKey || e.ctrlKey || e.shiftKey) return;
+    if (e.defaultPrevented) return;
+
+    let el = e.target as HTMLAnchorElement;
+    const eventPath = e.composedPath();
+    if (eventPath) {
+      for (let i = 0; i < eventPath.length; i++) {
+        const pathEl = eventPath[i] as HTMLAnchorElement;
+        if (!pathEl.nodeName) continue;
+        if (pathEl.nodeName.toUpperCase() !== 'A') continue;
+        if (!pathEl.href) continue;
+
+        el = pathEl;
+        break;
+      }
+    }
+
+    while (el && 'A' !== el.nodeName.toUpperCase())
+      el = el.parentNode as HTMLAnchorElement;
+    if (!el || 'A' !== el.nodeName.toUpperCase()) return;
+
+    if (el.hasAttribute('download') || el.getAttribute('rel') === 'external')
+      return;
+    const link = el.getAttribute('href');
+    if (samePath(el) && (el.hash || '#' === link)) return;
+    if (link && link.indexOf('mailto:') > -1) return;
+    if (el.target) return;
+    if (!sameOrigin(el.href)) return;
+
+    let path = el.pathname + el.search + (el.hash ?? '');
+    path = path[0] !== '/' ? '/' + path : path;
+
+    const orig = path;
+    if (path.indexOf(this.base) === 0) {
+      path = path.substr(this.base.length);
+    }
+    if (this.base && orig === path && window.location.protocol !== 'file:') {
+      return;
+    }
+    e.preventDefault();
+    this.show(orig);
+  };
+
+  popStateHandler = (e: PopStateEvent) => {
+    if (!this.documentLoaded) return;
+    if (e.state) {
+      const path = e.state.path;
+      this.replace(path, e.state);
+    } else {
+      const loc = window.location;
+      this.show(loc.pathname + loc.search + loc.hash, /* push */ false);
+    }
+  };
+}
+
+function sameOrigin(href: string) {
+  if (!href) return false;
+  const url = new URL(href, window.location.toString());
+  const loc = window.location;
+  return (
+    loc.protocol === url.protocol &&
+    loc.hostname === url.hostname &&
+    loc.port === url.port
+  );
+}
+
+function samePath(url: HTMLAnchorElement) {
+  const loc = window.location;
+  return url.pathname === loc.pathname && url.search === loc.search;
+}
+
+function escapeRegExp(s: string) {
+  return s.replace(/([.+*?=^!:${}()[\]|/\\])/g, '\\$1');
+}
+
+function decodeURIComponentString(val: string | undefined | null) {
+  if (!val) return '';
+  return decodeURIComponent(val.replace(/\+/g, ' '));
+}
+
+export class PageContext {
+  /**
+   * Includes everything: base, path, query and hash.
+   * NOT decoded.
+   */
+  canonicalPath = '';
+
+  /**
+   * Does not include base path.
+   * Does not include hash.
+   * Includes query string.
+   * NOT decoded.
+   */
+  path = '';
+
+  /** Decoded. Does not include hash. */
+  querystring = '';
+
+  /** Decoded. */
+  hash = '';
+
+  /**
+   * Regular expression matches of capturing groups. The first entry params[0]
+   * corresponds to the first capturing group. The entire matched string is not
+   * returned in this array.
+   * Each param is double decoded.
+   */
+  params: string[] = [];
+
+  /**
+   * Prevents `show()` from eventually calling `pushState()`. For example if
+   * the current context is not "valid" anymore, i.e. the URL has changed in the
+   * meantime.
+   *
+   * This is router internal state. Do not use it from routes.
+   */
+  preventPush = false;
+
+  private title = '';
+
+  constructor(
+    path: string,
+    private readonly state: PageState = {},
+    pageBase = ''
+  ) {
+    this.title = window.document.title;
+
+    if ('/' === path[0] && 0 !== path.indexOf(pageBase)) path = pageBase + path;
+    this.canonicalPath = path;
+    const re = new RegExp('^' + escapeRegExp(pageBase));
+    this.path = path.replace(re, '') || '/';
+    this.state.path = path;
+
+    const i = path.indexOf('?');
+    this.querystring =
+      i !== -1 ? decodeURIComponentString(path.slice(i + 1)) : '';
+
+    // Does the path include a hash? If yes, then remove it from path and
+    // querystring.
+    if (this.path.indexOf('#') === -1) return;
+    const parts = this.path.split('#');
+    this.path = parts[0];
+    this.hash = decodeURIComponentString(parts[1]) || '';
+    this.querystring = this.querystring.split('#')[0];
+  }
+
+  pushState() {
+    window.history.pushState(this.state, this.title, this.canonicalPath);
+  }
+
+  replaceState() {
+    window.history.replaceState(this.state, this.title, this.canonicalPath);
+  }
+
+  match(re: RegExp) {
+    const qsIndex = this.path.indexOf('?');
+    const pathname = qsIndex !== -1 ? this.path.slice(0, qsIndex) : this.path;
+    const matches = re.exec(decodeURIComponent(pathname));
+    if (matches) {
+      this.params = matches
+        .slice(1)
+        .map(match => decodeURIComponentString(match));
+    }
+    return !!matches;
+  }
+}
+
+function createRoute(re: RegExp, fn: Function) {
+  return (ctx: PageContext, next: Function) => {
+    const matches = ctx.match(re);
+    if (matches) {
+      fn(ctx, next);
+    } else {
+      next();
+    }
+  };
+}
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-page_test.ts b/polygerrit-ui/app/elements/core/gr-router/gr-page_test.ts
new file mode 100644
index 0000000..d194bf55
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-page_test.ts
@@ -0,0 +1,118 @@
+/**
+ * @license
+ * Copyright 2023 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import {html, assert, fixture, waitUntil} from '@open-wc/testing';
+import './gr-router';
+import {Page, PageContext} from './gr-page';
+
+suite('gr-page tests', () => {
+  let page: Page;
+
+  setup(() => {
+    page = new Page();
+    page.start({dispatch: false, base: ''});
+  });
+
+  teardown(() => {
+    page.stop();
+  });
+
+  test('click handler', async () => {
+    const spy = sinon.spy();
+    page.registerRoute(/\/settings/, spy);
+    const link = await fixture<HTMLAnchorElement>(
+      html`<a href="/settings"></a>`
+    );
+    link.click();
+    assert.isTrue(spy.calledOnce);
+  });
+
+  test('register route and exit', () => {
+    const handleA = sinon.spy();
+    const handleAExit = sinon.stub();
+    page.registerRoute(/\/A/, handleA);
+    page.registerExitRoute(/\/A/, handleAExit);
+
+    page.show('/A');
+    assert.equal(handleA.callCount, 1);
+    assert.equal(handleAExit.callCount, 0);
+
+    page.show('/B');
+    assert.equal(handleA.callCount, 1);
+    assert.equal(handleAExit.callCount, 1);
+  });
+
+  test('register, show, replace', () => {
+    const handleA = sinon.spy();
+    const handleB = sinon.spy();
+    page.registerRoute(/\/A/, handleA);
+    page.registerRoute(/\/B/, handleB);
+
+    page.show('/A');
+    assert.equal(handleA.callCount, 1);
+    assert.equal(handleB.callCount, 0);
+
+    page.show('/B');
+    assert.equal(handleA.callCount, 1);
+    assert.equal(handleB.callCount, 1);
+
+    page.replace('/A');
+    assert.equal(handleA.callCount, 2);
+    assert.equal(handleB.callCount, 1);
+
+    page.replace('/B');
+    assert.equal(handleA.callCount, 2);
+    assert.equal(handleB.callCount, 2);
+  });
+
+  test('popstate browser back', async () => {
+    const handleA = sinon.spy();
+    const handleB = sinon.spy();
+    page.registerRoute(/\/A/, handleA);
+    page.registerRoute(/\/B/, handleB);
+
+    page.show('/A');
+    assert.equal(handleA.callCount, 1);
+    assert.equal(handleB.callCount, 0);
+
+    page.show('/B');
+    assert.equal(handleA.callCount, 1);
+    assert.equal(handleB.callCount, 1);
+
+    window.history.back();
+    await waitUntil(() => window.location.href.includes('/A'));
+    assert.equal(handleA.callCount, 2);
+    assert.equal(handleB.callCount, 1);
+  });
+
+  test('register pattern, check context', async () => {
+    let context: PageContext;
+    const handler = (ctx: PageContext) => (context = ctx);
+    page.registerRoute(/\/asdf\/(.*)\/qwer\/(.*)\//, handler);
+    page.stop();
+    page.start({dispatch: false, base: '/base'});
+
+    page.show('/base/asdf/1234/qwer/abcd/');
+
+    await waitUntil(() => !!context);
+    assert.equal(context!.canonicalPath, '/base/asdf/1234/qwer/abcd/');
+    assert.equal(context!.path, '/asdf/1234/qwer/abcd/');
+    assert.equal(context!.querystring, '');
+    assert.equal(context!.hash, '');
+    assert.equal(context!.params[0], '1234');
+    assert.equal(context!.params[1], 'abcd');
+
+    page.show('/asdf//qwer////?a=b#go');
+
+    await waitUntil(() => !!context);
+    assert.equal(context!.canonicalPath, '/base/asdf//qwer////?a=b#go');
+    assert.equal(context!.path, '/asdf//qwer////?a=b');
+    assert.equal(context!.querystring, 'a=b');
+    assert.equal(context!.hash, 'go');
+    assert.equal(context!.params[0], '');
+    assert.equal(context!.params[1], '//');
+  });
+});
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router.ts b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
index c676a4a..aa6bb7a4 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
@@ -3,18 +3,17 @@
  * Copyright 2016 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-import {
-  page,
-  PageContext,
-  PageNextCallback,
-} from '../../../utils/page-wrapper-utils';
+import {Page, PageOptions, PageContext, PageNextCallback} from './gr-page';
 import {NavigationService} from '../gr-navigation/gr-navigation';
 import {getAppContext} from '../../../services/app-context';
-import {convertToPatchSetNum} from '../../../utils/patch-set-util';
-import {assertIsDefined} from '../../../utils/common-util';
+import {
+  computeAllPatchSets,
+  computeLatestPatchNum,
+  convertToPatchSetNum,
+} from '../../../utils/patch-set-util';
+import {assert, assertIsDefined} from '../../../utils/common-util';
 import {
   BasePatchSetNum,
-  DashboardId,
   GroupId,
   NumericChangeId,
   RevisionPatchSetNum,
@@ -22,13 +21,15 @@
   UrlEncodedCommentId,
   PARENT,
   PatchSetNumber,
+  BranchName,
 } from '../../../types/common';
 import {AppElement, AppElementParams} from '../../gr-app-types';
 import {LocationChangeEventDetail} from '../../../types/events';
 import {GerritView, RouterModel} from '../../../services/router/router-model';
-import {firePageError} from '../../../utils/event-util';
+import {fire, fireAlert, firePageError} from '../../../utils/event-util';
 import {windowLocationReload} from '../../../utils/dom-util';
 import {
+  encodeURL,
   getBaseUrl,
   PatchRangeParams,
   toPath,
@@ -44,6 +45,7 @@
   AdminChildView,
   AdminViewModel,
   AdminViewState,
+  PLUGIN_LIST_ROUTE,
 } from '../../../models/views/admin';
 import {
   AgreementViewModel,
@@ -55,20 +57,22 @@
   RepoViewState,
 } from '../../../models/views/repo';
 import {
+  createGroupUrl,
   GroupDetailView,
   GroupViewModel,
   GroupViewState,
 } from '../../../models/views/group';
-import {DiffViewModel, DiffViewState} from '../../../models/views/diff';
 import {
+  ChangeChildView,
   ChangeViewModel,
   ChangeViewState,
-  createChangeUrl,
+  createChangeViewUrl,
+  createDiffUrl,
 } from '../../../models/views/change';
-import {EditViewModel, EditViewState} from '../../../models/views/edit';
 import {
   DashboardViewModel,
   DashboardViewState,
+  PROJECT_DASHBOARD_ROUTE,
 } from '../../../models/views/dashboard';
 import {
   SettingsViewModel,
@@ -86,13 +90,28 @@
 import {SearchViewModel, SearchViewState} from '../../../models/views/search';
 import {DashboardSection} from '../../../utils/dashboard-util';
 import {Subscription} from 'rxjs';
+import {
+  addPath,
+  findComment,
+  getPatchRangeForCommentUrl,
+  isInBaseOfPatchRange,
+} from '../../../utils/comment-util';
+import {isFileUnchanged} from '../../../embed/diff/gr-diff/gr-diff-utils';
+import {Route, ViewState} from '../../../models/views/base';
+import {Model} from '../../../models/model';
+import {
+  InteractivePromise,
+  interactivePromise,
+  timeoutPromise,
+} from '../../../utils/async-util';
 
+// TODO: Move all patterns to view model files and use the `Route` interface,
+// which will enforce using `RegExp` in its `urlPattern` property.
 const RoutePattern = {
-  ROOT: '/',
+  ROOT: /^\/$/,
 
   DASHBOARD: /^\/dashboard\/(.+)$/,
   CUSTOM_DASHBOARD: /^\/dashboard\/?$/,
-  PROJECT_DASHBOARD: /^\/p\/(.+)\/\+\/dashboard\/(.+)/,
   LEGACY_PROJECT_DASHBOARD: /^\/projects\/(.+),dashboards\/(.+)/,
 
   AGREEMENTS: /^\/settings\/agreements\/?/,
@@ -101,7 +120,9 @@
 
   // Pattern for login and logout URLs intended to be passed-through. May
   // include a return URL.
-  LOG_IN_OR_OUT: /\/log(in|out)(\/(.+))?$/,
+  // TODO: Maybe this pattern and its handler can just be removed, because
+  // passing through is what the default router would eventually do anyway.
+  LOG_IN_OR_OUT: /^\/log(in|out)(\/(.+))?$/,
 
   // Pattern for a catchall route when no other pattern is matched.
   DEFAULT: /.*/,
@@ -122,11 +143,6 @@
   // Matches /admin/groups/[uuid-]<group>,members
   GROUP_MEMBERS: /^\/admin\/groups\/(?:uuid-)?(.+),members$/,
 
-  // Matches /admin/groups[,<offset>][/].
-  GROUP_LIST_OFFSET: /^\/admin\/groups(,(\d+))?(\/)?$/,
-  GROUP_LIST_FILTER: '/admin/groups/q/filter::filter',
-  GROUP_LIST_FILTER_OFFSET: '/admin/groups/q/filter::filter,:offset',
-
   // Matches /admin/create-project
   LEGACY_CREATE_PROJECT: /^\/admin\/create-project\/?$/,
 
@@ -141,6 +157,10 @@
   // Matches /admin/repos/<repo>,commands.
   REPO_COMMANDS: /^\/admin\/repos\/(.+),commands$/,
 
+  // For creating a change, and going directly into editing mode for one file.
+  REPO_EDIT_FILE:
+    /^\/admin\/repos\/edit\/repo\/(.+)\/branch\/(.+)\/file\/(.+)$/,
+
   REPO_GENERAL: /^\/admin\/repos\/(.+),general$/,
 
   // Matches /admin/repos/<repos>,access.
@@ -149,32 +169,21 @@
   // Matches /admin/repos/<repos>,access.
   REPO_DASHBOARDS: /^\/admin\/repos\/(.+),dashboards$/,
 
-  // Matches /admin/repos[,<offset>][/].
-  REPO_LIST_OFFSET: /^\/admin\/repos(,(\d+))?(\/)?$/,
-  REPO_LIST_FILTER: '/admin/repos/q/filter::filter',
-  REPO_LIST_FILTER_OFFSET: '/admin/repos/q/filter::filter,:offset',
-
-  // Matches /admin/repos/<repo>,branches[,<offset>].
-  BRANCH_LIST_OFFSET: /^\/admin\/repos\/(.+),branches(,(.+))?$/,
-  BRANCH_LIST_FILTER: '/admin/repos/:repo,branches/q/filter::filter',
-  BRANCH_LIST_FILTER_OFFSET:
-    '/admin/repos/:repo,branches/q/filter::filter,:offset',
-
-  // Matches /admin/repos/<repo>,tags[,<offset>].
-  TAG_LIST_OFFSET: /^\/admin\/repos\/(.+),tags(,(.+))?$/,
-  TAG_LIST_FILTER: '/admin/repos/:repo,tags/q/filter::filter',
-  TAG_LIST_FILTER_OFFSET: '/admin/repos/:repo,tags/q/filter::filter,:offset',
-
   PLUGINS: /^\/plugins\/(.+)$/,
 
-  PLUGIN_LIST: /^\/admin\/plugins(\/)?$/,
+  // Matches /admin/plugins with optional filter and offset.
+  PLUGIN_LIST: /^\/admin\/plugins\/?(?:\/q\/filter:(.*?))?(?:,(\d+))?$/,
+  // Matches /admin/groups with optional filter and offset.
+  GROUP_LIST: /^\/admin\/groups\/?(?:\/q\/filter:(.*?))?(?:,(\d+))?$/,
+  // Matches /admin/repos with optional filter and offset.
+  REPO_LIST: /^\/admin\/repos\/?(?:\/q\/filter:(.*?))?(?:,(\d+))?$/,
+  // Matches /admin/repos/$REPO,branches with optional filter and offset.
+  BRANCH_LIST:
+    /^\/admin\/repos\/(.+),branches\/?(?:\/q\/filter:(.*?))?(?:,(\d+))?$/,
+  // Matches /admin/repos/$REPO,tags with optional filter and offset.
+  TAG_LIST: /^\/admin\/repos\/(.+),tags\/?(?:\/q\/filter:(.*?))?(?:,(\d+))?$/,
 
-  // Matches /admin/plugins[,<offset>][/].
-  PLUGIN_LIST_OFFSET: /^\/admin\/plugins(,(\d+))?(\/)?$/,
-  PLUGIN_LIST_FILTER: '/admin/plugins/q/filter::filter',
-  PLUGIN_LIST_FILTER_OFFSET: '/admin/plugins/q/filter::filter,:offset',
-
-  QUERY: /^\/q\/([^,]+)(,(\d+))?$/,
+  QUERY: /^\/q\/(.+?)(,(\d+))?$/,
 
   /**
    * Support vestigial params from GWT UI.
@@ -234,13 +243,11 @@
 
   PLUGIN_SCREEN: /^\/x\/([\w-]+)\/([\w-]+)\/?/,
 
-  DOCUMENTATION_SEARCH_FILTER: '/Documentation/q/filter::filter',
+  DOCUMENTATION_SEARCH_FILTER: /^\/Documentation\/q\/filter:(.*)$/,
   DOCUMENTATION_SEARCH: /^\/Documentation\/q\/(.*)$/,
   DOCUMENTATION: /^\/Documentation(\/)?(.+)?/,
 };
 
-export const _testOnly_RoutePattern = RoutePattern;
-
 /**
  * Pattern to recognize and parse the diff line locations as they appear in
  * the hash of diff URLs. In this format, a number on its own indicates that
@@ -289,6 +296,18 @@
 
   private view?: GerritView;
 
+  // While this set is not empty, the router will refuse to navigate to
+  // other pages, but instead show an alert. It will also install a
+  // `beforeUnload` handler that prevents the browser from closing the tab.
+  private navigationBlockers: Set<string> = new Set<string>();
+
+  // While navigationBlockers is not empty, this promise will continuously
+  // check for navigationBlockers to become empty again.
+  // This is undefined, iff navigationBlockers is empty.
+  private navigationBlockerPromise?: InteractivePromise<void>;
+
+  readonly page = new Page();
+
   constructor(
     private readonly reporting: ReportingService,
     private readonly routerModel: RouterModel,
@@ -297,9 +316,7 @@
     private readonly agreementViewModel: AgreementViewModel,
     private readonly changeViewModel: ChangeViewModel,
     private readonly dashboardViewModel: DashboardViewModel,
-    private readonly diffViewModel: DiffViewModel,
     private readonly documentationViewModel: DocumentationViewModel,
-    private readonly editViewModel: EditViewModel,
     private readonly groupViewModel: GroupViewModel,
     private readonly pluginViewModel: PluginViewModel,
     private readonly repoViewModel: RepoViewModel,
@@ -316,26 +333,60 @@
         // So this check is slightly fragile, but should work.
         if (this.view !== GerritView.CHANGE) return;
         const browserUrl = new URL(window.location.toString());
-        const stateUrl = new URL(createChangeUrl(state), browserUrl);
+        const stateUrl = new URL(createChangeViewUrl(state), browserUrl);
+
+        // Keeping the hash and certain parameters are stop-gap solution. We
+        // should find better ways of maintaining an overall consistent URL
+        // state.
         stateUrl.hash = browserUrl.hash;
+        for (const p of browserUrl.searchParams.entries()) {
+          if (p[0] === 'experiment') stateUrl.searchParams.append(p[0], p[1]);
+        }
+
         if (browserUrl.toString() !== stateUrl.toString()) {
-          page.replace(
-            stateUrl.toString(),
-            null,
-            /* init: */ false,
-            /* dispatch: */ false
-          );
+          this.page.replace(stateUrl.toString(), {}, /* dispatch: */ false);
         }
       }),
       this.routerModel.routerView$.subscribe(view => (this.view = view)),
     ];
   }
 
+  blockNavigation(reason: string): void {
+    assert(!!reason, 'empty reason is not allowed');
+    this.navigationBlockers.add(reason);
+    if (this.navigationBlockers.size === 1) {
+      this.navigationBlockerPromise = interactivePromise();
+      window.addEventListener('beforeunload', this.beforeUnloadHandler);
+    }
+  }
+
+  releaseNavigation(reason: string): void {
+    assert(!!reason, 'empty reason is not allowed');
+    this.navigationBlockers.delete(reason);
+    if (this.navigationBlockers.size === 0) {
+      window.removeEventListener('beforeunload', this.beforeUnloadHandler);
+      this.navigationBlockerPromise?.resolve();
+    }
+  }
+
+  private beforeUnloadHandler = (event: BeforeUnloadEvent) => {
+    const reason = [...this.navigationBlockers][0];
+    if (!reason) return;
+
+    event.preventDefault(); // Cancel the event (per the standard).
+    event.returnValue = reason; // Chrome requires returnValue to be set.
+    // Note that we could as well just use '' instead of `reason`. Browsers will
+    // just show a generic message anyway.
+    return reason;
+  };
+
   finalize(): void {
     for (const subscription of this.subscriptions) {
       subscription.unsubscribe();
     }
     this.subscriptions = [];
+    this.page.stop();
+    window.removeEventListener('beforeunload', this.beforeUnloadHandler);
   }
 
   start() {
@@ -346,20 +397,18 @@
   }
 
   setState(state: AppElementParams) {
-    if (
-      'project' in state &&
-      state.project !== undefined &&
-      'changeNum' in state
-    )
-      this.restApiService.setInProjectLookup(state.changeNum, state.project);
+    // TODO: Move this logic into the change model.
+    if ('repo' in state && state.repo !== undefined && 'changeNum' in state)
+      this.restApiService.setInProjectLookup(state.changeNum, state.repo);
 
-    this.routerModel.setState({
-      view: state.view,
-      changeNum: 'changeNum' in state ? state.changeNum : undefined,
-      patchNum: 'patchNum' in state ? state.patchNum ?? undefined : undefined,
-      basePatchNum:
-        'basePatchNum' in state ? state.basePatchNum ?? undefined : undefined,
-    });
+    this.routerModel.setState({view: state.view});
+    // We are trying to reset the change (view) model when navigating to other
+    // views, because we don't trust our reset logic at the moment. The models
+    // singletons and might unintentionally keep state from one change to
+    // another. TODO: Let's find some way to avoid that.
+    if (state.view !== GerritView.CHANGE) {
+      this.changeViewModel.setState(undefined);
+    }
     this.appElement().params = state;
   }
 
@@ -380,7 +429,7 @@
 
   redirect(url: string) {
     this._isRedirecting = true;
-    page.redirect(url);
+    this.page.redirect(url);
   }
 
   /**
@@ -409,11 +458,13 @@
    */
   redirectToLogin(returnUrl: string) {
     const basePath = getBaseUrl() || '';
-    page('/login/' + encodeURIComponent(returnUrl.substring(basePath.length)));
+    this.setUrl(
+      '/login/' + encodeURIComponent(returnUrl.substring(basePath.length))
+    );
   }
 
   /**
-   * Hashes parsed by page.js exclude "inner" hashes, so a URL like "/a#b#c"
+   * Hashes parsed by gr-page exclude "inner" hashes, so a URL like "/a#b#c"
    * is parsed to have a hash of "b" rather than "b#c". Instead, this method
    * parses hashes correctly. Will return an empty string if there is no hash.
    *
@@ -442,18 +493,18 @@
    * @return A promise yielding the original route ctx
    * (if it resolves).
    */
-  redirectIfNotLoggedIn(ctx: PageContext) {
+  redirectIfNotLoggedIn(path: string) {
     return this.restApiService.getLoggedIn().then(loggedIn => {
       if (loggedIn) {
         return Promise.resolve();
       } else {
-        this.redirectToLogin(ctx.canonicalPath);
+        this.redirectToLogin(path);
         return Promise.reject(new Error());
       }
     });
   }
 
-  /**  Page.js middleware that warms the REST API's logged-in cache line. */
+  /**  gr-page middleware that warms the REST API's logged-in cache line. */
   private loadUserMiddleware(_: PageContext, next: PageNextCallback) {
     this.restApiService.getLoggedIn().then(() => {
       next();
@@ -463,35 +514,62 @@
   /**
    * Map a route to a method on the router.
    *
-   * @param pattern The page.js pattern for the route.
+   * @param pattern The regex pattern for the route.
    * @param handlerName The method name for the handler. If the
    * route is matched, the handler will be executed with `this` referring
-   * to the component. Its return value will be discarded so that it does
-   * not interfere with page.js.
+   * to the component. Its return value will be discarded.
+   * TODO: Get rid of this parameter. This is really not something that the
+   * router wants to be concerned with. The reporting service and the view
+   * models should figure that out between themselves.
    * @param authRedirect If true, then auth is checked before
    * executing the handler. If the user is not logged in, it will redirect
    * to the login flow and the handler will not be executed. The login
    * redirect specifies the matched URL to be used after successful auth.
    */
   mapRoute(
-    pattern: string | RegExp,
+    pattern: RegExp,
     handlerName: string,
     handler: (ctx: PageContext) => void,
     authRedirect?: boolean
   ) {
-    page(
-      pattern,
-      (ctx, next) => this.loadUserMiddleware(ctx, next),
-      ctx => {
-        this.reporting.locationChanged(handlerName);
-        const promise = authRedirect
-          ? this.redirectIfNotLoggedIn(ctx)
-          : Promise.resolve();
-        promise.then(() => {
-          handler(ctx);
-        });
-      }
+    this.page.registerRoute(pattern, (ctx, next) =>
+      this.loadUserMiddleware(ctx, next)
     );
+    this.page.registerRoute(pattern, ctx => {
+      this.reporting.locationChanged(handlerName);
+      const promise = authRedirect
+        ? this.redirectIfNotLoggedIn(ctx.canonicalPath)
+        : Promise.resolve();
+      promise.then(() => {
+        handler(ctx);
+      });
+    });
+  }
+
+  /**
+   * Convenience wrapper of `mapRoute()` for when you have a `Route` object that
+   * can deal with state creation. Takes care of setting the view model state,
+   * which is currently duplicated lots of times for direct callers of
+   * `mapRoute()`.
+   */
+  mapRouteState<T extends ViewState>(
+    route: Route<T>,
+    viewModel: Model<T | undefined>,
+    handlerName: string,
+    authRedirect?: boolean
+  ) {
+    const handler = (ctx: PageContext) => {
+      const state = route.createState(ctx);
+      // Note that order is important: `this.setState()` must be called before
+      // `viewModel.setState()`. Otherwise the chain of model subscriptions
+      // would be very different. Some views may want app element to swap the
+      // top level view first. Also, `this.setState()` has some special change
+      // view model resetting logic. Eventually the order might not be important
+      // anymore, but be careful! :-)
+      this.setState(state as AppElementParams);
+      viewModel.setState(state);
+    };
+    this.mapRoute(route.urlPattern, handlerName, handler, authRedirect);
   }
 
   /**
@@ -504,14 +582,14 @@
    * page.show() eventually just calls `window.history.pushState()`.
    */
   setUrl(url: string) {
-    page.show(url);
+    this.page.show(url);
   }
 
   /**
    * Navigate to this URL, but replace the current URL in the history instead of
    * adding a new one (which is what `setUrl()` would do).
    *
-   * page.redirect() eventually just calls `window.history.replaceState()`.
+   * this.page.redirect() eventually just calls `window.history.replaceState()`.
    */
   replaceUrl(url: string) {
     this.redirect(url);
@@ -522,22 +600,15 @@
       hash: window.location.hash,
       pathname: window.location.pathname,
     };
-    document.dispatchEvent(
-      new CustomEvent('location-change', {
-        detail,
-        composed: true,
-        bubbles: true,
-      })
-    );
+    fire(document, 'location-change', detail);
   }
 
-  startRouter() {
-    const base = getBaseUrl();
-    if (base) {
-      page.base(base);
-    }
+  _testOnly_startRouter() {
+    this.startRouter({dispatch: false, base: getBaseUrl()});
+  }
 
-    page.exit('*', (_, next) => {
+  startRouter(opts: PageOptions = {dispatch: true, base: getBaseUrl()}) {
+    this.page.registerExitRoute(/(.*)/, (_, next) => {
       if (!this._isRedirecting) {
         this.reporting.beforeLocationChanged();
       }
@@ -548,7 +619,7 @@
 
     // Remove the tracking param 'usp' (User Source Parameter) from the URL,
     // just to have users look at cleaner URLs.
-    page((ctx, next) => {
+    this.page.registerRoute(/(.*)/, (ctx, next) => {
       if (window.URLSearchParams) {
         const pathname = toPathname(ctx.canonicalPath);
         const searchParams = toSearchParams(ctx.canonicalPath);
@@ -563,8 +634,32 @@
       next();
     });
 
+    // Block navigation while navigationBlockers exist. But wait 1 second for
+    // those blockers to resolve. If they do, then still navigate. We don't want
+    // to annoy users by forcing them to navigate twice only because it took
+    // another 200ms for a comment to save or something similar.
+    this.page.registerRoute(/(.*)/, (_, next) => {
+      if (this.navigationBlockers.size === 0) {
+        next();
+        return;
+      }
+
+      const msg = 'Waiting 1 second for navigation blockers to resolve ...';
+      fireAlert(document, msg);
+      Promise.race([this.navigationBlockerPromise, timeoutPromise(1000)]).then(
+        () => {
+          if (this.navigationBlockers.size === 0) {
+            next();
+          } else {
+            const reason = [...this.navigationBlockers][0];
+            fireAlert(document, `Navigation is blocked by: ${reason}`);
+          }
+        }
+      );
+    });
+
     // Middleware
-    page((ctx, next) => {
+    this.page.registerRoute(/(.*)/, (ctx, next) => {
       document.body.scrollTop = 0;
 
       if (ctx.hash.match(RoutePattern.PLUGIN_SCREEN)) {
@@ -597,10 +692,10 @@
       ctx => this.handleCustomDashboardRoute(ctx)
     );
 
-    this.mapRoute(
-      RoutePattern.PROJECT_DASHBOARD,
-      'handleProjectDashboardRoute',
-      ctx => this.handleProjectDashboardRoute(ctx)
+    this.mapRouteState(
+      PROJECT_DASHBOARD_ROUTE,
+      this.dashboardViewModel,
+      'handleProjectDashboardRoute'
     );
 
     this.mapRoute(
@@ -631,23 +726,9 @@
     );
 
     this.mapRoute(
-      RoutePattern.GROUP_LIST_OFFSET,
-      'handleGroupListOffsetRoute',
-      ctx => this.handleGroupListOffsetRoute(ctx),
-      true
-    );
-
-    this.mapRoute(
-      RoutePattern.GROUP_LIST_FILTER_OFFSET,
-      'handleGroupListFilterOffsetRoute',
-      ctx => this.handleGroupListFilterOffsetRoute(ctx),
-      true
-    );
-
-    this.mapRoute(
-      RoutePattern.GROUP_LIST_FILTER,
-      'handleGroupListFilterRoute',
-      ctx => this.handleGroupListFilterRoute(ctx),
+      RoutePattern.GROUP_LIST,
+      'handleGroupListRoute',
+      ctx => this.handleGroupListRoute(ctx),
       true
     );
 
@@ -676,6 +757,13 @@
       true
     );
 
+    this.mapRoute(
+      RoutePattern.REPO_EDIT_FILE,
+      'handleRepoEditFileRoute',
+      ctx => this.handleRepoEditFileRoute(ctx),
+      true
+    );
+
     this.mapRoute(RoutePattern.REPO_GENERAL, 'handleRepoGeneralRoute', ctx =>
       this.handleRepoGeneralRoute(ctx)
     );
@@ -690,40 +778,12 @@
       ctx => this.handleRepoDashboardsRoute(ctx)
     );
 
-    this.mapRoute(
-      RoutePattern.BRANCH_LIST_OFFSET,
-      'handleBranchListOffsetRoute',
-      ctx => this.handleBranchListOffsetRoute(ctx)
+    this.mapRoute(RoutePattern.BRANCH_LIST, 'handleBranchListRoute', ctx =>
+      this.handleBranchListRoute(ctx)
     );
 
-    this.mapRoute(
-      RoutePattern.BRANCH_LIST_FILTER_OFFSET,
-      'handleBranchListFilterOffsetRoute',
-      ctx => this.handleBranchListFilterOffsetRoute(ctx)
-    );
-
-    this.mapRoute(
-      RoutePattern.BRANCH_LIST_FILTER,
-      'handleBranchListFilterRoute',
-      ctx => this.handleBranchListFilterRoute(ctx)
-    );
-
-    this.mapRoute(
-      RoutePattern.TAG_LIST_OFFSET,
-      'handleTagListOffsetRoute',
-      ctx => this.handleTagListOffsetRoute(ctx)
-    );
-
-    this.mapRoute(
-      RoutePattern.TAG_LIST_FILTER_OFFSET,
-      'handleTagListFilterOffsetRoute',
-      ctx => this.handleTagListFilterOffsetRoute(ctx)
-    );
-
-    this.mapRoute(
-      RoutePattern.TAG_LIST_FILTER,
-      'handleTagListFilterRoute',
-      ctx => this.handleTagListFilterRoute(ctx)
+    this.mapRoute(RoutePattern.TAG_LIST, 'handleTagListRoute', ctx =>
+      this.handleTagListRoute(ctx)
     );
 
     this.mapRoute(
@@ -740,22 +800,8 @@
       true
     );
 
-    this.mapRoute(
-      RoutePattern.REPO_LIST_OFFSET,
-      'handleRepoListOffsetRoute',
-      ctx => this.handleRepoListOffsetRoute(ctx)
-    );
-
-    this.mapRoute(
-      RoutePattern.REPO_LIST_FILTER_OFFSET,
-      'handleRepoListFilterOffsetRoute',
-      ctx => this.handleRepoListFilterOffsetRoute(ctx)
-    );
-
-    this.mapRoute(
-      RoutePattern.REPO_LIST_FILTER,
-      'handleRepoListFilterRoute',
-      ctx => this.handleRepoListFilterRoute(ctx)
+    this.mapRoute(RoutePattern.REPO_LIST, 'handleRepoListRoute', ctx =>
+      this.handleRepoListRoute(ctx)
     );
 
     this.mapRoute(RoutePattern.REPO, 'handleRepoRoute', ctx =>
@@ -767,30 +813,16 @@
     );
 
     this.mapRoute(
-      RoutePattern.PLUGIN_LIST_OFFSET,
-      'handlePluginListOffsetRoute',
-      ctx => this.handlePluginListOffsetRoute(ctx),
-      true
-    );
-
-    this.mapRoute(
-      RoutePattern.PLUGIN_LIST_FILTER_OFFSET,
-      'handlePluginListFilterOffsetRoute',
-      ctx => this.handlePluginListFilterOffsetRoute(ctx),
-      true
-    );
-
-    this.mapRoute(
-      RoutePattern.PLUGIN_LIST_FILTER,
+      RoutePattern.PLUGIN_LIST,
       'handlePluginListFilterRoute',
       ctx => this.handlePluginListFilterRoute(ctx),
       true
     );
 
-    this.mapRoute(
-      RoutePattern.PLUGIN_LIST,
+    this.mapRouteState(
+      PLUGIN_LIST_ROUTE,
+      this.adminViewModel,
       'handlePluginListRoute',
-      ctx => this.handlePluginListRoute(ctx),
       true
     );
 
@@ -928,7 +960,7 @@
       this.handleDefaultRoute()
     );
 
-    page.start();
+    this.page.start(opts);
   }
 
   /**
@@ -945,7 +977,7 @@
     // For backward compatibility with GWT links.
     if (hash) {
       // In certain login flows the server may redirect to a hash without
-      // a leading slash, which page.js doesn't handle correctly.
+      // a leading slash, which gr-page doesn't handle correctly.
       if (hash[0] !== '/') {
         hash = '/' + hash;
       }
@@ -983,7 +1015,7 @@
         if (ctx.params[0].toLowerCase() === 'self') {
           this.redirectToLogin(ctx.canonicalPath);
         } else {
-          this.redirect('/q/owner:' + encodeURIComponent(ctx.params[0]));
+          this.redirect('/q/owner:' + encodeURL(ctx.params[0]));
         }
       } else {
         const state: DashboardViewState = {
@@ -1033,25 +1065,13 @@
     return Promise.resolve();
   }
 
-  handleProjectDashboardRoute(ctx: PageContext) {
-    const project = ctx.params[0] as RepoName;
-    const state: DashboardViewState = {
-      view: GerritView.DASHBOARD,
-      project,
-      dashboard: decodeURIComponent(ctx.params[1]) as DashboardId,
-    };
-    // Note that router model view must be updated before view models.
-    this.setState(state);
-    this.dashboardViewModel.setState(state);
-    this.reporting.setRepoName(project);
-  }
-
   handleLegacyProjectDashboardRoute(ctx: PageContext) {
     this.redirect('/p/' + ctx.params[0] + '/+/dashboard/' + ctx.params[1]);
   }
 
   handleGroupInfoRoute(ctx: PageContext) {
-    this.redirect('/admin/groups/' + encodeURIComponent(ctx.params[0]));
+    const groupId = ctx.params[0] as GroupId;
+    this.redirect(createGroupUrl({groupId}));
   }
 
   handleGroupSelfRedirectRoute(_: PageContext) {
@@ -1090,36 +1110,14 @@
     this.groupViewModel.setState(state);
   }
 
-  handleGroupListOffsetRoute(ctx: PageContext) {
+  handleGroupListRoute(ctx: PageContext) {
     const state: AdminViewState = {
       view: GerritView.ADMIN,
       adminView: AdminChildView.GROUPS,
-      offset: ctx.params[1] || 0,
-      filter: null,
-      openCreateModal: ctx.hash === 'create',
-    };
-    // Note that router model view must be updated before view models.
-    this.setState(state);
-    this.adminViewModel.setState(state);
-  }
-
-  handleGroupListFilterOffsetRoute(ctx: PageContext) {
-    const state: AdminViewState = {
-      view: GerritView.ADMIN,
-      adminView: AdminChildView.GROUPS,
-      offset: ctx.params['offset'],
-      filter: ctx.params['filter'],
-    };
-    // Note that router model view must be updated before view models.
-    this.setState(state);
-    this.adminViewModel.setState(state);
-  }
-
-  handleGroupListFilterRoute(ctx: PageContext) {
-    const state: AdminViewState = {
-      view: GerritView.ADMIN,
-      adminView: AdminChildView.GROUPS,
-      filter: ctx.params['filter'] || null,
+      offset: ctx.params[1] || '0',
+      filter: ctx.params[0] ?? null,
+      openCreateModal:
+        !ctx.params[0] && !ctx.params[1] && ctx.hash === 'create',
     };
     // Note that router model view must be updated before view models.
     this.setState(state);
@@ -1135,6 +1133,8 @@
       }
     }
 
+    // TODO: Change the route pattern to match `repo` and `detailView`
+    // separately, and then use `createRepoUrl()` here.
     this.redirect(`/admin/repos/${params}`);
   }
 
@@ -1151,6 +1151,22 @@
     this.reporting.setRepoName(repo);
   }
 
+  handleRepoEditFileRoute(ctx: PageContext) {
+    const repo = ctx.params[0] as RepoName;
+    const branch = ctx.params[1] as BranchName;
+    const path = ctx.params[2];
+    const state: RepoViewState = {
+      view: GerritView.REPO,
+      detail: RepoDetailView.COMMANDS,
+      repo,
+      createEdit: {branch, path},
+    };
+    // Note that router model view must be updated before view models.
+    this.setState(state);
+    this.repoViewModel.setState(state);
+    this.reporting.setRepoName(repo);
+  }
+
   handleRepoGeneralRoute(ctx: PageContext) {
     const repo = ctx.params[0] as RepoName;
     const state: RepoViewState = {
@@ -1190,112 +1206,40 @@
     this.reporting.setRepoName(repo);
   }
 
-  handleBranchListOffsetRoute(ctx: PageContext) {
+  handleBranchListRoute(ctx: PageContext) {
     const state: RepoViewState = {
       view: GerritView.REPO,
       detail: RepoDetailView.BRANCHES,
       repo: ctx.params[0] as RepoName,
-      offset: ctx.params[2] || 0,
-      filter: null,
+      offset: ctx.params[2] || '0',
+      filter: ctx.params[1] ?? null,
     };
     // Note that router model view must be updated before view models.
     this.setState(state);
     this.repoViewModel.setState(state);
   }
 
-  handleBranchListFilterOffsetRoute(ctx: PageContext) {
-    const state: RepoViewState = {
-      view: GerritView.REPO,
-      detail: RepoDetailView.BRANCHES,
-      repo: ctx.params['repo'] as RepoName,
-      offset: ctx.params['offset'],
-      filter: ctx.params['filter'],
-    };
-    // Note that router model view must be updated before view models.
-    this.setState(state);
-    this.repoViewModel.setState(state);
-  }
-
-  handleBranchListFilterRoute(ctx: PageContext) {
-    const state: RepoViewState = {
-      view: GerritView.REPO,
-      detail: RepoDetailView.BRANCHES,
-      repo: ctx.params['repo'] as RepoName,
-      filter: ctx.params['filter'] || null,
-    };
-    // Note that router model view must be updated before view models.
-    this.setState(state);
-    this.repoViewModel.setState(state);
-  }
-
-  handleTagListOffsetRoute(ctx: PageContext) {
+  handleTagListRoute(ctx: PageContext) {
     const state: RepoViewState = {
       view: GerritView.REPO,
       detail: RepoDetailView.TAGS,
       repo: ctx.params[0] as RepoName,
-      offset: ctx.params[2] || 0,
-      filter: null,
+      offset: ctx.params[2] || '0',
+      filter: ctx.params[1] ?? null,
     };
     // Note that router model view must be updated before view models.
     this.setState(state);
     this.repoViewModel.setState(state);
   }
 
-  handleTagListFilterOffsetRoute(ctx: PageContext) {
-    const state: RepoViewState = {
-      view: GerritView.REPO,
-      detail: RepoDetailView.TAGS,
-      repo: ctx.params['repo'] as RepoName,
-      offset: ctx.params['offset'],
-      filter: ctx.params['filter'],
-    };
-    // Note that router model view must be updated before view models.
-    this.setState(state);
-    this.repoViewModel.setState(state);
-  }
-
-  handleTagListFilterRoute(ctx: PageContext) {
-    const state: RepoViewState = {
-      view: GerritView.REPO,
-      detail: RepoDetailView.TAGS,
-      repo: ctx.params['repo'] as RepoName,
-      filter: ctx.params['filter'] || null,
-    };
-    // Note that router model view must be updated before view models.
-    this.setState(state);
-    this.repoViewModel.setState(state);
-  }
-
-  handleRepoListOffsetRoute(ctx: PageContext) {
+  handleRepoListRoute(ctx: PageContext) {
     const state: AdminViewState = {
       view: GerritView.ADMIN,
       adminView: AdminChildView.REPOS,
-      offset: ctx.params[1] || 0,
-      filter: null,
-      openCreateModal: ctx.hash === 'create',
-    };
-    // Note that router model view must be updated before view models.
-    this.setState(state);
-    this.adminViewModel.setState(state);
-  }
-
-  handleRepoListFilterOffsetRoute(ctx: PageContext) {
-    const state: AdminViewState = {
-      view: GerritView.ADMIN,
-      adminView: AdminChildView.REPOS,
-      offset: ctx.params['offset'],
-      filter: ctx.params['filter'],
-    };
-    // Note that router model view must be updated before view models.
-    this.setState(state);
-    this.adminViewModel.setState(state);
-  }
-
-  handleRepoListFilterRoute(ctx: PageContext) {
-    const state: AdminViewState = {
-      view: GerritView.ADMIN,
-      adminView: AdminChildView.REPOS,
-      filter: ctx.params['filter'] || null,
+      offset: ctx.params[1] || '0',
+      filter: ctx.params[0] ?? null,
+      openCreateModal:
+        !ctx.params[0] && !ctx.params[1] && ctx.hash === 'create',
     };
     // Note that router model view must be updated before view models.
     this.setState(state);
@@ -1318,45 +1262,12 @@
     this.redirect(ctx.path + ',general');
   }
 
-  handlePluginListOffsetRoute(ctx: PageContext) {
-    const state: AdminViewState = {
-      view: GerritView.ADMIN,
-      adminView: AdminChildView.PLUGINS,
-      offset: ctx.params[1] || 0,
-      filter: null,
-    };
-    // Note that router model view must be updated before view models.
-    this.setState(state);
-    this.adminViewModel.setState(state);
-  }
-
-  handlePluginListFilterOffsetRoute(ctx: PageContext) {
-    const state: AdminViewState = {
-      view: GerritView.ADMIN,
-      adminView: AdminChildView.PLUGINS,
-      offset: ctx.params['offset'],
-      filter: ctx.params['filter'],
-    };
-    // Note that router model view must be updated before view models.
-    this.setState(state);
-    this.adminViewModel.setState(state);
-  }
-
   handlePluginListFilterRoute(ctx: PageContext) {
     const state: AdminViewState = {
       view: GerritView.ADMIN,
       adminView: AdminChildView.PLUGINS,
-      filter: ctx.params['filter'] || null,
-    };
-    // Note that router model view must be updated before view models.
-    this.setState(state);
-    this.adminViewModel.setState(state);
-  }
-
-  handlePluginListRoute(_: PageContext) {
-    const state: AdminViewState = {
-      view: GerritView.ADMIN,
-      adminView: AdminChildView.PLUGINS,
+      offset: ctx.params[1] || '0',
+      filter: ctx.params[0] ?? null,
     };
     // Note that router model view must be updated before view models.
     this.setState(state);
@@ -1364,10 +1275,11 @@
   }
 
   handleQueryRoute(ctx: PageContext) {
-    const state: Partial<SearchViewState> = {
+    const state: SearchViewState = {
       view: GerritView.SEARCH,
       query: ctx.params[0],
-      offset: ctx.params[2],
+      offset: ctx.params[2] || '0',
+      loading: false,
     };
     // Note that router model view must be updated before view models.
     this.setState(state as AppElementParams);
@@ -1378,10 +1290,11 @@
     // TODO(pcc): This will need to indicate that this was a change ID query if
     // standard queries gain the ability to search places like commit messages
     // for change IDs.
-    const state: Partial<SearchViewState> = {
+    const state: SearchViewState = {
       view: GerritView.SEARCH,
       query: ctx.params[0],
-      offset: undefined,
+      offset: '0',
+      loading: false,
     };
     // Note that router model view must be updated before view models.
     this.setState(state as AppElementParams);
@@ -1393,25 +1306,30 @@
   }
 
   handleChangeNumberLegacyRoute(ctx: PageContext) {
-    this.redirect('/c/' + encodeURIComponent(ctx.params[0]));
+    this.redirect(
+      '/c/' +
+        ctx.params[0] +
+        (ctx.querystring.length > 0 ? `?${ctx.querystring}` : '')
+    );
   }
 
   handleChangeRoute(ctx: PageContext) {
     // Parameter order is based on the regex group number matched.
     const changeNum = Number(ctx.params[1]) as NumericChangeId;
     const state: ChangeViewState = {
-      project: ctx.params[0] as RepoName,
+      repo: ctx.params[0] as RepoName,
       changeNum,
       basePatchNum: convertToPatchSetNum(ctx.params[4]) as BasePatchSetNum,
       patchNum: convertToPatchSetNum(ctx.params[6]) as RevisionPatchSetNum,
       view: GerritView.CHANGE,
+      childView: ChangeChildView.OVERVIEW,
     };
 
     const queryMap = new URLSearchParams(ctx.querystring);
-    if (queryMap.has('forceReload')) state.forceReload = true;
     if (queryMap.has('openReplyDialog')) state.openReplyDialog = true;
 
     const tab = queryMap.get('tab');
+    if (queryMap.has('forceReload')) state.forceReload = true;
     if (tab) state.tab = tab;
     const checksPatchset = Number(queryMap.get('checksPatchset'));
     if (Number.isInteger(checksPatchset) && checksPatchset > 0) {
@@ -1426,8 +1344,8 @@
     const selected = queryMap.get('checksRunsSelected');
     if (selected) state.checksRunsSelected = new Set(selected.split(','));
 
-    assertIsDefined(state.project, 'project');
-    this.reporting.setRepoName(state.project);
+    assertIsDefined(state.repo, 'project');
+    this.reporting.setRepoName(state.repo);
     this.reporting.setChangeId(changeNum);
     this.normalizePatchRangeParams(state);
     // Note that router model view must be updated before view models.
@@ -1435,33 +1353,79 @@
     this.changeViewModel.setState(state);
   }
 
-  handleCommentRoute(ctx: PageContext) {
+  async handleCommentRoute(ctx: PageContext) {
     const changeNum = Number(ctx.params[1]) as NumericChangeId;
-    const state: DiffViewState = {
-      project: ctx.params[0] as RepoName,
+    const repo = ctx.params[0] as RepoName;
+    const commentId = ctx.params[2] as UrlEncodedCommentId;
+
+    const [comments, robotComments, drafts, change] = await Promise.all([
+      this.restApiService.getDiffComments(changeNum),
+      this.restApiService.getDiffRobotComments(changeNum),
+      this.restApiService.getDiffDrafts(changeNum),
+      this.restApiService.getChangeDetail(changeNum),
+    ]);
+
+    const comment =
+      findComment(addPath(comments), commentId) ??
+      findComment(addPath(robotComments), commentId) ??
+      findComment(addPath(drafts), commentId);
+    const path = comment?.path;
+    const patchsets = computeAllPatchSets(change);
+    const latestPatchNum = computeLatestPatchNum(patchsets);
+    if (!comment || !path || !latestPatchNum) {
+      this.show404();
+      return;
+    }
+    let {basePatchNum, patchNum} = getPatchRangeForCommentUrl(
+      comment,
+      latestPatchNum
+    );
+
+    if (basePatchNum !== PARENT) {
+      const diff = await this.restApiService.getDiff(
+        changeNum,
+        basePatchNum,
+        patchNum,
+        path
+      );
+      if (diff && isFileUnchanged(diff)) {
+        fireAlert(
+          document,
+          `File is unchanged between Patchset ${basePatchNum} and ${patchNum}.
+           Showing diff of Base vs ${basePatchNum}.`
+        );
+        patchNum = basePatchNum as RevisionPatchSetNum;
+        basePatchNum = PARENT;
+      }
+    }
+
+    const diffUrl = createDiffUrl({
       changeNum,
-      commentId: ctx.params[2] as UrlEncodedCommentId,
-      view: GerritView.DIFF,
-      commentLink: true,
-    };
-    this.reporting.setRepoName(state.project ?? '');
-    this.reporting.setChangeId(changeNum);
-    this.normalizePatchRangeParams(state);
-    // Note that router model view must be updated before view models.
-    this.setState(state);
-    this.diffViewModel.setState(state);
+      repo,
+      patchNum,
+      basePatchNum,
+      diffView: {
+        path,
+        lineNum: comment.line,
+        leftSide: isInBaseOfPatchRange(comment, {basePatchNum, patchNum}),
+      },
+    });
+    this.redirect(diffUrl);
   }
 
   handleCommentsRoute(ctx: PageContext) {
     const changeNum = Number(ctx.params[1]) as NumericChangeId;
     const state: ChangeViewState = {
-      project: ctx.params[0] as RepoName,
+      repo: ctx.params[0] as RepoName,
       changeNum,
       commentId: ctx.params[2] as UrlEncodedCommentId,
       view: GerritView.CHANGE,
+      childView: ChangeChildView.OVERVIEW,
     };
-    assertIsDefined(state.project);
-    this.reporting.setRepoName(state.project);
+    const queryMap = new URLSearchParams(ctx.querystring);
+    if (queryMap.has('forceReload')) state.forceReload = true;
+    assertIsDefined(state.repo);
+    this.reporting.setRepoName(state.repo);
     this.reporting.setChangeId(changeNum);
     this.normalizePatchRangeParams(state);
     // Note that router model view must be updated before view models.
@@ -1472,25 +1436,28 @@
   handleDiffRoute(ctx: PageContext) {
     const changeNum = Number(ctx.params[1]) as NumericChangeId;
     // Parameter order is based on the regex group number matched.
-    const state: DiffViewState = {
-      project: ctx.params[0] as RepoName,
+    const state: ChangeViewState = {
+      repo: ctx.params[0] as RepoName,
       changeNum,
       basePatchNum: convertToPatchSetNum(ctx.params[4]) as BasePatchSetNum,
       patchNum: convertToPatchSetNum(ctx.params[6]) as RevisionPatchSetNum,
-      path: ctx.params[8],
-      view: GerritView.DIFF,
+      view: GerritView.CHANGE,
+      childView: ChangeChildView.DIFF,
+      diffView: {path: ctx.params[8]},
     };
+    const queryMap = new URLSearchParams(ctx.querystring);
+    if (queryMap.has('forceReload')) state.forceReload = true;
     const address = this.parseLineAddress(ctx.hash);
     if (address) {
-      state.leftSide = address.leftSide;
-      state.lineNum = address.lineNum;
+      state.diffView!.leftSide = address.leftSide;
+      state.diffView!.lineNum = address.lineNum;
     }
-    this.reporting.setRepoName(state.project ?? '');
+    this.reporting.setRepoName(state.repo ?? '');
     this.reporting.setChangeId(changeNum);
     this.normalizePatchRangeParams(state);
     // Note that router model view must be updated before view models.
     this.setState(state);
-    this.diffViewModel.setState(state);
+    this.changeViewModel.setState(state);
   }
 
   handleChangeLegacyRoute(ctx: PageContext) {
@@ -1506,7 +1473,10 @@
         this.show404();
         return;
       }
-      this.redirect(`/c/${project}/+/${changeNum}/${ctx.params[1]}`);
+      this.redirect(
+        `/c/${project}/+/${changeNum}/${ctx.params[1]}` +
+          (ctx.querystring.length > 0 ? `?${ctx.querystring}` : '')
+      );
     });
   }
 
@@ -1518,19 +1488,21 @@
     // Parameter order is based on the regex group number matched.
     const project = ctx.params[0] as RepoName;
     const changeNum = Number(ctx.params[1]) as NumericChangeId;
-    const state: EditViewState = {
-      project,
+    const state: ChangeViewState = {
+      repo: project,
       changeNum,
       // for edit view params, patchNum cannot be undefined
       patchNum: convertToPatchSetNum(ctx.params[2]) as RevisionPatchSetNum,
-      path: ctx.params[3],
-      lineNum: Number(ctx.hash),
-      view: GerritView.EDIT,
+      view: GerritView.CHANGE,
+      childView: ChangeChildView.EDIT,
+      editView: {path: ctx.params[3], lineNum: Number(ctx.hash)},
     };
+    const queryMap = new URLSearchParams(ctx.querystring);
+    if (queryMap.has('forceReload')) state.forceReload = true;
     this.normalizePatchRangeParams(state);
     // Note that router model view must be updated before view models.
     this.setState(state);
-    this.editViewModel.setState(state);
+    this.changeViewModel.setState(state);
     this.reporting.setRepoName(project);
     this.reporting.setChangeId(changeNum);
   }
@@ -1541,22 +1513,16 @@
     const changeNum = Number(ctx.params[1]) as NumericChangeId;
     const queryMap = new URLSearchParams(ctx.querystring);
     const state: ChangeViewState = {
-      project,
+      repo: project,
       changeNum,
       patchNum: convertToPatchSetNum(ctx.params[3]) as RevisionPatchSetNum,
       view: GerritView.CHANGE,
+      childView: ChangeChildView.OVERVIEW,
       edit: true,
     };
     const tab = queryMap.get('tab');
     if (tab) state.tab = tab;
-    if (queryMap.has('forceReload')) {
-      state.forceReload = true;
-      history.replaceState(
-        null,
-        '',
-        location.href.replace(/[?&]forceReload=true/, '')
-      );
-    }
+    if (queryMap.has('forceReload')) state.forceReload = true;
     this.normalizePatchRangeParams(state);
     // Note that router model view must be updated before view models.
     this.setState(state);
@@ -1648,7 +1614,7 @@
   handleDocumentationSearchRoute(ctx: PageContext) {
     const state: DocumentationViewState = {
       view: GerritView.DOCUMENTATION_SEARCH,
-      filter: ctx.params['filter'] || null,
+      filter: ctx.params[0] ?? '',
     };
     // Note that router model view must be updated before view models.
     this.setState(state);
@@ -1656,9 +1622,7 @@
   }
 
   handleDocumentationSearchRedirectRoute(ctx: PageContext) {
-    this.redirect(
-      '/Documentation/q/filter:' + encodeURIComponent(ctx.params[0])
-    );
+    this.redirect('/Documentation/q/filter:' + encodeURL(ctx.params[0]));
   }
 
   handleDocumentationRedirectRoute(ctx: PageContext) {
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router_test.ts b/polygerrit-ui/app/elements/core/gr-router/gr-router_test.ts
index d1d0c86..234bf95 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router_test.ts
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router_test.ts
@@ -5,41 +5,64 @@
  */
 import '../../../test/common-test-setup';
 import './gr-router';
-import {page, PageContext} from '../../../utils/page-wrapper-utils';
+import {Page, PageContext} from './gr-page';
 import {
   stubBaseUrl,
   stubRestApi,
   addListenerForTest,
-  waitEventLoop,
+  waitUntilCalled,
+  mockPromise,
+  MockPromise,
 } from '../../../test/test-utils';
-import {GrRouter, routerToken, _testOnly_RoutePattern} from './gr-router';
+import {GrRouter, routerToken} from './gr-router';
 import {GerritView} from '../../../services/router/router-model';
 import {
   BasePatchSetNum,
-  GroupId,
   NumericChangeId,
   PARENT,
   RepoName,
   RevisionPatchSetNum,
   UrlEncodedCommentId,
 } from '../../../types/common';
-import {AppElementParams} from '../../gr-app-types';
+import {AppElementJustRegisteredParams} from '../../gr-app-types';
 import {assert} from '@open-wc/testing';
-import {AdminChildView} from '../../../models/views/admin';
+import {AdminChildView, AdminViewState} from '../../../models/views/admin';
 import {RepoDetailView} from '../../../models/views/repo';
 import {GroupDetailView} from '../../../models/views/group';
-import {EditViewState} from '../../../models/views/edit';
-import {ChangeViewState} from '../../../models/views/change';
+import {ChangeChildView} from '../../../models/views/change';
 import {PatchRangeParams} from '../../../utils/url-util';
-import {DependencyRequestEvent} from '../../../models/dependency';
+import {testResolver} from '../../../test/common-test-setup';
+import {
+  createAdminPluginsViewState,
+  createAdminReposViewState,
+  createChangeViewState,
+  createComment,
+  createDashboardViewState,
+  createDiff,
+  createDiffViewState,
+  createEditViewState,
+  createGroupViewState,
+  createParsedChange,
+  createRepoBranchesViewState,
+  createRepoTagsViewState,
+  createRepoViewState,
+  createRevision,
+  createSearchViewState,
+} from '../../../test/test-data-generators';
+import {ParsedChangeInfo} from '../../../types/types';
+import {ViewState} from '../../../models/views/base';
 
 suite('gr-router tests', () => {
   let router: GrRouter;
+  let page: Page;
 
   setup(() => {
-    document.dispatchEvent(
-      new DependencyRequestEvent(routerToken, x => (router = x))
-    );
+    router = testResolver(routerToken);
+    page = router.page;
+  });
+
+  teardown(async () => {
+    router.finalize();
   });
 
   test('getHashFromCanonicalPath', () => {
@@ -97,14 +120,13 @@
     });
   });
 
-  test('startRouter requires auth for the right handlers', () => {
+  test('startRouterForTesting requires auth for the right handlers', () => {
     // This test encodes the lists of route handler methods that gr-router
     // automatically checks for authentication before triggering.
 
     const requiresAuth: any = {};
     const doesNotRequireAuth: any = {};
     sinon.stub(page, 'start');
-    sinon.stub(page, 'base');
     sinon
       .stub(router, 'mapRoute')
       .callsFake((_pattern, methodName, _method, usesAuth) => {
@@ -114,7 +136,7 @@
           doesNotRequireAuth[methodName] = true;
         }
       });
-    router.startRouter();
+    router._testOnly_startRouter();
 
     const actualRequiresAuth = Object.keys(requiresAuth);
     actualRequiresAuth.sort();
@@ -129,27 +151,22 @@
       'handleDiffEditRoute',
       'handleGroupAuditLogRoute',
       'handleGroupInfoRoute',
-      'handleGroupListFilterOffsetRoute',
-      'handleGroupListFilterRoute',
-      'handleGroupListOffsetRoute',
+      'handleGroupListRoute',
       'handleGroupMembersRoute',
       'handleGroupRoute',
       'handleGroupSelfRedirectRoute',
       'handleNewAgreementsRoute',
-      'handlePluginListFilterOffsetRoute',
       'handlePluginListFilterRoute',
-      'handlePluginListOffsetRoute',
       'handlePluginListRoute',
       'handleRepoCommandsRoute',
+      'handleRepoEditFileRoute',
       'handleSettingsLegacyRoute',
       'handleSettingsRoute',
     ];
     assert.deepEqual(actualRequiresAuth, shouldRequireAutoAuth);
 
     const unauthenticatedHandlers = [
-      'handleBranchListFilterOffsetRoute',
-      'handleBranchListFilterRoute',
-      'handleBranchListOffsetRoute',
+      'handleBranchListRoute',
       'handleChangeIdQueryRoute',
       'handleChangeNumberLegacyRoute',
       'handleChangeRoute',
@@ -170,16 +187,12 @@
       'handleRepoAccessRoute',
       'handleRepoDashboardsRoute',
       'handleRepoGeneralRoute',
-      'handleRepoListFilterOffsetRoute',
-      'handleRepoListFilterRoute',
-      'handleRepoListOffsetRoute',
+      'handleRepoListRoute',
       'handleRepoRoute',
       'handleQueryLegacySuffixRoute',
       'handleQueryRoute',
       'handleRegisterRoute',
-      'handleTagListFilterOffsetRoute',
-      'handleTagListFilterRoute',
-      'handleTagListOffsetRoute',
+      'handleTagListRoute',
       'handlePluginScreen',
     ];
 
@@ -200,20 +213,8 @@
 
   test('redirectIfNotLoggedIn while logged in', () => {
     stubRestApi('getLoggedIn').returns(Promise.resolve(true));
-    const ctx = {
-      save() {},
-      handled: true,
-      canonicalPath: '',
-      path: '',
-      querystring: '',
-      pathname: '',
-      state: '',
-      title: '',
-      hash: '',
-      params: {test: 'test'},
-    };
     const redirectStub = sinon.stub(router, 'redirectToLogin');
-    return router.redirectIfNotLoggedIn(ctx).then(() => {
+    return router.redirectIfNotLoggedIn('somepath').then(() => {
       assert.isFalse(redirectStub.called);
     });
   });
@@ -221,21 +222,9 @@
   test('redirectIfNotLoggedIn while logged out', () => {
     stubRestApi('getLoggedIn').returns(Promise.resolve(false));
     const redirectStub = sinon.stub(router, 'redirectToLogin');
-    const ctx = {
-      save() {},
-      handled: true,
-      canonicalPath: '',
-      path: '',
-      querystring: '',
-      pathname: '',
-      state: '',
-      title: '',
-      hash: '',
-      params: {test: 'test'},
-    };
     return new Promise(resolve => {
       router
-        .redirectIfNotLoggedIn(ctx)
+        .redirectIfNotLoggedIn('somepath')
         .then(() => {
           assert.isTrue(false, 'Should never execute');
         })
@@ -267,85 +256,166 @@
     });
   });
 
+  suite('navigation blockers', () => {
+    let clock: sinon.SinonFakeTimers;
+    let redirectStub: sinon.SinonStub;
+    let urlPromise: MockPromise<string>;
+
+    setup(() => {
+      stubRestApi('setInProjectLookup');
+      urlPromise = mockPromise<string>();
+      redirectStub = sinon
+        .stub(router, 'redirect')
+        .callsFake(urlPromise.resolve);
+      router._testOnly_startRouter();
+      clock = sinon.useFakeTimers();
+    });
+
+    test('no blockers: normal redirect', async () => {
+      router.page.show('/settings/agreements');
+      const url = await urlPromise;
+      assert.isTrue(redirectStub.calledOnce);
+      assert.equal(url, '/settings/#Agreements');
+    });
+
+    test('redirect blocked', async () => {
+      const firstAlertPromise = mockPromise<Event>();
+      addListenerForTest(document, 'show-alert', firstAlertPromise.resolve);
+
+      router.blockNavigation('a good reason');
+      router.page.show('/settings/agreements');
+
+      const firstAlert = (await firstAlertPromise) as CustomEvent;
+      assert.equal(
+        firstAlert.detail.message,
+        'Waiting 1 second for navigation blockers to resolve ...'
+      );
+
+      const secondAlertPromise = mockPromise<Event>();
+      addListenerForTest(document, 'show-alert', secondAlertPromise.resolve);
+
+      clock.tick(2000);
+
+      const secondAlert = (await secondAlertPromise) as CustomEvent;
+      assert.equal(
+        secondAlert.detail.message,
+        'Navigation is blocked by: a good reason'
+      );
+
+      assert.isFalse(redirectStub.called);
+    });
+
+    test('redirect blocked, but resolved within one second', async () => {
+      const firstAlertPromise = mockPromise<Event>();
+      addListenerForTest(document, 'show-alert', firstAlertPromise.resolve);
+
+      router.blockNavigation('a good reason');
+      router.page.show('/settings/agreements');
+
+      const firstAlert = (await firstAlertPromise) as CustomEvent;
+      assert.equal(
+        firstAlert.detail.message,
+        'Waiting 1 second for navigation blockers to resolve ...'
+      );
+
+      const secondAlertPromise = mockPromise<Event>();
+      addListenerForTest(document, 'show-alert', secondAlertPromise.resolve);
+
+      clock.tick(500);
+      router.releaseNavigation('a good reason');
+      clock.tick(2000);
+
+      await urlPromise;
+      assert.isTrue(redirectStub.calledOnce);
+    });
+  });
+
   suite('route handlers', () => {
     let redirectStub: sinon.SinonStub;
     let setStateStub: sinon.SinonStub;
     let handlePassThroughRoute: sinon.SinonStub;
+    let redirectToLoginStub: sinon.SinonStub;
 
-    // Simple route handlers are direct mappings from parsed route ctx to a
-    // new set of app.params. This test helper asserts that passing `ctx`
-    // into `methodName` results in setting the params specified in `params`.
-    function assertctxToParams(
-      ctx: PageContext,
-      methodName: string,
-      params: AppElementParams
+    async function checkUrlToState<T extends ViewState>(
+      url: string,
+      state: T | AppElementJustRegisteredParams
     ) {
-      (router as any)[methodName](ctx);
-      assert.deepEqual(setStateStub.lastCall.args[0], params);
+      setStateStub.reset();
+      router.page.show(url);
+      await waitUntilCalled(setStateStub, 'setState');
+      assert.isTrue(setStateStub.calledOnce);
+      assert.deepEqual(setStateStub.lastCall.firstArg, state);
     }
 
-    function createPageContext(): PageContext {
-      return {
-        canonicalPath: '',
-        path: '',
-        querystring: '',
-        pathname: '',
-        hash: '',
-        params: {},
-      };
+    async function checkRedirect(fromUrl: string, toUrl: string) {
+      redirectStub.reset();
+      router.page.show(fromUrl);
+      await waitUntilCalled(redirectStub, 'redirect');
+      assert.isTrue(redirectStub.calledOnce);
+      assert.isFalse(setStateStub.called);
+      assert.equal(redirectStub.lastCall.firstArg, toUrl);
+    }
+
+    async function checkRedirectToLogin(fromUrl: string, toUrl: string) {
+      redirectToLoginStub.reset();
+      router.page.show(fromUrl);
+      await waitUntilCalled(redirectToLoginStub, 'redirectToLogin');
+      assert.isTrue(redirectToLoginStub.calledOnce);
+      assert.isFalse(redirectStub.called);
+      assert.isFalse(setStateStub.called);
+      assert.equal(redirectToLoginStub.lastCall.firstArg, toUrl);
+    }
+
+    async function checkUrlNotMatched(url: string) {
+      handlePassThroughRoute.reset();
+      router.page.show(url);
+      await waitUntilCalled(handlePassThroughRoute, 'handlePassThroughRoute');
     }
 
     setup(() => {
+      stubRestApi('setInProjectLookup');
       redirectStub = sinon.stub(router, 'redirect');
+      redirectToLoginStub = sinon.stub(router, 'redirectToLogin');
       setStateStub = sinon.stub(router, 'setState');
       handlePassThroughRoute = sinon.stub(router, 'handlePassThroughRoute');
+      router._testOnly_startRouter();
     });
 
-    test('handleLegacyProjectDashboardRoute', () => {
-      const params = {
-        ...createPageContext(),
-        params: {0: 'gerrit/project', 1: 'dashboard:main'},
-      };
-      router.handleLegacyProjectDashboardRoute(params);
-      assert.isTrue(redirectStub.calledOnce);
-      assert.equal(
-        redirectStub.lastCall.args[0],
+    test('LEGACY_PROJECT_DASHBOARD', async () => {
+      // LEGACY_PROJECT_DASHBOARD: /^\/projects\/(.+),dashboards\/(.+)/,
+      await checkRedirect(
+        '/projects/gerrit/project,dashboards/dashboard:main',
         '/p/gerrit/project/+/dashboard/dashboard:main'
       );
     });
 
-    test('handleAgreementsRoute', () => {
-      router.handleAgreementsRoute();
-      assert.isTrue(redirectStub.calledOnce);
-      assert.equal(redirectStub.lastCall.args[0], '/settings/#Agreements');
+    test('AGREEMENTS', async () => {
+      // AGREEMENTS: /^\/settings\/agreements\/?/,
+      await checkRedirect('/settings/agreements', '/settings/#Agreements');
     });
 
-    test('handleNewAgreementsRoute', () => {
-      router.handleNewAgreementsRoute();
-      assert.isTrue(setStateStub.calledOnce);
-      assert.equal(setStateStub.lastCall.args[0].view, GerritView.AGREEMENTS);
-    });
-
-    test('handleSettingsLegacyRoute', () => {
-      const ctx = {...createPageContext(), params: {0: 'my-token'}};
-      assertctxToParams(ctx, 'handleSettingsLegacyRoute', {
-        view: GerritView.SETTINGS,
-        emailToken: 'my-token',
+    test('NEW_AGREEMENTS', async () => {
+      // NEW_AGREEMENTS: /^\/settings\/new-agreement\/?/,
+      await checkUrlToState('/settings/new-agreement', {
+        view: GerritView.AGREEMENTS,
+      });
+      await checkUrlToState('/settings/new-agreement/', {
+        view: GerritView.AGREEMENTS,
       });
     });
 
-    test('handleSettingsLegacyRoute with +', () => {
-      const ctx = {...createPageContext(), params: {0: 'my-token test'}};
-      assertctxToParams(ctx, 'handleSettingsLegacyRoute', {
+    test('SETTINGS', async () => {
+      // SETTINGS: /^\/settings\/?/,
+      // SETTINGS_LEGACY: /^\/settings\/VE\/(\S+)/,
+      await checkUrlToState('/settings', {view: GerritView.SETTINGS});
+      await checkUrlToState('/settings/', {view: GerritView.SETTINGS});
+      await checkUrlToState('/settings/VE/asdf', {
         view: GerritView.SETTINGS,
-        emailToken: 'my-token+test',
+        emailToken: 'asdf',
       });
-    });
-
-    test('handleSettingsRoute', () => {
-      const ctx = createPageContext();
-      assertctxToParams(ctx, 'handleSettingsRoute', {
+      await checkUrlToState('/settings/VE/asdf%40qwer', {
         view: GerritView.SETTINGS,
+        emailToken: 'asdf@qwer',
       });
     });
 
@@ -365,10 +435,9 @@
       ) => {
         onExit = _onExit;
       };
-      sinon.stub(page, 'exit').callsFake(onRegisteringExit);
+      sinon.stub(page, 'registerExitRoute').callsFake(onRegisteringExit);
       sinon.stub(page, 'start');
-      sinon.stub(page, 'base');
-      router.startRouter();
+      router._testOnly_startRouter();
 
       router.handleDefaultRoute();
 
@@ -378,90 +447,76 @@
       assert.isTrue(handlePassThroughRoute.calledOnce);
     });
 
-    test('handleImproperlyEncodedPlusRoute', () => {
-      const params = {
-        ...createPageContext(),
-        canonicalPath: '/c/test/%20/42',
-        params: {0: 'test', 1: '42'},
-      };
-      // Regression test for Issue 7100.
-      router.handleImproperlyEncodedPlusRoute(params);
-      assert.isTrue(redirectStub.calledOnce);
-      assert.equal(redirectStub.lastCall.args[0], '/c/test/+/42');
-
-      sinon.stub(router, 'getHashFromCanonicalPath').returns('foo');
-      router.handleImproperlyEncodedPlusRoute(params);
-      assert.equal(redirectStub.lastCall.args[0], '/c/test/+/42#foo');
+    test('IMPROPERLY_ENCODED_PLUS', async () => {
+      // IMPROPERLY_ENCODED_PLUS: /^\/c\/(.+)\/ \/(.+)$/,
+      await checkRedirect('/c/repo/ /42', '/c/repo/+/42');
+      await checkRedirect('/c/repo/%20/42', '/c/repo/+/42');
+      await checkRedirect('/c/repo/ /42#foo', '/c/repo/+/42#foo');
     });
 
-    test('handleQueryRoute', () => {
-      const ctx: PageContext = {
-        ...createPageContext(),
-        params: {0: 'project:foo/bar/baz'},
-      };
-      assertctxToParams(ctx, 'handleQueryRoute', {
-        view: GerritView.SEARCH,
+    test('QUERY', async () => {
+      // QUERY: /^\/q\/(.+?)(,(\d+))?$/,
+      await checkUrlToState('/q/asdf', {
+        ...createSearchViewState(),
+        query: 'asdf',
+      });
+      await checkUrlToState('/q/project:foo/bar/baz', {
+        ...createSearchViewState(),
         query: 'project:foo/bar/baz',
-        offset: undefined,
-      } as AppElementParams);
-
-      ctx.params[1] = '123';
-      ctx.params[2] = '123';
-      assertctxToParams(ctx, 'handleQueryRoute', {
-        view: GerritView.SEARCH,
-        query: 'project:foo/bar/baz',
+      });
+      await checkUrlToState('/q/asdf,123', {
+        ...createSearchViewState(),
+        query: 'asdf',
         offset: '123',
-      } as AppElementParams);
+      });
+      await checkUrlToState('/q/asdf,qwer', {
+        ...createSearchViewState(),
+        query: 'asdf,qwer',
+      });
+      await checkUrlToState('/q/asdf,qwer,123', {
+        ...createSearchViewState(),
+        query: 'asdf,qwer',
+        offset: '123',
+      });
     });
 
-    test('handleQueryLegacySuffixRoute', () => {
-      const params = {...createPageContext(), path: '/q/foo+bar,n,z'};
-      router.handleQueryLegacySuffixRoute(params);
-      assert.isTrue(redirectStub.calledOnce);
-      assert.equal(redirectStub.lastCall.args[0], '/q/foo+bar');
+    test('QUERY_LEGACY_SUFFIX', async () => {
+      // QUERY_LEGACY_SUFFIX: /^\/q\/.+,n,z$/,
+      await checkRedirect('/q/foo+bar,n,z', '/q/foo+bar');
     });
 
-    test('handleChangeIdQueryRoute', () => {
-      const ctx = {
-        ...createPageContext(),
-        params: {0: 'I0123456789abcdef0123456789abcdef01234567'},
-      };
-      assertctxToParams(ctx, 'handleChangeIdQueryRoute', {
-        view: GerritView.SEARCH,
+    test('CHANGE_ID_QUERY', async () => {
+      // CHANGE_ID_QUERY: /^\/id\/(I[0-9a-f]{40})$/,
+      await checkUrlToState('/id/I0123456789abcdef0123456789abcdef01234567', {
+        ...createSearchViewState(),
         query: 'I0123456789abcdef0123456789abcdef01234567',
-        offset: undefined,
-      } as AppElementParams);
-    });
-
-    suite('handleRegisterRoute', () => {
-      test('happy path', () => {
-        const ctx = {...createPageContext(), params: {0: '/foo/bar'}};
-        router.handleRegisterRoute(ctx);
-        assert.isTrue(redirectStub.calledWithExactly('/foo/bar'));
-        assert.isTrue(setStateStub.calledOnce);
-        assert.isTrue(setStateStub.lastCall.args[0].justRegistered);
-      });
-
-      test('no param', () => {
-        const ctx = createPageContext();
-        router.handleRegisterRoute(ctx);
-        assert.isTrue(redirectStub.calledWithExactly('/'));
-        assert.isTrue(setStateStub.calledOnce);
-        assert.isTrue(setStateStub.lastCall.args[0].justRegistered);
-      });
-
-      test('prevent redirect', () => {
-        const ctx = {...createPageContext(), params: {0: '/register'}};
-        router.handleRegisterRoute(ctx);
-        assert.isTrue(redirectStub.calledWithExactly('/'));
-        assert.isTrue(setStateStub.calledOnce);
-        assert.isTrue(setStateStub.lastCall.args[0].justRegistered);
       });
     });
 
-    suite('handleRootRoute', () => {
+    test('REGISTER', async () => {
+      // REGISTER: /^\/register(\/.*)?$/,
+      await checkUrlToState('/register/foo/bar', {
+        justRegistered: true,
+      });
+      assert.isTrue(redirectStub.calledWithExactly('/foo/bar'));
+
+      await checkUrlToState('/register', {
+        justRegistered: true,
+      });
+      assert.isTrue(redirectStub.calledWithExactly('/'));
+
+      await checkUrlToState('/register/register', {
+        justRegistered: true,
+      });
+      assert.isTrue(redirectStub.calledWithExactly('/'));
+    });
+
+    suite('ROOT', () => {
       test('closes for closeAfterLogin', () => {
-        const ctx = {...createPageContext(), querystring: 'closeAfterLogin'};
+        const ctx = {
+          querystring: 'closeAfterLogin',
+          canonicalPath: '',
+        } as PageContext;
         const closeStub = sinon.stub(window, 'close');
         const result = router.handleRootRoute(ctx);
         assert.isNotOk(result);
@@ -469,870 +524,566 @@
         assert.isFalse(redirectStub.called);
       });
 
-      test('redirects to dashboard if logged in', () => {
-        const ctx = {...createPageContext(), canonicalPath: '/', path: '/'};
-        const result = router.handleRootRoute(ctx);
-        assert.isOk(result);
-        return result!.then(() => {
-          assert.isTrue(redirectStub.calledWithExactly('/dashboard/self'));
-        });
+      test('ROOT logged in', async () => {
+        stubRestApi('getLoggedIn').resolves(true);
+        await checkRedirect('/', '/dashboard/self');
       });
 
-      test('redirects to open changes if not logged in', () => {
-        stubRestApi('getLoggedIn').returns(Promise.resolve(false));
-        const ctx = {...createPageContext(), canonicalPath: '/', path: '/'};
-        const result = router.handleRootRoute(ctx);
-        assert.isOk(result);
-        return result!.then(() => {
-          assert.isTrue(
-            redirectStub.calledWithExactly('/q/status:open+-is:wip')
-          );
-        });
+      test('ROOT not logged in', async () => {
+        stubRestApi('getLoggedIn').resolves(false);
+        await checkRedirect('/', '/q/status:open+-is:wip');
       });
 
-      suite('GWT hash-path URLs', () => {
-        test('redirects hash-path URLs', () => {
-          const ctx = {
-            ...createPageContext(),
-            canonicalPath: '/#/foo/bar/baz',
-            hash: '/foo/bar/baz',
-          };
-          const result = router.handleRootRoute(ctx);
-          assert.isNotOk(result);
-          assert.isTrue(redirectStub.called);
-          assert.isTrue(redirectStub.calledWithExactly('/foo/bar/baz'));
+      suite('ROOT GWT hash-path URLs', () => {
+        test('ROOT hash-path URLs', async () => {
+          await checkRedirect('/#/foo/bar/baz', '/foo/bar/baz');
         });
 
-        test('redirects hash-path URLs w/o leading slash', () => {
-          const ctx = {
-            ...createPageContext(),
-            canonicalPath: '/#foo/bar/baz',
-            hash: 'foo/bar/baz',
-          };
-          const result = router.handleRootRoute(ctx);
-          assert.isNotOk(result);
-          assert.isTrue(redirectStub.called);
-          assert.isTrue(redirectStub.calledWithExactly('/foo/bar/baz'));
+        test('ROOT hash-path URLs w/o leading slash', async () => {
+          await checkRedirect('/#foo/bar/baz', '/foo/bar/baz');
         });
 
-        test('normalizes "/ /" in hash to "/+/"', () => {
-          const ctx = {
-            ...createPageContext(),
-            canonicalPath: '/#/foo/bar/+/123/4',
-            hash: '/foo/bar/ /123/4',
-          };
-          const result = router.handleRootRoute(ctx);
-          assert.isNotOk(result);
-          assert.isTrue(redirectStub.called);
-          assert.isTrue(redirectStub.calledWithExactly('/foo/bar/+/123/4'));
+        test('ROOT normalizes "/ /" in hash to "/+/"', async () => {
+          await checkRedirect('/#/foo/bar/+/123/4', '/foo/bar/+/123/4');
         });
 
-        test('prepends baseurl to hash-path', () => {
-          const ctx = {
-            ...createPageContext(),
-            canonicalPath: '/#/foo/bar',
-            hash: '/foo/bar',
-          };
+        test('ROOT prepends baseurl to hash-path', async () => {
           stubBaseUrl('/baz');
-          const result = router.handleRootRoute(ctx);
-          assert.isNotOk(result);
-          assert.isTrue(redirectStub.called);
-          assert.isTrue(redirectStub.calledWithExactly('/baz/foo/bar'));
+          await checkRedirect('/#/foo/bar', '/baz/foo/bar');
         });
 
-        test('normalizes /VE/ settings hash-paths', () => {
-          const ctx = {
-            ...createPageContext(),
-            canonicalPath: '/#/VE/foo/bar',
-            hash: '/VE/foo/bar',
-          };
-          const result = router.handleRootRoute(ctx);
-          assert.isNotOk(result);
-          assert.isTrue(redirectStub.called);
-          assert.isTrue(redirectStub.calledWithExactly('/settings/VE/foo/bar'));
+        test('ROOT normalizes /VE/ settings hash-paths', async () => {
+          await checkRedirect('/#/VE/foo/bar', '/settings/VE/foo/bar');
         });
 
-        test('does not drop "inner hashes"', () => {
-          const ctx = {
-            ...createPageContext(),
-            canonicalPath: '/#/foo/bar#baz',
-            hash: '/foo/bar',
-          };
-          const result = router.handleRootRoute(ctx);
-          assert.isNotOk(result);
-          assert.isTrue(redirectStub.called);
-          assert.isTrue(redirectStub.calledWithExactly('/foo/bar#baz'));
+        test('ROOT does not drop "inner hashes"', async () => {
+          await checkRedirect('/#/foo/bar#baz', '/foo/bar#baz');
         });
       });
     });
 
-    suite('handleDashboardRoute', () => {
-      let redirectToLoginStub: sinon.SinonStub;
-
-      setup(() => {
-        redirectToLoginStub = sinon.stub(router, 'redirectToLogin');
+    suite('DASHBOARD', () => {
+      test('DASHBOARD own dashboard but signed out redirects to login', async () => {
+        stubRestApi('getLoggedIn').resolves(false);
+        await checkRedirectToLogin('/dashboard/seLF', '/dashboard/seLF');
       });
 
-      test('own dashboard but signed out redirects to login', () => {
-        stubRestApi('getLoggedIn').returns(Promise.resolve(false));
-        const ctx = {
-          ...createPageContext(),
-          canonicalPath: '/dashboard/',
-          params: {0: 'seLF'},
-        };
-        return router.handleDashboardRoute(ctx).then(() => {
-          assert.isTrue(redirectToLoginStub.calledOnce);
-          assert.isFalse(redirectStub.called);
-          assert.isFalse(setStateStub.called);
-        });
+      test('DASHBOARD non-self dashboard but signed out redirects', async () => {
+        stubRestApi('getLoggedIn').resolves(false);
+        await checkRedirect('/dashboard/foo', '/q/owner:foo');
       });
 
-      test('non-self dashboard but signed out does not redirect', () => {
-        stubRestApi('getLoggedIn').returns(Promise.resolve(false));
-        const ctx = {
-          ...createPageContext(),
-          canonicalPath: '/dashboard/',
-          params: {0: 'foo'},
-        };
-        return router.handleDashboardRoute(ctx).then(() => {
-          assert.isFalse(redirectToLoginStub.called);
-          assert.isFalse(setStateStub.called);
-          assert.isTrue(redirectStub.calledOnce);
-          assert.equal(redirectStub.lastCall.args[0], '/q/owner:foo');
-        });
-      });
-
-      test('dashboard while signed in sets params', () => {
-        const ctx = {
-          ...createPageContext(),
-          canonicalPath: '/dashboard/',
-          params: {0: 'foo'},
-        };
-        return router.handleDashboardRoute(ctx).then(() => {
-          assert.isFalse(redirectToLoginStub.called);
-          assert.isFalse(redirectStub.called);
-          assert.isTrue(setStateStub.calledOnce);
-          assert.deepEqual(setStateStub.lastCall.args[0], {
-            view: GerritView.DASHBOARD,
-            user: 'foo',
-          });
+      test('DASHBOARD', async () => {
+        // DASHBOARD: /^\/dashboard\/(.+)$/,
+        await checkUrlToState('/dashboard/foo', {
+          ...createDashboardViewState(),
+          user: 'foo',
         });
       });
     });
 
-    suite('handleCustomDashboardRoute', () => {
-      let redirectToLoginStub: sinon.SinonStub;
-
-      setup(() => {
-        redirectToLoginStub = sinon.stub(router, 'redirectToLogin');
+    suite('CUSTOM_DASHBOARD', () => {
+      test('CUSTOM_DASHBOARD no user specified', async () => {
+        await checkRedirect('/dashboard/', '/dashboard/self');
       });
 
-      test('no user specified', () => {
-        const ctx: PageContext = {
-          ...createPageContext(),
-          canonicalPath: '/dashboard/',
-          params: {0: ''},
-          querystring: '',
-        };
-        return router.handleCustomDashboardRoute(ctx).then(() => {
-          assert.isFalse(setStateStub.called);
-          assert.isTrue(redirectStub.called);
-          assert.equal(redirectStub.lastCall.args[0], '/dashboard/self');
+      test('CUSTOM_DASHBOARD', async () => {
+        // CUSTOM_DASHBOARD: /^\/dashboard\/?$/,
+        await checkUrlToState('/dashboard?title=Custom Dashboard&a=b&d=e', {
+          ...createDashboardViewState(),
+          sections: [
+            {name: 'a', query: 'b'},
+            {name: 'd', query: 'e'},
+          ],
+          title: 'Custom Dashboard',
         });
-      });
-
-      test('custom dashboard without title', () => {
-        const ctx: PageContext = {
-          ...createPageContext(),
-          canonicalPath: '/dashboard/',
-          params: {0: ''},
-          querystring: '?a=b&c&d=e',
-        };
-        return router.handleCustomDashboardRoute(ctx).then(() => {
-          assert.isFalse(redirectStub.called);
-          assert.isTrue(setStateStub.calledOnce);
-          assert.deepEqual(setStateStub.lastCall.args[0], {
-            view: GerritView.DASHBOARD,
-            user: 'self',
-            sections: [
-              {name: 'a', query: 'b'},
-              {name: 'd', query: 'e'},
-            ],
-            title: 'Custom Dashboard',
-          });
-        });
-      });
-
-      test('custom dashboard with title', () => {
-        const ctx: PageContext = {
-          ...createPageContext(),
-          canonicalPath: '/dashboard/',
-          params: {0: ''},
-          querystring: '?a=b&c&d=&=e&title=t',
-        };
-        return router.handleCustomDashboardRoute(ctx).then(() => {
-          assert.isFalse(redirectToLoginStub.called);
-          assert.isFalse(redirectStub.called);
-          assert.isTrue(setStateStub.calledOnce);
-          assert.deepEqual(setStateStub.lastCall.args[0], {
-            view: GerritView.DASHBOARD,
-            user: 'self',
-            sections: [{name: 'a', query: 'b'}],
-            title: 't',
-          });
-        });
-      });
-
-      test('custom dashboard with foreach', () => {
-        const ctx: PageContext = {
-          ...createPageContext(),
-          canonicalPath: '/dashboard/',
-          params: {0: ''},
-          querystring: '?a=b&c&d=&=e&foreach=is:open',
-        };
-        return router.handleCustomDashboardRoute(ctx).then(() => {
-          assert.isFalse(redirectToLoginStub.called);
-          assert.isFalse(redirectStub.called);
-          assert.isTrue(setStateStub.calledOnce);
-          assert.deepEqual(setStateStub.lastCall.args[0], {
-            view: GerritView.DASHBOARD,
-            user: 'self',
-            sections: [{name: 'a', query: 'is:open b'}],
-            title: 'Custom Dashboard',
-          });
+        await checkUrlToState('/dashboard?a=b&c&d=&=e&foreach=is:open', {
+          ...createDashboardViewState(),
+          sections: [{name: 'a', query: 'is:open b'}],
+          title: 'Custom Dashboard',
         });
       });
     });
 
     suite('group routes', () => {
-      test('handleGroupInfoRoute', () => {
-        const ctx = {...createPageContext(), params: {0: '1234'}};
-        router.handleGroupInfoRoute(ctx);
-        assert.isTrue(redirectStub.calledOnce);
-        assert.equal(redirectStub.lastCall.args[0], '/admin/groups/1234');
+      test('GROUP_INFO', async () => {
+        // GROUP_INFO: /^\/admin\/groups\/(?:uuid-)?(.+),info$/,
+        await checkRedirect('/admin/groups/1234,info', '/admin/groups/1234');
       });
 
-      test('handleGroupAuditLogRoute', () => {
-        const ctx = {...createPageContext(), params: {0: '1234'}};
-        assertctxToParams(ctx, 'handleGroupAuditLogRoute', {
-          view: GerritView.GROUP,
+      test('GROUP_AUDIT_LOG', async () => {
+        // GROUP_AUDIT_LOG: /^\/admin\/groups\/(?:uuid-)?(.+),audit-log$/,
+        await checkUrlToState('/admin/groups/1234,audit-log', {
+          ...createGroupViewState(),
           detail: GroupDetailView.LOG,
-          groupId: '1234' as GroupId,
+          groupId: '1234',
         });
       });
 
-      test('handleGroupMembersRoute', () => {
-        const ctx = {...createPageContext(), params: {0: '1234'}};
-        assertctxToParams(ctx, 'handleGroupMembersRoute', {
-          view: GerritView.GROUP,
+      test('GROUP_MEMBERS', async () => {
+        // GROUP_MEMBERS: /^\/admin\/groups\/(?:uuid-)?(.+),members$/,
+        await checkUrlToState('/admin/groups/1234,members', {
+          ...createGroupViewState(),
           detail: GroupDetailView.MEMBERS,
-          groupId: '1234' as GroupId,
+          groupId: '1234',
         });
       });
 
-      test('handleGroupListOffsetRoute', () => {
-        const ctx = createPageContext();
-        assertctxToParams(ctx, 'handleGroupListOffsetRoute', {
-          view: GerritView.ADMIN,
-          adminView: AdminChildView.GROUPS,
-          offset: 0,
-          filter: null,
-          openCreateModal: false,
-        });
+      test('GROUP_LIST', async () => {
+        // GROUP_LIST: /^\/admin\/groups(\/q\/filter:(.*?))?(,(\d+))?(\/)?$/,
 
-        ctx.params[1] = '42';
-        assertctxToParams(ctx, 'handleGroupListOffsetRoute', {
+        const defaultState: AdminViewState = {
           view: GerritView.ADMIN,
           adminView: AdminChildView.GROUPS,
-          offset: '42',
-          filter: null,
+          offset: '0',
           openCreateModal: false,
-        });
+          filter: '',
+        };
 
-        ctx.hash = 'create';
-        assertctxToParams(ctx, 'handleGroupListOffsetRoute', {
-          view: GerritView.ADMIN,
-          adminView: AdminChildView.GROUPS,
-          offset: '42',
-          filter: null,
+        await checkUrlToState('/admin/groups', defaultState);
+        await checkUrlToState('/admin/groups/', defaultState);
+        await checkUrlToState('/admin/groups#create', {
+          ...defaultState,
           openCreateModal: true,
         });
-      });
-
-      test('handleGroupListFilterOffsetRoute', () => {
-        const ctx = {
-          ...createPageContext(),
-          params: {filter: 'foo', offset: '42'},
-        };
-        assertctxToParams(ctx, 'handleGroupListFilterOffsetRoute', {
-          view: GerritView.ADMIN,
-          adminView: AdminChildView.GROUPS,
+        await checkUrlToState('/admin/groups,42', {
+          ...defaultState,
           offset: '42',
+        });
+        // #create is ignored when there is an offset
+        await checkUrlToState('/admin/groups,42#create', {
+          ...defaultState,
+          offset: '42',
+        });
+
+        await checkUrlToState('/admin/groups/q/filter:foo', {
+          ...defaultState,
           filter: 'foo',
         });
+        await checkUrlToState('/admin/groups/q/filter:foo/%2F%20%2525%252F', {
+          ...defaultState,
+          filter: 'foo// %/',
+        });
+        await checkUrlToState('/admin/groups/q/filter:foo,42', {
+          ...defaultState,
+          filter: 'foo',
+          offset: '42',
+        });
+        // #create is ignored when filtering
+        await checkUrlToState('/admin/groups/q/filter:foo,42#create', {
+          ...defaultState,
+          filter: 'foo',
+          offset: '42',
+        });
       });
 
-      test('handleGroupListFilterRoute', () => {
-        const ctx = {...createPageContext(), params: {filter: 'foo'}};
-        assertctxToParams(ctx, 'handleGroupListFilterRoute', {
-          view: GerritView.ADMIN,
-          adminView: AdminChildView.GROUPS,
-          filter: 'foo',
-        });
-      });
-
-      test('handleGroupRoute', () => {
-        const ctx = {...createPageContext(), params: {0: '4321'}};
-        assertctxToParams(ctx, 'handleGroupRoute', {
-          view: GerritView.GROUP,
-          groupId: '4321' as GroupId,
+      test('GROUP', async () => {
+        // GROUP: /^\/admin\/groups\/(?:uuid-)?([^,]+)$/,
+        await checkUrlToState('/admin/groups/4321', {
+          ...createGroupViewState(),
+          groupId: '4321',
         });
       });
     });
 
-    suite('repo routes', () => {
-      test('handleProjectsOldRoute', () => {
-        const ctx = {...createPageContext(), params: {}};
-        router.handleProjectsOldRoute(ctx);
-        assert.isTrue(redirectStub.calledOnce);
-        assert.equal(redirectStub.lastCall.args[0], '/admin/repos/');
-      });
-
-      test('handleProjectsOldRoute test', () => {
-        const ctx = {...createPageContext(), params: {1: 'test'}};
-        router.handleProjectsOldRoute(ctx);
-        assert.isTrue(redirectStub.calledOnce);
-        assert.equal(redirectStub.lastCall.args[0], '/admin/repos/test');
-      });
-
-      test('handleProjectsOldRoute test,branches', () => {
-        const ctx = {...createPageContext(), params: {1: 'test,branches'}};
-        router.handleProjectsOldRoute(ctx);
-        assert.isTrue(redirectStub.calledOnce);
-        assert.equal(
-          redirectStub.lastCall.args[0],
+    suite('REPO*', () => {
+      test('PROJECT_OLD', async () => {
+        // PROJECT_OLD: /^\/admin\/(projects)\/?(.+)?$/,
+        await checkRedirect('/admin/projects/', '/admin/repos/');
+        await checkRedirect('/admin/projects/test', '/admin/repos/test');
+        await checkRedirect(
+          '/admin/projects/test,branches',
           '/admin/repos/test,branches'
         );
       });
 
-      test('handleRepoRoute', () => {
-        const ctx = {...createPageContext(), path: '/admin/repos/test'};
-        router.handleRepoRoute(ctx);
-        assert.isTrue(redirectStub.calledOnce);
-        assert.equal(
-          redirectStub.lastCall.args[0],
-          '/admin/repos/test,general'
-        );
+      test('REPO', async () => {
+        // REPO: /^\/admin\/repos\/([^,]+)$/,
+        await checkRedirect('/admin/repos/test', '/admin/repos/test,general');
       });
 
-      test('handleRepoGeneralRoute', () => {
-        const ctx = {...createPageContext(), params: {0: '4321'}};
-        assertctxToParams(ctx, 'handleRepoGeneralRoute', {
-          view: GerritView.REPO,
+      test('REPO_GENERAL', async () => {
+        // REPO_GENERAL: /^\/admin\/repos\/(.+),general$/,
+        await checkUrlToState('/admin/repos/4321,general', {
+          ...createRepoViewState(),
           detail: RepoDetailView.GENERAL,
           repo: '4321' as RepoName,
         });
       });
 
-      test('handleRepoCommandsRoute', () => {
-        const ctx = {...createPageContext(), params: {0: '4321'}};
-        assertctxToParams(ctx, 'handleRepoCommandsRoute', {
-          view: GerritView.REPO,
+      test('REPO_COMMANDS', async () => {
+        // REPO_COMMANDS: /^\/admin\/repos\/(.+),commands$/,
+        await checkUrlToState('/admin/repos/4321,commands', {
+          ...createRepoViewState(),
           detail: RepoDetailView.COMMANDS,
           repo: '4321' as RepoName,
         });
       });
 
-      test('handleRepoAccessRoute', () => {
-        const ctx = {...createPageContext(), params: {0: '4321'}};
-        assertctxToParams(ctx, 'handleRepoAccessRoute', {
-          view: GerritView.REPO,
+      test('REPO_ACCESS', async () => {
+        // REPO_ACCESS: /^\/admin\/repos\/(.+),access$/,
+        await checkUrlToState('/admin/repos/4321,access', {
+          ...createRepoViewState(),
           detail: RepoDetailView.ACCESS,
           repo: '4321' as RepoName,
         });
       });
 
-      suite('branch list routes', () => {
-        test('handleBranchListOffsetRoute', () => {
-          const ctx: PageContext = {
-            ...createPageContext(),
-            params: {0: '4321'},
-          };
-          assertctxToParams(ctx, 'handleBranchListOffsetRoute', {
-            view: GerritView.REPO,
-            detail: RepoDetailView.BRANCHES,
-            repo: '4321' as RepoName,
-            offset: 0,
-            filter: null,
-          });
-
-          ctx.params[2] = '42';
-          assertctxToParams(ctx, 'handleBranchListOffsetRoute', {
-            view: GerritView.REPO,
-            detail: RepoDetailView.BRANCHES,
-            repo: '4321' as RepoName,
-            offset: '42',
-            filter: null,
-          });
+      test('BRANCH_LIST', async () => {
+        await checkUrlToState('/admin/repos/4321,branches', {
+          ...createRepoBranchesViewState(),
+          repo: '4321' as RepoName,
         });
-
-        test('handleBranchListFilterOffsetRoute', () => {
-          const ctx = {
-            ...createPageContext(),
-            params: {repo: '4321', filter: 'foo', offset: '42'},
-          };
-          assertctxToParams(ctx, 'handleBranchListFilterOffsetRoute', {
-            view: GerritView.REPO,
-            detail: RepoDetailView.BRANCHES,
-            repo: '4321' as RepoName,
-            offset: '42',
-            filter: 'foo',
-          });
+        await checkUrlToState('/admin/repos/4321,branches,42', {
+          ...createRepoBranchesViewState(),
+          repo: '4321' as RepoName,
+          offset: '42',
         });
-
-        test('handleBranchListFilterRoute', () => {
-          const ctx = {
-            ...createPageContext(),
-            params: {repo: '4321', filter: 'foo'},
-          };
-          assertctxToParams(ctx, 'handleBranchListFilterRoute', {
-            view: GerritView.REPO,
-            detail: RepoDetailView.BRANCHES,
-            repo: '4321' as RepoName,
-            filter: 'foo',
-          });
+        await checkUrlToState('/admin/repos/4321,branches/q/filter:foo,42', {
+          ...createRepoBranchesViewState(),
+          repo: '4321' as RepoName,
+          offset: '42',
+          filter: 'foo',
         });
+        await checkUrlToState('/admin/repos/4321,branches/q/filter:foo', {
+          ...createRepoBranchesViewState(),
+          repo: '4321' as RepoName,
+          filter: 'foo',
+        });
+        await checkUrlToState(
+          '/admin/repos/asdf/%2F%20%2525%252Fqwer,branches/q/filter:foo/%2F%20%2525%252F',
+          {
+            ...createRepoBranchesViewState(),
+            repo: 'asdf// %/qwer' as RepoName,
+            filter: 'foo// %/',
+          }
+        );
       });
 
-      suite('tag list routes', () => {
-        test('handleTagListOffsetRoute', () => {
-          const ctx = {...createPageContext(), params: {0: '4321'}};
-          assertctxToParams(ctx, 'handleTagListOffsetRoute', {
-            view: GerritView.REPO,
-            detail: RepoDetailView.TAGS,
-            repo: '4321' as RepoName,
-            offset: 0,
-            filter: null,
-          });
+      test('TAG_LIST', async () => {
+        await checkUrlToState('/admin/repos/4321,tags', {
+          ...createRepoTagsViewState(),
+          repo: '4321' as RepoName,
         });
-
-        test('handleTagListFilterOffsetRoute', () => {
-          const ctx = {
-            ...createPageContext(),
-            params: {repo: '4321', filter: 'foo', offset: '42'},
-          };
-          assertctxToParams(ctx, 'handleTagListFilterOffsetRoute', {
-            view: GerritView.REPO,
-            detail: RepoDetailView.TAGS,
-            repo: '4321' as RepoName,
-            offset: '42',
-            filter: 'foo',
-          });
+        await checkUrlToState('/admin/repos/4321,tags,42', {
+          ...createRepoTagsViewState(),
+          repo: '4321' as RepoName,
+          offset: '42',
         });
-
-        test('handleTagListFilterRoute', () => {
-          const ctx: PageContext = {
-            ...createPageContext(),
-            params: {repo: '4321'},
-          };
-          assertctxToParams(ctx, 'handleTagListFilterRoute', {
-            view: GerritView.REPO,
-            detail: RepoDetailView.TAGS,
-            repo: '4321' as RepoName,
-            filter: null,
-          });
-
-          ctx.params.filter = 'foo';
-          assertctxToParams(ctx, 'handleTagListFilterRoute', {
-            view: GerritView.REPO,
-            detail: RepoDetailView.TAGS,
-            repo: '4321' as RepoName,
-            filter: 'foo',
-          });
+        await checkUrlToState('/admin/repos/4321,tags/q/filter:foo,42', {
+          ...createRepoTagsViewState(),
+          repo: '4321' as RepoName,
+          offset: '42',
+          filter: 'foo',
         });
+        await checkUrlToState('/admin/repos/4321,tags/q/filter:foo', {
+          ...createRepoTagsViewState(),
+          repo: '4321' as RepoName,
+          filter: 'foo',
+        });
+        await checkUrlToState(
+          '/admin/repos/asdf/%2F%20%2525%252Fqwer,tags/q/filter:foo/%2F%20%2525%252F',
+          {
+            ...createRepoTagsViewState(),
+            repo: 'asdf// %/qwer' as RepoName,
+            filter: 'foo// %/',
+          }
+        );
       });
 
-      suite('repo list routes', () => {
-        test('handleRepoListOffsetRoute', () => {
-          const ctx = createPageContext();
-          assertctxToParams(ctx, 'handleRepoListOffsetRoute', {
-            view: GerritView.ADMIN,
-            adminView: AdminChildView.REPOS,
-            offset: 0,
-            filter: null,
-            openCreateModal: false,
-          });
-
-          ctx.params[1] = '42';
-          assertctxToParams(ctx, 'handleRepoListOffsetRoute', {
-            view: GerritView.ADMIN,
-            adminView: AdminChildView.REPOS,
-            offset: '42',
-            filter: null,
-            openCreateModal: false,
-          });
-
-          ctx.hash = 'create';
-          assertctxToParams(ctx, 'handleRepoListOffsetRoute', {
-            view: GerritView.ADMIN,
-            adminView: AdminChildView.REPOS,
-            offset: '42',
-            filter: null,
-            openCreateModal: true,
-          });
+      test('REPO_LIST', async () => {
+        await checkUrlToState('/admin/repos', {
+          ...createAdminReposViewState(),
         });
-
-        test('handleRepoListFilterOffsetRoute', () => {
-          const ctx = {
-            ...createPageContext(),
-            params: {filter: 'foo', offset: '42'},
-          };
-          assertctxToParams(ctx, 'handleRepoListFilterOffsetRoute', {
-            view: GerritView.ADMIN,
-            adminView: AdminChildView.REPOS,
-            offset: '42',
-            filter: 'foo',
-          });
+        await checkUrlToState('/admin/repos/', {
+          ...createAdminReposViewState(),
         });
-
-        test('handleRepoListFilterRoute', () => {
-          const ctx = createPageContext();
-          assertctxToParams(ctx, 'handleRepoListFilterRoute', {
-            view: GerritView.ADMIN,
-            adminView: AdminChildView.REPOS,
-            filter: null,
-          });
-
-          ctx.params.filter = 'foo';
-          assertctxToParams(ctx, 'handleRepoListFilterRoute', {
-            view: GerritView.ADMIN,
-            adminView: AdminChildView.REPOS,
-            filter: 'foo',
-          });
+        await checkUrlToState('/admin/repos,42', {
+          ...createAdminReposViewState(),
+          offset: '42',
+        });
+        await checkUrlToState('/admin/repos#create', {
+          ...createAdminReposViewState(),
+          openCreateModal: true,
+        });
+        await checkUrlToState('/admin/repos/q/filter:foo', {
+          ...createAdminReposViewState(),
+          filter: 'foo',
+        });
+        await checkUrlToState('/admin/repos/q/filter:foo/%2F%20%2525%252F', {
+          ...createAdminReposViewState(),
+          filter: 'foo// %/',
+        });
+        await checkUrlToState('/admin/repos/q/filter:foo,42', {
+          ...createAdminReposViewState(),
+          filter: 'foo',
+          offset: '42',
         });
       });
     });
 
-    suite('plugin routes', () => {
-      test('handlePluginListOffsetRoute', () => {
-        const ctx = createPageContext();
-        assertctxToParams(ctx, 'handlePluginListOffsetRoute', {
-          view: GerritView.ADMIN,
-          adminView: AdminChildView.PLUGINS,
-          offset: 0,
-          filter: null,
-        });
-
-        ctx.params[1] = '42';
-        assertctxToParams(ctx, 'handlePluginListOffsetRoute', {
-          view: GerritView.ADMIN,
-          adminView: AdminChildView.PLUGINS,
-          offset: '42',
-          filter: null,
-        });
+    test('PLUGIN_LIST', async () => {
+      await checkUrlToState('/admin/plugins', {
+        ...createAdminPluginsViewState(),
       });
-
-      test('handlePluginListFilterOffsetRoute', () => {
-        const ctx = {
-          ...createPageContext(),
-          params: {filter: 'foo', offset: '42'},
-        };
-        assertctxToParams(ctx, 'handlePluginListFilterOffsetRoute', {
-          view: GerritView.ADMIN,
-          adminView: AdminChildView.PLUGINS,
-          offset: '42',
-          filter: 'foo',
-        });
+      await checkUrlToState('/admin/plugins/', {
+        ...createAdminPluginsViewState(),
       });
-
-      test('handlePluginListFilterRoute', () => {
-        const ctx = createPageContext();
-        assertctxToParams(ctx, 'handlePluginListFilterRoute', {
-          view: GerritView.ADMIN,
-          adminView: AdminChildView.PLUGINS,
-          filter: null,
-        });
-
-        ctx.params.filter = 'foo';
-        assertctxToParams(ctx, 'handlePluginListFilterRoute', {
-          view: GerritView.ADMIN,
-          adminView: AdminChildView.PLUGINS,
-          filter: 'foo',
-        });
+      await checkUrlToState('/admin/plugins,42', {
+        ...createAdminPluginsViewState(),
+        offset: '42',
       });
-
-      test('handlePluginListRoute', () => {
-        const ctx = createPageContext();
-        assertctxToParams(ctx, 'handlePluginListRoute', {
-          view: GerritView.ADMIN,
-          adminView: AdminChildView.PLUGINS,
-        });
+      await checkUrlToState('/admin/plugins/q/filter:foo', {
+        ...createAdminPluginsViewState(),
+        filter: 'foo',
+      });
+      await checkUrlToState('/admin/plugins/q/filter:foo%2F%20%2525%252F', {
+        ...createAdminPluginsViewState(),
+        filter: 'foo/ %/',
+      });
+      await checkUrlToState('/admin/plugins/q/filter:foo,42', {
+        ...createAdminPluginsViewState(),
+        offset: '42',
+        filter: 'foo',
+      });
+      await checkUrlToState('/admin/plugins/q/filter:foo,asdf', {
+        ...createAdminPluginsViewState(),
+        filter: 'foo,asdf',
       });
     });
 
-    suite('change/diff routes', () => {
-      test('handleChangeNumberLegacyRoute', () => {
-        const ctx = {...createPageContext(), params: {0: '12345'}};
-        router.handleChangeNumberLegacyRoute(ctx);
-        assert.isTrue(redirectStub.calledOnce);
-        assert.isTrue(redirectStub.calledWithExactly('/c/12345'));
+    suite('CHANGE* / DIFF*', () => {
+      test('CHANGE_NUMBER_LEGACY', async () => {
+        // CHANGE_NUMBER_LEGACY: /^\/(\d+)\/?/,
+        await checkRedirect('/12345', '/c/12345');
       });
 
-      test('handleChangeLegacyRoute', async () => {
-        stubRestApi('getFromProjectLookup').returns(
-          Promise.resolve('project' as RepoName)
-        );
-        const ctx = {
-          ...createPageContext(),
-          params: {0: '1234', 1: 'comment/6789'},
-        };
-        router.handleChangeLegacyRoute(ctx);
-        await waitEventLoop();
-        assert.isTrue(
-          redirectStub.calledWithExactly('/c/project/+/1234' + '/comment/6789')
+      test('CHANGE_LEGACY', async () => {
+        // CHANGE_LEGACY: /^\/c\/(\d+)\/?(.*)$/,
+        stubRestApi('getFromProjectLookup').resolves('project' as RepoName);
+        await checkRedirect('/c/1234', '/c/project/+/1234/');
+        await checkRedirect(
+          '/c/1234/comment/6789',
+          '/c/project/+/1234/comment/6789'
         );
       });
 
-      test('handleLegacyLinenum w/ @321', () => {
-        const ctx = {...createPageContext(), path: '/c/1234/3..8/foo/bar@321'};
-        router.handleLegacyLinenum(ctx);
-        assert.isTrue(redirectStub.calledOnce);
-        assert.isTrue(
-          redirectStub.calledWithExactly('/c/1234/3..8/foo/bar#321')
+      test('DIFF_LEGACY_LINENUM', async () => {
+        await checkRedirect(
+          '/c/1234/3..8/foo/bar@321',
+          '/c/1234/3..8/foo/bar#321'
+        );
+        await checkRedirect(
+          '/c/1234/3..8/foo/bar@b321',
+          '/c/1234/3..8/foo/bar#b321'
         );
       });
 
-      test('handleLegacyLinenum w/ @b123', () => {
-        const ctx = {...createPageContext(), path: '/c/1234/3..8/foo/bar@b123'};
-        router.handleLegacyLinenum(ctx);
-        assert.isTrue(redirectStub.calledOnce);
-        assert.isTrue(
-          redirectStub.calledWithExactly('/c/1234/3..8/foo/bar#b123')
-        );
-      });
-
-      suite('handleChangeRoute', () => {
-        function makeParams(_path: string, _hash: string): PageContext {
-          return {
-            ...createPageContext(),
-            params: {
-              0: 'foo/bar', // 0 Project
-              1: '1234', // 1 Change number
-              2: '', // 2 Unused
-              3: '', // 3 Unused
-              4: '4', // 4 Base patch number
-              5: '', // 5 Unused
-              6: '7', // 6 Patch number
-            },
-          };
-        }
-
-        setup(() => {
-          stubRestApi('setInProjectLookup');
+      test('CHANGE', async () => {
+        // CHANGE: /^\/c\/(.+)\/\+\/(\d+)(\/?((-?\d+|edit)(\.\.(\d+|edit))?))?\/?$/,
+        await checkUrlToState('/c/test-project/+/42', {
+          ...createChangeViewState(),
+          basePatchNum: undefined,
+          patchNum: undefined,
         });
-
-        test('change view', () => {
-          const ctx = makeParams('', '');
-          assertctxToParams(ctx, 'handleChangeRoute', {
-            view: GerritView.CHANGE,
-            project: 'foo/bar' as RepoName,
-            changeNum: 1234 as NumericChangeId,
-            basePatchNum: 4 as BasePatchSetNum,
-            patchNum: 7 as RevisionPatchSetNum,
-          });
-          assert.isFalse(redirectStub.called);
+        await checkUrlToState('/c/test-project/+/42/7', {
+          ...createChangeViewState(),
+          basePatchNum: PARENT,
+          patchNum: 7,
         });
-
-        test('params', () => {
-          const ctx = makeParams('', '');
-          const queryMap = new URLSearchParams();
-          queryMap.set('tab', 'checks');
-          queryMap.set('filter', 'fff');
-          queryMap.set('select', 'sss');
-          queryMap.set('attempt', '1');
-          queryMap.set('checksRunsSelected', 'asdf,qwer');
-          queryMap.set('checksResultsFilter', 'asdf.*qwer');
-          ctx.querystring = queryMap.toString();
-          assertctxToParams(ctx, 'handleChangeRoute', {
-            view: GerritView.CHANGE,
-            project: 'foo/bar' as RepoName,
-            changeNum: 1234 as NumericChangeId,
-            basePatchNum: 4 as BasePatchSetNum,
-            patchNum: 7 as RevisionPatchSetNum,
+        await checkUrlToState('/c/test-project/+/42/4..7', {
+          ...createChangeViewState(),
+          basePatchNum: 4,
+          patchNum: 7,
+        });
+        await checkUrlToState(
+          '/c/test-project/+/42/4..7?tab=checks&filter=fff&attempt=1&checksRunsSelected=asdf,qwer&checksResultsFilter=asdf.*qwer',
+          {
+            ...createChangeViewState(),
+            basePatchNum: 4,
+            patchNum: 7,
             attempt: 1,
             filter: 'fff',
             tab: 'checks',
             checksRunsSelected: new Set(['asdf', 'qwer']),
             checksResultsFilter: 'asdf.*qwer',
-          });
-        });
+          }
+        );
+      });
+
+      test('COMMENTS_TAB', async () => {
+        // COMMENTS_TAB: /^\/c\/(.+)\/\+\/(\d+)\/comments(?:\/)?(\w+)?\/?$/,
+        await checkUrlToState(
+          '/c/gerrit/+/264833/comments/00049681_f34fd6a9/',
+          {
+            ...createChangeViewState(),
+            repo: 'gerrit' as RepoName,
+            changeNum: 264833 as NumericChangeId,
+            commentId: '00049681_f34fd6a9' as UrlEncodedCommentId,
+            view: GerritView.CHANGE,
+            childView: ChangeChildView.OVERVIEW,
+          }
+        );
       });
 
       suite('handleDiffRoute', () => {
-        function makeParams(path: string, hash: string): PageContext {
-          return {
-            ...createPageContext(),
-            hash,
-            params: {
-              0: 'foo/bar', // 0 Project
-              1: '1234', // 1 Change number
-              2: '', // 2 Unused
-              3: '', // 3 Unused
-              4: '4', // 4 Base patch number
-              5: '', // 5 Unused
-              6: '7', // 6 Patch number
-              7: '', // 7 Unused,
-              8: path, // 8 Diff path
-            },
-          };
-        }
-
-        setup(() => {
-          stubRestApi('setInProjectLookup');
-        });
-
-        test('diff view', () => {
-          const ctx = makeParams('foo/bar/baz', 'b44');
-          assertctxToParams(ctx, 'handleDiffRoute', {
-            view: GerritView.DIFF,
-            project: 'foo/bar' as RepoName,
-            changeNum: 1234 as NumericChangeId,
+        test('DIFF', async () => {
+          // DIFF: /^\/c\/(.+)\/\+\/(\d+)(\/((-?\d+|edit)(\.\.(\d+|edit))?(\/(.+))))\/?$/,
+          await checkUrlToState('/c/test-project/+/42/4..7/foo/bar/baz#b44', {
+            ...createDiffViewState(),
             basePatchNum: 4 as BasePatchSetNum,
             patchNum: 7 as RevisionPatchSetNum,
-            path: 'foo/bar/baz',
-            leftSide: true,
-            lineNum: 44,
+            diffView: {
+              path: 'foo/bar/baz',
+              lineNum: 44,
+              leftSide: true,
+            },
           });
-          assert.isFalse(redirectStub.called);
         });
 
-        test('comment route', () => {
-          const url = '/c/gerrit/+/264833/comment/00049681_f34fd6a9/';
-          const groups = url.match(_testOnly_RoutePattern.COMMENT);
-          assert.deepEqual(groups!.slice(1), [
-            'gerrit', // project
-            '264833', // changeNum
-            '00049681_f34fd6a9', // commentId
-          ]);
-          assertctxToParams(
-            {params: groups!.slice(1)} as any,
-            'handleCommentRoute',
-            {
-              project: 'gerrit' as RepoName,
-              changeNum: 264833 as NumericChangeId,
-              commentId: '00049681_f34fd6a9' as UrlEncodedCommentId,
-              commentLink: true,
-              view: GerritView.DIFF,
-            }
+        test('COMMENT base..1', async () => {
+          const change: ParsedChangeInfo = createParsedChange();
+          const repo = change.project;
+          const changeNum = change._number;
+          const ps = 1 as RevisionPatchSetNum;
+          const line = 23;
+          const id = '00049681_f34fd6a9' as UrlEncodedCommentId;
+          stubRestApi('getChangeDetail').resolves(change);
+          stubRestApi('getDiffComments').resolves({
+            filepath: [{...createComment(), id, patch_set: ps, line}],
+          });
+
+          await checkRedirect(
+            `/c/${repo}/+/${changeNum}/comment/${id}/`,
+            `/c/${repo}/+/${changeNum}/${ps}/filepath#${line}`
           );
         });
 
-        test('comments route', () => {
-          const url = '/c/gerrit/+/264833/comments/00049681_f34fd6a9/';
-          const groups = url.match(_testOnly_RoutePattern.COMMENTS_TAB);
-          assert.deepEqual(groups!.slice(1), [
-            'gerrit', // project
-            '264833', // changeNum
-            '00049681_f34fd6a9', // commentId
-          ]);
-          assertctxToParams(
-            {params: groups!.slice(1)} as any,
-            'handleCommentsRoute',
-            {
-              project: 'gerrit' as RepoName,
-              changeNum: 264833 as NumericChangeId,
-              commentId: '00049681_f34fd6a9' as UrlEncodedCommentId,
-              view: GerritView.CHANGE,
-            }
+        test('COMMENT 1..2', async () => {
+          const change: ParsedChangeInfo = {
+            ...createParsedChange(),
+            revisions: {
+              abc: createRevision(1),
+              def: createRevision(2),
+            },
+          };
+          const repo = change.project;
+          const changeNum = change._number;
+          const ps = 1 as RevisionPatchSetNum;
+          const line = 23;
+          const id = '00049681_f34fd6a9' as UrlEncodedCommentId;
+
+          stubRestApi('getChangeDetail').resolves(change);
+          stubRestApi('getDiffComments').resolves({
+            filepath: [{...createComment(), id, patch_set: ps, line}],
+          });
+          const diffStub = stubRestApi('getDiff');
+
+          // If getDiff() returns a diff with changes, then we will compare
+          // the patchset of the comment (1) against latest (2).
+          diffStub.onFirstCall().resolves(createDiff());
+          await checkRedirect(
+            `/c/${repo}/+/${changeNum}/comment/${id}/`,
+            `/c/${repo}/+/${changeNum}/${ps}..2/filepath#b${line}`
+          );
+
+          // If getDiff() returns an unchanged diff, then we will compare
+          // the patchset of the comment (1) against base.
+          diffStub.onSecondCall().resolves({
+            ...createDiff(),
+            content: [],
+          });
+          await checkRedirect(
+            `/c/${repo}/+/${changeNum}/comment/${id}/`,
+            `/c/${repo}/+/${changeNum}/${ps}/filepath#${line}`
           );
         });
       });
 
-      test('handleDiffEditRoute', () => {
-        stubRestApi('setInProjectLookup');
-        const ctx = {
-          ...createPageContext(),
-          hash: '',
-          params: {
-            0: 'foo/bar', // 0 Project
-            1: '1234', // 1 Change number
-            2: '3', // 2 Patch num
-            3: 'foo/bar/baz', // 3 File path
-          },
-        };
-        const appParams: EditViewState = {
-          project: 'foo/bar' as RepoName,
-          changeNum: 1234 as NumericChangeId,
-          view: GerritView.EDIT,
-          path: 'foo/bar/baz',
-          patchNum: 3 as RevisionPatchSetNum,
-          lineNum: 0,
-        };
-
-        router.handleDiffEditRoute(ctx);
-        assert.isFalse(redirectStub.called);
-        assert.deepEqual(setStateStub.lastCall.args[0], appParams);
-      });
-
-      test('handleDiffEditRoute with lineNum', () => {
-        stubRestApi('setInProjectLookup');
-        const ctx = {
-          ...createPageContext(),
-          hash: '4',
-          params: {
-            0: 'foo/bar', // 0 Project
-            1: '1234', // 1 Change number
-            2: '3', // 2 Patch num
-            3: 'foo/bar/baz', // 3 File path
-          },
-        };
-        const appParams: EditViewState = {
-          project: 'foo/bar' as RepoName,
-          changeNum: 1234 as NumericChangeId,
-          view: GerritView.EDIT,
-          path: 'foo/bar/baz',
-          patchNum: 3 as RevisionPatchSetNum,
-          lineNum: 4,
-        };
-
-        router.handleDiffEditRoute(ctx);
-        assert.isFalse(redirectStub.called);
-        assert.deepEqual(setStateStub.lastCall.args[0], appParams);
-      });
-
-      test('handleChangeEditRoute', () => {
-        stubRestApi('setInProjectLookup');
-        const ctx = {
-          ...createPageContext(),
-          params: {
-            0: 'foo/bar', // 0 Project
-            1: '1234', // 1 Change number
-            2: '',
-            3: '3', // 3 Patch num
-          },
-        };
-        const appParams: ChangeViewState = {
-          project: 'foo/bar' as RepoName,
+      test('DIFF_EDIT', async () => {
+        // DIFF_EDIT: /^\/c\/(.+)\/\+\/(\d+)\/(\d+|edit)\/(.+),edit(#\d+)?$/,
+        await checkUrlToState('/c/foo/bar/+/1234/3/foo/bar/baz,edit', {
+          ...createEditViewState(),
+          repo: 'foo/bar' as RepoName,
           changeNum: 1234 as NumericChangeId,
           view: GerritView.CHANGE,
+          childView: ChangeChildView.EDIT,
+          patchNum: 3 as RevisionPatchSetNum,
+          editView: {path: 'foo/bar/baz', lineNum: 0},
+        });
+        await checkUrlToState('/c/foo/bar/+/1234/3/foo/bar/baz,edit#4', {
+          ...createEditViewState(),
+          repo: 'foo/bar' as RepoName,
+          changeNum: 1234 as NumericChangeId,
+          view: GerritView.CHANGE,
+          childView: ChangeChildView.EDIT,
+          patchNum: 3 as RevisionPatchSetNum,
+          editView: {path: 'foo/bar/baz', lineNum: 4},
+        });
+      });
+
+      test('CHANGE_EDIT', async () => {
+        // CHANGE_EDIT: /^\/c\/(.+)\/\+\/(\d+)(\/(\d+))?,edit\/?$/,
+        await checkUrlToState('/c/foo/bar/+/1234/3,edit', {
+          ...createChangeViewState(),
+          repo: 'foo/bar' as RepoName,
+          changeNum: 1234 as NumericChangeId,
+          view: GerritView.CHANGE,
+          childView: ChangeChildView.OVERVIEW,
           patchNum: 3 as RevisionPatchSetNum,
           edit: true,
-        };
-
-        router.handleChangeEditRoute(ctx);
-        assert.isFalse(redirectStub.called);
-        assert.deepEqual(setStateStub.lastCall.args[0], appParams);
+        });
       });
     });
 
-    test('handlePluginScreen', () => {
-      const ctx = {...createPageContext(), params: {0: 'foo', 1: 'bar'}};
-      assertctxToParams(ctx, 'handlePluginScreen', {
+    test('LOG_IN_OR_OUT pass through', async () => {
+      // LOG_IN_OR_OUT: /^\/log(in|out)(\/(.+))?$/,
+      await checkUrlNotMatched('/login/asdf');
+      await checkUrlNotMatched('/logout/asdf');
+    });
+
+    test('PLUGIN_SCREEN', async () => {
+      // PLUGIN_SCREEN: /^\/x\/([\w-]+)\/([\w-]+)\/?/,
+      await checkUrlToState('/x/foo/bar', {
         view: GerritView.PLUGIN_SCREEN,
         plugin: 'foo',
         screen: 'bar',
       });
-      assert.isFalse(redirectStub.called);
+    });
+
+    test('DOCUMENTATION_SEARCH*', async () => {
+      // DOCUMENTATION_SEARCH_FILTER: '/Documentation/q/filter::filter',
+      // DOCUMENTATION_SEARCH: /^\/Documentation\/q\/(.*)$/,
+      await checkRedirect(
+        '/Documentation/q/asdf',
+        '/Documentation/q/filter:asdf'
+      );
+      await checkRedirect(
+        '/Documentation/q/as%3Fdf',
+        '/Documentation/q/filter:as%3Fdf'
+      );
+
+      await checkUrlToState('/Documentation/q/filter:', {
+        view: GerritView.DOCUMENTATION_SEARCH,
+        filter: '',
+      });
+      await checkUrlToState('/Documentation/q/filter:asdf', {
+        view: GerritView.DOCUMENTATION_SEARCH,
+        filter: 'asdf',
+      });
+      // Percent decoding works fine. gr-page decodes twice, so the only problem
+      // is having `%25` in the URL, because the first decoding pass will yield
+      // `%`, and then the second decoding pass will throw `URI malformed`.
+      await checkUrlToState('/Documentation/q/filter:as%20%2fdf', {
+        view: GerritView.DOCUMENTATION_SEARCH,
+        filter: 'as /df',
+      });
+      // We accept and process double-encoded values, but only *require* it for
+      // the percent symbol `%`.
+      await checkUrlToState('/Documentation/q/filter:as%252f%2525df', {
+        view: GerritView.DOCUMENTATION_SEARCH,
+        filter: 'as/%df',
+      });
     });
   });
 });
diff --git a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.ts b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.ts
index 17edc19..0856eed 100644
--- a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.ts
+++ b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.ts
@@ -25,6 +25,11 @@
 import {configModelToken} from '../../../models/config/config-model';
 import {resolve} from '../../../models/dependency';
 import {subscribe} from '../../lit/subscription-controller';
+import {
+  AutocompleteCommitEvent,
+  ValueChangedEvent,
+} from '../../../types/events';
+import {fireNoBubbleNoCompose} from '../../../utils/event-util';
 
 // Possible static search options for auto complete, without negations.
 const SEARCH_OPERATORS: ReadonlyArray<string> = [
@@ -159,9 +164,6 @@
   @state()
   mergeabilityComputationBehavior?: MergeabilityComputationBehavior;
 
-  @property({type: String})
-  label = '';
-
   // private but used in test
   @state() inputVal = '';
 
@@ -198,6 +200,9 @@
     return [
       sharedStyles,
       css`
+        gr-icon.searchIcon {
+          margin: 0 var(--spacing-xs);
+        }
         form {
           display: flex;
         }
@@ -216,8 +221,7 @@
       <form>
         <gr-autocomplete
           id="searchInput"
-          .label=${this.label}
-          show-search-icon
+          label="Search for changes"
           .text=${this.inputVal}
           .query=${this.query}
           allow-non-suggested-values
@@ -225,13 +229,14 @@
           .threshold=${this.threshold}
           tab-complete
           .verticalOffset=${30}
-          @commit=${(e: Event) => {
+          @commit=${(e: AutocompleteCommitEvent) => {
             this.handleInputCommit(e);
           }}
-          @text-changed=${(e: CustomEvent) => {
+          @text-changed=${(e: ValueChangedEvent) => {
             this.handleSearchTextChanged(e);
           }}
         >
+          <gr-icon icon="search" class="searchIcon" slot="prefix"></gr-icon>
           <a
             class="help"
             slot="suffix"
@@ -275,14 +280,14 @@
     // fallback to gerrit's official doc
     let baseUrl =
       this.docsBaseUrl ||
-      'https://gerrit-review.googlesource.com/documentation/';
+      'https://gerrit-review.googlesource.com/Documentation/';
     if (baseUrl.endsWith('/')) {
       baseUrl = baseUrl.substring(0, baseUrl.length - 1);
     }
     return `${baseUrl}/user-search.html`;
   }
 
-  private handleInputCommit(e: Event) {
+  private handleInputCommit(e: AutocompleteCommitEvent) {
     this.preventDefaultAndNavigateToInputVal(e);
   }
 
@@ -292,7 +297,7 @@
    * - e.target is the gr-autocomplete widget (#searchInput)
    * - e.target is the input element wrapped within #searchInput
    */
-  private preventDefaultAndNavigateToInputVal(e: Event) {
+  private preventDefaultAndNavigateToInputVal(e: AutocompleteCommitEvent) {
     e.preventDefault();
     if (!this.inputVal) return;
     const trimmedInput = this.inputVal.trim();
@@ -306,11 +311,7 @@
       const detail: SearchBarHandleSearchDetail = {
         inputVal: this.inputVal,
       };
-      this.dispatchEvent(
-        new CustomEvent('handle-search', {
-          detail,
-        })
-      );
+      fireNoBubbleNoCompose(this, 'handle-search', detail);
     }
   }
 
@@ -422,7 +423,7 @@
     this.searchInput.selectAll();
   }
 
-  private handleSearchTextChanged(e: CustomEvent) {
+  private handleSearchTextChanged(e: ValueChangedEvent) {
     this.inputVal = e.detail.value;
   }
 }
diff --git a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.ts b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.ts
index dbb3db9..603ea7b 100644
--- a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.ts
+++ b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.ts
@@ -6,10 +6,11 @@
 import '../../../test/common-test-setup';
 import './gr-search-bar';
 import {GrSearchBar} from './gr-search-bar';
-import '../../../scripts/util';
+import '../../../utils/async-util';
 import {
   mockPromise,
   pressKey,
+  stubRestApi,
   waitUntil,
   waitUntilObserved,
 } from '../../../test/test-utils';
@@ -37,12 +38,13 @@
   let configModel: ConfigModel;
 
   setup(async () => {
+    const serverConfig = createServerInfo();
+    serverConfig.gerrit.doc_url = 'https://mydocumentationurl.google.com/';
+    stubRestApi('getConfig').returns(Promise.resolve(serverConfig));
     configModel = new ConfigModel(
       testResolver(changeModelToken),
       getAppContext().restApiService
     );
-    const serverConfig = createServerInfo();
-    serverConfig.gerrit.doc_url = 'https://mydocumentationurl.google.com/';
     configModel.updateServerConfig(serverConfig);
     await waitUntilObserved(
       configModel.docsBaseUrl$,
@@ -68,10 +70,11 @@
           <gr-autocomplete
             allow-non-suggested-values=""
             id="searchInput"
+            label="Search for changes"
             multi=""
-            show-search-icon=""
             tab-complete=""
           >
+            <gr-icon icon="search" class="searchIcon" slot="prefix"></gr-icon>
             <a
               class="help"
               href="https://mydocumentationurl.google.com/user-search.html"
@@ -319,7 +322,7 @@
       await element.updateComplete;
       assert.equal(
         element.computeHelpDocLink(),
-        'https://gerrit-review.googlesource.com/documentation/' +
+        'https://gerrit-review.googlesource.com/Documentation/' +
           'user-search.html'
       );
     });
diff --git a/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.ts b/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.ts
index 13116fd..8b4b52f 100644
--- a/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.ts
+++ b/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.ts
@@ -14,11 +14,15 @@
 import {AutocompleteSuggestion} from '../../shared/gr-autocomplete/gr-autocomplete';
 import {getAppContext} from '../../../services/app-context';
 import {LitElement, html} from 'lit';
-import {customElement, property, state} from 'lit/decorators.js';
+import {customElement, state} from 'lit/decorators.js';
 import {subscribe} from '../../lit/subscription-controller';
 import {resolve} from '../../../models/dependency';
 import {configModelToken} from '../../../models/config/config-model';
-import {createSearchUrl} from '../../../models/views/search';
+import {
+  createSearchUrl,
+  searchViewModelToken,
+} from '../../../models/views/search';
+import {throwingErrorCallback} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
 
 const MAX_AUTOCOMPLETE_RESULTS = 10;
 const SELF_EXPRESSION = 'self';
@@ -35,29 +39,31 @@
 
 @customElement('gr-smart-search')
 export class GrSmartSearch extends LitElement {
-  @property({type: String})
+  @state()
   searchQuery = '';
 
   @state()
   serverConfig?: ServerInfo;
 
-  @property({type: String})
-  label = '';
-
   private readonly restApiService = getAppContext().restApiService;
 
   private readonly getConfigModel = resolve(this, configModelToken);
 
   private readonly getNavigation = resolve(this, navigationToken);
 
+  private readonly getSearchViewModel = resolve(this, searchViewModelToken);
+
   constructor() {
     super();
     subscribe(
       this,
       () => this.getConfigModel().serverConfig$,
-      config => {
-        this.serverConfig = config;
-      }
+      config => (this.serverConfig = config)
+    );
+    subscribe(
+      this,
+      () => this.getSearchViewModel().query$,
+      query => (this.searchQuery = query ?? '')
     );
   }
 
@@ -71,7 +77,6 @@
     return html`
       <gr-search-bar
         id="search"
-        .label=${this.label}
         .value=${this.searchQuery}
         .projectSuggestions=${projectSuggestions}
         .groupSuggestions=${groupSuggestions}
@@ -98,7 +103,11 @@
     expression: string
   ): Promise<AutocompleteSuggestion[]> {
     return this.restApiService
-      .getSuggestedProjects(expression, MAX_AUTOCOMPLETE_RESULTS)
+      .getSuggestedRepos(
+        expression,
+        MAX_AUTOCOMPLETE_RESULTS,
+        throwingErrorCallback
+      )
       .then(projects => {
         if (!projects) {
           return [];
@@ -128,7 +137,12 @@
       return Promise.resolve([]);
     }
     return this.restApiService
-      .getSuggestedGroups(expression, undefined, MAX_AUTOCOMPLETE_RESULTS)
+      .getSuggestedGroups(
+        expression,
+        undefined,
+        MAX_AUTOCOMPLETE_RESULTS,
+        throwingErrorCallback
+      )
       .then(groups => {
         if (!groups) {
           return [];
@@ -158,7 +172,13 @@
       return Promise.resolve([]);
     }
     return this.restApiService
-      .getSuggestedAccounts(expression, MAX_AUTOCOMPLETE_RESULTS)
+      .getSuggestedAccounts(
+        expression,
+        MAX_AUTOCOMPLETE_RESULTS,
+        /* canSee=*/ undefined,
+        /* filterActive=*/ undefined,
+        throwingErrorCallback
+      )
       .then(accounts => {
         if (!accounts) {
           return [];
diff --git a/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search_test.ts b/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search_test.ts
index a0d49c8..7e3b896 100644
--- a/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search_test.ts
+++ b/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search_test.ts
@@ -94,7 +94,7 @@
   });
 
   test('Autocompletes projects', () => {
-    stubRestApi('getSuggestedProjects').callsFake(() =>
+    stubRestApi('getSuggestedRepos').callsFake(() =>
       Promise.resolve({Polygerrit: {id: 'test' as UrlEncodedRepoName}})
     );
     return element.fetchProjects('project', 'pol').then(s => {
diff --git a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts
index 17d7516..b97f54f 100644
--- a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts
+++ b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts
@@ -6,7 +6,6 @@
 import '../../../styles/shared-styles';
 import '../../shared/gr-dialog/gr-dialog';
 import '../../shared/gr-icon/gr-icon';
-import '../../shared/gr-overlay/gr-overlay';
 import '../../../embed/diff/gr-diff/gr-diff';
 import {navigationToken} from '../../core/gr-navigation/gr-navigation';
 import {
@@ -18,15 +17,13 @@
   FilePathToDiffInfoMap,
 } from '../../../types/common';
 import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
-import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
 import {PROVIDED_FIX_ID} from '../../../utils/comment-util';
 import {OpenFixPreviewEvent} from '../../../types/events';
 import {getAppContext} from '../../../services/app-context';
-import {fireCloseFixPreview} from '../../../utils/event-util';
 import {DiffLayer, ParsedChangeInfo} from '../../../types/types';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {TokenHighlightLayer} from '../../../embed/diff/gr-diff-builder/token-highlight-layer';
-import {css, html, LitElement} from 'lit';
+import {css, html, LitElement, nothing} from 'lit';
 import {customElement, property, query, state} from 'lit/decorators.js';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {subscribe} from '../../lit/subscription-controller';
@@ -34,6 +31,13 @@
 import {resolve} from '../../../models/dependency';
 import {createChangeUrl} from '../../../models/views/change';
 import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
+import {userModelToken} from '../../../models/user/user-model';
+import {modalStyles} from '../../../styles/gr-modal-styles';
+import {GrSyntaxLayerWorker} from '../../../embed/diff/gr-syntax-layer/gr-syntax-layer-worker';
+import {highlightServiceToken} from '../../../services/highlight/highlight-service';
+import {anyLineTooLong} from '../../../embed/diff/gr-diff/gr-diff-utils';
+import {fireReload} from '../../../utils/event-util';
+import {when} from 'lit/directives/when.js';
 
 interface FilePreview {
   filepath: string;
@@ -42,8 +46,8 @@
 
 @customElement('gr-apply-fix-dialog')
 export class GrApplyFixDialog extends LitElement {
-  @query('#applyFixOverlay')
-  applyFixOverlay?: GrOverlay;
+  @query('#applyFixModal')
+  applyFixModal?: HTMLDialogElement;
 
   @query('#applyFixDialog')
   applyFixDialog?: GrDialog;
@@ -87,35 +91,50 @@
   @state()
   diffPrefs?: DiffPreferencesInfo;
 
+  @state()
+  loading = false;
+
+  @state()
+  onCloseFixPreviewCallbacks: ((fixapplied: boolean) => void)[] = [];
+
   private readonly restApiService = getAppContext().restApiService;
 
-  private readonly userModel = getAppContext().userModel;
+  private readonly getUserModel = resolve(this, userModelToken);
 
   private readonly getNavigation = resolve(this, navigationToken);
 
+  private readonly syntaxLayer = new GrSyntaxLayerWorker(
+    resolve(this, highlightServiceToken),
+    () => getAppContext().reportingService
+  );
+
   constructor() {
     super();
     subscribe(
       this,
-      () => this.userModel.preferences$,
+      () => this.getUserModel().preferences$,
       preferences => {
+        const layers: DiffLayer[] = [this.syntaxLayer];
         if (!preferences?.disable_token_highlighting) {
-          this.layers = [new TokenHighlightLayer(this)];
+          layers.push(new TokenHighlightLayer(this));
         }
+        this.layers = layers;
       }
     );
     subscribe(
       this,
-      () => this.userModel.diffPreferences$,
+      () => this.getUserModel().diffPreferences$,
       diffPreferences => {
         if (!diffPreferences) return;
         this.diffPrefs = diffPreferences;
+        this.syntaxLayer.setEnabled(!!this.diffPrefs.syntax_highlighting);
       }
     );
   }
 
   static override styles = [
     sharedStyles,
+    modalStyles,
     css`
       .diffContainer {
         padding: var(--spacing-l) 0;
@@ -140,9 +159,11 @@
 
   override render() {
     return html`
-      <gr-overlay id="applyFixOverlay" with-backdrop="">
+      <dialog id="applyFixModal" tabindex="-1">
         <gr-dialog
           id="applyFixDialog"
+          ?loading=${this.loading}
+          .loadingLabel=${'Creating preview ...'}
           .confirmLabel=${this.isApplyFixLoading ? 'Saving...' : 'Apply Fix'}
           .confirmTooltip=${this.computeTooltip()}
           ?disabled=${this.computeDisableApplyFixButton()}
@@ -151,43 +172,14 @@
         >
           ${this.renderHeader()} ${this.renderMain()} ${this.renderFooter()}
         </gr-dialog>
-      </gr-overlay>
+      </dialog>
     `;
   }
 
-  override updated() {
-    this.updateDialogObserver();
-  }
-
   override disconnectedCallback() {
-    this.removeDialogObserver();
     super.disconnectedCallback();
   }
 
-  private removeDialogObserver() {
-    this.dialogObserver?.disconnect();
-    this.dialogObserver = undefined;
-    this.observedDialog = undefined;
-  }
-
-  private updateDialogObserver() {
-    if (
-      this.applyFixDialog === this.observedDialog &&
-      this.dialogObserver !== undefined
-    ) {
-      return;
-    }
-
-    this.removeDialogObserver();
-    if (!this.applyFixDialog) return;
-
-    this.observedDialog = this.applyFixDialog;
-    this.dialogObserver = new ResizeObserver(() => {
-      this.applyFixOverlay?.refit();
-    });
-    this.dialogObserver.observe(this.observedDialog);
-  }
-
   private renderHeader() {
     return html`
       <div slot="header">${this.currentFix?.description ?? ''}</div>
@@ -200,44 +192,63 @@
         <div class="file-name">
           <span>${item.filepath}</span>
         </div>
-        <div class="diffContainer">
-          <gr-diff
-            .prefs=${this.overridePartialDiffPrefs()}
-            .path=${item.filepath}
-            .diff=${item.preview}
-            .layers=${this.layers}
-          ></gr-diff>
-        </div>
+        <div class="diffContainer">${this.renderDiff(item)}</div>
       `
     );
     return html`<div slot="main">${items}</div>`;
   }
 
+  private renderDiff(preview: FilePreview) {
+    const diff = preview.preview;
+    if (!anyLineTooLong(diff)) {
+      this.syntaxLayer.process(diff);
+    }
+    return html`<gr-diff
+      .prefs=${this.overridePartialDiffPrefs()}
+      .path=${preview.filepath}
+      .diff=${diff}
+      .layers=${this.layers}
+    ></gr-diff>`;
+  }
+
   private renderFooter() {
-    const id = this.selectedFixIdx;
     const fixCount = this.fixSuggestions?.length ?? 0;
-    if (fixCount < 2) return;
+    const reasonForDisabledApplyButton = this.computeTooltip();
+    if (fixCount < 2 && !reasonForDisabledApplyButton) return nothing;
+    return html`<div slot="footer" class="fix-picker">
+      ${when(fixCount >= 2, () =>
+        this.renderNavForMultipleSuggestedFixes(fixCount)
+      )}
+      ${this.renderWarning(reasonForDisabledApplyButton)}
+    </div>`;
+  }
+
+  private renderNavForMultipleSuggestedFixes(fixCount: number) {
+    const id = this.selectedFixIdx;
     return html`
-      <div slot="footer" class="fix-picker">
-        <span>Suggested fix ${id + 1} of ${fixCount}</span>
-        <gr-button
-          id="prevFix"
-          @click=${this.onPrevFixClick}
-          ?disabled=${id === 0}
-        >
-          <gr-icon icon="chevron_left"></gr-icon>
-        </gr-button>
-        <gr-button
-          id="nextFix"
-          @click=${this.onNextFixClick}
-          ?disabled=${id === fixCount - 1}
-        >
-          <gr-icon icon="chevron_right"></gr-icon>
-        </gr-button>
-      </div>
+      <span>Suggested fix ${id + 1} of ${fixCount}</span>
+      <gr-button
+        id="prevFix"
+        @click=${this.onPrevFixClick}
+        ?disabled=${id === 0}
+      >
+        <gr-icon icon="chevron_left"></gr-icon>
+      </gr-button>
+      <gr-button
+        id="nextFix"
+        @click=${this.onNextFixClick}
+        ?disabled=${id === fixCount - 1}
+      >
+        <gr-icon icon="chevron_right"></gr-icon>
+      </gr-button>
     `;
   }
 
+  private renderWarning(message: string) {
+    if (!message) return nothing;
+    return html`<span><gr-icon icon="info"></gr-icon>${message}</span>`;
+  }
+
   /**
    * Given event with fixSuggestions, fetch diffs associated with first
    * suggested fix and open dialog.
@@ -245,18 +256,18 @@
   open(e: OpenFixPreviewEvent) {
     this.patchNum = e.detail.patchNum;
     this.fixSuggestions = e.detail.fixSuggestions;
+    this.onCloseFixPreviewCallbacks = e.detail.onCloseFixPreviewCallbacks;
     assert(this.fixSuggestions.length > 0, 'no fix in the event');
     this.selectedFixIdx = 0;
-    const promises = [];
-    promises.push(
-      this.showSelectedFixSuggestion(this.fixSuggestions[0]),
-      this.applyFixOverlay?.open()
-    );
+    this.applyFixModal?.showModal();
+    return this.showSelectedFixSuggestion(this.fixSuggestions[0]);
   }
 
   private async showSelectedFixSuggestion(fixSuggestion: FixSuggestionInfo) {
     this.currentFix = fixSuggestion;
+    this.loading = true;
     await this.fetchFixPreview(fixSuggestion);
+    this.loading = false;
   }
 
   private async fetchFixPreview(fixSuggestion: FixSuggestionInfo) {
@@ -333,8 +344,9 @@
     this.currentPreviews = [];
     this.isApplyFixLoading = false;
 
-    fireCloseFixPreview(this, fixApplied);
-    this.applyFixOverlay?.close();
+    this.onCloseFixPreviewCallbacks.forEach(fn => fn(fixApplied));
+    this.applyFixModal?.close();
+    if (fixApplied) fireReload(this);
   }
 
   private computeTooltip() {
@@ -342,7 +354,7 @@
     const latestPatchNum =
       this.change.revisions[this.change.current_revision]._number;
     return latestPatchNum !== this.patchNum
-      ? 'Fix can only be applied to the latest patchset'
+      ? 'You cannot apply this fix because it is from a previous patchset'
       : '';
   }
 
diff --git a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_test.ts b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_test.ts
index 4d2d454..267c569 100644
--- a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_test.ts
+++ b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_test.ts
@@ -5,7 +5,10 @@
  */
 import '../../../test/common-test-setup';
 import './gr-apply-fix-dialog';
-import {navigationToken} from '../../core/gr-navigation/gr-navigation';
+import {
+  NavigationService,
+  navigationToken,
+} from '../../core/gr-navigation/gr-navigation';
 import {queryAndAssert, stubRestApi} from '../../../test/test-utils';
 import {GrApplyFixDialog} from './gr-apply-fix-dialog';
 import {PatchSetNum} from '../../../types/common';
@@ -17,19 +20,15 @@
 } from '../../../test/test-data-generators';
 import {createDefaultDiffPrefs} from '../../../constants/constants';
 import {DiffInfo} from '../../../types/diff';
-import {
-  CloseFixPreviewEventDetail,
-  EventType,
-  OpenFixPreviewEventDetail,
-} from '../../../types/events';
+import {OpenFixPreviewEventDetail} from '../../../types/events';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {fixture, html, assert} from '@open-wc/testing';
-import {SinonStub} from 'sinon';
+import {SinonStubbedMember} from 'sinon';
 import {testResolver} from '../../../test/common-test-setup';
 
 suite('gr-apply-fix-dialog tests', () => {
   let element: GrApplyFixDialog;
-  let setUrlStub: SinonStub;
+  let setUrlStub: SinonStubbedMember<NavigationService['setUrl']>;
 
   const TWO_FIXES: OpenFixPreviewEventDetail = {
     patchNum: 2 as PatchSetNum,
@@ -37,11 +36,13 @@
       createFixSuggestionInfo('fix_1'),
       createFixSuggestionInfo('fix_2'),
     ],
+    onCloseFixPreviewCallbacks: [],
   };
 
   const ONE_FIX: OpenFixPreviewEventDetail = {
     patchNum: 2 as PatchSetNum,
     fixSuggestions: [createFixSuggestionInfo('fix_1')],
+    onCloseFixPreviewCallbacks: [],
   };
 
   function getConfirmButton(): GrButton {
@@ -53,7 +54,7 @@
 
   async function open(detail: OpenFixPreviewEventDetail) {
     element.open(
-      new CustomEvent<OpenFixPreviewEventDetail>(EventType.OPEN_FIX_PREVIEW, {
+      new CustomEvent<OpenFixPreviewEventDetail>('open-fix-preview', {
         detail,
       })
     );
@@ -142,7 +143,7 @@
           f2: diffInfo2,
         })
       );
-      sinon.stub(element.applyFixOverlay!, 'open').returns(Promise.resolve());
+      sinon.stub(element.applyFixModal!, 'showModal');
     });
 
     test('dialog opens fetch and sets previews', async () => {
@@ -173,7 +174,7 @@
       assert.isTrue(button.hasAttribute('disabled'));
       assert.equal(
         button.getAttribute('title'),
-        'Fix can only be applied to the latest patchset'
+        'You cannot apply this fix because it is from a previous patchset'
       );
     });
   });
@@ -183,8 +184,8 @@
     assert.shadowDom.equal(
       element,
       /* HTML */ `
-        <gr-overlay id="applyFixOverlay" tabindex="-1" with-backdrop="">
-          <gr-dialog id="applyFixDialog" role="dialog">
+        <dialog id="applyFixModal" tabindex="-1" open="">
+          <gr-dialog id="applyFixDialog" role="dialog" loading="">
             <div slot="header">Fix fix_1</div>
             <div slot="main"></div>
             <div class="fix-picker" slot="footer">
@@ -208,7 +209,7 @@
               </gr-button>
             </div>
           </gr-dialog>
-        </gr-overlay>
+        </dialog>
       `,
       {ignoreAttributes: ['style']}
     );
@@ -216,11 +217,12 @@
 
   test('next button state updated when suggestions changed', async () => {
     stubRestApi('getRobotCommentFixPreview').returns(Promise.resolve({}));
-    sinon.stub(element.applyFixOverlay!, 'open').returns(Promise.resolve());
 
     await open(ONE_FIX);
     await element.updateComplete;
     assert.notOk(element.nextFix);
+    element.applyFixModal?.close();
+
     await open(TWO_FIXES);
     assert.ok(element.nextFix);
     assert.notOk(element.nextFix!.disabled);
@@ -245,11 +247,7 @@
     element.currentFix = createFixSuggestionInfo('123');
 
     const closeFixPreviewEventSpy = sinon.spy();
-    // Element is recreated after each test, removeEventListener isn't required
-    element.addEventListener(
-      EventType.CLOSE_FIX_PREVIEW,
-      closeFixPreviewEventSpy
-    );
+    element.onCloseFixPreviewCallbacks.push(closeFixPreviewEventSpy);
 
     await element.handleApplyFix(new CustomEvent('confirm'));
 
@@ -262,14 +260,7 @@
     assert.isTrue(setUrlStub.called);
     assert.equal(setUrlStub.lastCall.firstArg, '/c/test-project/+/42/2..edit');
 
-    sinon.assert.calledOnceWithExactly(
-      closeFixPreviewEventSpy,
-      new CustomEvent<CloseFixPreviewEventDetail>(EventType.CLOSE_FIX_PREVIEW, {
-        detail: {
-          fixApplied: true,
-        },
-      })
-    );
+    sinon.assert.calledOnceWithExactly(closeFixPreviewEventSpy, true);
     // reset gr-apply-fix-dialog and close
     assert.equal(element.currentFix, undefined);
     assert.equal(element.currentPreviews.length, 0);
@@ -294,7 +285,7 @@
   });
 
   test('select fix forward and back of multiple suggested fixes', async () => {
-    sinon.stub(element.applyFixOverlay!, 'open').returns(Promise.resolve());
+    sinon.stub(element.applyFixModal!, 'showModal');
 
     await open(TWO_FIXES);
     element.onNextFixClick(new CustomEvent('click'));
@@ -310,11 +301,7 @@
     element.currentFix = createFixSuggestionInfo('fix_123');
 
     const closeFixPreviewEventSpy = sinon.spy();
-    // Element is recreated after each test, removeEventListener isn't required
-    element.addEventListener(
-      EventType.CLOSE_FIX_PREVIEW,
-      closeFixPreviewEventSpy
-    );
+    element.onCloseFixPreviewCallbacks.push(closeFixPreviewEventSpy);
 
     let expectedError;
     await element.handleApplyFix(new CustomEvent('click')).catch(e => {
@@ -327,19 +314,8 @@
 
   test('onCancel fires close with correct parameters', () => {
     const closeFixPreviewEventSpy = sinon.spy();
-    // Element is recreated after each test, removeEventListener isn't required
-    element.addEventListener(
-      EventType.CLOSE_FIX_PREVIEW,
-      closeFixPreviewEventSpy
-    );
+    element.onCloseFixPreviewCallbacks.push(closeFixPreviewEventSpy);
     element.onCancel(new CustomEvent('cancel'));
-    sinon.assert.calledOnceWithExactly(
-      closeFixPreviewEventSpy,
-      new CustomEvent<CloseFixPreviewEventDetail>(EventType.CLOSE_FIX_PREVIEW, {
-        detail: {
-          fixApplied: false,
-        },
-      })
-    );
+    sinon.assert.calledOnceWithExactly(closeFixPreviewEventSpy, false);
   });
 });
diff --git a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts
index 7570ac5..00e013b 100644
--- a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts
+++ b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts
@@ -7,51 +7,46 @@
   PatchRange,
   PatchSetNum,
   RobotCommentInfo,
-  UrlEncodedCommentId,
-  PathToCommentsInfoMap,
   FileInfo,
   PARENT,
+  CommentThread,
+  Comment,
+  CommentMap,
+  DraftInfo,
   CommentInfo,
 } from '../../../types/common';
 import {
-  Comment,
-  CommentMap,
-  CommentThread,
-  DraftInfo,
   isUnresolved,
   createCommentThreads,
   isInPatchRange,
   isDraftThread,
   isPatchsetLevel,
   addPath,
+  id,
 } from '../../../utils/comment-util';
 import {PatchSetFile, PatchNumOnly, isPatchSetFile} from '../../../types/types';
 import {CommentSide} from '../../../constants/constants';
 import {pluralize} from '../../../utils/string-util';
 import {NormalizedFileInfo} from '../../change/gr-file-list/gr-file-list';
 
-export type CommentIdToCommentThreadMap = {
-  [urlEncodedCommentId: string]: CommentThread;
-};
-
 // TODO: Move file out of elements/ directory
 export class ChangeComments {
-  private readonly _comments: PathToCommentsInfoMap;
+  private readonly _comments: {[path: string]: CommentInfo[]};
 
   private readonly _robotComments: {[path: string]: RobotCommentInfo[]};
 
   private readonly _drafts: {[path: string]: DraftInfo[]};
 
-  private readonly _portedComments: PathToCommentsInfoMap;
+  private readonly _portedComments: {[path: string]: CommentInfo[]};
 
-  private readonly _portedDrafts: PathToCommentsInfoMap;
+  private readonly _portedDrafts: {[path: string]: DraftInfo[]};
 
   constructor(
-    comments?: PathToCommentsInfoMap,
+    comments?: {[path: string]: CommentInfo[]},
     robotComments?: {[path: string]: RobotCommentInfo[]},
     drafts?: {[path: string]: DraftInfo[]},
-    portedComments?: PathToCommentsInfoMap,
-    portedDrafts?: PathToCommentsInfoMap
+    portedComments?: {[path: string]: CommentInfo[]},
+    portedDrafts?: {[path: string]: DraftInfo[]}
   ) {
     this._comments = addPath(comments);
     this._robotComments = addPath(robotComments);
@@ -64,26 +59,6 @@
     return this._drafts;
   }
 
-  findCommentById(
-    commentId?: UrlEncodedCommentId
-  ): CommentInfo | DraftInfo | undefined {
-    if (!commentId) return undefined;
-    const findComment = (comments: {
-      [path: string]: (CommentInfo | DraftInfo)[];
-    }) => {
-      let comment;
-      for (const path of Object.keys(comments)) {
-        comment = comment || comments[path].find(c => c.id === commentId);
-      }
-      return comment;
-    };
-    return (
-      findComment(this._comments) ||
-      findComment(this._robotComments) ||
-      findComment(this._drafts)
-    );
-  }
-
   /**
    * Get an object mapping file paths to a boolean representing whether that
    * path contains diff comments in the given patch set (including drafts and
@@ -127,7 +102,7 @@
    */
   getAllComments(includeDrafts?: boolean, patchNum?: PatchSetNum) {
     const paths = this.getPaths();
-    const publishedComments: {[path: string]: CommentInfo[]} = {};
+    const publishedComments: {[path: string]: Comment[]} = {};
     for (const path of Object.keys(paths)) {
       publishedComments[path] = this.getAllCommentsForPath(
         path,
@@ -161,8 +136,8 @@
     path: string,
     patchNum?: PatchSetNum,
     includeDrafts?: boolean
-  ): CommentInfo[] {
-    const comments: CommentInfo[] = this._comments[path] || [];
+  ): Comment[] {
+    const comments: Comment[] = this._comments[path] || [];
     const robotComments = this._robotComments[path] || [];
     let allComments = comments.concat(robotComments);
     if (includeDrafts) {
@@ -217,7 +192,7 @@
    *
    * // TODO(taoalpha): maybe merge in *ForPath
    */
-  getAllDraftsForFile(file: PatchSetFile): CommentInfo[] {
+  getAllDraftsForFile(file: PatchSetFile): DraftInfo[] {
     let allDrafts = this.getAllDraftsForPath(file.path, file.patchNum);
     if (file.basePath) {
       allDrafts = allDrafts.concat(
@@ -234,11 +209,9 @@
    *
    * @param patchRange The patch-range object containing patchNum
    * and basePatchNum properties to represent the range.
-   * @param projectConfig Optional project config object to
-   * include in the meta sub-object.
    */
-  getCommentsForPath(path: string, patchRange: PatchRange): CommentInfo[] {
-    let comments: CommentInfo[] = [];
+  getCommentsForPath(path: string, patchRange: PatchRange): Comment[] {
+    let comments: Comment[] = [];
     let drafts: DraftInfo[] = [];
     let robotComments: RobotCommentInfo[] = [];
     if (this._comments && this._comments[path]) {
@@ -296,7 +269,7 @@
     file: PatchSetFile,
     patchRange: PatchRange
   ): CommentThread[] {
-    const portedComments = this._portedComments[file.path] || [];
+    const portedComments: Comment[] = this._portedComments[file.path] || [];
     portedComments.push(...(this._portedDrafts[file.path] || []));
     if (file.basePath) {
       portedComments.push(...(this._portedComments[file.basePath] || []));
@@ -308,7 +281,7 @@
     // ported comments will involve comments that may not belong to the
     // current patchrange, so we need to form threads for them using all
     // comments
-    const allComments: CommentInfo[] = this.getAllCommentsForFile(file, true);
+    const allComments: Comment[] = this.getAllCommentsForFile(file, true);
 
     return createCommentThreads(allComments).filter(thread => {
       // Robot comments and drafts are not ported over. A human reply to
@@ -316,12 +289,12 @@
       // have the root comment of the thread not be ported, hence loop over
       // entire thread
       const portedComment = portedComments.find(portedComment =>
-        thread.comments.some(c => portedComment.id === c.id)
+        thread.comments.some(c => id(portedComment) === id(c))
       );
       if (!portedComment) return false;
 
       const originalComment = thread.comments.find(
-        comment => comment.id === portedComment.id
+        comment => id(comment) === id(portedComment)
       )!;
 
       // Original comment shown anyway? No need to port.
@@ -373,13 +346,8 @@
    *
    * @param patchRange The patch-range object containing patchNum
    * and basePatchNum properties to represent the range.
-   * @param projectConfig Optional project config object to
-   * include in the meta sub-object.
    */
-  getCommentsForFile(
-    file: PatchSetFile,
-    patchRange: PatchRange
-  ): CommentInfo[] {
+  getCommentsForFile(file: PatchSetFile, patchRange: PatchRange): Comment[] {
     const comments = this.getCommentsForPath(file.path, patchRange);
     if (file.basePath) {
       comments.push(...this.getCommentsForPath(file.basePath, patchRange));
@@ -395,24 +363,24 @@
   }
 
   /**
-   * Computes the number of comment threads in a given file or patch.
+   * Computes the comment threads in a given file or patch.
    */
-  computeCommentThreadCount(
+  computeCommentThreads(
     file: PatchSetFile | PatchNumOnly,
     ignorePatchsetLevelComments?: boolean
   ) {
-    let comments: CommentInfo[] = [];
+    let comments: Comment[] = [];
     if (isPatchSetFile(file)) {
       comments = this.getAllCommentsForFile(file);
     } else {
-      comments = this._commentObjToArray<CommentInfo>(
+      comments = this._commentObjToArray<Comment>(
         this.getAllPublishedComments(file.patchNum)
       );
     }
     let threads = createCommentThreads(comments);
     if (ignorePatchsetLevelComments)
       threads = threads.filter(thread => !isPatchsetLevel(thread));
-    return threads.length;
+    return threads;
   }
 
   /**
@@ -461,6 +429,23 @@
     return getCommentForPath(file.__path) + getCommentForPath(file.old_path);
   }
 
+  computeCommentsThreads(
+    patchRange: PatchRange,
+    path: string,
+    changeFileInfo?: FileInfo
+  ) {
+    const threads = this.getThreadsBySideForFile({path}, patchRange);
+    if (changeFileInfo?.old_path) {
+      threads.push(
+        ...this.getThreadsBySideForFile(
+          {path: changeFileInfo.old_path},
+          patchRange
+        )
+      );
+    }
+    return threads;
+  }
+
   /**
    * @param includeUnmodified Included unmodified status of the file in the
    * comment string or not. For files we opt of chip instead of a string.
@@ -475,15 +460,11 @@
     if (!path) return '';
     if (!patchRange) return '';
 
-    const threads = this.getThreadsBySideForFile({path}, patchRange);
-    if (changeFileInfo?.old_path) {
-      threads.push(
-        ...this.getThreadsBySideForFile(
-          {path: changeFileInfo.old_path},
-          patchRange
-        )
-      );
-    }
+    const threads = this.computeCommentsThreads(
+      patchRange,
+      path,
+      changeFileInfo
+    );
     const commentThreadCount = threads.filter(
       thread => !isDraftThread(thread)
     ).length;
@@ -516,8 +497,8 @@
     file: PatchSetFile | PatchNumOnly,
     ignorePatchsetLevelComments?: boolean
   ) {
-    let comments: CommentInfo[] = [];
-    let drafts: CommentInfo[] = [];
+    let comments: Comment[] = [];
+    let drafts: Comment[] = [];
 
     if (isPatchSetFile(file)) {
       comments = this.getAllCommentsForFile(file);
diff --git a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.ts b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.ts
index b5ce12883..3e36ab5 100644
--- a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.ts
+++ b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.ts
@@ -11,8 +11,6 @@
   isDraftThread,
   isUnresolved,
   createCommentThreads,
-  DraftInfo,
-  CommentThread,
 } from '../../../utils/comment-util';
 import {
   createDraft,
@@ -26,10 +24,11 @@
 import {
   BasePatchSetNum,
   CommentInfo,
+  CommentThread,
+  DraftInfo,
   PARENT,
   PatchRange,
   PatchSetNum,
-  PathToCommentsInfoMap,
   RevisionPatchSetNum,
   RobotCommentInfo,
   Timestamp,
@@ -49,7 +48,7 @@
     });
 
     suite('ported comments', () => {
-      let portedComments: PathToCommentsInfoMap;
+      let portedComments: {[path: string]: CommentInfo[]};
       const comment1: CommentInfo = {
         ...createComment(),
         unresolved: true,
@@ -593,7 +592,7 @@
       const robotComments: {[path: string]: RobotCommentInfo[]} = {
         'file/one': [comments[0], comments[1]],
       };
-      const commentsByFile: PathToCommentsInfoMap = {
+      const commentsByFile: {[path: string]: CommentInfo[]} = {
         'file/one': [comments[2], comments[3]],
         'file/two': [comments[4], comments[5]],
         'file/three': [comments[6], comments[7], comments[8]],
@@ -741,7 +740,7 @@
       });
 
       test('computeUnresolvedNum w/ non-linear thread', () => {
-        const comments: PathToCommentsInfoMap = {
+        const comments: {[path: string]: CommentInfo[]} = {
           path: [
             {
               id: '9c6ba3c6_28b7d467' as UrlEncodedCommentId,
@@ -911,30 +910,47 @@
         );
       });
 
-      test('computeCommentThreadCount', () => {
+      test('computeCommentThreads - check length', () => {
         assert.equal(
-          changeComments.computeCommentThreadCount({
+          changeComments.computeCommentThreads({
             patchNum: 2 as PatchSetNum,
             path: 'file/one',
-          }),
+          }).length,
           3
         );
-        assert.equal(
-          changeComments.computeCommentThreadCount({
+        assert.deepEqual(
+          changeComments.computeCommentThreads({
             patchNum: 1 as PatchSetNum,
             path: 'file/one',
           }),
-          0
+          []
         );
         assert.equal(
-          changeComments.computeCommentThreadCount({
+          changeComments.computeCommentThreads({
             patchNum: 2 as PatchSetNum,
             path: 'file/three',
-          }),
+          }).length,
           1
         );
       });
 
+      test('computeCommentThreads - check content', () => {
+        const expectedThreads: CommentThread[] = [
+          {
+            ...createCommentThread([{...comments[9], path: 'file/four'}]),
+          },
+          {
+            ...createCommentThread([{...comments[10], path: 'file/four'}]),
+          },
+        ];
+        assert.deepEqual(
+          changeComments.computeCommentThreads({
+            path: 'file/four',
+          }),
+          expectedThreads
+        );
+      });
+
       test('computeDraftCount', () => {
         assert.equal(
           changeComments.computeDraftCount({
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 baf89c0..6cc4048 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
@@ -21,21 +21,17 @@
   isNumber,
 } from '../../../utils/patch-set-util';
 import {
-  CommentThread,
-  equalLocation,
+  createNew,
   isInBaseOfPatchRange,
   isInRevisionOfPatchRange,
 } from '../../../utils/comment-util';
-import {
-  CommitRange,
-  CoverageRange,
-  DiffLayer,
-  PatchSetFile,
-} from '../../../types/types';
+import {CoverageRange, DiffLayer, PatchSetFile} from '../../../types/types';
 import {
   Base64ImageFile,
   BlameInfo,
   ChangeInfo,
+  CommentThread,
+  DraftInfo,
   EDIT,
   NumericChangeId,
   PARENT,
@@ -49,6 +45,7 @@
   DiffInfo,
   DiffPreferencesInfo,
   IgnoreWhitespaceType,
+  WebLinkInfo,
 } from '../../../types/diff';
 import {
   CreateCommentEventDetail,
@@ -63,18 +60,20 @@
   firePageError,
   fireAlert,
   fireServerError,
-  fireEvent,
-  waitForEventOnce,
   fire,
+  waitForEventOnce,
 } from '../../../utils/event-util';
-import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 import {assertIsDefined} from '../../../utils/common-util';
 import {DiffContextExpandedEventDetail} from '../../../embed/diff/gr-diff-builder/gr-diff-builder';
 import {TokenHighlightLayer} from '../../../embed/diff/gr-diff-builder/token-highlight-layer';
-import {Timing, Interaction} from '../../../constants/reporting';
+import {Timing} from '../../../constants/reporting';
 import {ChangeComments} from '../gr-comment-api/gr-comment-api';
 import {Subscription} from 'rxjs';
-import {DisplayLine, RenderPreferences} from '../../../api/diff';
+import {
+  DisplayLine,
+  LineSelectedEventDetail,
+  RenderPreferences,
+} from '../../../api/diff';
 import {resolve} from '../../../models/dependency';
 import {browserModelToken} from '../../../models/browser/browser-model';
 import {commentsModelToken} from '../../../models/comments/comments-model';
@@ -84,7 +83,10 @@
 import {deepEqual} from '../../../utils/deep-util';
 import {Category} from '../../../api/checks';
 import {GrSyntaxLayerWorker} from '../../../embed/diff/gr-syntax-layer/gr-syntax-layer-worker';
-import {CODE_MAX_LINES} from '../../../services/highlight/highlight-service';
+import {
+  CODE_MAX_LINES,
+  highlightServiceToken,
+} from '../../../services/highlight/highlight-service';
 import {html, LitElement, PropertyValues} from 'lit';
 import {customElement, property, query, state} from 'lit/decorators.js';
 import {ValueChangedEvent} from '../../../types/events';
@@ -92,9 +94,11 @@
   debounceP,
   DelayedPromise,
   DELAYED_CANCELLATION,
+  noAwait,
 } from '../../../utils/async-util';
 import {subscribe} from '../../lit/subscription-controller';
-import {GeneratedWebLink} from '../../../utils/weblink-util';
+import {userModelToken} from '../../../models/user/user-model';
+import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 
 const EMPTY_BLAME = 'No blame information for this diff.';
 
@@ -119,19 +123,19 @@
 
 declare global {
   interface HTMLElementEventMap {
-    /* prettier-ignore */
-    'render': CustomEvent;
+    // prettier-ignore
+    'render': CustomEvent<{}>;
     'diff-context-expanded': CustomEvent<DiffContextExpandedEventDetail>;
     'create-comment': CustomEvent<CreateCommentEventDetail>;
     'is-blame-loaded-changed': ValueChangedEvent<boolean>;
     'diff-changed': ValueChangedEvent<DiffInfo | undefined>;
-    'edit-weblinks-changed': ValueChangedEvent<GeneratedWebLink[] | undefined>;
+    'edit-weblinks-changed': ValueChangedEvent<WebLinkInfo[] | undefined>;
     'files-weblinks-changed': ValueChangedEvent<FilesWebLinks | undefined>;
     'is-image-diff-changed': ValueChangedEvent<boolean>;
     // Fired when the user selects a line (See gr-diff).
-    'line-selected': CustomEvent;
+    'line-selected': CustomEvent<LineSelectedEventDetail>;
     // Fired if being logged in is required.
-    'show-auth-required': void;
+    'show-auth-required': CustomEvent<{}>;
   }
 }
 
@@ -171,9 +175,6 @@
   @property({type: String})
   projectName?: RepoName;
 
-  @property({type: Boolean})
-  displayLine = false;
-
   @state()
   private _isImageDiff = false;
 
@@ -187,17 +188,14 @@
     fire(this, 'is-image-diff-changed', {value: isImageDiff});
   }
 
-  @property({type: Object})
-  commitRange?: CommitRange;
-
   @state()
-  private _editWeblinks?: GeneratedWebLink[];
+  private _editWeblinks?: WebLinkInfo[];
 
   get editWeblinks() {
     return this._editWeblinks;
   }
 
-  set editWeblinks(editWeblinks: GeneratedWebLink[] | undefined) {
+  set editWeblinks(editWeblinks: WebLinkInfo[] | undefined) {
     if (this._editWeblinks === editWeblinks) return;
     this._editWeblinks = editWeblinks;
     fire(this, 'edit-weblinks-changed', {value: editWeblinks});
@@ -322,6 +320,8 @@
 
   private readonly getChecksModel = resolve(this, checksModelToken);
 
+  private readonly getPluginLoader = resolve(this, pluginLoaderToken);
+
   // visible for testing
   readonly reporting = getAppContext().reportingService;
 
@@ -330,28 +330,19 @@
   private readonly restApiService = getAppContext().restApiService;
 
   // visible for testing
-  readonly userModel = getAppContext().userModel;
-
-  // visible for testing
-  readonly jsAPI = getAppContext().jsApiService;
+  readonly getUserModel = resolve(this, userModelToken);
 
   // visible for testing
   readonly syntaxLayer: GrSyntaxLayerWorker;
 
   private checksSubscription?: Subscription;
 
-  // for DIFF_AUTOCLOSE logging purposes only
-  readonly uid = performance.now().toString(36) + Math.random().toString(36);
-
   constructor() {
     super();
-    this.syntaxLayer = new GrSyntaxLayerWorker();
-    this.renderPrefs = {
-      ...this.renderPrefs,
-      use_lit_components: this.flags.isEnabled(
-        KnownExperimentId.DIFF_RENDERING_LIT
-      ),
-    };
+    this.syntaxLayer = new GrSyntaxLayerWorker(
+      resolve(this, highlightServiceToken),
+      () => getAppContext().reportingService
+    );
     this.addEventListener(
       // These are named inconsistently for a reason:
       // The create-comment event is fired to indicate that we should
@@ -372,7 +363,7 @@
     );
     subscribe(
       this,
-      () => this.userModel.loggedIn$,
+      () => this.getUserModel().loggedIn$,
       loggedIn => (this.loggedIn = loggedIn)
     );
     subscribe(
@@ -384,28 +375,11 @@
     );
     subscribe(
       this,
-      () => this.userModel.diffPreferences$,
+      () => this.getUserModel().diffPreferences$,
       diffPreferences => {
         this.prefs = diffPreferences;
       }
     );
-    this.logForDiffAutoClose();
-  }
-
-  // for DIFF_AUTOCLOSE logging purposes only
-  private logForDiffAutoClose() {
-    this.reporting.reportInteraction(
-      Interaction.DIFF_AUTOCLOSE_DIFF_HOST_CREATED,
-      {uid: this.uid}
-    );
-    setTimeout(() => {
-      if (!this.hasReloadBeenCalledOnce) {
-        this.reporting.reportInteraction(
-          Interaction.DIFF_AUTOCLOSE_DIFF_HOST_NOT_RENDERING,
-          {uid: this.uid}
-        );
-      }
-    }, /* 10 seconds */ 10000);
   }
 
   override connectedCallback() {
@@ -480,7 +454,9 @@
     // this method calls getThreadEls which inspects the DOM. Also <gr-diff>
     // only starts observing nodes (for thread element changes) after rendering
     // is done.
-    if (changedProperties.has('threads')) {
+    // Change in layers will likely cause gr-diff to update. Since we add
+    // threads manually we need to call threadsChanged in this case as well.
+    if (changedProperties.has('threads') || changedProperties.has('layers')) {
       this.threadsChanged(this.threads);
     }
   }
@@ -525,7 +501,6 @@
       .noAutoRender=${this.noAutoRender}
       .path=${this.path}
       .prefs=${this.prefs}
-      .displayLine=${this.displayLine}
       .isImageDiff=${this.isImageDiff}
       .noRenderOnPrefsChange=${this.noRenderOnPrefsChange}
       .renderPrefs=${this.renderPrefs}
@@ -548,17 +523,16 @@
 
   async initLayers() {
     const preferencesPromise = this.restApiService.getPreferences();
-    await getPluginLoader().awaitPluginsLoaded();
     const prefs = await preferencesPromise;
     const enableTokenHighlight = !prefs?.disable_token_highlighting;
 
     assertIsDefined(this.path, 'path');
-    this.layers = this.getLayers(this.path, enableTokenHighlight);
+    this.layers = this.getLayers(enableTokenHighlight);
     this.coverageRanges = [];
     // We kick off fetching the data here, but we don't return the promise,
     // so awaiting initLayers() will not wait for coverage data to be
     // completely loaded.
-    this.getCoverageData();
+    noAwait(this.getCoverageData());
   }
 
   /**
@@ -591,26 +565,19 @@
     return this.reloadPromise;
   }
 
-  // for DIFF_AUTOCLOSE logging purposes only
-  private reloadOngoing = false;
-
-  // for DIFF_AUTOCLOSE logging purposes only
-  private hasReloadBeenCalledOnce = false;
-
   async reloadInternal(shouldReportMetric?: boolean) {
-    this.hasReloadBeenCalledOnce = true;
     this.reporting.time(Timing.DIFF_TOTAL);
     this.reporting.time(Timing.DIFF_LOAD);
+    // TODO: Find better names for these 3 clear/cancel methods. Ideally the
+    // <gr-diff-host> should not re-used at all for another diff rendering pass.
     this.clear();
+    this.cancel();
+    this.clearDiffContent();
     assertIsDefined(this.path, 'path');
     assertIsDefined(this.changeNum, 'changeNum');
     this.diff = undefined;
     this.errorMessage = null;
     const whitespaceLevel = this.getIgnoreWhitespace();
-    if (this.reloadOngoing) {
-      this.reporting.reportInteraction(Interaction.DIFF_AUTOCLOSE_DIFF_ONGOING);
-    }
-    this.reloadOngoing = true;
 
     try {
       // We are carefully orchestrating operations that have to wait for another
@@ -620,11 +587,6 @@
       // assets in parallel.
       const layerPromise = this.initLayers();
       const diff = await this.getDiff();
-      if (diff === undefined) {
-        this.reporting.reportInteraction(
-          Interaction.DIFF_AUTOCLOSE_DIFF_UNDEFINED
-        );
-      }
       this.loadedWhitespaceLevel = whitespaceLevel;
       this.reportDiff(diff);
 
@@ -671,7 +633,6 @@
       }
     } finally {
       this.reporting.timeEnd(Timing.DIFF_TOTAL, this.timingDetails());
-      this.reloadOngoing = false;
     }
   }
 
@@ -705,19 +666,16 @@
     };
   }
 
-  private getLayers(path: string, enableTokenHighlight: boolean): DiffLayer[] {
+  private getLayers(enableTokenHighlight: boolean): DiffLayer[] {
     const layers = [];
     if (enableTokenHighlight) {
       layers.push(new TokenHighlightLayer(this));
     }
     layers.push(this.syntaxLayer);
-    // Get layers from plugins (if any).
-    layers.push(...this.jsAPI.getDiffLayers(path));
     return layers;
   }
 
   clear() {
-    if (this.path) this.jsAPI.disposeDiffLayers(this.path);
     this.layers = [];
   }
 
@@ -762,9 +720,6 @@
     const idToEl = new Map<string, GrDiffCheckResult>();
     const checkEls = this.getCheckEls();
     const dontRemove = new Set<GrDiffCheckResult>();
-    let createdCount = 0;
-    let updatedCount = 0;
-    let removedCount = 0;
     const checksCount = checks.length;
     const checkElsCount = checkEls.length;
     if (checksCount === 0 && checkElsCount === 0) return;
@@ -779,23 +734,16 @@
       if (existingEl) {
         existingEl.result = check;
         dontRemove.add(existingEl);
-        updatedCount++;
       } else {
         const newEl = this.createCheckEl(check);
         dontRemove.add(newEl);
-        createdCount++;
       }
     }
     // Remove all check els that don't have a matching check anymore.
     for (const el of checkEls) {
       if (dontRemove.has(el)) continue;
       el.remove();
-      removedCount++;
     }
-    this.reporting.reportInteraction(
-      Interaction.COMMENTS_AUTOCLOSE_CHECKS_UPDATED,
-      {createdCount, updatedCount, removedCount, checksCount, checkElsCount}
-    );
   }
 
   /**
@@ -832,7 +780,7 @@
     return el;
   }
 
-  private getCoverageData() {
+  private async getCoverageData() {
     assertIsDefined(this.changeNum, 'changeNum');
     assertIsDefined(this.change, 'change');
     assertIsDefined(this.path, 'path');
@@ -847,58 +795,39 @@
 
     const basePatchNum = toNumberOnly(this.patchRange.basePatchNum);
     const patchNum = toNumberOnly(this.patchRange.patchNum);
-    this.jsAPI
-      .getCoverageAnnotationApis()
-      .then(coverageAnnotationApis => {
-        coverageAnnotationApis.forEach(coverageAnnotationApi => {
-          const provider = coverageAnnotationApi.getCoverageProvider();
-          if (!provider) return;
-          provider(changeNum, path, basePatchNum, patchNum, change)
-            .then(coverageRanges => {
-              assertIsDefined(this.patchRange, 'patchRange');
-              if (
-                !coverageRanges ||
-                changeNum !== this.changeNum ||
-                change !== this.change ||
-                path !== this.path ||
-                basePatchNum !== toNumberOnly(this.patchRange.basePatchNum) ||
-                patchNum !== toNumberOnly(this.patchRange.patchNum)
-              ) {
-                return;
-              }
-
-              const existingCoverageRanges = this.coverageRanges;
-              this.coverageRanges = coverageRanges;
-
-              // Notify with existing coverage ranges in case there is some
-              // existing coverage data that needs to be removed
-              existingCoverageRanges.forEach(range => {
-                coverageAnnotationApi.notify(
-                  path,
-                  range.code_range.start_line,
-                  range.code_range.end_line,
-                  range.side
-                );
-              });
-
-              // Notify with new coverage data
-              coverageRanges.forEach(range => {
-                coverageAnnotationApi.notify(
-                  path,
-                  range.code_range.start_line,
-                  range.code_range.end_line,
-                  range.side
-                );
-              });
-            })
-            .catch(err => {
-              this.reporting.error('GrDiffHost Coverage', err);
-            });
-        });
-      })
-      .catch(err => {
-        this.reporting.error('GrDiffHost Coverage', err);
-      });
+    // We are simply waiting here for all plugins to be loaded. Ideally we would
+    // just react to state changes, but plugins are loaded quickly once at app
+    // startup, and coordinating incoming coverage providers with the reloading
+    // process seems to be complex enough to avoid it for the time being.
+    await this.getPluginLoader().awaitPluginsLoaded();
+    const plugins =
+      this.getPluginLoader().pluginsModel.getState().coveragePlugins;
+    const providers = plugins.map(p => p.provider);
+    for (const provider of providers) {
+      try {
+        const coverageRanges = await provider(
+          changeNum,
+          path,
+          basePatchNum,
+          patchNum,
+          change
+        );
+        assertIsDefined(this.patchRange, 'patchRange');
+        if (
+          !coverageRanges ||
+          changeNum !== this.changeNum ||
+          change !== this.change ||
+          path !== this.path ||
+          basePatchNum !== toNumberOnly(this.patchRange.basePatchNum) ||
+          patchNum !== toNumberOnly(this.patchRange.patchNum)
+        ) {
+          continue;
+        }
+        this.coverageRanges = coverageRanges;
+      } catch (e) {
+        if (e instanceof Error) this.reporting.error('GrDiffHost Coverage', e);
+      }
+    }
   }
 
   private computeFileThreads(
@@ -1118,20 +1047,12 @@
 
   private threadsChanged(threads: CommentThread[]) {
     const rootIdToThreadEl = new Map<UrlEncodedCommentId, GrCommentThread>();
-    const unsavedThreadEls: GrCommentThread[] = [];
     const threadEls = this.getThreadEls();
     for (const threadEl of threadEls) {
-      if (threadEl.rootId) {
-        rootIdToThreadEl.set(threadEl.rootId, threadEl);
-      } else {
-        // Unsaved thread els must have editing:true, just being defensive here.
-        if (threadEl.editing) unsavedThreadEls.push(threadEl);
-      }
+      assertIsDefined(threadEl.rootId, 'threadEl.rootId');
+      rootIdToThreadEl.set(threadEl.rootId, threadEl);
     }
     const dontRemove = new Set<GrCommentThread>();
-    let createdCount = 0;
-    let updatedCount = 0;
-    let removedCount = 0;
     const threadCount = threads.length;
     const threadElCount = threadEls.length;
     if (threadCount === 0 && threadElCount === 0) return;
@@ -1139,23 +1060,8 @@
     for (const thread of threads) {
       // Let's find an existing DOM element matching the thread. Normally this
       // is as simple as matching the rootIds.
-      let existingThreadEl =
+      const existingThreadEl =
         thread.rootId && rootIdToThreadEl.get(thread.rootId);
-      // But unsaved threads don't have rootIds. The incoming thread might be
-      // the saved version of the unsaved thread element. To verify that we
-      // check that the thread only has one comment and that their location is
-      // identical.
-      // TODO(brohlfs): This matching is not perfect. You could quickly create
-      // two new threads on the same line/range. Then this code just makes a
-      // random guess.
-      if (!existingThreadEl && thread.comments?.length === 1) {
-        for (const unsavedThreadEl of unsavedThreadEls) {
-          if (equalLocation(unsavedThreadEl.thread, thread)) {
-            existingThreadEl = unsavedThreadEl;
-            break;
-          }
-        }
-      }
       // There is a case possible where the rootIds match but the locations
       // are different. Such as when a thread was originally attached on the
       // right side of the diff but now should be attached on the left side of
@@ -1173,28 +1079,17 @@
       ) {
         existingThreadEl.thread = thread;
         dontRemove.add(existingThreadEl);
-        updatedCount++;
       } else {
         const threadEl = this.createThreadElement(thread);
         this.attachThreadElement(threadEl);
         dontRemove.add(threadEl);
-        createdCount++;
       }
     }
     // Remove all threads that are no longer existing.
     for (const threadEl of this.getThreadEls()) {
       if (dontRemove.has(threadEl)) continue;
-      // The user may have opened a couple of comment boxes for editing. They
-      // might be unsaved and thus not be reflected in `threads` yet, so let's
-      // keep them open.
-      if (threadEl.editing && threadEl.thread?.comments.length === 0) continue;
-      removedCount++;
       threadEl.remove();
     }
-    this.reporting.reportInteraction(
-      Interaction.COMMENTS_AUTOCLOSE_THREADS_UPDATED,
-      {createdCount, updatedCount, removedCount, threadCount, threadElCount}
-    );
     const portedThreadsCount = threads.filter(thread => thread.ported).length;
     const portedThreadsWithoutRange = threads.filter(
       thread => thread.ported && thread.rangeInfoLost
@@ -1247,24 +1142,21 @@
     assertIsDefined(path, 'path');
 
     const parentIndex = this.computeParentIndex();
-    const newThread: CommentThread = {
-      rootId: undefined,
-      comments: [],
-      patchNum: patchNum as RevisionPatchSetNum,
-      commentSide,
-      // TODO: Maybe just compute from patchRange.base on the fly?
-      mergeParentNum: parentIndex ?? undefined,
+    const draft: DraftInfo = {
+      ...createNew('', true),
+      patch_set: patchNum as RevisionPatchSetNum,
+      side: commentSide,
+      parent: parentIndex ?? undefined,
       path,
-      line: lineNum,
+      line: typeof lineNum === 'number' ? lineNum : undefined,
       range,
     };
-    const el = this.createThreadElement(newThread);
-    this.attachThreadElement(el);
+    this.getCommentsModel().addNewDraft(draft);
   }
 
   private canCommentOnPatchSetNum(patchNum: PatchSetNum) {
     if (!this.loggedIn) {
-      fireEvent(this, 'show-auth-required');
+      fire(this, 'show-auth-required', {});
       return false;
     }
     if (!this.patchRange) {
@@ -1394,9 +1286,6 @@
       preferredWhitespaceLevel !== loadedWhitespaceLevel &&
       !noRenderOnPrefsChange
     ) {
-      this.reporting.reportInteraction(
-        Interaction.DIFF_AUTOCLOSE_RELOAD_ON_WHITESPACE
-      );
       return this.reload();
     }
   }
@@ -1415,9 +1304,6 @@
     if (oldPrefs?.syntax_highlighting === prefs.syntax_highlighting) return;
 
     if (!noRenderOnPrefsChange) {
-      this.reporting.reportInteraction(
-        Interaction.DIFF_AUTOCLOSE_RELOAD_ON_SYNTAX
-      );
       return this.reload();
     }
   }
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.ts b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.ts
index 9d7736c..43045f7 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.ts
@@ -33,6 +33,7 @@
   BasePatchSetNum,
   BlameInfo,
   CommentRange,
+  DraftInfo,
   EDIT,
   ImageInfo,
   NumericChangeId,
@@ -46,18 +47,26 @@
 import {GrDiffHost, LineInfo} from './gr-diff-host';
 import {DiffInfo, DiffViewMode, IgnoreWhitespaceType} from '../../../api/diff';
 import {ErrorCallback} from '../../../api/rest';
-import {SinonStub} from 'sinon';
+import {SinonStub, SinonStubbedMember} from 'sinon';
 import {RunResult} from '../../../models/checks/checks-model';
 import {GrCommentThread} from '../../shared/gr-comment-thread/gr-comment-thread';
 import {assertIsDefined} from '../../../utils/common-util';
-import {GrAnnotationActionsInterface} from '../../shared/gr-js-api-interface/gr-annotation-actions-js-api';
 import {fixture, html, assert} from '@open-wc/testing';
-import {EventType} from '../../../types/events';
+import {testResolver} from '../../../test/common-test-setup';
+import {userModelToken, UserModel} from '../../../models/user/user-model';
+import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
+import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
+import {RestApiService} from '../../../services/gr-rest-api/gr-rest-api';
+import {
+  CommentsModel,
+  commentsModelToken,
+} from '../../../models/comments/comments-model';
 
 suite('gr-diff-host tests', () => {
   let element: GrDiffHost;
   let account = createAccountDetailWithId(1);
-  let getDiffRestApiStub: SinonStub;
+  let getDiffRestApiStub: SinonStubbedMember<RestApiService['getDiff']>;
+  let userModel: UserModel;
 
   setup(async () => {
     stubRestApi('getAccount').callsFake(() => Promise.resolve(account));
@@ -70,28 +79,7 @@
     // Fall back in case a test forgets to set one up
     getDiffRestApiStub.returns(Promise.resolve(createDiff()));
     await element.updateComplete;
-  });
-
-  suite('plugin layers', () => {
-    let getDiffLayersStub: sinon.SinonStub;
-    const pluginLayers = [{annotate: () => {}}, {annotate: () => {}}];
-    setup(async () => {
-      element = await fixture(html`<gr-diff-host></gr-diff-host>`);
-      getDiffLayersStub = sinon
-        .stub(element.jsAPI, 'getDiffLayers')
-        .returns(pluginLayers);
-      element.changeNum = 123 as NumericChangeId;
-      element.change = createChange();
-      element.patchRange = createPatchRange();
-      element.path = 'some/path';
-      await element.updateComplete;
-    });
-
-    test('plugin layers requested', async () => {
-      getDiffRestApiStub.returns(Promise.resolve(createDiff()));
-      await element.reload();
-      assert(getDiffLayersStub.called);
-    });
+    userModel = testResolver(userModelToken);
   });
 
   suite('render reporting', () => {
@@ -592,7 +580,7 @@
   });
 
   test('cannot create comments when not logged in', () => {
-    element.userModel.setAccount(undefined);
+    userModel.setAccount(undefined);
     element.patchRange = createPatchRange();
     const showAuthRequireSpy = sinon.spy();
     element.addEventListener('show-auth-required', showAuthRequireSpy);
@@ -681,7 +669,7 @@
     test('loadBlame', async () => {
       const mockBlame: BlameInfo[] = [createBlame()];
       const showAlertStub = sinon.stub();
-      element.addEventListener(EventType.SHOW_ALERT, showAlertStub);
+      element.addEventListener('show-alert', showAlertStub);
       const getBlameStub = stubRestApi('getBlame').returns(
         Promise.resolve(mockBlame)
       );
@@ -713,7 +701,7 @@
       const mockBlame: BlameInfo[] = [];
       const showAlertStub = sinon.stub();
       const isBlameLoadedStub = sinon.stub();
-      element.addEventListener(EventType.SHOW_ALERT, showAlertStub);
+      element.addEventListener('show-alert', showAlertStub);
       element.addEventListener('is-blame-loaded-changed', isBlameLoadedStub);
       stubRestApi('getBlame').returns(Promise.resolve(mockBlame));
       const changeNum = 42 as NumericChangeId;
@@ -793,14 +781,6 @@
     assert.equal(element.diffElement.prefs, value);
   });
 
-  test('passes in displayLine', async () => {
-    const value = true;
-    element.displayLine = value;
-    await element.updateComplete;
-    assertIsDefined(element.diffElement);
-    assert.equal(element.diffElement.displayLine, value);
-  });
-
   test('passes in hidden', async () => {
     const value = true;
     element.hidden = value;
@@ -843,7 +823,7 @@
   });
 
   suite('reportDiff', () => {
-    let reportStub: SinonStub;
+    let reportStub: SinonStubbedMember<ReportingService['reportInteraction']>;
 
     setup(async () => {
       element = await fixture(html`<gr-diff-host></gr-diff-host>`);
@@ -1024,7 +1004,12 @@
   });
 
   suite('create-comment', () => {
+    let addDraftSpy: sinon.SinonSpy;
+
     setup(async () => {
+      const commentsModel: CommentsModel = testResolver(commentsModelToken);
+      addDraftSpy = sinon.spy(commentsModel, 'addNewDraft');
+
       account = createAccountDetailWithId(1);
       element.disconnectedCallback();
       element.connectedCallback();
@@ -1042,17 +1027,12 @@
           },
         })
       );
-      assertIsDefined(element.diffElement);
-      let threads =
-        element.diffElement.querySelectorAll<GrCommentThread>(
-          'gr-comment-thread'
-        );
 
-      assert.equal(threads.length, 1);
-      assert.equal(threads[0].thread?.commentSide, CommentSide.PARENT);
-      assert.equal(threads[0].getAttribute('diff-side'), Side.LEFT);
-      assert.equal(threads[0].thread?.range, undefined);
-      assert.equal(threads[0].thread?.patchNum, 1 as RevisionPatchSetNum);
+      assert.equal(addDraftSpy.callCount, 1);
+      const draft1: DraftInfo = addDraftSpy.lastCall.firstArg;
+      assert.equal(draft1.side, CommentSide.PARENT);
+      assert.equal(draft1.range, undefined);
+      assert.equal(draft1.patch_set, 1 as RevisionPatchSetNum);
 
       // Try to fetch a thread with a different range.
       const range = {
@@ -1074,17 +1054,11 @@
         })
       );
 
-      assertIsDefined(element.diffElement);
-      threads =
-        element.diffElement.querySelectorAll<GrCommentThread>(
-          'gr-comment-thread'
-        );
-
-      assert.equal(threads.length, 2);
-      assert.equal(threads[0].thread?.commentSide, CommentSide.PARENT);
-      assert.equal(threads[0].getAttribute('diff-side'), Side.LEFT);
-      assert.equal(threads[1].thread?.range, range);
-      assert.equal(threads[1].thread?.patchNum, 1 as RevisionPatchSetNum);
+      assert.equal(addDraftSpy.callCount, 2);
+      const draft2: DraftInfo = addDraftSpy.lastCall.firstArg;
+      assert.equal(draft2.side, CommentSide.PARENT);
+      assert.equal(draft2.range, range);
+      assert.equal(draft2.patch_set, 1 as RevisionPatchSetNum);
     });
 
     test('should not be on parent if on the right', async () => {
@@ -1099,16 +1073,10 @@
         })
       );
 
-      assertIsDefined(element.diffElement);
-      const threads =
-        element.diffElement.querySelectorAll<GrCommentThread>(
-          'gr-comment-thread'
-        );
-      assert.equal(threads.length, 1);
-      const threadEl = threads[0];
-
-      assert.equal(threadEl.thread?.commentSide, CommentSide.REVISION);
-      assert.equal(threadEl.getAttribute('diff-side'), Side.RIGHT);
+      assert.equal(addDraftSpy.callCount, 1);
+      const draft1: DraftInfo = addDraftSpy.lastCall.firstArg;
+      assert.equal(draft1.side, CommentSide.REVISION);
+      assert.equal(draft1.patch_set, 3 as RevisionPatchSetNum);
     });
 
     test('should be on parent if right and base is PARENT', () => {
@@ -1122,15 +1090,10 @@
         })
       );
 
-      assertIsDefined(element.diffElement);
-      const threads =
-        element.diffElement.querySelectorAll<GrCommentThread>(
-          'gr-comment-thread'
-        );
-      const threadEl = threads[0];
-
-      assert.equal(threadEl.thread?.commentSide, CommentSide.PARENT);
-      assert.equal(threadEl.getAttribute('diff-side'), Side.LEFT);
+      assert.equal(addDraftSpy.callCount, 1);
+      const draft1: DraftInfo = addDraftSpy.lastCall.firstArg;
+      assert.equal(draft1.side, CommentSide.PARENT);
+      assert.equal(draft1.patch_set, 1 as RevisionPatchSetNum);
     });
 
     test('should be on parent if right and base negative', () => {
@@ -1144,15 +1107,11 @@
         })
       );
 
-      assertIsDefined(element.diffElement);
-      const threads =
-        element.diffElement.querySelectorAll<GrCommentThread>(
-          'gr-comment-thread'
-        );
-      const threadEl = threads[0];
-
-      assert.equal(threadEl.thread?.commentSide, CommentSide.PARENT);
-      assert.equal(threadEl.getAttribute('diff-side'), Side.LEFT);
+      assert.equal(addDraftSpy.callCount, 1);
+      const draft1: DraftInfo = addDraftSpy.lastCall.firstArg;
+      assert.equal(draft1.side, CommentSide.PARENT);
+      assert.equal(draft1.patch_set, 3 as RevisionPatchSetNum);
+      assert.equal(draft1.parent, 2);
     });
 
     test('should not be on parent otherwise', () => {
@@ -1165,15 +1124,10 @@
         })
       );
 
-      assertIsDefined(element.diffElement);
-      const threads =
-        element.diffElement.querySelectorAll<GrCommentThread>(
-          'gr-comment-thread'
-        );
-      const threadEl = threads[0];
-
-      assert.equal(threadEl.thread?.commentSide, CommentSide.REVISION);
-      assert.equal(threadEl.getAttribute('diff-side'), Side.LEFT);
+      assert.equal(addDraftSpy.callCount, 1);
+      const draft1: DraftInfo = addDraftSpy.lastCall.firstArg;
+      assert.equal(draft1.side, CommentSide.REVISION);
+      assert.equal(draft1.patch_set, 2 as RevisionPatchSetNum);
     });
 
     test(
@@ -1193,14 +1147,11 @@
           })
         );
 
-        assertIsDefined(element.diffElement);
-        const threads =
-          element.diffElement.querySelectorAll<GrCommentThread>(
-            'gr-comment-thread'
-          );
-        assert.equal(threads.length, 1);
-        assert.equal(threads[0].getAttribute('diff-side'), Side.LEFT);
-        assert.equal(threads[0].thread?.path, element.file.basePath);
+        assert.equal(addDraftSpy.callCount, 1);
+        const draft1: DraftInfo = addDraftSpy.lastCall.firstArg;
+        assert.equal(draft1.side, CommentSide.REVISION);
+        assert.equal(draft1.patch_set, 2 as RevisionPatchSetNum);
+        assert.equal(draft1.path, element.file.basePath);
       }
     );
 
@@ -1221,15 +1172,11 @@
           })
         );
 
-        assertIsDefined(element.diffElement);
-        const threads =
-          element.diffElement.querySelectorAll<GrCommentThread>(
-            'gr-comment-thread'
-          );
-
-        assert.equal(threads.length, 1);
-        assert.equal(threads[0].getAttribute('diff-side'), Side.RIGHT);
-        assert.equal(threads[0].thread?.path, element.file.path);
+        assert.equal(addDraftSpy.callCount, 1);
+        const draft1: DraftInfo = addDraftSpy.lastCall.firstArg;
+        assert.equal(draft1.side, CommentSide.REVISION);
+        assert.equal(draft1.patch_set, 3 as RevisionPatchSetNum);
+        assert.equal(draft1.path, element.file.path);
       }
     );
 
@@ -1282,48 +1229,6 @@
       assert.equal(threads.length, 2);
     });
 
-    test('unsaved thread changes to draft', async () => {
-      element.patchRange = createPatchRange(2, 3);
-      element.file = {basePath: 'file_renamed.txt', path: element.path ?? ''};
-      element.threads = [];
-      await element.updateComplete;
-
-      element.dispatchEvent(
-        new CustomEvent('create-comment', {
-          detail: {
-            side: Side.RIGHT,
-            path: element.path,
-            lineNum: 13,
-          },
-        })
-      );
-      await element.updateComplete;
-      assert.equal(element.getThreadEls().length, 1);
-      const threadEl = element.getThreadEls()[0];
-      assert.equal(threadEl.thread?.line, 13);
-      assert.isDefined(threadEl.unsavedComment);
-      assert.equal(threadEl.thread?.comments.length, 0);
-
-      const draftThread = createCommentThread([
-        {
-          path: element.path,
-          patch_set: 3 as RevisionPatchSetNum,
-          line: 13,
-          __draft: true,
-        },
-      ]);
-      element.threads = [draftThread];
-      await element.updateComplete;
-
-      // We expect that no additional thread element was created.
-      assert.equal(element.getThreadEls().length, 1);
-      // In fact the thread element must still be the same.
-      assert.equal(element.getThreadEls()[0], threadEl);
-      // But it must have been updated from unsaved to draft:
-      assert.isUndefined(threadEl.unsavedComment);
-      assert.equal(threadEl.thread?.comments.length, 1);
-    });
-
     test(
       'thread should use new file path if first created ' +
         'on patch set (left) but is base',
@@ -1341,21 +1246,17 @@
           })
         );
 
-        assertIsDefined(element.diffElement);
-        const threads =
-          element.diffElement.querySelectorAll<GrCommentThread>(
-            'gr-comment-thread'
-          );
-
-        assert.equal(threads.length, 1);
-        assert.equal(threads[0].getAttribute('diff-side'), Side.LEFT);
-        assert.equal(threads[0].thread?.path, element.file.path);
+        assert.equal(addDraftSpy.callCount, 1);
+        const draft1: DraftInfo = addDraftSpy.lastCall.firstArg;
+        assert.equal(draft1.side, CommentSide.PARENT);
+        assert.equal(draft1.patch_set, 1 as RevisionPatchSetNum);
+        assert.equal(draft1.path, element.file.path);
       }
     );
 
     test('cannot create thread on an edit', () => {
       const alertSpy = sinon.spy();
-      element.addEventListener(EventType.SHOW_ALERT, alertSpy);
+      element.addEventListener('show-alert', alertSpy);
 
       const diffSide = Side.RIGHT;
       element.patchRange = {
@@ -1371,19 +1272,13 @@
         })
       );
 
-      assertIsDefined(element.diffElement);
-      const threads =
-        element.diffElement.querySelectorAll<GrCommentThread>(
-          'gr-comment-thread'
-        );
-
-      assert.equal(threads.length, 0);
+      assert.isFalse(addDraftSpy.called);
       assert.isTrue(alertSpy.called);
     });
 
     test('cannot create thread on an edit base', () => {
       const alertSpy = sinon.spy();
-      element.addEventListener(EventType.SHOW_ALERT, alertSpy);
+      element.addEventListener('show-alert', alertSpy);
 
       const diffSide = Side.LEFT;
       element.patchRange = {
@@ -1399,12 +1294,7 @@
         })
       );
 
-      assertIsDefined(element.diffElement);
-      const threads =
-        element.diffElement.querySelectorAll<GrCommentThread>(
-          'gr-comment-thread'
-        );
-      assert.equal(threads.length, 0);
+      assert.isFalse(addDraftSpy.called);
       assert.isTrue(alertSpy.called);
     });
   });
@@ -1513,7 +1403,7 @@
           ...createDiff(),
           content: [
             {
-              a: [new Array(501).join('*')],
+              a: ['*'.repeat(501)],
             },
           ],
         })
@@ -1569,7 +1459,7 @@
           ...createDiff(),
           content: [
             {
-              a: [new Array(501).join('*')],
+              a: ['*'.repeat(501)],
             },
           ],
         })
@@ -1580,9 +1470,7 @@
   });
 
   suite('coverage layer', () => {
-    let notifyStub: SinonStub;
     let coverageProviderStub: SinonStub;
-    let getCoverageAnnotationApisStub: SinonStub;
     const exampleRanges = [
       {
         type: CoverageType.COVERED,
@@ -1603,7 +1491,6 @@
     ];
 
     setup(async () => {
-      notifyStub = sinon.stub();
       coverageProviderStub = sinon
         .stub()
         .returns(Promise.resolve(exampleRanges));
@@ -1628,37 +1515,13 @@
           content: [{a: ['foo']}],
         })
       );
-      getCoverageAnnotationApisStub = sinon
-        .stub(element.jsAPI, 'getCoverageAnnotationApis')
-        .returns(
-          Promise.resolve([
-            {
-              notify: notifyStub,
-              getCoverageProvider() {
-                return coverageProviderStub;
-              },
-            } as unknown as GrAnnotationActionsInterface,
-          ])
-        );
+      testResolver(pluginLoaderToken).pluginsModel.coverageRegister({
+        pluginName: 'test-coverage-plugin',
+        provider: coverageProviderStub,
+      });
       await element.reload();
     });
 
-    test('getCoverageAnnotationApis should be called', async () => {
-      await element.waitForReloadToRender();
-      assert.isTrue(getCoverageAnnotationApisStub.calledOnce);
-    });
-
-    test('coverageRangeChanged should be called', async () => {
-      await element.waitForReloadToRender();
-      assert.equal(notifyStub.callCount, 2);
-      assert.isTrue(
-        notifyStub.calledWithExactly('some/path', 1, 2, Side.RIGHT)
-      );
-      assert.isTrue(
-        notifyStub.calledWithExactly('some/path', 3, 4, Side.RIGHT)
-      );
-    });
-
     test('provider is called with appropriate params', async () => {
       element.patchRange = createPatchRange(1, 3);
       await element.updateComplete;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog.ts b/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog.ts
index 17004d9..d580127 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog.ts
@@ -5,31 +5,27 @@
  */
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-diff-preferences/gr-diff-preferences';
-import '../../shared/gr-overlay/gr-overlay';
 import {GrDiffPreferences} from '../../shared/gr-diff-preferences/gr-diff-preferences';
-import {GrButton} from '../../shared/gr-button/gr-button';
-import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
 import {assertIsDefined} from '../../../utils/common-util';
 import {sharedStyles} from '../../../styles/shared-styles';
-import {LitElement, html, css, PropertyValues} from 'lit';
+import {LitElement, html, css} from 'lit';
 import {customElement, query, state} from 'lit/decorators.js';
 import {ValueChangedEvent} from '../../../types/events';
+import {modalStyles} from '../../../styles/gr-modal-styles';
+import {fireNoBubble} from '../../../utils/event-util';
 
 @customElement('gr-diff-preferences-dialog')
 export class GrDiffPreferencesDialog extends LitElement {
   @query('#diffPreferences') private diffPreferences?: GrDiffPreferences;
 
-  @query('#saveButton') private saveButton?: GrButton;
-
-  @query('#cancelButton') private cancelButton?: GrButton;
-
-  @query('#diffPrefsOverlay') private diffPrefsOverlay?: GrOverlay;
+  @query('#diffPrefsModal') private diffPrefsModal?: HTMLDialogElement;
 
   @state() diffPrefsChanged?: boolean;
 
   static override get styles() {
     return [
       sharedStyles,
+      modalStyles,
       css`
         .diffHeader,
         .diffActions {
@@ -48,7 +44,7 @@
           display: flex;
           justify-content: flex-end;
         }
-        .diffPrefsOverlay gr-button {
+        .diffPrefsModal gr-button {
           margin-left: var(--spacing-l);
         }
         div.edited:after {
@@ -65,7 +61,7 @@
 
   override render() {
     return html`
-      <gr-overlay id="diffPrefsOverlay" with-backdrop="">
+      <dialog id="diffPrefsModal" tabindex="-1">
         <div role="dialog" aria-labelledby="diffPreferencesTitle">
           <h3
             class="heading-3 diffHeader ${this.diffPrefsChanged
@@ -100,26 +96,10 @@
             </gr-button>
           </div>
         </div>
-      </gr-overlay>
+      </dialog>
     `;
   }
 
-  override willUpdate(changedProperties: PropertyValues) {
-    if (changedProperties.has('diffPrefsChanged')) {
-      this.onDiffPrefsChanged();
-    }
-  }
-
-  getFocusStops() {
-    assertIsDefined(this.diffPreferences, 'diffPreferences');
-    assertIsDefined(this.saveButton, 'saveButton');
-    assertIsDefined(this.cancelButton, 'cancelbutton');
-    return {
-      start: this.diffPreferences.contextSelect!,
-      end: this.saveButton.disabled ? this.cancelButton : this.saveButton,
-    };
-  }
-
   resetFocus() {
     assertIsDefined(this.diffPreferences, 'diffPreferences');
 
@@ -128,35 +108,21 @@
 
   private readonly handleCancelDiff = (e: MouseEvent) => {
     e.stopPropagation();
-    assertIsDefined(this.diffPrefsOverlay, 'diffPrefsOverlay');
-    this.diffPrefsOverlay.close();
+    assertIsDefined(this.diffPrefsModal, 'diffPrefsModal');
+    this.diffPrefsModal.close();
   };
 
-  private onDiffPrefsChanged() {
-    assertIsDefined(this.diffPrefsOverlay, 'diffPrefsOverlay');
-    this.diffPrefsOverlay.setFocusStops(this.getFocusStops());
-  }
-
   open() {
-    assertIsDefined(this.diffPrefsOverlay, 'diffPrefsOverlay');
-    this.diffPrefsOverlay.open().then(() => {
-      const focusStops = this.getFocusStops();
-      this.diffPrefsOverlay!.setFocusStops(focusStops);
-      this.resetFocus();
-    });
+    assertIsDefined(this.diffPrefsModal, 'diffPrefsModal');
+    this.diffPrefsModal.showModal();
   }
 
   private async handleSaveDiffPreferences() {
     assertIsDefined(this.diffPreferences, 'diffPreferences');
-    assertIsDefined(this.diffPrefsOverlay, 'diffPrefsOverlay');
+    assertIsDefined(this.diffPrefsModal, 'diffPrefsModal');
     await this.diffPreferences.save();
-    this.dispatchEvent(
-      new CustomEvent('reload-diff-preference', {
-        composed: true,
-        bubbles: false,
-      })
-    );
-    this.diffPrefsOverlay.close();
+    fireNoBubble(this, 'reload-diff-preference', {});
+    this.diffPrefsModal.close();
   }
 
   private readonly handleHasUnsavedChangesChanged = (
@@ -170,4 +136,7 @@
   interface HTMLElementTagNameMap {
     'gr-diff-preferences-dialog': GrDiffPreferencesDialog;
   }
+  interface HTMLElementEventMap {
+    'reload-diff-preference': CustomEvent<{}>;
+  }
 }
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog_test.ts b/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog_test.ts
index 1b484b0..7fc1044 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog_test.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog_test.ts
@@ -36,13 +36,7 @@
     assert.shadowDom.equal(
       element,
       /* HTML */ `
-        <gr-overlay
-          aria-hidden="true"
-          id="diffPrefsOverlay"
-          style="outline: none; display: none;"
-          tabindex="-1"
-          with-backdrop=""
-        >
+        <dialog id="diffPrefsModal" tabindex="-1">
           <div aria-labelledby="diffPreferencesTitle" role="dialog">
             <h3 class="diffHeader heading-3" id="diffPreferencesTitle">
               Diff Preferences
@@ -71,7 +65,7 @@
               </gr-button>
             </div>
           </div>
-        </gr-overlay>
+        </dialog>
       `
     );
   });
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
index 057a20a..bdb634b 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
@@ -12,6 +12,7 @@
 import '../../shared/gr-dropdown-list/gr-dropdown-list';
 import '../../shared/gr-icon/gr-icon';
 import '../../shared/gr-select/gr-select';
+import '../../shared/gr-weblink/gr-weblink';
 import '../../shared/revision-info/revision-info';
 import '../../../embed/diff/gr-diff-cursor/gr-diff-cursor';
 import '../gr-apply-fix-dialog/gr-apply-fix-dialog';
@@ -20,22 +21,12 @@
 import '../gr-diff-preferences-dialog/gr-diff-preferences-dialog';
 import '../gr-patch-range-select/gr-patch-range-select';
 import '../../change/gr-download-dialog/gr-download-dialog';
-import '../../shared/gr-overlay/gr-overlay';
-import {navigationToken} from '../../core/gr-navigation/gr-navigation';
 import {getAppContext} from '../../../services/app-context';
+import {isMergeParent, getParentIndex} from '../../../utils/patch-set-util';
 import {
-  computeAllPatchSets,
-  computeLatestPatchNum,
-  PatchSet,
-  isMergeParent,
-  getParentIndex,
-} from '../../../utils/patch-set-util';
-import {
-  addUnmodifiedFiles,
   computeDisplayPath,
   computeTruncatedPath,
   isMagicPath,
-  specialFilePathCompare,
 } from '../../../utils/path-list-util';
 import {changeBaseURL, changeIsOpen} from '../../../utils/change-util';
 import {GrDiffHost} from '../../diff/gr-diff-host/gr-diff-host';
@@ -43,52 +34,36 @@
   DropdownItem,
   GrDropdownList,
 } from '../../shared/gr-dropdown-list/gr-dropdown-list';
-import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
+import {CommentAnchorTapEventDetail} from '../../shared/gr-comment/gr-comment';
 import {ChangeComments} from '../../diff/gr-comment-api/gr-comment-api';
 import {
   BasePatchSetNum,
-  ChangeInfo,
-  CommitId,
   EDIT,
-  FileInfo,
   NumericChangeId,
   PARENT,
   PatchRange,
-  PatchSetNum,
   PatchSetNumber,
   PreferencesInfo,
   RepoName,
-  RevisionInfo,
   RevisionPatchSetNum,
   ServerInfo,
+  CommentMap,
 } from '../../../types/common';
-import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
+import {DiffInfo, DiffPreferencesInfo, WebLinkInfo} from '../../../types/diff';
+import {FileRange, ParsedChangeInfo} from '../../../types/types';
 import {
-  CommitRange,
-  EditRevisionInfo,
-  FileRange,
-  ParsedChangeInfo,
-} from '../../../types/types';
-import {FilesWebLinks} from '../gr-patch-range-select/gr-patch-range-select';
+  FilesWebLinks,
+  PatchRangeChangeEvent,
+} from '../gr-patch-range-select/gr-patch-range-select';
 import {GrDiffCursor} from '../../../embed/diff/gr-diff-cursor/gr-diff-cursor';
 import {CommentSide, DiffViewMode, Side} from '../../../constants/constants';
 import {GrApplyFixDialog} from '../gr-apply-fix-dialog/gr-apply-fix-dialog';
-import {
-  CommentMap,
-  getPatchRangeForCommentUrl,
-  isInBaseOfPatchRange,
-} from '../../../utils/comment-util';
-import {
-  EventType,
-  OpenFixPreviewEvent,
-  ValueChangedEvent,
-} from '../../../types/events';
-import {fireAlert, fireEvent, fireTitleChange} from '../../../utils/event-util';
-import {GerritView} from '../../../services/router/router-model';
-import {assertIsDefined} from '../../../utils/common-util';
-import {Key, toggleClass} from '../../../utils/dom-util';
+import {OpenFixPreviewEvent, ValueChangedEvent} from '../../../types/events';
+import {fireAlert, fire} from '../../../utils/event-util';
+import {assertIsDefined, queryAndAssert} from '../../../utils/common-util';
+import {toggleClass, whenVisible} from '../../../utils/dom-util';
 import {CursorMoveResult} from '../../../api/core';
-import {isFalse, throttleWrap, until} from '../../../utils/async-util';
+import {throttleWrap} from '../../../utils/async-util';
 import {filter, take, switchMap} from 'rxjs/operators';
 import {combineLatest} from 'rxjs';
 import {
@@ -96,15 +71,12 @@
   ShortcutSection,
   shortcutsServiceToken,
 } from '../../../services/shortcuts/shortcuts-service';
-import {LoadingStatus} from '../../../models/change/change-model';
-import {DisplayLine} from '../../../api/diff';
+import {DisplayLine, LineSelectedEventDetail} from '../../../api/diff';
 import {GrDownloadDialog} from '../../change/gr-download-dialog/gr-download-dialog';
-import {browserModelToken} from '../../../models/browser/browser-model';
 import {commentsModelToken} from '../../../models/comments/comments-model';
 import {changeModelToken} from '../../../models/change/change-model';
 import {resolve} from '../../../models/dependency';
-import {BehaviorSubject} from 'rxjs';
-import {css, html, LitElement, PropertyValues} from 'lit';
+import {css, html, LitElement, nothing, PropertyValues} from 'lit';
 import {ShortcutController} from '../../lit/shortcut-controller';
 import {subscribe} from '../../lit/subscription-controller';
 import {customElement, property, query, state} from 'lit/decorators.js';
@@ -115,12 +87,17 @@
 import {when} from 'lit/directives/when.js';
 import {
   createDiffUrl,
-  diffViewModelToken,
-  DiffViewState,
-} from '../../../models/views/diff';
-import {createChangeUrl} from '../../../models/views/change';
-import {createEditUrl} from '../../../models/views/edit';
-import {GeneratedWebLink} from '../../../utils/weblink-util';
+  ChangeChildView,
+  changeViewModelToken,
+} from '../../../models/views/change';
+import {userModelToken} from '../../../models/user/user-model';
+import {modalStyles} from '../../../styles/gr-modal-styles';
+import {PaperTabsElement} from '@polymer/paper-tabs/paper-tabs';
+import {GrDiffPreferencesDialog} from '../gr-diff-preferences-dialog/gr-diff-preferences-dialog';
+import {
+  FileNameToNormalizedFileInfoMap,
+  filesModelToken,
+} from '../../../models/change/files-model';
 
 const LOADING_BLAME = 'Loading blame...';
 const LOADED_BLAME = 'Blame loaded';
@@ -130,23 +107,14 @@
 
 // visible for testing
 export interface Files {
-  sortedFileList: string[];
-  changeFilesByPath: {[path: string]: FileInfo};
+  /** All file paths sorted by `specialFilePathCompare`. */
+  sortedPaths: string[];
+  changeFilesByPath: FileNameToNormalizedFileInfoMap;
 }
 
-interface CommentSkips {
-  previous: string | null;
-  next: string | null;
-}
 @customElement('gr-diff-view')
 export class GrDiffView extends LitElement {
   /**
-   * Fired when the title of the page should change.
-   *
-   * @event title-change
-   */
-
-  /**
    * Fired when user tries to navigate away while comments are pending save.
    *
    * @event show-alert
@@ -154,11 +122,11 @@
   @query('#diffHost')
   diffHost?: GrDiffHost;
 
-  @query('#reviewed')
-  reviewed?: HTMLInputElement;
+  @state()
+  reviewed = false;
 
-  @query('#downloadOverlay')
-  downloadOverlay?: GrOverlay;
+  @query('#downloadModal')
+  downloadModal?: HTMLDialogElement;
 
   @query('#downloadDialog')
   downloadDialog?: GrDownloadDialog;
@@ -170,35 +138,33 @@
   applyFixDialog?: GrApplyFixDialog;
 
   @query('#diffPreferencesDialog')
-  diffPreferencesDialog?: GrOverlay;
+  diffPreferencesDialog?: GrDiffPreferencesDialog;
 
-  private _viewState: DiffViewState | undefined;
-
+  // Private but used in tests.
   @state()
-  get viewState(): DiffViewState | undefined {
-    return this._viewState;
-  }
-
-  set viewState(viewState: DiffViewState | undefined) {
-    if (this._viewState === viewState) return;
-    const oldViewState = this._viewState;
-    this._viewState = viewState;
-    this.viewStateChanged();
-    this.requestUpdate('viewState', oldViewState);
+  get patchRange(): PatchRange | undefined {
+    if (!this.patchNum) return undefined;
+    return {
+      patchNum: this.patchNum,
+      basePatchNum: this.basePatchNum,
+    };
   }
 
   // Private but used in tests.
   @state()
-  patchRange?: PatchRange;
+  patchNum?: RevisionPatchSetNum;
 
   // Private but used in tests.
   @state()
-  commitRange?: CommitRange;
+  basePatchNum: BasePatchSetNum = PARENT;
 
   // Private but used in tests.
   @state()
   change?: ParsedChangeInfo;
 
+  @state()
+  latestPatchNum?: PatchSetNumber;
+
   // Private but used in tests.
   @state()
   changeComments?: ChangeComments;
@@ -211,35 +177,20 @@
   @state()
   diff?: DiffInfo;
 
-  // TODO: Move to using files-model.
   // Private but used in tests.
   @state()
-  files: Files = {sortedFileList: [], changeFilesByPath: {}};
+  files: Files = {sortedPaths: [], changeFilesByPath: {}};
 
-  // Private but used in tests
-  // Use path getter/setter.
-  _path?: string;
+  @state() path?: string;
 
-  get path() {
-    return this._path;
-  }
-
-  set path(path: string | undefined) {
-    if (this._path === path) return;
-    const oldPath = this._path;
-    this._path = path;
-    this.pathChanged();
-    this.requestUpdate('path', oldPath);
-  }
+  /** Allows us to react when the user switches to the DIFF view. */
+  // Private but used in tests.
+  @state() isActiveChildView = false;
 
   // Private but used in tests.
   @state()
   loggedIn = false;
 
-  // Private but used in tests.
-  @state()
-  loading = true;
-
   @property({type: Object})
   prefs?: DiffPreferencesInfo;
 
@@ -254,79 +205,68 @@
   private isImageDiff?: boolean;
 
   @state()
-  private editWeblinks?: GeneratedWebLink[];
+  private editWeblinks?: WebLinkInfo[];
 
   @state()
   private filesWeblinks?: FilesWebLinks;
 
   // Private but used in tests.
   @state()
-  commentMap?: CommentMap;
-
-  @state()
-  private commentSkips?: CommentSkips;
-
-  // Private but used in tests.
-  @state()
   isBlameLoaded?: boolean;
 
   @state()
   private isBlameLoading = false;
 
-  @state()
-  private allPatchSets?: PatchSet[] = [];
-
+  /** Directly reflects the view model property `diffView.lineNum`. */
   // Private but used in tests.
   @state()
   focusLineNum?: number;
 
+  /** Directly reflects the view model property `diffView.leftSide`. */
+  @state()
+  leftSide = false;
+
   // visible for testing
   reviewedFiles = new Set<string>();
 
   private readonly reporting = getAppContext().reportingService;
 
-  private readonly restApiService = getAppContext().restApiService;
+  private readonly getUserModel = resolve(this, userModelToken);
 
-  // Private but used in tests.
-  readonly routerModel = getAppContext().routerModel;
+  private readonly getChangeModel = resolve(this, changeModelToken);
 
-  // Private but used in tests.
-  readonly userModel = getAppContext().userModel;
+  private readonly getCommentsModel = resolve(this, commentsModelToken);
 
-  // Private but used in tests.
-  readonly getChangeModel = resolve(this, changeModelToken);
-
-  // Private but used in tests.
-  readonly getBrowserModel = resolve(this, browserModelToken);
-
-  // Private but used in tests.
-  readonly getCommentsModel = resolve(this, commentsModelToken);
+  private readonly getFilesModel = resolve(this, filesModelToken);
 
   private readonly getShortcutsService = resolve(this, shortcutsServiceToken);
 
   private readonly getConfigModel = resolve(this, configModelToken);
 
-  private readonly getViewModel = resolve(this, diffViewModelToken);
+  private readonly getViewModel = resolve(this, changeViewModelToken);
 
   private throttledToggleFileReviewed?: (e: KeyboardEvent) => void;
 
   @state()
   cursor?: GrDiffCursor;
 
-  private connected$ = new BehaviorSubject(false);
-
   private readonly shortcutsController = new ShortcutController(this);
 
-  private readonly getNavigation = resolve(this, navigationToken);
-
   constructor() {
     super();
     this.setupKeyboardShortcuts();
     this.setupSubscriptions();
     subscribe(
       this,
-      () => this.getViewModel().state$,
-      x => (this.viewState = x)
+      () => this.getFilesModel().filesIncludingUnmodified$,
+      files => {
+        const filesByPath: FileNameToNormalizedFileInfoMap = {};
+        for (const f of files) filesByPath[f.__path] = f;
+        this.files = {
+          sortedPaths: files.map(f => f.__path),
+          changeFilesByPath: filesByPath,
+        };
+      }
     );
   }
 
@@ -340,10 +280,10 @@
     listen(Shortcut.PREV_LINE, _ => this.handlePrevLine());
     listen(Shortcut.VISIBLE_LINE, _ => this.cursor?.moveToVisibleArea());
     listen(Shortcut.NEXT_FILE_WITH_COMMENTS, _ =>
-      this.moveToNextFileWithComment()
+      this.moveToFileWithComment(1)
     );
     listen(Shortcut.PREV_FILE_WITH_COMMENTS, _ =>
-      this.moveToPreviousFileWithComment()
+      this.moveToFileWithComment(-1)
     );
     listen(Shortcut.NEW_COMMENT, _ => this.handleNewComment());
     listen(Shortcut.SAVE_COMMENT, _ => {});
@@ -356,7 +296,9 @@
     listen(Shortcut.OPEN_REPLY_DIALOG, _ => this.handleOpenReplyDialog());
     listen(Shortcut.TOGGLE_LEFT_PANE, _ => this.handleToggleLeftPane());
     listen(Shortcut.OPEN_DOWNLOAD_DIALOG, _ => this.handleOpenDownloadDialog());
-    listen(Shortcut.UP_TO_CHANGE, _ => this.handleUpToChange());
+    listen(Shortcut.UP_TO_CHANGE, _ =>
+      this.getChangeModel().navigateToChange()
+    );
     listen(Shortcut.OPEN_DIFF_PREFS, _ => this.handleCommaKey());
     listen(Shortcut.TOGGLE_DIFF_MODE, _ => this.handleToggleDiffMode());
     listen(Shortcut.TOGGLE_FILE_REVIEWED, e => {
@@ -386,16 +328,12 @@
     );
     listen(Shortcut.EXPAND_ALL_COMMENT_THREADS, _ => {}); // docOnly
     listen(Shortcut.COLLAPSE_ALL_COMMENT_THREADS, _ => {}); // docOnly
-    this.shortcutsController.addGlobal({key: Key.ESC}, _ => {
-      assertIsDefined(this.diffHost, 'diffHost');
-      this.diffHost.displayLine = false;
-    });
   }
 
   private setupSubscriptions() {
     subscribe(
       this,
-      () => this.userModel.loggedIn$,
+      () => this.getUserModel().loggedIn$,
       loggedIn => {
         this.loggedIn = loggedIn;
       }
@@ -416,14 +354,14 @@
     );
     subscribe(
       this,
-      () => this.userModel.preferences$,
+      () => this.getUserModel().preferences$,
       preferences => {
         this.userPrefs = preferences;
       }
     );
     subscribe(
       this,
-      () => this.userModel.diffPreferences$,
+      () => this.getUserModel().diffPreferences$,
       diffPreferences => {
         this.prefs = diffPreferences;
       }
@@ -439,6 +377,11 @@
     );
     subscribe(
       this,
+      () => this.getChangeModel().latestPatchNum$,
+      latestPatchNum => (this.latestPatchNum = latestPatchNum)
+    );
+    subscribe(
+      this,
       () => this.getChangeModel().reviewedFiles$,
       reviewedFiles => {
         this.reviewedFiles = new Set(reviewedFiles) ?? new Set();
@@ -446,45 +389,80 @@
     );
     subscribe(
       this,
-      () => this.getChangeModel().diffPath$,
+      () => this.getViewModel().changeNum$,
+      changeNum => {
+        if (!changeNum || this.changeNum === changeNum) return;
+
+        // We are only setting the changeNum of the diff view once.
+        // Everything in the diff view is tied to the change. It seems better to
+        // force the re-creation of the diff view when the change number changes.
+        // The parent element will make sure that a new change view is created
+        // when the change number changes (using the `keyed` directive).
+        if (!this.changeNum) this.changeNum = changeNum;
+      }
+    );
+    subscribe(
+      this,
+      () => this.getViewModel().childView$,
+      childView => (this.isActiveChildView = childView === ChangeChildView.DIFF)
+    );
+    subscribe(
+      this,
+      () => this.getViewModel().diffPath$,
       path => (this.path = path)
     );
-
+    subscribe(
+      this,
+      () => this.getViewModel().diffLine$,
+      line => (this.focusLineNum = line)
+    );
+    subscribe(
+      this,
+      () => this.getViewModel().diffLeftSide$,
+      leftSide => (this.leftSide = leftSide)
+    );
+    subscribe(
+      this,
+      () => this.getViewModel().patchNum$,
+      patchNum => (this.patchNum = patchNum)
+    );
+    subscribe(
+      this,
+      () => this.getViewModel().basePatchNum$,
+      basePatchNum => (this.basePatchNum = basePatchNum ?? PARENT)
+    );
     subscribe(
       this,
       () =>
         combineLatest([
-          this.getChangeModel().diffPath$,
+          this.getViewModel().diffPath$,
           this.getChangeModel().reviewedFiles$,
         ]),
       ([path, files]) => {
-        this.updateComplete.then(() => {
-          assertIsDefined(this.reviewed, 'reviewed');
-          this.reviewed.checked = !!path && !!files && files.includes(path);
-        });
+        this.reviewed = !!path && !!files && files.includes(path);
       }
     );
 
-    // When user initially loads the diff view, we want to autmatically mark
+    // When user initially loads the diff view, we want to automatically mark
     // the file as reviewed if they have it enabled. We can't observe these
     // properties since the method will be called anytime a property updates
     // but we only want to call this on the initial load.
     subscribe(
       this,
       () =>
-        this.getChangeModel().diffPath$.pipe(
+        this.getViewModel().diffPath$.pipe(
           filter(diffPath => !!diffPath),
           switchMap(() =>
             combineLatest([
               this.getChangeModel().patchNum$,
-              this.routerModel.routerView$,
-              this.userModel.diffPreferences$,
+              this.getViewModel().childView$,
+              this.getUserModel().diffPreferences$,
               this.getChangeModel().reviewedFiles$,
             ]).pipe(
               filter(
-                ([patchNum, routerView, diffPrefs, reviewedFiles]) =>
+                ([patchNum, childView, diffPrefs, reviewedFiles]) =>
                   !!patchNum &&
-                  routerView === GerritView.DIFF &&
+                  childView === ChangeChildView.DIFF &&
                   !!diffPrefs &&
                   !!reviewedFiles
               ),
@@ -493,20 +471,18 @@
           )
         ),
       ([patchNum, _routerView, diffPrefs]) => {
-        this.setReviewedStatus(patchNum!, diffPrefs);
+        // `patchNum` must be defined, because of the `!!patchNum` filter above.
+        assertIsDefined(patchNum, 'patchNum');
+        this.setReviewedStatus(patchNum, diffPrefs);
       }
     );
-    subscribe(
-      this,
-      () => this.getChangeModel().diffPath$,
-      path => (this.path = path)
-    );
   }
 
   static override get styles() {
     return [
       a11yStyles,
       sharedStyles,
+      modalStyles,
       css`
         :host {
           display: block;
@@ -692,48 +668,21 @@
 
   override connectedCallback() {
     super.connectedCallback();
-    this.connected$.next(true);
     this.throttledToggleFileReviewed = throttleWrap(_ =>
       this.handleToggleFileReviewed()
     );
     this.addEventListener('open-fix-preview', e => this.onOpenFixPreview(e));
     this.cursor = new GrDiffCursor();
+    if (this.diffHost) this.reInitCursor();
   }
 
   override disconnectedCallback() {
     this.cursor?.dispose();
-    this.connected$.next(false);
     super.disconnectedCallback();
   }
 
-  protected override willUpdate(changedProperties: PropertyValues) {
-    super.willUpdate(changedProperties);
-    if (changedProperties.has('change')) {
-      this.allPatchSets = computeAllPatchSets(this.change);
-    }
-    if (
-      changedProperties.has('commentMap') ||
-      changedProperties.has('files') ||
-      changedProperties.has('path')
-    ) {
-      this.commentSkips = this.computeCommentSkips(
-        this.commentMap,
-        this.files?.sortedFileList,
-        this.path
-      );
-    }
-
-    if (
-      changedProperties.has('changeNum') ||
-      changedProperties.has('changeComments') ||
-      changedProperties.has('patchRange')
-    ) {
-      this.fetchFiles();
-    }
-  }
-
   private reInitCursor() {
-    assertIsDefined(this.diffHost, 'diffHost');
+    if (!this.diffHost) return;
     this.cursor?.replaceDiffs([this.diffHost]);
     this.cursor?.reInitCursor();
   }
@@ -741,16 +690,35 @@
   protected override updated(changedProperties: PropertyValues): void {
     super.updated(changedProperties);
     if (
+      changedProperties.has('change') ||
+      changedProperties.has('path') ||
+      changedProperties.has('patchNum') ||
+      changedProperties.has('basePatchNum')
+    ) {
+      this.reloadDiff();
+    } else if (
+      changedProperties.has('isActiveChildView') &&
+      this.isActiveChildView
+    ) {
+      this.initializePositions();
+    }
+    if (
+      changedProperties.has('focusLineNum') ||
+      changedProperties.has('leftSide')
+    ) {
+      this.initCursor();
+    }
+    if (
+      changedProperties.has('change') ||
       changedProperties.has('changeComments') ||
       changedProperties.has('path') ||
-      changedProperties.has('patchRange') ||
+      changedProperties.has('patchNum') ||
+      changedProperties.has('basePatchNum') ||
       changedProperties.has('files')
     ) {
-      if (this.changeComments && this.path && this.patchRange) {
+      if (this.change && this.changeComments && this.path && this.patchRange) {
         assertIsDefined(this.diffHost, 'diffHost');
-        const file = this.files?.changeFilesByPath
-          ? this.files.changeFilesByPath[this.path]
-          : undefined;
+        const file = this.files?.changeFilesByPath?.[this.path];
         this.diffHost.updateComplete.then(() => {
           assertIsDefined(this.path);
           assertIsDefined(this.patchRange);
@@ -766,23 +734,25 @@
   }
 
   override render() {
+    if (!this.isActiveChildView) return nothing;
+    if (!this.patchNum || !this.changeNum || !this.change || !this.path) {
+      return html`<div class="loading">Loading...</div>`;
+    }
     const file = this.getFileRange();
     return html`
       ${this.renderStickyHeader()}
-      <div class="loading" ?hidden=${!this.loading}>Loading...</div>
       <h2 class="assistive-tech-only">Diff view</h2>
       <gr-diff-host
         id="diffHost"
-        ?hidden=${this.loading}
         .changeNum=${this.changeNum}
         .change=${this.change}
-        .commitRange=${this.commitRange}
         .patchRange=${this.patchRange}
         .file=${file}
+        .lineOfInterest=${this.getLineOfInterest()}
         .path=${this.path}
         .projectName=${this.change?.project}
         @is-blame-loaded-changed=${this.onIsBlameLoadedChanged}
-        @comment-anchor-tap=${this.onLineSelected}
+        @comment-anchor-tap=${this.onCommentAnchorTap}
         @line-selected=${this.onLineSelected}
         @diff-changed=${this.onDiffChanged}
         @edit-weblinks-changed=${this.onEditWeblinksChanged}
@@ -797,7 +767,7 @@
 
   private renderStickyHeader() {
     return html` <div
-      class="stickyHeader ${this.computeEditMode() ? 'editMode' : ''}"
+      class="stickyHeader ${this.patchNum === EDIT ? 'editMode' : ''}"
     >
       <h1 class="assistive-tech-only">
         Diff of ${this.path ? computeTruncatedPath(this.path) : ''}
@@ -823,7 +793,8 @@
     const fileNum = this.computeFileNum(formattedFiles);
     const fileNumClass = this.computeFileNumClass(fileNum, formattedFiles);
     return html` <div>
-        <a href=${this.getChangePath()}>${this.changeNum}</a
+        <a href=${ifDefined(this.getChangeModel().changeUrl())}
+          >${this.changeNum}</a
         ><span class="changeNumberColon">:</span>
         <span class="headerSubject">${this.change?.subject}</span>
         <input
@@ -833,6 +804,7 @@
           ?hidden=${!this.loggedIn}
           title="Toggle reviewed status of file"
           aria-label="file reviewed"
+          .checked=${this.reviewed}
           @change=${this.handleReviewedChange}
         />
         <div class="jumpToFileContainer">
@@ -866,7 +838,7 @@
             Shortcut.UP_TO_CHANGE,
             ShortcutSection.NAVIGATION
           )}
-          href=${this.getChangePath()}
+          href=${ifDefined(this.getChangeModel().changeUrl())}
           >Up</a
         >
         <span class="separator"></span>
@@ -943,26 +915,22 @@
         () => html`
           <span class="separator"></span>
           ${this.editWeblinks!.map(
-            weblink => html`
-              <a target="_blank" href=${ifDefined(weblink.url)}
-                >${weblink.name}</a
-              >
-            `
+            weblink => html`<gr-weblink .info=${weblink}></gr-weblink>`
           )}
         `
       )}
-      <span class="separator"></span>
-      <div class="diffModeSelector ${diffModeSelectorClass}">
-        <span>Diff view:</span>
-        <gr-diff-mode-selector
-          id="modeSelect"
-          .saveOnChange=${this.loggedIn}
-          show-tooltip-below
-        ></gr-diff-mode-selector>
-      </div>
       ${when(
         this.loggedIn && this.prefs,
         () => html`
+          <span class="separator"></span>
+          <div class="diffModeSelector ${diffModeSelectorClass}">
+            <span>Diff view:</span>
+            <gr-diff-mode-selector
+              id="modeSelect"
+              .saveOnChange=${this.loggedIn}
+              show-tooltip-below
+            ></gr-diff-mode-selector>
+          </div>
           <span id="diffPrefsContainer">
             <span class="preferences desktop">
               <gr-tooltip-content
@@ -1009,15 +977,15 @@
         @reload-diff-preference=${this.handleReloadingDiffPreference}
       >
       </gr-diff-preferences-dialog>
-      <gr-overlay id="downloadOverlay">
+      <dialog id="downloadModal" tabindex="-1">
         <gr-download-dialog
           id="downloadDialog"
           .change=${this.change}
-          .patchNum=${this.patchRange?.patchNum}
+          .patchNum=${this.patchNum}
           .config=${this.serverConfig?.download}
           @close=${this.handleDownloadDialogClose}
         ></gr-download-dialog>
-      </gr-overlay>`;
+      </dialog>`;
   }
 
   /**
@@ -1038,36 +1006,12 @@
     if (!this.files || !this.path) return;
     const fileInfo = this.files.changeFilesByPath[this.path];
     const fileRange: FileRange = {path: this.path};
-    if (fileInfo && fileInfo.old_path) {
+    if (fileInfo?.old_path) {
       fileRange.basePath = fileInfo.old_path;
     }
     return fileRange;
   }
 
-  // Private but used in tests.
-  fetchFiles() {
-    if (!this.changeNum || !this.patchRange || !this.changeComments) {
-      return Promise.resolve();
-    }
-
-    if (!this.patchRange.patchNum) {
-      return Promise.resolve();
-    }
-
-    return this.restApiService
-      .getChangeFiles(this.changeNum, this.patchRange)
-      .then(changeFiles => {
-        if (!changeFiles) return;
-        const commentedPaths = this.changeComments!.getPaths(this.patchRange);
-        const files = {...changeFiles};
-        addUnmodifiedFiles(files, commentedPaths);
-        this.files = {
-          sortedFileList: Object.keys(files).sort(specialFilePathCompare),
-          changeFilesByPath: files,
-        };
-      });
-  }
-
   private handleReviewedChange(e: Event) {
     const input = e.target as HTMLInputElement;
     this.setReviewed(input.checked ?? false);
@@ -1076,12 +1020,14 @@
   // Private but used in tests.
   setReviewed(
     reviewed: boolean,
-    patchNum: RevisionPatchSetNum | undefined = this.patchRange?.patchNum
+    patchNum: RevisionPatchSetNum | undefined = this.patchNum
   ) {
-    if (this.computeEditMode()) return;
+    if (this.patchNum === EDIT) return;
     if (!patchNum || !this.path || !this.changeNum) return;
     // if file is already reviewed then do not make a saveReview request
     if (this.reviewedFiles.has(this.path) && reviewed) return;
+    // optimistic update
+    this.reviewed = reviewed;
     this.getChangeModel().setReviewedFilesStatus(
       this.changeNum,
       patchNum,
@@ -1092,13 +1038,11 @@
 
   // Private but used in tests.
   handleToggleFileReviewed() {
-    assertIsDefined(this.reviewed);
-    this.setReviewed(!this.reviewed.checked);
+    this.setReviewed(!this.reviewed);
   }
 
   private handlePrevLine() {
     assertIsDefined(this.diffHost, 'diffHost');
-    this.diffHost.displayLine = true;
     this.cursor?.moveUp();
   }
 
@@ -1116,7 +1060,7 @@
   }
 
   private onEditWeblinksChanged(
-    e: ValueChangedEvent<GeneratedWebLink[] | undefined>
+    e: ValueChangedEvent<WebLinkInfo[] | undefined>
   ) {
     this.editWeblinks = e.detail.value;
   }
@@ -1133,53 +1077,17 @@
 
   private handleNextLine() {
     assertIsDefined(this.diffHost, 'diffHost');
-    this.diffHost.displayLine = true;
     this.cursor?.moveDown();
   }
 
   // Private but used in tests.
-  moveToPreviousFileWithComment() {
-    if (!this.commentSkips) return;
-    if (!this.change) return;
-    if (!this.patchRange?.patchNum) return;
-
-    // If there is no previous diff with comments, then return to the change
-    // view.
-    if (!this.commentSkips.previous) {
-      this.navToChangeView();
-      return;
+  moveToFileWithComment(direction: -1 | 1) {
+    const path = this.findFileWithComment(direction);
+    if (!path) {
+      this.getChangeModel().navigateToChange();
+    } else {
+      this.getChangeModel().navigateToDiff({path});
     }
-
-    this.getNavigation().setUrl(
-      createDiffUrl({
-        change: this.change,
-        path: this.commentSkips.previous,
-        patchNum: this.patchRange.patchNum,
-        basePatchNum: this.patchRange.basePatchNum,
-      })
-    );
-  }
-
-  // Private but used in tests.
-  moveToNextFileWithComment() {
-    if (!this.commentSkips) return;
-    if (!this.change) return;
-    if (!this.patchRange?.patchNum) return;
-
-    // If there is no next diff with comments, then return to the change view.
-    if (!this.commentSkips.next) {
-      this.navToChangeView();
-      return;
-    }
-
-    this.getNavigation().setUrl(
-      createDiffUrl({
-        change: this.change,
-        path: this.commentSkips.next,
-        patchNum: this.patchRange.patchNum,
-        basePatchNum: this.patchRange.basePatchNum,
-      })
-    );
   }
 
   private handleNewComment() {
@@ -1189,14 +1097,14 @@
 
   private handlePrevFile() {
     if (!this.path) return;
-    if (!this.files?.sortedFileList) return;
-    this.navToFile(this.files.sortedFileList, -1);
+    if (!this.files?.sortedPaths) return;
+    this.navToFile(this.files.sortedPaths, -1);
   }
 
   private handleNextFile() {
     if (!this.path) return;
-    if (!this.files?.sortedFileList) return;
-    this.navToFile(this.files.sortedFileList, 1);
+    if (!this.files?.sortedPaths) return;
+    this.navToFile(this.files.sortedPaths, 1);
   }
 
   private handleNextChunk() {
@@ -1240,11 +1148,11 @@
 
   private navigateToUnreviewedFile(direction: string) {
     if (!this.path) return;
-    if (!this.files?.sortedFileList) return;
+    if (!this.files?.sortedPaths) return;
     if (!this.reviewedFiles) return;
     // Ensure that the currently viewed file always appears in unreviewedFiles
     // so we resolve the right "next" file.
-    const unreviewedFiles = this.files.sortedFileList.filter(
+    const unreviewedFiles = this.files.sortedPaths.filter(
       file => file === this.path || !this.reviewedFiles.has(file)
     );
 
@@ -1265,10 +1173,10 @@
   // Similar to gr-change-view.handleOpenReplyDialog
   private handleOpenReplyDialog() {
     if (!this.loggedIn) {
-      fireEvent(this, 'show-auth-required');
+      fire(this, 'show-auth-required', {});
       return;
     }
-    this.navToChangeView(true);
+    this.getChangeModel().navigateToChange(true);
   }
 
   private handleToggleLeftPane() {
@@ -1277,22 +1185,31 @@
   }
 
   private handleOpenDownloadDialog() {
-    assertIsDefined(this.downloadOverlay, 'downloadOverlay');
-    this.downloadOverlay.open().then(() => {
-      assertIsDefined(this.downloadOverlay, 'downloadOverlay');
-      assertIsDefined(this.downloadDialog, 'downloadOverlay');
-      this.downloadOverlay.setFocusStops(this.downloadDialog.getFocusStops());
+    assertIsDefined(this.downloadModal, 'downloadModal');
+    this.downloadModal.showModal();
+    whenVisible(this.downloadModal, () => {
+      assertIsDefined(this.downloadModal, 'downloadModal');
+      assertIsDefined(this.downloadDialog, 'downloadDialog');
       this.downloadDialog.focus();
+      const downloadCommands = queryAndAssert(
+        this.downloadDialog,
+        'gr-download-commands'
+      );
+      const paperTabs = queryAndAssert<PaperTabsElement>(
+        downloadCommands,
+        'paper-tabs'
+      );
+      // Paper Tabs normally listen to 'iron-resize' event to call this method.
+      // After migrating to Dialog element, this event is no longer fired
+      // which means this method is not called which ends up styling the
+      // selected paper tab with an underline.
+      paperTabs._onTabSizingChanged();
     });
   }
 
   private handleDownloadDialogClose() {
-    assertIsDefined(this.downloadOverlay, 'downloadOverlay');
-    this.downloadOverlay.close();
-  }
-
-  private handleUpToChange() {
-    this.navToChangeView();
+    assertIsDefined(this.downloadModal, 'downloadModal');
+    this.downloadModal.close();
   }
 
   private handleCommaKey() {
@@ -1305,28 +1222,15 @@
   handleToggleDiffMode() {
     if (!this.userPrefs) return;
     if (this.userPrefs.diff_view === DiffViewMode.SIDE_BY_SIDE) {
-      this.userModel.updatePreferences({diff_view: DiffViewMode.UNIFIED});
+      this.getUserModel().updatePreferences({diff_view: DiffViewMode.UNIFIED});
     } else {
-      this.userModel.updatePreferences({
+      this.getUserModel().updatePreferences({
         diff_view: DiffViewMode.SIDE_BY_SIDE,
       });
     }
   }
 
   // Private but used in tests.
-  navToChangeView(openReplyDialog = false) {
-    if (!this.changeNum || !this.patchRange?.patchNum) {
-      return;
-    }
-    this.navigateToChange(
-      this.change,
-      this.patchRange,
-      this.change && this.change.revisions,
-      openReplyDialog
-    );
-  }
-
-  // Private but used in tests.
   navToFile(
     fileList: string[],
     direction: -1 | 1,
@@ -1334,15 +1238,10 @@
   ) {
     const newPath = this.getNavLinkPath(fileList, direction);
     if (!newPath) return;
-    if (!this.change) return;
     if (!this.patchRange) return;
 
     if (newPath.up) {
-      this.navigateToChange(
-        this.change,
-        this.patchRange,
-        this.change && this.change.revisions
-      );
+      this.getChangeModel().navigateToChange();
       return;
     }
 
@@ -1353,15 +1252,7 @@
         newPath.path,
         this.patchRange
       )?.[0].line;
-    this.getNavigation().setUrl(
-      createDiffUrl({
-        change: this.change,
-        path: newPath.path,
-        patchNum: this.patchRange.patchNum,
-        basePatchNum: this.patchRange.basePatchNum,
-        lineNum,
-      })
-    );
+    this.getChangeModel().navigateToDiff({path: newPath.path, lineNum});
   }
 
   /**
@@ -1372,35 +1263,25 @@
   private computeNavLinkURL(direction?: -1 | 1) {
     if (!this.change) return;
     if (!this.path) return;
-    if (!this.files?.sortedFileList) return;
+    if (!this.files?.sortedPaths) return;
     if (!direction) return;
 
-    const newPath = this.getNavLinkPath(this.files.sortedFileList, direction);
-    if (!newPath) {
-      return;
-    }
-
-    if (newPath.up) {
-      return this.getChangePath();
-    }
-    return this.getDiffUrl(this.change, this.patchRange, newPath.path);
+    const newPath = this.getNavLinkPath(this.files.sortedPaths, direction);
+    if (!newPath) return;
+    if (newPath.up) return this.getChangeModel().changeUrl();
+    if (!newPath.path) return;
+    return this.getChangeModel().diffUrl({path: newPath.path});
   }
 
   private goToEditFile() {
-    if (!this.change) return;
-    if (!this.path) return;
-    if (!this.patchRange) return;
+    assertIsDefined(this.path, 'path');
 
     // TODO(taoalpha): add a shortcut for editing
     const cursorAddress = this.cursor?.getAddress();
-    const editUrl = createEditUrl({
-      changeNum: this.change._number,
-      project: this.change.project,
+    this.getChangeModel().navigateToEdit({
       path: this.path,
-      patchNum: this.patchRange.patchNum,
       lineNum: cursorAddress?.number,
     });
-    this.getNavigation().setUrl(editUrl);
   }
 
   /**
@@ -1422,7 +1303,6 @@
     if (!this.path || !fileList || fileList.length === 0) {
       return null;
     }
-
     let idx = fileList.indexOf(this.path);
     if (idx === -1) {
       const file = direction > 0 ? fileList[0] : fileList[fileList.length - 1];
@@ -1430,7 +1310,7 @@
     }
 
     idx += direction;
-    // Redirect to the change view if opt_noUp isn’t truthy and idx falls
+    // Redirect to the change view if noUp isn’t truthy and idx falls
     // outside the bounds of [0, fileList.length).
     if (idx < 0 || idx > fileList.length - 1) {
       return {up: true};
@@ -1439,412 +1319,71 @@
     return {path: fileList[idx]};
   }
 
-  // Private but used in tests.
-  initLineOfInterestAndCursor(leftSide: boolean) {
-    assertIsDefined(this.diffHost, 'diffHost');
-    this.diffHost.lineOfInterest = this.getLineOfInterest(leftSide);
-    this.initCursor(leftSide);
-  }
-
-  // Private but used in tests.
-  displayDiffBaseAgainstLeftToast() {
-    if (!this.patchRange) return;
-    fireAlert(
-      this,
-      `Patchset ${this.patchRange.basePatchNum} vs ` +
-        `${this.patchRange.patchNum} selected. Press v + \u2190 to view ` +
-        `Base vs ${this.patchRange.basePatchNum}`
-    );
-  }
-
-  private displayDiffAgainstLatestToast(latestPatchNum?: PatchSetNum) {
-    if (!this.patchRange) return;
-    const leftPatchset =
-      this.patchRange.basePatchNum === PARENT
-        ? 'Base'
-        : `Patchset ${this.patchRange.basePatchNum}`;
-    fireAlert(
-      this,
-      `${leftPatchset} vs
-            ${this.patchRange.patchNum} selected\n. Press v + \u2191 to view
-            ${leftPatchset} vs Patchset ${latestPatchNum}`
-    );
-  }
-
-  private displayToasts() {
-    if (!this.patchRange) return;
-    if (this.patchRange.basePatchNum !== PARENT) {
-      this.displayDiffBaseAgainstLeftToast();
-      return;
-    }
-    const latestPatchNum = computeLatestPatchNum(this.allPatchSets);
-    if (this.patchRange.patchNum !== latestPatchNum) {
-      this.displayDiffAgainstLatestToast(latestPatchNum);
-      return;
-    }
-  }
-
-  private initCommitRange() {
-    let commit: CommitId | undefined;
-    let baseCommit: CommitId | undefined;
-    if (!this.change) return;
-    if (!this.patchRange || !this.patchRange.patchNum) return;
-    const revisions = this.change.revisions ?? {};
-    for (const [commitSha, revision] of Object.entries(revisions)) {
-      const patchNum = revision._number;
-      if (patchNum === this.patchRange.patchNum) {
-        commit = commitSha as CommitId;
-        const commitObj = revision.commit;
-        const parents = commitObj?.parents || [];
-        if (this.patchRange.basePatchNum === PARENT && parents.length) {
-          baseCommit = parents[parents.length - 1].commit;
-        }
-      } else if (patchNum === this.patchRange.basePatchNum) {
-        baseCommit = commitSha as CommitId;
-      }
-    }
-    this.commitRange = commit && baseCommit ? {commit, baseCommit} : undefined;
-  }
-
   private updateUrlToDiffUrl(lineNum?: number, leftSide?: boolean) {
     if (!this.change) return;
-    if (!this.patchRange) return;
+    if (!this.patchNum) return;
     if (!this.changeNum) return;
     if (!this.path) return;
     const url = createDiffUrl({
       changeNum: this.changeNum,
-      project: this.change.project,
-      path: this.path,
-      patchNum: this.patchRange.patchNum,
-      basePatchNum: this.patchRange.basePatchNum,
-      lineNum,
-      leftSide,
+      repo: this.change.project,
+      patchNum: this.patchNum,
+      basePatchNum: this.basePatchNum,
+      diffView: {
+        path: this.path,
+        lineNum,
+        leftSide,
+      },
     });
     history.replaceState(null, '', url);
   }
 
-  // Private but used in tests.
-  initPatchRange() {
-    let leftSide = false;
-    if (!this.change) return;
-    if (this.viewState?.view !== GerritView.DIFF) return;
-    if (this.viewState?.commentId) {
-      const comment = this.changeComments?.findCommentById(
-        this.viewState.commentId
-      );
-      if (!comment) {
-        fireAlert(this, 'comment not found');
-        this.getNavigation().setUrl(createChangeUrl({change: this.change}));
-        return;
-      }
-      this.getChangeModel().updatePath(comment.path);
-
-      const latestPatchNum = computeLatestPatchNum(this.allPatchSets);
-      if (!latestPatchNum) throw new Error('Missing allPatchSets');
-      this.patchRange = getPatchRangeForCommentUrl(comment, latestPatchNum);
-      leftSide = isInBaseOfPatchRange(comment, this.patchRange);
-
-      this.focusLineNum = comment.line;
-    } else {
-      if (this.viewState.path) {
-        this.getChangeModel().updatePath(this.viewState.path);
-      }
-      if (this.viewState.patchNum) {
-        this.patchRange = {
-          patchNum: this.viewState.patchNum,
-          basePatchNum: this.viewState.basePatchNum || PARENT,
-        };
-      }
-      if (this.viewState.lineNum) {
-        this.focusLineNum = this.viewState.lineNum;
-        leftSide = !!this.viewState.leftSide;
-      }
-    }
-    assertIsDefined(this.patchRange, 'patchRange');
-    this.initLineOfInterestAndCursor(leftSide);
-
-    if (this.viewState?.commentId) {
-      // url is of type /comment/{commentId} which isn't meaningful
-      this.updateUrlToDiffUrl(this.focusLineNum, leftSide);
-    }
-
-    this.commentMap = this.getPaths();
+  async reloadDiff() {
+    if (!this.diffHost) return;
+    await this.diffHost.reload(true);
+    this.reporting.diffViewDisplayed();
+    if (this.isBlameLoaded) this.loadBlame();
   }
 
-  // Private but used in tests.
-  isFileUnchanged(diff?: DiffInfo) {
-    if (!diff || !diff.content) return false;
-    return !diff.content.some(
-      content =>
-        (content.a && !content.common) || (content.b && !content.common)
-    );
-  }
-
-  private isSameDiffLoaded(value: DiffViewState) {
-    return (
-      this.patchRange?.basePatchNum === value.basePatchNum &&
-      this.patchRange?.patchNum === value.patchNum &&
-      this.path === value.path
-    );
-  }
-
-  private async untilModelLoaded() {
-    // NOTE: Wait until this page is connected before determining whether the
-    // model is loaded.  This can happen when params are changed when setting up
-    // this view. It's unclear whether this issue is related to Polymer
-    // specifically.
-    if (!this.isConnected) {
-      await until(this.connected$, connected => connected);
-    }
-    await until(
-      this.getChangeModel().changeLoadingStatus$,
-      status => status === LoadingStatus.LOADED
-    );
-  }
-
-  // Private but used in tests.
-  viewStateChanged() {
-    if (this.viewState === undefined) return;
-    const viewState = this.viewState;
-
+  /**
+   * (Re-initialize) the diff view without actually reloading the diff. The
+   * typical user journey is that the user comes back from the change page.
+   */
+  initializePositions() {
     // The diff view is kept in the background once created. If the user
     // scrolls in the change page, the scrolling is reflected in the diff view
     // as well, which means the diff is scrolled to a random position based
     // on how much the change view was scrolled.
     // Hence, reset the scroll position here.
     document.documentElement.scrollTop = 0;
-
-    // Everything in the diff view is tied to the change. It seems better to
-    // force the re-creation of the diff view when the change number changes.
-    const changeChanged = this.changeNum !== viewState.changeNum;
-    if (this.changeNum !== undefined && changeChanged) {
-      fireEvent(this, EventType.RECREATE_DIFF_VIEW);
-      return;
-    } else if (
-      this.changeNum !== undefined &&
-      this.isSameDiffLoaded(viewState)
-    ) {
-      // changeNum has not changed, so check if there are changes in patchRange
-      // path. If no changes then we can simply render the view as is.
-      this.reporting.reportInteraction('diff-view-re-rendered');
-      // Make sure to re-initialize the cursor because this is typically
-      // done on the 'render' event which doesn't fire in this path as
-      // rerendering is avoided.
-      this.reInitCursor();
-      this.diffHost?.initLayers();
-      return;
-    }
-
-    this.files = {sortedFileList: [], changeFilesByPath: {}};
-    if (this.isConnected) {
-      this.getChangeModel().updatePath(undefined);
-    }
-    this.patchRange = undefined;
-    this.commitRange = undefined;
-    this.focusLineNum = undefined;
-
-    if (viewState.changeNum && viewState.project) {
-      this.restApiService.setInProjectLookup(
-        viewState.changeNum,
-        viewState.project
-      );
-    }
-
-    this.changeNum = viewState.changeNum;
+    this.reInitCursor();
+    this.diffHost?.initLayers();
     this.classList.remove('hideComments');
-
-    // When navigating away from the page, there is a possibility that the
-    // patch number is no longer a part of the URL (say when navigating to
-    // the top-level change info view) and therefore undefined in `params`.
-    // If route is of type /comment/<commentId>/ then no patchNum is present
-    if (!viewState.patchNum && !viewState.commentLink) {
-      this.reporting.error(
-        'GrDiffView',
-        new Error(`Invalid diff view URL, no patchNum found: ${this.viewState}`)
-      );
-      return;
-    }
-
-    const promises: Promise<unknown>[] = [];
-    if (!this.change) {
-      promises.push(this.untilModelLoaded());
-    }
-    promises.push(this.waitUntilCommentsLoaded());
-
-    if (this.diffHost) {
-      this.diffHost.cancel();
-      this.diffHost.clearDiffContent();
-    }
-    this.loading = true;
-    return Promise.all(promises)
-      .then(() => {
-        this.loading = false;
-        this.initPatchRange();
-        this.initCommitRange();
-        return this.updateComplete.then(() => this.diffHost!.reload(true));
-      })
-      .then(() => {
-        this.reporting.diffViewDisplayed();
-      })
-      .then(() => {
-        const fileUnchanged = this.isFileUnchanged(this.diff);
-        if (fileUnchanged && viewState.commentLink) {
-          assertIsDefined(this.change, 'change');
-          assertIsDefined(this.path, 'path');
-          assertIsDefined(this.patchRange, 'patchRange');
-
-          if (this.patchRange.basePatchNum === PARENT) {
-            // file is unchanged between Base vs X
-            // hence should not show diff between Base vs Base
-            return;
-          }
-
-          fireAlert(
-            this,
-            `File is unchanged between Patchset
-                  ${this.patchRange.basePatchNum} and
-                  ${this.patchRange.patchNum}. Showing diff of Base vs
-                  ${this.patchRange.basePatchNum}`
-          );
-          this.getNavigation().setUrl(
-            createDiffUrl({
-              change: this.change,
-              path: this.path,
-              patchNum: this.patchRange.basePatchNum as RevisionPatchSetNum,
-              basePatchNum: PARENT,
-              lineNum: this.focusLineNum,
-            })
-          );
-          return;
-        }
-        if (viewState.commentLink) {
-          this.displayToasts();
-        }
-        // If the blame was loaded for a previous file and user navigates to
-        // another file, then we load the blame for this file too
-        if (this.isBlameLoaded) this.loadBlame();
-      });
-  }
-
-  private async waitUntilCommentsLoaded() {
-    await until(this.connected$, c => c);
-    await until(this.getCommentsModel().commentsLoading$, isFalse);
   }
 
   /**
    * If the params specify a diff address then configure the diff cursor.
    * Private but used in tests.
    */
-  initCursor(leftSide: boolean) {
-    if (this.focusLineNum === undefined) {
-      return;
-    }
+  initCursor() {
+    if (!this.focusLineNum) return;
     if (!this.cursor) return;
-    if (leftSide) {
-      this.cursor.side = Side.LEFT;
-    } else {
-      this.cursor.side = Side.RIGHT;
-    }
+    this.cursor.side = this.leftSide ? Side.LEFT : Side.RIGHT;
     this.cursor.initialLineNumber = this.focusLineNum;
   }
 
   // Private but used in tests.
-  getLineOfInterest(leftSide: boolean): DisplayLine | undefined {
+  getLineOfInterest(): DisplayLine | undefined {
     // If there is a line number specified, pass it along to the diff so that
     // it will not get collapsed.
-    if (!this.focusLineNum) {
-      return undefined;
-    }
+    if (!this.focusLineNum) return undefined;
 
     return {
       lineNum: this.focusLineNum,
-      side: leftSide ? Side.LEFT : Side.RIGHT,
+      side: this.leftSide ? Side.LEFT : Side.RIGHT,
     };
   }
 
-  private pathChanged() {
-    if (this.path) {
-      fireTitleChange(this, computeTruncatedPath(this.path));
-    }
-  }
-
-  private getDiffUrl(
-    change?: ChangeInfo | ParsedChangeInfo,
-    patchRange?: PatchRange,
-    path?: string
-  ) {
-    if (!change || !patchRange || !path) return '';
-    return createDiffUrl({
-      changeNum: change._number,
-      project: change.project,
-      path,
-      patchNum: patchRange.patchNum,
-      basePatchNum: patchRange.basePatchNum,
-    });
-  }
-
-  /**
-   * When the latest patch of the change is selected (and there is no base
-   * patch) then the patch range need not appear in the URL. Return a patch
-   * range object with undefined values when a range is not needed.
-   */
-  private getChangeUrlRange(
-    patchRange?: PatchRange,
-    revisions?: {[revisionId: string]: RevisionInfo | EditRevisionInfo}
-  ) {
-    let patchNum = undefined;
-    let basePatchNum = undefined;
-    let latestPatchNum = -1;
-    for (const rev of Object.values(revisions || {})) {
-      if (typeof rev._number === 'number') {
-        latestPatchNum = Math.max(latestPatchNum, rev._number);
-      }
-    }
-    if (!patchRange) return {patchNum, basePatchNum};
-    if (
-      patchRange.basePatchNum !== PARENT ||
-      patchRange.patchNum !== latestPatchNum
-    ) {
-      patchNum = patchRange.patchNum;
-      basePatchNum = patchRange.basePatchNum;
-    }
-    return {patchNum, basePatchNum};
-  }
-
-  private getChangePath() {
-    if (!this.change) return '';
-    if (!this.patchRange) return '';
-
-    const range = this.getChangeUrlRange(
-      this.patchRange,
-      this.change.revisions
-    );
-    return createChangeUrl({
-      change: this.change,
-      patchNum: range.patchNum,
-      basePatchNum: range.basePatchNum,
-    });
-  }
-
-  // Private but used in tests.
-  navigateToChange(
-    change?: ChangeInfo | ParsedChangeInfo,
-    patchRange?: PatchRange,
-    revisions?: {[revisionId: string]: RevisionInfo | EditRevisionInfo},
-    openReplyDialog?: boolean
-  ) {
-    if (!change) return;
-    const range = this.getChangeUrlRange(patchRange, revisions);
-    this.getNavigation().setUrl(
-      createChangeUrl({
-        change,
-        patchNum: range.patchNum,
-        basePatchNum: range.basePatchNum,
-        openReplyDialog: !!openReplyDialog,
-      })
-    );
-  }
-
   // Private but used in tests
   formatFilesForDropdown(): DropdownItem[] {
     if (!this.files) return [];
@@ -1852,7 +1391,8 @@
     if (!this.changeComments) return [];
 
     const dropdownContent: DropdownItem[] = [];
-    for (const path of this.files.sortedFileList) {
+    for (const path of this.files.sortedPaths) {
+      const file = this.files.changeFilesByPath[path];
       dropdownContent.push({
         text: computeDisplayPath(path),
         mobileText: computeTruncatedPath(path),
@@ -1860,56 +1400,35 @@
         bottomText: this.changeComments.computeCommentsString(
           this.patchRange,
           path,
-          this.files.changeFilesByPath[path],
+          file,
           /* includeUnmodified= */ true
         ),
-        file: {...this.files.changeFilesByPath[path], __path: path},
+        file,
       });
     }
     return dropdownContent;
   }
 
   // Private but used in tests.
-  handleFileChange(e: CustomEvent) {
-    if (!this.change) return;
-    if (!this.patchRange) return;
-
-    // This is when it gets set initially.
-    const path = e.detail.value;
-    if (path === this.path) {
-      return;
-    }
-
-    this.getNavigation().setUrl(
-      createDiffUrl({
-        change: this.change,
-        path,
-        patchNum: this.patchRange.patchNum,
-        basePatchNum: this.patchRange.basePatchNum,
-      })
-    );
+  handleFileChange(e: ValueChangedEvent<string>) {
+    const path: string = e.detail.value;
+    if (path === this.path) return;
+    this.getChangeModel().navigateToDiff({path});
   }
 
   // Private but used in tests.
-  handlePatchChange(e: CustomEvent) {
-    if (!this.change) return;
+  handlePatchChange(e: PatchRangeChangeEvent) {
     if (!this.path) return;
-    if (!this.patchRange) return;
+    if (!this.patchNum) return;
 
     const {basePatchNum, patchNum} = e.detail;
-    if (
-      basePatchNum === this.patchRange.basePatchNum &&
-      patchNum === this.patchRange.patchNum
-    ) {
+    if (basePatchNum === this.basePatchNum && patchNum === this.patchNum) {
       return;
     }
-    this.getNavigation().setUrl(
-      createDiffUrl({
-        change: this.change,
-        path: this.path,
-        patchNum,
-        basePatchNum,
-      })
+    this.getChangeModel().navigateToDiff(
+      {path: this.path},
+      patchNum,
+      basePatchNum
     );
   }
 
@@ -1921,20 +1440,27 @@
   }
 
   // Private but used in tests.
-  onLineSelected(e: CustomEvent) {
-    // for on-comment-anchor-tap side can be PARENT/REVISIONS
-    // for on-line-selected side can be left/right
+  onCommentAnchorTap(e: CustomEvent<CommentAnchorTapEventDetail>) {
+    const lineNumber = e.detail.number;
+    if (!Number.isInteger(lineNumber)) return;
     this.updateUrlToDiffUrl(
-      e.detail.number,
-      e.detail.side === Side.LEFT || e.detail.side === CommentSide.PARENT
+      lineNumber as number,
+      e.detail.side === CommentSide.PARENT
     );
   }
 
   // Private but used in tests.
+  onLineSelected(e: CustomEvent<LineSelectedEventDetail>) {
+    const lineNumber = e.detail.number;
+    if (!Number.isInteger(lineNumber)) return;
+    this.updateUrlToDiffUrl(lineNumber as number, e.detail.side === Side.LEFT);
+  }
+
+  // Private but used in tests.
   computeDownloadDropdownLinks() {
     if (!this.change?.project) return [];
     if (!this.changeNum) return [];
-    if (!this.patchRange?.patchNum) return [];
+    if (!this.patchRange) return [];
     if (!this.path) return [];
 
     const links = [
@@ -1982,9 +1508,10 @@
     return links;
   }
 
+  // TODO: Move to view-model or router.
   // Private but used in tests.
   computeDownloadFileLink(
-    project: RepoName,
+    repo: RepoName,
     changeNum: NumericChangeId,
     patchRange: PatchRange,
     path: string,
@@ -2003,69 +1530,40 @@
       }
     }
     let url =
-      changeBaseURL(project, changeNum, patchNum) +
+      changeBaseURL(repo, changeNum, patchNum) +
       `/files/${encodeURIComponent(path)}/download`;
     if (parent) url += `?parent=${parent}`;
 
     return url;
   }
 
+  // TODO: Move to view-model or router.
   // Private but used in tests.
   computeDownloadPatchLink(
-    project: RepoName,
+    repo: RepoName,
     changeNum: NumericChangeId,
     patchRange: PatchRange,
     path: string
   ) {
-    let url = changeBaseURL(project, changeNum, patchRange.patchNum);
+    let url = changeBaseURL(repo, changeNum, patchRange.patchNum);
     url += '/patch?zip&path=' + encodeURIComponent(path);
     return url;
   }
 
   // Private but used in tests.
-  getPaths(): CommentMap {
-    if (!this.changeComments) return {};
-    return this.changeComments.getPaths(this.patchRange);
-  }
+  findFileWithComment(direction: -1 | 1): string | undefined {
+    const fileList = this.files?.sortedPaths;
+    const commentMap: CommentMap =
+      this.changeComments?.getPaths(this.patchRange) ?? {};
+    if (!fileList || fileList.length === 0) return undefined;
+    if (!this.path) return undefined;
 
-  // Private but used in tests.
-  computeCommentSkips(
-    commentMap?: CommentMap,
-    fileList?: string[],
-    path?: string
-  ): CommentSkips | undefined {
-    if (!commentMap) return undefined;
-    if (!fileList) return undefined;
-    if (!path) return undefined;
-
-    const skips: CommentSkips = {previous: null, next: null};
-    if (!fileList.length) {
-      return skips;
+    const pathIndex = fileList.indexOf(this.path);
+    const stopIndex = direction === 1 ? fileList.length : -1;
+    for (let i = pathIndex + direction; i !== stopIndex; i += direction) {
+      if (commentMap[fileList[i]]) return fileList[i];
     }
-    const pathIndex = fileList.indexOf(path);
-
-    // Scan backward for the previous file.
-    for (let i = pathIndex - 1; i >= 0; i--) {
-      if (commentMap[fileList[i]]) {
-        skips.previous = fileList[i];
-        break;
-      }
-    }
-
-    // Scan forward for the next file.
-    for (let i = pathIndex + 1; i < fileList.length; i++) {
-      if (commentMap[fileList[i]]) {
-        skips.next = fileList[i];
-        break;
-      }
-    }
-
-    return skips;
-  }
-
-  // Private but used in tests.
-  computeEditMode() {
-    return this.patchRange?.patchNum === EDIT;
+    return undefined;
   }
 
   // Private but used in tests.
@@ -2108,111 +1606,89 @@
 
   // Private but used in tests.
   handleDiffAgainstBase() {
-    if (!this.change) return;
-    if (!this.path) return;
-    if (!this.patchRange) return;
+    if (!this.isActiveChildView) return;
+    assertIsDefined(this.path, 'path');
+    assertIsDefined(this.patchNum, 'patchNum');
 
-    if (this.patchRange.basePatchNum === PARENT) {
+    if (this.basePatchNum === PARENT) {
       fireAlert(this, 'Base is already selected.');
       return;
     }
-    this.getNavigation().setUrl(
-      createDiffUrl({
-        change: this.change,
-        path: this.path,
-        patchNum: this.patchRange.patchNum,
-      })
+    this.getChangeModel().navigateToDiff(
+      {path: this.path},
+      this.patchNum,
+      PARENT
     );
   }
 
   // Private but used in tests.
   handleDiffBaseAgainstLeft() {
-    if (!this.change) return;
-    if (!this.path) return;
-    if (!this.patchRange) return;
+    if (!this.isActiveChildView) return;
+    assertIsDefined(this.path, 'path');
+    assertIsDefined(this.patchNum, 'patchNum');
 
-    if (this.patchRange.basePatchNum === PARENT) {
+    if (this.basePatchNum === PARENT) {
       fireAlert(this, 'Left is already base.');
       return;
     }
-    const lineNum =
-      this.viewState?.view === GerritView.DIFF && this.viewState?.commentLink
-        ? this.focusLineNum
-        : undefined;
-    this.getNavigation().setUrl(
-      createDiffUrl({
-        change: this.change,
-        path: this.path,
-        patchNum: this.patchRange.basePatchNum as RevisionPatchSetNum,
-        basePatchNum: PARENT,
-        lineNum,
-      })
+    this.getChangeModel().navigateToDiff(
+      {path: this.path},
+      this.basePatchNum as RevisionPatchSetNum,
+      PARENT
     );
   }
 
   // Private but used in tests.
   handleDiffAgainstLatest() {
-    if (!this.change) return;
-    if (!this.path) return;
-    if (!this.patchRange) return;
+    if (!this.isActiveChildView) return;
+    assertIsDefined(this.path, 'path');
+    assertIsDefined(this.patchNum, 'patchNum');
 
-    const latestPatchNum = computeLatestPatchNum(this.allPatchSets);
-    if (this.patchRange.patchNum === latestPatchNum) {
+    if (this.patchNum === this.latestPatchNum) {
       fireAlert(this, 'Latest is already selected.');
       return;
     }
 
-    this.getNavigation().setUrl(
-      createDiffUrl({
-        change: this.change,
-        path: this.path,
-        patchNum: latestPatchNum,
-        basePatchNum: this.patchRange.basePatchNum,
-      })
+    this.getChangeModel().navigateToDiff(
+      {path: this.path},
+      this.latestPatchNum,
+      this.basePatchNum
     );
   }
 
   // Private but used in tests.
   handleDiffRightAgainstLatest() {
-    if (!this.change) return;
-    if (!this.path) return;
-    if (!this.patchRange) return;
+    if (!this.isActiveChildView) return;
+    assertIsDefined(this.path, 'path');
+    assertIsDefined(this.patchNum, 'patchNum');
 
-    const latestPatchNum = computeLatestPatchNum(this.allPatchSets);
-    if (this.patchRange.patchNum === latestPatchNum) {
+    if (this.patchNum === this.latestPatchNum) {
       fireAlert(this, 'Right is already latest.');
       return;
     }
-    this.getNavigation().setUrl(
-      createDiffUrl({
-        change: this.change,
-        path: this.path,
-        patchNum: latestPatchNum,
-        basePatchNum: this.patchRange.patchNum as BasePatchSetNum,
-      })
+
+    this.getChangeModel().navigateToDiff(
+      {path: this.path},
+      this.latestPatchNum,
+      this.patchNum as BasePatchSetNum
     );
   }
 
   // Private but used in tests.
   handleDiffBaseAgainstLatest() {
-    if (!this.change) return;
-    if (!this.path) return;
-    if (!this.patchRange) return;
+    if (!this.isActiveChildView) return;
+    assertIsDefined(this.path, 'path');
+    assertIsDefined(this.patchNum, 'patchNum');
 
-    const latestPatchNum = computeLatestPatchNum(this.allPatchSets);
-    if (
-      this.patchRange.patchNum === latestPatchNum &&
-      this.patchRange.basePatchNum === PARENT
-    ) {
+    if (this.patchNum === this.latestPatchNum && this.basePatchNum === PARENT) {
       fireAlert(this, 'Already diffing base against latest.');
       return;
     }
-    this.getNavigation().setUrl(
-      createDiffUrl({
-        change: this.change,
-        path: this.path,
-        patchNum: latestPatchNum,
-      })
+
+    this.getChangeModel().navigateToDiff(
+      {path: this.path},
+      this.latestPatchNum,
+      PARENT
     );
   }
 
@@ -2243,20 +1719,20 @@
 
   private navigateToNextFileWithCommentThread() {
     if (!this.path) return;
-    if (!this.files?.sortedFileList) return;
-    if (!this.patchRange) return;
+    if (!this.files?.sortedPaths) return;
+    const range = this.patchRange;
+    if (!range) return;
     if (!this.change) return;
     const hasComment = (path: string) =>
-      this.changeComments?.getCommentsForPath(path, this.patchRange!)?.length ??
-      0 > 0;
-    const filesWithComments = this.files.sortedFileList.filter(
+      this.changeComments?.getCommentsForPath(path, range)?.length ?? 0 > 0;
+    const filesWithComments = this.files.sortedPaths.filter(
       file => file === this.path || hasComment(file)
     );
     this.navToFile(filesWithComments, 1, true);
   }
 
   private handleReloadingDiffPreference() {
-    this.userModel.getDiffPreferences();
+    this.getUserModel().getDiffPreferences();
   }
 
   private computeCanEdit() {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.ts b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.ts
index b6e26ab..737e964 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.ts
@@ -5,7 +5,6 @@
  */
 import '../../../test/common-test-setup';
 import './gr-diff-view';
-import {navigationToken} from '../../core/gr-navigation/gr-navigation';
 import {
   ChangeStatus,
   DiffViewMode,
@@ -18,58 +17,66 @@
   query,
   queryAll,
   queryAndAssert,
-  stubReporting,
   stubRestApi,
-  stubUsers,
   waitEventLoop,
   waitUntil,
 } from '../../../test/test-utils';
 import {ChangeComments} from '../gr-comment-api/gr-comment-api';
-import {GerritView} from '../../../services/router/router-model';
 import {
   createRevisions,
   createComment as createCommentGeneric,
-  TEST_NUMERIC_CHANGE_ID,
   createDiff,
-  createPatchRange,
   createServerInfo,
   createConfig,
   createParsedChange,
   createRevision,
-  createCommit,
   createFileInfo,
+  createDiffViewState,
+  TEST_NUMERIC_CHANGE_ID,
 } from '../../../test/test-data-generators';
 import {
   BasePatchSetNum,
   CommentInfo,
-  CommitId,
   EDIT,
-  FileInfo,
   NumericChangeId,
   PARENT,
-  PatchRange,
   PatchSetNum,
   PatchSetNumber,
-  PathToCommentsInfoMap,
   RepoName,
   RevisionPatchSetNum,
   UrlEncodedCommentId,
 } from '../../../types/common';
 import {CursorMoveResult} from '../../../api/core';
-import {DiffInfo, Side} from '../../../api/diff';
+import {Side} from '../../../api/diff';
 import {Files, GrDiffView} from './gr-diff-view';
 import {DropdownItem} from '../../shared/gr-dropdown-list/gr-dropdown-list';
-import {SinonFakeTimers, SinonStub, SinonSpy} from 'sinon';
-import {LoadingStatus} from '../../../models/change/change-model';
-import {CommentMap} from '../../../utils/comment-util';
-import {ParsedChangeInfo} from '../../../types/types';
+import {SinonFakeTimers, SinonStub, SinonStubbedMember} from 'sinon';
+import {
+  changeModelToken,
+  ChangeModel,
+  LoadingStatus,
+} from '../../../models/change/change-model';
 import {assertIsDefined} from '../../../utils/common-util';
 import {GrDiffModeSelector} from '../../../embed/diff/gr-diff-mode-selector/gr-diff-mode-selector';
 import {fixture, html, assert} from '@open-wc/testing';
-import {EventType} from '../../../types/events';
-import {Key} from '../../../utils/dom-util';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {testResolver} from '../../../test/common-test-setup';
+import {UserModel, userModelToken} from '../../../models/user/user-model';
+import {
+  commentsModelToken,
+  CommentsModel,
+} from '../../../models/comments/comments-model';
+import {
+  BrowserModel,
+  browserModelToken,
+} from '../../../models/browser/browser-model';
+import {
+  ChangeViewModel,
+  changeViewModelToken,
+} from '../../../models/views/change';
+import {FileNameToNormalizedFileInfoMap} from '../../../models/change/files-model';
+import {RestApiService} from '../../../services/gr-rest-api/gr-rest-api';
+import {GrDiffCursor} from '../../../embed/diff/gr-diff-cursor/gr-diff-cursor';
 
 function createComment(
   id: string,
@@ -91,22 +98,28 @@
     let element: GrDiffView;
     let clock: SinonFakeTimers;
     let diffCommentsStub;
-    let getDiffRestApiStub: SinonStub;
-    let setUrlStub: SinonStub;
+    let getDiffRestApiStub: SinonStubbedMember<RestApiService['getDiff']>;
+    let navToChangeStub: SinonStubbedMember<ChangeModel['navigateToChange']>;
+    let navToDiffStub: SinonStubbedMember<ChangeModel['navigateToDiff']>;
+    let navToEditStub: SinonStubbedMember<ChangeModel['navigateToEdit']>;
+    let changeModel: ChangeModel;
+    let viewModel: ChangeViewModel;
+    let commentsModel: CommentsModel;
+    let browserModel: BrowserModel;
+    let userModel: UserModel;
 
     function getFilesFromFileList(fileList: string[]): Files {
       const changeFilesByPath = fileList.reduce((files, path) => {
-        files[path] = createFileInfo();
+        files[path] = createFileInfo(path);
         return files;
-      }, {} as {[path: string]: FileInfo});
+      }, {} as FileNameToNormalizedFileInfoMap);
       return {
-        sortedFileList: fileList,
+        sortedPaths: fileList,
         changeFilesByPath,
       };
     }
 
     setup(async () => {
-      setUrlStub = sinon.stub(testResolver(navigationToken), 'setUrl');
       stubRestApi('getConfig').returns(Promise.resolve(createServerInfo()));
       stubRestApi('getLoggedIn').returns(Promise.resolve(false));
       stubRestApi('getProjectConfig').returns(Promise.resolve(createConfig()));
@@ -125,14 +138,17 @@
       stubRestApi('getPortedComments').returns(Promise.resolve({}));
 
       element = await fixture(html`<gr-diff-view></gr-diff-view>`);
-      element.changeNum = 42 as NumericChangeId;
+      viewModel = testResolver(changeViewModelToken);
+      viewModel.setState(createDiffViewState());
+      await waitUntil(() => element.changeNum === TEST_NUMERIC_CHANGE_ID);
       element.path = 'some/path.txt';
       element.change = createParsedChange();
       element.diff = {...createDiff(), content: []};
       getDiffRestApiStub = stubRestApi('getDiff');
       // Delayed in case a test updates element.diff.
       getDiffRestApiStub.callsFake(() => Promise.resolve(element.diff));
-      element.patchRange = createPatchRange();
+      element.patchNum = 1 as RevisionPatchSetNum;
+      element.basePatchNum = PARENT;
       element.changeComments = new ChangeComments({
         '/COMMIT_MSG': [
           createComment('c1', 10, 2, '/COMMIT_MSG'),
@@ -140,8 +156,15 @@
         ],
       });
       await element.updateComplete;
+      commentsModel = testResolver(commentsModelToken);
+      changeModel = testResolver(changeModelToken);
+      browserModel = testResolver(browserModelToken);
+      userModel = testResolver(userModelToken);
+      navToChangeStub = sinon.stub(changeModel, 'navigateToChange');
+      navToDiffStub = sinon.stub(changeModel, 'navigateToDiff');
+      navToEditStub = sinon.stub(changeModel, 'navigateToEdit');
 
-      element.getCommentsModel().setState({
+      commentsModel.setState({
         comments: {},
         robotComments: {},
         drafts: {},
@@ -156,279 +179,6 @@
       sinon.restore();
     });
 
-    test('viewState change triggers diffViewDisplayed()', () => {
-      const diffViewDisplayedStub = stubReporting('diffViewDisplayed');
-      assertIsDefined(element.diffHost);
-      sinon.stub(element.diffHost, 'reload').returns(Promise.resolve());
-      sinon.stub(element, 'initPatchRange');
-      sinon.stub(element, 'fetchFiles');
-      const viewStateChangedSpy = sinon.spy(element, 'viewStateChanged');
-      element.viewState = {
-        view: GerritView.DIFF,
-        changeNum: 42 as NumericChangeId,
-        patchNum: 2 as RevisionPatchSetNum,
-        basePatchNum: 1 as BasePatchSetNum,
-        path: '/COMMIT_MSG',
-      };
-      element.path = '/COMMIT_MSG';
-      element.patchRange = createPatchRange();
-      return viewStateChangedSpy.returnValues[0]?.then(() => {
-        assert.isTrue(diffViewDisplayedStub.calledOnce);
-      });
-    });
-
-    suite('comment route', () => {
-      let initLineOfInterestAndCursorStub: SinonStub;
-      let replaceStateStub: SinonStub;
-      let viewStateChangedSpy: SinonSpy;
-      setup(() => {
-        initLineOfInterestAndCursorStub = sinon.stub(
-          element,
-          'initLineOfInterestAndCursor'
-        );
-        replaceStateStub = sinon.stub(history, 'replaceState');
-        sinon.stub(element, 'fetchFiles');
-        stubReporting('diffViewDisplayed');
-        assertIsDefined(element.diffHost);
-        sinon.stub(element.diffHost, 'reload').returns(Promise.resolve());
-        viewStateChangedSpy = sinon.spy(element, 'viewStateChanged');
-        element.getChangeModel().setState({
-          change: {
-            ...createParsedChange(),
-            revisions: createRevisions(11),
-          },
-          loadingStatus: LoadingStatus.LOADED,
-        });
-      });
-
-      test('comment url resolves to comment.patch_set vs latest', () => {
-        element.getCommentsModel().setState({
-          comments: {
-            '/COMMIT_MSG': [
-              createComment('c1', 10, 2, '/COMMIT_MSG'),
-              createComment('c3', 10, PARENT, '/COMMIT_MSG'),
-            ],
-          },
-          robotComments: {},
-          drafts: {},
-          portedComments: {},
-          portedDrafts: {},
-          discardedDrafts: [],
-        });
-        element.viewState = {
-          view: GerritView.DIFF,
-          changeNum: 42 as NumericChangeId,
-          commentLink: true,
-          commentId: 'c1' as UrlEncodedCommentId,
-          path: 'abcd',
-          patchNum: 1 as RevisionPatchSetNum,
-        };
-        element.change = {
-          ...createParsedChange(),
-          revisions: createRevisions(11),
-        };
-        return viewStateChangedSpy.returnValues[0].then(() => {
-          assert.isTrue(
-            initLineOfInterestAndCursorStub.calledWithExactly(true)
-          );
-          assert.equal(element.focusLineNum, 10);
-          assert.equal(element.patchRange?.patchNum, 11 as RevisionPatchSetNum);
-          assert.equal(element.patchRange?.basePatchNum, 2 as BasePatchSetNum);
-          assert.isTrue(replaceStateStub.called);
-        });
-      });
-    });
-
-    test('viewState change causes blame to load if it was set to true', () => {
-      // Blame loads for subsequent files if it was loaded for one file
-      element.isBlameLoaded = true;
-      stubReporting('diffViewDisplayed');
-      const loadBlameStub = sinon.stub(element, 'loadBlame');
-      assertIsDefined(element.diffHost);
-      sinon.stub(element.diffHost, 'reload').returns(Promise.resolve());
-      const viewStateChangedSpy = sinon.spy(element, 'viewStateChanged');
-      sinon.stub(element, 'initPatchRange');
-      sinon.stub(element, 'fetchFiles');
-      element.viewState = {
-        view: GerritView.DIFF,
-        changeNum: 42 as NumericChangeId,
-        patchNum: 2 as RevisionPatchSetNum,
-        basePatchNum: 1 as BasePatchSetNum,
-        path: '/COMMIT_MSG',
-      };
-      element.path = '/COMMIT_MSG';
-      element.patchRange = createPatchRange();
-      return viewStateChangedSpy.returnValues[0]!.then(() => {
-        assert.isTrue(element.isBlameLoaded);
-        assert.isTrue(loadBlameStub.calledOnce);
-      });
-    });
-
-    test('unchanged diff X vs latest from comment links navigates to base vs X', async () => {
-      element.getCommentsModel().setState({
-        comments: {
-          '/COMMIT_MSG': [
-            createComment('c1', 10, 2, '/COMMIT_MSG'),
-            createComment('c3', 10, PARENT, '/COMMIT_MSG'),
-          ],
-        },
-        robotComments: {},
-        drafts: {},
-        portedComments: {},
-        portedDrafts: {},
-        discardedDrafts: [],
-      });
-      stubReporting('diffViewDisplayed');
-      sinon.stub(element, 'loadBlame');
-      assertIsDefined(element.diffHost);
-      sinon.stub(element.diffHost, 'reload').returns(Promise.resolve());
-      sinon.stub(element, 'isFileUnchanged').returns(true);
-      const viewStateChangedSpy = sinon.spy(element, 'viewStateChanged');
-      element.getChangeModel().setState({
-        change: {
-          ...createParsedChange(),
-          revisions: createRevisions(11),
-        },
-        loadingStatus: LoadingStatus.LOADED,
-      });
-      element.viewState = {
-        view: GerritView.DIFF,
-        changeNum: 42 as NumericChangeId,
-        path: '/COMMIT_MSG',
-        commentLink: true,
-        commentId: 'c1' as UrlEncodedCommentId,
-      };
-      element.change = {
-        ...createParsedChange(),
-        revisions: createRevisions(11),
-      };
-      await viewStateChangedSpy.returnValues[0];
-      assert.isTrue(setUrlStub.calledOnce);
-      assert.equal(
-        setUrlStub.lastCall.firstArg,
-        '/c/test-project/+/42/2//COMMIT_MSG#10'
-      );
-    });
-
-    test('unchanged diff Base vs latest from comment does not navigate', async () => {
-      element.getCommentsModel().setState({
-        comments: {
-          '/COMMIT_MSG': [
-            createComment('c1', 10, 2, '/COMMIT_MSG'),
-            createComment('c3', 10, PARENT, '/COMMIT_MSG'),
-          ],
-        },
-        robotComments: {},
-        drafts: {},
-        portedComments: {},
-        portedDrafts: {},
-        discardedDrafts: [],
-      });
-      stubReporting('diffViewDisplayed');
-      sinon.stub(element, 'loadBlame');
-      assertIsDefined(element.diffHost);
-      sinon.stub(element.diffHost, 'reload').returns(Promise.resolve());
-      sinon.stub(element, 'isFileUnchanged').returns(true);
-      const viewStateChangedSpy = sinon.spy(element, 'viewStateChanged');
-      element.getChangeModel().setState({
-        change: {
-          ...createParsedChange(),
-          revisions: createRevisions(11),
-        },
-        loadingStatus: LoadingStatus.LOADED,
-      });
-      element.viewState = {
-        view: GerritView.DIFF,
-        changeNum: 42 as NumericChangeId,
-        path: '/COMMIT_MSG',
-        commentLink: true,
-        commentId: 'c3' as UrlEncodedCommentId,
-      };
-      element.change = {
-        ...createParsedChange(),
-        revisions: createRevisions(11),
-      };
-      await viewStateChangedSpy.returnValues[0];
-      assert.isFalse(setUrlStub.calledOnce);
-    });
-
-    test('isFileUnchanged', () => {
-      let diff: DiffInfo = {
-        ...createDiff(),
-        content: [
-          {a: ['abcd'], ab: ['ef']},
-          {b: ['ancd'], a: ['xx']},
-        ],
-      };
-      assert.equal(element.isFileUnchanged(diff), false);
-      diff = {
-        ...createDiff(),
-        content: [{ab: ['abcd']}, {ab: ['ancd']}],
-      };
-      assert.equal(element.isFileUnchanged(diff), true);
-      diff = {
-        ...createDiff(),
-        content: [
-          {a: ['abcd'], ab: ['ef'], common: true},
-          {b: ['ancd'], ab: ['xx']},
-        ],
-      };
-      assert.equal(element.isFileUnchanged(diff), false);
-      diff = {
-        ...createDiff(),
-        content: [
-          {a: ['abcd'], ab: ['ef'], common: true},
-          {b: ['ancd'], ab: ['xx'], common: true},
-        ],
-      };
-      assert.equal(element.isFileUnchanged(diff), true);
-    });
-
-    test('diff toast to go to latest is shown and not base', async () => {
-      element.getCommentsModel().setState({
-        comments: {
-          '/COMMIT_MSG': [
-            createComment('c1', 10, 2, '/COMMIT_MSG'),
-            createComment('c3', 10, PARENT, '/COMMIT_MSG'),
-          ],
-        },
-        robotComments: {},
-        drafts: {},
-        portedComments: {},
-        portedDrafts: {},
-        discardedDrafts: [],
-      });
-
-      stubReporting('diffViewDisplayed');
-      sinon.stub(element, 'loadBlame');
-      assertIsDefined(element.diffHost);
-      sinon.stub(element.diffHost, 'reload').returns(Promise.resolve());
-      const viewStateChangedSpy = sinon.spy(element, 'viewStateChanged');
-      element.change = undefined;
-      element.getChangeModel().setState({
-        change: {
-          ...createParsedChange(),
-          revisions: createRevisions(11),
-        },
-        loadingStatus: LoadingStatus.LOADED,
-      });
-      element.patchRange = {
-        patchNum: 2 as RevisionPatchSetNum,
-        basePatchNum: 1 as BasePatchSetNum,
-      };
-      sinon.stub(element, 'isFileUnchanged').returns(false);
-      const toastStub = sinon.stub(element, 'displayDiffBaseAgainstLeftToast');
-      element.viewState = {
-        view: GerritView.DIFF,
-        changeNum: 42 as NumericChangeId,
-        project: 'p' as RepoName,
-        commentId: 'c1' as UrlEncodedCommentId,
-        commentLink: true,
-      };
-      await viewStateChangedSpy.returnValues[0];
-      assert.isTrue(toastStub.called);
-    });
-
     test('toggle left diff with a hotkey', () => {
       assertIsDefined(element.diffHost);
       const toggleLeftDiffStub = sinon.stub(element.diffHost, 'toggleLeftDiff');
@@ -437,20 +187,17 @@
     });
 
     test('renders', async () => {
-      clock = sinon.useFakeTimers();
-      element.changeNum = 42 as NumericChangeId;
-      element.getBrowserModel().setScreenWidth(0);
-      element.patchRange = {
-        basePatchNum: PARENT,
-        patchNum: 10 as RevisionPatchSetNum,
-      };
-      element.change = {
+      browserModel.setScreenWidth(0);
+      element.patchNum = 10 as RevisionPatchSetNum;
+      element.basePatchNum = PARENT;
+      const change = {
         ...createParsedChange(),
         _number: 42 as NumericChangeId,
         revisions: {
           a: createRevision(10),
         },
       };
+      changeModel.updateStateChange(change);
       element.files = getFilesFromFileList([
         'chell.go',
         'glados.txt',
@@ -602,20 +349,15 @@
               </a>
             </div>
           </div>
-          <div class="loading">Loading...</div>
           <h2 class="assistive-tech-only">Diff view</h2>
-          <gr-diff-host hidden="" id="diffHost"> </gr-diff-host>
+          <gr-diff-host id="diffHost"> </gr-diff-host>
           <gr-apply-fix-dialog id="applyFixDialog"> </gr-apply-fix-dialog>
           <gr-diff-preferences-dialog id="diffPreferencesDialog">
           </gr-diff-preferences-dialog>
-          <gr-overlay
-            aria-hidden="true"
-            id="downloadOverlay"
-            style="outline: none; display: none;"
-          >
+          <dialog id="downloadModal" tabindex="-1">
             <gr-download-dialog id="downloadDialog" role="dialog">
             </gr-download-dialog>
-          </gr-overlay>
+          </dialog>
         `
       );
     });
@@ -623,11 +365,9 @@
     test('keyboard shortcuts', async () => {
       clock = sinon.useFakeTimers();
       element.changeNum = 42 as NumericChangeId;
-      element.getBrowserModel().setScreenWidth(0);
-      element.patchRange = {
-        basePatchNum: PARENT,
-        patchNum: 10 as RevisionPatchSetNum,
-      };
+      browserModel.setScreenWidth(0);
+      element.patchNum = 10 as RevisionPatchSetNum;
+      element.basePatchNum = PARENT;
       element.change = {
         ...createParsedChange(),
         _number: 42 as NumericChangeId,
@@ -643,51 +383,42 @@
       element.path = 'glados.txt';
       element.loggedIn = true;
       await element.updateComplete;
-      setUrlStub.reset();
+      navToChangeStub.reset();
 
       pressKey(element, 'u');
-      assert.equal(setUrlStub.callCount, 1);
-      assert.equal(setUrlStub.lastCall.firstArg, '/c/test-project/+/42');
+      assert.isTrue(navToChangeStub.calledOnce);
       await element.updateComplete;
 
       pressKey(element, ']');
-      assert.equal(setUrlStub.callCount, 2);
-      assert.equal(
-        setUrlStub.lastCall.firstArg,
-        '/c/test-project/+/42/10/wheatley.md'
-      );
+      assert.equal(navToDiffStub.callCount, 1);
+      assert.deepEqual(navToDiffStub.lastCall.args, [
+        {path: 'wheatley.md', lineNum: undefined},
+      ]);
+
       element.path = 'wheatley.md';
       await element.updateComplete;
 
-      assert.isTrue(element.loading);
-
       pressKey(element, '[');
-      assert.equal(setUrlStub.callCount, 3);
-      assert.equal(
-        setUrlStub.lastCall.firstArg,
-        '/c/test-project/+/42/10/glados.txt'
-      );
+      assert.equal(navToDiffStub.callCount, 2);
+      assert.deepEqual(navToDiffStub.lastCall.args, [
+        {path: 'glados.txt', lineNum: undefined},
+      ]);
+
       element.path = 'glados.txt';
       await element.updateComplete;
 
-      assert.isTrue(element.loading);
-
       pressKey(element, '[');
-      assert.equal(setUrlStub.callCount, 4);
-      assert.equal(
-        setUrlStub.lastCall.firstArg,
-        '/c/test-project/+/42/10/chell.go'
-      );
+      assert.equal(navToDiffStub.callCount, 3);
+      assert.deepEqual(navToDiffStub.lastCall.args, [
+        {path: 'chell.go', lineNum: undefined},
+      ]);
+
       element.path = 'chell.go';
       await element.updateComplete;
 
-      assert.isTrue(element.loading);
-
       pressKey(element, '[');
-      assert.equal(setUrlStub.callCount, 5);
-      assert.equal(setUrlStub.lastCall.firstArg, '/c/test-project/+/42');
+      assert.equal(navToChangeStub.callCount, 2);
       await element.updateComplete;
-      assert.isTrue(element.loading);
 
       assertIsDefined(element.diffPreferencesDialog);
       const showPrefsStub = sinon
@@ -727,22 +458,9 @@
         element.diffHost.diffElement.viewMode,
         DiffViewMode.SIDE_BY_SIDE
       );
-      assert.isTrue(element.diffHost.diffElement.displayLine);
 
-      pressKey(element, Key.ESC);
-      await element.updateComplete;
-      assert.equal(
-        element.diffHost.diffElement.viewMode,
-        DiffViewMode.SIDE_BY_SIDE
-      );
-      assert.isFalse(element.diffHost.diffElement.displayLine);
-
-      // Note that stubbing setReviewed means that the value of the
-      // `element.reviewed` checkbox is not flipped.
       const setReviewedStub = sinon.stub(element, 'setReviewed');
       const handleToggleSpy = sinon.spy(element, 'handleToggleFileReviewed');
-      assertIsDefined(element.reviewed);
-      element.reviewed.checked = false;
       assert.isFalse(handleToggleSpy.called);
       assert.isFalse(setReviewedStub.called);
 
@@ -768,14 +486,12 @@
       assertIsDefined(element.cursor);
       sinon.stub(element.cursor, 'isAtEnd').returns(true);
       element.changeNum = 42 as NumericChangeId;
-      const comment: PathToCommentsInfoMap = {
+      const comment: {[path: string]: CommentInfo[]} = {
         'wheatley.md': [createComment('c2', 21, 10, 'wheatley.md')],
       };
       element.changeComments = new ChangeComments(comment);
-      element.patchRange = {
-        basePatchNum: PARENT,
-        patchNum: 10 as RevisionPatchSetNum,
-      };
+      element.patchNum = 10 as RevisionPatchSetNum;
+      element.basePatchNum = PARENT;
       element.change = {
         ...createParsedChange(),
         _number: 42 as NumericChangeId,
@@ -791,23 +507,21 @@
       element.path = 'glados.txt';
       element.loggedIn = true;
       await element.updateComplete;
-      setUrlStub.reset();
+      navToDiffStub.reset();
 
       pressKey(element, 'N');
       await element.updateComplete;
-      assert.equal(setUrlStub.callCount, 1);
-      assert.equal(
-        setUrlStub.lastCall.firstArg,
-        '/c/test-project/+/42/10/wheatley.md#21'
-      );
+      assert.equal(navToDiffStub.callCount, 1);
+      assert.deepEqual(navToDiffStub.lastCall.args, [
+        {path: 'wheatley.md', lineNum: 21},
+      ]);
 
       element.path = 'wheatley.md'; // navigated to next file
 
       pressKey(element, 'N');
       await element.updateComplete;
 
-      assert.equal(setUrlStub.callCount, 2);
-      assert.equal(setUrlStub.lastCall.firstArg, '/c/test-project/+/42');
+      assert.equal(navToChangeStub.callCount, 1);
     });
 
     test('shift+x shortcut toggles all diff context', async () => {
@@ -819,114 +533,76 @@
     });
 
     test('diff against base', async () => {
-      element.patchRange = {
-        basePatchNum: 5 as BasePatchSetNum,
-        patchNum: 10 as RevisionPatchSetNum,
-      };
+      element.patchNum = 10 as RevisionPatchSetNum;
+      element.basePatchNum = 5 as BasePatchSetNum;
       await element.updateComplete;
       element.handleDiffAgainstBase();
-      assert.equal(
-        setUrlStub.lastCall.firstArg,
-        '/c/test-project/+/42/10/some/path.txt'
-      );
+      assert.deepEqual(navToDiffStub.lastCall.args, [
+        {path: 'some/path.txt'},
+        10 as RevisionPatchSetNum,
+        PARENT,
+      ]);
     });
 
     test('diff against latest', async () => {
       element.path = 'foo';
-      element.change = {
-        ...createParsedChange(),
-        revisions: createRevisions(12),
-      };
-      element.patchRange = {
-        basePatchNum: 5 as BasePatchSetNum,
-        patchNum: 10 as RevisionPatchSetNum,
-      };
+      element.latestPatchNum = 12 as PatchSetNumber;
+      element.patchNum = 10 as RevisionPatchSetNum;
+      element.basePatchNum = 5 as BasePatchSetNum;
       await element.updateComplete;
       element.handleDiffAgainstLatest();
-      assert.equal(
-        setUrlStub.lastCall.firstArg,
-        '/c/test-project/+/42/5..12/foo'
-      );
+      assert.deepEqual(navToDiffStub.lastCall.args, [
+        {path: 'foo'},
+        12 as RevisionPatchSetNum,
+        5 as BasePatchSetNum,
+      ]);
     });
 
     test('handleDiffBaseAgainstLeft', async () => {
       element.path = 'foo';
-      element.change = {
-        ...createParsedChange(),
-        revisions: createRevisions(10),
-      };
-      element.patchRange = {
+      element.latestPatchNum = 10 as PatchSetNumber;
+      element.patchNum = 3 as RevisionPatchSetNum;
+      element.basePatchNum = 1 as BasePatchSetNum;
+      viewModel.setState({
+        ...createDiffViewState(),
         patchNum: 3 as RevisionPatchSetNum,
         basePatchNum: 1 as BasePatchSetNum,
-      };
-      element.viewState = {
-        view: GerritView.DIFF,
-        changeNum: 42 as NumericChangeId,
-        patchNum: 3 as RevisionPatchSetNum,
-        basePatchNum: 1 as BasePatchSetNum,
-        path: 'foo',
-      };
+        diffView: {path: 'foo'},
+      });
       await element.updateComplete;
       element.handleDiffBaseAgainstLeft();
-      assert.equal(setUrlStub.lastCall.firstArg, '/c/test-project/+/42/1/foo');
-    });
-
-    test('handleDiffBaseAgainstLeft when initially navigating to a comment', () => {
-      element.change = {
-        ...createParsedChange(),
-        revisions: createRevisions(10),
-      };
-      element.patchRange = {
-        patchNum: 3 as RevisionPatchSetNum,
-        basePatchNum: 1 as BasePatchSetNum,
-      };
-      sinon.stub(element, 'viewStateChanged');
-      element.viewState = {
-        commentLink: true,
-        view: GerritView.DIFF,
-        changeNum: 42 as NumericChangeId,
-      };
-      element.focusLineNum = 10;
-      element.handleDiffBaseAgainstLeft();
-      assert.equal(
-        setUrlStub.lastCall.firstArg,
-        '/c/test-project/+/42/1/some/path.txt#10'
-      );
+      assert.deepEqual(navToDiffStub.lastCall.args, [
+        {path: 'foo'},
+        1 as RevisionPatchSetNum,
+        PARENT,
+      ]);
     });
 
     test('handleDiffRightAgainstLatest', async () => {
       element.path = 'foo';
-      element.change = {
-        ...createParsedChange(),
-        revisions: createRevisions(10),
-      };
-      element.patchRange = {
-        basePatchNum: 1 as BasePatchSetNum,
-        patchNum: 3 as RevisionPatchSetNum,
-      };
+      element.latestPatchNum = 10 as PatchSetNumber;
+      element.patchNum = 3 as RevisionPatchSetNum;
+      element.basePatchNum = 1 as BasePatchSetNum;
       await element.updateComplete;
       element.handleDiffRightAgainstLatest();
-      assert.equal(
-        setUrlStub.lastCall.firstArg,
-        '/c/test-project/+/42/3..10/foo'
-      );
+      assert.deepEqual(navToDiffStub.lastCall.args, [
+        {path: 'foo'},
+        10 as RevisionPatchSetNum,
+        3 as BasePatchSetNum,
+      ]);
     });
 
     test('handleDiffBaseAgainstLatest', async () => {
-      element.change = {
-        ...createParsedChange(),
-        revisions: createRevisions(10),
-      };
-      element.patchRange = {
-        basePatchNum: 1 as BasePatchSetNum,
-        patchNum: 3 as RevisionPatchSetNum,
-      };
+      element.latestPatchNum = 10 as PatchSetNumber;
+      element.patchNum = 3 as RevisionPatchSetNum;
+      element.basePatchNum = 1 as BasePatchSetNum;
       await element.updateComplete;
       element.handleDiffBaseAgainstLatest();
-      assert.equal(
-        setUrlStub.lastCall.firstArg,
-        '/c/test-project/+/42/10/some/path.txt'
-      );
+      assert.deepEqual(navToDiffStub.lastCall.args, [
+        {path: 'some/path.txt'},
+        10 as RevisionPatchSetNum,
+        PARENT,
+      ]);
     });
 
     test('A fires an error event when not logged in', async () => {
@@ -935,16 +611,14 @@
       element.addEventListener('show-auth-required', loggedInErrorSpy);
       pressKey(element, 'a');
       await element.updateComplete;
-      assert.isFalse(setUrlStub.calledOnce);
+      assert.isFalse(navToDiffStub.calledOnce);
       assert.isTrue(loggedInErrorSpy.called);
     });
 
     test('A navigates to change with logged in', async () => {
       element.changeNum = 42 as NumericChangeId;
-      element.patchRange = {
-        basePatchNum: 5 as BasePatchSetNum,
-        patchNum: 10 as RevisionPatchSetNum,
-      };
+      element.patchNum = 10 as RevisionPatchSetNum;
+      element.basePatchNum = 5 as BasePatchSetNum;
       element.change = {
         ...createParsedChange(),
         _number: 42 as NumericChangeId,
@@ -957,25 +631,20 @@
       await element.updateComplete;
       const loggedInErrorSpy = sinon.spy();
       element.addEventListener('show-auth-required', loggedInErrorSpy);
-      setUrlStub.reset();
+      navToDiffStub.reset();
 
       pressKey(element, 'a');
 
       await element.updateComplete;
-      assert.equal(setUrlStub.callCount, 1);
-      assert.equal(
-        setUrlStub.lastCall.firstArg,
-        '/c/test-project/+/42/5..10?openReplyDialog=true'
-      );
+      assert.isTrue(navToChangeStub.calledOnce);
+      assert.deepEqual(navToChangeStub.lastCall.args, [true]);
       assert.isFalse(loggedInErrorSpy.called);
     });
 
     test('A navigates to change with old patch number with logged in', async () => {
       element.changeNum = 42 as NumericChangeId;
-      element.patchRange = {
-        basePatchNum: PARENT,
-        patchNum: 1 as RevisionPatchSetNum,
-      };
+      element.patchNum = 1 as RevisionPatchSetNum;
+      element.basePatchNum = PARENT;
       element.change = {
         ...createParsedChange(),
         _number: 42 as NumericChangeId,
@@ -989,20 +658,15 @@
       element.addEventListener('show-auth-required', loggedInErrorSpy);
       pressKey(element, 'a');
       await element.updateComplete;
-      assert.isTrue(setUrlStub.calledOnce);
-      assert.equal(
-        setUrlStub.lastCall.firstArg,
-        '/c/test-project/+/42/1?openReplyDialog=true'
-      );
+      assert.isTrue(navToChangeStub.calledOnce);
+      assert.deepEqual(navToChangeStub.lastCall.args, [true]);
       assert.isFalse(loggedInErrorSpy.called);
     });
 
     test('keyboard shortcuts with patch range', () => {
       element.changeNum = 42 as NumericChangeId;
-      element.patchRange = {
-        basePatchNum: 5 as BasePatchSetNum,
-        patchNum: 10 as RevisionPatchSetNum,
-      };
+      element.patchNum = 10 as RevisionPatchSetNum;
+      element.basePatchNum = 5 as BasePatchSetNum;
       element.change = {
         ...createParsedChange(),
         _number: 42 as NumericChangeId,
@@ -1019,55 +683,42 @@
       element.path = 'glados.txt';
 
       pressKey(element, 'u');
-      assert.equal(setUrlStub.callCount, 1);
-      assert.equal(setUrlStub.lastCall.firstArg, '/c/test-project/+/42/5..10');
+      assert.equal(navToChangeStub.callCount, 1);
 
       pressKey(element, ']');
-      assert.isTrue(element.loading);
-      assert.equal(setUrlStub.callCount, 2);
-      assert.equal(
-        setUrlStub.lastCall.firstArg,
-        '/c/test-project/+/42/5..10/wheatley.md'
-      );
+      assert.equal(navToDiffStub.callCount, 1);
+      assert.deepEqual(navToDiffStub.lastCall.args, [
+        {path: 'wheatley.md', lineNum: undefined},
+      ]);
       element.path = 'wheatley.md';
 
       pressKey(element, '[');
-      assert.isTrue(element.loading);
-      assert.equal(setUrlStub.callCount, 3);
-      assert.equal(
-        setUrlStub.lastCall.firstArg,
-        '/c/test-project/+/42/5..10/glados.txt'
-      );
+      assert.equal(navToDiffStub.callCount, 2);
+      assert.deepEqual(navToDiffStub.lastCall.args, [
+        {path: 'glados.txt', lineNum: undefined},
+      ]);
       element.path = 'glados.txt';
 
       pressKey(element, '[');
-      assert.isTrue(element.loading);
-      assert.equal(setUrlStub.callCount, 4);
-      assert.equal(
-        setUrlStub.lastCall.firstArg,
-        '/c/test-project/+/42/5..10/chell.go'
-      );
+      assert.equal(navToDiffStub.callCount, 3);
+      assert.deepEqual(navToDiffStub.lastCall.args, [
+        {path: 'chell.go', lineNum: undefined},
+      ]);
       element.path = 'chell.go';
 
       pressKey(element, '[');
-      assert.isTrue(element.loading);
-      assert.equal(setUrlStub.callCount, 5);
-      assert.equal(setUrlStub.lastCall.firstArg, '/c/test-project/+/42/5..10');
+      assert.equal(navToChangeStub.callCount, 2);
 
-      assertIsDefined(element.downloadOverlay);
-      const downloadOverlayStub = sinon
-        .stub(element.downloadOverlay, 'open')
-        .returns(Promise.resolve());
+      assertIsDefined(element.downloadModal);
+      const downloadModalStub = sinon.stub(element.downloadModal, 'showModal');
       pressKey(element, 'd');
-      assert.isTrue(downloadOverlayStub.called);
+      assert.isTrue(downloadModalStub.called);
     });
 
-    test('keyboard shortcuts with old patch number', () => {
+    test('keyboard shortcuts with old patch number', async () => {
       element.changeNum = 42 as NumericChangeId;
-      element.patchRange = {
-        basePatchNum: PARENT,
-        patchNum: 1 as RevisionPatchSetNum,
-      };
+      element.patchNum = 1 as RevisionPatchSetNum;
+      element.basePatchNum = PARENT;
       element.change = {
         ...createParsedChange(),
         _number: 42 as NumericChangeId,
@@ -1084,53 +735,57 @@
       element.path = 'glados.txt';
 
       pressKey(element, 'u');
-      assert.isTrue(setUrlStub.calledOnce);
-      assert.equal(setUrlStub.lastCall.firstArg, '/c/test-project/+/42/1');
+      assert.isTrue(navToChangeStub.calledOnce);
 
       pressKey(element, ']');
-      assert.equal(
-        setUrlStub.lastCall.firstArg,
-        '/c/test-project/+/42/1/wheatley.md'
-      );
+      assert.deepEqual(navToDiffStub.lastCall.args, [
+        {path: 'wheatley.md', lineNum: undefined},
+      ]);
       element.path = 'wheatley.md';
 
       pressKey(element, '[');
-      assert.equal(
-        setUrlStub.lastCall.firstArg,
-        '/c/test-project/+/42/1/glados.txt'
-      );
+      assert.deepEqual(navToDiffStub.lastCall.args, [
+        {path: 'glados.txt', lineNum: undefined},
+      ]);
       element.path = 'glados.txt';
 
       pressKey(element, '[');
-      assert.equal(
-        setUrlStub.lastCall.firstArg,
-        '/c/test-project/+/42/1/chell.go'
-      );
-      element.path = 'chell.go';
+      assert.deepEqual(navToDiffStub.lastCall.args, [
+        {path: 'chell.go', lineNum: undefined},
+      ]);
 
-      setUrlStub.reset();
+      element.path = 'chell.go';
+      await element.updateComplete;
+      navToDiffStub.reset();
       pressKey(element, '[');
-      assert.isTrue(setUrlStub.calledOnce);
-      assert.equal(setUrlStub.lastCall.firstArg, '/c/test-project/+/42/1');
+      assert.equal(navToChangeStub.callCount, 2);
+    });
+
+    test('reloadDiff is called when patchNum changes', async () => {
+      const reloadStub = sinon.stub(element, 'reloadDiff');
+      element.patchNum = 5 as RevisionPatchSetNum;
+      await element.updateComplete;
+      assert.isTrue(reloadStub.called);
+    });
+
+    test('initializePositions is called when view becomes active', async () => {
+      const reloadStub = sinon.stub(element, 'reloadDiff');
+      const initializeStub = sinon.stub(element, 'initializePositions');
+
+      element.isActiveChildView = false;
+      await element.updateComplete;
+      element.isActiveChildView = true;
+      await element.updateComplete;
+
+      assert.isTrue(initializeStub.calledOnce);
+      assert.isFalse(reloadStub.called);
     });
 
     test('edit should redirect to edit page', async () => {
       element.loggedIn = true;
       element.path = 't.txt';
-      element.patchRange = {
-        basePatchNum: PARENT,
-        patchNum: 1 as RevisionPatchSetNum,
-      };
-      element.change = {
-        ...createParsedChange(),
-        _number: 42 as NumericChangeId,
-        project: 'gerrit' as RepoName,
-        status: ChangeStatus.NEW,
-        revisions: {
-          a: createRevision(1),
-          b: createRevision(2),
-        },
-      };
+      element.patchNum = 1 as RevisionPatchSetNum;
+      element.basePatchNum = PARENT;
       await element.updateComplete;
       const editBtn = queryAndAssert<GrButton>(
         element,
@@ -1138,28 +793,18 @@
       );
       assert.isTrue(!!editBtn);
       editBtn.click();
-      assert.equal(setUrlStub.callCount, 1);
-      assert.equal(setUrlStub.lastCall.firstArg, '/c/gerrit/+/42/1/t.txt,edit');
+      assert.equal(navToEditStub.callCount, 1);
+      assert.deepEqual(navToEditStub.lastCall.args, [
+        {path: 't.txt', lineNum: undefined},
+      ]);
     });
 
     test('edit should redirect to edit page with line number', async () => {
       const lineNumber = 42;
       element.loggedIn = true;
       element.path = 't.txt';
-      element.patchRange = {
-        basePatchNum: PARENT,
-        patchNum: 1 as RevisionPatchSetNum,
-      };
-      element.change = {
-        ...createParsedChange(),
-        _number: 42 as NumericChangeId,
-        project: 'gerrit' as RepoName,
-        status: ChangeStatus.NEW,
-        revisions: {
-          a: createRevision(1),
-          b: createRevision(2),
-        },
-      };
+      element.patchNum = 1 as RevisionPatchSetNum;
+      element.basePatchNum = PARENT;
       assertIsDefined(element.cursor);
       sinon
         .stub(element.cursor, 'getAddress')
@@ -1171,11 +816,10 @@
       );
       assert.isTrue(!!editBtn);
       editBtn.click();
-      assert.equal(setUrlStub.callCount, 1);
-      assert.equal(
-        setUrlStub.lastCall.firstArg,
-        '/c/gerrit/+/42/1/t.txt,edit#42'
-      );
+      assert.equal(navToEditStub.callCount, 1);
+      assert.deepEqual(navToEditStub.lastCall.args, [
+        {path: 't.txt', lineNum: 42},
+      ]);
     });
 
     async function isEditVisibile({
@@ -1187,10 +831,8 @@
     }): Promise<boolean> {
       element.loggedIn = loggedIn;
       element.path = 't.txt';
-      element.patchRange = {
-        basePatchNum: PARENT,
-        patchNum: 1 as RevisionPatchSetNum,
-      };
+      element.patchNum = 1 as RevisionPatchSetNum;
+      element.basePatchNum = PARENT;
       element.change = {
         ...createParsedChange(),
         _number: 42 as NumericChangeId,
@@ -1287,16 +929,10 @@
     });
 
     suite('url parameters', () => {
-      setup(() => {
-        sinon.stub(element, 'fetchFiles');
-      });
-
       test('_formattedFiles', () => {
         element.changeNum = 42 as NumericChangeId;
-        element.patchRange = {
-          basePatchNum: PARENT,
-          patchNum: 10 as RevisionPatchSetNum,
-        };
+        element.patchNum = 10 as RevisionPatchSetNum;
+        element.basePatchNum = PARENT;
         element.change = {
           ...createParsedChange(),
           _number: 42 as NumericChangeId,
@@ -1369,18 +1005,19 @@
       });
 
       test('prev/up/next links', async () => {
-        element.changeNum = 42 as NumericChangeId;
-        element.patchRange = {
-          basePatchNum: PARENT,
-          patchNum: 10 as RevisionPatchSetNum,
-        };
-        element.change = {
+        viewModel.setState({
+          ...createDiffViewState(),
+        });
+        const change = {
           ...createParsedChange(),
           _number: 42 as NumericChangeId,
           revisions: {
             a: createRevision(10),
           },
         };
+        changeModel.updateStateChange(change);
+        await element.updateComplete;
+
         element.files = getFilesFromFileList([
           'chell.go',
           'glados.txt',
@@ -1400,24 +1037,30 @@
           linkEls[2].getAttribute('href'),
           '/c/test-project/+/42/10/wheatley.md'
         );
+
         element.path = 'wheatley.md';
         await element.updateComplete;
+
         assert.equal(
           linkEls[0].getAttribute('href'),
           '/c/test-project/+/42/10/glados.txt'
         );
         assert.equal(linkEls[1].getAttribute('href'), '/c/test-project/+/42');
         assert.equal(linkEls[2].getAttribute('href'), '/c/test-project/+/42');
+
         element.path = 'chell.go';
         await element.updateComplete;
+
         assert.equal(linkEls[0].getAttribute('href'), '/c/test-project/+/42');
         assert.equal(linkEls[1].getAttribute('href'), '/c/test-project/+/42');
         assert.equal(
           linkEls[2].getAttribute('href'),
           '/c/test-project/+/42/10/glados.txt'
         );
+
         element.path = 'not_a_real_file';
         await element.updateComplete;
+
         assert.equal(
           linkEls[0].getAttribute('href'),
           '/c/test-project/+/42/10/wheatley.md'
@@ -1430,26 +1073,30 @@
       });
 
       test('prev/up/next links with patch range', async () => {
-        element.changeNum = 42 as NumericChangeId;
-        element.patchRange = {
+        viewModel.setState({
+          ...createDiffViewState(),
           basePatchNum: 5 as BasePatchSetNum,
           patchNum: 10 as RevisionPatchSetNum,
-        };
-        element.change = {
+          diffView: {path: 'glados.txt'},
+        });
+        const change = {
           ...createParsedChange(),
           _number: 42 as NumericChangeId,
           revisions: {
             a: createRevision(5),
             b: createRevision(10),
+            c: createRevision(12),
           },
         };
+        changeModel.updateStateChange(change);
         element.files = getFilesFromFileList([
           'chell.go',
           'glados.txt',
           'wheatley.md',
         ]);
-        element.path = 'glados.txt';
-        await element.updateComplete;
+        await waitUntil(() => element.path === 'glados.txt');
+        await waitUntil(() => element.patchRange?.patchNum === 10);
+
         const linkEls = queryAll(element, '.navLink');
         assert.equal(linkEls.length, 3);
         assert.equal(
@@ -1464,8 +1111,10 @@
           linkEls[2].getAttribute('href'),
           '/c/test-project/+/42/5..10/wheatley.md'
         );
-        element.path = 'wheatley.md';
-        await element.updateComplete;
+
+        viewModel.updateState({diffView: {path: 'wheatley.md'}});
+        await waitUntil(() => element.path === 'wheatley.md');
+
         assert.equal(
           linkEls[0].getAttribute('href'),
           '/c/test-project/+/42/5..10/glados.txt'
@@ -1478,8 +1127,10 @@
           linkEls[2].getAttribute('href'),
           '/c/test-project/+/42/5..10'
         );
-        element.path = 'chell.go';
-        await element.updateComplete;
+
+        viewModel.updateState({diffView: {path: 'chell.go'}});
+        await waitUntil(() => element.path === 'chell.go');
+
         assert.equal(
           linkEls[0].getAttribute('href'),
           '/c/test-project/+/42/5..10'
@@ -1496,40 +1147,32 @@
     });
 
     test('handlePatchChange calls setUrl correctly', async () => {
-      element.change = {
-        ...createParsedChange(),
-        _number: 321 as NumericChangeId,
-        project: 'foo/bar' as RepoName,
-      };
       element.path = 'path/to/file.txt';
-
-      element.patchRange = {
-        basePatchNum: PARENT,
-        patchNum: 3 as RevisionPatchSetNum,
-      };
+      element.patchNum = 3 as RevisionPatchSetNum;
+      element.basePatchNum = PARENT;
       await element.updateComplete;
 
       const detail = {
         basePatchNum: PARENT,
         patchNum: 1 as RevisionPatchSetNum,
       };
-
       queryAndAssert(element, '#rangeSelect').dispatchEvent(
         new CustomEvent('patch-range-change', {detail, bubbles: false})
       );
 
-      assert.equal(
-        setUrlStub.lastCall.firstArg,
-        '/c/foo/bar/+/321/1/path/to/file.txt'
-      );
+      assert.deepEqual(navToDiffStub.lastCall.args, [
+        {path: element.path},
+        detail.patchNum,
+        detail.basePatchNum,
+      ]);
     });
 
     test(
-      '_prefs.manual_review true means set reviewed is not ' +
+      'prefs.manual_review true means set reviewed is not ' +
         'automatically called',
       async () => {
         const setReviewedFileStatusStub = sinon
-          .stub(element.getChangeModel(), 'setReviewedFilesStatus')
+          .stub(changeModel, 'setReviewedFilesStatus')
           .callsFake(() => Promise.resolve());
 
         const setReviewedStatusStub = sinon.spy(element, 'setReviewedStatus');
@@ -1541,39 +1184,29 @@
           ...createDefaultDiffPrefs(),
           manual_review: true,
         };
-        element.userModel.setDiffPreferences(diffPreferences);
-        element.getChangeModel().setState({
+        userModel.setDiffPreferences(diffPreferences);
+        viewModel.updateState({diffView: {path: 'wheatley.md'}});
+        changeModel.setState({
           change: createParsedChange(),
-          diffPath: '/COMMIT_MSG',
           reviewedFiles: [],
           loadingStatus: LoadingStatus.LOADED,
         });
 
-        element.routerModel.setState({
-          changeNum: TEST_NUMERIC_CHANGE_ID,
-          view: GerritView.DIFF,
-          patchNum: 2 as RevisionPatchSetNum,
-        });
-        element.patchRange = {
-          patchNum: 2 as RevisionPatchSetNum,
-          basePatchNum: 1 as BasePatchSetNum,
-        };
-
         await waitUntil(() => setReviewedStatusStub.called);
 
         assert.isFalse(setReviewedFileStatusStub.called);
 
         // if prefs are updated then the reviewed status should not be set again
-        element.userModel.setDiffPreferences(createDefaultDiffPrefs());
+        userModel.setDiffPreferences(createDefaultDiffPrefs());
 
         await element.updateComplete;
         assert.isFalse(setReviewedFileStatusStub.called);
       }
     );
 
-    test('_prefs.manual_review false means set reviewed is called', async () => {
+    test('prefs.manual_review false means set reviewed is called', async () => {
       const setReviewedFileStatusStub = sinon
-        .stub(element.getChangeModel(), 'setReviewedFilesStatus')
+        .stub(changeModel, 'setReviewedFilesStatus')
         .callsFake(() => Promise.resolve());
 
       assertIsDefined(element.diffHost);
@@ -1583,70 +1216,54 @@
         ...createDefaultDiffPrefs(),
         manual_review: false,
       };
-      element.userModel.setDiffPreferences(diffPreferences);
-      element.getChangeModel().setState({
+      userModel.setDiffPreferences(diffPreferences);
+      viewModel.updateState({diffView: {path: 'wheatley.md'}});
+      changeModel.setState({
         change: createParsedChange(),
-        diffPath: '/COMMIT_MSG',
         reviewedFiles: [],
         loadingStatus: LoadingStatus.LOADED,
       });
 
-      element.routerModel.setState({
-        changeNum: TEST_NUMERIC_CHANGE_ID,
-        view: GerritView.DIFF,
-        patchNum: 22 as RevisionPatchSetNum,
-      });
-      element.patchRange = {
-        patchNum: 2 as RevisionPatchSetNum,
-        basePatchNum: 1 as BasePatchSetNum,
-      };
-
       await waitUntil(() => setReviewedFileStatusStub.called);
 
       assert.isTrue(setReviewedFileStatusStub.called);
     });
 
     test('file review status', async () => {
-      element.getChangeModel().setState({
+      const saveReviewedStub = sinon
+        .stub(changeModel, 'setReviewedFilesStatus')
+        .callsFake(() => Promise.resolve());
+      userModel.setDiffPreferences(createDefaultDiffPrefs());
+      viewModel.updateState({
+        patchNum: 1 as RevisionPatchSetNum,
+        basePatchNum: PARENT,
+        diffView: {path: '/COMMIT_MSG'},
+      });
+      changeModel.setState({
         change: createParsedChange(),
-        diffPath: '/COMMIT_MSG',
         reviewedFiles: [],
         loadingStatus: LoadingStatus.LOADED,
       });
       element.loggedIn = true;
-      const saveReviewedStub = sinon
-        .stub(element.getChangeModel(), 'setReviewedFilesStatus')
-        .callsFake(() => Promise.resolve());
+      await waitUntil(() => element.patchRange?.patchNum === 1);
+      await element.updateComplete;
       assertIsDefined(element.diffHost);
       sinon.stub(element.diffHost, 'reload');
 
-      element.userModel.setDiffPreferences(createDefaultDiffPrefs());
-
-      element.routerModel.setState({
-        changeNum: TEST_NUMERIC_CHANGE_ID,
-        view: GerritView.DIFF,
-        patchNum: 2 as RevisionPatchSetNum,
-      });
-
-      element.patchRange = {
-        patchNum: 2 as RevisionPatchSetNum,
-        basePatchNum: 1 as BasePatchSetNum,
-      };
-
       await waitUntil(() => saveReviewedStub.called);
 
-      element.getChangeModel().updateStateFileReviewed('/COMMIT_MSG', true);
+      changeModel.updateStateFileReviewed('/COMMIT_MSG', true);
       await element.updateComplete;
 
       const reviewedStatusCheckBox = queryAndAssert<HTMLInputElement>(
         element,
-        'input[type="checkbox"]'
+        'input#reviewed'
       );
 
       assert.isTrue(reviewedStatusCheckBox.checked);
       assert.deepEqual(saveReviewedStub.lastCall.args, [
         42,
-        2,
+        1,
         '/COMMIT_MSG',
         true,
       ]);
@@ -1655,30 +1272,29 @@
       assert.isFalse(reviewedStatusCheckBox.checked);
       assert.deepEqual(saveReviewedStub.lastCall.args, [
         42,
-        2,
+        1,
         '/COMMIT_MSG',
         false,
       ]);
 
-      element.getChangeModel().updateStateFileReviewed('/COMMIT_MSG', false);
+      changeModel.updateStateFileReviewed('/COMMIT_MSG', false);
       await element.updateComplete;
 
       reviewedStatusCheckBox.click();
       assert.isTrue(reviewedStatusCheckBox.checked);
       assert.deepEqual(saveReviewedStub.lastCall.args, [
         42,
-        2,
+        1,
         '/COMMIT_MSG',
         true,
       ]);
 
       const callCount = saveReviewedStub.callCount;
 
-      element.viewState = {
-        view: GerritView.DIFF,
-        changeNum: 42 as NumericChangeId,
-        project: 'test' as RepoName,
-      };
+      viewModel.setState({
+        ...createDiffViewState(),
+        repo: 'test' as RepoName,
+      });
       await element.updateComplete;
 
       // saveReviewedState observer observes viewState, but should not fire when
@@ -1686,36 +1302,27 @@
       assert.equal(saveReviewedStub.callCount, callCount);
     });
 
-    test('file review status with edit loaded', async () => {
+    test('do not set file review status for EDIT patchset', async () => {
       const saveReviewedStub = sinon.stub(
-        element.getChangeModel(),
+        changeModel,
         'setReviewedFilesStatus'
       );
 
-      element.patchRange = {
-        basePatchNum: 1 as BasePatchSetNum,
-        patchNum: EDIT,
-      };
+      element.patchNum = EDIT;
+      element.basePatchNum = 1 as BasePatchSetNum;
       await waitEventLoop();
 
-      assert.isTrue(element.computeEditMode());
       element.setReviewed(true);
+
       assert.isFalse(saveReviewedStub.called);
     });
 
     test('hash is determined from viewState', async () => {
       assertIsDefined(element.diffHost);
       sinon.stub(element.diffHost, 'reload');
-      const initLineStub = sinon.stub(element, 'initLineOfInterestAndCursor');
+      const initLineStub = sinon.stub(element, 'initCursor');
 
-      element.loggedIn = true;
-      element.viewState = {
-        view: GerritView.DIFF,
-        changeNum: 42 as NumericChangeId,
-        patchNum: 2 as RevisionPatchSetNum,
-        basePatchNum: 1 as BasePatchSetNum,
-        path: '/COMMIT_MSG',
-      };
+      element.focusLineNum = 123;
 
       await element.updateComplete;
       await waitEventLoop();
@@ -1730,9 +1337,9 @@
         ...createDefaultPreferences(),
         diff_view: DiffViewMode.SIDE_BY_SIDE,
       };
-      element.getBrowserModel().setScreenWidth(0);
+      browserModel.setScreenWidth(0);
 
-      const userStub = stubUsers('updatePreferences');
+      const userStub = sinon.stub(userModel, 'updatePreferences');
 
       await element.updateComplete;
       // The mode selected in the view state reflects the selected option.
@@ -1763,115 +1370,56 @@
       assert.isTrue(diffModeSelector.classList.contains('hide'));
     });
 
-    suite('commitRange', () => {
-      const change: ParsedChangeInfo = {
-        ...createParsedChange(),
-        _number: 42 as NumericChangeId,
-        revisions: {
-          'commit-sha-1': {
-            ...createRevision(1),
-            commit: {
-              ...createCommit(),
-              parents: [{subject: 's1', commit: 'sha-1-parent' as CommitId}],
-            },
-          },
-          'commit-sha-2': createRevision(2),
-          'commit-sha-3': createRevision(3),
-          'commit-sha-4': createRevision(4),
-          'commit-sha-5': {
-            ...createRevision(5),
-            commit: {
-              ...createCommit(),
-              parents: [{subject: 's5', commit: 'sha-5-parent' as CommitId}],
-            },
-          },
-        },
-      };
-      setup(async () => {
-        assertIsDefined(element.diffHost);
-        sinon.stub(element.diffHost, 'reload');
-        sinon.stub(element, 'initCursor');
-        element.change = change;
-        await element.updateComplete;
-        await element.diffHost.updateComplete;
-      });
-
-      test('uses the patchNum and basePatchNum ', async () => {
-        element.viewState = {
-          view: GerritView.DIFF,
-          changeNum: 42 as NumericChangeId,
-          patchNum: 4 as RevisionPatchSetNum,
-          basePatchNum: 2 as BasePatchSetNum,
-          path: '/COMMIT_MSG',
-        };
-        element.change = change;
-        await element.updateComplete;
-        await waitEventLoop();
-        assert.deepEqual(element.commitRange, {
-          baseCommit: 'commit-sha-2' as CommitId,
-          commit: 'commit-sha-4' as CommitId,
-        });
-      });
-
-      test('uses the parent when there is no base patch num ', async () => {
-        element.viewState = {
-          view: GerritView.DIFF,
-          changeNum: 42 as NumericChangeId,
-          patchNum: 5 as RevisionPatchSetNum,
-          path: '/COMMIT_MSG',
-        };
-        element.change = change;
-        await element.updateComplete;
-        await waitEventLoop();
-        assert.deepEqual(element.commitRange, {
-          commit: 'commit-sha-5' as CommitId,
-          baseCommit: 'sha-5-parent' as CommitId,
-        });
-      });
-    });
-
     test('initCursor', () => {
       assertIsDefined(element.cursor);
       assert.isNotOk(element.cursor.initialLineNumber);
 
       // Does nothing when viewState specify no cursor address:
-      element.initCursor(false);
+      element.leftSide = false;
+      element.initCursor();
       assert.isNotOk(element.cursor.initialLineNumber);
 
       // Does nothing when viewState specify side but no number:
-      element.initCursor(true);
+      element.leftSide = true;
+      element.initCursor();
       assert.isNotOk(element.cursor.initialLineNumber);
 
       // Revision hash: specifies lineNum but not side.
 
       element.focusLineNum = 234;
-      element.initCursor(false);
+      element.leftSide = false;
+      element.initCursor();
       assert.equal(element.cursor.initialLineNumber, 234);
       assert.equal(element.cursor.side, Side.RIGHT);
 
       // Base hash: specifies lineNum and side.
       element.focusLineNum = 345;
-      element.initCursor(true);
+      element.leftSide = true;
+      element.initCursor();
       assert.equal(element.cursor.initialLineNumber, 345);
       assert.equal(element.cursor.side, Side.LEFT);
 
       // Specifies right side:
       element.focusLineNum = 123;
-      element.initCursor(false);
+      element.leftSide = false;
+      element.initCursor();
       assert.equal(element.cursor.initialLineNumber, 123);
       assert.equal(element.cursor.side, Side.RIGHT);
     });
 
     test('getLineOfInterest', () => {
-      assert.isUndefined(element.getLineOfInterest(false));
+      element.leftSide = false;
+      assert.isUndefined(element.getLineOfInterest());
 
       element.focusLineNum = 12;
-      let result = element.getLineOfInterest(false);
+      element.leftSide = false;
+      let result = element.getLineOfInterest();
       assert.isOk(result);
       assert.equal(result!.lineNum, 12);
       assert.equal(result!.side, Side.RIGHT);
 
-      result = element.getLineOfInterest(true);
+      element.leftSide = true;
+      result = element.getLineOfInterest();
       assert.isOk(result);
       assert.equal(result!.lineNum, 12);
       assert.equal(result!.side, Side.LEFT);
@@ -1890,10 +1438,8 @@
         _number: 321 as NumericChangeId,
         project: 'foo/bar' as RepoName,
       };
-      element.patchRange = {
-        basePatchNum: 3 as BasePatchSetNum,
-        patchNum: 5 as RevisionPatchSetNum,
-      };
+      element.patchNum = 5 as RevisionPatchSetNum;
+      element.basePatchNum = 3 as BasePatchSetNum;
       const e = {detail: {number: 123, side: Side.RIGHT}} as CustomEvent;
 
       element.onLineSelected(e);
@@ -1914,10 +1460,8 @@
         _number: 321 as NumericChangeId,
         project: 'foo/bar' as RepoName,
       };
-      element.patchRange = {
-        basePatchNum: 3 as BasePatchSetNum,
-        patchNum: 5 as RevisionPatchSetNum,
-      };
+      element.patchNum = 5 as RevisionPatchSetNum;
+      element.basePatchNum = 3 as BasePatchSetNum;
       const e = {detail: {number: 123, side: Side.LEFT}} as CustomEvent;
 
       element.onLineSelected(e);
@@ -1926,7 +1470,7 @@
     });
 
     test('handleToggleDiffMode', () => {
-      const userStub = stubUsers('updatePreferences');
+      const userStub = sinon.stub(userModel, 'updatePreferences');
       element.userPrefs = {
         ...createDefaultPreferences(),
         diff_view: DiffViewMode.SIDE_BY_SIDE,
@@ -1948,199 +1492,116 @@
       });
     });
 
-    suite('initPatchRange', () => {
-      setup(async () => {
-        getDiffRestApiStub.returns(Promise.resolve(createDiff()));
-        element.viewState = {
-          view: GerritView.DIFF,
-          changeNum: 42 as NumericChangeId,
-          patchNum: 3 as RevisionPatchSetNum,
-          path: 'abcd',
-        };
-        await element.updateComplete;
-      });
-      test('empty', () => {
-        sinon.stub(element, 'getPaths').returns({});
-        element.initPatchRange();
-        assert.equal(Object.keys(element.commentMap ?? {}).length, 0);
-      });
-
-      test('has paths', () => {
-        sinon.stub(element, 'fetchFiles');
-        sinon.stub(element, 'getPaths').returns({
-          'path/to/file/one.cpp': true,
-          'path-to/file/two.py': true,
-        });
-        element.changeNum = 42 as NumericChangeId;
-        element.patchRange = {
-          basePatchNum: 3 as BasePatchSetNum,
-          patchNum: 5 as RevisionPatchSetNum,
-        };
-        element.initPatchRange();
-        assert.deepEqual(Object.keys(element.commentMap ?? {}), [
-          'path/to/file/one.cpp',
-          'path-to/file/two.py',
-        ]);
-      });
-    });
-
-    suite('computeCommentSkips', () => {
+    suite('findFileWithComment', () => {
       test('empty file list', () => {
-        const commentMap = {
-          'path/one.jpg': true,
-          'path/three.wav': true,
-        };
-        const path = 'path/two.m4v';
-        const result = element.computeCommentSkips(commentMap, [], path);
-        assert.isOk(result);
-        assert.isNotOk(result!.previous);
-        assert.isNotOk(result!.next);
+        element.changeComments = new ChangeComments({
+          'path/one.jpg': [createComment('c1', 1, 1, 'path/one.jpg')],
+          'path/three.wav': [createComment('c1', 1, 1, 'path/three.wav')],
+        });
+        element.path = 'path/two.m4v';
+        assert.isUndefined(element.findFileWithComment(-1));
+        assert.isUndefined(element.findFileWithComment(1));
       });
 
       test('finds skips', () => {
         const fileList = ['path/one.jpg', 'path/two.m4v', 'path/three.wav'];
-        let path = fileList[1];
-        const commentMap: CommentMap = {};
-        commentMap[fileList[0]] = true;
-        commentMap[fileList[1]] = false;
-        commentMap[fileList[2]] = true;
+        element.files = {sortedPaths: fileList, changeFilesByPath: {}};
+        element.path = fileList[1];
+        element.changeComments = new ChangeComments({
+          'path/one.jpg': [createComment('c1', 1, 1, 'path/one.jpg')],
+          'path/three.wav': [createComment('c1', 1, 1, 'path/three.wav')],
+        });
 
-        let result = element.computeCommentSkips(commentMap, fileList, path);
-        assert.isOk(result);
-        assert.equal(result!.previous, fileList[0]);
-        assert.equal(result!.next, fileList[2]);
+        assert.equal(element.findFileWithComment(-1), fileList[0]);
+        assert.equal(element.findFileWithComment(1), fileList[2]);
 
-        commentMap[fileList[1]] = true;
+        element.changeComments = new ChangeComments({
+          'path/one.jpg': [createComment('c1', 1, 1, 'path/one.jpg')],
+          'path/two.m4v': [createComment('c1', 1, 1, 'path/two.m4v')],
+          'path/three.wav': [createComment('c1', 1, 1, 'path/three.wav')],
+        });
 
-        result = element.computeCommentSkips(commentMap, fileList, path);
-        assert.isOk(result);
-        assert.equal(result!.previous, fileList[0]);
-        assert.equal(result!.next, fileList[2]);
+        assert.equal(element.findFileWithComment(-1), fileList[0]);
+        assert.equal(element.findFileWithComment(1), fileList[2]);
 
-        path = fileList[0];
+        element.path = fileList[0];
 
-        result = element.computeCommentSkips(commentMap, fileList, path);
-        assert.isOk(result);
-        assert.isNull(result!.previous);
-        assert.equal(result!.next, fileList[1]);
+        assert.isUndefined(element.findFileWithComment(-1));
+        assert.equal(element.findFileWithComment(1), fileList[1]);
 
-        path = fileList[2];
+        element.path = fileList[2];
 
-        result = element.computeCommentSkips(commentMap, fileList, path);
-        assert.isOk(result);
-        assert.equal(result!.previous, fileList[1]);
-        assert.isNull(result!.next);
+        assert.equal(element.findFileWithComment(-1), fileList[1]);
+        assert.isUndefined(element.findFileWithComment(1));
       });
 
       suite('skip next/previous', () => {
-        let navToChangeStub: SinonStub;
-
         setup(() => {
-          navToChangeStub = sinon.stub(element, 'navToChangeView');
           element.files = getFilesFromFileList([
             'path/one.jpg',
             'path/two.m4v',
             'path/three.wav',
           ]);
-          element.patchRange = {
-            patchNum: 2 as RevisionPatchSetNum,
-            basePatchNum: 1 as BasePatchSetNum,
-          };
+          element.patchNum = 2 as RevisionPatchSetNum;
+          element.basePatchNum = 1 as BasePatchSetNum;
         });
 
-        suite('moveToPreviousFileWithComment', () => {
-          test('no skips', () => {
-            element.moveToPreviousFileWithComment();
-            assert.isFalse(navToChangeStub.called);
-            assert.isFalse(setUrlStub.called);
-          });
-
+        suite('moveToFileWithComment previous', () => {
           test('no previous', async () => {
-            const commentMap: CommentMap = {};
-            commentMap[element.files.sortedFileList[0]!] = false;
-            commentMap[element.files.sortedFileList[1]!] = false;
-            commentMap[element.files.sortedFileList[2]!] = true;
-            element.commentMap = commentMap;
-            element.path = element.files.sortedFileList[1];
+            element.changeComments = new ChangeComments({
+              'path/three.wav': [createComment('c1', 1, 1, 'path/three.wav')],
+            });
+            element.path = element.files.sortedPaths[1];
             await element.updateComplete;
 
-            element.moveToPreviousFileWithComment();
+            element.moveToFileWithComment(-1);
             assert.isTrue(navToChangeStub.calledOnce);
-            assert.isFalse(setUrlStub.called);
+            assert.isFalse(navToDiffStub.called);
           });
 
           test('w/ previous', async () => {
-            const commentMap: CommentMap = {};
-            commentMap[element.files.sortedFileList[0]!] = true;
-            commentMap[element.files.sortedFileList[1]!] = false;
-            commentMap[element.files.sortedFileList[2]!] = true;
-            element.commentMap = commentMap;
-            element.path = element.files.sortedFileList[1];
+            element.changeComments = new ChangeComments({
+              'path/one.jpg': [createComment('c1', 1, 1, 'path/one.jpg')],
+              'path/three.wav': [createComment('c1', 1, 1, 'path/three.wav')],
+            });
+            element.path = element.files.sortedPaths[1];
             await element.updateComplete;
 
-            element.moveToPreviousFileWithComment();
+            element.moveToFileWithComment(-1);
             assert.isFalse(navToChangeStub.called);
-            assert.isTrue(setUrlStub.calledOnce);
+            assert.isTrue(navToDiffStub.calledOnce);
           });
         });
 
-        suite('moveToNextFileWithComment', () => {
-          test('no skips', () => {
-            element.moveToNextFileWithComment();
-            assert.isFalse(navToChangeStub.called);
-            assert.isFalse(setUrlStub.called);
-          });
-
+        suite('moveToFileWithComment next', () => {
           test('no previous', async () => {
-            const commentMap: CommentMap = {};
-            commentMap[element.files.sortedFileList[0]!] = true;
-            commentMap[element.files.sortedFileList[1]!] = false;
-            commentMap[element.files.sortedFileList[2]!] = false;
-            element.commentMap = commentMap;
-            element.path = element.files.sortedFileList[1];
+            element.changeComments = new ChangeComments({
+              'path/one.jpg': [createComment('c1', 1, 1, 'path/one.jpg')],
+            });
+            element.path = element.files.sortedPaths[1];
             await element.updateComplete;
 
-            element.moveToNextFileWithComment();
+            element.moveToFileWithComment(1);
             assert.isTrue(navToChangeStub.calledOnce);
-            assert.isFalse(setUrlStub.called);
+            assert.isFalse(navToDiffStub.called);
           });
 
           test('w/ previous', async () => {
-            const commentMap: CommentMap = {};
-            commentMap[element.files.sortedFileList[0]!] = true;
-            commentMap[element.files.sortedFileList[1]!] = false;
-            commentMap[element.files.sortedFileList[2]!] = true;
-            element.commentMap = commentMap;
-            element.path = element.files.sortedFileList[1];
+            element.changeComments = new ChangeComments({
+              'path/one.jpg': [createComment('c1', 1, 1, 'path/one.jpg')],
+              'path/three.wav': [createComment('c1', 1, 1, 'path/three.wav')],
+            });
+            element.path = element.files.sortedPaths[1];
             await element.updateComplete;
 
-            element.moveToNextFileWithComment();
+            element.moveToFileWithComment(1);
             assert.isFalse(navToChangeStub.called);
-            assert.isTrue(setUrlStub.calledOnce);
+            assert.isTrue(navToDiffStub.calledOnce);
           });
         });
       });
     });
 
-    test('_computeEditMode', () => {
-      const callCompute = (range: PatchRange) => {
-        element.patchRange = range;
-        return element.computeEditMode();
-      };
-      assert.isFalse(
-        callCompute({
-          basePatchNum: PARENT,
-          patchNum: 1 as RevisionPatchSetNum,
-        })
-      );
-      assert.isTrue(
-        callCompute({
-          basePatchNum: 1 as BasePatchSetNum,
-          patchNum: EDIT,
-        })
-      );
-    });
-
     test('computeFileNum', () => {
       element.path = '/foo';
       assert.equal(
@@ -2207,25 +1668,32 @@
 
       test('reviewed checkbox', async () => {
         sinon.stub(element, 'handlePatchChange');
-        element.patchRange = createPatchRange();
-        await element.updateComplete;
-        assertIsDefined(element.reviewed);
-        // Reviewed checkbox should be shown.
-        assert.isTrue(isVisible(element.reviewed));
-        element.patchRange = {...element.patchRange, patchNum: EDIT};
+        element.patchNum = 1 as RevisionPatchSetNum;
+        element.basePatchNum = PARENT;
         await element.updateComplete;
 
-        assert.isFalse(isVisible(element.reviewed));
+        let checkbox = queryAndAssert(element, '#reviewed');
+        assert.isTrue(isVisible(checkbox));
+
+        element.patchNum = EDIT;
+        await element.updateComplete;
+
+        checkbox = queryAndAssert(element, '#reviewed');
+        assert.isFalse(isVisible(checkbox));
       });
     });
 
     suite('switching files', () => {
-      let dispatchEventStub: SinonStub;
-      let navToFileStub: SinonStub;
-      let moveToPreviousChunkStub: SinonStub;
-      let moveToNextChunkStub: SinonStub;
-      let isAtStartStub: SinonStub;
-      let isAtEndStub: SinonStub;
+      let dispatchEventStub: SinonStubbedMember<Element['dispatchEvent']>;
+      let navToFileStub: SinonStubbedMember<GrDiffView['navToFile']>;
+      let moveToPreviousChunkStub: SinonStubbedMember<
+        GrDiffCursor['moveToPreviousChunk']
+      >;
+      let moveToNextChunkStub: SinonStubbedMember<
+        GrDiffCursor['moveToNextChunk']
+      >;
+      let isAtStartStub: SinonStubbedMember<GrDiffCursor['isAtStart']>;
+      let isAtEndStub: SinonStubbedMember<GrDiffCursor['isAtEnd']>;
       let nowStub: SinonStub;
 
       setup(() => {
@@ -2249,10 +1717,7 @@
         pressKey(element, 'n');
 
         assert.isTrue(moveToNextChunkStub.called);
-        assert.equal(
-          dispatchEventStub.lastCall.args[0].type,
-          EventType.SHOW_ALERT
-        );
+        assert.equal(dispatchEventStub.lastCall.args[0].type, 'show-alert');
         assert.isFalse(navToFileStub.called);
       });
 
@@ -2292,10 +1757,7 @@
         pressKey(element, 'p');
 
         assert.isTrue(moveToPreviousChunkStub.called);
-        assert.equal(
-          dispatchEventStub.lastCall.args[0].type,
-          EventType.SHOW_ALERT
-        );
+        assert.equal(dispatchEventStub.lastCall.args[0].type, 'show-alert');
         assert.isFalse(navToFileStub.called);
       });
 
@@ -2360,48 +1822,43 @@
 
     test('File change should trigger setUrl once', async () => {
       element.files = getFilesFromFileList(['file1', 'file2', 'file3']);
-      sinon.stub(element, 'initLineOfInterestAndCursor');
+      sinon.stub(element, 'initCursor');
 
       // Load file1
-      element.viewState = {
-        view: GerritView.DIFF,
+      viewModel.setState({
+        ...createDiffViewState(),
         patchNum: 1 as RevisionPatchSetNum,
-        changeNum: 101 as NumericChangeId,
-        project: 'test-project' as RepoName,
-        path: 'file1',
-      };
-      element.patchRange = {
-        patchNum: 1 as RevisionPatchSetNum,
-        basePatchNum: PARENT,
-      };
+        repo: 'test-project' as RepoName,
+        diffView: {path: 'file1'},
+      });
+      element.patchNum = 1 as RevisionPatchSetNum;
+      element.basePatchNum = PARENT;
       element.change = {
         ...createParsedChange(),
         revisions: createRevisions(1),
       };
       await element.updateComplete;
-      assert.isFalse(setUrlStub.called);
+      assert.isFalse(navToDiffStub.called);
 
       // Switch to file2
       element.handleFileChange(
         new CustomEvent('value-change', {detail: {value: 'file2'}})
       );
-      assert.isTrue(setUrlStub.calledOnce);
+      assert.isTrue(navToDiffStub.calledOnce);
+      assert.deepEqual(navToDiffStub.lastCall.firstArg, {path: 'file2'});
 
       // This is to mock the param change triggered by above navigate
-      element.viewState = {
-        view: GerritView.DIFF,
+      viewModel.setState({
+        ...createDiffViewState(),
         patchNum: 1 as RevisionPatchSetNum,
-        changeNum: 101 as NumericChangeId,
-        project: 'test-project' as RepoName,
-        path: 'file2',
-      };
-      element.patchRange = {
-        patchNum: 1 as RevisionPatchSetNum,
-        basePatchNum: PARENT,
-      };
+        repo: 'test-project' as RepoName,
+        diffView: {path: 'file2'},
+      });
+      element.patchNum = 1 as RevisionPatchSetNum;
+      element.basePatchNum = PARENT;
 
       // No extra call
-      assert.isTrue(setUrlStub.calledOnce);
+      assert.isTrue(navToDiffStub.calledOnce);
     });
 
     test('_computeDownloadDropdownLinks', () => {
@@ -2423,10 +1880,8 @@
       element.change = createParsedChange();
       element.change.project = 'test' as RepoName;
       element.changeNum = 12 as NumericChangeId;
-      element.patchRange = {
-        patchNum: 1 as RevisionPatchSetNum,
-        basePatchNum: PARENT,
-      };
+      element.patchNum = 1 as RevisionPatchSetNum;
+      element.basePatchNum = PARENT;
       element.path = 'index.php';
       element.diff = createDiff();
       assert.deepEqual(element.computeDownloadDropdownLinks(), downloadLinks);
@@ -2455,10 +1910,8 @@
       element.change = createParsedChange();
       element.change.project = 'test' as RepoName;
       element.changeNum = 12 as NumericChangeId;
-      element.patchRange = {
-        patchNum: 3 as RevisionPatchSetNum,
-        basePatchNum: 2 as BasePatchSetNum,
-      };
+      element.patchNum = 3 as RevisionPatchSetNum;
+      element.basePatchNum = 2 as BasePatchSetNum;
       element.path = 'index.php';
       element.diff = diff;
       assert.deepEqual(element.computeDownloadDropdownLinks(), downloadLinks);
@@ -2522,49 +1975,4 @@
       );
     });
   });
-
-  suite('unmodified files with comments', () => {
-    let element: GrDiffView;
-
-    setup(async () => {
-      const changedFiles = {
-        'file1.txt': createFileInfo(),
-        'a/b/test.c': createFileInfo(),
-      };
-      stubRestApi('getConfig').returns(Promise.resolve(createServerInfo()));
-      stubRestApi('getProjectConfig').returns(Promise.resolve(createConfig()));
-      stubRestApi('getChangeFiles').returns(Promise.resolve(changedFiles));
-      stubRestApi('saveFileReviewed').returns(Promise.resolve(new Response()));
-      stubRestApi('getDiffComments').returns(Promise.resolve({}));
-      stubRestApi('getDiffRobotComments').returns(Promise.resolve({}));
-      stubRestApi('getDiffDrafts').returns(Promise.resolve({}));
-      stubRestApi('getReviewedFiles').returns(Promise.resolve([]));
-      element = await fixture(html`<gr-diff-view></gr-diff-view>`);
-      element.changeNum = 42 as NumericChangeId;
-    });
-
-    test('fetchFiles add files with comments without changes', () => {
-      element.patchRange = {
-        basePatchNum: 5 as BasePatchSetNum,
-        patchNum: 10 as RevisionPatchSetNum,
-      };
-      element.changeComments = {
-        getPaths: sinon.stub().returns({
-          'file2.txt': {},
-          'file1.txt': {},
-        }),
-      } as unknown as ChangeComments;
-      element.changeNum = 23 as NumericChangeId;
-      return element.fetchFiles().then(() => {
-        assert.deepEqual(element.files, {
-          sortedFileList: ['a/b/test.c', 'file1.txt', 'file2.txt'],
-          changeFilesByPath: {
-            'file1.txt': createFileInfo(),
-            'file2.txt': {status: 'U'} as FileInfo,
-            'a/b/test.c': createFileInfo(),
-          },
-        });
-      });
-    });
-  });
 });
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 5881cc6..76d67af 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
@@ -5,6 +5,7 @@
  */
 import '../../shared/gr-dropdown-list/gr-dropdown-list';
 import '../../shared/gr-select/gr-select';
+import '../../shared/gr-weblink/gr-weblink';
 import {convertToString, pluralize} from '../../../utils/string-util';
 import {getAppContext} from '../../../services/app-context';
 import {
@@ -13,7 +14,6 @@
   getParentIndex,
   getRevisionByPatchNum,
   isMergeParent,
-  sortRevisions,
   PatchSet,
   convertToPatchSetNum,
 } from '../../../utils/patch-set-util';
@@ -27,6 +27,7 @@
   RevisionInfo,
   RevisionPatchSetNum,
   Timestamp,
+  WebLinkInfo,
 } from '../../../types/common';
 import {RevisionInfo as RevisionInfoClass} from '../../shared/revision-info/revision-info';
 import {ChangeComments} from '../gr-comment-api/gr-comment-api';
@@ -42,10 +43,10 @@
 import {subscribe} from '../../lit/subscription-controller';
 import {commentsModelToken} from '../../../models/comments/comments-model';
 import {resolve} from '../../../models/dependency';
-import {ifDefined} from 'lit/directives/if-defined.js';
 import {ValueChangedEvent} from '../../../types/events';
-import {GeneratedWebLink} from '../../../utils/weblink-util';
 import {changeModelToken} from '../../../models/change/change-model';
+import {changeViewModelToken} from '../../../models/views/change';
+import {fireNoBubbleNoCompose} from '../../../utils/event-util';
 
 // Maximum length for patch set descriptions.
 const PATCH_DESC_MAX_LENGTH = 500;
@@ -55,15 +56,15 @@
 }
 
 export interface PatchRangeChangeDetail {
-  patchNum?: PatchSetNum;
+  patchNum?: RevisionPatchSetNum;
   basePatchNum?: BasePatchSetNum;
 }
 
 export type PatchRangeChangeEvent = CustomEvent<PatchRangeChangeDetail>;
 
 export interface FilesWebLinks {
-  meta_a: GeneratedWebLink[];
-  meta_b: GeneratedWebLink[];
+  meta_a: WebLinkInfo[];
+  meta_b: WebLinkInfo[];
 }
 
 declare global {
@@ -72,6 +73,12 @@
   }
 }
 
+declare global {
+  interface HTMLElementEventMap {
+    'patch-range-change': PatchRangeChangeEvent;
+  }
+}
+
 /**
  * Fired when the patch range changes
  *
@@ -116,14 +123,13 @@
 
   private readonly getChangeModel = resolve(this, changeModelToken);
 
-  // Private but used in tests.
-  readonly routerModel = getAppContext().routerModel;
+  private readonly getViewModel = resolve(this, changeViewModelToken);
 
   constructor() {
     super();
     subscribe(
       this,
-      () => this.routerModel.routerChangeNum$,
+      () => this.getViewModel().changeNum$,
       x => (this.changeNum = x)
     );
     subscribe(
@@ -149,7 +155,7 @@
     subscribe(
       this,
       () => this.getChangeModel().revisions$,
-      x => (this.sortedRevisions = sortRevisions(Object.values(x || {})))
+      x => (this.sortedRevisions = x)
     );
     subscribe(
       this,
@@ -178,6 +184,9 @@
           --trigger-style-text-color: var(--deemphasized-text-color);
           --trigger-style-font-family: var(--font-family);
         }
+        .filesWeblinks gr-weblink {
+          vertical-align: baseline;
+        }
         @media screen and (max-width: 50em) {
           .filesWeblinks {
             display: none;
@@ -224,15 +233,11 @@
     `;
   }
 
-  private renderWeblinks(fileLinks?: GeneratedWebLink[]) {
+  private renderWeblinks(fileLinks?: WebLinkInfo[]) {
     if (!fileLinks) return;
     return html`<span class="filesWeblinks">
       ${fileLinks.map(
-        weblink => html`
-          <a target="_blank" rel="noopener" href=${ifDefined(weblink.url)}>
-            ${weblink.name}
-          </a>
-        `
+        weblink => html`<gr-weblink .info=${weblink}></gr-weblink>`
       )}</span
     > `;
   }
@@ -318,11 +323,7 @@
   }
 
   private computeText(patchNum: PatchSetNum, prefix: string, sha: string) {
-    return (
-      `${prefix}${patchNum}` +
-      `${this.computePatchSetCommentsString(patchNum)}` +
-      ` | ${sha}`
-    );
+    return `${prefix}${patchNum} | ${sha}`;
   }
 
   private createDropdownEntry(
@@ -336,6 +337,12 @@
       mobileText: this.computeMobileText(patchNum),
       bottomText: `${this.computePatchSetDescription(patchNum)}`,
       value: patchNum,
+      commentThreads: this.changeComments?.computeCommentThreads(
+        {
+          patchNum,
+        },
+        true
+      ),
     };
     const date = this.computePatchSetDate(patchNum);
     if (date) {
@@ -410,12 +417,12 @@
   computePatchSetCommentsString(patchNum: PatchSetNum): string {
     if (!this.changeComments) return '';
 
-    const commentThreadCount = this.changeComments.computeCommentThreadCount(
+    const commentThreadCount = this.changeComments.computeCommentThreads(
       {
         patchNum,
       },
       true
-    );
+    ).length;
     const commentThreadString = pluralize(commentThreadCount, 'comment');
 
     const unresolvedCount = this.changeComments.computeUnresolvedNum(
@@ -463,7 +470,9 @@
       basePatchNum: this.basePatchNum,
     };
     const target = e.target;
-    const patchSetValue = convertToPatchSetNum(e.detail.value)!;
+    const patchSetValue = convertToPatchSetNum(
+      e.detail.value
+    ) as RevisionPatchSetNum;
     const latestPatchNum = computeLatestPatchNum(this.availablePatches);
     if (target === this.patchNumDropdown) {
       if (detail.patchNum === patchSetValue) return;
@@ -471,9 +480,9 @@
         previous: detail.patchNum,
         current: patchSetValue,
         latest: latestPatchNum,
-        commentCount: this.changeComments?.computeCommentThreadCount({
+        commentCount: this.changeComments?.computeCommentThreads({
           patchNum: patchSetValue,
-        }),
+        }).length,
       });
       detail.patchNum = patchSetValue;
     } else {
@@ -481,15 +490,13 @@
       this.reporting.reportInteraction('left-patchset-changed', {
         previous: detail.basePatchNum,
         current: patchSetValue,
-        commentCount: this.changeComments?.computeCommentThreadCount({
+        commentCount: this.changeComments?.computeCommentThreads({
           patchNum: patchSetValue,
-        }),
+        }).length,
       });
       detail.basePatchNum = patchSetValue as BasePatchSetNum;
     }
 
-    this.dispatchEvent(
-      new CustomEvent('patch-range-change', {detail, bubbles: false})
-    );
+    fireNoBubbleNoCompose(this, 'patch-range-change', detail);
   }
 }
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 bc30b1d..b4ab043 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
@@ -9,7 +9,7 @@
 import {GrPatchRangeSelect} from './gr-patch-range-select';
 import {RevisionInfo as RevisionInfoClass} from '../../shared/revision-info/revision-info';
 import {ChangeComments} from '../gr-comment-api/gr-comment-api';
-import {stubReporting} from '../../../test/test-utils';
+import {queryAll, stubReporting} from '../../../test/test-utils';
 import {
   BasePatchSetNum,
   EDIT,
@@ -20,7 +20,7 @@
   RevisionInfo,
   Timestamp,
   UrlEncodedCommentId,
-  PathToCommentsInfoMap,
+  CommentInfo,
 } from '../../../types/common';
 import {EditRevisionInfo, ParsedChangeInfo} from '../../../types/types';
 import {SpecialFilePath} from '../../../constants/constants';
@@ -30,7 +30,6 @@
   createParsedChange,
   createRevision,
   createRevisions,
-  TEST_NUMERIC_CHANGE_ID,
 } from '../../../test/test-data-generators';
 import {PatchSet} from '../../../utils/patch-set-util';
 import {
@@ -43,7 +42,6 @@
 import {testResolver} from '../../../test/common-test-setup';
 import {changeViewModelToken} from '../../../models/views/change';
 import {changeModelToken} from '../../../models/change/change-model';
-import {GerritView} from '../../../services/router/router-model';
 
 type RevIdToRevisionInfo = {
   [revisionId: string]: RevisionInfo | EditRevisionInfo;
@@ -67,11 +65,6 @@
       html`<gr-patch-range-select></gr-patch-range-select>`
     );
 
-    element.routerModel.setState({
-      changeNum: TEST_NUMERIC_CHANGE_ID,
-      view: GerritView.CHANGE,
-      patchNum: 1 as RevisionPatchSetNum,
-    });
     const viewModel = testResolver(changeViewModelToken);
     viewModel.setState({
       ...createChangeViewState(),
@@ -154,6 +147,7 @@
         mobileText: EDIT,
         bottomText: '',
         value: EDIT,
+        commentThreads: [],
       },
       {
         disabled: true,
@@ -163,6 +157,7 @@
         bottomText: '',
         value: 3,
         date: '2020-02-01 01:02:03.000000000' as Timestamp,
+        commentThreads: [],
       } as DropdownItem,
       {
         disabled: true,
@@ -172,6 +167,7 @@
         bottomText: '',
         value: 2,
         date: '2020-02-01 01:02:03.000000000' as Timestamp,
+        commentThreads: [],
       } as DropdownItem,
       {
         disabled: true,
@@ -181,6 +177,7 @@
         bottomText: '',
         value: 1,
         date: '2020-02-01 01:02:03.000000000' as Timestamp,
+        commentThreads: [],
       } as DropdownItem,
       {
         text: 'Base',
@@ -294,6 +291,7 @@
         mobileText: EDIT,
         bottomText: '',
         value: EDIT,
+        commentThreads: [],
       },
       {
         disabled: false,
@@ -303,6 +301,7 @@
         bottomText: '',
         value: 3,
         date: '2020-02-01 01:02:03.000000000' as Timestamp,
+        commentThreads: [],
       } as DropdownItem,
       {
         disabled: false,
@@ -312,6 +311,7 @@
         bottomText: 'description',
         value: 2,
         date: '2020-02-01 01:02:03.000000000' as Timestamp,
+        commentThreads: [],
       } as DropdownItem,
       {
         disabled: true,
@@ -321,6 +321,7 @@
         bottomText: '',
         value: 1,
         date: '2020-02-01 01:02:03.000000000' as Timestamp,
+        commentThreads: [],
       } as DropdownItem,
     ];
 
@@ -329,33 +330,16 @@
 
   test('filesWeblinks', async () => {
     element.filesWeblinks = {
-      meta_a: [
-        {
-          name: 'foo',
-          url: 'f.oo',
-        },
-      ],
-      meta_b: [
-        {
-          name: 'bar',
-          url: 'ba.r',
-        },
-      ],
+      meta_a: [{name: 'foo', url: 'f.oo'}],
+      meta_b: [{name: 'bar', url: 'ba.r'}],
     };
     await element.updateComplete;
-    assert.equal(
-      queryAndAssert(element, 'a[href="f.oo"]').textContent!.trim(),
-      'foo'
-    );
-    assert.equal(
-      queryAndAssert(element, 'a[href="ba.r"]').textContent!.trim(),
-      'bar'
-    );
+    assert.equal(queryAll(element, 'gr-weblink').length, 2);
   });
 
   test('computePatchSetCommentsString', () => {
     // Test string with unresolved comments.
-    const comments: PathToCommentsInfoMap = {
+    const comments: {[path: string]: CommentInfo[]} = {
       foo: [
         {
           id: '27dcee4d_f7b77cfa' as UrlEncodedCommentId,
@@ -476,10 +460,10 @@
     await element.updateComplete;
 
     const stub = stubReporting('reportInteraction');
-    fire(element.patchNumDropdown!, 'value-change', {value: '1'});
+    fire(element.patchNumDropdown, 'value-change', {value: '1'});
     assert.isFalse(stub.called);
 
-    fire(element.patchNumDropdown!, 'value-change', {value: '2'});
+    fire(element.patchNumDropdown, 'value-change', {value: '2'});
     assert.isTrue(stub.called);
   });
 });
diff --git a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.ts b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.ts
index 6dbdca6..87fe27f 100644
--- a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.ts
+++ b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.ts
@@ -14,7 +14,10 @@
 import {customElement, state} from 'lit/decorators.js';
 import {resolve} from '../../../models/dependency';
 import {subscribe} from '../../lit/subscription-controller';
-import {documentationViewModelToken} from '../../../models/views/documentation';
+import {
+  createDocumentationUrl,
+  documentationViewModelToken,
+} from '../../../models/views/documentation';
 
 @customElement('gr-documentation-search')
 export class GrDocumentationSearch extends LitElement {
@@ -45,7 +48,7 @@
 
   override connectedCallback() {
     super.connectedCallback();
-    fireTitleChange(this, 'Documentation Search');
+    fireTitleChange('Documentation Search');
   }
 
   static override get styles() {
@@ -57,7 +60,7 @@
       .filter=${this.filter}
       .offset=${0}
       .loading=${this.loading}
-      .path=${'/Documentation'}
+      .path=${createDocumentationUrl()}
     >
       <table id="list" class="genericList">
         <tbody>
diff --git a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_test.ts b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_test.ts
index 0092193..ea5e9f3 100644
--- a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_test.ts
+++ b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_test.ts
@@ -6,10 +6,11 @@
 import '../../../test/common-test-setup';
 import './gr-documentation-search';
 import {GrDocumentationSearch} from './gr-documentation-search';
-import {page} from '../../../utils/page-wrapper-utils';
 import {queryAndAssert, stubRestApi} from '../../../test/test-utils';
 import {DocResult} from '../../../types/common';
 import {fixture, html, assert} from '@open-wc/testing';
+import {testResolver} from '../../../test/common-test-setup';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
 
 function documentationGenerator(counter: number) {
   return {
@@ -31,7 +32,7 @@
   let documentationSearches: DocResult[];
 
   setup(async () => {
-    sinon.stub(page, 'show');
+    sinon.stub(testResolver(navigationToken), 'setUrl');
     element = await fixture(
       html`<gr-documentation-search></gr-documentation-search>`
     );
diff --git a/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor.ts b/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor.ts
index 7229c63..25d3cf4 100644
--- a/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor.ts
+++ b/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor.ts
@@ -6,11 +6,16 @@
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, css, html} from 'lit';
 import {customElement, property} from 'lit/decorators.js';
+import {fire} from '../../../utils/event-util';
+import {ValueChangedEvent} from '../../../types/events';
 
 declare global {
   interface HTMLElementTagNameMap {
     'gr-default-editor': GrDefaultEditor;
   }
+  interface HTMLElementEventMap {
+    'content-change': ValueChangedEvent;
+  }
 }
 
 @customElement('gr-default-editor')
@@ -56,12 +61,7 @@
   }
 
   _handleTextareaInput(e: Event) {
-    this.dispatchEvent(
-      new CustomEvent('content-change', {
-        detail: {value: (e.target as HTMLTextAreaElement).value},
-        bubbles: true,
-        composed: true,
-      })
-    );
+    const value = (e.target as HTMLTextAreaElement).value;
+    fire(this, 'content-change', {value});
   }
 }
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.ts b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.ts
index c0dd00b..5786112 100644
--- a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.ts
+++ b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.ts
@@ -8,7 +8,6 @@
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-dialog/gr-dialog';
 import '../../shared/gr-dropdown/gr-dropdown';
-import '../../shared/gr-overlay/gr-overlay';
 import {GrEditAction, GrEditConstants} from '../gr-edit-constants';
 import {navigationToken} from '../../core/gr-navigation/gr-navigation';
 import {ChangeInfo, RevisionPatchSetNum} from '../../../types/common';
@@ -19,7 +18,7 @@
   GrAutocomplete,
 } from '../../shared/gr-autocomplete/gr-autocomplete';
 import {getAppContext} from '../../../services/app-context';
-import {fireAlert, fireReload} from '../../../utils/event-util';
+import {fireAlert} from '../../../utils/event-util';
 import {
   assertIsDefined,
   query as queryUtil,
@@ -29,17 +28,20 @@
 import {LitElement, html, css} from 'lit';
 import {customElement, property, query, state} from 'lit/decorators.js';
 import {BindValueChangeEvent} from '../../../types/events';
-import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
 import {IronInputElement} from '@polymer/iron-input/iron-input';
-import {createEditUrl} from '../../../models/views/edit';
+import {createEditUrl} from '../../../models/views/change';
 import {resolve} from '../../../models/dependency';
+import {modalStyles} from '../../../styles/gr-modal-styles';
+import {whenVisible} from '../../../utils/dom-util';
+import {throwingErrorCallback} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
+import {changeModelToken} from '../../../models/change/change-model';
 
 @customElement('gr-edit-controls')
 export class GrEditControls extends LitElement {
   // private but used in test
   @query('#newPathIronInput') newPathIronInput?: IronInputElement;
 
-  @query('#overlay') protected overlay?: GrOverlay;
+  @query('#modal') modal?: HTMLDialogElement;
 
   // private but used in test
   @query('#openDialog') openDialog?: GrDialog;
@@ -76,11 +78,14 @@
 
   private readonly restApiService = getAppContext().restApiService;
 
+  private readonly getChangeModel = resolve(this, changeModelToken);
+
   private readonly getNavigation = resolve(this, navigationToken);
 
   static override get styles() {
     return [
       sharedStyles,
+      modalStyles,
       css`
         :host {
           align-items: center;
@@ -137,10 +142,10 @@
   override render() {
     return html`
       ${this.actions.map(action => this.renderAction(action))}
-      <gr-overlay id="overlay" with-backdrop="">
+      <dialog id="modal" tabindex="-1">
         ${this.renderOpenDialog()} ${this.renderDeleteDialog()}
         ${this.renderRenameDialog()} ${this.renderRestoreDialog()}
-      </gr-overlay>
+      </dialog>
     `;
   }
 
@@ -309,7 +314,7 @@
       this.path = path;
     }
     assertIsDefined(this.openDialog, 'openDialog');
-    return this.showDialog(this.openDialog);
+    this.showDialog(this.openDialog);
   }
 
   openDeleteDialog(path?: string) {
@@ -317,7 +322,7 @@
       this.path = path;
     }
     assertIsDefined(this.deleteDialog, 'deleteDialog');
-    return this.showDialog(this.deleteDialog);
+    this.showDialog(this.deleteDialog);
   }
 
   openRenameDialog(path?: string) {
@@ -325,7 +330,7 @@
       this.path = path;
     }
     assertIsDefined(this.renameDialog, 'renameDialog');
-    return this.showDialog(this.renameDialog);
+    this.showDialog(this.renameDialog);
   }
 
   openRestoreDialog(path?: string) {
@@ -333,7 +338,7 @@
     if (path) {
       this.path = path;
     }
-    return this.showDialog(this.restoreDialog);
+    this.showDialog(this.restoreDialog);
   }
 
   /**
@@ -361,23 +366,20 @@
 
   // private but used in test
   showDialog(dialog: GrDialog) {
-    assertIsDefined(this.overlay, 'overlay');
+    assertIsDefined(this.modal, 'modal');
 
     // Some dialogs may not fire their on-close event when closed in certain
     // ways (e.g. by clicking outside the dialog body). This call prevents
-    // multiple dialogs from being shown in the same overlay.
+    // multiple dialogs from being shown in the same modal.
     this.hideAllDialogs();
 
-    return this.overlay.open().then(() => {
+    this.modal.showModal();
+    whenVisible(this.modal, () => {
       dialog.classList.toggle('invisible', false);
       const autocomplete = queryUtil<GrAutocomplete>(dialog, 'gr-autocomplete');
       if (autocomplete) {
         autocomplete.focus();
       }
-      setTimeout(() => {
-        assertIsDefined(this.overlay, 'overlay');
-        this.overlay.center();
-      }, 1);
     });
   }
 
@@ -412,8 +414,8 @@
 
     dialog.classList.toggle('invisible', true);
 
-    assertIsDefined(this.overlay, 'overlay');
-    this.overlay.close();
+    assertIsDefined(this.modal, 'modal');
+    this.modal.close();
   }
 
   private readonly handleDialogCancel = (e: Event) => {
@@ -429,9 +431,9 @@
     assertIsDefined(this.patchNum, 'patchset number');
     const url = createEditUrl({
       changeNum: this.change._number,
-      project: this.change.project,
-      path: this.path,
+      repo: this.change.project,
       patchNum: this.patchNum,
+      editView: {path: this.path},
     });
 
     this.getNavigation().setUrl(url);
@@ -452,7 +454,7 @@
           return;
         }
         this.closeDialog(this.openDialog);
-        fireReload(this, true);
+        this.getChangeModel().navigateToChangeResetReload();
       });
   }
 
@@ -472,7 +474,7 @@
           return;
         }
         this.closeDialog(dialog);
-        fireReload(this, true);
+        this.getChangeModel().navigateToChangeResetReload();
       });
   };
 
@@ -490,7 +492,7 @@
           return;
         }
         this.closeDialog(dialog);
-        fireReload(this, true);
+        this.getChangeModel().navigateToChangeResetReload();
       });
   };
 
@@ -508,7 +510,7 @@
           return;
         }
         this.closeDialog(dialog);
-        fireReload(this, true);
+        this.getChangeModel().navigateToChangeResetReload();
       });
   };
 
@@ -516,7 +518,12 @@
     assertIsDefined(this.change, 'this.change');
     assertIsDefined(this.patchNum, 'this.patchNum');
     return this.restApiService
-      .queryChangeFiles(this.change._number, this.patchNum, input)
+      .queryChangeFiles(
+        this.change._number,
+        this.patchNum,
+        input,
+        throwingErrorCallback
+      )
       .then(res => {
         if (!res)
           throw new Error('Failed to retrieve files. Response not set.');
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_test.ts b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_test.ts
index b4469db..0e6778a 100644
--- a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_test.ts
+++ b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_test.ts
@@ -7,7 +7,12 @@
 import './gr-edit-controls';
 import {GrEditControls} from './gr-edit-controls';
 import {navigationToken} from '../../core/gr-navigation/gr-navigation';
-import {queryAll, stubRestApi, waitUntil} from '../../../test/test-utils';
+import {
+  queryAll,
+  stubRestApi,
+  waitUntil,
+  waitUntilVisible,
+} from '../../../test/test-utils';
 import {createChange, createRevision} from '../../../test/test-data-generators';
 import {GrAutocomplete} from '../../shared/gr-autocomplete/gr-autocomplete';
 import {
@@ -21,8 +26,12 @@
 import {fixture, html, assert} from '@open-wc/testing';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import '../../shared/gr-dialog/gr-dialog';
-import {waitForEventOnce} from '../../../utils/event-util';
 import {testResolver} from '../../../test/common-test-setup';
+import {
+  ChangeModel,
+  changeModelToken,
+} from '../../../models/change/change-model';
+import {SinonStubbedMember} from 'sinon';
 
 suite('gr-edit-controls tests', () => {
   let element: GrEditControls;
@@ -31,6 +40,9 @@
   let closeDialogSpy: sinon.SinonSpy;
   let hideDialogStub: sinon.SinonStub;
   let queryStub: sinon.SinonStub;
+  let navigateResetStub: SinonStubbedMember<
+    ChangeModel['navigateToChangeResetReload']
+  >;
 
   setup(async () => {
     element = await fixture<GrEditControls>(html`
@@ -42,6 +54,10 @@
     closeDialogSpy = sinon.spy(element, 'closeDialog');
     hideDialogStub = sinon.stub(element, 'hideAllDialogs');
     queryStub = stubRestApi('queryChangeFiles').returns(Promise.resolve([]));
+    navigateResetStub = sinon.stub(
+      testResolver(changeModelToken),
+      'navigateToChangeResetReload'
+    );
     await element.updateComplete;
   });
 
@@ -86,13 +102,7 @@
         >
           Restore
         </gr-button>
-        <gr-overlay
-          aria-hidden="true"
-          id="overlay"
-          style="outline: none; display: none;"
-          tabindex="-1"
-          with-backdrop=""
-        >
+        <dialog id="modal" tabindex="-1">
           <gr-dialog
             class="dialog invisible"
             confirm-label="Confirm"
@@ -180,7 +190,7 @@
               </iron-input>
             </div>
           </gr-dialog>
-        </gr-overlay>
+        </dialog>
       `
     );
   });
@@ -225,9 +235,13 @@
       // Setup focused manually - in headless mode Chrome sometimes doesn't
       // setup focus. waitEventLoop() doesn't help.
       openAutoComplete.focused = true;
-      openAutoComplete.noDebounce = true;
       openAutoComplete.text = 'src/test.cpp';
+      // Focus happens after updateComplete, so we first wait for it explicitly.
+      await new Promise<void>(resolve => {
+        openAutoComplete.addEventListener('focus', () => resolve());
+      });
       await element.updateComplete;
+      await openAutoComplete.latestSuggestionUpdateComplete;
       assert.isTrue(queryStub.called);
       await waitUntil(() => !element.openDialog!.disabled);
       queryAndAssert<GrButton>(
@@ -241,17 +255,15 @@
 
     test('cancel', async () => {
       queryAndAssert<GrButton>(element, '#open').click();
-      return showDialogSpy.lastCall.returnValue.then(async () => {
-        assert.isTrue(element.openDialog!.disabled);
-        openAutoComplete.noDebounce = true;
-        openAutoComplete.text = 'src/test.cpp';
-        await element.updateComplete;
-        await waitUntil(() => !element.openDialog!.disabled);
-        queryAndAssert<GrButton>(element.openDialog, 'gr-button').click();
-        assert.isFalse(setUrlStub.called);
-        await waitUntil(() => closeDialogSpy.called);
-        assert.equal(element.path, '');
-      });
+      await waitUntilVisible(element.modal!);
+      assert.isTrue(element.openDialog!.disabled);
+      openAutoComplete.text = 'src/test.cpp';
+      await element.updateComplete;
+      await waitUntil(() => !element.openDialog!.disabled);
+      queryAndAssert<GrButton>(element.openDialog, 'gr-button').click();
+      assert.isFalse(setUrlStub.called);
+      await waitUntil(() => closeDialogSpy.called);
+      assert.equal(element.path, '');
     });
   });
 
@@ -279,9 +291,13 @@
       // Setup focused manually - in headless mode Chrome sometimes doesn't
       // setup focus. waitEventLoop() doesn't help.
       deleteAutocomplete.focused = true;
-      deleteAutocomplete.noDebounce = true;
       deleteAutocomplete.text = 'src/test.cpp';
+      // Focus happens after updateComplete, so we first wait for it explicitly.
+      await new Promise<void>(resolve => {
+        deleteAutocomplete.addEventListener('focus', () => resolve());
+      });
       await element.updateComplete;
+      await deleteAutocomplete.latestSuggestionUpdateComplete;
       assert.isTrue(queryStub.called);
       await waitUntil(() => !element.deleteDialog!.disabled);
       queryAndAssert<GrButton>(
@@ -293,7 +309,7 @@
       assert.isTrue(deleteStub.called);
       await deleteStub.lastCall.returnValue;
       assert.equal(element.path, '');
-      assert.equal(eventStub.firstCall.args[0].type, 'reload');
+      assert.equal(navigateResetStub.callCount, 1);
       assert.isTrue(closeDialogSpy.called);
     });
 
@@ -306,9 +322,13 @@
       // Setup focused manually - in headless mode Chrome sometimes doesn't
       // setup focus. waitEventLoop() doesn't help.
       deleteAutocomplete.focused = true;
-      deleteAutocomplete.noDebounce = true;
       deleteAutocomplete.text = 'src/test.cpp';
+      // Focus happens after updateComplete, so we first wait for it explicitly.
+      await new Promise<void>(resolve => {
+        deleteAutocomplete.addEventListener('focus', () => resolve());
+      });
       await element.updateComplete;
+      await deleteAutocomplete.latestSuggestionUpdateComplete;
       assert.isTrue(queryStub.called);
       await waitUntil(() => !element.deleteDialog!.disabled);
       queryAndAssert<GrButton>(
@@ -324,21 +344,20 @@
       assert.isFalse(closeDialogSpy.called);
     });
 
-    test('cancel', () => {
+    test('cancel', async () => {
       queryAndAssert<GrButton>(element, '#delete').click();
-      return showDialogSpy.lastCall.returnValue.then(async () => {
-        assert.isTrue(element.deleteDialog!.disabled);
-        queryAndAssert<GrAutocomplete>(
-          element.deleteDialog,
-          'gr-autocomplete'
-        ).text = 'src/test.cpp';
-        await element.updateComplete;
-        await waitUntil(() => !element.deleteDialog!.disabled);
-        queryAndAssert<GrButton>(element.deleteDialog, 'gr-button').click();
-        assert.isFalse(eventStub.called);
-        assert.isTrue(closeDialogSpy.called);
-        await waitUntil(() => element.path === '');
-      });
+      await waitUntilVisible(element.modal!);
+      assert.isTrue(element.deleteDialog!.disabled);
+      queryAndAssert<GrAutocomplete>(
+        element.deleteDialog,
+        'gr-autocomplete'
+      ).text = 'src/test.cpp';
+      await element.updateComplete;
+      await waitUntil(() => !element.deleteDialog!.disabled);
+      queryAndAssert<GrButton>(element.deleteDialog, 'gr-button').click();
+      assert.isFalse(eventStub.called);
+      assert.isTrue(closeDialogSpy.called);
+      await waitUntil(() => element.path === '');
     });
   });
 
@@ -366,9 +385,13 @@
       // Setup focused manually - in headless mode Chrome sometimes doesn't
       // setup focus. waitEventLoop() doesn't help.
       renameAutocomplete.focused = true;
-      renameAutocomplete.noDebounce = true;
       renameAutocomplete.text = 'src/test.cpp';
+      // Focus happens after updateComplete, so we first wait for it explicitly.
+      await new Promise<void>(resolve => {
+        renameAutocomplete.addEventListener('focus', () => resolve());
+      });
       await element.updateComplete;
+      await renameAutocomplete.latestSuggestionUpdateComplete;
       assert.isTrue(queryStub.called);
       assert.isTrue(element.renameDialog!.disabled);
 
@@ -385,7 +408,7 @@
 
       await renameStub.lastCall.returnValue;
       assert.equal(element.path, '');
-      assert.equal(eventStub.firstCall.args[0].type, 'reload');
+      assert.equal(navigateResetStub.callCount, 1);
       assert.isTrue(closeDialogSpy.called);
     });
 
@@ -398,9 +421,13 @@
       // Setup focused manually - in headless mode Chrome sometimes doesn't
       // setup focus. waitEventLoop() doesn't help.
       renameAutocomplete.focused = true;
-      renameAutocomplete.noDebounce = true;
       renameAutocomplete.text = 'src/test.cpp';
+      // Focus happens after updateComplete, so we first wait for it explicitly.
+      await new Promise<void>(resolve => {
+        renameAutocomplete.addEventListener('focus', () => resolve());
+      });
       await element.updateComplete;
+      await renameAutocomplete.latestSuggestionUpdateComplete;
       assert.isTrue(queryStub.called);
       assert.isTrue(element.renameDialog!.disabled);
 
@@ -421,22 +448,21 @@
       assert.isFalse(closeDialogSpy.called);
     });
 
-    test('cancel', () => {
+    test('cancel', async () => {
       queryAndAssert<GrButton>(element, '#rename').click();
-      return showDialogSpy.lastCall.returnValue.then(async () => {
-        assert.isTrue(element.renameDialog!.disabled);
-        queryAndAssert<GrAutocomplete>(
-          element.renameDialog,
-          'gr-autocomplete'
-        ).text = 'src/test.cpp';
-        element.newPathIronInput!.bindValue = 'src/test.newPath';
-        await element.updateComplete;
-        assert.isFalse(element.renameDialog!.disabled);
-        queryAndAssert<GrButton>(element.renameDialog, 'gr-button').click();
-        assert.isFalse(eventStub.called);
-        assert.isTrue(closeDialogSpy.called);
-        await waitUntil(() => element.path === '');
-      });
+      await waitUntilVisible(element.modal!);
+      assert.isTrue(element.renameDialog!.disabled);
+      queryAndAssert<GrAutocomplete>(
+        element.renameDialog,
+        'gr-autocomplete'
+      ).text = 'src/test.cpp';
+      element.newPathIronInput!.bindValue = 'src/test.newPath';
+      await element.updateComplete;
+      assert.isFalse(element.renameDialog!.disabled);
+      queryAndAssert<GrButton>(element.renameDialog, 'gr-button').click();
+      assert.isFalse(eventStub.called);
+      assert.isTrue(closeDialogSpy.called);
+      await waitUntil(() => element.path === '');
     });
   });
 
@@ -455,56 +481,53 @@
       );
     });
 
-    test('restore', () => {
+    test('restore', async () => {
       restoreStub.returns(Promise.resolve({ok: true}));
       element.path = 'src/test.cpp';
       queryAndAssert<GrButton>(element, '#restore').click();
-      return showDialogSpy.lastCall.returnValue.then(async () => {
-        queryAndAssert<GrButton>(
-          element.restoreDialog,
-          'gr-button[primary]'
-        ).click();
-        await element.updateComplete;
+      await waitUntilVisible(element.modal!);
+      queryAndAssert<GrButton>(
+        element.restoreDialog,
+        'gr-button[primary]'
+      ).click();
+      await element.updateComplete;
 
-        assert.isTrue(restoreStub.called);
-        assert.equal(restoreStub.lastCall.args[1], 'src/test.cpp');
-        return restoreStub.lastCall.returnValue.then(() => {
-          assert.equal(element.path, '');
-          assert.equal(eventStub.firstCall.args[0].type, 'reload');
-          assert.isTrue(closeDialogSpy.called);
-        });
+      assert.isTrue(restoreStub.called);
+      assert.equal(restoreStub.lastCall.args[1], 'src/test.cpp');
+      return restoreStub.lastCall.returnValue.then(() => {
+        assert.equal(element.path, '');
+        assert.equal(navigateResetStub.callCount, 1);
+        assert.isTrue(closeDialogSpy.called);
       });
     });
 
-    test('restore fails', () => {
+    test('restore fails', async () => {
       restoreStub.returns(Promise.resolve({ok: false}));
       element.path = 'src/test.cpp';
       queryAndAssert<GrButton>(element, '#restore').click();
-      return showDialogSpy.lastCall.returnValue.then(async () => {
-        queryAndAssert<GrButton>(
-          element.restoreDialog,
-          'gr-button[primary]'
-        ).click();
-        await element.updateComplete;
+      await waitUntilVisible(element.modal!);
+      queryAndAssert<GrButton>(
+        element.restoreDialog,
+        'gr-button[primary]'
+      ).click();
+      await element.updateComplete;
 
-        assert.isTrue(restoreStub.called);
-        assert.equal(restoreStub.lastCall.args[1], 'src/test.cpp');
-        return restoreStub.lastCall.returnValue.then(() => {
-          assert.isFalse(eventStub.called);
-          assert.isFalse(closeDialogSpy.called);
-        });
+      assert.isTrue(restoreStub.called);
+      assert.equal(restoreStub.lastCall.args[1], 'src/test.cpp');
+      return restoreStub.lastCall.returnValue.then(() => {
+        assert.isFalse(eventStub.called);
+        assert.isFalse(closeDialogSpy.called);
       });
     });
 
-    test('cancel', () => {
+    test('cancel', async () => {
       element.path = 'src/test.cpp';
       queryAndAssert<GrButton>(element, '#restore').click();
-      return showDialogSpy.lastCall.returnValue.then(() => {
-        queryAndAssert<GrButton>(element.restoreDialog, 'gr-button').click();
-        assert.isFalse(eventStub.called);
-        assert.isTrue(closeDialogSpy.called);
-        assert.equal(element.path, '');
-      });
+      await waitUntilVisible(element.modal!);
+      queryAndAssert<GrButton>(element.restoreDialog, 'gr-button').click();
+      assert.isFalse(eventStub.called);
+      assert.isTrue(closeDialogSpy.called);
+      assert.equal(element.path, '');
     });
   });
 
@@ -541,17 +564,18 @@
       assert.equal(fileStub.lastCall.args[0], 1);
       assert.equal(fileStub.lastCall.args[1], 'test.php');
       assert.equal(fileStub.lastCall.args[2], 'base64');
-      await waitForEventOnce(element, 'reload');
+      await waitUntil(() => navigateResetStub.called);
+      assert.equal(navigateResetStub.callCount, 1);
     });
   });
 
   test('openOpenDialog', async () => {
-    await element.openOpenDialog('test/path.cpp');
+    element.openOpenDialog('test/path.cpp');
     assert.isFalse(element.openDialog!.hasAttribute('hidden'));
-    assert.equal(
-      queryAndAssert<GrAutocomplete>(element.openDialog, 'gr-autocomplete')
-        .text,
-      'test/path.cpp'
+    await waitUntil(
+      () =>
+        queryAndAssert<GrAutocomplete>(element.openDialog, 'gr-autocomplete')
+          .text === 'test/path.cpp'
     );
   });
 
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls.ts b/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls.ts
index c442aa6..7e49a12 100644
--- a/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls.ts
+++ b/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls.ts
@@ -6,8 +6,11 @@
 import '../../shared/gr-dropdown/gr-dropdown';
 import {GrEditConstants} from '../gr-edit-constants';
 import {sharedStyles} from '../../../styles/shared-styles';
+import {FileActionTapEvent} from '../../../types/events';
 import {LitElement, css, html} from 'lit';
 import {customElement, property} from 'lit/decorators.js';
+import {fire} from '../../../utils/event-util';
+import {DropdownLink} from '../../../types/common';
 
 interface EditAction {
   label: string;
@@ -16,12 +19,6 @@
 
 @customElement('gr-edit-file-controls')
 export class GrEditFileControls extends LitElement {
-  /**
-   * Fired when an action in the overflow menu is tapped.
-   *
-   * @event file-action-tap
-   */
-
   @property({type: String})
   filePath?: string;
 
@@ -64,23 +61,20 @@
     >`;
   }
 
-  _handleActionTap(e: CustomEvent) {
+  _handleActionTap(e: CustomEvent<DropdownLink>) {
     e.preventDefault();
     e.stopPropagation();
-    this._dispatchFileAction(e.detail.id, this.filePath);
+    const actionId = e.detail.id;
+    if (!actionId) return;
+    if (!this.filePath) return;
+    this._dispatchFileAction(actionId, this.filePath);
   }
 
-  _dispatchFileAction(action: EditAction, path?: string) {
-    this.dispatchEvent(
-      new CustomEvent('file-action-tap', {
-        detail: {action, path},
-        bubbles: true,
-        composed: true,
-      })
-    );
+  _dispatchFileAction(action: string, path: string) {
+    fire(this, 'file-action-tap', {action, path});
   }
 
-  _computeFileActions(actions: EditAction[]) {
+  _computeFileActions(actions: EditAction[]): DropdownLink[] {
     // TODO(kaspern): conditionally disable some actions based on file status.
     return actions.map(action => {
       return {
@@ -95,4 +89,7 @@
   interface HTMLElementTagNameMap {
     'gr-edit-file-controls': GrEditFileControls;
   }
+  interface HTMLElementEventMap {
+    'file-action-tap': FileActionTapEvent;
+  }
 }
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 804d5ea..f37a3d9 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
@@ -9,7 +9,6 @@
 import '../../shared/gr-editable-label/gr-editable-label';
 import '../gr-default-editor/gr-default-editor';
 import {navigationToken} from '../../core/gr-navigation/gr-navigation';
-import {computeTruncatedPath} from '../../../utils/path-list-util';
 import {
   EditPreferencesInfo,
   Base64FileContent,
@@ -17,11 +16,7 @@
 } from '../../../types/common';
 import {ParsedChangeInfo} from '../../../types/types';
 import {HttpMethod, NotifyType} from '../../../constants/constants';
-import {
-  fireAlert,
-  fireTitleChange,
-  fireReload,
-} from '../../../utils/event-util';
+import {fireAlert} from '../../../utils/event-util';
 import {getAppContext} from '../../../services/app-context';
 import {ErrorCallback} from '../../../api/rest';
 import {assertIsDefined} from '../../../utils/common-util';
@@ -35,8 +30,15 @@
 import {resolve} from '../../../models/dependency';
 import {changeModelToken} from '../../../models/change/change-model';
 import {ShortcutController} from '../../lit/shortcut-controller';
-import {editViewModelToken, EditViewState} from '../../../models/views/edit';
-import {createChangeUrl} from '../../../models/views/change';
+import {
+  ChangeChildView,
+  changeViewModelToken,
+  ChangeViewState,
+  createChangeUrl,
+} from '../../../models/views/change';
+import {userModelToken} from '../../../models/user/user-model';
+import {storageServiceToken} from '../../../services/storage/gr-storage_impl';
+import {isDarkTheme} from '../../../utils/theme-util';
 
 const RESTORED_MESSAGE = 'Content restored from a previous edit.';
 const SAVING_MESSAGE = 'Saving changes...';
@@ -50,18 +52,12 @@
 @customElement('gr-editor-view')
 export class GrEditorView extends LitElement {
   /**
-   * Fired when the title of the page should change.
-   *
-   * @event title-change
-   */
-
-  /**
    * Fired to notify the user of
    *
    * @event show-alert
    */
 
-  @state() viewState?: EditViewState;
+  @state() viewState?: ChangeViewState;
 
   // private but used in test
   @state() change?: ParsedChangeInfo;
@@ -86,17 +82,19 @@
   // private but used in test
   @state() latestPatchsetNumber?: RevisionPatchSetNum;
 
-  private readonly restApiService = getAppContext().restApiService;
+  @state() private darkMode = false;
 
-  private readonly storage = getAppContext().storageService;
+  private readonly restApiService = getAppContext().restApiService;
 
   private readonly reporting = getAppContext().reportingService;
 
-  private readonly userModel = getAppContext().userModel;
+  private readonly getStorage = resolve(this, storageServiceToken);
+
+  private readonly getUserModel = resolve(this, userModelToken);
 
   private readonly getChangeModel = resolve(this, changeModelToken);
 
-  private readonly getEditViewModel = resolve(this, editViewModelToken);
+  private readonly getViewModel = resolve(this, changeViewModelToken);
 
   private readonly getNavigation = resolve(this, navigationToken);
 
@@ -112,13 +110,20 @@
     });
     subscribe(
       this,
-      () => this.userModel.editPreferences$,
+      () => this.getChangeModel().change$,
+      x => (this.change = x)
+    );
+    subscribe(
+      this,
+      () => this.getUserModel().editPreferences$,
       editPreferences => (this.editPrefs = editPreferences)
     );
     subscribe(
       this,
-      () => this.getEditViewModel().state$,
+      () => this.getViewModel().state$,
       state => {
+        // TODO: Add a setter for `viewState` instead of relying on the
+        // `viewStateChanged()` call here.
         this.viewState = state;
         this.viewStateChanged();
       }
@@ -128,6 +133,13 @@
       () => this.getChangeModel().latestPatchNumWithEdit$,
       x => (this.latestPatchsetNumber = x)
     );
+    subscribe(
+      this,
+      () => this.getUserModel().preferenceTheme$,
+      theme => {
+        this.darkMode = isDarkTheme(theme);
+      }
+    );
     this.shortcuts.addLocal({key: 's', modifiers: [Modifier.CTRL_KEY]}, () =>
       this.handleSaveShortcut()
     );
@@ -207,7 +219,7 @@
   }
 
   override render() {
-    if (!this.viewState) return;
+    if (this.viewState?.childView !== ChangeChildView.EDIT) return nothing;
     return html` ${this.renderHeader()} ${this.renderEndpoint()} `;
   }
 
@@ -221,7 +233,7 @@
             <span class="separator"></span>
             <gr-editable-label
               labelText="File path"
-              .value=${this.viewState?.path}
+              .value=${this.viewState?.editView?.path}
               placeholder="File path..."
               @changed=${this.handlePathChanged}
             ></gr-editable-label>
@@ -278,7 +290,11 @@
           ></gr-endpoint-param>
           <gr-endpoint-param
             name="lineNum"
-            .value=${this.viewState?.lineNum}
+            .value=${this.viewState?.editView?.lineNum}
+          ></gr-endpoint-param>
+          <gr-endpoint-param
+            name="darkMode"
+            .value=${this.darkMode}
           ></gr-endpoint-param>
           <gr-default-editor
             id="file"
@@ -299,34 +315,18 @@
   }
 
   get storageKey() {
-    return `c${this.viewState?.changeNum}_ps${this.viewState?.patchNum}_${this.viewState?.path}`;
+    return `c${this.viewState?.changeNum}_ps${this.viewState?.patchNum}_${this.viewState?.editView?.path}`;
   }
 
   // private but used in test
   viewStateChanged() {
-    if (!this.viewState) return;
-
-    // NOTE: This may be called before attachment (e.g. while parentElement is
-    // null). Fire title-change in an async so that, if attachment to the DOM
-    // has been queued, the event can bubble up to the handler in gr-app.
-    setTimeout(() => {
-      if (!this.viewState) return;
-      const title = `Editing ${computeTruncatedPath(this.viewState.path)}`;
-      fireTitleChange(this, title);
-    });
+    if (this.viewState?.childView !== ChangeChildView.EDIT) return;
 
     const promises = [];
-    promises.push(this.getChangeDetail());
     promises.push(this.getFileData());
     return Promise.all(promises);
   }
 
-  private async getChangeDetail() {
-    const changeNum = this.viewState?.changeNum;
-    assertIsDefined(changeNum, 'change number');
-    this.change = await this.restApiService.getChangeDetail(changeNum);
-  }
-
   private navigateToChangeIfEdit() {
     if (!this.change) return;
     if (!changeIsMerged(this.change) && !changeIsAbandoned(this.change)) return;
@@ -348,7 +348,7 @@
   // private but used in test
   async handlePathChanged(e: CustomEvent<string>): Promise<void> {
     const changeNum = this.viewState?.changeNum;
-    const currentPath = this.viewState?.path;
+    const currentPath = this.viewState?.editView?.path;
     assertIsDefined(changeNum, 'change number');
     assertIsDefined(currentPath, 'path');
 
@@ -377,12 +377,14 @@
   getFileData() {
     const changeNum = this.viewState?.changeNum;
     const patchNum = this.viewState?.patchNum;
-    const path = this.viewState?.path;
+    const path = this.viewState?.editView?.path;
     assertIsDefined(changeNum, 'change number');
     assertIsDefined(patchNum, 'patchset number');
     assertIsDefined(path, 'path');
 
-    const storedContent = this.storage.getEditableContentItem(this.storageKey);
+    const storedContent = this.getStorage().getEditableContentItem(
+      this.storageKey
+    );
 
     return this.restApiService
       .getFileContent(changeNum, path, patchNum)
@@ -415,13 +417,13 @@
   // private but used in test
   saveEdit() {
     const changeNum = this.viewState?.changeNum;
-    const path = this.viewState?.path;
+    const path = this.viewState?.editView?.path;
     assertIsDefined(changeNum, 'change number');
     assertIsDefined(path, 'path');
 
     this.saving = true;
     this.showAlert(SAVING_MESSAGE);
-    this.storage.eraseEditableContentItem(this.storageKey);
+    this.getStorage().eraseEditableContentItem(this.storageKey);
     if (!this.newContent)
       return Promise.reject(new Error('new content undefined'));
     return this.restApiService
@@ -488,15 +490,7 @@
         )
         .then(() => {
           assertIsDefined(this.change, 'change');
-          // TODO: `forceReload: true` does not seem to work as expected:
-          // The patchset is not updated.
-          // Thus we are also calling `fireReload()` here.
-          // That can probably be cleaned up once the change-view was migrated
-          // to fully relying on the change model.
-          fireReload(this);
-          this.getNavigation().setUrl(
-            createChangeUrl({change: this.change, forceReload: true})
-          );
+          this.getChangeModel().navigateToChangeResetReload();
         });
     });
   };
@@ -508,9 +502,9 @@
         const content = e.detail.value;
         if (content) {
           this.newContent = e.detail.value;
-          this.storage.setEditableContentItem(this.storageKey, content);
+          this.getStorage().setEditableContentItem(this.storageKey, content);
         } else {
-          this.storage.eraseEditableContentItem(this.storageKey);
+          this.getStorage().eraseEditableContentItem(this.storageKey);
         }
       },
       STORAGE_DEBOUNCE_INTERVAL_MS
diff --git a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.ts b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.ts
index 390cfad..c86f02f 100644
--- a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.ts
+++ b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.ts
@@ -13,7 +13,6 @@
   pressKey,
   query,
   stubRestApi,
-  stubStorage,
 } from '../../../test/test-utils';
 import {
   EDIT,
@@ -28,23 +27,23 @@
 import {GrDefaultEditor} from '../gr-default-editor/gr-default-editor';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {fixture, html, assert} from '@open-wc/testing';
-import {EventType} from '../../../types/events';
 import {Modifier} from '../../../utils/dom-util';
 import {testResolver} from '../../../test/common-test-setup';
+import {storageServiceToken} from '../../../services/storage/gr-storage_impl';
+import {StorageService} from '../../../services/storage/gr-storage';
 
 suite('gr-editor-view tests', () => {
   let element: GrEditorView;
 
   let savePathStub: sinon.SinonStub;
   let saveFileStub: sinon.SinonStub;
-  let changeDetailStub: sinon.SinonStub;
   let navigateStub: sinon.SinonStub;
+  let storageService: StorageService;
 
   setup(async () => {
     element = await fixture(html`<gr-editor-view></gr-editor-view>`);
     savePathStub = stubRestApi('renameFileInChangeEdit');
     saveFileStub = stubRestApi('saveChangeEdit');
-    changeDetailStub = stubRestApi('getChangeDetail');
     navigateStub = sinon.stub(element, 'viewEditInChangeView');
     element.viewState = {
       ...createEditViewState(),
@@ -52,6 +51,7 @@
     };
     element.latestPatchsetNumber = 1 as RevisionPatchSetNum;
     await element.updateComplete;
+    storageService = testResolver(storageServiceToken);
   });
 
   test('render', () => {
@@ -68,7 +68,7 @@
                 labeltext="File path"
                 placeholder="File path..."
                 tabindex="0"
-                title="${element.viewState?.path}"
+                title="${element.viewState?.editView?.path}"
               >
               </gr-editable-label>
             </span>
@@ -115,6 +115,7 @@
             <gr-endpoint-param name="prefs"> </gr-endpoint-param>
             <gr-endpoint-param name="fileType"> </gr-endpoint-param>
             <gr-endpoint-param name="lineNum"> </gr-endpoint-param>
+            <gr-endpoint-param name="darkMode"> </gr-endpoint-param>
             <gr-default-editor id="file"> </gr-default-editor>
           </gr-endpoint-decorator>
         </div>
@@ -124,7 +125,6 @@
 
   suite('viewStateChanged', () => {
     test('good view state proceed', async () => {
-      changeDetailStub.returns(Promise.resolve({}));
       const fileStub = sinon.stub(element, 'getFileData').callsFake(() => {
         element.content = 'text';
         element.newContent = 'text';
@@ -137,8 +137,6 @@
 
       await element.updateComplete;
 
-      const changeNum = 42 as NumericChangeId;
-      assert.deepEqual(changeDetailStub.lastCall.args[0], changeNum);
       assert.isTrue(fileStub.called);
 
       return promises?.then(() => {
@@ -177,7 +175,7 @@
   });
 
   test('reacts to content-change event', async () => {
-    const storageStub = stubStorage('setEditableContentItem');
+    const storageStub = sinon.stub(storageService, 'setEditableContentItem');
     element.newContent = 'test';
     await element.updateComplete;
     query<GrEndpointDecorator>(element, '#editorEndpoint')!.dispatchEvent(
@@ -218,7 +216,7 @@
 
     test('file modification and save, !ok response', async () => {
       const saveSpy = sinon.spy(element, 'saveEdit');
-      const eraseStub = stubStorage('eraseEditableContentItem');
+      const eraseStub = sinon.stub(storageService, 'eraseEditableContentItem');
       const alertStub = sinon.stub(element, 'showAlert');
       saveFileStub.returns(Promise.resolve({ok: false}));
       element.newContent = newText;
@@ -354,7 +352,7 @@
       element.newContent = 'initial';
       element.content = 'initial';
       element.type = 'initial';
-      stubStorage('getEditableContentItem').returns(null);
+      sinon.stub(storageService, 'getEditableContentItem').returns(null);
     });
 
     test('res.ok', () => {
@@ -369,7 +367,7 @@
         ...createEditViewState(),
         changeNum: 1 as NumericChangeId,
         patchNum: EDIT,
-        path: 'test/path',
+        editView: {path: 'test/path'},
       };
 
       // Ensure no data is set with a bad response.
@@ -388,7 +386,7 @@
         ...createEditViewState(),
         changeNum: 1 as NumericChangeId,
         patchNum: EDIT,
-        path: 'test/path',
+        editView: {path: 'test/path'},
       };
 
       // Ensure no data is set with a bad response.
@@ -411,7 +409,7 @@
         ...createEditViewState(),
         changeNum: 1 as NumericChangeId,
         patchNum: EDIT,
-        path: 'test/path',
+        editView: {path: 'test/path'},
       };
 
       return element.getFileData().then(() => {
@@ -429,7 +427,7 @@
         ...createEditViewState(),
         changeNum: 1 as NumericChangeId,
         patchNum: EDIT,
-        path: 'test/path',
+        editView: {path: 'test/path'},
       };
 
       return element.getFileData().then(() => {
@@ -442,7 +440,7 @@
 
   test('showAlert', async () => {
     const promise = mockPromise();
-    element.addEventListener(EventType.SHOW_ALERT, e => {
+    element.addEventListener('show-alert', e => {
       assert.deepEqual(e.detail, {message: 'test message', showDismiss: true});
       assert.isTrue(e.bubbles);
       promise.resolve();
@@ -511,7 +509,7 @@
 
   suite('gr-storage caching', () => {
     test('local edit exists', () => {
-      stubStorage('getEditableContentItem').returns({
+      sinon.stub(storageService, 'getEditableContentItem').returns({
         message: 'pending edit',
         updated: 0,
       });
@@ -526,11 +524,11 @@
         ...createEditViewState(),
         changeNum: 1 as NumericChangeId,
         patchNum: 1 as RevisionPatchSetNum,
-        path: 'test',
+        editView: {path: 'test'},
       };
 
       const alertStub = sinon.stub();
-      element.addEventListener(EventType.SHOW_ALERT, alertStub);
+      element.addEventListener('show-alert', alertStub);
 
       return element.getFileData().then(async () => {
         await element.updateComplete;
@@ -543,7 +541,7 @@
     });
 
     test('local edit exists, is same as remote edit', () => {
-      stubStorage('getEditableContentItem').returns({
+      sinon.stub(storageService, 'getEditableContentItem').returns({
         message: 'pending edit',
         updated: 0,
       });
@@ -558,11 +556,11 @@
         ...createEditViewState(),
         changeNum: 1 as NumericChangeId,
         patchNum: 1 as RevisionPatchSetNum,
-        path: 'test',
+        editView: {path: 'test'},
       };
 
       const alertStub = sinon.stub();
-      element.addEventListener(EventType.SHOW_ALERT, alertStub);
+      element.addEventListener('show-alert', alertStub);
 
       return element.getFileData().then(async () => {
         await element.updateComplete;
@@ -579,7 +577,7 @@
         ...createEditViewState(),
         changeNum: 1 as NumericChangeId,
         patchNum: 1 as RevisionPatchSetNum,
-        path: 'test',
+        editView: {path: 'test'},
       };
       assert.equal(element.storageKey, 'c1_ps1_test');
     });
diff --git a/polygerrit-ui/app/elements/gr-app-element.ts b/polygerrit-ui/app/elements/gr-app-element.ts
index 58e445c..f81640d 100644
--- a/polygerrit-ui/app/elements/gr-app-element.ts
+++ b/polygerrit-ui/app/elements/gr-app-element.ts
@@ -23,21 +23,20 @@
 import './plugins/gr-endpoint-decorator/gr-endpoint-decorator';
 import './plugins/gr-endpoint-param/gr-endpoint-param';
 import './plugins/gr-endpoint-slot/gr-endpoint-slot';
-import './plugins/gr-external-style/gr-external-style';
 import './plugins/gr-plugin-host/gr-plugin-host';
 import './settings/gr-cla-view/gr-cla-view';
 import './settings/gr-registration-dialog/gr-registration-dialog';
 import './settings/gr-settings-view/gr-settings-view';
-import {navigationToken} from './core/gr-navigation/gr-navigation';
+import './core/gr-notifications-prompt/gr-notifications-prompt';
 import {loginUrl} from '../utils/url-util';
+import {navigationToken} from './core/gr-navigation/gr-navigation';
 import {getAppContext} from '../services/app-context';
 import {routerToken} from './core/gr-router/gr-router';
-import {AccountDetailInfo, ServerInfo} from '../types/common';
+import {AccountDetailInfo, NumericChangeId, ServerInfo} from '../types/common';
 import {
   constructServerErrorMsg,
   GrErrorManager,
 } from './core/gr-error-manager/gr-error-manager';
-import {GrOverlay} from './shared/gr-overlay/gr-overlay';
 import {GrRegistrationDialog} from './settings/gr-registration-dialog/gr-registration-dialog';
 import {
   AppElementJustRegisteredParams,
@@ -48,17 +47,15 @@
 import {GrSettingsView} from './settings/gr-settings-view/gr-settings-view';
 import {
   DialogChangeEventDetail,
-  EventType,
   PageErrorEventDetail,
   RpcLogEvent,
   TitleChangeEventDetail,
 } from '../types/events';
-import {GerritView} from '../services/router/router-model';
+import {GerritView, routerModelToken} from '../services/router/router-model';
 import {LifeCycle} from '../constants/reporting';
 import {fireIronAnnounce} from '../utils/event-util';
 import {resolve} from '../models/dependency';
 import {browserModelToken} from '../models/browser/browser-model';
-import {configModelToken} from '../models/config/config-model';
 import {sharedStyles} from '../styles/shared-styles';
 import {LitElement, PropertyValues, html, css, nothing} from 'lit';
 import {customElement, property, query, state} from 'lit/decorators.js';
@@ -71,9 +68,14 @@
 import {AppTheme} from '../constants/constants';
 import {subscribe} from './lit/subscription-controller';
 import {PluginViewState} from '../models/views/plugin';
-import {createSearchUrl, SearchViewState} from '../models/views/search';
+import {createSearchUrl} from '../models/views/search';
 import {createSettingsUrl} from '../models/views/settings';
 import {createDashboardUrl} from '../models/views/dashboard';
+import {userModelToken} from '../models/user/user-model';
+import {modalStyles} from '../styles/gr-modal-styles';
+import {AdminChildView, createAdminUrl} from '../models/views/admin';
+import {ChangeChildView, changeViewModelToken} from '../models/views/change';
+import {configModelToken} from '../models/config/config-model';
 
 interface ErrorInfo {
   text: string;
@@ -81,6 +83,12 @@
   moreInfo?: string;
 }
 
+/**
+ * This is simple hacky way for allowing certain plugin screens to hide the
+ * header and the footer of the Gerrit page.
+ */
+const WHITE_LISTED_FULL_SCREEN_PLUGINS = ['git_source_editor/screen/edit'];
+
 // TODO(TS): implement AppElement interface from gr-app-types.ts
 @customElement('gr-app-element')
 export class GrAppElement extends LitElement {
@@ -96,11 +104,11 @@
 
   @query('#mainHeader') mainHeader?: GrMainHeader;
 
-  @query('#registrationOverlay') registrationOverlay?: GrOverlay;
+  @query('#registrationModal') registrationModal?: HTMLDialogElement;
 
   @query('#registrationDialog') registrationDialog?: GrRegistrationDialog;
 
-  @query('#keyboardShortcuts') keyboardShortcuts?: GrOverlay;
+  @query('#keyboardShortcuts') keyboardShortcuts?: HTMLDialogElement;
 
   @query('gr-settings-view') settingsView?: GrSettingsView;
 
@@ -109,12 +117,16 @@
 
   @state() private account?: AccountDetailInfo;
 
-  @state() private serverConfig?: ServerInfo;
-
   @state() private version?: string;
 
   @state() private view?: GerritView;
 
+  // TODO: Introduce a wrapper element for CHANGE, DIFF, EDIT view.
+  @state() private childView?: ChangeChildView;
+
+  // Used as a key for caching the CHANGE, DIFF, EDIT view.
+  @state() private changeNum?: NumericChangeId;
+
   @state() private lastError?: ErrorInfo;
 
   // private but used in test
@@ -138,15 +150,10 @@
   // (e.g. shortcut dialog) is open
   @state() private mainAriaHidden = false;
 
-  // Triggers dom-if unsetting/setting restamp behaviour in lit
-  @state() private invalidateChangeViewCache = false;
-
-  // Triggers dom-if unsetting/setting restamp behaviour in lit
-  @state() private invalidateDiffViewCache = false;
-
   @state() private theme = AppTheme.AUTO;
 
-  @state() private themeEndpoint = 'app-theme-light';
+  @state()
+  serverConfig?: ServerInfo;
 
   readonly getRouter = resolve(this, routerToken);
 
@@ -160,34 +167,28 @@
 
   private readonly shortcuts = new ShortcutController(this);
 
-  private readonly userModel = getAppContext().userModel;
+  private readonly getUserModel = resolve(this, userModelToken);
 
-  private readonly routerModel = getAppContext().routerModel;
+  private readonly getRouterModel = resolve(this, routerModelToken);
+
+  private readonly getChangeViewModel = resolve(this, changeViewModelToken);
 
   private readonly getConfigModel = resolve(this, configModelToken);
 
   constructor() {
     super();
 
-    document.addEventListener(EventType.PAGE_ERROR, e => {
+    document.addEventListener('page-error', e => {
       this.handlePageError(e);
     });
-    this.addEventListener(EventType.TITLE_CHANGE, e => {
+    document.addEventListener('title-change', e => {
       this.handleTitleChange(e);
     });
-    this.addEventListener(EventType.DIALOG_CHANGE, e => {
+    this.addEventListener('dialog-change', e => {
       this.handleDialogChange(e as CustomEvent<DialogChangeEventDetail>);
     });
-    document.addEventListener(EventType.LOCATION_CHANGE, () =>
-      this.requestUpdate()
-    );
-    this.addEventListener(EventType.RECREATE_CHANGE_VIEW, () =>
-      this.handleRecreateView()
-    );
-    this.addEventListener(EventType.RECREATE_DIFF_VIEW, () =>
-      this.handleRecreateView()
-    );
-    document.addEventListener(EventType.GR_RPC_LOG, e => this.handleRpcLog(e));
+    document.addEventListener('location-change', () => this.requestUpdate());
+    document.addEventListener('gr-rpc-log', e => this.handleRpcLog(e));
     this.shortcuts.addAbstract(Shortcut.OPEN_SHORTCUT_HELP_DIALOG, () =>
       this.showKeyboardShortcuts()
     );
@@ -208,6 +209,16 @@
         createSearchUrl({query: 'is:watched is:open'})
       )
     );
+    this.shortcuts.addAbstract(Shortcut.GO_TO_REPOS, () =>
+      this.getNavigation().setUrl(
+        createAdminUrl({adminView: AdminChildView.REPOS})
+      )
+    );
+    this.shortcuts.addAbstract(Shortcut.GO_TO_GROUPS, () =>
+      this.getNavigation().setUrl(
+        createAdminUrl({adminView: AdminChildView.GROUPS})
+      )
+    );
 
     subscribe(
       this,
@@ -219,7 +230,7 @@
 
     subscribe(
       this,
-      () => this.userModel.preferenceTheme$,
+      () => this.getUserModel().preferenceTheme$,
       theme => {
         this.theme = theme;
         this.applyTheme();
@@ -227,12 +238,24 @@
     );
     subscribe(
       this,
-      () => this.routerModel.routerView$,
+      () => this.getRouterModel().routerView$,
       view => {
         this.view = view;
         if (view) this.errorView?.classList.remove('show');
       }
     );
+    subscribe(
+      this,
+      () => this.getChangeViewModel().childView$,
+      childView => (this.childView = childView)
+    );
+    subscribe(
+      this,
+      () => this.getChangeViewModel().changeNum$,
+      changeNum => {
+        this.changeNum = changeNum;
+      }
+    );
 
     prefersDarkColorScheme().addEventListener('change', () => {
       if (this.theme === AppTheme.AUTO) {
@@ -270,6 +293,7 @@
   static override get styles() {
     return [
       sharedStyles,
+      modalStyles,
       css`
         :host {
           background-color: var(--background-color-tertiary);
@@ -353,21 +377,22 @@
     return html`
       <gr-css-mixins></gr-css-mixins>
       <gr-endpoint-decorator name="banner"></gr-endpoint-decorator>
-      <gr-main-header
-        id="mainHeader"
-        .searchQuery=${(this.params as SearchViewState)?.query}
-        @mobile-search=${this.mobileSearchToggle}
-        @show-keyboard-shortcuts=${this.showKeyboardShortcuts}
-        .mobileSearchHidden=${!this.mobileSearch}
-        .loginUrl=${loginUrl(this.serverConfig?.auth)}
-        .loginText=${this.serverConfig?.auth.login_text ?? 'Sign in'}
-        ?aria-hidden=${this.footerHeaderAriaHidden}
-      >
-      </gr-main-header>
+      ${this.renderHeader()}
       <main ?aria-hidden=${this.mainAriaHidden}>
         ${this.renderMobileSearch()} ${this.renderChangeListView()}
-        ${this.renderDashboardView()} ${this.renderChangeView()}
-        ${this.renderEditorView()} ${this.renderDiffView()}
+        ${this.renderDashboardView()}
+        ${
+          // `keyed(this.changeNum, ...)` makes sure that these views are not
+          // re-used across changes, which is a precaution, because we have run
+          // into issue with that. That could be re-considered at some point.
+          keyed(
+            this.changeNum,
+            html`
+              ${this.renderChangeView()} ${this.renderEditorView()}
+              ${this.renderDiffView()}
+            `
+          )
+        }
         ${this.renderSettingsView()} ${this.renderAdminView()}
         ${this.renderPluginScreen()} ${this.renderCLAView()}
         ${this.renderDocumentationSearch()}
@@ -377,6 +402,38 @@
           <div class="errorMoreInfo">${this.lastError?.moreInfo}</div>
         </div>
       </main>
+      ${this.renderFooter()} ${this.renderKeyboardShortcutsDialog()}
+      ${this.renderRegistrationDialog()}
+      <gr-notifications-prompt></gr-notifications-prompt>
+      <gr-endpoint-decorator name="plugin-overlay"></gr-endpoint-decorator>
+      <gr-error-manager
+        id="errorManager"
+        .loginUrl=${loginUrl(this.serverConfig?.auth)}
+        .loginText=${this.serverConfig?.auth.login_text ?? 'Sign in'}
+      ></gr-error-manager>
+      <gr-plugin-host id="plugins"></gr-plugin-host>
+    `;
+  }
+
+  private renderHeader() {
+    if (this.hideHeaderAndFooter()) return nothing;
+    return html`
+      <gr-main-header
+        id="mainHeader"
+        @mobile-search=${this.mobileSearchToggle}
+        @show-keyboard-shortcuts=${this.showKeyboardShortcuts}
+        .mobileSearchHidden=${!this.mobileSearch}
+        .loginUrl=${loginUrl(this.serverConfig?.auth)}
+        .loginText=${this.serverConfig?.auth.login_text ?? 'Sign in'}
+        ?aria-hidden=${this.footerHeaderAriaHidden}
+      >
+      </gr-main-header>
+    `;
+  }
+
+  private renderFooter() {
+    if (this.hideHeaderAndFooter()) return nothing;
+    return html`
       <footer ?aria-hidden=${this.footerHeaderAriaHidden}>
         <div>
           Powered by
@@ -394,35 +451,19 @@
           <gr-endpoint-decorator name="footer-right"></gr-endpoint-decorator>
         </div>
       </footer>
-      ${this.renderKeyboardShortcutsDialog()} ${this.renderRegistrationDialog()}
-      <gr-endpoint-decorator name="plugin-overlay"></gr-endpoint-decorator>
-      <gr-error-manager
-        id="errorManager"
-        .loginUrl=${loginUrl(this.serverConfig?.auth)}
-        .loginText=${this.serverConfig?.auth.login_text ?? 'Sign in'}
-      ></gr-error-manager>
-      <gr-plugin-host id="plugins"></gr-plugin-host>
-      <gr-external-style
-        id="externalStyleForAll"
-        name="app-theme"
-      ></gr-external-style>
-      <gr-external-style
-        id="externalStyleForTheme"
-        name=${this.themeEndpoint}
-      ></gr-external-style>
     `;
   }
 
+  private hideHeaderAndFooter() {
+    return (
+      this.view === GerritView.PLUGIN_SCREEN &&
+      WHITE_LISTED_FULL_SCREEN_PLUGINS.includes(this.computePluginScreenName())
+    );
+  }
+
   private renderMobileSearch() {
     if (!this.mobileSearch) return nothing;
-    return html`
-      <gr-smart-search
-        id="search"
-        label="Search for changes"
-        .searchQuery=${(this.params as SearchViewState)?.query}
-      >
-      </gr-smart-search>
-    `;
+    return html`<gr-smart-search id="search"></gr-smart-search>`;
   }
 
   private renderChangeListView() {
@@ -442,39 +483,51 @@
   }
 
   private renderChangeView() {
-    if (this.invalidateChangeViewCache) {
-      this.updateComplete.then(() => (this.invalidateChangeViewCache = false));
-      return nothing;
-    }
+    // The `cache()` is required for re-using the change view when switching
+    // back and forth between change, diff and editor views.
     return cache(
-      this.view === GerritView.CHANGE ? this.changeViewTemplate() : nothing
+      this.isChangeView()
+        ? html`<gr-change-view
+            .backPage=${this.lastSearchPage}
+          ></gr-change-view>`
+        : nothing
     );
   }
 
-  // Template as not to create duplicates, for renderChangeView() only.
-  private changeViewTemplate() {
-    return html`
-      <gr-change-view .backPage=${this.lastSearchPage}></gr-change-view>
-    `;
+  private isChangeView() {
+    return (
+      this.view === GerritView.CHANGE &&
+      this.childView === ChangeChildView.OVERVIEW
+    );
   }
 
   private renderEditorView() {
-    if (this.view !== GerritView.EDIT) return nothing;
-    return html`<gr-editor-view></gr-editor-view>`;
+    // For some reason caching the editor view caused an issue (b/269308770).
+    // We did not bother to root cause that issue, but instead let's forgo
+    // caching of the editor view. It does not help much anyway.
+    return this.isEditorView()
+      ? html`<gr-editor-view></gr-editor-view>`
+      : nothing;
   }
 
-  private renderDiffView() {
-    if (this.invalidateDiffViewCache) {
-      this.updateComplete.then(() => (this.invalidateDiffViewCache = false));
-      return nothing;
-    }
-    return cache(
-      this.view === GerritView.DIFF ? this.diffViewTemplate() : nothing
+  private isEditorView() {
+    return (
+      this.view === GerritView.CHANGE && this.childView === ChangeChildView.EDIT
     );
   }
 
-  private diffViewTemplate() {
-    return html`<gr-diff-view></gr-diff-view>`;
+  private renderDiffView() {
+    // The `cache()` is required for re-using the diff view when switching
+    // back and forth between change, diff and editor views.
+    return cache(
+      this.isDiffView() ? html`<gr-diff-view></gr-diff-view>` : nothing
+    );
+  }
+
+  private isDiffView() {
+    return (
+      this.view === GerritView.CHANGE && this.childView === ChangeChildView.DIFF
+    );
   }
 
   private renderSettingsView() {
@@ -527,22 +580,22 @@
   private renderKeyboardShortcutsDialog() {
     if (!this.loadKeyboardShortcutsDialog) return nothing;
     return html`
-      <gr-overlay
+      <dialog
         id="keyboardShortcuts"
-        with-backdrop=""
-        @iron-overlay-canceled=${this.onOverlayCanceled}
+        tabindex="-1"
+        @close=${this.onModalCanceled}
       >
         <gr-keyboard-shortcuts-dialog
           @close=${this.handleKeyboardShortcutDialogClose}
         ></gr-keyboard-shortcuts-dialog>
-      </gr-overlay>
+      </dialog>
     `;
   }
 
   private renderRegistrationDialog() {
     if (!this.loadRegistrationDialog) return nothing;
     return html`
-      <gr-overlay id="registrationOverlay" with-backdrop="">
+      <dialog id="registrationModal" tabindex="-1">
         <gr-registration-dialog
           id="registrationDialog"
           .settingsUrl=${this.settingsUrl}
@@ -550,7 +603,7 @@
           @close=${this.handleRegistrationDialogClose}
         >
         </gr-registration-dialog>
-      </gr-overlay>
+      </dialog>
     `;
   }
 
@@ -577,15 +630,6 @@
         (this.account && this.account._account_id) || null;
   }
 
-  /**
-   * Throws away the view and re-creates it. The view itself fires an event, if
-   * it wants to be re-created.
-   */
-  private handleRecreateView() {
-    this.invalidateChangeViewCache = true;
-    this.invalidateDiffViewCache = true;
-  }
-
   private async viewChanged() {
     if (
       this.params &&
@@ -594,12 +638,10 @@
     ) {
       this.loadRegistrationDialog = true;
       await this.updateComplete;
-      assertIsDefined(this.registrationOverlay, 'registrationOverlay');
+      assertIsDefined(this.registrationModal, 'registrationModal');
       assertIsDefined(this.registrationDialog, 'registrationDialog');
-      await this.registrationOverlay.open();
-      await this.registrationDialog.loadData().then(() => {
-        this.registrationOverlay!.refit();
-      });
+      this.registrationModal.showModal();
+      await this.registrationDialog.loadData();
     }
     // To fix bug announce read after each new view, we reset announce with
     // empty space
@@ -610,11 +652,12 @@
     const showDarkTheme = isDarkTheme(this.theme);
     document.documentElement.classList.toggle('darkTheme', showDarkTheme);
     document.documentElement.classList.toggle('lightTheme', !showDarkTheme);
+    // TODO: Remove this code for adding/removing dark theme style. We should
+    // be able to just always add them once we have changed its css selector
+    // from `html` to `html.darkTheme`.
     if (showDarkTheme) {
-      this.themeEndpoint = 'app-theme-dark';
       applyDarkTheme();
     } else {
-      this.themeEndpoint = 'app-theme-light';
       removeDarkTheme();
     }
   }
@@ -677,13 +720,13 @@
     await this.updateComplete;
     assertIsDefined(this.keyboardShortcuts, 'keyboardShortcuts');
 
-    if (this.keyboardShortcuts.opened) {
-      this.keyboardShortcuts.cancel();
+    if (this.keyboardShortcuts.hasAttribute('open')) {
+      this.keyboardShortcuts.close();
       return;
     }
     this.footerHeaderAriaHidden = true;
     this.mainAriaHidden = true;
-    await this.keyboardShortcuts.open();
+    this.keyboardShortcuts.showModal();
   }
 
   private handleKeyboardShortcutDialogClose() {
@@ -691,7 +734,7 @@
     this.keyboardShortcuts.close();
   }
 
-  onOverlayCanceled() {
+  onModalCanceled() {
     this.footerHeaderAriaHidden = false;
     this.mainAriaHidden = false;
   }
@@ -705,8 +748,8 @@
     // The registration dialog is visible only if this.params is
     // instanceof AppElementJustRegisteredParams
     (this.params as AppElementJustRegisteredParams).justRegistered = false;
-    assertIsDefined(this.registrationOverlay, 'registrationOverlay');
-    this.registrationOverlay.close();
+    assertIsDefined(this.registrationModal, 'registrationModal');
+    this.registrationModal.close();
   }
 
   private computePluginScreenName() {
diff --git a/polygerrit-ui/app/elements/gr-app-global-var-init.ts b/polygerrit-ui/app/elements/gr-app-global-var-init.ts
index 4132c0e..d6a14ed 100644
--- a/polygerrit-ui/app/elements/gr-app-global-var-init.ts
+++ b/polygerrit-ui/app/elements/gr-app-global-var-init.ts
@@ -13,11 +13,34 @@
 
 import {GrAnnotation} from '../embed/diff/gr-diff-highlight/gr-annotation';
 import {GrPluginActionContext} from './shared/gr-js-api-interface/gr-plugin-action-context';
-import {initGerritPluginApi} from './shared/gr-js-api-interface/gr-gerrit';
-import {AppContext} from '../services/app-context';
+import {AppContext, injectAppContext} from '../services/app-context';
+import {PluginLoader} from './shared/gr-js-api-interface/gr-plugin-loader';
+import {
+  initVisibilityReporter,
+  initPerformanceReporter,
+  initErrorReporter,
+  initWebVitals,
+  initClickReporter,
+} from '../services/gr-reporting/gr-reporting_impl';
+import {Finalizable} from '../services/registry';
 
-export function initGlobalVariables(appContext: AppContext) {
+export function initGlobalVariables(
+  appContext: AppContext & Finalizable,
+  initializeReporting: boolean
+) {
+  injectAppContext(appContext);
+  if (initializeReporting) {
+    const reportingService = appContext.reportingService;
+    initVisibilityReporter(reportingService);
+    initPerformanceReporter(reportingService);
+    initWebVitals(reportingService);
+    initErrorReporter(reportingService);
+    initClickReporter(reportingService);
+  }
   window.GrAnnotation = GrAnnotation;
   window.GrPluginActionContext = GrPluginActionContext;
-  initGerritPluginApi(appContext);
+}
+
+export function initGerrit(pluginLoader: PluginLoader) {
+  window.Gerrit = pluginLoader;
 }
diff --git a/polygerrit-ui/app/elements/gr-app-types.ts b/polygerrit-ui/app/elements/gr-app-types.ts
index 3008236..68e7309 100644
--- a/polygerrit-ui/app/elements/gr-app-types.ts
+++ b/polygerrit-ui/app/elements/gr-app-types.ts
@@ -13,8 +13,6 @@
 import {SearchViewState} from '../models/views/search';
 import {DashboardViewState} from '../models/views/dashboard';
 import {ChangeViewState} from '../models/views/change';
-import {DiffViewState} from '../models/views/diff';
-import {EditViewState} from '../models/views/edit';
 
 export interface AppElement extends HTMLElement {
   params: AppElementParams;
@@ -30,6 +28,7 @@
   justRegistered: boolean;
 }
 
+// TODO: Get rid of this type. <gr-app-element> needs to be refactored for that.
 export type AppElementParams =
   | DashboardViewState
   | GroupViewState
@@ -41,8 +40,6 @@
   | SearchViewState
   | SettingsViewState
   | AgreementViewState
-  | DiffViewState
-  | EditViewState
   | AppElementJustRegisteredParams;
 
 export function isAppElementJustRegisteredParams(
diff --git a/polygerrit-ui/app/elements/gr-app.ts b/polygerrit-ui/app/elements/gr-app.ts
index 0cec8dd..40869a9 100644
--- a/polygerrit-ui/app/elements/gr-app.ts
+++ b/polygerrit-ui/app/elements/gr-app.ts
@@ -5,6 +5,7 @@
  */
 import {safeTypesBridge} from '../utils/safe-types-util';
 import './font-roboto-local-loader';
+import '../types/globals';
 // Sets up global Polymer variable, because plugins requires it.
 import '../scripts/bundled-polymer';
 
@@ -21,34 +22,32 @@
 setCancelSyntheticClickEvents(false);
 setPassiveTouchGestures(true);
 
-import {initGlobalVariables} from './gr-app-global-var-init';
+import {initGerrit, initGlobalVariables} from './gr-app-global-var-init';
 import './gr-app-element';
 import {Finalizable} from '../services/registry';
-import {provide} from '../models/dependency';
+import {
+  DependencyError,
+  DependencyToken,
+  provide,
+  Provider,
+} from '../models/dependency';
 import {installPolymerResin} from '../scripts/polymer-resin-install';
 
 import {
   createAppContext,
   createAppDependencies,
+  Creator,
 } from '../services/app-context-init';
-import {
-  initVisibilityReporter,
-  initPerformanceReporter,
-  initErrorReporter,
-  initWebVitals,
-} from '../services/gr-reporting/gr-reporting_impl';
-import {injectAppContext} from '../services/app-context';
 import {html, LitElement} from 'lit';
 import {customElement} from 'lit/decorators.js';
-import {ServiceWorkerInstaller} from '../services/service-worker-installer';
+import {
+  ServiceWorkerInstaller,
+  serviceWorkerInstallerToken,
+} from '../services/service-worker-installer';
+import {pluginLoaderToken} from './shared/gr-js-api-interface/gr-plugin-loader';
+import {getAppContext} from '../services/app-context';
 
-const appContext = createAppContext();
-injectAppContext(appContext);
-const reportingService = appContext.reportingService;
-initVisibilityReporter(reportingService);
-initPerformanceReporter(reportingService);
-initWebVitals(reportingService);
-initErrorReporter(reportingService);
+initGlobalVariables(createAppContext(), true);
 
 installPolymerResin(safeTypesBridge);
 
@@ -60,16 +59,47 @@
 
   override connectedCallback() {
     super.connectedCallback();
-    const dependencies = createAppDependencies(appContext);
-    for (const [token, service] of dependencies) {
-      this.finalizables.push(service);
-      provide(this, token, () => service);
+    const dependencies = new Map<DependencyToken<unknown>, Provider<unknown>>();
+
+    const injectDependency = <T>(
+      token: DependencyToken<T>,
+      creator: Creator<T>
+    ) => {
+      let service: (T & Finalizable) | undefined = undefined;
+      dependencies.set(token, () => {
+        if (service) return service;
+        service = creator();
+        this.finalizables.push(service);
+        return service;
+      });
+    };
+
+    const resolver = <T>(token: DependencyToken<T>): T => {
+      const provider = dependencies.get(token);
+      if (provider) {
+        return provider() as T;
+      } else {
+        throw new DependencyError(
+          token,
+          'Forgot to set up dependency for gr-app'
+        );
+      }
+    };
+
+    for (const [token, creator] of createAppDependencies(
+      getAppContext(),
+      resolver
+    )) {
+      injectDependency(token, creator);
     }
+    for (const [token, provider] of dependencies) {
+      provide(this, token, provider);
+    }
+
+    initGerrit(resolver(pluginLoaderToken));
+
     if (!this.serviceWorkerInstaller) {
-      this.serviceWorkerInstaller = new ServiceWorkerInstaller(
-        appContext.flagsService,
-        appContext.userModel
-      );
+      this.serviceWorkerInstaller = resolver(serviceWorkerInstallerToken);
     }
   }
 
@@ -91,5 +121,3 @@
     'gr-app': GrApp;
   }
 }
-
-initGlobalVariables(appContext);
diff --git a/polygerrit-ui/app/elements/gr-app_test.ts b/polygerrit-ui/app/elements/gr-app_test.ts
index 34ca39c..15cfda6 100644
--- a/polygerrit-ui/app/elements/gr-app_test.ts
+++ b/polygerrit-ui/app/elements/gr-app_test.ts
@@ -16,8 +16,11 @@
   createServerInfo,
 } from '../test/test-data-generators';
 import {GrAppElement} from './gr-app-element';
-import {GrRouter} from './core/gr-router/gr-router';
+import {GrRouter, routerToken} from './core/gr-router/gr-router';
+import {resolve} from '../models/dependency';
+import {removeRequestDependencyListener} from '../test/common-test-setup';
 import {ReactiveElement} from 'lit';
+
 suite('gr-app callback tests', () => {
   const requestUpdateStub = sinon.stub(
     ReactiveElement.prototype,
@@ -30,6 +33,7 @@
   setup(async () => {
     await fixture<GrApp>(html`<gr-app id="app"></gr-app>`);
   });
+
   test("requestUpdate in reactive-element is called after dispatching 'location-change' event in gr-router", () => {
     dispatchLocationChangeEventSpy();
     assert.isTrue(requestUpdateStub.calledOnce);
@@ -52,9 +56,14 @@
     stubRestApi('getPreferences').returns(Promise.resolve(createPreferences()));
     stubRestApi('getVersion').returns(Promise.resolve('42'));
     stubRestApi('probePath').returns(Promise.resolve(false));
-
     grApp = await fixture<GrApp>(html`<gr-app id="app"></gr-app>`);
-    await grApp.updateComplete;
+  });
+
+  test('models resolve', () => {
+    // Verify that models resolve on grApp without falling back
+    // to the ones instantiated by the test-setup.
+    removeRequestDependencyListener();
+    assert.ok(resolve(grApp, routerToken)());
   });
 
   test('reporting', () => {
diff --git a/polygerrit-ui/app/elements/gr-css-mixins.ts b/polygerrit-ui/app/elements/gr-css-mixins.ts
index 733b386..523a056 100644
--- a/polygerrit-ui/app/elements/gr-css-mixins.ts
+++ b/polygerrit-ui/app/elements/gr-css-mixins.ts
@@ -77,6 +77,10 @@
           --paper-listbox: {
             padding: 0;
           };
+          --iron-autogrow-textarea: {
+            box-sizing: border-box;
+            padding: var(--spacing-s);
+          };
         }
       </style>
     `;
diff --git a/polygerrit-ui/app/elements/integration_test.ts b/polygerrit-ui/app/elements/integration_test.ts
new file mode 100644
index 0000000..a6e02be
--- /dev/null
+++ b/polygerrit-ui/app/elements/integration_test.ts
@@ -0,0 +1,74 @@
+/**
+ * @license
+ * Copyright 2023 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../test/common-test-setup';
+import './gr-app-element';
+import {testResolver} from '../test/common-test-setup';
+import {fixture, html, assert} from '@open-wc/testing';
+import {GrRouter, routerToken} from './core/gr-router/gr-router';
+import {
+  queryAndAssert,
+  queryAll,
+  stubRestApi,
+  waitQueryAndAssert,
+} from '../test/test-utils';
+import {GrAppElement} from './gr-app-element';
+import {LitElement} from 'lit';
+import {createSearchUrl} from '../models/views/search';
+import {createChange} from '../test/test-data-generators';
+import {NumericChangeId} from '../api/rest-api';
+import {createSettingsUrl} from '../models/views/settings';
+
+suite('integration tests', () => {
+  let appElement: GrAppElement;
+  let router: GrRouter;
+
+  const assertView = async function <T extends LitElement>(tagName: string) {
+    await appElement.updateComplete;
+    const view = await waitQueryAndAssert<T>(appElement, tagName);
+    assert.isOk(view);
+    return view;
+  };
+
+  const assertItems = function (el: HTMLElement) {
+    const list = queryAndAssert(el, 'gr-change-list');
+    const section = queryAndAssert(list, 'gr-change-list-section');
+    return queryAll(section, 'gr-change-list-item');
+  };
+
+  setup(async () => {
+    appElement = await fixture<GrAppElement>(
+      html`<gr-app-element id="app-element"></gr-app-element>`
+    );
+    router = testResolver(routerToken);
+    router._testOnly_startRouter();
+    await appElement.updateComplete;
+  });
+
+  teardown(async () => {
+    router.finalize();
+  });
+
+  test('navigate from search view page to settings page and back', async () => {
+    stubRestApi('getChanges').returns(
+      Promise.resolve([
+        createChange({_number: 1 as NumericChangeId}),
+        createChange({_number: 2 as NumericChangeId}),
+        createChange({_number: 3 as NumericChangeId}),
+      ])
+    );
+
+    router.setUrl(createSearchUrl({query: 'asdf'}));
+    let view = await assertView('gr-change-list-view');
+    assert.equal(assertItems(view).length, 3);
+
+    router.setUrl(createSettingsUrl());
+    await assertView('gr-settings-view');
+
+    window.history.back();
+    view = await assertView('gr-change-list-view');
+    assert.equal(assertItems(view).length, 3);
+  });
+});
diff --git a/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api.ts b/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api.ts
index da0035e..033df49 100644
--- a/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api.ts
@@ -5,7 +5,7 @@
  */
 import {EventType, PluginApi} from '../../../api/plugin';
 import {AdminPluginApi, MenuLink} from '../../../api/admin';
-import {getAppContext} from '../../../services/app-context';
+import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
 
 /**
  * GrAdminApi class.
@@ -16,9 +16,10 @@
   // TODO(TS): maybe define as enum if its a limited set
   private menuLinks: MenuLink[] = [];
 
-  private readonly reporting = getAppContext().reportingService;
-
-  constructor(private readonly plugin: PluginApi) {
+  constructor(
+    private readonly reporting: ReportingService,
+    private readonly plugin: PluginApi
+  ) {
     this.reporting.trackApi(this.plugin, 'admin', 'constructor');
     this.plugin.on(EventType.ADMIN_MENU_LINKS, this);
   }
diff --git a/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api_test.ts b/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api_test.ts
index da7aa9e..0d041d4 100644
--- a/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api_test.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api_test.ts
@@ -7,8 +7,9 @@
 import {AdminPluginApi} from '../../../api/admin';
 import {PluginApi} from '../../../api/plugin';
 import '../../../test/common-test-setup';
+import {testResolver} from '../../../test/common-test-setup';
 import '../../shared/gr-js-api-interface/gr-js-api-interface';
-import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
+import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 
 suite('gr-admin-api tests', () => {
   let adminApi: AdminPluginApi;
@@ -22,7 +23,7 @@
       '0.1',
       'http://test.com/plugins/testplugin/static/test.js'
     );
-    getPluginLoader().loadPlugins([]);
+    testResolver(pluginLoaderToken).loadPlugins([]);
     adminApi = plugin.admin();
   });
 
diff --git a/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper.ts b/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper.ts
index ab71255..b34e1c3 100644
--- a/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper.ts
@@ -5,17 +5,20 @@
  */
 import {AttributeHelperPluginApi} from '../../../api/attribute-helper';
 import {PluginApi} from '../../../api/plugin';
-import {getAppContext} from '../../../services/app-context';
+import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
+import {ValueChangedEvent} from '../../../types/events';
 
 export class GrAttributeHelper implements AttributeHelperPluginApi {
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
   private readonly _promises = new Map<string, Promise<any>>();
 
-  private readonly reporting = getAppContext().reportingService;
-
   // TODO(TS): Change any to something more like HTMLElement.
-  // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  constructor(readonly plugin: PluginApi, public element: any) {
+  constructor(
+    private readonly reporting: ReportingService,
+    readonly plugin: PluginApi,
+    // eslint-disable-next-line @typescript-eslint/no-explicit-any
+    public element: any
+  ) {
     this.reporting.trackApi(this.plugin, 'attribute', 'constructor');
   }
 
@@ -49,7 +52,7 @@
   bind(name: string, callback: (value: any) => void) {
     this.reporting.trackApi(this.plugin, 'attribute', 'bind');
     const attributeChangedEventName = this._getChangedEventName(name);
-    const changedHandler = (e: CustomEvent) =>
+    const changedHandler = (e: ValueChangedEvent) =>
       this._reportValue(callback, e.detail.value);
     const unbind = () =>
       this.element.removeEventListener(
diff --git a/polygerrit-ui/app/elements/plugins/gr-checks-api/gr-checks-api.ts b/polygerrit-ui/app/elements/plugins/gr-checks-api/gr-checks-api.ts
index 4d59dc9..51cddbe 100644
--- a/polygerrit-ui/app/elements/plugins/gr-checks-api/gr-checks-api.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-checks-api/gr-checks-api.ts
@@ -11,7 +11,8 @@
   CheckResult,
   CheckRun,
 } from '../../../api/checks';
-import {getAppContext} from '../../../services/app-context';
+import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
+import {PluginsModel} from '../../../models/plugins/plugins-model';
 
 const DEFAULT_CONFIG: ChecksApiConfig = {
   fetchPollingIntervalSeconds: 60,
@@ -32,11 +33,11 @@
 export class GrChecksApi implements ChecksPluginApi {
   private state = State.NOT_REGISTERED;
 
-  private readonly reporting = getAppContext().reportingService;
-
-  private readonly pluginsModel = getAppContext().pluginsModel;
-
-  constructor(readonly plugin: PluginApi) {
+  constructor(
+    private readonly reporting: ReportingService,
+    private readonly pluginsModel: PluginsModel,
+    readonly plugin: PluginApi
+  ) {
     this.reporting.trackApi(this.plugin, 'checks', 'constructor');
   }
 
diff --git a/polygerrit-ui/app/elements/plugins/gr-checks-api/gr-checks-api_test.ts b/polygerrit-ui/app/elements/plugins/gr-checks-api/gr-checks-api_test.ts
index 011fbbf..54197ea 100644
--- a/polygerrit-ui/app/elements/plugins/gr-checks-api/gr-checks-api_test.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-checks-api/gr-checks-api_test.ts
@@ -4,10 +4,11 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import '../../../test/common-test-setup';
-import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 import {PluginApi} from '../../../api/plugin';
 import {ChecksPluginApi} from '../../../api/checks';
 import {assert} from '@open-wc/testing';
+import {testResolver} from '../../../test/common-test-setup';
+import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 
 suite('gr-settings-api tests', () => {
   let checksApi: ChecksPluginApi | undefined;
@@ -21,7 +22,7 @@
       '0.1',
       'http://test.com/plugins/testplugin/static/test.js'
     );
-    getPluginLoader().loadPlugins([]);
+    testResolver(pluginLoaderToken).loadPlugins([]);
     assert.isOk(pluginApi);
     checksApi = pluginApi!.checks();
   });
diff --git a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.ts b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.ts
index 61279cf..9cacaea 100644
--- a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.ts
@@ -6,14 +6,15 @@
 import {html, LitElement} from 'lit';
 import {customElement, property} from 'lit/decorators.js';
 import {
-  getPluginEndpoints,
+  EndpointType,
   ModuleInfo,
 } from '../../shared/gr-js-api-interface/gr-plugin-endpoints';
-import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 import {PluginApi} from '../../../api/plugin';
 import {HookApi, PluginElement} from '../../../api/hook';
 import {getAppContext} from '../../../services/app-context';
 import {assertIsDefined} from '../../../utils/common-util';
+import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
+import {resolve} from '../../../models/dependency';
 
 const INIT_PROPERTIES_TIMEOUT_MS = 10000;
 
@@ -38,6 +39,8 @@
 
   private readonly reporting = getAppContext().reportingService;
 
+  private readonly getPluginLoader = resolve(this, pluginLoaderToken);
+
   override render() {
     return html`<slot></slot>`;
   }
@@ -45,12 +48,17 @@
   override connectedCallback() {
     super.connectedCallback();
     assertIsDefined(this.name);
-    getPluginEndpoints().onNewEndpoint(this.name, this.initModule);
-    getPluginLoader()
+    this.getPluginLoader().pluginEndPoints.onNewEndpoint(
+      this.name,
+      this.initModule
+    );
+    this.getPluginLoader()
       .awaitPluginsLoaded()
       .then(() => {
         assertIsDefined(this.name);
-        const modules = getPluginEndpoints().getDetails(this.name);
+        const modules = this.getPluginLoader().pluginEndPoints.getDetails(
+          this.name
+        );
         for (const module of modules) {
           this.initModule(module);
         }
@@ -62,7 +70,10 @@
       domHook.handleInstanceDetached(el);
     }
     assertIsDefined(this.name);
-    getPluginEndpoints().onDetachedEndpoint(this.name, this.initModule);
+    this.getPluginLoader().pluginEndPoints.onDetachedEndpoint(
+      this.name,
+      this.initModule
+    );
     super.disconnectedCallback();
   }
 
@@ -187,10 +198,10 @@
     }
     let initPromise;
     switch (type) {
-      case 'decorate':
+      case EndpointType.DECORATE:
         initPromise = this.initDecoration(moduleName, plugin, slot);
         break;
-      case 'replace':
+      case EndpointType.REPLACE:
         initPromise = this.initReplacement(moduleName, plugin);
         break;
     }
diff --git a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.ts b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.ts
index c3e6911..57888fa 100644
--- a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.ts
@@ -8,12 +8,7 @@
 import '../gr-endpoint-param/gr-endpoint-param';
 import '../gr-endpoint-slot/gr-endpoint-slot';
 import {fixture, html, assert} from '@open-wc/testing';
-import {
-  mockPromise,
-  queryAndAssert,
-  resetPlugins,
-} from '../../../test/test-utils';
-import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
+import {mockPromise, queryAndAssert} from '../../../test/test-utils';
 import {GrEndpointDecorator} from './gr-endpoint-decorator';
 import {PluginApi} from '../../../api/plugin';
 import {GrEndpointParam} from '../gr-endpoint-param/gr-endpoint-param';
@@ -30,7 +25,6 @@
   let banana: GrEndpointDecorator;
 
   setup(async () => {
-    resetPlugins();
     container = await fixture(
       html`<div>
         <gr-endpoint-decorator name="first">
@@ -100,18 +94,11 @@
     const replacementHookPromise = mockPromise();
     replacementHook.onAttached(() => replacementHookPromise.resolve());
 
-    // Mimic all plugins loaded.
-    getPluginLoader().loadPlugins([]);
-
     await decorationHookPromise;
     await decorationHookSlotPromise;
     await replacementHookPromise;
   });
 
-  teardown(() => {
-    resetPlugins();
-  });
-
   test('imports plugin-provided modules into endpoints', () => {
     const endpoints = Array.from(
       container.querySelectorAll('gr-endpoint-decorator')
diff --git a/polygerrit-ui/app/elements/plugins/gr-endpoint-param/gr-endpoint-param.ts b/polygerrit-ui/app/elements/plugins/gr-endpoint-param/gr-endpoint-param.ts
index e73aad6..d3429fe 100644
--- a/polygerrit-ui/app/elements/plugins/gr-endpoint-param/gr-endpoint-param.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-endpoint-param/gr-endpoint-param.ts
@@ -5,6 +5,7 @@
  */
 import {LitElement, PropertyValues} from 'lit';
 import {customElement, property} from 'lit/decorators.js';
+import {fireNoBubbleNoCompose} from '../../../utils/event-util';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -22,9 +23,7 @@
 
   override willUpdate(changedProperties: PropertyValues) {
     if (changedProperties.has('value')) {
-      this.dispatchEvent(
-        new CustomEvent('value-changed', {detail: {value: this.value}})
-      );
+      fireNoBubbleNoCompose(this, 'value-changed', {value: this.value});
     }
   }
 }
diff --git a/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper.ts b/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper.ts
index 9915d4c..641d87b 100644
--- a/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper.ts
@@ -8,12 +8,14 @@
   UnsubscribeCallback,
 } from '../../../api/event-helper';
 import {PluginApi} from '../../../api/plugin';
-import {getAppContext} from '../../../services/app-context';
+import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
 
 export class GrEventHelper implements EventHelperPluginApi {
-  private readonly reporting = getAppContext().reportingService;
-
-  constructor(readonly plugin: PluginApi, readonly element: HTMLElement) {
+  constructor(
+    private readonly reporting: ReportingService,
+    readonly plugin: PluginApi,
+    readonly element: HTMLElement
+  ) {
     this.reporting.trackApi(this.plugin, 'event', 'constructor');
   }
 
diff --git a/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style.ts b/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style.ts
deleted file mode 100644
index 43d9805..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style.ts
+++ /dev/null
@@ -1,75 +0,0 @@
-/**
- * @license
- * Copyright 2017 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-import {updateStyles} from '@polymer/polymer/lib/mixins/element-mixin';
-import {getPluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints';
-import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
-import {LitElement, html, PropertyValues} from 'lit';
-import {customElement, property} from 'lit/decorators.js';
-
-@customElement('gr-external-style')
-export class GrExternalStyle extends LitElement {
-  // This is a required value for this component.
-  @property({type: String, reflect: true})
-  name!: string;
-
-  // private but used in test
-  stylesApplied: string[] = [];
-
-  stylesElements: HTMLElement[] = [];
-
-  override render() {
-    return html`<slot></slot>`;
-  }
-
-  override willUpdate(changedProperties: PropertyValues) {
-    if (changedProperties.has('name')) {
-      // We remove all styles defined for different name.
-      this.removeStyles();
-      this.importAndApply();
-      getPluginLoader()
-        .awaitPluginsLoaded()
-        .then(() => this.importAndApply());
-    }
-  }
-
-  // private but used in test
-  applyStyle(name: string) {
-    if (this.stylesApplied.includes(name)) {
-      return;
-    }
-    this.stylesApplied.push(name);
-
-    const s = document.createElement('style');
-    s.setAttribute('include', name);
-    const cs = document.createElement('custom-style');
-    this.stylesElements.push(cs);
-    cs.appendChild(s);
-    // When using Shadow DOM <custom-style> must be added to the <body>.
-    // Within <gr-external-style> itself the styles would have no effect.
-    const topEl = document.getElementsByTagName('body')[0];
-    topEl.insertBefore(cs, topEl.firstChild);
-    updateStyles();
-  }
-
-  removeStyles() {
-    this.stylesElements.forEach(el => el.remove());
-    this.stylesElements = [];
-    this.stylesApplied = [];
-  }
-
-  private importAndApply() {
-    const moduleNames = getPluginEndpoints().getModules(this.name);
-    for (const name of moduleNames) {
-      this.applyStyle(name);
-    }
-  }
-}
-
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-external-style': GrExternalStyle;
-  }
-}
diff --git a/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style_test.ts b/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style_test.ts
deleted file mode 100644
index ce87acb..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style_test.ts
+++ /dev/null
@@ -1,124 +0,0 @@
-/**
- * @license
- * Copyright 2020 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-import '../../../test/common-test-setup';
-import {mockPromise, MockPromise, resetPlugins} from '../../../test/test-utils';
-import './gr-external-style';
-import {GrExternalStyle} from './gr-external-style';
-import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
-import {PluginApi} from '../../../api/plugin';
-import {fixture, html, assert} from '@open-wc/testing';
-
-suite('gr-external-style integration tests', () => {
-  const TEST_URL = 'http://some.com/plugins/url.js';
-
-  let element: GrExternalStyle;
-  let plugin: PluginApi;
-  let pluginsLoaded: MockPromise<void>;
-  let applyStyleSpy: sinon.SinonSpy;
-
-  const installPlugin = () => {
-    if (plugin) {
-      return;
-    }
-    window.Gerrit.install(
-      p => {
-        plugin = p;
-      },
-      '0.1',
-      TEST_URL
-    );
-  };
-
-  const createElement = async () => {
-    applyStyleSpy = sinon.spy(GrExternalStyle.prototype, 'applyStyle');
-    element = await fixture(
-      html`<gr-external-style .name=${'foo'}></gr-external-style>`
-    );
-    await element.updateComplete;
-  };
-
-  /**
-   * Installs the plugin, creates the element, registers style module.
-   */
-  const lateRegister = async () => {
-    installPlugin();
-    await createElement();
-    plugin.registerStyleModule('foo', 'some-module');
-  };
-
-  /**
-   * Installs the plugin, registers style module, creates the element.
-   */
-  const earlyRegister = async () => {
-    installPlugin();
-    plugin.registerStyleModule('foo', 'some-module');
-    await createElement();
-  };
-
-  setup(() => {
-    pluginsLoaded = mockPromise();
-    sinon.stub(getPluginLoader(), 'awaitPluginsLoaded').returns(pluginsLoaded);
-  });
-
-  teardown(() => {
-    resetPlugins();
-    document.body
-      .querySelectorAll('custom-style')
-      .forEach(style => style.remove());
-  });
-
-  test('applies plugin-provided styles', async () => {
-    await lateRegister();
-    pluginsLoaded.resolve();
-    await element.updateComplete;
-    assert.isTrue(applyStyleSpy.calledWith('some-module'));
-  });
-
-  test('does not double apply', async () => {
-    await earlyRegister();
-    await element.updateComplete;
-    plugin.registerStyleModule('foo', 'some-module');
-    await element.updateComplete;
-    const stylesApplied = element.stylesApplied.filter(
-      name => name === 'some-module'
-    );
-    assert.strictEqual(stylesApplied.length, 1);
-  });
-
-  test('loads and applies preloaded modules', async () => {
-    await earlyRegister();
-    await element.updateComplete;
-    assert.isTrue(applyStyleSpy.calledWith('some-module'));
-  });
-
-  test('removes old custom-style if name is changed', async () => {
-    installPlugin();
-    plugin.registerStyleModule('bar', 'some-module');
-    await earlyRegister();
-    await element.updateComplete;
-    let customStyles = document.body.querySelectorAll('custom-style');
-    assert.strictEqual(customStyles.length, 1);
-    element.name = 'bar';
-    await element.updateComplete;
-    customStyles = document.body.querySelectorAll('custom-style');
-    assert.strictEqual(customStyles.length, 1);
-    element.name = 'baz';
-    await element.updateComplete;
-    customStyles = document.body.querySelectorAll('custom-style');
-    assert.strictEqual(customStyles.length, 0);
-  });
-
-  test('can apply more than one style', async () => {
-    await earlyRegister();
-    await element.updateComplete;
-    plugin.registerStyleModule('foo', 'some-module2');
-    pluginsLoaded.resolve();
-    await element.updateComplete;
-    assert.strictEqual(element.stylesApplied.length, 2);
-    const customStyles = document.body.querySelectorAll('custom-style');
-    assert.strictEqual(customStyles.length, 2);
-  });
-});
diff --git a/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host.ts b/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host.ts
index b0993b9..80765a8 100644
--- a/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host.ts
@@ -5,19 +5,20 @@
  */
 import {LitElement} from 'lit';
 import {customElement, state} from 'lit/decorators.js';
-import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 import {ServerInfo} from '../../../types/common';
 import {subscribe} from '../../lit/subscription-controller';
 import {resolve} from '../../../models/dependency';
 import {configModelToken} from '../../../models/config/config-model';
+import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 
 @customElement('gr-plugin-host')
 export class GrPluginHost extends LitElement {
   @state()
   config?: ServerInfo;
 
-  // visible for testing
-  readonly getConfigModel = resolve(this, configModelToken);
+  private readonly getConfigModel = resolve(this, configModelToken);
+
+  private readonly getPluginLoader = resolve(this, pluginLoaderToken);
 
   constructor() {
     super();
@@ -31,7 +32,10 @@
           ? [config.default_theme]
           : [];
         const instanceId = config?.gerrit?.instance_id;
-        getPluginLoader().loadPlugins([...themes, ...jsPlugins], instanceId);
+        this.getPluginLoader().loadPlugins(
+          [...themes, ...jsPlugins],
+          instanceId
+        );
       }
     );
   }
diff --git a/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host_test.ts b/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host_test.ts
index bb89d12..e0b792f 100644
--- a/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host_test.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host_test.ts
@@ -5,29 +5,42 @@
  */
 import '../../../test/common-test-setup';
 import './gr-plugin-host';
-import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 import {GrPluginHost} from './gr-plugin-host';
 import {fixture, html, assert} from '@open-wc/testing';
-import {SinonStub} from 'sinon';
+import {SinonStubbedMember} from 'sinon';
 import {createServerInfo} from '../../../test/test-data-generators';
+import {
+  ConfigModel,
+  configModelToken,
+} from '../../../models/config/config-model';
+import {testResolver} from '../../../test/common-test-setup';
+import {
+  PluginLoader,
+  pluginLoaderToken,
+} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 
 suite('gr-plugin-host tests', () => {
   let element: GrPluginHost;
-  let loadPluginsStub: SinonStub;
+  let loadPluginsStub: SinonStubbedMember<PluginLoader['loadPlugins']>;
+  let configModel: ConfigModel;
 
   setup(async () => {
-    loadPluginsStub = sinon.stub(getPluginLoader(), 'loadPlugins');
+    loadPluginsStub = sinon.stub(
+      testResolver(pluginLoaderToken),
+      'loadPlugins'
+    );
     element = await fixture<GrPluginHost>(html`
       <gr-plugin-host></gr-plugin-host>
     `);
     await element.updateComplete;
+    configModel = testResolver(configModelToken);
 
     sinon.stub(document.body, 'appendChild');
   });
 
   test('load plugins should be called', async () => {
     loadPluginsStub.reset();
-    element.getConfigModel().updateServerConfig({
+    configModel.updateServerConfig({
       ...createServerInfo(),
       plugin: {
         has_avatars: false,
@@ -46,7 +59,7 @@
 
   test('theme plugins should be loaded if enabled', async () => {
     loadPluginsStub.reset();
-    element.getConfigModel().updateServerConfig({
+    configModel.updateServerConfig({
       ...createServerInfo(),
       default_theme: 'gerrit-theme.js',
       plugin: {
@@ -69,7 +82,7 @@
     loadPluginsStub.reset();
     const config = createServerInfo();
     config.gerrit.instance_id = 'test-id';
-    element.getConfigModel().updateServerConfig(config);
+    configModel.updateServerConfig(config);
     assert.isTrue(loadPluginsStub.calledOnce);
     assert.isTrue(loadPluginsStub.calledWith([], 'test-id'));
   });
diff --git a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup.ts b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup.ts
index 135ae51..7c99f14 100644
--- a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup.ts
@@ -3,11 +3,10 @@
  * Copyright 2017 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-import '../../shared/gr-overlay/gr-overlay';
-import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, html} from 'lit';
 import {customElement, query} from 'lit/decorators.js';
+import {modalStyles} from '../../../styles/gr-modal-styles';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -17,27 +16,27 @@
 
 @customElement('gr-plugin-popup')
 export class GrPluginPopup extends LitElement {
-  @query('#overlay') protected overlay!: GrOverlay;
+  @query('#modal') protected modal!: HTMLDialogElement;
 
   static override get styles() {
-    return [sharedStyles];
+    return [sharedStyles, modalStyles];
   }
 
   override render() {
-    return html`<gr-overlay id="overlay" with-backdrop="">
+    return html`<dialog id="modal">
       <slot></slot>
-    </gr-overlay>`;
+    </dialog>`;
   }
 
   get opened() {
-    return this.overlay.opened;
+    return this.modal.hasAttribute('open');
   }
 
   open() {
-    return this.overlay.open();
+    this.modal.showModal();
   }
 
   close() {
-    this.overlay.close();
+    this.modal.close();
   }
 }
diff --git a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup_test.ts b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup_test.ts
index 86243c4..8e7605d 100644
--- a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup_test.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup_test.ts
@@ -11,29 +11,27 @@
 
 suite('gr-plugin-popup tests', () => {
   let element: GrPluginPopup;
-  let overlayOpen: sinon.SinonStub;
-  let overlayClose: sinon.SinonStub;
+  let modalOpen: sinon.SinonStub;
+  let modalClose: sinon.SinonStub;
 
   setup(async () => {
     element = await fixture(html`<gr-plugin-popup></gr-plugin-popup>`);
     await element.updateComplete;
-    overlayOpen = stubElement('gr-overlay', 'open').callsFake(() =>
-      Promise.resolve()
-    );
-    overlayClose = stubElement('gr-overlay', 'close');
+    modalOpen = stubElement('dialog', 'showModal');
+    modalClose = stubElement('dialog', 'close');
   });
 
   test('exists', () => {
     assert.isOk(element);
   });
 
-  test('open uses open() from gr-overlay', async () => {
-    await element.open();
-    assert.isTrue(overlayOpen.called);
+  test('open uses open() from dialog', () => {
+    element.open();
+    assert.isTrue(modalOpen.called);
   });
 
-  test('close uses close() from gr-overlay', () => {
+  test('close uses close() from dialog', () => {
     element.close();
-    assert.isTrue(overlayClose.called);
+    assert.isTrue(modalClose.called);
   });
 });
diff --git a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface.ts b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface.ts
index 9dbb231..1ee5f5d 100644
--- a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface.ts
@@ -28,7 +28,8 @@
 
   constructor(
     readonly plugin: PluginApi,
-    private moduleName: string | null = null
+    // private but used in tests
+    readonly moduleName: string | null = null
   ) {
     this.reporting.trackApi(this.plugin, 'popup', 'constructor');
   }
@@ -65,7 +66,7 @@
           }
           this.popup = hookEl.appendChild(popup);
           await this.popup.updateComplete;
-          await this.popup.open();
+          this.popup.open();
           return this;
         });
     }
diff --git a/polygerrit-ui/app/elements/polymer-util.ts b/polygerrit-ui/app/elements/polymer-util.ts
new file mode 100644
index 0000000..d325b7b
--- /dev/null
+++ b/polygerrit-ui/app/elements/polymer-util.ts
@@ -0,0 +1,14 @@
+/**
+ * @license
+ * Copyright 2023 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {IronA11yAnnouncer} from '@polymer/iron-a11y-announcer/iron-a11y-announcer';
+
+export interface FixIronA11yAnnouncer extends IronA11yAnnouncer {
+  requestAvailability(): void;
+}
+
+export function ironAnnouncerRequestAvailability() {
+  (IronA11yAnnouncer as unknown as FixIronA11yAnnouncer).requestAvailability();
+}
diff --git a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.ts b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.ts
index c9603f4..89513e3 100644
--- a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.ts
+++ b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.ts
@@ -3,15 +3,19 @@
  * Copyright 2016 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
 import '@polymer/iron-input/iron-input';
 import '../../shared/gr-avatar/gr-avatar';
 import '../../shared/gr-date-formatter/gr-date-formatter';
+import '../../shared/gr-tooltip-content/gr-tooltip-content';
 import '../../../styles/gr-form-styles';
 import '../../../styles/shared-styles';
+import '../../shared/gr-account-chip/gr-account-chip';
+import '../../shared/gr-hovercard-account/gr-hovercard-account-contents';
 import {AccountDetailInfo, ServerInfo} from '../../../types/common';
 import {EditableAccountField} from '../../../constants/constants';
 import {getAppContext} from '../../../services/app-context';
-import {fire, fireEvent} from '../../../utils/event-util';
+import {fire} from '../../../utils/event-util';
 import {LitElement, css, html, nothing, PropertyValues} from 'lit';
 import {customElement, property, state} from 'lit/decorators.js';
 import {sharedStyles} from '../../../styles/shared-styles';
@@ -21,12 +25,6 @@
 
 @customElement('gr-account-info')
 export class GrAccountInfo extends LitElement {
-  /**
-   * Fired when account details are changed.
-   *
-   * @event account-detail-update
-   */
-
   // private but used in test
   @state() nameMutable?: boolean;
 
@@ -75,12 +73,42 @@
       div section.hide {
         display: none;
       }
+      gr-hovercard-account-contents {
+        display: block;
+        max-width: 600px;
+        margin-top: var(--spacing-m);
+        background: var(--dialog-background-color);
+        border: 1px solid var(--border-color);
+        border-radius: var(--border-radius);
+        box-shadow: var(--elevation-level-5);
+      }
+      iron-autogrow-textarea {
+        background-color: var(--view-background-color);
+        color: var(--primary-text-color);
+      }
+      .lengthCounter {
+        font-weight: var(--font-weight-normal);
+      }
+      p {
+        max-width: 65ch;
+        margin-bottom: var(--spacing-m);
+      }
     `,
   ];
 
   override render() {
     if (!this.account || this.loading) return nothing;
     return html`<div class="gr-form-styles">
+      <p>
+        All profile fields below may be publicly displayed to others, including
+        on changes you are associated with, as well as in search and
+        autocompletion.
+        <a
+          href="https://gerrit-review.googlesource.com/Documentation/user-privacy.html"
+          >Learn more</a
+        >
+      </p>
+      <gr-endpoint-decorator name="profile"></gr-endpoint-decorator>
       <section>
         <span class="title"></span>
         <span class="value">
@@ -188,25 +216,43 @@
         </span>
       </section>
       <section>
-        <label class="title" for="statusInput">About me (e.g. employer)</label>
+        <span class="title">
+          <label for="statusInput">About me (e.g. employer)</label>
+          <div class="lengthCounter">
+            ${this.account.status?.length ?? 0}/140
+          </div>
+        </span>
         <span class="value">
-          <iron-input
-            id="statusIronInput"
-            @keydown=${this.handleKeydown}
-            .bindValue=${this.account?.status}
+          <iron-autogrow-textarea
+            id="statusInput"
+            .name=${'statusInput'}
+            ?disabled=${this.saving}
+            maxlength="140"
+            .value=${this.account?.status}
             @bind-value-changed=${(e: BindValueChangeEvent) => {
               const oldAccount = this.account;
               if (!oldAccount || oldAccount.status === e.detail.value) return;
               this.account = {...oldAccount, status: e.detail.value};
               this.hasStatusChange = true;
             }}
+          ></iron-autogrow-textarea>
+        </span>
+      </section>
+      <section>
+        <span class="title">
+          <gr-tooltip-content
+            title="This is how you appear to others"
+            has-tooltip
+            show-icon
           >
-            <input
-              id="statusInput"
-              ?disabled=${this.saving}
-              @keydown=${this.handleKeydown}
-            />
-          </iron-input>
+            Account preview
+          </gr-tooltip-content>
+        </span>
+        <span class="value">
+          <gr-account-chip .account=${this.account}></gr-account-chip>
+          <gr-hovercard-account-contents
+            .account=${this.account}
+          ></gr-hovercard-account-contents>
         </span>
       </section>
     </div>`;
@@ -289,7 +335,7 @@
         this.hasDisplayNameChange = false;
         this.hasStatusChange = false;
         this.saving = false;
-        fireEvent(this, 'account-detail-update');
+        fire(this, 'account-detail-update', {});
       });
   }
 
@@ -358,6 +404,8 @@
 declare global {
   interface HTMLElementEventMap {
     'unsaved-changes-changed': ValueChangedEvent<boolean>;
+    /** Fired when account details are changed. */
+    'account-detail-update': CustomEvent<{}>;
   }
   interface HTMLElementTagNameMap {
     'gr-account-info': GrAccountInfo;
diff --git a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.ts b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.ts
index 518828a..f954960 100644
--- a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.ts
+++ b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.ts
@@ -5,7 +5,12 @@
  */
 import '../../../test/common-test-setup';
 import './gr-account-info';
-import {query, queryAll, stubRestApi} from '../../../test/test-utils';
+import {
+  query,
+  queryAll,
+  queryAndAssert,
+  stubRestApi,
+} from '../../../test/test-utils';
 import {GrAccountInfo} from './gr-account-info';
 import {AccountDetailInfo, ServerInfo} from '../../../types/common';
 import {
@@ -20,6 +25,7 @@
 import {RestApiService} from '../../../services/gr-rest-api/gr-rest-api';
 import {EditableAccountField} from '../../../api/rest-api';
 import {fixture, html, assert} from '@open-wc/testing';
+import {IronAutogrowTextareaElement} from '@polymer/iron-autogrow-textarea';
 
 suite('gr-account-info tests', () => {
   let element!: GrAccountInfo;
@@ -63,6 +69,16 @@
       element,
       /* HTML */ `
         <div class="gr-form-styles">
+          <p>
+            All profile fields below may be publicly displayed to others,
+            including on changes you are associated with, as well as in search
+            and autocompletion.
+            <a
+              href="https://gerrit-review.googlesource.com/Documentation/user-privacy.html"
+              >Learn more</a
+            >
+          </p>
+          <gr-endpoint-decorator name="profile"></gr-endpoint-decorator>
           <section>
             <span class="title"></span>
             <span class="value">
@@ -100,17 +116,36 @@
             </span>
           </section>
           <section>
-            <label class="title" for="statusInput">
-              About me (e.g. employer)
-            </label>
+            <span class="title">
+              <label for="statusInput">About me (e.g. employer)</label>
+              <div class="lengthCounter">0/140</div>
+            </span>
             <span class="value">
-              <iron-input id="statusIronInput">
-                <input id="statusInput" />
-              </iron-input>
+              <iron-autogrow-textarea
+                aria-disabled="false"
+                id="statusInput"
+                maxlength="140"
+              />
+            </span>
+          </section>
+          <section>
+            <span class="title">
+              <gr-tooltip-content
+                has-tooltip=""
+                show-icon=""
+                title="This is how you appear to others"
+              >
+                Account preview
+              </gr-tooltip-content>
+            </span>
+            <span class="value"
+              ><gr-account-chip></gr-account-chip>
+              <gr-hovercard-account-contents></gr-hovercard-account-contents>
             </span>
           </section>
         </div>
-      `
+      `,
+      {ignoreChildren: ['p']}
     );
   });
 
@@ -261,8 +296,11 @@
     test('status', async () => {
       assert.isFalse(element.hasUnsavedChanges);
 
-      const statusInputEl = queryIronInput('#statusIronInput');
-      statusInputEl.bindValue = 'new status';
+      const statusTextarea = queryAndAssert<IronAutogrowTextareaElement>(
+        element,
+        '#statusInput'
+      );
+      statusTextarea.value = 'new status';
       await element.updateComplete;
       assert.isFalse(element.hasNameChange);
       assert.isTrue(element.hasStatusChange);
@@ -305,8 +343,11 @@
       await element.updateComplete;
       assert.isTrue(element.hasNameChange);
 
-      const statusInputEl = queryIronInput('#statusIronInput');
-      statusInputEl.bindValue = 'new status';
+      const statusTextarea = queryAndAssert<IronAutogrowTextareaElement>(
+        element,
+        '#statusInput'
+      );
+      statusTextarea.value = 'new status';
       await element.updateComplete;
       assert.isTrue(element.hasStatusChange);
 
@@ -351,8 +392,11 @@
       assert.equal(displaySpan.textContent, account.name);
       assert.isUndefined(inputSpan);
 
-      const inputEl = queryIronInput('#statusIronInput');
-      inputEl.bindValue = 'new status';
+      const statusTextarea = queryAndAssert<IronAutogrowTextareaElement>(
+        element,
+        '#statusInput'
+      );
+      statusTextarea.value = 'new status';
       await element.updateComplete;
       assert.isTrue(element.hasStatusChange);
 
diff --git a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.ts b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.ts
index 2f835f7..a2b61e0 100644
--- a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.ts
+++ b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.ts
@@ -53,7 +53,7 @@
     super.connectedCallback();
     this.loadData();
 
-    fireTitleChange(this, 'New Contributor Agreement');
+    fireTitleChange('New Contributor Agreement');
   }
 
   static override get styles() {
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 f554ff0..183425d 100644
--- a/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences.ts
+++ b/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences.ts
@@ -7,7 +7,6 @@
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-select/gr-select';
 import {EditPreferencesInfo} from '../../../types/common';
-import {getAppContext} from '../../../services/app-context';
 import {formStyles} from '../../../styles/gr-form-styles';
 import {menuPageStyles} from '../../../styles/gr-menu-page-styles';
 import {sharedStyles} from '../../../styles/shared-styles';
@@ -15,6 +14,8 @@
 import {customElement, query, state} from 'lit/decorators.js';
 import {convertToString} from '../../../utils/string-util';
 import {subscribe} from '../../lit/subscription-controller';
+import {resolve} from '../../../models/dependency';
+import {userModelToken} from '../../../models/user/user-model';
 
 @customElement('gr-edit-preferences')
 export class GrEditPreferences extends LitElement {
@@ -46,13 +47,13 @@
 
   @state() private originalEditPrefs?: EditPreferencesInfo;
 
-  private readonly userModel = getAppContext().userModel;
+  private readonly getUserModel = resolve(this, userModelToken);
 
   constructor() {
     super();
     subscribe(
       this,
-      () => this.userModel.editPreferences$,
+      () => this.getUserModel().editPreferences$,
       editPreferences => {
         this.originalEditPrefs = editPreferences;
         this.editPrefs = {...editPreferences};
@@ -307,7 +308,7 @@
 
   async save() {
     if (!this.editPrefs) return;
-    await this.userModel.updateEditPreference(this.editPrefs);
+    await this.getUserModel().updateEditPreference(this.editPrefs);
   }
 }
 
diff --git a/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor.ts b/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor.ts
index b2f8acd..32b32e2 100644
--- a/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor.ts
+++ b/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor.ts
@@ -6,10 +6,8 @@
 import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-copy-clipboard/gr-copy-clipboard';
-import '../../shared/gr-overlay/gr-overlay';
 import {GpgKeyInfo, GpgKeyId} from '../../../types/common';
 import {GrButton} from '../../shared/gr-button/gr-button';
-import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
 import {IronAutogrowTextareaElement} from '@polymer/iron-autogrow-textarea';
 import {getAppContext} from '../../../services/app-context';
 import {css, html, LitElement} from 'lit';
@@ -19,6 +17,7 @@
 import {assertIsDefined} from '../../../utils/common-util';
 import {BindValueChangeEvent} from '../../../types/events';
 import {fire} from '../../../utils/event-util';
+import {modalStyles} from '../../../styles/gr-modal-styles';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -27,7 +26,7 @@
 }
 @customElement('gr-gpg-editor')
 export class GrGpgEditor extends LitElement {
-  @query('#viewKeyOverlay') viewKeyOverlay?: GrOverlay;
+  @query('#viewKeyModal') viewKeyModal?: HTMLDialogElement;
 
   @query('#addButton') addButton?: GrButton;
 
@@ -53,6 +52,7 @@
   static override styles = [
     formStyles,
     sharedStyles,
+    modalStyles,
     css`
       .keyHeader {
         width: 9em;
@@ -60,7 +60,7 @@
       .userIdHeader {
         width: 15em;
       }
-      #viewKeyOverlay {
+      #viewKeyModal {
         padding: var(--spacing-xxl);
         width: 50em;
       }
@@ -97,7 +97,7 @@
               ${this.keys.map((key, index) => this.renderKey(key, index))}
             </tbody>
           </table>
-          <gr-overlay id="viewKeyOverlay" with-backdrop="">
+          <dialog id="viewKeyModal" tabindex="-1">
             <fieldset>
               <section>
                 <span class="title">Status</span>
@@ -111,11 +111,11 @@
             <gr-button
               class="closeButton"
               @click=${() => {
-                this.viewKeyOverlay?.close();
+                this.viewKeyModal?.close();
               }}
               >Close</gr-button
             >
-          </gr-overlay>
+          </dialog>
           <gr-button @click=${this.save} ?disabled=${!this.hasUnsavedChanges}
             >Save changes</gr-button
           >
@@ -201,7 +201,7 @@
 
   private showKey(key: GpgKeyInfo) {
     this.keyToView = key;
-    this.viewKeyOverlay?.open();
+    this.viewKeyModal?.showModal();
   }
 
   private handleNewKeyChanged(e: BindValueChangeEvent) {
diff --git a/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor_test.ts b/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor_test.ts
index 8e653fc..5be5b29 100644
--- a/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor_test.ts
+++ b/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor_test.ts
@@ -138,13 +138,7 @@
               </tr>
             </tbody>
           </table>
-          <gr-overlay
-            aria-hidden="true"
-            id="viewKeyOverlay"
-            style="outline: none; display: none;"
-            tabindex="-1"
-            with-backdrop=""
-          >
+          <dialog id="viewKeyModal" tabindex="-1">
             <fieldset>
               <section>
                 <span class="title"> Status </span> <span class="value"> </span>
@@ -161,7 +155,7 @@
             >
               Close
             </gr-button>
-          </gr-overlay>
+          </dialog>
           <gr-button
             aria-disabled="true"
             disabled=""
@@ -242,7 +236,7 @@
   });
 
   test('show key', () => {
-    const openSpy = sinon.spy(element.viewKeyOverlay!, 'open');
+    const openSpy = sinon.spy(element.viewKeyModal!, 'showModal');
 
     // Get the show button for the last row.
     const button = queryAndAssert<GrButton>(
diff --git a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.ts b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.ts
index 9595391..16e262b 100644
--- a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.ts
+++ b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.ts
@@ -5,13 +5,12 @@
  */
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-copy-clipboard/gr-copy-clipboard';
-import '../../shared/gr-overlay/gr-overlay';
-import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
 import {getAppContext} from '../../../services/app-context';
 import {formStyles} from '../../../styles/gr-form-styles';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, css, html} from 'lit';
 import {customElement, property, query} from 'lit/decorators.js';
+import {modalStyles} from '../../../styles/gr-modal-styles';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -21,8 +20,8 @@
 
 @customElement('gr-http-password')
 export class GrHttpPassword extends LitElement {
-  @query('#generatedPasswordOverlay')
-  generatedPasswordOverlay?: GrOverlay;
+  @query('#generatedPasswordModal')
+  generatedPasswordModal?: HTMLDialogElement;
 
   @property({type: String})
   _username?: string;
@@ -68,13 +67,14 @@
     return [
       sharedStyles,
       formStyles,
+      modalStyles,
       css`
         .password {
           font-family: var(--monospace-font-family);
           font-size: var(--font-size-mono);
           line-height: var(--line-height-mono);
         }
-        #generatedPasswordOverlay {
+        #generatedPasswordModal {
           padding: var(--spacing-xxl);
           width: 50em;
         }
@@ -120,10 +120,10 @@
           (opens in a new tab)
         </span>
       </div>
-      <gr-overlay
-        id="generatedPasswordOverlay"
-        @iron-overlay-closed=${this._generatedPasswordOverlayClosed}
-        with-backdrop
+      <dialog
+        tabindex="-1"
+        id="generatedPasswordModal"
+        @closed=${this._generatedPasswordModalClosed}
       >
         <div class="gr-form-styles">
           <section id="generatedPasswordDisplay">
@@ -141,26 +141,26 @@
             This password will not be displayed again.<br />
             If you lose it, you will need to generate a new one.
           </section>
-          <gr-button link="" class="closeButton" @click=${this._closeOverlay}
+          <gr-button link="" class="closeButton" @click=${this._closeModal}
             >Close</gr-button
           >
         </div>
-      </gr-overlay>`;
+      </dialog>`;
   }
 
   _handleGenerateTap() {
     this._generatedPassword = 'Generating...';
-    this.generatedPasswordOverlay?.open();
+    this.generatedPasswordModal?.showModal();
     this.restApiService.generateAccountHttpPassword().then(newPassword => {
       this._generatedPassword = newPassword;
     });
   }
 
-  _closeOverlay() {
-    this.generatedPasswordOverlay?.close();
+  _closeModal() {
+    this.generatedPasswordModal?.close();
   }
 
-  _generatedPasswordOverlayClosed() {
+  _generatedPasswordModalClosed() {
     this._generatedPassword = '';
   }
 }
diff --git a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_test.ts b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_test.ts
index 116d349..a582044 100644
--- a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_test.ts
+++ b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_test.ts
@@ -57,13 +57,7 @@
             (opens in a new tab)
           </span>
         </div>
-        <gr-overlay
-          aria-hidden="true"
-          id="generatedPasswordOverlay"
-          style="outline: none; display: none;"
-          tabindex="-1"
-          with-backdrop=""
-        >
+        <dialog tabindex="-1" id="generatedPasswordModal">
           <div class="gr-form-styles">
             <section id="generatedPasswordDisplay">
               <span class="title"> New Password: </span>
@@ -90,7 +84,7 @@
               Close
             </gr-button>
           </div>
-        </gr-overlay>
+        </dialog>
       `
     );
   });
diff --git a/polygerrit-ui/app/elements/settings/gr-identities/gr-identities.ts b/polygerrit-ui/app/elements/settings/gr-identities/gr-identities.ts
index 4f5411d..7f67ea8 100644
--- a/polygerrit-ui/app/elements/settings/gr-identities/gr-identities.ts
+++ b/polygerrit-ui/app/elements/settings/gr-identities/gr-identities.ts
@@ -5,10 +5,8 @@
  */
 import '../../admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog';
 import '../../shared/gr-button/gr-button';
-import '../../shared/gr-overlay/gr-overlay';
 import {getBaseUrl} from '../../../utils/url-util';
 import {AccountExternalIdInfo, ServerInfo} from '../../../types/common';
-import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
 import {getAppContext} from '../../../services/app-context';
 import {AuthType} from '../../../constants/constants';
 import {LitElement, css, html, PropertyValues} from 'lit';
@@ -18,12 +16,13 @@
 import {classMap} from 'lit/directives/class-map.js';
 import {when} from 'lit/directives/when.js';
 import {assertIsDefined} from '../../../utils/common-util';
+import {modalStyles} from '../../../styles/gr-modal-styles';
 
 const AUTH = [AuthType.OPENID, AuthType.OAUTH];
 
 @customElement('gr-identities')
 export class GrIdentities extends LitElement {
-  @query('#overlay') overlay?: GrOverlay;
+  @query('#modal') modal?: HTMLDialogElement;
 
   @state() private identities: AccountExternalIdInfo[] = [];
 
@@ -40,6 +39,7 @@
   static override styles = [
     sharedStyles,
     formStyles,
+    modalStyles,
     css`
       tr th.emailAddressHeader,
       tr th.identityHeader {
@@ -98,7 +98,7 @@
           </fieldset>`
         )}
       </div>
-      <gr-overlay id="overlay" with-backdrop>
+      <dialog id="modal" tabindex="-1">
         <gr-confirm-delete-item-dialog
           class="confirmDialog"
           @confirm=${this.handleDeleteItemConfirm}
@@ -106,7 +106,7 @@
           .item=${this.idName}
           itemtypename="ID"
         ></gr-confirm-delete-item-dialog>
-      </gr-overlay>`;
+      </dialog>`;
   }
 
   private renderIdentity(account: AccountExternalIdInfo, index: number) {
@@ -156,7 +156,7 @@
   }
 
   handleDeleteItemConfirm() {
-    this.overlay?.close();
+    this.modal?.close();
     assertIsDefined(this.idName);
     return this.restApiService.deleteAccountIdentity([this.idName]).then(() => {
       this.loadData();
@@ -164,12 +164,12 @@
   }
 
   private handleConfirmDialogCancel() {
-    this.overlay?.close();
+    this.modal?.close();
   }
 
   private handleDeleteItem(name: string) {
     this.idName = name;
-    this.overlay?.open();
+    this.modal?.showModal();
   }
 
   // private but used in test
diff --git a/polygerrit-ui/app/elements/settings/gr-identities/gr-identities_test.ts b/polygerrit-ui/app/elements/settings/gr-identities/gr-identities_test.ts
index 84df178..d52b423 100644
--- a/polygerrit-ui/app/elements/settings/gr-identities/gr-identities_test.ts
+++ b/polygerrit-ui/app/elements/settings/gr-identities/gr-identities_test.ts
@@ -7,7 +7,7 @@
 import './gr-identities';
 import {GrIdentities} from './gr-identities';
 import {AuthType} from '../../../constants/constants';
-import {stubRestApi} from '../../../test/test-utils';
+import {stubRestApi, waitUntilVisible} from '../../../test/test-utils';
 import {ServerInfo} from '../../../types/common';
 import {createServerInfo} from '../../../test/test-data-generators';
 import {queryAll, queryAndAssert} from '../../../test/test-utils';
@@ -96,19 +96,13 @@
             </table>
           </fieldset>
         </div>
-        <gr-overlay
-          aria-hidden="true"
-          id="overlay"
-          style="outline: none; display: none;"
-          tabindex="-1"
-          with-backdrop=""
-        >
+        <dialog id="modal" tabindex="-1">
           <gr-confirm-delete-item-dialog
             class="confirmDialog"
             itemtypename="ID"
           >
-          </gr-confirm-delete-item-dialog
-        ></gr-overlay>`
+          </gr-confirm-delete-item-dialog>
+        </dialog>`
     );
   });
 
@@ -150,7 +144,7 @@
     const deleteBtn = queryAndAssert<GrButton>(element, '.deleteButton');
     deleteBtn.click();
     await element.updateComplete;
-    assert.isTrue(element.overlay?.opened);
+    await waitUntilVisible(element.modal!);
   });
 
   test('computeShowLinkAnotherIdentity', () => {
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 460cc7c..9c23857 100644
--- a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.ts
+++ b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.ts
@@ -12,12 +12,13 @@
 import {state, customElement} from 'lit/decorators.js';
 import {BindValueChangeEvent} from '../../../types/events';
 import {subscribe} from '../../lit/subscription-controller';
-import {getAppContext} from '../../../services/app-context';
 import {deepEqual} from '../../../utils/deep-util';
 import {createDefaultPreferences} from '../../../constants/constants';
 import {fontStyles} from '../../../styles/gr-font-styles';
 import {classMap} from 'lit/directives/class-map.js';
 import {menuPageStyles} from '../../../styles/gr-menu-page-styles';
+import {userModelToken} from '../../../models/user/user-model';
+import {resolve} from '../../../models/dependency';
 
 @customElement('gr-menu-editor')
 export class GrMenuEditor extends LitElement {
@@ -33,13 +34,13 @@
   @state()
   newUrl = '';
 
-  private readonly userModel = getAppContext().userModel;
+  private readonly getUserModel = resolve(this, userModelToken);
 
   constructor() {
     super();
     subscribe(
       this,
-      () => this.userModel.preferences$,
+      () => this.getUserModel().preferences$,
       prefs => {
         this.originalPrefs = prefs;
         this.menuItems = [...prefs.my];
@@ -196,7 +197,7 @@
   }
 
   private handleSave() {
-    this.userModel.updatePreferences({
+    this.getUserModel().updatePreferences({
       ...this.originalPrefs,
       my: this.menuItems,
     });
diff --git a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.ts b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.ts
index a20c0ee..c6c023e 100644
--- a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.ts
+++ b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.ts
@@ -9,7 +9,7 @@
 import {ServerInfo, AccountDetailInfo} from '../../../types/common';
 import {EditableAccountField} from '../../../constants/constants';
 import {getAppContext} from '../../../services/app-context';
-import {fireEvent} from '../../../utils/event-util';
+import {fire} from '../../../utils/event-util';
 import {LitElement, css, html, PropertyValues} from 'lit';
 import {customElement, property, query, state} from 'lit/decorators.js';
 import {sharedStyles} from '../../../styles/shared-styles';
@@ -27,12 +27,6 @@
 @customElement('gr-registration-dialog')
 export class GrRegistrationDialog extends LitElement {
   /**
-   * Fired when account details are changed.
-   *
-   * @event account-detail-update
-   */
-
-  /**
    * Fired when the close button is pressed.
    *
    * @event close
@@ -293,7 +287,7 @@
 
     return Promise.all(promises).then(() => {
       this.saving = false;
-      fireEvent(this, 'account-detail-update');
+      fire(this, 'account-detail-update', {});
     });
   }
 
@@ -309,7 +303,7 @@
 
   private close() {
     this.saving = true; // disable buttons indefinitely
-    fireEvent(this, 'close');
+    fire(this, 'close', {});
   }
 
   // private but used in test
diff --git a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog_test.ts b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog_test.ts
index 7f0f85d..83ca149 100644
--- a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog_test.ts
+++ b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog_test.ts
@@ -90,10 +90,10 @@
     return promise;
   }
 
-  function close(opt_action?: Function) {
+  function close(action?: Function) {
     const promise = listen('close');
-    if (opt_action) {
-      opt_action();
+    if (action) {
+      action();
     } else {
       queryAndAssert<GrButton>(element, '#closeButton').click();
     }
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 392f136..fc61ba0 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
@@ -11,6 +11,7 @@
 import '../../shared/gr-diff-preferences/gr-diff-preferences';
 import '../../shared/gr-page-nav/gr-page-nav';
 import '../../shared/gr-select/gr-select';
+import '../../shared/gr-icon/gr-icon';
 import '../gr-account-info/gr-account-info';
 import '../gr-agreements-list/gr-agreements-list';
 import '../gr-edit-preferences/gr-edit-preferences';
@@ -22,7 +23,6 @@
 import '../gr-menu-editor/gr-menu-editor';
 import '../gr-ssh-editor/gr-ssh-editor';
 import '../gr-watched-projects-editor/gr-watched-projects-editor';
-import {getDocsBaseUrl} from '../../../utils/url-util';
 import {GrAccountInfo} from '../gr-account-info/gr-account-info';
 import {GrWatchedProjectsEditor} from '../gr-watched-projects-editor/gr-watched-projects-editor';
 import {GrGroupList} from '../gr-group-list/gr-group-list';
@@ -63,6 +63,7 @@
 import {resolve} from '../../../models/dependency';
 import {settingsViewModelToken} from '../../../models/views/settings';
 import {areNotificationsEnabled} from '../../../utils/worker-util';
+import {userModelToken} from '../../../models/user/user-model';
 
 const GERRIT_DOCS_BASE_URL =
   'https://gerrit-review.googlesource.com/' + 'Documentation';
@@ -80,12 +81,6 @@
 @customElement('gr-settings-view')
 export class GrSettingsView extends LitElement {
   /**
-   * Fired when the title of the page should change.
-   *
-   * @event title-change
-   */
-
-  /**
    * Fired with email confirmation text, or when the page reloads.
    *
    * @event show-alert
@@ -201,7 +196,7 @@
 
   private readonly restApiService = getAppContext().restApiService;
 
-  private readonly userModel = getAppContext().userModel;
+  private readonly getUserModel = resolve(this, userModelToken);
 
   // private but used in test
   readonly flagsService = getAppContext().flagsService;
@@ -220,14 +215,14 @@
     );
     subscribe(
       this,
-      () => this.userModel.account$,
+      () => this.getUserModel().account$,
       acc => {
         this.account = acc;
       }
     );
     subscribe(
       this,
-      () => this.userModel.preferences$,
+      () => this.getUserModel().preferences$,
       prefs => {
         if (!prefs) {
           throw new Error('getPreferences returned undefined');
@@ -260,7 +255,7 @@
     // Polymer 2: anchor tag won't work on shadow DOM
     // we need to manually calling scrollIntoView when hash changed
     document.addEventListener('location-change', this.handleLocationChange);
-    fireTitleChange(this, 'Settings');
+    fireTitleChange('Settings');
   }
 
   override firstUpdated() {
@@ -289,7 +284,7 @@
         }
 
         configPromises.push(
-          getDocsBaseUrl(config, this.restApiService).then(baseUrl => {
+          this.restApiService.getDocsBaseUrl(config).then(baseUrl => {
             this.docsBaseUrl = baseUrl;
           })
         );
@@ -879,12 +874,26 @@
   private renderBrowserNotifications() {
     if (!this.flagsService.isEnabled(KnownExperimentId.PUSH_NOTIFICATIONS))
       return nothing;
-    if (!areNotificationsEnabled(this.account)) return nothing;
+    if (
+      !this.flagsService.isEnabled(
+        KnownExperimentId.PUSH_NOTIFICATIONS_DEVELOPER
+      ) &&
+      !areNotificationsEnabled(this.account)
+    )
+      return nothing;
     return html`
       <section id="allowBrowserNotificationsSection">
-        <label class="title" for="allowBrowserNotifications"
-          >Allow browser notifications</label
-        >
+        <div class="title">
+          <label for="allowBrowserNotifications"
+            >Allow browser notifications</label
+          >
+          <a
+            href="https://gerrit-review.googlesource.com/Documentation/user-attention-set.html#_browser_notifications"
+            target="_blank"
+          >
+            <gr-icon icon="help" title="read documentation"></gr-icon>
+          </a>
+        </div>
         <span class="value">
           <input
             id="allowBrowserNotifications"
@@ -1113,7 +1122,7 @@
       // Use shadowRoot for Polymer 2
       const elem = (this.shadowRoot || document).querySelector(urlHash);
       if (elem) {
-        elem.scrollIntoView();
+        setTimeout(() => elem.scrollIntoView(), 0);
       }
     }
   };
@@ -1136,7 +1145,7 @@
 
   // private but used in test
   handleSavePreferences() {
-    return this.userModel.updatePreferences(this.localPrefs);
+    return this.getUserModel().updatePreferences(this.localPrefs);
   }
 
   // private but used in test
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.ts b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.ts
index 24a73b2..0bcb09c 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.ts
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.ts
@@ -38,7 +38,6 @@
 } from '../../../test/test-data-generators';
 import {GrSelect} from '../../shared/gr-select/gr-select';
 import {fixture, html, assert} from '@open-wc/testing';
-import {EventType} from '../../../types/events';
 
 suite('gr-settings-view tests', () => {
   let element: GrSettingsView;
@@ -525,9 +524,17 @@
     assert.dom.equal(
       queryAndAssert(element, '#allowBrowserNotificationsSection'),
       /* HTML */ `<section id="allowBrowserNotificationsSection">
-        <label class="title" for="allowBrowserNotifications">
-          Allow browser notifications
-        </label>
+        <div class="title">
+          <label for="allowBrowserNotifications">
+            Allow browser notifications
+          </label>
+          <a
+            href="https://gerrit-review.googlesource.com/Documentation/user-attention-set.html#_browser_notifications"
+            target="_blank"
+          >
+            <gr-icon icon="help" title="read documentation"> </gr-icon>
+          </a>
+        </div>
         <span class="value">
           <input checked="" id="allowBrowserNotifications" type="checkbox" />
         </span>
@@ -537,10 +544,8 @@
 
   test('calls the title-change event', async () => {
     const titleChangedStub = sinon.stub();
-
-    // Create a new view.
     const newElement = document.createElement('gr-settings-view');
-    newElement.addEventListener('title-change', titleChangedStub);
+    document.addEventListener('title-change', titleChangedStub);
 
     const div = await fixture(html`<div></div>`);
     div.appendChild(newElement);
@@ -907,7 +912,7 @@
       await element._testOnly_loadingPromise;
       assert.equal(
         (dispatchEventSpy.lastCall.args[0] as CustomEvent).type,
-        EventType.SHOW_ALERT
+        'show-alert'
       );
       assert.deepEqual(
         (dispatchEventSpy.lastCall.args[0] as CustomEvent).detail,
diff --git a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.ts b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.ts
index a73170a..9c323aa 100644
--- a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.ts
+++ b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.ts
@@ -6,11 +6,9 @@
 import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-copy-clipboard/gr-copy-clipboard';
-import '../../shared/gr-overlay/gr-overlay';
 import {SshKeyInfo} from '../../../types/common';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {IronAutogrowTextareaElement} from '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
-import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
 import {getAppContext} from '../../../services/app-context';
 import {LitElement, css, html, PropertyValues} from 'lit';
 import {customElement, property, query, state} from 'lit/decorators.js';
@@ -18,6 +16,7 @@
 import {sharedStyles} from '../../../styles/shared-styles';
 import {fire} from '../../../utils/event-util';
 import {BindValueChangeEvent} from '../../../types/events';
+import {modalStyles} from '../../../styles/gr-modal-styles';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -47,7 +46,7 @@
 
   @query('#newKey') newKeyEditor!: IronAutogrowTextareaElement;
 
-  @query('#viewKeyOverlay') viewKeyOverlay!: GrOverlay;
+  @query('#viewKeyModal') viewKeyModal!: HTMLDialogElement;
 
   private readonly restApiService = getAppContext().restApiService;
 
@@ -55,6 +54,7 @@
     return [
       formStyles,
       sharedStyles,
+      modalStyles,
       css`
         .statusHeader {
           width: 4em;
@@ -62,7 +62,7 @@
         .keyHeader {
           width: 7.5em;
         }
-        #viewKeyOverlay {
+        #viewKeyModal {
           padding: var(--spacing-xxl);
           width: 50em;
         }
@@ -121,7 +121,7 @@
               ${this.keys.map((key, index) => this.renderKey(key, index))}
             </tbody>
           </table>
-          <gr-overlay id="viewKeyOverlay" with-backdrop="">
+          <dialog id="viewKeyModal" tabindex="-1">
             <fieldset>
               <section>
                 <span class="title">Algorithm</span>
@@ -140,10 +140,10 @@
             </fieldset>
             <gr-button
               class="closeButton"
-              @click=${() => this.viewKeyOverlay.close()}
+              @click=${() => this.viewKeyModal.close()}
               >Close</gr-button
             >
-          </gr-overlay>
+          </dialog>
           <gr-button
             @click=${() => this.save()}
             ?disabled=${!this.hasUnsavedChanges}
@@ -231,7 +231,7 @@
     const el = e.target as GrButton;
     const index = Number(el.getAttribute('data-index')!);
     this.keyToView = this.keys[index];
-    this.viewKeyOverlay.open();
+    this.viewKeyModal.showModal();
   }
 
   private handleDeleteKey(e: Event) {
diff --git a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_test.ts b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_test.ts
index c5641ff..9528fb2 100644
--- a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_test.ts
+++ b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_test.ts
@@ -127,13 +127,7 @@
                 </tr>
               </tbody>
             </table>
-            <gr-overlay
-              aria-hidden="true"
-              id="viewKeyOverlay"
-              style="outline: none; display: none;"
-              tabindex="-1"
-              with-backdrop=""
-            >
+            <dialog id="viewKeyModal" tabindex="-1">
               <fieldset>
                 <section>
                   <span class="title"> Algorithm </span>
@@ -156,7 +150,7 @@
               >
                 Close
               </gr-button>
-            </gr-overlay>
+            </dialog>
             <gr-button
               aria-disabled="true"
               disabled=""
@@ -227,7 +221,7 @@
   });
 
   test('show key', () => {
-    const openSpy = sinon.spy(element.viewKeyOverlay, 'open');
+    const openSpy = sinon.spy(element.viewKeyModal, 'showModal');
 
     // Get the show button for the last row.
     const button = query<GrButton>(
diff --git a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.ts b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.ts
index fb38b59..2996e50 100644
--- a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.ts
+++ b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.ts
@@ -21,6 +21,7 @@
 import {when} from 'lit/directives/when.js';
 import {fire} from '../../../utils/event-util';
 import {PropertiesOfType} from '../../../utils/type-util';
+import {throwingErrorCallback} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
 
 type NotificationKey = PropertiesOfType<Required<ProjectWatchInfo>, boolean>;
 
@@ -193,13 +194,15 @@
 
   // private but used in tests.
   getProjectSuggestions(input: string) {
-    return this.restApiService.getSuggestedProjects(input).then(response => {
-      const projects: AutocompleteSuggestion[] = [];
-      for (const [name, project] of Object.entries(response ?? {})) {
-        projects.push({name, value: project.id});
-      }
-      return projects;
-    });
+    return this.restApiService
+      .getSuggestedRepos(input, /* n=*/ undefined, throwingErrorCallback)
+      .then(response => {
+        const repos: AutocompleteSuggestion[] = [];
+        for (const [name, repo] of Object.entries(response ?? {})) {
+          repos.push({name, value: repo.id});
+        }
+        return repos;
+      });
   }
 
   private handleRemoveProject(project: ProjectWatchInfo) {
diff --git a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_test.ts b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_test.ts
index 1280d6e..c608656 100644
--- a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_test.ts
+++ b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_test.ts
@@ -44,7 +44,7 @@
     ] as ProjectWatchInfo[];
 
     stubRestApi('getWatchedProjects').returns(Promise.resolve(projects));
-    suggestionStub = stubRestApi('getSuggestedProjects').callsFake(input => {
+    suggestionStub = stubRestApi('getSuggestedRepos').callsFake(input => {
       if (input.startsWith('th')) {
         return Promise.resolve({
           'the project': {
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 31e4f5d..d7c6c8b 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.ts
@@ -17,6 +17,8 @@
 import {customElement, property} from 'lit/decorators.js';
 import {ClassInfo, classMap} from 'lit/directives/class-map.js';
 import {getLabelStatus, hasVoted, LabelStatus} from '../../../utils/label-util';
+import {fire} from '../../../utils/event-util';
+import {RemoveAccountEvent} from '../../../types/events';
 
 @customElement('gr-account-chip')
 export class GrAccountChip extends LitElement {
@@ -196,13 +198,8 @@
 
   private handleRemoveTap(e: MouseEvent) {
     e.preventDefault();
-    this.dispatchEvent(
-      new CustomEvent('remove', {
-        detail: {account: this.account},
-        composed: true,
-        bubbles: true,
-      })
-    );
+    if (!this.account) return;
+    fire(this, 'remove-account', {account: this.account});
   }
 
   private getHasAvatars() {
@@ -232,4 +229,7 @@
   interface HTMLElementTagNameMap {
     'gr-account-chip': GrAccountChip;
   }
+  interface HTMLElementEventMap {
+    'remove-account': RemoveAccountEvent;
+  }
 }
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 0509925..2249b5d 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
@@ -11,9 +11,14 @@
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, PropertyValues, html, css} from 'lit';
 import {customElement, property, query, state} from 'lit/decorators.js';
-import {BindValueChangeEvent} from '../../../types/events';
+import {
+  AddAccountEvent,
+  AutocompleteCommitEvent,
+  BindValueChangeEvent,
+} from '../../../types/events';
 import {SuggestedReviewerInfo} from '../../../types/common';
 import {PaperInputElement} from '@polymer/paper-input/paper-input';
+import {fire} from '../../../utils/event-util';
 
 /**
  * gr-account-entry is an element for entering account
@@ -23,20 +28,6 @@
 export class GrAccountEntry extends LitElement {
   @query('#input') private input?: GrAutocomplete;
 
-  /**
-   * Fired when an account is entered.
-   *
-   * @event add
-   */
-
-  /**
-   * When allowAnyInput is true, account-text-changed is fired when input text
-   * changed. This is needed so that the reply dialog's save button can be
-   * enabled for arbitrary cc's, which don't need a 'commit'.
-   *
-   * @event account-text-changed
-   */
-
   @property({type: Boolean})
   allowAnyInput = false;
 
@@ -110,22 +101,14 @@
     return this.input!.text;
   }
 
-  private handleInputCommit(e: CustomEvent) {
-    this.dispatchEvent(
-      new CustomEvent('add', {
-        detail: {value: e.detail.value},
-        composed: true,
-        bubbles: true,
-      })
-    );
+  private handleInputCommit(e: AutocompleteCommitEvent) {
+    fire(this, 'add', {value: e.detail.value});
     this.input!.focus();
   }
 
   private inputTextChanged() {
     if (this.inputText.length && this.allowAnyInput) {
-      this.dispatchEvent(
-        new CustomEvent('account-text-changed', {bubbles: true, composed: true})
-      );
+      fire(this, 'account-text-changed', {});
     }
   }
 
@@ -138,4 +121,15 @@
   interface HTMLElementTagNameMap {
     'gr-account-entry': GrAccountEntry;
   }
+  interface HTMLElementEventMap {
+    /** Fired when an account is entered. */
+    // prettier-ignore
+    'add': AddAccountEvent;
+    /**
+     * When allowAnyInput is true, account-text-changed is fired when input text
+     * changed. This is needed so that the reply dialog's save button can be
+     * enabled for arbitrary cc's, which don't need a 'commit'.
+     */
+    'account-text-changed': CustomEvent<{}>;
+  }
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts
index aa5fd58e..cf7ff2209 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts
@@ -13,15 +13,16 @@
 import {isSelf, isServiceUser} from '../../../utils/account-util';
 import {ChangeInfo, AccountInfo, ServerInfo} from '../../../types/common';
 import {assertIsDefined, hasOwnProperty} from '../../../utils/common-util';
-import {fireEvent} from '../../../utils/event-util';
+import {fire} from '../../../utils/event-util';
 import {isInvolved} from '../../../utils/change-util';
-import {EventType, ShowAlertEventDetail} from '../../../types/events';
 import {LitElement, css, html, TemplateResult} from 'lit';
 import {customElement, property, state} from 'lit/decorators.js';
 import {classMap} from 'lit/directives/class-map.js';
 import {getRemovedByIconClickReason} from '../../../utils/attention-set-util';
 import {ifDefined} from 'lit/directives/if-defined.js';
 import {createSearchUrl} from '../../../models/views/search';
+import {accountsModelToken} from '../../../models/accounts-model/accounts-model';
+import {resolve} from '../../../models/dependency';
 
 @customElement('gr-account-label')
 export class GrAccountLabel extends LitElement {
@@ -97,7 +98,7 @@
 
   private readonly restApiService = getAppContext().restApiService;
 
-  private readonly accountsModel = getAppContext().accountsModel;
+  private readonly getAccountsModel = resolve(this, accountsModelToken);
 
   static override get styles() {
     return [
@@ -190,7 +191,7 @@
 
   override async updated() {
     assertIsDefined(this.account, 'account');
-    const account = await this.accountsModel.fillDetails(this.account);
+    const account = await this.getAccountsModel().fillDetails(this.account);
     if (account) this.account = account;
   }
 
@@ -362,16 +363,10 @@
     e.stopPropagation();
     if (!this.account._account_id) return;
 
-    this.dispatchEvent(
-      new CustomEvent<ShowAlertEventDetail>(EventType.SHOW_ALERT, {
-        detail: {
-          message: 'Saving attention set update ...',
-          dismissOnNavigation: true,
-        },
-        composed: true,
-        bubbles: true,
-      })
-    );
+    fire(this, 'show-alert', {
+      message: 'Saving attention set update ...',
+      dismissOnNavigation: true,
+    });
 
     // We are deliberately updating the UI before making the API call. It is a
     // risk that we are taking to achieve a better UX for 99.9% of the cases.
@@ -392,7 +387,7 @@
         reason
       )
       .then(() => {
-        fireEvent(this, 'hide-alert');
+        fire(this, 'hide-alert', {});
       });
   }
 
diff --git a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.ts b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.ts
index e7c0536..71f8391 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.ts
@@ -88,7 +88,7 @@
       /* HTML */ `
         <div class="container">
           <gr-hovercard-account for="hovercardTarget"></gr-hovercard-account>
-          <a class="ownerLink" href="/q/owner:user-31%2540" tabindex="-1">
+          <a class="ownerLink" href="/q/owner:user-31@" tabindex="-1">
             <span class="hovercardTargetWrapper">
               <gr-avatar hidden="" imagesize="32"> </gr-avatar>
               <span
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 0e5a840..965a9c4 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
@@ -16,7 +16,7 @@
   SuggestedReviewerInfo,
   isGroup,
 } from '../../../types/common';
-import {ReviewerSuggestionsProvider} from '../../../scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider';
+import {ReviewerSuggestionsProvider} from '../../../services/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider';
 import {GrAccountEntry} from '../gr-account-entry/gr-account-entry';
 import {GrAccountChip} from '../gr-account-chip/gr-account-chip';
 import {fire, fireAlert} from '../../../utils/event-util';
@@ -42,6 +42,7 @@
 import {PaperInputElement} from '@polymer/paper-input/paper-input';
 import {IronInputElement} from '@polymer/iron-input';
 import {ReviewerState} from '../../../api/rest-api';
+import {repeat} from 'lit/directives/repeat.js';
 
 const VALID_EMAIL_ALERT = 'Please input a valid email.';
 const VALID_USER_GROUP_ALERT = 'Please input a valid user or group.';
@@ -122,7 +123,7 @@
   constructor() {
     super();
     this.querySuggestions = input => this.getSuggestions(input);
-    this.addEventListener('remove', e =>
+    this.addEventListener('remove-account', e =>
       this.handleRemove(e as CustomEvent<{account: AccountInput}>)
     );
   }
@@ -156,7 +157,9 @@
 
   override render() {
     return html`<div class="list">
-        ${this.accounts.map(
+        ${repeat(
+          this.accounts,
+          account => getUserId(account),
           account => html`
             <gr-account-chip
               .account=${account}
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 368c8da..eaf8974 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
@@ -22,7 +22,7 @@
   queryAndAssert,
   waitUntil,
 } from '../../../test/test-utils';
-import {ReviewerSuggestionsProvider} from '../../../scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider';
+import {ReviewerSuggestionsProvider} from '../../../services/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider';
 import {
   AutocompleteSuggestion,
   GrAutocomplete,
@@ -31,7 +31,6 @@
 import {createChange} from '../../../test/test-data-generators';
 import {ReviewerState} from '../../../api/rest-api';
 import {fixture, html, assert} from '@open-wc/testing';
-import {EventType} from '../../../types/events';
 import {AccountInfoInput, RawAccountInput} from '../../../utils/account-util';
 
 class MockSuggestionsProvider implements ReviewerSuggestionsProvider {
@@ -151,7 +150,7 @@
 
     // Removed accounts are taken out of the list.
     element.dispatchEvent(
-      new CustomEvent('remove', {
+      new CustomEvent('remove-account', {
         detail: {account: existingAccount1},
         composed: true,
         bubbles: true,
@@ -165,14 +164,14 @@
 
     // Invalid remove is ignored.
     element.dispatchEvent(
-      new CustomEvent('remove', {
+      new CustomEvent('remove-account', {
         detail: {account: existingAccount1},
         composed: true,
         bubbles: true,
       })
     );
     element.dispatchEvent(
-      new CustomEvent('remove', {
+      new CustomEvent('remove-account', {
         detail: {account: newAccount},
         composed: true,
         bubbles: true,
@@ -194,7 +193,7 @@
 
     // Removed groups are taken out of the list.
     element.dispatchEvent(
-      new CustomEvent('remove', {
+      new CustomEvent('remove-account', {
         detail: {account: newGroup},
         composed: true,
         bubbles: true,
@@ -289,7 +288,7 @@
   test('addAccountItem with invalid item', () => {
     const toastHandler = sinon.stub();
     element.allowAnyInput = false;
-    element.addEventListener(EventType.SHOW_ALERT, toastHandler);
+    element.addEventListener('show-alert', toastHandler);
     const result = element.addAccountItem('test');
     assert.isFalse(result);
     assert.isTrue(toastHandler.called);
@@ -407,8 +406,8 @@
     );
     input.text = 'newTest';
     input.input!.focus();
-    input.noDebounce = true;
     await element.updateComplete;
+    await input.latestSuggestionUpdateComplete;
     assert.isTrue(getSuggestionsStub.calledOnce);
     assert.equal(getSuggestionsStub.lastCall.args[0], 'newTest');
     await waitUntil(() => makeSuggestionItemSpy.getCalls().length === 2);
@@ -431,7 +430,7 @@
 
     test('toasts on invalid email', () => {
       const toastHandler = sinon.stub();
-      element.addEventListener(EventType.SHOW_ALERT, toastHandler);
+      element.addEventListener('show-alert', toastHandler);
       handleAdd('test');
       assert.isTrue(toastHandler.called);
     });
diff --git a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.ts b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.ts
index 9b80282..9342715 100644
--- a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.ts
+++ b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.ts
@@ -5,7 +5,6 @@
  */
 import '../gr-button/gr-button';
 import '../../../styles/shared-styles';
-import {getRootElement} from '../../../scripts/rootElement';
 import {ErrorType} from '../../../types/types';
 import {LitElement, css, html} from 'lit';
 import {customElement, property} from 'lit/decorators.js';
@@ -170,14 +169,14 @@
     this.actionText = actionText;
     this._hideActionButton = !actionText;
     this._actionCallback = actionCallback;
-    getRootElement().appendChild(this);
+    document.body.appendChild(this);
     this.shown = true;
   }
 
   hide() {
     this.shown = false;
     if (this._hasZeroTransitionDuration()) {
-      getRootElement().removeChild(this);
+      document.body.removeChild(this);
     }
   }
 
@@ -197,7 +196,7 @@
       return;
     }
 
-    getRootElement().removeChild(this);
+    document.body.removeChild(this);
   }
 
   _handleActionTap(e: MouseEvent) {
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.ts b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.ts
index 3fd1b82..4b27948 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.ts
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.ts
@@ -6,11 +6,12 @@
 import '../gr-cursor-manager/gr-cursor-manager';
 import '../../../styles/shared-styles';
 import {GrCursorManager} from '../gr-cursor-manager/gr-cursor-manager';
-import {fireEvent} from '../../../utils/event-util';
+import {fire} from '../../../utils/event-util';
 import {Key} from '../../../utils/dom-util';
 import {FitController} from '../../lit/fit-controller';
 import {css, html, LitElement, PropertyValues} from 'lit';
 import {customElement, property, query} from 'lit/decorators.js';
+import {when} from 'lit/directives/when.js';
 import {repeat} from 'lit/directives/repeat.js';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {ShortcutController} from '../../lit/shortcut-controller';
@@ -19,6 +20,9 @@
   interface HTMLElementTagNameMap {
     'gr-autocomplete-dropdown': GrAutocompleteDropdown;
   }
+  interface HTMLElementEventMap {
+    'dropdown-closed': CustomEvent<{}>;
+  }
 }
 
 export interface Item {
@@ -29,11 +33,21 @@
   value?: string;
 }
 
-export interface ItemSelectedEvent {
+export interface ItemSelectedEventDetail {
   trigger: string;
   selected: HTMLElement | null;
 }
 
+export enum AutocompleteQueryStatusType {
+  LOADING = 'loading',
+  ERROR = 'error',
+}
+
+export interface AutocompleteQueryStatus {
+  type: AutocompleteQueryStatusType;
+  message: string;
+}
+
 @customElement('gr-autocomplete-dropdown')
 export class GrAutocompleteDropdown extends LitElement {
   /**
@@ -54,6 +68,12 @@
   @property({type: Boolean, reflect: true, attribute: 'is-hidden'})
   isHidden = true;
 
+  /** If specified a single non-interactable line is shown instead of
+   * suggestions.
+   */
+  @property({type: Object})
+  queryStatus?: AutocompleteQueryStatus;
+
   @property({type: Number})
   verticalOffset = 0;
 
@@ -79,6 +99,11 @@
       css`
         :host {
           z-index: 100;
+          box-shadow: var(--elevation-level-2);
+          overflow: auto;
+          background: var(--dropdown-background-color);
+          border-radius: var(--border-radius);
+          max-height: 50vh;
         }
         :host([is-hidden]) {
           display: none;
@@ -105,12 +130,13 @@
         li.selected {
           background-color: var(--hover-background-color);
         }
-        .dropdown-content {
-          background: var(--dropdown-background-color);
-          box-shadow: var(--elevation-level-2);
-          border-radius: var(--border-radius);
-          max-height: 50vh;
-          overflow: auto;
+        li.query-status {
+          background-color: var(--disabled-background);
+          cursor: default;
+        }
+        li.query-status.error {
+          color: var(--error-foreground);
+          white-space: pre-wrap;
         }
         @media only screen and (max-height: 35em) {
           .dropdown-content {
@@ -128,21 +154,25 @@
     ];
   }
 
+  private isSuggestionListInteractible() {
+    return !this.isHidden && !this.queryStatus;
+  }
+
   constructor() {
     super();
     this.cursor.cursorTargetClass = 'selected';
     this.cursor.focusOnMove = true;
-    this.shortcuts.addLocal({key: Key.UP}, () => this.handleUp());
-    this.shortcuts.addLocal({key: Key.DOWN}, () => this.handleDown());
+    this.shortcuts.addLocal({key: Key.UP, allowRepeat: true}, () =>
+      this.cursorUp()
+    );
+    this.shortcuts.addLocal({key: Key.DOWN, allowRepeat: true}, () =>
+      this.cursorDown()
+    );
     this.shortcuts.addLocal({key: Key.ENTER}, () => this.handleEnter());
     this.shortcuts.addLocal({key: Key.ESC}, () => this.handleEscape());
     this.shortcuts.addLocal({key: Key.TAB}, () => this.handleTab());
   }
 
-  override connectedCallback() {
-    super.connectedCallback();
-  }
-
   override disconnectedCallback() {
     this.cursor.unsetCursor();
     super.disconnectedCallback();
@@ -157,7 +187,8 @@
   override updated(changedProperties: PropertyValues) {
     if (
       changedProperties.has('suggestions') ||
-      changedProperties.has('isHidden')
+      changedProperties.has('isHidden') ||
+      changedProperties.has('queryStatus')
     ) {
       if (!this.isHidden) {
         this.computeCursorStopsAndRefit();
@@ -165,32 +196,50 @@
     }
   }
 
+  private renderStatus() {
+    return html`
+      <li
+        tabindex="-1"
+        aria-label="autocomplete query status"
+        class="query-status ${this.queryStatus?.type}"
+      >
+        <span>${this.queryStatus?.message}</span>
+        <span class="label"
+          >${this.queryStatus?.type === AutocompleteQueryStatusType.ERROR
+            ? 'ERROR'
+            : ''}</span
+        >
+      </li>
+    `;
+  }
+
   override render() {
     return html`
-      <div
-        class="dropdown-content"
-        slot="dropdown-content"
-        id="suggestions"
-        role="listbox"
-      >
+      <div class="dropdown-content" id="suggestions" role="listbox">
         <ul>
-          ${repeat(
-            this.suggestions,
-            (item, index) => html`
-              <li
-                data-index=${index}
-                data-value=${item.dataValue ?? ''}
-                tabindex="-1"
-                aria-label=${item.name ?? ''}
-                class="autocompleteOption"
-                role="option"
-                @click=${this.handleClickItem}
-              >
-                <span>${item.text}</span>
-                <span class="label ${this.computeLabelClass(item)}"
-                  >${item.label}</span
-                >
-              </li>
+          ${when(
+            this.queryStatus,
+            () => this.renderStatus(),
+            () => html`
+              ${repeat(
+                this.suggestions,
+                (item, index) => html`
+                  <li
+                    data-index=${index}
+                    data-value=${item.dataValue ?? ''}
+                    tabindex="-1"
+                    aria-label=${item.name ?? ''}
+                    class="autocompleteOption"
+                    role="option"
+                    @click=${this.handleClickItem}
+                  >
+                    <span>${item.text}</span>
+                    <span class="label ${this.computeLabelClass(item)}"
+                      >${item.label}</span
+                    >
+                  </li>
+                `
+              )}
             `
           )}
         </ul>
@@ -207,55 +256,42 @@
   }
 
   getCurrentText() {
-    return this.getCursorTarget()?.dataset['value'] || '';
+    if (!this.queryStatus) {
+      return this.getCursorTarget()?.dataset['value'] || '';
+    }
+    return '';
   }
 
   setPositionTarget(target: HTMLElement) {
-    this.fitController?.setPositionTarget(target);
-  }
-
-  private handleUp() {
-    if (!this.isHidden) this.cursorUp();
-  }
-
-  private handleDown() {
-    if (!this.isHidden) this.cursorDown();
+    this.fitController.setPositionTarget(target);
   }
 
   cursorDown() {
-    if (!this.isHidden) this.cursor.next();
+    if (this.isSuggestionListInteractible()) this.cursor.next();
   }
 
   cursorUp() {
-    if (!this.isHidden) this.cursor.previous();
+    if (this.isSuggestionListInteractible()) this.cursor.previous();
   }
 
   // private but used in tests
   handleTab() {
-    this.dispatchEvent(
-      new CustomEvent<ItemSelectedEvent>('item-selected', {
-        detail: {
-          trigger: 'tab',
-          selected: this.cursor.target,
-        },
-        composed: true,
-        bubbles: true,
-      })
-    );
+    if (this.isSuggestionListInteractible()) {
+      fire(this, 'item-selected', {
+        trigger: 'tab',
+        selected: this.cursor.target,
+      });
+    }
   }
 
   // private but used in tests
   handleEnter() {
-    this.dispatchEvent(
-      new CustomEvent<ItemSelectedEvent>('item-selected', {
-        detail: {
-          trigger: 'enter',
-          selected: this.cursor.target,
-        },
-        composed: true,
-        bubbles: true,
-      })
-    );
+    if (this.isSuggestionListInteractible()) {
+      fire(this, 'item-selected', {
+        trigger: 'enter',
+        selected: this.cursor.target,
+      });
+    }
   }
 
   private handleEscape() {
@@ -273,20 +309,14 @@
       }
       selected = selected.parentElement!;
     }
-    this.dispatchEvent(
-      new CustomEvent<ItemSelectedEvent>('item-selected', {
-        detail: {
-          trigger: 'click',
-          selected,
-        },
-        composed: true,
-        bubbles: true,
-      })
-    );
+    fire(this, 'item-selected', {
+      trigger: 'click',
+      selected,
+    });
   }
 
   private fireClose() {
-    fireEvent(this, 'dropdown-closed');
+    fire(this, 'dropdown-closed', {});
   }
 
   getCursorTarget() {
@@ -296,13 +326,13 @@
   computeCursorStopsAndRefit() {
     if (this.suggestions.length > 0) {
       this.cursor.stops = Array.from(
-        this.suggestionsDiv?.querySelectorAll('li') ?? []
+        this.suggestionsDiv?.querySelectorAll('li.autocompleteOption') ?? []
       );
       this.resetCursorIndex();
     } else {
       this.cursor.stops = [];
     }
-    this.fitController?.refit();
+    this.fitController.refit();
   }
 
   private setIndex() {
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.ts b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.ts
index 641dd2d..10ba5d0 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.ts
@@ -5,7 +5,10 @@
  */
 import '../../../test/common-test-setup';
 import './gr-autocomplete-dropdown';
-import {GrAutocompleteDropdown} from './gr-autocomplete-dropdown';
+import {
+  AutocompleteQueryStatusType,
+  GrAutocompleteDropdown,
+} from './gr-autocomplete-dropdown';
 import {
   pressKey,
   queryAll,
@@ -18,160 +21,261 @@
 import {Key} from '../../../utils/dom-util';
 
 suite('gr-autocomplete-dropdown', () => {
-  let element: GrAutocompleteDropdown;
+  suite('suggestion tests', () => {
+    let element: GrAutocompleteDropdown;
 
-  const suggestionsEl = () => queryAndAssert(element, '#suggestions');
+    const suggestionsEl = () => queryAndAssert(element, '#suggestions');
 
-  setup(async () => {
-    element = await fixture(
-      html`<gr-autocomplete-dropdown></gr-autocomplete-dropdown>`
-    );
-    element.open();
-    element.suggestions = [
-      {dataValue: 'test value 1', name: 'test name 1', text: '1', label: 'hi'},
-      {dataValue: 'test value 2', name: 'test name 2', text: '2'},
-    ];
-    await waitEventLoop();
-  });
+    setup(async () => {
+      element = await fixture(
+        html`<gr-autocomplete-dropdown></gr-autocomplete-dropdown>`
+      );
+      element.open();
+      element.suggestions = [
+        {
+          dataValue: 'test value 1',
+          name: 'test name 1',
+          text: '1',
+          label: 'hi',
+        },
+        {dataValue: 'test value 2', name: 'test name 2', text: '2'},
+      ];
+      await waitEventLoop();
+    });
 
-  teardown(() => {
-    element.close();
-  });
+    teardown(() => {
+      element.close();
+    });
 
-  test('renders', () => {
-    assert.shadowDom.equal(
-      element,
-      /* HTML */ `
-        <div
-          class="dropdown-content"
-          id="suggestions"
-          role="listbox"
-          slot="dropdown-content"
-        >
-          <ul>
-            <li
-              aria-label="test name 1"
-              class="autocompleteOption selected"
-              data-index="0"
-              data-value="test value 1"
-              role="option"
-              tabindex="-1"
-            >
-              <span> 1 </span>
-              <span class="label"> hi </span>
-            </li>
-            <li
-              aria-label="test name 2"
-              class="autocompleteOption"
-              data-index="1"
-              data-value="test value 2"
-              role="option"
-              tabindex="-1"
-            >
-              <span> 2 </span>
-              <span class="hide label"> </span>
-            </li>
-          </ul>
-        </div>
-      `
-    );
-  });
+    test('renders', () => {
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <div class="dropdown-content" id="suggestions" role="listbox">
+            <ul>
+              <li
+                aria-label="test name 1"
+                class="autocompleteOption selected"
+                data-index="0"
+                data-value="test value 1"
+                role="option"
+                tabindex="-1"
+              >
+                <span> 1 </span>
+                <span class="label"> hi </span>
+              </li>
+              <li
+                aria-label="test name 2"
+                class="autocompleteOption"
+                data-index="1"
+                data-value="test value 2"
+                role="option"
+                tabindex="-1"
+              >
+                <span> 2 </span>
+                <span class="hide label"> </span>
+              </li>
+            </ul>
+          </div>
+        `
+      );
+    });
 
-  test('shows labels', () => {
-    const els = queryAll<HTMLElement>(suggestionsEl(), 'li');
-    assert.equal(els[0].innerText.trim(), '1\nhi');
-    assert.equal(els[1].innerText.trim(), '2');
-  });
+    test('shows labels', () => {
+      const els = queryAll<HTMLElement>(suggestionsEl(), 'li');
+      assert.equal(els[0].innerText.trim(), '1\nhi');
+      assert.equal(els[1].innerText.trim(), '2');
+    });
 
-  test('escape key', async () => {
-    const closeSpy = sinon.spy(element, 'close');
-    pressKey(element, Key.ESC);
-    await waitEventLoop();
-    assert.isTrue(closeSpy.called);
-  });
+    test('escape key close suggestions', async () => {
+      const closeSpy = sinon.spy(element, 'close');
+      pressKey(element, Key.ESC);
+      await waitEventLoop();
+      assert.isTrue(closeSpy.called);
+    });
 
-  test('tab key', () => {
-    const handleTabSpy = sinon.spy(element, 'handleTab');
-    const itemSelectedStub = sinon.stub();
-    element.addEventListener('item-selected', itemSelectedStub);
-    pressKey(element, Key.TAB);
-    assert.isTrue(handleTabSpy.called);
-    assert.equal(element.cursor.index, 0);
-    assert.isTrue(itemSelectedStub.called);
-    assert.deepEqual(itemSelectedStub.lastCall.args[0].detail, {
-      trigger: 'tab',
-      selected: element.getCursorTarget(),
+    test('tab key', () => {
+      const handleTabSpy = sinon.spy(element, 'handleTab');
+      const itemSelectedStub = sinon.stub();
+      element.addEventListener('item-selected', itemSelectedStub);
+      pressKey(element, Key.TAB);
+      assert.isTrue(handleTabSpy.called);
+      assert.equal(element.cursor.index, 0);
+      assert.isTrue(itemSelectedStub.called);
+      assert.deepEqual(itemSelectedStub.lastCall.args[0].detail, {
+        trigger: 'tab',
+        selected: element.getCursorTarget(),
+      });
+    });
+
+    test('enter key', () => {
+      const handleEnterSpy = sinon.spy(element, 'handleEnter');
+      const itemSelectedStub = sinon.stub();
+      element.addEventListener('item-selected', itemSelectedStub);
+      pressKey(element, Key.ENTER);
+      assert.isTrue(handleEnterSpy.called);
+      assert.equal(element.cursor.index, 0);
+      assert.deepEqual(itemSelectedStub.lastCall.args[0].detail, {
+        trigger: 'enter',
+        selected: element.getCursorTarget(),
+      });
+    });
+
+    test('down key', () => {
+      element.isHidden = true;
+      const nextSpy = sinon.spy(element.cursor, 'next');
+      pressKey(element, 'ArrowDown');
+      assert.isFalse(nextSpy.called);
+      assert.equal(element.cursor.index, 0);
+      element.isHidden = false;
+      pressKey(element, 'ArrowDown');
+      assert.isTrue(nextSpy.called);
+      assert.equal(element.cursor.index, 1);
+    });
+
+    test('up key', () => {
+      element.isHidden = true;
+      const prevSpy = sinon.spy(element.cursor, 'previous');
+      pressKey(element, 'ArrowUp');
+      assert.isFalse(prevSpy.called);
+      assert.equal(element.cursor.index, 0);
+      element.isHidden = false;
+      element.cursor.setCursorAtIndex(1);
+      assert.equal(element.cursor.index, 1);
+      pressKey(element, 'ArrowUp');
+      assert.isTrue(prevSpy.called);
+      assert.equal(element.cursor.index, 0);
+    });
+
+    test('tapping selects item', async () => {
+      const itemSelectedStub = sinon.stub();
+      element.addEventListener('item-selected', itemSelectedStub);
+
+      suggestionsEl().querySelectorAll('li')[1].click();
+      await waitEventLoop();
+      assert.deepEqual(itemSelectedStub.lastCall.args[0].detail, {
+        trigger: 'click',
+        selected: suggestionsEl().querySelectorAll('li')[1],
+      });
+    });
+
+    test('tapping child still selects item', async () => {
+      const itemSelectedStub = sinon.stub();
+      element.addEventListener('item-selected', itemSelectedStub);
+      const lastElChild = queryAll<HTMLLIElement>(suggestionsEl(), 'li')[0]
+        ?.lastElementChild;
+      assertIsDefined(lastElChild);
+      (lastElChild as HTMLSpanElement).click();
+      await waitEventLoop();
+      assert.deepEqual(itemSelectedStub.lastCall.args[0].detail, {
+        trigger: 'click',
+        selected: queryAll<HTMLElement>(suggestionsEl(), 'li')[0],
+      });
+    });
+
+    test('updated suggestions resets cursor stops', async () => {
+      const resetStopsSpy = sinon.spy(element, 'computeCursorStopsAndRefit');
+      element.suggestions = [];
+      await waitUntil(() => resetStopsSpy.called);
     });
   });
 
-  test('enter key', () => {
-    const handleEnterSpy = sinon.spy(element, 'handleEnter');
-    const itemSelectedStub = sinon.stub();
-    element.addEventListener('item-selected', itemSelectedStub);
-    pressKey(element, Key.ENTER);
-    assert.isTrue(handleEnterSpy.called);
-    assert.equal(element.cursor.index, 0);
-    assert.deepEqual(itemSelectedStub.lastCall.args[0].detail, {
-      trigger: 'enter',
-      selected: element.getCursorTarget(),
+  suite('status tests', () => {
+    let element: GrAutocompleteDropdown;
+
+    setup(async () => {
+      element = await fixture(
+        html`<gr-autocomplete-dropdown></gr-autocomplete-dropdown>`
+      );
+      element.open();
+      element.queryStatus = {
+        type: AutocompleteQueryStatusType.ERROR,
+        message: 'Failed query error',
+      };
+      await waitEventLoop();
     });
-  });
 
-  test('down key', () => {
-    element.isHidden = true;
-    const nextSpy = sinon.spy(element.cursor, 'next');
-    pressKey(element, 'ArrowDown');
-    assert.isFalse(nextSpy.called);
-    assert.equal(element.cursor.index, 0);
-    element.isHidden = false;
-    pressKey(element, 'ArrowDown');
-    assert.isTrue(nextSpy.called);
-    assert.equal(element.cursor.index, 1);
-  });
-
-  test('up key', () => {
-    element.isHidden = true;
-    const prevSpy = sinon.spy(element.cursor, 'previous');
-    pressKey(element, 'ArrowUp');
-    assert.isFalse(prevSpy.called);
-    assert.equal(element.cursor.index, 0);
-    element.isHidden = false;
-    element.cursor.setCursorAtIndex(1);
-    assert.equal(element.cursor.index, 1);
-    pressKey(element, 'ArrowUp');
-    assert.isTrue(prevSpy.called);
-    assert.equal(element.cursor.index, 0);
-  });
-
-  test('tapping selects item', async () => {
-    const itemSelectedStub = sinon.stub();
-    element.addEventListener('item-selected', itemSelectedStub);
-
-    suggestionsEl().querySelectorAll('li')[1].click();
-    await waitEventLoop();
-    assert.deepEqual(itemSelectedStub.lastCall.args[0].detail, {
-      trigger: 'click',
-      selected: suggestionsEl().querySelectorAll('li')[1],
+    teardown(() => {
+      element.close();
     });
-  });
 
-  test('tapping child still selects item', async () => {
-    const itemSelectedStub = sinon.stub();
-    element.addEventListener('item-selected', itemSelectedStub);
-    const lastElChild = queryAll<HTMLLIElement>(suggestionsEl(), 'li')[0]
-      ?.lastElementChild;
-    assertIsDefined(lastElChild);
-    (lastElChild as HTMLSpanElement).click();
-    await waitEventLoop();
-    assert.deepEqual(itemSelectedStub.lastCall.args[0].detail, {
-      trigger: 'click',
-      selected: queryAll<HTMLElement>(suggestionsEl(), 'li')[0],
+    test('renders error', () => {
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <div class="dropdown-content" id="suggestions" role="listbox">
+            <ul>
+              <li
+                aria-label="autocomplete query status"
+                class="query-status error"
+                tabindex="-1"
+              >
+                <span>Failed query error</span>
+                <span class="label">ERROR</span>
+              </li>
+            </ul>
+          </div>
+        `
+      );
     });
-  });
 
-  test('updated suggestions resets cursor stops', async () => {
-    const resetStopsSpy = sinon.spy(element, 'computeCursorStopsAndRefit');
-    element.suggestions = [];
-    await waitUntil(() => resetStopsSpy.called);
+    test('renders loading', async () => {
+      element.queryStatus = {
+        type: AutocompleteQueryStatusType.LOADING,
+        message: 'Loading...',
+      };
+      await waitEventLoop();
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <div class="dropdown-content" id="suggestions" role="listbox">
+            <ul>
+              <li
+                aria-label="autocomplete query status"
+                class="query-status loading"
+                tabindex="-1"
+              >
+                <span>Loading...</span>
+                <span class="label"></span>
+              </li>
+            </ul>
+          </div>
+        `
+      );
+    });
+
+    test('escape key close dropdown with error', async () => {
+      const closeSpy = sinon.spy(element, 'close');
+      pressKey(element, Key.ESC);
+      await waitEventLoop();
+      assert.isTrue(closeSpy.called);
+    });
+
+    test('tab key when error shown sends no event', () => {
+      const handleTabSpy = sinon.spy(element, 'handleTab');
+      const itemSelectedStub = sinon.stub();
+      element.addEventListener('item-selected', itemSelectedStub);
+      pressKey(element, Key.TAB);
+      assert.isTrue(handleTabSpy.called);
+      assert.isFalse(itemSelectedStub.called);
+    });
+
+    test('enter key when error shown sends no event', () => {
+      const handleEnterSpy = sinon.spy(element, 'handleEnter');
+      const itemSelectedStub = sinon.stub();
+      element.addEventListener('item-selected', itemSelectedStub);
+      pressKey(element, Key.ENTER);
+      assert.isTrue(handleEnterSpy.called);
+      assert.isFalse(itemSelectedStub.called);
+    });
+
+    test('up/down disabled when error', () => {
+      const nextSpy = sinon.spy(element.cursor, 'next');
+      const prevSpy = sinon.spy(element.cursor, 'previous');
+      pressKey(element, 'ArrowUp');
+      pressKey(element, 'ArrowDown');
+      assert.isFalse(nextSpy.called);
+      assert.isFalse(prevSpy.called);
+    });
   });
 });
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.ts b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.ts
index d554f7c..fd1311c 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.ts
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.ts
@@ -6,11 +6,19 @@
 import '@polymer/paper-input/paper-input';
 import '../gr-autocomplete-dropdown/gr-autocomplete-dropdown';
 import '../gr-cursor-manager/gr-cursor-manager';
-import '../gr-icon/gr-icon';
 import '../../../styles/shared-styles';
-import {GrAutocompleteDropdown} from '../gr-autocomplete-dropdown/gr-autocomplete-dropdown';
-import {fire, fireEvent} from '../../../utils/event-util';
-import {debounce, DelayedTask} from '../../../utils/async-util';
+import {
+  AutocompleteQueryStatus,
+  AutocompleteQueryStatusType,
+  GrAutocompleteDropdown,
+  ItemSelectedEventDetail,
+} from '../gr-autocomplete-dropdown/gr-autocomplete-dropdown';
+import {fire} from '../../../utils/event-util';
+import {
+  debounce,
+  DelayedTask,
+  ResolvedDelayedTaskStatus,
+} from '../../../utils/async-util';
 import {PropertyType} from '../../../types/common';
 import {modifierPressed} from '../../../utils/dom-util';
 import {sharedStyles} from '../../../styles/shared-styles';
@@ -44,13 +52,6 @@
   text?: string;
 }
 
-export interface AutocompleteCommitEventDetail {
-  value: string;
-}
-
-export type AutocompleteCommitEvent =
-  CustomEvent<AutocompleteCommitEventDetail>;
-
 @customElement('gr-autocomplete')
 export class GrAutocomplete extends LitElement {
   /**
@@ -66,13 +67,6 @@
    */
 
   /**
-   * Fired on keydown to allow for custom hooks into autocomplete textbox
-   * behavior.
-   *
-   * @event input-keydown
-   */
-
-  /**
    * Query for requesting autocomplete suggestions. The function should
    * accept the input as a string parameter and return a promise. The
    * promise yields an array of suggestion objects with "name", "label",
@@ -81,6 +75,9 @@
    * next to the "name" as label text. The "value" property will be emitted
    * if that suggestion is selected.
    *
+   * If query fails, the function should return rejected promise containing
+   * an Error. The "message" property will be shown in a dropdown instead of
+   * rendering suggestions.
    */
   @property({type: Object})
   query?: AutocompleteQuery = () => Promise.resolve([]);
@@ -105,9 +102,6 @@
   @property({type: Boolean})
   disabled = false;
 
-  @property({type: Boolean, attribute: 'show-search-icon'})
-  showSearchIcon = false;
-
   /**
    * Vertical offset needed for an element with 20px line-height, 4px
    * padding and 1px border (30px height total). Plus 1px spacing between
@@ -151,12 +145,6 @@
   @property({type: Boolean, attribute: 'warn-uncommitted'})
   warnUncommitted = false;
 
-  /**
-   * When true, querying for suggestions is not debounced w/r/t keypresses
-   */
-  @property({type: Boolean, attribute: 'no-debounce'})
-  noDebounce = false;
-
   @property({type: Boolean, attribute: 'show-blue-focus-border'})
   showBlueFocusBorder = false;
 
@@ -169,6 +157,8 @@
 
   @state() suggestions: AutocompleteSuggestion[] = [];
 
+  @state() queryStatus?: AutocompleteQueryStatus;
+
   @state() index: number | null = null;
 
   // Enabled to suppress showing/updating suggestions when changing properties
@@ -180,8 +170,32 @@
 
   @state() selected: HTMLElement | null = null;
 
+  /**
+   * The query id that status or suggestions correspond to.
+   */
+  private activeQueryId = 0;
+
+  /**
+   * Last scheduled update suggestions task.
+   */
   private updateSuggestionsTask?: DelayedTask;
 
+  // Generate ids for scheduled suggestion queries to easily distinguish them.
+  private static NEXT_QUERY_ID = 1;
+
+  private static getNextQueryId() {
+    return GrAutocomplete.NEXT_QUERY_ID++;
+  }
+
+  /**
+   * @return Promise that resolves when suggestions are update.
+   */
+  get latestSuggestionUpdateComplete():
+    | Promise<ResolvedDelayedTaskStatus>
+    | undefined {
+    return this.updateSuggestionsTask?.promise;
+  }
+
   get nativeInput() {
     return (this.input!.inputElement as IronInputElement)
       .inputElement as HTMLInputElement;
@@ -190,15 +204,6 @@
   static override styles = [
     sharedStyles,
     css`
-      .searchIcon {
-        display: none;
-      }
-      .searchIcon.showSearchIcon {
-        display: inline-block;
-      }
-      gr-icon {
-        margin: 0 var(--spacing-xs);
-      }
       paper-input.borderless {
         border: none;
         padding: 0;
@@ -262,14 +267,13 @@
   }
 
   override willUpdate(changedProperties: PropertyValues) {
-    if (
-      changedProperties.has('text') ||
-      changedProperties.has('threshold') ||
-      changedProperties.has('noDebounce')
-    ) {
+    if (changedProperties.has('text') || changedProperties.has('threshold')) {
       this.updateSuggestions();
     }
-    if (changedProperties.has('suggestions')) {
+    if (
+      changedProperties.has('suggestions') ||
+      changedProperties.has('queryStatus')
+    ) {
       this.updateDropdownVisibility();
     }
     if (changedProperties.has('text')) {
@@ -288,7 +292,7 @@
         class=${this.computeClass()}
         ?disabled=${this.disabled}
         .value=${this.text}
-        @value-changed=${(e: CustomEvent) => {
+        @value-changed=${(e: ValueChangedEvent) => {
           this.text = e.detail.value;
         }}
         .placeholder=${this.placeholder}
@@ -299,12 +303,7 @@
         .label=${this.label}
       >
         <div slot="prefix">
-          <gr-icon
-            icon="search"
-            class="searchIcon ${this.computeShowSearchIconClass(
-              this.showSearchIcon
-            )}"
-          ></gr-icon>
+          <slot name="prefix"></slot>
         </div>
 
         <div slot="suffix">
@@ -317,6 +316,7 @@
         @item-selected=${this.handleItemSelect}
         @dropdown-closed=${this.focusWithoutDisplayingSuggestions}
         .suggestions=${this.suggestions}
+        .queryStatus=${this.queryStatus}
         role="listbox"
         .index=${this.index}
       >
@@ -353,14 +353,16 @@
     this.text = '';
   }
 
-  private handleItemSelectEnter(e: CustomEvent | KeyboardEvent) {
+  private handleItemSelectEnter(
+    e: CustomEvent<ItemSelectedEventDetail> | KeyboardEvent
+  ) {
     this.handleInputCommit();
     e.stopPropagation();
     e.preventDefault();
     this.focusWithoutDisplayingSuggestions();
   }
 
-  handleItemSelect(e: CustomEvent) {
+  handleItemSelect(e: CustomEvent<ItemSelectedEventDetail>) {
     if (e.detail.trigger === 'click') {
       this.selected = e.detail.selected;
       this._commit();
@@ -413,17 +415,12 @@
   }
 
   updateSuggestions() {
-    if (
-      this.text === undefined ||
-      this.threshold === undefined ||
-      this.noDebounce === undefined
-    )
-      return;
+    if (this.text === undefined || this.threshold === undefined) return;
 
     // Reset suggestions for every update
     // This will also prevent from carrying over suggestions:
     // @see Issue 12039
-    this.suggestions = [];
+    this.resetQueryOutput();
 
     // TODO(taoalpha): Also skip if text has not changed
 
@@ -431,8 +428,7 @@
       return;
     }
 
-    const query = this.query;
-    if (!query) {
+    if (!this.query) {
       return;
     }
 
@@ -445,32 +441,55 @@
       return;
     }
 
-    const requestText = this.text;
-    const update = () => {
-      query(this.text).then(suggestions => {
-        if (requestText !== this.text) {
-          // Late response.
-          return;
-        }
-        for (const suggestion of suggestions) {
-          suggestion.text = suggestion?.name ?? '';
-        }
-        this.suggestions = suggestions;
-        if (this.index === -1) {
-          this.value = '';
-        }
-      });
-    };
+    const queryId = GrAutocomplete.getNextQueryId();
+    this.activeQueryId = queryId;
+    this.setQueryStatus({
+      type: AutocompleteQueryStatusType.LOADING,
+      message: 'Loading...',
+    });
+    this.updateSuggestionsTask = debounce(
+      this.updateSuggestionsTask,
+      this.createUpdateTask(queryId, this.query, this.text),
+      DEBOUNCE_WAIT_MS
+    );
+  }
 
-    if (this.noDebounce) {
-      update();
-    } else {
-      this.updateSuggestionsTask = debounce(
-        this.updateSuggestionsTask,
-        update,
-        DEBOUNCE_WAIT_MS
-      );
-    }
+  private createUpdateTask(
+    queryId: number,
+    query: AutocompleteQuery,
+    text: string
+  ): () => Promise<void> {
+    return async () => {
+      let suggestions: AutocompleteSuggestion[];
+      try {
+        suggestions = await query(text);
+      } catch (e) {
+        this.value = '';
+        if (typeof e === 'string') {
+          this.setQueryStatus({
+            type: AutocompleteQueryStatusType.ERROR,
+            message: e,
+          });
+        } else if (e instanceof Error) {
+          this.setQueryStatus({
+            type: AutocompleteQueryStatusType.ERROR,
+            message: e.message,
+          });
+        }
+        return;
+      }
+      if (queryId !== this.activeQueryId) {
+        // Late response.
+        return;
+      }
+      for (const suggestion of suggestions) {
+        suggestion.text = suggestion?.name ?? '';
+      }
+      this.setSuggestions(suggestions);
+      if (this.index === -1) {
+        this.value = '';
+      }
+    };
   }
 
   setFocus(focused: boolean) {
@@ -479,8 +498,12 @@
     this.updateDropdownVisibility();
   }
 
+  private shouldShowDropdown() {
+    return (this.suggestions.length > 0 || this.queryStatus) && this.focused;
+  }
+
   updateDropdownVisibility() {
-    if (this.suggestions.length > 0 && this.focused) {
+    if (this.shouldShowDropdown()) {
       this.suggestionsDropdown?.open();
       return;
     }
@@ -513,10 +536,26 @@
         this.cancel();
         break;
       case 'Tab':
-        if (this.suggestions.length > 0 && this.tabComplete) {
+        if (
+          this.queryStatus?.type === AutocompleteQueryStatusType.LOADING &&
+          this.tabComplete
+        ) {
           e.preventDefault();
+          // Queue tab on load.
+          this.queryStatus = {
+            type: AutocompleteQueryStatusType.LOADING,
+            message: 'Loading... (Handle Tab on load)',
+          };
+          const queryId = this.activeQueryId;
+          this.latestSuggestionUpdateComplete?.then(() => {
+            if (queryId === this.activeQueryId) {
+              this.handleInputCommit(/* _tabComplete=*/ true);
+            }
+          });
+        } else if (this.suggestions.length > 0 && this.tabComplete) {
+          e.preventDefault();
+          this.handleInputCommit(/* _tabComplete=*/ true);
           this.focus();
-          this.handleInputCommit(true);
         } else {
           this.setFocus(false);
         }
@@ -525,11 +564,24 @@
         if (modifierPressed(e)) {
           break;
         }
-        if (this.suggestions.length > 0) {
+        e.preventDefault();
+        if (this.queryStatus?.type === AutocompleteQueryStatusType.LOADING) {
+          // Queue enter on load.
+          this.queryStatus = {
+            type: AutocompleteQueryStatusType.LOADING,
+            message: 'Loading... (Handle Enter on load)',
+          };
+          const queryId = this.activeQueryId;
+          this.latestSuggestionUpdateComplete?.then(() => {
+            if (queryId === this.activeQueryId) {
+              this.handleItemSelectEnter(e);
+            }
+          });
+        } else if (this.suggestions.length > 0) {
           // If suggestions are shown, act as if the keypress is in dropdown.
+          // suggestions length is 0 if error is shown.
           this.handleItemSelectEnter(e);
         } else {
-          e.preventDefault();
           this.handleInputCommit();
         }
         break;
@@ -542,29 +594,29 @@
         // been based on a previous input. Clear them. This prevents an
         // outdated suggestion from being used if the input keystroke is
         // immediately followed by a commit keystroke. @see Issue 8655
-        this.suggestions = [];
+        this.resetQueryOutput();
+        this.activeQueryId = 0;
     }
-    this.dispatchEvent(
-      new CustomEvent('input-keydown', {
-        detail: {key: e.key, input: this.input},
-        composed: true,
-        bubbles: true,
-      })
-    );
   }
 
   cancel() {
-    if (this.suggestions.length) {
-      this.suggestions = [];
+    if (this.shouldShowDropdown()) {
+      this.resetQueryOutput();
+      // If query is in flight by setting id to 0 we indicate that the results
+      // are outdated.
+      this.activeQueryId = 0;
       this.requestUpdate();
     } else {
-      fireEvent(this, 'cancel');
+      fire(this, 'cancel', {});
     }
   }
 
   handleInputCommit(_tabComplete?: boolean) {
-    // Nothing to do if the dropdown is not open.
-    if (!this.allowNonSuggestedValues && this.suggestionsDropdown?.isHidden) {
+    // Nothing to do if no suggestions.
+    if (
+      !this.allowNonSuggestedValues &&
+      (this.suggestionsDropdown?.isHidden || this.suggestions.length === 0)
+    ) {
       return;
     }
 
@@ -605,6 +657,7 @@
       }
     }
     this.setFocus(false);
+    this.activeQueryId = 0;
   };
 
   /**
@@ -641,23 +694,30 @@
       }
     }
 
-    this.suggestions = [];
+    this.resetQueryOutput();
     // we need willUpdate to send text-changed event before we can send the
     // 'commit' event
     await this.updateComplete;
     if (!silent) {
-      this.dispatchEvent(
-        new CustomEvent('commit', {
-          detail: {value} as AutocompleteCommitEventDetail,
-          composed: true,
-          bubbles: true,
-        })
-      );
+      fire(this, 'commit', {value});
     }
   }
 
-  computeShowSearchIconClass(showSearchIcon: boolean) {
-    return showSearchIcon ? 'showSearchIcon' : '';
+  // resetQueryOutput, setSuggestions and setQueryStatus insure that suggestions
+  // and queryStatus are never set at the same time.
+  private resetQueryOutput() {
+    this.suggestions = [];
+    this.queryStatus = undefined;
+  }
+
+  private setSuggestions(suggestions: AutocompleteSuggestion[]) {
+    this.suggestions = suggestions;
+    this.queryStatus = undefined;
+  }
+
+  private setQueryStatus(queryStatus: AutocompleteQueryStatus) {
+    this.suggestions = [];
+    this.queryStatus = queryStatus;
   }
 }
 
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.ts b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.ts
index d991180..0cef331 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.ts
@@ -6,8 +6,17 @@
 import '../../../test/common-test-setup';
 import './gr-autocomplete';
 import {AutocompleteSuggestion, GrAutocomplete} from './gr-autocomplete';
-import {pressKey, queryAndAssert, waitUntil} from '../../../test/test-utils';
-import {GrAutocompleteDropdown} from '../gr-autocomplete-dropdown/gr-autocomplete-dropdown';
+import {
+  assertFails,
+  mockPromise,
+  pressKey,
+  queryAndAssert,
+  waitUntil,
+} from '../../../test/test-utils';
+import {
+  AutocompleteQueryStatusType,
+  GrAutocompleteDropdown,
+} from '../gr-autocomplete-dropdown/gr-autocomplete-dropdown';
 import {PaperInputElement} from '@polymer/paper-input/paper-input';
 import {fixture, html, assert} from '@open-wc/testing';
 import {Key, Modifier} from '../../../utils/dom-util';
@@ -25,9 +34,7 @@
   const inputEl = () => queryAndAssert<HTMLInputElement>(element, '#input');
 
   setup(async () => {
-    element = await fixture(
-      html`<gr-autocomplete no-debounce></gr-autocomplete>`
-    );
+    element = await fixture(html`<gr-autocomplete></gr-autocomplete>`);
   });
 
   test('renders', () => {
@@ -41,7 +48,7 @@
           tabindex="0"
         >
           <div slot="prefix">
-            <gr-icon icon="search" class="searchIcon"></gr-icon>
+            <slot name="prefix"> </slot>
           </div>
           <div slot="suffix">
             <slot name="suffix"> </slot>
@@ -91,7 +98,7 @@
           tabindex="0"
         >
           <div slot="prefix">
-            <gr-icon icon="search" class="searchIcon"></gr-icon>
+            <slot name="prefix"> </slot>
           </div>
           <div slot="suffix">
             <slot name="suffix"> </slot>
@@ -109,6 +116,49 @@
     );
   });
 
+  test('renders with error', async () => {
+    const queryStub = sinon.spy((input: string) =>
+      Promise.reject(new Error(`${input} not allowed`))
+    );
+    element.query = queryStub;
+
+    focusOnInput();
+    element.text = 'blah';
+    await waitUntil(() => queryStub.called);
+    await element.updateComplete;
+
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <paper-input
+          aria-disabled="false"
+          autocomplete="off"
+          id="input"
+          tabindex="0"
+        >
+          <div slot="prefix">
+            <slot name="prefix"> </slot>
+          </div>
+          <div slot="suffix">
+            <slot name="suffix"> </slot>
+          </div>
+        </paper-input>
+        <gr-autocomplete-dropdown id="suggestions" role="listbox">
+        </gr-autocomplete-dropdown>
+      `,
+      {
+        // gr-autocomplete-dropdown sizing seems to vary between local & CI
+        ignoreAttributes: [
+          {tags: ['gr-autocomplete-dropdown'], attributes: ['style']},
+        ],
+      }
+    );
+    assert.equal(
+      element.suggestionsDropdown?.queryStatus?.message,
+      'blah not allowed'
+    );
+  });
+
   test('cursor starts on suggestions', async () => {
     const queryStub = sinon.spy((input: string) =>
       Promise.resolve([
@@ -181,17 +231,52 @@
     });
   });
 
-  test('emits commit and handles cursor movement', async () => {
+  test('esc key behavior on error', async () => {
     let promise: Promise<AutocompleteSuggestion[]> = Promise.resolve([]);
     const queryStub = sinon.spy(
-      (input: string) =>
-        (promise = Promise.resolve([
-          {name: input + ' 0', value: '0'},
-          {name: input + ' 1', value: '1'},
-          {name: input + ' 2', value: '2'},
-          {name: input + ' 3', value: '3'},
-          {name: input + ' 4', value: '4'},
-        ] as AutocompleteSuggestion[]))
+      (_: string) => (promise = Promise.reject(new Error('Test error')))
+    );
+    element.query = queryStub;
+
+    assert.isTrue(suggestionsEl().isHidden);
+
+    element.setFocus(true);
+    element.text = 'blah';
+    await element.updateComplete;
+
+    return assertFails(promise).then(async () => {
+      await element.latestSuggestionUpdateComplete;
+      await waitUntil(() => !suggestionsEl().isHidden);
+
+      const cancelHandler = sinon.spy();
+      element.addEventListener('cancel', cancelHandler);
+      assert.deepEqual(element.queryStatus, {
+        type: AutocompleteQueryStatusType.ERROR,
+        message: 'Test error',
+      });
+
+      pressKey(inputEl(), Key.ESC);
+      await waitUntil(() => suggestionsEl().isHidden);
+
+      assert.isFalse(cancelHandler.called);
+      assert.isUndefined(element.queryStatus);
+
+      pressKey(inputEl(), Key.ESC);
+      await element.updateComplete;
+
+      assert.isTrue(cancelHandler.called);
+    });
+  });
+
+  test('emits commit and handles cursor movement', async () => {
+    const queryStub = sinon.spy((input: string) =>
+      Promise.resolve([
+        {name: input + ' 0', value: '0'},
+        {name: input + ' 1', value: '1'},
+        {name: input + ' 2', value: '2'},
+        {name: input + ' 3', value: '3'},
+        {name: input + ' 4', value: '4'},
+      ] as AutocompleteSuggestion[])
     );
     element.query = queryStub;
     await element.updateComplete;
@@ -202,7 +287,7 @@
     element.text = 'blah';
     await element.updateComplete;
 
-    return promise.then(async () => {
+    return element.latestSuggestionUpdateComplete!.then(async () => {
       await waitUntil(() => !suggestionsEl().isHidden);
 
       const commitHandler = sinon.spy();
@@ -313,7 +398,6 @@
 
     element.query = queryStub;
     await element.updateComplete;
-    element.noDebounce = false;
     focusOnInput();
     element.text = 'a';
 
@@ -335,7 +419,6 @@
   test('empty text results in no suggestions', async () => {
     element.text = '';
     element.threshold = 0;
-    element.noDebounce = false;
     await element.updateComplete;
     assert.equal(element.suggestions.length, 0);
   });
@@ -380,29 +463,48 @@
   });
 
   test('suggestions should not carry over', async () => {
-    let promise: Promise<AutocompleteSuggestion[]> = Promise.resolve([]);
     const queryStub = sinon
       .stub()
-      .returns(
-        (promise = Promise.resolve([
-          {name: 'suggestion', value: '0'},
-        ] as AutocompleteSuggestion[]))
-      );
+      .resolves([{name: 'suggestion', value: '0'}] as AutocompleteSuggestion[]);
     element.query = queryStub;
     focusOnInput();
     element.text = 'bla';
     await element.updateComplete;
-    return promise.then(async () => {
+    return element.latestSuggestionUpdateComplete!.then(async () => {
       await waitUntil(() => element.suggestions.length > 0);
       assert.equal(element.suggestions.length, 1);
+
+      queryStub.resolves([] as AutocompleteSuggestion[]);
       element.text = '';
       element.threshold = 0;
-      element.noDebounce = false;
       await element.updateComplete;
+      await element.latestSuggestionUpdateComplete;
       assert.equal(element.suggestions.length, 0);
     });
   });
 
+  test('error should not carry over', async () => {
+    let promise: Promise<AutocompleteSuggestion[]> = Promise.resolve([]);
+    const queryStub = sinon
+      .stub()
+      .returns((promise = Promise.reject(new Error('Test error'))));
+    element.query = queryStub;
+    focusOnInput();
+    element.text = 'bla';
+    await element.updateComplete;
+    return assertFails(promise).then(async () => {
+      await element.latestSuggestionUpdateComplete;
+      await waitUntil(() => element.queryStatus?.message === 'Test error');
+
+      queryStub.resolves([] as AutocompleteSuggestion[]);
+      element.text = '';
+      element.threshold = 0;
+      await element.updateComplete;
+      await element.latestSuggestionUpdateComplete;
+      assert.isUndefined(element.queryStatus);
+    });
+  });
+
   test('multi completes only the last part of the query', async () => {
     let promise;
     const queryStub = sinon
@@ -421,6 +523,7 @@
     return promise.then(async () => {
       const commitHandler = sinon.spy();
       element.addEventListener('commit', commitHandler);
+      await element.latestSuggestionUpdateComplete;
       await waitUntil(() => element.suggestionsDropdown?.isHidden === false);
 
       pressKey(inputEl(), Key.ENTER);
@@ -431,15 +534,24 @@
   });
 
   test('tabComplete flag functions', async () => {
+    element.query = sinon
+      .stub()
+      .resolves([
+        {name: 'tunnel snakes rule!', value: 'snakes'},
+      ] as AutocompleteSuggestion[]);
+
     // commitHandler checks for the commit event, whereas commitSpy checks for
     // the _commit function of the element.
     const commitHandler = sinon.spy();
     element.addEventListener('commit', commitHandler);
     const commitSpy = sinon.spy(element, '_commit');
     element.setFocus(true);
-
-    element.suggestions = [{text: 'tunnel snakes rule!', name: ''}];
     element.tabComplete = false;
+    element.text = 'text1';
+    await element.updateComplete;
+
+    await element.latestSuggestionUpdateComplete;
+    await element.updateComplete;
     pressKey(inputEl(), Key.TAB);
     await element.updateComplete;
 
@@ -447,9 +559,12 @@
     assert.isFalse(commitSpy.called);
     assert.isFalse(element.focused);
 
-    element.tabComplete = true;
-    await element.updateComplete;
     element.setFocus(true);
+    element.tabComplete = true;
+    element.text = 'text2';
+    await element.updateComplete;
+
+    await element.latestSuggestionUpdateComplete;
     await element.updateComplete;
     pressKey(inputEl(), Key.TAB);
 
@@ -466,20 +581,6 @@
     assert.isTrue(element.focused);
   });
 
-  test('search icon shows with showSearchIcon property', async () => {
-    assert.equal(
-      getComputedStyle(queryAndAssert(element, 'gr-icon')).display,
-      'none'
-    );
-    element.showSearchIcon = true;
-    await element.updateComplete;
-
-    assert.notEqual(
-      getComputedStyle(queryAndAssert(element, 'gr-icon')).display,
-      'none'
-    );
-  });
-
   test('vertical offset overridden by param if it exists', async () => {
     assert.equal(suggestionsEl().verticalOffset, 31);
 
@@ -504,7 +605,7 @@
 
   test(
     'handleInputCommit with autocomplete hidden does nothing without' +
-      'without allowNonSuggestedValues',
+      ' allowNonSuggestedValues',
     () => {
       const commitStub = sinon.stub(element, '_commit');
       suggestionsEl().isHidden = true;
@@ -514,6 +615,21 @@
   );
 
   test(
+    'handleInputCommit with query error does nothing without' +
+      ' allowNonSuggestedValues',
+    () => {
+      const commitStub = sinon.stub(element, '_commit');
+      element.queryStatus = {
+        type: AutocompleteQueryStatusType.ERROR,
+        message: 'Error',
+      };
+      element.suggestions = [];
+      element.handleInputCommit();
+      assert.isFalse(commitStub.called);
+    }
+  );
+
+  test(
     'handleInputCommit with autocomplete hidden with' +
       'allowNonSuggestedValues',
     () => {
@@ -525,9 +641,25 @@
     }
   );
 
+  test(
+    'handleInputCommit with query error with' + 'allowNonSuggestedValues',
+    () => {
+      const commitStub = sinon.stub(element, '_commit');
+      element.allowNonSuggestedValues = true;
+      element.queryStatus = {
+        type: AutocompleteQueryStatusType.ERROR,
+        message: 'Error',
+      };
+      element.suggestions = [];
+      element.handleInputCommit();
+      assert.isTrue(commitStub.called);
+    }
+  );
+
   test('handleInputCommit with autocomplete open calls commit', () => {
     const commitStub = sinon.stub(element, '_commit');
     suggestionsEl().isHidden = false;
+    element.suggestions = [{name: 'first suggestion'}];
     element.handleInputCommit();
     assert.isTrue(commitStub.calledOnce);
   });
@@ -570,6 +702,215 @@
     assert.equal(element.text, 'file:x');
   });
 
+  test('render loading replace with suggestions when done', async () => {
+    const queryPromise = mockPromise<AutocompleteSuggestion[]>();
+    element.query = (_: string) => queryPromise;
+
+    element.setFocus(true);
+    element.text = 'blah';
+    await element.updateComplete;
+    await waitUntil(() => !suggestionsEl().isHidden);
+    assert.deepEqual(element.queryStatus, {
+      type: AutocompleteQueryStatusType.LOADING,
+      message: 'Loading...',
+    });
+
+    queryPromise.resolve([{name: 'suggestion 1'}] as AutocompleteSuggestion[]);
+    await element.latestSuggestionUpdateComplete;
+    await element.updateComplete;
+
+    assert.equal(element.suggestions.length, 1);
+    assert.isUndefined(element.queryStatus);
+  });
+
+  test('render loading replace with error when done', async () => {
+    const queryPromise = mockPromise<AutocompleteSuggestion[]>();
+    element.query = (_: string) => queryPromise;
+
+    element.setFocus(true);
+    element.text = 'blah';
+    await element.updateComplete;
+    await waitUntil(() => !suggestionsEl().isHidden);
+    assert.deepEqual(element.queryStatus, {
+      type: AutocompleteQueryStatusType.LOADING,
+      message: 'Loading...',
+    });
+
+    queryPromise.reject(new Error('Test error'));
+    await assertFails(queryPromise);
+    await element.latestSuggestionUpdateComplete;
+    await element.updateComplete;
+
+    assert.equal(element.suggestions.length, 0);
+    assert.deepEqual(element.queryStatus, {
+      type: AutocompleteQueryStatusType.ERROR,
+      message: 'Test error',
+    });
+  });
+
+  test('render loading esc cancels', async () => {
+    const queryPromise = mockPromise<AutocompleteSuggestion[]>();
+    element.query = (_: string) => queryPromise;
+
+    element.setFocus(true);
+    element.text = 'blah';
+    await element.updateComplete;
+    await waitUntil(() => !suggestionsEl().isHidden);
+    assert.deepEqual(element.queryStatus, {
+      type: AutocompleteQueryStatusType.LOADING,
+      message: 'Loading...',
+    });
+
+    const cancelHandler = sinon.spy();
+    element.addEventListener('cancel', cancelHandler);
+    pressKey(inputEl(), Key.ESC);
+    await waitUntil(() => suggestionsEl().isHidden);
+
+    assert.isFalse(cancelHandler.called);
+    assert.isUndefined(element.queryStatus);
+
+    pressKey(inputEl(), Key.ESC);
+    await element.updateComplete;
+
+    assert.isTrue(cancelHandler.called);
+  });
+
+  test('while loading queue enter commits', async () => {
+    const commitHandler = sinon.stub();
+    element.addEventListener('commit', commitHandler);
+    let resolvePromise: (value: AutocompleteSuggestion[]) => void;
+    const blockingPromise = new Promise<AutocompleteSuggestion[]>(resolve => {
+      resolvePromise = resolve;
+    });
+    element.query = (_: string) => blockingPromise;
+
+    element.setFocus(true);
+    element.text = 'blah';
+    await element.updateComplete;
+    await waitUntil(() => !suggestionsEl().isHidden);
+    assert.deepEqual(element.queryStatus, {
+      type: AutocompleteQueryStatusType.LOADING,
+      message: 'Loading...',
+    });
+
+    pressKey(inputEl(), Key.ENTER);
+    await element.updateComplete;
+    assert.deepEqual(element.queryStatus, {
+      type: AutocompleteQueryStatusType.LOADING,
+      message: 'Loading... (Handle Enter on load)',
+    });
+
+    resolvePromise!([{name: 'suggestion 1'}] as AutocompleteSuggestion[]);
+    await element.latestSuggestionUpdateComplete;
+    await element.updateComplete;
+
+    assert.equal(element.suggestions.length, 0);
+    assert.isUndefined(element.queryStatus);
+    assert.isTrue(commitHandler.called);
+  });
+
+  test('while loading queue tab completes', async () => {
+    element.tabComplete = true;
+    const commitHandler = sinon.stub();
+    element.addEventListener('commit', commitHandler);
+    const queryPromise = mockPromise<AutocompleteSuggestion[]>();
+    element.query = (_: string) => queryPromise;
+
+    element.setFocus(true);
+    element.text = 'blah';
+    await element.updateComplete;
+    await waitUntil(() => !suggestionsEl().isHidden);
+    assert.deepEqual(element.queryStatus, {
+      type: AutocompleteQueryStatusType.LOADING,
+      message: 'Loading...',
+    });
+
+    pressKey(inputEl(), Key.TAB);
+    await element.updateComplete;
+    assert.deepEqual(element.queryStatus, {
+      type: AutocompleteQueryStatusType.LOADING,
+      message: 'Loading... (Handle Tab on load)',
+    });
+
+    queryPromise.resolve([{name: 'suggestion 1'}] as AutocompleteSuggestion[]);
+    await element.latestSuggestionUpdateComplete;
+    await element.updateComplete;
+
+    assert.equal(element.suggestions.length, 0);
+    assert.isUndefined(element.queryStatus);
+    assert.isFalse(commitHandler.called);
+    assert.equal(element.text, 'suggestion 1');
+  });
+
+  test('while loading and queued update text cancels', async () => {
+    const commitHandler = sinon.stub();
+    element.addEventListener('commit', commitHandler);
+    const queryPromise = mockPromise<AutocompleteSuggestion[]>();
+    element.query = (_: string) => queryPromise;
+
+    element.setFocus(true);
+    element.text = 'blah';
+    await element.updateComplete;
+    await waitUntil(() => !suggestionsEl().isHidden);
+    assert.deepEqual(element.queryStatus, {
+      type: AutocompleteQueryStatusType.LOADING,
+      message: 'Loading...',
+    });
+
+    pressKey(inputEl(), Key.ENTER);
+    await element.updateComplete;
+    assert.deepEqual(element.queryStatus, {
+      type: AutocompleteQueryStatusType.LOADING,
+      message: 'Loading... (Handle Enter on load)',
+    });
+
+    element.text = 'more blah';
+    await element.updateComplete;
+
+    queryPromise.resolve([{name: 'suggestion 1'}] as AutocompleteSuggestion[]);
+    await element.latestSuggestionUpdateComplete;
+    await element.updateComplete;
+
+    // Commit for stale request is not called.
+    assert.isFalse(commitHandler.called);
+  });
+
+  test('while loading and queued esc cancels', async () => {
+    const commitHandler = sinon.stub();
+    element.addEventListener('commit', commitHandler);
+    const queryPromise = mockPromise<AutocompleteSuggestion[]>();
+    element.query = (_: string) => queryPromise;
+
+    element.setFocus(true);
+    element.text = 'blah';
+    await element.updateComplete;
+    await waitUntil(() => !suggestionsEl().isHidden);
+    assert.deepEqual(element.queryStatus, {
+      type: AutocompleteQueryStatusType.LOADING,
+      message: 'Loading...',
+    });
+
+    pressKey(inputEl(), Key.ENTER);
+    await element.updateComplete;
+    assert.deepEqual(element.queryStatus, {
+      type: AutocompleteQueryStatusType.LOADING,
+      message: 'Loading... (Handle Enter on load)',
+    });
+
+    pressKey(inputEl(), Key.ESC);
+    await element.updateComplete;
+
+    queryPromise.resolve([{name: 'suggestion 1'}] as AutocompleteSuggestion[]);
+    await element.latestSuggestionUpdateComplete;
+    await element.updateComplete;
+
+    // Commit for stale request is not called.
+    assert.isFalse(commitHandler.called);
+    // Query results and status are cleared
+    assert.equal(element.suggestions.length, 0);
+    assert.isUndefined(element.queryStatus);
+  });
+
   suite('focus', () => {
     let commitSpy: sinon.SinonSpy;
     let focusSpy: sinon.SinonSpy;
@@ -587,6 +928,24 @@
       await element.updateComplete;
 
       assert.equal(element.suggestions.length, 0);
+      assert.isUndefined(element.queryStatus);
+      assert.isTrue(suggestionsEl().isHidden);
+    });
+
+    test('enter in input does not re-render error', async () => {
+      element.allowNonSuggestedValues = true;
+      element.queryStatus = {
+        type: AutocompleteQueryStatusType.ERROR,
+        message: 'Error message',
+      };
+
+      pressKey(inputEl(), Key.ENTER);
+
+      await waitUntil(() => commitSpy.called);
+      await element.updateComplete;
+
+      assert.equal(element.suggestions.length, 0);
+      assert.isUndefined(element.queryStatus);
       assert.isTrue(suggestionsEl().isHidden);
     });
 
@@ -704,27 +1063,14 @@
     });
   });
 
-  test('input-keydown event fired', async () => {
-    const listener = sinon.spy();
-    element.addEventListener('input-keydown', listener);
-    pressKey(inputEl(), Key.TAB);
-    await element.updateComplete;
-    assert.isTrue(listener.called);
-  });
-
   test('enter with modifier does not complete', async () => {
-    const dispatchEventStub = sinon.stub(element, 'dispatchEvent');
     const commitStub = sinon.stub(element, 'handleInputCommit');
+
     pressKey(inputEl(), Key.ENTER, Modifier.CTRL_KEY);
     await element.updateComplete;
 
-    assert.equal(dispatchEventStub.lastCall.args[0].type, 'input-keydown');
-    assert.equal(
-      (dispatchEventStub.lastCall.args[0] as CustomEvent).detail.key,
-      Key.ENTER
-    );
-
     assert.isFalse(commitStub.called);
+
     pressKey(inputEl(), Key.ENTER);
     await element.updateComplete;
 
diff --git a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar-stack.ts b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar-stack.ts
index 4fa716b..1bfb55b 100644
--- a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar-stack.ts
+++ b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar-stack.ts
@@ -7,7 +7,10 @@
 import {AccountInfo} from '../../../types/common';
 import {LitElement, css, html} from 'lit';
 import {customElement, property, state} from 'lit/decorators.js';
-import {uniqueDefinedAvatar} from '../../../utils/account-util';
+import {
+  uniqueAccountId,
+  uniqueDefinedAvatar,
+} from '../../../utils/account-util';
 import {resolve} from '../../../models/dependency';
 import {configModelToken} from '../../../models/config/config-model';
 import {subscribe} from '../../lit/subscription-controller';
@@ -39,6 +42,15 @@
   imageSize = 16;
 
   /**
+   * In gr-app, gr-account-chip is in charge of loading a full account, so
+   * avatars will be set. However, code-owners will create gr-avatars with a
+   * bare account-id. To enable fetching of those avatars, a flag is added to
+   * gr-avatar that will disregard the absence of avatar urls.
+   */
+  @property({type: Boolean})
+  forceFetch = false;
+
+  /**
    * Reflects plugins.has_avatars value of server configuration.
    */
   @state() private hasAvatars = false;
@@ -74,9 +86,11 @@
   }
 
   override render() {
-    const uniqueAvatarAccounts = this.accounts
-      .filter(account => !!account?.avatars?.[0]?.url)
-      .filter(uniqueDefinedAvatar);
+    const uniqueAvatarAccounts = this.forceFetch
+      ? this.accounts.filter(uniqueAccountId)
+      : this.accounts
+          .filter(account => !!account?.avatars?.[0]?.url)
+          .filter(uniqueDefinedAvatar);
     if (
       !this.hasAvatars ||
       uniqueAvatarAccounts.length === 0 ||
@@ -86,7 +100,11 @@
     }
     return uniqueAvatarAccounts.map(
       account =>
-        html`<gr-avatar .account=${account} .imageSize=${this.imageSize}>
+        html`<gr-avatar
+          .forceFetch=${this.forceFetch}
+          .account=${account}
+          .imageSize=${this.imageSize}
+        >
         </gr-avatar>`
     );
   }
diff --git a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.ts b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.ts
index b34724c..8cfe2d0 100644
--- a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.ts
+++ b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.ts
@@ -4,11 +4,12 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import {getBaseUrl} from '../../../utils/url-util';
-import {getPluginLoader} from '../gr-js-api-interface/gr-plugin-loader';
 import {AccountInfo} from '../../../types/common';
 import {getAppContext} from '../../../services/app-context';
 import {LitElement, css, html} from 'lit';
 import {customElement, property, state} from 'lit/decorators.js';
+import {pluginLoaderToken} from '../gr-js-api-interface/gr-plugin-loader';
+import {resolve} from '../../../models/dependency';
 
 /**
  * The <gr-avatar> component works by updating its own background and visibility
@@ -24,8 +25,18 @@
 
   @state() private hasAvatars = false;
 
+  // In gr-app, gr-account-chip is in charge of loading a full account, so
+  // avatars will be set. However, code-owners will create gr-avatars with a
+  // bare account-id. To enable fetching of those avatars, a flag is added to
+  // gr-avatar that will disregard the absence of avatar urls.
+
+  @property({type: Boolean})
+  forceFetch = false;
+
   private readonly restApiService = getAppContext().restApiService;
 
+  private readonly getPluginLoader = resolve(this, pluginLoaderToken);
+
   static override get styles() {
     return [
       css`
@@ -54,7 +65,7 @@
     super.connectedCallback();
     Promise.all([
       this.restApiService.getConfig(),
-      getPluginLoader().awaitPluginsLoaded(),
+      this.getPluginLoader().awaitPluginsLoaded(),
     ]).then(([cfg]) => {
       this.hasAvatars = Boolean(cfg?.plugin?.has_avatars);
       this.updateHostVisibilityAndImage();
@@ -87,7 +98,7 @@
     const avatars = account.avatars || [];
     // if there is no avatar url in account, there is no avatar set on server,
     // and request /avatar?s will be 404.
-    if (avatars.length === 0) {
+    if (avatars.length === 0 && !this.forceFetch) {
       return '';
     }
     for (let i = 0; i < avatars.length; i++) {
diff --git a/polygerrit-ui/app/elements/shared/gr-button/gr-button.ts b/polygerrit-ui/app/elements/shared/gr-button/gr-button.ts
index f33f7d8..b44a16b 100644
--- a/polygerrit-ui/app/elements/shared/gr-button/gr-button.ts
+++ b/polygerrit-ui/app/elements/shared/gr-button/gr-button.ts
@@ -11,6 +11,7 @@
 import {addShortcut, getEventPath, Key} from '../../../utils/dom-util';
 import {getAppContext} from '../../../services/app-context';
 import {classMap} from 'lit/directives/class-map.js';
+import {Interaction} from '../../../constants/reporting';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -115,23 +116,6 @@
             var(--background-color);
         }
 
-        /* Some mobile browsers treat focused element as hovered element.
-        As a result, element remains hovered after click (has grey background in default theme).
-        Use @media (hover:none) to remove background if
-        user's primary input mechanism can't hover over elements.
-        See: https://developer.mozilla.org/en-US/docs/Web/CSS/@media/hover
-
-        Note 1: not all browsers support this media query
-        (see https://caniuse.com/#feat=css-media-interaction).
-        If browser doesn't support it, then the whole content of @media .. is ignored.
-        This is why the default behavior is placed outside of @media.
-        */
-        @media (hover: none) {
-          paper-button:hover {
-            background: transparent;
-          }
-        }
-
         :host([primary]) {
           --background-color: var(--primary-button-background-color);
           --text-color: var(--primary-button-text-color);
@@ -245,6 +229,8 @@
       return;
     }
 
-    this.reporting.reportInteraction('button-click', {path: getEventPath(e)});
+    this.reporting.reportInteraction(Interaction.BUTTON_CLICK, {
+      path: getEventPath(e),
+    });
   }
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.ts b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.ts
index 0bb451c..54fb825 100644
--- a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.ts
+++ b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.ts
@@ -14,6 +14,7 @@
 import {resolve} from '../../../models/dependency';
 import {shortcutsServiceToken} from '../../../services/shortcuts/shortcuts-service';
 import {assertIsDefined} from '../../../utils/common-util';
+import {fire} from '../../../utils/event-util';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -93,12 +94,6 @@
       change: this.change,
       starred: newVal,
     };
-    this.dispatchEvent(
-      new CustomEvent('toggle-star', {
-        bubbles: true,
-        composed: true,
-        detail,
-      })
-    );
+    fire(this, 'toggle-star', detail);
   }
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.ts b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.ts
index d2b9e2d..c93cc97 100644
--- a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.ts
+++ b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.ts
@@ -6,26 +6,11 @@
 import '../gr-icon/gr-icon';
 import '../gr-tooltip-content/gr-tooltip-content';
 import '../../../styles/shared-styles';
-import {ChangeInfo} from '../../../types/common';
-import {ParsedChangeInfo} from '../../../types/types';
+import {ChangeInfo, ChangeStates, WebLinkInfo} from '../../../types/common';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, PropertyValues, html, css} from 'lit';
 import {customElement, property, state} from 'lit/decorators.js';
 import {createSearchUrl} from '../../../models/views/search';
-import {GeneratedWebLink} from '../../../utils/weblink-util';
-
-export enum ChangeStates {
-  ABANDONED = 'Abandoned',
-  ACTIVE = 'Active',
-  MERGE_CONFLICT = 'Merge Conflict',
-  GIT_CONFLICT = 'Git Conflict',
-  MERGED = 'Merged',
-  PRIVATE = 'Private',
-  READY_TO_SUBMIT = 'Ready to submit',
-  REVERT_CREATED = 'Revert Created',
-  REVERT_SUBMITTED = 'Revert Submitted',
-  WIP = 'WIP',
-}
 
 export const WIP_TOOLTIP =
   "This change isn't ready to be reviewed or submitted. " +
@@ -50,9 +35,6 @@
   @property({type: Boolean, reflect: true})
   flat = false;
 
-  @property({type: Object})
-  change?: ChangeInfo | ParsedChangeInfo;
-
   @property({type: String})
   status?: ChangeStates;
 
@@ -64,7 +46,7 @@
   revertedChange?: ChangeInfo;
 
   @property({type: Object})
-  resolveWeblinks?: GeneratedWebLink[] = [];
+  resolveWeblinks?: WebLinkInfo[] = [];
 
   static override get styles() {
     return [
diff --git a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_test.ts b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_test.ts
index 4a046e7..89d30ee 100644
--- a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_test.ts
@@ -9,10 +9,11 @@
   TEST_NUMERIC_CHANGE_ID,
 } from '../../../test/test-data-generators';
 import './gr-change-status';
-import {ChangeStates, GrChangeStatus, WIP_TOOLTIP} from './gr-change-status';
+import {GrChangeStatus, WIP_TOOLTIP} from './gr-change-status';
 import {MERGE_CONFLICT_TOOLTIP} from './gr-change-status';
 import {fixture, html, assert} from '@open-wc/testing';
 import {queryAndAssert} from '../../../test/test-utils';
+import {ChangeStates} from '../../../types/common';
 
 const PRIVATE_TOOLTIP =
   'This change is only visible to its owner and ' +
@@ -79,7 +80,7 @@
     );
     assert.equal(element.tooltipText, '');
     assert.isTrue(element.classList.contains('merged'));
-    element.resolveWeblinks = [{url: 'http://google.com'}];
+    element.resolveWeblinks = [{name: 'browse', url: 'http://google.com'}];
     element.status = ChangeStates.MERGED;
     assert.isFalse(element.showResolveIcon());
   });
@@ -116,7 +117,7 @@
   test('merge conflict with resolve link', () => {
     const status = ChangeStates.MERGE_CONFLICT;
     const url = 'http://google.com';
-    const weblinks = [{url}];
+    const weblinks = [{name: 'browse', url}];
 
     element.revertedChange = undefined;
     element.resolveWeblinks = weblinks;
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 9dfbc04..c76f04c 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
@@ -19,18 +19,11 @@
 } from 'lit/decorators.js';
 import {
   computeDiffFromContext,
-  isDraft,
-  isRobot,
-  Comment,
-  CommentThread,
   getLastComment,
-  UnsavedInfo,
-  isDraftOrUnsaved,
-  createUnsavedComment,
   getFirstComment,
-  createUnsavedReply,
-  isUnsaved,
+  createNewReply,
   NEWLINE_PATTERN,
+  id,
 } from '../../../utils/comment-util';
 import {ChangeMessageId} from '../../../api/rest-api';
 import {getAppContext} from '../../../services/app-context';
@@ -41,7 +34,11 @@
 import {computeDisplayPath} from '../../../utils/path-list-util';
 import {
   AccountDetailInfo,
+  Comment,
   CommentRange,
+  CommentThread,
+  isDraft,
+  isRobot,
   NumericChangeId,
   RepoName,
   UrlEncodedCommentId,
@@ -51,7 +48,11 @@
 import {GrButton} from '../gr-button/gr-button';
 import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
 import {DiffLayer, RenderPreferences} from '../../../api/diff';
-import {assertIsDefined, copyToClipbard} from '../../../utils/common-util';
+import {
+  assert,
+  assertIsDefined,
+  copyToClipbard,
+} from '../../../utils/common-util';
 import {fire} from '../../../utils/event-util';
 import {GrSyntaxLayerWorker} from '../../../embed/diff/gr-syntax-layer/gr-syntax-layer-worker';
 import {TokenHighlightLayer} from '../../../embed/diff/gr-diff-builder/token-highlight-layer';
@@ -70,10 +71,9 @@
 import {commentsModelToken} from '../../../models/comments/comments-model';
 import {changeModelToken} from '../../../models/change/change-model';
 import {whenRendered} from '../../../utils/dom-util';
-import {Interaction} from '../../../constants/reporting';
-import {HtmlPatched} from '../../../utils/lit-util';
-import {createDiffUrl} from '../../../models/views/diff';
-import {createChangeUrl} from '../../../models/views/change';
+import {createChangeUrl, createDiffUrl} from '../../../models/views/change';
+import {userModelToken} from '../../../models/user/user-model';
+import {highlightServiceToken} from '../../../services/highlight/highlight-service';
 
 declare global {
   interface HTMLElementEventMap {
@@ -129,13 +129,14 @@
   thread?: CommentThread;
 
   /**
-   * Id of the first comment and thus must not change. Will be derived from
+   * Id of the first comment, must not change. Will be derived from
    * the `thread` property in the first willUpdate() cycle.
    *
    * The `rootId` property is also used in gr-diff for maintaining lists and
    * maps of threads and their associated elements.
    *
-   * Only stays `undefined` for new threads that only have an unsaved comment.
+   * For newly created threads in this session the `client_id` property  of the
+   * first comment will be used instead of the `id` property.
    */
   @property({type: String})
   rootId?: UrlEncodedCommentId;
@@ -191,15 +192,6 @@
   @property({type: Boolean, attribute: 'false'})
   editing = false;
 
-  /**
-   * This can either be an unsaved reply to the last comment or the unsaved
-   * content of a brand new comment thread (then `comments` is empty).
-   * If set, then `thread.comments` must not contain a draft. A thread can only
-   * contain *either* an unsaved comment *or* a draft, not both.
-   */
-  @state()
-  unsavedComment?: UnsavedInfo;
-
   @state()
   changeNum?: NumericChangeId;
 
@@ -247,28 +239,18 @@
   @state()
   saving = false;
 
-  // Private but used in tests.
-  readonly getCommentsModel = resolve(this, commentsModelToken);
+  private readonly getCommentsModel = resolve(this, commentsModelToken);
 
   private readonly getChangeModel = resolve(this, changeModelToken);
 
-  private readonly userModel = getAppContext().userModel;
-
-  private readonly reporting = getAppContext().reportingService;
+  private readonly getUserModel = resolve(this, userModelToken);
 
   private readonly shortcuts = new ShortcutController(this);
 
-  private readonly syntaxLayer = new GrSyntaxLayerWorker();
-
-  // for COMMENTS_AUTOCLOSE logging purposes only
-  readonly uid = performance.now().toString(36) + Math.random().toString(36);
-
-  private readonly patched = new HtmlPatched(key => {
-    this.reporting.reportInteraction(Interaction.AUTOCLOSE_HTML_PATCHED, {
-      component: this.tagName,
-      key: key.substring(0, 300),
-    });
-  });
+  private readonly syntaxLayer = new GrSyntaxLayerWorker(
+    resolve(this, highlightServiceToken),
+    () => getAppContext().reportingService
+  );
 
   constructor() {
     super();
@@ -281,7 +263,7 @@
     );
     subscribe(
       this,
-      () => this.userModel.account$,
+      () => this.getUserModel().account$,
       x => (this.account = x)
     );
     subscribe(
@@ -291,12 +273,12 @@
     );
     subscribe(
       this,
-      () => this.userModel.diffPreferences$,
+      () => this.getUserModel().diffPreferences$,
       x => this.syntaxLayer.setEnabled(!!x.syntax_highlighting)
     );
     subscribe(
       this,
-      () => this.userModel.preferences$,
+      () => this.getUserModel().preferences$,
       prefs => {
         const layers: DiffLayer[] = [this.syntaxLayer];
         if (!prefs.disable_token_highlighting) {
@@ -307,7 +289,7 @@
     );
     subscribe(
       this,
-      () => this.userModel.diffPreferences$,
+      () => this.getUserModel().diffPreferences$,
       prefs => {
         this.prefs = {
           ...prefs,
@@ -319,15 +301,6 @@
     );
   }
 
-  override disconnectedCallback() {
-    if (this.editing) {
-      this.reporting.reportInteraction(
-        Interaction.COMMENTS_AUTOCLOSE_EDITING_THREAD_DISCONNECTED
-      );
-    }
-    super.disconnectedCallback();
-  }
-
   static override get styles() {
     return [
       a11yStyles,
@@ -497,52 +470,53 @@
   renderComments() {
     assertIsDefined(this.thread, 'thread');
     const publishedComments = repeat(
-      this.thread.comments.filter(c => !isDraftOrUnsaved(c)),
+      this.thread.comments.filter(c => !isDraft(c)),
       comment => comment.id,
       comment => this.renderComment(comment)
     );
     // We are deliberately not including the draft in the repeat directive,
     // because we ran into spurious issues with <gr-comment> being destroyed
     // and re-created when an unsaved draft transitions to 'saved' state.
-    const draftComment = this.renderComment(this.getDraftOrUnsaved());
+    // TODO: Revisit this, because this transition should not cause issues
+    // anymore. Just put the draft into the `repeat` directive above and
+    // then use `id()` instead of `.id` above.
+    const draftComment = this.renderComment(this.getDraft());
     return html`${publishedComments}${draftComment}`;
   }
 
   private renderComment(comment?: Comment) {
     if (!comment) return nothing;
-    const robotButtonDisabled = !this.account || this.isDraftOrUnsaved();
+    const robotButtonDisabled = !this.account || this.isDraft();
+    const isFirstComment = this.getFirstComment() === comment;
     const initiallyCollapsed =
-      !isDraftOrUnsaved(comment) &&
+      !isDraft(comment) &&
       (this.messageId
         ? comment.change_message_id !== this.messageId
         : !this.unresolved);
-    return this.patched.html`
+    return html`
       <gr-comment
         .comment=${comment}
         .comments=${this.thread!.comments}
         ?initially-collapsed=${initiallyCollapsed}
         ?robot-button-disabled=${robotButtonDisabled}
         ?show-patchset=${this.showPatchset}
-        ?show-ported-comment=${
-          this.showPortedComment && comment.id === this.rootId
-        }
+        ?show-ported-comment=${this.showPortedComment && isFirstComment}
         @reply-to-comment=${this.handleReplyToComment}
         @copy-comment-link=${this.handleCopyLink}
         @comment-editing-changed=${(
           e: CustomEvent<CommentEditingChangedDetail>
         ) => {
-          if (isDraftOrUnsaved(comment)) this.editing = e.detail.editing;
+          if (isDraft(comment)) this.editing = e.detail.editing;
         }}
         @comment-unresolved-changed=${(e: ValueChangedEvent<boolean>) => {
-          if (isDraftOrUnsaved(comment)) this.unresolved = e.detail.value;
+          if (isDraft(comment)) this.unresolved = e.detail.value;
         }}
       ></gr-comment>
     `;
   }
 
   renderActions() {
-    if (!this.account || this.isDraftOrUnsaved() || this.isRobotComment())
-      return;
+    if (!this.account || this.isDraft() || this.isRobotComment()) return;
     return html`
       <div id="actionsContainer">
         <span id="unresolvedLabel">${
@@ -634,9 +608,6 @@
     if (this.firstWillUpdateDone) return;
     this.firstWillUpdateDone = true;
 
-    if (this.getFirstComment() === undefined) {
-      this.unsavedComment = createUnsavedComment(this.thread);
-    }
     this.unresolved = this.getLastComment()?.unresolved ?? true;
     this.diff = this.computeDiff();
     this.highlightRange = this.computeHighlightRange();
@@ -645,28 +616,18 @@
   override willUpdate(changed: PropertyValues) {
     this.firstWillUpdate();
     if (changed.has('thread')) {
-      if (!this.isDraftOrUnsaved()) {
+      assertIsDefined(this.thread, 'thread');
+      assertIsDefined(this.getFirstComment(), 'first comment');
+      if (!this.isDraft()) {
         // We can only do this for threads without draft, because otherwise we
         // are relying on the <gr-comment> component for the draft to fire
         // events about the *dirty* `unresolved` state.
         this.unresolved = this.getLastComment()?.unresolved ?? true;
       }
-      this.hasDraft = this.isDraftOrUnsaved();
-      this.rootId = this.getFirstComment()?.id;
-      if (this.isDraft()) {
-        this.unsavedComment = undefined;
-      }
+      this.hasDraft = this.isDraft();
+      this.rootId = id(this.getFirstComment()!);
     }
     if (changed.has('editing')) {
-      // changed.get('editing') contains the old value. We only want to trigger
-      // when changing from editing to non-editing (user has cancelled/saved).
-      // We do *not* want to trigger on first render (old value is `null`)
-      if (!this.editing && changed.get('editing') === true) {
-        this.unsavedComment = undefined;
-        if (this.thread?.comments.length === 0) {
-          this.remove();
-        }
-      }
       fire(this, 'comment-thread-editing-changed', {value: this.editing});
     }
   }
@@ -691,24 +652,11 @@
     return isDraft(this.getLastComment());
   }
 
-  private isDraftOrUnsaved(): boolean {
-    return this.isDraft() || this.isUnsaved();
-  }
-
-  private getDraftOrUnsaved(): Comment | undefined {
-    if (this.unsavedComment) return this.unsavedComment;
+  private getDraft(): Comment | undefined {
     if (this.isDraft()) return this.getLastComment();
     return undefined;
   }
 
-  private isNewThread(): boolean {
-    return this.thread?.comments.length === 0;
-  }
-
-  private isUnsaved(): boolean {
-    return !!this.unsavedComment || this.thread?.comments.length === 0;
-  }
-
   private isPatchsetLevel() {
     return this.thread?.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS;
   }
@@ -738,12 +686,11 @@
     if (!this.changeNum || !this.repoName || !this.thread?.path) {
       return undefined;
     }
-    if (this.isNewThread()) return undefined;
     return createDiffUrl({
       changeNum: this.changeNum,
-      project: this.repoName,
-      path: this.thread.path,
+      repo: this.repoName,
       patchNum: this.thread.patchNum,
+      diffView: {path: this.thread.path},
     });
   }
 
@@ -764,14 +711,12 @@
 
   // Does not work for patchset level comments
   private getUrlForFileComment() {
-    if (!this.repoName || !this.changeNum || this.isNewThread()) {
-      return undefined;
-    }
-    assertIsDefined(this.rootId, 'rootId of comment thread');
+    const id = this.getFirstComment()?.id;
+    if (!id || !this.repoName || !this.changeNum) return undefined;
     return createDiffUrl({
       changeNum: this.changeNum,
-      project: this.repoName,
-      commentId: this.rootId,
+      repo: this.repoName,
+      commentId: id,
     });
   }
 
@@ -784,13 +729,13 @@
     if (this.isPatchsetLevel()) {
       url = createChangeUrl({
         changeNum: this.changeNum,
-        project: this.repoName,
+        repo: this.repoName,
         commentId: comment.id,
       });
     } else {
       url = createDiffUrl({
         changeNum: this.changeNum,
-        project: this.repoName,
+        repo: this.repoName,
         commentId: comment.id,
       });
     }
@@ -848,19 +793,14 @@
     const replyingTo = this.getLastComment();
     assertIsDefined(this.thread, 'thread');
     assertIsDefined(replyingTo, 'the comment that the user wants to reply to');
-    if (isDraft(replyingTo)) {
-      throw new Error('cannot reply to draft');
-    }
-    if (isUnsaved(replyingTo)) {
-      throw new Error('cannot reply to unsaved comment');
-    }
-    const unsaved = createUnsavedReply(replyingTo, content, unresolved);
+    assert(!isDraft(replyingTo), 'cannot reply to draft');
+    const newReply = createNewReply(replyingTo, content, unresolved);
     if (userWantsToEdit) {
-      this.unsavedComment = unsaved;
+      this.getCommentsModel().addNewDraft(newReply);
     } else {
       try {
         this.saving = true;
-        await this.getCommentsModel().saveDraft(unsaved);
+        await this.getCommentsModel().saveDraft(newReply);
       } finally {
         this.saving = false;
       }
@@ -880,7 +820,7 @@
   }
 
   private handleCommentAck() {
-    this.createReplyComment('Ack', false, false);
+    this.createReplyComment('Acknowledged', false, false);
   }
 
   private handleCommentDone() {
@@ -896,7 +836,7 @@
     const author = this.getFirstComment()?.author ?? this.account;
     const user = getUserName(undefined, author);
     const unresolvedStatus = this.unresolved ? 'Unresolved ' : '';
-    const draftStatus = this.isDraftOrUnsaved() ? 'Draft ' : '';
+    const draftStatus = this.isDraft() ? 'Draft ' : '';
     return `${unresolvedStatus}${draftStatus}Comment thread by ${user}`;
   }
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts
index 0a3df6d..1027a62 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts
@@ -5,7 +5,7 @@
  */
 import '../../../test/common-test-setup';
 import './gr-comment-thread';
-import {DraftInfo, sortComments} from '../../../utils/comment-util';
+import {sortComments} from '../../../utils/comment-util';
 import {GrCommentThread} from './gr-comment-thread';
 import {
   NumericChangeId,
@@ -13,6 +13,8 @@
   Timestamp,
   CommentInfo,
   RepoName,
+  DraftInfo,
+  SavingState,
 } from '../../../types/common';
 import {
   mockPromise,
@@ -24,21 +26,27 @@
 import {
   createAccountDetailWithId,
   createThread,
+  createNewDraft,
 } from '../../../test/test-data-generators';
-import {SinonStub} from 'sinon';
-import {fixture, html, waitUntil, assert} from '@open-wc/testing';
+import {SinonStubbedMember} from 'sinon';
+import {fixture, html, assert} from '@open-wc/testing';
 import {GrButton} from '../gr-button/gr-button';
 import {SpecialFilePath} from '../../../constants/constants';
 import {GrIcon} from '../gr-icon/gr-icon';
+import {
+  CommentsModel,
+  commentsModelToken,
+} from '../../../models/comments/comments-model';
+import {testResolver} from '../../../test/common-test-setup';
 
-const c1 = {
+const c1: CommentInfo = {
   author: {name: 'Kermit'},
   id: 'the-root' as UrlEncodedCommentId,
   message: 'start the conversation',
   updated: '2021-11-01 10:11:12.000000000' as Timestamp,
 };
 
-const c2 = {
+const c2: CommentInfo = {
   author: {name: 'Ms Piggy'},
   id: 'the-reply' as UrlEncodedCommentId,
   message: 'keep it going',
@@ -46,13 +54,13 @@
   in_reply_to: 'the-root' as UrlEncodedCommentId,
 };
 
-const c3 = {
+const c3: DraftInfo = {
   author: {name: 'Kermit'},
   id: 'the-draft' as UrlEncodedCommentId,
   message: 'stop it',
   updated: '2021-11-03 10:11:12.000000000' as Timestamp,
   in_reply_to: 'the-reply' as UrlEncodedCommentId,
-  __draft: true,
+  savingState: SavingState.OK,
 };
 
 const commentWithContext = {
@@ -120,23 +128,23 @@
   });
 
   test('renders unsaved', async () => {
-    element.thread = createThread();
+    element.thread = createThread(createNewDraft());
     await element.updateComplete;
     assert.shadowDom.equal(
       element,
       /* HTML */ `
         <div class="fileName">
-          <span>test-path-comment-thread</span>
+          <a href="/c/test-repo-name/+/1/1/test-path-comment-thread">
+            test-path-comment-thread
+          </a>
           <gr-copy-clipboard hideinput=""></gr-copy-clipboard>
         </div>
         <div class="pathInfo">
           <span>#314</span>
         </div>
         <div id="container">
-          <h3 class="assistive-tech-only">
-            Unresolved Draft Comment thread by Yoda
-          </h3>
-          <div class="comment-box unresolved" tabindex="0">
+          <h3 class="assistive-tech-only">Draft Comment thread by Yoda</h3>
+          <div class="comment-box" tabindex="0">
             <gr-comment robot-button-disabled="" show-patchset=""></gr-comment>
           </div>
         </div>
@@ -307,23 +315,25 @@
 
   suite('action button clicks', () => {
     let savePromise: MockPromise<DraftInfo>;
-    let stub: SinonStub;
+    let stubSave: SinonStubbedMember<CommentsModel['saveDraft']>;
+    let stubAdd: SinonStubbedMember<CommentsModel['addNewDraft']>;
 
     setup(async () => {
       savePromise = mockPromise<DraftInfo>();
-      stub = sinon
-        .stub(element.getCommentsModel(), 'saveDraft')
+      stubSave = sinon
+        .stub(testResolver(commentsModelToken), 'saveDraft')
         .returns(savePromise);
+      stubAdd = sinon.stub(testResolver(commentsModelToken), 'addNewDraft');
 
       element.thread = createThread(c1, {...c2, unresolved: true});
       await element.updateComplete;
     });
 
-    test('handle Ack', async () => {
+    test('handle Acknowledge', async () => {
       queryAndAssert<GrButton>(element, '#ackBtn').click();
-      waitUntilCalled(stub, 'saveDraft()');
-      assert.equal(stub.lastCall.firstArg.message, 'Ack');
-      assert.equal(stub.lastCall.firstArg.unresolved, false);
+      waitUntilCalled(stubSave, 'saveDraft()');
+      assert.equal(stubSave.lastCall.firstArg.message, 'Acknowledged');
+      assert.equal(stubSave.lastCall.firstArg.unresolved, false);
       assert.isTrue(element.saving);
 
       savePromise.resolve();
@@ -333,47 +343,23 @@
 
     test('handle Done', async () => {
       queryAndAssert<GrButton>(element, '#doneBtn').click();
-      waitUntilCalled(stub, 'saveDraft()');
-      assert.equal(stub.lastCall.firstArg.message, 'Done');
-      assert.equal(stub.lastCall.firstArg.unresolved, false);
+      waitUntilCalled(stubSave, 'saveDraft()');
+      assert.equal(stubSave.lastCall.firstArg.message, 'Done');
+      assert.equal(stubSave.lastCall.firstArg.unresolved, false);
     });
 
     test('handle Reply', async () => {
-      assert.isUndefined(element.unsavedComment);
+      assert.equal(element.thread?.comments.length, 2);
       queryAndAssert<GrButton>(element, '#replyBtn').click();
-      assert.equal(element.unsavedComment?.message, '');
+      assert.isTrue(stubAdd.called);
+      assert.equal(stubAdd.lastCall.firstArg.message, '');
     });
 
     test('handle Quote', async () => {
-      assert.isUndefined(element.unsavedComment);
+      assert.equal(element.thread?.comments.length, 2);
       queryAndAssert<GrButton>(element, '#quoteBtn').click();
-      assert.equal(element.unsavedComment?.message?.trim(), `> ${c2.message}`);
-    });
-  });
-
-  suite('self removal when empty thread changed to editing:false', () => {
-    let threadEl: GrCommentThread;
-
-    setup(async () => {
-      threadEl = await fixture(html`<gr-comment-thread></gr-comment-thread>`);
-      threadEl.thread = createThread();
-    });
-
-    test('new thread el normally has a parent and an unsaved comment', async () => {
-      await waitUntil(() => threadEl.editing);
-      assert.isOk(threadEl.unsavedComment);
-      assert.isOk(threadEl.parentElement);
-    });
-
-    test('thread el removed after clicking CANCEL', async () => {
-      await waitUntil(() => threadEl.editing);
-
-      const commentEl = queryAndAssert(threadEl, 'gr-comment');
-      const buttonEl = queryAndAssert<GrButton>(commentEl, 'gr-button.cancel');
-      buttonEl.click();
-
-      await waitUntil(() => !threadEl.editing);
-      assert.isNotOk(threadEl.parentElement);
+      assert.isTrue(stubAdd.called);
+      assert.equal(stubAdd.lastCall.firstArg.message.trim(), `> ${c2.message}`);
     });
   });
 
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 9e8264c..27a5590 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
@@ -3,7 +3,6 @@
  * Copyright 2015 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
 import '../../../styles/shared-styles';
 import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
 import '../../plugins/gr-endpoint-param/gr-endpoint-param';
@@ -11,7 +10,6 @@
 import '../gr-dialog/gr-dialog';
 import '../gr-formatted-text/gr-formatted-text';
 import '../gr-icon/gr-icon';
-import '../gr-overlay/gr-overlay';
 import '../gr-textarea/gr-textarea';
 import '../gr-tooltip-content/gr-tooltip-content';
 import '../gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog';
@@ -21,24 +19,26 @@
 import {customElement, property, query, state} from 'lit/decorators.js';
 import {resolve} from '../../../models/dependency';
 import {GrTextarea} from '../gr-textarea/gr-textarea';
-import {GrOverlay} from '../gr-overlay/gr-overlay';
 import {
   AccountDetailInfo,
+  DraftInfo,
   NumericChangeId,
   RepoName,
   RobotCommentInfo,
+  Comment,
+  isRobot,
+  isSaving,
+  isError,
+  isDraft,
+  isNew,
 } from '../../../types/common';
 import {GrConfirmDeleteCommentDialog} from '../gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog';
 import {
-  Comment,
   createUserFixSuggestion,
-  DraftInfo,
   getContentInCommentRange,
   getUserSuggestion,
   hasUserSuggestion,
-  isDraftOrUnsaved,
-  isRobot,
-  isUnsaved,
+  id,
   NEWLINE_PATTERN,
   USER_SUGGESTION_START_PATTERN,
 } from '../../../utils/comment-util';
@@ -47,9 +47,9 @@
   ReplyToCommentEventDetail,
   ValueChangedEvent,
 } from '../../../types/events';
-import {fire, fireEvent} from '../../../utils/event-util';
+import {fire} from '../../../utils/event-util';
 import {assertIsDefined, assert} from '../../../utils/common-util';
-import {Key, Modifier} from '../../../utils/dom-util';
+import {Key, Modifier, whenVisible} from '../../../utils/dom-util';
 import {commentsModelToken} from '../../../models/comments/comments-model';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {subscribe} from '../../lit/subscription-controller';
@@ -60,20 +60,17 @@
 import {Subject} from 'rxjs';
 import {debounceTime} from 'rxjs/operators';
 import {changeModelToken} from '../../../models/change/change-model';
-import {Interaction} from '../../../constants/reporting';
 import {KnownExperimentId} from '../../../services/flags/flags';
 import {isBase64FileContent} from '../../../api/rest-api';
-import {createDiffUrl} from '../../../models/views/diff';
-
-const UNSAVED_MESSAGE = 'Unable to save draft';
+import {createDiffUrl} from '../../../models/views/change';
+import {userModelToken} from '../../../models/user/user-model';
+import {modalStyles} from '../../../styles/gr-modal-styles';
 
 const FILE = 'FILE';
 
 // visible for testing
 export const AUTO_SAVE_DEBOUNCE_DELAY_MS = 2000;
 
-export const __testOnly_UNSAVED_MESSAGE = UNSAVED_MESSAGE;
-
 declare global {
   interface HTMLElementEventMap {
     'comment-editing-changed': CustomEvent<CommentEditingChangedDetail>;
@@ -128,8 +125,11 @@
   @query('#resolvedCheckbox')
   resolvedCheckbox?: HTMLInputElement;
 
-  @query('#confirmDeleteOverlay')
-  confirmDeleteOverlay?: GrOverlay;
+  @query('#confirmDeleteModal')
+  confirmDeleteModal?: HTMLDialogElement;
+
+  @query('#confirmDeleteCommentDialog')
+  confirmDeleteDialog?: GrConfirmDeleteCommentDialog;
 
   @property({type: Object})
   comment?: Comment;
@@ -165,21 +165,11 @@
   @property({type: String})
   messagePlaceholder?: string;
 
-  /* private, but used in css rules */
-  @property({type: Boolean, reflect: true})
-  saving = false;
-
   // GrReplyDialog requires the patchset level comment to always remain
   // editable.
   @property({type: Boolean, attribute: 'permanent-editing-mode'})
   permanentEditingMode = false;
 
-  /**
-   * `saving` and `autoSaving` are separate and cannot be set at the same time.
-   * `saving` affects the UI state (disabled buttons, etc.) and eventually
-   * leaves editing mode, but `autoSaving` just happens in the background
-   * without the user noticing.
-   */
   @state()
   autoSaving?: Promise<DraftInfo>;
 
@@ -200,12 +190,6 @@
   @state()
   unresolved = true;
 
-  @property({type: Boolean})
-  showConfirmDeleteOverlay = false;
-
-  @property({type: Boolean})
-  unableToSave = false;
-
   @property({type: Boolean, attribute: 'show-patchset'})
   showPatchset = false;
 
@@ -229,10 +213,9 @@
 
   private readonly getChangeModel = resolve(this, changeModelToken);
 
-  // Private but used in tests.
-  readonly getCommentsModel = resolve(this, commentsModelToken);
+  private readonly getCommentsModel = resolve(this, commentsModelToken);
 
-  private readonly userModel = getAppContext().userModel;
+  private readonly getUserModel = resolve(this, userModelToken);
 
   private readonly shortcuts = new ShortcutController(this);
 
@@ -277,17 +260,18 @@
         this.save();
       });
     }
-    if (this.flagsService.isEnabled(KnownExperimentId.MENTION_USERS)) {
-      this.messagePlaceholder = 'Mention others with @';
-    }
+    this.addEventListener('open-user-suggest-preview', e => {
+      this.handleShowFix(e.detail.code);
+    });
+    this.messagePlaceholder = 'Mention others with @';
     subscribe(
       this,
-      () => this.userModel.account$,
+      () => this.getUserModel().account$,
       x => (this.account = x)
     );
     subscribe(
       this,
-      () => this.userModel.isAdmin$,
+      () => this.getUserModel().isAdmin$,
       x => (this.isAdmin = x)
     );
 
@@ -319,17 +303,13 @@
   override disconnectedCallback() {
     // Clean up emoji dropdown.
     if (this.textarea) this.textarea.closeDropdown();
-    if (this.editing) {
-      this.reporting.reportInteraction(
-        Interaction.COMMENTS_AUTOCLOSE_EDITING_DISCONNECTED
-      );
-    }
     super.disconnectedCallback();
   }
 
   static override get styles() {
     return [
       sharedStyles,
+      modalStyles,
       css`
         :host {
           display: block;
@@ -339,13 +319,9 @@
         :host([collapsed]) {
           padding: var(--spacing-s) var(--spacing-m);
         }
-        :host([saving]) {
-          pointer-events: none;
-        }
-        :host([saving]) .actions,
-        :host([saving]) .robotActions,
-        :host([saving]) .date {
-          opacity: 0.5;
+        :host([error]) {
+          background-color: var(--error-background);
+          border-radius: var(--border-radius);
         }
         .header {
           align-items: center;
@@ -500,19 +476,37 @@
           margin-left: var(--spacing-m);
           cursor: pointer;
         }
+        .suggestEdit {
+          /** same height as header */
+          --margin: calc(0px - var(--spacing-s));
+          margin-right: var(--spacing-s);
+        }
+        .suggestEdit gr-icon {
+          color: inherit;
+          margin-right: var(--spacing-s);
+        }
       `,
     ];
   }
 
   override render() {
-    if (isUnsaved(this.comment) && !this.editing) return;
-    const classes = {container: true, draft: isDraftOrUnsaved(this.comment)};
+    if (!this.comment) return;
+    this.toggleAttribute('saving', isSaving(this.comment));
+    this.toggleAttribute('error', isError(this.comment));
+    const classes = {
+      container: true,
+      draft: isDraft(this.comment),
+    };
     return html`
       <gr-endpoint-decorator name="comment">
         <gr-endpoint-param name="comment" .value=${this.comment}>
         </gr-endpoint-param>
         <gr-endpoint-param name="editing" .value=${this.editing}>
         </gr-endpoint-param>
+        <gr-endpoint-param name="message" .value=${this.messageText}>
+        </gr-endpoint-param>
+        <gr-endpoint-param name="isDraft" .value=${isDraft(this.comment)}>
+        </gr-endpoint-param>
         <div id="container" class=${classMap(classes)}>
           ${this.renderHeader()}
           <div class="body">
@@ -520,7 +514,6 @@
             ${this.renderCommentMessage()}
             <gr-endpoint-slot name="above-actions"></gr-endpoint-slot>
             ${this.renderHumanActions()} ${this.renderRobotActions()}
-            ${this.renderSuggestEditActions()}
           </div>
         </div>
       </gr-endpoint-decorator>
@@ -541,24 +534,21 @@
           ${this.renderDraftLabel()}
         </div>
         <div class="headerMiddle">${this.renderCollapsedContent()}</div>
-        ${this.renderRunDetails()} ${this.renderDeleteButton()}
-        ${this.renderPatchset()} ${this.renderDate()} ${this.renderToggle()}
+        ${this.renderSuggestEditButton()} ${this.renderRunDetails()}
+        ${this.renderDeleteButton()} ${this.renderPatchset()}
+        ${this.renderSeparator()} ${this.renderDate()} ${this.renderToggle()}
       </div>
     `;
   }
 
   private renderAuthor() {
-    if (isDraftOrUnsaved(this.comment)) return;
+    if (isDraft(this.comment)) return;
     if (isRobot(this.comment)) {
       const id = this.comment.robot_id;
       return html`<span class="robotName">${id}</span>`;
     }
-    const classes = {draft: isDraftOrUnsaved(this.comment)};
     return html`
-      <gr-account-label
-        .account=${this.comment?.author ?? this.account}
-        class=${classMap(classes)}
-      >
+      <gr-account-label .account=${this.comment?.author ?? this.account}>
       </gr-account-label>
     `;
   }
@@ -576,13 +566,13 @@
   }
 
   private renderDraftLabel() {
-    if (!isDraftOrUnsaved(this.comment)) return;
+    if (!isDraft(this.comment)) return;
     let label = 'Draft';
     let tooltip =
       'This draft is only visible to you. ' +
       "To publish drafts, click the 'Reply' or 'Start review' button " +
       "at the top of the change or press the 'a' key.";
-    if (this.unableToSave) {
+    if (isError(this.comment)) {
       label += ' (Failed to save)';
       tooltip = 'Unable to save draft. Please try to save again.';
     }
@@ -625,12 +615,7 @@
    * a draft. It is an action applied to published comments.
    */
   private renderDeleteButton() {
-    if (
-      !this.isAdmin ||
-      isDraftOrUnsaved(this.comment) ||
-      isRobot(this.comment)
-    )
-      return;
+    if (!this.isAdmin || isDraft(this.comment) || isRobot(this.comment)) return;
     if (this.collapsed) return;
     return html`
       <gr-button
@@ -638,7 +623,10 @@
         title="Delete Comment"
         link
         class="action delete"
-        @click=${this.openDeleteCommentOverlay}
+        @click=${(e: MouseEvent) => {
+          e.stopPropagation();
+          this.openDeleteCommentModal();
+        }}
       >
         <gr-icon id="icon" icon="delete" filled></gr-icon>
       </gr-button>
@@ -653,19 +641,36 @@
     `;
   }
 
+  private renderSeparator() {
+    // This should match the condition of `renderPatchset()`.
+    if (!this.showPatchset) return;
+    // This should match the condition of `renderDate()`.
+    if (this.collapsed) return;
+    // Render separator, if both are present: patchset AND date.
+    return html`<span class="separator"></span>`;
+  }
+
   private renderDate() {
-    if (!this.comment?.updated || this.collapsed) return;
+    if (this.collapsed) return;
     return html`
-      <span class="separator"></span>
       <span class="date" tabindex="0" @click=${this.handleAnchorClick}>
-        <gr-date-formatter
-          withTooltip
-          .dateStr=${this.comment.updated}
-        ></gr-date-formatter>
+        ${this.renderDateInner()}
       </span>
     `;
   }
 
+  private renderDateInner() {
+    if (isError(this.comment)) return 'Error';
+    if (isSaving(this.comment) && !this.autoSaving) return 'Saving';
+    if (isNew(this.comment)) return 'New';
+    return html`
+      <gr-date-formatter
+        withTooltip
+        .dateStr=${this.comment!.updated}
+      ></gr-date-formatter>
+    `;
+  }
+
   private renderToggle() {
     const icon = this.collapsed ? 'expand_more' : 'expand_less';
     const ariaLabel = this.collapsed ? 'Expand' : 'Collapse';
@@ -697,7 +702,6 @@
         class="editMessage"
         autocomplete="on"
         code=""
-        ?disabled=${this.saving}
         rows="4"
         .placeholder=${this.messagePlaceholder}
         text=${this.messageText}
@@ -729,7 +733,7 @@
 
   private renderCopyLinkIcon() {
     // Only show the icon when the thread contains a published comment.
-    if (!this.comment?.in_reply_to && isDraftOrUnsaved(this.comment)) return;
+    if (!this.comment?.in_reply_to && isDraft(this.comment)) return;
     return html`
       <gr-icon
         icon="link"
@@ -744,7 +748,7 @@
 
   private renderHumanActions() {
     if (!this.account || isRobot(this.comment)) return;
-    if (this.collapsed || !isDraftOrUnsaved(this.comment)) return;
+    if (this.collapsed || !isDraft(this.comment)) return;
     return html`
       <div class="actions">
         <div class="action resolve">
@@ -764,42 +768,22 @@
   }
 
   private renderDraftActions() {
-    if (!isDraftOrUnsaved(this.comment)) return;
+    if (!isDraft(this.comment)) return;
     return html`
       <div class="rightActions">
-        ${this.autoSaving ? html`.&nbsp;&nbsp;` : ''}
-        ${this.renderDiscardButton()} ${this.renderSuggestEditButton()}
-        ${this.renderPreviewSuggestEditButton()} ${this.renderEditButton()}
+        ${this.renderDiscardButton()} ${this.renderEditButton()}
         ${this.renderCancelButton()} ${this.renderSaveButton()}
         ${this.renderCopyLinkIcon()}
       </div>
     `;
   }
 
-  private renderPreviewSuggestEditButton() {
-    if (!this.flagsService.isEnabled(KnownExperimentId.SUGGEST_EDIT)) {
-      return nothing;
-    }
-    assertIsDefined(this.comment, 'comment');
-    if (!hasUserSuggestion(this.comment)) return nothing;
-    return html`
-      <gr-button
-        link
-        secondary
-        class="action show-fix"
-        ?disabled=${this.saving}
-        @click=${this.handleShowFix}
-      >
-        Preview Fix
-      </gr-button>
-    `;
-  }
-
   private renderSuggestEditButton() {
     if (!this.flagsService.isEnabled(KnownExperimentId.SUGGEST_EDIT)) {
       return nothing;
     }
     if (
+      !this.editing ||
       this.permanentEditingMode ||
       this.comment?.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS
     ) {
@@ -815,8 +799,9 @@
     return html`<gr-button
       link
       class="action suggestEdit"
+      title="This button copies the text to make a suggestion"
       @click=${this.createSuggestEdit}
-      >Suggest Fix</gr-button
+      ><gr-icon icon="edit" id="icon" filled></gr-icon> Suggest edit</gr-button
     >`;
   }
 
@@ -824,7 +809,7 @@
     if (this.editing || this.permanentEditingMode) return;
     return html`<gr-button
       link
-      ?disabled=${this.saving}
+      ?disabled=${isSaving(this.comment) && !this.autoSaving}
       class="action discard"
       @click=${this.discard}
       >Discard</gr-button
@@ -833,11 +818,7 @@
 
   private renderEditButton() {
     if (this.editing) return;
-    return html`<gr-button
-      link
-      ?disabled=${this.saving}
-      class="action edit"
-      @click=${this.edit}
+    return html`<gr-button link class="action edit" @click=${this.edit}
       >Edit</gr-button
     >`;
   }
@@ -847,7 +828,7 @@
     return html`
       <gr-button
         link
-        ?disabled=${this.saving}
+        ?disabled=${isSaving(this.comment) && !this.autoSaving}
         class="action cancel"
         @click=${this.cancel}
         >Cancel</gr-button
@@ -856,7 +837,7 @@
   }
 
   private renderSaveButton() {
-    if (!this.editing && !this.unableToSave) return;
+    if (!this.editing) return;
     return html`
       <gr-button
         link
@@ -884,31 +865,15 @@
     `;
   }
 
-  private renderSuggestEditActions() {
-    if (!this.flagsService.isEnabled(KnownExperimentId.SUGGEST_EDIT)) {
-      return nothing;
-    }
-    if (
-      !this.account ||
-      isRobot(this.comment) ||
-      isDraftOrUnsaved(this.comment)
-    ) {
-      return nothing;
-    }
-    return html`
-      <div class="robotActions">${this.renderPreviewSuggestEditButton()}</div>
-    `;
-  }
-
   private renderShowFixButton() {
-    if (!(this.comment as RobotCommentInfo)?.fix_suggestions) return;
+    const fix_suggestions = (this.comment as RobotCommentInfo)?.fix_suggestions;
+    if (!fix_suggestions || fix_suggestions.length === 0) return;
     return html`
       <gr-button
         link
         secondary
         class="action show-fix"
-        ?disabled=${this.saving}
-        @click=${this.handleShowFix}
+        @click=${() => this.handleShowFix()}
       >
         Show Fix
       </gr-button>
@@ -930,27 +895,24 @@
   }
 
   private renderConfirmDialog() {
-    if (!this.showConfirmDeleteOverlay) return;
     return html`
-      <gr-overlay id="confirmDeleteOverlay" with-backdrop>
+      <dialog id="confirmDeleteModal" tabindex="-1">
         <gr-confirm-delete-comment-dialog
-          id="confirmDeleteComment"
+          id="confirmDeleteCommentDialog"
           @confirm=${this.handleConfirmDeleteComment}
-          @cancel=${this.closeDeleteCommentOverlay}
+          @cancel=${this.closeDeleteCommentModal}
         >
         </gr-confirm-delete-comment-dialog>
-      </gr-overlay>
+      </dialog>
     `;
   }
 
   private getUrlForComment() {
-    const comment = this.comment;
-    if (!comment || !this.changeNum || !this.repoName) return '';
-    if (!comment.id) throw new Error('comment must have an id');
+    if (!this.changeNum || !this.repoName || !this.comment?.id) return '';
     return createDiffUrl({
       changeNum: this.changeNum,
-      project: this.repoName,
-      commentId: comment.id,
+      repo: this.repoName,
+      commentId: this.comment.id,
     });
   }
 
@@ -958,24 +920,41 @@
 
   firstWillUpdate() {
     if (this.firstWillUpdateDone) return;
-    this.firstWillUpdateDone = true;
-    if (this.permanentEditingMode) this.editing = true;
     assertIsDefined(this.comment, 'comment');
+    this.firstWillUpdateDone = true;
     this.unresolved = this.comment.unresolved ?? true;
-    if (isUnsaved(this.comment)) this.editing = true;
-    if (isDraftOrUnsaved(this.comment)) {
-      this.reporting.reportInteraction(
-        Interaction.COMMENTS_AUTOCLOSE_FIRST_UPDATE,
-        {editing: this.editing, unsaved: isUnsaved(this.comment)}
-      );
+    if (this.permanentEditingMode) {
+      this.edit();
+    }
+    if (
+      isDraft(this.comment) &&
+      isNew(this.comment) &&
+      !isSaving(this.comment)
+    ) {
+      this.edit();
+    }
+    if (isDraft(this.comment)) {
       this.collapsed = false;
     } else {
       this.collapsed = !!this.initiallyCollapsed;
     }
   }
 
+  override updated(changed: PropertyValues) {
+    if (changed.has('editing')) {
+      if (this.editing && !this.permanentEditingMode) {
+        whenVisible(this, () => this.textarea?.putCursorAtEnd());
+      }
+    }
+  }
+
   override willUpdate(changed: PropertyValues) {
     this.firstWillUpdate();
+    if (changed.has('comment')) {
+      if (isDraft(this.comment) && isError(this.comment)) {
+        this.edit();
+      }
+    }
     if (changed.has('editing')) {
       this.onEditingChanged();
     }
@@ -1000,14 +979,12 @@
   }
 
   private handleCopyLink() {
-    fireEvent(this, 'copy-comment-link');
+    fire(this, 'copy-comment-link', {});
   }
 
   /** Enter editing mode. */
   private edit() {
-    if (!isDraftOrUnsaved(this.comment)) {
-      throw new Error('Cannot edit published comment.');
-    }
+    assert(isDraft(this.comment), 'only drafts are editable');
     if (this.editing) return;
     this.editing = true;
   }
@@ -1022,12 +999,14 @@
   }
 
   // private, but visible for testing
-  async createFixPreview(): Promise<OpenFixPreviewEventDetail> {
+  async createFixPreview(
+    replacement?: string
+  ): Promise<OpenFixPreviewEventDetail> {
     assertIsDefined(this.comment?.patch_set, 'comment.patch_set');
     assertIsDefined(this.comment?.path, 'comment.path');
 
-    if (hasUserSuggestion(this.comment)) {
-      const replacement = getUserSuggestion(this.comment);
+    if (hasUserSuggestion(this.comment) || replacement) {
+      replacement = replacement ?? getUserSuggestion(this.comment);
       assert(!!replacement, 'malformed user suggestion');
       const line = await this.getCommentedCode();
 
@@ -1038,6 +1017,11 @@
           replacement
         ),
         patchNum: this.comment.patch_set,
+        onCloseFixPreviewCallbacks: [
+          fixApplied => {
+            if (fixApplied) this.handleAppliedFix();
+          },
+        ],
       };
     }
     if (isRobot(this.comment) && this.comment.fix_suggestions.length > 0) {
@@ -1050,6 +1034,7 @@
           };
         }),
         patchNum: this.comment.patch_set,
+        onCloseFixPreviewCallbacks: [],
       };
     }
     throw new Error('unable to create preview fix event');
@@ -1060,9 +1045,10 @@
       this.collapsed = false;
       this.messageText = this.comment?.message ?? '';
       this.unresolved = this.comment?.unresolved ?? true;
-      this.originalMessage = this.messageText;
-      this.originalUnresolved = this.unresolved;
-      setTimeout(() => this.textarea?.putCursorAtEnd(), 1);
+      if (!isError(this.comment) && !isSaving(this.comment)) {
+        this.originalMessage = this.messageText;
+        this.originalUnresolved = this.unresolved;
+      }
     }
 
     // Parent components such as the reply dialog might be interested in whether
@@ -1076,10 +1062,14 @@
   // private, but visible for testing
   isSaveDisabled() {
     assertIsDefined(this.comment, 'comment');
-    if (this.saving) return true;
+    if (isSaving(this.comment) && !this.autoSaving) return true;
     return !this.messageText?.trimEnd();
   }
 
+  override focus() {
+    this.textarea?.focus();
+  }
+
   private handleEsc() {
     // vim users don't like ESC to cancel/discard, so only do this when the
     // comment text is empty.
@@ -1114,12 +1104,25 @@
     fire(this, 'reply-to-comment', eventDetail);
   }
 
-  private async handleShowFix() {
-    // Handled top-level in the diff and change view components.
-    fire(this, 'open-fix-preview', await this.createFixPreview());
+  private handleAppliedFix() {
+    const message = this.comment?.message;
+    assert(!!message, 'empty message');
+    const eventDetail: ReplyToCommentEventDetail = {
+      content: 'Fix applied.',
+      userWantsToEdit: false,
+      unresolved: false,
+    };
+    // Handled by <gr-comment-thread>.
+    fire(this, 'reply-to-comment', eventDetail);
   }
 
-  async createSuggestEdit() {
+  private async handleShowFix(replacement?: string) {
+    // Handled top-level in the diff and change view components.
+    fire(this, 'open-fix-preview', await this.createFixPreview(replacement));
+  }
+
+  async createSuggestEdit(e: MouseEvent) {
+    e.stopPropagation();
     const line = await this.getCommentedCode();
     this.messageText += `${USER_SUGGESTION_START_PATTERN}${line}${'\n```'}`;
   }
@@ -1146,24 +1149,22 @@
   // private, but visible for testing
   cancel() {
     assertIsDefined(this.comment, 'comment');
-    if (!isDraftOrUnsaved(this.comment)) {
-      throw new Error('only unsaved and draft comments are editable');
-    }
+    assert(isDraft(this.comment), 'only drafts are editable');
     this.messageText = this.originalMessage;
     this.unresolved = this.originalUnresolved;
     this.save();
   }
 
   async autoSave() {
-    if (this.saving || this.autoSaving) return;
+    if (isSaving(this.comment) || this.autoSaving) return;
     if (!this.editing || !this.comment) return;
-    if (!isDraftOrUnsaved(this.comment)) return;
+    assert(isDraft(this.comment), 'only drafts are editable');
     const messageToSave = this.messageText.trimEnd();
     if (messageToSave === '') return;
     if (messageToSave === this.comment.message) return;
 
     try {
-      this.autoSaving = this.rawSave(messageToSave, {showToast: false});
+      this.autoSaving = this.rawSave({showToast: false});
       await this.autoSaving;
     } finally {
       this.autoSaving = undefined;
@@ -1176,55 +1177,51 @@
   }
 
   async save() {
-    if (!isDraftOrUnsaved(this.comment)) throw new Error('not a draft');
-    // If it's an unsaved comment then it does not have a draftID yet which
-    // means sending another save() request will create a new draft
-    if (isUnsaved(this.comment) && this.saving) return;
+    assert(isDraft(this.comment), 'only drafts are editable');
+    // There is a minimal chance of `isSaving()` being false between iterations
+    // of the below while loop. But this will be extremely rare and just lead
+    // to a harmless assertion error. So let's not bother.
+    if (isSaving(this.comment) && !this.autoSaving) return;
 
-    try {
-      this.saving = true;
-      this.unableToSave = false;
-      if (this.autoSaving) {
-        this.comment = await this.autoSaving;
+    if (!this.permanentEditingMode) {
+      this.editing = false;
+    }
+    if (this.autoSaving) {
+      this.comment = await this.autoSaving;
+    }
+    // Depending on whether `messageToSave` is empty we treat this either as
+    // a discard or a save action.
+    const messageToSave = this.messageText.trimEnd();
+    if (messageToSave === '') {
+      if (!this.permanentEditingMode || this.somethingToSave()) {
+        await this.getCommentsModel().discardDraft(id(this.comment));
       }
-      // Depending on whether `messageToSave` is empty we treat this either as
-      // a discard or a save action.
-      const messageToSave = this.messageText.trimEnd();
-      if (messageToSave === '') {
-        // Don't try to discard UnsavedInfo. Nothing to do then.
-        if (this.comment.id) {
-          await this.getCommentsModel().discardDraft(this.comment.id);
-        }
-      } else {
-        // No need to make a backend call when nothing has changed.
-        if (
-          messageToSave !== this.comment?.message ||
-          this.unresolved !== this.comment.unresolved
-        ) {
-          await this.rawSave(messageToSave, {showToast: true});
-        }
+    } else {
+      // No need to make a backend call when nothing has changed.
+      while (this.somethingToSave()) {
+        this.comment = await this.rawSave({showToast: true});
+        if (isError(this.comment)) return;
       }
-      this.reporting.reportInteraction(
-        Interaction.COMMENTS_AUTOCLOSE_EDITING_FALSE_SAVE
-      );
-      if (!this.permanentEditingMode) {
-        this.editing = false;
-      }
-    } catch (e) {
-      this.unableToSave = true;
-      throw e;
-    } finally {
-      this.saving = false;
     }
   }
 
+  private somethingToSave() {
+    if (!this.comment) return false;
+    return (
+      isError(this.comment) ||
+      this.messageText.trimEnd() !== this.comment?.message ||
+      this.unresolved !== this.comment.unresolved
+    );
+  }
+
   /** For sharing between save() and autoSave(). */
-  private rawSave(message: string, options: {showToast: boolean}) {
-    if (!isDraftOrUnsaved(this.comment)) throw new Error('not a draft');
+  private rawSave(options: {showToast: boolean}) {
+    assert(isDraft(this.comment), 'only drafts are editable');
+    assert(!isSaving(this.comment), 'saving already in progress');
     return this.getCommentsModel().saveDraft(
       {
         ...this.comment,
-        message,
+        message: this.messageText.trimEnd(),
         unresolved: this.unresolved,
       },
       options.showToast
@@ -1243,51 +1240,35 @@
     }
   }
 
-  private async openDeleteCommentOverlay() {
-    this.showConfirmDeleteOverlay = true;
-    await this.updateComplete;
-    await this.confirmDeleteOverlay?.open();
+  private openDeleteCommentModal() {
+    this.confirmDeleteModal?.showModal();
+    whenVisible(this.confirmDeleteDialog!, () => {
+      this.confirmDeleteDialog!.resetFocus();
+    });
   }
 
-  private closeDeleteCommentOverlay() {
-    this.showConfirmDeleteOverlay = false;
-    this.confirmDeleteOverlay?.remove();
-    this.confirmDeleteOverlay?.close();
+  private closeDeleteCommentModal() {
+    this.confirmDeleteModal?.close();
   }
 
   /**
    * Deleting a *published* comment is an admin feature. It means more than just
    * discarding a draft.
-   *
-   * TODO: Also move this into the comments-service.
-   * TODO: Figure out a good reloading strategy when deleting was successful.
-   *       `this.comment = newComment` does not seem sufficient.
    */
   // private, but visible for testing
-  handleConfirmDeleteComment() {
-    const dialog = this.confirmDeleteOverlay?.querySelector(
-      '#confirmDeleteComment'
-    ) as GrConfirmDeleteCommentDialog | null;
-    if (!dialog || !dialog.message) {
+  async handleConfirmDeleteComment() {
+    if (!this.confirmDeleteDialog || !this.confirmDeleteDialog.message) {
       throw new Error('missing confirm delete dialog');
     }
     assertIsDefined(this.changeNum, 'changeNum');
     assertIsDefined(this.comment, 'comment');
-    assertIsDefined(this.comment.patch_set, 'comment.patch_set');
-    if (isDraftOrUnsaved(this.comment)) {
-      throw new Error('Admin deletion is only for published comments.');
-    }
-    this.restApiService
-      .deleteComment(
-        this.changeNum,
-        this.comment.patch_set,
-        this.comment.id,
-        dialog.message
-      )
-      .then(newComment => {
-        this.closeDeleteCommentOverlay();
-        this.comment = newComment;
-      });
+
+    await this.getCommentsModel().deleteComment(
+      this.changeNum,
+      this.comment,
+      this.confirmDeleteDialog.message
+    );
+    this.closeDeleteCommentModal();
   }
 }
 
@@ -1295,4 +1276,7 @@
   interface HTMLElementTagNameMap {
     'gr-comment': GrComment;
   }
+  interface HTMLElementEventMap {
+    'copy-comment-link': CustomEvent<{}>;
+  }
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
index 2293619..59485e2 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
@@ -17,9 +17,12 @@
   dispatch,
   MockPromise,
   stubFlags,
+  waitUntil,
 } from '../../../test/test-utils';
 import {
   AccountId,
+  DraftInfo,
+  SavingState,
   EmailAddress,
   NumericChangeId,
   PatchSetNum,
@@ -29,27 +32,25 @@
 import {
   createComment,
   createDraft,
-  createFixSuggestionInfo,
   createRobotComment,
-  createUnsaved,
+  createNewDraft,
 } from '../../../test/test-data-generators';
-import {
-  ReplyToCommentEvent,
-  OpenFixPreviewEventDetail,
-} from '../../../types/events';
+import {ReplyToCommentEvent} from '../../../types/events';
 import {GrConfirmDeleteCommentDialog} from '../gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog';
-import {
-  DraftInfo,
-  USER_SUGGESTION_START_PATTERN,
-} from '../../../utils/comment-util';
 import {assertIsDefined} from '../../../utils/common-util';
 import {Modifier} from '../../../utils/dom-util';
-import {SinonStub} from 'sinon';
+import {SinonStubbedMember} from 'sinon';
 import {fixture, html, assert} from '@open-wc/testing';
 import {GrButton} from '../gr-button/gr-button';
+import {testResolver} from '../../../test/common-test-setup';
+import {
+  CommentsModel,
+  commentsModelToken,
+} from '../../../models/comments/comments-model';
 
 suite('gr-comment tests', () => {
   let element: GrComment;
+  let commentsModel: CommentsModel;
   const account = {
     email: 'dhruvsri@google.com' as EmailAddress,
     name: 'Dhruv Srivastava',
@@ -77,6 +78,7 @@
         .comment=${comment}
       ></gr-comment>`
     );
+    commentsModel = testResolver(commentsModelToken);
   });
 
   suite('DOM rendering', () => {
@@ -95,6 +97,8 @@
           <gr-endpoint-decorator name="comment">
             <gr-endpoint-param name="comment"></gr-endpoint-param>
             <gr-endpoint-param name="editing"></gr-endpoint-param>
+            <gr-endpoint-param name="message"></gr-endpoint-param>
+            <gr-endpoint-param name="isDraft"></gr-endpoint-param>
             <div class="container" id="container">
               <div class="header" id="header">
                 <div class="headerLeft">
@@ -118,6 +122,10 @@
               </div>
             </div>
           </gr-endpoint-decorator>
+          <dialog id="confirmDeleteModal" tabindex="-1">
+            <gr-confirm-delete-comment-dialog id="confirmDeleteCommentDialog">
+            </gr-confirm-delete-comment-dialog>
+          </dialog>
         `
       );
     });
@@ -131,6 +139,8 @@
           <gr-endpoint-decorator name="comment">
             <gr-endpoint-param name="comment"></gr-endpoint-param>
             <gr-endpoint-param name="editing"></gr-endpoint-param>
+            <gr-endpoint-param name="message"></gr-endpoint-param>
+            <gr-endpoint-param name="isDraft"></gr-endpoint-param>
             <div class="container" id="container">
               <div class="header" id="header">
                 <div class="headerLeft">
@@ -155,6 +165,10 @@
               </div>
             </div>
           </gr-endpoint-decorator>
+          <dialog id="confirmDeleteModal" tabindex="-1">
+            <gr-confirm-delete-comment-dialog id="confirmDeleteCommentDialog">
+            </gr-confirm-delete-comment-dialog>
+          </dialog>
         `
       );
     });
@@ -169,6 +183,8 @@
           <gr-endpoint-decorator name="comment">
             <gr-endpoint-param name="comment"></gr-endpoint-param>
             <gr-endpoint-param name="editing"></gr-endpoint-param>
+            <gr-endpoint-param name="message"></gr-endpoint-param>
+            <gr-endpoint-param name="isDraft"></gr-endpoint-param>
             <div class="container" id="container">
               <div class="header" id="header">
                 <div class="headerLeft">
@@ -225,6 +241,10 @@
               </div>
             </div>
           </gr-endpoint-decorator>
+          <dialog id="confirmDeleteModal" tabindex="-1">
+            <gr-confirm-delete-comment-dialog id="confirmDeleteCommentDialog">
+            </gr-confirm-delete-comment-dialog>
+          </dialog>
         `
       );
     });
@@ -253,7 +273,7 @@
 
     test('renders draft', async () => {
       element.initiallyCollapsed = false;
-      (element.comment as DraftInfo).__draft = true;
+      (element.comment as DraftInfo).savingState = SavingState.OK;
       await element.updateComplete;
       assert.shadowDom.equal(
         element,
@@ -261,6 +281,8 @@
           <gr-endpoint-decorator name="comment">
             <gr-endpoint-param name="comment"></gr-endpoint-param>
             <gr-endpoint-param name="editing"></gr-endpoint-param>
+            <gr-endpoint-param name="message"></gr-endpoint-param>
+            <gr-endpoint-param name="isDraft"></gr-endpoint-param>
             <div class="container draft" id="container">
               <div class="header" id="header">
                 <div class="headerLeft">
@@ -321,13 +343,17 @@
               </div>
             </div>
           </gr-endpoint-decorator>
+          <dialog id="confirmDeleteModal" tabindex="-1">
+            <gr-confirm-delete-comment-dialog id="confirmDeleteCommentDialog">
+            </gr-confirm-delete-comment-dialog>
+          </dialog>
         `
       );
     });
 
     test('renders draft in editing mode', async () => {
       element.initiallyCollapsed = false;
-      (element.comment as DraftInfo).__draft = true;
+      (element.comment as DraftInfo).savingState = SavingState.OK;
       element.editing = true;
       await element.updateComplete;
       assert.shadowDom.equal(
@@ -336,6 +362,8 @@
           <gr-endpoint-decorator name="comment">
             <gr-endpoint-param name="comment"></gr-endpoint-param>
             <gr-endpoint-param name="editing"></gr-endpoint-param>
+            <gr-endpoint-param name="message"></gr-endpoint-param>
+            <gr-endpoint-param name="isDraft"></gr-endpoint-param>
             <div class="container draft" id="container">
               <div class="header" id="header">
                 <div class="headerLeft">
@@ -404,6 +432,10 @@
               </div>
             </div>
           </gr-endpoint-decorator>
+          <dialog id="confirmDeleteModal" tabindex="-1">
+            <gr-confirm-delete-comment-dialog id="confirmDeleteCommentDialog">
+            </gr-confirm-delete-comment-dialog>
+          </dialog>
         `
       );
     });
@@ -435,7 +467,7 @@
       },
       line: 5,
       path: 'test',
-      __draft: true,
+      savingState: SavingState.OK,
       message: 'hello world',
     };
     element.editing = true;
@@ -460,7 +492,7 @@
       },
       line: 5,
       path: 'test',
-      __draft: true,
+      savingState: SavingState.OK,
       message: 'hello world',
     };
     element.editing = true;
@@ -483,10 +515,10 @@
     deleteButton.click();
     await element.updateComplete;
 
-    assertIsDefined(element.confirmDeleteOverlay, 'confirmDeleteOverlay');
+    assertIsDefined(element.confirmDeleteModal, 'confirmDeleteModal');
     const dialog = queryAndAssert<GrConfirmDeleteCommentDialog>(
-      element.confirmDeleteOverlay,
-      '#confirmDeleteComment'
+      element.confirmDeleteModal,
+      '#confirmDeleteCommentDialog'
     );
     dialog.message = 'removal reason';
     await element.updateComplete;
@@ -510,14 +542,13 @@
       element.changeNum = 42 as NumericChangeId;
       element.comment = {
         ...createComment(),
-        __draft: true,
+        savingState: SavingState.OK,
         path: '/path/to/file',
         line: 5,
       };
     });
 
     test('isSaveDisabled', async () => {
-      element.saving = false;
       element.unresolved = true;
       element.comment = {...createComment(), unresolved: true};
       element.messageText = 'asdf';
@@ -534,7 +565,7 @@
       await element.updateComplete;
       assert.isTrue(element.isSaveDisabled());
 
-      element.saving = true;
+      element.comment = {...element.comment, savingState: SavingState.SAVING};
       await element.updateComplete;
       assert.isTrue(element.isSaveDisabled());
     });
@@ -550,9 +581,7 @@
 
     test('save', async () => {
       const savePromise = mockPromise<DraftInfo>();
-      const stub = sinon
-        .stub(element.getCommentsModel(), 'saveDraft')
-        .returns(savePromise);
+      const stub = sinon.stub(commentsModel, 'saveDraft').returns(savePromise);
 
       element.comment = createDraft();
       element.editing = true;
@@ -568,14 +597,12 @@
       waitUntilCalled(stub, 'saveDraft()');
       assert.equal(stub.lastCall.firstArg.message, textToSave);
       assert.equal(stub.lastCall.firstArg.unresolved, true);
-      assert.isTrue(element.editing);
-      assert.isTrue(element.saving);
+      assert.isFalse(element.editing);
 
       savePromise.resolve();
       await element.updateComplete;
 
       assert.isFalse(element.editing);
-      assert.isFalse(element.saving);
     });
 
     test('previewing formatting triggers save', async () => {
@@ -596,28 +623,36 @@
     });
 
     test('save failed', async () => {
-      sinon
-        .stub(element.getCommentsModel(), 'saveDraft')
-        .returns(Promise.reject(new Error('saving failed')));
+      sinon.stub(commentsModel, 'saveDraft').returns(
+        Promise.resolve({
+          ...createNewDraft(),
+          message: 'something, not important',
+          unresolved: true,
+          savingState: SavingState.ERROR,
+        })
+      );
 
-      element.comment = createDraft();
+      element.comment = createNewDraft({
+        message: '',
+        unresolved: true,
+      });
+      element.unresolved = true;
       element.editing = true;
       await element.updateComplete;
       element.messageText = 'something, not important';
       await element.updateComplete;
 
       element.save();
-      await element.updateComplete;
+      assert.isFalse(element.editing);
 
-      assert.isTrue(element.unableToSave);
+      await waitUntil(() => element.hasAttribute('error'));
       assert.isTrue(element.editing);
-      assert.isFalse(element.saving);
     });
 
     test('discard', async () => {
       const discardPromise = mockPromise<void>();
       const stub = sinon
-        .stub(element.getCommentsModel(), 'discardDraft')
+        .stub(commentsModel, 'discardDraft')
         .returns(discardPromise);
 
       element.comment = createDraft();
@@ -629,21 +664,19 @@
       await element.updateComplete;
       waitUntilCalled(stub, 'discardDraft()');
       assert.equal(stub.lastCall.firstArg, element.comment.id);
-      assert.isTrue(element.editing);
-      assert.isTrue(element.saving);
+      assert.isFalse(element.editing);
 
       discardPromise.resolve();
       await element.updateComplete;
 
       assert.isFalse(element.editing);
-      assert.isFalse(element.saving);
     });
 
     test('resolved comment state indicated by checkbox', async () => {
-      const saveStub = sinon.stub(element.getCommentsModel(), 'saveDraft');
+      const saveStub = sinon.stub(commentsModel, 'saveDraft');
       element.comment = {
         ...createComment(),
-        __draft: true,
+        savingState: SavingState.OK,
         unresolved: false,
       };
       await element.updateComplete;
@@ -664,11 +697,8 @@
     });
 
     test('saving empty text calls discard()', async () => {
-      const saveStub = sinon.stub(element.getCommentsModel(), 'saveDraft');
-      const discardStub = sinon.stub(
-        element.getCommentsModel(),
-        'discardDraft'
-      );
+      const saveStub = sinon.stub(commentsModel, 'saveDraft');
+      const discardStub = sinon.stub(commentsModel, 'discardDraft');
       element.comment = createDraft();
       element.editing = true;
       await element.updateComplete;
@@ -715,38 +745,19 @@
       actions = query(element, '.robotActions gr-button.fix');
       assert.isNotOk(actions);
     });
-
-    test('handleShowFix fires open-fix-preview event', async () => {
-      const listener = listenOnce<CustomEvent<OpenFixPreviewEventDetail>>(
-        element,
-        'open-fix-preview'
-      );
-      element.comment = {
-        ...createRobotComment(),
-        fix_suggestions: [{...createFixSuggestionInfo()}],
-      };
-      await element.updateComplete;
-
-      queryAndAssert<GrButton>(element, '.show-fix').click();
-
-      const e = await listener;
-      assert.deepEqual(e.detail, await element.createFixPreview());
-    });
   });
 
   suite('auto saving', () => {
     let clock: sinon.SinonFakeTimers;
     let savePromise: MockPromise<DraftInfo>;
-    let saveStub: SinonStub;
+    let saveStub: SinonStubbedMember<CommentsModel['saveDraft']>;
 
     setup(async () => {
       clock = sinon.useFakeTimers();
       savePromise = mockPromise<DraftInfo>();
-      saveStub = sinon
-        .stub(element.getCommentsModel(), 'saveDraft')
-        .returns(savePromise);
+      saveStub = sinon.stub(commentsModel, 'saveDraft').returns(savePromise);
 
-      element.comment = createUnsaved();
+      element.comment = createNewDraft();
       element.editing = true;
       await element.updateComplete;
     });
@@ -772,32 +783,43 @@
     });
 
     test('saving while auto saving', async () => {
+      saveStub.reset();
+      const autoSavePromise = mockPromise<DraftInfo>();
+      saveStub.onCall(0).returns(autoSavePromise);
+      saveStub.onCall(1).returns(savePromise);
+
       const textarea = queryAndAssert<HTMLElement>(element, '#editTextarea');
       dispatch(textarea, 'text-changed', {value: 'auto save text'});
 
       clock.tick(2 * AUTO_SAVE_DEBOUNCE_DELAY_MS);
-      assert.isTrue(saveStub.called);
+      assert.equal(saveStub.callCount, 1);
       assert.equal(saveStub.firstCall.firstArg.message, 'auto save text');
-      saveStub.reset();
 
       element.messageText = 'actual save text';
       const save = element.save();
       await element.updateComplete;
       // First wait for the auto saving to finish.
-      assert.isFalse(saveStub.called);
+      assert.equal(saveStub.callCount, 1);
 
-      // Resolve auto-saving promise.
-      savePromise.resolve({
+      autoSavePromise.resolve({
         ...element.comment,
-        __draft: true,
+        savingState: SavingState.OK,
+        message: 'auto save text',
         id: 'exp123' as UrlEncodedCommentId,
         updated: '2018-02-13 22:48:48.018000000' as Timestamp,
       });
+      savePromise.resolve({
+        ...element.comment,
+        savingState: SavingState.OK,
+        message: 'actual save text',
+        id: 'exp123' as UrlEncodedCommentId,
+        updated: '2018-02-13 22:48:49.018000000' as Timestamp,
+      });
       await save;
       // Only then save.
-      assert.isTrue(saveStub.called);
-      assert.equal(saveStub.firstCall.firstArg.message, 'actual save text');
-      assert.equal(saveStub.firstCall.firstArg.id, 'exp123');
+      assert.equal(saveStub.callCount, 2);
+      assert.equal(saveStub.lastCall.firstArg.message, 'actual save text');
+      assert.equal(saveStub.lastCall.firstArg.id, 'exp123');
     });
   });
 
@@ -813,7 +835,7 @@
         },
         line: 5,
         path: 'test',
-        __draft: true,
+        savingState: SavingState.OK,
         message: 'hello world',
       };
       element = await fixture(
@@ -824,46 +846,19 @@
           .initiallyCollapsed=${false}
         ></gr-comment>`
       );
+      element.editing = true;
     });
-    test('renders suggest fix button', () => {
+    test('renders suggest edit button', () => {
       assert.dom.equal(
         queryAndAssert(element, 'gr-button.suggestEdit'),
         /* HTML */ `<gr-button
-          aria-disabled="false"
           class="action suggestEdit"
           link=""
           role="button"
           tabindex="0"
+          title="This button copies the text to make a suggestion"
         >
-          Suggest Fix
-        </gr-button> `
-      );
-    });
-
-    test('renders preview suggest fix', async () => {
-      element.comment = {
-        ...createComment(),
-        author: {
-          name: 'Mr. Peanutbutter',
-          email: 'tenn1sballchaser@aol.com' as EmailAddress,
-        },
-        line: 5,
-        path: 'test',
-        message: `${USER_SUGGESTION_START_PATTERN}afterSuggestion${'\n```'}`,
-      };
-      await element.updateComplete;
-
-      assert.dom.equal(
-        queryAndAssert(element, 'gr-button.show-fix'),
-        /* HTML */ `<gr-button
-          aria-disabled="false"
-          class="action show-fix"
-          link=""
-          role="button"
-          secondary
-          tabindex="0"
-        >
-          Preview Fix
+          <gr-icon icon="edit" id="icon" filled></gr-icon> Suggest edit
         </gr-button> `
       );
     });
diff --git a/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.ts b/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.ts
index b6512b3..e8ab172 100644
--- a/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.ts
+++ b/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.ts
@@ -11,6 +11,7 @@
 import {sharedStyles} from '../../../styles/shared-styles';
 import {assertIsDefined} from '../../../utils/common-util';
 import {BindValueChangeEvent} from '../../../types/events';
+import {fireNoBubble} from '../../../utils/event-util';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -75,6 +76,7 @@
   override render() {
     return html` <gr-dialog
       confirm-label="Delete"
+      ?disabled=${this.message === ''}
       @confirm=${this.handleConfirmTap}
       @cancel=${this.handleCancelTap}
     >
@@ -107,23 +109,12 @@
   private handleConfirmTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
-    this.dispatchEvent(
-      new CustomEvent('confirm', {
-        detail: {reason: this.message},
-        composed: true,
-        bubbles: false,
-      })
-    );
+    fireNoBubble(this, 'confirm', {});
   }
 
   private handleCancelTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
-    this.dispatchEvent(
-      new CustomEvent('cancel', {
-        composed: true,
-        bubbles: false,
-      })
-    );
+    fireNoBubble(this, 'cancel', {});
   }
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog_test.ts b/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog_test.ts
index bd84ac4..b7551c1 100644
--- a/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog_test.ts
@@ -7,6 +7,7 @@
 import {fixture, html, assert} from '@open-wc/testing';
 import {GrConfirmDeleteCommentDialog} from './gr-confirm-delete-comment-dialog';
 import './gr-confirm-delete-comment-dialog';
+import {GrDialog} from '../gr-dialog/gr-dialog';
 
 suite('gr-confirm-delete-comment-dialog tests', () => {
   let element: GrConfirmDeleteCommentDialog;
@@ -17,7 +18,10 @@
     );
   });
 
-  test('render', () => {
+  test('render', async () => {
+    element.message = 'Just cause';
+    await element.updateComplete;
+
     // prettier and shadowDom string disagree about wrapping in <p> tag.
     assert.shadowDom.equal(
       element,
@@ -43,4 +47,13 @@
     `
     );
   });
+
+  test('dialog is disabled when message is empty', async () => {
+    element.message = '';
+    await element.updateComplete;
+
+    assert.isTrue(
+      (element.shadowRoot!.querySelector('gr-dialog') as GrDialog).disabled
+    );
+  });
 });
diff --git a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.ts b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.ts
index 0e9b874..5b26ede 100644
--- a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.ts
+++ b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.ts
@@ -39,6 +39,10 @@
   @property({type: Boolean})
   hideInput = false;
 
+  // Optional property for toast to announce correct name of target that was copied
+  @property({type: String, reflect: true})
+  copyTargetName?: string;
+
   @query('#icon')
   iconEl!: GrIcon;
 
@@ -66,7 +70,10 @@
           color: var(--primary-text-color);
         }
         gr-icon {
-          color: var(--deemphasized-text-color);
+          color: var(
+            --gr-copy-clipboard-icon-color,
+            var(--deemphasized-text-color)
+          );
         }
         gr-button {
           display: block;
@@ -105,7 +112,8 @@
             link=""
             class="copyToClipboard"
             @click=${this._copyToClipboard}
-            aria-label="Click to copy to clipboard"
+            aria-label="copy"
+            aria-description="Click to copy to clipboard"
           >
             <div>
               <gr-icon id="icon" icon="content_copy" small></gr-icon>
@@ -133,7 +141,7 @@
     this.text = queryAndAssert<HTMLInputElement>(this, '#input').value;
     assertIsDefined(this.text, 'text');
     this.iconEl.icon = 'check';
-    copyToClipbard(this.text, 'Link');
+    copyToClipbard(this.text, this.copyTargetName ?? 'Link');
     setTimeout(() => (this.iconEl.icon = 'content_copy'), COPY_TIMEOUT_MS);
   }
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_test.ts b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_test.ts
index 6e93803..4c36a56 100644
--- a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_test.ts
@@ -42,7 +42,8 @@
           <gr-tooltip-content>
             <gr-button
               aria-disabled="false"
-              aria-label="Click to copy to clipboard"
+              aria-label="copy"
+              aria-description="Click to copy to clipboard"
               class="copyToClipboard"
               id="copy-clipboard-button"
               link=""
diff --git a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_test.js b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_test.ts
similarity index 85%
rename from polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_test.js
rename to polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_test.ts
index 4b8157e..81d2b45 100644
--- a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_test.ts
@@ -4,18 +4,16 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import '../../../test/common-test-setup';
-// eslint-disable-next-line import/named
 import {fixture, html, assert} from '@open-wc/testing';
 import {AbortStop, CursorMoveResult} from '../../../api/core';
 import {GrCursorManager} from './gr-cursor-manager';
 
 suite('gr-cursor-manager tests', () => {
-  let cursor;
-  let list;
+  let cursor: GrCursorManager;
+  let list: Element;
 
   setup(async () => {
-    list = await fixture(html`
-    <ul>
+    list = await fixture(html` <ul>
       <li>A</li>
       <li>B</li>
       <li>C</li>
@@ -42,11 +40,11 @@
     assert.isNotOk(cursor.target);
 
     // Select the third stop.
-    cursor.setCursor(list.children[2]);
+    cursor.setCursor(list.children[2] as HTMLElement);
 
     // It should update its internal state and update the element's class.
     assert.equal(cursor.index, 2);
-    assert.equal(cursor.target, list.children[2]);
+    assert.equal(cursor.target, list.children[2] as HTMLElement);
     assert.isTrue(list.children[2].classList.contains('targeted'));
     assert.isFalse(cursor.isAtStart());
     assert.isFalse(cursor.isAtEnd());
@@ -58,7 +56,7 @@
     // unselected.
     assert.equal(result, CursorMoveResult.MOVED);
     assert.equal(cursor.index, 3);
-    assert.equal(cursor.target, list.children[3]);
+    assert.equal(cursor.target, list.children[3] as HTMLElement);
     assert.isTrue(cursor.isAtEnd());
     assert.isFalse(list.children[2].classList.contains('targeted'));
     assert.isTrue(list.children[3].classList.contains('targeted'));
@@ -69,7 +67,7 @@
     // We should still be at the end.
     assert.equal(result, CursorMoveResult.CLIPPED);
     assert.equal(cursor.index, 3);
-    assert.equal(cursor.target, list.children[3]);
+    assert.equal(cursor.target, list.children[3] as HTMLElement);
     assert.isTrue(cursor.isAtEnd());
 
     // Wind the cursor all the way back to the first stop.
@@ -82,7 +80,7 @@
 
     // The element state should reflect the start of the list.
     assert.equal(cursor.index, 0);
-    assert.equal(cursor.target, list.children[0]);
+    assert.equal(cursor.target, list.children[0] as HTMLElement);
     assert.isTrue(cursor.isAtStart());
     assert.isTrue(list.children[0].classList.contains('targeted'));
 
@@ -118,7 +116,7 @@
 
     assert.equal(result, CursorMoveResult.MOVED);
     assert.equal(cursor.index, 0);
-    assert.equal(cursor.target, list.children[0]);
+    assert.equal(cursor.target, list.children[0] as HTMLElement);
     assert.isTrue(list.children[0].classList.contains('targeted'));
     assert.isTrue(cursor.isAtStart());
     assert.isFalse(cursor.isAtEnd());
@@ -141,7 +139,7 @@
     assert.equal(result, CursorMoveResult.MOVED);
     const lastIndex = list.children.length - 1;
     assert.equal(cursor.index, lastIndex);
-    assert.equal(cursor.target, list.children[lastIndex]);
+    assert.equal(cursor.target, list.children[lastIndex] as HTMLElement);
     assert.isTrue(list.children[lastIndex].classList.contains('targeted'));
     assert.isFalse(cursor.isAtStart());
     assert.isTrue(cursor.isAtEnd());
@@ -161,7 +159,7 @@
     // Initialize the cursor with its stops.
     cursor.stops = [...list.querySelectorAll('li')];
     // Select the first stop.
-    cursor.setCursor(list.children[0]);
+    cursor.setCursor(list.children[0] as HTMLElement);
     const getTargetHeight = sinon.stub();
 
     // Move the cursor without an optional get target height function.
@@ -176,7 +174,7 @@
   test('_moveCursor from for invalid index does not check height', () => {
     cursor.stops = [];
     const getTargetHeight = sinon.stub();
-    cursor._moveCursor(1, () => false, {getTargetHeight});
+    cursor._moveCursor(1, {filter: () => false, getTargetHeight});
     assert.isFalse(getTargetHeight.called);
   });
 
@@ -194,12 +192,12 @@
   });
 
   test('move with filter', () => {
-    const isLetterB = function(row) {
+    const isLetterB = function (row: HTMLElement) {
       return row.textContent === 'B';
     };
     cursor.stops = [...list.querySelectorAll('li')];
     // Start cursor at the first stop.
-    cursor.setCursor(list.children[0]);
+    cursor.setCursor(list.children[0] as HTMLElement);
 
     // Move forward to meet the next condition.
     cursor.next({filter: isLetterB});
@@ -225,19 +223,19 @@
 
   test('focusOnMove prop', () => {
     const listEls = [...list.querySelectorAll('li')];
-    for (let i = 0; i < listEls.length; i++) {
-      sinon.spy(listEls[i], 'focus');
-    }
+    const listFocusStubs = listEls.map(listEl => sinon.spy(listEl, 'focus'));
     cursor.stops = listEls;
-    cursor.setCursor(list.children[0]);
+    cursor.setCursor(list.children[0] as HTMLElement);
 
     cursor.focusOnMove = false;
     cursor.next();
-    assert.isFalse(cursor.target.focus.called);
+    assert.equal(listEls[1], cursor.target);
+    assert.isFalse(listFocusStubs[1].called);
 
     cursor.focusOnMove = true;
     cursor.next();
-    assert.isTrue(cursor.target.focus.called);
+    assert.equal(listEls[2], cursor.target);
+    assert.isTrue(listFocusStubs[2].called);
   });
 
   suite('circular options', () => {
@@ -247,26 +245,26 @@
     });
 
     test('previous() on first element goes to last element', () => {
-      cursor.setCursor(list.children[0]);
+      cursor.setCursor(list.children[0] as HTMLElement);
       cursor.previous(options);
       assert.equal(cursor.index, list.children.length - 1);
     });
 
     test('next() on last element goes to first element', () => {
-      cursor.setCursor(list.children[list.children.length - 1]);
+      cursor.setCursor(list.children[list.children.length - 1] as HTMLElement);
       cursor.next(options);
       assert.equal(cursor.index, 0);
     });
   });
 
   suite('_scrollToTarget', () => {
-    let scrollStub;
+    let scrollStub: sinon.SinonStub;
     setup(() => {
       cursor.stops = [...list.querySelectorAll('li')];
       cursor.scrollMode = 'keep-visible';
 
       // There is a target which has a targetNext
-      cursor.setCursor(list.children[0]);
+      cursor.setCursor(list.children[0] as HTMLElement);
       cursor._moveCursor(1);
       scrollStub = sinon.stub(window, 'scrollTo');
       window.innerHeight = 60;
@@ -285,8 +283,9 @@
     });
 
     test('Called when top is visible, bottom is not, scroll is lower', () => {
-      const visibleStub = sinon.stub(cursor, '_targetIsVisible').callsFake(
-          () => visibleStub.callCount === 2);
+      const visibleStub = sinon
+        .stub(cursor, '_targetIsVisible')
+        .callsFake(() => visibleStub.callCount === 2);
       window.scrollX = 123;
       window.scrollY = 15;
       window.innerHeight = 1000;
@@ -299,8 +298,9 @@
     });
 
     test('Called when top is visible, bottom not, scroll is higher', () => {
-      const visibleStub = sinon.stub(cursor, '_targetIsVisible').callsFake(
-          () => visibleStub.callCount === 2);
+      const visibleStub = sinon
+        .stub(cursor, '_targetIsVisible')
+        .callsFake(() => visibleStub.callCount === 2);
       window.scrollX = 123;
       window.scrollY = 25;
       window.innerHeight = 1000;
@@ -316,8 +316,8 @@
       window.scrollY = 25;
       window.innerHeight = 300;
       window.pageYOffset = 0;
-      assert.equal(cursor._calculateScrollToValue(1000, {offsetHeight: 10}),
-          905);
+      const fakeElement = {offsetHeight: 10} as HTMLElement;
+      assert.equal(cursor._calculateScrollToValue(1000, fakeElement), 905);
     });
   });
 
diff --git a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.ts b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.ts
index 25ee130..05b7fb5 100644
--- a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.ts
+++ b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.ts
@@ -18,8 +18,10 @@
 } from '../../../utils/date-util';
 import {TimeFormat, DateFormat} from '../../../constants/constants';
 import {assertNever} from '../../../utils/common-util';
-import {Timestamp} from '../../../types/common';
-import {getAppContext} from '../../../services/app-context';
+import {PreferencesInfo, Timestamp} from '../../../types/common';
+import {resolve} from '../../../models/dependency';
+import {userModelToken} from '../../../models/user/user-model';
+import {subscribe} from '../../lit/subscription-controller';
 
 const TimeFormats = {
   TIME_12: 'h:mm A', // 2:14 PM
@@ -95,7 +97,7 @@
   @state()
   relative = false;
 
-  private readonly restApiService = getAppContext().restApiService;
+  private readonly getUserModel = resolve(this, userModelToken);
 
   static override get styles() {
     return [
@@ -108,17 +110,30 @@
     ];
   }
 
-  override render() {
-    if (!this.withTooltip) {
-      return this.renderDateString();
-    }
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.getUserModel().preferences$,
+      prefs => this.setPreferences(prefs)
+    );
+  }
 
-    const fullDateStr = this.computeFullDateStr();
-    if (!fullDateStr) {
-      return this.renderDateString();
-    }
+  // private but used by tests
+  setPreferences(prefs: PreferencesInfo) {
+    this.decideDateFormat(prefs.date_format);
+    this.decideTimeFormat(prefs.time_format);
+    this.relative =
+      this.forceRelative || Boolean(prefs?.relative_date_in_change_table);
+  }
+
+  override render() {
+    if (!this.withTooltip) return this.renderDateString();
+    const tooltip = this.computeFullDateStr();
+    if (!tooltip) return this.renderDateString();
+
     return html`
-      <gr-tooltip-content has-tooltip title=${fullDateStr}>
+      <gr-tooltip-content has-tooltip title=${tooltip}>
         ${this.renderDateString()}
       </gr-tooltip-content>
     `;
@@ -128,38 +143,11 @@
     return html` <span>${this.computeDateStr()}</span>`;
   }
 
-  override connectedCallback() {
-    super.connectedCallback();
-    this.loadPreferences();
-  }
-
   // private but used by tests
-  _getUtcOffsetString() {
+  getUtcOffsetString() {
     return utcOffsetString();
   }
 
-  // private but used by tests
-  async loadPreferences() {
-    const loggedIn = await this.restApiService.getLoggedIn();
-    if (!loggedIn) {
-      this.timeFormat = TimeFormats.TIME_24;
-      this.dateFormat = DateFormats.STD;
-      this.relative = this.forceRelative;
-      return;
-    }
-    await Promise.all([this.loadTimeFormat(), this.loadRelative()]);
-  }
-
-  // private but used in gr/file-list_test.ts
-  async loadTimeFormat() {
-    const preferences = await this.restApiService.getPreferences();
-    if (!preferences) {
-      throw Error('Preferences is not set');
-    }
-    this.decideTimeFormat(preferences.time_format);
-    this.decideDateFormat(preferences.date_format);
-  }
-
   private decideTimeFormat(timeFormat: TimeFormat) {
     switch (timeFormat) {
       case TimeFormat.HHMM_12:
@@ -195,12 +183,6 @@
     }
   }
 
-  private async loadRelative() {
-    const prefs = await this.restApiService.getPreferences();
-    this.relative =
-      this.forceRelative || Boolean(prefs?.relative_date_in_change_table);
-  }
-
   private computeDateStr() {
     if (!this.dateStr || !this.timeFormat || !this.dateFormat) {
       return '';
@@ -222,33 +204,24 @@
       if (isWithinHalfYear(now, date)) {
         format = this.dateFormat.short;
       }
-      if (this.showDateAndTime || this.showDateAndTime) {
+      if (this.showDateAndTime) {
         format = `${format} ${this.timeFormat}`;
       }
     }
     return formatDate(date, format);
   }
 
-  private computeFullDateStr() {
-    if (
-      [this.dateStr, this.timeFormat].includes(undefined) ||
-      !this.dateFormat
-    ) {
-      return undefined;
-    }
-
-    if (!this.dateStr) {
-      return '';
-    }
+  private computeFullDateStr(): string {
+    if (!this.dateStr) return '';
+    if (!this.timeFormat) return '';
+    if (!this.dateFormat) return '';
     const date = parseDate(this.dateStr as Timestamp);
-    if (!isValidDate(date)) {
-      return '';
-    }
-    let format = this.dateFormat.full + ', ';
-    format +=
+    if (!isValidDate(date)) return '';
+    const timeFormat =
       this.timeFormat === TimeFormats.TIME_12
         ? TimeFormats.TIME_12_WITH_SEC
         : TimeFormats.TIME_24_WITH_SEC;
-    return formatDate(date, format) + this._getUtcOffsetString();
+    const format = `dddd, ${this.dateFormat.full}, ${timeFormat}`;
+    return formatDate(date, format) + this.getUtcOffsetString();
   }
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_test.ts b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_test.ts
index d7c38df..98f2d82 100644
--- a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_test.ts
@@ -8,16 +8,15 @@
 import {GrDateFormatter} from './gr-date-formatter';
 import {parseDate} from '../../../utils/date-util';
 import {fixture, html, assert} from '@open-wc/testing';
-import {query, queryAndAssert, stubRestApi} from '../../../test/test-utils';
+import {query, queryAndAssert} from '../../../test/test-utils';
 import {GrTooltipContent} from '../gr-tooltip-content/gr-tooltip-content';
 import {Timestamp} from '../../../api/rest-api';
-import {PreferencesInfo} from '../../../types/common';
-import {createPreferences} from '../../../test/test-data-generators';
 import {
   createDefaultPreferences,
   DateFormat,
   TimeFormat,
 } from '../../../constants/constants';
+import {PreferencesInfo} from '../../../types/common';
 
 const basicTemplate = html`
   <gr-date-formatter withTooltip dateStr="2015-09-24 23:30:17.033000000">
@@ -41,6 +40,10 @@
     return d;
   }
 
+  function setPrefs(prefs: Partial<PreferencesInfo>) {
+    element.setPreferences({...createDefaultPreferences(), ...prefs});
+  }
+
   async function testDates(
     nowStr: string,
     dateStr: string,
@@ -68,23 +71,11 @@
     assert.equal(span.textContent?.trim(), expectedWithDateAndTime);
   }
 
-  function stubRestAPI(preferences?: PreferencesInfo) {
-    stubRestApi('getLoggedIn').resolves(preferences !== undefined);
-    stubRestApi('getPreferences').resolves(preferences);
-  }
-
   suite('STD + 24 hours time format preference', () => {
     setup(async () => {
-      stubRestAPI({
-        ...createPreferences(),
-        time_format: TimeFormat.HHMM_24,
-        date_format: DateFormat.STD,
-        relative_date_in_change_table: false,
-      });
-
       element = await fixture(basicTemplate);
-      sinon.stub(element, '_getUtcOffsetString').returns('');
-      await element.loadPreferences();
+      setPrefs({date_format: DateFormat.STD, time_format: TimeFormat.HHMM_24});
+      sinon.stub(element, 'getUtcOffsetString').returns('');
     });
 
     test('Within 24 hours on same day', async () => {
@@ -93,7 +84,7 @@
         '2015-07-29 15:34:14.985000000',
         '15:34',
         '15:34',
-        'Jul 29, 2015, 15:34:14'
+        'Wednesday, Jul 29, 2015, 15:34:14'
       );
     });
 
@@ -103,7 +94,7 @@
         '2015-07-28 20:25:14.985000000',
         'Jul 28',
         'Jul 28 20:25',
-        'Jul 28, 2015, 20:25:14'
+        'Tuesday, Jul 28, 2015, 20:25:14'
       );
     });
 
@@ -113,7 +104,7 @@
         '2015-06-15 03:25:14.985000000',
         'Jun 15',
         'Jun 15 03:25',
-        'Jun 15, 2015, 03:25:14'
+        'Monday, Jun 15, 2015, 03:25:14'
       );
     });
 
@@ -123,22 +114,16 @@
         '2015-01-15 03:25:00.000000000',
         'Jan 15, 2015',
         'Jan 15, 2015 03:25',
-        'Jan 15, 2015, 03:25:00'
+        'Thursday, Jan 15, 2015, 03:25:00'
       );
     });
   });
 
   suite('US + 24 hours time format preference', () => {
     setup(async () => {
-      stubRestAPI({
-        ...createPreferences(),
-        time_format: TimeFormat.HHMM_24,
-        date_format: DateFormat.US,
-        relative_date_in_change_table: false,
-      });
       element = await fixture(basicTemplate);
-      sinon.stub(element, '_getUtcOffsetString').returns('');
-      await element.loadPreferences();
+      setPrefs({date_format: DateFormat.US, time_format: TimeFormat.HHMM_24});
+      sinon.stub(element, 'getUtcOffsetString').returns('');
     });
 
     test('Within 24 hours on same day', async () => {
@@ -147,7 +132,7 @@
         '2015-07-29 15:34:14.985000000',
         '15:34',
         '15:34',
-        '07/29/15, 15:34:14'
+        'Wednesday, 07/29/15, 15:34:14'
       );
     });
 
@@ -157,7 +142,7 @@
         '2015-07-28 20:25:14.985000000',
         '07/28',
         '07/28 20:25',
-        '07/28/15, 20:25:14'
+        'Tuesday, 07/28/15, 20:25:14'
       );
     });
 
@@ -167,23 +152,16 @@
         '2015-06-15 03:25:14.985000000',
         '06/15',
         '06/15 03:25',
-        '06/15/15, 03:25:14'
+        'Monday, 06/15/15, 03:25:14'
       );
     });
   });
 
   suite('ISO + 24 hours time format preference', () => {
     setup(async () => {
-      stubRestAPI({
-        ...createPreferences(),
-        time_format: TimeFormat.HHMM_24,
-        date_format: DateFormat.ISO,
-        relative_date_in_change_table: false,
-      });
-
       element = await fixture(basicTemplate);
-      sinon.stub(element, '_getUtcOffsetString').returns('');
-      await element.loadPreferences();
+      setPrefs({date_format: DateFormat.ISO, time_format: TimeFormat.HHMM_24});
+      sinon.stub(element, 'getUtcOffsetString').returns('');
     });
 
     test('Within 24 hours on same day', async () => {
@@ -192,7 +170,7 @@
         '2015-07-29 15:34:14.985000000',
         '15:34',
         '15:34',
-        '2015-07-29, 15:34:14'
+        'Wednesday, 2015-07-29, 15:34:14'
       );
     });
 
@@ -202,7 +180,7 @@
         '2015-07-28 20:25:14.985000000',
         '07-28',
         '07-28 20:25',
-        '2015-07-28, 20:25:14'
+        'Tuesday, 2015-07-28, 20:25:14'
       );
     });
 
@@ -212,23 +190,16 @@
         '2015-06-15 03:25:14.985000000',
         '06-15',
         '06-15 03:25',
-        '2015-06-15, 03:25:14'
+        'Monday, 2015-06-15, 03:25:14'
       );
     });
   });
 
   suite('EURO + 24 hours time format preference', () => {
     setup(async () => {
-      stubRestAPI({
-        ...createPreferences(),
-        time_format: TimeFormat.HHMM_24,
-        date_format: DateFormat.EURO,
-        relative_date_in_change_table: false,
-      });
-
       element = await fixture(basicTemplate);
-      sinon.stub(element, '_getUtcOffsetString').returns('');
-      await element.loadPreferences();
+      setPrefs({date_format: DateFormat.EURO, time_format: TimeFormat.HHMM_24});
+      sinon.stub(element, 'getUtcOffsetString').returns('');
     });
 
     test('Within 24 hours on same day', async () => {
@@ -237,7 +208,7 @@
         '2015-07-29 15:34:14.985000000',
         '15:34',
         '15:34',
-        '29.07.2015, 15:34:14'
+        'Wednesday, 29.07.2015, 15:34:14'
       );
     });
 
@@ -247,7 +218,7 @@
         '2015-07-28 20:25:14.985000000',
         '28. Jul',
         '28. Jul 20:25',
-        '28.07.2015, 20:25:14'
+        'Tuesday, 28.07.2015, 20:25:14'
       );
     });
 
@@ -257,23 +228,16 @@
         '2015-06-15 03:25:14.985000000',
         '15. Jun',
         '15. Jun 03:25',
-        '15.06.2015, 03:25:14'
+        'Monday, 15.06.2015, 03:25:14'
       );
     });
   });
 
   suite('UK + 24 hours time format preference', () => {
     setup(async () => {
-      stubRestAPI({
-        ...createPreferences(),
-        time_format: TimeFormat.HHMM_24,
-        date_format: DateFormat.UK,
-        relative_date_in_change_table: false,
-      });
-
       element = await fixture(basicTemplate);
-      sinon.stub(element, '_getUtcOffsetString').returns('');
-      await element.loadPreferences();
+      setPrefs({date_format: DateFormat.UK, time_format: TimeFormat.HHMM_24});
+      sinon.stub(element, 'getUtcOffsetString').returns('');
     });
 
     test('Within 24 hours on same day', async () => {
@@ -282,7 +246,7 @@
         '2015-07-29 15:34:14.985000000',
         '15:34',
         '15:34',
-        '29/07/2015, 15:34:14'
+        'Wednesday, 29/07/2015, 15:34:14'
       );
     });
 
@@ -292,7 +256,7 @@
         '2015-07-28 20:25:14.985000000',
         '28/07',
         '28/07 20:25',
-        '28/07/2015, 20:25:14'
+        'Tuesday, 28/07/2015, 20:25:14'
       );
     });
 
@@ -302,22 +266,16 @@
         '2015-06-15 03:25:14.985000000',
         '15/06',
         '15/06 03:25',
-        '15/06/2015, 03:25:14'
+        'Monday, 15/06/2015, 03:25:14'
       );
     });
   });
 
   suite('STD + 12 hours time format preference', () => {
     setup(async () => {
-      // relative_date_in_change_table is not set when false.
-      stubRestAPI({
-        ...createPreferences(),
-        time_format: TimeFormat.HHMM_12,
-        date_format: DateFormat.STD,
-      });
       element = await fixture(basicTemplate);
-      sinon.stub(element, '_getUtcOffsetString').returns('');
-      await element.loadPreferences();
+      setPrefs({date_format: DateFormat.STD, time_format: TimeFormat.HHMM_12});
+      sinon.stub(element, 'getUtcOffsetString').returns('');
     });
 
     test('Within 24 hours on same day', async () => {
@@ -326,22 +284,16 @@
         '2015-07-29 15:34:14.985000000',
         '3:34 PM',
         '3:34 PM',
-        'Jul 29, 2015, 3:34:14 PM'
+        'Wednesday, Jul 29, 2015, 3:34:14 PM'
       );
     });
   });
 
   suite('US + 12 hours time format preference', () => {
     setup(async () => {
-      // relative_date_in_change_table is not set when false.
-      stubRestAPI({
-        ...createPreferences(),
-        time_format: TimeFormat.HHMM_12,
-        date_format: DateFormat.US,
-      });
       element = await fixture(basicTemplate);
-      sinon.stub(element, '_getUtcOffsetString').returns('');
-      await element.loadPreferences();
+      setPrefs({date_format: DateFormat.US, time_format: TimeFormat.HHMM_12});
+      sinon.stub(element, 'getUtcOffsetString').returns('');
     });
 
     test('Within 24 hours on same day', async () => {
@@ -350,22 +302,16 @@
         '2015-07-29 15:34:14.985000000',
         '3:34 PM',
         '3:34 PM',
-        '07/29/15, 3:34:14 PM'
+        'Wednesday, 07/29/15, 3:34:14 PM'
       );
     });
   });
 
   suite('ISO + 12 hours time format preference', () => {
     setup(async () => {
-      // relative_date_in_change_table is not set when false.
-      stubRestAPI({
-        ...createPreferences(),
-        time_format: TimeFormat.HHMM_12,
-        date_format: DateFormat.ISO,
-      });
       element = await fixture(basicTemplate);
-      sinon.stub(element, '_getUtcOffsetString').returns('');
-      await element.loadPreferences();
+      setPrefs({date_format: DateFormat.ISO, time_format: TimeFormat.HHMM_12});
+      sinon.stub(element, 'getUtcOffsetString').returns('');
     });
 
     test('Within 24 hours on same day', async () => {
@@ -374,22 +320,16 @@
         '2015-07-29 15:34:14.985000000',
         '3:34 PM',
         '3:34 PM',
-        '2015-07-29, 3:34:14 PM'
+        'Wednesday, 2015-07-29, 3:34:14 PM'
       );
     });
   });
 
   suite('EURO + 12 hours time format preference', () => {
     setup(async () => {
-      // relative_date_in_change_table is not set when false.
-      stubRestAPI({
-        ...createPreferences(),
-        time_format: TimeFormat.HHMM_12,
-        date_format: DateFormat.EURO,
-      });
       element = await fixture(basicTemplate);
-      sinon.stub(element, '_getUtcOffsetString').returns('');
-      await element.loadPreferences();
+      setPrefs({date_format: DateFormat.EURO, time_format: TimeFormat.HHMM_12});
+      sinon.stub(element, 'getUtcOffsetString').returns('');
     });
 
     test('Within 24 hours on same day', async () => {
@@ -398,22 +338,16 @@
         '2015-07-29 15:34:14.985000000',
         '3:34 PM',
         '3:34 PM',
-        '29.07.2015, 3:34:14 PM'
+        'Wednesday, 29.07.2015, 3:34:14 PM'
       );
     });
   });
 
   suite('UK + 12 hours time format preference', () => {
     setup(async () => {
-      // relative_date_in_change_table is not set when false.
-      stubRestAPI({
-        ...createPreferences(),
-        time_format: TimeFormat.HHMM_12,
-        date_format: DateFormat.UK,
-      });
       element = await fixture(basicTemplate);
-      sinon.stub(element, '_getUtcOffsetString').returns('');
-      await element.loadPreferences();
+      setPrefs({date_format: DateFormat.UK, time_format: TimeFormat.HHMM_12});
+      sinon.stub(element, 'getUtcOffsetString').returns('');
     });
 
     test('Within 24 hours on same day', async () => {
@@ -422,22 +356,20 @@
         '2015-07-29 15:34:14.985000000',
         '3:34 PM',
         '3:34 PM',
-        '29/07/2015, 3:34:14 PM'
+        'Wednesday, 29/07/2015, 3:34:14 PM'
       );
     });
   });
 
   suite('relative date preference', () => {
     setup(async () => {
-      stubRestAPI({
-        ...createPreferences(),
-        time_format: TimeFormat.HHMM_12,
+      element = await fixture(basicTemplate);
+      setPrefs({
         date_format: DateFormat.STD,
+        time_format: TimeFormat.HHMM_12,
         relative_date_in_change_table: true,
       });
-      element = await fixture(basicTemplate);
-      sinon.stub(element, '_getUtcOffsetString').returns('');
-      await element.loadPreferences();
+      sinon.stub(element, 'getUtcOffsetString').returns('');
     });
 
     test('Within 24 hours on same day', async () => {
@@ -446,7 +378,7 @@
         '2015-07-29 15:34:14.985000000',
         '5 hours ago',
         '5 hours ago',
-        'Jul 29, 2015, 3:34:14 PM'
+        'Wednesday, Jul 29, 2015, 3:34:14 PM'
       );
     });
 
@@ -456,21 +388,19 @@
         '2015-01-15 03:25:00.000000000',
         '8 months ago',
         '8 months ago',
-        'Jan 15, 2015, 3:25:00 AM'
+        'Thursday, Jan 15, 2015, 3:25:00 AM'
       );
     });
   });
 
   suite('logged in', () => {
     setup(async () => {
-      stubRestAPI({
-        ...createPreferences(),
-        time_format: TimeFormat.HHMM_12,
+      element = await fixture(basicTemplate);
+      setPrefs({
         date_format: DateFormat.US,
+        time_format: TimeFormat.HHMM_12,
         relative_date_in_change_table: true,
       });
-      element = await fixture(basicTemplate);
-      await element.loadPreferences();
     });
 
     test('Preferences are respected', () => {
@@ -483,13 +413,12 @@
 
   suite('logged out', () => {
     setup(async () => {
-      stubRestAPI(undefined);
       element = await fixture(basicTemplate);
-      await element.loadPreferences();
+      setPrefs({});
     });
 
     test('Default preferences are respected', () => {
-      assert.equal(element.timeFormat, 'HH:mm');
+      assert.equal(element.timeFormat, 'h:mm A');
       assert.equal(element.dateFormat?.short, 'MMM DD');
       assert.equal(element.dateFormat?.full, 'MMM DD, YYYY');
       assert.isFalse(element.relative);
@@ -498,9 +427,8 @@
 
   suite('with tooltip', () => {
     setup(async () => {
-      stubRestAPI(createDefaultPreferences());
       element = await fixture(basicTemplate);
-      await element.loadPreferences();
+      setPrefs({});
       await element.updateComplete;
     });
 
@@ -515,9 +443,8 @@
 
   suite('without tooltip', () => {
     setup(async () => {
-      stubRestAPI(createDefaultPreferences());
       element = await fixture(lightTemplate);
-      await element.loadPreferences();
+      setPrefs({});
       await element.updateComplete;
     });
 
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 dbf75f4..93201c8 100644
--- a/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog.ts
+++ b/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog.ts
@@ -10,6 +10,7 @@
 import {sharedStyles} from '../../../styles/shared-styles';
 import {fontStyles} from '../../../styles/gr-font-styles';
 import {when} from 'lit/directives/when.js';
+import {fireNoBubble} from '../../../utils/event-util';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -31,6 +32,9 @@
    * @event cancel
    */
 
+  @query('#cancel')
+  cancelButton?: GrButton;
+
   @query('#confirm')
   confirmButton?: GrButton;
 
@@ -102,6 +106,7 @@
           display: flex;
           flex-shrink: 0;
           padding-top: var(--spacing-xl);
+          align-items: center;
         }
         .flex-space {
           flex-grow: 1;
@@ -147,6 +152,7 @@
               <span class="loadingLabel"> ${this.loadingLabel} </span>
             `
           )}
+          <slot name="footer"></slot>
           <div class="flex-space"></div>
           <gr-button
             id="cancel"
@@ -195,23 +201,13 @@
 
     e.preventDefault();
     e.stopPropagation();
-    this.dispatchEvent(
-      new CustomEvent('confirm', {
-        composed: true,
-        bubbles: false,
-      })
-    );
+    fireNoBubble(this, 'confirm', {});
   }
 
   private handleCancelTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
-    this.dispatchEvent(
-      new CustomEvent('cancel', {
-        composed: true,
-        bubbles: false,
-      })
-    );
+    fireNoBubble(this, 'cancel', {});
   }
 
   _handleKeydown(e: KeyboardEvent) {
@@ -221,6 +217,10 @@
   }
 
   resetFocus() {
-    this.confirmButton!.focus();
+    if (this.disabled && this.cancelLabel) {
+      this.cancelButton!.focus();
+    } else {
+      this.confirmButton!.focus();
+    }
   }
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog_test.ts b/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog_test.ts
index d386c32..41fcfed 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
@@ -36,6 +36,7 @@
           </div>
         </main>
         <footer>
+          <slot name="footer"></slot>
           <div class="flex-space"></div>
           <gr-button
             aria-disabled="false"
@@ -81,6 +82,7 @@
           <span class="loadingSpin" aria-label="Loading!!" role="progressbar">
           </span>
           <span class="loadingLabel"> Loading!! </span>
+          <slot name="footer"></slot>
           <div class="flex-space"></div>
           <gr-button
             aria-disabled="false"
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 15d7072..019bec1 100644
--- a/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences.ts
+++ b/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences.ts
@@ -8,7 +8,6 @@
 import '../gr-button/gr-button';
 import '../gr-select/gr-select';
 import {DiffPreferencesInfo, IgnoreWhitespaceType} from '../../../types/diff';
-import {getAppContext} from '../../../services/app-context';
 import {subscribe} from '../../lit/subscription-controller';
 import {formStyles} from '../../../styles/gr-form-styles';
 import {sharedStyles} from '../../../styles/shared-styles';
@@ -18,6 +17,8 @@
 import {fire} from '../../../utils/event-util';
 import {ValueChangedEvent} from '../../../types/events';
 import {GrSelect} from '../gr-select/gr-select';
+import {resolve} from '../../../models/dependency';
+import {userModelToken} from '../../../models/user/user-model';
 
 @customElement('gr-diff-preferences')
 export class GrDiffPreferences extends LitElement {
@@ -51,13 +52,13 @@
 
   @state() private originalDiffPrefs?: DiffPreferencesInfo;
 
-  private readonly userModel = getAppContext().userModel;
+  private readonly getUserModel = resolve(this, userModelToken);
 
   constructor() {
     super();
     subscribe(
       this,
-      () => this.userModel.diffPreferences$,
+      () => this.getUserModel().diffPreferences$,
       diffPreferences => {
         if (!diffPreferences) return;
         this.originalDiffPrefs = diffPreferences;
@@ -314,7 +315,7 @@
 
   async save() {
     if (!this.diffPrefs) return;
-    await this.userModel.updateDiffPreference(this.diffPrefs);
+    await this.getUserModel().updateDiffPreference(this.diffPrefs);
     fire(this, 'has-unsaved-changes-changed', {
       value: this.hasUnsavedChanges(),
     });
diff --git a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.ts b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.ts
index 2d93227..886894e 100644
--- a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.ts
+++ b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.ts
@@ -16,6 +16,8 @@
 import {customElement, property, state} from 'lit/decorators.js';
 import {fire} from '../../../utils/event-util';
 import {BindValueChangeEvent} from '../../../types/events';
+import {resolve} from '../../../models/dependency';
+import {userModelToken} from '../../../models/user/user-model';
 
 declare global {
   interface HTMLElementEventMap {
@@ -53,7 +55,7 @@
   private readonly restApiService = getAppContext().restApiService;
 
   // Private but used in tests.
-  readonly userModel = getAppContext().userModel;
+  readonly getUserModel = resolve(this, userModelToken);
 
   private subscriptions: Subscription[] = [];
 
@@ -63,7 +65,7 @@
       this.loggedIn = loggedIn;
     });
     this.subscriptions.push(
-      this.userModel.preferences$.subscribe(prefs => {
+      this.getUserModel().preferences$.subscribe(prefs => {
         if (prefs?.download_scheme) {
           // Note (issue 5180): normalize the download scheme with lower-case.
           this.selectedScheme = prefs.download_scheme.toLowerCase();
@@ -194,7 +196,7 @@
       this.selectedScheme = scheme;
       fire(this, 'selected-scheme-changed', {value: scheme});
       if (this.loggedIn) {
-        this.userModel.updatePreferences({
+        this.getUserModel().updatePreferences({
           download_scheme: this.selectedScheme,
         });
       }
diff --git a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_test.ts b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_test.ts
index 695c674..b1d4e36 100644
--- a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_test.ts
@@ -18,6 +18,8 @@
 import {PaperTabsElement} from '@polymer/paper-tabs/paper-tabs';
 import {fixture, html, assert} from '@open-wc/testing';
 import {PaperTabElement} from '@polymer/paper-tabs/paper-tab';
+import {UserModel, userModelToken} from '../../../models/user/user-model';
+import {testResolver} from '../../../test/common-test-setup';
 
 suite('gr-download-commands', () => {
   let element: GrDownloadCommands;
@@ -170,11 +172,16 @@
     });
   });
   suite('authenticated', () => {
-    test('loads scheme from preferences', async () => {
-      const element: GrDownloadCommands = await fixture(
+    let element: GrDownloadCommands;
+    let userModel: UserModel;
+    setup(async () => {
+      userModel = testResolver(userModelToken);
+      element = await fixture(
         html`<gr-download-commands></gr-download-commands>`
       );
-      element.userModel.setPreferences({
+    });
+    test('loads scheme from preferences', async () => {
+      userModel.setPreferences({
         ...createPreferences(),
         download_scheme: 'repo',
       });
@@ -182,10 +189,7 @@
     });
 
     test('normalize scheme from preferences', async () => {
-      const element: GrDownloadCommands = await fixture(
-        html`<gr-download-commands></gr-download-commands>`
-      );
-      element.userModel.setPreferences({
+      userModel.setPreferences({
         ...createPreferences(),
         download_scheme: 'REPO',
       });
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.ts b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.ts
index b6ca9f5..9b74e24 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.ts
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.ts
@@ -14,7 +14,7 @@
 import {css, html, LitElement, PropertyValues} from 'lit';
 import {customElement, property, query} from 'lit/decorators.js';
 import {IronDropdownElement} from '@polymer/iron-dropdown/iron-dropdown';
-import {Timestamp} from '../../../types/common';
+import {CommentThread, Timestamp} from '../../../types/common';
 import {NormalizedFileInfo} from '../../change/gr-file-list/gr-file-list';
 import {GrButton} from '../gr-button/gr-button';
 import {assertIsDefined} from '../../../utils/common-util';
@@ -23,6 +23,7 @@
 import {incrementalRepeat} from '../../lit/incremental-repeat';
 import {when} from 'lit/directives/when.js';
 import {isMagicPath} from '../../../utils/path-list-util';
+import {fireNoBubble} from '../../../utils/event-util';
 
 /**
  * Required values are text and value. mobileText and triggerText will
@@ -42,6 +43,7 @@
   date?: Timestamp;
   disabled?: boolean;
   file?: NormalizedFileInfo;
+  commentThreads?: CommentThread[];
 }
 
 declare global {
@@ -164,6 +166,9 @@
             --selection-background-color
           );
         }
+        gr-comments-summary {
+          padding-left: var(--spacing-s);
+        }
         @media only screen and (max-width: 50em) {
           gr-select {
             display: var(--gr-select-style-display, inline);
@@ -250,7 +255,17 @@
     return html`
       <paper-item ?disabled=${item.disabled} data-value=${item.value}>
         <div class="topContent">
-          <div>${item.text}</div>
+          <div>
+            <span>${item.text}</span>
+            ${when(
+              item.commentThreads,
+              () => html`<gr-comments-summary
+                .commentThreads=${item.commentThreads}
+                emptyWhenNoComments
+                showAvatarForResolved
+              ></gr-comments-summary>`
+            )}
+          </div>
           ${when(
             item.date,
             () => html`
@@ -303,12 +318,7 @@
     this.text = selectedObj.triggerText
       ? selectedObj.triggerText
       : selectedObj.text;
-    this.dispatchEvent(
-      new CustomEvent('value-change', {
-        detail: {value: this.value},
-        bubbles: false,
-      })
-    );
+    fireNoBubble(this, 'value-change', {value: this.value});
   }
 
   /**
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_test.ts b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_test.ts
index b9380cb..c148a1b 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_test.ts
@@ -91,7 +91,7 @@
               tabindex="-1"
             >
               <div class="topContent">
-                <div>Top Text 1</div>
+                <div><span>Top Text 1</span></div>
               </div>
             </paper-item>
             <paper-item
@@ -103,7 +103,7 @@
               tabindex="0"
             >
               <div class="topContent">
-                <div>Top Text 2</div>
+                <div><span>Top Text 2</span></div>
               </div>
               <div class="bottomContent">
                 <div>Bottom Text 2</div>
@@ -119,7 +119,7 @@
               tabindex="-1"
             >
               <div class="topContent">
-                <div>Top Text 3</div>
+                <div><span>Top Text 3</span></div>
                 <gr-date-formatter> </gr-date-formatter>
               </div>
               <div class="bottomContent">
@@ -231,7 +231,8 @@
     assert.equal(items[0].dataset.value, element.items[0].value as any);
     assert.equal(mobileItems[0].value, element.items[0].value);
     assert.equal(
-      queryAndAssert<HTMLDivElement>(items[0], '.topContent div').innerText,
+      queryAndAssert<HTMLDivElement>(items[0], '.topContent div span')
+        .innerText,
       element.items[0].text
     );
 
@@ -250,7 +251,8 @@
     assert.equal(items[1].dataset.value, element.items[1].value as any);
     assert.equal(mobileItems[1].value, element.items[1].value);
     assert.equal(
-      queryAndAssert<HTMLDivElement>(items[1], '.topContent div').innerText,
+      queryAndAssert<HTMLDivElement>(items[1], '.topContent div span')
+        .textContent,
       element.items[1].text
     );
 
@@ -273,7 +275,8 @@
     assert.equal(items[2].dataset.value, element.items[2].value as any);
     assert.equal(mobileItems[2].value, element.items[2].value);
     assert.equal(
-      queryAndAssert<HTMLDivElement>(items[2], '.topContent div').innerText,
+      queryAndAssert<HTMLDivElement>(items[2], '.topContent div span')
+        .innerText,
       element.items[2].text
     );
 
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.ts b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.ts
index 3a8946a..fbcd893 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.ts
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.ts
@@ -21,6 +21,7 @@
 import {ValueChangedEvent} from '../../../types/events';
 import {assertIsDefined} from '../../../utils/common-util';
 import {ShortcutController} from '../../lit/shortcut-controller';
+import {DropdownLink} from '../../../types/common';
 
 const REL_NOOPENER = 'noopener';
 const REL_EXTERNAL = 'external';
@@ -34,16 +35,6 @@
   }
 }
 
-export interface DropdownLink {
-  url?: string;
-  name?: string;
-  external?: boolean;
-  target?: string | null;
-  download?: boolean;
-  id?: string;
-  tooltip?: string;
-}
-
 export interface DropdownContent {
   text: string;
   bold?: boolean;
@@ -242,7 +233,8 @@
         allowOutsideScroll
         .horizontalAlign=${this.horizontalAlign}
         @click=${() => this.close()}
-        @opened-changed=${(e: CustomEvent) => (this.opened = e.detail.value)}
+        @opened-changed=${(e: ValueChangedEvent<boolean>) =>
+          (this.opened = e.detail.value)}
       >
         ${this.renderDropdownContent()}
       </iron-dropdown>`;
@@ -460,13 +452,7 @@
     const item = this.items.find(item => item.id === id);
     if (id && !this.disabledIds.includes(id)) {
       if (item) {
-        this.dispatchEvent(
-          new CustomEvent('tap-item', {
-            detail: item,
-            bubbles: true,
-            composed: true,
-          })
-        );
+        fire(this, 'tap-item', item);
       }
       this.dispatchEvent(new CustomEvent('tap-item-' + id));
     }
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.ts b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.ts
index fab742f..e9ef52b 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.ts
@@ -5,11 +5,12 @@
  */
 import '../../../test/common-test-setup';
 import './gr-dropdown';
-import {DropdownLink, GrDropdown} from './gr-dropdown';
+import {GrDropdown} from './gr-dropdown';
 import {pressKey, queryAll, queryAndAssert} from '../../../test/test-utils';
 import {GrTooltipContent} from '../gr-tooltip-content/gr-tooltip-content';
 import {assertIsDefined} from '../../../utils/common-util';
 import {fixture, html, assert} from '@open-wc/testing';
+import {DropdownLink} from '../../../types/common';
 
 suite('gr-dropdown tests', () => {
   let element: GrDropdown;
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 d6a4d94..3110a96 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
@@ -10,7 +10,7 @@
 import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
 import '../../plugins/gr-endpoint-param/gr-endpoint-param';
 import '../../plugins/gr-endpoint-slot/gr-endpoint-slot';
-import {fire, fireAlert, fireEvent} from '../../../utils/event-util';
+import {fire, fireAlert} from '../../../utils/event-util';
 import {getAppContext} from '../../../services/app-context';
 import {debounce, DelayedTask} from '../../../utils/async-util';
 import {queryAndAssert} from '../../../utils/common-util';
@@ -21,11 +21,17 @@
 import {sharedStyles} from '../../../styles/shared-styles';
 import {css} from 'lit';
 import {PropertyValues} from 'lit';
-import {BindValueChangeEvent, ValueChangedEvent} from '../../../types/events';
+import {
+  BindValueChangeEvent,
+  EditableContentSaveEvent,
+  ValueChangedEvent,
+} from '../../../types/events';
 import {nothing} from 'lit';
 import {classMap} from 'lit/directives/class-map.js';
 import {when} from 'lit/directives/when.js';
 import {fontStyles} from '../../../styles/gr-font-styles';
+import {storageServiceToken} from '../../../services/storage/gr-storage_impl';
+import {resolve} from '../../../models/dependency';
 
 const RESTORED_MESSAGE = 'Content restored from a previous edit.';
 const STORAGE_DEBOUNCE_INTERVAL_MS = 400;
@@ -37,24 +43,16 @@
   interface HTMLElementEventMap {
     'content-changed': ValueChangedEvent<string>;
     'editing-changed': ValueChangedEvent<boolean>;
+    /** Fired when the 'cancel' button is pressed. */
+    'editable-content-cancel': CustomEvent<{}>;
+    /** Fired when the 'save' button is pressed. */
+    'editable-content-save': EditableContentSaveEvent;
   }
 }
 
 @customElement('gr-editable-content')
 export class GrEditableContent extends LitElement {
   /**
-   * Fired when the save button is pressed.
-   *
-   * @event editable-content-save
-   */
-
-  /**
-   * Fired when the cancel button is pressed.
-   *
-   * @event editable-content-cancel
-   */
-
-  /**
    * Fired when content is restored from storage.
    *
    * @event show-alert
@@ -90,7 +88,7 @@
 
   @state() newContent = '';
 
-  private readonly storage = getAppContext().storageService;
+  private readonly getStorage = resolve(this, storageServiceToken);
 
   private readonly reporting = getAppContext().reportingService;
 
@@ -321,14 +319,14 @@
       this.storeTask,
       () => {
         if (this.newContent.length) {
-          this.storage.setEditableContentItem(storageKey, this.newContent);
+          this.getStorage().setEditableContentItem(storageKey, this.newContent);
         } else {
           // This does not really happen, because we don't clear newContent
           // after saving (see below). So this only occurs when the user clears
           // all the content in the editable textarea. But GrStorage cleans
           // up itself after one day, so we are not so concerned about leaving
           // some garbage behind.
-          this.storage.eraseEditableContentItem(storageKey);
+          this.getStorage().eraseEditableContentItem(storageKey);
         }
       },
       STORAGE_DEBOUNCE_INTERVAL_MS
@@ -358,7 +356,7 @@
 
     let content;
     if (this.storageKey) {
-      const storedContent = this.storage.getEditableContentItem(
+      const storedContent = this.getStorage().getEditableContentItem(
         this.storageKey
       );
       if (storedContent?.message) {
@@ -384,13 +382,7 @@
 
   handleSave(e: Event) {
     e.preventDefault();
-    this.dispatchEvent(
-      new CustomEvent('editable-content-save', {
-        detail: {content: this.newContent},
-        composed: true,
-        bubbles: true,
-      })
-    );
+    fire(this, 'editable-content-save', {content: this.newContent});
     // It would be nice, if we would set this.newContent = undefined here,
     // but we can only do that when we are sure that the save operation has
     // succeeded.
@@ -399,7 +391,7 @@
   handleCancel(e: Event) {
     e.preventDefault();
     this.editing = false;
-    fireEvent(this, 'editable-content-cancel');
+    fire(this, 'editable-content-cancel', {});
   }
 
   toggleCommitCollapsed() {
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 fec347c6..4a5611b 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
@@ -6,17 +6,21 @@
 import '../../../test/common-test-setup';
 import './gr-editable-content';
 import {GrEditableContent} from './gr-editable-content';
-import {query, queryAndAssert, stubStorage} from '../../../test/test-utils';
+import {query, queryAndAssert} from '../../../test/test-utils';
 import {GrButton} from '../gr-button/gr-button';
 import {fixture, html, assert} from '@open-wc/testing';
-import {EventType} from '../../../types/events';
+import {StorageService} from '../../../services/storage/gr-storage';
+import {storageServiceToken} from '../../../services/storage/gr-storage_impl';
+import {testResolver} from '../../../test/common-test-setup';
 
 suite('gr-editable-content tests', () => {
   let element: GrEditableContent;
+  let storageService: StorageService;
 
   setup(async () => {
     element = await fixture(html`<gr-editable-content></gr-editable-content>`);
     await element.updateComplete;
+    storageService = testResolver(storageServiceToken);
   });
 
   test('renders', () => {
@@ -177,7 +181,7 @@
     });
 
     test('editing toggled to true, has stored data', async () => {
-      stubStorage('getEditableContentItem').returns({
+      sinon.stub(storageService, 'getEditableContentItem').returns({
         message: 'stored content',
         updated: 0,
       });
@@ -185,11 +189,11 @@
       await element.updateComplete;
       assert.equal(element.newContent, 'stored content');
       assert.isTrue(dispatchSpy.called);
-      assert.equal(dispatchSpy.lastCall.args[0].type, EventType.SHOW_ALERT);
+      assert.equal(dispatchSpy.lastCall.args[0].type, 'show-alert');
     });
 
     test('editing toggled to true, has no stored data', async () => {
-      stubStorage('getEditableContentItem').returns(null);
+      sinon.stub(storageService, 'getEditableContentItem').returns(null);
       element.editing = true;
 
       await element.updateComplete;
@@ -199,8 +203,8 @@
     });
 
     test('edits are cached', async () => {
-      const storeStub = stubStorage('setEditableContentItem');
-      const eraseStub = stubStorage('eraseEditableContentItem');
+      const storeStub = sinon.stub(storageService, 'setEditableContentItem');
+      const eraseStub = sinon.stub(storageService, 'eraseEditableContentItem');
       element.editing = true;
 
       // Needed because editingChanged resets newContent
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.ts b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.ts
index bf8209b..b82c023 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.ts
+++ b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.ts
@@ -21,6 +21,8 @@
 import {PaperInputElement} from '@polymer/paper-input/paper-input';
 import {IronInputElement} from '@polymer/iron-input';
 import {ShortcutController} from '../../lit/shortcut-controller';
+import {ValueChangedEvent} from '../../../types/events';
+import {fire} from '../../../utils/event-util';
 
 const AWAIT_MAX_ITERS = 10;
 const AWAIT_STEP = 5;
@@ -118,6 +120,7 @@
         .inputContainer {
           background-color: var(--dialog-background-color);
           padding: var(--spacing-m);
+          white-space: nowrap;
         }
         /* This makes inputContainer on one line. */
         .inputContainer gr-autocomplete,
@@ -207,7 +210,7 @@
         .text=${this.inputText}
         .query=${this.query}
         @cancel=${this.cancel}
-        @text-changed=${(e: CustomEvent) => {
+        @text-changed=${(e: ValueChangedEvent) => {
           this.inputText = e.detail.value;
         }}
       >
@@ -308,13 +311,8 @@
       this.value = this.inputText || '';
     }
     this.editing = false;
-    this.dispatchEvent(
-      new CustomEvent('changed', {
-        detail: this.value,
-        composed: true,
-        bubbles: true,
-      })
-    );
+    // TODO: This event seems to be unused (no listener). Remove?
+    fire(this, 'changed', this.value);
   }
 
   private cancel() {
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_test.ts b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_test.ts
index d916118..3bb058e 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_test.ts
@@ -295,7 +295,10 @@
       suggestions = [{name: 'value text 1'}, {name: 'value text 2'}];
       await element.open();
 
+      // Waiting until dropdown not hidden, will ensure dialog is open and input
+      // is focused, but not that the suggestion has loaded.
       await waitUntil(() => !autocomplete.suggestionsDropdown!.isHidden);
+      await autocomplete.latestSuggestionUpdateComplete;
 
       pressKey(autocomplete.input!, Key.ENTER);
 
@@ -312,7 +315,11 @@
     test('autocomplete suggestions closed enter saves suggestion', async () => {
       suggestions = [{name: 'value text 1'}, {name: 'value text 2'}];
       await element.open();
+      // Waiting until dropdown not hidden, will ensure dialog is open and input
+      // is focused, but not that the suggestion has loaded.
       await waitUntil(() => !autocomplete.suggestionsDropdown!.isHidden);
+      await autocomplete.latestSuggestionUpdateComplete;
+
       // Press enter to close suggestions.
       pressKey(autocomplete.input!, Key.ENTER);
 
diff --git a/polygerrit-ui/app/elements/shared/gr-file-status/gr-file-status.ts b/polygerrit-ui/app/elements/shared/gr-file-status/gr-file-status.ts
index 578eda4..943f4de 100644
--- a/polygerrit-ui/app/elements/shared/gr-file-status/gr-file-status.ts
+++ b/polygerrit-ui/app/elements/shared/gr-file-status/gr-file-status.ts
@@ -10,7 +10,7 @@
 import '../gr-tooltip-content/gr-tooltip-content';
 import '../gr-icon/gr-icon';
 
-function statusString(status: FileInfoStatus) {
+function statusString(status?: FileInfoStatus) {
   if (!status) return '';
   switch (status) {
     case FileInfoStatus.ADDED:
@@ -115,15 +115,17 @@
 
   private renderStatus() {
     const classes = ['status', this.status];
-    return html`<gr-tooltip-content title=${this.computeLabel()} has-tooltip>
-      <div
-        class=${classes.join(' ')}
-        tabindex="0"
-        aria-label=${this.computeLabel()}
+    return html`
+      <gr-tooltip-content
+        title=${this.computeLabel()}
+        has-tooltip
+        aria-label=${statusString(this.status)}
       >
-        ${this.renderIconOrLetter()}
-      </div>
-    </gr-tooltip-content>`;
+        <div class=${classes.join(' ')} aria-hidden="true">
+          ${this.renderIconOrLetter()}
+        </div>
+      </gr-tooltip-content>
+    `;
   }
 
   private renderIconOrLetter() {
@@ -135,13 +137,15 @@
 
   private renderNewlyChanged() {
     if (!this.newlyChanged) return;
-    return html`<gr-tooltip-content title=${this.computeLabel()} has-tooltip>
-      <gr-icon
-        icon="new_releases"
-        class="size-16"
-        aria-label=${this.computeLabel()}
-      ></gr-icon>
-    </gr-tooltip-content>`;
+    return html`
+      <gr-tooltip-content
+        title=${this.computeLabel()}
+        has-tooltip
+        aria-label="newly"
+      >
+        <gr-icon icon="new_releases" class="size-16"></gr-icon>
+      </gr-tooltip-content>
+    `;
   }
 
   private computeLabel() {
diff --git a/polygerrit-ui/app/elements/shared/gr-file-status/gr-file-status_test.ts b/polygerrit-ui/app/elements/shared/gr-file-status/gr-file-status_test.ts
index 3bf877e..555b237 100644
--- a/polygerrit-ui/app/elements/shared/gr-file-status/gr-file-status_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-file-status/gr-file-status_test.ts
@@ -28,8 +28,8 @@
       assert.shadowDom.equal(
         element,
         /* HTML */ `
-          <gr-tooltip-content has-tooltip="" title="">
-            <div class="status" aria-label="" tabindex="0"><span></span></div>
+          <gr-tooltip-content has-tooltip="" title="" aria-label="">
+            <div class="status" aria-hidden="true"><span></span></div>
           </gr-tooltip-content>
         `
       );
@@ -40,8 +40,8 @@
       assert.shadowDom.equal(
         element,
         /* HTML */ `
-          <gr-tooltip-content has-tooltip="" title="Added">
-            <div class="A status" aria-label="Added" tabindex="0">
+          <gr-tooltip-content has-tooltip="" title="Added" aria-label="Added">
+            <div class="A status" aria-hidden="true">
               <span>A</span>
             </div>
           </gr-tooltip-content>
@@ -54,15 +54,19 @@
       assert.shadowDom.equal(
         element,
         /* HTML */ `
-          <gr-tooltip-content has-tooltip="" title="Newly Added">
-            <gr-icon
-              icon="new_releases"
-              class="size-16"
-              aria-label="Newly Added"
-            ></gr-icon>
+          <gr-tooltip-content
+            has-tooltip=""
+            title="Newly Added"
+            aria-label="newly"
+          >
+            <gr-icon icon="new_releases" class="size-16"></gr-icon>
           </gr-tooltip-content>
-          <gr-tooltip-content has-tooltip="" title="Newly Added">
-            <div class="A status" aria-label="Newly Added" tabindex="0">
+          <gr-tooltip-content
+            has-tooltip=""
+            title="Newly Added"
+            aria-label="Added"
+          >
+            <div class="A status" aria-hidden="true">
               <span>A</span>
             </div>
           </gr-tooltip-content>
diff --git a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts
index 5a1db30..a01f349 100644
--- a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts
+++ b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts
@@ -18,8 +18,13 @@
 import {CommentLinks, EmailAddress} from '../../../api/rest-api';
 import {linkifyUrlsAndApplyRewrite} from '../../../utils/link-util';
 import '../gr-account-chip/gr-account-chip';
+import '../gr-user-suggestion-fix/gr-user-suggestion-fix';
 import {KnownExperimentId} from '../../../services/flags/flags';
 import {getAppContext} from '../../../services/app-context';
+import {
+  getUserSuggestionFromString,
+  USER_SUGGESTION_INFO_STRING,
+} from '../../../utils/comment-util';
 
 /**
  * This element optionally renders markdown and also applies some regex
@@ -40,6 +45,12 @@
 
   private readonly getConfigModel = resolve(this, configModelToken);
 
+  // Private const but used in tests.
+  // Limit the length of markdown because otherwise the markdown lexer will
+  // run out of memory causing the tab to crash.
+  @state()
+  MARKDOWN_LIMIT = 100000;
+
   /**
    * Note: Do not use sharedStyles or other styles here that should not affect
    * the generated HTML of the markdown.
@@ -84,11 +95,6 @@
       :not(pre) > code {
         display: inline;
       }
-      p {
-        /* prose will automatically wrap but inline <code> blocks won't and we
-           should overflow in that case rather than wrapping or leaking out */
-        overflow-x: auto;
-      }
       li {
         margin-left: var(--spacing-xl);
       }
@@ -100,6 +106,14 @@
         white-space: var(--linked-text-white-space, pre-wrap);
         word-wrap: var(--linked-text-word-wrap, break-word);
       }
+      .markdown-html {
+        /* code overrides white-space to pre, everything else should wrap as
+           normal. */
+        white-space: normal;
+        /* prose will automatically wrap but inline <code> blocks won't and we
+           should overflow in that case rather than wrapping or leaking out */
+        overflow-x: auto;
+      }
     `,
   ];
 
@@ -108,12 +122,20 @@
     subscribe(
       this,
       () => this.getConfigModel().repoCommentLinks$,
-      repoCommentLinks => (this.repoCommentLinks = repoCommentLinks)
+      repoCommentLinks => {
+        this.repoCommentLinks = repoCommentLinks;
+        // Always linkify URLs starting with https?://
+        this.repoCommentLinks['ALWAYS_LINK_HTTP'] = {
+          match: '(https?://\\S+[\\w/~-])',
+          link: '$1',
+          enabled: true,
+        };
+      }
     );
   }
 
   override render() {
-    if (this.markdown) {
+    if (this.markdown && this.content.length < this.MARKDOWN_LIMIT) {
       return this.renderAsMarkdown();
     } else {
       return this.renderAsPlaintext();
@@ -132,11 +154,36 @@
   }
 
   private renderAsMarkdown() {
-    // <marked-element> internals will be in charge of calling our custom
-    // renderer so we wrap 'this.rewriteText' so that 'this' is preserved via
-    // closure.
-    const boundRewriteText = (text: string) =>
-      linkifyUrlsAndApplyRewrite(text, this.repoCommentLinks);
+    // Need to find out here, since customRender is not arrow function
+    const suggestEditsEnable = this.flagsService.isEnabled(
+      KnownExperimentId.SUGGEST_EDIT
+    );
+    // Bind `this` via closure.
+    const boundRewriteText = (text: string) => {
+      const nonAsteriskRewrites = Object.fromEntries(
+        Object.entries(this.repoCommentLinks).filter(
+          ([_name, rewrite]) => !rewrite.match.includes('\\*')
+        )
+      );
+      return linkifyUrlsAndApplyRewrite(text, nonAsteriskRewrites);
+    };
+
+    // Due to a tokenizer bug in the old version of markedjs we use, text with a
+    // single asterisk is separated into 2 tokens before passing to renderer
+    // ['text'] which breaks our rewrites that would span across the 2 tokens.
+    // Since upgrading our markedjs version is infeasible, we are applying those
+    // asterisk rewrites again at the end (using renderer['paragraph'] hook)
+    // after all the nodes are combined.
+    // Bind `this` via closure.
+    const boundRewriteAsterisks = (text: string) => {
+      const asteriskRewrites = Object.fromEntries(
+        Object.entries(this.repoCommentLinks).filter(([_name, rewrite]) =>
+          rewrite.match.includes('\\*')
+        )
+      );
+      const linkedText = linkifyUrlsAndApplyRewrite(text, asteriskRewrites);
+      return `<p>${linkedText}</p>`;
+    };
 
     // We are overriding some marked-element renderers for a few reasons:
     // 1. Disable inline images as a design/policy choice.
@@ -165,7 +212,23 @@
         `![${text}](${href})`;
       renderer['codespan'] = (text: string) =>
         `<code>${unescapeHTML(text)}</code>`;
-      renderer['code'] = (text: string) => `<pre><code>${text}</code></pre>`;
+      renderer['code'] = (text: string, infostring: string) => {
+        if (suggestEditsEnable && infostring === USER_SUGGESTION_INFO_STRING) {
+          // default santizer in markedjs is very restrictive, we need to use
+          // existing html element to mark element. We cannot use css class for
+          // it. Therefore we pick mark - as not frequently used html element to
+          // represent unconverted gr-user-suggestion-fix.
+          // TODO(milutin): Find a way to override sanitizer to directly use
+          // gr-user-suggestion-fix
+          return `<mark>${text}</mark>`;
+        } else {
+          return `<pre><code>${text}</code></pre>`;
+        }
+      };
+      // <marked-element> internals will be in charge of calling our custom
+      // renderer so we write these functions separately so that 'this' is
+      // preserved via closure.
+      renderer['paragraph'] = boundRewriteAsterisks;
       renderer['text'] = boundRewriteText;
     }
 
@@ -181,7 +244,7 @@
         .callback=${(_error: string | null, contents: string) =>
           sanitizeHtml(contents)}
       >
-        <div slot="markdown-html"></div>
+        <div class="markdown-html" slot="markdown-html"></div>
       </marked-element>
     `;
   }
@@ -192,15 +255,25 @@
     text = htmlEscape(text).toString();
     // Unescape block quotes '>'. This is slightly dangerous as '>' can be used
     // in HTML fragments, but it is insufficient on it's own.
-    text = text.replace(/(^|\n)&gt;/g, '$1>');
+    for (;;) {
+      const newText = text.replace(
+        /(^|\n)((?:\s{0,3}&gt;)*\s{0,3})&gt;/g,
+        '$1$2>'
+      );
+      if (newText === text) {
+        break;
+      }
+      text = newText;
+    }
 
     return text;
   }
 
   override updated() {
     // Look for @mentions and replace them with an account-label chip.
-    if (this.flagsService.isEnabled(KnownExperimentId.MENTION_USERS)) {
-      this.convertEmailsToAccountChips();
+    this.convertEmailsToAccountChips();
+    if (this.flagsService.isEnabled(KnownExperimentId.SUGGEST_EDIT)) {
+      this.convertCodeToSuggestions();
     }
   }
 
@@ -226,6 +299,24 @@
       }
     }
   }
+
+  private convertCodeToSuggestions() {
+    const marks = this.renderRoot.querySelectorAll('mark');
+    if (marks.length > 0) {
+      const userSuggestionMark = marks[0];
+      const userSuggestion = document.createElement('gr-user-suggestion-fix');
+      // Temporary workaround for bug - tabs replacement
+      if (this.content.includes('\t')) {
+        userSuggestion.textContent = getUserSuggestionFromString(this.content);
+      } else {
+        userSuggestion.textContent = userSuggestionMark.textContent ?? '';
+      }
+      userSuggestionMark.parentNode?.replaceChild(
+        userSuggestion,
+        userSuggestionMark
+      );
+    }
+  }
 }
 
 declare global {
diff --git a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.ts b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.ts
index 67a94c6..206082b 100644
--- a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.ts
@@ -15,14 +15,9 @@
 import './gr-formatted-text';
 import {GrFormattedText} from './gr-formatted-text';
 import {createConfig} from '../../../test/test-data-generators';
-import {
-  queryAndAssert,
-  stubFlags,
-  waitUntilObserved,
-} from '../../../test/test-utils';
+import {queryAndAssert, waitUntilObserved} from '../../../test/test-utils';
 import {CommentLinks, EmailAddress} from '../../../api/rest-api';
 import {testResolver} from '../../../test/common-test-setup';
-import {KnownExperimentId} from '../../../services/flags/flags';
 import {GrAccountChip} from '../gr-account-chip/gr-account-chip';
 
 suite('gr-formatted-text tests', () => {
@@ -47,10 +42,6 @@
         match: '(LinkRewriteMe)',
         link: 'http://google.com/$1',
       },
-      customHtmlRewrite: {
-        match: 'HTMLRewriteMe',
-        html: '<div>HTMLRewritten</div>',
-      },
       complexLinkRewrite: {
         match: '(^|\\s)A Link (\\d+)($|\\s)',
         link: '/page?id=$2',
@@ -101,11 +92,13 @@
       await setCommentLinks({
         capitalizeFoo: {
           match: 'foo',
-          html: 'FOO',
+          prefix: 'FOO',
+          link: 'a.b.c',
         },
         lowercaseFoo: {
           match: 'FOO',
-          html: 'foo',
+          prefix: 'foo',
+          link: 'c.d.e',
         },
       });
       element.content = 'foo';
@@ -115,9 +108,8 @@
         element,
         /* HTML */ `
           <pre class="plaintext">
-          FOO
-        </pre
-          >
+          FOO<a href="a.b.c" rel="noopener" target="_blank">foo</a>
+        </pre>
         `
       );
     });
@@ -126,11 +118,15 @@
       await setCommentLinks({
         bracketNum: {
           match: '(Start:) ([0-9]+)',
-          html: '$1 [$2]',
+          prefix: '$1 ',
+          link: 'bug/$2',
+          text: 'bug/$2',
         },
         bracketNum2: {
           match: '(Start: [0-9]+) ([0-9]+)',
-          html: '$1 [$2]',
+          prefix: '$1 ',
+          link: 'bug/$2',
+          text: 'bug/$2',
         },
       });
       element.content = 'Start: 123 456';
@@ -140,9 +136,14 @@
         element,
         /* HTML */ `
           <pre class="plaintext">
-            Start: [123] [456]
-          </pre
-          >
+            Start:
+            <a href="bug/123" rel="noopener" target="_blank">
+              bug/123
+            </a>
+            <a href="bug/456" rel="noopener" target="_blank">
+              bug/456
+            </a>
+          </pre>
         `
       );
     });
@@ -151,8 +152,7 @@
       element.content = `
         text with plain link: http://google.com
         text with config link: LinkRewriteMe
-        text with complex link: A Link 12
-        text with config html: HTMLRewriteMe`;
+        text with complex link: A Link 12`;
       await element.updateComplete;
 
       assert.shadowDom.equal(
@@ -183,8 +183,6 @@
             >
               Link 12
             </a>
-            text with config html:
-            <div>HTMLRewritten</div>
           </pre>
         `
       );
@@ -210,6 +208,22 @@
         /* HTML */ '<pre class="plaintext"># A Markdown Heading</pre>'
       );
     });
+
+    test('does default linking', async () => {
+      const checkLinking = async (url: string) => {
+        element.content = url;
+        await element.updateComplete;
+        const a = queryAndAssert<HTMLElement>(element, 'a');
+        assert.equal(a.getAttribute('href'), url);
+        assert.equal(a.innerText, url);
+      };
+
+      await checkLinking('http://www.google.com');
+      await checkLinking('https://www.google.com');
+      await checkLinking('https://www.google.com/');
+      await checkLinking('https://www.google.com/asdf~');
+      await checkLinking('https://www.google.com/asdf-');
+    });
   });
 
   suite('as markdown', () => {
@@ -222,15 +236,14 @@
         \ntext with plain link: http://google.com
         \ntext with config link: LinkRewriteMe
         \ntext without a link: NotA Link 15 cats
-        \ntext with complex link: A Link 12
-        \ntext with config html: HTMLRewriteMe`;
+        \ntext with complex link: A Link 12`;
       await element.updateComplete;
 
       assert.shadowDom.equal(
         element,
         /* HTML */ `
           <marked-element>
-            <div slot="markdown-html">
+            <div slot="markdown-html" class="markdown-html">
               <p>text</p>
               <p>
                 text with plain link:
@@ -259,15 +272,56 @@
                   Link 12
                 </a>
               </p>
-              <p>text with config html:</p>
-              <div>HTMLRewritten</div>
-              <p></p>
             </div>
           </marked-element>
         `
       );
     });
 
+    test('does not render if too long', async () => {
+      element.content = `text
+        text with plain link: http://google.com
+        text with config link: LinkRewriteMe
+        text without a link: NotA Link 15 cats
+        text with complex link: A Link 12`;
+      element.MARKDOWN_LIMIT = 10;
+      await element.updateComplete;
+
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <pre class="plaintext">
+          text
+        text with plain link:
+        <a
+          href="http://google.com"
+          rel="noopener"
+          target="_blank"
+        >
+          http://google.com
+        </a>
+        text with config link:
+          <a
+            href="http://google.com/LinkRewriteMe"
+            rel="noopener"
+            target="_blank"
+          >
+            LinkRewriteMe
+          </a>
+        text without a link: NotA Link 15 cats
+        text with complex link: A
+          <a
+            href="http://localhost/page?id=12"
+            rel="noopener"
+            target="_blank"
+          >
+            Link 12
+          </a>
+        </pre>
+        `
+      );
+    });
+
     test('renders headings with links and rewrites', async () => {
       element.content = `# h1-heading
         \n## h2-heading
@@ -276,15 +330,14 @@
         \n##### h5-heading
         \n###### h6-heading
         \n# heading with plain link: http://google.com
-        \n# heading with config link: LinkRewriteMe
-        \n# heading with config html: HTMLRewriteMe`;
+        \n# heading with config link: LinkRewriteMe`;
       await element.updateComplete;
 
       assert.shadowDom.equal(
         element,
         /* HTML */ `
           <marked-element>
-            <div slot="markdown-html">
+            <div slot="markdown-html" class="markdown-html">
               <h1>h1-heading</h1>
               <h2>h2-heading</h2>
               <h3>h3-heading</h3>
@@ -307,10 +360,6 @@
                   LinkRewriteMe
                 </a>
               </h1>
-              <h1>
-                heading with config html:
-                <div>HTMLRewritten</div>
-              </h1>
             </div>
           </marked-element>
         `
@@ -320,15 +369,14 @@
     test('renders inline-code without linking or rewriting', async () => {
       element.content = `\`inline code\`
         \n\`inline code with plain link: google.com\`
-        \n\`inline code with config link: LinkRewriteMe\`
-        \n\`inline code with config html: HTMLRewriteMe\``;
+        \n\`inline code with config link: LinkRewriteMe\``;
       await element.updateComplete;
 
       assert.shadowDom.equal(
         element,
         /* HTML */ `
           <marked-element>
-            <div slot="markdown-html">
+            <div slot="markdown-html" class="markdown-html">
               <p>
                 <code>inline code</code>
               </p>
@@ -338,9 +386,6 @@
               <p>
                 <code>inline code with config link: LinkRewriteMe</code>
               </p>
-              <p>
-                <code>inline code with config html: HTMLRewriteMe</code>
-              </p>
             </div>
           </marked-element>
         `
@@ -350,15 +395,14 @@
     test('renders multiline-code without linking or rewriting', async () => {
       element.content = `\`\`\`\nmultiline code\n\`\`\`
         \n\`\`\`\nmultiline code with plain link: google.com\n\`\`\`
-        \n\`\`\`\nmultiline code with config link: LinkRewriteMe\n\`\`\`
-        \n\`\`\`\nmultiline code with config html: HTMLRewriteMe\n\`\`\``;
+        \n\`\`\`\nmultiline code with config link: LinkRewriteMe\n\`\`\``;
       await element.updateComplete;
 
       assert.shadowDom.equal(
         element,
         /* HTML */ `
           <marked-element>
-            <div slot="markdown-html">
+            <div slot="markdown-html" class="markdown-html">
               <pre>
               <code>multiline code</code>
             </pre>
@@ -368,9 +412,6 @@
               <pre>
               <code>multiline code with config link: LinkRewriteMe</code>
             </pre>
-              <pre>
-              <code>multiline code with config html: HTMLRewriteMe</code>
-            </pre>
             </div>
           </marked-element>
         `
@@ -385,7 +426,7 @@
         element,
         /* HTML */ `
           <marked-element>
-            <div slot="markdown-html">
+            <div slot="markdown-html" class="markdown-html">
               <p>![img](google.com/img.png)</p>
             </div>
           </marked-element>
@@ -393,10 +434,7 @@
       );
     });
 
-    test('does not handle @mentions if not enabled', async () => {
-      stubFlags('isEnabled')
-        .withArgs(KnownExperimentId.MENTION_USERS)
-        .returns(false);
+    test('handles @mentions', async () => {
       element.content = '@someone@google.com';
       await element.updateComplete;
 
@@ -404,35 +442,7 @@
         element,
         /* HTML */ `
           <marked-element>
-            <div slot="markdown-html">
-              <p>
-                @
-                <a
-                  href="mailto:someone@google.com"
-                  rel="noopener"
-                  target="_blank"
-                >
-                  someone@google.com
-                </a>
-              </p>
-            </div>
-          </marked-element>
-        `
-      );
-    });
-
-    test('handles @mentions if enabled', async () => {
-      stubFlags('isEnabled')
-        .withArgs(KnownExperimentId.MENTION_USERS)
-        .returns(true);
-      element.content = '@someone@google.com';
-      await element.updateComplete;
-
-      assert.shadowDom.equal(
-        element,
-        /* HTML */ `
-          <marked-element>
-            <div slot="markdown-html">
+            <div slot="markdown-html" class="markdown-html">
               <p>
                 <gr-account-chip></gr-account-chip>
               </p>
@@ -451,9 +461,6 @@
     });
 
     test('does not handle @mentions that is part of a code block', async () => {
-      stubFlags('isEnabled')
-        .withArgs(KnownExperimentId.MENTION_USERS)
-        .returns(true);
       element.content = '`@`someone@google.com';
       await element.updateComplete;
 
@@ -461,7 +468,7 @@
         element,
         /* HTML */ `
           <marked-element>
-            <div slot="markdown-html">
+            <div slot="markdown-html" class="markdown-html">
               <p>
                 <code>@</code>
                 <a
@@ -486,7 +493,7 @@
         element,
         /* HTML */ `
           <marked-element>
-            <div slot="markdown-html">
+            <div slot="markdown-html" class="markdown-html">
               <p>
                 <a href="https://www.google.com" rel="noopener" target="_blank"
                   >myLink</a
@@ -501,15 +508,14 @@
     test('renders block quotes with links and rewrites', async () => {
       element.content = `> block quote
         \n> block quote with plain link: http://google.com
-        \n> block quote with config link: LinkRewriteMe
-        \n> block quote with config html: HTMLRewriteMe`;
+        \n> block quote with config link: LinkRewriteMe`;
       await element.updateComplete;
 
       assert.shadowDom.equal(
         element,
         /* HTML */ `
           <marked-element>
-            <div slot="markdown-html">
+            <div slot="markdown-html" class="markdown-html">
               <blockquote>
                 <p>block quote</p>
               </blockquote>
@@ -533,11 +539,6 @@
                   </a>
                 </p>
               </blockquote>
-              <blockquote>
-                <p>block quote with config html:</p>
-                <div>HTMLRewritten</div>
-                <p></p>
-              </blockquote>
             </div>
           </marked-element>
         `
@@ -557,7 +558,7 @@
         element,
         /* HTML */ `
           <marked-element>
-            <div slot="markdown-html">
+            <div slot="markdown-html" class="markdown-html">
               <p>plain text ${escapedDiv}</p>
               <p>
                 <code>inline code ${escapedDiv}</code>
@@ -580,5 +581,90 @@
         `
       );
     });
+
+    test('renders nested block quotes', async () => {
+      element.content = '> > > block quote';
+      await element.updateComplete;
+
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <marked-element>
+            <div slot="markdown-html" class="markdown-html">
+              <blockquote>
+                <blockquote>
+                  <blockquote>
+                    <p>block quote</p>
+                  </blockquote>
+                </blockquote>
+              </blockquote>
+            </div>
+          </marked-element>
+        `
+      );
+    });
+
+    test('renders rewrites with an asterisk', async () => {
+      await setCommentLinks({
+        customLinkRewrite: {
+          match: 'asterisks (\\*) rule',
+          link: 'http://google.com',
+        },
+      });
+
+      element.content = 'I think asterisks * rule';
+      await element.updateComplete;
+
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <marked-element>
+            <div slot="markdown-html" class="markdown-html">
+              <p>
+                I think
+                <a href="http://google.com" rel="noopener" target="_blank"
+                  >asterisks * rule</a
+                >
+              </p>
+            </div>
+          </marked-element>
+        `
+      );
+    });
+
+    test('does default linking', async () => {
+      const checkLinking = async (url: string) => {
+        element.content = url;
+        await element.updateComplete;
+        const a = queryAndAssert<HTMLElement>(element, 'a');
+        const p = queryAndAssert<HTMLElement>(element, 'p');
+        assert.equal(a.getAttribute('href'), url);
+        assert.equal(p.innerText, url);
+      };
+
+      await checkLinking('http://www.google.com');
+      await checkLinking('https://www.google.com');
+      await checkLinking('https://www.google.com/');
+    });
+
+    suite('user suggest fix', () => {
+      setup(async () => {
+        const flagsService = getAppContext().flagsService;
+        sinon.stub(flagsService, 'isEnabled').returns(true);
+      });
+
+      test('renders', async () => {
+        element.content = '```suggestion\nHello World```';
+        await element.updateComplete;
+        assert.shadowDom.equal(
+          element,
+          /* HTML */ `<marked-element>
+            <div class="markdown-html" slot="markdown-html">
+              <gr-user-suggestion-fix>Hello World</gr-user-suggestion-fix>
+            </div>
+          </marked-element>`
+        );
+      });
+    });
   });
 });
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account-contents.ts b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account-contents.ts
new file mode 100644
index 0000000..394015e
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account-contents.ts
@@ -0,0 +1,579 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../gr-avatar/gr-avatar';
+import '../gr-button/gr-button';
+import '../gr-icon/gr-icon';
+import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
+import '../../plugins/gr-endpoint-param/gr-endpoint-param';
+import {getAppContext} from '../../../services/app-context';
+import {
+  accountKey,
+  computeVoteableText,
+  isAccountEmailOnly,
+  isSelf,
+} from '../../../utils/account-util';
+import {customElement, property, state} from 'lit/decorators.js';
+import {
+  AccountInfo,
+  ChangeInfo,
+  ServerInfo,
+  ReviewInput,
+} from '../../../types/common';
+import {
+  canHaveAttention,
+  getAddedByReason,
+  getLastUpdate,
+  getReason,
+  getRemovedByReason,
+  hasAttention,
+} from '../../../utils/attention-set-util';
+import {ReviewerState} from '../../../constants/constants';
+import {CURRENT} from '../../../utils/patch-set-util';
+import {isInvolved, isRemovableReviewer} from '../../../utils/change-util';
+import {assertIsDefined} from '../../../utils/common-util';
+import {fontStyles} from '../../../styles/gr-font-styles';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {css, html, LitElement, nothing} from 'lit';
+import {ifDefined} from 'lit/directives/if-defined.js';
+import {subscribe} from '../../lit/subscription-controller';
+import {resolve} from '../../../models/dependency';
+import {configModelToken} from '../../../models/config/config-model';
+import {createSearchUrl} from '../../../models/views/search';
+import {createDashboardUrl} from '../../../models/views/dashboard';
+import {fire, fireReload} from '../../../utils/event-util';
+import {userModelToken} from '../../../models/user/user-model';
+
+@customElement('gr-hovercard-account-contents')
+export class GrHovercardAccountContents extends LitElement {
+  @property({type: Object})
+  account!: AccountInfo;
+
+  @state()
+  selfAccount?: AccountInfo;
+
+  /**
+   * Optional ChangeInfo object, typically comes from the change page or
+   * from a row in a list of search results. This is needed for some change
+   * related features like adding the user as a reviewer.
+   */
+  @property({type: Object})
+  change?: ChangeInfo;
+
+  /**
+   * Should attention set related features be shown in the component? Note
+   * that the information whether the user is in the attention set or not is
+   * part of the ChangeInfo object in the change property.
+   */
+  @property({type: Boolean})
+  highlightAttention = false;
+
+  @state()
+  serverConfig?: ServerInfo;
+
+  private readonly restApiService = getAppContext().restApiService;
+
+  private readonly reporting = getAppContext().reportingService;
+
+  private readonly getUserModel = resolve(this, userModelToken);
+
+  private readonly getConfigModel = resolve(this, configModelToken);
+
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.getUserModel().account$,
+      x => (this.selfAccount = x)
+    );
+    subscribe(
+      this,
+      () => this.getConfigModel().serverConfig$,
+      config => {
+        this.serverConfig = config;
+      }
+    );
+  }
+
+  static override get styles() {
+    return [
+      sharedStyles,
+      fontStyles,
+      css`
+        .top,
+        .attention,
+        .status,
+        .voteable {
+          padding: var(--spacing-s) var(--spacing-l);
+        }
+        .links {
+          padding: var(--spacing-m) 0px var(--spacing-l) var(--spacing-xxl);
+        }
+        .top {
+          display: flex;
+          padding-top: var(--spacing-xl);
+          min-width: 300px;
+        }
+        gr-avatar {
+          height: 48px;
+          width: 48px;
+          margin-right: var(--spacing-l);
+        }
+        .title,
+        .email {
+          color: var(--deemphasized-text-color);
+        }
+        .action {
+          border-top: 1px solid var(--border-color);
+          padding: var(--spacing-s) var(--spacing-l);
+          --gr-button-padding: var(--spacing-s) var(--spacing-m);
+        }
+        .attention {
+          background-color: var(--emphasis-color);
+        }
+        .attention a {
+          text-decoration: none;
+        }
+        .status gr-icon {
+          font-size: 14px;
+          position: relative;
+          top: 2px;
+        }
+        gr-icon.attentionIcon {
+          transform: scaleX(0.8);
+        }
+        gr-icon.linkIcon {
+          font-size: var(--line-height-normal, 20px);
+          color: var(--deemphasized-text-color);
+          padding-right: 12px;
+        }
+        .links a {
+          color: var(--link-color);
+          padding: 0px 4px;
+        }
+        .reason {
+          padding-top: var(--spacing-s);
+        }
+        .status .value {
+          white-space: pre-wrap;
+        }
+        /* Make sure that users cannot break the layout with super long
+           "About Me" texts. */
+        div.status {
+          max-height: 8em;
+          overflow-y: auto;
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    return html`
+      <div class="top">
+        <div class="avatar">
+          <gr-avatar .account=${this.account} .imageSize=${56}></gr-avatar>
+        </div>
+        <div class="account">
+          <h3 class="name heading-3">${this.account.name}</h3>
+          <div class="email">${this.account.email}</div>
+        </div>
+      </div>
+      ${this.renderAccountStatusPlugins()} ${this.renderAccountStatus()}
+      ${this.renderLinks()} ${this.renderChangeRelatedInfoAndActions()}
+    `;
+  }
+
+  private renderChangeRelatedInfoAndActions() {
+    if (this.change === undefined) {
+      return nothing;
+    }
+    const voteableText = computeVoteableText(this.change, this.account);
+    return html`
+      ${voteableText
+        ? html`
+            <div class="voteable">
+              <span class="title">Voteable:</span>
+              <span class="value">${voteableText}</span>
+            </div>
+          `
+        : ''}
+      ${this.renderNeedsAttention()} ${this.renderAddToAttention()}
+      ${this.renderRemoveFromAttention()} ${this.renderReviewerOrCcActions()}
+    `;
+  }
+
+  private renderReviewerOrCcActions() {
+    // `selfAccount` is required so that logged out users can't perform actions.
+    if (!this.selfAccount || !isRemovableReviewer(this.change, this.account))
+      return nothing;
+    return html`
+      <div class="action">
+        <gr-button
+          class="removeReviewerOrCC"
+          link
+          no-uppercase
+          @click=${this.handleRemoveReviewerOrCC}
+        >
+          Remove ${this.computeReviewerOrCCText()}
+        </gr-button>
+      </div>
+      <div class="action">
+        <gr-button
+          class="changeReviewerOrCC"
+          link
+          no-uppercase
+          @click=${this.handleChangeReviewerOrCCStatus}
+        >
+          ${this.computeChangeReviewerOrCCText()}
+        </gr-button>
+      </div>
+    `;
+  }
+
+  private renderAccountStatusPlugins() {
+    return html`
+      <gr-endpoint-decorator name="hovercard-status">
+        <gr-endpoint-param
+          name="account"
+          .value=${this.account}
+        ></gr-endpoint-param>
+      </gr-endpoint-decorator>
+    `;
+  }
+
+  private renderLinks() {
+    if (!this.account || isAccountEmailOnly(this.account)) return nothing;
+    return html` <div class="links">
+      <gr-icon icon="link" class="linkIcon"></gr-icon>
+      <a
+        href=${ifDefined(this.computeOwnerChangesLink())}
+        @click=${() => {
+          fire(this, 'link-clicked', {});
+        }}
+        @enter=${() => {
+          fire(this, 'link-clicked', {});
+        }}
+      >
+        Changes
+      </a>
+      ·
+      <a
+        href=${ifDefined(this.computeOwnerDashboardLink())}
+        @click=${() => {
+          fire(this, 'link-clicked', {});
+        }}
+        @enter=${() => {
+          fire(this, 'link-clicked', {});
+        }}
+      >
+        Dashboard
+      </a>
+    </div>`;
+  }
+
+  private renderAccountStatus() {
+    if (!this.account.status) return nothing;
+    return html`
+      <div class="status">
+        <span class="title">About me:</span>
+        <span class="value">${this.account.status.trim()}</span>
+      </div>
+    `;
+  }
+
+  private renderNeedsAttention() {
+    if (!(this.isAttentionEnabled && this.hasUserAttention)) return nothing;
+    const lastUpdate = getLastUpdate(this.account, this.change);
+    return html`
+      <div class="attention">
+        <div>
+          <gr-icon
+            icon="label_important"
+            filled
+            small
+            class="attentionIcon"
+          ></gr-icon>
+          <span> ${this.computePronoun()} turn to take action. </span>
+          <a
+            href="https://gerrit-review.googlesource.com/Documentation/user-attention-set.html"
+            target="_blank"
+          >
+            <gr-icon icon="help" title="read documentation"></gr-icon>
+          </a>
+        </div>
+        <div class="reason">
+          <span class="title">Reason:</span>
+          <span class="value">
+            ${getReason(this.serverConfig, this.account, this.change)}
+          </span>
+          ${lastUpdate
+            ? html` (
+                <gr-date-formatter
+                  withTooltip
+                  .dateStr=${lastUpdate}
+                ></gr-date-formatter>
+                )`
+            : ''}
+        </div>
+      </div>
+    `;
+  }
+
+  private renderAddToAttention() {
+    if (!this.computeShowActionAddToAttentionSet()) return nothing;
+    return html`
+      <div class="action">
+        <gr-button
+          class="addToAttentionSet"
+          link
+          no-uppercase
+          @click=${this.handleClickAddToAttentionSet}
+        >
+          Add to attention set
+        </gr-button>
+      </div>
+    `;
+  }
+
+  private renderRemoveFromAttention() {
+    if (!this.computeShowActionRemoveFromAttentionSet()) return nothing;
+    return html`
+      <div class="action">
+        <gr-button
+          class="removeFromAttentionSet"
+          link
+          no-uppercase
+          @click=${this.handleClickRemoveFromAttentionSet}
+        >
+          Remove from attention set
+        </gr-button>
+      </div>
+    `;
+  }
+
+  // private but used by tests
+  computePronoun() {
+    if (!this.account || !this.selfAccount) return '';
+    return isSelf(this.account, this.selfAccount) ? 'Your' : 'Their';
+  }
+
+  computeOwnerChangesLink() {
+    if (!this.account) return undefined;
+    return createSearchUrl({
+      owner:
+        this.account.email ||
+        this.account.username ||
+        this.account.name ||
+        `${this.account._account_id}`,
+    });
+  }
+
+  computeOwnerDashboardLink() {
+    if (!this.account) return undefined;
+    if (this.account._account_id)
+      return createDashboardUrl({user: `${this.account._account_id}`});
+    if (this.account.email)
+      return createDashboardUrl({user: this.account.email});
+    return undefined;
+  }
+
+  get isAttentionEnabled() {
+    return (
+      !!this.highlightAttention &&
+      !!this.change &&
+      canHaveAttention(this.account)
+    );
+  }
+
+  get hasUserAttention() {
+    return hasAttention(this.account, this.change);
+  }
+
+  private getReviewerState(change: ChangeInfo) {
+    if (
+      change.reviewers[ReviewerState.REVIEWER]?.some(
+        (reviewer: AccountInfo) =>
+          reviewer._account_id === this.account._account_id
+      )
+    ) {
+      return ReviewerState.REVIEWER;
+    }
+    return ReviewerState.CC;
+  }
+
+  private computeReviewerOrCCText() {
+    if (!this.change || !this.account) return '';
+    return this.getReviewerState(this.change) === ReviewerState.REVIEWER
+      ? 'Reviewer'
+      : 'CC';
+  }
+
+  private computeChangeReviewerOrCCText() {
+    if (!this.change || !this.account) return '';
+    return this.getReviewerState(this.change) === ReviewerState.REVIEWER
+      ? 'Move Reviewer to CC'
+      : 'Move CC to Reviewer';
+  }
+
+  private handleChangeReviewerOrCCStatus() {
+    assertIsDefined(this.change, 'change');
+    // accountKey() throws an error if _account_id & email is not found, which
+    // we want to check before showing reloading toast
+    const _accountKey = accountKey(this.account);
+    fire(this, 'show-alert', {
+      message: 'Reloading page...',
+    });
+    const reviewInput: Partial<ReviewInput> = {};
+    reviewInput.reviewers = [
+      {
+        reviewer: _accountKey,
+        state:
+          this.getReviewerState(this.change) === ReviewerState.CC
+            ? ReviewerState.REVIEWER
+            : ReviewerState.CC,
+      },
+    ];
+
+    this.restApiService
+      .saveChangeReview(this.change._number, CURRENT, reviewInput)
+      .then(response => {
+        if (!response || !response.ok) {
+          throw new Error(
+            'something went wrong when toggling' +
+              this.getReviewerState(this.change!)
+          );
+        }
+        fireReload(this);
+      });
+  }
+
+  private handleRemoveReviewerOrCC() {
+    if (!this.change || !(this.account?._account_id || this.account?.email))
+      throw new Error('Missing change or account.');
+    fire(this, 'show-alert', {
+      message: 'Reloading page...',
+    });
+    this.restApiService
+      .removeChangeReviewer(
+        this.change._number,
+        (this.account?._account_id || this.account?.email)!
+      )
+      .then((response: Response | undefined) => {
+        if (!response || !response.ok) {
+          throw new Error('something went wrong when removing user');
+        }
+        fireReload(this);
+        return response;
+      });
+  }
+
+  private computeShowActionAddToAttentionSet() {
+    const involvedOrSelf =
+      isInvolved(this.change, this.selfAccount) ||
+      isSelf(this.account, this.selfAccount);
+    return involvedOrSelf && this.isAttentionEnabled && !this.hasUserAttention;
+  }
+
+  private computeShowActionRemoveFromAttentionSet() {
+    const involvedOrSelf =
+      isInvolved(this.change, this.selfAccount) ||
+      isSelf(this.account, this.selfAccount);
+    return involvedOrSelf && this.isAttentionEnabled && this.hasUserAttention;
+  }
+
+  private handleClickAddToAttentionSet() {
+    if (!this.change || !this.account._account_id) return;
+    fire(this, 'show-alert', {
+      message: 'Reloading page...',
+      dismissOnNavigation: true,
+    });
+
+    // We are deliberately updating the UI before making the API call. It is a
+    // risk that we are taking to achieve a better UX for 99.9% of the cases.
+    const reason = getAddedByReason(this.selfAccount, this.serverConfig);
+
+    if (!this.change.attention_set) this.change.attention_set = {};
+    this.change.attention_set[this.account._account_id] = {
+      account: this.account,
+      reason,
+      reason_account: this.selfAccount,
+    };
+    fire(this, 'attention-set-updated', {});
+
+    this.reporting.reportInteraction(
+      'attention-hovercard-add',
+      this.reportingDetails()
+    );
+    this.restApiService
+      .addToAttentionSet(this.change._number, this.account._account_id, reason)
+      .then(() => {
+        fire(this, 'hide-alert', {});
+      });
+    fire(this, 'action-taken', {});
+  }
+
+  private handleClickRemoveFromAttentionSet() {
+    if (!this.change || !this.account._account_id) return;
+    fire(this, 'show-alert', {
+      message: 'Saving attention set update ...',
+      dismissOnNavigation: true,
+    });
+
+    // We are deliberately updating the UI before making the API call. It is a
+    // risk that we are taking to achieve a better UX for 99.9% of the cases.
+
+    const reason = getRemovedByReason(this.selfAccount, this.serverConfig);
+    if (this.change.attention_set)
+      delete this.change.attention_set[this.account._account_id];
+    fire(this, 'attention-set-updated', {});
+
+    this.reporting.reportInteraction(
+      'attention-hovercard-remove',
+      this.reportingDetails()
+    );
+    this.restApiService
+      .removeFromAttentionSet(
+        this.change._number,
+        this.account._account_id,
+        reason
+      )
+      .then(() => {
+        fire(this, 'hide-alert', {});
+      });
+    fire(this, 'action-taken', {});
+  }
+
+  private reportingDetails() {
+    const targetId = this.account._account_id;
+    const ownerId =
+      (this.change && this.change.owner && this.change.owner._account_id) || -1;
+    const selfId = (this.selfAccount && this.selfAccount._account_id) || -1;
+    const reviewers =
+      this.change && this.change.reviewers && this.change.reviewers.REVIEWER
+        ? [...this.change.reviewers.REVIEWER]
+        : [];
+    const reviewerIds = reviewers
+      .map(r => r._account_id)
+      .filter(rId => rId !== ownerId);
+    return {
+      actionByOwner: selfId === ownerId,
+      actionByReviewer: selfId !== -1 && reviewerIds.includes(selfId),
+      targetIsOwner: targetId === ownerId,
+      targetIsReviewer: reviewerIds.includes(targetId),
+      targetIsSelf: targetId === selfId,
+    };
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-hovercard-account-contents': GrHovercardAccountContents;
+  }
+  interface HTMLElementEventMap {
+    'action-taken': CustomEvent<{}>;
+    'attention-set-updated': CustomEvent<{}>;
+    'link-clicked': CustomEvent<{}>;
+  }
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account-contents_test.ts b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account-contents_test.ts
new file mode 100644
index 0000000..7df06f4
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account-contents_test.ts
@@ -0,0 +1,385 @@
+/**
+ * @license
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import {fixture, assert} from '@open-wc/testing';
+import {html} from 'lit';
+import './gr-hovercard-account-contents';
+import {GrHovercardAccountContents} from './gr-hovercard-account-contents';
+import {
+  mockPromise,
+  query,
+  queryAndAssert,
+  stubRestApi,
+} from '../../../test/test-utils';
+import {
+  AccountDetailInfo,
+  AccountId,
+  EmailAddress,
+  ReviewerState,
+} from '../../../api/rest-api';
+import {
+  createAccountDetailWithId,
+  createChange,
+  createDetailedLabelInfo,
+} from '../../../test/test-data-generators';
+import {GrButton} from '../gr-button/gr-button';
+import {testResolver} from '../../../test/common-test-setup';
+import {userModelToken} from '../../../models/user/user-model';
+
+suite('gr-hovercard-account-contents tests', () => {
+  let element: GrHovercardAccountContents;
+
+  const ACCOUNT: AccountDetailInfo = {
+    ...createAccountDetailWithId(31),
+    email: 'kermit@gmail.com' as EmailAddress,
+    username: 'kermit',
+    name: 'Kermit The Frog',
+    status: '  I am a frog  ',
+    _account_id: 31415926535 as AccountId,
+  };
+
+  setup(async () => {
+    const change = {
+      ...createChange(),
+      attention_set: {},
+      reviewers: {},
+      owner: {...ACCOUNT},
+    };
+    element = await fixture(
+      html`<gr-hovercard-account-contents .account=${ACCOUNT} .change=${change}>
+      </gr-hovercard-account-contents>`
+    );
+    testResolver(userModelToken).setAccount({...ACCOUNT});
+    await element.updateComplete;
+  });
+
+  test('renders', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div class="top">
+          <div class="avatar">
+            <gr-avatar hidden=""></gr-avatar>
+          </div>
+          <div class="account">
+            <h3 class="heading-3 name">Kermit The Frog</h3>
+            <div class="email">kermit@gmail.com</div>
+          </div>
+        </div>
+        <gr-endpoint-decorator name="hovercard-status">
+          <gr-endpoint-param name="account"></gr-endpoint-param>
+        </gr-endpoint-decorator>
+        <div class="status">
+          <span class="title">About me:</span>
+          <span class="value">I am a frog</span>
+        </div>
+        <div class="links">
+          <gr-icon icon="link" class="linkIcon"></gr-icon>
+          <a href="/q/owner:kermit@gmail.com">Changes</a>
+          ·
+          <a href="/dashboard/31415926535">Dashboard</a>
+        </div>
+      `
+    );
+  });
+
+  test('renders without change data', async () => {
+    const elementWithoutChange = await fixture(
+      html`<gr-hovercard-account-contents
+        .account=${ACCOUNT}
+      ></gr-hovercard-account-contents>`
+    );
+    assert.shadowDom.equal(
+      elementWithoutChange,
+      /* HTML */ `
+        <div class="top">
+          <div class="avatar">
+            <gr-avatar hidden=""></gr-avatar>
+          </div>
+          <div class="account">
+            <h3 class="heading-3 name">Kermit The Frog</h3>
+            <div class="email">kermit@gmail.com</div>
+          </div>
+        </div>
+        <gr-endpoint-decorator name="hovercard-status">
+          <gr-endpoint-param name="account"> </gr-endpoint-param>
+        </gr-endpoint-decorator>
+        <div class="status">
+          <span class="title"> About me: </span>
+          <span class="value"> I am a frog </span>
+        </div>
+        <div class="links">
+          <gr-icon class="linkIcon" icon="link"> </gr-icon>
+          <a href="/q/owner:kermit@gmail.com"> Changes </a>
+          ·
+          <a href="/dashboard/31415926535"> Dashboard </a>
+        </div>
+      `
+    );
+  });
+
+  test('account name is shown', () => {
+    const name = queryAndAssert<HTMLHeadingElement>(element, '.name');
+    assert.equal(name.innerText, 'Kermit The Frog');
+  });
+
+  test('computePronoun', async () => {
+    element.account = createAccountDetailWithId(1);
+    element.selfAccount = createAccountDetailWithId(1);
+    await element.updateComplete;
+    assert.equal(element.computePronoun(), 'Your');
+    element.account = createAccountDetailWithId(2);
+    await element.updateComplete;
+    assert.equal(element.computePronoun(), 'Their');
+  });
+
+  test('account status is not shown if the property is not set', async () => {
+    element.account = {...ACCOUNT, status: undefined};
+    await element.updateComplete;
+    assert.isUndefined(query(element, '.status'));
+  });
+
+  test('account status is displayed', () => {
+    const status = queryAndAssert<HTMLSpanElement>(element, '.status .value');
+    assert.equal(status.innerText, 'I am a frog');
+  });
+
+  test('voteable div is not shown if the property is not set', () => {
+    assert.isUndefined(query(element, '.voteable'));
+  });
+
+  test('voteable div is displayed', async () => {
+    element.change = {
+      ...createChange(),
+      labels: {
+        Foo: {
+          ...createDetailedLabelInfo(),
+          all: [
+            {
+              _account_id: 7 as AccountId,
+              permitted_voting_range: {max: 2, min: 0},
+            },
+          ],
+        },
+        Bar: {
+          ...createDetailedLabelInfo(),
+          all: [
+            {
+              ...createAccountDetailWithId(1),
+              permitted_voting_range: {max: 1, min: 0},
+            },
+            {
+              _account_id: 7 as AccountId,
+              permitted_voting_range: {max: 1, min: 0},
+            },
+          ],
+        },
+        FooBar: {
+          ...createDetailedLabelInfo(),
+          all: [{_account_id: 7 as AccountId, value: 0}],
+        },
+      },
+      permitted_labels: {
+        Foo: ['-1', ' 0', '+1', '+2'],
+        FooBar: ['-1', ' 0'],
+      },
+    };
+    element.account = createAccountDetailWithId(1);
+
+    await element.updateComplete;
+    const voteableEl = queryAndAssert<HTMLSpanElement>(
+      element,
+      '.voteable .value'
+    );
+    assert.equal(voteableEl.innerText, 'Bar: +1');
+  });
+
+  test('remove reviewer', async () => {
+    element.change = {
+      ...createChange(),
+      removable_reviewers: [ACCOUNT],
+      reviewers: {
+        [ReviewerState.REVIEWER]: [ACCOUNT],
+      },
+    };
+    await element.updateComplete;
+    stubRestApi('removeChangeReviewer').returns(
+      Promise.resolve({...new Response(), ok: true})
+    );
+    const reloadListener = sinon.spy();
+    element.addEventListener('reload', reloadListener);
+    const button = queryAndAssert<GrButton>(element, '.removeReviewerOrCC');
+    assert.isOk(button);
+    assert.equal(button.innerText, 'Remove Reviewer');
+    button.click();
+    await element.updateComplete;
+    assert.isTrue(reloadListener.called);
+  });
+
+  test('move reviewer to cc', async () => {
+    element.change = {
+      ...createChange(),
+      removable_reviewers: [ACCOUNT],
+      reviewers: {
+        [ReviewerState.REVIEWER]: [ACCOUNT],
+      },
+    };
+    await element.updateComplete;
+    const saveReviewStub = stubRestApi('saveChangeReview').returns(
+      Promise.resolve({...new Response(), ok: true})
+    );
+    stubRestApi('removeChangeReviewer').returns(
+      Promise.resolve({...new Response(), ok: true})
+    );
+    const reloadListener = sinon.spy();
+    element.addEventListener('reload', reloadListener);
+
+    const button = queryAndAssert<GrButton>(element, '.changeReviewerOrCC');
+
+    assert.isOk(button);
+    assert.equal(button.innerText, 'Move Reviewer to CC');
+    button.click();
+    await element.updateComplete;
+    assert.isTrue(saveReviewStub.called);
+    assert.isTrue(reloadListener.called);
+  });
+
+  test('move reviewer to cc', async () => {
+    element.change = {
+      ...createChange(),
+      removable_reviewers: [ACCOUNT],
+      reviewers: {
+        [ReviewerState.REVIEWER]: [],
+      },
+    };
+    await element.updateComplete;
+    const saveReviewStub = stubRestApi('saveChangeReview').returns(
+      Promise.resolve({...new Response(), ok: true})
+    );
+    stubRestApi('removeChangeReviewer').returns(
+      Promise.resolve({...new Response(), ok: true})
+    );
+    const reloadListener = sinon.spy();
+    element.addEventListener('reload', reloadListener);
+
+    const button = queryAndAssert<GrButton>(element, '.changeReviewerOrCC');
+    assert.isOk(button);
+    assert.equal(button.innerText, 'Move CC to Reviewer');
+
+    button.click();
+    await element.updateComplete;
+    assert.isTrue(saveReviewStub.called);
+    assert.isTrue(reloadListener.called);
+  });
+
+  test('remove cc', async () => {
+    element.change = {
+      ...createChange(),
+      removable_reviewers: [ACCOUNT],
+      reviewers: {
+        [ReviewerState.REVIEWER]: [],
+      },
+    };
+    await element.updateComplete;
+    stubRestApi('removeChangeReviewer').returns(
+      Promise.resolve({...new Response(), ok: true})
+    );
+    const reloadListener = sinon.spy();
+    element.addEventListener('reload', reloadListener);
+
+    const button = queryAndAssert<GrButton>(element, '.removeReviewerOrCC');
+
+    assert.equal(button.innerText, 'Remove CC');
+    assert.isOk(button);
+    button.click();
+    await element.updateComplete;
+    assert.isTrue(reloadListener.called);
+  });
+
+  test('add to attention set', async () => {
+    const apiPromise = mockPromise<Response>();
+    const apiSpy = stubRestApi('addToAttentionSet').returns(apiPromise);
+    element.highlightAttention = true;
+    await element.updateComplete;
+    const showAlertListener = sinon.spy();
+    const hideAlertListener = sinon.spy();
+    const updatedListener = sinon.spy();
+    element.addEventListener('show-alert', showAlertListener);
+    element.addEventListener('hide-alert', hideAlertListener);
+    element.addEventListener('attention-set-updated', updatedListener);
+
+    const button = queryAndAssert<GrButton>(element, '.addToAttentionSet');
+    assert.isOk(button);
+    button.click();
+
+    assert.equal(Object.keys(element.change?.attention_set ?? {}).length, 1);
+    const attention_set_info = Object.values(
+      element.change?.attention_set ?? {}
+    )[0];
+    assert.equal(
+      attention_set_info.reason,
+      `Added by <GERRIT_ACCOUNT_${ACCOUNT._account_id}>` +
+        ' using the hovercard menu'
+    );
+    assert.equal(
+      attention_set_info.reason_account?._account_id,
+      ACCOUNT._account_id
+    );
+    assert.isTrue(showAlertListener.called, 'showAlertListener was called');
+    assert.isTrue(updatedListener.called, 'updatedListener was called');
+
+    apiPromise.resolve({...new Response(), ok: true});
+    await element.updateComplete;
+    assert.isTrue(apiSpy.calledOnce);
+    assert.equal(
+      apiSpy.lastCall.args[2],
+      `Added by <GERRIT_ACCOUNT_${ACCOUNT._account_id}>` +
+        ' using the hovercard menu'
+    );
+    assert.isTrue(hideAlertListener.called, 'hideAlertListener was called');
+  });
+
+  test('remove from attention set', async () => {
+    const apiPromise = mockPromise<Response>();
+    const apiSpy = stubRestApi('removeFromAttentionSet').returns(apiPromise);
+    element.highlightAttention = true;
+    element.change = {
+      ...createChange(),
+      attention_set: {
+        '31415926535': {account: ACCOUNT, reason: 'a good reason'},
+      },
+      reviewers: {},
+      owner: {...ACCOUNT},
+    };
+    await element.updateComplete;
+    const showAlertListener = sinon.spy();
+    const hideAlertListener = sinon.spy();
+    const updatedListener = sinon.spy();
+    element.addEventListener('show-alert', showAlertListener);
+    element.addEventListener('hide-alert', hideAlertListener);
+    element.addEventListener('attention-set-updated', updatedListener);
+
+    const button = queryAndAssert<GrButton>(element, '.removeFromAttentionSet');
+    assert.isOk(button);
+    button.click();
+
+    assert.isDefined(element.change?.attention_set);
+    assert.equal(Object.keys(element.change?.attention_set ?? {}).length, 0);
+    assert.isTrue(showAlertListener.called, 'showAlertListener was called');
+    assert.isTrue(updatedListener.called, 'updatedListener was called');
+
+    apiPromise.resolve({...new Response(), ok: true});
+    await element.updateComplete;
+
+    assert.isTrue(apiSpy.calledOnce);
+    assert.equal(
+      apiSpy.lastCall.args[2],
+      `Removed by <GERRIT_ACCOUNT_${ACCOUNT._account_id}>` +
+        ' using the hovercard menu'
+    );
+    assert.isTrue(hideAlertListener.called, 'hideAlertListener was called');
+  });
+});
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.ts b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.ts
index 9647141..543f5bc 100644
--- a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.ts
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.ts
@@ -8,42 +8,12 @@
 import '../gr-icon/gr-icon';
 import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
 import '../../plugins/gr-endpoint-param/gr-endpoint-param';
-import {getAppContext} from '../../../services/app-context';
-import {
-  accountKey,
-  computeVoteableText,
-  isAccountEmailOnly,
-  isSelf,
-} from '../../../utils/account-util';
-import {customElement, property, state} from 'lit/decorators.js';
-import {
-  AccountInfo,
-  ChangeInfo,
-  ServerInfo,
-  ReviewInput,
-} from '../../../types/common';
-import {
-  canHaveAttention,
-  getAddedByReason,
-  getLastUpdate,
-  getReason,
-  getRemovedByReason,
-  hasAttention,
-} from '../../../utils/attention-set-util';
-import {ReviewerState} from '../../../constants/constants';
-import {CURRENT} from '../../../utils/patch-set-util';
-import {isInvolved, isRemovableReviewer} from '../../../utils/change-util';
-import {assertIsDefined} from '../../../utils/common-util';
-import {fontStyles} from '../../../styles/gr-font-styles';
-import {css, html, LitElement, nothing} from 'lit';
-import {ifDefined} from 'lit/directives/if-defined.js';
+import {customElement, property} from 'lit/decorators.js';
+import {AccountInfo, ChangeInfo} from '../../../types/common';
+import {html, LitElement} from 'lit';
 import {HovercardMixin} from '../../../mixins/hovercard-mixin/hovercard-mixin';
-import {EventType} from '../../../types/events';
-import {subscribe} from '../../lit/subscription-controller';
-import {resolve} from '../../../models/dependency';
-import {configModelToken} from '../../../models/config/config-model';
-import {createSearchUrl} from '../../../models/views/search';
-import {createDashboardUrl} from '../../../models/views/dashboard';
+import {when} from 'lit/directives/when.js';
+import './gr-hovercard-account-contents';
 
 // This avoids JSC_DYNAMIC_EXTENDS_WITHOUT_JSDOC closure compiler error.
 const base = HovercardMixin(LitElement);
@@ -53,9 +23,6 @@
   @property({type: Object})
   account!: AccountInfo;
 
-  @state()
-  selfAccount?: AccountInfo;
-
   /**
    * Optional ChangeInfo object, typically comes from the change page or
    * from a row in a list of search results. This is needed for some change
@@ -72,498 +39,30 @@
   @property({type: Boolean})
   highlightAttention = false;
 
-  @state()
-  serverConfig?: ServerInfo;
-
-  private readonly restApiService = getAppContext().restApiService;
-
-  private readonly reporting = getAppContext().reportingService;
-
-  // private but used in tests
-  readonly userModel = getAppContext().userModel;
-
-  private readonly getConfigModel = resolve(this, configModelToken);
-
-  constructor() {
-    super();
-    subscribe(
-      this,
-      () => this.userModel.account$,
-      x => (this.selfAccount = x)
-    );
-    subscribe(
-      this,
-      () => this.getConfigModel().serverConfig$,
-      config => {
-        this.serverConfig = config;
-      }
-    );
-  }
-
-  static override get styles() {
-    return [
-      fontStyles,
-      base.styles || [],
-      css`
-        .top,
-        .attention,
-        .status,
-        .voteable {
-          padding: var(--spacing-s) var(--spacing-l);
-        }
-        .links {
-          padding: var(--spacing-m) 0px var(--spacing-l) var(--spacing-xxl);
-        }
-        .top {
-          display: flex;
-          padding-top: var(--spacing-xl);
-          min-width: 300px;
-        }
-        gr-avatar {
-          height: 48px;
-          width: 48px;
-          margin-right: var(--spacing-l);
-        }
-        .title,
-        .email {
-          color: var(--deemphasized-text-color);
-        }
-        .action {
-          border-top: 1px solid var(--border-color);
-          padding: var(--spacing-s) var(--spacing-l);
-          --gr-button-padding: var(--spacing-s) var(--spacing-m);
-        }
-        .attention {
-          background-color: var(--emphasis-color);
-        }
-        .attention a {
-          text-decoration: none;
-        }
-        .status gr-icon {
-          font-size: 14px;
-          position: relative;
-          top: 2px;
-        }
-        gr-icon.attentionIcon {
-          transform: scaleX(0.8);
-        }
-        gr-icon.linkIcon {
-          font-size: var(--line-height-normal, 20px);
-          color: var(--deemphasized-text-color);
-          padding-right: 12px;
-        }
-        .links a {
-          color: var(--link-color);
-          padding: 0px 4px;
-        }
-        .reason {
-          padding-top: var(--spacing-s);
-        }
-      `,
-    ];
-  }
-
   override render() {
     return html`
       <div id="container" role="tooltip" tabindex="-1">
-        ${this.renderContent()}
+        ${when(
+          this._isShowing,
+          () =>
+            html`<gr-hovercard-account-contents
+              .account=${this.account}
+              .change=${this.change}
+              .highlightAttention=${this.highlightAttention}
+              @link-clicked=${this.forceHide}
+              @action-taken=${this.mouseHide}
+              @attention-set-updated=${this.redirectEventToTarget}
+              @hide-alert=${this.redirectEventToTarget}
+              @show-alert=${this.redirectEventToTarget}
+              @reload=${this.redirectEventToTarget}
+            ></gr-hovercard-account-contents>`
+        )}
       </div>
     `;
   }
 
-  private renderContent() {
-    if (!this._isShowing) return;
-    return html`
-      <div class="top">
-        <div class="avatar">
-          <gr-avatar .account=${this.account} imageSize="56"></gr-avatar>
-        </div>
-        <div class="account">
-          <h3 class="name heading-3">${this.account.name}</h3>
-          <div class="email">${this.account.email}</div>
-        </div>
-      </div>
-      ${this.renderAccountStatusPlugins()} ${this.renderAccountStatus()}
-      ${this.renderLinks()} ${this.renderChangeRelatedInfoAndActions()}
-    `;
-  }
-
-  private renderChangeRelatedInfoAndActions() {
-    if (this.change === undefined) {
-      return;
-    }
-    const voteableText = computeVoteableText(this.change, this.account);
-    return html`
-      ${voteableText
-        ? html`
-            <div class="voteable">
-              <span class="title">Voteable:</span>
-              <span class="value">${voteableText}</span>
-            </div>
-          `
-        : ''}
-      ${this.renderNeedsAttention()} ${this.renderAddToAttention()}
-      ${this.renderRemoveFromAttention()} ${this.renderReviewerOrCcActions()}
-    `;
-  }
-
-  private renderReviewerOrCcActions() {
-    if (!this.selfAccount || !isRemovableReviewer(this.change, this.account))
-      return;
-    return html`
-      <div class="action">
-        <gr-button
-          class="removeReviewerOrCC"
-          link=""
-          no-uppercase
-          @click=${this.handleRemoveReviewerOrCC}
-        >
-          Remove ${this.computeReviewerOrCCText()}
-        </gr-button>
-      </div>
-      <div class="action">
-        <gr-button
-          class="changeReviewerOrCC"
-          link=""
-          no-uppercase
-          @click=${this.handleChangeReviewerOrCCStatus}
-        >
-          ${this.computeChangeReviewerOrCCText()}
-        </gr-button>
-      </div>
-    `;
-  }
-
-  private renderAccountStatusPlugins() {
-    return html`
-      <gr-endpoint-decorator name="hovercard-status">
-        <gr-endpoint-param
-          name="account"
-          .value=${this.account}
-        ></gr-endpoint-param>
-      </gr-endpoint-decorator>
-    `;
-  }
-
-  private renderLinks() {
-    if (!this.account || isAccountEmailOnly(this.account)) return nothing;
-    return html` <div class="links">
-      <gr-icon icon="link" class="linkIcon"></gr-icon
-      ><a
-        href=${ifDefined(this.computeOwnerChangesLink())}
-        @click=${() => {
-          this.forceHide();
-          return true;
-        }}
-        @enter=${() => {
-          this.forceHide();
-          return true;
-        }}
-        >Changes</a
-      >·<a
-        href=${ifDefined(this.computeOwnerDashboardLink())}
-        @click=${() => {
-          this.forceHide();
-          return true;
-        }}
-        @enter=${() => {
-          this.forceHide();
-          return true;
-        }}
-        >Dashboard</a
-      >
-    </div>`;
-  }
-
-  private renderAccountStatus() {
-    if (!this.account.status) return;
-    return html`
-      <div class="status">
-        <span class="title">About me:</span>
-        <span class="value">${this.account.status}</span>
-      </div>
-    `;
-  }
-
-  private renderNeedsAttention() {
-    if (!(this.isAttentionEnabled && this.hasUserAttention)) return;
-    const lastUpdate = getLastUpdate(this.account, this.change);
-    return html`
-      <div class="attention">
-        <div>
-          <gr-icon
-            icon="label_important"
-            filled
-            small
-            class="attentionIcon"
-          ></gr-icon>
-          <span> ${this.computePronoun()} turn to take action. </span>
-          <a
-            href="https://gerrit-review.googlesource.com/Documentation/user-attention-set.html"
-            target="_blank"
-          >
-            <gr-icon icon="help" title="read documentation"></gr-icon>
-          </a>
-        </div>
-        <div class="reason">
-          <span class="title">Reason:</span>
-          <span class="value">
-            ${getReason(this.serverConfig, this.account, this.change)}
-          </span>
-          ${lastUpdate
-            ? html` (<gr-date-formatter
-                  withTooltip
-                  .dateStr=${lastUpdate}
-                ></gr-date-formatter
-                >)`
-            : ''}
-        </div>
-      </div>
-    `;
-  }
-
-  private renderAddToAttention() {
-    if (!this.computeShowActionAddToAttentionSet()) return;
-    return html`
-      <div class="action">
-        <gr-button
-          class="addToAttentionSet"
-          link=""
-          no-uppercase
-          @click=${this.handleClickAddToAttentionSet}
-        >
-          Add to attention set
-        </gr-button>
-      </div>
-    `;
-  }
-
-  private renderRemoveFromAttention() {
-    if (!this.computeShowActionRemoveFromAttentionSet()) return;
-    return html`
-      <div class="action">
-        <gr-button
-          class="removeFromAttentionSet"
-          link=""
-          no-uppercase
-          @click=${this.handleClickRemoveFromAttentionSet}
-        >
-          Remove from attention set
-        </gr-button>
-      </div>
-    `;
-  }
-
-  // private but used by tests
-  computePronoun() {
-    if (!this.account || !this.selfAccount) return '';
-    return isSelf(this.account, this.selfAccount) ? 'Your' : 'Their';
-  }
-
-  computeOwnerChangesLink() {
-    if (!this.account) return undefined;
-    return createSearchUrl({
-      owner:
-        this.account.email ||
-        this.account.username ||
-        this.account.name ||
-        `${this.account._account_id}`,
-    });
-  }
-
-  computeOwnerDashboardLink() {
-    if (!this.account) return undefined;
-    if (this.account._account_id)
-      return createDashboardUrl({user: `${this.account._account_id}`});
-    if (this.account.email)
-      return createDashboardUrl({user: this.account.email});
-    return undefined;
-  }
-
-  get isAttentionEnabled() {
-    return (
-      !!this.highlightAttention &&
-      !!this.change &&
-      canHaveAttention(this.account)
-    );
-  }
-
-  get hasUserAttention() {
-    return hasAttention(this.account, this.change);
-  }
-
-  private getReviewerState() {
-    if (
-      this.change!.reviewers[ReviewerState.REVIEWER]?.some(
-        (reviewer: AccountInfo) =>
-          reviewer._account_id === this.account._account_id
-      )
-    ) {
-      return ReviewerState.REVIEWER;
-    }
-    return ReviewerState.CC;
-  }
-
-  private computeReviewerOrCCText() {
-    if (!this.change || !this.account) return '';
-    return this.getReviewerState() === ReviewerState.REVIEWER
-      ? 'Reviewer'
-      : 'CC';
-  }
-
-  private computeChangeReviewerOrCCText() {
-    if (!this.change || !this.account) return '';
-    return this.getReviewerState() === ReviewerState.REVIEWER
-      ? 'Move Reviewer to CC'
-      : 'Move CC to Reviewer';
-  }
-
-  private handleChangeReviewerOrCCStatus() {
-    assertIsDefined(this.change, 'change');
-    // accountKey() throws an error if _account_id & email is not found, which
-    // we want to check before showing reloading toast
-    const _accountKey = accountKey(this.account);
-    this.dispatchEventThroughTarget(EventType.SHOW_ALERT, {
-      message: 'Reloading page...',
-    });
-    const reviewInput: Partial<ReviewInput> = {};
-    reviewInput.reviewers = [
-      {
-        reviewer: _accountKey,
-        state:
-          this.getReviewerState() === ReviewerState.CC
-            ? ReviewerState.REVIEWER
-            : ReviewerState.CC,
-      },
-    ];
-
-    this.restApiService
-      .saveChangeReview(this.change._number, CURRENT, reviewInput)
-      .then(response => {
-        if (!response || !response.ok) {
-          throw new Error(
-            'something went wrong when toggling' + this.getReviewerState()
-          );
-        }
-        this.dispatchEventThroughTarget('reload', {clearPatchset: true});
-      });
-  }
-
-  private handleRemoveReviewerOrCC() {
-    if (!this.change || !(this.account?._account_id || this.account?.email))
-      throw new Error('Missing change or account.');
-    this.dispatchEventThroughTarget(EventType.SHOW_ALERT, {
-      message: 'Reloading page...',
-    });
-    this.restApiService
-      .removeChangeReviewer(
-        this.change._number,
-        (this.account?._account_id || this.account?.email)!
-      )
-      .then((response: Response | undefined) => {
-        if (!response || !response.ok) {
-          throw new Error('something went wrong when removing user');
-        }
-        this.dispatchEventThroughTarget('reload', {clearPatchset: true});
-        return response;
-      });
-  }
-
-  private computeShowActionAddToAttentionSet() {
-    const involvedOrSelf =
-      isInvolved(this.change, this.selfAccount) ||
-      isSelf(this.account, this.selfAccount);
-    return involvedOrSelf && this.isAttentionEnabled && !this.hasUserAttention;
-  }
-
-  private computeShowActionRemoveFromAttentionSet() {
-    const involvedOrSelf =
-      isInvolved(this.change, this.selfAccount) ||
-      isSelf(this.account, this.selfAccount);
-    return involvedOrSelf && this.isAttentionEnabled && this.hasUserAttention;
-  }
-
-  private handleClickAddToAttentionSet(e: MouseEvent) {
-    if (!this.change || !this.account._account_id) return;
-    this.dispatchEventThroughTarget(EventType.SHOW_ALERT, {
-      message: 'Saving attention set update ...',
-      dismissOnNavigation: true,
-    });
-
-    // We are deliberately updating the UI before making the API call. It is a
-    // risk that we are taking to achieve a better UX for 99.9% of the cases.
-    const reason = getAddedByReason(this.selfAccount, this.serverConfig);
-
-    if (!this.change.attention_set) this.change.attention_set = {};
-    this.change.attention_set[this.account._account_id] = {
-      account: this.account,
-      reason,
-      reason_account: this.selfAccount,
-    };
-    this.dispatchEventThroughTarget('attention-set-updated');
-
-    this.reporting.reportInteraction(
-      'attention-hovercard-add',
-      this.reportingDetails()
-    );
-    this.restApiService
-      .addToAttentionSet(this.change._number, this.account._account_id, reason)
-      .then(() => {
-        this.dispatchEventThroughTarget('hide-alert');
-      });
-    this.mouseHide(e);
-  }
-
-  private handleClickRemoveFromAttentionSet(e: MouseEvent) {
-    if (!this.change || !this.account._account_id) return;
-    this.dispatchEventThroughTarget(EventType.SHOW_ALERT, {
-      message: 'Saving attention set update ...',
-      dismissOnNavigation: true,
-    });
-
-    // We are deliberately updating the UI before making the API call. It is a
-    // risk that we are taking to achieve a better UX for 99.9% of the cases.
-
-    const reason = getRemovedByReason(this.selfAccount, this.serverConfig);
-    if (this.change.attention_set)
-      delete this.change.attention_set[this.account._account_id];
-    this.dispatchEventThroughTarget('attention-set-updated');
-
-    this.reporting.reportInteraction(
-      'attention-hovercard-remove',
-      this.reportingDetails()
-    );
-    this.restApiService
-      .removeFromAttentionSet(
-        this.change._number,
-        this.account._account_id,
-        reason
-      )
-      .then(() => {
-        this.dispatchEventThroughTarget('hide-alert');
-      });
-    this.mouseHide(e);
-  }
-
-  private reportingDetails() {
-    const targetId = this.account._account_id;
-    const ownerId =
-      (this.change && this.change.owner && this.change.owner._account_id) || -1;
-    const selfId = (this.selfAccount && this.selfAccount._account_id) || -1;
-    const reviewers =
-      this.change && this.change.reviewers && this.change.reviewers.REVIEWER
-        ? [...this.change.reviewers.REVIEWER]
-        : [];
-    const reviewerIds = reviewers
-      .map(r => r._account_id)
-      .filter(rId => rId !== ownerId);
-    return {
-      actionByOwner: selfId === ownerId,
-      actionByReviewer: selfId !== -1 && reviewerIds.includes(selfId),
-      targetIsOwner: targetId === ownerId,
-      targetIsReviewer: reviewerIds.includes(targetId),
-      targetIsSelf: targetId === selfId,
-    };
+  private redirectEventToTarget(e: CustomEvent<unknown>) {
+    this.dispatchEventThroughTarget(e.type, e.detail);
   }
 }
 
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_test.ts b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_test.ts
index 89bc043..40e4c75 100644
--- a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_test.ts
@@ -8,56 +8,35 @@
 import {html} from 'lit';
 import './gr-hovercard-account';
 import {GrHovercardAccount} from './gr-hovercard-account';
-import {
-  mockPromise,
-  query,
-  queryAndAssert,
-  stubRestApi,
-} from '../../../test/test-utils';
-import {
-  AccountDetailInfo,
-  AccountId,
-  EmailAddress,
-  ReviewerState,
-} from '../../../api/rest-api';
+import {queryAndAssert} from '../../../test/test-utils';
 import {
   createAccountDetailWithId,
   createChange,
-  createDetailedLabelInfo,
 } from '../../../test/test-data-generators';
 import {GrButton} from '../gr-button/gr-button';
-import {EventType} from '../../../types/events';
+import {GrHovercardAccountContents} from './gr-hovercard-account-contents';
+import {userModelToken} from '../../../models/user/user-model';
+import {testResolver} from '../../../test/common-test-setup';
 
 suite('gr-hovercard-account tests', () => {
   let element: GrHovercardAccount;
-
-  const ACCOUNT: AccountDetailInfo = {
-    ...createAccountDetailWithId(31),
-    email: 'kermit@gmail.com' as EmailAddress,
-    username: 'kermit',
-    name: 'Kermit The Frog',
-    status: 'I am a frog',
-    _account_id: 31415926535 as AccountId,
-  };
+  let contents: GrHovercardAccountContents;
 
   setup(async () => {
-    const change = {
-      ...createChange(),
-      attention_set: {},
-      reviewers: {},
-      owner: {...ACCOUNT},
-    };
+    const account = createAccountDetailWithId(31);
     element = await fixture<GrHovercardAccount>(
       html`<gr-hovercard-account
         class="hovered"
-        .account=${ACCOUNT}
-        .change=${change}
+        .account=${account}
+        .change=${createChange()}
+        .highlightAttention=${true}
       >
       </gr-hovercard-account>`
     );
     await element.show({});
-    element.userModel.setAccount({...ACCOUNT});
+    testResolver(userModelToken).setAccount({...account});
     await element.updateComplete;
+    contents = queryAndAssert(element, 'gr-hovercard-account-contents');
   });
 
   teardown(async () => {
@@ -70,337 +49,29 @@
       element,
       /* HTML */ `
         <div id="container" role="tooltip" tabindex="-1">
-          <div class="top">
-            <div class="avatar">
-              <gr-avatar hidden="" imagesize="56"></gr-avatar>
-            </div>
-            <div class="account">
-              <h3 class="heading-3 name">Kermit The Frog</h3>
-              <div class="email">kermit@gmail.com</div>
-            </div>
-          </div>
-          <gr-endpoint-decorator name="hovercard-status">
-            <gr-endpoint-param name="account"></gr-endpoint-param>
-          </gr-endpoint-decorator>
-          <div class="status">
-            <span class="title">About me:</span>
-            <span class="value">I am a frog</span>
-          </div>
-          <div class="links">
-            <gr-icon icon="link" class="linkIcon"></gr-icon>
-            <a href="/q/owner:kermit%2540gmail.com">Changes</a>
-            ·
-            <a href="/dashboard/31415926535">Dashboard</a>
-          </div>
+          <gr-hovercard-account-contents></gr-hovercard-account-contents>
         </div>
       `
     );
   });
 
-  test('renders without change data', async () => {
-    const elementWithoutChange = await fixture<GrHovercardAccount>(
-      html`<gr-hovercard-account class="hovered" .account=${ACCOUNT}>
-      </gr-hovercard-account>`
-    );
-    await elementWithoutChange.show({});
-    assert.shadowDom.equal(
-      elementWithoutChange,
-      /* HTML */ `
-        <div id="container" role="tooltip" tabindex="-1">
-          <div class="top">
-            <div class="avatar">
-              <gr-avatar hidden="" imagesize="56"> </gr-avatar>
-            </div>
-            <div class="account">
-              <h3 class="heading-3 name">Kermit The Frog</h3>
-              <div class="email">kermit@gmail.com</div>
-            </div>
-          </div>
-          <gr-endpoint-decorator name="hovercard-status">
-            <gr-endpoint-param name="account"> </gr-endpoint-param>
-          </gr-endpoint-decorator>
-          <div class="status">
-            <span class="title"> About me: </span>
-            <span class="value"> I am a frog </span>
-          </div>
-          <div class="links">
-            <gr-icon class="linkIcon" icon="link"> </gr-icon>
-            <a href="/q/owner:kermit%2540gmail.com"> Changes </a>
-            ·
-            <a href="/dashboard/31415926535"> Dashboard </a>
-          </div>
-        </div>
-      `
-    );
-    elementWithoutChange.mouseHide(new MouseEvent('click'));
-    await elementWithoutChange.updateComplete;
+  test('hides when links are clicked', () => {
+    const changesLink = queryAndAssert<HTMLAnchorElement>(contents, 'a');
+    // Actually redirecting will break the test, replace URL with no-op
+    changesLink.href = 'javascript:';
+
+    assert.isTrue(element._isShowing);
+
+    changesLink.click();
+
+    assert.isFalse(element._isShowing);
   });
 
-  test('account name is shown', () => {
-    const name = queryAndAssert<HTMLHeadingElement>(element, '.name');
-    assert.equal(name.innerText, 'Kermit The Frog');
-  });
+  test('hides when actions are performed', () => {
+    assert.isTrue(element._isShowing);
 
-  test('computePronoun', async () => {
-    element.account = createAccountDetailWithId(1);
-    element.selfAccount = createAccountDetailWithId(1);
-    await element.updateComplete;
-    assert.equal(element.computePronoun(), 'Your');
-    element.account = createAccountDetailWithId(2);
-    await element.updateComplete;
-    assert.equal(element.computePronoun(), 'Their');
-  });
+    queryAndAssert<GrButton>(contents, 'gr-button.addToAttentionSet').click();
 
-  test('account status is not shown if the property is not set', async () => {
-    element.account = {...ACCOUNT, status: undefined};
-    await element.updateComplete;
-    assert.isUndefined(query(element, '.status'));
-  });
-
-  test('account status is displayed', () => {
-    const status = queryAndAssert<HTMLSpanElement>(element, '.status .value');
-    assert.equal(status.innerText, 'I am a frog');
-  });
-
-  test('voteable div is not shown if the property is not set', () => {
-    assert.isUndefined(query(element, '.voteable'));
-  });
-
-  test('voteable div is displayed', async () => {
-    element.change = {
-      ...createChange(),
-      labels: {
-        Foo: {
-          ...createDetailedLabelInfo(),
-          all: [
-            {
-              _account_id: 7 as AccountId,
-              permitted_voting_range: {max: 2, min: 0},
-            },
-          ],
-        },
-        Bar: {
-          ...createDetailedLabelInfo(),
-          all: [
-            {
-              ...createAccountDetailWithId(1),
-              permitted_voting_range: {max: 1, min: 0},
-            },
-            {
-              _account_id: 7 as AccountId,
-              permitted_voting_range: {max: 1, min: 0},
-            },
-          ],
-        },
-        FooBar: {
-          ...createDetailedLabelInfo(),
-          all: [{_account_id: 7 as AccountId, value: 0}],
-        },
-      },
-      permitted_labels: {
-        Foo: ['-1', ' 0', '+1', '+2'],
-        FooBar: ['-1', ' 0'],
-      },
-    };
-    element.account = createAccountDetailWithId(1);
-
-    await element.updateComplete;
-    const voteableEl = queryAndAssert<HTMLSpanElement>(
-      element,
-      '.voteable .value'
-    );
-    assert.equal(voteableEl.innerText, 'Bar: +1');
-  });
-
-  test('remove reviewer', async () => {
-    element.change = {
-      ...createChange(),
-      removable_reviewers: [ACCOUNT],
-      reviewers: {
-        [ReviewerState.REVIEWER]: [ACCOUNT],
-      },
-    };
-    await element.updateComplete;
-    stubRestApi('removeChangeReviewer').returns(
-      Promise.resolve({...new Response(), ok: true})
-    );
-    const reloadListener = sinon.spy();
-    element._target?.addEventListener('reload', reloadListener);
-    const button = queryAndAssert<GrButton>(element, '.removeReviewerOrCC');
-    assert.isOk(button);
-    assert.equal(button.innerText, 'Remove Reviewer');
-    button.click();
-    await element.updateComplete;
-    assert.isTrue(reloadListener.called);
-  });
-
-  test('move reviewer to cc', async () => {
-    element.change = {
-      ...createChange(),
-      removable_reviewers: [ACCOUNT],
-      reviewers: {
-        [ReviewerState.REVIEWER]: [ACCOUNT],
-      },
-    };
-    await element.updateComplete;
-    const saveReviewStub = stubRestApi('saveChangeReview').returns(
-      Promise.resolve({...new Response(), ok: true})
-    );
-    stubRestApi('removeChangeReviewer').returns(
-      Promise.resolve({...new Response(), ok: true})
-    );
-    const reloadListener = sinon.spy();
-    element._target?.addEventListener('reload', reloadListener);
-
-    const button = queryAndAssert<GrButton>(element, '.changeReviewerOrCC');
-
-    assert.isOk(button);
-    assert.equal(button.innerText, 'Move Reviewer to CC');
-    button.click();
-    await element.updateComplete;
-    assert.isTrue(saveReviewStub.called);
-    assert.isTrue(reloadListener.called);
-  });
-
-  test('move reviewer to cc', async () => {
-    element.change = {
-      ...createChange(),
-      removable_reviewers: [ACCOUNT],
-      reviewers: {
-        [ReviewerState.REVIEWER]: [],
-      },
-    };
-    await element.updateComplete;
-    const saveReviewStub = stubRestApi('saveChangeReview').returns(
-      Promise.resolve({...new Response(), ok: true})
-    );
-    stubRestApi('removeChangeReviewer').returns(
-      Promise.resolve({...new Response(), ok: true})
-    );
-    const reloadListener = sinon.spy();
-    element._target?.addEventListener('reload', reloadListener);
-
-    const button = queryAndAssert<GrButton>(element, '.changeReviewerOrCC');
-    assert.isOk(button);
-    assert.equal(button.innerText, 'Move CC to Reviewer');
-
-    button.click();
-    await element.updateComplete;
-    assert.isTrue(saveReviewStub.called);
-    assert.isTrue(reloadListener.called);
-  });
-
-  test('remove cc', async () => {
-    element.change = {
-      ...createChange(),
-      removable_reviewers: [ACCOUNT],
-      reviewers: {
-        [ReviewerState.REVIEWER]: [],
-      },
-    };
-    await element.updateComplete;
-    stubRestApi('removeChangeReviewer').returns(
-      Promise.resolve({...new Response(), ok: true})
-    );
-    const reloadListener = sinon.spy();
-    element._target?.addEventListener('reload', reloadListener);
-
-    const button = queryAndAssert<GrButton>(element, '.removeReviewerOrCC');
-
-    assert.equal(button.innerText, 'Remove CC');
-    assert.isOk(button);
-    button.click();
-    await element.updateComplete;
-    assert.isTrue(reloadListener.called);
-  });
-
-  test('add to attention set', async () => {
-    const apiPromise = mockPromise<Response>();
-    const apiSpy = stubRestApi('addToAttentionSet').returns(apiPromise);
-    element.highlightAttention = true;
-    element._target = document.createElement('div');
-    await element.updateComplete;
-    const showAlertListener = sinon.spy();
-    const hideAlertListener = sinon.spy();
-    const updatedListener = sinon.spy();
-    element._target.addEventListener(EventType.SHOW_ALERT, showAlertListener);
-    element._target.addEventListener('hide-alert', hideAlertListener);
-    element._target.addEventListener('attention-set-updated', updatedListener);
-
-    const button = queryAndAssert<GrButton>(element, '.addToAttentionSet');
-    assert.isOk(button);
-    assert.isTrue(element._isShowing, 'hovercard is showing');
-    button.click();
-
-    assert.equal(Object.keys(element.change?.attention_set ?? {}).length, 1);
-    const attention_set_info = Object.values(
-      element.change?.attention_set ?? {}
-    )[0];
-    assert.equal(
-      attention_set_info.reason,
-      `Added by <GERRIT_ACCOUNT_${ACCOUNT._account_id}>` +
-        ' using the hovercard menu'
-    );
-    assert.equal(
-      attention_set_info.reason_account?._account_id,
-      ACCOUNT._account_id
-    );
-    assert.isTrue(showAlertListener.called, 'showAlertListener was called');
-    assert.isTrue(updatedListener.called, 'updatedListener was called');
-    assert.isFalse(element._isShowing, 'hovercard is hidden');
-
-    apiPromise.resolve({...new Response(), ok: true});
-    await element.updateComplete;
-    assert.isTrue(apiSpy.calledOnce);
-    assert.equal(
-      apiSpy.lastCall.args[2],
-      `Added by <GERRIT_ACCOUNT_${ACCOUNT._account_id}>` +
-        ' using the hovercard menu'
-    );
-    assert.isTrue(hideAlertListener.called, 'hideAlertListener was called');
-  });
-
-  test('remove from attention set', async () => {
-    const apiPromise = mockPromise<Response>();
-    const apiSpy = stubRestApi('removeFromAttentionSet').returns(apiPromise);
-    element.highlightAttention = true;
-    element.change = {
-      ...createChange(),
-      attention_set: {
-        '31415926535': {account: ACCOUNT, reason: 'a good reason'},
-      },
-      reviewers: {},
-      owner: {...ACCOUNT},
-    };
-    element._target = document.createElement('div');
-    await element.updateComplete;
-    const showAlertListener = sinon.spy();
-    const hideAlertListener = sinon.spy();
-    const updatedListener = sinon.spy();
-    element._target.addEventListener(EventType.SHOW_ALERT, showAlertListener);
-    element._target.addEventListener('hide-alert', hideAlertListener);
-    element._target.addEventListener('attention-set-updated', updatedListener);
-
-    const button = queryAndAssert<GrButton>(element, '.removeFromAttentionSet');
-    assert.isOk(button);
-    assert.isTrue(element._isShowing, 'hovercard is showing');
-    button.click();
-
-    assert.isDefined(element.change?.attention_set);
-    assert.equal(Object.keys(element.change?.attention_set ?? {}).length, 0);
-    assert.isTrue(showAlertListener.called, 'showAlertListener was called');
-    assert.isTrue(updatedListener.called, 'updatedListener was called');
-    assert.isFalse(element._isShowing, 'hovercard is hidden');
-
-    apiPromise.resolve({...new Response(), ok: true});
-    await element.updateComplete;
-
-    assert.isTrue(apiSpy.calledOnce);
-    assert.equal(
-      apiSpy.lastCall.args[2],
-      `Removed by <GERRIT_ACCOUNT_${ACCOUNT._account_id}>` +
-        ' using the hovercard menu'
-    );
-    assert.isTrue(hideAlertListener.called, 'hideAlertListener was called');
+    assert.isFalse(element._isShowing);
   });
 });
diff --git a/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.ts b/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.ts
index 65b1778..89c8770 100644
--- a/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.ts
+++ b/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.ts
@@ -10,165 +10,12 @@
 $_documentContainer.innerHTML = `<iron-iconset-svg name="gr-icons" size="24">
   <svg>
     <defs>
-      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
-      <g id="expand-less"><path d="M12 8l-6 6 1.41 1.41L12 10.83l4.59 4.58L18 14z"></path></g>
-      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
-      <g id="expand-more"><path d="M16.59 8.59L12 13.17 7.41 8.59 6 10l6 6 6-6z"></path></g>
-      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=unfold_more -->
-      <g id="unfold-more"><path d="M0 0h24v24H0z" fill="none"></path><path d="M12 5.83L15.17 9l1.41-1.41L12 3 7.41 7.59 8.83 9 12 5.83zm0 12.34L8.83 15l-1.41 1.41L12 21l4.59-4.59L15.17 15 12 18.17z"></path></g>
-      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
-      <g id="search"><path d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"></path></g>
-      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
-      <g id="settings"><path d="M19.43 12.98c.04-.32.07-.64.07-.98s-.03-.66-.07-.98l2.11-1.65c.19-.15.24-.42.12-.64l-2-3.46c-.12-.22-.39-.3-.61-.22l-2.49 1c-.52-.4-1.08-.73-1.69-.98l-.38-2.65C14.46 2.18 14.25 2 14 2h-4c-.25 0-.46.18-.49.42l-.38 2.65c-.61.25-1.17.59-1.69.98l-2.49-1c-.23-.09-.49 0-.61.22l-2 3.46c-.13.22-.07.49.12.64l2.11 1.65c-.04.32-.07.65-.07.98s.03.66.07.98l-2.11 1.65c-.19.15-.24.42-.12.64l2 3.46c.12.22.39.3.61.22l2.49-1c.52.4 1.08.73 1.69.98l.38 2.65c.03.24.24.42.49.42h4c.25 0 .46-.18.49-.42l.38-2.65c.61-.25 1.17-.59 1.69-.98l2.49 1c.23.09.49 0 .61-.22l2-3.46c.12-.22.07-.49-.12-.64l-2.11-1.65zM12 15.5c-1.93 0-3.5-1.57-3.5-3.5s1.57-3.5 3.5-3.5 3.5 1.57 3.5 3.5-1.57 3.5-3.5 3.5z"></path></g>
-      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
-      <g id="create"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"></path></g>
-      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
-      <g id="star"><path d="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z"></path></g>
-      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
-      <g id="star-border"><path d="M22 9.24l-7.19-.62L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21 12 17.27 18.18 21l-1.63-7.03L22 9.24zM12 15.4l-3.76 2.27 1-4.28-3.32-2.88 4.38-.38L12 6.1l1.71 4.04 4.38.38-3.32 2.88 1 4.28L12 15.4z"></path></g>
-      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
-      <g id="close"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"></path></g>
-      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
-      <g id="chevron-left"><path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"></path></g>
-      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
-      <g id="chevron-right"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"></path></g>
-      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
-      <g id="more-horiz"><path d="M6 10c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm12 0c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm-6 0c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"></path></g>
-      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
-      <g id="more-vert"><path d="M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"></path></g>
-      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
-      <g id="deleteEdit"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"></path></g>
-      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/editor-icons.html -->
-      <g id="publishEdit"><path d="M5 4v2h14V4H5zm0 10h4v6h6v-6h4l-7-7-7 7z"></path></g>
-      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/editor-icons.html -->
-      <g id="delete"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"></path></g>
-      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
-      <g id="help"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 17h-2v-2h2v2zm2.07-7.75l-.9.92C13.45 12.9 13 13.5 13 15h-2v-.5c0-1.1.45-2.1 1.17-2.83l1.24-1.26c.37-.36.59-.86.59-1.41 0-1.1-.9-2-2-2s-2 .9-2 2H8c0-2.21 1.79-4 4-4s4 1.79 4 4c0 .88-.36 1.68-.93 2.25z"></path></g>
-      <!-- This SVG is a copy from material.io https://material.io/resources/icons/?icon=help_outline -->
-      <g id="help-outline"><path d="M11 18h2v-2h-2v2zm1-16C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm0-14c-2.21 0-4 1.79-4 4h2c0-1.1.9-2 2-2s2 .9 2 2c0 2-3 1.75-3 5h2c0-2.25 3-2.5 3-5 0-2.21-1.79-4-4-4z"/></g>
-      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
-      <g id="info"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z"></path></g>
-      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
-      <g id="info-outline"><path d="M11 17h2v-6h-2v6zm1-15C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zM11 9h2V7h-2v2z"></path></g>
-      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=ic_hourglass_full-->
-      <g id="hourglass"><path d="M6 2v6h.01L6 8.01 10 12l-4 4 .01.01H6V22h12v-5.99h-.01L18 16l-4-4 4-3.99-.01-.01H18V2H6z"></path><path d="M0 0h24v24H0V0z" fill="none"></path></g>
-      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=mode_comment-->
-      <g id="comment"><path d="M21.99 4c0-1.1-.89-2-1.99-2H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h14l4 4-.01-18z"></path><path d="M0 0h24v24H0z" fill="none"></path></g>
-      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=calendar_today-->
-      <g id="calendar"><path d="M20 3h-1V1h-2v2H7V1H5v2H4c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 18H4V8h16v13z"></path><path d="M0 0h24v24H0z" fill="none"></path></g>
-      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
-      <g id="error"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"></path></g>
-      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
-      <g id="lightbulb-outline"><path d="M9 21c0 .55.45 1 1 1h4c.55 0 1-.45 1-1v-1H9v1zm3-19C8.14 2 5 5.14 5 9c0 2.38 1.19 4.47 3 5.74V17c0 .55.45 1 1 1h6c.55 0 1-.45 1-1v-2.26c1.81-1.27 3-3.36 3-5.74 0-3.86-3.14-7-7-7zm2.85 11.1l-.85.6V16h-4v-2.3l-.85-.6C7.8 12.16 7 10.63 7 9c0-2.76 2.24-5 5-5s5 2.24 5 5c0 1.63-.8 3.16-2.15 4.1z"></path></g>
-      <!-- This is a custom PolyGerrit SVG -->
-      <g id="side-by-side"><path d="M17.1578947,10.8888889 L2.84210526,10.8888889 C2.37894737,10.8888889 2,11.2888889 2,11.7777778 L2,17.1111111 C2,17.6 2.37894737,18 2.84210526,18 L17.1578947,18 C17.6210526,18 18,17.6 18,17.1111111 L18,11.7777778 C18,11.2888889 17.6210526,10.8888889 17.1578947,10.8888889 Z M17.1578947,2 L2.84210526,2 C2.37894737,2 2,2.4 2,2.88888889 L2,8.22222222 C2,8.71111111 2.37894737,9.11111111 2.84210526,9.11111111 L17.1578947,9.11111111 C17.6210526,9.11111111 18,8.71111111 18,8.22222222 L18,2.88888889 C18,2.4 17.6210526,2 17.1578947,2 Z M16.1973628,2 L2.78874238,2 C2.35493407,2 2,2.4 2,2.88888889 L2,8.22222222 C2,8.71111111 2.35493407,9.11111111 2.78874238,9.11111111 L16.1973628,9.11111111 C16.6311711,9.11111111 16.9861052,8.71111111 16.9861052,8.22222222 L16.9861052,2.88888889 C16.9861052,2.4 16.6311711,2 16.1973628,2 Z" id="Shape" transform="scale(1.2) translate(10.000000, 10.000000) rotate(-90.000000) translate(-10.000000, -10.000000)"></path></g>
-      <!-- This is a custom PolyGerrit SVG -->
-      <g id="unified"><path d="M4,2 L17,2 C18.1045695,2 19,2.8954305 19,4 L19,16 C19,17.1045695 18.1045695,18 17,18 L4,18 C2.8954305,18 2,17.1045695 2,16 L2,4 L2,4 C2,2.8954305 2.8954305,2 4,2 L4,2 Z M4,7 L4,9 L17,9 L17,7 L4,7 Z M4,11 L4,13 L17,13 L17,11 L4,11 Z" id="Combined-Shape" transform="scale(1.12, 1.2)"></path></g>
-      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
-      <g id="content-copy"><path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"></path></g>
-      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
-      <g id="build"><path d="M22.7 19l-9.1-9.1c.9-2.3.4-5-1.5-6.9-2-2-5-2.4-7.4-1.3L9 6 6 9 1.6 4.7C.4 7.1.9 10.1 2.9 12.1c1.9 1.9 4.6 2.4 6.9 1.5l9.1 9.1c.4.4 1 .4 1.4 0l2.3-2.3c.5-.4.5-1.1.1-1.4z"></path></g>
-      <!-- This is a custom PolyGerrit SVG -->
-      <g id="check"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"></path></g>
-      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=check_circle-->
-      <g id="check-circle"><path d="M0 0h24v24H0z" fill="none"/><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/></g>
-      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=check_circle_outline-->
-      <g id="check-circle-outline"><path d="M0 0h24v24H0V0zm0 0h24v24H0V0z" fill="none"/><path d="M16.59 7.58L10 14.17l-3.59-3.58L5 12l5 5 8-8zM12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z"/></g>
-      <!-- This SVG is a copy from https://fonts.google.com/icons?selected=Material+Icons:event_busy&icon.query=check+circle-->
-      <g id="check-circle-filled"><path d="M12,2C6.48,2,2,6.48,2,12c0,5.52,4.48,10,10,10s10-4.48,10-10C22,6.48,17.52,2,12,2z M10,17l-4-4l1.4-1.4l2.6,2.6l6.6-6.6 L18,9L10,17z"/><path d="M0,0h24v24H0V0z" fill="none"/></g>
-      <!-- This SVG is a copy from https://fonts.google.com/icons?selected=Material+Icons:event_busy&icon.query=block-->
-      <g id="block"><path xmlns="http://www.w3.org/2000/svg" d="M0 0h24v24H0V0z" fill="none"/><path xmlns="http://www.w3.org/2000/svg" d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zM4 12c0-4.42 3.58-8 8-8 1.85 0 3.55.63 4.9 1.69L5.69 16.9C4.63 15.55 4 13.85 4 12zm8 8c-1.85 0-3.55-.63-4.9-1.69L18.31 7.1C19.37 8.45 20 10.15 20 12c0 4.42-3.58 8-8 8z"/></g>
-      <!-- This is a custom PolyGerrit SVG -->
-      <g id="robot"><path d="M4.137453,5.61015591 L4.54835569,1.5340419 C4.5717665,1.30180904 4.76724872,1.12504213 5.00065859,1.12504213 C5.23327176,1.12504213 5.42730868,1.30282046 5.44761309,1.53454578 L5.76084628,5.10933916 C6.16304484,5.03749412 6.57714381,5 7,5 L17,5 C20.8659932,5 24,8.13400675 24,12 L24,15.1250421 C24,18.9910354 20.8659932,22.1250421 17,22.1250421 L7,22.1250421 C3.13400675,22.1250421 2.19029351e-15,18.9910354 0,15.1250421 L0,12 C-3.48556243e-16,9.15382228 1.69864167,6.70438358 4.137453,5.61015591 Z M5.77553049,6.12504213 C3.04904264,6.69038358 1,9.10590202 1,12 L1,15.1250421 C1,18.4387506 3.6862915,21.1250421 7,21.1250421 L17,21.1250421 C20.3137085,21.1250421 23,18.4387506 23,15.1250421 L23,12 C23,8.6862915 20.3137085,6 17,6 L7,6 C6.60617231,6 6.2212068,6.03794347 5.84855971,6.11037415 L5.84984496,6.12504213 L5.77553049,6.12504213 Z M6.93003717,6.95027711 L17.1232083,6.95027711 C19.8638332,6.95027711 22.0855486,9.17199258 22.0855486,11.9126175 C22.0855486,14.6532424 19.8638332,16.8749579 17.1232083,16.8749579 L6.93003717,16.8749579 C4.18941226,16.8749579 1.9676968,14.6532424 1.9676968,11.9126175 C1.9676968,9.17199258 4.18941226,6.95027711 6.93003717,6.95027711 Z M7.60124392,14.0779303 C9.03787127,14.0779303 10.2024878,12.9691885 10.2024878,11.6014862 C10.2024878,10.2337839 9.03787127,9.12504213 7.60124392,9.12504213 C6.16461657,9.12504213 5,10.2337839 5,11.6014862 C5,12.9691885 6.16461657,14.0779303 7.60124392,14.0779303 Z M16.617997,14.1098288 C18.0638768,14.1098288 19.2359939,12.9939463 19.2359939,11.6174355 C19.2359939,10.2409246 18.0638768,9.12504213 16.617997,9.12504213 C15.1721172,9.12504213 14,10.2409246 14,11.6174355 C14,12.9939463 15.1721172,14.1098288 16.617997,14.1098288 Z M9.79751216,18.1250421 L15,18.1250421 L15,19.1250421 C15,19.6773269 14.5522847,20.1250421 14,20.1250421 L10.7975122,20.1250421 C10.2452274,20.1250421 9.79751216,19.6773269 9.79751216,19.1250421 L9.79751216,18.1250421 Z"></path></g>
-      <!-- This is a custom PolyGerrit SVG -->
-      <g id="abandon"><path d="M17.65675,17.65725 C14.77275,20.54125 10.23775,20.75625 7.09875,18.31525 L18.31475,7.09925 C20.75575,10.23825 20.54075,14.77325 17.65675,17.65725 M6.34275,6.34325 C9.22675,3.45925 13.76275,3.24425 16.90075,5.68525 L5.68475,16.90125 C3.24375,13.76325 3.45875,9.22725 6.34275,6.34325 M19.07075,4.92925 C15.16575,1.02425 8.83375,1.02425 4.92875,4.92925 C1.02375,8.83425 1.02375,15.16625 4.92875,19.07125 C8.83375,22.97625 15.16575,22.97625 19.07075,19.07125 C22.97575,15.16625 22.97575,8.83425 19.07075,4.92925"></path></g>
-      <!-- This is a custom PolyGerrit SVG -->
-      <g id="edit"><path d="M3,17.2525 L3,21.0025 L6.75,21.0025 L17.81,9.9425 L14.06,6.1925 L3,17.2525 L3,17.2525 Z M20.71,7.0425 C21.1,6.6525 21.1,6.0225 20.71,5.6325 L18.37,3.2925 C17.98,2.9025 17.35,2.9025 16.96,3.2925 L15.13,5.1225 L18.88,8.8725 L20.71,7.0425 L20.71,7.0425 Z"></path></g>
-      <!-- This is a custom PolyGerrit SVG -->
-      <g id="rebase"><path d="M15.5759,19.4241 L14.5861,20.4146 L11.7574,23.2426 L10.3434,21.8286 L12.171569,20 L7.82933006,20 C7.41754308,21.1652555 6.30635522,22 5,22 C3.343,22 2,20.657 2,19 C2,17.6936448 2.83474451,16.5824569 4,16.1706699 L4,7.82933006 C2.83474451,7.41754308 2,6.30635522 2,5 C2,3.343 3.343,2 5,2 C6.30635522,2 7.41754308,2.83474451 7.82933006,4 L12.1715,4 L10.3431,2.1716 L11.7571,0.7576 L15.36365,4.3633 L16.0000001,4.99920039 C16.0004321,3.34256796 17.3432665,2 19,2 C20.657,2 22,3.343 22,5 C22,6.30635522 21.1652555,7.41754308 20,7.82933006 L20,16.1706699 C21.1652555,16.5824569 22,17.6936448 22,19 C22,20.657 20.657,22 19,22 C17.343,22 16,20.657 16,19 L15.5759,19.4241 Z M12.1715,18 L10.3431,16.1716 L11.7571,14.7576 L15.36365,18.3633 L16.0000001,18.9992004 C16.0003407,17.6931914 16.8349823,16.5823729 18,16.1706699 L18,7.82933006 C16.8347445,7.41754308 16,6.30635522 16,5 L15.5759,5.4241 L14.5861,6.4146 L11.7574,9.2426 L10.3434,7.8286 L12.171569,6 L7.82933006,6 C7.52807271,6.85248394 6.85248394,7.52807271 6,7.82933006 L6,16.1706699 C6.85248394,16.4719273 7.52807271,17.1475161 7.82933006,18 L12.1715,18 Z"></path></g>
-      <!-- This is a custom PolyGerrit SVG -->
-      <g id="rebaseEdit"><path d="M15.5759,19.4241 L14.5861,20.4146 L11.7574,23.2426 L10.3434,21.8286 L12.171569,20 L7.82933006,20 C7.41754308,21.1652555 6.30635522,22 5,22 C3.343,22 2,20.657 2,19 C2,17.6936448 2.83474451,16.5824569 4,16.1706699 L4,7.82933006 C2.83474451,7.41754308 2,6.30635522 2,5 C2,3.343 3.343,2 5,2 C6.30635522,2 7.41754308,2.83474451 7.82933006,4 L12.1715,4 L10.3431,2.1716 L11.7571,0.7576 L15.36365,4.3633 L16.0000001,4.99920039 C16.0004321,3.34256796 17.3432665,2 19,2 C20.657,2 22,3.343 22,5 C22,6.30635522 21.1652555,7.41754308 20,7.82933006 L20,16.1706699 C21.1652555,16.5824569 22,17.6936448 22,19 C22,20.657 20.657,22 19,22 C17.343,22 16,20.657 16,19 L15.5759,19.4241 Z M12.1715,18 L10.3431,16.1716 L11.7571,14.7576 L15.36365,18.3633 L16.0000001,18.9992004 C16.0003407,17.6931914 16.8349823,16.5823729 18,16.1706699 L18,7.82933006 C16.8347445,7.41754308 16,6.30635522 16,5 L15.5759,5.4241 L14.5861,6.4146 L11.7574,9.2426 L10.3434,7.8286 L12.171569,6 L7.82933006,6 C7.52807271,6.85248394 6.85248394,7.52807271 6,7.82933006 L6,16.1706699 C6.85248394,16.4719273 7.52807271,17.1475161 7.82933006,18 L12.1715,18 Z"></path></g>
-      <!-- This is a custom PolyGerrit SVG -->
-      <g id="restore"><path d="M12,8 L12,13 L16.28,15.54 L17,14.33 L13.5,12.25 L13.5,8 L12,8 Z M13,3 C8.03,3 4,7.03 4,12 L1,12 L4.89,15.89 L4.96,16.03 L9,12 L6,12 C6,8.13 9.13,5 13,5 C16.87,5 20,8.13 20,12 C20,15.87 16.87,19 13,19 C11.07,19 9.32,18.21 8.06,16.94 L6.64,18.36 C8.27,19.99 10.51,21 13,21 C17.97,21 22,16.97 22,12 C22,7.03 17.97,3 13,3 Z"></path></g>
-      <!-- This is a custom PolyGerrit SVG -->
-      <g id="revert"><path d="M12.3,8.5 C9.64999995,8.5 7.24999995,9.49 5.39999995,11.1 L1.79999995,7.5 L1.79999995,16.5 L10.8,16.5 L7.17999995,12.88 C8.56999995,11.72 10.34,11 12.3,11 C15.84,11 18.85,13.31 19.9,16.5 L22.27,15.72 C20.88,11.53 16.95,8.5 12.3,8.5"></path></g>
-      <g id="revert_submission"><path d="M12.3,8.5 C9.64999995,8.5 7.24999995,9.49 5.39999995,11.1 L1.79999995,7.5 L1.79999995,16.5 L10.8,16.5 L7.17999995,12.88 C8.56999995,11.72 10.34,11 12.3,11 C15.84,11 18.85,13.31 19.9,16.5 L22.27,15.72 C20.88,11.53 16.95,8.5 12.3,8.5"></path></g>
-      <!-- This is a custom PolyGerrit SVG -->
-      <g id="stopEdit"><path d="M4 4 20 4 20 20 4 20z"></path></g>
-      <!-- This is a custom PolyGerrit SVG -->
-      <g id="submit"><path d="M22.23,5 L11.65,15.58 L7.47000001,11.41 L6.06000001,12.82 L11.65,18.41 L23.649,6.41 L22.23,5 Z M16.58,5 L10.239,11.34 L11.65,12.75 L17.989,6.41 L16.58,5 Z M0.400000006,12.82 L5.99000001,18.41 L7.40000001,17 L1.82000001,11.41 L0.400000006,12.82 Z"></path></g>
-      <!-- This is a custom PolyGerrit SVG -->
-      <g id="review"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"></path></g>
-      <!-- This is a custom PolyGerrit SVG -->
-      <g id="zeroState"><path d="M22 9V7h-2V5c0-1.1-.9-2-2-2H4c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2v-2h2v-2h-2v-2h2v-2h-2V9h2zm-4 10H4V5h14v14zM6 13h5v4H6zm6-6h4v3h-4zM6 7h5v5H6zm6 4h4v6h-4z"></path></g>
-      <!-- This SVG is an adaptation of material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=label_important-->
-      <g id="attention"><path d="M1 23 l13 0 c.67 0 1.27 -.33 1.63 -.84 l7.37 -10.16 l-7.37 -10.16 c-.36 -.51 -.96 -.84 -1.63 -.84 L1 1 L7 12 z"></path></g>
-      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=pets-->
-      <g id="pets"><circle cx="4.5" cy="9.5" r="2.5"/><circle cx="9" cy="5.5" r="2.5"/><circle cx="15" cy="5.5" r="2.5"/><circle cx="19.5" cy="9.5" r="2.5"/><path d="M17.34 14.86c-.87-1.02-1.6-1.89-2.48-2.91-.46-.54-1.05-1.08-1.75-1.32-.11-.04-.22-.07-.33-.09-.25-.04-.52-.04-.78-.04s-.53 0-.79.05c-.11.02-.22.05-.33.09-.7.24-1.28.78-1.75 1.32-.87 1.02-1.6 1.89-2.48 2.91-1.31 1.31-2.92 2.76-2.62 4.79.29 1.02 1.02 2.03 2.33 2.32.73.15 3.06-.44 5.54-.44h.18c2.48 0 4.81.58 5.54.44 1.31-.29 2.04-1.31 2.33-2.32.31-2.04-1.3-3.49-2.61-4.8z"/><path d="M0 0h24v24H0z" fill="none"/></g>
-      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=visibility-->
-      <g id="ready"><path d="M0 0h24v24H0z" fill="none"/><path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z"/></g>
-      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons -->
-      <g id="schedule"><path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67z"></path></g>
-      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=bug_report-->
-      <g id="bug"><path d="M0 0h24v24H0z" fill="none"/><path d="M20 8h-2.81c-.45-.78-1.07-1.45-1.82-1.96L17 4.41 15.59 3l-2.17 2.17C12.96 5.06 12.49 5 12 5c-.49 0-.96.06-1.41.17L8.41 3 7 4.41l1.62 1.63C7.88 6.55 7.26 7.22 6.81 8H4v2h2.09c-.05.33-.09.66-.09 1v1H4v2h2v1c0 .34.04.67.09 1H4v2h2.81c1.04 1.79 2.97 3 5.19 3s4.15-1.21 5.19-3H20v-2h-2.09c.05-.33.09-.66.09-1v-1h2v-2h-2v-1c0-.34-.04-.67-.09-1H20V8zm-6 8h-4v-2h4v2zm0-4h-4v-2h4v2z"/></g>
-      <!-- This SVG is a copy from material.io https://fonts.gstatic.com/s/i/googlematerialicons/move_item/v1/24px.svg -->
-      <g id="move-item"><path d="M15,19H5V5h10v4h2V5c0-1.1-0.89-2-2-2H5C3.9,3,3,3.9,3,5v14c0,1.1,0.9,2,2,2h10c1.11,0,2-0.9,2-2v-4h-2V19z"/><polygon points="20.01,8.01 18.59,9.41 20.17,11 8,11 8,13 20.17,13 18.59,14.59 20.01,15.99 24,12"/></g>
-      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=warning-->
-      <g id="warning"><path d="M0 0h24v24H0z" fill="none"/><path d="M1 21h22L12 2 1 21zm12-3h-2v-2h2v2zm0-4h-2v-4h2v4z"/></g>
-      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=timelapse-->
-      <g id="timelapse"><path d="M0 0h24v24H0z" fill="none"/><path d="M16.24 7.76C15.07 6.59 13.54 6 12 6v6l-4.24 4.24c2.34 2.34 6.14 2.34 8.49 0 2.34-2.34 2.34-6.14-.01-8.48zM12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z"/></g>
-      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=mark_chat_read-->
-      <g id="markChatRead"><path d="M12,18l-6,0l-4,4V4c0-1.1,0.9-2,2-2h16c1.1,0,2,0.9,2,2v7l-2,0V4H4v12l8,0V18z M23,14.34l-1.41-1.41l-4.24,4.24l-2.12-2.12 l-1.41,1.41L17.34,20L23,14.34z"/></g>
-      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=message-->
-      <g id="message"><path d="M0 0h24v24H0z" fill="none"/><path d="M20 2H4c-1.1 0-1.99.9-1.99 2L2 22l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-2 12H6v-2h12v2zm0-3H6V9h12v2zm0-3H6V6h12v2z"/></g>
-      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=launch-->
-      <g id="launch"><path d="M0 0h24v24H0z" fill="none"/><path d="M19 19H5V5h7V3H5c-1.11 0-2 .9-2 2v14c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2v-7h-2v7zM14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7z"/></g>
-      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=filter-->
-      <g id="filter"><path d="M0,0h24 M24,24H0" fill="none"/><path d="M4.25,5.61C6.27,8.2,10,13,10,13v6c0,0.55,0.45,1,1,1h2c0.55,0,1-0.45,1-1v-6c0,0,3.72-4.8,5.74-7.39 C20.25,4.95,19.78,4,18.95,4H5.04C4.21,4,3.74,4.95,4.25,5.61z"/><path d="M0,0h24v24H0V0z" fill="none"/></g>
-      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=arrow_drop_down-->
-      <g id="arrowDropDown"><path d="M0 0h24v24H0z" fill="none"/><path d="M7 10l5 5 5-5z"/></g>
-      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=arrow_drop_up-->
-      <g id="arrowDropUp"><path d="M0 0h24v24H0z" fill="none"/><path d="M7 14l5-5 5 5z"/></g>
-      <!-- This is just a placeholder, i.e. an empty icon that has the same size as a normal icon. -->
-      <g id="placeholder"><path d="M0 0h24v24H0z" fill="none"/></g>
-      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=insert_photo-->
-      <g id="insert-photo"><path d="M0 0h24v24H0z" fill="none"/><path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/></g>
-      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=download-->
-      <g id="download"><path d="M0 0h24v24H0z" fill="none"/><path d="M5,20h14v-2H5V20z M19,9h-4V3H9v6H5l7,7L19,9z"/></g>
-      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=system_update-->
-      <g id="system-update"><path d="M0 0h24v24H0z" fill="none"/><path d="M17 1.01L7 1c-1.1 0-2 .9-2 2v18c0 1.1.9 2 2 2h10c1.1 0 2-.9 2-2V3c0-1.1-.9-1.99-2-1.99zM17 19H7V5h10v14zm-1-6h-3V8h-2v5H8l4 4 4-4z"/></g>
       <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=swap_horiz-->
       <g id="swapHoriz"><path d="M0 0h24v24H0z" fill="none"/><path d="M6.99 11L3 15l3.99 4v-3H14v-2H6.99v-3zM21 9l-3.99-4v3H10v2h7.01v3L21 9z"/></g>
-      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=link-->
-      <g id="link"><path d="M0 0h24v24H0z" fill="none"/><path d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z"/></g>
       <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material%20Icons%3Aplay_arrow-->
       <g id="playArrow"><path d="M0 0h24v24H0z" fill="none"/><path d="M8 5v14l11-7z"/></g>
       <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material%20Icons%3Apause-->
       <g id="pause"><path d="M0 0h24v24H0z" fill="none"/><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></g>
-      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material%20Icons%3Acode-->
-      <g id="code"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M9.4 16.6L4.8 12l4.6-4.6L8 6l-6 6 6 6 1.4-1.4zm5.2 0l4.6-4.6-4.6-4.6L16 6l6 6-6 6-1.4-1.4z"/></g>
-      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material%20Icons%3Afile_present-->
-      <g id="file-present"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M15 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V7l-5-5zM6 20V4h8v4h4v12H6zm10-10v5c0 2.21-1.79 4-4 4s-4-1.79-4-4V8.5c0-1.47 1.26-2.64 2.76-2.49 1.3.13 2.24 1.32 2.24 2.63V15h-2V8.5c0-.28-.22-.5-.5-.5s-.5.22-.5.5V15c0 1.1.9 2 2 2s2-.9 2-2v-5h2z"/></g>
-      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material%20Icons%3Aarrow_forward-->
-      <g id="arrow-forward"><path d="M0 0h24v24H0z" fill="none"/><path d="M12 4l-1.41 1.41L16.17 11H4v2h12.17l-5.58 5.59L12 20l8-8z"/></g>
-      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons:feedback -->
-      <g id="feedback"><path d="M0 0h24v24H0z" fill="none"/><path d="M20 2H4c-1.1 0-1.99.9-1.99 2L2 22l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-7 12h-2v-2h2v2zm0-4h-2V6h2v4z"/></g>
-      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=description -->
-      <g id="description"><path xmlns="http://www.w3.org/2000/svg" d="M0 0h24v24H0V0z" fill="none"/><path xmlns="http://www.w3.org/2000/svg" d="M8 16h8v2H8zm0-4h8v2H8zm6-10H6c-1.1 0-2 .9-2 2v16c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6zm4 18H6V4h7v5h5v11z"/></g>
-      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=settings_backup_restore and 0.65 scale and 4 translate https://fonts.google.com/icons?selected=Material+Icons&icon.query=done-->
-      <g id="overridden"><path xmlns="http://www.w3.org/2000/svg" d="M0 0h24v24H0V0z" fill="none"/><path xmlns="http://www.w3.org/2000/svg" d="M12 15 zM2 4v6h6V8H5.09C6.47 5.61 9.04 4 12 4c4.42 0 8 3.58 8 8s-3.58 8-8 8-8-3.58-8-8H2c0 5.52 4.48 10 10.01 10C17.53 22 22 17.52 22 12S17.53 2 12.01 2C8.73 2 5.83 3.58 4 6.01V4H2z"/><path xmlns="http://www.w3.org/2000/svg" d="M9.85 14.53 7.12 11.8l-.91.91L9.85 16.35 17.65 8.55l-.91-.91L9.85 14.53z"/></g>
-      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons:event_busy -->
-      <g id="unavailable"><path d="M0 0h24v24H0z" fill="none"/><path d="M9.31 17l2.44-2.44L14.19 17l1.06-1.06-2.44-2.44 2.44-2.44L14.19 10l-2.44 2.44L9.31 10l-1.06 1.06 2.44 2.44-2.44 2.44L9.31 17zM19 3h-1V1h-2v2H8V1H6v2H5c-1.11 0-1.99.9-1.99 2L3 19c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H5V8h14v11z"/></g>
-      <!-- This SVG is a custom PolyGerrit SVG -->
-      <g id="not-working-hours"><path d="M20.8,13.9c-0.6,0.1-1.3,0.2-2,0.2c-4.9,0-8.9-4-8.9-8.9c0-0.7,0.1-1.4,0.2-2c-4,0.9-6.9,4.5-6.9,8.7c0,4.9,4,8.9,8.9,8.9C16.3,20.8,19.9,17.9,20.8,13.9z"/></g>
-      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons:pending_actions -->
-      <g id="scheduled"><path d="M0 0h24v24H0z" fill="none"/><path d="M17.0 22.0Q14.925 22.0 13.4625 20.5375Q12.0 19.075 12.0 17.0Q12.0 14.925 13.4625 13.4625Q14.925 12.0 17.0 12.0Q19.075 12.0 20.5375 13.4625Q22.0 14.925 22.0 17.0Q22.0 19.075 20.5375 20.5375Q19.075 22.0 17.0 22.0ZM18.675 19.375 19.375 18.675 17.5 16.8V14.0H16.5V17.2ZM5.0 21.0Q4.175 21.0 3.5875 20.4125Q3.0 19.825 3.0 19.0V5.0Q3.0 4.175 3.5875 3.5875Q4.175 3.0 5.0 3.0H9.175Q9.5 2.125 10.2625 1.5625Q11.025 1.0 12.0 1.0Q12.975 1.0 13.7375 1.5625Q14.5 2.125 14.825 3.0H19.0Q19.825 3.0 20.4125 3.5875Q21.0 4.175 21.0 5.0V11.25Q20.55 10.925 20.05 10.7Q19.55 10.475 19.0 10.3V5.0Q19.0 5.0 19.0 5.0Q19.0 5.0 19.0 5.0H17.0V8.0H7.0V5.0H5.0Q5.0 5.0 5.0 5.0Q5.0 5.0 5.0 5.0V19.0Q5.0 19.0 5.0 19.0Q5.0 19.0 5.0 19.0H10.3Q10.475 19.55 10.7 20.05Q10.925 20.55 11.25 21.0ZM12.0 5.0Q12.425 5.0 12.7125 4.7125Q13.0 4.425 13.0 4.0Q13.0 3.575 12.7125 3.2875Q12.425 3.0 12.0 3.0Q11.575 3.0 11.2875 3.2875Q11.0 3.575 11.0 4.0Q11.0 4.425 11.2875 4.7125Q11.575 5.0 12.0 5.0Z"/></g>
-      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons:new_releases -->
-      <g id="new"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M23 12l-2.44-2.78.34-3.68-3.61-.82-1.89-3.18L12 3 8.6 1.54 6.71 4.72l-3.61.81.34 3.68L1 12l2.44 2.78-.34 3.69 3.61.82 1.89 3.18L12 21l3.4 1.46 1.89-3.18 3.61-.82-.34-3.68L23 12zm-4.51 2.11l.26 2.79-2.74.62-1.43 2.41L12 18.82l-2.58 1.11-1.43-2.41-2.74-.62.26-2.8L3.66 12l1.85-2.12-.26-2.78 2.74-.61 1.43-2.41L12 5.18l2.58-1.11 1.43 2.41 2.74.62-.26 2.79L20.34 12l-1.85 2.11zM11 15h2v2h-2zm0-8h2v6h-2z"/></g>
-      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons:arrow_right_alt -->
-      <g id="arrow-right"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M14 6l-1.41 1.41L16.17 11H4v2h12.17l-3.58 3.59L14 18l6-6z"/></g>
-      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons:cancel -->
-      <g id="cancel"><path xmlns="http://www.w3.org/2000/svg" d="M0 0h24v24H0z" fill="none"/><path xmlns="http://www.w3.org/2000/svg" d="M12 2C6.47 2 2 6.47 2 12s4.47 10 10 10 10-4.47 10-10S17.53 2 12 2zm5 13.59L15.59 17 12 13.41 8.41 17 7 15.59 10.59 12 7 8.41 8.41 7 12 10.59 15.59 7 17 8.41 13.41 12 17 15.59z"/></g>
     </defs>
   </svg>
 </iron-iconset-svg>`;
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api.ts
index 7ab4689..4b5913c 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api.ts
@@ -3,132 +3,25 @@
  * Copyright 2017 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-import {DiffLayer, DiffLayerListener} from '../../../types/types';
-import {Side} from '../../../constants/constants';
-import {EventType, PluginApi} from '../../../api/plugin';
-import {getAppContext} from '../../../services/app-context';
 import {AnnotationPluginApi, CoverageProvider} from '../../../api/annotation';
+import {PluginApi} from '../../../api/plugin';
+import {PluginsModel} from '../../../models/plugins/plugins-model';
+import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
 
 export class GrAnnotationActionsInterface implements AnnotationPluginApi {
-  /**
-   * Collect all annotation layers instantiated by createLayer. This is only
-   * used for being able to look up the appropriate layer when notify() is
-   * being called by plugins.
-   */
-  private annotationLayers: AnnotationLayer[] = [];
-
-  private coverageProvider?: CoverageProvider;
-
-  private readonly reporting = getAppContext().reportingService;
-
-  constructor(private readonly plugin: PluginApi) {
+  constructor(
+    private readonly reporting: ReportingService,
+    private readonly pluginsModel: PluginsModel,
+    private readonly plugin: PluginApi
+  ) {
     this.reporting.trackApi(this.plugin, 'annotation', 'constructor');
-    plugin.on(EventType.ANNOTATE_DIFF, this);
   }
 
-  setCoverageProvider(
-    coverageProvider: CoverageProvider
-  ): GrAnnotationActionsInterface {
+  setCoverageProvider(provider: CoverageProvider) {
     this.reporting.trackApi(this.plugin, 'annotation', 'setCoverageProvider');
-    if (this.coverageProvider) {
-      this.reporting.error(
-        'Annotation Plugin',
-        new Error(
-          `Overwriting coverage provider: ${this.plugin.getPluginName()}`
-        )
-      );
-    }
-    this.coverageProvider = coverageProvider;
-    return this;
-  }
-
-  /**
-   * Used by Gerrit to look up the coverage provider. Not intended to be called
-   * by plugins.
-   */
-  getCoverageProvider() {
-    return this.coverageProvider;
-  }
-
-  notify(path: string, start: number, end: number, side: Side) {
-    this.reporting.trackApi(this.plugin, 'annotation', 'notify');
-    for (const annotationLayer of this.annotationLayers) {
-      // Notify only the annotation layer that is associated with the specified
-      // path.
-      if (annotationLayer.path === path) {
-        annotationLayer.notifyListeners(start, end, side);
-      }
-    }
-  }
-
-  /**
-   * Factory method called by Gerrit for creating a DiffLayer for each diff that
-   * is rendered.
-   *
-   * Don't forget to also call disposeLayer().
-   */
-  createLayer(path: string) {
-    const annotationLayer = new AnnotationLayer(path);
-    this.annotationLayers.push(annotationLayer);
-    return annotationLayer;
-  }
-
-  /**
-   * Called by Gerrit for each diff renderer that had called createLayer().
-   */
-  disposeLayer(path: string) {
-    this.annotationLayers = this.annotationLayers.filter(
-      annotationLayer => annotationLayer.path !== path
-    );
-  }
-}
-
-/**
- * An AnnotationLayer exists for each file that is being rendered. This class is
- * not exposed to plugins, but being used by Gerrit's diff rendering.
- */
-export class AnnotationLayer implements DiffLayer {
-  private listeners: DiffLayerListener[] = [];
-
-  /**
-   * Used to create an instance of the Annotation Layer interface.
-   *
-   * @param path The file path (eg: /COMMIT_MSG').
-   */
-  constructor(readonly path: string) {
-    this.listeners = [];
-  }
-
-  /**
-   * Register a listener for layer updates.
-   * Don't forget to removeListener when you stop using layer.
-   *
-   * @param fn The update handler function.
-   * Should accept as arguments the line numbers for the start and end of
-   * the update and the side as a string.
-   */
-  addListener(listener: DiffLayerListener) {
-    this.listeners.push(listener);
-  }
-
-  removeListener(listener: DiffLayerListener) {
-    this.listeners = this.listeners.filter(f => f !== listener);
-  }
-
-  annotate() {}
-
-  /**
-   * Notify layer listeners (which typically is just Gerrit's diff renderer) of
-   * changes to annotations after the diff rendering had already completed. This
-   * is indirectly called by plugins using the AnnotationPluginApi.notify().
-   *
-   * @param start The line where the update starts.
-   * @param end The line where the update ends.
-   * @param side The side of the update. ('left' or 'right')
-   */
-  notifyListeners(start: number, end: number, side: Side) {
-    for (const listener of this.listeners) {
-      listener(start, end, side);
-    }
+    this.pluginsModel.coverageRegister({
+      pluginName: this.plugin.getPluginName(),
+      provider,
+    });
   }
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api_test.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api_test.js
deleted file mode 100644
index f103b60..0000000
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api_test.js
+++ /dev/null
@@ -1,81 +0,0 @@
-/**
- * @license
- * Copyright 2016 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-import '../../../test/common-test-setup';
-import '../../change/gr-change-actions/gr-change-actions';
-import {assert} from '@open-wc/testing';
-
-suite('gr-annotation-actions-js-api tests', () => {
-  let annotationActions;
-
-  let plugin;
-
-  setup(() => {
-    window.Gerrit.install(p => { plugin = p; }, '0.1',
-        'http://test.com/plugins/testplugin/static/test.js');
-    annotationActions = plugin.annotationApi();
-  });
-
-  teardown(() => {
-    annotationActions = null;
-  });
-
-  test('add notifier', () => {
-    const path1 = '/dummy/path1';
-    const path2 = '/dummy/path2';
-    const annotationLayer1 = annotationActions.createLayer(path1, 1);
-    const annotationLayer2 = annotationActions.createLayer(path2, 1);
-    const layer1Spy = sinon.spy(annotationLayer1, 'notifyListeners');
-    const layer2Spy = sinon.spy(annotationLayer2, 'notifyListeners');
-
-    // Assert that no layers are invoked with a different path.
-    annotationActions.notify('/dummy/path3', 0, 10, 'right');
-    assert.isFalse(layer1Spy.called);
-    assert.isFalse(layer2Spy.called);
-
-    // Assert that only the 1st layer is invoked with path1.
-    annotationActions.notify(path1, 0, 10, 'right');
-    assert.isTrue(layer1Spy.called);
-    assert.isFalse(layer2Spy.called);
-
-    // Reset spies.
-    layer1Spy.resetHistory();
-    layer2Spy.resetHistory();
-
-    // Assert that only the 2nd layer is invoked with path2.
-    annotationActions.notify(path2, 0, 20, 'left');
-    assert.isFalse(layer1Spy.called);
-    assert.isTrue(layer2Spy.called);
-  });
-
-  test('layer notify listeners', () => {
-    const annotationLayer = annotationActions.createLayer('/dummy/path', 1);
-    let listenerCalledTimes = 0;
-    const startRange = 10;
-    const endRange = 20;
-    const side = 'right';
-    const listener = (st, end, s) => {
-      listenerCalledTimes++;
-      assert.equal(st, startRange);
-      assert.equal(end, endRange);
-      assert.equal(s, side);
-    };
-
-    // Notify with 0 listeners added.
-    annotationLayer.notifyListeners(startRange, endRange, side);
-    assert.equal(listenerCalledTimes, 0);
-
-    // Add 1 listener.
-    annotationLayer.addListener(listener);
-    annotationLayer.notifyListeners(startRange, endRange, side);
-    assert.equal(listenerCalledTimes, 1);
-
-    // Add 1 more listener. Total 2 listeners.
-    annotationLayer.addListener(listener);
-    annotationLayer.notifyListeners(startRange, endRange, side);
-    assert.equal(listenerCalledTimes, 3);
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils.ts
index 0bd491c..7b99660 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils.ts
@@ -60,11 +60,11 @@
   restApiService: RestApiService,
   method: HttpMethod,
   url: string,
-  opt_callback?: (response: unknown) => void,
-  opt_payload?: RequestPayload
+  callback?: (response: unknown) => void,
+  payload?: RequestPayload
 ) {
   return restApiService
-    .send(method, url, opt_payload)
+    .send(method, url, payload)
     .then(response => {
       if (response.status < 200 || response.status >= 300) {
         return response.text().then((text: string | undefined) => {
@@ -79,8 +79,8 @@
       }
     })
     .then(response => {
-      if (opt_callback) {
-        opt_callback(response);
+      if (callback) {
+        callback(response);
       }
       return response;
     });
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.ts
index b8bfd21..f0143a6 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.ts
@@ -15,6 +15,7 @@
   RevisionActions,
 } from '../../../api/change-actions';
 import {PropertyDeclaration} from 'lit';
+import {JsApiService} from './gr-js-api-types';
 
 export interface UIActionInfo extends RequireProperties<ActionInfo, 'label'> {
   __key: string;
@@ -65,9 +66,11 @@
 
   private readonly reporting = getAppContext().reportingService;
 
-  private readonly jsApiService = getAppContext().jsApiService;
-
-  constructor(public plugin: PluginApi, el?: GrChangeActionsElement) {
+  constructor(
+    public plugin: PluginApi,
+    private readonly jsApiService: JsApiService,
+    el?: GrChangeActionsElement
+  ) {
     this.reporting.trackApi(this.plugin, 'actions', 'constructor');
     this.setEl(el);
   }
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.ts
index df9adc9..e3ef083e 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.ts
@@ -5,13 +5,7 @@
  */
 import '../../../test/common-test-setup';
 import '../../change/gr-change-actions/gr-change-actions';
-import {
-  query,
-  queryAll,
-  queryAndAssert,
-  resetPlugins,
-} from '../../../test/test-utils';
-import {getPluginLoader} from './gr-plugin-loader';
+import {query, queryAll, queryAndAssert} from '../../../test/test-utils';
 import {GrChangeActions} from '../../change/gr-change-actions/gr-change-actions';
 import {fixture, html, assert} from '@open-wc/testing';
 import {PluginApi} from '../../../api/plugin';
@@ -24,6 +18,8 @@
 import {ChangeViewChangeInfo} from '../../../types/common';
 import {GrDropdown} from '../gr-dropdown/gr-dropdown';
 import {GrIcon} from '../gr-icon/gr-icon';
+import {testResolver} from '../../../test/common-test-setup';
+import {pluginLoaderToken} from './gr-plugin-loader';
 
 suite('gr-change-actions-js-api-interface tests', () => {
   let element: GrChangeActions;
@@ -32,7 +28,6 @@
 
   suite('early init', () => {
     setup(async () => {
-      resetPlugins();
       window.Gerrit.install(
         p => {
           plugin = p;
@@ -41,17 +36,13 @@
         'http://test.com/plugins/testplugin/static/test.js'
       );
       // Mimic all plugins loaded.
-      getPluginLoader().loadPlugins([]);
+      testResolver(pluginLoaderToken).loadPlugins([]);
       changeActions = plugin.changeActions();
       element = await fixture<GrChangeActions>(html`
         <gr-change-actions></gr-change-actions>
       `);
     });
 
-    teardown(() => {
-      resetPlugins();
-    });
-
     test('does not throw', () => {
       assert.doesNotThrow(() => {
         changeActions.add(ActionType.CHANGE, 'foo');
@@ -61,12 +52,10 @@
 
   suite('normal init', () => {
     setup(async () => {
-      resetPlugins();
       element = await fixture<GrChangeActions>(html`
         <gr-change-actions></gr-change-actions>
       `);
       element.change = {} as ChangeViewChangeInfo;
-      element._hasKnownChainState = false;
       window.Gerrit.install(
         p => {
           plugin = p;
@@ -76,11 +65,7 @@
       );
       changeActions = plugin.changeActions();
       // Mimic all plugins loaded.
-      getPluginLoader().loadPlugins([]);
-    });
-
-    teardown(() => {
-      resetPlugins();
+      testResolver(pluginLoaderToken).loadPlugins([]);
     });
 
     test('property existence', () => {
@@ -161,20 +146,17 @@
     test('move action button to overflow', async () => {
       const key = changeActions.add(ActionType.REVISION, 'Bork!');
       await element.updateComplete;
-      assert.isTrue(queryAndAssert<GrDropdown>(element, '#moreActions').hidden);
-      assert.isOk(
-        queryAndAssert<GrButton>(element, `[data-action-key="${key}"]`)
-      );
+
+      let items = queryAndAssert<GrDropdown>(element, '#moreActions').items;
+      assert.isFalse(items?.some(item => item.name === 'Bork!'));
+      assert.isOk(query<GrButton>(element, `[data-action-key="${key}"]`));
+
       changeActions.setActionOverflow(ActionType.REVISION, key, true);
       await element.updateComplete;
+
+      items = queryAndAssert<GrDropdown>(element, '#moreActions').items;
+      assert.isTrue(items?.some(item => item.name === 'Bork!'));
       assert.isNotOk(query<GrButton>(element, `[data-action-key="${key}"]`));
-      assert.isFalse(
-        queryAndAssert<GrDropdown>(element, '#moreActions').hidden
-      );
-      assert.strictEqual(
-        queryAndAssert<GrDropdown>(element, '#moreActions').items![0].name,
-        'Bork!'
-      );
     });
 
     test('change actions priority', async () => {
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api_test.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api_test.ts
index 1d47d37..65d2687 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api_test.ts
@@ -6,15 +6,17 @@
 import '../../../test/common-test-setup';
 import '../../change/gr-reply-dialog/gr-reply-dialog';
 import {stubElement} from '../../../test/test-utils';
-import {assert} from '@open-wc/testing';
+import {assert, fixture} from '@open-wc/testing';
 import {PluginApi} from '../../../api/plugin';
 import {ChangeReplyPluginApi} from '../../../api/change-reply';
+import {GrReplyDialog} from '../../change/gr-reply-dialog/gr-reply-dialog';
+import {html} from 'lit';
 
 suite('gr-change-reply-js-api tests', () => {
   let changeReply: ChangeReplyPluginApi;
   let plugin: PluginApi;
 
-  suite('early init', () => {
+  suite('init', () => {
     setup(async () => {
       window.Gerrit.install(
         p => {
@@ -24,39 +26,13 @@
         'http://test.com/plugins/testplugin/static/test.js'
       );
       changeReply = plugin.changeReply();
+      await fixture<GrReplyDialog>(html`<gr-reply-dialog></gr-reply-dialog>>`);
+      assert.ok(changeReply);
     });
 
     test('works', () => {
       stubElement('gr-reply-dialog', 'getLabelValue').returns('+123');
-      assert.equal(changeReply.getLabelValue('My-Label'), '+123');
-
-      const setLabelValueStub = stubElement('gr-reply-dialog', 'setLabelValue');
-      changeReply.setLabelValue('My-Label', '+1337');
-      assert.isTrue(setLabelValueStub.calledWithExactly('My-Label', '+1337'));
-
-      const setPluginMessageStub = stubElement(
-        'gr-reply-dialog',
-        'setPluginMessage'
-      );
-      changeReply.showMessage('foobar');
-      assert.isTrue(setPluginMessageStub.calledWithExactly('foobar'));
-    });
-  });
-
-  suite('normal init', () => {
-    setup(async () => {
-      window.Gerrit.install(
-        p => {
-          plugin = p;
-        },
-        '0.1',
-        'http://test.com/plugins/testplugin/static/test.js'
-      );
-      changeReply = plugin.changeReply();
-    });
-
-    test('works', () => {
-      stubElement('gr-reply-dialog', 'getLabelValue').returns('+123');
+      assert.ok(changeReply);
       assert.equal(changeReply.getLabelValue('My-Label'), '+123');
 
       const setLabelValueStub = stubElement('gr-reply-dialog', 'setLabelValue');
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit.ts
deleted file mode 100644
index 4900ed5..0000000
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit.ts
+++ /dev/null
@@ -1,306 +0,0 @@
-/**
- * @license
- * Copyright 2019 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-
-/**
- * This defines the Gerrit instance. All methods directly attached to Gerrit
- * should be defined or linked here.
- */
-import {getPluginLoader, PluginOptionMap} from './gr-plugin-loader';
-import {send} from './gr-api-utils';
-import {getAppContext, AppContext} from '../../../services/app-context';
-import {PluginApi} from '../../../api/plugin';
-import {AuthService} from '../../../services/gr-auth/gr-auth';
-import {RestApiService} from '../../../services/gr-rest-api/gr-rest-api';
-import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
-import {HttpMethod} from '../../../constants/constants';
-import {RequestPayload} from '../../../types/common';
-import {
-  EventCallback,
-  EventEmitterService,
-} from '../../../services/gr-event-interface/gr-event-interface';
-import {Gerrit} from '../../../api/gerrit';
-import {fontStyles} from '../../../styles/gr-font-styles';
-import {formStyles} from '../../../styles/gr-form-styles';
-import {menuPageStyles} from '../../../styles/gr-menu-page-styles';
-import {spinnerStyles} from '../../../styles/gr-spinner-styles';
-import {subpageStyles} from '../../../styles/gr-subpage-styles';
-import {tableStyles} from '../../../styles/gr-table-styles';
-import {assertIsDefined} from '../../../utils/common-util';
-import {iconStyles} from '../../../styles/gr-icon-styles';
-
-/**
- * These are the methods and properties that are exposed explicitly in the
- * public global `Gerrit` interface. In reality JavaScript plugins do depend
- * on some of this "internal" stuff. But we want to convert plugins to
- * TypeScript one by one and while doing that remove those dependencies.
- */
-export interface GerritInternal extends EventEmitterService, Gerrit {
-  css(rule: string): string;
-  install(
-    callback: (plugin: PluginApi) => void,
-    opt_version?: string,
-    src?: string
-  ): void;
-  getLoggedIn(): Promise<boolean>;
-  get(url: string, callback?: (response: unknown) => void): void;
-  post(
-    url: string,
-    payload?: RequestPayload,
-    callback?: (response: unknown) => void
-  ): void;
-  put(
-    url: string,
-    payload?: RequestPayload,
-    callback?: (response: unknown) => void
-  ): void;
-  delete(url: string, callback?: (response: unknown) => void): void;
-  isPluginLoaded(pathOrUrl: string): boolean;
-  awaitPluginsLoaded(): Promise<unknown>;
-  _loadPlugins(plugins: string[], opts: PluginOptionMap): void;
-  _arePluginsLoaded(): boolean;
-  _isPluginEnabled(pathOrUrl: string): boolean;
-  _isPluginLoaded(pathOrUrl: string): boolean;
-  _customStyleSheet?: CSSStyleSheet;
-
-  // exposed methods
-  Auth: AuthService;
-}
-
-export function initGerritPluginApi(appContext: AppContext) {
-  window.Gerrit = window.Gerrit ?? new GerritImpl(appContext);
-}
-
-export function _testOnly_getGerritInternalPluginApi(): GerritInternal {
-  if (!window.Gerrit) throw new Error('initGerritPluginApi was not called');
-  return window.Gerrit as GerritInternal;
-}
-
-export function deprecatedDelete(
-  url: string,
-  callback?: (response: Response) => void
-) {
-  console.warn('.delete() is deprecated! Use plugin.restApi().delete()');
-  return getAppContext()
-    .restApiService.send(HttpMethod.DELETE, url)
-    .then(response => {
-      if (response.status !== 204) {
-        return response.text().then(text => {
-          if (text) {
-            return Promise.reject(new Error(text));
-          } else {
-            return Promise.reject(new Error(`${response.status}`));
-          }
-        });
-      }
-      if (callback) callback(response);
-      return response;
-    });
-}
-
-const fakeApi = {
-  getPluginName: () => 'global',
-};
-
-/**
- * TODO(brohlfs): Reduce this step by step until it only contains install().
- */
-class GerritImpl implements GerritInternal {
-  _customStyleSheet?: CSSStyleSheet;
-
-  public readonly Auth: AuthService;
-
-  private readonly reportingService: ReportingService;
-
-  private readonly eventEmitter: EventEmitterService;
-
-  private readonly restApiService: RestApiService;
-
-  public readonly styles = {
-    font: fontStyles,
-    form: formStyles,
-    icon: iconStyles,
-    menuPage: menuPageStyles,
-    spinner: spinnerStyles,
-    subPage: subpageStyles,
-    table: tableStyles,
-  };
-
-  constructor(appContext: AppContext) {
-    this.Auth = appContext.authService;
-    this.reportingService = appContext.reportingService;
-    this.eventEmitter = appContext.eventEmitter;
-    this.restApiService = appContext.restApiService;
-    assertIsDefined(this.reportingService, 'reportingService');
-    assertIsDefined(this.eventEmitter, 'eventEmitter');
-    assertIsDefined(this.restApiService, 'restApiService');
-  }
-
-  finalize() {}
-
-  /**
-   * @deprecated Use plugin.styles().css(rulesStr) instead. Please, consult
-   * the documentation how to replace it accordingly.
-   */
-  css(rulesStr: string) {
-    this.reportingService.trackApi(fakeApi, 'global', 'css');
-    console.warn(
-      'Gerrit.css(rulesStr) is deprecated!',
-      'Use plugin.styles().css(rulesStr)'
-    );
-    if (!this._customStyleSheet) {
-      const styleEl = document.createElement('style');
-      document.head.appendChild(styleEl);
-      this._customStyleSheet = styleEl.sheet!;
-    }
-
-    const name = `__pg_js_api_class_${this._customStyleSheet.cssRules.length}`;
-    this._customStyleSheet.insertRule('.' + name + '{' + rulesStr + '}', 0);
-    return name;
-  }
-
-  install(
-    callback: (plugin: PluginApi) => void,
-    version?: string,
-    src?: string
-  ) {
-    getPluginLoader().install(callback, version, src);
-  }
-
-  getLoggedIn() {
-    this.reportingService.trackApi(fakeApi, 'global', 'getLoggedIn');
-    console.warn(
-      'Gerrit.getLoggedIn() is deprecated! ' +
-        'Use plugin.restApi().getLoggedIn()'
-    );
-    return this.restApiService.getLoggedIn();
-  }
-
-  get(url: string, callback?: (response: unknown) => void) {
-    this.reportingService.trackApi(fakeApi, 'global', 'get');
-    console.warn('.get() is deprecated! Use plugin.restApi().get()');
-    send(this.restApiService, HttpMethod.GET, url, callback);
-  }
-
-  post(
-    url: string,
-    payload?: RequestPayload,
-    callback?: (response: unknown) => void
-  ) {
-    this.reportingService.trackApi(fakeApi, 'global', 'post');
-    console.warn('.post() is deprecated! Use plugin.restApi().post()');
-    send(this.restApiService, HttpMethod.POST, url, callback, payload);
-  }
-
-  put(
-    url: string,
-    payload?: RequestPayload,
-    callback?: (response: unknown) => void
-  ) {
-    this.reportingService.trackApi(fakeApi, 'global', 'put');
-    console.warn('.put() is deprecated! Use plugin.restApi().put()');
-    send(this.restApiService, HttpMethod.PUT, url, callback, payload);
-  }
-
-  delete(url: string, callback?: (response: Response) => void) {
-    this.reportingService.trackApi(fakeApi, 'global', 'delete');
-    deprecatedDelete(url, callback);
-  }
-
-  awaitPluginsLoaded() {
-    this.reportingService.trackApi(fakeApi, 'global', 'awaitPluginsLoaded');
-    return getPluginLoader().awaitPluginsLoaded();
-  }
-
-  // TODO(taoalpha): consider removing these proxy methods
-  // and using getPluginLoader() directly
-  _loadPlugins(plugins: string[] = []) {
-    this.reportingService.trackApi(fakeApi, 'global', '_loadPlugins');
-    getPluginLoader().loadPlugins(plugins);
-  }
-
-  _arePluginsLoaded() {
-    this.reportingService.trackApi(fakeApi, 'global', '_arePluginsLoaded');
-    return getPluginLoader().arePluginsLoaded();
-  }
-
-  _isPluginEnabled(pathOrUrl: string) {
-    this.reportingService.trackApi(fakeApi, 'global', '_isPluginEnabled');
-    return getPluginLoader().isPluginEnabled(pathOrUrl);
-  }
-
-  isPluginLoaded(pathOrUrl: string) {
-    return this._isPluginLoaded(pathOrUrl);
-  }
-
-  _isPluginLoaded(pathOrUrl: string) {
-    this.reportingService.trackApi(fakeApi, 'global', '_isPluginLoaded');
-    return getPluginLoader().isPluginLoaded(pathOrUrl);
-  }
-
-  /**
-   * Enabling EventEmitter interface on Gerrit.
-   *
-   * This will enable to signal across different parts of js code without relying on DOM,
-   * including core to core, plugin to plugin and also core to plugin.
-   *
-   * @example
-   *
-   * // Emit this event from pluginA
-   * Gerrit.install(pluginA => {
-   *   fetch("some-api").then(() => {
-   *     Gerrit.on("your-special-event", {plugin: pluginA});
-   *   });
-   * });
-   *
-   * // Listen on your-special-event from pluginB
-   * Gerrit.install(pluginB => {
-   *   Gerrit.on("your-special-event", ({plugin}) => {
-   *     // do something, plugin is pluginA
-   *   });
-   * });
-   */
-  addListener(eventName: string, cb: EventCallback) {
-    this.reportingService.trackApi(fakeApi, 'global', 'addListener');
-    return this.eventEmitter.addListener(eventName, cb);
-  }
-
-  // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  dispatch(eventName: string, detail: any) {
-    this.reportingService.trackApi(fakeApi, 'global', 'dispatch');
-    return this.eventEmitter.dispatch(eventName, detail);
-  }
-
-  // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  emit(eventName: string, detail: any) {
-    this.reportingService.trackApi(fakeApi, 'global', 'emit');
-    return this.eventEmitter.emit(eventName, detail);
-  }
-
-  off(eventName: string, cb: EventCallback) {
-    this.reportingService.trackApi(fakeApi, 'global', 'off');
-    this.eventEmitter.off(eventName, cb);
-  }
-
-  on(eventName: string, cb: EventCallback) {
-    this.reportingService.trackApi(fakeApi, 'global', 'on');
-    return this.eventEmitter.on(eventName, cb);
-  }
-
-  once(eventName: string, cb: EventCallback) {
-    this.reportingService.trackApi(fakeApi, 'global', 'once');
-    return this.eventEmitter.once(eventName, cb);
-  }
-
-  removeAllListeners(eventName: string) {
-    this.reportingService.trackApi(fakeApi, 'global', 'removeAllListeners');
-    this.eventEmitter.removeAllListeners(eventName);
-  }
-
-  removeListener(eventName: string, cb: EventCallback) {
-    this.reportingService.trackApi(fakeApi, 'global', 'removeListener');
-    this.eventEmitter.removeListener(eventName, cb);
-  }
-}
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit_test.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit_test.ts
deleted file mode 100644
index b906891..0000000
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit_test.ts
+++ /dev/null
@@ -1,63 +0,0 @@
-/**
- * @license
- * Copyright 2019 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-import '../../../test/common-test-setup';
-import {getPluginLoader} from './gr-plugin-loader';
-import {resetPlugins} from '../../../test/test-utils';
-import {
-  GerritInternal,
-  _testOnly_getGerritInternalPluginApi,
-} from './gr-gerrit';
-import {stubRestApi} from '../../../test/test-utils';
-import {getAppContext} from '../../../services/app-context';
-import {GrJsApiInterface} from './gr-js-api-interface-element';
-import {SinonFakeTimers} from 'sinon';
-import {Timestamp} from '../../../api/rest-api';
-import {assert} from '@open-wc/testing';
-
-suite('gr-gerrit tests', () => {
-  let element: GrJsApiInterface;
-  let clock: SinonFakeTimers;
-  let pluginApi: GerritInternal;
-
-  setup(() => {
-    clock = sinon.useFakeTimers();
-
-    stubRestApi('getAccount').returns(
-      Promise.resolve({name: 'Judy Hopps', registered_on: '' as Timestamp})
-    );
-    stubRestApi('send').returns(
-      Promise.resolve({...new Response(), status: 200})
-    );
-    element = getAppContext().jsApiService as GrJsApiInterface;
-    pluginApi = _testOnly_getGerritInternalPluginApi();
-  });
-
-  teardown(() => {
-    clock.restore();
-    element._removeEventCallbacks();
-    resetPlugins();
-  });
-
-  suite('proxy methods', () => {
-    test('Gerrit._isPluginEnabled proxy to getPluginLoader()', () => {
-      const stubFn = sinon.stub();
-      sinon
-        .stub(getPluginLoader(), 'isPluginEnabled')
-        .callsFake((...args) => stubFn(...args));
-      pluginApi._isPluginEnabled('test_plugin');
-      assert.isTrue(stubFn.calledWith('test_plugin'));
-    });
-
-    test('Gerrit._isPluginLoaded proxy to getPluginLoader()', () => {
-      const stubFn = sinon.stub();
-      sinon
-        .stub(getPluginLoader(), 'isPluginLoaded')
-        .callsFake((...args) => stubFn(...args));
-      pluginApi._isPluginLoaded('test_plugin');
-      assert.isTrue(stubFn.calledWith('test_plugin'));
-    });
-  });
-});
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.ts
index 93b1684..46c759b 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.ts
@@ -3,15 +3,14 @@
  * Copyright 2020 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-import {getPluginLoader} from './gr-plugin-loader';
 import {hasOwnProperty} from '../../../utils/common-util';
 import {
   ChangeInfo,
   LabelNameToValueMap,
+  PARENT,
   ReviewInput,
   RevisionInfo,
 } from '../../../types/common';
-import {GrAnnotationActionsInterface} from './gr-annotation-actions-js-api';
 import {GrAdminApi} from '../../plugins/gr-admin-api/gr-admin-api';
 import {
   JsApiService,
@@ -20,53 +19,23 @@
   ShowRevisionActionsDetail,
 } from './gr-js-api-types';
 import {EventType, TargetElement} from '../../../api/plugin';
-import {DiffLayer, HighlightJS, ParsedChangeInfo} from '../../../types/types';
+import {ParsedChangeInfo} from '../../../types/types';
 import {MenuLink} from '../../../api/admin';
 import {Finalizable} from '../../../services/registry';
 import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
+import {Provider} from '../../../models/dependency';
 
 const elements: {[key: string]: HTMLElement} = {};
 const eventCallbacks: {[key: string]: EventCallback[]} = {};
 
 export class GrJsApiInterface implements JsApiService, Finalizable {
-  constructor(readonly reporting: ReportingService) {}
+  constructor(
+    private waitForPluginsToLoad: Provider<Promise<void>>,
+    readonly reporting: ReportingService
+  ) {}
 
   finalize() {}
 
-  // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  handleEvent(type: EventType, detail: any) {
-    getPluginLoader()
-      .awaitPluginsLoaded()
-      .then(() => {
-        switch (type) {
-          case EventType.HISTORY:
-            this._handleHistory(detail);
-            break;
-          case EventType.SHOW_CHANGE:
-            this._handleShowChange(detail);
-            break;
-          case EventType.COMMENT:
-            this._handleComment(detail);
-            break;
-          case EventType.LABEL_CHANGE:
-            this._handleLabelChange(detail);
-            break;
-          case EventType.SHOW_REVISION_ACTIONS:
-            this._handleShowRevisionActions(detail);
-            break;
-          case EventType.HIGHLIGHTJS_LOADED:
-            this._handleHighlightjsLoaded(detail);
-            break;
-          default:
-            console.warn(
-              'handleEvent called with unsupported event type:',
-              type
-            );
-            break;
-        }
-      });
-  }
-
   addElement(key: TargetElement, el: HTMLElement) {
     elements[key] = el;
   }
@@ -107,23 +76,9 @@
     }
   }
 
-  // TODO(TS): The HISTORY event and its handler seem unused.
-  _handleHistory(detail: {path: string}) {
-    for (const cb of this._getEventCallbacks(EventType.HISTORY)) {
-      try {
-        cb(detail.path);
-      } catch (err: unknown) {
-        this.reporting.error(
-          'GrJsApiInterface',
-          new Error('handleHistory callback error'),
-          err
-        );
-      }
-    }
-  }
-
-  _handleShowChange(detail: ShowChangeDetail) {
+  async handleShowChange(detail: ShowChangeDetail) {
     if (!detail.change) return;
+    await this.waitForPluginsToLoad();
     // Note (issue 8221) Shallow clone the change object and add a mergeable
     // getter with deprecation warning. This makes the change detail appear as
     // though SKIP_MERGEABLE was not set, so that plugins that expect it can
@@ -144,20 +99,22 @@
         return detail.info && detail.info.mergeable;
       },
     };
-    const patchNum = detail.patchNum;
-    const info = detail.info;
+    const {patchNum, info, basePatchNum} = detail;
 
     let revision;
+    let baseRevision;
     for (const rev of Object.values(change.revisions || {})) {
       if (rev._number === patchNum) {
         revision = rev;
-        break;
+      }
+      if (rev._number === basePatchNum) {
+        baseRevision = rev;
       }
     }
 
     for (const cb of this._getEventCallbacks(EventType.SHOW_CHANGE)) {
       try {
-        cb(change, revision, info);
+        cb(change, revision, info, baseRevision ?? PARENT);
       } catch (err: unknown) {
         this.reporting.error(
           'GrJsApiInterface',
@@ -168,7 +125,8 @@
     }
   }
 
-  _handleShowRevisionActions(detail: ShowRevisionActionsDetail) {
+  async handleShowRevisionActions(detail: ShowRevisionActionsDetail) {
+    await this.waitForPluginsToLoad();
     const registeredCallbacks = this._getEventCallbacks(
       EventType.SHOW_REVISION_ACTIONS
     );
@@ -199,22 +157,8 @@
     }
   }
 
-  // TODO(TS): The COMMENT event and its handler seem unused.
-  _handleComment(detail: {node: Node}) {
-    for (const cb of this._getEventCallbacks(EventType.COMMENT)) {
-      try {
-        cb(detail.node);
-      } catch (err: unknown) {
-        this.reporting.error(
-          'GrJsApiInterface',
-          new Error('comment callback error'),
-          err
-        );
-      }
-    }
-  }
-
-  _handleLabelChange(detail: {change: ChangeInfo}) {
+  async handleLabelChange(detail: {change?: ParsedChangeInfo}) {
+    await this.waitForPluginsToLoad();
     for (const cb of this._getEventCallbacks(EventType.LABEL_CHANGE)) {
       try {
         cb(detail.change);
@@ -228,20 +172,6 @@
     }
   }
 
-  _handleHighlightjsLoaded(detail: {hljs: HighlightJS}) {
-    for (const cb of this._getEventCallbacks(EventType.HIGHLIGHTJS_LOADED)) {
-      try {
-        cb(detail.hljs);
-      } catch (err: unknown) {
-        this.reporting.error(
-          'GrJsApiInterface',
-          new Error('HighlightjsLoaded callback error'),
-          err
-        );
-      }
-    }
-  }
-
   modifyRevertMsg(change: ChangeInfo, revertMsg: string, origMsg: string) {
     for (const cb of this._getEventCallbacks(EventType.REVERT)) {
       try {
@@ -280,60 +210,6 @@
     return revertSubmissionMsg;
   }
 
-  getDiffLayers(path: string) {
-    const layers: DiffLayer[] = [];
-    for (const cb of this._getEventCallbacks(EventType.ANNOTATE_DIFF)) {
-      const annotationApi = cb as unknown as GrAnnotationActionsInterface;
-      try {
-        const layer = annotationApi.createLayer(path);
-        if (layer) layers.push(layer);
-      } catch (err: unknown) {
-        this.reporting.error(
-          'GrJsApiInterface',
-          new Error('getDiffLayers callback error'),
-          err
-        );
-      }
-    }
-    return layers;
-  }
-
-  disposeDiffLayers(path: string) {
-    for (const cb of this._getEventCallbacks(EventType.ANNOTATE_DIFF)) {
-      try {
-        const annotationApi = cb as unknown as GrAnnotationActionsInterface;
-        annotationApi.disposeLayer(path);
-      } catch (err: unknown) {
-        this.reporting.error(
-          'GrJsApiInterface',
-          new Error('disposeDiffLayers callback error'),
-          err
-        );
-      }
-    }
-  }
-
-  /**
-   * Retrieves coverage data possibly provided by a plugin.
-   *
-   * Will wait for plugins to be loaded. If multiple plugins offer a coverage
-   * provider, the first one is returned. If no plugin offers a coverage provider,
-   * will resolve to null.
-   */
-  getCoverageAnnotationApis(): Promise<GrAnnotationActionsInterface[]> {
-    return getPluginLoader()
-      .awaitPluginsLoaded()
-      .then(() => {
-        const providers: GrAnnotationActionsInterface[] = [];
-        this._getEventCallbacks(EventType.ANNOTATE_DIFF).forEach(cb => {
-          const annotationApi = cb as unknown as GrAnnotationActionsInterface;
-          const provider = annotationApi.getCoverageProvider();
-          if (provider) providers.push(annotationApi);
-        });
-        return providers;
-      });
-  }
-
   getAdminMenuLinks(): MenuLink[] {
     const links: MenuLink[] = [];
     for (const cb of this._getEventCallbacks(EventType.ADMIN_MENU_LINKS)) {
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.ts
index 240bc0b..2ec4f27 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.ts
@@ -5,4 +5,3 @@
  */
 import './gr-js-api-interface-element';
 import './gr-public-js-api';
-import './gr-gerrit';
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.js
deleted file mode 100644
index 4fc403d..0000000
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.js
+++ /dev/null
@@ -1,349 +0,0 @@
-/**
- * @license
- * Copyright 2016 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-import '../../../test/common-test-setup';
-import './gr-js-api-interface';
-import {GrPopupInterface} from '../../plugins/gr-popup-interface/gr-popup-interface';
-import {EventType} from '../../../api/plugin';
-import {PLUGIN_LOADING_TIMEOUT_MS} from './gr-api-utils';
-import {getPluginLoader} from './gr-plugin-loader';
-import {
-  stubRestApi,
-  stubBaseUrl,
-  waitEventLoop,
-} from '../../../test/test-utils';
-import {getAppContext} from '../../../services/app-context';
-import {assert} from '@open-wc/testing';
-
-suite('GrJsApiInterface tests', () => {
-  let element;
-  let plugin;
-  let errorStub;
-
-  let sendStub;
-  let clock;
-
-  const throwErrFn = function() {
-    throw Error('Unfortunately, this handler has stopped');
-  };
-
-  setup(() => {
-    clock = sinon.useFakeTimers();
-
-    stubRestApi('getAccount').returns(Promise.resolve({name: 'Judy Hopps'}));
-    sendStub = stubRestApi('send').returns(Promise.resolve({status: 200}));
-    element = getAppContext().jsApiService;
-    errorStub = sinon.stub(element.reporting, 'error');
-    window.Gerrit.install(p => { plugin = p; }, '0.1',
-        'http://test.com/plugins/testplugin/static/test.js');
-    getPluginLoader().loadPlugins([]);
-  });
-
-  teardown(() => {
-    clock.restore();
-    element._removeEventCallbacks();
-    plugin = null;
-  });
-
-  test('url', () => {
-    assert.equal(plugin.url(), 'http://test.com/plugins/testplugin/');
-    assert.equal(plugin.url('/static/test.js'),
-        'http://test.com/plugins/testplugin/static/test.js');
-  });
-
-  test('_send on failure rejects with response text', () => {
-    sendStub.returns(Promise.resolve(
-        {status: 400, text() { return Promise.resolve('text'); }}));
-    return plugin._send().catch(r => {
-      assert.equal(r.message, 'text');
-    });
-  });
-
-  test('_send on failure without text rejects with code', () => {
-    sendStub.returns(Promise.resolve(
-        {status: 400, text() { return Promise.resolve(null); }}));
-    return plugin._send().catch(r => {
-      assert.equal(r.message, '400');
-    });
-  });
-
-  test('history event', async () => {
-    let resolve;
-    const promise = new Promise(r => resolve = r);
-    plugin.on(EventType.HISTORY, throwErrFn);
-    plugin.on(EventType.HISTORY, resolve);
-    element.handleEvent(EventType.HISTORY, {path: '/path/to/awesomesauce'});
-    const path = await promise;
-    assert.equal(path, '/path/to/awesomesauce');
-    assert.isTrue(errorStub.calledOnce);
-  });
-
-  test('showchange event', async () => {
-    let resolve;
-    const promise = new Promise(r => resolve = r);
-    const testChange = {
-      _number: 42,
-      revisions: {def: {_number: 2}, abc: {_number: 1}},
-    };
-    const expectedChange = {mergeable: false, ...testChange};
-    plugin.on(EventType.SHOW_CHANGE, throwErrFn);
-    plugin.on(EventType.SHOW_CHANGE, (change, revision, info) => {
-      resolve({change, revision, info});
-    });
-    element.handleEvent(EventType.SHOW_CHANGE,
-        {change: testChange, patchNum: 1, info: {mergeable: false}});
-
-    const {change, revision, info} = await promise;
-    assert.deepEqual(change, expectedChange);
-    assert.deepEqual(revision, testChange.revisions.abc);
-    assert.deepEqual(info, {mergeable: false});
-    assert.isTrue(errorStub.calledOnce);
-  });
-
-  test('show-revision-actions event', async () => {
-    let resolve;
-    const promise = new Promise(r => resolve = r);
-    const testChange = {
-      _number: 42,
-      revisions: {def: {_number: 2}, abc: {_number: 1}},
-    };
-    plugin.on(EventType.SHOW_REVISION_ACTIONS, throwErrFn);
-    plugin.on(EventType.SHOW_REVISION_ACTIONS, (actions, change) => {
-      resolve({change, actions});
-    });
-    element.handleEvent(EventType.SHOW_REVISION_ACTIONS,
-        {change: testChange, revisionActions: {test: {}}});
-
-    const {change, actions} = await promise;
-    assert.deepEqual(change, testChange);
-    assert.deepEqual(actions, {test: {}});
-    assert.isTrue(errorStub.calledOnce);
-  });
-
-  test('handleEvent awaits plugins load', async () => {
-    const testChange = {
-      _number: 42,
-      revisions: {def: {_number: 2}, abc: {_number: 1}},
-    };
-    const spy = sinon.spy();
-    getPluginLoader().loadPlugins(['plugins/test.js']);
-    plugin.on(EventType.SHOW_CHANGE, spy);
-    element.handleEvent(EventType.SHOW_CHANGE,
-        {change: testChange, patchNum: 1});
-    assert.isFalse(spy.called);
-
-    // Timeout on loading plugins
-    clock.tick(PLUGIN_LOADING_TIMEOUT_MS * 2);
-
-    await waitEventLoop();
-    assert.isTrue(spy.called);
-  });
-
-  test('comment event', async () => {
-    let resolve;
-    const promise = new Promise(r => resolve = r);
-    const testCommentNode = {foo: 'bar'};
-    plugin.on(EventType.COMMENT, throwErrFn);
-    plugin.on(EventType.COMMENT, resolve);
-    element.handleEvent(EventType.COMMENT, {node: testCommentNode});
-
-    const commentNode = await promise;
-    assert.deepEqual(commentNode, testCommentNode);
-    assert.isTrue(errorStub.calledOnce);
-  });
-
-  test('revert event', () => {
-    function appendToRevertMsg(c, revertMsg, originalMsg) {
-      return revertMsg + '\n' + originalMsg.replace(/^/gm, '> ') + '\ninfo';
-    }
-
-    assert.equal(element.modifyRevertMsg(null, 'test', 'origTest'), 'test');
-    assert.equal(errorStub.callCount, 0);
-
-    plugin.on(EventType.REVERT, throwErrFn);
-    plugin.on(EventType.REVERT, appendToRevertMsg);
-    assert.equal(element.modifyRevertMsg(null, 'test', 'origTest'),
-        'test\n> origTest\ninfo');
-    assert.isTrue(errorStub.calledOnce);
-
-    plugin.on(EventType.REVERT, appendToRevertMsg);
-    assert.equal(element.modifyRevertMsg(null, 'test', 'origTest'),
-        'test\n> origTest\ninfo\n> origTest\ninfo');
-    assert.isTrue(errorStub.calledTwice);
-  });
-
-  test('postrevert event labels', () => {
-    function getLabels(c) {
-      return {'Code-Review': 1};
-    }
-
-    assert.deepEqual(element.getReviewPostRevert(null), {});
-    assert.equal(errorStub.callCount, 0);
-
-    plugin.on(EventType.POST_REVERT, throwErrFn);
-    plugin.on(EventType.POST_REVERT, getLabels);
-    assert.deepEqual(
-        element.getReviewPostRevert(null), {labels: {'Code-Review': 1}});
-    assert.isTrue(errorStub.calledOnce);
-  });
-
-  test('postrevert event review', () => {
-    function getReview(c) {
-      return {labels: {'Code-Review': 1}};
-    }
-
-    assert.deepEqual(element.getReviewPostRevert(null), {});
-    assert.equal(errorStub.callCount, 0);
-
-    plugin.on(EventType.POST_REVERT, throwErrFn);
-    plugin.on(EventType.POST_REVERT, getReview);
-    assert.deepEqual(
-        element.getReviewPostRevert(null), {labels: {'Code-Review': 1}});
-    assert.isTrue(errorStub.calledOnce);
-  });
-
-  test('commitmsgedit event', async () => {
-    let resolve;
-    const promise = new Promise(r => resolve = r);
-    const testMsg = 'Test CL commit message';
-    plugin.on(EventType.COMMIT_MSG_EDIT, throwErrFn);
-    plugin.on(EventType.COMMIT_MSG_EDIT, (change, msg) => {
-      resolve(msg);
-    });
-    element.handleCommitMessage(null, testMsg);
-
-    const msg = await promise;
-    assert.deepEqual(msg, testMsg);
-    assert.isTrue(errorStub.calledOnce);
-  });
-
-  test('labelchange event', async () => {
-    let resolve;
-    const promise = new Promise(r => resolve = r);
-    const testChange = {_number: 42};
-    plugin.on(EventType.LABEL_CHANGE, throwErrFn);
-    plugin.on(EventType.LABEL_CHANGE, resolve);
-    element.handleEvent(EventType.LABEL_CHANGE, {change: testChange});
-
-    const change = await promise;
-    assert.deepEqual(change, testChange);
-    assert.isTrue(errorStub.calledOnce);
-  });
-
-  test('submitchange', () => {
-    plugin.on(EventType.SUBMIT_CHANGE, throwErrFn);
-    plugin.on(EventType.SUBMIT_CHANGE, () => true);
-    assert.isTrue(element.canSubmitChange());
-    assert.isTrue(errorStub.calledOnce);
-    plugin.on(EventType.SUBMIT_CHANGE, () => false);
-    plugin.on(EventType.SUBMIT_CHANGE, () => true);
-    assert.isFalse(element.canSubmitChange());
-    assert.isTrue(errorStub.calledTwice);
-  });
-
-  test('highlightjs-loaded event', async () => {
-    let resolve;
-    const promise = new Promise(r => resolve = r);
-    const testHljs = {_number: 42};
-    plugin.on(EventType.HIGHLIGHTJS_LOADED, throwErrFn);
-    plugin.on(EventType.HIGHLIGHTJS_LOADED, resolve);
-    element.handleEvent(EventType.HIGHLIGHTJS_LOADED, {hljs: testHljs});
-
-    const hljs = await promise;
-    assert.deepEqual(hljs, testHljs);
-    assert.isTrue(errorStub.calledOnce);
-  });
-
-  test('getLoggedIn', () => {
-    // fake fetch for authCheck
-    sinon.stub(window, 'fetch').callsFake(() => Promise.resolve({status: 204}));
-    return plugin.restApi().getLoggedIn()
-        .then(loggedIn => {
-          assert.isTrue(loggedIn);
-        });
-  });
-
-  test('attributeHelper', () => {
-    assert.isOk(plugin.attributeHelper());
-  });
-
-  test('getAdminMenuLinks', () => {
-    const links = [{text: 'a', url: 'b'}, {text: 'c', url: 'd'}];
-    const getCallbacksStub = sinon.stub(element, '_getEventCallbacks')
-        .returns([
-          {getMenuLinks: () => [links[0]]},
-          {getMenuLinks: () => [links[1]]},
-        ]);
-    const result = element.getAdminMenuLinks();
-    assert.deepEqual(result, links);
-    assert.isTrue(getCallbacksStub.calledOnce);
-    assert.equal(getCallbacksStub.lastCall.args[0],
-        EventType.ADMIN_MENU_LINKS);
-  });
-
-  suite('test plugin with base url', () => {
-    let baseUrlPlugin;
-
-    setup(() => {
-      stubBaseUrl('/r');
-
-      window.Gerrit.install(p => { baseUrlPlugin = p; }, '0.1',
-          'http://test.com/r/plugins/baseurlplugin/static/test.js');
-    });
-
-    test('url', () => {
-      assert.notEqual(baseUrlPlugin.url(),
-          'http://test.com/plugins/baseurlplugin/');
-      assert.equal(baseUrlPlugin.url(),
-          'http://test.com/r/plugins/baseurlplugin/');
-      assert.equal(baseUrlPlugin.url('/static/test.js'),
-          'http://test.com/r/plugins/baseurlplugin/static/test.js');
-    });
-  });
-
-  suite('popup', () => {
-    test('popup(element) is deprecated', () => {
-      sinon.stub(console, 'error');
-      plugin.popup(document.createElement('div'));
-      assert.isTrue(console.error.calledOnce);
-    });
-
-    test('popup(moduleName) creates popup with component', () => {
-      const openStub = sinon.stub(GrPopupInterface.prototype, 'open').callsFake(
-          function() {
-            // Arrow function can't be used here, because we want to
-            // get properties from the instance of GrPopupInterface
-            // eslint-disable-next-line no-invalid-this
-            const grPopupInterface = this;
-            assert.equal(grPopupInterface.plugin, plugin);
-            assert.equal(grPopupInterface.moduleName, 'some-name');
-          });
-      plugin.popup('some-name');
-      assert.isTrue(openStub.calledOnce);
-    });
-  });
-
-  suite('screen', () => {
-    test('screenUrl()', () => {
-      stubBaseUrl('/base');
-      assert.equal(
-          plugin.screenUrl(),
-          `${location.origin}/base/x/testplugin`
-      );
-      assert.equal(
-          plugin.screenUrl('foo'),
-          `${location.origin}/base/x/testplugin/foo`
-      );
-    });
-
-    test('works', () => {
-      sinon.stub(plugin, 'registerCustomComponent');
-      plugin.screen('foo', 'some-module');
-      assert.isTrue(plugin.registerCustomComponent.calledWith(
-          'testplugin-screen-foo', 'some-module'));
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.ts
new file mode 100644
index 0000000..a21ddc3
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.ts
@@ -0,0 +1,401 @@
+/**
+ * @license
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-js-api-interface';
+import {GrPopupInterface} from '../../plugins/gr-popup-interface/gr-popup-interface';
+import {EventType} from '../../../api/plugin';
+import {PLUGIN_LOADING_TIMEOUT_MS} from './gr-api-utils';
+import {
+  stubRestApi,
+  stubBaseUrl,
+  waitEventLoop,
+  waitUntilCalled,
+  assertFails,
+} from '../../../test/test-utils';
+import {assert} from '@open-wc/testing';
+import {testResolver} from '../../../test/common-test-setup';
+import {PluginLoader, pluginLoaderToken} from './gr-plugin-loader';
+import {useFakeTimers, stub, SinonFakeTimers, SinonStub} from 'sinon';
+import {GrJsApiInterface} from './gr-js-api-interface-element';
+import {Plugin} from './gr-public-js-api';
+import {
+  ChangeInfo,
+  HttpMethod,
+  NumericChangeId,
+  PatchSetNum,
+  RevisionPatchSetNum,
+  Timestamp,
+} from '../../../api/rest-api';
+import {ParsedChangeInfo} from '../../../types/types';
+import {
+  createChange,
+  createParsedChange,
+  createRevision,
+} from '../../../test/test-data-generators';
+import {EventCallback} from './gr-js-api-types';
+
+suite('GrJsApiInterface tests', () => {
+  let element: GrJsApiInterface;
+  let plugin: Plugin;
+  let errorStub: SinonStub;
+  let pluginLoader: PluginLoader;
+
+  let sendStub: SinonStub;
+  let clock: SinonFakeTimers;
+
+  const throwErrFn = function () {
+    throw Error('Unfortunately, this handler has stopped');
+  };
+
+  setup(() => {
+    clock = useFakeTimers();
+
+    stubRestApi('getAccount').resolves({
+      name: 'Judy Hopps',
+      registered_on: '' as Timestamp,
+    });
+    sendStub = stubRestApi('send').resolves(
+      new Response(undefined, {status: 200})
+    );
+    pluginLoader = testResolver(pluginLoaderToken);
+
+    // We are using the jsApiService as the implementation class rather than the
+    // interface to better set up tests.
+    element = pluginLoader.jsApiService as GrJsApiInterface;
+    errorStub = stub(element.reporting, 'error');
+    pluginLoader.install(
+      p => {
+        // We are using the plugin API as the implementation class rather than
+        // the interface to better set up tests.
+        plugin = p as Plugin;
+      },
+      '0.1',
+      'http://test.com/plugins/testplugin/static/test.js'
+    );
+    testResolver(pluginLoaderToken).loadPlugins([]);
+  });
+
+  teardown(() => {
+    clock.restore();
+    element._removeEventCallbacks();
+  });
+
+  test('url', () => {
+    assert.equal(plugin.url(), 'http://test.com/plugins/testplugin/');
+    assert.equal(
+      plugin.url('/static/test.js'),
+      'http://test.com/plugins/testplugin/static/test.js'
+    );
+  });
+
+  test('_send on failure rejects with response text', async () => {
+    sendStub.resolves({
+      status: 400,
+      text() {
+        return Promise.resolve('text');
+      },
+    });
+    const error = await assertFails<Error>(plugin._send(HttpMethod.POST, ''));
+    assert.equal(error.message, 'text');
+  });
+
+  test('_send on failure without text rejects with code', async () => {
+    sendStub.resolves({
+      status: 400,
+      text() {
+        return Promise.resolve(null);
+      },
+    });
+    const error = await assertFails<Error>(plugin._send(HttpMethod.POST, ''));
+    assert.equal(error.message, '400');
+  });
+
+  test('showchange event', async () => {
+    const showChangeStub = stub();
+    const testChange: ParsedChangeInfo = {
+      ...createParsedChange(),
+      _number: 42 as NumericChangeId,
+      revisions: {
+        def: {...createRevision(), _number: 2 as RevisionPatchSetNum},
+        abc: {...createRevision(), _number: 1 as RevisionPatchSetNum},
+      },
+    };
+    const expectedChange = {mergeable: false, ...testChange};
+
+    plugin.on(EventType.SHOW_CHANGE, throwErrFn);
+    plugin.on(EventType.SHOW_CHANGE, showChangeStub);
+    element.handleShowChange({
+      change: testChange,
+      patchNum: 1 as PatchSetNum,
+      info: {mergeable: false},
+    });
+    await waitUntilCalled(showChangeStub, 'showChangeStub');
+
+    const [change, revision, info] = showChangeStub.firstCall.args;
+    assert.deepEqual(change, expectedChange);
+    assert.deepEqual(revision, testChange.revisions.abc);
+    assert.deepEqual(info, {mergeable: false});
+    assert.isTrue(errorStub.calledOnce);
+  });
+
+  test('show-revision-actions event', async () => {
+    const showRevisionActionsStub = stub();
+    const testChange: ChangeInfo = {
+      ...createChange(),
+      _number: 42 as NumericChangeId,
+      revisions: {
+        def: {...createRevision(), _number: 2 as RevisionPatchSetNum},
+        abc: {...createRevision(), _number: 1 as RevisionPatchSetNum},
+      },
+    };
+
+    plugin.on(EventType.SHOW_REVISION_ACTIONS, throwErrFn);
+    plugin.on(EventType.SHOW_REVISION_ACTIONS, showRevisionActionsStub);
+    element.handleShowRevisionActions({
+      change: testChange,
+      revisionActions: {test: {}},
+    });
+    await waitUntilCalled(showRevisionActionsStub, 'showRevisionActionsStub');
+
+    const [actions, change] = showRevisionActionsStub.firstCall.args;
+    assert.deepEqual(change, testChange);
+    assert.deepEqual(actions, {test: {}});
+    assert.isTrue(errorStub.calledOnce);
+  });
+
+  test('handleShowChange awaits plugins load', async () => {
+    const testChange: ParsedChangeInfo = {
+      ...createParsedChange(),
+      _number: 42 as NumericChangeId,
+      revisions: {
+        def: {...createRevision(), _number: 2 as RevisionPatchSetNum},
+        abc: {...createRevision(), _number: 1 as RevisionPatchSetNum},
+      },
+    };
+    const showChangeStub = stub();
+    testResolver(pluginLoaderToken).loadPlugins(['plugins/test.js']);
+    plugin.on(EventType.SHOW_CHANGE, showChangeStub);
+    element.handleShowChange({
+      change: testChange,
+      patchNum: 1 as PatchSetNum,
+      info: {mergeable: null},
+    });
+    assert.isFalse(showChangeStub.called);
+
+    // Timeout on loading plugins
+    clock.tick(PLUGIN_LOADING_TIMEOUT_MS * 2);
+
+    await waitEventLoop();
+    assert.isTrue(showChangeStub.called);
+  });
+
+  test('revert event', () => {
+    function appendToRevertMsg(
+      _c: unknown,
+      revertMsg: string,
+      originalMsg: string
+    ) {
+      return revertMsg + '\n' + originalMsg.replace(/^/gm, '> ') + '\ninfo';
+    }
+    const change = createChange();
+
+    assert.equal(element.modifyRevertMsg(change, 'test', 'origTest'), 'test');
+    assert.equal(errorStub.callCount, 0);
+
+    plugin.on(EventType.REVERT, throwErrFn);
+    plugin.on(EventType.REVERT, appendToRevertMsg);
+    assert.equal(
+      element.modifyRevertMsg(change, 'test', 'origTest'),
+      'test\n> origTest\ninfo'
+    );
+    assert.isTrue(errorStub.calledOnce);
+
+    plugin.on(EventType.REVERT, appendToRevertMsg);
+    assert.equal(
+      element.modifyRevertMsg(change, 'test', 'origTest'),
+      'test\n> origTest\ninfo\n> origTest\ninfo'
+    );
+    assert.isTrue(errorStub.calledTwice);
+  });
+
+  test('postrevert event labels', () => {
+    function getLabels(_c: unknown) {
+      return {'Code-Review': 1};
+    }
+
+    assert.deepEqual(element.getReviewPostRevert(undefined), {});
+    assert.equal(errorStub.callCount, 0);
+
+    plugin.on(EventType.POST_REVERT, throwErrFn);
+    plugin.on(EventType.POST_REVERT, getLabels);
+    assert.deepEqual(element.getReviewPostRevert(undefined), {
+      labels: {'Code-Review': 1},
+    });
+    assert.isTrue(errorStub.calledOnce);
+  });
+
+  test('postrevert event review', () => {
+    function getReview(_c: unknown) {
+      return {labels: {'Code-Review': 1}};
+    }
+
+    assert.deepEqual(element.getReviewPostRevert(undefined), {});
+    assert.equal(errorStub.callCount, 0);
+
+    plugin.on(EventType.POST_REVERT, throwErrFn);
+    plugin.on(EventType.POST_REVERT, getReview);
+    assert.deepEqual(element.getReviewPostRevert(undefined), {
+      labels: {'Code-Review': 1},
+    });
+    assert.isTrue(errorStub.calledOnce);
+  });
+
+  test('commitmsgedit event', async () => {
+    const commitMsgEditStub = stub();
+    const testMsg = 'Test CL commit message';
+
+    plugin.on(EventType.COMMIT_MSG_EDIT, throwErrFn);
+    plugin.on(EventType.COMMIT_MSG_EDIT, commitMsgEditStub);
+    element.handleCommitMessage(createChange(), testMsg);
+    await waitUntilCalled(commitMsgEditStub, 'commitMsgEditStub');
+
+    const msg = commitMsgEditStub.firstCall.args[1];
+    assert.deepEqual(msg, testMsg);
+    assert.isTrue(errorStub.calledOnce);
+  });
+
+  test('labelchange event', async () => {
+    const labelChangeStub = stub();
+    const testChange: ParsedChangeInfo = {
+      ...createParsedChange(),
+      _number: 42 as NumericChangeId,
+    };
+
+    plugin.on(EventType.LABEL_CHANGE, throwErrFn);
+    plugin.on(EventType.LABEL_CHANGE, labelChangeStub);
+    element.handleLabelChange({change: testChange});
+    await waitUntilCalled(labelChangeStub, 'labelChangeStub');
+
+    const [change] = labelChangeStub.firstCall.args;
+    assert.deepEqual(change, testChange);
+    assert.isTrue(errorStub.calledOnce);
+  });
+
+  test('submitchange', () => {
+    plugin.on(EventType.SUBMIT_CHANGE, throwErrFn);
+    plugin.on(EventType.SUBMIT_CHANGE, () => true);
+    assert.isTrue(element.canSubmitChange(createChange()));
+    assert.isTrue(errorStub.calledOnce);
+    plugin.on(EventType.SUBMIT_CHANGE, () => false);
+    plugin.on(EventType.SUBMIT_CHANGE, () => true);
+    assert.isFalse(element.canSubmitChange(createChange()));
+    assert.isTrue(errorStub.calledTwice);
+  });
+
+  test('getLoggedIn', async () => {
+    // fake fetch for authCheck
+    stub(window, 'fetch').resolves(new Response(undefined, {status: 204}));
+    const loggedIn = await plugin.restApi().getLoggedIn();
+    assert.isTrue(loggedIn);
+  });
+
+  test('attributeHelper', () => {
+    assert.isOk(plugin.attributeHelper(document.createElement('div')));
+  });
+
+  test('getAdminMenuLinks', () => {
+    const links = [
+      {text: 'a', url: 'b'},
+      {text: 'c', url: 'd'},
+    ];
+    // getAdminMenuLinks expects _getEventCallbacks to really return
+    // GrAdminApi[] even though _getEventCallbacks has return type
+    // EventCallback[]. Therefore this test must also return GrAdminApi[]
+    // disguised as EventCallback[].
+    const getCallbacksStub = stub(element, '_getEventCallbacks').returns([
+      {getMenuLinks: () => [links[0]]},
+      {getMenuLinks: () => [links[1]]},
+    ] as unknown as EventCallback[]);
+    const result = element.getAdminMenuLinks();
+    assert.deepEqual(result, links);
+    assert.isTrue(getCallbacksStub.calledOnce);
+    assert.equal(getCallbacksStub.lastCall.args[0], EventType.ADMIN_MENU_LINKS);
+  });
+
+  suite('test plugin with base url', () => {
+    let baseUrlPlugin: Plugin;
+
+    setup(() => {
+      stubBaseUrl('/r');
+
+      pluginLoader.install(
+        p => {
+          // We are using the plugin API as the implementation class rather than
+          // the interface to better set up tests.
+          baseUrlPlugin = p as Plugin;
+        },
+        '0.1',
+        'http://test.com/r/plugins/baseurlplugin/static/test.js'
+      );
+    });
+
+    test('url', () => {
+      assert.notEqual(
+        baseUrlPlugin.url(),
+        'http://test.com/plugins/baseurlplugin/'
+      );
+      assert.equal(
+        baseUrlPlugin.url(),
+        'http://test.com/r/plugins/baseurlplugin/'
+      );
+      assert.equal(
+        baseUrlPlugin.url('/static/test.js'),
+        'http://test.com/r/plugins/baseurlplugin/static/test.js'
+      );
+    });
+  });
+
+  suite('popup', () => {
+    test('popup(moduleName) creates popup with component', () => {
+      const openStub = stub(GrPopupInterface.prototype, 'open').callsFake(
+        async function (this: GrPopupInterface) {
+          // Arrow function can't be used here, because we want to
+          // get properties from the instance of GrPopupInterface
+          assert.equal(this.plugin, plugin);
+          assert.equal(this.moduleName, 'some-name');
+          return this;
+        }
+      );
+      plugin.popup('some-name');
+      assert.isTrue(openStub.calledOnce);
+    });
+  });
+
+  suite('screen', () => {
+    test('screenUrl()', () => {
+      stubBaseUrl('/base');
+      assert.equal(plugin.screenUrl(), `${location.origin}/base/x/testplugin`);
+      assert.equal(
+        plugin.screenUrl('foo'),
+        `${location.origin}/base/x/testplugin/foo`
+      );
+    });
+
+    test('works', () => {
+      const registerCustomComponentStub = stub(
+        plugin,
+        'registerCustomComponent'
+      );
+      plugin.screen('foo', 'some-module');
+      assert.isTrue(
+        registerCustomComponentStub.calledWith(
+          'testplugin-screen-foo',
+          'some-module'
+        )
+      );
+    });
+  });
+});
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-types.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-types.ts
index 7aad2f0..8e3a87d 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-types.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-types.ts
@@ -6,25 +6,26 @@
 import {
   ActionInfo,
   ChangeInfo,
+  BasePatchSetNum,
   PatchSetNum,
   ReviewInput,
   RevisionInfo,
 } from '../../../types/common';
 import {Finalizable} from '../../../services/registry';
 import {EventType, TargetElement} from '../../../api/plugin';
-import {DiffLayer, ParsedChangeInfo} from '../../../types/types';
-import {GrAnnotationActionsInterface} from './gr-annotation-actions-js-api';
+import {ParsedChangeInfo} from '../../../types/types';
 import {MenuLink} from '../../../api/admin';
 
 export interface ShowChangeDetail {
-  change: ChangeInfo;
-  patchNum: PatchSetNum;
-  info: {mergeable: boolean};
+  change?: ParsedChangeInfo;
+  basePatchNum?: BasePatchSetNum;
+  patchNum?: PatchSetNum;
+  info: {mergeable: boolean | null};
 }
 
 export interface ShowRevisionActionsDetail {
   change: ChangeInfo;
-  revisionActions: {[key: string]: ActionInfo};
+  revisionActions: {[key: string]: ActionInfo | undefined};
 }
 
 // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -38,17 +39,15 @@
     revertSubmissionMsg: string,
     origMsg: string
   ): string;
-  // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  handleEvent(eventName: EventType, detail: any): void;
+  handleShowChange(detail: ShowChangeDetail): void;
+  handleShowRevisionActions(detail: ShowRevisionActionsDetail): void;
+  handleLabelChange(detail: {change?: ParsedChangeInfo}): void;
   modifyRevertMsg(
     change: ChangeInfo,
     revertMsg: string,
     origMsg: string
   ): string;
   addElement(key: TargetElement, el: HTMLElement): void;
-  getDiffLayers(path: string): DiffLayer[];
-  disposeDiffLayers(path: string): void;
-  getCoverageAnnotationApis(): Promise<GrAnnotationActionsInterface[]>;
   getAdminMenuLinks(): MenuLink[];
   handleCommitMessage(change: ChangeInfo | ParsedChangeInfo, msg: string): void;
   canSubmitChange(change: ChangeInfo, revision?: RevisionInfo | null): boolean;
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context.ts
index 0628d2f..c81f586 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context.ts
@@ -4,13 +4,13 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import {RevisionInfo, ChangeInfo, RequestPayload} from '../../../types/common';
-import {EventType, ShowAlertEventDetail} from '../../../types/events';
 import {PluginApi} from '../../../api/plugin';
 import {UIActionInfo} from './gr-change-actions-js-api';
 import {windowLocationReload} from '../../../utils/dom-util';
 import {PopupPluginApi} from '../../../api/popup';
 import {GrPopupInterface} from '../../plugins/gr-popup-interface/gr-popup-interface';
 import {getAppContext} from '../../../services/app-context';
+import {fireAlert} from '../../../utils/event-util';
 
 interface ButtonCallBacks {
   onclick: (event: Event) => boolean;
@@ -110,13 +110,7 @@
       .send(this.action.method, this.action.__url, payload)
       .then(onSuccess)
       .catch((error: unknown) => {
-        document.dispatchEvent(
-          new CustomEvent<ShowAlertEventDetail>(EventType.SHOW_ALERT, {
-            detail: {
-              message: `Plugin network error: ${error}`,
-            },
-          })
-        );
+        fireAlert(document, `Plugin network error: ${error}`);
       });
   }
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context_test.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context_test.js
deleted file mode 100644
index a401351..0000000
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context_test.js
+++ /dev/null
@@ -1,136 +0,0 @@
-/**
- * @license
- * Copyright 2017 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-import '../../../test/common-test-setup';
-import './gr-js-api-interface';
-import {GrPluginActionContext} from './gr-plugin-action-context';
-import {addListenerForTest, waitEventLoop} from '../../../test/test-utils';
-import {EventType} from '../../../types/events';
-import {assert} from '@open-wc/testing';
-
-suite('gr-plugin-action-context tests', () => {
-  let instance;
-
-  let plugin;
-
-  setup(() => {
-    window.Gerrit.install(p => { plugin = p; }, '0.1',
-        'http://test.com/plugins/testplugin/static/test.js');
-    instance = new GrPluginActionContext(plugin);
-  });
-
-  test('popup() and hide()', async () => {
-    const popupApiStub = {
-      _getElement: sinon.stub().returns(document.createElement('div')),
-      close: sinon.stub(),
-    };
-    sinon.stub(plugin, 'popup').returns(Promise.resolve(popupApiStub));
-    const el = document.createElement('span');
-    instance.popup(el);
-    await waitEventLoop();
-    assert.isTrue(popupApiStub._getElement.called);
-    instance.hide();
-    assert.isTrue(popupApiStub.close.called);
-  });
-
-  test('textfield', () => {
-    assert.equal(instance.textfield().tagName, 'PAPER-INPUT');
-  });
-
-  test('br', () => {
-    assert.equal(instance.br().tagName, 'BR');
-  });
-
-  test('msg', () => {
-    const el = instance.msg('foobar');
-    assert.equal(el.tagName, 'GR-LABEL');
-    assert.equal(el.textContent, 'foobar');
-  });
-
-  test('div', () => {
-    const el1 = document.createElement('span');
-    el1.textContent = 'foo';
-    const el2 = document.createElement('div');
-    el2.textContent = 'bar';
-    const div = instance.div(el1, el2);
-    assert.equal(div.tagName, 'DIV');
-    assert.equal(div.textContent, 'foobar');
-  });
-
-  suite('button', () => {
-    let clickStub;
-    let button;
-    setup(() => {
-      clickStub = sinon.stub();
-      button = instance.button('foo', {onclick: clickStub});
-      // If you don't attach a Polymer element to the DOM, then the ready()
-      // callback will not be called and then e.g. this.$ is undefined.
-      document.body.appendChild(button);
-    });
-
-    test('click', async () => {
-      button.click();
-      await waitEventLoop();
-      assert.isTrue(clickStub.called);
-      assert.equal(button.textContent, 'foo');
-    });
-
-    teardown(() => {
-      button.remove();
-    });
-  });
-
-  test('checkbox', () => {
-    const el = instance.checkbox();
-    assert.equal(el.tagName, 'INPUT');
-    assert.equal(el.type, 'checkbox');
-  });
-
-  test('label', () => {
-    const fakeMsg = {};
-    const fakeCheckbox = {};
-    sinon.stub(instance, 'div');
-    sinon.stub(instance, 'msg').returns(fakeMsg);
-    instance.label(fakeCheckbox, 'foo');
-    assert.isTrue(instance.div.calledWithExactly(fakeCheckbox, fakeMsg));
-  });
-
-  test('call', () => {
-    instance.action = {
-      method: 'METHOD',
-      __key: 'key',
-      __url: '/changes/1/revisions/2/foo~bar',
-    };
-    const sendStub = sinon.stub().returns(Promise.resolve());
-    sinon.stub(plugin, 'restApi').returns({
-      send: sendStub,
-    });
-    const payload = {foo: 'foo'};
-    const successStub = sinon.stub();
-    instance.call(payload, successStub);
-    assert.isTrue(sendStub.calledWith(
-        'METHOD', '/changes/1/revisions/2/foo~bar', payload));
-  });
-
-  test('call error', async () => {
-    instance.action = {
-      method: 'METHOD',
-      __key: 'key',
-      __url: '/changes/1/revisions/2/foo~bar',
-    };
-    const sendStub = sinon.stub().returns(Promise.reject(new Error('boom')));
-    sinon.stub(plugin, 'restApi').returns({
-      send: sendStub,
-    });
-    const errorStub = sinon.stub();
-    addListenerForTest(document, EventType.SHOW_ALERT, errorStub);
-    instance.call();
-    await waitEventLoop();
-    assert.isTrue(errorStub.calledOnce);
-    assert.equal(errorStub.args[0][0].detail.message,
-        'Plugin network error: Error: boom');
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context_test.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context_test.ts
new file mode 100644
index 0000000..76a6573
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context_test.ts
@@ -0,0 +1,160 @@
+/**
+ * @license
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-js-api-interface';
+import {GrPluginActionContext} from './gr-plugin-action-context';
+import {addListenerForTest, waitEventLoop} from '../../../test/test-utils';
+import {assert} from '@open-wc/testing';
+import {PluginApi} from '../../../api/plugin';
+import {SinonStub, stub, spy} from 'sinon';
+import {PopupPluginApi} from '../../../api/popup';
+import {GrButton} from '../gr-button/gr-button';
+import {createChange, createRevision} from '../../../test/test-data-generators';
+import {ActionType} from '../../../api/change-actions';
+import {HttpMethod} from '../../../api/rest-api';
+import {RestPluginApi} from '../../../api/rest';
+
+suite('gr-plugin-action-context tests', () => {
+  let instance: GrPluginActionContext;
+
+  let plugin: PluginApi;
+
+  setup(() => {
+    window.Gerrit.install(
+      p => {
+        plugin = p;
+      },
+      '0.1',
+      'http://test.com/plugins/testplugin/static/test.js'
+    );
+    instance = new GrPluginActionContext(
+      plugin,
+      {
+        label: 'MyAction',
+        method: HttpMethod.POST,
+        __key: 'key',
+        __url: '/changes/1/revisions/2/foo~bar',
+        __type: ActionType.REVISION,
+      },
+      createChange(),
+      createRevision()
+    );
+  });
+
+  test('popup() and hide()', async () => {
+    const popupApiStub = {
+      _getElement: stub().returns(document.createElement('div')),
+      close: stub(),
+    } as PopupPluginApi & {_getElement: SinonStub; close: SinonStub};
+    stub(plugin, 'popup').resolves(popupApiStub);
+    const el = document.createElement('span');
+    instance.popup(el);
+    await waitEventLoop();
+    assert.isTrue(popupApiStub._getElement.called);
+    instance.hide();
+    assert.isTrue(popupApiStub.close.called);
+  });
+
+  test('textfield', () => {
+    assert.equal(instance.textfield().tagName, 'PAPER-INPUT');
+  });
+
+  test('br', () => {
+    assert.equal(instance.br().tagName, 'BR');
+  });
+
+  test('msg', () => {
+    const el = instance.msg('foobar');
+    assert.equal(el.tagName, 'GR-LABEL');
+    assert.equal(el.textContent, 'foobar');
+  });
+
+  test('div', () => {
+    const el1 = document.createElement('span');
+    el1.textContent = 'foo';
+    const el2 = document.createElement('div');
+    el2.textContent = 'bar';
+    const div = instance.div(el1, el2);
+    assert.equal(div.tagName, 'DIV');
+    assert.equal(div.textContent, 'foobar');
+  });
+
+  suite('button', () => {
+    let clickStub: SinonStub;
+    let button: GrButton;
+    setup(() => {
+      clickStub = stub();
+      button = instance.button('foo', {onclick: clickStub});
+      // If you don't attach a Polymer element to the DOM, then the ready()
+      // callback will not be called and then e.g. this.$ is undefined.
+      document.body.appendChild(button);
+    });
+
+    test('click', async () => {
+      button.click();
+      await waitEventLoop();
+      assert.isTrue(clickStub.called);
+      assert.equal(button.textContent, 'foo');
+    });
+
+    teardown(() => {
+      button.remove();
+    });
+  });
+
+  test('checkbox', () => {
+    const el = instance.checkbox();
+    assert.equal(el.tagName, 'INPUT');
+    assert.equal(el.type, 'checkbox');
+  });
+
+  test('label', () => {
+    const divSpy = spy(instance, 'div');
+    const fakeMsg = document.createElement('gr-label');
+    const fakeCheckbox = document.createElement('input');
+    stub(instance, 'msg').returns(fakeMsg);
+
+    instance.label(fakeCheckbox, 'foo');
+
+    assert.isTrue(divSpy.calledWithExactly(fakeCheckbox, fakeMsg));
+  });
+
+  test('call', () => {
+    const fakeRestApi = {
+      send: stub().resolves(),
+    } as RestPluginApi & {send: SinonStub};
+    stub(plugin, 'restApi').returns(fakeRestApi);
+
+    const payload = {foo: 'foo'};
+    instance.call(payload, () => {});
+
+    assert.isTrue(
+      fakeRestApi.send.calledWith(
+        HttpMethod.POST,
+        '/changes/1/revisions/2/foo~bar',
+        payload
+      )
+    );
+  });
+
+  test('call error', async () => {
+    const fakeRestApi = {
+      send: () => Promise.reject(new Error('boom')),
+    } as unknown as RestPluginApi;
+    stub(plugin, 'restApi').returns(fakeRestApi);
+    const errorStub = stub();
+    addListenerForTest(document, 'show-alert', errorStub);
+
+    instance.call({}, () => {});
+    await waitEventLoop();
+
+    assert.isTrue(errorStub.calledOnce);
+    assert.equal(
+      errorStub.args[0][0].detail.message,
+      'Plugin network error: Error: boom'
+    );
+  });
+});
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints.ts
index 9ced917..b1b66ad 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints.ts
@@ -4,7 +4,6 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import {PluginApi} from '../../../api/plugin';
-import {notUndefined} from '../../../types/types';
 import {HookApi, PluginElement} from '../../../api/hook';
 
 // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -14,16 +13,29 @@
   moduleName: string;
   plugin: PluginApi;
   pluginUrl?: URL;
-  type?: string;
+  type?: EndpointType;
   domHook?: HookApi<PluginElement>;
   slot?: string;
 }
 
+/**
+ * Plugin-provided custom components can affect content in extension
+ * points using one of following methods:
+ * - DECORATE: custom component is set with `content` attribute and may
+ *   decorate (e.g. style) DOM element.
+ * - REPLACE: contents of extension point are replaced with the custom
+ *   component.
+ */
+export enum EndpointType {
+  DECORATE = 'decorate',
+  REPLACE = 'replace',
+}
+
 interface Options {
   endpoint: string;
   dynamicEndpoint?: string;
   slot?: string;
-  type?: string;
+  type?: EndpointType;
   moduleName?: string;
   domHook?: HookApi<PluginElement>;
 }
@@ -125,53 +137,7 @@
    * Get detailed information about modules registered with an extension
    * endpoint.
    */
-  getDetails(name: string, options?: Options): ModuleInfo[] {
-    const type = options && options.type;
-    const moduleName = options && options.moduleName;
-    if (!this._endpoints.has(name)) {
-      return [];
-    } else {
-      return this._endpoints
-        .get(name)!
-        .filter(
-          (item: ModuleInfo) =>
-            (!type || item.type === type) &&
-            (!moduleName || moduleName === item.moduleName)
-        );
-    }
+  getDetails(name: string): ModuleInfo[] {
+    return this._endpoints.get(name) ?? [];
   }
-
-  /**
-   * Get detailed module names for instantiating at the endpoint.
-   */
-  getModules(name: string, options?: Options): string[] {
-    const modulesData = this.getDetails(name, options);
-    if (!modulesData.length) {
-      return [];
-    }
-    return modulesData.map(m => m.moduleName);
-  }
-
-  /**
-   * Get plugin URLs with element and module definitions.
-   */
-  getPlugins(name: string, options?: Options): URL[] {
-    const modulesData = this.getDetails(name, options);
-    if (!modulesData.length) {
-      return [];
-    }
-    return Array.from(new Set(modulesData.map(m => m.pluginUrl))).filter(
-      notUndefined
-    );
-  }
-}
-
-let pluginEndpoints = new GrPluginEndpoints();
-
-// To avoid mutable-exports, we don't want to export above variable directly
-export function getPluginEndpoints() {
-  return pluginEndpoints;
-}
-export function _testOnly_resetEndpoints() {
-  pluginEndpoints = new GrPluginEndpoints();
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints_test.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints_test.ts
index 15e19e6..ddba546 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints_test.ts
@@ -4,9 +4,8 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import '../../../test/common-test-setup';
-import {resetPlugins} from '../../../test/test-utils';
 import './gr-js-api-interface';
-import {GrPluginEndpoints} from './gr-plugin-endpoints';
+import {EndpointType, GrPluginEndpoints} from './gr-plugin-endpoints';
 import {PluginApi} from '../../../api/plugin';
 import {HookApi, HookCallback, PluginElement} from '../../../api/hook';
 import {assert} from '@open-wc/testing';
@@ -40,7 +39,7 @@
 suite('gr-plugin-endpoints tests', () => {
   let instance: GrPluginEndpoints;
   let decoratePlugin: PluginApi;
-  let stylePlugin: PluginApi;
+  let replacePlugin: PluginApi;
   let domHook: HookApi<PluginElement>;
 
   setup(() => {
@@ -53,102 +52,51 @@
     );
     instance.registerModule(decoratePlugin, {
       endpoint: 'my-endpoint',
-      type: 'decorate',
+      type: EndpointType.DECORATE,
       moduleName: 'decorate-module',
       domHook,
     });
     window.Gerrit.install(
-      plugin => (stylePlugin = plugin),
+      plugin => (replacePlugin = plugin),
       '0.1',
-      'http://test.com/plugins/testplugin/static/style.js'
+      'http://test.com/plugins/testplugin/static/replace.js'
     );
-    instance.registerModule(stylePlugin, {
+    instance.registerModule(replacePlugin, {
       endpoint: 'my-endpoint',
-      type: 'style',
-      moduleName: 'style-module',
+      type: EndpointType.REPLACE,
+      moduleName: 'replace-module',
       domHook,
     });
   });
 
-  teardown(() => {
-    resetPlugins();
-  });
-
   test('getDetails all', () => {
     assert.deepEqual(instance.getDetails('my-endpoint'), [
       {
         moduleName: 'decorate-module',
         plugin: decoratePlugin,
         pluginUrl: decoratePlugin._url,
-        type: 'decorate',
+        type: EndpointType.DECORATE,
         domHook,
         slot: undefined,
       },
       {
-        moduleName: 'style-module',
-        plugin: stylePlugin,
-        pluginUrl: stylePlugin._url,
-        type: 'style',
+        moduleName: 'replace-module',
+        plugin: replacePlugin,
+        pluginUrl: replacePlugin._url,
+        type: EndpointType.REPLACE,
         domHook,
         slot: undefined,
       },
     ]);
   });
 
-  test('getDetails by type', () => {
-    assert.deepEqual(
-      instance.getDetails('my-endpoint', {endpoint: 'a-place', type: 'style'}),
-      [
-        {
-          moduleName: 'style-module',
-          plugin: stylePlugin,
-          pluginUrl: stylePlugin._url,
-          type: 'style',
-          domHook,
-          slot: undefined,
-        },
-      ]
-    );
-  });
-
-  test('getDetails by module', () => {
-    assert.deepEqual(
-      instance.getDetails('my-endpoint', {
-        endpoint: 'my-endpoint',
-        moduleName: 'decorate-module',
-      }),
-      [
-        {
-          moduleName: 'decorate-module',
-          plugin: decoratePlugin,
-          pluginUrl: decoratePlugin._url,
-          type: 'decorate',
-          domHook,
-          slot: undefined,
-        },
-      ]
-    );
-  });
-
-  test('getModules', () => {
-    assert.deepEqual(instance.getModules('my-endpoint'), [
-      'decorate-module',
-      'style-module',
-    ]);
-  });
-
-  test('getPlugins URLs are unique', () => {
-    assert.equal(decoratePlugin._url, stylePlugin._url);
-    assert.deepEqual(instance.getPlugins('my-endpoint'), [decoratePlugin._url]);
-  });
-
   test('onNewEndpoint', () => {
     const newModuleStub = sinon.stub();
     instance.setPluginsReady();
     instance.onNewEndpoint('my-endpoint', newModuleStub);
     instance.registerModule(decoratePlugin, {
       endpoint: 'my-endpoint',
-      type: 'replace',
+      type: EndpointType.REPLACE,
       moduleName: 'replace-module',
       domHook,
     });
@@ -156,7 +104,7 @@
       moduleName: 'replace-module',
       plugin: decoratePlugin,
       pluginUrl: decoratePlugin._url,
-      type: 'replace',
+      type: EndpointType.REPLACE,
       domHook,
       slot: undefined,
     });
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader.ts
index 4a90314..a58c6cc 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader.ts
@@ -3,7 +3,6 @@
  * Copyright 2019 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-import {getAppContext} from '../../../services/app-context';
 import {
   PLUGIN_LOADING_TIMEOUT_MS,
   getPluginNameFromUrl,
@@ -12,10 +11,25 @@
 } from './gr-api-utils';
 import {Plugin} from './gr-public-js-api';
 import {getBaseUrl} from '../../../utils/url-util';
-import {getPluginEndpoints} from './gr-plugin-endpoints';
+import {GrPluginEndpoints} from './gr-plugin-endpoints';
 import {PluginApi} from '../../../api/plugin';
 import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
 import {fireAlert} from '../../../utils/event-util';
+import {JsApiService} from './gr-js-api-types';
+import {RestApiService} from '../../../services/gr-rest-api/gr-rest-api';
+import {Finalizable} from '../../../services/registry';
+import {PluginsModel} from '../../../models/plugins/plugins-model';
+import {Gerrit} from '../../../api/gerrit';
+import {fontStyles} from '../../../styles/gr-font-styles';
+import {formStyles} from '../../../styles/gr-form-styles';
+import {menuPageStyles} from '../../../styles/gr-menu-page-styles';
+import {spinnerStyles} from '../../../styles/gr-spinner-styles';
+import {subpageStyles} from '../../../styles/gr-subpage-styles';
+import {tableStyles} from '../../../styles/gr-table-styles';
+import {iconStyles} from '../../../styles/gr-icon-styles';
+import {GrJsApiInterface} from './gr-js-api-interface-element';
+import {define} from '../../../models/dependency';
+import {modalStyles} from '../../../styles/gr-modal-styles';
 
 enum PluginState {
   /** State that indicates the plugin is pending to be loaded. */
@@ -55,6 +69,8 @@
 // plugins with incompatible version will not be loaded.
 const API_VERSION = '0.1';
 
+export const pluginLoaderToken = define<PluginLoader>('plugin-loader');
+
 /**
  * PluginLoader, responsible for:
  *
@@ -64,32 +80,54 @@
  * Retrieve plugin.
  * Check plugin status and if all plugins loaded.
  */
-export class PluginLoader {
-  _pluginListLoaded = false;
+export class PluginLoader implements Gerrit, Finalizable {
+  public readonly styles = {
+    font: fontStyles,
+    form: formStyles,
+    icon: iconStyles,
+    menuPage: menuPageStyles,
+    spinner: spinnerStyles,
+    subPage: subpageStyles,
+    table: tableStyles,
+    modal: modalStyles,
+  };
 
-  _plugins = new Map<string, PluginObject>();
+  private pluginListLoaded = false;
 
-  _reporting: ReportingService | null = null;
+  private plugins = new Map<string, PluginObject>();
 
   // Promise that resolves when all plugins loaded
-  _loadingPromise: Promise<void> | null = null;
+  private loadingPromise: Promise<void> | null = null;
 
-  // Resolver to resolve _loadingPromise once all plugins loaded
-  _loadingResolver: (() => void) | null = null;
+  // Resolver to resolve loadingPromise once all plugins loaded
+  private loadingResolver: (() => void) | null = null;
 
   private instanceId?: string;
 
-  _getReporting() {
-    if (!this._reporting) {
-      this._reporting = getAppContext().reportingService;
-    }
-    return this._reporting;
+  public readonly jsApiService: JsApiService;
+
+  public readonly pluginsModel: PluginsModel;
+
+  public pluginEndPoints: GrPluginEndpoints;
+
+  constructor(
+    private readonly reportingService: ReportingService,
+    private readonly restApiService: RestApiService
+  ) {
+    this.jsApiService = new GrJsApiInterface(
+      () => this.awaitPluginsLoaded(),
+      this.reportingService
+    );
+    this.pluginsModel = new PluginsModel();
+    this.pluginEndPoints = new GrPluginEndpoints();
   }
 
+  finalize() {}
+
   /**
    * Use the plugin name or use the full url if not recognized.
    */
-  _getPluginKeyFromUrl(url: string) {
+  private getPluginKeyFromUrl(url: string) {
     return getPluginNameFromUrl(url) || `${UNKNOWN_PLUGIN_PREFIX}${url}`;
   }
 
@@ -98,41 +136,41 @@
    */
   loadPlugins(plugins: string[] = [], instanceId?: string) {
     this.instanceId = instanceId;
-    this._pluginListLoaded = true;
+    this.pluginListLoaded = true;
 
     plugins.forEach(path => {
-      const url = this._urlFor(path, window.ASSETS_PATH);
-      const pluginKey = this._getPluginKeyFromUrl(url);
+      const url = this.urlFor(path, window.ASSETS_PATH);
+      const pluginKey = this.getPluginKeyFromUrl(url);
       // Skip if already installed.
-      if (this._plugins.has(pluginKey)) return;
-      this._plugins.set(pluginKey, {
+      if (this.plugins.has(pluginKey)) return;
+      this.plugins.set(pluginKey, {
         name: pluginKey,
         url,
         state: PluginState.PENDING,
         plugin: null,
       });
 
-      if (this._isPathEndsWith(url, '.js')) {
-        this._loadJsPlugin(path);
+      if (this.isPathEndsWith(url, '.js')) {
+        this.loadJsPlugin(path);
       } else {
-        this._failToLoad(`Unrecognized plugin path ${path}`, path);
+        this.failToLoad(`Unrecognized plugin path ${path}`, path);
       }
     });
 
     this.awaitPluginsLoaded().then(() => {
       const loaded = this.getPluginsByState(PluginState.LOADED);
       const failed = this.getPluginsByState(PluginState.LOAD_FAILED);
-      this._getReporting().pluginsLoaded(loaded.map(p => p.name));
-      this._getReporting().pluginsFailed(failed.map(p => p.name));
+      this.reportingService.pluginsLoaded(loaded.map(p => p.name));
+      this.reportingService.pluginsFailed(failed.map(p => p.name));
     });
   }
 
-  _isPathEndsWith(url: string | URL, suffix: string) {
+  private isPathEndsWith(url: string | URL, suffix: string) {
     if (!(url instanceof URL)) {
       try {
         url = new URL(url);
       } catch (e: unknown) {
-        this._getReporting().error(
+        this.reportingService.error(
           'GrPluginLoader',
           new Error('url parse error'),
           e
@@ -145,7 +183,7 @@
   }
 
   private getPluginsByState(state: PluginState) {
-    return [...this._plugins.values()].filter(p => p.state === state);
+    return [...this.plugins.values()].filter(p => p.state === state);
   }
 
   install(
@@ -163,31 +201,38 @@
       src = script && script.baseURI;
     }
     if (!src) {
-      this._failToLoad('Failed to determine src.');
+      this.failToLoad('Failed to determine src.');
       return;
     }
     if (version && version !== API_VERSION) {
-      this._failToLoad(
+      this.failToLoad(
         `Plugin ${src} install error: only version ${API_VERSION} is supported in PolyGerrit. ${version} was given.`,
         src
       );
       return;
     }
 
-    const url = this._urlFor(src);
+    const url = this.urlFor(src);
     const pluginObject = this.getPlugin(url);
     let plugin = pluginObject && pluginObject.plugin;
     if (!plugin) {
-      plugin = new Plugin(url);
+      plugin = new Plugin(
+        url,
+        this.jsApiService,
+        this.reportingService,
+        this.restApiService,
+        this.pluginsModel,
+        this.pluginEndPoints
+      );
     }
     try {
       callback(plugin);
-      this._pluginInstalled(url, plugin);
+      this.pluginInstalled(url, plugin);
     } catch (e: unknown) {
       if (e instanceof Error) {
-        this._failToLoad(`${e.name}: ${e.message}`, src);
+        this.failToLoad(`${e.name}: ${e.message}`, src);
       } else {
-        this._getReporting().error(
+        this.reportingService.error(
           'GrPluginLoader',
           new Error('plugin callback error'),
           e
@@ -197,27 +242,27 @@
   }
 
   arePluginsLoaded() {
-    if (!this._pluginListLoaded) return false;
+    if (!this.pluginListLoaded) return false;
     return this.getPluginsByState(PluginState.PENDING).length === 0;
   }
 
-  _checkIfCompleted() {
+  private checkIfCompleted() {
     if (this.arePluginsLoaded()) {
-      getPluginEndpoints().setPluginsReady();
-      if (this._loadingResolver) {
-        this._loadingResolver();
-        this._loadingResolver = null;
-        this._loadingPromise = null;
+      this.pluginEndPoints.setPluginsReady();
+      if (this.loadingResolver) {
+        this.loadingResolver();
+        this.loadingResolver = null;
+        this.loadingPromise = null;
       }
     }
   }
 
-  _timeout() {
+  private timeout() {
     const pending = this.getPluginsByState(PluginState.PENDING);
     for (const plugin of pending) {
-      this._updatePluginState(plugin.url, PluginState.LOAD_FAILED);
+      this.updatePluginState(plugin.url, PluginState.LOAD_FAILED);
     }
-    this._checkIfCompleted();
+    this.checkIfCompleted();
     const errorMessage = `Timeout when loading plugins: ${pending
       .map(p => p.name)
       .join(',')}`;
@@ -225,21 +270,25 @@
     return errorMessage;
   }
 
-  _failToLoad(message: string, pluginUrl?: string) {
+  // Private but mocked in tests.
+  failToLoad(message: string, pluginUrl?: string) {
     // Show an alert with the error
     fireAlert(document, `Plugin install error: ${message} from ${pluginUrl}`);
-    if (pluginUrl) this._updatePluginState(pluginUrl, PluginState.LOAD_FAILED);
-    this._checkIfCompleted();
+    if (pluginUrl) this.updatePluginState(pluginUrl, PluginState.LOAD_FAILED);
+    this.checkIfCompleted();
   }
 
-  _updatePluginState(pluginUrl: string, state: PluginState): PluginObject {
-    const key = this._getPluginKeyFromUrl(pluginUrl);
-    if (this._plugins.has(key)) {
-      this._plugins.get(key)!.state = state;
+  private updatePluginState(
+    pluginUrl: string,
+    state: PluginState
+  ): PluginObject {
+    const key = this.getPluginKeyFromUrl(pluginUrl);
+    if (this.plugins.has(key)) {
+      this.plugins.get(key)!.state = state;
     } else {
       // Plugin is not recorded for some reason.
       console.info(`Plugin loaded separately: ${pluginUrl}`);
-      this._plugins.set(key, {
+      this.plugins.set(key, {
         name: key,
         url: pluginUrl,
         state,
@@ -247,59 +296,61 @@
       });
     }
     console.debug(`Plugin ${key} ${state}`);
-    return this._plugins.get(key)!;
+    return this.plugins.get(key)!;
   }
 
-  _pluginInstalled(url: string, plugin: PluginApi) {
-    const pluginObj = this._updatePluginState(url, PluginState.LOADED);
+  private pluginInstalled(url: string, plugin: PluginApi) {
+    const pluginObj = this.updatePluginState(url, PluginState.LOADED);
     pluginObj.plugin = plugin;
-    this._getReporting().pluginLoaded(plugin.getPluginName() || url);
-    this._checkIfCompleted();
+    this.reportingService.pluginLoaded(plugin.getPluginName() || url);
+    this.checkIfCompleted();
   }
 
   /**
    * Checks if given plugin path/url is enabled or not.
    */
   isPluginEnabled(pathOrUrl: string) {
-    const url = this._urlFor(pathOrUrl);
-    const key = this._getPluginKeyFromUrl(url);
-    return this._plugins.has(key);
+    const url = this.urlFor(pathOrUrl);
+    const key = this.getPluginKeyFromUrl(url);
+    return this.plugins.has(key);
   }
 
   /**
    * Returns the plugin object with a given url.
    */
   getPlugin(pathOrUrl: string) {
-    const url = this._urlFor(pathOrUrl);
-    const key = this._getPluginKeyFromUrl(url);
-    return this._plugins.get(key);
+    const url = this.urlFor(pathOrUrl);
+    const key = this.getPluginKeyFromUrl(url);
+    return this.plugins.get(key);
   }
 
   /**
    * Checks if given plugin path/url is loaded or not.
    */
   isPluginLoaded(pathOrUrl: string): boolean {
-    const url = this._urlFor(pathOrUrl);
-    const key = this._getPluginKeyFromUrl(url);
-    return this._plugins.has(key)
-      ? this._plugins.get(key)!.state === PluginState.LOADED
+    const url = this.urlFor(pathOrUrl);
+    const key = this.getPluginKeyFromUrl(url);
+    return this.plugins.has(key)
+      ? this.plugins.get(key)!.state === PluginState.LOADED
       : false;
   }
 
-  _loadJsPlugin(pluginUrl: string) {
-    const urlWithAP = this._urlFor(pluginUrl, window.ASSETS_PATH);
-    const urlWithoutAP = this._urlFor(pluginUrl);
+  // Private but mocked in tests.
+  loadJsPlugin(pluginUrl: string) {
+    const urlWithAP = this.urlFor(pluginUrl, window.ASSETS_PATH);
+    const urlWithoutAP = this.urlFor(pluginUrl);
     let onerror = undefined;
     if (urlWithAP !== urlWithoutAP) {
-      onerror = () => this._createScriptTag(urlWithoutAP);
+      onerror = () => this.createScriptTag(urlWithoutAP);
     }
 
-    this._createScriptTag(urlWithAP, onerror);
+    this.createScriptTag(urlWithAP, onerror);
   }
 
-  _createScriptTag(url: string, onerror?: OnErrorEventHandler) {
+  // Private but mocked in tests.
+  createScriptTag(url: string, onerror?: OnErrorEventHandler) {
     if (!onerror) {
-      onerror = () => this._failToLoad(`${url} load error`, url);
+      onerror = () => this.failToLoad(`${url} load error`, url);
     }
 
     const el = document.createElement('script');
@@ -313,7 +364,7 @@
     return document.body.appendChild(el);
   }
 
-  _urlFor(pathOrUrl: string, assetsPath?: string): string {
+  private urlFor(pathOrUrl: string, assetsPath?: string): string {
     if (isThemeFile(pathOrUrl)) {
       if (assetsPath && this.instanceId) {
         return `${assetsPath}/hosts/${this.instanceId}${THEME_JS}`;
@@ -341,39 +392,28 @@
 
   awaitPluginsLoaded() {
     // Resolve if completed.
-    this._checkIfCompleted();
+    this.checkIfCompleted();
 
     if (this.arePluginsLoaded()) {
       return Promise.resolve();
     }
-    if (!this._loadingPromise) {
+    if (!this.loadingPromise) {
       // specify window here so that TS pulls the correct setTimeout method
       // if window is not specified, then the function is pulled from node
       // and the return type is NodeJS.Timeout object
       let timerId: number;
-      this._loadingPromise = Promise.race([
-        new Promise<void>(resolve => (this._loadingResolver = resolve)),
+      this.loadingPromise = Promise.race([
+        new Promise<void>(resolve => (this.loadingResolver = resolve)),
         new Promise(
           (_, reject) =>
             (timerId = window.setTimeout(() => {
-              reject(new Error(this._timeout()));
+              reject(new Error(this.timeout()));
             }, PLUGIN_LOADING_TIMEOUT_MS))
         ),
       ]).finally(() => {
         if (timerId) clearTimeout(timerId);
       }) as Promise<void>;
     }
-    return this._loadingPromise;
+    return this.loadingPromise;
   }
 }
-
-// TODO(dmfilippov): Convert to service and add to appContext
-let pluginLoader = new PluginLoader();
-export function _testOnly_resetPluginLoader() {
-  pluginLoader = new PluginLoader();
-  return pluginLoader;
-}
-
-export function getPluginLoader() {
-  return pluginLoader;
-}
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader_test.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader_test.ts
index 3005c37..b2ac2bf 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader_test.ts
@@ -5,18 +5,14 @@
  */
 import '../../../test/common-test-setup';
 import {PLUGIN_LOADING_TIMEOUT_MS} from './gr-api-utils';
-import {PluginLoader, _testOnly_resetPluginLoader} from './gr-plugin-loader';
-import {
-  resetPlugins,
-  stubBaseUrl,
-  waitEventLoop,
-} from '../../../test/test-utils';
+import {PluginLoader} from './gr-plugin-loader';
+import {stubBaseUrl, waitEventLoop} from '../../../test/test-utils';
 import {addListenerForTest, stubRestApi} from '../../../test/test-utils';
 import {PluginApi} from '../../../api/plugin';
 import {SinonFakeTimers} from 'sinon';
 import {Timestamp} from '../../../api/rest-api';
-import {EventType} from '../../../types/events';
 import {assert} from '@open-wc/testing';
+import {getAppContext} from '../../../services/app-context';
 
 suite('gr-plugin-loader tests', () => {
   let plugin: PluginApi;
@@ -35,18 +31,20 @@
     stubRestApi('send').returns(
       Promise.resolve({...new Response(), status: 200})
     );
-    pluginLoader = _testOnly_resetPluginLoader();
+    pluginLoader = new PluginLoader(
+      getAppContext().reportingService,
+      getAppContext().restApiService
+    );
     bodyStub = sinon.stub(document.body, 'appendChild');
     url = window.location.origin;
   });
 
   teardown(() => {
     clock.restore();
-    resetPlugins();
   });
 
   test('reuse plugin for install calls', () => {
-    window.Gerrit.install(
+    pluginLoader.install(
       p => {
         plugin = p;
       },
@@ -55,7 +53,7 @@
     );
 
     let otherPlugin;
-    window.Gerrit.install(
+    pluginLoader.install(
       p => {
         otherPlugin = p;
       },
@@ -67,17 +65,17 @@
 
   test('versioning', () => {
     const callback = sinon.spy();
-    window.Gerrit.install(callback, '0.0pre-alpha');
+    pluginLoader.install(callback, '0.0pre-alpha');
     assert(callback.notCalled);
   });
 
   test('report pluginsLoaded', async () => {
     const pluginsLoadedStub = sinon.stub(
-      pluginLoader._getReporting(),
+      getAppContext().reportingService,
       'pluginsLoaded'
     );
     pluginsLoadedStub.reset();
-    (window.Gerrit as any)._loadPlugins([]);
+    pluginLoader.loadPlugins([]);
     await waitEventLoop();
     assert.isTrue(pluginsLoadedStub.called);
   });
@@ -99,11 +97,11 @@
   });
 
   test('plugins installed successfully', async () => {
-    sinon.stub(pluginLoader, '_loadJsPlugin').callsFake(url => {
-      window.Gerrit.install(() => void 0, undefined, url);
+    sinon.stub(pluginLoader, 'loadJsPlugin').callsFake(url => {
+      pluginLoader.install(() => void 0, undefined, url);
     });
     const pluginsLoadedStub = sinon.stub(
-      pluginLoader._getReporting(),
+      getAppContext().reportingService,
       'pluginsLoaded'
     );
 
@@ -119,8 +117,8 @@
   });
 
   test('isPluginEnabled and isPluginLoaded', async () => {
-    sinon.stub(pluginLoader, '_loadJsPlugin').callsFake(url => {
-      window.Gerrit.install(() => void 0, undefined, url);
+    sinon.stub(pluginLoader, 'loadJsPlugin').callsFake(url => {
+      pluginLoader.install(() => void 0, undefined, url);
     });
 
     const plugins = [
@@ -145,10 +143,10 @@
     ];
 
     const alertStub = sinon.stub();
-    addListenerForTest(document, EventType.SHOW_ALERT, alertStub);
+    addListenerForTest(document, 'show-alert', alertStub);
 
-    sinon.stub(pluginLoader, '_loadJsPlugin').callsFake(url => {
-      window.Gerrit.install(
+    sinon.stub(pluginLoader, 'loadJsPlugin').callsFake(url => {
+      pluginLoader.install(
         () => {
           if (url === plugins[0]) {
             throw new Error('failed');
@@ -160,7 +158,7 @@
     });
 
     const pluginsLoadedStub = sinon.stub(
-      pluginLoader._getReporting(),
+      getAppContext().reportingService,
       'pluginsLoaded'
     );
 
@@ -179,10 +177,10 @@
     ];
 
     const alertStub = sinon.stub();
-    addListenerForTest(document, EventType.SHOW_ALERT, alertStub);
+    addListenerForTest(document, 'show-alert', alertStub);
 
-    sinon.stub(pluginLoader, '_loadJsPlugin').callsFake(url => {
-      window.Gerrit.install(
+    sinon.stub(pluginLoader, 'loadJsPlugin').callsFake(url => {
+      pluginLoader.install(
         () => {
           if (url === plugins[0]) {
             throw new Error('failed');
@@ -194,7 +192,7 @@
     });
 
     const pluginsLoadedStub = sinon.stub(
-      pluginLoader._getReporting(),
+      getAppContext().reportingService,
       'pluginsLoaded'
     );
 
@@ -218,10 +216,10 @@
     ];
 
     const alertStub = sinon.stub();
-    addListenerForTest(document, EventType.SHOW_ALERT, alertStub);
+    addListenerForTest(document, 'show-alert', alertStub);
 
-    sinon.stub(pluginLoader, '_loadJsPlugin').callsFake(url => {
-      window.Gerrit.install(
+    sinon.stub(pluginLoader, 'loadJsPlugin').callsFake(url => {
+      pluginLoader.install(
         () => {
           throw new Error('failed');
         },
@@ -231,7 +229,7 @@
     });
 
     const pluginsLoadedStub = sinon.stub(
-      pluginLoader._getReporting(),
+      getAppContext().reportingService,
       'pluginsLoaded'
     );
 
@@ -250,14 +248,14 @@
     ];
 
     const alertStub = sinon.stub();
-    addListenerForTest(document, EventType.SHOW_ALERT, alertStub);
+    addListenerForTest(document, 'show-alert', alertStub);
 
-    sinon.stub(pluginLoader, '_loadJsPlugin').callsFake(url => {
-      window.Gerrit.install(() => {}, url === plugins[0] ? '' : 'alpha', url);
+    sinon.stub(pluginLoader, 'loadJsPlugin').callsFake(url => {
+      pluginLoader.install(() => {}, url === plugins[0] ? '' : 'alpha', url);
     });
 
     const pluginsLoadedStub = sinon.stub(
-      pluginLoader._getReporting(),
+      getAppContext().reportingService,
       'pluginsLoaded'
     );
 
@@ -270,11 +268,11 @@
   });
 
   test('multiple assets for same plugin installed successfully', async () => {
-    sinon.stub(pluginLoader, '_loadJsPlugin').callsFake(url => {
-      window.Gerrit.install(() => void 0, undefined, url);
+    sinon.stub(pluginLoader, 'loadJsPlugin').callsFake(url => {
+      pluginLoader.install(() => void 0, undefined, url);
     });
     const pluginsLoadedStub = sinon.stub(
-      pluginLoader._getReporting(),
+      getAppContext().reportingService,
       'pluginsLoaded'
     );
 
@@ -295,7 +293,7 @@
     setup(() => {
       loadJsPluginStub = sinon.stub();
       sinon
-        .stub(pluginLoader, '_createScriptTag')
+        .stub(pluginLoader, 'createScriptTag')
         .callsFake((url: string, _onerror?: OnErrorEventHandler | undefined) =>
           loadJsPluginStub(url)
         );
@@ -303,7 +301,7 @@
 
     test('invalid plugin path', () => {
       const failToLoadStub = sinon.stub();
-      sinon.stub(pluginLoader, '_failToLoad').callsFake((...args) => {
+      sinon.stub(pluginLoader, 'failToLoad').callsFake((...args) => {
         failToLoadStub(...args);
       });
 
@@ -353,7 +351,7 @@
       window.ASSETS_PATH = 'https://cdn.com';
       loadJsPluginStub = sinon.stub();
       sinon
-        .stub(pluginLoader, '_createScriptTag')
+        .stub(pluginLoader, 'createScriptTag')
         .callsFake((url: string, _onerror?: OnErrorEventHandler | undefined) =>
           loadJsPluginStub(url)
         );
@@ -409,8 +407,8 @@
         installed = true;
       }
     }
-    sinon.stub(pluginLoader, '_loadJsPlugin').callsFake(url => {
-      window.Gerrit.install(() => pluginCallback(url), undefined, url);
+    sinon.stub(pluginLoader, 'loadJsPlugin').callsFake(url => {
+      pluginLoader.install(() => pluginCallback(url), undefined, url);
     });
 
     pluginLoader.loadPlugins(plugins);
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api.ts
index b4f7324..65e4960 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api.ts
@@ -5,9 +5,10 @@
  */
 import {HttpMethod} from '../../../constants/constants';
 import {RequestPayload} from '../../../types/common';
-import {getAppContext} from '../../../services/app-context';
 import {ErrorCallback, RestPluginApi} from '../../../api/rest';
 import {PluginApi} from '../../../api/plugin';
+import {RestApiService} from '../../../services/gr-rest-api/gr-rest-api';
+import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
 
 async function getErrorMessage(response: Response): Promise<string> {
   const text = await response.text();
@@ -24,11 +25,12 @@
 }
 
 export class GrPluginRestApi implements RestPluginApi {
-  private readonly restApi = getAppContext().restApiService;
-
-  private readonly reporting = getAppContext().reportingService;
-
-  constructor(readonly plugin: PluginApi, private readonly prefix = '') {
+  constructor(
+    private readonly restApi: RestApiService,
+    private readonly reporting: ReportingService,
+    readonly plugin: PluginApi,
+    private readonly prefix = ''
+  ) {
     this.reporting.trackApi(this.plugin, 'rest', 'constructor');
   }
 
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api_test.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api_test.ts
index d6d7fc2..c5bef85 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api_test.ts
@@ -14,6 +14,7 @@
   createServerInfo,
 } from '../../../test/test-data-generators';
 import {HttpMethod} from '../../../api/rest-api';
+import {getAppContext} from '../../../services/app-context';
 
 suite('gr-plugin-rest-api tests', () => {
   let instance: GrPluginRestApi;
@@ -32,7 +33,11 @@
       '0.1',
       'http://test.com/plugins/testplugin/static/test.js'
     );
-    instance = new GrPluginRestApi(pluginApi!);
+    instance = new GrPluginRestApi(
+      getAppContext().restApiService,
+      getAppContext().reportingService,
+      pluginApi!
+    );
   });
 
   test('fetch', async () => {
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-style-api.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-style-api.ts
new file mode 100644
index 0000000..13eefc8
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-style-api.ts
@@ -0,0 +1,43 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {PluginApi} from '../../../api/plugin';
+import {StylePluginApi} from '../../../api/styles';
+import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
+
+function getOrCreatePluginStyleEl(): HTMLStyleElement {
+  const el =
+    document.head.querySelector<HTMLStyleElement>('style#plugin-style');
+  if (el) return el;
+
+  const styleEl = document.createElement('style');
+  styleEl.setAttribute('id', 'plugin-style');
+  // Append at the end so that they override the default light and dark theme
+  // styles.
+  document.head.appendChild(styleEl);
+  return styleEl;
+}
+
+export class GrPluginStyleApi implements StylePluginApi {
+  constructor(
+    private readonly reporting: ReportingService,
+    private readonly plugin: PluginApi
+  ) {
+    this.reporting.trackApi(this.plugin, 'style', 'constructor');
+  }
+
+  insertCSSRule(rule: string): void {
+    this.reporting.trackApi(this.plugin, 'style', 'insertCSSRule');
+
+    const styleEl = getOrCreatePluginStyleEl();
+    try {
+      styleEl.sheet?.insertRule(rule);
+    } catch (error) {
+      console.error(
+        `Failed to insert CSS rule for plugin ${this.plugin.getPluginName()}: ${error}`
+      );
+    }
+  }
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-style-api_test.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-style-api_test.ts
new file mode 100644
index 0000000..469d667
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-style-api_test.ts
@@ -0,0 +1,51 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-js-api-interface';
+import {query, queryAndAssert} from '../../../utils/common-util';
+import {assert} from '@open-wc/testing';
+import {StylePluginApi} from '../../../api/styles';
+
+suite('gr-plugin-style-api tests', () => {
+  let styleApi: StylePluginApi;
+
+  setup(() => {
+    window.Gerrit.install(
+      p => (styleApi = p.styleApi()),
+      '0.1',
+      'http://test.com/plugins/testplugin/static/test.js'
+    );
+  });
+
+  teardown(() => {
+    const styleEl = query<HTMLStyleElement>(
+      document.head,
+      'style#plugin-style'
+    );
+    styleEl?.remove();
+  });
+
+  test('insertCSSRule adds a rule', async () => {
+    styleApi.insertCSSRule('html{color:green;}');
+    const styleEl = queryAndAssert<HTMLStyleElement>(
+      document.head,
+      'style#plugin-style'
+    );
+    const styleSheet = styleEl.sheet;
+    assert.equal(styleSheet?.cssRules.length, 1);
+  });
+
+  test('insertCSSRule re-uses the <style> element', async () => {
+    styleApi.insertCSSRule('html{color:green;}');
+    styleApi.insertCSSRule('html{margin:0px;}');
+    const styleEl = queryAndAssert<HTMLStyleElement>(
+      document.head,
+      'style#plugin-style'
+    );
+    const styleSheet = styleEl.sheet;
+    assert.equal(styleSheet?.cssRules.length, 2);
+  });
+});
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.ts
index 21ab10a..832b97e 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.ts
@@ -13,7 +13,7 @@
 import {GrAnnotationActionsInterface} from './gr-annotation-actions-js-api';
 import {GrEventHelper} from '../../plugins/gr-event-helper/gr-event-helper';
 import {GrPluginRestApi} from './gr-plugin-rest-api';
-import {getPluginEndpoints} from './gr-plugin-endpoints';
+import {EndpointType, GrPluginEndpoints} from './gr-plugin-endpoints';
 import {getPluginNameFromUrl, send} from './gr-api-utils';
 import {GrReportingJsApi} from './gr-reporting-js-api';
 import {EventType, PluginApi, TargetElement} from '../../../api/plugin';
@@ -21,7 +21,6 @@
 import {HttpMethod} from '../../../constants/constants';
 import {GrChangeActions} from '../../change/gr-change-actions/gr-change-actions';
 import {GrChecksApi} from '../../plugins/gr-checks-api/gr-checks-api';
-import {getAppContext} from '../../../services/app-context';
 import {AdminPluginApi} from '../../../api/admin';
 import {AnnotationPluginApi} from '../../../api/annotation';
 import {EventHelperPluginApi} from '../../../api/event-helper';
@@ -32,22 +31,12 @@
 import {RestPluginApi} from '../../../api/rest';
 import {HookApi, PluginElement, RegisterOptions} from '../../../api/hook';
 import {AttributeHelperPluginApi} from '../../../api/attribute-helper';
-
-/**
- * Plugin-provided custom components can affect content in extension
- * points using one of following methods:
- * - DECORATE: custom component is set with `content` attribute and may
- *   decorate (e.g. style) DOM element.
- * - REPLACE: contents of extension point are replaced with the custom
- *   component.
- * - STYLE: custom component is a shared styles module that is inserted
- *   into the extension point.
- */
-enum EndpointType {
-  DECORATE = 'decorate',
-  REPLACE = 'replace',
-  STYLE = 'style',
-}
+import {JsApiService} from './gr-js-api-types';
+import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
+import {RestApiService} from '../../../services/gr-rest-api/gr-rest-api';
+import {PluginsModel} from '../../../models/plugins/plugins-model';
+import {GrPluginStyleApi} from './gr-plugin-style-api';
+import {StylePluginApi} from '../../../api/styles';
 
 const PLUGIN_NAME_NOT_SET = 'NULL';
 
@@ -60,13 +49,14 @@
 
   private readonly _name: string = PLUGIN_NAME_NOT_SET;
 
-  private readonly jsApi = getAppContext().jsApiService;
-
-  private readonly report = getAppContext().reportingService;
-
-  private readonly restApiService = getAppContext().restApiService;
-
-  constructor(url?: string) {
+  constructor(
+    url: string,
+    private readonly jsApi: JsApiService,
+    private readonly report: ReportingService,
+    private readonly restApiService: RestApiService,
+    private readonly pluginsModel: PluginsModel,
+    private readonly pluginEndpoints: GrPluginEndpoints
+  ) {
     this.domHooks = new GrDomHooksManager(this);
 
     if (!url) {
@@ -88,18 +78,6 @@
     return this._name;
   }
 
-  registerStyleModule(endpoint: string, moduleName: string) {
-    console.warn(
-      `The deprecated plugin API 'registerStyleModule()' was called with parameters '${endpoint}' and '${moduleName}'.`
-    );
-    this.report.trackApi(this, 'plugin', 'registerStyleModule');
-    getPluginEndpoints().registerModule(this, {
-      endpoint,
-      type: EndpointType.STYLE,
-      moduleName,
-    });
-  }
-
   /**
    * Registers an endpoint for the plugin.
    */
@@ -145,7 +123,7 @@
     const slot = options?.slot ?? '';
     const domHook = this.domHooks.getDomHook<T>(endpoint, moduleName);
     moduleName = moduleName || domHook.getModuleName();
-    getPluginEndpoints().registerModule(this, {
+    this.pluginEndpoints.registerModule(this, {
       slot,
       endpoint,
       type,
@@ -211,12 +189,17 @@
   }
 
   annotationApi(): AnnotationPluginApi {
-    return new GrAnnotationActionsInterface(this);
+    return new GrAnnotationActionsInterface(
+      this.report,
+      this.pluginsModel,
+      this
+    );
   }
 
   changeActions(): ChangeActionsPluginApi {
     return new GrChangeActionsInterface(
       this,
+      this.jsApi,
       this.jsApi.getElement(
         TargetElement.CHANGE_ACTIONS
       ) as unknown as GrChangeActions
@@ -228,27 +211,31 @@
   }
 
   checks(): GrChecksApi {
-    return new GrChecksApi(this);
+    return new GrChecksApi(this.report, this.pluginsModel, this);
   }
 
   reporting(): ReportingPluginApi {
-    return new GrReportingJsApi(this);
+    return new GrReportingJsApi(this.report, this);
+  }
+
+  styleApi(): StylePluginApi {
+    return new GrPluginStyleApi(this.report, this);
   }
 
   admin(): AdminPluginApi {
-    return new GrAdminApi(this);
+    return new GrAdminApi(this.report, this);
   }
 
   restApi(prefix?: string): RestPluginApi {
-    return new GrPluginRestApi(this, prefix);
+    return new GrPluginRestApi(this.restApiService, this.report, this, prefix);
   }
 
   attributeHelper(element: HTMLElement): AttributeHelperPluginApi {
-    return new GrAttributeHelper(this, element);
+    return new GrAttributeHelper(this.report, this, element);
   }
 
   eventHelper(element: HTMLElement): EventHelperPluginApi {
-    return new GrEventHelper(this, element);
+    return new GrEventHelper(this.report, this, element);
   }
 
   popup(): Promise<PopupPluginApi>;
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-reporting-js-api.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-reporting-js-api.ts
index fab0e6c..d82b68d 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-reporting-js-api.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-reporting-js-api.ts
@@ -3,17 +3,18 @@
  * Copyright 2020 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-import {getAppContext} from '../../../services/app-context';
 import {PluginApi} from '../../../api/plugin';
 import {EventDetails, ReportingPluginApi} from '../../../api/reporting';
+import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
 
 /**
  * Defines all methods that will be exported to plugin from reporting service.
  */
 export class GrReportingJsApi implements ReportingPluginApi {
-  private readonly reporting = getAppContext().reportingService;
-
-  constructor(private readonly plugin: PluginApi) {
+  constructor(
+    private readonly reporting: ReportingService,
+    private readonly plugin: PluginApi
+  ) {
     this.reporting.trackApi(this.plugin, 'reporting', 'constructor');
   }
 
diff --git a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.ts b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.ts
index 47d722d..02f830d 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
@@ -22,7 +22,7 @@
 import {customElement, property} from 'lit/decorators.js';
 import {GrButton} from '../gr-button/gr-button';
 import {
-  canVote,
+  canReviewerVote,
   getApprovalInfo,
   hasNeutralStatus,
   hasVoted,
@@ -143,7 +143,7 @@
       .filter(reviewer => {
         if (this.showAllReviewers) {
           if (isDetailedLabelInfo(labelInfo)) {
-            return canVote(labelInfo, reviewer);
+            return canReviewerVote(labelInfo, reviewer);
           } else {
             // isQuickLabelInfo
             return hasVoted(labelInfo, reviewer);
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.ts b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.ts
index e48dcb3..7717683 100644
--- a/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.ts
+++ b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.ts
@@ -6,7 +6,7 @@
 import '../gr-button/gr-button';
 import '../gr-icon/gr-icon';
 import '../gr-limited-text/gr-limited-text';
-import {fireEvent} from '../../../utils/event-util';
+import {fire} from '../../../utils/event-util';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, css, html} from 'lit';
 import {customElement, property} from 'lit/decorators.js';
@@ -15,6 +15,11 @@
   interface HTMLElementTagNameMap {
     'gr-linked-chip': GrLinkedChip;
   }
+  interface HTMLElementEventMap {
+    /** Fired when the 'remove' button was clicked. */
+    // prettier-ignore
+    'remove': CustomEvent<{}>;
+  }
 }
 
 @customElement('gr-linked-chip')
@@ -101,6 +106,6 @@
 
   private handleRemoveTap(e: Event) {
     e.preventDefault();
-    fireEvent(this, 'remove');
+    fire(this, 'remove', {});
   }
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.ts b/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.ts
index fd43869..14b5d14 100644
--- a/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.ts
+++ b/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.ts
@@ -7,13 +7,14 @@
 import '../gr-button/gr-button';
 import '../gr-icon/gr-icon';
 import {encodeURL, getBaseUrl} from '../../../utils/url-util';
-import {page} from '../../../utils/page-wrapper-utils';
-import {fireEvent} from '../../../utils/event-util';
+import {fire} from '../../../utils/event-util';
 import {debounce, DelayedTask} from '../../../utils/async-util';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, PropertyValues, css, html} from 'lit';
 import {customElement, property} from 'lit/decorators.js';
 import {BindValueChangeEvent} from '../../../types/events';
+import {resolve} from '../../../models/dependency';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
 
 const REQUEST_DEBOUNCE_INTERVAL_MS = 200;
 
@@ -21,6 +22,9 @@
   interface HTMLElementTagNameMap {
     'gr-list-view': GrListView;
   }
+  interface HTMLElementEventMap {
+    'create-clicked': CustomEvent<{}>;
+  }
 }
 
 @customElement('gr-list-view')
@@ -43,11 +47,14 @@
   @property({type: Boolean})
   loading?: boolean;
 
+  /** Must include the base path. */
   @property({type: String})
   path?: string;
 
   private reloadTask?: DelayedTask;
 
+  private readonly getNavigation = resolve(this, navigationToken);
+
   override disconnectedCallback() {
     this.reloadTask?.cancel();
     super.disconnectedCallback();
@@ -121,30 +128,18 @@
       </div>
       <slot></slot>
       <nav>
-        Page ${this.computePage(this.offset, this.itemsPerPage)}
+        Page ${this.computePage()}
         <a
           id="prevArrow"
-          href=${this.computeNavLink(
-            this.offset,
-            -1,
-            this.itemsPerPage,
-            this.filter,
-            this.path
-          )}
+          href=${this.computeNavLink(-1)}
           ?hidden=${this.loading || this.offset === 0}
         >
           <gr-icon icon="chevron_left"></gr-icon>
         </a>
         <a
           id="nextArrow"
-          href=${this.computeNavLink(
-            this.offset,
-            1,
-            this.itemsPerPage,
-            this.filter,
-            this.path
-          )}
-          ?hidden=${this.hideNextArrow(this.loading, this.items)}
+          href=${this.computeNavLink(1)}
+          ?hidden=${this.hideNextArrow()}
         >
           <gr-icon icon="chevron_right"></gr-icon>
         </a>
@@ -177,33 +172,30 @@
       () => {
         if (!this.isConnected || !this.path) return;
         if (filter) {
-          page.show(`${this.path}/q/filter:${encodeURL(filter, false)}`);
+          this.getNavigation().setUrl(
+            `${this.path}/q/filter:${encodeURL(filter)}`
+          );
           return;
         }
-        page.show(this.path);
+        this.getNavigation().setUrl(this.path);
       },
       REQUEST_DEBOUNCE_INTERVAL_MS
     );
   }
 
   private createNewItem() {
-    fireEvent(this, 'create-clicked');
+    fire(this, 'create-clicked', {});
   }
 
   // private but used in test
-  computeNavLink(
-    offset: number,
-    direction: number,
-    itemsPerPage: number,
-    filter: string | undefined,
-    path = ''
-  ) {
+  computeNavLink(direction: number) {
     // Offset could be a string when passed from the router.
-    offset = +(offset || 0);
-    const newOffset = Math.max(0, offset + itemsPerPage * direction);
-    let href = getBaseUrl() + path;
-    if (filter) {
-      href += '/q/filter:' + encodeURL(filter, false);
+    const offset = +(this.offset || 0);
+    const newOffset = Math.max(0, offset + this.itemsPerPage * direction);
+    // Note that `this.path` already includes the base URL, if set and non-empty;
+    let href = this.path || getBaseUrl();
+    if (this.filter) {
+      href += '/q/filter:' + encodeURL(this.filter);
     }
     if (newOffset > 0) {
       href += `,${newOffset}`;
@@ -212,11 +204,9 @@
   }
 
   // private but used in test
-  hideNextArrow(loading?: boolean, items?: unknown[]) {
-    if (loading || !items || !items.length) {
-      return true;
-    }
-    const lastPage = items.length < this.itemsPerPage + 1;
+  hideNextArrow() {
+    if (this.loading || !this.items?.length) return true;
+    const lastPage = this.items.length < this.itemsPerPage + 1;
     return lastPage;
   }
 
@@ -224,8 +214,8 @@
   // to either support a decimal or make it go to the nearest
   // whole number (e.g 3).
   // private but used in test
-  computePage(offset: number, itemsPerPage: number) {
-    return offset / itemsPerPage + 1;
+  computePage() {
+    return this.offset / this.itemsPerPage + 1;
   }
 
   private handleFilterBindValueChanged(e: BindValueChangeEvent) {
diff --git a/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view_test.ts b/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view_test.ts
index bbbef72..5b1e162 100644
--- a/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view_test.ts
@@ -6,10 +6,11 @@
 import '../../../test/common-test-setup';
 import './gr-list-view';
 import {GrListView} from './gr-list-view';
-import {page} from '../../../utils/page-wrapper-utils';
 import {queryAndAssert, stubBaseUrl} from '../../../test/test-utils';
 import {GrButton} from '../gr-button/gr-button';
 import {fixture, html, assert} from '@open-wc/testing';
+import {testResolver} from '../../../test/common-test-setup';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
 
 suite('gr-list-view tests', () => {
   let element: GrListView;
@@ -57,37 +58,32 @@
   });
 
   test('computeNavLink', () => {
-    const offset = 25;
-    const projectsPerPage = 25;
-    let filter = 'test';
-    const path = '/admin/projects';
+    element.offset = 25;
+    element.itemsPerPage = 25;
+    element.filter = 'test';
+    element.path = '/base/admin/projects';
 
-    stubBaseUrl('');
+    stubBaseUrl('/base');
 
     assert.equal(
-      element.computeNavLink(offset, 1, projectsPerPage, filter, path),
-      '/admin/projects/q/filter:test,50'
+      element.computeNavLink(1),
+      '/base/admin/projects/q/filter:test,50'
     );
 
     assert.equal(
-      element.computeNavLink(offset, -1, projectsPerPage, filter, path),
-      '/admin/projects/q/filter:test'
+      element.computeNavLink(-1),
+      '/base/admin/projects/q/filter:test'
     );
 
-    assert.equal(
-      element.computeNavLink(offset, 1, projectsPerPage, undefined, path),
-      '/admin/projects,50'
-    );
+    element.filter = undefined;
+    assert.equal(element.computeNavLink(1), '/base/admin/projects,50');
 
-    assert.equal(
-      element.computeNavLink(offset, -1, projectsPerPage, undefined, path),
-      '/admin/projects'
-    );
+    assert.equal(element.computeNavLink(-1), '/base/admin/projects');
 
-    filter = 'plugins/';
+    element.filter = 'plugins/';
     assert.equal(
-      element.computeNavLink(offset, 1, projectsPerPage, filter, path),
-      '/admin/projects/q/filter:plugins%252F,50'
+      element.computeNavLink(1),
+      '/base/admin/projects/q/filter:plugins/,50'
     );
   });
 
@@ -95,7 +91,9 @@
     let resolve: (url: string) => void;
     const promise = new Promise(r => (resolve = r));
     element.path = '/admin/projects';
-    sinon.stub(page, 'show').callsFake(r => resolve(r));
+    sinon
+      .stub(testResolver(navigationToken), 'setUrl')
+      .callsFake(r => resolve(r));
 
     element.filter = 'test';
     await element.updateComplete;
@@ -113,19 +111,19 @@
 
   test('next button', async () => {
     element.itemsPerPage = 25;
-    let projects = new Array(26);
+    element.items = Array.from({length: 26});
+    element.loading = false;
     await element.updateComplete;
 
-    let loading;
-    assert.isFalse(element.hideNextArrow(loading, projects));
-    loading = true;
-    assert.isTrue(element.hideNextArrow(loading, projects));
-    loading = false;
-    assert.isFalse(element.hideNextArrow(loading, projects));
-    projects = [];
-    assert.isTrue(element.hideNextArrow(loading, projects));
-    projects = new Array(4);
-    assert.isTrue(element.hideNextArrow(loading, projects));
+    assert.isFalse(element.hideNextArrow());
+    element.loading = true;
+    assert.isTrue(element.hideNextArrow());
+    element.loading = false;
+    assert.isFalse(element.hideNextArrow());
+    element.items = [];
+    assert.isTrue(element.hideNextArrow());
+    element.items = Array.from({length: 4});
+    assert.isTrue(element.hideNextArrow());
   });
 
   test('prev button', async () => {
@@ -186,20 +184,40 @@
   test('next/prev links change when path changes', async () => {
     const BRANCHES_PATH = '/path/to/branches';
     const TAGS_PATH = '/path/to/tags';
-    const computeNavLinkStub = sinon.stub(element, 'computeNavLink');
     element.offset = 0;
     element.itemsPerPage = 25;
     element.filter = '';
     element.path = BRANCHES_PATH;
     await element.updateComplete;
-    assert.equal(computeNavLinkStub.lastCall.args[4], BRANCHES_PATH);
+
+    assert.dom.equal(
+      queryAndAssert(element, 'nav a'),
+      /* HTML */ `
+        <a hidden="" href="${BRANCHES_PATH}" id="prevArrow">
+          <gr-icon icon="chevron_left"> </gr-icon>
+        </a>
+      `
+    );
+
     element.path = TAGS_PATH;
     await element.updateComplete;
-    assert.equal(computeNavLinkStub.lastCall.args[4], TAGS_PATH);
+
+    assert.dom.equal(
+      queryAndAssert(element, 'nav a'),
+      /* HTML */ `
+        <a hidden="" href="${TAGS_PATH}" id="prevArrow">
+          <gr-icon icon="chevron_left"> </gr-icon>
+        </a>
+      `
+    );
   });
 
   test('computePage', () => {
-    assert.equal(element.computePage(0, 25), 1);
-    assert.equal(element.computePage(50, 25), 3);
+    element.offset = 0;
+    element.itemsPerPage = 25;
+    assert.equal(element.computePage(), 1);
+    element.offset = 50;
+    element.itemsPerPage = 25;
+    assert.equal(element.computePage(), 3);
   });
 });
diff --git a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.ts b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.ts
deleted file mode 100644
index c18a31d..0000000
--- a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.ts
+++ /dev/null
@@ -1,162 +0,0 @@
-/**
- * @license
- * Copyright 2016 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-import '../../../styles/shared-styles';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-overlay_html';
-import {IronOverlayMixin} from '../../../mixins/iron-overlay-mixin/iron-overlay-mixin';
-import {customElement} from '@polymer/decorators';
-import {IronOverlayBehavior} from '@polymer/iron-overlay-behavior/iron-overlay-behavior';
-import {findActiveElement} from '../../../utils/dom-util';
-import {fireEvent} from '../../../utils/event-util';
-import {getHovercardContainer} from '../../../mixins/hovercard-mixin/hovercard-mixin';
-import {getFocusableElements} from '../../../utils/focusable';
-
-const AWAIT_MAX_ITERS = 10;
-const AWAIT_STEP = 5;
-const BREAKPOINT_FULLSCREEN_OVERLAY = '50em';
-
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-overlay': GrOverlay;
-  }
-}
-
-// This avoids JSC_DYNAMIC_EXTENDS_WITHOUT_JSDOC closure compiler error.
-const base = IronOverlayMixin(
-  PolymerElement,
-  IronOverlayBehavior as IronOverlayBehavior
-);
-
-/**
- * @attr {Boolean} with-backdrop - inherited from IronOverlay
- * @attr {Boolean} always-on-top - inherited from IronOverlay
- * @attr {Boolean} no-cancel-on-esc-key - inherited from IronOverlay
- * @attr {Boolean} no-cancel-on-outside-click - inherited from IronOverlay
- * @attr {String} scroll-action - inherited from IronOverlay
- */
-@customElement('gr-overlay')
-export class GrOverlay extends base {
-  static get template() {
-    return htmlTemplate;
-  }
-
-  /**
-   * Fired when a fullscreen overlay is closed
-   *
-   * @event fullscreen-overlay-closed
-   */
-
-  /**
-   * Fired when an overlay is opened in full screen mode
-   *
-   * @event fullscreen-overlay-opened
-   */
-
-  // private but used in test
-  fullScreenOpen = false;
-
-  // private but used in test
-  _boundHandleClose: () => void = () => super.close();
-
-  private focusableNodes?: Node[];
-
-  private returnFocusTo?: HTMLElement;
-
-  override get _focusableNodes() {
-    if (this.focusableNodes) {
-      return this.focusableNodes;
-    }
-    return Array.from(getFocusableElements(this));
-  }
-
-  constructor() {
-    super();
-    this.addEventListener('iron-overlay-closed', () => this._overlayClosed());
-    this.addEventListener('iron-overlay-cancelled', () =>
-      this._overlayClosed()
-    );
-  }
-
-  override open() {
-    this.returnFocusTo = findActiveElement(document, true) ?? undefined;
-    window.addEventListener('popstate', this._boundHandleClose);
-    return new Promise<void>((resolve, reject) => {
-      super.open.apply(this);
-      if (this._isMobile()) {
-        fireEvent(this, 'fullscreen-overlay-opened');
-        this.fullScreenOpen = true;
-      }
-      this._awaitOpen(resolve, reject);
-    });
-  }
-
-  _isMobile() {
-    return window.matchMedia(`(max-width: ${BREAKPOINT_FULLSCREEN_OVERLAY})`);
-  }
-
-  // called after iron-overlay is closed. Does not actually close the overlay
-  _overlayClosed() {
-    window.removeEventListener('popstate', this._boundHandleClose);
-    if (this.fullScreenOpen) {
-      fireEvent(this, 'fullscreen-overlay-closed');
-      this.fullScreenOpen = false;
-    }
-    if (this.returnFocusTo) {
-      this.returnFocusTo.focus();
-      this.returnFocusTo = undefined;
-    }
-  }
-
-  override _onCaptureFocus(e: Event) {
-    const hovercardContainer = getHovercardContainer();
-    if (hovercardContainer) {
-      // Hovercard container is not a child of an overlay.
-      // When an overlay is opened and a user clicks inside hovercard,
-      // the IronOverlayBehavior doesn't allow to set focus inside a hovercard.
-      // As a result, user can't select a text (username) in the hovercard
-      // in a dialog. We should skip default _onCaptureFocus for hovercards.
-      const path = e.composedPath();
-      if (path.indexOf(hovercardContainer) >= 0) return;
-    }
-    super._onCaptureFocus(e);
-  }
-
-  /**
-   * Override the focus stops that iron-overlay-behavior tries to find.
-   */
-  setFocusStops(stops: GrOverlayStops) {
-    this.focusableNodes = [stops.start, stops.end];
-  }
-
-  /**
-   * NOTE: (wyatta) Slightly hacky way to listen to the overlay actually
-   * opening. Eventually replace with a direct way to listen to the overlay.
-   */
-  _awaitOpen(fn: (this: GrOverlay) => void, reject: (error: Error) => void) {
-    let iters = 0;
-    const step = () => {
-      setTimeout(() => {
-        if (this.style.display !== 'none') {
-          fn.call(this);
-        } else if (iters++ < AWAIT_MAX_ITERS) {
-          step.call(this);
-        } else {
-          reject(new Error('gr-overlay _awaitOpen failed to resolve'));
-        }
-      }, AWAIT_STEP);
-    };
-    step.call(this);
-  }
-
-  _id() {
-    return this.getAttribute('id') || 'global';
-  }
-}
-
-export interface GrOverlayStops {
-  start: Node;
-  end: Node;
-}
diff --git a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay_html.ts b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay_html.ts
deleted file mode 100644
index f6818a5..0000000
--- a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay_html.ts
+++ /dev/null
@@ -1,29 +0,0 @@
-/**
- * @license
- * Copyright 2020 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    :host {
-      background: var(--dialog-background-color);
-      border-radius: var(--border-radius);
-      box-shadow: var(--elevation-level-5);
-    }
-
-    @media screen and (max-width: 50em) {
-      :host {
-        height: 100%;
-        left: 0;
-        position: fixed;
-        right: 0;
-        top: 0;
-        border-radius: 0;
-        box-shadow: none;
-      }
-    }
-  </style>
-  <slot></slot>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay_test.ts b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay_test.ts
deleted file mode 100644
index dc98745..0000000
--- a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay_test.ts
+++ /dev/null
@@ -1,77 +0,0 @@
-/**
- * @license
- * Copyright 2017 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-import '../../../test/common-test-setup';
-import './gr-overlay';
-import {GrOverlay} from './gr-overlay';
-import {fixture, html, assert} from '@open-wc/testing';
-
-suite('gr-overlay tests', () => {
-  let element: GrOverlay;
-
-  setup(async () => {
-    element = await fixture(html`<gr-overlay><div>content</div></gr-overlay>`);
-  });
-
-  test('render', async () => {
-    await element.open();
-    assert.shadowDom.equal(element, /* HTML */ ' <slot></slot> ');
-  });
-
-  test('popstate listener is attached on open and removed on close', () => {
-    const addEventListenerStub = sinon.stub(window, 'addEventListener');
-    const removeEventListenerStub = sinon.stub(window, 'removeEventListener');
-    element.open();
-    assert.isTrue(addEventListenerStub.called);
-    assert.equal(addEventListenerStub.lastCall.args[0], 'popstate');
-    assert.equal(
-      addEventListenerStub.lastCall.args[1],
-      element._boundHandleClose
-    );
-    element._overlayClosed();
-    assert.isTrue(removeEventListenerStub.called);
-    assert.equal(removeEventListenerStub.lastCall.args[0], 'popstate');
-    assert.equal(
-      removeEventListenerStub.lastCall.args[1],
-      element._boundHandleClose
-    );
-  });
-
-  test('events are fired on fullscreen view', async () => {
-    const isMobileStub = sinon.stub(element, '_isMobile').returns(true as any);
-    const openHandler = sinon.stub();
-    const closeHandler = sinon.stub();
-    element.addEventListener('fullscreen-overlay-opened', openHandler);
-    element.addEventListener('fullscreen-overlay-closed', closeHandler);
-
-    await element.open();
-
-    assert.isTrue(isMobileStub.called);
-    assert.isTrue(element.fullScreenOpen);
-    assert.isTrue(openHandler.called);
-
-    element._overlayClosed();
-    assert.isFalse(element.fullScreenOpen);
-    assert.isTrue(closeHandler.called);
-  });
-
-  test('events are not fired on desktop view', async () => {
-    const isMobileStub = sinon.stub(element, '_isMobile').returns(false as any);
-    const openHandler = sinon.stub();
-    const closeHandler = sinon.stub();
-    element.addEventListener('fullscreen-overlay-opened', openHandler);
-    element.addEventListener('fullscreen-overlay-closed', closeHandler);
-
-    await element.open();
-
-    assert.isTrue(isMobileStub.called);
-    assert.isFalse(element.fullScreenOpen);
-    assert.isFalse(openHandler.called);
-
-    element._overlayClosed();
-    assert.isFalse(element.fullScreenOpen);
-    assert.isFalse(closeHandler.called);
-  });
-});
diff --git a/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker.ts b/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker.ts
index 8fa351b..6d2fe20 100644
--- a/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker.ts
+++ b/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker.ts
@@ -21,6 +21,7 @@
 import {assertIsDefined} from '../../../utils/common-util';
 import {fire} from '../../../utils/event-util';
 import {BindValueChangeEvent} from '../../../types/events';
+import {throwingErrorCallback} from '../gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
 
 const SUGGESTIONS_LIMIT = 15;
 const REF_PREFIX = 'refs/heads/';
@@ -121,7 +122,13 @@
       input = input.substring(REF_PREFIX.length);
     }
     return this.restApiService
-      .getRepoBranches(input, this.repo, SUGGESTIONS_LIMIT)
+      .getRepoBranches(
+        input,
+        this.repo,
+        SUGGESTIONS_LIMIT,
+        /* offset=*/ undefined,
+        throwingErrorCallback
+      )
       .then(res => this.branchResponseToSuggestions(res));
   }
 
@@ -143,7 +150,12 @@
   // private but used in test
   getRepoSuggestions(input: string) {
     return this.restApiService
-      .getRepos(input, SUGGESTIONS_LIMIT)
+      .getRepos(
+        input,
+        SUGGESTIONS_LIMIT,
+        /* offset=*/ undefined,
+        throwingErrorCallback
+      )
       .then(res => this.repoResponseToSuggestions(res));
   }
 
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator_test.ts b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator_test.ts
index 4f22f25..fd69a33 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator_test.ts
@@ -10,12 +10,12 @@
 suite('gr-etag-decorator', () => {
   let etag: GrEtagDecorator;
 
-  const fakeRequest = (opt_etag?: string, opt_status?: number) => {
+  const fakeRequest = (etag?: string, status?: number) => {
     const headers = new Headers();
-    if (opt_etag) {
-      headers.set('etag', opt_etag);
+    if (etag) {
+      headers.set('etag', etag);
     }
-    const status = opt_status || 200;
+    status = status || 200;
     return {...new Response(), ok: true, status, headers};
   };
 
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.ts b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.ts
index 9c54349..1084512 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.ts
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.ts
@@ -5,10 +5,7 @@
  */
 import {getBaseUrl} from '../../../../utils/url-util';
 import {CancelConditionCallback} from '../../../../services/gr-rest-api/gr-rest-api';
-import {
-  AuthRequestInit,
-  AuthService,
-} from '../../../../services/gr-auth/gr-auth';
+import {AuthService} from '../../../../services/gr-auth/gr-auth';
 import {
   AccountDetailInfo,
   EmailInfo,
@@ -17,8 +14,12 @@
 } from '../../../../types/common';
 import {HttpMethod} from '../../../../constants/constants';
 import {RpcLogEventDetail} from '../../../../types/events';
-import {fireNetworkError, fireServerError} from '../../../../utils/event-util';
-import {FetchRequest} from '../../../../types/types';
+import {
+  fire,
+  fireNetworkError,
+  fireServerError,
+} from '../../../../utils/event-util';
+import {AuthRequestInit, FetchRequest} from '../../../../types/types';
 import {ErrorCallback} from '../../../../api/rest';
 import {Scheduler, Task} from '../../../../services/scheduler/scheduler';
 import {RetryError} from '../../../../services/scheduler/retry-scheduler';
@@ -102,7 +103,7 @@
 
   get(key: '/accounts/self/emails'): EmailInfo[] | null;
 
-  get(key: '/accounts/self/detail'): AccountDetailInfo[] | null;
+  get(key: '/accounts/self/detail'): AccountDetailInfo | null;
 
   get(key: string): ParsedJSON | null;
 
@@ -112,7 +113,7 @@
 
   set(key: '/accounts/self/emails', value: EmailInfo[]): void;
 
-  set(key: '/accounts/self/detail', value: AccountDetailInfo[]): void;
+  set(key: '/accounts/self/detail', value: AccountDetailInfo): void;
 
   set(key: string, value: ParsedJSON | null): void;
 
@@ -183,6 +184,35 @@
   [name: string]: string[] | string | number | boolean | undefined | null;
 };
 
+/**
+ * Error callback that throws an error.
+ *
+ * Pass into REST API methods as errFn to make the returned Promises reject on
+ * error.
+ *
+ * If error is provided, it's thrown.
+ * Otherwise if response with error is provided the promise that will throw an
+ * error is returned.
+ */
+export function throwingErrorCallback(
+  response?: Response | null,
+  err?: Error
+): void | Promise<void> {
+  if (err) throw err;
+  if (!response) return;
+
+  return response.text().then(errorText => {
+    let message = `Error ${response.status}`;
+    if (response.statusText) {
+      message += ` (${response.statusText})`;
+    }
+    if (errorText) {
+      message += `: ${errorText}`;
+    }
+    throw new Error(message);
+  });
+}
+
 interface SendRequestBase {
   method: HttpMethod | undefined;
   body?: RequestPayload;
@@ -275,16 +305,14 @@
    * by this method, it should be called immediately after the request
    * finishes.
    *
+   * Private, but used in tests.
+   *
    * @param startTime the time that the request was started.
    * @param status the HTTP status of the response. The status value
    *     is used here rather than the response object so there is no way this
    *     method can read the body stream.
    */
-  private _logCall(
-    req: FetchRequest,
-    startTime: number,
-    status: number | null
-  ) {
+  _logCall(req: FetchRequest, startTime: number, status: number | null) {
     const method =
       req.fetchOptions && req.fetchOptions.method
         ? req.fetchOptions.method
@@ -310,13 +338,7 @@
         elapsed,
         anonymizedUrl: req.anonymizedUrl,
       };
-      document.dispatchEvent(
-        new CustomEvent('gr-rpc-log', {
-          detail,
-          composed: true,
-          bubbles: true,
-        })
-      );
+      fire(document, 'gr-rpc-log', detail);
     }
   }
 
@@ -363,27 +385,26 @@
    *
    * @param noAcceptHeader - don't add default accept json header
    */
-  fetchJSON(
+  async fetchJSON(
     req: FetchJSONRequest,
     noAcceptHeader?: boolean
   ): Promise<ParsedJSON | undefined> {
     if (!noAcceptHeader) {
       req = this.addAcceptJsonHeader(req);
     }
-    return this.fetchRawJSON(req).then(response => {
-      if (!response) {
+    const response = await this.fetchRawJSON(req);
+    if (!response) {
+      return;
+    }
+    if (!response.ok) {
+      if (req.errFn) {
+        await req.errFn.call(undefined, response);
         return;
       }
-      if (!response.ok) {
-        if (req.errFn) {
-          req.errFn.call(null, response);
-          return;
-        }
-        fireServerError(response, req);
-        return;
-      }
-      return this.getResponseObject(response);
-    });
+      fireServerError(response, req);
+      return;
+    }
+    return this.getResponseObject(response);
   }
 
   urlWithParams(url: string, fetchParams?: FetchParams): string {
@@ -393,9 +414,7 @@
 
     const params: Array<string | number | boolean> = [];
     for (const [p, paramValue] of Object.entries(fetchParams)) {
-      // TODO(TS): Replace == null with === and check for null and undefined
-      // eslint-disable-next-line eqeqeq
-      if (paramValue == null) {
+      if (paramValue === null || paramValue === undefined) {
         params.push(this.encodeRFC5987(p));
         continue;
       }
@@ -476,7 +495,7 @@
    *     (i.e. no exception and response.ok is true). If response fails then
    *     promise resolves either to void if errFn is set or rejects if errFn
    *     is not set   */
-  send(req: SendRequest): Promise<Response | ParsedJSON | undefined> {
+  async send(req: SendRequest): Promise<Response | ParsedJSON | undefined> {
     const options: AuthRequestInit = {method: req.method};
     if (req.body) {
       options.headers = new Headers();
@@ -501,38 +520,30 @@
       fetchOptions: options,
       anonymizedUrl: req.reportUrlAsIs ? url : req.anonymizedUrl,
     };
-    const xhr = this.fetch(fetchReq)
-      .catch(err => {
-        fireNetworkError(err);
-        if (req.errFn) {
-          req.errFn.call(undefined, null, err);
-          return;
-        } else {
-          throw err;
-        }
-      })
-      .then(response => {
-        if (response && !response.ok) {
-          if (req.errFn) {
-            req.errFn.call(undefined, response);
-            return;
-          }
-          fireServerError(response, fetchReq);
-        }
-        return response;
-      });
+    let xhr;
+    try {
+      xhr = await this.fetch(fetchReq);
+    } catch (err) {
+      fireNetworkError(err as Error);
+      if (req.errFn) {
+        await req.errFn.call(undefined, null, err as Error);
+        xhr = undefined;
+      } else {
+        throw err;
+      }
+    }
+    if (xhr && !xhr.ok) {
+      if (req.errFn) {
+        await req.errFn.call(undefined, xhr);
+      } else {
+        fireServerError(xhr, fetchReq);
+      }
+    }
 
     if (req.parseResponse) {
-      // TODO(TS): remove as Response and fix error.
-      // Javascript code allows returning of a Response object from errFn.
-      // This can be a mistake and we should add check here or it can be used
-      // somewhere - in this case we should fix it carefully (define
-      // different type of callback if parseResponse is true, etc...).
-      return xhr.then(res => this.getResponseObject(res as Response));
+      xhr = xhr && this.getResponseObject(xhr);
     }
-    // The actual xhr type is Promise<Response|undefined|void> because of the
-    // catch callback
-    return xhr as Promise<Response | undefined>;
+    return xhr;
   }
 
   invalidateFetchPromisesPrefix(prefix: string) {
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper_test.ts b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper_test.ts
index 712ece4..9f0319e 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper_test.ts
@@ -9,14 +9,15 @@
   FetchPromisesCache,
   GrRestApiHelper,
 } from './gr-rest-api-helper';
-import {getAppContext} from '../../../../services/app-context';
-import {stubAuth, waitEventLoop} from '../../../../test/test-utils';
+import {assertFails, waitEventLoop} from '../../../../test/test-utils';
 import {FakeScheduler} from '../../../../services/scheduler/fake-scheduler';
 import {RetryScheduler} from '../../../../services/scheduler/retry-scheduler';
 import {ParsedJSON} from '../../../../types/common';
 import {HttpMethod} from '../../../../api/rest-api';
 import {SinonFakeTimers} from 'sinon';
 import {assert} from '@open-wc/testing';
+import {AuthService} from '../../../../services/gr-auth/gr-auth';
+import {GrAuthMock} from '../../../../services/gr-auth/gr-auth_mock';
 
 function makeParsedJSON<T>(val: T): ParsedJSON {
   return val as unknown as ParsedJSON;
@@ -32,6 +33,7 @@
   let authFetchStub: sinon.SinonStub;
   let readScheduler: FakeScheduler<Response>;
   let writeScheduler: FakeScheduler<Response>;
+  let authService: AuthService;
 
   setup(() => {
     clock = sinon.useFakeTimers();
@@ -42,7 +44,8 @@
     window.CANONICAL_PATH = 'testhelper';
 
     const testJSON = ')]}\'\n{"hello": "bonjour"}';
-    authFetchStub = stubAuth('fetch').returns(
+    authService = new GrAuthMock();
+    authFetchStub = sinon.stub(authService, 'fetch').returns(
       Promise.resolve({
         ...new Response(),
         ok: true,
@@ -57,7 +60,7 @@
 
     helper = new GrRestApiHelper(
       cache,
-      getAppContext().authService,
+      authService,
       fetchPromisesCache,
       readScheduler,
       writeScheduler
@@ -226,6 +229,79 @@
     assert.isTrue(cancelCalled);
   });
 
+  suite('throwing in errFn', () => {
+    function throwInPromise(response?: Response | null, _?: Error) {
+      return response?.text().then(text => {
+        throw new Error(text);
+      });
+    }
+
+    function throwImmediately(_1?: Response | null, _2?: Error) {
+      throw new Error('Error Callback error');
+    }
+
+    setup(() => {
+      authFetchStub.returns(
+        Promise.resolve({
+          ...new Response(),
+          status: 400,
+          ok: false,
+          text() {
+            return Promise.resolve('Nope');
+          },
+        })
+      );
+    });
+
+    test('errFn with Promise throw cause send to reject on error', async () => {
+      const promise = helper.send({
+        method: HttpMethod.GET,
+        url: '/dummy/url',
+        parseResponse: false,
+        errFn: throwInPromise,
+      });
+      await assertReadRequest();
+
+      const err = await assertFails(promise);
+      assert.equal((err as Error).message, 'Nope');
+    });
+
+    test('errFn with Promise throw cause fetchJSON to reject on error', async () => {
+      const promise = helper.fetchJSON({
+        url: '/dummy/url',
+        errFn: throwInPromise,
+      });
+      await assertReadRequest();
+
+      const err = await assertFails(promise);
+      assert.equal((err as Error).message, 'Nope');
+    });
+
+    test('errFn with immediate throw cause send to reject on error', async () => {
+      const promise = helper.send({
+        method: HttpMethod.GET,
+        url: '/dummy/url',
+        parseResponse: false,
+        errFn: throwImmediately,
+      });
+      await assertReadRequest();
+
+      const err = await assertFails(promise);
+      assert.equal((err as Error).message, 'Error Callback error');
+    });
+
+    test('errFn with immediate Promise cause fetchJSON to reject on error', async () => {
+      const promise = helper.fetchJSON({
+        url: '/dummy/url',
+        errFn: throwImmediately,
+      });
+      await assertReadRequest();
+
+      const err = await assertFails(promise);
+      assert.equal((err as Error).message, 'Error Callback error');
+    });
+  });
+
   suite('429 errors', () => {
     setup(() => {
       authFetchStub.returns(
@@ -270,7 +346,7 @@
     test('are retried', async () => {
       helper = new GrRestApiHelper(
         cache,
-        getAppContext().authService,
+        authService,
         fetchPromisesCache,
         new RetryScheduler<Response>(readScheduler, 1, 50),
         writeScheduler
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser_test.js b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser_test.js
index 9c87910..4225173 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser_test.js
@@ -97,11 +97,11 @@
     const date2 = '2017-01-26 12:11:55.000000000'; // Within threshold.
     const date3 = '2017-01-26 12:33:50.000000000';
     const date4 = '2017-01-26 12:44:50.000000000';
-    const makeItem = function(state, reviewer, opt_date, opt_author) {
+    const makeItem = function(state, reviewer, date, author) {
       return {
         reviewer,
-        updated: opt_date || date1,
-        updated_by: opt_author || reviewer1,
+        updated: date || date1,
+        updated_by: author || reviewer1,
         state,
       };
     };
@@ -173,9 +173,9 @@
   test('format reviewer updates', () => {
     const reviewer1 = {_account_id: 1};
     const reviewer2 = {_account_id: 2};
-    const makeItem = function(prev, state, opt_reviewer) {
+    const makeItem = function(prev, state, reviewer) {
       return {
-        reviewer: opt_reviewer || reviewer1,
+        reviewer: reviewer || reviewer1,
         prev_state: prev,
         state,
       };
diff --git a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
index 4d683ef..7779fff 100644
--- a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
+++ b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
@@ -5,7 +5,6 @@
  */
 import '../gr-autocomplete-dropdown/gr-autocomplete-dropdown';
 import '../gr-cursor-manager/gr-cursor-manager';
-import '../gr-overlay/gr-overlay';
 import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
 import '../../../styles/shared-styles';
 import {getAppContext} from '../../../services/app-context';
@@ -13,17 +12,16 @@
 import {
   GrAutocompleteDropdown,
   Item,
-  ItemSelectedEvent,
+  ItemSelectedEventDetail,
 } from '../gr-autocomplete-dropdown/gr-autocomplete-dropdown';
 import {Key} from '../../../utils/dom-util';
 import {ValueChangedEvent} from '../../../types/events';
 import {fire} from '../../../utils/event-util';
-import {LitElement, css, html, nothing} from 'lit';
+import {LitElement, css, html} from 'lit';
 import {customElement, property, query, state} from 'lit/decorators.js';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {PropertyValues} from 'lit';
 import {classMap} from 'lit/directives/class-map.js';
-import {KnownExperimentId} from '../../../services/flags/flags';
 import {NumericChangeId, ServerInfo} from '../../../api/rest-api';
 import {subscribe} from '../../lit/subscription-controller';
 import {resolve} from '../../../models/dependency';
@@ -69,7 +67,7 @@
 
 declare global {
   interface HTMLElementEventMap {
-    'item-selected': CustomEvent<ItemSelectedEvent>;
+    'item-selected': CustomEvent<ItemSelectedEventDetail>;
   }
 }
 
@@ -116,8 +114,6 @@
 
   private readonly getChangeModel = resolve(this, changeModelToken);
 
-  private readonly flagsService = getAppContext().flagsService;
-
   private readonly restApiService = getAppContext().restApiService;
 
   private readonly getConfigModel = resolve(this, configModelToken);
@@ -234,9 +230,7 @@
       hiddenText in order to correctly position the dropdown. After being moved,
       it is set as the positionTarget for the emojiSuggestions dropdown. -->
       <span id="caratSpan"></span>
-      ${this.renderEmojiDropdown()}
-      ${this.renderMentionsDropdown()}
-      </gr-autocomplete-dropdown>
+      ${this.renderEmojiDropdown()} ${this.renderMentionsDropdown()}
       <iron-autogrow-textarea
         id="textarea"
         class=${classMap({noBorder: this.hideBorder})}
@@ -268,8 +262,6 @@
   }
 
   private renderMentionsDropdown() {
-    if (!this.flagsService.isEnabled(KnownExperimentId.MENTION_USERS))
-      return nothing;
     return html` <gr-autocomplete-dropdown
       id="mentionsSuggestions"
       .suggestions=${this.suggestions}
@@ -302,14 +294,16 @@
     return this.textarea!.textarea;
   }
 
+  override focus() {
+    this.textarea?.textarea.focus();
+  }
+
   putCursorAtEnd() {
     const textarea = this.getNativeTextarea();
     // Put the cursor at the end always.
     textarea.selectionStart = textarea.value.length;
     textarea.selectionEnd = textarea.selectionStart;
-    setTimeout(() => {
-      textarea.focus();
-    });
+    textarea.focus();
   }
 
   private getVisibleDropdown() {
@@ -343,7 +337,7 @@
     e.preventDefault();
     e.stopPropagation();
     this.getVisibleDropdown().cursorUp();
-    this.textarea!.textarea.focus();
+    this.focus();
   }
 
   private handleDownKey(e: KeyboardEvent) {
@@ -353,16 +347,12 @@
     e.preventDefault();
     e.stopPropagation();
     this.getVisibleDropdown().cursorDown();
-    this.textarea!.textarea.focus();
+    this.focus();
   }
 
   private handleTabKey(e: KeyboardEvent) {
-    // Tab should have normal behavior if the picker is closed or if the user
-    // has only typed ':'.
-    if (
-      !this.isDropdownVisible() ||
-      (this.isEmojiDropdownActive() && this.currentSearchString === '')
-    ) {
+    // Tab should have normal behavior if the picker is closed.
+    if (!this.isDropdownVisible()) {
       return;
     }
     e.preventDefault();
@@ -372,12 +362,9 @@
 
   // private but used in test
   handleEnterByKey(e: KeyboardEvent) {
-    // Enter should have newline behavior if the picker is closed or if the user
-    // has only typed ':'. Also make sure that shortcuts aren't clobbered.
-    if (
-      !this.isDropdownVisible() ||
-      (this.isEmojiDropdownActive() && this.currentSearchString === '')
-    ) {
+    // Enter should have newline behavior if the picker is closed. Also make
+    // sure that shortcuts aren't clobbered.
+    if (!this.isDropdownVisible()) {
       this.indent(e);
       return;
     }
@@ -388,7 +375,7 @@
   }
 
   // private but used in test
-  handleDropdownItemSelect(e: CustomEvent<ItemSelectedEvent>) {
+  handleDropdownItemSelect(e: CustomEvent<ItemSelectedEventDetail>) {
     if (e.detail.selected?.dataset['value']) {
       this.setValue(e.detail.selected?.dataset['value']);
     }
@@ -488,14 +475,19 @@
   }
 
   private async computeSuggestions() {
+    this.suggestions = [];
     if (this.currentSearchString === undefined) {
-      this.suggestions = [];
       return;
     }
+    const searchString = this.currentSearchString;
+    let suggestions: (Item | EmojiSuggestion)[] = [];
     if (this.isEmojiDropdownActive()) {
-      this.computeEmojiSuggestions(this.currentSearchString);
+      suggestions = this.computeEmojiSuggestions(this.currentSearchString);
     } else if (this.isMentionsDropdownActive()) {
-      await this.computeReviewerSuggestions();
+      suggestions = await this.computeReviewerSuggestions();
+    }
+    if (searchString === this.currentSearchString) {
+      this.suggestions = suggestions;
     }
   }
 
@@ -532,8 +524,6 @@
   }
 
   private isMentionsDropdownActive() {
-    if (!this.flagsService.isEnabled(KnownExperimentId.MENTION_USERS))
-      return false;
     return (
       this.specialCharIndex !== -1 && this.text[this.specialCharIndex] === '@'
     );
@@ -548,10 +538,8 @@
   private computeSpecialCharIndex() {
     const charAtCursor = this.text[this.textarea!.selectionStart - 1];
 
-    if (this.flagsService.isEnabled(KnownExperimentId.MENTION_USERS)) {
-      if (charAtCursor === '@' && this.specialCharIndex === -1) {
-        this.specialCharIndex = this.getSpecialCharIndex(this.text);
-      }
+    if (charAtCursor === '@' && this.specialCharIndex === -1) {
+      this.specialCharIndex = this.getSpecialCharIndex(this.text);
     }
     if (charAtCursor === ':' && this.specialCharIndex === -1) {
       this.specialCharIndex = this.getSpecialCharIndex(this.text);
@@ -573,7 +561,7 @@
   async handleTextChanged() {
     await this.computeSuggestions();
     this.openOrResetDropdown();
-    this.textarea!.textarea.focus();
+    this.focus();
   }
 
   private openEmojiDropdown() {
@@ -587,7 +575,7 @@
   }
 
   // private but used in test
-  formatSuggestions(matchedSuggestions: EmojiSuggestion[]) {
+  formatSuggestions(matchedSuggestions: EmojiSuggestion[]): EmojiSuggestion[] {
     const suggestions = [];
     for (const suggestion of matchedSuggestions) {
       assert(isEmojiSuggestion(suggestion), 'malformed suggestion');
@@ -595,28 +583,27 @@
       suggestion.text = `${suggestion.value} ${suggestion.match}`;
       suggestions.push(suggestion);
     }
-    this.suggestions = suggestions;
+    return suggestions;
   }
 
   // private but used in test
-  computeEmojiSuggestions(suggestionsText?: string) {
+  computeEmojiSuggestions(suggestionsText?: string): EmojiSuggestion[] {
     if (suggestionsText === undefined) {
-      this.suggestions = [];
-      return;
+      return [];
     }
     if (!suggestionsText.length) {
-      this.formatSuggestions(ALL_SUGGESTIONS);
+      return this.formatSuggestions(ALL_SUGGESTIONS);
     } else {
       const matches = ALL_SUGGESTIONS.filter(suggestion =>
         suggestion.match.includes(suggestionsText)
       ).slice(0, MAX_ITEMS_DROPDOWN);
-      this.formatSuggestions(matches);
+      return this.formatSuggestions(matches);
     }
   }
 
   // TODO(dhruvsri): merge with getAccountSuggestions in account-util
-  async computeReviewerSuggestions() {
-    this.suggestions = (
+  async computeReviewerSuggestions(): Promise<Item[]> {
+    return (
       (await this.restApiService.getSuggestedAccounts(
         this.currentSearchString ?? '',
         /* number= */ 15,
@@ -644,13 +631,7 @@
   }
 
   private fireChangedEvents() {
-    // This is a bit redundant, because the `text` property has `notify:true`,
-    // so whenever the `text` changes the component fires two identical events
-    // `text-changed` and `value-changed`.
-    fire(this, 'value-changed', {value: this.text});
     fire(this, 'text-changed', {value: this.text});
-    // Relay the event.
-    fire(this, 'bind-value-changed', {value: this.text});
   }
 
   private indent(e: KeyboardEvent): void {
diff --git a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.ts b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.ts
index 78c8aa3..4dcaa80 100644
--- a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.ts
@@ -6,10 +6,13 @@
 import '../../../test/common-test-setup';
 import './gr-textarea';
 import {GrTextarea} from './gr-textarea';
-import {ItemSelectedEvent} from '../gr-autocomplete-dropdown/gr-autocomplete-dropdown';
 import {
+  Item,
+  ItemSelectedEventDetail,
+} from '../gr-autocomplete-dropdown/gr-autocomplete-dropdown';
+import {
+  mockPromise,
   pressKey,
-  stubFlags,
   stubRestApi,
   waitUntil,
 } from '../../../test/test-utils';
@@ -31,14 +34,16 @@
       element,
       /* HTML */ `<div id="hiddenText"></div>
         <span id="caratSpan"> </span>
+        <gr-autocomplete-dropdown id="emojiSuggestions" is-hidden="">
+        </gr-autocomplete-dropdown>
         <gr-autocomplete-dropdown
-          id="emojiSuggestions"
+          id="mentionsSuggestions"
           is-hidden=""
-          style="position: fixed; top: 150px; left: 392.5px; box-sizing: border-box; max-height: 300px; max-width: 785px;"
+          role="listbox"
         >
         </gr-autocomplete-dropdown>
         <iron-autogrow-textarea aria-disabled="false" focused="" id="textarea">
-        </iron-autogrow-textarea> `,
+        </iron-autogrow-textarea>`,
       {
         // gr-autocomplete-dropdown sizing seems to vary between local & CI
         ignoreAttributes: [
@@ -49,52 +54,11 @@
   });
 
   suite('mention users', () => {
-    setup(async () => {
-      stubFlags('isEnabled').returns(true);
-      element.requestUpdate();
-      await element.updateComplete;
-    });
-
-    test('renders', () => {
-      assert.shadowDom.equal(
-        element,
-        /* HTML */ `
-          <div id="hiddenText"></div>
-          <span id="caratSpan"> </span>
-          <gr-autocomplete-dropdown
-            id="emojiSuggestions"
-            is-hidden=""
-            style="position: fixed; top: 478px; left: 321px; box-sizing: border-box; max-height: 956px; max-width: 642px;"
-          >
-          </gr-autocomplete-dropdown>
-          <gr-autocomplete-dropdown
-            id="mentionsSuggestions"
-            is-hidden=""
-            role="listbox"
-            style="position: fixed; top: 478px; left: 321px; box-sizing: border-box; max-height: 956px; max-width: 642px;"
-          >
-          </gr-autocomplete-dropdown>
-          <iron-autogrow-textarea
-            focused=""
-            aria-disabled="false"
-            id="textarea"
-          >
-          </iron-autogrow-textarea>
-        `,
-        {
-          // gr-autocomplete-dropdown sizing seems to vary between local & CI
-          ignoreAttributes: [
-            {tags: ['gr-autocomplete-dropdown'], attributes: ['style']},
-          ],
-        }
-      );
-    });
-
     test('mentions selector is open when @ is typed & the textarea has focus', async () => {
       // Needed for Safari tests. selectionStart is not updated when text is
       // updated.
       const listenerStub = sinon.stub();
-      element.addEventListener('bind-value-changed', listenerStub);
+      element.addEventListener('text-changed', listenerStub);
       stubRestApi('getSuggestedAccounts').returns(
         Promise.resolve([
           createAccountWithEmail('abc@google.com'),
@@ -164,6 +128,98 @@
       assert.isFalse(element.mentionsSuggestions!.isHidden);
     });
 
+    test('mention suggestions cleared before request returns', async () => {
+      const promise = mockPromise<Item[]>();
+      stubRestApi('getSuggestedAccounts').returns(promise);
+      element.textarea!.focus();
+      await waitUntil(() => element.textarea!.focused === true);
+
+      element.suggestions = [
+        {dataValue: 'prior@google.com', text: 'Prior suggestion'},
+      ];
+      element.textarea!.selectionStart = 1;
+      element.textarea!.selectionEnd = 1;
+      element.text = '@';
+
+      await element.updateComplete;
+      assert.equal(element.suggestions.length, 0);
+
+      promise.resolve([
+        createAccountWithEmail('abc@google.com'),
+        createAccountWithEmail('abcdef@google.com'),
+      ]);
+      await waitUntil(() => element.suggestions.length !== 0);
+      assert.deepEqual(element.suggestions, [
+        {
+          dataValue: 'abc@google.com',
+          text: 'abc@google.com <abc@google.com>',
+        },
+        {
+          dataValue: 'abcdef@google.com',
+          text: 'abcdef@google.com <abcdef@google.com>',
+        },
+      ]);
+    });
+
+    test('mention dropdown shows suggestion for latest text', async () => {
+      const promise1 = mockPromise<Item[]>();
+      const promise2 = mockPromise<Item[]>();
+      const suggestionStub = stubRestApi('getSuggestedAccounts');
+      suggestionStub.returns(promise1);
+      element.textarea!.focus();
+      await waitUntil(() => element.textarea!.focused === true);
+
+      element.textarea!.selectionStart = 1;
+      element.textarea!.selectionEnd = 1;
+      element.text = '@';
+      await element.updateComplete;
+      assert.equal(element.currentSearchString, '');
+
+      suggestionStub.returns(promise2);
+      element.text = '@abc@google.com';
+      // None of suggestions returned yet.
+      assert.equal(element.suggestions.length, 0);
+      await element.updateComplete;
+      assert.equal(element.currentSearchString, 'abc@google.com');
+
+      promise2.resolve([
+        createAccountWithEmail('abc@google.com'),
+        createAccountWithEmail('abcdef@google.com'),
+      ]);
+
+      await waitUntil(() => element.suggestions.length !== 0);
+      assert.deepEqual(element.suggestions, [
+        {
+          dataValue: 'abc@google.com',
+          text: 'abc@google.com <abc@google.com>',
+        },
+        {
+          dataValue: 'abcdef@google.com',
+          text: 'abcdef@google.com <abcdef@google.com>',
+        },
+      ]);
+
+      promise1.resolve([
+        createAccountWithEmail('dce@google.com'),
+        createAccountWithEmail('defcba@google.com'),
+      ]);
+      // Empty the event queue.
+      await new Promise<void>(resolve => {
+        setTimeout(() => resolve());
+      });
+      // Suggestions didn't change
+      assert.deepEqual(element.suggestions, [
+        {
+          dataValue: 'abc@google.com',
+          text: 'abc@google.com <abc@google.com>',
+        },
+        {
+          dataValue: 'abcdef@google.com',
+          text: 'abcdef@google.com <abcdef@google.com>',
+        },
+      ]);
+    });
+
     test('emoji selector does not open when previous char is \n', async () => {
       element.textarea!.focus();
       await waitUntil(() => element.textarea!.focused === true);
@@ -210,7 +266,7 @@
 
     test('emoji dropdown does not open if mention dropdown is open', async () => {
       const listenerStub = sinon.stub();
-      element.addEventListener('bind-value-changed', listenerStub);
+      element.addEventListener('text-changed', listenerStub);
       const resetSpy = sinon.spy(element, 'resetDropdown');
       stubRestApi('getSuggestedAccounts').returns(
         Promise.resolve([
@@ -239,21 +295,25 @@
       assert.isFalse(element.mentionsSuggestions!.isHidden);
 
       element.text = '@h';
+      await waitUntil(() => element.suggestions.length > 0);
       await element.updateComplete;
       assert.isTrue(element.emojiSuggestions!.isHidden);
       assert.isFalse(element.mentionsSuggestions!.isHidden);
 
       element.text = '@h ';
+      await waitUntil(() => element.suggestions.length > 0);
       await element.updateComplete;
       assert.isTrue(element.emojiSuggestions!.isHidden);
       assert.isFalse(element.mentionsSuggestions!.isHidden);
 
       element.text = '@h :';
+      await waitUntil(() => element.suggestions.length > 0);
       await element.updateComplete;
       assert.isTrue(element.emojiSuggestions!.isHidden);
       assert.isFalse(element.mentionsSuggestions!.isHidden);
 
       element.text = '@h :D';
+      await waitUntil(() => element.suggestions.length > 0);
       await element.updateComplete;
       assert.isTrue(element.emojiSuggestions!.isHidden);
       assert.isFalse(element.mentionsSuggestions!.isHidden);
@@ -261,7 +321,7 @@
 
     test('mention dropdown does not open if emoji dropdown is open', async () => {
       const listenerStub = sinon.stub();
-      element.addEventListener('bind-value-changed', listenerStub);
+      element.addEventListener('text-changed', listenerStub);
       element.textarea!.focus();
       await waitUntil(() => element.textarea!.focused === true);
 
@@ -355,7 +415,7 @@
     // Needed for Safari tests. selectionStart is not updated when text is
     // updated.
     const listenerStub = sinon.stub();
-    element.addEventListener('bind-value-changed', listenerStub);
+    element.addEventListener('text-changed', listenerStub);
     element.textarea!.focus();
     await waitUntil(() => element.textarea!.focused === true);
     element.textarea!.selectionStart = 1;
@@ -509,13 +569,13 @@
       {value: '😢', match: 'tear'},
       {value: '😂', match: 'tears'},
     ];
-    element.formatSuggestions(matchedSuggestions);
+    const suggestions = element.formatSuggestions(matchedSuggestions);
     assert.deepEqual(
       [
         {value: '😢', dataValue: '😢', match: 'tear', text: '😢 tear'},
         {value: '😂', dataValue: '😂', match: 'tears', text: '😂 tears'},
       ],
-      element.suggestions
+      suggestions
     );
   });
 
@@ -526,7 +586,7 @@
     element.specialCharIndex = 10;
     await element.updateComplete;
     const selectedItem = {dataset: {value: '😂'}} as unknown as HTMLElement;
-    const event = new CustomEvent<ItemSelectedEvent>('item-selected', {
+    const event = new CustomEvent<ItemSelectedEventDetail>('item-selected', {
       detail: {trigger: 'click', selected: selectedItem},
     });
     element.handleDropdownItemSelect(event);
@@ -553,7 +613,7 @@
     assert.deepEqual(indentCommand.args[0], ['insertText', false, '\n    ']);
   });
 
-  test('emoji dropdown is closed when iron-overlay-closed is fired', async () => {
+  test('emoji dropdown is closed when dropdown-closed is fired', async () => {
     const resetSpy = sinon.spy(element, 'closeDropdown');
     element.emojiSuggestions!.dispatchEvent(
       new CustomEvent('dropdown-closed', {
@@ -617,28 +677,9 @@
       await element.updateComplete;
       assert.equal(element.text, '💯');
     });
-
-    test('enter key - ignored on just colon without more information', async () => {
-      const enterSpy = sinon.spy(element.emojiSuggestions!, 'getCursorTarget');
-      pressKey(element.textarea! as HTMLElement, Key.ENTER);
-      assert.isFalse(enterSpy.called);
-      element.textarea!.focus();
-      element.textarea!.selectionStart = 1;
-      element.textarea!.selectionEnd = 1;
-      element.text = ':';
-      await element.updateComplete;
-      pressKey(element.textarea! as HTMLElement, Key.ENTER);
-      assert.isFalse(enterSpy.called);
-    });
   });
 
   suite('gr-textarea monospace', () => {
-    // gr-textarea set monospace class in the ready() method.
-    // In Polymer2, ready() is called from the fixture(...) method,
-    // If ready() is called again later, some nested elements doesn't
-    // handle it correctly. A separate test-fixture is used to set
-    // properties before ready() is called.
-
     let element: GrTextarea;
 
     setup(async () => {
@@ -654,12 +695,6 @@
   });
 
   suite('gr-textarea hideBorder', () => {
-    // gr-textarea set noBorder class in the ready() method.
-    // In Polymer2, ready() is called from the fixture(...) method,
-    // If ready() is called again later, some nested elements doesn't
-    // handle it correctly. A separate test-fixture is used to set
-    // properties before ready() is called.
-
     let element: GrTextarea;
 
     setup(async () => {
diff --git a/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content.ts b/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content.ts
index 0e6b19e..4ccc635 100644
--- a/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content.ts
+++ b/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content.ts
@@ -5,7 +5,6 @@
  */
 import '../gr-icon/gr-icon';
 import '../gr-tooltip/gr-tooltip';
-import {getRootElement} from '../../../scripts/rootElement';
 import {GrTooltip} from '../gr-tooltip/gr-tooltip';
 import {css, html, LitElement, PropertyValues} from 'lit';
 import {customElement, property, state} from 'lit/decorators.js';
@@ -142,7 +141,7 @@
     // Set visibility to hidden before appending to the DOM so that
     // calculations can be made based on the element’s size.
     tooltip.style.visibility = 'hidden';
-    getRootElement().appendChild(tooltip);
+    document.body.appendChild(tooltip);
     await tooltip.updateComplete;
     this._positionTooltip(tooltip);
     tooltip.style.visibility = 'initial';
diff --git a/polygerrit-ui/app/elements/shared/gr-user-suggestion-fix/gr-user-suggestion-fix.ts b/polygerrit-ui/app/elements/shared/gr-user-suggestion-fix/gr-user-suggestion-fix.ts
new file mode 100644
index 0000000..ff068a7
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-user-suggestion-fix/gr-user-suggestion-fix.ts
@@ -0,0 +1,111 @@
+/**
+ * @license
+ * Copyright 2023 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {css, html, LitElement, nothing} from 'lit';
+import {customElement} from 'lit/decorators.js';
+import {getAppContext} from '../../../services/app-context';
+import {KnownExperimentId} from '../../../services/flags/flags';
+import {fire} from '../../../utils/event-util';
+
+declare global {
+  interface HTMLElementEventMap {
+    'open-user-suggest-preview': OpenUserSuggestionPreviewEvent;
+  }
+}
+
+export type OpenUserSuggestionPreviewEvent =
+  CustomEvent<OpenUserSuggestionPreviewEventDetail>;
+export interface OpenUserSuggestionPreviewEventDetail {
+  code: string;
+}
+
+@customElement('gr-user-suggestion-fix')
+export class GrUserSuggetionFix extends LitElement {
+  private readonly flagsService = getAppContext().flagsService;
+
+  static override styles = [
+    css`
+      .header {
+        background-color: var(--background-color-primary);
+        border: 1px solid var(--border-color);
+        padding: var(--spacing-xs) var(--spacing-xl);
+        display: flex;
+        align-items: center;
+        border-top-left-radius: var(--border-radius);
+        border-top-right-radius: var(--border-radius);
+      }
+      .header .title {
+        flex: 1;
+      }
+      .copyButton {
+        margin-right: var(--spacing-l);
+      }
+      code {
+        max-width: var(--gr-formatted-text-prose-max-width, none);
+        background-color: var(--background-color-secondary);
+        border: 1px solid var(--border-color);
+        border-top: 0;
+        display: block;
+        font-family: var(--monospace-font-family);
+        font-size: var(--font-size-code);
+        line-height: var(--line-height-mono);
+        margin-bottom: var(--spacing-m);
+        padding: var(--spacing-xxs) var(--spacing-s);
+        overflow-x: auto;
+        /* Pre will preserve whitespace and line breaks but not wrap */
+        white-space: pre;
+        border-bottom-left-radius: var(--border-radius);
+        border-bottom-right-radius: var(--border-radius);
+      }
+    `,
+  ];
+
+  override render() {
+    if (!this.flagsService.isEnabled(KnownExperimentId.SUGGEST_EDIT)) {
+      return nothing;
+    }
+    if (!this.textContent) return nothing;
+    const code = this.textContent;
+    return html`<div class="header">
+        <div class="title">
+          <span>Suggested edit</span>
+          <a
+            href="https://gerrit-review.googlesource.com/Documentation/user-suggest-edits.html"
+            target="_blank"
+            ><gr-icon icon="help" title="read documentation"></gr-icon
+          ></a>
+        </div>
+        <div class="copyButton">
+          <gr-copy-clipboard
+            hideInput=""
+            text=${code}
+            copyTargetName="Suggested edit"
+          ></gr-copy-clipboard>
+        </div>
+        <div>
+          <gr-button
+            secondary
+            flatten
+            class="action show-fix"
+            @click=${this.handleShowFix}
+          >
+            Show edit
+          </gr-button>
+        </div>
+      </div>
+      <code>${code}</code>`;
+  }
+
+  handleShowFix() {
+    if (!this.textContent) return;
+    fire(this, 'open-user-suggest-preview', {code: this.textContent});
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-user-suggestion-fix': GrUserSuggetionFix;
+  }
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-user-suggestion-fix/gr-user-suggestion-fix_test.ts b/polygerrit-ui/app/elements/shared/gr-user-suggestion-fix/gr-user-suggestion-fix_test.ts
new file mode 100644
index 0000000..aecd93b
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-user-suggestion-fix/gr-user-suggestion-fix_test.ts
@@ -0,0 +1,54 @@
+/**
+ * @license
+ * Copyright 2023 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-user-suggestion-fix';
+import {fixture, html, assert} from '@open-wc/testing';
+import {GrUserSuggetionFix} from './gr-user-suggestion-fix';
+import {getAppContext} from '../../../services/app-context';
+
+suite('gr-user-suggestion-fix tests', () => {
+  let element: GrUserSuggetionFix;
+
+  setup(async () => {
+    const flagsService = getAppContext().flagsService;
+    sinon.stub(flagsService, 'isEnabled').returns(true);
+    element = await fixture<GrUserSuggetionFix>(html`
+      <gr-user-suggestion-fix>Hello World</gr-user-suggestion-fix>
+    `);
+    await element.updateComplete;
+  });
+
+  test('render', async () => {
+    await element.updateComplete;
+
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `<div class="header">
+          <div class="title">
+            <span>Suggested edit</span>
+            <a
+              href="https://gerrit-review.googlesource.com/Documentation/user-suggest-edits.html"
+              target="_blank"
+              ><gr-icon icon="help" title="read documentation"></gr-icon
+            ></a>
+          </div>
+          <div class="copyButton">
+            <gr-copy-clipboard
+              hideinput=""
+              text="Hello World"
+              copytargetname="Suggested edit"
+            ></gr-copy-clipboard>
+          </div>
+          <div>
+            <gr-button class="action show-fix" secondary="" flatten=""
+              >Show edit</gr-button
+            >
+          </div>
+        </div>
+        <code>Hello World</code>`
+    );
+  });
+});
diff --git a/polygerrit-ui/app/elements/shared/gr-weblink/gr-weblink.ts b/polygerrit-ui/app/elements/shared/gr-weblink/gr-weblink.ts
new file mode 100644
index 0000000..fb6372c
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-weblink/gr-weblink.ts
@@ -0,0 +1,71 @@
+/**
+ * @license
+ * Copyright 2023 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../gr-tooltip-content/gr-tooltip-content';
+import {LitElement, css, html, nothing} from 'lit';
+import {customElement, property} from 'lit/decorators.js';
+import {WebLinkInfo} from '../../../api/rest-api';
+import {ifDefined} from 'lit/directives/if-defined.js';
+import {when} from 'lit/directives/when.js';
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-weblink': GrWeblink;
+  }
+}
+@customElement('gr-weblink')
+export class GrWeblink extends LitElement {
+  @property({type: Object})
+  info?: WebLinkInfo;
+
+  @property({type: Boolean})
+  imageAndText = false;
+
+  static override get styles() {
+    return [
+      css`
+        :host {
+          display: inline-block;
+          vertical-align: top;
+          line-height: var(--line-height-normal);
+          margin-right: var(--spacing-s);
+        }
+        a {
+          color: var(--link-color);
+        }
+        :host([imageAndText]) img {
+          margin-right: var(--spacing-s);
+        }
+        img {
+          vertical-align: top;
+          width: var(--line-height-normal);
+          height: var(--line-height-normal);
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    if (!this.info?.url) return nothing;
+    if (!this.info?.name) return nothing;
+
+    return html`
+      <a href=${this.info.url} rel="noopener" target="_blank">
+        <gr-tooltip-content
+          title=${ifDefined(this.info.tooltip)}
+          ?has-tooltip=${this.info.tooltip !== undefined}
+        >
+          ${when(
+            this.info.image_url,
+            () => html`<img src=${this.info!.image_url!} />`
+          )}${when(
+            !this.info.image_url || this.imageAndText,
+            () => html`<span>${this.info!.name}</span>`
+          )}
+        </gr-tooltip-content>
+      </a>
+    `;
+  }
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-weblink/gr-weblink_test.ts b/polygerrit-ui/app/elements/shared/gr-weblink/gr-weblink_test.ts
new file mode 100644
index 0000000..acb309f
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-weblink/gr-weblink_test.ts
@@ -0,0 +1,56 @@
+/**
+ * @license
+ * Copyright 2023 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import {fixture, assert} from '@open-wc/testing';
+import {html} from 'lit';
+import './gr-weblink';
+import {GrWeblink} from './gr-weblink';
+import {WebLinkInfo} from '../../../api/rest-api';
+
+suite('gr-weblink tests', () => {
+  test('renders with image', async () => {
+    const info: WebLinkInfo = {
+      name: 'gitiles',
+      url: 'https://www.google.com',
+      image_url: 'https://www.google.com/favicon.ico',
+      tooltip: 'Open in Gitiles',
+    };
+    const element = await fixture<GrWeblink>(
+      html`<gr-weblink .info=${info}></gr-weblink>`
+    );
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <a href="https://www.google.com" rel="noopener" target="_blank">
+          <gr-tooltip-content title="Open in Gitiles" has-tooltip>
+            <img src="https://www.google.com/favicon.ico" />
+          </gr-tooltip-content>
+        </a>
+      `
+    );
+  });
+
+  test('renders with text', async () => {
+    const info: WebLinkInfo = {
+      name: 'gitiles',
+      url: 'https://www.google.com',
+      tooltip: 'Open in Gitiles',
+    };
+    const element = await fixture<GrWeblink>(
+      html`<gr-weblink .info=${info}></gr-weblink>`
+    );
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <a href="https://www.google.com" rel="noopener" target="_blank">
+          <gr-tooltip-content title="Open in Gitiles" has-tooltip>
+            <span>gitiles</span>
+          </gr-tooltip-content>
+        </a>
+      `
+    );
+  });
+});
diff --git a/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls-section.ts b/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls-section.ts
new file mode 100644
index 0000000..79c40de
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls-section.ts
@@ -0,0 +1,132 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../elements/shared/gr-button/gr-button';
+import {html, LitElement} from 'lit';
+import {customElement, property, state} from 'lit/decorators.js';
+import {DiffInfo, DiffViewMode, RenderPreferences} from '../../../api/diff';
+import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group';
+import {diffClasses} from '../gr-diff/gr-diff-utils';
+import {getShowConfig} from './gr-context-controls';
+import {ifDefined} from 'lit/directives/if-defined.js';
+import {when} from 'lit/directives/when.js';
+
+@customElement('gr-context-controls-section')
+export class GrContextControlsSection extends LitElement {
+  /** Should context controls be rendered for expanding above the section? */
+  @property({type: Boolean}) showAbove = false;
+
+  /** Should context controls be rendered for expanding below the section? */
+  @property({type: Boolean}) showBelow = false;
+
+  /** Must be of type GrDiffGroupType.CONTEXT_CONTROL. */
+  @property({type: Object})
+  group?: GrDiffGroup;
+
+  @property({type: Object})
+  diff?: DiffInfo;
+
+  @property({type: Object})
+  renderPrefs?: RenderPreferences;
+
+  /**
+   * Semantic DOM diff testing does not work with just table fragments, so when
+   * running such tests the render() method has to wrap the DOM in a proper
+   * <table> element.
+   */
+  @state()
+  addTableWrapperForTesting = false;
+
+  /**
+   * The browser API for handling selection does not (yet) work for selection
+   * across multiple shadow DOM elements. So we are rendering gr-diff components
+   * into the light DOM instead of the shadow DOM by overriding this method,
+   * which was the recommended workaround by the lit team.
+   * See also https://github.com/WICG/webcomponents/issues/79.
+   */
+  override createRenderRoot() {
+    return this;
+  }
+
+  private renderPaddingRow(whereClass: 'above' | 'below') {
+    if (!this.showAbove && whereClass === 'above') return;
+    if (!this.showBelow && whereClass === 'below') return;
+    const modeClass = this.isSideBySide() ? 'side-by-side' : 'unified';
+    const type = this.isSideBySide()
+      ? GrDiffGroupType.CONTEXT_CONTROL
+      : undefined;
+    return html`
+      <tr
+        class=${diffClasses('contextBackground', modeClass, whereClass)}
+        left-type=${ifDefined(type)}
+        right-type=${ifDefined(type)}
+      >
+        <td class=${diffClasses('blame')} data-line-number="0"></td>
+        <td class=${diffClasses('contextLineNum')}></td>
+        ${when(
+          this.isSideBySide(),
+          () => html`
+            <td class=${diffClasses('sign')}></td>
+            <td class=${diffClasses()}></td>
+          `
+        )}
+        <td class=${diffClasses('contextLineNum')}></td>
+        ${when(
+          this.isSideBySide(),
+          () => html`<td class=${diffClasses('sign')}></td>`
+        )}
+        <td class=${diffClasses()}></td>
+      </tr>
+    `;
+  }
+
+  private isSideBySide() {
+    return this.renderPrefs?.view_mode !== DiffViewMode.UNIFIED;
+  }
+
+  private createContextControlRow() {
+    // Note that <td> table cells that have `display: none` don't count!
+    const colspan = this.renderPrefs?.show_sign_col ? '5' : '3';
+    const showConfig = getShowConfig(this.showAbove, this.showBelow);
+    return html`
+      <tr class=${diffClasses('dividerRow', `show-${showConfig}`)}>
+        <td class=${diffClasses('blame')} data-line-number="0"></td>
+        ${when(
+          this.isSideBySide(),
+          () => html`<td class=${diffClasses()}></td>`
+        )}
+        <td class=${diffClasses('dividerCell')} colspan=${colspan}>
+          <gr-context-controls
+            class=${diffClasses()}
+            .diff=${this.diff}
+            .renderPreferences=${this.renderPrefs}
+            .group=${this.group}
+            .showConfig=${showConfig}
+          >
+          </gr-context-controls>
+        </td>
+      </tr>
+    `;
+  }
+
+  override render() {
+    const rows = html`
+      ${this.renderPaddingRow('above')} ${this.createContextControlRow()}
+      ${this.renderPaddingRow('below')}
+    `;
+    if (this.addTableWrapperForTesting) {
+      return html`<table>
+        ${rows}
+      </table>`;
+    }
+    return rows;
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-context-controls-section': GrContextControlsSection;
+  }
+}
diff --git a/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls-section_test.ts b/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls-section_test.ts
new file mode 100644
index 0000000..6a557fc
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls-section_test.ts
@@ -0,0 +1,70 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-context-controls-section';
+import {GrContextControlsSection} from './gr-context-controls-section';
+import {fixture, html, assert} from '@open-wc/testing';
+
+suite('gr-context-controls-section test', () => {
+  let element: GrContextControlsSection;
+
+  setup(async () => {
+    element = await fixture<GrContextControlsSection>(
+      html`<gr-context-controls-section></gr-context-controls-section>`
+    );
+    element.addTableWrapperForTesting = true;
+    await element.updateComplete;
+  });
+
+  test('render: normal with showAbove and showBelow', async () => {
+    element.showAbove = true;
+    element.showBelow = true;
+    await element.updateComplete;
+    assert.lightDom.equal(
+      element,
+      /* HTML */ `
+        <table>
+          <tbody>
+            <tr
+              class="above contextBackground gr-diff side-by-side"
+              left-type="contextControl"
+              right-type="contextControl"
+            >
+              <td class="blame gr-diff" data-line-number="0"></td>
+              <td class="contextLineNum gr-diff"></td>
+              <td class="gr-diff sign"></td>
+              <td class="gr-diff"></td>
+              <td class="contextLineNum gr-diff"></td>
+              <td class="gr-diff sign"></td>
+              <td class="gr-diff"></td>
+            </tr>
+            <tr class="dividerRow gr-diff show-both">
+              <td class="blame gr-diff" data-line-number="0"></td>
+              <td class="gr-diff"></td>
+              <td class="dividerCell gr-diff" colspan="3">
+                <gr-context-controls class="gr-diff" showconfig="both">
+                </gr-context-controls>
+              </td>
+            </tr>
+            <tr
+              class="below contextBackground gr-diff side-by-side"
+              left-type="contextControl"
+              right-type="contextControl"
+            >
+              <td class="blame gr-diff" data-line-number="0"></td>
+              <td class="contextLineNum gr-diff"></td>
+              <td class="gr-diff sign"></td>
+              <td class="gr-diff"></td>
+              <td class="contextLineNum gr-diff"></td>
+              <td class="gr-diff sign"></td>
+              <td class="gr-diff"></td>
+            </tr>
+          </tbody>
+        </table>
+      `
+    );
+  });
+});
diff --git a/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls.ts b/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls.ts
index a0d06f6..4a2fee5 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
@@ -69,6 +69,19 @@
 
 export type GrContextControlsShowConfig = 'above' | 'below' | 'both';
 
+export function getShowConfig(
+  showAbove: boolean,
+  showBelow: boolean
+): GrContextControlsShowConfig {
+  if (showAbove && !showBelow) return 'above';
+  if (!showAbove && showBelow) return 'below';
+
+  // Note that !showAbove && !showBelow also intentionally returns 'both'.
+  // This means the file is completely collapsed, which is unusual, but at least
+  // happens in one test.
+  return 'both';
+}
+
 @customElement('gr-context-controls')
 export class GrContextControls extends LitElement {
   @property({type: Object}) renderPreferences?: RenderPreferences;
@@ -148,6 +161,10 @@
       /* same as defined in gr-button */
       background: rgba(0, 0, 0, 0.12);
     }
+    paper-button:focus-visible {
+      /* paper-button sets this to 0, thus preventing focus-based styling. */
+      outline-width: 1px;
+    }
 
     .aboveBelowButtons {
       display: flex;
diff --git a/polygerrit-ui/app/embed/diff/gr-coverage-layer/gr-coverage-layer.ts b/polygerrit-ui/app/embed/diff/gr-coverage-layer/gr-coverage-layer.ts
index dae5c03..859a49d 100644
--- a/polygerrit-ui/app/embed/diff/gr-coverage-layer/gr-coverage-layer.ts
+++ b/polygerrit-ui/app/embed/diff/gr-coverage-layer/gr-coverage-layer.ts
@@ -4,7 +4,12 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import {Side} from '../../../api/diff';
-import {CoverageRange, CoverageType, DiffLayer} from '../../../types/types';
+import {
+  CoverageRange,
+  CoverageType,
+  DiffLayer,
+  DiffLayerListener,
+} from '../../../types/types';
 
 const TOOLTIP_MAP = new Map([
   [CoverageType.COVERED, 'Covered by tests.'],
@@ -13,6 +18,31 @@
   [CoverageType.NOT_INSTRUMENTED, 'Not instrumented by any tests.'],
 ]);
 
+// Ranges are considered half-open: [start, end)
+export type Range = {start: number; end: number};
+
+export function mergeRanges(ranges: Range[]): Range[] {
+  ranges.sort((a, b) => a.start - b.start);
+
+  if (ranges.length <= 1) {
+    return ranges;
+  }
+
+  const stack: Range[] = [];
+  stack.push(ranges[0]);
+
+  for (let j = 1; j < ranges.length; j++) {
+    const interval = ranges[j];
+    const top = stack[stack.length - 1];
+    if (top.end < interval.start) {
+      stack.push(interval);
+    } else if (top.end < interval.end) {
+      top.end = interval.end;
+    }
+  }
+  return stack;
+}
+
 export class GrCoverageLayer implements DiffLayer {
   /**
    * Must be sorted by code_range.start_line.
@@ -35,14 +65,56 @@
    */
   private index = 0;
 
+  /**
+   * Has any line been annotated already in the lifetime of this layer?
+   * If not, then `setRanges()` does not have to call `notify()` and thus
+   * trigger re-rendering of the affected diff rows.
+   */
+  // visible for testing
+  annotated = false;
+
+  private listeners: DiffLayerListener[] = [];
+
   constructor(private readonly side: Side) {}
 
+  addListener(listener: DiffLayerListener) {
+    this.listeners.push(listener);
+  }
+
+  removeListener(listener: DiffLayerListener) {
+    this.listeners = this.listeners.filter(f => f !== listener);
+  }
+
   /**
    * Must be sorted by code_range.start_line.
    * Must only contain ranges that match the side.
    */
   setRanges(ranges: CoverageRange[]) {
+    const oldRanges = this.coverageRanges;
+    if (oldRanges.length === 0 && ranges.length === 0) return;
     this.coverageRanges = ranges;
+
+    // If ranges are set before any diff row was rendered, then great, no need
+    // to notify and re-render.
+    if (this.annotated) this.notify([...oldRanges, ...ranges]);
+  }
+
+  /**
+   * Notify listeners (should be just gr-diff triggering a re-render).
+   *
+   * We are optimizing the notification calls by converting the coverange ranges
+   * to an array of [start, end) ranges and then merging them to non-overlapping
+   * set of ranges.
+   */
+  private notify(ranges: CoverageRange[]) {
+    const notifyRanges = mergeRanges(
+      ranges.map(r => {
+        return {start: r.code_range.start_line, end: r.code_range.end_line + 1};
+      })
+    );
+    for (const r of notifyRanges) {
+      for (const l of this.listeners) l(r.start, r.end - 1, this.side);
+    }
   }
 
   /**
@@ -74,6 +146,7 @@
       this.index = 0;
     }
     this.lastLineNumber = elementLineNumber;
+    this.annotated = true;
 
     // We simply loop through all the coverage ranges until we find one that
     // matches the line number.
diff --git a/polygerrit-ui/app/embed/diff/gr-coverage-layer/gr-coverage-layer_test.ts b/polygerrit-ui/app/embed/diff/gr-coverage-layer/gr-coverage-layer_test.ts
index c1a123e..a8cdff6 100644
--- a/polygerrit-ui/app/embed/diff/gr-coverage-layer/gr-coverage-layer_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-coverage-layer/gr-coverage-layer_test.ts
@@ -4,51 +4,108 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import '../../../test/common-test-setup';
-import {CoverageRange, CoverageType, Side} from '../../../api/diff';
-import {GrCoverageLayer} from './gr-coverage-layer';
+import {CoverageType, Side} from '../../../api/diff';
+import {GrCoverageLayer, mergeRanges} from './gr-coverage-layer';
 import {assert} from '@open-wc/testing';
+import {SinonStub} from 'sinon';
+
+const RANGES = [
+  {
+    type: CoverageType.COVERED,
+    side: Side.RIGHT,
+    code_range: {
+      start_line: 1,
+      end_line: 2,
+    },
+  },
+  {
+    type: CoverageType.NOT_COVERED,
+    side: Side.RIGHT,
+    code_range: {
+      start_line: 3,
+      end_line: 4,
+    },
+  },
+  {
+    type: CoverageType.PARTIALLY_COVERED,
+    side: Side.RIGHT,
+    code_range: {
+      start_line: 5,
+      end_line: 6,
+    },
+  },
+  {
+    type: CoverageType.NOT_INSTRUMENTED,
+    side: Side.RIGHT,
+    code_range: {
+      start_line: 8,
+      end_line: 9,
+    },
+  },
+];
 
 suite('gr-coverage-layer', () => {
   let layer: GrCoverageLayer;
 
-  setup(() => {
-    const initialCoverageRanges: CoverageRange[] = [
-      {
-        type: CoverageType.COVERED,
-        side: Side.RIGHT,
-        code_range: {
-          start_line: 1,
-          end_line: 2,
-        },
-      },
-      {
-        type: CoverageType.NOT_COVERED,
-        side: Side.RIGHT,
-        code_range: {
-          start_line: 3,
-          end_line: 4,
-        },
-      },
-      {
-        type: CoverageType.PARTIALLY_COVERED,
-        side: Side.RIGHT,
-        code_range: {
-          start_line: 5,
-          end_line: 6,
-        },
-      },
-      {
-        type: CoverageType.NOT_INSTRUMENTED,
-        side: Side.RIGHT,
-        code_range: {
-          start_line: 8,
-          end_line: 9,
-        },
-      },
-    ];
+  test('mergeRanges', () => {
+    assert.deepEqual(mergeRanges([]), []);
+    assert.deepEqual(mergeRanges([{start: 1, end: 2}]), [{start: 1, end: 2}]);
+    assert.deepEqual(
+      mergeRanges([
+        {start: 1, end: 2},
+        {start: 2, end: 3},
+      ]),
+      [{start: 1, end: 3}]
+    );
+    assert.deepEqual(
+      mergeRanges([
+        {start: 2, end: 3},
+        {start: 1, end: 2},
+      ]),
+      [{start: 1, end: 3}]
+    );
+    assert.deepEqual(
+      mergeRanges([
+        {start: 1, end: 3},
+        {start: 4, end: 5},
+      ]),
+      [
+        {start: 1, end: 3},
+        {start: 4, end: 5},
+      ]
+    );
+  });
 
-    layer = new GrCoverageLayer(Side.RIGHT);
-    layer.setRanges(initialCoverageRanges);
+  suite('setRanges and notify', () => {
+    let listener: SinonStub;
+
+    setup(() => {
+      layer = new GrCoverageLayer(Side.RIGHT);
+      listener = sinon.stub();
+      layer.addListener(listener);
+    });
+
+    test('empty ranges do not notify', () => {
+      layer.annotated = true;
+      layer.setRanges([]);
+      assert.isFalse(listener.called);
+    });
+
+    test('do not notify while annotated is false', () => {
+      layer.setRanges(RANGES);
+      assert.isFalse(listener.called);
+    });
+
+    test('RANGES', () => {
+      layer.annotated = true;
+      layer.setRanges(RANGES);
+      assert.isTrue(listener.called);
+      assert.equal(listener.callCount, 2);
+      assert.equal(listener.getCall(0).args[0], 1);
+      assert.equal(listener.getCall(0).args[1], 6);
+      assert.equal(listener.getCall(1).args[0], 8);
+      assert.equal(listener.getCall(1).args[1], 9);
+    });
   });
 
   suite('annotate', () => {
@@ -73,6 +130,11 @@
       assert.isTrue(contains);
     }
 
+    setup(() => {
+      layer = new GrCoverageLayer(Side.RIGHT);
+      layer.setRanges(RANGES);
+    });
+
     test('line 1-2 are covered', () => {
       checkLine(1, 'COVERED');
       checkLine(2, 'COVERED');
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-binary.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-binary.ts
index c095ffb..cc45e1e 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-binary.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-binary.ts
@@ -3,13 +3,13 @@
  * Copyright 2017 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-import {GrDiffBuilderUnified} from './gr-diff-builder-unified';
+import {GrDiffBuilder} from './gr-diff-builder';
 import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
-import {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line';
-import {queryAndAssert} from '../../../utils/common-util';
 import {createElementDiff} from '../gr-diff/gr-diff-utils';
+import {GrDiffGroup} from '../gr-diff/gr-diff-group';
+import {html, render} from 'lit';
 
-export class GrDiffBuilderBinary extends GrDiffBuilderUnified {
+export class GrDiffBuilderBinary extends GrDiffBuilder {
   constructor(
     diff: DiffInfo,
     prefs: DiffPreferencesInfo,
@@ -18,13 +18,25 @@
     super(diff, prefs, outputEl);
   }
 
-  override buildSectionElement(): HTMLElement {
+  override buildSectionElement(group: GrDiffGroup): HTMLElement {
     const section = createElementDiff('tbody', 'binary-diff');
-    const line = new GrDiffLine(GrDiffLineType.BOTH, 'FILE', 'FILE');
-    const fileRow = this.createRow(line);
-    const contentTd = queryAndAssert(fileRow, 'td.both.file')!;
-    contentTd.textContent = ' Difference in binary files';
-    section.appendChild(fileRow);
-    return section;
+    // Do not create a diff row for 'LOST'.
+    if (group.lines[0].beforeNumber !== 'FILE') return section;
+    return super.buildSectionElement(group);
+  }
+
+  public renderBinaryDiff() {
+    render(
+      html`
+        <tbody class="gr-diff binary-diff">
+          <tr class="gr-diff">
+            <td colspan="5" class="gr-diff">
+              <span>Difference in binary files</span>
+            </td>
+          </tr>
+        </tbody>
+      `,
+      this.outputEl
+    );
   }
 }
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element.ts
index cf76b8c..328b577 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
@@ -5,14 +5,15 @@
  */
 import '../gr-diff-processor/gr-diff-processor';
 import '../../../elements/shared/gr-hovercard/gr-hovercard';
-import './gr-diff-builder-side-by-side';
 import {GrAnnotation} from '../gr-diff-highlight/gr-annotation';
-import {DiffBuilder, DiffContextExpandedEventDetail} from './gr-diff-builder';
-import {GrDiffBuilderSideBySide} from './gr-diff-builder-side-by-side';
+import {
+  GrDiffBuilder,
+  DiffContextExpandedEventDetail,
+  isImageDiffBuilder,
+  isBinaryDiffBuilder,
+} from './gr-diff-builder';
 import {GrDiffBuilderImage} from './gr-diff-builder-image';
-import {GrDiffBuilderUnified} from './gr-diff-builder-unified';
 import {GrDiffBuilderBinary} from './gr-diff-builder-binary';
-import {CancelablePromise, makeCancelable} from '../../../scripts/util';
 import {BlameInfo, ImageInfo} from '../../../types/common';
 import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
 import {CoverageRange, DiffLayer} from '../../../types/types';
@@ -35,7 +36,7 @@
   hideInContextControl,
 } from '../gr-diff/gr-diff-group';
 import {getLineNumber, getSideByLineEl} from '../gr-diff/gr-diff-utils';
-import {fireAlert, fireEvent} from '../../../utils/event-util';
+import {fireAlert, fire} from '../../../utils/event-util';
 import {assertIsDefined} from '../../../utils/common-util';
 
 const TRAILING_WHITESPACE_PATTERN = /\s+$/;
@@ -113,7 +114,7 @@
   layers: DiffLayer[] = [];
 
   // visible for testing
-  builder?: DiffBuilder;
+  builder?: GrDiffBuilder;
 
   /**
    * All layers, both from the outside and the default ones. See `layers` for
@@ -128,13 +129,6 @@
   // visible for testing
   showTrailingWhitespace?: boolean;
 
-  /**
-   * The promise last returned from `render()` while the asynchronous
-   * rendering is running - `null` otherwise. Provides a `cancel()`
-   * method that rejects it with `{isCancelled: true}`.
-   */
-  private cancelableRenderPromise: CancelablePromise<unknown> | null = null;
-
   private coverageLayerLeft = new GrCoverageLayer(Side.LEFT);
 
   private coverageLayerRight = new GrCoverageLayer(Side.RIGHT);
@@ -142,7 +136,7 @@
   private rangeLayer?: GrRangedCommentLayer;
 
   // visible for testing
-  processor = new GrDiffProcessor();
+  processor?: GrDiffProcessor;
 
   /**
    * Groups are mostly just passed on to the diff builder (this.builder). But
@@ -154,10 +148,6 @@
    */
   private groups: GrDiffGroup[] = [];
 
-  constructor() {
-    this.processor.consumer = this;
-  }
-
   updateCommentRanges(ranges: CommentRangeLayer[]) {
     this.rangeLayer?.updateRanges(ranges);
   }
@@ -168,6 +158,9 @@
   }
 
   render(keyLocations: KeyLocations): Promise<void> {
+    assertIsDefined(this.diff, 'diff');
+    assertIsDefined(this.diffElement, 'diff table');
+
     // Setting up annotation layers must happen after plugins are
     // installed, and |render| satisfies the requirement, however,
     // |attached| doesn't because in the diff view page, the element is
@@ -177,21 +170,20 @@
     this.showTabs = this.prefs.show_tabs;
     this.showTrailingWhitespace = this.prefs.show_whitespace_errors;
 
-    // Stop the processor if it's running.
-    this.cancel();
-
-    this.builder?.clear();
-    assertIsDefined(this.diff, 'diff');
-    assertIsDefined(this.diffElement, 'diff table');
+    this.cleanup();
     this.builder = this.getDiffBuilder();
+    this.init();
 
+    // TODO: Just pass along the diff model here instead of setting many
+    // individual properties.
+    this.processor = new GrDiffProcessor();
+    this.processor.consumer = this;
     this.processor.context = this.prefs.context;
     this.processor.keyLocations = keyLocations;
-
-    this.diffElement.addEventListener(
-      'diff-context-expanded',
-      this.onDiffContextExpanded
-    );
+    if (this.renderPrefs?.num_lines_rendered_at_once) {
+      this.processor.asyncThreshold =
+        this.renderPrefs.num_lines_rendered_at_once;
+    }
 
     this.clearDiffContent();
     this.builder.addColumns(
@@ -201,21 +193,18 @@
 
     const isBinary = !!(this.isImageDiff || this.diff.binary);
 
-    this.fireDiffEvent('render-start');
-    // TODO: processor.process() returns a cancelable promise already.
-    // Why wrap another one around it?
-    this.cancelableRenderPromise = makeCancelable(
-      this.processor.process(this.diff.content, isBinary)
-    );
-    // All then/catch/finally clauses must be outside of makeCancelable().
+    fire(this.diffElement, 'render-start', {});
     return (
-      this.cancelableRenderPromise
+      this.processor
+        .process(this.diff.content, isBinary)
         .then(async () => {
-          if (this.isImageDiff) {
-            (this.builder as GrDiffBuilderImage).renderDiff();
+          if (isImageDiffBuilder(this.builder)) {
+            this.builder.renderImageDiff();
+          } else if (isBinaryDiffBuilder(this.builder)) {
+            this.builder.renderBinaryDiff();
           }
           await this.untilGroupsRendered();
-          this.fireDiffEvent('render-content');
+          fire(this.diffElement, 'render-content', {});
         })
         // Mocha testing does not like uncaught rejections, so we catch
         // the cancels which are expected and should not throw errors in
@@ -224,9 +213,6 @@
           if (!e.isCanceled) return Promise.reject(e);
           return;
         })
-        .finally(() => {
-          this.cancelableRenderPromise = null;
-        })
     );
   }
 
@@ -243,11 +229,6 @@
     this.replaceGroup(e.detail.contextGroup, e.detail.groups);
   };
 
-  private fireDiffEvent<K extends keyof HTMLElementEventMap>(type: K) {
-    assertIsDefined(this.diffElement, 'diff table');
-    fireEvent(this.diffElement, type);
-  }
-
   // visible for testing
   setupAnnotationLayers() {
     this.rangeLayer = new GrRangedCommentLayer();
@@ -268,31 +249,21 @@
     this.layersInternal = layers;
   }
 
-  getContentTdByLine(lineNumber: LineNumber, side?: Side, root?: Element) {
-    if (!this.builder) return null;
-    return this.builder.getContentTdByLine(lineNumber, side, root);
+  getContentTdByLine(lineNumber: LineNumber, side?: Side) {
+    if (!this.builder) return undefined;
+    return this.builder.getContentTdByLine(lineNumber, side);
   }
 
-  private getDiffRowByChild(child: Element) {
-    while (!child.classList.contains('diff-row') && child.parentElement) {
-      child = child.parentElement;
-    }
-    return child;
-  }
-
-  getContentTdByLineEl(lineEl?: Element): Element | null {
-    if (!lineEl) return null;
+  getContentTdByLineEl(lineEl?: Element): Element | undefined {
+    if (!lineEl) return undefined;
     const line = getLineNumber(lineEl);
-    if (!line) return null;
+    if (!line) return undefined;
     const side = getSideByLineEl(lineEl);
-    // Performance optimization because we already have an element in the
-    // correct row
-    const row = this.getDiffRowByChild(lineEl);
-    return this.getContentTdByLine(line, side, row);
+    return this.getContentTdByLine(line, side);
   }
 
   getLineElByNumber(lineNumber: LineNumber, side?: Side) {
-    if (!this.builder) return null;
+    if (!this.builder) return undefined;
     return this.builder.getLineElByNumber(lineNumber, side);
   }
 
@@ -360,20 +331,41 @@
     newGroups: readonly GrDiffGroup[]
   ) {
     if (!this.builder) return;
-    this.fireDiffEvent('render-start');
+    fire(this.diffElement, 'render-start', {});
     this.builder.replaceGroup(contextGroup, newGroups);
     this.groups = this.groups.filter(g => g !== contextGroup);
     this.groups.push(...newGroups);
     this.untilGroupsRendered(newGroups).then(() => {
-      this.fireDiffEvent('render-content');
+      fire(this.diffElement, 'render-content', {});
     });
   }
 
-  cancel() {
-    this.processor.cancel();
-    this.builder?.clear();
-    this.cancelableRenderPromise?.cancel();
-    this.cancelableRenderPromise = null;
+  /**
+   * This is meant to be called when the gr-diff component re-connects, or when
+   * the diff is (re-)rendered.
+   *
+   * Make sure that this method is symmetric with cleanup(), which is called
+   * when gr-diff disconnects.
+   */
+  init() {
+    this.cleanup();
+    this.diffElement?.addEventListener(
+      'diff-context-expanded',
+      this.onDiffContextExpanded
+    );
+    this.builder?.init();
+  }
+
+  /**
+   * This is meant to be called when the gr-diff component disconnects, or when
+   * the diff is (re-)rendered.
+   *
+   * Make sure that this method is symmetric with init(), which is called when
+   * gr-diff re-connects.
+   */
+  cleanup() {
+    this.processor?.cancel();
+    this.builder?.cleanup();
     this.diffElement?.removeEventListener(
       'diff-context-expanded',
       this.onDiffContextExpanded
@@ -391,7 +383,7 @@
   }
 
   // visible for testing
-  getDiffBuilder(): DiffBuilder {
+  getDiffBuilder(): GrDiffBuilder {
     assertIsDefined(this.diff, 'diff');
     assertIsDefined(this.diffElement, 'diff table');
     if (isNaN(this.prefs.tab_size) || this.prefs.tab_size <= 0) {
@@ -421,10 +413,13 @@
         this.useNewImageDiffUi
       );
     } else if (this.diff.binary) {
-      // If the diff is binary, but not an image.
       return new GrDiffBuilderBinary(this.diff, localPrefs, this.diffElement);
     } else if (this.viewMode === DiffViewMode.SIDE_BY_SIDE) {
-      builder = new GrDiffBuilderSideBySide(
+      this.renderPrefs = {
+        ...this.renderPrefs,
+        view_mode: DiffViewMode.SIDE_BY_SIDE,
+      };
+      builder = new GrDiffBuilder(
         this.diff,
         localPrefs,
         this.diffElement,
@@ -432,7 +427,11 @@
         this.renderPrefs
       );
     } else if (this.viewMode === DiffViewMode.UNIFIED) {
-      builder = new GrDiffBuilderUnified(
+      this.renderPrefs = {
+        ...this.renderPrefs,
+        view_mode: DiffViewMode.UNIFIED,
+      };
+      builder = new GrDiffBuilder(
         this.diff,
         localPrefs,
         this.diffElement,
@@ -489,7 +488,7 @@
           // If endIndex isn't present, continue to the end of the line.
           const endIndex =
             highlight.endIndex === undefined
-              ? line.text.length
+              ? GrAnnotation.getStringLength(line.text)
               : highlight.endIndex;
 
           GrAnnotation.annotateElement(
@@ -571,6 +570,5 @@
 
   updateRenderPrefs(renderPrefs: RenderPreferences) {
     this.builder?.updateRenderPrefs(renderPrefs);
-    this.processor.updateRenderPrefs(renderPrefs);
   }
 }
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element_test.ts
index 2cfb895..da2e9f1 100644
--- 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
@@ -6,44 +6,38 @@
 import '../../../test/common-test-setup';
 import {
   createConfig,
-  createDiff,
   createEmptyDiff,
 } from '../../../test/test-data-generators';
 import './gr-diff-builder-element';
-import {queryAndAssert, stubBaseUrl, waitUntil} from '../../../test/test-utils';
+import {stubBaseUrl, waitUntil} from '../../../test/test-utils';
 import {GrAnnotation} from '../gr-diff-highlight/gr-annotation';
 import {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line';
-import {GrDiffBuilderSideBySide} from './gr-diff-builder-side-by-side';
 import {
   DiffContent,
-  DiffInfo,
   DiffLayer,
   DiffPreferencesInfo,
   DiffViewMode,
   Side,
 } from '../../../api/diff';
 import {stubRestApi} from '../../../test/test-utils';
-import {GrDiffBuilderLegacy} from './gr-diff-builder-legacy';
 import {waitForEventOnce} from '../../../utils/event-util';
 import {GrDiffBuilderElement} from './gr-diff-builder-element';
 import {createDefaultDiffPrefs} from '../../../constants/constants';
 import {KeyLocations} from '../gr-diff-processor/gr-diff-processor';
-import {BlameInfo} from '../../../types/common';
 import {fixture, html, assert} from '@open-wc/testing';
-import {EventType} from '../../../types/events';
+import {GrDiffRow} from './gr-diff-row';
+import {GrDiffBuilder} from './gr-diff-builder';
+import {querySelectorAll} from '../../../utils/dom-util';
 
 const DEFAULT_PREFS = createDefaultDiffPrefs();
 
 suite('gr-diff-builder tests', () => {
   let element: GrDiffBuilderElement;
-  let builder: GrDiffBuilderLegacy;
+  let builder: GrDiffBuilder;
   let diffTable: HTMLTableElement;
 
-  const LINE_BREAK_HTML = '<span class="gr-diff br"></span>';
-  const WBR_HTML = '<wbr class="gr-diff">';
-
   const setBuilderPrefs = (prefs: Partial<DiffPreferencesInfo>) => {
-    builder = new GrDiffBuilderSideBySide(
+    builder = new GrDiffBuilder(
       createEmptyDiff(),
       {...createDefaultDiffPrefs(), ...prefs},
       diffTable
@@ -66,24 +60,6 @@
     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';
@@ -94,8 +70,8 @@
         tab_size: 4,
         line_length: 50,
       };
-      builder = element.getDiffBuilder() as GrDiffBuilderLegacy;
-      assert.equal(builder._prefs.line_length, 50);
+      builder = element.getDiffBuilder();
+      assert.equal(builder.prefs.line_length, 50);
     });
 
     test(`line_length ignored for commit msg under ${mode}`, () => {
@@ -107,26 +83,11 @@
         tab_size: 4,
         line_length: 50,
       };
-      builder = element.getDiffBuilder() as GrDiffBuilderLegacy;
-      assert.equal(builder._prefs.line_length, 72);
+      builder = element.getDiffBuilder();
+      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());
@@ -134,7 +95,7 @@
 
   test('_handlePreferenceError triggers alert and javascript error', () => {
     const errorStub = sinon.stub();
-    diffTable.addEventListener(EventType.SHOW_ALERT, errorStub);
+    diffTable.addEventListener('show-alert', errorStub);
     assert.throws(() => element.handlePreferenceError('tab size'));
     assert.equal(
       errorStub.lastCall.args[0].detail.message,
@@ -271,10 +232,11 @@
 
       const str0 = slice(str, 0, 6);
       const str1 = slice(str, 6);
+      const numHighlightedChars = GrAnnotation.getStringLength(str1);
 
       layer.annotate(el, lineNumberEl, l, Side.LEFT);
 
-      assert.isTrue(annotateElementSpy.called);
+      assert.isTrue(annotateElementSpy.calledWith(el, 6, numHighlightedChars));
       assert.equal(el.childNodes.length, 2);
 
       assert.instanceOf(el.childNodes[0], Text);
@@ -509,15 +471,11 @@
   });
 
   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,
@@ -542,8 +500,7 @@
       element.diff = {...createEmptyDiff(), content};
       element.render(keyLocations);
       await waitForEventOnce(diffTable, 'render-content');
-      assert.isTrue(processStub.calledOnce);
-      assert.isFalse(processStub.lastCall.args[1]);
+      assert.equal(querySelectorAll(diffTable, 'tbody')?.length, 4);
     });
 
     test('image', async () => {
@@ -551,105 +508,14 @@
       element.isImageDiff = true;
       element.render(keyLocations);
       await waitForEventOnce(diffTable, 'render-content');
-      assert.isTrue(processStub.calledOnce);
-      assert.isTrue(processStub.lastCall.args[1]);
+      assert.equal(querySelectorAll(diffTable, 'tbody')?.length, 4);
     });
 
     test('binary', async () => {
       element.diff = {...createEmptyDiff(), content, binary: true};
       element.render(keyLocations);
       await waitForEventOnce(diffTable, 'render-content');
-      assert.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);
+      assert.equal(querySelectorAll(diffTable, 'tbody')?.length, 3);
     });
   });
 
@@ -691,7 +557,7 @@
       assert.include(diffRows[4].textContent, 'unchanged 11');
     });
 
-    test('clicking +x common lines expands those lines', () => {
+    test('clicking +x common lines expands those lines', async () => {
       const contextControls = diffTable.querySelectorAll('gr-context-controls');
       const topExpandCommonButton =
         contextControls[0].shadowRoot?.querySelectorAll<HTMLElement>(
@@ -699,10 +565,19 @@
         )[0];
       assert.isOk(topExpandCommonButton);
       assert.include(topExpandCommonButton!.textContent, '+9 common lines');
+      let diffRows = diffTable.querySelectorAll('.diff-row');
+      // 5 lines:
+      // FILE, LOST, the changed line plus one line of context in each direction
+      assert.equal(diffRows.length, 5);
+
       topExpandCommonButton!.click();
-      const diffRows = diffTable.querySelectorAll('.diff-row');
-      // The first two are LOST and FILE line
-      assert.equal(diffRows.length, 2 + 10 + 1 + 1);
+
+      await waitUntil(() => {
+        diffRows = diffTable.querySelectorAll<GrDiffRow>('.diff-row');
+        return diffRows.length === 14;
+      });
+      // 14 lines: The 5 above plus the 9 unchanged lines that were expanded
+      assert.equal(diffRows.length, 14);
       assert.include(diffRows[2].textContent, 'unchanged 1');
       assert.include(diffRows[3].textContent, 'unchanged 2');
       assert.include(diffRows[4].textContent, 'unchanged 3');
@@ -722,6 +597,11 @@
       dispatchStub.reset();
       element.unhideLine(4, Side.LEFT);
 
+      await waitUntil(() => {
+        const rows = diffTable.querySelectorAll<GrDiffRow>('.diff-row');
+        return rows.length === 2 + 5 + 1 + 1 + 1;
+      });
+
       const diffRows = diffTable.querySelectorAll('.diff-row');
       // The first two are LOST and FILE line
       // Lines 3-5 (Line 4 plus 1 context in each direction) will be expanded
@@ -744,427 +624,4 @@
       assert.include(firedEventTypes, 'render-content');
     });
   });
-
-  [DiffViewMode.UNIFIED, DiffViewMode.SIDE_BY_SIDE].forEach(mode => {
-    suite(`mock-diff mode:${mode}`, () => {
-      let builder: GrDiffBuilderSideBySide;
-      let diff: DiffInfo;
-      let keyLocations: KeyLocations;
-
-      setup(() => {
-        element.viewMode = mode;
-        diff = createDiff();
-        element.diff = diff;
-
-        keyLocations = {left: {}, right: {}};
-
-        element.prefs = {
-          ...createDefaultDiffPrefs(),
-          line_length: 80,
-          show_tabs: true,
-          tab_size: 4,
-        };
-        element.render(keyLocations);
-        builder = element.builder as GrDiffBuilderSideBySide;
-      });
-
-      test('aria-labels on added line numbers', () => {
-        const deltaLineNumberButton = diffTable.querySelectorAll(
-          '.lineNumButton.right'
-        )[5];
-
-        assert.isOk(deltaLineNumberButton);
-        assert.equal(
-          deltaLineNumberButton.getAttribute('aria-label'),
-          '5 added'
-        );
-      });
-
-      test('aria-labels on removed line numbers', () => {
-        const deltaLineNumberButton = diffTable.querySelectorAll(
-          '.lineNumButton.left'
-        )[10];
-
-        assert.isOk(deltaLineNumberButton);
-        assert.equal(
-          deltaLineNumberButton.getAttribute('aria-label'),
-          '10 removed'
-        );
-      });
-
-      test('getContentByLine', () => {
-        let actual: HTMLElement | null;
-
-        actual = builder.getContentByLine(2, Side.LEFT);
-        assert.equal(actual?.textContent, diff.content[0].ab?.[1]);
-
-        actual = builder.getContentByLine(2, Side.RIGHT);
-        assert.equal(actual?.textContent, diff.content[0].ab?.[1]);
-
-        actual = builder.getContentByLine(5, Side.LEFT);
-        assert.equal(actual?.textContent, diff.content[2].ab?.[0]);
-
-        actual = builder.getContentByLine(5, Side.RIGHT);
-        assert.equal(actual?.textContent, diff.content[1].b?.[0]);
-      });
-
-      test('getContentTdByLineEl works both with button and td', () => {
-        const diffRow = diffTable.querySelectorAll('tr.diff-row')[2];
-
-        const lineNumTdLeft = queryAndAssert(diffRow, 'td.lineNum.left');
-        const lineNumButtonLeft = queryAndAssert(lineNumTdLeft, 'button');
-        const contentTdLeft = diffRow.querySelectorAll('.content')[0];
-
-        const lineNumTdRight = queryAndAssert(diffRow, 'td.lineNum.right');
-        const lineNumButtonRight = queryAndAssert(lineNumTdRight, 'button');
-        const contentTdRight =
-          mode === DiffViewMode.SIDE_BY_SIDE
-            ? diffRow.querySelectorAll('.content')[1]
-            : contentTdLeft;
-
-        assert.equal(
-          element.getContentTdByLineEl(lineNumTdLeft),
-          contentTdLeft
-        );
-        assert.equal(
-          element.getContentTdByLineEl(lineNumButtonLeft),
-          contentTdLeft
-        );
-        assert.equal(
-          element.getContentTdByLineEl(lineNumTdRight),
-          contentTdRight
-        );
-        assert.equal(
-          element.getContentTdByLineEl(lineNumButtonRight),
-          contentTdRight
-        );
-      });
-
-      test('findLinesByRange LEFT', () => {
-        const lines: GrDiffLine[] = [];
-        const elems: HTMLElement[] = [];
-        const start = 1;
-        const end = 44;
-
-        // lines 26-29 are collapsed, so minus 4
-        let count = end - start + 1 - 4;
-        // Lines 14+15 are part of a 'common' chunk. And we have a bug in
-        // unified diff that results in not rendering these lines for the LEFT
-        // side. TODO: Fix that bug!
-        if (mode === DiffViewMode.UNIFIED) count -= 2;
-
-        builder.findLinesByRange(start, end, Side.LEFT, lines, elems);
-
-        assert.equal(lines.length, count);
-        assert.equal(elems.length, count);
-
-        for (let i = 0; i < count; i++) {
-          assert.instanceOf(lines[i], GrDiffLine);
-          assert.instanceOf(elems[i], HTMLElement);
-          assert.equal(lines[i].text, elems[i].textContent);
-        }
-      });
-
-      test('findLinesByRange RIGHT', () => {
-        const lines: GrDiffLine[] = [];
-        const elems: HTMLElement[] = [];
-        const start = 1;
-        const end = 48;
-
-        // lines 26-29 are collapsed, so minus 4
-        const count = end - start + 1 - 4;
-
-        builder.findLinesByRange(start, end, Side.RIGHT, lines, elems);
-
-        assert.equal(lines.length, count);
-        assert.equal(elems.length, count);
-
-        for (let i = 0; i < count; i++) {
-          assert.instanceOf(lines[i], GrDiffLine);
-          assert.instanceOf(elems[i], HTMLElement);
-          assert.equal(lines[i].text, elems[i].textContent);
-        }
-      });
-
-      test('renderContentByRange', () => {
-        const spy = sinon.spy(builder, 'createTextEl');
-        const start = 9;
-        const end = 14;
-        let count = end - start + 1;
-        // Lines 14+15 are part of a 'common' chunk. And we have a bug in
-        // unified diff that results in not rendering these lines for the LEFT
-        // side. TODO: Fix that bug!
-        if (mode === DiffViewMode.UNIFIED) count -= 1;
-
-        builder.renderContentByRange(start, end, Side.LEFT);
-
-        assert.equal(spy.callCount, count);
-        spy.getCalls().forEach((call, i: number) => {
-          assert.equal(call.args[1].beforeNumber, start + i);
-        });
-      });
-
-      test('renderContentByRange non-existent elements', () => {
-        const spy = sinon.spy(builder, 'createTextEl');
-
-        sinon
-          .stub(builder, 'getLineNumberEl')
-          .returns(document.createElement('div'));
-        sinon
-          .stub(builder, 'findLinesByRange')
-          .callsFake((_1, _2, _3, lines, elements) => {
-            // Add a line and a corresponding element.
-            lines?.push(new GrDiffLine(GrDiffLineType.BOTH));
-            const tr = document.createElement('tr');
-            const td = document.createElement('td');
-            const el = document.createElement('div');
-            tr.appendChild(td);
-            td.appendChild(el);
-            elements?.push(el);
-
-            // Add 2 lines without corresponding elements.
-            lines?.push(new GrDiffLine(GrDiffLineType.BOTH));
-            lines?.push(new GrDiffLine(GrDiffLineType.BOTH));
-          });
-
-        builder.renderContentByRange(1, 10, Side.LEFT);
-        // Should be called only once because only one line had a corresponding
-        // element.
-        assert.equal(spy.callCount, 1);
-      });
-
-      test('getLineNumberEl side-by-side left', () => {
-        const contentEl = builder.getContentByLine(
-          5,
-          Side.LEFT,
-          element.diffElement as HTMLTableElement
-        );
-        assert.isOk(contentEl);
-        const lineNumberEl = builder.getLineNumberEl(contentEl!, Side.LEFT);
-        assert.isOk(lineNumberEl);
-        assert.isTrue(lineNumberEl!.classList.contains('lineNum'));
-        assert.isTrue(lineNumberEl!.classList.contains(Side.LEFT));
-      });
-
-      test('getLineNumberEl side-by-side right', () => {
-        const contentEl = builder.getContentByLine(
-          5,
-          Side.RIGHT,
-          element.diffElement as HTMLTableElement
-        );
-        assert.isOk(contentEl);
-        const lineNumberEl = builder.getLineNumberEl(contentEl!, Side.RIGHT);
-        assert.isOk(lineNumberEl);
-        assert.isTrue(lineNumberEl!.classList.contains('lineNum'));
-        assert.isTrue(lineNumberEl!.classList.contains(Side.RIGHT));
-      });
-
-      test('getLineNumberEl unified left', async () => {
-        // Re-render as unified:
-        element.viewMode = 'UNIFIED_DIFF';
-        element.render(keyLocations);
-        builder = element.builder as GrDiffBuilderSideBySide;
-
-        const contentEl = builder.getContentByLine(
-          5,
-          Side.LEFT,
-          element.diffElement as HTMLTableElement
-        );
-        assert.isOk(contentEl);
-        const lineNumberEl = builder.getLineNumberEl(contentEl!, Side.LEFT);
-        assert.isOk(lineNumberEl);
-        assert.isTrue(lineNumberEl!.classList.contains('lineNum'));
-        assert.isTrue(lineNumberEl!.classList.contains(Side.LEFT));
-      });
-
-      test('getLineNumberEl unified right', async () => {
-        // Re-render as unified:
-        element.viewMode = 'UNIFIED_DIFF';
-        element.render(keyLocations);
-        builder = element.builder as GrDiffBuilderSideBySide;
-
-        const contentEl = builder.getContentByLine(
-          5,
-          Side.RIGHT,
-          element.diffElement as HTMLTableElement
-        );
-        assert.isOk(contentEl);
-        const lineNumberEl = builder.getLineNumberEl(contentEl!, Side.RIGHT);
-        assert.isOk(lineNumberEl);
-        assert.isTrue(lineNumberEl!.classList.contains('lineNum'));
-        assert.isTrue(lineNumberEl!.classList.contains(Side.RIGHT));
-      });
-
-      test('getNextContentOnSide side-by-side left', () => {
-        const startElem = builder.getContentByLine(
-          5,
-          Side.LEFT,
-          element.diffElement as HTMLTableElement
-        );
-        assert.isOk(startElem);
-        const expectedStartString = diff.content[2].ab?.[0];
-        const expectedNextString = diff.content[2].ab?.[1];
-        assert.equal(startElem!.textContent, expectedStartString);
-
-        const nextElem = builder.getNextContentOnSide(startElem!, Side.LEFT);
-        assert.isOk(nextElem);
-        assert.equal(nextElem!.textContent, expectedNextString);
-      });
-
-      test('getNextContentOnSide side-by-side right', () => {
-        const startElem = builder.getContentByLine(
-          5,
-          Side.RIGHT,
-          element.diffElement as HTMLTableElement
-        );
-        const expectedStartString = diff.content[1].b?.[0];
-        const expectedNextString = diff.content[1].b?.[1];
-        assert.isOk(startElem);
-        assert.equal(startElem!.textContent, expectedStartString);
-
-        const nextElem = builder.getNextContentOnSide(startElem!, Side.RIGHT);
-        assert.isOk(nextElem);
-        assert.equal(nextElem!.textContent, expectedNextString);
-      });
-
-      test('getNextContentOnSide unified left', async () => {
-        // Re-render as unified:
-        element.viewMode = 'UNIFIED_DIFF';
-        element.render(keyLocations);
-        builder = element.builder as GrDiffBuilderSideBySide;
-
-        const startElem = builder.getContentByLine(
-          5,
-          Side.LEFT,
-          element.diffElement as HTMLTableElement
-        );
-        const expectedStartString = diff.content[2].ab?.[0];
-        const expectedNextString = diff.content[2].ab?.[1];
-        assert.isOk(startElem);
-        assert.equal(startElem!.textContent, expectedStartString);
-
-        const nextElem = builder.getNextContentOnSide(startElem!, Side.LEFT);
-        assert.isOk(nextElem);
-        assert.equal(nextElem!.textContent, expectedNextString);
-      });
-
-      test('getNextContentOnSide unified right', async () => {
-        // Re-render as unified:
-        element.viewMode = 'UNIFIED_DIFF';
-        element.render(keyLocations);
-        builder = element.builder as GrDiffBuilderSideBySide;
-
-        const startElem = builder.getContentByLine(
-          5,
-          Side.RIGHT,
-          element.diffElement as HTMLTableElement
-        );
-        const expectedStartString = diff.content[1].b?.[0];
-        const expectedNextString = diff.content[1].b?.[1];
-        assert.isOk(startElem);
-        assert.equal(startElem!.textContent, expectedStartString);
-
-        const nextElem = builder.getNextContentOnSide(startElem!, Side.RIGHT);
-        assert.isOk(nextElem);
-        assert.equal(nextElem!.textContent, expectedNextString);
-      });
-    });
-  });
-
-  suite('blame', () => {
-    let mockBlame: BlameInfo[];
-
-    setup(() => {
-      mockBlame = [
-        {
-          author: 'test-author',
-          time: 314,
-          commit_msg: 'test-commit-message',
-          id: 'commit 1',
-          ranges: [
-            {start: 1, end: 2},
-            {start: 10, end: 16},
-          ],
-        },
-        {
-          author: 'test-author',
-          time: 314,
-          commit_msg: 'test-commit-message',
-          id: 'commit 2',
-          ranges: [
-            {start: 4, end: 10},
-            {start: 17, end: 32},
-          ],
-        },
-      ];
-    });
-
-    test('setBlame attempts to render each blamed line', () => {
-      const getBlameStub = sinon
-        .stub(builder, 'getBlameTdByLine')
-        .returns(undefined);
-      builder.setBlame(mockBlame);
-      assert.equal(getBlameStub.callCount, 32);
-    });
-
-    test('getBlameCommitForBaseLine', () => {
-      sinon.stub(builder, 'getBlameTdByLine').returns(undefined);
-      builder.setBlame(mockBlame);
-      assert.isOk(builder.getBlameCommitForBaseLine(1));
-      assert.equal(builder.getBlameCommitForBaseLine(1)?.id, 'commit 1');
-
-      assert.isOk(builder.getBlameCommitForBaseLine(11));
-      assert.equal(builder.getBlameCommitForBaseLine(11)?.id, 'commit 1');
-
-      assert.isOk(builder.getBlameCommitForBaseLine(32));
-      assert.equal(builder.getBlameCommitForBaseLine(32)?.id, 'commit 2');
-
-      assert.isUndefined(builder.getBlameCommitForBaseLine(33));
-    });
-
-    test('getBlameCommitForBaseLine w/o blame returns null', () => {
-      assert.isUndefined(builder.getBlameCommitForBaseLine(1));
-      assert.isUndefined(builder.getBlameCommitForBaseLine(11));
-      assert.isUndefined(builder.getBlameCommitForBaseLine(31));
-    });
-
-    test('createBlameCell', () => {
-      const mockBlameInfo = {
-        time: 1576155200,
-        id: '1234567890',
-        author: 'Clark Kent',
-        commit_msg: 'Testing Commit',
-        ranges: [{start: 4, end: 10}],
-      };
-      const getBlameStub = sinon
-        .stub(builder, 'getBlameCommitForBaseLine')
-        .returns(mockBlameInfo);
-      const line = new GrDiffLine(GrDiffLineType.BOTH);
-      line.beforeNumber = 3;
-      line.afterNumber = 5;
-
-      const result = builder.createBlameCell(line.beforeNumber);
-
-      assert.isTrue(getBlameStub.calledWithExactly(3));
-      assert.equal(result.getAttribute('data-line-number'), '3');
-      assert.dom.equal(
-        result,
-        /* HTML */ `
-          <span class="gr-diff">
-            <a class="blameDate gr-diff" href="/r/q/1234567890"> 12/12/2019 </a>
-            <span class="blameAuthor gr-diff">Clark</span>
-            <gr-hovercard class="gr-diff">
-              <span class="blameHoverCard gr-diff">
-                Commit 1234567890<br />
-                Author: Clark Kent<br />
-                Date: 12/12/2019<br />
-                <br />
-                Testing Commit
-              </span>
-            </gr-hovercard>
-          </span>
-        `
-      );
-    });
-  });
 });
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-image.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-image.ts
index 3cdd1f9..1f7ffd3 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-image.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-image.ts
@@ -3,228 +3,270 @@
  * Copyright 2016 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-
-import {GrDiffBuilderSideBySide} from './gr-diff-builder-side-by-side';
 import {ImageInfo} from '../../../types/common';
 import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
-import {GrEndpointParam} from '../../../elements/plugins/gr-endpoint-param/gr-endpoint-param';
-import {RenderPreferences} from '../../../api/diff';
+import {RenderPreferences, Side} from '../../../api/diff';
 import '../gr-diff-image-viewer/gr-image-viewer';
-import {GrImageViewer} from '../gr-diff-image-viewer/gr-image-viewer';
+import {html, LitElement, nothing} from 'lit';
+import {customElement, property, query, state} from 'lit/decorators.js';
+import {GrDiffBuilder} from './gr-diff-builder';
 import {createElementDiff} from '../gr-diff/gr-diff-utils';
+import {GrDiffGroup} from '../gr-diff/gr-diff-group';
 
 // MIME types for images we allow showing. Do not include SVG, it can contain
 // arbitrary JavaScript.
 const IMAGE_MIME_PATTERN = /^image\/(bmp|gif|x-icon|jpeg|jpg|png|tiff|webp)$/;
 
-export class GrDiffBuilderImage extends GrDiffBuilderSideBySide {
+export class GrDiffBuilderImage extends GrDiffBuilder {
   constructor(
     diff: DiffInfo,
     prefs: DiffPreferencesInfo,
     outputEl: HTMLElement,
-    private readonly _baseImage: ImageInfo | null,
-    private readonly _revisionImage: ImageInfo | null,
+    private readonly baseImage: ImageInfo | null,
+    private readonly revisionImage: ImageInfo | null,
     renderPrefs?: RenderPreferences,
-    private readonly _useNewImageDiffUi: boolean = false
+    private readonly useNewImageDiffUi: boolean = false
   ) {
     super(diff, prefs, outputEl, [], renderPrefs);
   }
 
-  public renderDiff() {
-    const section = createElementDiff('tbody', 'image-diff');
-
-    if (this._useNewImageDiffUi) {
-      this._emitImageViewer(section);
-
-      this.outputEl.appendChild(section);
-    } else {
-      this._emitImagePair(section);
-      this._emitImageLabels(section);
-
-      this.outputEl.appendChild(section);
-      this.outputEl.appendChild(this._createEndpoint());
-    }
+  override buildSectionElement(group: GrDiffGroup): HTMLElement {
+    const section = createElementDiff('tbody');
+    // Do not create a diff row for 'LOST'.
+    if (group.lines[0].beforeNumber !== 'FILE') return section;
+    return super.buildSectionElement(group);
   }
 
-  private _createEndpoint() {
-    const tbody = createElementDiff('tbody');
-    const tr = createElementDiff('tr');
-    const td = createElementDiff('td');
-
-    // TODO(kaspern): Support blame for image diffs and remove the hardcoded 4
-    // column limit.
-    td.setAttribute('colspan', '4');
-    const endpointDomApi = createElementDiff('gr-endpoint-decorator');
-    endpointDomApi.setAttribute('name', 'image-diff');
-    endpointDomApi.appendChild(
-      this._createEndpointParam('baseImage', this._baseImage)
-    );
-    endpointDomApi.appendChild(
-      this._createEndpointParam('revisionImage', this._revisionImage)
-    );
-    td.appendChild(endpointDomApi);
-    tr.appendChild(td);
-    tbody.appendChild(tr);
-    return tbody;
+  public renderImageDiff() {
+    const imageDiff = this.useNewImageDiffUi
+      ? this.createImageDiffNew()
+      : this.createImageDiffOld();
+    this.outputEl.appendChild(imageDiff);
   }
 
-  private _createEndpointParam(name: string, value: ImageInfo | null) {
-    const endpointParam = createElementDiff(
-      'gr-endpoint-param'
-    ) as GrEndpointParam;
-    endpointParam.name = name;
-    endpointParam.value = value;
-    return endpointParam;
+  private createImageDiffNew() {
+    const imageDiff = document.createElement('gr-diff-image-new');
+    imageDiff.automaticBlink = this.autoBlink();
+    imageDiff.baseImage = this.baseImage ?? undefined;
+    imageDiff.revisionImage = this.revisionImage ?? undefined;
+    return imageDiff;
   }
 
-  private _emitImageViewer(section: HTMLElement) {
-    const tr = createElementDiff('tr');
-    const td = createElementDiff('td');
-    // TODO(hermannloose): Support blame for image diffs, see above.
-    td.setAttribute('colspan', '4');
-    const imageViewer = createElementDiff('gr-image-viewer') as GrImageViewer;
-
-    imageViewer.baseUrl = this._getImageSrc(this._baseImage);
-    imageViewer.revisionUrl = this._getImageSrc(this._revisionImage);
-    imageViewer.automaticBlink =
-      !!this.renderPrefs?.image_diff_prefs?.automatic_blink;
-
-    td.appendChild(imageViewer);
-    tr.appendChild(td);
-    section.appendChild(tr);
+  private createImageDiffOld() {
+    const imageDiff = document.createElement('gr-diff-image-old');
+    imageDiff.baseImage = this.baseImage ?? undefined;
+    imageDiff.revisionImage = this.revisionImage ?? undefined;
+    return imageDiff;
   }
 
-  private _getImageSrc(image: ImageInfo | null): string {
-    return image && IMAGE_MIME_PATTERN.test(image.type)
-      ? `data:${image.type};base64,${image.body}`
-      : '';
-  }
-
-  private _emitImagePair(section: HTMLElement) {
-    const tr = createElementDiff('tr');
-
-    tr.appendChild(createElementDiff('td', 'left lineNum blank'));
-    tr.appendChild(this._createImageCell(this._baseImage, 'left', section));
-
-    tr.appendChild(createElementDiff('td', 'right lineNum blank'));
-    tr.appendChild(
-      this._createImageCell(this._revisionImage, 'right', section)
-    );
-
-    section.appendChild(tr);
-  }
-
-  private _createImageCell(
-    image: ImageInfo | null,
-    className: string,
-    section: HTMLElement
-  ) {
-    const td = createElementDiff('td', className);
-    const src = this._getImageSrc(image);
-    if (image && src) {
-      const imageEl = createElementDiff('img') as HTMLImageElement;
-      imageEl.onload = () => {
-        image._height = imageEl.naturalHeight;
-        image._width = imageEl.naturalWidth;
-        this._updateImageLabel(section, className, image);
-      };
-      imageEl.addEventListener('error', (e: Event) => {
-        imageEl.remove();
-        td.textContent = '[Image failed to load] ' + e.type;
-      });
-      imageEl.setAttribute('src', src);
-      td.appendChild(imageEl);
-    }
-    return td;
-  }
-
-  private _updateImageLabel(
-    section: HTMLElement,
-    className: string,
-    image: ImageInfo
-  ) {
-    const label = section.querySelector(
-      '.' + className + ' span.label'
-    ) as HTMLElement;
-    this._setLabelText(label, image);
-  }
-
-  private _setLabelText(label: HTMLElement, image: ImageInfo | null) {
-    label.textContent = _getImageLabel(image);
-  }
-
-  private _emitImageLabels(section: HTMLElement) {
-    const tr = createElementDiff('tr');
-
-    let addNamesInLabel = false;
-
-    if (
-      this._baseImage &&
-      this._revisionImage &&
-      this._baseImage._name !== this._revisionImage._name
-    ) {
-      addNamesInLabel = true;
-    }
-
-    tr.appendChild(createElementDiff('td', 'left lineNum blank'));
-    let td = createElementDiff('td', 'left');
-    let label = createElementDiff('label');
-    let nameSpan;
-    let labelSpan = createElementDiff('span', 'label');
-
-    if (addNamesInLabel) {
-      nameSpan = createElementDiff('span', 'name');
-      nameSpan.textContent = this._baseImage?._name ?? '';
-      label.appendChild(nameSpan);
-      label.appendChild(createElementDiff('br'));
-    }
-
-    this._setLabelText(labelSpan, this._baseImage);
-
-    label.appendChild(labelSpan);
-    td.appendChild(label);
-    tr.appendChild(td);
-
-    tr.appendChild(createElementDiff('td', 'right lineNum blank'));
-    td = createElementDiff('td', 'right');
-    label = createElementDiff('label');
-    labelSpan = createElementDiff('span', 'label');
-
-    if (addNamesInLabel) {
-      nameSpan = createElementDiff('span', 'name');
-      nameSpan.textContent = this._revisionImage?._name ?? '';
-      label.appendChild(nameSpan);
-      label.appendChild(createElementDiff('br'));
-    }
-
-    this._setLabelText(labelSpan, this._revisionImage);
-
-    label.appendChild(labelSpan);
-    td.appendChild(label);
-    tr.appendChild(td);
-
-    section.appendChild(tr);
+  private autoBlink(): boolean {
+    return !!this.renderPrefs?.image_diff_prefs?.automatic_blink;
   }
 
   override updateRenderPrefs(renderPrefs: RenderPreferences) {
-    const imageViewer = this.outputEl.querySelector(
-      'gr-image-viewer'
-    ) as GrImageViewer;
-    if (this._useNewImageDiffUi && imageViewer) {
-      imageViewer.automaticBlink =
-        !!renderPrefs?.image_diff_prefs?.automatic_blink;
-    }
+    this.renderPrefs = renderPrefs;
+
+    // We have to update `imageDiff.automaticBlink` manually, because `this` is
+    // not a LitElement.
+    const imageDiff = this.outputEl.querySelector(
+      'gr-diff-image-new'
+    ) as GrDiffImageNew;
+    if (imageDiff) imageDiff.automaticBlink = this.autoBlink();
   }
 }
 
-function _getImageLabel(image: ImageInfo | null) {
-  if (image) {
-    const type = image.type ?? image._expectedType;
-    if (image._width && image._height) {
-      return `${image._width}×${image._height} ${type}`;
-    } else {
-      return type;
-    }
+@customElement('gr-diff-image-new')
+class GrDiffImageNew extends LitElement {
+  @property() baseImage?: ImageInfo;
+
+  @property() revisionImage?: ImageInfo;
+
+  @property() automaticBlink = false;
+
+  /**
+   * The browser API for handling selection does not (yet) work for selection
+   * across multiple shadow DOM elements. So we are rendering gr-diff components
+   * into the light DOM instead of the shadow DOM by overriding this method,
+   * which was the recommended workaround by the lit team.
+   * See also https://github.com/WICG/webcomponents/issues/79.
+   */
+  override createRenderRoot() {
+    return this;
   }
-  return 'No image';
+
+  override render() {
+    return html`
+      <tbody class="gr-diff image-diff">
+        <tr class="gr-diff">
+          <td class="gr-diff" colspan="4">
+            <gr-image-viewer
+              class="gr-diff"
+              .baseUrl=${imageSrc(this.baseImage)}
+              .revisionUrl=${imageSrc(this.revisionImage)}
+              .automaticBlink=${this.automaticBlink}
+            >
+            </gr-image-viewer>
+          </td>
+        </tr>
+      </tbody>
+    `;
+  }
+}
+
+@customElement('gr-diff-image-old')
+class GrDiffImageOld extends LitElement {
+  @property() baseImage?: ImageInfo;
+
+  @property() revisionImage?: ImageInfo;
+
+  @query('img.left') baseImageEl?: HTMLImageElement;
+
+  @query('img.right') revisionImageEl?: HTMLImageElement;
+
+  @state() baseError?: string;
+
+  @state() revisionError?: string;
+
+  /**
+   * The browser API for handling selection does not (yet) work for selection
+   * across multiple shadow DOM elements. So we are rendering gr-diff components
+   * into the light DOM instead of the shadow DOM by overriding this method,
+   * which was the recommended workaround by the lit team.
+   * See also https://github.com/WICG/webcomponents/issues/79.
+   */
+  override createRenderRoot() {
+    return this;
+  }
+
+  override render() {
+    return html`
+      <tbody class="gr-diff image-diff">
+        ${this.renderImagePairRow()} ${this.renderImageLabelRow()}
+      </tbody>
+      ${this.renderEndpoint()}
+    `;
+  }
+
+  private renderEndpoint() {
+    return html`
+      <tbody class="gr-diff endpoint">
+        <tr class="gr-diff">
+          <td class="gr-diff" colspan="4">
+            <gr-endpoint-decorator class="gr-diff" name="image-diff">
+              ${this.renderEndpointParam('baseImage', this.baseImage)}
+              ${this.renderEndpointParam('revisionImage', this.revisionImage)}
+            </gr-endpoint-decorator>
+          </td>
+        </tr>
+      </tbody>
+    `;
+  }
+
+  private renderEndpointParam(name: string, value: unknown) {
+    if (!value) return nothing;
+    return html`
+      <gr-endpoint-param class="gr-diff" name=${name} .value=${value}>
+      </gr-endpoint-param>
+    `;
+  }
+
+  private renderImagePairRow() {
+    return html`
+      <tr class="gr-diff">
+        <td class="gr-diff left lineNum blank"></td>
+        <td class="gr-diff left">${this.renderImage(Side.LEFT)}</td>
+        <td class="gr-diff right lineNum blank"></td>
+        <td class="gr-diff right">${this.renderImage(Side.RIGHT)}</td>
+      </tr>
+    `;
+  }
+
+  private renderImage(side: Side) {
+    const image = side === Side.LEFT ? this.baseImage : this.revisionImage;
+    if (!image) return nothing;
+    const error = side === Side.LEFT ? this.baseError : this.revisionError;
+    if (error) return error;
+    const src = imageSrc(image);
+    if (!src) return nothing;
+
+    return html`
+      <img
+        class="gr-diff ${side}"
+        src=${src}
+        @load=${this.handleLoad}
+        @error=${(e: Event) => this.handleError(e, side)}
+      >
+      </img>
+    `;
+  }
+
+  private handleLoad() {
+    this.requestUpdate();
+  }
+
+  private handleError(e: Event, side: Side) {
+    const msg = `[Image failed to load] ${e.type}`;
+    if (side === Side.LEFT) this.baseError = msg;
+    if (side === Side.RIGHT) this.revisionError = msg;
+  }
+
+  private renderImageLabelRow() {
+    return html`
+      <tr class="gr-diff">
+        <td class="gr-diff left lineNum blank"></td>
+        <td class="gr-diff left">
+          <label class="gr-diff">
+            ${this.renderName(this.baseImage?._name ?? '')}
+            <span class="gr-diff label">${this.imageLabel(Side.LEFT)}</span>
+          </label>
+        </td>
+        <td class="gr-diff right lineNum blank"></td>
+        <td class="gr-diff right">
+          <label class="gr-diff">
+            ${this.renderName(this.revisionImage?._name ?? '')}
+            <span class="gr-diff label"> ${this.imageLabel(Side.RIGHT)} </span>
+          </label>
+        </td>
+      </tr>
+    `;
+  }
+
+  private renderName(name?: string) {
+    const addNamesInLabel =
+      this.baseImage &&
+      this.revisionImage &&
+      this.baseImage._name !== this.revisionImage._name;
+    if (!addNamesInLabel) return nothing;
+    return html`
+      <span class="gr-diff name">${name}</span><br class="gr-diff" />
+    `;
+  }
+
+  private imageLabel(side: Side) {
+    const image = side === Side.LEFT ? this.baseImage : this.revisionImage;
+    const imageEl =
+      side === Side.LEFT ? this.baseImageEl : this.revisionImageEl;
+    if (image) {
+      const type = image.type ?? image._expectedType;
+      if (imageEl?.naturalWidth && imageEl.naturalHeight) {
+        return `${imageEl?.naturalWidth}×${imageEl.naturalHeight} ${type}`;
+      } else {
+        return type;
+      }
+    }
+    return 'No image';
+  }
+}
+
+function imageSrc(image?: ImageInfo): string {
+  return image && IMAGE_MIME_PATTERN.test(image.type)
+    ? `data:${image.type};base64,${image.body}`
+    : '';
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-diff-image-new': GrDiffImageNew;
+    'gr-diff-image-old': GrDiffImageOld;
+  }
 }
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-legacy.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-legacy.ts
deleted file mode 100644
index 8176e14..0000000
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-legacy.ts
+++ /dev/null
@@ -1,503 +0,0 @@
-/**
- * @license
- * Copyright 2016 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-import {
-  MovedLinkClickedEventDetail,
-  RenderPreferences,
-} from '../../../api/diff';
-import {fire} from '../../../utils/event-util';
-import {GrDiffLine, GrDiffLineType, LineNumber} from '../gr-diff/gr-diff-line';
-import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group';
-import '../gr-context-controls/gr-context-controls';
-import {
-  GrContextControls,
-  GrContextControlsShowConfig,
-} from '../gr-context-controls/gr-context-controls';
-import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
-import {DiffViewMode, Side} from '../../../constants/constants';
-import {DiffLayer} from '../../../types/types';
-import {
-  createBlameElement,
-  createElementDiff,
-  createElementDiffWithText,
-  formatText,
-  getResponsiveMode,
-} from '../gr-diff/gr-diff-utils';
-import {GrDiffBuilder} from './gr-diff-builder';
-import {BlameInfo} from '../../../types/common';
-
-function lineTdSelector(lineNumber: LineNumber, side?: Side): string {
-  const sideSelector = side ? `.${side}` : '';
-  return `td.lineNum[data-value="${lineNumber}"]${sideSelector}`;
-}
-/**
- * Base class for builders that are creating the DOM elements programmatically
- * by calling `document.createElement()` and such. We are calling such builders
- * "legacy", because we want to create (Lit) component based diff elements.
- *
- * TODO: Do not subclass `GrDiffBuilder`. Use composition and interfaces.
- */
-export abstract class GrDiffBuilderLegacy extends GrDiffBuilder {
-  constructor(
-    diff: DiffInfo,
-    prefs: DiffPreferencesInfo,
-    outputEl: HTMLElement,
-    layers: DiffLayer[] = [],
-    renderPrefs?: RenderPreferences
-  ) {
-    super(diff, prefs, outputEl, layers, renderPrefs);
-  }
-
-  override getContentTdByLine(
-    lineNumber: LineNumber,
-    side?: Side,
-    root: Element = this.outputEl
-  ): HTMLTableCellElement | null {
-    return root.querySelector<HTMLTableCellElement>(
-      `${lineTdSelector(lineNumber, side)} ~ td.content`
-    );
-  }
-
-  override getLineElByNumber(
-    lineNumber: LineNumber,
-    side?: Side
-  ): HTMLTableCellElement | null {
-    return this.outputEl.querySelector<HTMLTableCellElement>(
-      lineTdSelector(lineNumber, side)
-    );
-  }
-
-  override getLineNumberRows() {
-    return Array.from(
-      this.outputEl.querySelectorAll<HTMLTableRowElement>(
-        ':not(.contextControl) > .diff-row'
-      ) ?? []
-    ).filter(tr => tr.querySelector('button'));
-  }
-
-  override getLineNumEls(side: Side): HTMLTableCellElement[] {
-    return Array.from(
-      this.outputEl.querySelectorAll<HTMLTableCellElement>(
-        `td.lineNum.${side}`
-      ) ?? []
-    );
-  }
-
-  override getBlameTdByLine(lineNum: number): Element | undefined {
-    return (
-      this.outputEl.querySelector(`td.blame[data-line-number="${lineNum}"]`) ??
-      undefined
-    );
-  }
-
-  override getContentByLine(
-    lineNumber: LineNumber,
-    side?: Side,
-    root?: HTMLElement
-  ): HTMLElement | null {
-    const td = this.getContentTdByLine(lineNumber, side, root);
-    return td ? td.querySelector('.contentText') : null;
-  }
-
-  override renderContentByRange(
-    start: LineNumber,
-    end: LineNumber,
-    side: Side
-  ) {
-    const lines: GrDiffLine[] = [];
-    const elements: HTMLElement[] = [];
-    let line;
-    let el;
-    this.findLinesByRange(start, end, side, lines, elements);
-    for (let i = 0; i < lines.length; i++) {
-      line = lines[i];
-      el = elements[i];
-      if (!el || !el.parentElement) {
-        // Cannot re-render an element if it does not exist. This can happen
-        // if lines are collapsed and not visible on the page yet.
-        continue;
-      }
-      const lineNumberEl = this.getLineNumberEl(el, side);
-      const newContent = this.createTextEl(lineNumberEl, line, side)
-        .firstChild as HTMLElement;
-      // Note that ${el.id} ${newContent.id} might actually mismatch: In unified
-      // diff we are rendering the same content twice for all the diff chunk
-      // that are unchanged from left to right. TODO: Be smarter about this.
-      el.parentElement.replaceChild(newContent, el);
-    }
-  }
-
-  override renderBlameByRange(blame: BlameInfo, start: number, end: number) {
-    for (let i = start; i <= end; i++) {
-      // TODO(wyatta): this query is expensive, but, when traversing a
-      // range, the lines are consecutive, and given the previous blame
-      // cell, the next one can be reached cheaply.
-      const blameCell = this.getBlameTdByLine(i);
-      if (!blameCell) continue;
-
-      // Remove the element's children (if any).
-      while (blameCell.hasChildNodes()) {
-        blameCell.removeChild(blameCell.lastChild!);
-      }
-      const blameEl = createBlameElement(i, blame);
-      if (blameEl) blameCell.appendChild(blameEl);
-    }
-  }
-
-  /**
-   * Finds the line number element given the content element by walking up the
-   * DOM tree to the diff row and then querying for a .lineNum element on the
-   * requested side.
-   *
-   * TODO(brohlfs): Consolidate this with getLineEl... methods in html file.
-   */
-  // visible for testing
-  getLineNumberEl(content: HTMLElement, side: Side): HTMLElement | null {
-    let row: HTMLElement | null = content;
-    while (row && !row.classList.contains('diff-row')) row = row.parentElement;
-    return row ? (row.querySelector('.lineNum.' + side) as HTMLElement) : null;
-  }
-
-  /**
-   * Adds <tr> table rows to a <tbody> section for allowing the user to expand
-   * collapsed of lines. Called by subclasses.
-   */
-  protected createContextControls(
-    section: HTMLElement,
-    group: GrDiffGroup,
-    viewMode: DiffViewMode
-  ) {
-    const leftStart = group.lineRange.left.start_line;
-    const leftEnd = group.lineRange.left.end_line;
-    const firstGroupIsSkipped = !!group.contextGroups[0].skip;
-    const lastGroupIsSkipped =
-      !!group.contextGroups[group.contextGroups.length - 1].skip;
-
-    const containsWholeFile = this.numLinesLeft === leftEnd - leftStart + 1;
-    const showAbove =
-      (leftStart > 1 && !firstGroupIsSkipped) || containsWholeFile;
-    const showBelow = leftEnd < this.numLinesLeft && !lastGroupIsSkipped;
-
-    if (showAbove) {
-      const paddingRow = this.createContextControlPaddingRow(viewMode);
-      paddingRow.classList.add('above');
-      section.appendChild(paddingRow);
-    }
-    section.appendChild(
-      this.createContextControlRow(group, showAbove, showBelow, viewMode)
-    );
-    if (showBelow) {
-      const paddingRow = this.createContextControlPaddingRow(viewMode);
-      paddingRow.classList.add('below');
-      section.appendChild(paddingRow);
-    }
-  }
-
-  /**
-   * Creates a context control <tr> table row for with buttons the allow the
-   * user to expand collapsed lines. Buttons extend from the gap created by this
-   * method up or down into the area of code that they affect.
-   */
-  private createContextControlRow(
-    group: GrDiffGroup,
-    showAbove: boolean,
-    showBelow: boolean,
-    viewMode: DiffViewMode
-  ): HTMLElement {
-    const row = createElementDiff('tr', 'dividerRow');
-    let showConfig: GrContextControlsShowConfig;
-    if (showAbove && !showBelow) {
-      showConfig = 'above';
-    } else if (!showAbove && showBelow) {
-      showConfig = 'below';
-    } else {
-      // Note that !showAbove && !showBelow also intentionally creates
-      // "show-both". This means the file is completely collapsed, which is
-      // unusual, but at least happens in one test.
-      showConfig = 'both';
-    }
-    row.classList.add(`show-${showConfig}`);
-
-    row.appendChild(this.createBlameCell(0));
-    if (viewMode === DiffViewMode.SIDE_BY_SIDE) {
-      row.appendChild(createElementDiff('td'));
-    }
-
-    const cell = createElementDiff('td', 'dividerCell');
-    const colspan = this.renderPrefs?.show_sign_col ? '5' : '3';
-    cell.setAttribute('colspan', colspan);
-    row.appendChild(cell);
-
-    const contextControls = createElementDiff(
-      'gr-context-controls'
-    ) as GrContextControls;
-    contextControls.diff = this._diff;
-    contextControls.renderPreferences = this.renderPrefs;
-    contextControls.group = group;
-    contextControls.showConfig = showConfig;
-    cell.appendChild(contextControls);
-    return row;
-  }
-
-  /**
-   * Creates a table row to serve as padding between code and context controls.
-   * Blame column, line gutters, and content area will continue visually, but
-   * context controls can render over this background to map more clearly to
-   * the area of code they expand.
-   */
-  private createContextControlPaddingRow(viewMode: DiffViewMode) {
-    const row = createElementDiff('tr', 'contextBackground');
-
-    if (viewMode === DiffViewMode.SIDE_BY_SIDE) {
-      row.classList.add('side-by-side');
-      row.setAttribute('left-type', GrDiffGroupType.CONTEXT_CONTROL);
-      row.setAttribute('right-type', GrDiffGroupType.CONTEXT_CONTROL);
-    } else {
-      row.classList.add('unified');
-    }
-
-    row.appendChild(this.createBlameCell(0));
-    row.appendChild(createElementDiff('td', 'contextLineNum'));
-    if (viewMode === DiffViewMode.SIDE_BY_SIDE) {
-      row.appendChild(createElementDiff('td', 'sign'));
-      row.appendChild(createElementDiff('td'));
-    }
-    row.appendChild(createElementDiff('td', 'contextLineNum'));
-    if (viewMode === DiffViewMode.SIDE_BY_SIDE) {
-      row.appendChild(createElementDiff('td', 'sign'));
-    }
-    row.appendChild(createElementDiff('td'));
-
-    return row;
-  }
-
-  protected createLineEl(
-    line: GrDiffLine,
-    number: LineNumber,
-    type: GrDiffLineType,
-    side: Side
-  ) {
-    const td = createElementDiff('td');
-    td.classList.add(side);
-    if (line.type === GrDiffLineType.BLANK) {
-      td.classList.add('blankLineNum');
-      return td;
-    }
-    if (line.type === GrDiffLineType.BOTH || line.type === type) {
-      td.classList.add('lineNum');
-      td.dataset['value'] = number.toString();
-
-      if (
-        ((this._prefs.show_file_comment_button === false ||
-          this.renderPrefs?.show_file_comment_button === false) &&
-          number === 'FILE') ||
-        number === 'LOST'
-      ) {
-        return td;
-      }
-
-      const button = createElementDiff('button');
-      td.appendChild(button);
-      button.tabIndex = -1;
-      button.classList.add('lineNumButton');
-      button.classList.add(side);
-      button.dataset['value'] = number.toString();
-      button.id =
-        side === Side.LEFT ? `left-button-${number}` : `right-button-${number}`;
-      button.textContent = number === 'FILE' ? 'File' : number.toString();
-      if (number === 'FILE') {
-        button.setAttribute('aria-label', 'Add file comment');
-      }
-
-      // Add aria-labels for valid line numbers.
-      // For unified diff, this method will be called with number set to 0 for
-      // the empty line number column for added/removed lines. This should not
-      // be announced to the screenreader.
-      if (number > 0) {
-        if (line.type === GrDiffLineType.REMOVE) {
-          button.setAttribute('aria-label', `${number} removed`);
-        } else if (line.type === GrDiffLineType.ADD) {
-          button.setAttribute('aria-label', `${number} added`);
-        } else {
-          button.setAttribute('aria-label', `${number} unmodified`);
-        }
-      }
-      this.addLineNumberMouseEvents(td, number, side);
-    }
-    return td;
-  }
-
-  private addLineNumberMouseEvents(
-    el: HTMLElement,
-    number: LineNumber,
-    side: Side
-  ) {
-    el.addEventListener('mouseenter', () => {
-      fire(el, 'line-mouse-enter', {lineNum: number, side});
-    });
-    el.addEventListener('mouseleave', () => {
-      fire(el, 'line-mouse-leave', {lineNum: number, side});
-    });
-  }
-
-  // visible for testing
-  createTextEl(
-    lineNumberEl: HTMLElement | null,
-    line: GrDiffLine,
-    side?: Side
-  ) {
-    const td = createElementDiff('td');
-    if (line.type !== GrDiffLineType.BLANK) {
-      td.classList.add('content');
-    }
-    if (side) {
-      td.classList.add(side);
-    }
-
-    // If intraline info is not available, the entire line will be
-    // considered as changed and marked as dark red / green color
-    if (!line.hasIntralineInfo) {
-      td.classList.add('no-intraline-info');
-    }
-    td.classList.add(line.type);
-
-    const {beforeNumber, afterNumber} = line;
-    if (beforeNumber !== 'FILE' && beforeNumber !== 'LOST') {
-      const responsiveMode = getResponsiveMode(this._prefs, this.renderPrefs);
-      const contentText = formatText(
-        line.text,
-        responsiveMode,
-        this._prefs.tab_size,
-        this._prefs.line_length,
-        side === Side.LEFT
-          ? `left-content-${beforeNumber}`
-          : `right-content-${afterNumber}`
-      );
-
-      if (side) {
-        contentText.setAttribute('data-side', side);
-        const number = side === Side.LEFT ? beforeNumber : afterNumber;
-        this.addLineNumberMouseEvents(td, number, side);
-      }
-
-      if (lineNumberEl && side) {
-        for (const layer of this.layers) {
-          if (typeof layer.annotate === 'function') {
-            layer.annotate(contentText, lineNumberEl, line, side);
-          }
-        }
-      } else {
-        console.error('lineNumberEl or side not set, skipping layer.annotate');
-      }
-
-      td.appendChild(contentText);
-    } else if (line.beforeNumber === 'FILE') td.classList.add('file');
-    else if (line.beforeNumber === 'LOST') td.classList.add('lost');
-
-    return td;
-  }
-
-  private createMovedLineAnchor(line: number, side: Side) {
-    const anchor = createElementDiffWithText('a', `${line}`);
-
-    // href is not actually used but important for Screen Readers
-    anchor.setAttribute('href', `#${line}`);
-    anchor.addEventListener('click', e => {
-      e.preventDefault();
-      anchor.dispatchEvent(
-        new CustomEvent<MovedLinkClickedEventDetail>('moved-link-clicked', {
-          detail: {
-            lineNum: line,
-            side,
-          },
-          composed: true,
-          bubbles: true,
-        })
-      );
-    });
-    return anchor;
-  }
-
-  private createMoveDescriptionDiv(movedIn: boolean, group: GrDiffGroup) {
-    const div = createElementDiff('div');
-    if (group.moveDetails?.range) {
-      const {changed, range} = group.moveDetails;
-      const otherSide = movedIn ? Side.LEFT : Side.RIGHT;
-      const andChangedLabel = changed ? 'and changed ' : '';
-      const direction = movedIn ? 'from' : 'to';
-      const textLabel = `Moved ${andChangedLabel}${direction} lines `;
-      div.appendChild(createElementDiffWithText('span', textLabel));
-      div.appendChild(this.createMovedLineAnchor(range.start, otherSide));
-      div.appendChild(createElementDiffWithText('span', ' - '));
-      div.appendChild(this.createMovedLineAnchor(range.end, otherSide));
-    } else {
-      div.appendChild(
-        createElementDiffWithText('span', movedIn ? 'Moved in' : 'Moved out')
-      );
-    }
-    return div;
-  }
-
-  protected buildMoveControls(group: GrDiffGroup) {
-    const movedIn = group.adds.length > 0;
-    const {
-      numberOfCells,
-      movedOutIndex,
-      movedInIndex,
-      lineNumberCols,
-      signCols,
-    } = this.getMoveControlsConfig();
-
-    let controlsClass;
-    let descriptionIndex;
-    const descriptionTextDiv = this.createMoveDescriptionDiv(movedIn, group);
-    if (movedIn) {
-      controlsClass = 'movedIn';
-      descriptionIndex = movedInIndex;
-    } else {
-      controlsClass = 'movedOut';
-      descriptionIndex = movedOutIndex;
-    }
-
-    const controls = createElementDiff('tr', `moveControls ${controlsClass}`);
-    const cells = [...Array(numberOfCells).keys()].map(() =>
-      createElementDiff('td')
-    );
-    lineNumberCols.forEach(index => {
-      cells[index].classList.add('moveControlsLineNumCol');
-    });
-
-    if (signCols) {
-      cells[signCols.left].classList.add('sign', 'left');
-      cells[signCols.right].classList.add('sign', 'right');
-    }
-    const moveRangeHeader = createElementDiff('gr-range-header');
-    moveRangeHeader.setAttribute('icon', 'move_item');
-    moveRangeHeader.appendChild(descriptionTextDiv);
-    cells[descriptionIndex].classList.add('moveHeader');
-    cells[descriptionIndex].appendChild(moveRangeHeader);
-    cells.forEach(c => {
-      controls.appendChild(c);
-    });
-    return controls;
-  }
-
-  /**
-   * Create a blame cell for the given base line. Blame information will be
-   * included in the cell if available.
-   */
-  // visible for testing
-  createBlameCell(lineNumber: LineNumber): HTMLTableCellElement {
-    const blameTd = createElementDiff('td', 'blame') as HTMLTableCellElement;
-    blameTd.setAttribute('data-line-number', lineNumber.toString());
-    if (!lineNumber) return blameTd;
-
-    const blameInfo = this.getBlameCommitForBaseLine(lineNumber);
-    if (!blameInfo) return blameTd;
-
-    blameTd.appendChild(createBlameElement(lineNumber, blameInfo));
-    return blameTd;
-  }
-}
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-side-by-side.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-side-by-side.ts
deleted file mode 100644
index f7d1552..0000000
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-side-by-side.ts
+++ /dev/null
@@ -1,178 +0,0 @@
-/**
- * @license
- * Copyright 2016 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group';
-import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
-import {GrDiffLine, GrDiffLineType, LineNumber} from '../gr-diff/gr-diff-line';
-import {DiffViewMode, Side} from '../../../constants/constants';
-import {DiffLayer} from '../../../types/types';
-import {RenderPreferences} from '../../../api/diff';
-import {createElementDiff} from '../gr-diff/gr-diff-utils';
-import {GrDiffBuilderLegacy} from './gr-diff-builder-legacy';
-
-export class GrDiffBuilderSideBySide extends GrDiffBuilderLegacy {
-  constructor(
-    diff: DiffInfo,
-    prefs: DiffPreferencesInfo,
-    outputEl: HTMLElement,
-    layers: DiffLayer[] = [],
-    renderPrefs?: RenderPreferences
-  ) {
-    super(diff, prefs, outputEl, layers, renderPrefs);
-  }
-
-  protected override getMoveControlsConfig() {
-    return {
-      numberOfCells: 6,
-      movedOutIndex: 2,
-      movedInIndex: 5,
-      lineNumberCols: [0, 3],
-      signCols: {left: 1, right: 4},
-    };
-  }
-
-  // visible for testing
-  override buildSectionElement(group: GrDiffGroup) {
-    const sectionEl = createElementDiff('tbody', 'section');
-    sectionEl.classList.add(group.type);
-    if (group.isTotal()) {
-      sectionEl.classList.add('total');
-    }
-    if (group.dueToRebase) {
-      sectionEl.classList.add('dueToRebase');
-    }
-    if (group.moveDetails) {
-      sectionEl.classList.add('dueToMove');
-      sectionEl.appendChild(this.buildMoveControls(group));
-    }
-    if (group.ignoredWhitespaceOnly) {
-      sectionEl.classList.add('ignoredWhitespaceOnly');
-    }
-    if (group.type === GrDiffGroupType.CONTEXT_CONTROL) {
-      this.createContextControls(sectionEl, group, DiffViewMode.SIDE_BY_SIDE);
-      return sectionEl;
-    }
-
-    const pairs = group.getSideBySidePairs();
-    for (let i = 0; i < pairs.length; i++) {
-      sectionEl.appendChild(this.createRow(pairs[i].left, pairs[i].right));
-    }
-    return sectionEl;
-  }
-
-  override addColumns(outputEl: HTMLElement, lineNumberWidth: number): void {
-    const colgroup = document.createElement('colgroup');
-
-    // Add the blame column.
-    let col = createElementDiff('col', 'blame');
-    colgroup.appendChild(col);
-
-    // Add left-side line number.
-    col = createElementDiff('col', 'left');
-    col.setAttribute('width', lineNumberWidth.toString());
-    colgroup.appendChild(col);
-
-    colgroup.appendChild(createElementDiff('col', 'sign left'));
-
-    // Add left-side content.
-    colgroup.appendChild(createElementDiff('col', 'left'));
-
-    // Add right-side line number.
-    col = document.createElement('col');
-    col.setAttribute('width', lineNumberWidth.toString());
-    colgroup.appendChild(col);
-
-    colgroup.appendChild(createElementDiff('col', 'sign right'));
-
-    // Add right-side content.
-    colgroup.appendChild(document.createElement('col'));
-
-    outputEl.appendChild(colgroup);
-  }
-
-  private createRow(leftLine: GrDiffLine, rightLine: GrDiffLine) {
-    const row = createElementDiff('tr');
-    row.classList.add('diff-row', 'side-by-side');
-    row.setAttribute('left-type', leftLine.type);
-    row.setAttribute('right-type', rightLine.type);
-    // TabIndex makes screen reader read a row when navigating with j/k
-    row.tabIndex = -1;
-    // Before Chrome 102, Chrome was able to compute a11y label from children
-    // content. Now Chrome 102 and Firefox are not computing a11y label because
-    // tr is not expected to have aria label. Adding aria role button is
-    // pushing browser to compute aria even for tr. This can be removed, once
-    // browsers will again compute a11y label even for tr when it is focused.
-    // TODO: Remove when Chrome 102 is out of date for 1 year.
-    if (
-      leftLine.beforeNumber !== 'FILE' &&
-      leftLine.beforeNumber !== 'LOST' &&
-      rightLine.beforeNumber !== 'FILE' &&
-      rightLine.beforeNumber !== 'LOST'
-    ) {
-      row.setAttribute(
-        'aria-labelledby',
-        [
-          leftLine.beforeNumber ? `left-button-${leftLine.beforeNumber}` : '',
-          leftLine.beforeNumber ? `left-content-${leftLine.beforeNumber}` : '',
-          rightLine.afterNumber ? `right-button-${rightLine.afterNumber}` : '',
-          rightLine.afterNumber ? `right-content-${rightLine.afterNumber}` : '',
-        ]
-          .join(' ')
-          .trim()
-      );
-    }
-
-    row.appendChild(this.createBlameCell(leftLine.beforeNumber));
-
-    this.appendPair(row, leftLine, leftLine.beforeNumber, Side.LEFT);
-    this.appendPair(row, rightLine, rightLine.afterNumber, Side.RIGHT);
-    return row;
-  }
-
-  private appendPair(
-    row: HTMLElement,
-    line: GrDiffLine,
-    lineNumber: LineNumber,
-    side: Side
-  ) {
-    const lineNumberEl = this.createLineEl(line, lineNumber, line.type, side);
-    row.appendChild(lineNumberEl);
-    row.appendChild(this.createSignEl(line, side));
-    row.appendChild(this.createTextEl(lineNumberEl, line, side));
-  }
-
-  private createSignEl(line: GrDiffLine, side: Side): HTMLElement {
-    const td = createElementDiff('td', 'sign');
-    td.classList.add(side);
-    if (line.type === GrDiffLineType.BLANK) {
-      td.classList.add('blank');
-    } else if (line.type === GrDiffLineType.ADD && side === Side.RIGHT) {
-      td.classList.add('add');
-      td.innerText = '+';
-    } else if (line.type === GrDiffLineType.REMOVE && side === Side.LEFT) {
-      td.classList.add('remove');
-      td.innerText = '-';
-    }
-    if (!line.hasIntralineInfo) {
-      td.classList.add('no-intraline-info');
-    }
-    return td;
-  }
-
-  // visible for testing
-  override getNextContentOnSide(
-    content: HTMLElement,
-    side: Side
-  ): HTMLElement | null {
-    let tr: HTMLElement = content.parentElement!.parentElement!;
-    while ((tr = tr.nextSibling as HTMLElement)) {
-      const nextContent = tr.querySelector(
-        'td.content .contentText[data-side="' + side + '"]'
-      );
-      if (nextContent) return nextContent as HTMLElement;
-    }
-    return null;
-  }
-}
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-unified.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-unified.ts
deleted file mode 100644
index a06701b..0000000
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-unified.ts
+++ /dev/null
@@ -1,165 +0,0 @@
-/**
- * @license
- * Copyright 2016 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-import {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line';
-import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group';
-import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
-import {DiffViewMode, Side} from '../../../constants/constants';
-import {DiffLayer} from '../../../types/types';
-import {RenderPreferences} from '../../../api/diff';
-import {createElementDiff} from '../gr-diff/gr-diff-utils';
-import {GrDiffBuilderLegacy} from './gr-diff-builder-legacy';
-
-export class GrDiffBuilderUnified extends GrDiffBuilderLegacy {
-  constructor(
-    diff: DiffInfo,
-    prefs: DiffPreferencesInfo,
-    outputEl: HTMLElement,
-    layers: DiffLayer[] = [],
-    renderPrefs?: RenderPreferences
-  ) {
-    super(diff, prefs, outputEl, layers, renderPrefs);
-  }
-
-  protected override getMoveControlsConfig() {
-    return {
-      numberOfCells: 3,
-      movedOutIndex: 2,
-      movedInIndex: 2,
-      lineNumberCols: [0, 1],
-    };
-  }
-
-  // visible for testing
-  override buildSectionElement(group: GrDiffGroup): HTMLElement {
-    const sectionEl = createElementDiff('tbody', 'section');
-    sectionEl.classList.add(group.type);
-    if (group.isTotal()) {
-      sectionEl.classList.add('total');
-    }
-    if (group.dueToRebase) {
-      sectionEl.classList.add('dueToRebase');
-    }
-    if (group.moveDetails) {
-      sectionEl.classList.add('dueToMove');
-      sectionEl.appendChild(this.buildMoveControls(group));
-    }
-    if (group.ignoredWhitespaceOnly) {
-      sectionEl.classList.add('ignoredWhitespaceOnly');
-    }
-    if (group.type === GrDiffGroupType.CONTEXT_CONTROL) {
-      this.createContextControls(sectionEl, group, DiffViewMode.UNIFIED);
-      return sectionEl;
-    }
-
-    for (let i = 0; i < group.lines.length; ++i) {
-      const line = group.lines[i];
-      // If only whitespace has changed and the settings ask for whitespace to
-      // be ignored, only render the right-side line in unified diff mode.
-      if (group.ignoredWhitespaceOnly && line.type === GrDiffLineType.REMOVE) {
-        continue;
-      }
-      sectionEl.appendChild(this.createRow(line));
-    }
-    return sectionEl;
-  }
-
-  override addColumns(outputEl: HTMLElement, lineNumberWidth: number): void {
-    const colgroup = document.createElement('colgroup');
-
-    // Add the blame column.
-    let col = createElementDiff('col', 'blame');
-    colgroup.appendChild(col);
-
-    // Add left-side line number.
-    col = document.createElement('col');
-    col.setAttribute('width', lineNumberWidth.toString());
-    colgroup.appendChild(col);
-
-    // Add right-side line number.
-    col = document.createElement('col');
-    col.setAttribute('width', lineNumberWidth.toString());
-    colgroup.appendChild(col);
-
-    // Add the content.
-    colgroup.appendChild(document.createElement('col'));
-
-    outputEl.appendChild(colgroup);
-  }
-
-  protected createRow(line: GrDiffLine) {
-    const row = createElementDiff('tr', line.type);
-    row.classList.add('diff-row', 'unified');
-    // TabIndex makes screen reader read a row when navigating with j/k
-    row.tabIndex = -1;
-    row.appendChild(this.createBlameCell(line.beforeNumber));
-    let lineNumberEl = this.createLineEl(
-      line,
-      line.beforeNumber,
-      GrDiffLineType.REMOVE,
-      Side.LEFT
-    );
-    row.appendChild(lineNumberEl);
-    lineNumberEl = this.createLineEl(
-      line,
-      line.afterNumber,
-      GrDiffLineType.ADD,
-      Side.RIGHT
-    );
-    row.appendChild(lineNumberEl);
-    let side = undefined;
-    if (line.type === GrDiffLineType.ADD || line.type === GrDiffLineType.BOTH) {
-      side = Side.RIGHT;
-    }
-    if (line.type === GrDiffLineType.REMOVE) {
-      side = Side.LEFT;
-    }
-
-    // Before Chrome 102, Chrome was able to compute a11y label from children
-    // content. Now Chrome 102 and Firefox are not computing a11y label because
-    // tr is not expected to have aria label. Adding aria role button is
-    // pushing browser to compute aria even for tr. This can be removed, once
-    // browsers will again compute a11y label even for tr when it is focused.
-    // TODO: Remove when Chrome 102 is out of date for 1 year.
-    if (line.beforeNumber !== 'FILE' && line.beforeNumber !== 'LOST') {
-      row.setAttribute(
-        'aria-labelledby',
-        [
-          line.beforeNumber ? `left-button-${line.beforeNumber}` : '',
-          line.afterNumber ? `right-button-${line.afterNumber}` : '',
-          side === Side.LEFT && line.beforeNumber
-            ? `left-content-${line.beforeNumber}`
-            : '',
-          side === Side.RIGHT && line.afterNumber
-            ? `right-content-${line.afterNumber}`
-            : '',
-        ]
-          .join(' ')
-          .trim()
-      );
-    }
-    row.appendChild(this.createTextEl(lineNumberEl, line, side));
-    return row;
-  }
-
-  getNextContentOnSide(content: HTMLElement, side: Side): HTMLElement | null {
-    let tr: HTMLElement = content.parentElement!.parentElement!;
-    while ((tr = tr.nextSibling as HTMLElement)) {
-      // Note that this does not work when there is a "common" chunk in the
-      // diff (different content only because of whitespace). Such chunks are
-      // rendered with class "add", so these rows will be skipped for the
-      // 'left' side.
-      // TODO: Fix this when writing a Lit component for unified diff.
-      if (
-        tr.classList.contains('both') ||
-        (side === 'left' && tr.classList.contains('remove')) ||
-        (side === 'right' && tr.classList.contains('add'))
-      ) {
-        return tr.querySelector('.contentText');
-      }
-    }
-    return null;
-  }
-}
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-unified_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-unified_test.ts
deleted file mode 100644
index 8c44727..0000000
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-unified_test.ts
+++ /dev/null
@@ -1,283 +0,0 @@
-/**
- * @license
- * Copyright 2016 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-import '../../../test/common-test-setup';
-import '../gr-diff/gr-diff-group';
-import './gr-diff-builder';
-import './gr-diff-builder-unified';
-import {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line';
-import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group';
-import {GrDiffBuilderUnified} from './gr-diff-builder-unified';
-import {DiffPreferencesInfo} from '../../../api/diff';
-import {createDefaultDiffPrefs} from '../../../constants/constants';
-import {createDiff} from '../../../test/test-data-generators';
-import {queryAndAssert} from '../../../utils/common-util';
-import {assert} from '@open-wc/testing';
-
-suite('GrDiffBuilderUnified tests', () => {
-  let prefs: DiffPreferencesInfo;
-  let outputEl: HTMLElement;
-  let diffBuilder: GrDiffBuilderUnified;
-
-  setup(() => {
-    prefs = {
-      ...createDefaultDiffPrefs(),
-      line_length: 10,
-      show_tabs: true,
-      tab_size: 4,
-    };
-    outputEl = document.createElement('div');
-    diffBuilder = new GrDiffBuilderUnified(createDiff(), prefs, outputEl, []);
-  });
-
-  suite('buildSectionElement for BOTH group', () => {
-    let lines: GrDiffLine[];
-    let group: GrDiffGroup;
-
-    setup(() => {
-      lines = [
-        new GrDiffLine(GrDiffLineType.BOTH, 1, 2),
-        new GrDiffLine(GrDiffLineType.BOTH, 2, 3),
-        new GrDiffLine(GrDiffLineType.BOTH, 3, 4),
-      ];
-      lines[0].text = 'def hello_world():';
-      lines[1].text = '  print "Hello World";';
-      lines[2].text = '  return True';
-
-      group = new GrDiffGroup({type: GrDiffGroupType.BOTH, lines});
-    });
-
-    test('creates the section', () => {
-      const sectionEl = diffBuilder.buildSectionElement(group);
-      assert.isTrue(sectionEl.classList.contains('section'));
-      assert.isTrue(sectionEl.classList.contains('both'));
-    });
-
-    test('creates each unchanged row once', () => {
-      const sectionEl = diffBuilder.buildSectionElement(group);
-      const rowEls = sectionEl.querySelectorAll('.diff-row');
-
-      assert.equal(rowEls.length, 3);
-
-      assert.equal(
-        queryAndAssert(rowEls[0], '.lineNum.left').textContent,
-        lines[0].beforeNumber.toString()
-      );
-      assert.equal(
-        queryAndAssert(rowEls[0], '.lineNum.right').textContent,
-        lines[0].afterNumber.toString()
-      );
-      assert.equal(
-        queryAndAssert(rowEls[0], '.content').textContent,
-        lines[0].text
-      );
-
-      assert.equal(
-        queryAndAssert(rowEls[1], '.lineNum.left').textContent,
-        lines[1].beforeNumber.toString()
-      );
-      assert.equal(
-        queryAndAssert(rowEls[1], '.lineNum.right').textContent,
-        lines[1].afterNumber.toString()
-      );
-      assert.equal(
-        queryAndAssert(rowEls[1], '.content').textContent,
-        lines[1].text
-      );
-
-      assert.equal(
-        queryAndAssert(rowEls[2], '.lineNum.left').textContent,
-        lines[2].beforeNumber.toString()
-      );
-      assert.equal(
-        queryAndAssert(rowEls[2], '.lineNum.right').textContent,
-        lines[2].afterNumber.toString()
-      );
-      assert.equal(
-        queryAndAssert(rowEls[2], '.content').textContent,
-        lines[2].text
-      );
-    });
-  });
-
-  suite('buildSectionElement for moved chunks', () => {
-    test('creates a moved out group', () => {
-      const lines = [
-        new GrDiffLine(GrDiffLineType.REMOVE, 15),
-        new GrDiffLine(GrDiffLineType.REMOVE, 16),
-      ];
-      lines[0].text = 'def hello_world():';
-      lines[1].text = '  print "Hello World"';
-      const group = new GrDiffGroup({
-        type: GrDiffGroupType.DELTA,
-        lines,
-        moveDetails: {changed: false},
-      });
-
-      const sectionEl = diffBuilder.buildSectionElement(group);
-
-      const rowEls = sectionEl.querySelectorAll('tr');
-      const moveControlsRow = rowEls[0];
-      const cells = moveControlsRow.querySelectorAll('td');
-      assert.isTrue(sectionEl.classList.contains('dueToMove'));
-      assert.equal(rowEls.length, 3);
-      assert.isTrue(moveControlsRow.classList.contains('movedOut'));
-      assert.equal(cells.length, 3);
-      assert.isTrue(cells[2].classList.contains('moveHeader'));
-      assert.equal(cells[2].textContent, 'Moved out');
-    });
-
-    test('creates a moved in group', () => {
-      const lines = [
-        new GrDiffLine(GrDiffLineType.ADD, 37),
-        new GrDiffLine(GrDiffLineType.ADD, 38),
-      ];
-      lines[0].text = 'def hello_world():';
-      lines[1].text = '  print "Hello World"';
-      const group = new GrDiffGroup({
-        type: GrDiffGroupType.DELTA,
-        lines,
-        moveDetails: {changed: false},
-      });
-
-      const sectionEl = diffBuilder.buildSectionElement(group);
-
-      const rowEls = sectionEl.querySelectorAll('tr');
-      const moveControlsRow = rowEls[0];
-      const cells = moveControlsRow.querySelectorAll('td');
-      assert.isTrue(sectionEl.classList.contains('dueToMove'));
-      assert.equal(rowEls.length, 3);
-      assert.isTrue(moveControlsRow.classList.contains('movedIn'));
-      assert.equal(cells.length, 3);
-      assert.isTrue(cells[2].classList.contains('moveHeader'));
-      assert.equal(cells[2].textContent, 'Moved in');
-    });
-  });
-
-  suite('buildSectionElement for DELTA group', () => {
-    let lines: GrDiffLine[];
-    let group: GrDiffGroup;
-
-    setup(() => {
-      lines = [
-        new GrDiffLine(GrDiffLineType.REMOVE, 1),
-        new GrDiffLine(GrDiffLineType.REMOVE, 2),
-        new GrDiffLine(GrDiffLineType.ADD, 2),
-        new GrDiffLine(GrDiffLineType.ADD, 3),
-      ];
-      lines[0].text = 'def hello_world():';
-      lines[1].text = '  print "Hello World"';
-      lines[2].text = 'def hello_universe()';
-      lines[3].text = '  print "Hello Universe"';
-    });
-
-    test('creates the section', () => {
-      group = new GrDiffGroup({type: GrDiffGroupType.DELTA, lines});
-      const sectionEl = diffBuilder.buildSectionElement(group);
-      assert.isTrue(sectionEl.classList.contains('section'));
-      assert.isTrue(sectionEl.classList.contains('delta'));
-    });
-
-    test('creates the section with class if ignoredWhitespaceOnly', () => {
-      group = new GrDiffGroup({
-        type: GrDiffGroupType.DELTA,
-        lines,
-        ignoredWhitespaceOnly: true,
-      });
-      const sectionEl = diffBuilder.buildSectionElement(group);
-      assert.isTrue(sectionEl.classList.contains('ignoredWhitespaceOnly'));
-    });
-
-    test('creates the section with class if dueToRebase', () => {
-      group = new GrDiffGroup({
-        type: GrDiffGroupType.DELTA,
-        lines,
-        dueToRebase: true,
-      });
-      const sectionEl = diffBuilder.buildSectionElement(group);
-      assert.isTrue(sectionEl.classList.contains('dueToRebase'));
-    });
-
-    test('creates first the removed and then the added rows', () => {
-      group = new GrDiffGroup({type: GrDiffGroupType.DELTA, lines});
-      const sectionEl = diffBuilder.buildSectionElement(group);
-      const rowEls = sectionEl.querySelectorAll('.diff-row');
-
-      assert.equal(rowEls.length, 4);
-
-      assert.equal(
-        queryAndAssert(rowEls[0], '.lineNum.left').textContent,
-        lines[0].beforeNumber.toString()
-      );
-      assert.isNotOk(rowEls[0].querySelector('.lineNum.right'));
-      assert.equal(
-        queryAndAssert(rowEls[0], '.content').textContent,
-        lines[0].text
-      );
-
-      assert.equal(
-        queryAndAssert(rowEls[1], '.lineNum.left').textContent,
-        lines[1].beforeNumber.toString()
-      );
-      assert.isNotOk(rowEls[1].querySelector('.lineNum.right'));
-      assert.equal(
-        queryAndAssert(rowEls[1], '.content').textContent,
-        lines[1].text
-      );
-
-      assert.isNotOk(rowEls[2].querySelector('.lineNum.left'));
-      assert.equal(
-        queryAndAssert(rowEls[2], '.lineNum.right').textContent,
-        lines[2].afterNumber.toString()
-      );
-      assert.equal(
-        queryAndAssert(rowEls[2], '.content').textContent,
-        lines[2].text
-      );
-
-      assert.isNotOk(rowEls[3].querySelector('.lineNum.left'));
-      assert.equal(
-        queryAndAssert(rowEls[3], '.lineNum.right').textContent,
-        lines[3].afterNumber.toString()
-      );
-      assert.equal(
-        queryAndAssert(rowEls[3], '.content').textContent,
-        lines[3].text
-      );
-    });
-
-    test('creates only the added rows if only ignored whitespace', () => {
-      group = new GrDiffGroup({
-        type: GrDiffGroupType.DELTA,
-        lines,
-        ignoredWhitespaceOnly: true,
-      });
-      const sectionEl = diffBuilder.buildSectionElement(group);
-      const rowEls = sectionEl.querySelectorAll('.diff-row');
-
-      assert.equal(rowEls.length, 2);
-
-      assert.isNotOk(rowEls[0].querySelector('.lineNum.left'));
-      assert.equal(
-        queryAndAssert(rowEls[0], '.lineNum.right').textContent,
-        lines[2].afterNumber.toString()
-      );
-      assert.equal(
-        queryAndAssert(rowEls[0], '.content').textContent,
-        lines[2].text
-      );
-
-      assert.isNotOk(rowEls[1].querySelector('.lineNum.left'));
-      assert.equal(
-        queryAndAssert(rowEls[1], '.lineNum.right').textContent,
-        lines[3].afterNumber.toString()
-      );
-      assert.equal(
-        queryAndAssert(rowEls[1], '.content').textContent,
-        lines[3].text
-      );
-    });
-  });
-});
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder.ts
index 0006f26..f38ba5c 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
@@ -3,19 +3,27 @@
  * Copyright 2016 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import './gr-diff-section';
+import '../gr-context-controls/gr-context-controls';
 import {
   ContentLoadNeededEventDetail,
   DiffContextExpandedExternalDetail,
+  DiffViewMode,
   RenderPreferences,
 } from '../../../api/diff';
-import {GrDiffLine, GrDiffLineType, LineNumber} from '../gr-diff/gr-diff-line';
+import {LineNumber} from '../gr-diff/gr-diff-line';
 import {GrDiffGroup} from '../gr-diff/gr-diff-group';
-import {assert} from '../../../utils/common-util';
-import '../gr-context-controls/gr-context-controls';
 import {BlameInfo} from '../../../types/common';
 import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
 import {Side} from '../../../constants/constants';
-import {DiffLayer} from '../../../types/types';
+import {DiffLayer, isDefined} from '../../../types/types';
+import {GrDiffRow} from './gr-diff-row';
+import {GrDiffSection} from './gr-diff-section';
+import {html, render} from 'lit';
+import {diffClasses} from '../gr-diff/gr-diff-utils';
+import {when} from 'lit/directives/when.js';
+import {GrDiffBuilderImage} from './gr-diff-builder-image';
+import {GrDiffBuilderBinary} from './gr-diff-builder-binary';
 
 export interface DiffContextExpandedEventDetail
   extends DiffContextExpandedExternalDetail {
@@ -32,62 +40,34 @@
   }
 }
 
-/**
- * Given that GrDiffBuilder has ~1,000 lines of code, this interface is just
- * making refactorings easier by emphasizing what the public facing "contract"
- * of this class is. There are no plans for adding separate implementations.
- */
-export interface DiffBuilder {
-  clear(): void;
-  addGroups(groups: readonly GrDiffGroup[]): void;
-  clearGroups(): void;
-  replaceGroup(
-    contextControl: GrDiffGroup,
-    groups: readonly GrDiffGroup[]
-  ): void;
-  findGroup(side: Side, line: LineNumber): GrDiffGroup | undefined;
-  addColumns(outputEl: HTMLElement, fontSize: number): void;
-  // TODO: Change `null` to `undefined`.
-  getContentTdByLine(
-    lineNumber: LineNumber,
-    side?: Side,
-    root?: Element
-  ): HTMLTableCellElement | null;
-  getLineElByNumber(
-    lineNumber: LineNumber,
-    side?: Side
-  ): HTMLTableCellElement | null;
-  getLineNumberRows(): HTMLTableRowElement[];
-  getLineNumEls(side: Side): HTMLTableCellElement[];
-  setBlame(blame: BlameInfo[]): void;
-  updateRenderPrefs(renderPrefs: RenderPreferences): void;
+export function isImageDiffBuilder<T extends GrDiffBuilder>(
+  x: T | GrDiffBuilderImage | undefined
+): x is GrDiffBuilderImage {
+  return !!x && !!(x as GrDiffBuilderImage).renderImageDiff;
+}
+
+export function isBinaryDiffBuilder<T extends GrDiffBuilder>(
+  x: T | GrDiffBuilderBinary | undefined
+): x is GrDiffBuilderBinary {
+  return !!x && !!(x as GrDiffBuilderBinary).renderBinaryDiff;
 }
 
 /**
- * Base class for different diff builders, like side-by-side, unified etc.
- *
  * The builder takes GrDiffGroups, and builds the corresponding DOM elements,
  * called sections. Only the builder should add or remove sections from the
  * DOM. Callers can use the ...group() methods to modify groups and thus cause
  * rendering changes.
- *
- * TODO: Do not subclass `GrDiffBuilder`. Use composition and interfaces.
  */
-export abstract class GrDiffBuilder implements DiffBuilder {
-  protected readonly _diff: DiffInfo;
+export class GrDiffBuilder {
+  private readonly diff: DiffInfo;
 
-  protected readonly numLinesLeft: number;
+  readonly prefs: DiffPreferencesInfo;
 
-  // visible for testing
-  readonly _prefs: DiffPreferencesInfo;
+  renderPrefs?: RenderPreferences;
 
-  protected readonly renderPrefs?: RenderPreferences;
+  readonly outputEl: HTMLElement;
 
-  protected readonly outputEl: HTMLElement;
-
-  protected groups: GrDiffGroup[];
-
-  private blameInfo: BlameInfo[] = [];
+  private groups: GrDiffGroup[];
 
   private readonly layerUpdateListener: (
     start: LineNumber,
@@ -102,14 +82,8 @@
     readonly layers: DiffLayer[] = [],
     renderPrefs?: RenderPreferences
   ) {
-    this._diff = diff;
-    this.numLinesLeft = this._diff.content
-      ? this._diff.content.reduce((sum, chunk) => {
-          const left = chunk.a || chunk.ab;
-          return sum + (left?.length || chunk.skip || 0);
-        }, 0)
-      : 0;
-    this._prefs = prefs;
+    this.diff = diff;
+    this.prefs = prefs;
     this.renderPrefs = renderPrefs;
     this.outputEl = outputEl;
     this.groups = [];
@@ -127,6 +101,150 @@
       end: LineNumber,
       side: Side
     ) => this.renderContentByRange(start, end, side);
+    this.init();
+  }
+
+  getContentTdByLine(
+    lineNumber: LineNumber,
+    side?: Side
+  ): HTMLTableCellElement | undefined {
+    if (!side) return undefined;
+    const row = this.findRow(lineNumber, side);
+    return row?.getContentCell(side);
+  }
+
+  getLineElByNumber(
+    lineNumber: LineNumber,
+    side?: Side
+  ): HTMLTableCellElement | undefined {
+    if (!side) return undefined;
+    const row = this.findRow(lineNumber, side);
+    return row?.getLineNumberCell(side);
+  }
+
+  private findRow(lineNumber?: LineNumber, side?: Side): GrDiffRow | undefined {
+    if (!side || !lineNumber) return undefined;
+    const group = this.findGroup(side, lineNumber);
+    if (!group) return undefined;
+    const section = this.findSection(group);
+    if (!section) return undefined;
+    return section.findRow(side, lineNumber);
+  }
+
+  private getDiffRows() {
+    const sections = [
+      ...this.outputEl.querySelectorAll<GrDiffSection>('gr-diff-section'),
+    ];
+    return sections.map(s => s.getDiffRows()).flat();
+  }
+
+  getLineNumberRows(): HTMLTableRowElement[] {
+    const rows = this.getDiffRows();
+    return rows.map(r => r.getTableRow()).filter(isDefined);
+  }
+
+  getLineNumEls(side: Side): HTMLTableCellElement[] {
+    const rows = this.getDiffRows();
+    return rows.map(r => r.getLineNumberCell(side)).filter(isDefined);
+  }
+
+  /** This is used when layers initiate an update. */
+  renderContentByRange(start: LineNumber, end: LineNumber, side: Side) {
+    const groups = this.getGroupsByLineRange(start, end, side);
+    for (const group of groups) {
+      const section = this.findSection(group);
+      for (const row of section?.getDiffRows() ?? []) {
+        row.requestUpdate();
+      }
+    }
+  }
+
+  private findSection(group: GrDiffGroup): GrDiffSection | undefined {
+    const leftClass = `left-${group.startLine(Side.LEFT)}`;
+    const rightClass = `right-${group.startLine(Side.RIGHT)}`;
+    return (
+      this.outputEl.querySelector<GrDiffSection>(
+        `gr-diff-section.${leftClass}.${rightClass}`
+      ) ?? undefined
+    );
+  }
+
+  buildSectionElement(group: GrDiffGroup): HTMLElement {
+    const leftCl = `left-${group.startLine(Side.LEFT)}`;
+    const rightCl = `right-${group.startLine(Side.RIGHT)}`;
+    const section = html`
+      <gr-diff-section
+        class="${leftCl} ${rightCl}"
+        .group=${group}
+        .diff=${this.diff}
+        .layers=${this.layers}
+        .diffPrefs=${this.prefs}
+        .renderPrefs=${this.renderPrefs}
+      ></gr-diff-section>
+    `;
+    // When using Lit's `render()` method it wants to be in full control of the
+    // element that it renders into, so we let it render into a temp element.
+    // Rendering into the diff table directly would interfere with
+    // `clearDiffContent()`for example.
+    // TODO: Convert <gr-diff> to be fully lit controlled and incorporate this
+    // method into Lit's `render()` cycle.
+    const tempEl = document.createElement('div');
+    render(section, tempEl);
+    const sectionEl = tempEl.firstElementChild as GrDiffSection;
+    return sectionEl;
+  }
+
+  addColumns(outputEl: HTMLElement, lineNumberWidth: number): void {
+    const colgroup = html`
+      <colgroup>
+        <col class=${diffClasses('blame')}></col>
+        ${when(
+          this.renderPrefs?.view_mode === DiffViewMode.UNIFIED,
+          () => html` ${this.renderUnifiedColumns(lineNumberWidth)} `,
+          () => html`
+            ${this.renderSideBySideColumns(Side.LEFT, lineNumberWidth)}
+            ${this.renderSideBySideColumns(Side.RIGHT, lineNumberWidth)}
+          `
+        )}
+      </colgroup>
+    `;
+    // When using Lit's `render()` method it wants to be in full control of the
+    // element that it renders into, so we let it render into a temp element.
+    // Rendering into the diff table directly would interfere with
+    // `clearDiffContent()`for example.
+    // TODO: Convert <gr-diff> to be fully lit controlled and incorporate this
+    // method into Lit's `render()` cycle.
+    const tempEl = document.createElement('div');
+    render(colgroup, tempEl);
+    const colgroupEl = tempEl.firstElementChild as HTMLElement;
+    outputEl.appendChild(colgroupEl);
+  }
+
+  private renderUnifiedColumns(lineNumberWidth: number) {
+    return html`
+      <col class=${diffClasses()} width=${lineNumberWidth}></col>
+      <col class=${diffClasses()} width=${lineNumberWidth}></col>
+      <col class=${diffClasses()}></col>
+    `;
+  }
+
+  private renderSideBySideColumns(side: Side, lineNumberWidth: number) {
+    return html`
+      <col class=${diffClasses(side)} width=${lineNumberWidth}></col>
+      <col class=${diffClasses(side, 'sign')}></col>
+      <col class=${diffClasses(side)}></col>
+    `;
+  }
+
+  /**
+   * This is meant to be called when the gr-diff component re-connects, or when
+   * the diff is (re-)rendered.
+   *
+   * Make sure that this method is symmetric with cleanup(), which is called
+   * when gr-diff disconnects.
+   */
+  init() {
+    this.cleanup();
     for (const layer of this.layers) {
       if (layer.addListener) {
         layer.addListener(this.layerUpdateListener);
@@ -134,7 +252,14 @@
     }
   }
 
-  clear() {
+  /**
+   * This is meant to be called when the gr-diff component disconnects, or when
+   * the diff is (re-)rendered.
+   *
+   * Make sure that this method is symmetric with init(), which is called when
+   * gr-diff re-connects.
+   */
+  cleanup() {
     for (const layer of this.layers) {
       if (layer.removeListener) {
         layer.removeListener(this.layerUpdateListener);
@@ -142,10 +267,6 @@
     }
   }
 
-  abstract addColumns(outputEl: HTMLElement, fontSize: number): void;
-
-  protected abstract buildSectionElement(group: GrDiffGroup): HTMLElement;
-
   addGroups(groups: readonly GrDiffGroup[]) {
     for (const group of groups) {
       this.groups.push(group);
@@ -208,151 +329,19 @@
       .filter(group => group.lines.length > 0);
   }
 
-  // TODO: Change `null` to `undefined`.
-  abstract getContentTdByLine(
-    lineNumber: LineNumber,
-    side?: Side,
-    root?: Element
-  ): HTMLTableCellElement | null;
-
-  // TODO: Change `null` to `undefined`.
-  abstract getLineElByNumber(
-    lineNumber: LineNumber,
-    side?: Side
-  ): HTMLTableCellElement | null;
-
-  abstract getLineNumberRows(): HTMLTableRowElement[];
-
-  abstract getLineNumEls(side: Side): HTMLTableCellElement[];
-
-  protected abstract getBlameTdByLine(lineNum: number): Element | undefined;
-
-  // TODO: Change `null` to `undefined`.
-  protected abstract getContentByLine(
-    lineNumber: LineNumber,
-    side?: Side,
-    root?: HTMLElement
-  ): HTMLElement | null;
-
-  /**
-   * Find line elements or line objects by a range of line numbers and a side.
-   *
-   * @param start The first line number
-   * @param end The last line number
-   * @param side The side of the range. Either 'left' or 'right'.
-   * @param out_lines The output list of line objects.
-   *        TODO: Change to camelCase.
-   * @param out_elements The output list of line elements.
-   *        TODO: Change to camelCase.
-   */
-  // visible for testing
-  findLinesByRange(
-    start: LineNumber,
-    end: LineNumber,
-    side: Side,
-    out_lines: GrDiffLine[],
-    out_elements: HTMLElement[]
-  ) {
-    const groups = this.getGroupsByLineRange(start, end, side);
-    for (const group of groups) {
-      let content: HTMLElement | null = null;
-      for (const line of group.lines) {
-        if (
-          (side === 'left' && line.type === GrDiffLineType.ADD) ||
-          (side === 'right' && line.type === GrDiffLineType.REMOVE)
-        ) {
-          continue;
-        }
-        const lineNumber =
-          side === 'left' ? line.beforeNumber : line.afterNumber;
-        if (lineNumber < start || lineNumber > end) {
-          continue;
-        }
-
-        if (content) {
-          content = this.getNextContentOnSide(content, side);
-        } else {
-          content = this.getContentByLine(lineNumber, side, group.element);
-        }
-        if (content) {
-          // out_lines and out_elements must match. So if we don't have an
-          // element to push, then also don't push a line.
-          out_lines.push(line);
-          out_elements.push(content);
-        }
-      }
-    }
-    assert(
-      out_lines.length === out_elements.length,
-      'findLinesByRange: lines and elements arrays must have same length'
-    );
-  }
-
-  protected abstract renderContentByRange(
-    start: LineNumber,
-    end: LineNumber,
-    side: Side
-  ): void;
-
-  protected abstract renderBlameByRange(
-    blame: BlameInfo,
-    start: number,
-    end: number
-  ): void;
-
-  /**
-   * Finds the next DIV.contentText element following the given element, and on
-   * the same side. Will only search within a group.
-   *
-   * TODO: Change `null` to `undefined`.
-   */
-  protected abstract getNextContentOnSide(
-    content: HTMLElement,
-    side: Side
-  ): HTMLElement | null;
-
-  /**
-   * Gets configuration for creating move controls for chunks marked with
-   * dueToMove
-   */
-  protected abstract getMoveControlsConfig(): {
-    numberOfCells: number;
-    movedOutIndex: number;
-    movedInIndex: number;
-    lineNumberCols: number[];
-    signCols?: {left: number; right: number};
-  };
-
   /**
    * Set the blame information for the diff. For any already-rendered line,
    * re-render its blame cell content.
    */
   setBlame(blame: BlameInfo[]) {
-    this.blameInfo = blame;
-    for (const commit of blame) {
-      for (const range of commit.ranges) {
-        this.renderBlameByRange(commit, range.start, range.end);
-      }
-    }
-  }
-
-  /**
-   * Given a base line number, return the commit containing that line in the
-   * current set of blame information. If no blame information has been
-   * provided, null is returned.
-   *
-   * @return The commit information.
-   */
-  // visible for testing
-  getBlameCommitForBaseLine(lineNum: LineNumber): BlameInfo | undefined {
-    for (const blameCommit of this.blameInfo) {
-      for (const range of blameCommit.ranges) {
-        if (range.start <= lineNum && range.end >= lineNum) {
-          return blameCommit;
+    for (const blameInfo of blame) {
+      for (const range of blameInfo.ranges) {
+        for (let line = range.start; line <= range.end; line++) {
+          const row = this.findRow(line, Side.LEFT);
+          if (row) row.blameInfo = blameInfo;
         }
       }
     }
-    return undefined;
   }
 
   /**
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-row.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-row.ts
new file mode 100644
index 0000000..9acda81
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-row.ts
@@ -0,0 +1,474 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {html, LitElement, nothing, TemplateResult} from 'lit';
+import {customElement, property, state} from 'lit/decorators.js';
+import {ifDefined} from 'lit/directives/if-defined.js';
+import {createRef, Ref, ref} from 'lit/directives/ref.js';
+import {
+  DiffResponsiveMode,
+  Side,
+  LineNumber,
+  DiffLayer,
+} from '../../../api/diff';
+import {BlameInfo} from '../../../types/common';
+import {assertIsDefined} from '../../../utils/common-util';
+import {fire} from '../../../utils/event-util';
+import {getBaseUrl} from '../../../utils/url-util';
+import './gr-diff-text';
+import {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line';
+import {diffClasses, isResponsive} from '../gr-diff/gr-diff-utils';
+
+@customElement('gr-diff-row')
+export class GrDiffRow extends LitElement {
+  contentLeftRef: Ref<LitElement> = createRef();
+
+  contentRightRef: Ref<LitElement> = createRef();
+
+  contentCellLeftRef: Ref<HTMLTableCellElement> = createRef();
+
+  contentCellRightRef: Ref<HTMLTableCellElement> = createRef();
+
+  lineNumberLeftRef: Ref<HTMLTableCellElement> = createRef();
+
+  lineNumberRightRef: Ref<HTMLTableCellElement> = createRef();
+
+  blameCellRef: Ref<HTMLTableCellElement> = createRef();
+
+  tableRowRef: Ref<HTMLTableRowElement> = createRef();
+
+  @property({type: Object})
+  left?: GrDiffLine;
+
+  @property({type: Object})
+  right?: GrDiffLine;
+
+  @property({type: Object})
+  blameInfo?: BlameInfo;
+
+  @property({type: Object})
+  responsiveMode?: DiffResponsiveMode;
+
+  /**
+   * true: side-by-side diff
+   * false: unified diff
+   */
+  @property({type: Boolean})
+  unifiedDiff = false;
+
+  @property({type: Number})
+  tabSize = 2;
+
+  @property({type: Number})
+  lineLength = 80;
+
+  @property({type: Boolean})
+  hideFileCommentButton = false;
+
+  @property({type: Object})
+  layers: DiffLayer[] = [];
+
+  /**
+   * Semantic DOM diff testing does not work with just table fragments, so when
+   * running such tests the render() method has to wrap the DOM in a proper
+   * <table> element.
+   */
+  @state()
+  addTableWrapperForTesting = false;
+
+  /**
+   * Keeps track of whether diff layers have already been applied to the diff
+   * row. That happens after the DOM has been created in the `updated()`
+   * lifecycle callback.
+   *
+   * Once layers are applied, the diff row requires two rendering passes for an
+   * update: 1. Remove all <gr-diff-text> elements and their layer manipulated
+   * DOMs. 2. Add fresh <gr-diff-text> elements and let layers re-apply in
+   * `updated()`.
+   */
+  private layersApplied = false;
+
+  /**
+   * The browser API for handling selection does not (yet) work for selection
+   * across multiple shadow DOM elements. So we are rendering gr-diff components
+   * into the light DOM instead of the shadow DOM by overriding this method,
+   * which was the recommended workaround by the lit team.
+   * See also https://github.com/WICG/webcomponents/issues/79.
+   */
+  override createRenderRoot() {
+    return this;
+  }
+
+  override updated() {
+    if (this.layersApplied) {
+      // <gr-diff-text> elements have been removed during rendering. Let's start
+      // another rendering cycle with freshly created <gr-diff-text> elements.
+      this.updateComplete.then(() => {
+        this.layersApplied = false;
+        this.requestUpdate();
+      });
+    } else {
+      this.updateLayers(Side.LEFT);
+      this.updateLayers(Side.RIGHT);
+    }
+  }
+
+  /**
+   * The diff layers API is designed to let layers manipulate the DOM. So we
+   * have to apply them after the rendering cycle is done (`updated()`). But
+   * when re-rendering a row that already has layers applied, then we have to
+   * first wipe away <gr-diff-text>. This is achieved by
+   * `this.layersApplied = true`.
+   */
+  private async updateLayers(side: Side) {
+    const line = this.line(side);
+    const contentEl = this.contentRef(side).value;
+    const lineNumberEl = this.lineNumberRef(side).value;
+    if (!line || !contentEl || !lineNumberEl) return;
+
+    // We have to wait for the <gr-diff-text> child component to finish
+    // rendering before we can apply layers, which will re-write the HTML.
+    await contentEl?.updateComplete;
+    for (const layer of this.layers) {
+      if (typeof layer.annotate === 'function') {
+        layer.annotate(contentEl, lineNumberEl, line, side);
+      }
+    }
+    // At this point we consider layers applied. So as soon as <gr-diff-row>
+    // enters a new rendering cycle <gr-diff-text> elements will be removed.
+    this.layersApplied = true;
+  }
+
+  override render() {
+    if (!this.left || !this.right) return;
+    const classes = this.unifiedDiff ? ['unified'] : ['side-by-side'];
+    const unifiedType = this.unifiedType();
+    if (this.unifiedDiff && unifiedType) classes.push(unifiedType);
+    const row = html`
+      <tr
+        ${ref(this.tableRowRef)}
+        class=${diffClasses('diff-row', ...classes)}
+        left-type=${ifDefined(this.getType(Side.LEFT))}
+        right-type=${ifDefined(this.getType(Side.RIGHT))}
+        tabindex="-1"
+        aria-labelledby=${this.ariaLabelIds()}
+      >
+        ${this.renderBlameCell()} ${this.renderLineNumberCell(Side.LEFT)}
+        ${this.renderSignCell(Side.LEFT)} ${this.renderContentCell(Side.LEFT)}
+        ${this.renderLineNumberCell(Side.RIGHT)}
+        ${this.renderSignCell(Side.RIGHT)} ${this.renderContentCell(Side.RIGHT)}
+      </tr>
+      ${this.renderPostLineSlot(Side.LEFT)}
+      ${this.renderPostLineSlot(Side.RIGHT)}
+    `;
+    if (this.addTableWrapperForTesting) {
+      return html`<table>
+        ${row}
+      </table>`;
+    }
+    return row;
+  }
+
+  private ariaLabelIds() {
+    const ids: string[] = [];
+    ids.push(this.lineNumberId(Side.LEFT));
+    if (!this.unifiedDiff) ids.push(this.contentId(Side.LEFT));
+    ids.push(this.lineNumberId(Side.RIGHT));
+    if (!this.unifiedDiff) ids.push(this.contentId(Side.RIGHT));
+    if (this.unifiedDiff) ids.push(this.contentId(this.unifiedSide()));
+    return ids.filter(id => !!id).join(' ');
+  }
+
+  private lineNumberId(side: Side): string {
+    const lineNumber = this.lineNumber(side);
+    if (!lineNumber) return '';
+    return `${side}-button-${lineNumber}`;
+  }
+
+  private unifiedSide() {
+    const isLeft = this.line(Side.RIGHT)?.type === GrDiffLineType.BLANK;
+    return isLeft ? Side.LEFT : Side.RIGHT;
+  }
+
+  private contentId(side: Side): string {
+    const lineNumber = this.lineNumber(side);
+    if (!lineNumber) return '';
+    return `${side}-content-${lineNumber}`;
+  }
+
+  getTableRow(): HTMLTableRowElement | undefined {
+    return this.tableRowRef.value;
+  }
+
+  getLineNumberCell(side: Side): HTMLTableCellElement | undefined {
+    return this.lineNumberRef(side).value;
+  }
+
+  getContentCell(side: Side) {
+    return this.contentCellRef(side)?.value;
+  }
+
+  getBlameCell() {
+    return this.blameCellRef.value;
+  }
+
+  private renderBlameCell() {
+    // td.blame has `white-space: pre`, so prettier must not add spaces.
+    // prettier-ignore
+    return html`
+      <td
+        ${ref(this.blameCellRef)}
+        class=${diffClasses('blame')}
+        data-line-number=${this.left?.beforeNumber ?? 0}
+      >${this.renderBlameElement()}</td>
+    `;
+  }
+
+  private renderBlameElement() {
+    const lineNum = this.left?.beforeNumber;
+    const commit = this.blameInfo;
+    if (!lineNum || !commit) return;
+
+    const isStartOfRange = commit.ranges.some(r => r.start === lineNum);
+    const extras: string[] = [];
+    if (isStartOfRange) extras.push('startOfRange');
+    const date = new Date(commit.time * 1000).toLocaleDateString();
+    const shortName = commit.author.split(' ')[0];
+    const url = `${getBaseUrl()}/q/${commit.id}`;
+
+    // td.blame has `white-space: pre`, so prettier must not add spaces.
+    // prettier-ignore
+    return html`<span class=${diffClasses(...extras)}
+        ><a href=${url} class=${diffClasses('blameDate')}>${date}</a
+        ><span class=${diffClasses('blameAuthor')}> ${shortName}</span
+        ><gr-hovercard class=${diffClasses()}>
+          <span class=${diffClasses('blameHoverCard')}>
+            Commit ${commit.id}<br />
+            Author: ${commit.author}<br />
+            Date: ${date}<br />
+            <br />
+            ${commit.commit_msg}
+          </span>
+        </gr-hovercard
+      ></span>`;
+  }
+
+  private renderLineNumberCell(side: Side): TemplateResult {
+    const line = this.line(side);
+    const lineNumber = this.lineNumber(side);
+    const isBlank = line?.type === GrDiffLineType.BLANK;
+    if (!line || !lineNumber || isBlank || this.layersApplied) {
+      const blankClass = isBlank && !this.unifiedDiff ? 'blankLineNum' : '';
+      return html`<td
+        ${ref(this.lineNumberRef(side))}
+        class=${diffClasses(side, blankClass)}
+      ></td>`;
+    }
+
+    return html`<td
+      ${ref(this.lineNumberRef(side))}
+      class=${diffClasses(side, 'lineNum')}
+      data-value=${lineNumber}
+    >
+      ${this.renderLineNumberButton(line, lineNumber, side)}
+    </td>`;
+  }
+
+  private renderLineNumberButton(
+    line: GrDiffLine,
+    lineNumber: LineNumber,
+    side: Side
+  ) {
+    if (this.hideFileCommentButton && lineNumber === 'FILE') return;
+    if (lineNumber === 'LOST') return;
+    // .lineNumButton has `white-space: pre`, so prettier must not add spaces.
+    // prettier-ignore
+    return html`
+      <button
+        id=${this.lineNumberId(side)}
+        class=${diffClasses('lineNumButton', side)}
+        tabindex="-1"
+        data-value=${lineNumber}
+        aria-label=${ifDefined(
+          this.computeLineNumberAriaLabel(line, lineNumber)
+        )}
+        @mouseenter=${() =>
+          fire(this, 'line-mouse-enter', {lineNum: lineNumber, side})}
+        @mouseleave=${() =>
+          fire(this, 'line-mouse-leave', {lineNum: lineNumber, side})}
+      >${lineNumber === 'FILE' ? 'File' : lineNumber.toString()}</button>
+    `;
+  }
+
+  private computeLineNumberAriaLabel(line: GrDiffLine, lineNumber: LineNumber) {
+    if (lineNumber === 'FILE') return 'Add file comment';
+
+    // Add aria-labels for valid line numbers.
+    // For unified diff, this method will be called with number set to 0 for
+    // the empty line number column for added/removed lines. This should not
+    // be announced to the screenreader.
+    if (lineNumber === 'LOST' || lineNumber <= 0) return undefined;
+
+    switch (line.type) {
+      case GrDiffLineType.REMOVE:
+        return `${lineNumber} removed`;
+      case GrDiffLineType.ADD:
+        return `${lineNumber} added`;
+      case GrDiffLineType.BOTH:
+      case GrDiffLineType.BLANK:
+        return `${lineNumber} unmodified`;
+    }
+  }
+
+  private renderContentCell(side: Side) {
+    let line = this.line(side);
+    if (this.unifiedDiff) {
+      if (side === Side.LEFT) return nothing;
+      if (line?.type === GrDiffLineType.BLANK) {
+        side = Side.LEFT;
+        line = this.line(Side.LEFT);
+      }
+    }
+    const lineNumber = this.lineNumber(side);
+    assertIsDefined(line, 'line');
+    const extras: string[] = [line.type, side];
+    if (line.type !== GrDiffLineType.BLANK) extras.push('content');
+    if (!line.hasIntralineInfo) extras.push('no-intraline-info');
+    if (line.beforeNumber === 'FILE') extras.push('file');
+    if (line.beforeNumber === 'LOST') extras.push('lost');
+
+    // .content has `white-space: pre`, so prettier must not add spaces.
+    // prettier-ignore
+    return html`
+      <td
+        ${ref(this.contentCellRef(side))}
+        class=${diffClasses(...extras)}
+        @mouseenter=${() => {
+          if (lineNumber)
+            fire(this, 'line-mouse-enter', {lineNum: lineNumber, side});
+        }}
+        @mouseleave=${() => {
+          if (lineNumber)
+            fire(this, 'line-mouse-leave', {lineNum: lineNumber, side});
+        }}
+      >${this.renderText(side)}${this.renderThreadGroup(side)}</td>
+    `;
+  }
+
+  private renderSignCell(side: Side) {
+    if (this.unifiedDiff) return nothing;
+    const line = this.line(side);
+    assertIsDefined(line, 'line');
+    const isBlank = line.type === GrDiffLineType.BLANK;
+    const isAdd = line.type === GrDiffLineType.ADD && side === Side.RIGHT;
+    const isRemove = line.type === GrDiffLineType.REMOVE && side === Side.LEFT;
+    const extras: string[] = ['sign', side];
+    if (isBlank) extras.push('blank');
+    if (isAdd) extras.push('add');
+    if (isRemove) extras.push('remove');
+    if (!line.hasIntralineInfo) extras.push('no-intraline-info');
+
+    const sign = isAdd ? '+' : isRemove ? '-' : '';
+    return html`<td class=${diffClasses(...extras)}>${sign}</td>`;
+  }
+
+  private renderThreadGroup(side: Side) {
+    const lineNumber = this.lineNumber(side);
+    if (!lineNumber) return nothing;
+    return html`<div class="thread-group" data-side=${side}>
+      <slot name="${side}-${lineNumber}"></slot>
+      ${this.renderSecondSlot()}
+    </div>`;
+  }
+
+  private renderSecondSlot() {
+    if (!this.unifiedDiff) return nothing;
+    if (this.line(Side.LEFT)?.type !== GrDiffLineType.BOTH) return nothing;
+    return html`<slot
+      name="${Side.LEFT}-${this.lineNumber(Side.LEFT)}"
+    ></slot>`;
+  }
+
+  private contentRef(side: Side) {
+    return side === Side.LEFT ? this.contentLeftRef : this.contentRightRef;
+  }
+
+  private contentCellRef(side: Side) {
+    return side === Side.LEFT
+      ? this.contentCellLeftRef
+      : this.contentCellRightRef;
+  }
+
+  private lineNumberRef(side: Side) {
+    return side === Side.LEFT
+      ? this.lineNumberLeftRef
+      : this.lineNumberRightRef;
+  }
+
+  private lineNumber(side: Side) {
+    return this.line(side)?.lineNumber(side);
+  }
+
+  private line(side: Side) {
+    return side === Side.LEFT ? this.left : this.right;
+  }
+
+  private getType(side?: Side): string | undefined {
+    if (this.unifiedDiff) return undefined;
+    if (side === Side.LEFT) return this.left?.type;
+    if (side === Side.RIGHT) return this.right?.type;
+    return undefined;
+  }
+
+  private unifiedType() {
+    return this.left?.type === GrDiffLineType.BLANK
+      ? this.right?.type
+      : this.left?.type;
+  }
+
+  /**
+   * Returns a 'div' element containing the supplied |text| as its innerText,
+   * with '\t' characters expanded to a width determined by |tabSize|, and the
+   * text wrapped at column |lineLimit|, which may be Infinity if no wrapping is
+   * desired.
+   */
+  private renderText(side: Side) {
+    const line = this.line(side);
+    const lineNumber = this.lineNumber(side);
+    if (lineNumber === 'FILE' || lineNumber === 'LOST') return;
+
+    // Note that `this.layersApplied` will wipe away the <gr-diff-text>, and
+    // another rendering cycle will be initiated in `updated()`.
+    // prettier-ignore
+    const textElement = line?.text && !this.layersApplied
+      ? html`<gr-diff-text
+          ${ref(this.contentRef(side))}
+          .text=${line?.text}
+          .tabSize=${this.tabSize}
+          .lineLimit=${this.lineLength}
+          .isResponsive=${isResponsive(this.responsiveMode)}
+        ></gr-diff-text>` : '';
+    // .content has `white-space: pre`, so prettier must not add spaces.
+    // prettier-ignore
+    return html`<div
+        class=${diffClasses('contentText')}
+        data-side=${ifDefined(side)}
+        id=${this.contentId(side)}
+      >${textElement}</div>`;
+  }
+
+  private renderPostLineSlot(side: Side) {
+    const lineNumber = this.lineNumber(side);
+    return lineNumber && Number.isInteger(lineNumber)
+      ? html`<slot name="post-${side}-line-${lineNumber}"></slot>`
+      : nothing;
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-diff-row': GrDiffRow;
+  }
+}
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-row_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-row_test.ts
new file mode 100644
index 0000000..42d30aa
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-row_test.ts
@@ -0,0 +1,271 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-diff-row';
+import {GrDiffRow} from './gr-diff-row';
+import {fixture, html, assert} from '@open-wc/testing';
+import {GrDiffLine} from '../gr-diff/gr-diff-line';
+import {GrDiffLineType} from '../../../api/diff';
+
+suite('gr-diff-row test', () => {
+  let element: GrDiffRow;
+
+  setup(async () => {
+    element = await fixture<GrDiffRow>(html`<gr-diff-row></gr-diff-row>`);
+    element.addTableWrapperForTesting = true;
+    await element.updateComplete;
+  });
+
+  test('both', async () => {
+    const line = new GrDiffLine(GrDiffLineType.BOTH, 1, 1);
+    line.text = 'lorem ipsum';
+    element.left = line;
+    element.right = line;
+    await element.updateComplete;
+    assert.lightDom.equal(
+      element,
+      /* HTML */ `
+        <table>
+          <tbody>
+            <tr
+              aria-labelledby="left-button-1 left-content-1 right-button-1 right-content-1"
+              class="diff-row gr-diff side-by-side"
+              left-type="both"
+              right-type="both"
+              tabindex="-1"
+            >
+              <td class="blame gr-diff" data-line-number="1"></td>
+              <td class="gr-diff left lineNum" data-value="1">
+                <button
+                  aria-label="1 unmodified"
+                  class="gr-diff left lineNumButton"
+                  data-value="1"
+                  id="left-button-1"
+                  tabindex="-1"
+                >
+                  1
+                </button>
+              </td>
+              <td class="gr-diff left no-intraline-info sign"></td>
+              <td class="both content gr-diff left no-intraline-info">
+                <div
+                  class="contentText gr-diff"
+                  data-side="left"
+                  id="left-content-1"
+                >
+                  <gr-diff-text> lorem ipsum </gr-diff-text>
+                </div>
+                <div class="thread-group" data-side="left">
+                  <slot name="left-1"> </slot>
+                </div>
+              </td>
+              <td class="gr-diff lineNum right" data-value="1">
+                <button
+                  aria-label="1 unmodified"
+                  class="gr-diff lineNumButton right"
+                  data-value="1"
+                  id="right-button-1"
+                  tabindex="-1"
+                >
+                  1
+                </button>
+              </td>
+              <td class="gr-diff no-intraline-info right sign"></td>
+              <td class="both content gr-diff no-intraline-info right">
+                <div
+                  class="contentText gr-diff"
+                  data-side="right"
+                  id="right-content-1"
+                >
+                  <gr-diff-text> lorem ipsum </gr-diff-text>
+                </div>
+                <div class="thread-group" data-side="right">
+                  <slot name="right-1"> </slot>
+                </div>
+              </td>
+            </tr>
+            <slot name="post-left-line-1"></slot>
+            <slot name="post-right-line-1"></slot>
+          </tbody>
+        </table>
+      `
+    );
+  });
+
+  test('both unified', async () => {
+    const line = new GrDiffLine(GrDiffLineType.BOTH, 1, 1);
+    line.text = 'lorem ipsum';
+    element.left = line;
+    element.right = line;
+    element.unifiedDiff = true;
+    await element.updateComplete;
+    assert.lightDom.equal(
+      element,
+      /* HTML */ `
+        <table>
+          <tbody>
+            <tr
+              aria-labelledby="left-button-1 right-button-1 right-content-1"
+              class="both diff-row gr-diff unified"
+              tabindex="-1"
+            >
+              <td class="blame gr-diff" data-line-number="1"></td>
+              <td class="gr-diff left lineNum" data-value="1">
+                <button
+                  aria-label="1 unmodified"
+                  class="gr-diff left lineNumButton"
+                  data-value="1"
+                  id="left-button-1"
+                  tabindex="-1"
+                >
+                  1
+                </button>
+              </td>
+              <td class="gr-diff lineNum right" data-value="1">
+                <button
+                  aria-label="1 unmodified"
+                  class="gr-diff lineNumButton right"
+                  data-value="1"
+                  id="right-button-1"
+                  tabindex="-1"
+                >
+                  1
+                </button>
+              </td>
+              <td class="both content gr-diff no-intraline-info right">
+                <div
+                  class="contentText gr-diff"
+                  data-side="right"
+                  id="right-content-1"
+                >
+                  <gr-diff-text> lorem ipsum </gr-diff-text>
+                </div>
+                <div class="thread-group" data-side="right">
+                  <slot name="right-1"> </slot>
+                  <slot name="left-1"> </slot>
+                </div>
+              </td>
+            </tr>
+            <slot name="post-left-line-1"></slot>
+            <slot name="post-right-line-1"></slot>
+          </tbody>
+        </table>
+      `
+    );
+  });
+
+  test('add', async () => {
+    const line = new GrDiffLine(GrDiffLineType.ADD, 0, 1);
+    line.text = 'lorem ipsum';
+    element.left = new GrDiffLine(GrDiffLineType.BLANK);
+    element.right = line;
+    await element.updateComplete;
+    assert.lightDom.equal(
+      element,
+      /* HTML */ `
+        <table>
+          <tbody>
+            <tr
+              aria-labelledby="right-button-1 right-content-1"
+              class="diff-row gr-diff side-by-side"
+              left-type="blank"
+              right-type="add"
+              tabindex="-1"
+            >
+              <td class="blame gr-diff" data-line-number="0"></td>
+              <td class="blankLineNum gr-diff left"></td>
+              <td class="blank gr-diff left no-intraline-info sign"></td>
+              <td class="blank gr-diff left no-intraline-info">
+                <div class="contentText gr-diff" data-side="left"></div>
+              </td>
+              <td class="gr-diff lineNum right" data-value="1">
+                <button
+                  aria-label="1 added"
+                  class="gr-diff lineNumButton right"
+                  data-value="1"
+                  id="right-button-1"
+                  tabindex="-1"
+                >
+                  1
+                </button>
+              </td>
+              <td class="add gr-diff no-intraline-info right sign">+</td>
+              <td class="add content gr-diff no-intraline-info right">
+                <div
+                  class="contentText gr-diff"
+                  data-side="right"
+                  id="right-content-1"
+                >
+                  <gr-diff-text> lorem ipsum </gr-diff-text>
+                </div>
+                <div class="thread-group" data-side="right">
+                  <slot name="right-1"> </slot>
+                </div>
+              </td>
+              <slot name="post-right-line-1"></slot>
+            </tr>
+          </tbody>
+        </table>
+      `
+    );
+  });
+
+  test('remove', async () => {
+    const line = new GrDiffLine(GrDiffLineType.REMOVE, 1, 0);
+    line.text = 'lorem ipsum';
+    element.left = line;
+    element.right = new GrDiffLine(GrDiffLineType.BLANK);
+    await element.updateComplete;
+    assert.lightDom.equal(
+      element,
+      /* HTML */ `
+        <table>
+          <tbody>
+            <tr
+              aria-labelledby="left-button-1 left-content-1"
+              class="diff-row gr-diff side-by-side"
+              left-type="remove"
+              right-type="blank"
+              tabindex="-1"
+            >
+              <td class="blame gr-diff" data-line-number="1"></td>
+              <td class="gr-diff left lineNum" data-value="1">
+                <button
+                  aria-label="1 removed"
+                  class="gr-diff left lineNumButton"
+                  data-value="1"
+                  id="left-button-1"
+                  tabindex="-1"
+                >
+                  1
+                </button>
+              </td>
+              <td class="gr-diff left no-intraline-info remove sign">-</td>
+              <td class="content gr-diff left no-intraline-info remove">
+                <div
+                  class="contentText gr-diff"
+                  data-side="left"
+                  id="left-content-1"
+                >
+                  <gr-diff-text> lorem ipsum </gr-diff-text>
+                </div>
+                <div class="thread-group" data-side="left">
+                  <slot name="left-1"> </slot>
+                </div>
+              </td>
+              <td class="blankLineNum gr-diff right"></td>
+              <td class="blank gr-diff no-intraline-info right sign"></td>
+              <td class="blank gr-diff no-intraline-info right">
+                <div class="contentText gr-diff" data-side="right"></div>
+              </td>
+            </tr>
+            <slot name="post-left-line-1"></slot>
+          </tbody>
+        </table>
+      `
+    );
+  });
+});
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-section.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-section.ts
new file mode 100644
index 0000000..e5d3d2e
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-section.ts
@@ -0,0 +1,250 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {html, LitElement} from 'lit';
+import {customElement, property, state} from 'lit/decorators.js';
+import {
+  DiffInfo,
+  DiffLayer,
+  DiffViewMode,
+  RenderPreferences,
+  Side,
+  LineNumber,
+  DiffPreferencesInfo,
+} from '../../../api/diff';
+import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group';
+import {
+  countLines,
+  diffClasses,
+  getResponsiveMode,
+} from '../gr-diff/gr-diff-utils';
+import {GrDiffRow} from './gr-diff-row';
+import '../gr-context-controls/gr-context-controls-section';
+import '../gr-context-controls/gr-context-controls';
+import '../gr-range-header/gr-range-header';
+import './gr-diff-row';
+import {when} from 'lit/directives/when.js';
+import {fire} from '../../../utils/event-util';
+
+@customElement('gr-diff-section')
+export class GrDiffSection extends LitElement {
+  @property({type: Object})
+  group?: GrDiffGroup;
+
+  @property({type: Object})
+  diff?: DiffInfo;
+
+  @property({type: Object})
+  renderPrefs?: RenderPreferences;
+
+  @property({type: Object})
+  diffPrefs?: DiffPreferencesInfo;
+
+  @property({type: Object})
+  layers: DiffLayer[] = [];
+
+  /**
+   * Semantic DOM diff testing does not work with just table fragments, so when
+   * running such tests the render() method has to wrap the DOM in a proper
+   * <table> element.
+   */
+  @state()
+  addTableWrapperForTesting = false;
+
+  /**
+   * The browser API for handling selection does not (yet) work for selection
+   * across multiple shadow DOM elements. So we are rendering gr-diff components
+   * into the light DOM instead of the shadow DOM by overriding this method,
+   * which was the recommended workaround by the lit team.
+   * See also https://github.com/WICG/webcomponents/issues/79.
+   */
+  override createRenderRoot() {
+    return this;
+  }
+
+  override render() {
+    if (!this.group) return;
+    const extras: string[] = [];
+    extras.push('section');
+    extras.push(this.group.type);
+    if (this.group.isTotal()) extras.push('total');
+    if (this.group.dueToRebase) extras.push('dueToRebase');
+    if (this.group.moveDetails) extras.push('dueToMove');
+    if (this.group.moveDetails?.changed) extras.push('changed');
+    if (this.group.ignoredWhitespaceOnly) extras.push('ignoredWhitespaceOnly');
+
+    const pairs = this.getLinePairs();
+    const responsiveMode = getResponsiveMode(this.diffPrefs, this.renderPrefs);
+    const hideFileCommentButton =
+      this.diffPrefs?.show_file_comment_button === false ||
+      this.renderPrefs?.show_file_comment_button === false;
+    const body = html`
+      <tbody class=${diffClasses(...extras)}>
+        ${this.renderContextControls()} ${this.renderMoveControls()}
+        ${pairs.map(pair => {
+          const leftCl = `left-${pair.left.lineNumber(Side.LEFT)}`;
+          const rightCl = `right-${pair.right.lineNumber(Side.RIGHT)}`;
+          return html`
+            <gr-diff-row
+              class="${leftCl} ${rightCl}"
+              .left=${pair.left}
+              .right=${pair.right}
+              .layers=${this.layers}
+              .lineLength=${this.diffPrefs?.line_length ?? 80}
+              .tabSize=${this.diffPrefs?.tab_size ?? 2}
+              .unifiedDiff=${this.isUnifiedDiff()}
+              .responsiveMode=${responsiveMode}
+              .hideFileCommentButton=${hideFileCommentButton}
+            >
+            </gr-diff-row>
+          `;
+        })}
+      </tbody>
+    `;
+    if (this.addTableWrapperForTesting) {
+      return html`<table>
+        ${body}
+      </table>`;
+    }
+    return body;
+  }
+
+  private isUnifiedDiff() {
+    return this.renderPrefs?.view_mode === DiffViewMode.UNIFIED;
+  }
+
+  getLinePairs() {
+    if (!this.group) return [];
+    const isControl = this.group.type === GrDiffGroupType.CONTEXT_CONTROL;
+    if (isControl) return [];
+    return this.isUnifiedDiff()
+      ? this.group.getUnifiedPairs()
+      : this.group.getSideBySidePairs();
+  }
+
+  getDiffRows(): GrDiffRow[] {
+    return [...this.querySelectorAll<GrDiffRow>('gr-diff-row')];
+  }
+
+  private renderContextControls() {
+    if (this.group?.type !== GrDiffGroupType.CONTEXT_CONTROL) return;
+
+    const leftStart = this.group.lineRange.left.start_line;
+    const leftEnd = this.group.lineRange.left.end_line;
+    const firstGroupIsSkipped = !!this.group.contextGroups[0].skip;
+    const lastGroupIsSkipped =
+      !!this.group.contextGroups[this.group.contextGroups.length - 1].skip;
+    const lineCountLeft = countLines(this.diff, Side.LEFT);
+    const containsWholeFile = lineCountLeft === leftEnd - leftStart + 1;
+    const showAbove =
+      (leftStart > 1 && !firstGroupIsSkipped) || containsWholeFile;
+    const showBelow = leftEnd < lineCountLeft && !lastGroupIsSkipped;
+
+    return html`
+      <gr-context-controls-section
+        .showAbove=${showAbove}
+        .showBelow=${showBelow}
+        .group=${this.group}
+        .diff=${this.diff}
+        .renderPrefs=${this.renderPrefs}
+      >
+      </gr-context-controls-section>
+    `;
+  }
+
+  findRow(side: Side, lineNumber: LineNumber): GrDiffRow | undefined {
+    return (
+      this.querySelector<GrDiffRow>(`gr-diff-row.${side}-${lineNumber}`) ??
+      undefined
+    );
+  }
+
+  private renderMoveControls() {
+    if (!this.group?.moveDetails) return;
+    const movedIn = this.group.adds.length > 0;
+    const plainCell = html`<td class=${diffClasses()}></td>`;
+    const signCell = html`<td class=${diffClasses('sign')}></td>`;
+    const lineNumberCell = html`
+      <td class=${diffClasses('moveControlsLineNumCol')}></td>
+    `;
+    const moveCell = html`
+      <td class=${diffClasses('moveHeader')}>
+        <gr-range-header class=${diffClasses()} icon="move_item">
+          ${this.renderMoveDescription(movedIn)}
+        </gr-range-header>
+      </td>
+    `;
+    return html`
+      <tr
+        class=${diffClasses('moveControls', movedIn ? 'movedIn' : 'movedOut')}
+      >
+        ${when(
+          this.isUnifiedDiff(),
+          () => html`${lineNumberCell} ${lineNumberCell} ${moveCell}`,
+          () => html`${lineNumberCell} ${signCell}
+          ${movedIn ? plainCell : moveCell} ${lineNumberCell} ${signCell}
+          ${movedIn ? moveCell : plainCell}`
+        )}
+      </tr>
+    `;
+  }
+
+  private renderMoveDescription(movedIn: boolean) {
+    if (this.group?.moveDetails?.range) {
+      const {changed, range} = this.group.moveDetails;
+      const otherSide = movedIn ? Side.LEFT : Side.RIGHT;
+      const andChangedLabel = changed ? 'and changed ' : '';
+      const direction = movedIn ? 'from' : 'to';
+      const textLabel = `Moved ${andChangedLabel}${direction} lines `;
+      return html`
+        <div class=${diffClasses()}>
+          <span class=${diffClasses()}>${textLabel}</span>
+          ${this.renderMovedLineAnchor(range.start, otherSide)}
+          <span class=${diffClasses()}> - </span>
+          ${this.renderMovedLineAnchor(range.end, otherSide)}
+        </div>
+      `;
+    }
+
+    return html`
+      <div class=${diffClasses()}>
+        <span class=${diffClasses()}
+          >${movedIn ? 'Moved in' : 'Moved out'}</span
+        >
+      </div>
+    `;
+  }
+
+  private renderMovedLineAnchor(line: number, side: Side) {
+    const listener = (e: MouseEvent) => {
+      e.preventDefault();
+      this.handleMovedLineAnchorClick(e.target, side, line);
+    };
+    // `href` is not actually used but important for Screen Readers
+    return html`
+      <a class=${diffClasses()} href=${`#${line}`} @click=${listener}
+        >${line}</a
+      >
+    `;
+  }
+
+  private handleMovedLineAnchorClick(
+    anchor: EventTarget | null,
+    side: Side,
+    line: number
+  ) {
+    if (!anchor) return;
+    fire(anchor, 'moved-link-clicked', {
+      lineNum: line,
+      side,
+    });
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-diff-section': GrDiffSection;
+  }
+}
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-section_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-section_test.ts
new file mode 100644
index 0000000..381f9b2
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-section_test.ts
@@ -0,0 +1,315 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-diff-section';
+import {GrDiffSection} from './gr-diff-section';
+import {fixture, html, assert} from '@open-wc/testing';
+import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group';
+import {GrDiffLine} from '../gr-diff/gr-diff-line';
+import {DiffViewMode, GrDiffLineType} from '../../../api/diff';
+import {waitQueryAndAssert} from '../../../test/test-utils';
+
+suite('gr-diff-section test', () => {
+  let element: GrDiffSection;
+
+  setup(async () => {
+    element = await fixture<GrDiffSection>(
+      html`<gr-diff-section></gr-diff-section>`
+    );
+    element.addTableWrapperForTesting = true;
+    await element.updateComplete;
+  });
+
+  suite('move controls', async () => {
+    setup(async () => {
+      const lines = [new GrDiffLine(GrDiffLineType.BOTH, 1, 1)];
+      lines[0].text = 'asdf';
+      const group = new GrDiffGroup({
+        type: GrDiffGroupType.BOTH,
+        lines,
+        moveDetails: {changed: false, range: {start: 1, end: 2}},
+      });
+      element.group = group;
+      await element.updateComplete;
+    });
+
+    test('side-by-side', async () => {
+      const row = await waitQueryAndAssert(element, 'tr.moveControls');
+      // Semantic dom diff has a problem with just comparing table rows or
+      // cells directly. So as a workaround put the row into an empty test
+      // table.
+      const testTable = document.createElement('table');
+      testTable.appendChild(row);
+      assert.dom.equal(
+        testTable,
+        /* HTML */ `
+          <table>
+            <tbody>
+              <tr class="gr-diff moveControls movedOut">
+                <td class="gr-diff moveControlsLineNumCol"></td>
+                <td class="gr-diff sign"></td>
+                <td class="gr-diff moveHeader">
+                  <gr-range-header class="gr-diff" icon="move_item">
+                    <div class="gr-diff">
+                      <span class="gr-diff"> Moved to lines </span>
+                      <a class="gr-diff" href="#1"> 1 </a>
+                      <span class="gr-diff"> - </span>
+                      <a class="gr-diff" href="#2"> 2 </a>
+                    </div>
+                  </gr-range-header>
+                </td>
+                <td class="gr-diff moveControlsLineNumCol"></td>
+                <td class="gr-diff sign"></td>
+                <td class="gr-diff"></td>
+              </tr>
+            </tbody>
+          </table>
+        `,
+        {}
+      );
+    });
+
+    test('unified', async () => {
+      element.renderPrefs = {
+        ...element.renderPrefs,
+        view_mode: DiffViewMode.UNIFIED,
+      };
+      const row = await waitQueryAndAssert(element, 'tr.moveControls');
+      // Semantic dom diff has a problem with just comparing table rows or
+      // cells directly. So as a workaround put the row into an empty test
+      // table.
+      const testTable = document.createElement('table');
+      testTable.appendChild(row);
+      assert.dom.equal(
+        testTable,
+        /* HTML */ `
+          <table>
+            <tbody>
+              <tr class="gr-diff moveControls movedOut">
+                <td class="gr-diff moveControlsLineNumCol"></td>
+                <td class="gr-diff moveControlsLineNumCol"></td>
+                <td class="gr-diff moveHeader">
+                  <gr-range-header class="gr-diff" icon="move_item">
+                    <div class="gr-diff">
+                      <span class="gr-diff"> Moved to lines </span>
+                      <a class="gr-diff" href="#1"> 1 </a>
+                      <span class="gr-diff"> - </span>
+                      <a class="gr-diff" href="#2"> 2 </a>
+                    </div>
+                  </gr-range-header>
+                </td>
+              </tr>
+            </tbody>
+          </table>
+        `,
+        {}
+      );
+    });
+  });
+
+  test('3 normal unchanged rows', async () => {
+    const lines = [
+      new GrDiffLine(GrDiffLineType.BOTH, 1, 1),
+      new GrDiffLine(GrDiffLineType.BOTH, 1, 1),
+      new GrDiffLine(GrDiffLineType.BOTH, 1, 1),
+    ];
+    lines[0].text = 'asdf';
+    lines[1].text = 'qwer';
+    lines[2].text = 'zxcv';
+    const group = new GrDiffGroup({type: GrDiffGroupType.BOTH, lines});
+    element.group = group;
+    await element.updateComplete;
+    assert.lightDom.equal(
+      element,
+      /* HTML */ `
+        <gr-diff-row class="left-1 right-1"> </gr-diff-row>
+        <slot name="post-left-line-1"></slot>
+        <slot name="post-right-line-1"></slot>
+        <gr-diff-row class="left-1 right-1"> </gr-diff-row>
+        <slot name="post-left-line-1"></slot>
+        <slot name="post-right-line-1"></slot>
+        <gr-diff-row class="left-1 right-1"> </gr-diff-row>
+        <slot name="post-left-line-1"></slot>
+        <slot name="post-right-line-1"></slot>
+        <table>
+          <tbody class="both gr-diff section">
+            <tr
+              aria-labelledby="left-button-1 left-content-1 right-button-1 right-content-1"
+              class="diff-row gr-diff side-by-side"
+              left-type="both"
+              right-type="both"
+              tabindex="-1"
+            >
+              <td class="blame gr-diff" data-line-number="1"></td>
+              <td class="gr-diff left lineNum" data-value="1">
+                <button
+                  aria-label="1 unmodified"
+                  class="gr-diff left lineNumButton"
+                  data-value="1"
+                  id="left-button-1"
+                  tabindex="-1"
+                >
+                  1
+                </button>
+              </td>
+              <td class="gr-diff left no-intraline-info sign"></td>
+              <td class="both content gr-diff left no-intraline-info">
+                <div
+                  class="contentText gr-diff"
+                  data-side="left"
+                  id="left-content-1"
+                >
+                  <gr-diff-text> </gr-diff-text>
+                </div>
+                <div class="thread-group" data-side="left">
+                  <slot name="left-1"> </slot>
+                </div>
+              </td>
+              <td class="gr-diff lineNum right" data-value="1">
+                <button
+                  aria-label="1 unmodified"
+                  class="gr-diff lineNumButton right"
+                  data-value="1"
+                  id="right-button-1"
+                  tabindex="-1"
+                >
+                  1
+                </button>
+              </td>
+              <td class="gr-diff no-intraline-info right sign"></td>
+              <td class="both content gr-diff no-intraline-info right">
+                <div
+                  class="contentText gr-diff"
+                  data-side="right"
+                  id="right-content-1"
+                >
+                  <gr-diff-text> </gr-diff-text>
+                </div>
+                <div class="thread-group" data-side="right">
+                  <slot name="right-1"> </slot>
+                </div>
+              </td>
+            </tr>
+            <tr
+              aria-labelledby="left-button-1 left-content-1 right-button-1 right-content-1"
+              class="diff-row gr-diff side-by-side"
+              left-type="both"
+              right-type="both"
+              tabindex="-1"
+            >
+              <td class="blame gr-diff" data-line-number="1"></td>
+              <td class="gr-diff left lineNum" data-value="1">
+                <button
+                  aria-label="1 unmodified"
+                  class="gr-diff left lineNumButton"
+                  data-value="1"
+                  id="left-button-1"
+                  tabindex="-1"
+                >
+                  1
+                </button>
+              </td>
+              <td class="gr-diff left no-intraline-info sign"></td>
+              <td class="both content gr-diff left no-intraline-info">
+                <div
+                  class="contentText gr-diff"
+                  data-side="left"
+                  id="left-content-1"
+                >
+                  <gr-diff-text> </gr-diff-text>
+                </div>
+                <div class="thread-group" data-side="left">
+                  <slot name="left-1"> </slot>
+                </div>
+              </td>
+              <td class="gr-diff lineNum right" data-value="1">
+                <button
+                  aria-label="1 unmodified"
+                  class="gr-diff lineNumButton right"
+                  data-value="1"
+                  id="right-button-1"
+                  tabindex="-1"
+                >
+                  1
+                </button>
+              </td>
+              <td class="gr-diff no-intraline-info right sign"></td>
+              <td class="both content gr-diff no-intraline-info right">
+                <div
+                  class="contentText gr-diff"
+                  data-side="right"
+                  id="right-content-1"
+                >
+                  <gr-diff-text> </gr-diff-text>
+                </div>
+                <div class="thread-group" data-side="right">
+                  <slot name="right-1"> </slot>
+                </div>
+              </td>
+            </tr>
+            <tr
+              aria-labelledby="left-button-1 left-content-1 right-button-1 right-content-1"
+              class="diff-row gr-diff side-by-side"
+              left-type="both"
+              right-type="both"
+              tabindex="-1"
+            >
+              <td class="blame gr-diff" data-line-number="1"></td>
+              <td class="gr-diff left lineNum" data-value="1">
+                <button
+                  aria-label="1 unmodified"
+                  class="gr-diff left lineNumButton"
+                  data-value="1"
+                  id="left-button-1"
+                  tabindex="-1"
+                >
+                  1
+                </button>
+              </td>
+              <td class="gr-diff left no-intraline-info sign"></td>
+              <td class="both content gr-diff left no-intraline-info">
+                <div
+                  class="contentText gr-diff"
+                  data-side="left"
+                  id="left-content-1"
+                >
+                  <gr-diff-text> </gr-diff-text>
+                </div>
+                <div class="thread-group" data-side="left">
+                  <slot name="left-1"> </slot>
+                </div>
+              </td>
+              <td class="gr-diff lineNum right" data-value="1">
+                <button
+                  aria-label="1 unmodified"
+                  class="gr-diff lineNumButton right"
+                  data-value="1"
+                  id="right-button-1"
+                  tabindex="-1"
+                >
+                  1
+                </button>
+              </td>
+              <td class="gr-diff no-intraline-info right sign"></td>
+              <td class="both content gr-diff no-intraline-info right">
+                <div
+                  class="contentText gr-diff"
+                  data-side="right"
+                  id="right-content-1"
+                >
+                  <gr-diff-text> </gr-diff-text>
+                </div>
+                <div class="thread-group" data-side="right">
+                  <slot name="right-1"> </slot>
+                </div>
+              </td>
+            </tr>
+          </tbody>
+        </table>
+      `
+    );
+  });
+});
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-text.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-text.ts
new file mode 100644
index 0000000..c1b13ac
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-text.ts
@@ -0,0 +1,152 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {LitElement, html, TemplateResult} from 'lit';
+import {customElement, property} from 'lit/decorators.js';
+import {styleMap} from 'lit/directives/style-map.js';
+import {diffClasses} from '../gr-diff/gr-diff-utils';
+
+const SURROGATE_PAIR = /[\uD800-\uDBFF][\uDC00-\uDFFF]/;
+
+const TAB = '\t';
+
+/**
+ * Renders one line of code on one side of the diff. It takes care of:
+ * - Tabs, see `tabSize` property.
+ * - Line Breaks, see `lineLimit` property.
+ * - Surrogate Character Pairs.
+ *
+ * Note that other modifications to the code in a gr-diff is done via diff
+ * layers, which manipulate the DOM directly. So `gr-diff-text` is thrown
+ * away and re-rendered every time something changes by its parent
+ * `gr-diff-row`. So don't bother to optimize this component for re-rendering
+ * performance. And be aware that building longer lived local state is not
+ * useful here.
+ */
+@customElement('gr-diff-text')
+export class GrDiffText extends LitElement {
+  /**
+   * The browser API for handling selection does not (yet) work for selection
+   * across multiple shadow DOM elements. So we are rendering gr-diff components
+   * into the light DOM instead of the shadow DOM by overriding this method,
+   * which was the recommended workaround by the lit team.
+   * See also https://github.com/WICG/webcomponents/issues/79.
+   */
+  override createRenderRoot() {
+    return this;
+  }
+
+  @property({type: String})
+  text = '';
+
+  @property({type: Boolean})
+  isResponsive = false;
+
+  @property({type: Number})
+  tabSize = 2;
+
+  @property({type: Number})
+  lineLimit = 80;
+
+  /** Temporary state while rendering. */
+  private textOffset = 0;
+
+  /** Temporary state while rendering. */
+  private columnPos = 0;
+
+  /** Temporary state while rendering. */
+  private pieces: (string | TemplateResult)[] = [];
+
+  /** Split up the string into tabs, surrogate pairs and regular segments. */
+  override render() {
+    this.textOffset = 0;
+    this.columnPos = 0;
+    this.pieces = [];
+    const splitByTab = this.text.split('\t');
+    for (let i = 0; i < splitByTab.length; i++) {
+      const splitBySurrogate = splitByTab[i].split(SURROGATE_PAIR);
+      for (let j = 0; j < splitBySurrogate.length; j++) {
+        this.renderSegment(splitBySurrogate[j]);
+        if (j < splitBySurrogate.length - 1) {
+          this.renderSurrogatePair();
+        }
+      }
+      if (i < splitByTab.length - 1) {
+        this.renderTab();
+      }
+    }
+    if (this.textOffset !== this.text.length) throw new Error('unfinished');
+    return this.pieces;
+  }
+
+  /** Render regular characters, but insert line breaks appropriately. */
+  private renderSegment(segment: string) {
+    let segmentOffset = 0;
+    while (segmentOffset < segment.length) {
+      const newOffset = Math.min(
+        segment.length,
+        segmentOffset + this.lineLimit - this.columnPos
+      );
+      this.renderString(segment.substring(segmentOffset, newOffset));
+      segmentOffset = newOffset;
+      if (segmentOffset < segment.length && this.columnPos === this.lineLimit) {
+        this.renderLineBreak();
+      }
+    }
+  }
+
+  /** Render regular characters. */
+  private renderString(s: string) {
+    if (s.length === 0) return;
+    this.pieces.push(s);
+    this.textOffset += s.length;
+    this.columnPos += s.length;
+    if (this.columnPos > this.lineLimit) throw new Error('over line limit');
+  }
+
+  /** Render a tab character. */
+  private renderTab() {
+    let tabSize = this.tabSize - (this.columnPos % this.tabSize);
+    if (this.columnPos + tabSize > this.lineLimit) {
+      this.renderLineBreak();
+      tabSize = this.tabSize;
+    }
+    const piece = html`<span
+      class=${diffClasses('tab')}
+      style=${styleMap({'tab-size': `${tabSize}`})}
+      >${TAB}</span
+    >`;
+    this.pieces.push(piece);
+    this.textOffset += 1;
+    this.columnPos += tabSize;
+  }
+
+  /** Render a surrogate pair: string length is 2, but is just 1 char. */
+  private renderSurrogatePair() {
+    if (this.columnPos === this.lineLimit) {
+      this.renderLineBreak();
+    }
+    this.pieces.push(this.text.substring(this.textOffset, this.textOffset + 2));
+    this.textOffset += 2;
+    this.columnPos += 1;
+  }
+
+  /** Render a line break, don't advance text offset, reset col position. */
+  private renderLineBreak() {
+    if (this.isResponsive) {
+      this.pieces.push(html`<wbr class=${diffClasses()}></wbr>`);
+    } else {
+      this.pieces.push(html`<span class=${diffClasses('br')}></span>`);
+    }
+    // this.textOffset += 0;
+    this.columnPos = 0;
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-diff-text': GrDiffText;
+  }
+}
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-text_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-text_test.ts
new file mode 100644
index 0000000..3858bed
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-text_test.ts
@@ -0,0 +1,166 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-diff-text';
+import {GrDiffText} from './gr-diff-text';
+import {fixture, html, assert} from '@open-wc/testing';
+
+const LINE_BREAK = '<span class="gr-diff br"></span>';
+
+const LINE_BREAK_WBR = '<wbr class="gr-diff"></wbr>';
+
+const TAB = '<span class="" style=""></span>';
+
+const TAB_IGNORE = ['class', 'style'];
+
+suite('gr-diff-text test', () => {
+  let element: GrDiffText;
+
+  setup(async () => {
+    element = await fixture<GrDiffText>(
+      html`<gr-diff-text tabsize="4" linelimit="10"></gr-diff-text>`
+    );
+  });
+
+  const check = async (
+    text: string,
+    html: string,
+    ignoreAttributes: string[] = []
+  ) => {
+    element.text = text;
+    await element.updateComplete;
+    assert.lightDom.equal(element, html, {ignoreAttributes});
+  };
+
+  suite('lit rendering', () => {
+    test('renderText newlines 1', async () => {
+      await check('abcdef', 'abcdef');
+      await check('a'.repeat(20), `aaaaaaaaaa${LINE_BREAK}aaaaaaaaaa`);
+    });
+
+    test('renderText newlines 1 responsive', async () => {
+      element.isResponsive = true;
+      await check('abcdef', 'abcdef');
+      await check('a'.repeat(20), `aaaaaaaaaa${LINE_BREAK_WBR}aaaaaaaaaa`);
+    });
+
+    test('renderText newlines 2', async () => {
+      await check(
+        '<span class="thumbsup">👍</span>',
+        '&lt;span clas' +
+          LINE_BREAK +
+          's="thumbsu' +
+          LINE_BREAK +
+          'p"&gt;👍&lt;/span' +
+          LINE_BREAK +
+          '&gt;'
+      );
+    });
+
+    test('renderText newlines 3', async () => {
+      await check(
+        '01234\t56789',
+        '01234' + TAB + '56' + LINE_BREAK + '789',
+        TAB_IGNORE
+      );
+    });
+
+    test('renderText newlines 4', async () => {
+      element.lineLimit = 20;
+      await element.updateComplete;
+      await check(
+        '👍'.repeat(58),
+        '👍'.repeat(20) +
+          LINE_BREAK +
+          '👍'.repeat(20) +
+          LINE_BREAK +
+          '👍'.repeat(18)
+      );
+    });
+
+    test('tab wrapper style', async () => {
+      element.lineLimit = 100;
+      element.tabSize = 4;
+      await check(
+        '\t',
+        /* HTML */ '<span class="gr-diff tab" style="tab-size:4;"></span>'
+      );
+      await check(
+        'abc\t',
+        /* HTML */ 'abc<span class="gr-diff tab" style="tab-size:1;"></span>'
+      );
+
+      element.tabSize = 8;
+      await check(
+        '\t',
+        /* HTML */ '<span class="gr-diff tab" style="tab-size:8;"></span>'
+      );
+      await check(
+        'abc\t',
+        /* HTML */ 'abc<span class="gr-diff tab" style="tab-size:5;"></span>'
+      );
+    });
+
+    test('tab wrapper insertion', async () => {
+      await check('abc\tdef', 'abc' + TAB + 'def', TAB_IGNORE);
+    });
+
+    test('escaping HTML', async () => {
+      element.lineLimit = 100;
+      await element.updateComplete;
+      await check(
+        '<script>alert("XSS");<' + '/script>',
+        '&lt;script&gt;alert("XSS");&lt;/script&gt;'
+      );
+      await check('& < > " \' / `', '&amp; &lt; &gt; " \' / `');
+    });
+
+    test('text length with tabs and unicode', async () => {
+      async function expectTextLength(
+        text: string,
+        tabSize: number,
+        expected: number
+      ) {
+        element.text = text;
+        element.tabSize = tabSize;
+        element.lineLimit = expected;
+        await element.updateComplete;
+        const result = element.innerHTML;
+
+        // Must not contain a line break.
+        assert.isNotOk(element.querySelector('span.br'));
+
+        // Increasing the line limit by 1 should not change anything.
+        element.lineLimit = expected + 1;
+        await element.updateComplete;
+        const resultPlusOne = element.innerHTML;
+        assert.equal(resultPlusOne, result);
+
+        // Increasing the line limit to infinity should not change anything.
+        element.lineLimit = Infinity;
+        await element.updateComplete;
+        const resultInf = element.innerHTML;
+        assert.equal(resultInf, result);
+
+        // Decreasing the line limit by 1 should introduce a line break.
+        element.lineLimit = expected + 1;
+        await element.updateComplete;
+        assert.isNotOk(element.querySelector('span.br'));
+      }
+      expectTextLength('12345', 4, 5);
+      expectTextLength('\t\t12', 4, 10);
+      expectTextLength('abc💢123', 4, 7);
+      expectTextLength('abc\t', 8, 8);
+      expectTextLength('abc\t\t', 10, 20);
+      expectTextLength('', 10, 0);
+      // 17 Thai combining chars.
+      expectTextLength('ก้้้้้้้้้้้้้้้้', 4, 17);
+      expectTextLength('abc\tde', 10, 12);
+      expectTextLength('abc\tde\t', 10, 20);
+      expectTextLength('\t\t\t\t\t', 20, 100);
+    });
+  });
+});
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/token-highlight-layer.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/token-highlight-layer.ts
index 19e0e22..e9076aa 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/token-highlight-layer.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/token-highlight-layer.ts
@@ -15,6 +15,7 @@
   getLineNumberByChild,
   lineNumberToNumber,
 } from '../gr-diff/gr-diff-utils';
+import {GrDiff} from '../gr-diff/gr-diff';
 
 const tokenMatcher = new RegExp(/[\w]+/g);
 
@@ -89,14 +90,43 @@
 
   private updateTokenTask?: DelayedTask;
 
+  /**
+   * Container that contains all annotated tokens and contains no shadow root
+   * elements that would prevent tokens to be queryable by querySelectorAll.
+   */
+  private getTokenQueryContainer?: () => HTMLElement;
+
+  /**
+   * @param container for registering "deselect" click
+   * @param tokenHighlightListener method that is called,
+   *   when token is highlighted.
+   * @param getTokenQueryContainer if specified, list of tokens to be
+   *   highlighted are recalculated every time using querySelectorAll inside
+   *   this element. Otherwise, the pointers calculated once at annotate() time
+   *   and are reused.
+   */
   constructor(
     container: HTMLElement,
-    tokenHighlightListener?: TokenHighlightListener
+    tokenHighlightListener?: TokenHighlightListener,
+    getTokenQueryContainer?: () => HTMLElement
   ) {
     this.tokenHighlightListener = tokenHighlightListener;
     container.addEventListener('click', e => {
       this.handleContainerClick(e);
     });
+    this.getTokenQueryContainer = getTokenQueryContainer;
+  }
+
+  static createTokenHighlightContainer(
+    container: HTMLElement,
+    getGrDiff: () => GrDiff,
+    tokenHighlightListener?: TokenHighlightListener
+  ): TokenHighlightLayer {
+    return new TokenHighlightLayer(
+      container,
+      tokenHighlightListener,
+      () => getGrDiff().diffTable!
+    );
   }
 
   annotate(el: HTMLElement, _1: HTMLElement, _2: GrDiffLine, _3: Side): void {
@@ -109,11 +139,17 @@
     let atLeastOneTokenMatched = false;
     while ((match = tokenMatcher.exec(text))) {
       const token = match[0];
-      const index = match.index;
-      const length = token.length;
+
       // Binary files encoded as text for example can have super long lines
       // with super long tokens. Let's guard against this scenario.
-      if (length > TOKEN_LENGTH_LIMIT) continue;
+      if (token.length > TOKEN_LENGTH_LIMIT) continue;
+
+      // This is to correctly count surrogate pairs in text and token.
+      // If the index calculation becomes a hotspot, we could precompute a code
+      // unit to code point index map for text before iterating over the results
+      const index = GrAnnotation.getStringLength(text.slice(0, match.index));
+      const length = GrAnnotation.getStringLength(token);
+
       atLeastOneTokenMatched = true;
       const highlightTypeClass =
         token === this.currentHighlight ? CSS_HIGHLIGHT : '';
@@ -265,8 +301,19 @@
     if (!token) {
       return;
     }
-    const tokenEls = this.tokenToElements.get(token);
-    if (!tokenEls) {
+    let tokenEls;
+    let tokenElsLength;
+    if (this.getTokenQueryContainer) {
+      tokenEls = this.getTokenQueryContainer().querySelectorAll(
+        `.${TOKEN_TEXT_PREFIX}${token}`
+      );
+      tokenElsLength = tokenEls.length;
+    } else {
+      tokenEls = this.tokenToElements.get(token);
+      tokenElsLength = tokenEls?.size;
+    }
+    if (!tokenEls || tokenElsLength === 0) {
+      console.warn(`No tokens have been found for '${token}'`);
       return;
     }
     for (const el of tokenEls) {
@@ -298,7 +345,7 @@
       start_line: line,
       start_column: index + 1, // 1-based inclusive
       end_line: line,
-      end_column: index + token.length, // 1-based inclusive
+      end_column: index + GrAnnotation.getStringLength(token), // 1-based inclusive
     };
     this.tokenHighlightListener({token, element, side, range});
   }
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/token-highlight-layer_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/token-highlight-layer_test.ts
index 0e2def0..8fd03bb 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/token-highlight-layer_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/token-highlight-layer_test.ts
@@ -105,15 +105,17 @@
   suite('annotate', () => {
     function assertAnnotation(
       args: any[],
-      el: HTMLElement,
-      start: number,
-      length: number,
-      cssClass: string
+      expected: {
+        parent: HTMLElement;
+        offset: number;
+        length: number;
+        cssClass: string;
+      }
     ) {
-      assert.equal(args[0], el);
-      assert.equal(args[1], start);
-      assert.equal(args[2], length);
-      assert.equal(args[3], cssClass);
+      assert.equal(args[0], expected.parent);
+      assert.equal(args[1], expected.offset);
+      assert.equal(args[2], expected.length);
+      assert.equal(args[3], expected.cssClass);
     }
 
     test('annotate adds css token', () => {
@@ -121,27 +123,51 @@
       const el = createLine('these are words');
       annotate(el);
       assert.isTrue(annotateElementStub.calledThrice);
-      assertAnnotation(
-        annotateElementStub.args[0],
-        el,
-        0,
-        5,
-        'tk-text-these tk-index-0 token '
-      );
-      assertAnnotation(
-        annotateElementStub.args[1],
-        el,
-        6,
-        3,
-        'tk-text-are tk-index-6 token '
-      );
-      assertAnnotation(
-        annotateElementStub.args[2],
-        el,
-        10,
-        5,
-        'tk-text-words tk-index-10 token '
-      );
+      assertAnnotation(annotateElementStub.args[0], {
+        parent: el,
+        offset: 0,
+        length: 5,
+        cssClass: 'tk-text-these tk-index-0 token ',
+      });
+      assertAnnotation(annotateElementStub.args[1], {
+        parent: el,
+        offset: 6,
+        length: 3,
+        cssClass: 'tk-text-are tk-index-6 token ',
+      });
+      assertAnnotation(annotateElementStub.args[2], {
+        parent: el,
+        offset: 10,
+        length: 5,
+        cssClass: 'tk-text-words tk-index-10 token ',
+      });
+    });
+
+    test('annotate adds css tokens w/ emojis', () => {
+      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+      const el = createLine('these 💩 are 👨‍👩‍👧‍👦 words');
+
+      annotate(el);
+
+      assert.isTrue(annotateElementStub.calledThrice);
+      assertAnnotation(annotateElementStub.args[0], {
+        parent: el,
+        offset: 0,
+        length: 5,
+        cssClass: 'tk-text-these tk-index-0 token ',
+      });
+      assertAnnotation(annotateElementStub.args[1], {
+        parent: el,
+        offset: 8,
+        length: 3,
+        cssClass: 'tk-text-are tk-index-8 token ',
+      });
+      assertAnnotation(annotateElementStub.args[2], {
+        parent: el,
+        offset: 20,
+        length: 5,
+        cssClass: 'tk-text-words tk-index-20 token ',
+      });
     });
 
     test('annotate adds mouse handlers', () => {
@@ -335,5 +361,44 @@
       assert.equal(listener.pending, 0);
       assert.isTrue(words1.classList.contains('token-highlight'));
     });
+
+    test('query based highlighting', async () => {
+      highlighter = new TokenHighlightLayer(
+        container,
+        tokenHighlightListener,
+        /* getTokenQueryContainer=*/ () => container
+      );
+      const clock = sinon.useFakeTimers();
+      const line1 = createLine('two words');
+      annotate(line1);
+      const line2 = createLine('three words', 2);
+      annotate(line2, Side.RIGHT, 2);
+      // Invalidate pointers.
+      for (const child of line1.childNodes) {
+        line1.replaceChild(child.cloneNode(), child);
+      }
+      for (const child of line2.childNodes) {
+        line2.replaceChild(child.cloneNode(), child);
+      }
+
+      const words1 = queryAndAssert(line1, '.tk-text-words');
+      assert.isTrue(words1.classList.contains('token'));
+      dispatchMouseEvent('mouseover', words1);
+      assert.equal(tokenHighlightingCalls.length, 0);
+      clock.tick(HOVER_DELAY_MS);
+      assert.equal(tokenHighlightingCalls.length, 1);
+      assert.deepEqual(tokenHighlightingCalls[0].details, {
+        token: 'words',
+        side: Side.RIGHT,
+        element: words1,
+        range: {start_line: 1, start_column: 5, end_line: 1, end_column: 9},
+      });
+      assert.isTrue(words1.classList.contains('token-highlight'));
+
+      container.click();
+      assert.equal(tokenHighlightingCalls.length, 2);
+      assert.deepEqual(tokenHighlightingCalls[1].details, undefined);
+      assert.isFalse(words1.classList.contains('token-highlight'));
+    });
   });
 });
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor.ts b/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor.ts
index 35439d6..9e3640b 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor.ts
@@ -8,7 +8,8 @@
 import {
   DiffViewMode,
   GrDiffCursor as GrDiffCursorApi,
-  LineNumberEventDetail,
+  LineNumber,
+  LineSelectedEventDetail,
 } from '../../../api/diff';
 import {ScrollMode, Side} from '../../../constants/constants';
 import {toggleClass} from '../../../utils/dom-util';
@@ -19,6 +20,7 @@
 import {GrDiffLineType} from '../gr-diff/gr-diff-line';
 import {GrDiffGroupType} from '../gr-diff/gr-diff-group';
 import {GrDiff} from '../gr-diff/gr-diff';
+import {fire} from '../../../utils/event-util';
 
 type GrDiffRowType = GrDiffLineType | GrDiffGroupType;
 
@@ -30,6 +32,29 @@
   number: number;
 }
 
+/**
+ * From <tr> diff row go up to <tbody> diff chunk.
+ *
+ * In Lit based diff there is a <gr-diff-row> element in between the two.
+ */
+export function fromRowToChunk(
+  rowEl: HTMLElement
+): HTMLTableSectionElement | undefined {
+  const parent = rowEl.parentElement;
+  if (!parent) return undefined;
+  if (parent.tagName === 'TBODY') {
+    return parent as HTMLTableSectionElement;
+  }
+
+  const grandParent = parent.parentElement;
+  if (!grandParent) return undefined;
+  if (grandParent.tagName === 'TBODY') {
+    return grandParent as HTMLTableSectionElement;
+  }
+
+  return undefined;
+}
+
 /** A subset of the GrDiff API that the cursor is using. */
 export interface GrDiffCursorable extends HTMLElement {
   isRangeSelected(): boolean;
@@ -179,8 +204,7 @@
   moveToNextChunk(clipToTop?: boolean): CursorMoveResult {
     const result = this.cursorManager.next({
       filter: (row: HTMLElement) => this._isFirstRowOfChunk(row),
-      getTargetHeight: target =>
-        (target?.parentNode as HTMLElement)?.scrollHeight || 0,
+      getTargetHeight: target => fromRowToChunk(target)?.scrollHeight || 0,
       clipToTop,
     });
     this._fixSide();
@@ -215,7 +239,7 @@
   }
 
   moveToLineNumber(
-    number: number,
+    number: LineNumber,
     side: Side,
     path?: string,
     intentionalMove?: boolean
@@ -330,13 +354,10 @@
     this.preventAutoScrollOnManualScroll = false;
   };
 
-  private _boundHandleDiffLineSelected = (event: Event) => {
-    const customEvent = event as CustomEvent;
-    this.moveToLineNumber(
-      customEvent.detail.number,
-      customEvent.detail.side,
-      customEvent.detail.path
-    );
+  private _boundHandleDiffLineSelected = (
+    e: CustomEvent<LineSelectedEventDetail>
+  ) => {
+    this.moveToLineNumber(e.detail.number, e.detail.side, e.detail.path);
   };
 
   createCommentInPlace() {
@@ -413,17 +434,21 @@
   }
 
   _isFirstRowOfChunk(row: HTMLElement) {
-    const parentClassList = (row.parentNode as HTMLElement).classList;
-    const isInChunk =
-      parentClassList.contains('section') && parentClassList.contains('delta');
-    const previousRow = row.previousSibling as HTMLElement;
-    const firstContentRow =
-      !previousRow || previousRow.classList.contains('moveControls');
-    return isInChunk && firstContentRow;
+    const chunk = fromRowToChunk(row);
+    if (!chunk) return false;
+
+    const isInDeltaChunk = chunk.classList.contains('delta');
+    if (!isInDeltaChunk) return false;
+
+    const firstRow = chunk.querySelector('tr:not(.moveControls)');
+    return firstRow === row;
   }
 
   _rowHasThread(row: HTMLElement): boolean {
-    return !!row.querySelector('.thread-group');
+    const slots = [
+      ...row.querySelectorAll<HTMLSlotElement>('.thread-group > slot'),
+    ];
+    return slots.some(slot => slot.assignedElements().length > 0);
   }
 
   /**
@@ -459,16 +484,10 @@
     const address = this.getAddressFor(row, side);
     if (address) {
       const {leftSide, number} = address;
-      row.dispatchEvent(
-        new CustomEvent<LineNumberEventDetail>(event, {
-          detail: {
-            lineNum: number,
-            side: leftSide ? Side.LEFT : Side.RIGHT,
-          },
-          composed: true,
-          bubbles: true,
-        })
-      );
+      fire(row, event, {
+        lineNum: number,
+        side: leftSide ? Side.LEFT : Side.RIGHT,
+      });
     }
   }
 
@@ -554,7 +573,7 @@
   }
 
   _findRowByNumberAndFile(
-    targetNumber: number,
+    targetNumber: LineNumber,
     side: Side,
     path?: string
   ): HTMLElement | undefined {
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor_test.ts
index 1e554b7..61f8551 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor_test.ts
@@ -7,7 +7,12 @@
 import '../gr-diff/gr-diff';
 import './gr-diff-cursor';
 import {fixture, html, assert} from '@open-wc/testing';
-import {mockPromise, queryAll, queryAndAssert} from '../../../test/test-utils';
+import {
+  mockPromise,
+  queryAll,
+  queryAndAssert,
+  waitUntil,
+} from '../../../test/test-utils';
 import {createDiff} from '../../../test/test-data-generators';
 import {createDefaultDiffPrefs} from '../../../constants/constants';
 import {GrDiffCursor} from './gr-diff-cursor';
@@ -46,32 +51,23 @@
   });
 
   test('diff cursor functionality (side-by-side)', () => {
-    // The cursor has been initialized to the first delta.
     assert.isOk(cursor.diffRow);
 
-    const firstDeltaRow = queryAndAssert<HTMLElement>(
+    const deltaRows = queryAll<HTMLTableRowElement>(
       diffElement,
-      '.section.delta .diff-row'
+      '.section.delta tr.diff-row'
     );
-    assert.equal(cursor.diffRow, firstDeltaRow);
+    assert.equal(cursor.diffRow, deltaRows[0]);
 
     cursor.moveDown();
 
-    assert.isOk(firstDeltaRow.nextElementSibling);
-    assert.notEqual(cursor.diffRow, firstDeltaRow);
-    assert.equal(
-      cursor.diffRow,
-      firstDeltaRow.nextElementSibling as HTMLElement
-    );
+    assert.notEqual(cursor.diffRow, deltaRows[0]);
+    assert.equal(cursor.diffRow, deltaRows[1]);
 
     cursor.moveUp();
 
-    assert.isOk(firstDeltaRow.nextElementSibling);
-    assert.notEqual(
-      cursor.diffRow,
-      firstDeltaRow.nextElementSibling as HTMLElement
-    );
-    assert.equal(cursor.diffRow, firstDeltaRow);
+    assert.notEqual(cursor.diffRow, deltaRows[1]);
+    assert.equal(cursor.diffRow, deltaRows[0]);
   });
 
   test('moveToFirstChunk', async () => {
@@ -115,20 +111,26 @@
     ] as HTMLElement[];
     assert.equal(chunks.length, 2);
 
+    const rows = [
+      ...queryAll(diffElement, '.section.delta tr.diff-row'),
+    ] as HTMLTableRowElement[];
+    assert.equal(rows.length, 2);
+
     // Verify it works on fresh diff.
     cursor.moveToFirstChunk();
     assert.ok(cursor.diffRow);
-    assert.equal(chunks.indexOf(cursor.diffRow!.parentElement!), 0);
+    assert.equal(cursor.diffRow, rows[0]);
     assert.equal(cursor.side, Side.RIGHT);
 
     // Verify it works from other cursor positions.
     cursor.moveToNextChunk();
     assert.ok(cursor.diffRow);
-    assert.equal(chunks.indexOf(cursor.diffRow!.parentElement!), 1);
+    assert.equal(cursor.diffRow, rows[1]);
     assert.equal(cursor.side, Side.LEFT);
+
     cursor.moveToFirstChunk();
     assert.ok(cursor.diffRow);
-    assert.equal(chunks.indexOf(cursor.diffRow!.parentElement!), 0);
+    assert.equal(cursor.diffRow, rows[0]);
     assert.equal(cursor.side, Side.RIGHT);
   });
 
@@ -164,20 +166,31 @@
     await waitForEventOnce(diffElement, 'render');
     cursor._updateStops();
 
-    const chunks = [...queryAll(diffElement, '.section.delta')];
+    const chunks = [
+      ...queryAll(diffElement, '.section.delta'),
+    ] as HTMLElement[];
     assert.equal(chunks.length, 2);
 
+    const rows = [
+      ...queryAll(diffElement, '.section.delta tr.diff-row'),
+    ] as HTMLTableRowElement[];
+    assert.equal(rows.length, 2);
+
     // Verify it works on fresh diff.
     cursor.moveToLastChunk();
-    assert.equal(chunks.indexOf(cursor.diffRow!.parentElement!), 1);
+    assert.ok(cursor.diffRow);
+    assert.equal(cursor.diffRow, rows[1]);
     assert.equal(cursor.side, Side.RIGHT);
 
     // Verify it works from other cursor positions.
     cursor.moveToPreviousChunk();
-    assert.equal(chunks.indexOf(cursor.diffRow!.parentElement!), 0);
+    assert.ok(cursor.diffRow);
+    assert.equal(cursor.diffRow, rows[0]);
     assert.equal(cursor.side, Side.LEFT);
+
     cursor.moveToLastChunk();
-    assert.equal(chunks.indexOf(cursor.diffRow!.parentElement!), 1);
+    assert.ok(cursor.diffRow);
+    assert.equal(cursor.diffRow, rows[1]);
     assert.equal(cursor.side, Side.RIGHT);
   });
 
@@ -221,30 +234,22 @@
     });
 
     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);
+      const rows = [
+        ...queryAll(diffElement, '.section.delta tr.diff-row'),
+      ] as HTMLTableRowElement[];
+      assert.equal(cursor.diffRow, rows[0]);
 
       cursor.moveDown();
 
-      assert.notEqual(cursor.diffRow, firstDeltaRow);
-      assert.equal(
-        cursor.diffRow,
-        firstDeltaRow.nextElementSibling as HTMLElement
-      );
+      assert.notEqual(cursor.diffRow, rows[0]);
+      assert.equal(cursor.diffRow, rows[1]);
 
       cursor.moveUp();
 
-      assert.notEqual(
-        cursor.diffRow,
-        firstDeltaRow.nextElementSibling as HTMLElement
-      );
-      assert.equal(cursor.diffRow, firstDeltaRow);
+      assert.notEqual(cursor.diffRow, rows[1]);
+      assert.equal(cursor.diffRow, rows[0]);
     });
   });
 
@@ -253,19 +258,21 @@
     // mode.
     assert.equal(diffElement.viewMode, 'SIDE_BY_SIDE');
 
-    const firstDeltaSection = queryAndAssert<HTMLElement>(
-      diffElement,
-      '.section.delta'
-    );
-    const firstDeltaRow = queryAndAssert<HTMLElement>(
-      firstDeltaSection,
-      '.diff-row'
-    );
+    const rows = [
+      ...queryAll(diffElement, '.section tr.diff-row'),
+    ] as HTMLTableRowElement[];
+    assert.equal(rows.length, 50);
+    const deltaRows = [
+      ...queryAll(diffElement, '.section.delta tr.diff-row'),
+    ] as HTMLTableRowElement[];
+    assert.equal(deltaRows.length, 14);
+    const indexFirstDelta = rows.indexOf(deltaRows[0]);
+    const rowBeforeFirstDelta = rows[indexFirstDelta - 1];
 
     // Because the first delta in this diff is on the right, it should be set
     // to the right side.
     assert.equal(cursor.side, Side.RIGHT);
-    assert.equal(cursor.diffRow, firstDeltaRow);
+    assert.equal(cursor.diffRow, deltaRows[0]);
     const firstIndex = cursor.cursorManager.index;
 
     // Move the side to the left. Because this delta only has a right side, we
@@ -274,33 +281,26 @@
     cursor.moveLeft();
 
     assert.equal(cursor.side, Side.LEFT);
-    assert.notEqual(cursor.diffRow, firstDeltaRow);
+    assert.notEqual(cursor.diffRow, rows[0]);
+    assert.equal(cursor.diffRow, rowBeforeFirstDelta);
     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.notEqual(cursor.diffRow, rowBeforeFirstDelta);
+    assert.notEqual(cursor.diffRow, rows[0]);
     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);
-    };
+    const deltaChunks = [...queryAll(diffElement, 'tbody.section.delta')];
 
     // We should be initialized to the first chunk. Since this chunk only has
     // content on the right side, our side should be right.
-    let currentIndex = indexOfChunk(cursor.diffRow!.parentElement!);
-    assert.equal(currentIndex, 0);
+    assert.equal(cursor.diffRow, deltaChunks[0].querySelector('tr'));
     assert.equal(cursor.side, Side.RIGHT);
 
     // Move to the next chunk.
@@ -308,9 +308,7 @@
 
     // 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.diffRow, deltaChunks[1].querySelector('tr'));
     assert.equal(cursor.side, Side.LEFT);
   });
 
@@ -358,10 +356,10 @@
 
     test('renders moveControls with simple descriptions', () => {
       const [movedIn, movedOut] = [
-        ...queryAll(diffElement, '.dueToMove .moveControls'),
+        ...queryAll<HTMLElement>(diffElement, '.dueToMove tr.moveControls'),
       ];
-      assert.equal(movedIn.textContent, 'Moved in');
-      assert.equal(movedOut.textContent, 'Moved out');
+      assert.include(movedIn.innerText, 'Moved in');
+      assert.include(movedOut.innerText, 'Moved out');
     });
   });
 
@@ -409,10 +407,10 @@
 
     test('renders moveControls with simple descriptions', () => {
       const [movedIn, movedOut] = [
-        ...queryAll(diffElement, '.dueToMove .moveControls'),
+        ...queryAll<HTMLElement>(diffElement, '.dueToMove tr.moveControls'),
       ];
-      assert.equal(movedIn.textContent, 'Moved from lines 4 - 6');
-      assert.equal(movedOut.textContent, 'Moved to lines 2 - 4');
+      assert.include(movedIn.innerText, 'Moved from lines 4 - 6');
+      assert.include(movedOut.innerText, 'Moved to lines 2 - 4');
     });
 
     test('startLineAnchor of movedIn chunk fires events', async () => {
@@ -609,6 +607,7 @@
     const showContext = queryAndAssert<HTMLElement>(controls, '.showContext');
     showContext.click();
     await waitForEventOnce(diffElement, 'render');
+    await waitUntil(() => spy.called);
     assert.isTrue(spy.called);
   });
 
@@ -661,7 +660,7 @@
       // Goto second last line of the first diff
       cursor.moveToLineNumber(lastLine - 1, Side.RIGHT);
       assert.equal(
-        cursor.getTargetLineElement()!.textContent,
+        cursor.getTargetLineElement()!.textContent?.trim(),
         `${lastLine - 1}`
       );
 
@@ -669,7 +668,7 @@
       cursor.moveDown();
       assert.equal(getTargetDiffIndex(), 0);
       assert.equal(
-        cursor.getTargetLineElement()!.textContent,
+        cursor.getTargetLineElement()!.textContent?.trim(),
         lastLine.toString()
       );
 
@@ -677,7 +676,7 @@
       cursor.moveDown();
       assert.equal(getTargetDiffIndex(), 0);
       assert.equal(
-        cursor.getTargetLineElement()!.textContent,
+        cursor.getTargetLineElement()!.textContent?.trim(),
         lastLine.toString()
       );
 
@@ -686,9 +685,10 @@
       await waitForEventOnce(diffElements[1], 'render');
 
       // Now we can go down
-      cursor.moveDown();
+      cursor.moveDown(); // LOST
+      cursor.moveDown(); // FILE
       assert.equal(getTargetDiffIndex(), 1);
-      assert.equal(cursor.getTargetLineElement()!.textContent, 'File');
+      assert.equal(cursor.getTargetLineElement()!.textContent?.trim(), 'File');
     });
   });
 });
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-annotation.ts b/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-annotation.ts
index cc7cd49..38bd707 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-annotation.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-annotation.ts
@@ -22,8 +22,14 @@
     return this.getStringLength(node.textContent || '');
   },
 
+  /**
+   * Returns the number of Unicode code points in the given string
+   *
+   * This is not necessarily the same as the number of visible symbols.
+   * See https://mathiasbynens.be/notes/javascript-unicode for more details.
+   */
   getStringLength(str: string) {
-    return str.replace(REGEX_ASTRAL_SYMBOL, '_').length;
+    return [...str].length;
   },
 
   /**
@@ -165,18 +171,20 @@
     cssClass: string,
     firstPart?: boolean
   ) {
-    if (this.getLength(node) === offset || offset === 0) {
-      return this.wrapInHighlight(node, cssClass);
-    } else {
-      if (firstPart) {
-        this.splitNode(node, offset);
-        // Node points to first part of the Text, second one is sibling.
-      } else {
-        // if node is Text then splitNode will return a Text
-        node = this.splitNode(node, offset) as Text;
-      }
+    if (
+      (this.getLength(node) === offset && firstPart) ||
+      (offset === 0 && !firstPart)
+    ) {
       return this.wrapInHighlight(node, cssClass);
     }
+    if (firstPart) {
+      this.splitNode(node, offset);
+      // Node points to first part of the Text, second one is sibling.
+    } else {
+      // if node is Text then splitNode will return a Text
+      node = this.splitNode(node, offset) as Text;
+    }
+    return this.wrapInHighlight(node, cssClass);
   },
 
   /**
@@ -219,7 +227,6 @@
    */
   splitTextNode(node: Text, offset: number) {
     if (node.textContent?.match(REGEX_ASTRAL_SYMBOL)) {
-      // TODO (viktard): Polyfill Array.from for IE10.
       const head = Array.from(node.textContent);
       const tail = head.splice(offset);
       const parent = node.parentNode;
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-annotation_test.js b/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-annotation_test.js
deleted file mode 100644
index c5cab0c..0000000
--- a/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-annotation_test.js
+++ /dev/null
@@ -1,318 +0,0 @@
-/**
- * @license
- * Copyright 2016 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-
-import '../../../test/common-test-setup';
-import {GrAnnotation} from './gr-annotation';
-import {
-  sanitizeDOMValue,
-  setSanitizeDOMValue,
-} from '@polymer/polymer/lib/utils/settings';
-// eslint-disable-next-line import/named
-import {assert, fixture, html} from '@open-wc/testing';
-
-suite('annotation', () => {
-  let str;
-  let parent;
-  let textNode;
-
-  setup(async () => {
-    parent = await fixture(
-        html`
-        <div>Lorem ipsum dolor sit amet, suspendisse inceptos vehicula</div>
-      `
-    );
-    textNode = parent.childNodes[0];
-    str = textNode.textContent;
-  });
-
-  test('_annotateText Case 1', () => {
-    GrAnnotation._annotateText(textNode, 0, str.length, 'foobar');
-
-    assert.equal(parent.childNodes.length, 1);
-    assert.instanceOf(parent.childNodes[0], HTMLElement);
-    assert.equal(parent.childNodes[0].className, 'foobar');
-    assert.instanceOf(parent.childNodes[0].childNodes[0], Text);
-    assert.equal(parent.childNodes[0].childNodes[0].textContent, str);
-  });
-
-  test('_annotateText Case 2', () => {
-    const length = 12;
-    const substr = str.substr(0, length);
-    const remainder = str.substr(length);
-
-    GrAnnotation._annotateText(textNode, 0, length, 'foobar');
-
-    assert.equal(parent.childNodes.length, 2);
-
-    assert.instanceOf(parent.childNodes[0], HTMLElement);
-    assert.equal(parent.childNodes[0].className, 'foobar');
-    assert.instanceOf(parent.childNodes[0].childNodes[0], Text);
-    assert.equal(parent.childNodes[0].childNodes[0].textContent, substr);
-
-    assert.instanceOf(parent.childNodes[1], Text);
-    assert.equal(parent.childNodes[1].textContent, remainder);
-  });
-
-  test('_annotateText Case 3', () => {
-    const index = 12;
-    const length = str.length - index;
-    const remainder = str.substr(0, index);
-    const substr = str.substr(index);
-
-    GrAnnotation._annotateText(textNode, index, length, 'foobar');
-
-    assert.equal(parent.childNodes.length, 2);
-
-    assert.instanceOf(parent.childNodes[0], Text);
-    assert.equal(parent.childNodes[0].textContent, remainder);
-
-    assert.instanceOf(parent.childNodes[1], HTMLElement);
-    assert.equal(parent.childNodes[1].className, 'foobar');
-    assert.instanceOf(parent.childNodes[1].childNodes[0], Text);
-    assert.equal(parent.childNodes[1].childNodes[0].textContent, substr);
-  });
-
-  test('_annotateText Case 4', () => {
-    const index = str.indexOf('dolor');
-    const length = 'dolor '.length;
-
-    const remainderPre = str.substr(0, index);
-    const substr = str.substr(index, length);
-    const remainderPost = str.substr(index + length);
-
-    GrAnnotation._annotateText(textNode, index, length, 'foobar');
-
-    assert.equal(parent.childNodes.length, 3);
-
-    assert.instanceOf(parent.childNodes[0], Text);
-    assert.equal(parent.childNodes[0].textContent, remainderPre);
-
-    assert.instanceOf(parent.childNodes[1], HTMLElement);
-    assert.equal(parent.childNodes[1].className, 'foobar');
-    assert.instanceOf(parent.childNodes[1].childNodes[0], Text);
-    assert.equal(parent.childNodes[1].childNodes[0].textContent, substr);
-
-    assert.instanceOf(parent.childNodes[2], Text);
-    assert.equal(parent.childNodes[2].textContent, remainderPost);
-  });
-
-  test('_annotateElement design doc example', () => {
-    const layers = ['amet, ', 'inceptos ', 'amet, ', 'et, suspendisse ince'];
-
-    // Apply the layers successively.
-    layers.forEach((layer, i) => {
-      GrAnnotation.annotateElement(
-          parent,
-          str.indexOf(layer),
-          layer.length,
-          `layer-${i + 1}`
-      );
-    });
-
-    assert.equal(parent.textContent, str);
-
-    // Layer 1:
-    const layer1 = parent.querySelectorAll('.layer-1');
-    assert.equal(layer1.length, 1);
-    assert.equal(layer1[0].textContent, layers[0]);
-    assert.equal(layer1[0].parentElement, parent);
-
-    // Layer 2:
-    const layer2 = parent.querySelectorAll('.layer-2');
-    assert.equal(layer2.length, 1);
-    assert.equal(layer2[0].textContent, layers[1]);
-    assert.equal(layer2[0].parentElement, parent);
-
-    // Layer 3:
-    const layer3 = parent.querySelectorAll('.layer-3');
-    assert.equal(layer3.length, 1);
-    assert.equal(layer3[0].textContent, layers[2]);
-    assert.equal(layer3[0].parentElement, layer1[0]);
-
-    // Layer 4:
-    const layer4 = parent.querySelectorAll('.layer-4');
-    assert.equal(layer4.length, 3);
-
-    assert.equal(layer4[0].textContent, 'et, ');
-    assert.equal(layer4[0].parentElement, layer3[0]);
-
-    assert.equal(layer4[1].textContent, 'suspendisse ');
-    assert.equal(layer4[1].parentElement, parent);
-
-    assert.equal(layer4[2].textContent, 'ince');
-    assert.equal(layer4[2].parentElement, layer2[0]);
-
-    assert.equal(
-        layer4[0].textContent + layer4[1].textContent + layer4[2].textContent,
-        layers[3]
-    );
-  });
-
-  test('splitTextNode', () => {
-    const helloString = 'hello';
-    const asciiString = 'ASCII';
-    const unicodeString = 'Unic💢de';
-
-    let node;
-    let tail;
-
-    // Non-unicode path:
-    node = document.createTextNode(helloString + asciiString);
-    tail = GrAnnotation.splitTextNode(node, helloString.length);
-    assert(node.textContent, helloString);
-    assert(tail.textContent, asciiString);
-
-    // Unicdoe path:
-    node = document.createTextNode(helloString + unicodeString);
-    tail = GrAnnotation.splitTextNode(node, helloString.length);
-    assert(node.textContent, helloString);
-    assert(tail.textContent, unicodeString);
-  });
-
-  suite('annotateWithElement', () => {
-    const fullText = '01234567890123456789';
-    let mockSanitize;
-    let originalSanitizeDOMValue;
-
-    setup(() => {
-      setSanitizeDOMValue((p0, p1, p2, node) => p0);
-      originalSanitizeDOMValue = sanitizeDOMValue;
-      assert.isDefined(originalSanitizeDOMValue);
-      mockSanitize = sinon.spy(originalSanitizeDOMValue);
-      setSanitizeDOMValue(mockSanitize);
-    });
-
-    teardown(() => {
-      setSanitizeDOMValue(originalSanitizeDOMValue);
-    });
-
-    test('annotates when fully contained', () => {
-      const length = 10;
-      const container = document.createElement('div');
-      container.textContent = fullText;
-      GrAnnotation.annotateWithElement(container, 1, length, {
-        tagName: 'test-wrapper',
-      });
-
-      assert.equal(
-          container.innerHTML,
-          '0<test-wrapper>1234567890</test-wrapper>123456789'
-      );
-    });
-
-    test('annotates when spanning multiple nodes', () => {
-      const length = 10;
-      const container = document.createElement('div');
-      container.textContent = fullText;
-      GrAnnotation.annotateElement(container, 5, length, 'testclass');
-      GrAnnotation.annotateWithElement(container, 1, length, {
-        tagName: 'test-wrapper',
-      });
-
-      assert.equal(
-          container.innerHTML,
-          '0' +
-          '<test-wrapper>' +
-          '1234' +
-          '<hl class="testclass">567890</hl>' +
-          '</test-wrapper>' +
-          '<hl class="testclass">1234</hl>' +
-          '56789'
-      );
-    });
-
-    test('annotates text node', () => {
-      const length = 10;
-      const container = document.createElement('div');
-      container.textContent = fullText;
-      GrAnnotation.annotateWithElement(container.childNodes[0], 1, length, {
-        tagName: 'test-wrapper',
-      });
-
-      assert.equal(
-          container.innerHTML,
-          '0<test-wrapper>1234567890</test-wrapper>123456789'
-      );
-    });
-
-    test('handles zero-length nodes', () => {
-      const container = document.createElement('div');
-      container.appendChild(document.createTextNode('0123456789'));
-      container.appendChild(document.createElement('span'));
-      container.appendChild(document.createTextNode('0123456789'));
-      GrAnnotation.annotateWithElement(container, 1, 10, {
-        tagName: 'test-wrapper',
-      });
-
-      assert.equal(
-          container.innerHTML,
-          '0<test-wrapper>123456789<span></span>0</test-wrapper>123456789'
-      );
-    });
-
-    test('handles comment nodes', () => {
-      const container = document.createElement('div');
-      container.appendChild(document.createComment('comment1'));
-      container.appendChild(document.createTextNode('0123456789'));
-      container.appendChild(document.createComment('comment2'));
-      container.appendChild(document.createElement('span'));
-      container.appendChild(document.createTextNode('0123456789'));
-      GrAnnotation.annotateWithElement(container, 1, 10, {
-        tagName: 'test-wrapper',
-      });
-
-      assert.equal(
-          container.innerHTML,
-          '<!--comment1-->' +
-          '0<test-wrapper>123456789' +
-          '<!--comment2-->' +
-          '<span></span>0</test-wrapper>123456789'
-      );
-    });
-
-    test('sets sanitized attributes', () => {
-      const container = document.createElement('div');
-      container.textContent = fullText;
-      const attributes = {
-        'href': 'foo',
-        'data-foo': 'bar',
-        'class': 'hello world',
-      };
-      GrAnnotation.annotateWithElement(container, 1, length, {
-        tagName: 'test-wrapper',
-        attributes,
-      });
-      assert(
-          mockSanitize.calledWith(
-              'foo',
-              'href',
-              'attribute',
-              sinon.match.instanceOf(Element)
-          )
-      );
-      assert(
-          mockSanitize.calledWith(
-              'bar',
-              'data-foo',
-              'attribute',
-              sinon.match.instanceOf(Element)
-          )
-      );
-      assert(
-          mockSanitize.calledWith(
-              'hello world',
-              'class',
-              'attribute',
-              sinon.match.instanceOf(Element)
-          )
-      );
-      const el = container.querySelector('test-wrapper');
-      assert.equal(el.getAttribute('href'), 'foo');
-      assert.equal(el.getAttribute('data-foo'), 'bar');
-      assert.equal(el.getAttribute('class'), 'hello world');
-    });
-  });
-});
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-annotation_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-annotation_test.ts
new file mode 100644
index 0000000..f319a3c
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-annotation_test.ts
@@ -0,0 +1,308 @@
+/**
+ * @license
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import '../../../test/common-test-setup';
+import {GrAnnotation} from './gr-annotation';
+import {
+  getSanitizeDOMValue,
+  setSanitizeDOMValue,
+} from '@polymer/polymer/lib/utils/settings';
+import {assert, fixture, html} from '@open-wc/testing';
+
+suite('annotation', () => {
+  let str: string;
+  let parent: HTMLDivElement;
+  let textNode: Text;
+
+  setup(async () => {
+    parent = await fixture(
+      html`
+        <div>Lorem ipsum dolor sit amet, suspendisse inceptos vehicula</div>
+      `
+    );
+    textNode = parent.childNodes[0] as Text;
+    str = textNode.textContent!;
+  });
+
+  test('_annotateText length:0 offset:0', () => {
+    GrAnnotation._annotateText(textNode, 0, 0, 'foobar');
+
+    assert.equal(parent.textContent, str);
+    assert.equal(
+      parent.innerHTML,
+      '<hl class="foobar"></hl>Lorem ipsum dolor sit amet, suspendisse inceptos vehicula'
+    );
+  });
+
+  test('_annotateText length:0 offset:1', () => {
+    GrAnnotation._annotateText(textNode, 1, 0, 'foobar');
+
+    assert.equal(parent.textContent, str);
+    assert.equal(
+      parent.innerHTML,
+      'L<hl class="foobar"></hl>orem ipsum dolor sit amet, suspendisse inceptos vehicula'
+    );
+  });
+
+  test('_annotateText length:0 offset:str.length', () => {
+    GrAnnotation._annotateText(textNode, str.length, 0, 'foobar');
+
+    assert.equal(parent.textContent, str);
+    assert.equal(
+      parent.innerHTML,
+      'Lorem ipsum dolor sit amet, suspendisse inceptos vehicula<hl class="foobar"></hl>'
+    );
+  });
+
+  test('_annotateText Case 1', () => {
+    GrAnnotation._annotateText(textNode, 0, str.length, 'foobar');
+
+    assert.equal(parent.textContent, str);
+    assert.equal(
+      parent.innerHTML,
+      '<hl class="foobar">Lorem ipsum dolor sit amet, suspendisse inceptos vehicula</hl>'
+    );
+  });
+
+  test('_annotateText Case 2', () => {
+    GrAnnotation._annotateText(textNode, 0, 12, 'foobar');
+
+    assert.equal(parent.textContent, str);
+    assert.equal(
+      parent.innerHTML,
+      '<hl class="foobar">Lorem ipsum </hl>dolor sit amet, suspendisse inceptos vehicula'
+    );
+  });
+
+  test('_annotateText Case 3', () => {
+    GrAnnotation._annotateText(textNode, 12, str.length - 12, 'foobar');
+
+    assert.equal(parent.textContent, str);
+    assert.equal(
+      parent.innerHTML,
+      'Lorem ipsum <hl class="foobar">dolor sit amet, suspendisse inceptos vehicula</hl>'
+    );
+  });
+
+  test('_annotateText Case 4', () => {
+    const index = str.indexOf('dolor');
+    const length = 'dolor '.length;
+
+    GrAnnotation._annotateText(textNode, index, length, 'foobar');
+
+    assert.equal(parent.textContent, str);
+    assert.equal(
+      parent.innerHTML,
+      'Lorem ipsum <hl class="foobar">dolor </hl>sit amet, suspendisse inceptos vehicula'
+    );
+  });
+
+  test('_annotateElement design doc example', () => {
+    const layers = ['amet, ', 'inceptos ', 'amet, ', 'et, suspendisse ince'];
+
+    // Apply the layers successively.
+    layers.forEach((layer, i) => {
+      GrAnnotation.annotateElement(
+        parent,
+        str.indexOf(layer),
+        layer.length,
+        `layer-${i + 1}`
+      );
+    });
+
+    assert.equal(parent.textContent, str);
+    assert.equal(
+      parent.innerHTML,
+      'Lorem ipsum dolor sit <hl class="layer-1"><hl class="layer-3">am<hl class="layer-4">et, </hl></hl></hl><hl class="layer-4">suspendisse </hl><hl class="layer-2"><hl class="layer-4">ince</hl>ptos </hl>vehicula'
+    );
+  });
+
+  test('splitTextNode', () => {
+    const helloString = 'hello';
+    const asciiString = 'ASCII';
+    const unicodeString = 'Unic💢de';
+
+    let node;
+    let tail;
+
+    // Non-unicode path:
+    node = document.createTextNode(helloString + asciiString);
+    tail = GrAnnotation.splitTextNode(node, helloString.length);
+    assert(node.textContent, helloString);
+    assert(tail.textContent, asciiString);
+
+    // Unicdoe path:
+    node = document.createTextNode(helloString + unicodeString);
+    tail = GrAnnotation.splitTextNode(node, helloString.length);
+    assert(node.textContent, helloString);
+    assert(tail.textContent, unicodeString);
+  });
+
+  suite('annotateWithElement', () => {
+    const fullText = '01234567890123456789';
+    let mockSanitize: sinon.SinonSpy;
+    let originalSanitizeDOMValue: (
+      p0: any,
+      p1: string,
+      p2: string,
+      p3: Node | null
+    ) => any;
+
+    setup(() => {
+      setSanitizeDOMValue(p0 => p0);
+      originalSanitizeDOMValue = getSanitizeDOMValue()!;
+      assert.isDefined(originalSanitizeDOMValue);
+      mockSanitize = sinon.spy(originalSanitizeDOMValue);
+      setSanitizeDOMValue(mockSanitize);
+    });
+
+    teardown(() => {
+      setSanitizeDOMValue(originalSanitizeDOMValue);
+    });
+
+    test('annotates when fully contained', () => {
+      const length = 10;
+      const container = document.createElement('div');
+      container.textContent = fullText;
+      GrAnnotation.annotateWithElement(container, 1, length, {
+        tagName: 'test-wrapper',
+      });
+
+      assert.equal(
+        container.innerHTML,
+        '0<test-wrapper>1234567890</test-wrapper>123456789'
+      );
+    });
+
+    test('annotates when spanning multiple nodes', () => {
+      const length = 10;
+      const container = document.createElement('div');
+      container.textContent = fullText;
+      GrAnnotation.annotateElement(container, 5, length, 'testclass');
+      GrAnnotation.annotateWithElement(container, 1, length, {
+        tagName: 'test-wrapper',
+      });
+
+      assert.equal(
+        container.innerHTML,
+        '0' +
+          '<test-wrapper>' +
+          '1234' +
+          '<hl class="testclass">567890</hl>' +
+          '</test-wrapper>' +
+          '<hl class="testclass">1234</hl>' +
+          '56789'
+      );
+    });
+
+    test('annotates text node', () => {
+      const length = 10;
+      const container = document.createElement('div');
+      container.textContent = fullText;
+      GrAnnotation.annotateWithElement(container.childNodes[0], 1, length, {
+        tagName: 'test-wrapper',
+      });
+
+      assert.equal(
+        container.innerHTML,
+        '0<test-wrapper>1234567890</test-wrapper>123456789'
+      );
+    });
+
+    test('handles zero-length nodes', () => {
+      const container = document.createElement('div');
+      container.appendChild(document.createTextNode('0123456789'));
+      container.appendChild(document.createElement('span'));
+      container.appendChild(document.createTextNode('0123456789'));
+      GrAnnotation.annotateWithElement(container, 1, 10, {
+        tagName: 'test-wrapper',
+      });
+
+      assert.equal(
+        container.innerHTML,
+        '0<test-wrapper>123456789<span></span>0</test-wrapper>123456789'
+      );
+    });
+
+    test('handles comment nodes', () => {
+      const container = document.createElement('div');
+      container.appendChild(document.createComment('comment1'));
+      container.appendChild(document.createTextNode('0123456789'));
+      container.appendChild(document.createComment('comment2'));
+      container.appendChild(document.createElement('span'));
+      container.appendChild(document.createTextNode('0123456789'));
+      GrAnnotation.annotateWithElement(container, 1, 10, {
+        tagName: 'test-wrapper',
+      });
+
+      assert.equal(
+        container.innerHTML,
+        '<!--comment1-->' +
+          '0<test-wrapper>123456789' +
+          '<!--comment2-->' +
+          '<span></span>0</test-wrapper>123456789'
+      );
+    });
+
+    test('sets sanitized attributes', () => {
+      const container = document.createElement('div');
+      container.textContent = fullText;
+      const attributes = {
+        href: 'foo',
+        'data-foo': 'bar',
+        class: 'hello world',
+      };
+      GrAnnotation.annotateWithElement(container, 1, length, {
+        tagName: 'test-wrapper',
+        attributes,
+      });
+      assert(
+        mockSanitize.calledWith(
+          'foo',
+          'href',
+          'attribute',
+          sinon.match.instanceOf(Element)
+        )
+      );
+      assert(
+        mockSanitize.calledWith(
+          'bar',
+          'data-foo',
+          'attribute',
+          sinon.match.instanceOf(Element)
+        )
+      );
+      assert(
+        mockSanitize.calledWith(
+          'hello world',
+          'class',
+          'attribute',
+          sinon.match.instanceOf(Element)
+        )
+      );
+      const el = container.querySelector('test-wrapper')!;
+      assert.equal(el.getAttribute('href'), 'foo');
+      assert.equal(el.getAttribute('data-foo'), 'bar');
+      assert.equal(el.getAttribute('class'), 'hello world');
+    });
+  });
+
+  suite('getStringLength', () => {
+    test('ASCII characters are counted correctly', () => {
+      assert.equal(GrAnnotation.getStringLength('ASCII'), 5);
+    });
+
+    test('Unicode surrogate pairs count as one symbol', () => {
+      assert.equal(GrAnnotation.getStringLength('Unic💢de'), 7);
+      assert.equal(GrAnnotation.getStringLength('💢💢'), 2);
+    });
+
+    test('Grapheme clusters count as multiple symbols', () => {
+      assert.equal(GrAnnotation.getStringLength('man\u0303ana'), 7); // mañana
+      assert.equal(GrAnnotation.getStringLength('q\u0307\u0323'), 3); // q̣̇
+    });
+  });
+});
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight.ts b/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight.ts
index 0714645..69c0f5c 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight.ts
@@ -20,6 +20,7 @@
 } from '../gr-diff/gr-diff-utils';
 import {debounce, DelayedTask} from '../../../utils/async-util';
 import {assertIsDefined, queryAndAssert} from '../../../utils/common-util';
+import {fire} from '../../../utils/event-util';
 
 interface SidedRange {
   side: Side;
@@ -43,7 +44,7 @@
  * fully blown dependency on GrDiffBuilderElement.
  */
 export interface DiffBuilderInterface {
-  getContentTdByLineEl(lineEl?: Element): Element | null;
+  getContentTdByLineEl(lineEl?: Element): Element | undefined;
 }
 
 /**
@@ -458,13 +459,9 @@
   }
 
   private fireCreateRangeComment(side: Side, range: CommentRange) {
-    this.diffTable?.dispatchEvent(
-      new CustomEvent('create-range-comment', {
-        detail: {side, range},
-        composed: true,
-        bubbles: true,
-      })
-    );
+    if (this.diffTable) {
+      fire(this.diffTable, 'create-range-comment', {side, range});
+    }
     this.removeActionBox();
   }
 
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight_test.ts
index af921e4..f04e6a2 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight_test.ts
@@ -5,7 +5,7 @@
  */
 import '../../../test/common-test-setup';
 import './gr-diff-highlight';
-import {_getTextOffset} from './gr-range-normalizer';
+import {getTextOffset} from './gr-range-normalizer';
 import {fixture, fixtureCleanup, html, assert} from '@open-wc/testing';
 import {
   GrDiffHighlight,
@@ -62,7 +62,7 @@
       <tr class="diff-row side-by-side" left-type="remove" right-type="add">
         <td class="left lineNum" data-value="140"></td>
         <!-- Next tag is formatted to eliminate zero-length text nodes. -->
-        <td class="content remove"><div class="contentText">na💢ti <hl class="foo">te, inquit</hl>, sumus <hl class="bar">aliquando</hl> otiosum, <hl>certe</hl> a <hl><span class="tab-indicator" style="tab-size:8;">\u0009</span></hl>udiam, <hl>quid</hl> sit, <span class="tab-indicator" style="tab-size:8;">\u0009</span>quod <hl>Epicurum</hl></div><div class="comment-thread">
+        <td class="content remove"><div class="contentText"><!-- a comment node -->na💢ti <hl class="foo">te, inquit</hl>, sumus <hl class="bar">aliquando</hl> otiosum, <hl>certe</hl> a <hl><span class="tab-indicator" style="tab-size:8;">\u0009</span></hl>udiam, <hl>quid</hl> sit, <span class="tab-indicator" style="tab-size:8;">\u0009</span>quod <hl>Epicurum</hl></div><div class="comment-thread">
           [Yet another random diff thread content here]
         </div></td>
         <td class="right lineNum" data-value="120"></td>
@@ -684,13 +684,13 @@
       if (!content.lastChild) assert.fail('last child of content not found');
       let child = content.lastChild.lastChild;
       if (!child) assert.fail('last child of last child of content not found');
-      let result = _getTextOffset(content, child);
+      let result = getTextOffset(content, child);
       assert.equal(result, 75);
       content = stubContent(146, Side.RIGHT);
       if (!content) assert.fail('content element not found');
       child = content.lastChild;
       if (!child) assert.fail('child element not found');
-      result = _getTextOffset(content, child);
+      result = getTextOffset(content, child);
       assert.equal(result, 0);
     });
 
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-range-normalizer.ts b/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-range-normalizer.ts
index 9f23162..b177e14 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-range-normalizer.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-range-normalizer.ts
@@ -25,12 +25,12 @@
  *     for syntax highlighting.
  */
 export function normalize(range: Range): NormalizedRange {
-  const startContainer = _getContentTextParent(range.startContainer);
+  const startContainer = getContentTextParent(range.startContainer);
   const startOffset =
-    range.startOffset + _getTextOffset(startContainer, range.startContainer);
-  const endContainer = _getContentTextParent(range.endContainer);
+    range.startOffset + getTextOffset(startContainer, range.startContainer);
+  const endContainer = getContentTextParent(range.endContainer);
   const endOffset =
-    range.endOffset + _getTextOffset(endContainer, range.endContainer);
+    range.endOffset + getTextOffset(endContainer, range.endContainer);
   return {
     startContainer,
     startOffset,
@@ -39,7 +39,7 @@
   };
 }
 
-function _getContentTextParent(target: Node): Node {
+function getContentTextParent(target: Node): Node {
   if (!target.parentElement) return target;
 
   let element: Element | null;
@@ -67,7 +67,7 @@
  * @param child The child element being searched for.
  */
 // TODO(TS): Only export for test.
-export function _getTextOffset(node: Node | null, child: Node): number {
+export function getTextOffset(node: Node | null, child: Node): number {
   let count = 0;
   let stack = [node];
   while (stack.length) {
@@ -83,7 +83,7 @@
       arr.reverse();
       stack = stack.concat(arr);
     } else {
-      count += _getLength(n);
+      count += getLength(n);
     }
   }
   return count;
@@ -96,8 +96,8 @@
  * @param node A text node.
  * @return The length of the text.
  */
-function _getLength(node?: Node | null) {
-  return node && node.textContent
+function getLength(node?: Node | null) {
+  return node && node.textContent && node.nodeType !== Node.COMMENT_NODE
     ? node.textContent.replace(REGEX_ASTRAL_SYMBOL, '_').length
     : 0;
 }
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-image-viewer.ts b/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-image-viewer.ts
index 8a92bcc..645de1b 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-image-viewer.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-image-viewer.ts
@@ -24,14 +24,10 @@
 import {classMap} from 'lit/directives/class-map.js';
 import {StyleInfo, styleMap} from 'lit/directives/style-map.js';
 
-import {
-  createEvent,
-  Dimensions,
-  fitToFrame,
-  FrameConstrainer,
-  Point,
-  Rect,
-} from './util';
+import {Dimensions, fitToFrame, FrameConstrainer, Point, Rect} from './util';
+import {ValueChangedEvent} from '../../../types/events';
+import {fire} from '../../../utils/event-util';
+import {ImageDiffAction} from '../../../api/diff';
 
 const DRAG_DEAD_ZONE_PIXELS = 5;
 
@@ -686,27 +682,25 @@
       });
   }
 
+  fireAction(detail: ImageDiffAction) {
+    fire(this, 'image-diff-action', detail);
+  }
+
   selectBase() {
     if (!this.baseUrl) return;
     this.baseSelected = true;
-    this.dispatchEvent(
-      createEvent({type: 'version-switcher-clicked', button: 'base'})
-    );
+    this.fireAction({type: 'version-switcher-clicked', button: 'base'});
   }
 
   selectRevision() {
     if (!this.revisionUrl) return;
     this.baseSelected = false;
-    this.dispatchEvent(
-      createEvent({type: 'version-switcher-clicked', button: 'revision'})
-    );
+    this.fireAction({type: 'version-switcher-clicked', button: 'revision'});
   }
 
   manualBlink() {
     this.toggleImage();
-    this.dispatchEvent(
-      createEvent({type: 'version-switcher-clicked', button: 'switch'})
-    );
+    this.fireAction({type: 'version-switcher-clicked', button: 'switch'});
   }
 
   private toggleImage() {
@@ -717,9 +711,10 @@
 
   toggleAutomaticBlink() {
     this.automaticBlink = !this.automaticBlink;
-    this.dispatchEvent(
-      createEvent({type: 'automatic-blink-changed', value: this.automaticBlink})
-    );
+    this.fireAction({
+      type: 'automatic-blink-changed',
+      value: this.automaticBlink,
+    });
   }
 
   private updateAutomaticBlink() {
@@ -751,52 +746,43 @@
 
   private toggleHighlight(source: 'controls' | 'magnifier') {
     this.showHighlight = !this.showHighlight;
-    this.dispatchEvent(
-      createEvent({
-        type: 'highlight-changes-changed',
-        value: this.showHighlight,
-        source,
-      })
-    );
+    this.fireAction({
+      type: 'highlight-changes-changed',
+      value: this.showHighlight,
+      source,
+    });
   }
 
-  zoomControlChanged(event: CustomEvent) {
-    const value = event.detail.value;
-    if (!value) return;
-    if (value === 'fit') {
+  zoomControlChanged(event: ValueChangedEvent<string>) {
+    const scaleString = event.detail.value;
+    if (!scaleString) return;
+    if (scaleString === 'fit') {
       this.scaledSelected = true;
-      this.dispatchEvent(
-        createEvent({type: 'zoom-level-changed', scale: 'fit'})
-      );
+      this.fireAction({type: 'zoom-level-changed', scale: 'fit'});
     }
-    if (value > 0) {
+    const scale = Number(scaleString);
+    if (Number.isFinite(scale) && scale > 0) {
       this.scaledSelected = false;
-      this.scale = value;
-      this.dispatchEvent(
-        createEvent({type: 'zoom-level-changed', scale: value})
-      );
+      this.scale = scale;
+      this.fireAction({type: 'zoom-level-changed', scale});
     }
     this.updateSizes();
   }
 
   followMouseChanged() {
     this.followMouse = !this.followMouse;
-    this.dispatchEvent(
-      createEvent({type: 'follow-mouse-changed', value: this.followMouse})
-    );
+    this.fireAction({type: 'follow-mouse-changed', value: this.followMouse});
   }
 
   pickColor(value: string) {
     this.checkerboardSelected = false;
     this.backgroundColor = value;
-    this.dispatchEvent(createEvent({type: 'background-color-changed', value}));
+    this.fireAction({type: 'background-color-changed', value});
   }
 
   pickCheckerboard() {
     this.checkerboardSelected = true;
-    this.dispatchEvent(
-      createEvent({type: 'background-color-changed', value: 'checkerboard'})
-    );
+    this.fireAction({type: 'background-color-changed', value: 'checkerboard'});
   }
 
   mousemoveImageArea(event: MouseEvent) {
@@ -849,9 +835,9 @@
     // external mice.
     if (distance < DRAG_DEAD_ZONE_PIXELS) {
       this.toggleImage();
-      this.dispatchEvent(createEvent({type: 'magnifier-clicked'}));
+      this.fireAction({type: 'magnifier-clicked'});
     } else {
-      this.dispatchEvent(createEvent({type: 'magnifier-dragged'}));
+      this.fireAction({type: 'magnifier-dragged'});
     }
   }
 
@@ -894,17 +880,17 @@
     if (!this.ownsMouseDown) return;
     this.grabbing = false;
     this.ownsMouseDown = false;
-    this.dispatchEvent(createEvent({type: 'magnifier-dragged'}));
+    this.fireAction({type: 'magnifier-dragged'});
   }
 
   dragstartMagnifier(event: DragEvent) {
     event.preventDefault();
   }
 
-  onOverviewCenterUpdated(event: CustomEvent) {
+  onOverviewCenterUpdated(event: CustomEvent<Point>) {
     this.frameConstrainer.requestCenter({
-      x: event.detail.x as number,
-      y: event.detail.y as number,
+      x: event.detail.x,
+      y: event.detail.y,
     });
     this.updateFrames();
   }
@@ -955,4 +941,7 @@
   interface HTMLElementTagNameMap {
     'gr-image-viewer': GrImageViewer;
   }
+  interface HTMLElementEventMap {
+    'image-diff-action': CustomEvent<ImageDiffAction>;
+  }
 }
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-overview-image.ts b/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-overview-image.ts
index 1bc1447..21a7cf8 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-overview-image.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-overview-image.ts
@@ -7,8 +7,9 @@
 import {customElement, property, query, state} from 'lit/decorators.js';
 import {StyleInfo, styleMap} from 'lit/directives/style-map.js';
 import {ImageDiffAction} from '../../../api/diff';
+import {fire} from '../../../utils/event-util';
 
-import {createEvent, Dimensions, fitToFrame, Point, Rect} from './util';
+import {Dimensions, fitToFrame, Point, Rect} from './util';
 
 /**
  * Displays a scaled-down version of an image with a draggable frame for
@@ -243,7 +244,7 @@
     const detail: ImageDiffAction = {
       type: this.dragging ? 'overview-frame-dragged' : 'overview-image-clicked',
     };
-    this.dispatchEvent(createEvent(detail));
+    fire(this, 'image-diff-action', detail);
 
     this.dragging = false;
     this.closeOverlay();
@@ -297,13 +298,7 @@
   }
 
   private notifyNewCenter(center: Point) {
-    this.dispatchEvent(
-      new CustomEvent('center-updated', {
-        detail: {...center},
-        bubbles: true,
-        composed: true,
-      })
-    );
+    fire(this, 'center-updated', {...center});
   }
 }
 
@@ -311,4 +306,7 @@
   interface HTMLElementTagNameMap {
     'gr-overview-image': GrOverviewImage;
   }
+  interface HTMLElementEventMap {
+    'center-updated': CustomEvent<Point>;
+  }
 }
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/util.ts b/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/util.ts
index 38a07b7..896dc11 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/util.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/util.ts
@@ -3,7 +3,6 @@
  * Copyright 2021 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-import {ImageDiffAction} from '../../../api/diff';
 
 export interface Point {
   x: number;
@@ -224,13 +223,3 @@
     };
   }
 }
-
-export function createEvent(
-  detail: ImageDiffAction
-): CustomEvent<ImageDiffAction> {
-  return new CustomEvent('image-diff-action', {
-    detail,
-    bubbles: true,
-    composed: true,
-  });
-}
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-mode-selector/gr-diff-mode-selector.ts b/polygerrit-ui/app/embed/diff/gr-diff-mode-selector/gr-diff-mode-selector.ts
index 5caffe6..a9bdab8 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-mode-selector/gr-diff-mode-selector.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-mode-selector/gr-diff-mode-selector.ts
@@ -9,14 +9,13 @@
 import '../../../elements/shared/gr-icon/gr-icon';
 import {DiffViewMode} from '../../../constants/constants';
 import {customElement, property, state} from 'lit/decorators.js';
-import {IronA11yAnnouncer} from '@polymer/iron-a11y-announcer/iron-a11y-announcer';
-import {FixIronA11yAnnouncer} from '../../../types/types';
-import {getAppContext} from '../../../services/app-context';
 import {fireIronAnnounce} from '../../../utils/event-util';
 import {browserModelToken} from '../../../models/browser/browser-model';
 import {resolve} from '../../../models/dependency';
 import {css, html, LitElement} from 'lit';
 import {sharedStyles} from '../../../styles/shared-styles';
+import {userModelToken} from '../../../models/user/user-model';
+import {ironAnnouncerRequestAvailability} from '../../../elements/polymer-util';
 
 @customElement('gr-diff-mode-selector')
 export class GrDiffModeSelector extends LitElement {
@@ -34,7 +33,7 @@
 
   private readonly getBrowserModel = resolve(this, browserModelToken);
 
-  private readonly userModel = getAppContext().userModel;
+  private readonly getUserModel = resolve(this, userModelToken);
 
   private subscriptions: Subscription[] = [];
 
@@ -44,9 +43,7 @@
 
   override connectedCallback() {
     super.connectedCallback();
-    (
-      IronA11yAnnouncer as unknown as FixIronA11yAnnouncer
-    ).requestAvailability();
+    ironAnnouncerRequestAvailability();
     this.subscriptions.push(
       this.getBrowserModel().diffViewMode$.subscribe(
         diffView => (this.mode = diffView)
@@ -118,7 +115,7 @@
    */
   private setMode(newMode: DiffViewMode) {
     if (this.saveOnChange && this.mode && this.mode !== newMode) {
-      this.userModel.updatePreferences({diff_view: newMode});
+      this.getUserModel().updatePreferences({diff_view: newMode});
     }
     this.mode = newMode;
     let announcement;
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-mode-selector/gr-diff-mode-selector_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-mode-selector/gr-diff-mode-selector_test.ts
index 34af01e..d646988 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-mode-selector/gr-diff-mode-selector_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-mode-selector/gr-diff-mode-selector_test.ts
@@ -7,21 +7,17 @@
 import './gr-diff-mode-selector';
 import {GrDiffModeSelector} from './gr-diff-mode-selector';
 import {DiffViewMode} from '../../../constants/constants';
-import {
-  queryAndAssert,
-  stubUsers,
-  waitUntilObserved,
-} from '../../../test/test-utils';
+import {queryAndAssert, waitUntilObserved} from '../../../test/test-utils';
 import {fixture, html, assert} from '@open-wc/testing';
 import {wrapInProvider} from '../../../models/di-provider-element';
 import {
   BrowserModel,
   browserModelToken,
 } from '../../../models/browser/browser-model';
-import {getAppContext} from '../../../services/app-context';
-import {UserModel} from '../../../models/user/user-model';
+import {UserModel, userModelToken} from '../../../models/user/user-model';
 import {createPreferences} from '../../../test/test-data-generators';
 import {GrButton} from '../../../elements/shared/gr-button/gr-button';
+import {testResolver} from '../../../test/common-test-setup';
 
 suite('gr-diff-mode-selector tests', () => {
   let element: GrDiffModeSelector;
@@ -29,7 +25,7 @@
   let userModel: UserModel;
 
   setup(async () => {
-    userModel = getAppContext().userModel;
+    userModel = testResolver(userModelToken);
     browserModel = new BrowserModel(userModel);
     element = (
       await fixture(
@@ -129,7 +125,7 @@
 
   test('set mode', async () => {
     browserModel.setScreenWidth(0);
-    const saveStub = stubUsers('updatePreferences');
+    const saveStub = sinon.stub(userModel, 'updatePreferences');
 
     // Setting the mode initially does not save prefs.
     element.saveOnChange = true;
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-model/gr-diff-model.ts b/polygerrit-ui/app/embed/diff/gr-diff-model/gr-diff-model.ts
new file mode 100644
index 0000000..8fbda14
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-diff-model/gr-diff-model.ts
@@ -0,0 +1,47 @@
+/**
+ * @license
+ * Copyright 2023 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {Observable} from 'rxjs';
+import {filter} from 'rxjs/operators';
+import {
+  DiffInfo,
+  DiffPreferencesInfo,
+  RenderPreferences,
+} from '../../../api/diff';
+import {define} from '../../../models/dependency';
+import {Model} from '../../../models/model';
+import {isDefined} from '../../../types/types';
+import {select} from '../../../utils/observable-util';
+
+export interface DiffState {
+  diff: DiffInfo;
+  path?: string;
+  renderPrefs: RenderPreferences;
+  diffPrefs: DiffPreferencesInfo;
+}
+
+export const diffModelToken = define<DiffModel>('diff-model');
+
+export class DiffModel extends Model<DiffState | undefined> {
+  readonly diff$: Observable<DiffInfo> = select(
+    this.state$.pipe(filter(isDefined)),
+    diffState => diffState.diff
+  );
+
+  readonly path$: Observable<string | undefined> = select(
+    this.state$.pipe(filter(isDefined)),
+    diffState => diffState.path
+  );
+
+  readonly renderPrefs$: Observable<RenderPreferences> = select(
+    this.state$.pipe(filter(isDefined)),
+    diffState => diffState.renderPrefs
+  );
+
+  readonly diffPrefs$: Observable<DiffPreferencesInfo> = select(
+    this.state$.pipe(filter(isDefined)),
+    diffState => diffState.diffPrefs
+  );
+}
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 062347f..05e5d3b 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,12 +15,11 @@
   GrDiffGroupType,
   hideInContextControl,
 } from '../gr-diff/gr-diff-group';
-import {CancelablePromise, makeCancelable} from '../../../scripts/util';
 import {DiffContent} from '../../../types/diff';
 import {Side} from '../../../constants/constants';
 import {debounce, DelayedTask} from '../../../utils/async-util';
-import {RenderPreferences} from '../../../api/diff';
-import {assertIsDefined} from '../../../utils/common-util';
+import {assert, assertIsDefined} from '../../../utils/common-util';
+import {GrAnnotation} from '../gr-diff-highlight/gr-annotation';
 
 const WHOLE_FILE = -1;
 
@@ -94,15 +93,17 @@
 
   keyLocations: KeyLocations = {left: {}, right: {}};
 
-  private asyncThreshold = 64;
-
-  private nextStepHandle: number | null = null;
-
-  private processPromise: CancelablePromise<void> | null = null;
+  asyncThreshold = 64;
 
   // visible for testing
   isScrolling?: boolean;
 
+  /** Just for making sure that process() is only called once. */
+  private isStarted = false;
+
+  /** Indicates that processing should be stopped. */
+  private isCancelled = false;
+
   private resetIsScrollingTask?: DelayedTask;
 
   private readonly handleWindowScroll = () => {
@@ -122,9 +123,9 @@
    * array of GrDiffGroups when the diff is completely processed.
    */
   process(chunks: DiffContent[], isBinary: boolean) {
-    // Cancel any still running process() calls, because they append to the
-    // same groups field.
-    this.cancel();
+    assert(this.isStarted === false, 'diff processor cannot be started twice');
+    this.isStarted = true;
+
     window.addEventListener('scroll', this.handleWindowScroll);
 
     assertIsDefined(this.consumer, 'consumer');
@@ -132,84 +133,61 @@
     this.consumer.addGroup(this.makeGroup('LOST'));
     this.consumer.addGroup(this.makeGroup(FILE));
 
-    // If it's a binary diff, we won't be rendering hunks of text differences
-    // so finish processing.
-    if (isBinary) {
-      return Promise.resolve();
-    }
+    if (isBinary) return Promise.resolve();
 
-    // TODO: Canceling this promise does not help much. `nextStep` will continue
-    // to be scheduled anyway. So either just remove the cancelable promise, so
-    // future programmers are not fooled about this promise can do. Or fix the
-    // scheduling of `nextStep` such that cancellation is taken into account.
-    // The easiest approach is likely to just not re-use the processor for
-    // multiple processing passes. There is no benefit from doing so.
-    this.processPromise = makeCancelable(
-      new Promise(resolve => {
-        const state = {
-          lineNums: {left: 0, right: 0},
-          chunkIndex: 0,
-        };
+    return new Promise<void>(resolve => {
+      const state = {
+        lineNums: {left: 0, right: 0},
+        chunkIndex: 0,
+      };
 
-        chunks = this.splitLargeChunks(chunks);
-        chunks = this.splitCommonChunksWithKeyLocations(chunks);
+      chunks = this.splitLargeChunks(chunks);
+      chunks = this.splitCommonChunksWithKeyLocations(chunks);
 
-        let currentBatch = 0;
-        const nextStep = () => {
-          if (this.isScrolling) {
-            this.nextStepHandle = window.setTimeout(nextStep, 100);
-            return;
-          }
-          // If we are done, resolve the promise.
-          if (state.chunkIndex >= chunks.length) {
-            resolve();
-            this.nextStepHandle = null;
-            return;
-          }
+      let currentBatch = 0;
+      const nextStep = () => {
+        if (this.isCancelled || state.chunkIndex >= chunks.length) {
+          resolve();
+          return;
+        }
+        if (this.isScrolling) {
+          window.setTimeout(nextStep, 100);
+          return;
+        }
 
-          // Process the next chunk and incorporate the result.
-          const stateUpdate = this.processNext(state, chunks);
-          for (const group of stateUpdate.groups) {
-            assertIsDefined(this.consumer, 'consumer');
-            this.consumer.addGroup(group);
-            currentBatch += group.lines.length;
-          }
-          state.lineNums.left += stateUpdate.lineDelta.left;
-          state.lineNums.right += stateUpdate.lineDelta.right;
+        const stateUpdate = this.processNext(state, chunks);
+        for (const group of stateUpdate.groups) {
+          this.consumer?.addGroup(group);
+          currentBatch += group.lines.length;
+        }
+        state.lineNums.left += stateUpdate.lineDelta.left;
+        state.lineNums.right += stateUpdate.lineDelta.right;
 
-          // Increment the index and recurse.
-          state.chunkIndex = stateUpdate.newChunkIndex;
-          if (currentBatch >= this.asyncThreshold) {
-            currentBatch = 0;
-            this.nextStepHandle = window.setTimeout(nextStep, 1);
-          } else {
-            nextStep.call(this);
-          }
-        };
+        state.chunkIndex = stateUpdate.newChunkIndex;
+        if (currentBatch >= this.asyncThreshold) {
+          currentBatch = 0;
+          window.setTimeout(nextStep, 1);
+        } else {
+          nextStep.call(this);
+        }
+      };
 
-        nextStep.call(this);
-      })
-    );
-    return this.processPromise.finally(() => {
-      this.processPromise = null;
-      window.removeEventListener('scroll', this.handleWindowScroll);
+      nextStep.call(this);
+    }).finally(() => {
+      this.finish();
     });
   }
 
-  /**
-   * Cancel any jobs that are running.
-   */
-  cancel() {
-    if (this.nextStepHandle !== null) {
-      window.clearTimeout(this.nextStepHandle);
-      this.nextStepHandle = null;
-    }
-    if (this.processPromise) {
-      this.processPromise.cancel();
-    }
+  finish() {
+    this.consumer = undefined;
     window.removeEventListener('scroll', this.handleWindowScroll);
   }
 
+  cancel() {
+    this.isCancelled = true;
+    this.finish();
+  }
+
   /**
    * Process the next uncollapsible chunk, or the next collapsible chunks.
    */
@@ -639,16 +617,19 @@
     rows: string[],
     intralineInfos: number[][]
   ): Highlights[] {
+    // +1 to account for the \n that is not part of the rows passed here
+    const lineLengths = rows.map(r => GrAnnotation.getStringLength(r) + 1);
+
     let rowIndex = 0;
     let idx = 0;
     const normalized = [];
     for (const [skipLength, markLength] of intralineInfos) {
-      let line = rows[rowIndex] + '\n';
+      let lineLength = lineLengths[rowIndex];
       let j = 0;
       while (j < skipLength) {
-        if (idx === line.length) {
+        if (idx === lineLength) {
           idx = 0;
-          line = rows[++rowIndex] + '\n';
+          lineLength = lineLengths[++rowIndex];
           continue;
         }
         idx++;
@@ -660,10 +641,10 @@
       };
 
       j = 0;
-      while (line && j < markLength) {
-        if (idx === line.length) {
+      while (lineLength && j < markLength) {
+        if (idx === lineLength) {
           idx = 0;
-          line = rows[++rowIndex] + '\n';
+          lineLength = lineLengths[++rowIndex];
           normalized.push(lineHighlight);
           lineHighlight = {
             contentIndex: rowIndex,
@@ -735,10 +716,4 @@
 
     return this.breakdown(head, size).concat([tail]);
   }
-
-  updateRenderPrefs(renderPrefs: RenderPreferences) {
-    if (renderPrefs.num_lines_rendered_at_once) {
-      this.asyncThreshold = renderPrefs.num_lines_rendered_at_once;
-    }
-  }
 }
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor_test.ts
index 6caeb62..adcfff8 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor_test.ts
@@ -135,7 +135,7 @@
         element.context = 10;
         const content = [
           {
-            ab: new Array(100).fill(
+            ab: Array.from<string>({length: 100}).fill(
               'all work and no play make jack a dull boy'
             ),
           },
@@ -165,9 +165,13 @@
       test('at the beginning with skip chunks', async () => {
         element.context = 10;
         const content = [
-          {ab: new Array(20).fill('all work and no play make jack a dull boy')},
+          {
+            ab: Array.from<string>({length: 20}).fill(
+              'all work and no play make jack a dull boy'
+            ),
+          },
           {skip: 43900},
-          {ab: new Array(30).fill('some other content')},
+          {ab: Array.from<string>({length: 30}).fill('some other content')},
           {a: ['some other content']},
         ];
 
@@ -213,7 +217,11 @@
       test('at the beginning, smaller than context', () => {
         element.context = 10;
         const content = [
-          {ab: new Array(5).fill('all work and no play make jack a dull boy')},
+          {
+            ab: Array.from<string>({length: 5}).fill(
+              'all work and no play make jack a dull boy'
+            ),
+          },
           {a: ['all work and no play make andybons a dull boy']},
         ];
 
@@ -235,7 +243,7 @@
         const content = [
           {a: ['all work and no play make andybons a dull boy']},
           {
-            ab: new Array(100).fill(
+            ab: Array.from<string>({length: 100}).fill(
               'all work and no play make jill a dull girl'
             ),
           },
@@ -266,7 +274,11 @@
         element.context = 10;
         const content = [
           {a: ['all work and no play make andybons a dull boy']},
-          {ab: new Array(5).fill('all work and no play make jill a dull girl')},
+          {
+            ab: Array.from<string>({length: 5}).fill(
+              'all work and no play make jill a dull girl'
+            ),
+          },
         ];
 
         return element.process(content, false).then(() => {
@@ -287,23 +299,39 @@
         element.context = 10;
         const content = [
           {a: ['all work and no play make andybons a dull boy']},
-          {ab: new Array(3).fill('all work and no play make jill a dull girl')},
           {
-            a: new Array(3).fill('all work and no play make jill a dull girl'),
-            b: new Array(3).fill(
+            ab: Array.from<string>({length: 3}).fill(
+              'all work and no play make jill a dull girl'
+            ),
+          },
+          {
+            a: Array.from<string>({length: 3}).fill(
+              'all work and no play make jill a dull girl'
+            ),
+            b: Array.from<string>({length: 3}).fill(
               '  all work and no play make jill a dull girl'
             ),
             common: true,
           },
-          {ab: new Array(3).fill('all work and no play make jill a dull girl')},
           {
-            a: new Array(3).fill('all work and no play make jill a dull girl'),
-            b: new Array(3).fill(
+            ab: Array.from<string>({length: 3}).fill(
+              'all work and no play make jill a dull girl'
+            ),
+          },
+          {
+            a: Array.from<string>({length: 3}).fill(
+              'all work and no play make jill a dull girl'
+            ),
+            b: Array.from<string>({length: 3}).fill(
               '  all work and no play make jill a dull girl'
             ),
             common: true,
           },
-          {ab: new Array(3).fill('all work and no play make jill a dull girl')},
+          {
+            ab: Array.from<string>({length: 3}).fill(
+              'all work and no play make jill a dull girl'
+            ),
+          },
         ];
 
         return element.process(content, false).then(() => {
@@ -387,7 +415,7 @@
         const content = [
           {a: ['all work and no play make andybons a dull boy']},
           {
-            ab: new Array(100).fill(
+            ab: Array.from<string>({length: 100}).fill(
               'all work and no play make jill a dull girl'
             ),
           },
@@ -425,7 +453,11 @@
         element.context = 10;
         const content = [
           {a: ['all work and no play make andybons a dull boy']},
-          {ab: new Array(5).fill('all work and no play make jill a dull girl')},
+          {
+            ab: Array.from<string>({length: 5}).fill(
+              'all work and no play make jill a dull girl'
+            ),
+          },
           {a: ['all work and no play make andybons a dull boy']},
         ];
 
@@ -448,9 +480,17 @@
       element.context = 10;
       const content = [
         {a: ['all work and no play make andybons a dull boy']},
-        {ab: new Array(20).fill('all work and no play make jill a dull girl')},
+        {
+          ab: Array.from<string>({length: 20}).fill(
+            'all work and no play make jill a dull girl'
+          ),
+        },
         {skip: 60},
-        {ab: new Array(20).fill('all work and no play make jill a dull girl')},
+        {
+          ab: Array.from<string>({length: 20}).fill(
+            'all work and no play make jill a dull girl'
+          ),
+        },
         {a: ['all work and no play make andybons a dull boy']},
       ];
 
@@ -723,15 +763,37 @@
           endIndex: 41,
         },
       ]);
+
+      content = ['🙈 a', '🙉 b', '🙊 c'];
+      highlights = [[2, 7]];
+      results = element.convertIntralineInfos(content, highlights);
+      assert.deepEqual(results, [
+        {
+          contentIndex: 0,
+          startIndex: 2,
+        },
+        {
+          contentIndex: 1,
+          startIndex: 0,
+        },
+        {
+          contentIndex: 2,
+          startIndex: 0,
+          endIndex: 1,
+        },
+      ]);
     });
 
-    test('scrolling pauses rendering', () => {
+    test('isScrolling paused', () => {
       const content = Array(200).fill({ab: ['', '']});
       element.isScrolling = true;
       element.process(content, false);
-      // Just the files group - no more processing during scrolling.
+      // Just the FILE and LOST groups.
       assert.equal(groups.length, 2);
+    });
 
+    test('isScrolling unpaused', () => {
+      const content = Array(200).fill({ab: ['', '']});
       element.isScrolling = false;
       element.process(content, false);
       // More groups have been processed. How many does not matter here.
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-selection/gr-diff-selection.ts b/polygerrit-ui/app/embed/diff/gr-diff-selection/gr-diff-selection.ts
index 4bb8cc3..a790736 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-selection/gr-diff-selection.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-selection/gr-diff-selection.ts
@@ -4,11 +4,12 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import '../../../styles/shared-styles';
+import {normalize} from '../gr-diff-highlight/gr-range-normalizer';
 import {
-  normalize,
-  NormalizedRange,
-} from '../gr-diff-highlight/gr-range-normalizer';
-import {descendedFromClass, querySelectorAll} from '../../../utils/dom-util';
+  descendedFromClass,
+  parentWithClass,
+  querySelectorAll,
+} from '../../../utils/dom-util';
 import {DiffInfo} from '../../../types/diff';
 import {Side} from '../../../constants/constants';
 import {
@@ -17,7 +18,6 @@
   getSideByLineEl,
   isThreadEl,
 } from '../gr-diff/gr-diff-utils';
-import {assertIsDefined} from '../../../utils/common-util';
 
 /**
  * Possible CSS classes indicating the state of selection. Dynamically added/
@@ -30,6 +30,10 @@
   BLAME: 'selected-blame',
 };
 
+function selectionClassForSide(side?: Side) {
+  return side === Side.LEFT ? SelectionClass.LEFT : SelectionClass.RIGHT;
+}
+
 interface LinesCache {
   left: string[] | null;
   right: string[] | null;
@@ -65,52 +69,31 @@
     this.diffTable.removeEventListener('mousedown', this.handleDown);
   }
 
-  handleDownOnRangeComment(node: Element) {
-    if (isThreadEl(node)) {
-      this.setClasses([
-        SelectionClass.COMMENT,
-        getSide(node) === Side.LEFT
-          ? SelectionClass.LEFT
-          : SelectionClass.RIGHT,
-      ]);
-      return true;
-    }
-    return false;
-  }
-
   handleDown = (e: Event) => {
     const target = e.target;
     if (!(target instanceof Element)) return;
-    const handled = this.handleDownOnRangeComment(target);
-    if (handled) return;
-    const lineEl = getLineElByChild(target);
-    const blameSelected = descendedFromClass(target, 'blame', this.diffTable);
-    if (!lineEl && !blameSelected) {
+
+    const commentEl = parentWithClass(target, 'comment-thread', this.diffTable);
+    if (commentEl && isThreadEl(commentEl)) {
+      this.setClasses([
+        SelectionClass.COMMENT,
+        selectionClassForSide(getSide(commentEl)),
+      ]);
       return;
     }
 
-    const targetClasses = [];
-
+    const blameSelected = descendedFromClass(target, 'blame', this.diffTable);
     if (blameSelected) {
-      targetClasses.push(SelectionClass.BLAME);
-    } else if (lineEl) {
-      const commentSelected = descendedFromClass(
-        target,
-        'gr-comment',
-        this.diffTable
-      );
-      const side = getSideByLineEl(lineEl);
-
-      targetClasses.push(
-        side === 'left' ? SelectionClass.LEFT : SelectionClass.RIGHT
-      );
-
-      if (commentSelected) {
-        targetClasses.push(SelectionClass.COMMENT);
-      }
+      this.setClasses([SelectionClass.BLAME]);
+      return;
     }
 
-    this.setClasses(targetClasses);
+    // This works for both, the content and the line number cells.
+    const lineEl = getLineElByChild(target);
+    if (lineEl) {
+      this.setClasses([selectionClassForSide(getSideByLineEl(lineEl))]);
+      return;
+    }
   };
 
   /**
@@ -134,19 +117,17 @@
   }
 
   handleCopy = (e: ClipboardEvent) => {
-    let commentSelected = false;
     const target = e.composedPath()[0];
     if (!(target instanceof Element)) return;
     if (target instanceof HTMLTextAreaElement) return;
     if (!descendedFromClass(target, 'diff-row', this.diffTable)) return;
     if (!this.diffTable) return;
-    if (this.diffTable.classList.contains(SelectionClass.COMMENT)) {
-      commentSelected = true;
-    }
+    if (this.diffTable.classList.contains(SelectionClass.COMMENT)) return;
+
     const lineEl = getLineElByChild(target);
     if (!lineEl) return;
     const side = getSideByLineEl(lineEl);
-    const text = this.getSelectedText(side, commentSelected);
+    const text = this.getSelectedText(side);
     if (text && e.clipboardData) {
       e.clipboardData.setData('Text', text);
       e.preventDefault();
@@ -179,14 +160,11 @@
    * @param commentSelected Whether or not a comment is selected.
    * @return The selected text.
    */
-  getSelectedText(side: Side, commentSelected: boolean) {
+  getSelectedText(side: Side) {
     const sel = this.getSelection();
     if (!sel || sel.rangeCount !== 1) {
       return ''; // No multi-select support yet.
     }
-    if (commentSelected) {
-      return this.getCommentLines(sel, side);
-    }
     const range = normalize(sel.getRangeAt(0));
     const startLineEl = getLineElByChild(range.startContainer);
     if (!startLineEl) return;
@@ -266,82 +244,4 @@
     this.linesCache[side] = lines;
     return lines;
   }
-
-  /**
-   * Query the diffElement for comments and check whether they lie inside the
-   * selection range.
-   *
-   * @param sel The selection of the window.
-   * @param side The side that is currently selected.
-   * @return The selected comment text.
-   */
-  getCommentLines(sel: Selection, side: Side) {
-    const range = normalize(sel.getRangeAt(0));
-    const content = [];
-    assertIsDefined(this.diffTable, 'diffTable');
-    const messages = this.diffTable.querySelectorAll(
-      `.side-by-side [data-side="${side}"] .message *, .unified .message *`
-    );
-
-    for (let i = 0; i < messages.length; i++) {
-      const el = messages[i];
-      // Check if the comment element exists inside the selection.
-      if (sel.containsNode(el, true)) {
-        // Padded elements require newlines for accurate spacing.
-        if (
-          el.parentElement!.id === 'container' ||
-          el.parentElement!.nodeName === 'BLOCKQUOTE'
-        ) {
-          if (content.length && content[content.length - 1] !== '') {
-            content.push('');
-          }
-        }
-
-        if (
-          el.id === 'output' &&
-          !descendedFromClass(el, 'collapsed', this.diffTable)
-        ) {
-          content.push(this.getTextContentForRange(el, sel, range));
-        }
-      }
-    }
-
-    return content.join('\n');
-  }
-
-  /**
-   * Given a DOM node, a selection, and a selection range, recursively get all
-   * of the text content within that selection.
-   * Using a domNode that isn't in the selection returns an empty string.
-   *
-   * @param domNode The root DOM node.
-   * @param sel The selection.
-   * @param range The normalized selection range.
-   * @return The text within the selection.
-   */
-  getTextContentForRange(
-    domNode: Node,
-    sel: Selection,
-    range: NormalizedRange
-  ) {
-    if (!sel.containsNode(domNode, true)) {
-      return '';
-    }
-
-    let text = '';
-    if (domNode instanceof Text) {
-      text = domNode.textContent || '';
-      if (domNode === range.endContainer) {
-        text = text.substring(0, range.endOffset);
-      }
-      if (domNode === range.startContainer) {
-        text = text.substring(range.startOffset);
-      }
-    } else {
-      for (const childNode of domNode.childNodes) {
-        text += this.getTextContentForRange(childNode, sel, range);
-      }
-    }
-    return text;
-  }
 }
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-selection/gr-diff-selection_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-selection/gr-diff-selection_test.ts
index 8acaf04..f216e04 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-selection/gr-diff-selection_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-selection/gr-diff-selection_test.ts
@@ -5,96 +5,25 @@
  */
 import '../../../test/common-test-setup';
 import './gr-diff-selection';
+import '../gr-diff/gr-diff';
+import '../../../elements/shared/gr-comment-thread/gr-comment-thread';
 import {GrDiffSelection} from './gr-diff-selection';
 import {createDiff} from '../../../test/test-data-generators';
 import {DiffInfo, Side} from '../../../api/diff';
-import {GrFormattedText} from '../../../elements/shared/gr-formatted-text/gr-formatted-text';
 import {fixture, html, assert} from '@open-wc/testing';
 import {mouseDown} from '../../../test/test-utils';
+import {GrDiff} from '../gr-diff/gr-diff';
+import {waitForEventOnce} from '../../../utils/event-util';
+import {createDefaultDiffPrefs} from '../../../constants/constants';
 
-const diffTableTemplate = html`
-  <table id="diffTable" class="side-by-side">
-    <tr class="diff-row">
-      <td class="blame" data-line-number="1"></td>
-      <td class="lineNum left" data-value="1">1</td>
-      <td class="content">
-        <div class="contentText" data-side="left">ba ba</div>
-        <div data-side="left">
-          <div class="comment-thread">
-            <div class="gr-formatted-text message">
-              <span id="output" class="gr-formatted-text"
-                >This is a comment</span
-              >
-            </div>
-          </div>
-        </div>
-      </td>
-      <td class="lineNum right" data-value="1">1</td>
-      <td class="content">
-        <div class="contentText" data-side="right">some other text</div>
-      </td>
-    </tr>
-    <tr class="diff-row">
-      <td class="blame" data-line-number="2"></td>
-      <td class="lineNum left" data-value="2">2</td>
-      <td class="content">
-        <div class="contentText" data-side="left">zin</div>
-      </td>
-      <td class="lineNum right" data-value="2">2</td>
-      <td class="content">
-        <div class="contentText" data-side="right">more more more</div>
-        <div data-side="right">
-          <div class="comment-thread">
-            <div class="gr-formatted-text message">
-              <span id="output" class="gr-formatted-text"
-                >This is a comment on the right</span
-              >
-            </div>
-          </div>
-        </div>
-      </td>
-    </tr>
-    <tr class="diff-row">
-      <td class="blame" data-line-number="3"></td>
-      <td class="lineNum left" data-value="3">3</td>
-      <td class="content">
-        <div class="contentText" data-side="left">ga ga</div>
-        <div data-side="left">
-          <div class="comment-thread">
-            <div class="gr-formatted-text message">
-              <span id="output" class="gr-formatted-text"
-                >This is <a>a</a> different comment 💩 unicode is fun</span
-              >
-            </div>
-          </div>
-        </div>
-      </td>
-      <td class="lineNum right" data-value="3">3</td>
-    </tr>
-    <tr class="diff-row">
-      <td class="blame" data-line-number="4"></td>
-      <td class="lineNum left" data-value="4">4</td>
-      <td class="content">
-        <div class="contentText" data-side="left">ga ga</div>
-        <div data-side="left">
-          <div class="comment-thread">
-            <textarea data-side="right">test for textarea copying</textarea>
-          </div>
-        </div>
-      </td>
-      <td class="lineNum right" data-value="4">4</td>
-    </tr>
-    <tr class="not-diff-row">
-      <td class="other">
-        <div class="contentText" data-side="right">some other text</div>
-      </td>
-    </tr>
-  </table>
-`;
+function firstTextNode(el: HTMLElement) {
+  return [...el.childNodes].filter(node => node.nodeType === Node.TEXT_NODE)[0];
+}
 
 suite('gr-diff-selection', () => {
   let element: GrDiffSelection;
-  let diffTable: HTMLTableElement;
+  let diffTable: HTMLElement;
+  let grDiff: GrDiff;
 
   const emulateCopyOn = function (target: HTMLElement | null) {
     const fakeEvent = {
@@ -112,8 +41,8 @@
   };
 
   setup(async () => {
-    element = new GrDiffSelection();
-    diffTable = await fixture<HTMLTableElement>(diffTableTemplate);
+    grDiff = await fixture<GrDiff>(html`<gr-diff></gr-diff>`);
+    element = grDiff.diffSelection;
 
     const diff: DiffInfo = {
       ...createDiff(),
@@ -132,51 +61,55 @@
         },
       ],
     };
-    element.init(diff, diffTable);
+    grDiff.prefs = createDefaultDiffPrefs();
+    grDiff.diff = diff;
+    await waitForEventOnce(grDiff, 'render');
+    assert.isOk(element.diffTable);
+    diffTable = element.diffTable!;
   });
 
   test('applies selected-left on left side click', () => {
-    element.diffTable!.classList.add('selected-right');
+    diffTable.classList.add('selected-right');
     const lineNumberEl = diffTable.querySelector<HTMLElement>('.lineNum.left');
     if (!lineNumberEl) assert.fail('line number element missing');
     mouseDown(lineNumberEl);
     assert.isTrue(
-      element.diffTable!.classList.contains('selected-left'),
+      diffTable.classList.contains('selected-left'),
       'adds selected-left'
     );
     assert.isFalse(
-      element.diffTable!.classList.contains('selected-right'),
+      diffTable.classList.contains('selected-right'),
       'removes selected-right'
     );
   });
 
   test('applies selected-right on right side click', () => {
-    element.diffTable!.classList.add('selected-left');
+    diffTable.classList.add('selected-left');
     const lineNumberEl = diffTable.querySelector<HTMLElement>('.lineNum.right');
     if (!lineNumberEl) assert.fail('line number element missing');
     mouseDown(lineNumberEl);
     assert.isTrue(
-      element.diffTable!.classList.contains('selected-right'),
+      diffTable.classList.contains('selected-right'),
       'adds selected-right'
     );
     assert.isFalse(
-      element.diffTable!.classList.contains('selected-left'),
+      diffTable.classList.contains('selected-left'),
       'removes selected-left'
     );
   });
 
   test('applies selected-blame on blame click', () => {
-    element.diffTable!.classList.add('selected-left');
+    diffTable.classList.add('selected-left');
     const blameDiv = document.createElement('div');
     blameDiv.classList.add('blame');
-    element.diffTable!.appendChild(blameDiv);
+    diffTable.appendChild(blameDiv);
     mouseDown(blameDiv);
     assert.isTrue(
-      element.diffTable!.classList.contains('selected-blame'),
+      diffTable.classList.contains('selected-blame'),
       'adds selected-right'
     );
     assert.isFalse(
-      element.diffTable!.classList.contains('selected-left'),
+      diffTable.classList.contains('selected-left'),
       'removes selected-left'
     );
   });
@@ -190,7 +123,7 @@
   test('asks for text for left side Elements', () => {
     const getSelectedTextStub = sinon.stub(element, 'getSelectedText');
     emulateCopyOn(diffTable.querySelector('div.contentText'));
-    assert.deepEqual([Side.LEFT, false], getSelectedTextStub.lastCall.args);
+    assert.deepEqual([Side.LEFT], getSelectedTextStub.lastCall.args);
   });
 
   test('reacts to copy for content Elements', () => {
@@ -216,25 +149,25 @@
   });
 
   test('setClasses adds given SelectionClass values, removes others', () => {
-    element.diffTable!.classList.add('selected-right');
+    diffTable.classList.add('selected-right');
     element.setClasses(['selected-comment', 'selected-left']);
-    assert.isTrue(element.diffTable!.classList.contains('selected-comment'));
-    assert.isTrue(element.diffTable!.classList.contains('selected-left'));
-    assert.isFalse(element.diffTable!.classList.contains('selected-right'));
-    assert.isFalse(element.diffTable!.classList.contains('selected-blame'));
+    assert.isTrue(diffTable.classList.contains('selected-comment'));
+    assert.isTrue(diffTable.classList.contains('selected-left'));
+    assert.isFalse(diffTable.classList.contains('selected-right'));
+    assert.isFalse(diffTable.classList.contains('selected-blame'));
 
     element.setClasses(['selected-blame']);
-    assert.isFalse(element.diffTable!.classList.contains('selected-comment'));
-    assert.isFalse(element.diffTable!.classList.contains('selected-left'));
-    assert.isFalse(element.diffTable!.classList.contains('selected-right'));
-    assert.isTrue(element.diffTable!.classList.contains('selected-blame'));
+    assert.isFalse(diffTable.classList.contains('selected-comment'));
+    assert.isFalse(diffTable.classList.contains('selected-left'));
+    assert.isFalse(diffTable.classList.contains('selected-right'));
+    assert.isTrue(diffTable.classList.contains('selected-blame'));
   });
 
   test('setClasses removes before it ads', () => {
-    element.diffTable!.classList.add('selected-right');
-    const addStub = sinon.stub(element.diffTable!.classList, 'add');
+    diffTable.classList.add('selected-right');
+    const addStub = sinon.stub(diffTable.classList, 'add');
     const removeStub = sinon
-      .stub(element.diffTable!.classList, 'remove')
+      .stub(diffTable.classList, 'remove')
       .callsFake(() => {
         assert.isFalse(addStub.called);
       });
@@ -244,149 +177,43 @@
   });
 
   test('copies content correctly', () => {
-    element.diffTable!.classList.add('selected-left');
-    element.diffTable!.classList.remove('selected-right');
+    diffTable.classList.add('selected-left');
+    diffTable.classList.remove('selected-right');
 
     const selection = document.getSelection();
     if (selection === null) assert.fail('no selection');
     selection.removeAllRanges();
     const range = document.createRange();
-    range.setStart(diffTable.querySelector('div.contentText')!.firstChild!, 3);
-    range.setEnd(
-      diffTable.querySelectorAll('div.contentText')[4]!.firstChild!,
-      2
-    );
+    const texts = diffTable.querySelectorAll<HTMLElement>('gr-diff-text');
+    range.setStart(firstTextNode(texts[0]), 3);
+    range.setEnd(firstTextNode(texts[4]), 2);
     selection.addRange(range);
-    assert.equal(element.getSelectedText(Side.LEFT, false), 'ba\nzin\nga');
-  });
 
-  test('copies comments', () => {
-    element.diffTable!.classList.add('selected-left');
-    element.diffTable!.classList.add('selected-comment');
-    element.diffTable!.classList.remove('selected-right');
-    const selection = document.getSelection();
-    if (selection === null) assert.fail('no selection');
-    selection.removeAllRanges();
-    const range = document.createRange();
-    range.setStart(
-      diffTable.querySelector('.gr-formatted-text *')!.firstChild!,
-      3
-    );
-    range.setEnd(
-      diffTable.querySelectorAll('.gr-formatted-text *')[2].childNodes[2],
-      7
-    );
-    selection.addRange(range);
-    assert.equal(
-      's is a comment\nThis is a differ',
-      element.getSelectedText(Side.LEFT, true)
-    );
-  });
-
-  test('respects astral chars in comments', () => {
-    element.diffTable!.classList.add('selected-left');
-    element.diffTable!.classList.add('selected-comment');
-    element.diffTable!.classList.remove('selected-right');
-    const selection = document.getSelection();
-    if (selection === null) assert.fail('no selection');
-    selection.removeAllRanges();
-    const range = document.createRange();
-    const nodes = diffTable.querySelectorAll('.gr-formatted-text *');
-    range.setStart(nodes[2].childNodes[2], 13);
-    range.setEnd(nodes[2].childNodes[2], 23);
-    selection.addRange(range);
-    assert.equal('mment 💩 u', element.getSelectedText(Side.LEFT, true));
+    assert.equal(element.getSelectedText(Side.LEFT), 'ba\nzin\nga');
   });
 
   test('defers to default behavior for textarea', () => {
-    element.diffTable!.classList.add('selected-left');
-    element.diffTable!.classList.remove('selected-right');
+    diffTable.classList.add('selected-left');
+    diffTable.classList.remove('selected-right');
     const selectedTextSpy = sinon.spy(element, 'getSelectedText');
     emulateCopyOn(diffTable.querySelector('textarea'));
+
     assert.isFalse(selectedTextSpy.called);
   });
 
   test('regression test for 4794', () => {
-    element.diffTable!.classList.add('selected-right');
-    element.diffTable!.classList.remove('selected-left');
+    diffTable.classList.add('selected-right');
+    diffTable.classList.remove('selected-left');
 
     const selection = document.getSelection();
     if (!selection) assert.fail('no selection');
     selection.removeAllRanges();
     const range = document.createRange();
-    range.setStart(
-      diffTable.querySelectorAll('div.contentText')[1]!.firstChild!,
-      4
-    );
-    range.setEnd(
-      diffTable.querySelectorAll('div.contentText')[1]!.firstChild!,
-      10
-    );
+    const texts = diffTable.querySelectorAll<HTMLElement>('gr-diff-text');
+    range.setStart(firstTextNode(texts[1]), 4);
+    range.setEnd(firstTextNode(texts[1]), 10);
     selection.addRange(range);
-    assert.equal(element.getSelectedText(Side.RIGHT, false), ' other');
-  });
 
-  test('copies to end of side (issue 7895)', () => {
-    element.diffTable!.classList.add('selected-left');
-    element.diffTable!.classList.remove('selected-right');
-    const selection = document.getSelection();
-    if (selection === null) assert.fail('no selection');
-    selection.removeAllRanges();
-    const range = document.createRange();
-    range.setStart(diffTable.querySelector('div.contentText')!.firstChild!, 3);
-    range.setEnd(
-      diffTable.querySelectorAll('div.contentText')[4]!.firstChild!,
-      2
-    );
-    selection.addRange(range);
-    assert.equal(element.getSelectedText(Side.LEFT, false), 'ba\nzin\nga');
-  });
-
-  suite('getTextContentForRange', () => {
-    let selection: Selection;
-    let range: Range;
-    let nodes: NodeListOf<GrFormattedText>;
-
-    setup(() => {
-      element.diffTable!.classList.add('selected-left');
-      element.diffTable!.classList.add('selected-comment');
-      element.diffTable!.classList.remove('selected-right');
-      const s = document.getSelection();
-      if (s === null) assert.fail('no selection');
-      selection = s;
-      selection.removeAllRanges();
-      range = document.createRange();
-      nodes = diffTable.querySelectorAll('.gr-formatted-text *');
-    });
-
-    test('multi level element contained in range', () => {
-      range.setStart(nodes[2].childNodes[0], 1);
-      range.setEnd(nodes[2].childNodes[2], 7);
-      selection.addRange(range);
-      assert.equal(
-        element.getTextContentForRange(diffTable, selection, range),
-        'his is a differ'
-      );
-    });
-
-    test('multi level element as startContainer of range', () => {
-      range.setStart(nodes[2].childNodes[1], 0);
-      range.setEnd(nodes[2].childNodes[2], 7);
-      selection.addRange(range);
-      assert.equal(
-        element.getTextContentForRange(diffTable, selection, range),
-        'a differ'
-      );
-    });
-
-    test('startContainer === endContainer', () => {
-      range.setStart(nodes[0].firstChild!, 2);
-      range.setEnd(nodes[0].firstChild!, 12);
-      selection.addRange(range);
-      assert.equal(
-        element.getTextContentForRange(diffTable, selection, range),
-        'is is a co'
-      );
-    });
+    assert.equal(element.getSelectedText(Side.RIGHT), ' other');
   });
 });
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group.ts
index 5a34ae3..6d80d78 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
@@ -8,6 +8,8 @@
 import {LineNumber} from './gr-diff-line';
 import {assertIsDefined, assert} from '../../../utils/common-util';
 import {untilRendered} from '../../../utils/dom-util';
+import {isDefined} from '../../../types/types';
+import {LitElement} from 'lit';
 
 export enum GrDiffGroupType {
   /** Unchanged context. */
@@ -65,7 +67,7 @@
   // because then that row would consume as much space as the collapsed code.
   if (numHidden > 3) {
     if (hiddenStart) {
-      [before, hidden] = _splitCommonGroups(hidden, hiddenStart);
+      [before, hidden] = splitCommonGroups(hidden, hiddenStart);
     }
     if (hiddenEnd) {
       let beforeLength = 0;
@@ -74,7 +76,7 @@
         const beforeEnd = before[before.length - 1].lineRange.left.end_line;
         beforeLength = beforeEnd - beforeStart + 1;
       }
-      [hidden, after] = _splitCommonGroups(hidden, hiddenEnd - beforeLength);
+      [hidden, after] = splitCommonGroups(hidden, hiddenEnd - beforeLength);
     }
   } else {
     [hidden, after] = [[], hidden];
@@ -95,7 +97,7 @@
 
 /**
  * Splits a group in two, defined by leftSplit and rightSplit. Primarily to be
- * used in function _splitCommonGroups
+ * used in function splitCommonGroups
  * Groups with some lines before and some lines after the split will be split
  * into two groups, which will be put into the first and second list.
  *
@@ -104,7 +106,7 @@
  * @param rightSplit The line number relative to the split on the right side
  * @return two new groups, one before the split and another after it
  */
-function _splitGroupInTwo(
+function splitGroupInTwo(
   group: GrDiffGroup,
   leftSplit: number,
   rightSplit: number
@@ -130,8 +132,14 @@
     const after = [];
     for (const line of group.lines) {
       if (
-        (line.beforeNumber && line.beforeNumber < leftSplit) ||
-        (line.afterNumber && line.afterNumber < rightSplit)
+        (line.beforeNumber &&
+          line.beforeNumber !== 'FILE' &&
+          line.beforeNumber !== 'LOST' &&
+          line.beforeNumber < leftSplit) ||
+        (line.afterNumber &&
+          line.afterNumber !== 'FILE' &&
+          line.afterNumber !== 'LOST' &&
+          line.afterNumber < rightSplit)
       ) {
         before.push(line);
       } else {
@@ -167,7 +175,7 @@
  * @return The outer array has 2 elements, the
  *   list of groups before and the list of groups after the split.
  */
-function _splitCommonGroups(
+function splitCommonGroups(
   groups: readonly GrDiffGroup[],
   split: number
 ): GrDiffGroup[][] {
@@ -189,7 +197,7 @@
     } else if (isCompletelyAfter) {
       afterGroups.push(group);
     } else {
-      const {beforeSplit, afterSplit} = _splitGroupInTwo(
+      const {beforeSplit, afterSplit} = splitGroupInTwo(
         group,
         leftSplit,
         rightSplit
@@ -385,10 +393,7 @@
       this.type === GrDiffGroupType.CONTEXT_CONTROL
     ) {
       return this.lines.map(line => {
-        return {
-          left: line,
-          right: line,
-        };
+        return {left: line, right: line};
       });
     }
 
@@ -406,9 +411,27 @@
     return pairs;
   }
 
+  getUnifiedPairs(): GrDiffLinePair[] {
+    return this.lines
+      .map(line => {
+        if (line.type === GrDiffLineType.ADD) {
+          return {left: BLANK_LINE, right: line};
+        }
+        if (line.type === GrDiffLineType.REMOVE) {
+          if (this.ignoredWhitespaceOnly) return undefined;
+          return {left: line, right: BLANK_LINE};
+        }
+        return {left: line, right: line};
+      })
+      .filter(isDefined);
+  }
+
   /** Returns true if it is, or contains, a skip group. */
   hasSkipGroup() {
-    return !!this.skip || this.contextGroups?.some(g => !!g.skip);
+    return (
+      this.skip !== undefined ||
+      this.contextGroups?.some(g => g.skip !== undefined)
+    );
   }
 
   containsLine(side: Side, line: LineNumber) {
@@ -420,6 +443,24 @@
     return lineRange.start_line <= line && line <= lineRange.end_line;
   }
 
+  startLine(side: Side): LineNumber {
+    // For both CONTEXT_CONTROL groups and SKIP groups the `lines` array will
+    // be empty. So we have to use `lineRange` instead of looking at the first
+    // line.
+    if (
+      this.type === GrDiffGroupType.CONTEXT_CONTROL ||
+      this.skip !== undefined
+    ) {
+      return side === Side.LEFT
+        ? this.lineRange.left.start_line
+        : this.lineRange.right.start_line;
+    }
+    // For "normal" groups we could also use the `lineRange`, but for FILE or
+    // LOST lines we want to return FILE or LOST. The `lineRange` contains
+    // numbers only.
+    return this.lines[0].lineNumber(side);
+  }
+
   private _updateRangeWithNewLine(line: GrDiffLine) {
     if (
       line.beforeNumber === 'FILE' ||
@@ -463,7 +504,7 @@
     // The LOST or FILE lines may be hidden and thus never resolve an
     // untilRendered() promise.
     if (
-      this.skip ||
+      this.skip !== undefined ||
       lineNumber === 'LOST' ||
       lineNumber === 'FILE' ||
       this.type === GrDiffGroupType.CONTEXT_CONTROL
@@ -471,7 +512,8 @@
       return Promise.resolve();
     }
     assertIsDefined(this.element);
-    await untilRendered(this.element);
+    await (this.element as LitElement).updateComplete;
+    await untilRendered(this.element.firstElementChild as HTMLElement);
   }
 
   /**
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group_test.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group_test.ts
index 71e2e71d..7ead68f 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group_test.ts
@@ -11,6 +11,7 @@
   hideInContextControl,
 } from './gr-diff-group';
 import {assert} from '@open-wc/testing';
+import {Side} from '../../../api/diff';
 
 suite('gr-diff-group tests', () => {
   test('delta line pairs', () => {
@@ -252,4 +253,62 @@
       assert.isFalse(group.isTotal());
     });
   });
+
+  suite('startLine', () => {
+    test('DELTA', () => {
+      const lines: GrDiffLine[] = [];
+      lines.push(new GrDiffLine(GrDiffLineType.BOTH, 3, 4));
+      const group = new GrDiffGroup({type: GrDiffGroupType.DELTA, lines});
+      assert.equal(group.startLine(Side.LEFT), 3);
+      assert.equal(group.startLine(Side.RIGHT), 4);
+    });
+
+    test('CONTEXT CONTROL', () => {
+      const lines: GrDiffLine[] = [];
+      lines.push(new GrDiffLine(GrDiffLineType.BOTH, 3, 4));
+      const delta = new GrDiffGroup({type: GrDiffGroupType.DELTA, lines});
+      const group = new GrDiffGroup({
+        type: GrDiffGroupType.CONTEXT_CONTROL,
+        contextGroups: [delta],
+      });
+      assert.equal(group.startLine(Side.LEFT), 3);
+      assert.equal(group.startLine(Side.RIGHT), 4);
+    });
+
+    test('SKIP', () => {
+      const group = new GrDiffGroup({
+        type: GrDiffGroupType.BOTH,
+        skip: 10,
+        offsetLeft: 3,
+        offsetRight: 6,
+      });
+      assert.equal(group.startLine(Side.LEFT), 3);
+      assert.equal(group.startLine(Side.RIGHT), 6);
+
+      const group2 = new GrDiffGroup({
+        type: GrDiffGroupType.BOTH,
+        skip: 0,
+        offsetLeft: 3,
+        offsetRight: 6,
+      });
+      assert.equal(group2.startLine(Side.LEFT), 3);
+      assert.equal(group2.startLine(Side.RIGHT), 6);
+    });
+
+    test('FILE', () => {
+      const lines: GrDiffLine[] = [];
+      lines.push(new GrDiffLine(GrDiffLineType.BOTH, 'FILE', 'FILE'));
+      const group = new GrDiffGroup({type: GrDiffGroupType.DELTA, lines});
+      assert.equal(group.startLine(Side.LEFT), 'FILE');
+      assert.equal(group.startLine(Side.RIGHT), 'FILE');
+    });
+
+    test('LOST', () => {
+      const lines: GrDiffLine[] = [];
+      lines.push(new GrDiffLine(GrDiffLineType.BOTH, 'LOST', 'LOST'));
+      const group = new GrDiffGroup({type: GrDiffGroupType.DELTA, lines});
+      assert.equal(group.startLine(Side.LEFT), 'LOST');
+      assert.equal(group.startLine(Side.RIGHT), 'LOST');
+    });
+  });
 });
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-line.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-line.ts
index 7ca4a03..338a275 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-line.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-line.ts
@@ -7,6 +7,7 @@
   GrDiffLine as GrDiffLineApi,
   GrDiffLineType,
   LineNumber,
+  Side,
 } from '../../../api/diff';
 
 export {GrDiffLineType};
@@ -27,6 +28,10 @@
 
   text = '';
 
+  lineNumber(side: Side) {
+    return side === Side.LEFT ? this.beforeNumber : this.afterNumber;
+  }
+
   // TODO(TS): remove this properties
   static readonly Type = GrDiffLineType;
 
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-styles.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-styles.ts
new file mode 100644
index 0000000..e7f4b51
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-styles.ts
@@ -0,0 +1,671 @@
+/**
+ * @license
+ * Copyright 2023 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {css} from 'lit';
+
+export const grDiffStyles = css`
+  /* This is used to hide all left side of the diff (e.g. diffs besides
+     comments in the change log). Since we want to remove the first 4
+     cells consistently in all rows except context buttons (.dividerRow). */
+  :host(.no-left) .sideBySide colgroup col:nth-child(-n + 4),
+  :host(.no-left) .sideBySide tr:not(.dividerRow) td:nth-child(-n + 4) {
+    display: none;
+  }
+  :host(.disable-context-control-buttons) {
+    --context-control-display: none;
+  }
+  :host(.disable-context-control-buttons) .section {
+    border-right: none;
+  }
+  :host(.hide-line-length-indicator) .full-width td.content .contentText {
+    background-image: none;
+  }
+
+  :host {
+    font-family: var(--monospace-font-family, ''), 'Roboto Mono';
+    font-size: var(--font-size, var(--font-size-code, 12px));
+    /* usually 16px = 12px + 4px */
+    line-height: calc(
+      var(--font-size, var(--font-size-code, 12px)) + var(--spacing-s, 4px)
+    );
+  }
+
+  .thread-group {
+    display: block;
+    max-width: var(--content-width, 80ch);
+    white-space: normal;
+    background-color: var(--diff-blank-background-color);
+  }
+  .diffContainer {
+    max-width: var(--diff-max-width, none);
+    font-family: var(--monospace-font-family);
+  }
+  table {
+    border-collapse: collapse;
+    table-layout: fixed;
+  }
+  td.lineNum {
+    /* Enforces background whenever lines wrap */
+    background-color: var(--diff-blank-background-color);
+  }
+
+  /* Provides the option to add side borders (left and right) to the line
+     number column. */
+  td.lineNum,
+  td.blankLineNum,
+  td.moveControlsLineNumCol,
+  td.contextLineNum {
+    box-shadow: var(--line-number-box-shadow, unset);
+  }
+
+  /* Context controls break up the table visually, so we set the right
+     border on individual sections to leave a gap for the divider.
+
+     Also taken into account for max-width calculations in SHRINK_ONLY mode
+     (check GrDiff.updatePreferenceStyles). */
+  .section {
+    border-right: 1px solid var(--border-color);
+  }
+  .section.contextControl {
+    /* Divider inside this section must not have border; we set borders on
+       the padding rows below. */
+    border-right-width: 0;
+  }
+  /* Padding rows behind context controls. The diff is styled to be cut
+     into two halves by the negative space of the divider on which the
+     context control buttons are anchored. */
+  .contextBackground {
+    border-right: 1px solid var(--border-color);
+  }
+  .contextBackground.above {
+    border-bottom: 1px solid var(--border-color);
+  }
+  .contextBackground.below {
+    border-top: 1px solid var(--border-color);
+  }
+
+  .lineNumButton {
+    display: block;
+    width: 100%;
+    height: 100%;
+    background-color: var(--diff-blank-background-color);
+    box-shadow: var(--line-number-box-shadow, unset);
+  }
+  td.lineNum {
+    vertical-align: top;
+  }
+
+  /* The only way to focus this (clicking) will apply our own focus
+     styling, so this default styling is not needed and distracting. */
+  .lineNumButton:focus {
+    outline: none;
+  }
+  gr-image-viewer {
+    width: 100%;
+    height: 100%;
+    max-width: var(--image-viewer-max-width, 95vw);
+    max-height: var(--image-viewer-max-height, 90vh);
+    /* Defined by paper-styles default-theme and used in various
+       components. background-color-secondary is a compromise between
+       fairly light in light theme (where we ideally would want
+       background-color-primary) yet slightly offset against the app
+       background in dark mode, where drop shadows e.g. around paper-card
+       are almost invisible. */
+    --primary-background-color: var(--background-color-secondary);
+  }
+  .image-diff .gr-diff {
+    text-align: center;
+  }
+  .image-diff img {
+    box-shadow: var(--elevation-level-1);
+    max-width: 50em;
+  }
+  .image-diff .right.lineNumButton {
+    border-left: 1px solid var(--border-color);
+  }
+  .image-diff label {
+    font-family: var(--font-family);
+    font-style: italic;
+  }
+  tbody.binary-diff td {
+    font-family: var(--font-family);
+    font-style: italic;
+    text-align: center;
+    padding: var(--spacing-s) 0;
+  }
+  .diff-row {
+    outline: none;
+    user-select: none;
+  }
+  .diff-row.target-row.target-side-left .lineNumButton.left,
+  .diff-row.target-row.target-side-right .lineNumButton.right,
+  .diff-row.target-row.unified .lineNumButton {
+    color: var(--primary-text-color);
+  }
+
+  /* Preparing selected line cells with position relative so it allows a
+     positioned overlay with 'position: absolute'. */
+  .target-row td {
+    position: relative;
+  }
+
+  /* Defines an overlay to the selected line for drawing an outline without
+     blocking user interaction (e.g. text selection). */
+  .target-row td::before {
+    border-width: 0;
+    border-style: solid;
+    border-color: var(--focused-line-outline-color);
+    position: absolute;
+    top: 0;
+    left: 0;
+    width: 100%;
+    height: 100%;
+    pointer-events: none;
+    user-select: none;
+    content: ' ';
+  }
+
+  /* The outline for the selected content cell should be the same in all
+     cases. */
+  .target-row.target-side-left td.left.content::before,
+  .target-row.target-side-right td.right.content::before,
+  .unified.target-row td.content::before {
+    border-width: 1px 1px 1px 0;
+  }
+
+  /* The outline for the sign cell should be always be contiguous
+     top/bottom. */
+  .target-row.target-side-left td.left.sign::before,
+  .target-row.target-side-right td.right.sign::before {
+    border-width: 1px 0;
+  }
+
+  /* For side-by-side we need to select the correct line number to
+     "visually close" the outline. */
+  .side-by-side.target-row.target-side-left td.left.lineNum::before,
+  .side-by-side.target-row.target-side-right td.right.lineNum::before {
+    border-width: 1px 0 1px 1px;
+  }
+
+  /* For unified diff we always start the overlay from the left cell. */
+  .unified.target-row td.left:not(.content)::before {
+    border-width: 1px 0 1px 1px;
+  }
+
+  /* For unified diff we should continue the top/bottom border in right
+     line number column. */
+  .unified.target-row td.right:not(.content)::before {
+    border-width: 1px 0;
+  }
+
+  .content {
+    background-color: var(--diff-blank-background-color);
+  }
+
+  /* Describes two states of semantic tokens: whenever a token has a
+     definition that can be navigated to (navigable) and whenever
+     the token is actually clickable to perform this navigation. */
+  .semantic-token.navigable {
+    text-decoration-style: dotted;
+    text-decoration-line: underline;
+  }
+  .semantic-token.navigable.clickable {
+    text-decoration-style: solid;
+    cursor: pointer;
+  }
+
+  /* The file line, which has no contentText, add some margin before the
+     first comment. We cannot add padding the container because we only
+     want it if there is at least one comment thread, and the slotting
+     makes :empty not work as expected. */
+  .content.file slot:first-child::slotted(.comment-thread) {
+    display: block;
+    margin-top: var(--spacing-xs);
+  }
+  .contentText {
+    background-color: var(--view-background-color);
+  }
+  .blank {
+    background-color: var(--diff-blank-background-color);
+  }
+  .image-diff .content {
+    background-color: var(--diff-blank-background-color);
+  }
+  .responsive {
+    width: 100%;
+  }
+  .responsive .contentText {
+    white-space: break-spaces;
+    word-break: break-all;
+  }
+  .lineNumButton,
+  .content {
+    vertical-align: top;
+    white-space: pre;
+  }
+  .contextLineNum,
+  .lineNumButton {
+    -webkit-user-select: none;
+    -moz-user-select: none;
+    -ms-user-select: none;
+    user-select: none;
+
+    color: var(--deemphasized-text-color);
+    padding: 0 var(--spacing-m);
+    text-align: right;
+  }
+  .canComment .lineNumButton {
+    cursor: pointer;
+  }
+  .sign {
+    min-width: 1ch;
+    width: 1ch;
+    background-color: var(--view-background-color);
+  }
+  .sign.blank {
+    background-color: var(--diff-blank-background-color);
+  }
+  .content {
+    /* Set min width since setting width on table cells still allows them
+       to shrink. Do not set max width because CJK
+       (Chinese-Japanese-Korean) glyphs have variable width. */
+    min-width: var(--content-width, 80ch);
+    width: var(--content-width, 80ch);
+  }
+  /* If there are no intraline info, consider everything changed */
+  .content.add .contentText .intraline,
+  .content.add.no-intraline-info .contentText,
+  .sign.add.no-intraline-info,
+  .delta.total .content.add .contentText {
+    background-color: var(--dark-add-highlight-color);
+  }
+  .content.add .contentText,
+  .sign.add {
+    background-color: var(--light-add-highlight-color);
+  }
+  /* If there are no intraline info, consider everything changed */
+  .content.remove .contentText .intraline,
+  .content.remove.no-intraline-info .contentText,
+  .delta.total .content.remove .contentText,
+  .sign.remove.no-intraline-info {
+    background-color: var(--dark-remove-highlight-color);
+  }
+  .content.remove .contentText,
+  .sign.remove {
+    background-color: var(--light-remove-highlight-color);
+  }
+
+  .ignoredWhitespaceOnly .sign.no-intraline-info {
+    background-color: var(--view-background-color);
+  }
+
+  /* dueToRebase */
+  .dueToRebase .content.add .contentText .intraline,
+  .delta.total.dueToRebase .content.add .contentText {
+    background-color: var(--dark-rebased-add-highlight-color);
+  }
+  .dueToRebase .content.add .contentText {
+    background-color: var(--light-rebased-add-highlight-color);
+  }
+  .dueToRebase .content.remove .contentText .intraline,
+  .delta.total.dueToRebase .content.remove .contentText {
+    background-color: var(--dark-rebased-remove-highlight-color);
+  }
+  .dueToRebase .content.remove .contentText {
+    background-color: var(--light-rebased-remove-highlight-color);
+  }
+
+  /* dueToMove */
+  .dueToMove .sign.add,
+  .dueToMove .content.add .contentText,
+  .dueToMove .moveControls.movedIn .sign.right,
+  .dueToMove .moveControls.movedIn .moveHeader,
+  .delta.total.dueToMove .content.add .contentText {
+    background-color: var(--diff-moved-in-background);
+  }
+
+  .dueToMove.changed .sign.add,
+  .dueToMove.changed .content.add .contentText,
+  .dueToMove.changed .moveControls.movedIn .sign.right,
+  .dueToMove.changed .moveControls.movedIn .moveHeader,
+  .delta.total.dueToMove.changed .content.add .contentText {
+    background-color: var(--diff-moved-in-changed-background);
+  }
+
+  .dueToMove .sign.remove,
+  .dueToMove .content.remove .contentText,
+  .dueToMove .moveControls.movedOut .moveHeader,
+  .dueToMove .moveControls.movedOut .sign.left,
+  .delta.total.dueToMove .content.remove .contentText {
+    background-color: var(--diff-moved-out-background);
+  }
+
+  .delta.dueToMove .movedIn .moveHeader {
+    --gr-range-header-color: var(--diff-moved-in-label-color);
+  }
+  .delta.dueToMove.changed .movedIn .moveHeader {
+    --gr-range-header-color: var(--diff-moved-in-changed-label-color);
+  }
+  .delta.dueToMove .movedOut .moveHeader {
+    --gr-range-header-color: var(--diff-moved-out-label-color);
+  }
+
+  .moveHeader a {
+    color: inherit;
+  }
+
+  /* ignoredWhitespaceOnly */
+  .ignoredWhitespaceOnly .content.add .contentText .intraline,
+  .delta.total.ignoredWhitespaceOnly .content.add .contentText,
+  .ignoredWhitespaceOnly .content.add .contentText,
+  .ignoredWhitespaceOnly .content.remove .contentText .intraline,
+  .delta.total.ignoredWhitespaceOnly .content.remove .contentText,
+  .ignoredWhitespaceOnly .content.remove .contentText {
+    background-color: var(--view-background-color);
+  }
+
+  .content .contentText gr-diff-text:empty:after,
+  .content .contentText gr-legacy-text:empty:after,
+  .content .contentText:empty:after {
+    /* Newline, to ensure empty lines are one line-height tall. */
+    content: '\\A';
+  }
+
+  /* Context controls */
+  .contextControl {
+    display: var(--context-control-display, table-row-group);
+    background-color: transparent;
+    border: none;
+    --divider-height: var(--spacing-s);
+    --divider-border: 1px;
+  }
+  /* TODO: Is this still used? */
+  .contextControl gr-button gr-icon {
+    /* should match line-height of gr-button */
+    font-size: var(--line-height-mono, 18px);
+  }
+  .contextControl td:not(.lineNumButton) {
+    text-align: center;
+  }
+
+  /* Padding rows behind context controls. Styled as a continuation of the
+     line gutters and code area. */
+  .contextBackground > .contextLineNum {
+    background-color: var(--diff-blank-background-color);
+  }
+  .contextBackground > td:not(.contextLineNum) {
+    background-color: var(--view-background-color);
+  }
+  .contextBackground {
+    /* One line of background behind the context expanders which they can
+       render on top of, plus some padding. */
+    height: calc(var(--line-height-normal) + var(--spacing-s));
+  }
+
+  .dividerCell {
+    vertical-align: top;
+  }
+  .dividerRow.show-both .dividerCell {
+    height: var(--divider-height);
+  }
+  .dividerRow.show-above .dividerCell,
+  .dividerRow.show-above .dividerCell {
+    height: 0;
+  }
+
+  .br:after {
+    /* Line feed */
+    content: '\\A';
+  }
+  .tab {
+    display: inline-block;
+  }
+  .tab-indicator:before {
+    color: var(--diff-tab-indicator-color);
+    /* >> character */
+    content: '\\00BB';
+    position: absolute;
+  }
+  .special-char-indicator {
+    /* spacing so elements don't collide */
+    padding-right: var(--spacing-m);
+  }
+  .special-char-indicator:before {
+    color: var(--diff-tab-indicator-color);
+    content: '•';
+    position: absolute;
+  }
+  .special-char-warning {
+    /* spacing so elements don't collide */
+    padding-right: var(--spacing-m);
+  }
+  .special-char-warning:before {
+    color: var(--warning-foreground);
+    content: '!';
+    position: absolute;
+  }
+  /* Is defined after other background-colors, such that this
+     rule wins in case of same specificity. */
+  .trailing-whitespace,
+  .content .contentText .trailing-whitespace,
+  .trailing-whitespace .intraline,
+  .content .contentText .trailing-whitespace .intraline {
+    border-radius: var(--border-radius, 4px);
+    background-color: var(--diff-trailing-whitespace-indicator);
+  }
+  #diffHeader {
+    background-color: var(--table-header-background-color);
+    border-bottom: 1px solid var(--border-color);
+    color: var(--link-color);
+    padding: var(--spacing-m) 0 var(--spacing-m) 48px;
+  }
+  #diffTable {
+    /* for gr-selection-action-box positioning */
+    position: relative;
+  }
+  #diffTable:focus {
+    outline: none;
+  }
+  #loadingError,
+  #sizeWarning {
+    display: block;
+    margin: var(--spacing-l) auto;
+    max-width: 60em;
+    text-align: center;
+  }
+  #loadingError {
+    color: var(--error-text-color);
+  }
+  #sizeWarning gr-button {
+    margin: var(--spacing-l);
+  }
+  .target-row td.blame {
+    background: var(--diff-selection-background-color);
+  }
+  td.lost div {
+    background-color: var(--info-background);
+  }
+  td.lost div.lost-message {
+    font-family: var(--font-family, 'Roboto');
+    font-size: var(--font-size-normal, 14px);
+    line-height: var(--line-height-normal);
+    padding: var(--spacing-s) 0;
+  }
+  td.lost div.lost-message gr-icon {
+    padding: 0 var(--spacing-s) 0 var(--spacing-m);
+    color: var(--blue-700);
+  }
+
+  col.sign,
+  td.sign {
+    display: none;
+  }
+
+  /* Sign column should only be shown in high-contrast mode. */
+  :host(.with-sign-col) col.sign {
+    display: table-column;
+  }
+  :host(.with-sign-col) td.sign {
+    display: table-cell;
+  }
+  col.blame {
+    display: none;
+  }
+  td.blame {
+    display: none;
+    padding: 0 var(--spacing-m);
+    white-space: pre;
+  }
+  :host(.showBlame) col.blame {
+    display: table-column;
+  }
+  :host(.showBlame) td.blame {
+    display: table-cell;
+  }
+  td.blame > span {
+    opacity: 0.6;
+  }
+  td.blame > span.startOfRange {
+    opacity: 1;
+  }
+  td.blame .blameDate {
+    font-family: var(--monospace-font-family);
+    color: var(--link-color);
+    text-decoration: none;
+  }
+  .responsive td.blame {
+    overflow: hidden;
+    width: 200px;
+  }
+  /** Support the line length indicator **/
+  .responsive td.content .contentText {
+    /* Same strategy as in
+       https://stackoverflow.com/questions/1179928/how-can-i-put-a-vertical-line-down-the-center-of-a-div
+       */
+    background-image: linear-gradient(
+      var(--line-length-indicator-color),
+      var(--line-length-indicator-color)
+    );
+    background-size: 1px 100%;
+    background-position: var(--line-limit-marker) 0;
+    background-repeat: no-repeat;
+  }
+  .newlineWarning {
+    color: var(--deemphasized-text-color);
+    text-align: center;
+  }
+  .newlineWarning.hidden {
+    display: none;
+  }
+  .lineNum.COVERED .lineNumButton {
+    color: var(
+      --coverage-covered-line-num-color,
+      var(--deemphasized-text-color)
+    );
+    background-color: var(--coverage-covered, #e0f2f1);
+  }
+  .lineNum.NOT_COVERED .lineNumButton {
+    color: var(
+      --coverage-covered-line-num-color,
+      var(--deemphasized-text-color)
+    );
+    background-color: var(--coverage-not-covered, #ffd1a4);
+  }
+  .lineNum.PARTIALLY_COVERED .lineNumButton {
+    color: var(
+      --coverage-covered-line-num-color,
+      var(--deemphasized-text-color)
+    );
+    background: linear-gradient(
+      to right bottom,
+      var(--coverage-not-covered, #ffd1a4) 0%,
+      var(--coverage-not-covered, #ffd1a4) 50%,
+      var(--coverage-covered, #e0f2f1) 50%,
+      var(--coverage-covered, #e0f2f1) 100%
+    );
+  }
+
+  // TODO: Investigate whether this CSS is still necessary.
+  /* BEGIN: Select and copy for Polymer 2 */
+  /* Below was copied and modified from the original css in gr-diff-selection.html. */
+  .content,
+  .contextControl,
+  .blame {
+    -webkit-user-select: none;
+    -moz-user-select: none;
+    -ms-user-select: none;
+    user-select: none;
+  }
+
+  .selected-left:not(.selected-comment)
+    .side-by-side
+    .left
+    + .content
+    .contentText,
+  .selected-right:not(.selected-comment)
+    .side-by-side
+    .right
+    + .content
+    .contentText,
+  .selected-left:not(.selected-comment)
+    .unified
+    .left.lineNum
+    ~ .content:not(.both)
+    .contentText,
+  .selected-right:not(.selected-comment)
+    .unified
+    .right.lineNum
+    ~ .content
+    .contentText,
+  .selected-left.selected-comment .side-by-side .left + .content .message,
+  .selected-right.selected-comment
+    .side-by-side
+    .right
+    + .content
+    .message
+    :not(.collapsedContent),
+  .selected-comment .unified .message :not(.collapsedContent),
+  .selected-blame .blame {
+    -webkit-user-select: text;
+    -moz-user-select: text;
+    -ms-user-select: text;
+    user-select: text;
+  }
+
+  /* Make comments and check results selectable when selected */
+  .selected-left.selected-comment ::slotted(.comment-thread[diff-side='left']),
+  .selected-right.selected-comment
+    ::slotted(.comment-thread[diff-side='right']) {
+    -webkit-user-select: text;
+    -moz-user-select: text;
+    -ms-user-select: text;
+    user-select: text;
+  }
+  /* END: Select and copy for Polymer 2 */
+
+  .whitespace-change-only-message {
+    background-color: var(--diff-context-control-background-color);
+    border: 1px solid var(--diff-context-control-border-color);
+    text-align: center;
+  }
+
+  .token-highlight {
+    background-color: var(--token-highlighting-color, #fffd54);
+  }
+
+  gr-selection-action-box {
+    /* Needs z-index to appear above wrapped content, since it's inserted
+       into DOM before it. */
+    z-index: 10;
+  }
+
+  gr-diff-image-new,
+  gr-diff-image-old,
+  gr-diff-section,
+  gr-context-controls-section,
+  gr-diff-row {
+    display: contents;
+  }
+`;
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-utils.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-utils.ts
index 2b61c8c..669537e 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-utils.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-utils.ts
@@ -34,27 +34,41 @@
  *   Graphemes: http://unicode.org/reports/tr29/#Grapheme_Cluster_Boundaries
  *   A proposed JS API: https://github.com/tc39/proposal-intl-segmenter
  */
-const REGEX_TAB_OR_SURROGATE_PAIR = /\t|[\uD800-\uDBFF][\uDC00-\uDFFF]/;
+export const REGEX_TAB_OR_SURROGATE_PAIR = /\t|[\uD800-\uDBFF][\uDC00-\uDFFF]/;
 
 // If any line of the diff is more than the character limit, then disable
 // syntax highlighting for the entire file.
 export const SYNTAX_MAX_LINE_LENGTH = 500;
 
+export function countLines(diff?: DiffInfo, side?: Side) {
+  if (!diff?.content || !side) return 0;
+  return diff.content.reduce((sum, chunk) => {
+    const sideChunk = side === Side.LEFT ? chunk.a : chunk.b;
+    return sum + (sideChunk?.length ?? chunk.ab?.length ?? chunk.skip ?? 0);
+  }, 0);
+}
+
+export function isFileUnchanged(diff: DiffInfo) {
+  return !diff.content.some(
+    content => (content.a && !content.common) || (content.b && !content.common)
+  );
+}
+
 export function getResponsiveMode(
-  prefs: DiffPreferencesInfo,
+  prefs?: DiffPreferencesInfo,
   renderPrefs?: RenderPreferences
 ): DiffResponsiveMode {
   if (renderPrefs?.responsive_mode) {
     return renderPrefs.responsive_mode;
   }
   // Backwards compatibility to the line_wrapping param.
-  if (prefs.line_wrapping) {
+  if (prefs?.line_wrapping) {
     return 'FULL_RESPONSIVE';
   }
   return 'NONE';
 }
 
-export function isResponsive(responsiveMode: DiffResponsiveMode) {
+export function isResponsive(responsiveMode?: DiffResponsiveMode) {
   return (
     responsiveMode === 'FULL_RESPONSIVE' || responsiveMode === 'SHRINK_ONLY'
   );
@@ -105,7 +119,12 @@
         return null;
       }
     }
-    node = node.previousSibling ?? node.parentElement ?? undefined;
+    node =
+      (node as Element).assignedSlot ??
+      (node as ShadowRoot).host ??
+      node.previousSibling ??
+      node.parentNode ??
+      undefined;
   }
   return null;
 }
@@ -149,7 +168,7 @@
   const rangeAtt = threadEl.getAttribute('range');
   if (!rangeAtt) return undefined;
   const range = JSON.parse(rangeAtt) as CommentRange;
-  if (!range.start_line) throw new Error(`invalid range: ${rangeAtt}`);
+  if (!range.start_line) return undefined;
   return range;
 }
 
@@ -184,9 +203,16 @@
 }
 
 /**
+ * Simple helper method for creating element classes in the context of
+ * gr-diff. This is just a super simple convenience function.
+ */
+export function diffClasses(...additionalClasses: string[]) {
+  return ['gr-diff', ...additionalClasses].join(' ');
+}
+
+/**
  * Simple helper method for creating elements in the context of gr-diff.
- *
- * Otherwise this is just a super simple convenience function.
+ * This is just a super simple convenience function.
  */
 export function createElementDiff(
   tagName: string,
@@ -254,6 +280,12 @@
   elementId: string
 ): HTMLElement {
   const contentText = createElementDiff('div', 'contentText');
+  // <gr-legacy-text> is not defined anywhere, so this behave just as a <div>
+  // would. We use this during the migration to lit based diff elements to
+  // match <gr-diff-text>. We define a css rule with `display:contents` making
+  // sure that this extra element is basically a no-op.
+  const legacyText = document.createElement('gr-legacy-text');
+  contentText.appendChild(legacyText);
   contentText.id = elementId;
   let columnPos = 0;
   let textOffset = 0;
@@ -265,16 +297,16 @@
       let rowStart = 0;
       let rowEnd = lineLimit - columnPos;
       while (rowEnd < segment.length) {
-        contentText.appendChild(
+        legacyText.appendChild(
           document.createTextNode(segment.substring(rowStart, rowEnd))
         );
-        contentText.appendChild(createLineBreak(responsiveMode));
+        legacyText.appendChild(createLineBreak(responsiveMode));
         columnPos = 0;
         rowStart = rowEnd;
         rowEnd += lineLimit;
       }
       // Append the last part of |segment|, which fits on the current line.
-      contentText.appendChild(
+      legacyText.appendChild(
         document.createTextNode(segment.substring(rowStart))
       );
       columnPos += segment.length - rowStart;
@@ -286,20 +318,20 @@
         // Append a single '\t' character.
         let effectiveTabSize = tabSize - (columnPos % tabSize);
         if (columnPos + effectiveTabSize > lineLimit) {
-          contentText.appendChild(createLineBreak(responsiveMode));
+          legacyText.appendChild(createLineBreak(responsiveMode));
           columnPos = 0;
           effectiveTabSize = tabSize;
         }
-        contentText.appendChild(createTabWrapper(effectiveTabSize));
+        legacyText.appendChild(createTabWrapper(effectiveTabSize));
         columnPos += effectiveTabSize;
         textOffset++;
       } else {
         // Append a single surrogate pair.
         if (columnPos >= lineLimit) {
-          contentText.appendChild(createLineBreak(responsiveMode));
+          legacyText.appendChild(createLineBreak(responsiveMode));
           columnPos = 0;
         }
-        contentText.appendChild(
+        legacyText.appendChild(
           document.createTextNode(text.substring(textOffset, textOffset + 2))
         );
         textOffset += 2;
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-utils_test.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-utils_test.ts
index 7e8eb4c..2438bcb 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-utils_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-utils_test.ts
@@ -4,8 +4,16 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import {assert} from '@open-wc/testing';
+import {DiffInfo} from '../../../api/diff';
 import '../../../test/common-test-setup';
-import {createElementDiff, formatText, createTabWrapper} from './gr-diff-utils';
+import {createDiff} from '../../../test/test-data-generators';
+import {
+  createElementDiff,
+  formatText,
+  createTabWrapper,
+  isFileUnchanged,
+  getRange,
+} from './gr-diff-utils';
 
 const LINE_BREAK_HTML = '<span class="gr-diff br"></span>';
 
@@ -20,10 +28,13 @@
   test('formatText newlines 1', () => {
     let text = 'abcdef';
 
-    assert.equal(formatText(text, 'NONE', 4, 10, '').innerHTML, text);
+    assert.equal(
+      formatText(text, 'NONE', 4, 10, '').firstElementChild?.innerHTML,
+      text
+    );
     text = 'a'.repeat(20);
     assert.equal(
-      formatText(text, 'NONE', 4, 10, '').innerHTML,
+      formatText(text, 'NONE', 4, 10, '').firstElementChild?.innerHTML,
       'a'.repeat(10) + LINE_BREAK_HTML + 'a'.repeat(10)
     );
   });
@@ -31,7 +42,7 @@
   test('formatText newlines 2', () => {
     const text = '<span class="thumbsup">👍</span>';
     assert.equal(
-      formatText(text, 'NONE', 4, 10, '').innerHTML,
+      formatText(text, 'NONE', 4, 10, '').firstElementChild?.innerHTML,
       '&lt;span clas' +
         LINE_BREAK_HTML +
         's="thumbsu' +
@@ -45,7 +56,7 @@
   test('formatText newlines 3', () => {
     const text = '01234\t56789';
     assert.equal(
-      formatText(text, 'NONE', 4, 10, '').innerHTML,
+      formatText(text, 'NONE', 4, 10, '').firstElementChild?.innerHTML,
       '01234' + createTabWrapper(3).outerHTML + '56' + LINE_BREAK_HTML + '789'
     );
   });
@@ -53,7 +64,7 @@
   test('formatText newlines 4', () => {
     const text = '👍'.repeat(58);
     assert.equal(
-      formatText(text, 'NONE', 4, 20, '').innerHTML,
+      formatText(text, 'NONE', 4, 20, '').firstElementChild?.innerHTML,
       '👍'.repeat(20) +
         LINE_BREAK_HTML +
         '👍'.repeat(20) +
@@ -82,7 +93,8 @@
     assert.ok(wrapper);
     assert.equal(wrapper.innerText, '\t');
     assert.equal(
-      formatText(html, 'NONE', tabSize, Infinity, '').innerHTML,
+      formatText(html, 'NONE', tabSize, Infinity, '').firstElementChild
+        ?.innerHTML,
       'abc' + wrapper.outerHTML + 'def'
     );
   });
@@ -91,31 +103,22 @@
     let input = '<script>alert("XSS");<' + '/script>';
     let expected = '&lt;script&gt;alert("XSS");&lt;/script&gt;';
 
-    let result = formatText(
-      input,
-      'NONE',
-      1,
-      Number.POSITIVE_INFINITY,
-      ''
-    ).innerHTML;
+    let result = formatText(input, 'NONE', 1, Number.POSITIVE_INFINITY, '')
+      .firstElementChild?.innerHTML;
     assert.equal(result, expected);
 
     input = '& < > " \' / `';
     expected = '&amp; &lt; &gt; " \' / `';
-    result = formatText(
-      input,
-      'NONE',
-      1,
-      Number.POSITIVE_INFINITY,
-      ''
-    ).innerHTML;
+    result = formatText(input, 'NONE', 1, Number.POSITIVE_INFINITY, '')
+      .firstElementChild?.innerHTML;
     assert.equal(result, expected);
   });
 
   test('text length with tabs and unicode', () => {
     function expectTextLength(text: string, tabSize: number, expected: number) {
       // Formatting to |expected| columns should not introduce line breaks.
-      const result = formatText(text, 'NONE', tabSize, expected, '');
+      const result = formatText(text, 'NONE', tabSize, expected, '')
+        .firstElementChild!;
       assert.isNotOk(
         result.querySelector('.contentText > .br'),
         '  Expected the result of: \n' +
@@ -126,19 +129,22 @@
 
       // Increasing the line limit should produce the same markup.
       assert.equal(
-        formatText(text, 'NONE', tabSize, Infinity, '').innerHTML,
+        formatText(text, 'NONE', tabSize, Infinity, '').firstElementChild
+          ?.innerHTML,
         result.innerHTML
       );
       assert.equal(
-        formatText(text, 'NONE', tabSize, expected + 1, '').innerHTML,
+        formatText(text, 'NONE', tabSize, expected + 1, '').firstElementChild
+          ?.innerHTML,
         result.innerHTML
       );
 
       // Decreasing the line limit should introduce line breaks.
       if (expected > 0) {
-        const tooSmall = formatText(text, 'NONE', tabSize, expected - 1, '');
+        const tooSmall = formatText(text, 'NONE', tabSize, expected - 1, '')
+          .firstElementChild!;
         assert.isOk(
-          tooSmall.querySelector('.contentText > .br'),
+          tooSmall.querySelector('.contentText .br'),
           '  Expected the result of: \n' +
             `      _formatText(${text}', ${tabSize}, ${expected - 1})\n` +
             '  to contain a br. But the actual result HTML was:\n' +
@@ -158,4 +164,52 @@
     expectTextLength('abc\tde\t', 10, 20);
     expectTextLength('\t\t\t\t\t', 20, 100);
   });
+
+  test('isFileUnchanged', () => {
+    let diff: DiffInfo = {
+      ...createDiff(),
+      content: [
+        {a: ['abcd'], ab: ['ef']},
+        {b: ['ancd'], a: ['xx']},
+      ],
+    };
+    assert.equal(isFileUnchanged(diff), false);
+    diff = {
+      ...createDiff(),
+      content: [{ab: ['abcd']}, {ab: ['ancd']}],
+    };
+    assert.equal(isFileUnchanged(diff), true);
+    diff = {
+      ...createDiff(),
+      content: [
+        {a: ['abcd'], ab: ['ef'], common: true},
+        {b: ['ancd'], ab: ['xx']},
+      ],
+    };
+    assert.equal(isFileUnchanged(diff), false);
+    diff = {
+      ...createDiff(),
+      content: [
+        {a: ['abcd'], ab: ['ef'], common: true},
+        {b: ['ancd'], ab: ['xx'], common: true},
+      ],
+    };
+    assert.equal(isFileUnchanged(diff), true);
+  });
+
+  test('getRange returns undefined with start_line = 0', () => {
+    const range = {
+      start_line: 0,
+      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');
+    assert.isUndefined(getRange(threadEl));
+  });
 });
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 53c2780..3929330 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts
@@ -27,10 +27,12 @@
   isResponsive,
   getDiffLength,
 } from './gr-diff-utils';
-import {getHiddenScroll} from '../../../scripts/hiddenscroll';
 import {BlameInfo, CommentRange, ImageInfo} from '../../../types/common';
 import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
-import {GrDiffHighlight} from '../gr-diff-highlight/gr-diff-highlight';
+import {
+  CreateRangeCommentEventDetail,
+  GrDiffHighlight,
+} from '../gr-diff-highlight/gr-diff-highlight';
 import {
   GrDiffBuilderElement,
   getLineNumberCellWidth,
@@ -43,7 +45,7 @@
   Side,
 } from '../../../constants/constants';
 import {KeyLocations} from '../gr-diff-processor/gr-diff-processor';
-import {fire, fireAlert, fireEvent} from '../../../utils/event-util';
+import {fire, fireAlert} from '../../../utils/event-util';
 import {MovedLinkClickedEvent, ValueChangedEvent} from '../../../types/events';
 import {getContentEditableRange} from '../../../utils/safari-selection-util';
 import {AbortStop} from '../../../api/core';
@@ -63,12 +65,16 @@
 import {GrDiffSelection} from '../gr-diff-selection/gr-diff-selection';
 import {customElement, property, query, state} from 'lit/decorators.js';
 import {sharedStyles} from '../../../styles/shared-styles';
-import {css, html, LitElement, nothing, PropertyValues} from 'lit';
+import {html, LitElement, nothing, PropertyValues} from 'lit';
 import {when} from 'lit/directives/when.js';
 import {grSyntaxTheme} from '../gr-syntax-themes/gr-syntax-theme';
 import {grRangedCommentTheme} from '../gr-ranged-comment-themes/gr-ranged-comment-theme';
 import {classMap} from 'lit/directives/class-map.js';
 import {iconStyles} from '../../../styles/gr-icon-styles';
+import {expandFileMode} from '../../../utils/file-util';
+import {DiffModel, diffModelToken} from '../gr-diff-model/gr-diff-model';
+import {provide} from '../../../models/dependency';
+import {grDiffStyles} from './gr-diff-styles';
 
 const NO_NEWLINE_LEFT = 'No newline at end of left file.';
 const NO_NEWLINE_RIGHT = 'No newline at end of right file.';
@@ -138,10 +144,7 @@
   prefs?: DiffPreferencesInfo;
 
   @property({type: Object})
-  renderPrefs?: RenderPreferences;
-
-  @property({type: Boolean})
-  displayLine = false;
+  renderPrefs: RenderPreferences = {};
 
   @property({type: Boolean})
   isImageDiff?: boolean;
@@ -259,7 +262,8 @@
   // Private but used in tests.
   renderDiffTableTask?: DelayedPromise<void>;
 
-  private diffSelection = new GrDiffSelection();
+  // Private but used in tests.
+  diffSelection = new GrDiffSelection();
 
   // Private but used in tests.
   highlights = new GrDiffHighlight();
@@ -267,713 +271,25 @@
   // Private but used in tests.
   diffBuilder = new GrDiffBuilderElement();
 
+  private diffModel = new DiffModel(undefined);
+
   static override get styles() {
     return [
       iconStyles,
       sharedStyles,
       grSyntaxTheme,
       grRangedCommentTheme,
-      css`
-        /**
-          This is used to hide all left side of the diff (e.g. diffs besides
-          comments in the change log). Since we want to remove the first 4
-          cells consistently in all rows except context buttons (.dividerRow).
-        */
-        :host(.no-left) .sideBySide colgroup col:nth-child(-n + 4),
-        :host(.no-left) .sideBySide tr:not(.dividerRow) td:nth-child(-n + 4) {
-          display: none;
-        }
-        :host(.disable-context-control-buttons) {
-          --context-control-display: none;
-        }
-        :host(.disable-context-control-buttons) .section {
-          border-right: none;
-        }
-        :host(.hide-line-length-indicator) .full-width td.content .contentText {
-          background-image: none;
-        }
-
-        :host {
-          font-family: var(--monospace-font-family, ''), 'Roboto Mono';
-          font-size: var(--font-size, var(--font-size-code, 12px));
-          /* usually 16px = 12px + 4px */
-          line-height: calc(
-            var(--font-size, var(--font-size-code, 12px)) +
-              var(--spacing-s, 4px)
-          );
-        }
-
-        .thread-group {
-          display: block;
-          max-width: var(--content-width, 80ch);
-          white-space: normal;
-          background-color: var(--diff-blank-background-color);
-        }
-        .diffContainer {
-          max-width: var(--diff-max-width, none);
-          display: flex;
-          font-family: var(--monospace-font-family);
-        }
-        .diffContainer.hiddenscroll {
-          margin-bottom: var(--spacing-m);
-        }
-        table {
-          border-collapse: collapse;
-          table-layout: fixed;
-        }
-        td.lineNum {
-          /* Enforces background whenever lines wrap */
-          background-color: var(--diff-blank-background-color);
-        }
-
-        /**
-          Provides the option to add side borders (left and right) to the line
-          number column.
-        */
-        td.lineNum,
-        td.blankLineNum,
-        td.moveControlsLineNumCol,
-        td.contextLineNum {
-          box-shadow: var(--line-number-box-shadow, unset);
-        }
-
-        /**
-          Context controls break up the table visually, so we set the right
-          border on individual sections to leave a gap for the divider.
-
-          Also taken into account for max-width calculations in SHRINK_ONLY mode
-          (check GrDiff.updatePreferenceStyles).
-        */
-        .section {
-          border-right: 1px solid var(--border-color);
-        }
-        .section.contextControl {
-          /**
-            Divider inside this section must not have border; we set borders on
-            the padding rows below.
-          */
-          border-right-width: 0;
-        }
-        /**
-          Padding rows behind context controls. The diff is styled to be cut
-          into two halves by the negative space of the divider on which the
-          context control buttons are anchored.
-        */
-        .contextBackground {
-          border-right: 1px solid var(--border-color);
-        }
-        .contextBackground.above {
-          border-bottom: 1px solid var(--border-color);
-        }
-        .contextBackground.below {
-          border-top: 1px solid var(--border-color);
-        }
-
-        .lineNumButton {
-          display: block;
-          width: 100%;
-          height: 100%;
-          background-color: var(--diff-blank-background-color);
-          box-shadow: var(--line-number-box-shadow, unset);
-        }
-        td.lineNum {
-          vertical-align: top;
-        }
-
-        /**
-          The only way to focus this (clicking) will apply our own focus
-          styling, so this default styling is not needed and distracting.
-        */
-        .lineNumButton:focus {
-          outline: none;
-        }
-        gr-image-viewer {
-          width: 100%;
-          height: 100%;
-          max-width: var(--image-viewer-max-width, 95vw);
-          max-height: var(--image-viewer-max-height, 90vh);
-          /**
-            Defined by paper-styles default-theme and used in various
-            components. background-color-secondary is a compromise between
-            fairly light in light theme (where we ideally would want
-            background-color-primary) yet slightly offset against the app
-            background in dark mode, where drop shadows e.g. around paper-card
-            are almost invisible.
-          */
-          --primary-background-color: var(--background-color-secondary);
-        }
-        .image-diff .gr-diff {
-          text-align: center;
-        }
-        .image-diff img {
-          box-shadow: var(--elevation-level-1);
-          max-width: 50em;
-        }
-        .image-diff .right.lineNumButton {
-          border-left: 1px solid var(--border-color);
-        }
-        .image-diff label,
-        .binary-diff label {
-          font-family: var(--font-family);
-          font-style: italic;
-        }
-        .diff-row {
-          outline: none;
-          user-select: none;
-        }
-        .diff-row.target-row.target-side-left .lineNumButton.left,
-        .diff-row.target-row.target-side-right .lineNumButton.right,
-        .diff-row.target-row.unified .lineNumButton {
-          color: var(--primary-text-color);
-        }
-
-        /**
-          Preparing selected line cells with position relative so it allows a
-          positioned overlay with 'position: absolute'.
-        */
-        .target-row td {
-          position: relative;
-        }
-
-        /**
-          Defines an overlay to the selected line for drawing an outline without
-          blocking user interaction (e.g. text selection).
-        */
-        .target-row td::before {
-          border-width: 0;
-          border-style: solid;
-          border-color: var(--focused-line-outline-color);
-          position: absolute;
-          top: 0;
-          left: 0;
-          width: 100%;
-          height: 100%;
-          pointer-events: none;
-          user-select: none;
-          content: ' ';
-        }
-
-        /**
-          the outline for the selected content cell should be the same in all
-          cases.
-        */
-        .target-row.target-side-left td.left.content::before,
-        .target-row.target-side-right td.right.content::before,
-        .unified.target-row td.content::before {
-          border-width: 1px 1px 1px 0;
-        }
-
-        /**
-          the outline for the sign cell should be always be contiguous
-          top/bottom.
-        */
-        .target-row.target-side-left td.left.sign::before,
-        .target-row.target-side-right td.right.sign::before {
-          border-width: 1px 0;
-        }
-
-        /**
-          For side-by-side we need to select the correct line number to
-          "visually close" the outline.
-        */
-        .side-by-side.target-row.target-side-left td.left.lineNum::before,
-        .side-by-side.target-row.target-side-right td.right.lineNum::before {
-          border-width: 1px 0 1px 1px;
-        }
-
-        /**
-          For unified diff we always start the overlay from the left cell
-        */
-        .unified.target-row td.left:not(.content)::before {
-          border-width: 1px 0 1px 1px;
-        }
-
-        /**
-          For unified diff we should continue the top/bottom border in right
-          line number column.
-        */
-        .unified.target-row td.right:not(.content)::before {
-          border-width: 1px 0;
-        }
-
-        .content {
-          background-color: var(--diff-blank-background-color);
-        }
-
-        /**
-          Describes two states of semantic tokens: whenever a token has a
-          definition that can be navigated to (navigable) and whenever
-          the token is actually clickable to perform this navigation.
-        */
-        .semantic-token.navigable {
-          text-decoration-style: dotted;
-          text-decoration-line: underline;
-        }
-        .semantic-token.navigable.clickable {
-          text-decoration-style: solid;
-          cursor: pointer;
-        }
-
-        /*
-          The file line, which has no contentText, add some margin before the
-          first comment. We cannot add padding the container because we only
-          want it if there is at least one comment thread, and the slotting
-          makes :empty not work as expected.
-        */
-        .content.file slot:first-child::slotted(.comment-thread) {
-          display: block;
-          margin-top: var(--spacing-xs);
-        }
-        .contentText {
-          background-color: var(--view-background-color);
-        }
-        .blank {
-          background-color: var(--diff-blank-background-color);
-        }
-        .image-diff .content {
-          background-color: var(--diff-blank-background-color);
-        }
-        .responsive {
-          width: 100%;
-        }
-        .responsive .contentText {
-          white-space: break-spaces;
-          word-break: break-all;
-        }
-        .lineNumButton,
-        .content {
-          vertical-align: top;
-          white-space: pre;
-        }
-        .contextLineNum,
-        .lineNumButton {
-          -webkit-user-select: none;
-          -moz-user-select: none;
-          -ms-user-select: none;
-          user-select: none;
-
-          color: var(--deemphasized-text-color);
-          padding: 0 var(--spacing-m);
-          text-align: right;
-        }
-        .canComment .lineNumButton {
-          cursor: pointer;
-        }
-        .sign {
-          min-width: 1ch;
-          width: 1ch;
-          background-color: var(--view-background-color);
-        }
-        .sign.blank {
-          background-color: var(--diff-blank-background-color);
-        }
-        .content {
-          /*
-            Set min width since setting width on table cells still allows them
-            to shrink. Do not set max width because CJK
-            (Chinese-Japanese-Korean) glyphs have variable width
-          */
-          min-width: var(--content-width, 80ch);
-          width: var(--content-width, 80ch);
-        }
-        .content.add .contentText .intraline,
-          /* If there are no intraline info, consider everything changed */
-          .content.add.no-intraline-info .contentText,
-          .sign.add.no-intraline-info,
-          .delta.total .content.add .contentText {
-          background-color: var(--dark-add-highlight-color);
-        }
-        .content.add .contentText,
-        .sign.add {
-          background-color: var(--light-add-highlight-color);
-        }
-        .content.remove .contentText .intraline,
-          /* If there are no intraline info, consider everything changed */
-          .content.remove.no-intraline-info .contentText,
-          .delta.total .content.remove .contentText,
-          .sign.remove.no-intraline-info {
-          background-color: var(--dark-remove-highlight-color);
-        }
-        .content.remove .contentText,
-        .sign.remove {
-          background-color: var(--light-remove-highlight-color);
-        }
-
-        .ignoredWhitespaceOnly .sign.no-intraline-info {
-          background-color: var(--view-background-color);
-        }
-
-        /* dueToRebase */
-        .dueToRebase .content.add .contentText .intraline,
-        .delta.total.dueToRebase .content.add .contentText {
-          background-color: var(--dark-rebased-add-highlight-color);
-        }
-        .dueToRebase .content.add .contentText {
-          background-color: var(--light-rebased-add-highlight-color);
-        }
-        .dueToRebase .content.remove .contentText .intraline,
-        .delta.total.dueToRebase .content.remove .contentText {
-          background-color: var(--dark-rebased-remove-highlight-color);
-        }
-        .dueToRebase .content.remove .contentText {
-          background-color: var(--light-rebased-remove-highlight-color);
-        }
-
-        /* dueToMove */
-        .dueToMove .sign.add,
-        .dueToMove .content.add .contentText,
-        .dueToMove .moveControls.movedIn .sign.right,
-        .dueToMove .moveControls.movedIn .moveHeader,
-        .delta.total.dueToMove .content.add .contentText {
-          background-color: var(--diff-moved-in-background);
-        }
-
-        .dueToMove .sign.remove,
-        .dueToMove .content.remove .contentText,
-        .dueToMove .moveControls.movedOut .moveHeader,
-        .dueToMove .moveControls.movedOut .sign.left,
-        .delta.total.dueToMove .content.remove .contentText {
-          background-color: var(--diff-moved-out-background);
-        }
-
-        .delta.dueToMove .movedIn .moveHeader {
-          --gr-range-header-color: var(--diff-moved-in-label-color);
-        }
-        .delta.dueToMove .movedOut .moveHeader {
-          --gr-range-header-color: var(--diff-moved-out-label-color);
-        }
-
-        .moveHeader a {
-          color: inherit;
-        }
-
-        /* ignoredWhitespaceOnly */
-        .ignoredWhitespaceOnly .content.add .contentText .intraline,
-        .delta.total.ignoredWhitespaceOnly .content.add .contentText,
-        .ignoredWhitespaceOnly .content.add .contentText,
-        .ignoredWhitespaceOnly .content.remove .contentText .intraline,
-        .delta.total.ignoredWhitespaceOnly .content.remove .contentText,
-        .ignoredWhitespaceOnly .content.remove .contentText {
-          background-color: var(--view-background-color);
-        }
-
-        .content .contentText:empty:after {
-          /* Newline, to ensure empty lines are one line-height tall. */
-          content: '\\A';
-        }
-
-        /* Context controls */
-        .contextControl {
-          display: var(--context-control-display, table-row-group);
-          background-color: transparent;
-          border: none;
-          --divider-height: var(--spacing-s);
-          --divider-border: 1px;
-        }
-        /* TODO: Is this still used? */
-        .contextControl gr-button gr-icon {
-          /* should match line-height of gr-button */
-          font-size: var(--line-height-mono, 18px);
-        }
-        .contextControl td:not(.lineNumButton) {
-          text-align: center;
-        }
-
-        /**
-          Padding rows behind context controls. Styled as a continuation of the
-          line gutters and code area.
-        */
-        .contextBackground > .contextLineNum {
-          background-color: var(--diff-blank-background-color);
-        }
-        .contextBackground > td:not(.contextLineNum) {
-          background-color: var(--view-background-color);
-        }
-        .contextBackground {
-          /**
-            One line of background behind the context expanders which they can
-            render on top of, plus some padding.
-          */
-          height: calc(var(--line-height-normal) + var(--spacing-s));
-        }
-
-        .dividerCell {
-          vertical-align: top;
-        }
-        .dividerRow.show-both .dividerCell {
-          height: var(--divider-height);
-        }
-        .dividerRow.show-above .dividerCell,
-        .dividerRow.show-above .dividerCell {
-          height: 0;
-        }
-
-        .br:after {
-          /* Line feed */
-          content: '\\A';
-        }
-        .tab {
-          display: inline-block;
-        }
-        .tab-indicator:before {
-          color: var(--diff-tab-indicator-color);
-          /* >> character */
-          content: '\\00BB';
-          position: absolute;
-        }
-        .special-char-indicator {
-          /* spacing so elements don't collide */
-          padding-right: var(--spacing-m);
-        }
-        .special-char-indicator:before {
-          color: var(--diff-tab-indicator-color);
-          content: '•';
-          position: absolute;
-        }
-        .special-char-warning {
-          /* spacing so elements don't collide */
-          padding-right: var(--spacing-m);
-        }
-        .special-char-warning:before {
-          color: var(--warning-foreground);
-          content: '!';
-          position: absolute;
-        }
-        /**
-          Is defined after other background-colors, such that this
-          rule wins in case of same specificity.
-        */
-        .trailing-whitespace,
-        .content .trailing-whitespace,
-        .trailing-whitespace .intraline,
-        .content .trailing-whitespace .intraline {
-          border-radius: var(--border-radius, 4px);
-          background-color: var(--diff-trailing-whitespace-indicator);
-        }
-        #diffHeader {
-          background-color: var(--table-header-background-color);
-          border-bottom: 1px solid var(--border-color);
-          color: var(--link-color);
-          padding: var(--spacing-m) 0 var(--spacing-m) 48px;
-        }
-        #diffTable {
-          /* for gr-selection-action-box positioning */
-          position: relative;
-        }
-        #diffTable:focus {
-          outline: none;
-        }
-        #loadingError,
-        #sizeWarning {
-          display: none;
-          margin: var(--spacing-l) auto;
-          max-width: 60em;
-          text-align: center;
-        }
-        #loadingError {
-          color: var(--error-text-color);
-        }
-        #sizeWarning gr-button {
-          margin: var(--spacing-l);
-        }
-        #loadingError.showError,
-        #sizeWarning.warn {
-          display: block;
-        }
-        .target-row td.blame {
-          background: var(--diff-selection-background-color);
-        }
-        td.lost div {
-          background-color: var(--info-background);
-          padding: var(--spacing-s) 0 0 0;
-        }
-        td.lost div:first-of-type {
-          font-family: var(--font-family, 'Roboto');
-          font-size: var(--font-size-normal, 14px);
-          line-height: var(--line-height-normal);
-        }
-        td.lost gr-icon {
-          padding: 0 var(--spacing-s) 0 var(--spacing-m);
-          color: var(--blue-700);
-        }
-
-        col.sign,
-        td.sign {
-          display: none;
-        }
-
-        /* Sign column should only be shown in high-contrast mode. */
-        :host(.with-sign-col) col.sign {
-          display: table-column;
-        }
-        :host(.with-sign-col) td.sign {
-          display: table-cell;
-        }
-        col.blame {
-          display: none;
-        }
-        td.blame {
-          display: none;
-          padding: 0 var(--spacing-m);
-          white-space: pre;
-        }
-        :host(.showBlame) col.blame {
-          display: table-column;
-        }
-        :host(.showBlame) td.blame {
-          display: table-cell;
-        }
-        td.blame > span {
-          opacity: 0.6;
-        }
-        td.blame > span.startOfRange {
-          opacity: 1;
-        }
-        td.blame .blameDate {
-          font-family: var(--monospace-font-family);
-          color: var(--link-color);
-          text-decoration: none;
-        }
-        .responsive td.blame {
-          overflow: hidden;
-          width: 200px;
-        }
-        /** Support the line length indicator **/
-        .responsive td.content .contentText {
-          /**
-            Same strategy as in
-            https://stackoverflow.com/questions/1179928/how-can-i-put-a-vertical-line-down-the-center-of-a-div
-          */
-          background-image: linear-gradient(
-            var(--line-length-indicator-color),
-            var(--line-length-indicator-color)
-          );
-          background-size: 1px 100%;
-          background-position: var(--line-limit-marker) 0;
-          background-repeat: no-repeat;
-        }
-        .newlineWarning {
-          color: var(--deemphasized-text-color);
-          text-align: center;
-        }
-        .newlineWarning.hidden {
-          display: none;
-        }
-        .lineNum.COVERED .lineNumButton {
-          color: var(
-            --coverage-covered-line-num-color,
-            var(--deemphasized-text-color)
-          );
-          background-color: var(--coverage-covered, #e0f2f1);
-        }
-        .lineNum.NOT_COVERED .lineNumButton {
-          color: var(
-            --coverage-covered-line-num-color,
-            var(--deemphasized-text-color)
-          );
-          background-color: var(--coverage-not-covered, #ffd1a4);
-        }
-        .lineNum.PARTIALLY_COVERED .lineNumButton {
-          color: var(
-            --coverage-covered-line-num-color,
-            var(--deemphasized-text-color)
-          );
-          background: linear-gradient(
-            to right bottom,
-            var(--coverage-not-covered, #ffd1a4) 0%,
-            var(--coverage-not-covered, #ffd1a4) 50%,
-            var(--coverage-covered, #e0f2f1) 50%,
-            var(--coverage-covered, #e0f2f1) 100%
-          );
-        }
-
-        // TODO: Investigate whether this CSS is still necessary.
-        /** BEGIN: Select and copy for Polymer 2 */
-        /**
-          Below was copied and modified from the original css in
-          gr-diff-selection.html
-        */
-        .content,
-        .contextControl,
-        .blame {
-          -webkit-user-select: none;
-          -moz-user-select: none;
-          -ms-user-select: none;
-          user-select: none;
-        }
-
-        .selected-left:not(.selected-comment)
-          .side-by-side
-          .left
-          + .content
-          .contentText,
-        .selected-right:not(.selected-comment)
-          .side-by-side
-          .right
-          + .content
-          .contentText,
-        .selected-left:not(.selected-comment)
-          .unified
-          .left.lineNum
-          ~ .content:not(.both)
-          .contentText,
-        .selected-right:not(.selected-comment)
-          .unified
-          .right.lineNum
-          ~ .content
-          .contentText,
-        .selected-left.selected-comment .side-by-side .left + .content .message,
-        .selected-right.selected-comment
-          .side-by-side
-          .right
-          + .content
-          .message
-          :not(.collapsedContent),
-        .selected-comment .unified .message :not(.collapsedContent),
-        .selected-blame .blame {
-          -webkit-user-select: text;
-          -moz-user-select: text;
-          -ms-user-select: text;
-          user-select: text;
-        }
-
-        /** Make comments and check results selectable when selected */
-        .selected-left.selected-comment
-          ::slotted(.comment-thread[diff-side='left']),
-        .selected-right.selected-comment
-          ::slotted(.comment-thread[diff-side='right']) {
-          -webkit-user-select: text;
-          -moz-user-select: text;
-          -ms-user-select: text;
-          user-select: text;
-        }
-        /** END: Select and copy for Polymer 2 */
-
-        .whitespace-change-only-message {
-          background-color: var(--diff-context-control-background-color);
-          border: 1px solid var(--diff-context-control-border-color);
-          text-align: center;
-        }
-
-        .token-highlight {
-          background-color: var(--token-highlighting-color, #fffd54);
-        }
-
-        gr-selection-action-box {
-          /**
-          * Needs z-index to appear above wrapped content, since it's inserted
-          * into DOM before it.
-          */
-          z-index: 10;
-        }
-      `,
+      grDiffStyles,
     ];
   }
 
   constructor() {
     super();
-    this.addEventListener('create-range-comment', (e: Event) =>
-      this.handleCreateRangeComment(e as CustomEvent)
+    provide(this, diffModelToken, () => this.diffModel);
+    this.addEventListener(
+      'create-range-comment',
+      (e: CustomEvent<CreateRangeCommentEventDetail>) =>
+        this.handleCreateRangeComment(e)
     );
     this.addEventListener('render-content', () => this.handleRenderContent());
     this.addEventListener('moved-link-clicked', (e: MovedLinkClickedEvent) => {
@@ -986,6 +302,13 @@
     if (this.loggedIn) {
       this.addSelectionListeners();
     }
+    if (this.diff && this.diffTable) {
+      this.diffSelection.init(this.diff, this.diffTable);
+    }
+    if (this.diffTable && this.diffBuilder) {
+      this.highlights.init(this.diffTable, this.diffBuilder);
+    }
+    this.diffBuilder.init();
   }
 
   override disconnectedCallback() {
@@ -993,7 +316,7 @@
     this.renderDiffTableTask?.cancel();
     this.diffSelection.cleanup();
     this.highlights.cleanup();
-    this.diffBuilder.cancel();
+    this.diffBuilder.cleanup();
     super.disconnectedCallback();
   }
 
@@ -1022,9 +345,6 @@
     }
     if (changedProperties.has('coverageRanges')) {
       this.diffBuilder.updateCoverageRanges(this.coverageRanges);
-      if (this.diff) {
-        this.debounceRenderDiffTable();
-      }
     }
     if (changedProperties.has('lineOfInterest')) {
       this.lineOfInterestChanged();
@@ -1061,9 +381,7 @@
       diffContainer: true,
       unified: this.viewMode === DiffViewMode.UNIFIED,
       sideBySide: this.viewMode === DiffViewMode.SIDE_BY_SIDE,
-      hiddenscroll: !!getHiddenScroll(),
       canComment: this.loggedIn,
-      displayLine: this.displayLine,
     };
     return html`
       <div class=${classMap(cssClasses)} @click=${this.handleTap}>
@@ -1087,24 +405,20 @@
 
   private renderNewlineWarning() {
     const newlineWarning = this.computeNewlineWarning();
-    const newlineWarningClass = this.computeNewlineWarningClass(
-      !!newlineWarning
-    );
-    return html` <div class=${newlineWarningClass}>${newlineWarning}</div> `;
+    if (!newlineWarning) return nothing;
+    return html`<div class="newlineWarning">${newlineWarning}</div>`;
   }
 
   private renderLoadingError() {
-    return html`
-      <div id="loadingError" class=${this.errorMessage ? 'showError' : ''}>
-        ${this.errorMessage}
-      </div>
-    `;
+    if (!this.errorMessage) return nothing;
+    return html`<div id="loadingError">${this.errorMessage}</div>`;
   }
 
   private renderSizeWarning() {
+    if (!this.showWarning) return nothing;
     // TODO: Update comment about 'Whole file' as it's not in settings.
     return html`
-      <div id="sizeWarning" class=${this.showWarning ? 'warn' : ''}>
+      <div id="sizeWarning">
         <p>
           Prevented render because "Whole file" is enabled and this diff is very
           large (about ${this.diffLength} lines).
@@ -1253,16 +567,16 @@
     threadEl: GrDiffThreadElement
   ) {
     hoverEl.addEventListener('mouseenter', () => {
-      fireEvent(threadEl, 'comment-thread-mouseenter');
+      fire(threadEl, 'comment-thread-mouseenter', {});
     });
     hoverEl.addEventListener('mouseleave', () => {
-      fireEvent(threadEl, 'comment-thread-mouseleave');
+      fire(threadEl, 'comment-thread-mouseleave', {});
     });
   }
 
   /** Cancel any remaining diff builder rendering work. */
   cancel() {
-    this.diffBuilder.cancel();
+    this.diffBuilder.cleanup();
     this.renderDiffTableTask?.cancel();
   }
 
@@ -1328,17 +642,11 @@
   }
 
   private dispatchSelectedLine(number: LineNumber, side: Side) {
-    this.dispatchEvent(
-      new CustomEvent('line-selected', {
-        detail: {
-          number,
-          side,
-          path: this.path,
-        },
-        composed: true,
-        bubbles: true,
-      })
-    );
+    fire(this, 'line-selected', {
+      number,
+      side,
+      path: this.path,
+    });
   }
 
   addDraftAtLine(el: Element) {
@@ -1371,7 +679,9 @@
     }
   }
 
-  private handleCreateRangeComment(e: CustomEvent) {
+  private handleCreateRangeComment(
+    e: CustomEvent<CreateRangeCommentEventDetail>
+  ) {
     const range = e.detail.range;
     const side = e.detail.side;
     this.createCommentForSelection(side, range);
@@ -1388,35 +698,12 @@
     if (!contentEl) throw new Error('content el not found for line el');
     side = side ?? this.getCommentSideByLineAndContent(lineEl, contentEl);
     assertIsDefined(this.path, 'path');
-    this.dispatchEvent(
-      new CustomEvent<CreateCommentEventDetail>('create-comment', {
-        bubbles: true,
-        composed: true,
-        detail: {
-          path: this.path,
-          side,
-          lineNum,
-          range,
-        },
-      })
-    );
-  }
-
-  /**
-   * Gets or creates a comment thread group for a specific line and side on a
-   * diff.
-   * Private but used in tests.
-   */
-  getOrCreateThreadGroup(contentEl: Element, commentSide: Side) {
-    // Check if thread group exists.
-    let threadGroupEl = contentEl.querySelector('.thread-group');
-    if (!threadGroupEl) {
-      threadGroupEl = document.createElement('div');
-      threadGroupEl.className = 'thread-group';
-      threadGroupEl.setAttribute('data-side', commentSide);
-      contentEl.appendChild(threadGroupEl);
-    }
-    return threadGroupEl;
+    fire(this, 'create-comment', {
+      path: this.path,
+      side,
+      lineNum,
+      range,
+    });
   }
 
   private getCommentSideByLineAndContent(
@@ -1447,6 +734,7 @@
 
   private prefsChanged() {
     if (!this.prefs) return;
+    this.diffModel.updateState({diffPrefs: this.prefs});
 
     this.blame = null;
     this.updatePreferenceStyles();
@@ -1517,7 +805,7 @@
   }
 
   private renderPrefsChanged() {
-    if (!this.renderPrefs) return;
+    this.diffModel.updateState({renderPrefs: this.renderPrefs});
     if (this.renderPrefs.hide_left_side) {
       this.classList.add('no-left');
     }
@@ -1567,10 +855,10 @@
     // (client), although it was not actually rendered. Clients need to know
     // when it is safe to perform operations like cursor moves, for example,
     // and if changing an input actually requires a reload of the diff table.
-    // Since `fireEvent` is synchronous it allows clients to be aware when an
+    // Since `fire` is synchronous it allows clients to be aware when an
     // async render is needed and that they can wait for a further `render`
     // event to actually take further action.
-    fireEvent(this, 'render-required');
+    fire(this, 'render-required', {});
     this.renderDiffTableTask = debounceP(
       this.renderDiffTableTask,
       async () => await this.renderDiffTable()
@@ -1584,8 +872,8 @@
   // Private but used in tests.
   async renderDiffTable() {
     this.unobserveNodes();
-    if (!this.prefs) {
-      fireEvent(this, 'render');
+    if (!this.diff || !this.prefs) {
+      fire(this, 'render', {});
       return;
     }
     if (
@@ -1595,7 +883,7 @@
       this.safetyBypass === null
     ) {
       this.showWarning = true;
-      fireEvent(this, 'render');
+      fire(this, 'render', {});
       return;
     }
 
@@ -1603,8 +891,15 @@
 
     const keyLocations = this.computeKeyLocations();
 
+    this.diffModel.setState({
+      diff: this.diff,
+      path: this.path,
+      renderPrefs: this.renderPrefs,
+      diffPrefs: this.prefs,
+    });
+
     // TODO: Setting tons of public properties like this is obviously a code
-    // smell. We are planning to introduce a diff model for managing all this
+    // smell. We are introducing a diff model for managing all this
     // data. Then diff builder will only need access to that model.
     this.diffBuilder.prefs = this.getBypassPrefs();
     this.diffBuilder.renderPrefs = this.renderPrefs;
@@ -1632,7 +927,7 @@
     this.observeNodes();
     // We are just converting 'render-content' into 'render' here. Maybe we
     // should retire the 'render' event in favor of 'render-content'?
-    fireEvent(this, 'render');
+    fire(this, 'render', {});
   }
 
   private observeNodes() {
@@ -1685,10 +980,9 @@
       }
       const contentEl = this.diffBuilder.getContentTdByLineEl(lineEl);
       if (!contentEl) continue;
-      if (lineNum === 'LOST' && !contentEl.hasChildNodes()) {
-        contentEl.appendChild(this.portedCommentsWithoutRangeMessage());
+      if (lineNum === 'LOST') {
+        this.insertPortedCommentsWithoutRangeMessage(contentEl);
       }
-      const threadGroupEl = this.getOrCreateThreadGroup(contentEl, commentSide);
 
       const slotAtt = threadEl.getAttribute('slot');
       if (range && isLongCommentRange(range) && slotAtt) {
@@ -1701,16 +995,6 @@
         this.insertBefore(longRangeCommentHint, threadEl);
         this.redispatchHoverEvents(longRangeCommentHint, threadEl);
       }
-
-      // Create a slot for the thread and attach it to the thread group.
-      // The Polyfill has some bugs and this only works if the slot is
-      // attached to the group after the group is attached to the DOM.
-      // The thread group may already have a slot with the right name, but
-      // that is okay because the first matching slot is used and the rest
-      // are ignored.
-      const slot = document.createElement('slot');
-      if (slotAtt) slot.name = slotAtt;
-      threadGroupEl.appendChild(slot);
     }
 
     for (const threadEl of removedThreadEls) {
@@ -1733,15 +1017,19 @@
     this.commentRanges = [];
   }
 
-  private portedCommentsWithoutRangeMessage() {
+  private insertPortedCommentsWithoutRangeMessage(lostCell: Element) {
+    const existingMessage = lostCell.querySelector('div.lost-message');
+    if (existingMessage) return;
+
     const div = document.createElement('div');
+    div.className = 'lost-message';
     const icon = document.createElement('gr-icon');
     icon.setAttribute('icon', 'info');
     div.appendChild(icon);
     const span = document.createElement('span');
     span.innerText = 'Original comment position not found in this patchset';
     div.appendChild(span);
-    return div;
+    lostCell.insertBefore(div, lostCell.firstChild);
   }
 
   /**
@@ -1765,19 +1053,18 @@
 
   // Private but used in tests.
   computeDiffHeaderItems() {
-    if (!this.diff || !this.diff.diff_header) {
-      return [];
-    }
-    return this.diff.diff_header.filter(
-      item =>
-        !(
-          item.startsWith('diff --git ') ||
-          item.startsWith('index ') ||
-          item.startsWith('+++ ') ||
-          item.startsWith('--- ') ||
-          item === 'Binary files differ'
-        )
-    );
+    return (this.diff?.diff_header ?? [])
+      .filter(
+        item =>
+          !(
+            item.startsWith('diff --git ') ||
+            item.startsWith('index ') ||
+            item.startsWith('+++ ') ||
+            item.startsWith('--- ') ||
+            item === 'Binary files differ'
+          )
+      )
+      .map(expandFileMode);
   }
 
   private handleFullBypass() {
@@ -1805,7 +1092,7 @@
     }
   }
 
-  private computeNewlineWarning() {
+  private computeNewlineWarning(): string | undefined {
     const messages = [];
     if (this.showNewlineWarningLeft) {
       messages.push(NO_NEWLINE_LEFT);
@@ -1814,18 +1101,10 @@
       messages.push(NO_NEWLINE_RIGHT);
     }
     if (!messages.length) {
-      return null;
+      return undefined;
     }
     return messages.join(' \u2014 '); // \u2014 - '—'
   }
-
-  // Private but used in tests.
-  computeNewlineWarningClass(warning: boolean) {
-    if (this.loading || !warning) {
-      return 'newlineWarning hidden';
-    }
-    return 'newlineWarning';
-  }
 }
 
 function extractAddedNodes(mutations: MutationRecord[]) {
@@ -1841,6 +1120,9 @@
     'gr-diff': GrDiff;
   }
   interface HTMLElementEventMap {
+    'comment-thread-mouseenter': CustomEvent<{}>;
+    'comment-thread-mouseleave': CustomEvent<{}>;
     'loading-changed': ValueChangedEvent<boolean>;
+    'render-required': CustomEvent<{}>;
   }
 }
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_test.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_test.ts
index 59bfc8d..f0826ad 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_test.ts
@@ -6,9 +6,7 @@
 import '../../../test/common-test-setup';
 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 '@polymer/paper-button/paper-button';
 import {
   DiffContent,
@@ -57,6 +55,2943 @@
     element = await fixture<GrDiff>(html`<gr-diff></gr-diff>`);
   });
 
+  suite('rendering', () => {
+    test('empty diff', async () => {
+      await element.updateComplete;
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <div class="diffContainer sideBySide">
+            <table id="diffTable"></table>
+          </div>
+        `
+      );
+    });
+
+    test('a unified diff lit', async () => {
+      element.viewMode = DiffViewMode.UNIFIED;
+      element.prefs = {...MINIMAL_PREFS};
+      element.diff = createDiff();
+      await element.updateComplete;
+      await waitForEventOnce(element, 'render');
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <div class="diffContainer unified">
+            <table class="selected-right" id="diffTable">
+              <colgroup>
+                <col class="blame gr-diff" />
+                <col class="gr-diff" width="48" />
+                <col class="gr-diff" width="48" />
+                <col class="gr-diff" />
+              </colgroup>
+              <tbody class="both gr-diff section">
+                <tr
+                  aria-labelledby="left-button-LOST right-button-LOST right-content-LOST"
+                  class="both diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="LOST"></td>
+                  <td class="gr-diff left lineNum" data-value="LOST"></td>
+                  <td class="gr-diff lineNum right" data-value="LOST"></td>
+                  <td class="both content gr-diff lost no-intraline-info right">
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+              </tbody>
+              <tbody class="both gr-diff section">
+                <tr
+                  aria-labelledby="left-button-FILE right-button-FILE right-content-FILE"
+                  class="both diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="FILE"></td>
+                  <td class="gr-diff left lineNum" data-value="FILE">
+                    <button
+                      aria-label="Add file comment"
+                      class="gr-diff left lineNumButton"
+                      data-value="FILE"
+                      id="left-button-FILE"
+                      tabindex="-1"
+                    >
+                      File
+                    </button>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="FILE">
+                    <button
+                      aria-label="Add file comment"
+                      class="gr-diff lineNumButton right"
+                      data-value="FILE"
+                      id="right-button-FILE"
+                      tabindex="-1"
+                    >
+                      File
+                    </button>
+                  </td>
+                  <td class="both content file gr-diff no-intraline-info right">
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+              </tbody>
+              <tbody class="both gr-diff section">
+                <tr
+                  aria-labelledby="left-button-1 right-button-1 right-content-1"
+                  class="both diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="1"></td>
+                  <td class="gr-diff left lineNum" data-value="1">
+                    <button
+                      aria-label="1 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="1"
+                      id="left-button-1"
+                      tabindex="-1"
+                    >
+                      1
+                    </button>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="1">
+                    <button
+                      aria-label="1 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="1"
+                      id="right-button-1"
+                      tabindex="-1"
+                    >
+                      1
+                    </button>
+                  </td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-1"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-2 right-button-2 right-content-2"
+                  class="both diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="2"></td>
+                  <td class="gr-diff left lineNum" data-value="2">
+                    <button
+                      aria-label="2 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="2"
+                      id="left-button-2"
+                      tabindex="-1"
+                    >
+                      2
+                    </button>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="2">
+                    <button
+                      aria-label="2 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="2"
+                      id="right-button-2"
+                      tabindex="-1"
+                    >
+                      2
+                    </button>
+                  </td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-2"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-3 right-button-3 right-content-3"
+                  class="both diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="3"></td>
+                  <td class="gr-diff left lineNum" data-value="3">
+                    <button
+                      aria-label="3 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="3"
+                      id="left-button-3"
+                      tabindex="-1"
+                    >
+                      3
+                    </button>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="3">
+                    <button
+                      aria-label="3 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="3"
+                      id="right-button-3"
+                      tabindex="-1"
+                    >
+                      3
+                    </button>
+                  </td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-3"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-4 right-button-4 right-content-4"
+                  class="both diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="4"></td>
+                  <td class="gr-diff left lineNum" data-value="4">
+                    <button
+                      aria-label="4 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="4"
+                      id="left-button-4"
+                      tabindex="-1"
+                    >
+                      4
+                    </button>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="4">
+                    <button
+                      aria-label="4 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="4"
+                      id="right-button-4"
+                      tabindex="-1"
+                    >
+                      4
+                    </button>
+                  </td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-4"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+              </tbody>
+              <tbody class="delta gr-diff section total">
+                <tr
+                  aria-labelledby="right-button-5 right-content-5"
+                  class="add diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="0"></td>
+                  <td class="gr-diff left"></td>
+                  <td class="gr-diff lineNum right" data-value="5">
+                    <button
+                      aria-label="5 added"
+                      class="gr-diff lineNumButton right"
+                      data-value="5"
+                      id="right-button-5"
+                      tabindex="-1"
+                    >
+                      5
+                    </button>
+                  </td>
+                  <td class="add content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-5"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="right-button-6 right-content-6"
+                  class="add diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="0"></td>
+                  <td class="gr-diff left"></td>
+                  <td class="gr-diff lineNum right" data-value="6">
+                    <button
+                      aria-label="6 added"
+                      class="gr-diff lineNumButton right"
+                      data-value="6"
+                      id="right-button-6"
+                      tabindex="-1"
+                    >
+                      6
+                    </button>
+                  </td>
+                  <td class="add content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-6"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="right-button-7 right-content-7"
+                  class="add diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="0"></td>
+                  <td class="gr-diff left"></td>
+                  <td class="gr-diff lineNum right" data-value="7">
+                    <button
+                      aria-label="7 added"
+                      class="gr-diff lineNumButton right"
+                      data-value="7"
+                      id="right-button-7"
+                      tabindex="-1"
+                    >
+                      7
+                    </button>
+                  </td>
+                  <td class="add content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-7"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+              </tbody>
+              <tbody class="both gr-diff section">
+                <tr
+                  aria-labelledby="left-button-5 right-button-8 right-content-8"
+                  class="both diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="5"></td>
+                  <td class="gr-diff left lineNum" data-value="5">
+                    <button
+                      aria-label="5 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="5"
+                      id="left-button-5"
+                      tabindex="-1"
+                    >
+                      5
+                    </button>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="8">
+                    <button
+                      aria-label="8 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="8"
+                      id="right-button-8"
+                      tabindex="-1"
+                    >
+                      8
+                    </button>
+                  </td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-8"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-6 right-button-9 right-content-9"
+                  class="both diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="6"></td>
+                  <td class="gr-diff left lineNum" data-value="6">
+                    <button
+                      aria-label="6 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="6"
+                      id="left-button-6"
+                      tabindex="-1"
+                    >
+                      6
+                    </button>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="9">
+                    <button
+                      aria-label="9 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="9"
+                      id="right-button-9"
+                      tabindex="-1"
+                    >
+                      9
+                    </button>
+                  </td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-9"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-7 right-button-10 right-content-10"
+                  class="both diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="7"></td>
+                  <td class="gr-diff left lineNum" data-value="7">
+                    <button
+                      aria-label="7 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="7"
+                      id="left-button-7"
+                      tabindex="-1"
+                    >
+                      7
+                    </button>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="10">
+                    <button
+                      aria-label="10 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="10"
+                      id="right-button-10"
+                      tabindex="-1"
+                    >
+                      10
+                    </button>
+                  </td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-10"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-8 right-button-11 right-content-11"
+                  class="both diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="8"></td>
+                  <td class="gr-diff left lineNum" data-value="8">
+                    <button
+                      aria-label="8 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="8"
+                      id="left-button-8"
+                      tabindex="-1"
+                    >
+                      8
+                    </button>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="11">
+                    <button
+                      aria-label="11 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="11"
+                      id="right-button-11"
+                      tabindex="-1"
+                    >
+                      11
+                    </button>
+                  </td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-11"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-9 right-button-12 right-content-12"
+                  class="both diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="9"></td>
+                  <td class="gr-diff left lineNum" data-value="9">
+                    <button
+                      aria-label="9 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="9"
+                      id="left-button-9"
+                      tabindex="-1"
+                    >
+                      9
+                    </button>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="12">
+                    <button
+                      aria-label="12 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="12"
+                      id="right-button-12"
+                      tabindex="-1"
+                    >
+                      12
+                    </button>
+                  </td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-12"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+              </tbody>
+              <tbody class="delta gr-diff section total">
+                <tr
+                  aria-labelledby="left-button-10 left-content-10"
+                  class="diff-row gr-diff remove unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="10"></td>
+                  <td class="gr-diff left lineNum" data-value="10">
+                    <button
+                      aria-label="10 removed"
+                      class="gr-diff left lineNumButton"
+                      data-value="10"
+                      id="left-button-10"
+                      tabindex="-1"
+                    >
+                      10
+                    </button>
+                  </td>
+                  <td class="gr-diff right"></td>
+                  <td class="content gr-diff left no-intraline-info remove">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-10"
+                    ></div>
+                    <div class="thread-group" data-side="left"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-11 left-content-11"
+                  class="diff-row gr-diff remove unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="11"></td>
+                  <td class="gr-diff left lineNum" data-value="11">
+                    <button
+                      aria-label="11 removed"
+                      class="gr-diff left lineNumButton"
+                      data-value="11"
+                      id="left-button-11"
+                      tabindex="-1"
+                    >
+                      11
+                    </button>
+                  </td>
+                  <td class="gr-diff right"></td>
+                  <td class="content gr-diff left no-intraline-info remove">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-11"
+                    ></div>
+                    <div class="thread-group" data-side="left"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-12 left-content-12"
+                  class="diff-row gr-diff remove unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="12"></td>
+                  <td class="gr-diff left lineNum" data-value="12">
+                    <button
+                      aria-label="12 removed"
+                      class="gr-diff left lineNumButton"
+                      data-value="12"
+                      id="left-button-12"
+                      tabindex="-1"
+                    >
+                      12
+                    </button>
+                  </td>
+                  <td class="gr-diff right"></td>
+                  <td class="content gr-diff left no-intraline-info remove">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-12"
+                    ></div>
+                    <div class="thread-group" data-side="left"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-13 left-content-13"
+                  class="diff-row gr-diff remove unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="13"></td>
+                  <td class="gr-diff left lineNum" data-value="13">
+                    <button
+                      aria-label="13 removed"
+                      class="gr-diff left lineNumButton"
+                      data-value="13"
+                      id="left-button-13"
+                      tabindex="-1"
+                    >
+                      13
+                    </button>
+                  </td>
+                  <td class="gr-diff right"></td>
+                  <td class="content gr-diff left no-intraline-info remove">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-13"
+                    ></div>
+                    <div class="thread-group" data-side="left"></div>
+                  </td>
+                </tr>
+              </tbody>
+              <tbody class="delta gr-diff ignoredWhitespaceOnly section">
+                <tr
+                  aria-labelledby="right-button-13 right-content-13"
+                  class="add diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="0"></td>
+                  <td class="gr-diff left"></td>
+                  <td class="gr-diff lineNum right" data-value="13">
+                    <button
+                      aria-label="13 added"
+                      class="gr-diff lineNumButton right"
+                      data-value="13"
+                      id="right-button-13"
+                      tabindex="-1"
+                    >
+                      13
+                    </button>
+                  </td>
+                  <td class="add content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-13"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="right-button-14 right-content-14"
+                  class="add diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="0"></td>
+                  <td class="gr-diff left"></td>
+                  <td class="gr-diff lineNum right" data-value="14">
+                    <button
+                      aria-label="14 added"
+                      class="gr-diff lineNumButton right"
+                      data-value="14"
+                      id="right-button-14"
+                      tabindex="-1"
+                    >
+                      14
+                    </button>
+                  </td>
+                  <td class="add content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-14"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+              </tbody>
+              <tbody class="delta gr-diff section">
+                <tr
+                  aria-labelledby="left-button-16 left-content-16"
+                  class="diff-row gr-diff remove unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="16"></td>
+                  <td class="gr-diff left lineNum" data-value="16">
+                    <button
+                      aria-label="16 removed"
+                      class="gr-diff left lineNumButton"
+                      data-value="16"
+                      id="left-button-16"
+                      tabindex="-1"
+                    >
+                      16
+                    </button>
+                  </td>
+                  <td class="gr-diff right"></td>
+                  <td class="content gr-diff left remove">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-16"
+                    ></div>
+                    <div class="thread-group" data-side="left"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="right-button-15 right-content-15"
+                  class="add diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="0"></td>
+                  <td class="gr-diff left"></td>
+                  <td class="gr-diff lineNum right" data-value="15">
+                    <button
+                      aria-label="15 added"
+                      class="gr-diff lineNumButton right"
+                      data-value="15"
+                      id="right-button-15"
+                      tabindex="-1"
+                    >
+                      15
+                    </button>
+                  </td>
+                  <td class="add content gr-diff right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-15"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+              </tbody>
+              <tbody class="both gr-diff section">
+                <tr
+                  aria-labelledby="left-button-17 right-button-16 right-content-16"
+                  class="both diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="17"></td>
+                  <td class="gr-diff left lineNum" data-value="17">
+                    <button
+                      aria-label="17 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="17"
+                      id="left-button-17"
+                      tabindex="-1"
+                    >
+                      17
+                    </button>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="16">
+                    <button
+                      aria-label="16 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="16"
+                      id="right-button-16"
+                      tabindex="-1"
+                    >
+                      16
+                    </button>
+                  </td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-16"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-18 right-button-17 right-content-17"
+                  class="both diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="18"></td>
+                  <td class="gr-diff left lineNum" data-value="18">
+                    <button
+                      aria-label="18 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="18"
+                      id="left-button-18"
+                      tabindex="-1"
+                    >
+                      18
+                    </button>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="17">
+                    <button
+                      aria-label="17 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="17"
+                      id="right-button-17"
+                      tabindex="-1"
+                    >
+                      17
+                    </button>
+                  </td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-17"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-19 right-button-18 right-content-18"
+                  class="both diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="19"></td>
+                  <td class="gr-diff left lineNum" data-value="19">
+                    <button
+                      aria-label="19 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="19"
+                      id="left-button-19"
+                      tabindex="-1"
+                    >
+                      19
+                    </button>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="18">
+                    <button
+                      aria-label="18 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="18"
+                      id="right-button-18"
+                      tabindex="-1"
+                    >
+                      18
+                    </button>
+                  </td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-18"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+              </tbody>
+              <tbody class="contextControl gr-diff section">
+                <tr class="above contextBackground gr-diff unified">
+                  <td class="blame gr-diff" data-line-number="0"></td>
+                  <td class="contextLineNum gr-diff"></td>
+                  <td class="contextLineNum gr-diff"></td>
+                  <td class="gr-diff"></td>
+                </tr>
+                <tr class="dividerRow gr-diff show-both">
+                  <td class="blame gr-diff" data-line-number="0"></td>
+                  <td class="dividerCell gr-diff" colspan="3">
+                    <gr-context-controls class="gr-diff" showconfig="both">
+                    </gr-context-controls>
+                  </td>
+                </tr>
+                <tr class="below contextBackground gr-diff unified">
+                  <td class="blame gr-diff" data-line-number="0"></td>
+                  <td class="contextLineNum gr-diff"></td>
+                  <td class="contextLineNum gr-diff"></td>
+                  <td class="gr-diff"></td>
+                </tr>
+              </tbody>
+              <tbody class="both gr-diff section">
+                <tr
+                  aria-labelledby="left-button-38 right-button-37 right-content-37"
+                  class="both diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="38"></td>
+                  <td class="gr-diff left lineNum" data-value="38">
+                    <button
+                      aria-label="38 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="38"
+                      id="left-button-38"
+                      tabindex="-1"
+                    >
+                      38
+                    </button>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="37">
+                    <button
+                      aria-label="37 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="37"
+                      id="right-button-37"
+                      tabindex="-1"
+                    >
+                      37
+                    </button>
+                  </td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-37"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-39 right-button-38 right-content-38"
+                  class="both diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="39"></td>
+                  <td class="gr-diff left lineNum" data-value="39">
+                    <button
+                      aria-label="39 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="39"
+                      id="left-button-39"
+                      tabindex="-1"
+                    >
+                      39
+                    </button>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="38">
+                    <button
+                      aria-label="38 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="38"
+                      id="right-button-38"
+                      tabindex="-1"
+                    >
+                      38
+                    </button>
+                  </td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-38"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-40 right-button-39 right-content-39"
+                  class="both diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="40"></td>
+                  <td class="gr-diff left lineNum" data-value="40">
+                    <button
+                      aria-label="40 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="40"
+                      id="left-button-40"
+                      tabindex="-1"
+                    >
+                      40
+                    </button>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="39">
+                    <button
+                      aria-label="39 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="39"
+                      id="right-button-39"
+                      tabindex="-1"
+                    >
+                      39
+                    </button>
+                  </td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-39"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+              </tbody>
+              <tbody class="delta gr-diff section total">
+                <tr
+                  aria-labelledby="right-button-40 right-content-40"
+                  class="add diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="0"></td>
+                  <td class="gr-diff left"></td>
+                  <td class="gr-diff lineNum right" data-value="40">
+                    <button
+                      aria-label="40 added"
+                      class="gr-diff lineNumButton right"
+                      data-value="40"
+                      id="right-button-40"
+                      tabindex="-1"
+                    >
+                      40
+                    </button>
+                  </td>
+                  <td class="add content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-40"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="right-button-41 right-content-41"
+                  class="add diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="0"></td>
+                  <td class="gr-diff left"></td>
+                  <td class="gr-diff lineNum right" data-value="41">
+                    <button
+                      aria-label="41 added"
+                      class="gr-diff lineNumButton right"
+                      data-value="41"
+                      id="right-button-41"
+                      tabindex="-1"
+                    >
+                      41
+                    </button>
+                  </td>
+                  <td class="add content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-41"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="right-button-42 right-content-42"
+                  class="add diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="0"></td>
+                  <td class="gr-diff left"></td>
+                  <td class="gr-diff lineNum right" data-value="42">
+                    <button
+                      aria-label="42 added"
+                      class="gr-diff lineNumButton right"
+                      data-value="42"
+                      id="right-button-42"
+                      tabindex="-1"
+                    >
+                      42
+                    </button>
+                  </td>
+                  <td class="add content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-42"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="right-button-43 right-content-43"
+                  class="add diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="0"></td>
+                  <td class="gr-diff left"></td>
+                  <td class="gr-diff lineNum right" data-value="43">
+                    <button
+                      aria-label="43 added"
+                      class="gr-diff lineNumButton right"
+                      data-value="43"
+                      id="right-button-43"
+                      tabindex="-1"
+                    >
+                      43
+                    </button>
+                  </td>
+                  <td class="add content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-43"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+              </tbody>
+              <tbody class="both gr-diff section">
+                <tr
+                  aria-labelledby="left-button-41 right-button-44 right-content-44"
+                  class="both diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="41"></td>
+                  <td class="gr-diff left lineNum" data-value="41">
+                    <button
+                      aria-label="41 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="41"
+                      id="left-button-41"
+                      tabindex="-1"
+                    >
+                      41
+                    </button>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="44">
+                    <button
+                      aria-label="44 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="44"
+                      id="right-button-44"
+                      tabindex="-1"
+                    >
+                      44
+                    </button>
+                  </td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-44"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-42 right-button-45 right-content-45"
+                  class="both diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="42"></td>
+                  <td class="gr-diff left lineNum" data-value="42">
+                    <button
+                      aria-label="42 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="42"
+                      id="left-button-42"
+                      tabindex="-1"
+                    >
+                      42
+                    </button>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="45">
+                    <button
+                      aria-label="45 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="45"
+                      id="right-button-45"
+                      tabindex="-1"
+                    >
+                      45
+                    </button>
+                  </td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-45"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-43 right-button-46 right-content-46"
+                  class="both diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="43"></td>
+                  <td class="gr-diff left lineNum" data-value="43">
+                    <button
+                      aria-label="43 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="43"
+                      id="left-button-43"
+                      tabindex="-1"
+                    >
+                      43
+                    </button>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="46">
+                    <button
+                      aria-label="46 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="46"
+                      id="right-button-46"
+                      tabindex="-1"
+                    >
+                      46
+                    </button>
+                  </td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-46"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-44 right-button-47 right-content-47"
+                  class="both diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="44"></td>
+                  <td class="gr-diff left lineNum" data-value="44">
+                    <button
+                      aria-label="44 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="44"
+                      id="left-button-44"
+                      tabindex="-1"
+                    >
+                      44
+                    </button>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="47">
+                    <button
+                      aria-label="47 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="47"
+                      id="right-button-47"
+                      tabindex="-1"
+                    >
+                      47
+                    </button>
+                  </td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-47"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-45 right-button-48 right-content-48"
+                  class="both diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="45"></td>
+                  <td class="gr-diff left lineNum" data-value="45">
+                    <button
+                      aria-label="45 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="45"
+                      id="left-button-45"
+                      tabindex="-1"
+                    >
+                      45
+                    </button>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="48">
+                    <button
+                      aria-label="48 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="48"
+                      id="right-button-48"
+                      tabindex="-1"
+                    >
+                      48
+                    </button>
+                  </td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-48"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+              </tbody>
+            </table>
+          </div>
+        `,
+        {
+          ignoreTags: [
+            'gr-context-controls-section',
+            'gr-diff-section',
+            'gr-diff-row',
+            'gr-diff-text',
+            'gr-legacy-text',
+            'slot',
+          ],
+        }
+      );
+    });
+
+    test('a normal diff lit', async () => {
+      element.prefs = {...MINIMAL_PREFS};
+      element.diff = createDiff();
+      await element.updateComplete;
+      await waitForEventOnce(element, 'render');
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <div class="diffContainer sideBySide">
+            <table class="selected-right" id="diffTable">
+              <colgroup>
+                <col class="blame gr-diff" />
+                <col class="gr-diff left" width="48" />
+                <col class="gr-diff left sign" />
+                <col class="gr-diff left" />
+                <col class="gr-diff right" width="48" />
+                <col class="gr-diff right sign" />
+                <col class="gr-diff right" />
+              </colgroup>
+              <tbody class="both gr-diff section">
+                <tr
+                  aria-labelledby="left-button-LOST left-content-LOST right-button-LOST right-content-LOST"
+                  class="diff-row gr-diff side-by-side"
+                  left-type="both"
+                  right-type="both"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="LOST"></td>
+                  <td class="gr-diff left lineNum" data-value="LOST"></td>
+                  <td class="gr-diff left no-intraline-info sign"></td>
+                  <td class="both content gr-diff left lost no-intraline-info">
+                    <div class="thread-group" data-side="left"></div>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="LOST"></td>
+                  <td class="gr-diff no-intraline-info right sign"></td>
+                  <td class="both content gr-diff lost no-intraline-info right">
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+              </tbody>
+              <tbody class="both gr-diff section">
+                <tr
+                  aria-labelledby="left-button-FILE left-content-FILE right-button-FILE right-content-FILE"
+                  class="diff-row gr-diff side-by-side"
+                  left-type="both"
+                  right-type="both"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="FILE"></td>
+                  <td class="gr-diff left lineNum" data-value="FILE">
+                    <button
+                      aria-label="Add file comment"
+                      class="gr-diff left lineNumButton"
+                      data-value="FILE"
+                      id="left-button-FILE"
+                      tabindex="-1"
+                    >
+                      File
+                    </button>
+                  </td>
+                  <td class="gr-diff left no-intraline-info sign"></td>
+                  <td class="both content file gr-diff left no-intraline-info">
+                    <div class="thread-group" data-side="left"></div>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="FILE">
+                    <button
+                      aria-label="Add file comment"
+                      class="gr-diff lineNumButton right"
+                      data-value="FILE"
+                      id="right-button-FILE"
+                      tabindex="-1"
+                    >
+                      File
+                    </button>
+                  </td>
+                  <td class="gr-diff no-intraline-info right sign"></td>
+                  <td class="both content file gr-diff no-intraline-info right">
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+              </tbody>
+              <tbody class="both gr-diff section">
+                <tr
+                  aria-labelledby="left-button-1 left-content-1 right-button-1 right-content-1"
+                  class="diff-row gr-diff side-by-side"
+                  left-type="both"
+                  right-type="both"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="1"></td>
+                  <td class="gr-diff left lineNum" data-value="1">
+                    <button
+                      aria-label="1 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="1"
+                      id="left-button-1"
+                      tabindex="-1"
+                    >
+                      1
+                    </button>
+                  </td>
+                  <td class="gr-diff left no-intraline-info sign"></td>
+                  <td class="both content gr-diff left no-intraline-info">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-1"
+                    ></div>
+                    <div class="thread-group" data-side="left"></div>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="1">
+                    <button
+                      aria-label="1 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="1"
+                      id="right-button-1"
+                      tabindex="-1"
+                    >
+                      1
+                    </button>
+                  </td>
+                  <td class="gr-diff no-intraline-info right sign"></td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-1"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-2 left-content-2 right-button-2 right-content-2"
+                  class="diff-row gr-diff side-by-side"
+                  left-type="both"
+                  right-type="both"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="2"></td>
+                  <td class="gr-diff left lineNum" data-value="2">
+                    <button
+                      aria-label="2 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="2"
+                      id="left-button-2"
+                      tabindex="-1"
+                    >
+                      2
+                    </button>
+                  </td>
+                  <td class="gr-diff left no-intraline-info sign"></td>
+                  <td class="both content gr-diff left no-intraline-info">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-2"
+                    ></div>
+                    <div class="thread-group" data-side="left"></div>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="2">
+                    <button
+                      aria-label="2 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="2"
+                      id="right-button-2"
+                      tabindex="-1"
+                    >
+                      2
+                    </button>
+                  </td>
+                  <td class="gr-diff no-intraline-info right sign"></td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-2"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-3 left-content-3 right-button-3 right-content-3"
+                  class="diff-row gr-diff side-by-side"
+                  left-type="both"
+                  right-type="both"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="3"></td>
+                  <td class="gr-diff left lineNum" data-value="3">
+                    <button
+                      aria-label="3 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="3"
+                      id="left-button-3"
+                      tabindex="-1"
+                    >
+                      3
+                    </button>
+                  </td>
+                  <td class="gr-diff left no-intraline-info sign"></td>
+                  <td class="both content gr-diff left no-intraline-info">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-3"
+                    ></div>
+                    <div class="thread-group" data-side="left"></div>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="3">
+                    <button
+                      aria-label="3 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="3"
+                      id="right-button-3"
+                      tabindex="-1"
+                    >
+                      3
+                    </button>
+                  </td>
+                  <td class="gr-diff no-intraline-info right sign"></td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-3"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-4 left-content-4 right-button-4 right-content-4"
+                  class="diff-row gr-diff side-by-side"
+                  left-type="both"
+                  right-type="both"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="4"></td>
+                  <td class="gr-diff left lineNum" data-value="4">
+                    <button
+                      aria-label="4 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="4"
+                      id="left-button-4"
+                      tabindex="-1"
+                    >
+                      4
+                    </button>
+                  </td>
+                  <td class="gr-diff left no-intraline-info sign"></td>
+                  <td class="both content gr-diff left no-intraline-info">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-4"
+                    ></div>
+                    <div class="thread-group" data-side="left"></div>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="4">
+                    <button
+                      aria-label="4 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="4"
+                      id="right-button-4"
+                      tabindex="-1"
+                    >
+                      4
+                    </button>
+                  </td>
+                  <td class="gr-diff no-intraline-info right sign"></td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-4"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+              </tbody>
+              <tbody class="delta gr-diff section total">
+                <tr
+                  aria-labelledby="right-button-5 right-content-5"
+                  class="diff-row gr-diff side-by-side"
+                  left-type="blank"
+                  right-type="add"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="0"></td>
+                  <td class="blankLineNum gr-diff left"></td>
+                  <td class="blank gr-diff left no-intraline-info sign"></td>
+                  <td class="blank gr-diff left no-intraline-info">
+                    <div class="contentText gr-diff" data-side="left"></div>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="5">
+                    <button
+                      aria-label="5 added"
+                      class="gr-diff lineNumButton right"
+                      data-value="5"
+                      id="right-button-5"
+                      tabindex="-1"
+                    >
+                      5
+                    </button>
+                  </td>
+                  <td class="add gr-diff no-intraline-info right sign">+</td>
+                  <td class="add content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-5"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="right-button-6 right-content-6"
+                  class="diff-row gr-diff side-by-side"
+                  left-type="blank"
+                  right-type="add"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="0"></td>
+                  <td class="blankLineNum gr-diff left"></td>
+                  <td class="blank gr-diff left no-intraline-info sign"></td>
+                  <td class="blank gr-diff left no-intraline-info">
+                    <div class="contentText gr-diff" data-side="left"></div>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="6">
+                    <button
+                      aria-label="6 added"
+                      class="gr-diff lineNumButton right"
+                      data-value="6"
+                      id="right-button-6"
+                      tabindex="-1"
+                    >
+                      6
+                    </button>
+                  </td>
+                  <td class="add gr-diff no-intraline-info right sign">+</td>
+                  <td class="add content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-6"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="right-button-7 right-content-7"
+                  class="diff-row gr-diff side-by-side"
+                  left-type="blank"
+                  right-type="add"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="0"></td>
+                  <td class="blankLineNum gr-diff left"></td>
+                  <td class="blank gr-diff left no-intraline-info sign"></td>
+                  <td class="blank gr-diff left no-intraline-info">
+                    <div class="contentText gr-diff" data-side="left"></div>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="7">
+                    <button
+                      aria-label="7 added"
+                      class="gr-diff lineNumButton right"
+                      data-value="7"
+                      id="right-button-7"
+                      tabindex="-1"
+                    >
+                      7
+                    </button>
+                  </td>
+                  <td class="add gr-diff no-intraline-info right sign">+</td>
+                  <td class="add content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-7"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+              </tbody>
+              <tbody class="both gr-diff section">
+                <tr
+                  aria-labelledby="left-button-5 left-content-5 right-button-8 right-content-8"
+                  class="diff-row gr-diff side-by-side"
+                  left-type="both"
+                  right-type="both"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="5"></td>
+                  <td class="gr-diff left lineNum" data-value="5">
+                    <button
+                      aria-label="5 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="5"
+                      id="left-button-5"
+                      tabindex="-1"
+                    >
+                      5
+                    </button>
+                  </td>
+                  <td class="gr-diff left no-intraline-info sign"></td>
+                  <td class="both content gr-diff left no-intraline-info">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-5"
+                    ></div>
+                    <div class="thread-group" data-side="left"></div>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="8">
+                    <button
+                      aria-label="8 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="8"
+                      id="right-button-8"
+                      tabindex="-1"
+                    >
+                      8
+                    </button>
+                  </td>
+                  <td class="gr-diff no-intraline-info right sign"></td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-8"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-6 left-content-6 right-button-9 right-content-9"
+                  class="diff-row gr-diff side-by-side"
+                  left-type="both"
+                  right-type="both"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="6"></td>
+                  <td class="gr-diff left lineNum" data-value="6">
+                    <button
+                      aria-label="6 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="6"
+                      id="left-button-6"
+                      tabindex="-1"
+                    >
+                      6
+                    </button>
+                  </td>
+                  <td class="gr-diff left no-intraline-info sign"></td>
+                  <td class="both content gr-diff left no-intraline-info">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-6"
+                    ></div>
+                    <div class="thread-group" data-side="left"></div>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="9">
+                    <button
+                      aria-label="9 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="9"
+                      id="right-button-9"
+                      tabindex="-1"
+                    >
+                      9
+                    </button>
+                  </td>
+                  <td class="gr-diff no-intraline-info right sign"></td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-9"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-7 left-content-7 right-button-10 right-content-10"
+                  class="diff-row gr-diff side-by-side"
+                  left-type="both"
+                  right-type="both"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="7"></td>
+                  <td class="gr-diff left lineNum" data-value="7">
+                    <button
+                      aria-label="7 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="7"
+                      id="left-button-7"
+                      tabindex="-1"
+                    >
+                      7
+                    </button>
+                  </td>
+                  <td class="gr-diff left no-intraline-info sign"></td>
+                  <td class="both content gr-diff left no-intraline-info">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-7"
+                    ></div>
+                    <div class="thread-group" data-side="left"></div>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="10">
+                    <button
+                      aria-label="10 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="10"
+                      id="right-button-10"
+                      tabindex="-1"
+                    >
+                      10
+                    </button>
+                  </td>
+                  <td class="gr-diff no-intraline-info right sign"></td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-10"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-8 left-content-8 right-button-11 right-content-11"
+                  class="diff-row gr-diff side-by-side"
+                  left-type="both"
+                  right-type="both"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="8"></td>
+                  <td class="gr-diff left lineNum" data-value="8">
+                    <button
+                      aria-label="8 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="8"
+                      id="left-button-8"
+                      tabindex="-1"
+                    >
+                      8
+                    </button>
+                  </td>
+                  <td class="gr-diff left no-intraline-info sign"></td>
+                  <td class="both content gr-diff left no-intraline-info">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-8"
+                    ></div>
+                    <div class="thread-group" data-side="left"></div>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="11">
+                    <button
+                      aria-label="11 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="11"
+                      id="right-button-11"
+                      tabindex="-1"
+                    >
+                      11
+                    </button>
+                  </td>
+                  <td class="gr-diff no-intraline-info right sign"></td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-11"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-9 left-content-9 right-button-12 right-content-12"
+                  class="diff-row gr-diff side-by-side"
+                  left-type="both"
+                  right-type="both"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="9"></td>
+                  <td class="gr-diff left lineNum" data-value="9">
+                    <button
+                      aria-label="9 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="9"
+                      id="left-button-9"
+                      tabindex="-1"
+                    >
+                      9
+                    </button>
+                  </td>
+                  <td class="gr-diff left no-intraline-info sign"></td>
+                  <td class="both content gr-diff left no-intraline-info">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-9"
+                    ></div>
+                    <div class="thread-group" data-side="left"></div>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="12">
+                    <button
+                      aria-label="12 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="12"
+                      id="right-button-12"
+                      tabindex="-1"
+                    >
+                      12
+                    </button>
+                  </td>
+                  <td class="gr-diff no-intraline-info right sign"></td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-12"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+              </tbody>
+              <tbody class="delta gr-diff section total">
+                <tr
+                  aria-labelledby="left-button-10 left-content-10"
+                  class="diff-row gr-diff side-by-side"
+                  left-type="remove"
+                  right-type="blank"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="10"></td>
+                  <td class="gr-diff left lineNum" data-value="10">
+                    <button
+                      aria-label="10 removed"
+                      class="gr-diff left lineNumButton"
+                      data-value="10"
+                      id="left-button-10"
+                      tabindex="-1"
+                    >
+                      10
+                    </button>
+                  </td>
+                  <td class="gr-diff left no-intraline-info remove sign">-</td>
+                  <td class="content gr-diff left no-intraline-info remove">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-10"
+                    ></div>
+                    <div class="thread-group" data-side="left"></div>
+                  </td>
+                  <td class="blankLineNum gr-diff right"></td>
+                  <td class="blank gr-diff no-intraline-info right sign"></td>
+                  <td class="blank gr-diff no-intraline-info right">
+                    <div class="contentText gr-diff" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-11 left-content-11"
+                  class="diff-row gr-diff side-by-side"
+                  left-type="remove"
+                  right-type="blank"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="11"></td>
+                  <td class="gr-diff left lineNum" data-value="11">
+                    <button
+                      aria-label="11 removed"
+                      class="gr-diff left lineNumButton"
+                      data-value="11"
+                      id="left-button-11"
+                      tabindex="-1"
+                    >
+                      11
+                    </button>
+                  </td>
+                  <td class="gr-diff left no-intraline-info remove sign">-</td>
+                  <td class="content gr-diff left no-intraline-info remove">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-11"
+                    ></div>
+                    <div class="thread-group" data-side="left"></div>
+                  </td>
+                  <td class="blankLineNum gr-diff right"></td>
+                  <td class="blank gr-diff no-intraline-info right sign"></td>
+                  <td class="blank gr-diff no-intraline-info right">
+                    <div class="contentText gr-diff" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-12 left-content-12"
+                  class="diff-row gr-diff side-by-side"
+                  left-type="remove"
+                  right-type="blank"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="12"></td>
+                  <td class="gr-diff left lineNum" data-value="12">
+                    <button
+                      aria-label="12 removed"
+                      class="gr-diff left lineNumButton"
+                      data-value="12"
+                      id="left-button-12"
+                      tabindex="-1"
+                    >
+                      12
+                    </button>
+                  </td>
+                  <td class="gr-diff left no-intraline-info remove sign">-</td>
+                  <td class="content gr-diff left no-intraline-info remove">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-12"
+                    ></div>
+                    <div class="thread-group" data-side="left"></div>
+                  </td>
+                  <td class="blankLineNum gr-diff right"></td>
+                  <td class="blank gr-diff no-intraline-info right sign"></td>
+                  <td class="blank gr-diff no-intraline-info right">
+                    <div class="contentText gr-diff" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-13 left-content-13"
+                  class="diff-row gr-diff side-by-side"
+                  left-type="remove"
+                  right-type="blank"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="13"></td>
+                  <td class="gr-diff left lineNum" data-value="13">
+                    <button
+                      aria-label="13 removed"
+                      class="gr-diff left lineNumButton"
+                      data-value="13"
+                      id="left-button-13"
+                      tabindex="-1"
+                    >
+                      13
+                    </button>
+                  </td>
+                  <td class="gr-diff left no-intraline-info remove sign">-</td>
+                  <td class="content gr-diff left no-intraline-info remove">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-13"
+                    ></div>
+                    <div class="thread-group" data-side="left"></div>
+                  </td>
+                  <td class="blankLineNum gr-diff right"></td>
+                  <td class="blank gr-diff no-intraline-info right sign"></td>
+                  <td class="blank gr-diff no-intraline-info right">
+                    <div class="contentText gr-diff" data-side="right"></div>
+                  </td>
+                </tr>
+              </tbody>
+              <tbody class="delta gr-diff ignoredWhitespaceOnly section">
+                <tr
+                  aria-labelledby="left-button-14 left-content-14 right-button-13 right-content-13"
+                  class="diff-row gr-diff side-by-side"
+                  left-type="remove"
+                  right-type="add"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="14"></td>
+                  <td class="gr-diff left lineNum" data-value="14">
+                    <button
+                      aria-label="14 removed"
+                      class="gr-diff left lineNumButton"
+                      data-value="14"
+                      id="left-button-14"
+                      tabindex="-1"
+                    >
+                      14
+                    </button>
+                  </td>
+                  <td class="gr-diff left no-intraline-info remove sign">-</td>
+                  <td class="content gr-diff left no-intraline-info remove">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-14"
+                    ></div>
+                    <div class="thread-group" data-side="left"></div>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="13">
+                    <button
+                      aria-label="13 added"
+                      class="gr-diff lineNumButton right"
+                      data-value="13"
+                      id="right-button-13"
+                      tabindex="-1"
+                    >
+                      13
+                    </button>
+                  </td>
+                  <td class="add gr-diff no-intraline-info right sign">+</td>
+                  <td class="add content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-13"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-15 left-content-15 right-button-14 right-content-14"
+                  class="diff-row gr-diff side-by-side"
+                  left-type="remove"
+                  right-type="add"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="15"></td>
+                  <td class="gr-diff left lineNum" data-value="15">
+                    <button
+                      aria-label="15 removed"
+                      class="gr-diff left lineNumButton"
+                      data-value="15"
+                      id="left-button-15"
+                      tabindex="-1"
+                    >
+                      15
+                    </button>
+                  </td>
+                  <td class="gr-diff left no-intraline-info remove sign">-</td>
+                  <td class="content gr-diff left no-intraline-info remove">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-15"
+                    ></div>
+                    <div class="thread-group" data-side="left"></div>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="14">
+                    <button
+                      aria-label="14 added"
+                      class="gr-diff lineNumButton right"
+                      data-value="14"
+                      id="right-button-14"
+                      tabindex="-1"
+                    >
+                      14
+                    </button>
+                  </td>
+                  <td class="add gr-diff no-intraline-info right sign">+</td>
+                  <td class="add content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-14"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+              </tbody>
+              <tbody class="delta gr-diff section">
+                <tr
+                  aria-labelledby="left-button-16 left-content-16 right-button-15 right-content-15"
+                  class="diff-row gr-diff side-by-side"
+                  left-type="remove"
+                  right-type="add"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="16"></td>
+                  <td class="gr-diff left lineNum" data-value="16">
+                    <button
+                      aria-label="16 removed"
+                      class="gr-diff left lineNumButton"
+                      data-value="16"
+                      id="left-button-16"
+                      tabindex="-1"
+                    >
+                      16
+                    </button>
+                  </td>
+                  <td class="gr-diff left remove sign">-</td>
+                  <td class="content gr-diff left remove">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-16"
+                    ></div>
+                    <div class="thread-group" data-side="left"></div>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="15">
+                    <button
+                      aria-label="15 added"
+                      class="gr-diff lineNumButton right"
+                      data-value="15"
+                      id="right-button-15"
+                      tabindex="-1"
+                    >
+                      15
+                    </button>
+                  </td>
+                  <td class="add gr-diff right sign">+</td>
+                  <td class="add content gr-diff right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-15"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+              </tbody>
+              <tbody class="both gr-diff section">
+                <tr
+                  aria-labelledby="left-button-17 left-content-17 right-button-16 right-content-16"
+                  class="diff-row gr-diff side-by-side"
+                  left-type="both"
+                  right-type="both"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="17"></td>
+                  <td class="gr-diff left lineNum" data-value="17">
+                    <button
+                      aria-label="17 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="17"
+                      id="left-button-17"
+                      tabindex="-1"
+                    >
+                      17
+                    </button>
+                  </td>
+                  <td class="gr-diff left no-intraline-info sign"></td>
+                  <td class="both content gr-diff left no-intraline-info">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-17"
+                    ></div>
+                    <div class="thread-group" data-side="left"></div>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="16">
+                    <button
+                      aria-label="16 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="16"
+                      id="right-button-16"
+                      tabindex="-1"
+                    >
+                      16
+                    </button>
+                  </td>
+                  <td class="gr-diff no-intraline-info right sign"></td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-16"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-18 left-content-18 right-button-17 right-content-17"
+                  class="diff-row gr-diff side-by-side"
+                  left-type="both"
+                  right-type="both"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="18"></td>
+                  <td class="gr-diff left lineNum" data-value="18">
+                    <button
+                      aria-label="18 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="18"
+                      id="left-button-18"
+                      tabindex="-1"
+                    >
+                      18
+                    </button>
+                  </td>
+                  <td class="gr-diff left no-intraline-info sign"></td>
+                  <td class="both content gr-diff left no-intraline-info">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-18"
+                    ></div>
+                    <div class="thread-group" data-side="left"></div>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="17">
+                    <button
+                      aria-label="17 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="17"
+                      id="right-button-17"
+                      tabindex="-1"
+                    >
+                      17
+                    </button>
+                  </td>
+                  <td class="gr-diff no-intraline-info right sign"></td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-17"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-19 left-content-19 right-button-18 right-content-18"
+                  class="diff-row gr-diff side-by-side"
+                  left-type="both"
+                  right-type="both"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="19"></td>
+                  <td class="gr-diff left lineNum" data-value="19">
+                    <button
+                      aria-label="19 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="19"
+                      id="left-button-19"
+                      tabindex="-1"
+                    >
+                      19
+                    </button>
+                  </td>
+                  <td class="gr-diff left no-intraline-info sign"></td>
+                  <td class="both content gr-diff left no-intraline-info">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-19"
+                    ></div>
+                    <div class="thread-group" data-side="left"></div>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="18">
+                    <button
+                      aria-label="18 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="18"
+                      id="right-button-18"
+                      tabindex="-1"
+                    >
+                      18
+                    </button>
+                  </td>
+                  <td class="gr-diff no-intraline-info right sign"></td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-18"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+              </tbody>
+              <tbody class="contextControl gr-diff section">
+                <tr
+                  class="above contextBackground gr-diff side-by-side"
+                  left-type="contextControl"
+                  right-type="contextControl"
+                >
+                  <td class="blame gr-diff" data-line-number="0"></td>
+                  <td class="contextLineNum gr-diff"></td>
+                  <td class="gr-diff sign"></td>
+                  <td class="gr-diff"></td>
+                  <td class="contextLineNum gr-diff"></td>
+                  <td class="gr-diff sign"></td>
+                  <td class="gr-diff"></td>
+                </tr>
+                <tr class="dividerRow gr-diff show-both">
+                  <td class="blame gr-diff" data-line-number="0"></td>
+                  <td class="gr-diff"></td>
+                  <td class="dividerCell gr-diff" colspan="3">
+                    <gr-context-controls
+                      class="gr-diff"
+                      showconfig="both"
+                    ></gr-context-controls>
+                  </td>
+                </tr>
+                <tr
+                  class="below contextBackground gr-diff side-by-side"
+                  left-type="contextControl"
+                  right-type="contextControl"
+                >
+                  <td class="blame gr-diff" data-line-number="0"></td>
+                  <td class="contextLineNum gr-diff"></td>
+                  <td class="gr-diff sign"></td>
+                  <td class="gr-diff"></td>
+                  <td class="contextLineNum gr-diff"></td>
+                  <td class="gr-diff sign"></td>
+                  <td class="gr-diff"></td>
+                </tr>
+              </tbody>
+              <tbody class="both gr-diff section">
+                <tr
+                  aria-labelledby="left-button-38 left-content-38 right-button-37 right-content-37"
+                  class="diff-row gr-diff side-by-side"
+                  left-type="both"
+                  right-type="both"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="38"></td>
+                  <td class="gr-diff left lineNum" data-value="38">
+                    <button
+                      aria-label="38 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="38"
+                      id="left-button-38"
+                      tabindex="-1"
+                    >
+                      38
+                    </button>
+                  </td>
+                  <td class="gr-diff left no-intraline-info sign"></td>
+                  <td class="both content gr-diff left no-intraline-info">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-38"
+                    ></div>
+                    <div class="thread-group" data-side="left"></div>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="37">
+                    <button
+                      aria-label="37 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="37"
+                      id="right-button-37"
+                      tabindex="-1"
+                    >
+                      37
+                    </button>
+                  </td>
+                  <td class="gr-diff no-intraline-info right sign"></td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-37"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-39 left-content-39 right-button-38 right-content-38"
+                  class="diff-row gr-diff side-by-side"
+                  left-type="both"
+                  right-type="both"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="39"></td>
+                  <td class="gr-diff left lineNum" data-value="39">
+                    <button
+                      aria-label="39 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="39"
+                      id="left-button-39"
+                      tabindex="-1"
+                    >
+                      39
+                    </button>
+                  </td>
+                  <td class="gr-diff left no-intraline-info sign"></td>
+                  <td class="both content gr-diff left no-intraline-info">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-39"
+                    ></div>
+                    <div class="thread-group" data-side="left"></div>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="38">
+                    <button
+                      aria-label="38 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="38"
+                      id="right-button-38"
+                      tabindex="-1"
+                    >
+                      38
+                    </button>
+                  </td>
+                  <td class="gr-diff no-intraline-info right sign"></td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-38"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-40 left-content-40 right-button-39 right-content-39"
+                  class="diff-row gr-diff side-by-side"
+                  left-type="both"
+                  right-type="both"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="40"></td>
+                  <td class="gr-diff left lineNum" data-value="40">
+                    <button
+                      aria-label="40 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="40"
+                      id="left-button-40"
+                      tabindex="-1"
+                    >
+                      40
+                    </button>
+                  </td>
+                  <td class="gr-diff left no-intraline-info sign"></td>
+                  <td class="both content gr-diff left no-intraline-info">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-40"
+                    ></div>
+                    <div class="thread-group" data-side="left"></div>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="39">
+                    <button
+                      aria-label="39 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="39"
+                      id="right-button-39"
+                      tabindex="-1"
+                    >
+                      39
+                    </button>
+                  </td>
+                  <td class="gr-diff no-intraline-info right sign"></td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-39"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+              </tbody>
+              <tbody class="delta gr-diff section total">
+                <tr
+                  aria-labelledby="right-button-40 right-content-40"
+                  class="diff-row gr-diff side-by-side"
+                  left-type="blank"
+                  right-type="add"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="0"></td>
+                  <td class="blankLineNum gr-diff left"></td>
+                  <td class="blank gr-diff left no-intraline-info sign"></td>
+                  <td class="blank gr-diff left no-intraline-info">
+                    <div class="contentText gr-diff" data-side="left"></div>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="40">
+                    <button
+                      aria-label="40 added"
+                      class="gr-diff lineNumButton right"
+                      data-value="40"
+                      id="right-button-40"
+                      tabindex="-1"
+                    >
+                      40
+                    </button>
+                  </td>
+                  <td class="add gr-diff no-intraline-info right sign">+</td>
+                  <td class="add content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-40"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="right-button-41 right-content-41"
+                  class="diff-row gr-diff side-by-side"
+                  left-type="blank"
+                  right-type="add"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="0"></td>
+                  <td class="blankLineNum gr-diff left"></td>
+                  <td class="blank gr-diff left no-intraline-info sign"></td>
+                  <td class="blank gr-diff left no-intraline-info">
+                    <div class="contentText gr-diff" data-side="left"></div>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="41">
+                    <button
+                      aria-label="41 added"
+                      class="gr-diff lineNumButton right"
+                      data-value="41"
+                      id="right-button-41"
+                      tabindex="-1"
+                    >
+                      41
+                    </button>
+                  </td>
+                  <td class="add gr-diff no-intraline-info right sign">+</td>
+                  <td class="add content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-41"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="right-button-42 right-content-42"
+                  class="diff-row gr-diff side-by-side"
+                  left-type="blank"
+                  right-type="add"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="0"></td>
+                  <td class="blankLineNum gr-diff left"></td>
+                  <td class="blank gr-diff left no-intraline-info sign"></td>
+                  <td class="blank gr-diff left no-intraline-info">
+                    <div class="contentText gr-diff" data-side="left"></div>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="42">
+                    <button
+                      aria-label="42 added"
+                      class="gr-diff lineNumButton right"
+                      data-value="42"
+                      id="right-button-42"
+                      tabindex="-1"
+                    >
+                      42
+                    </button>
+                  </td>
+                  <td class="add gr-diff no-intraline-info right sign">+</td>
+                  <td class="add content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-42"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="right-button-43 right-content-43"
+                  class="diff-row gr-diff side-by-side"
+                  left-type="blank"
+                  right-type="add"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="0"></td>
+                  <td class="blankLineNum gr-diff left"></td>
+                  <td class="blank gr-diff left no-intraline-info sign"></td>
+                  <td class="blank gr-diff left no-intraline-info">
+                    <div class="contentText gr-diff" data-side="left"></div>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="43">
+                    <button
+                      aria-label="43 added"
+                      class="gr-diff lineNumButton right"
+                      data-value="43"
+                      id="right-button-43"
+                      tabindex="-1"
+                    >
+                      43
+                    </button>
+                  </td>
+                  <td class="add gr-diff no-intraline-info right sign">+</td>
+                  <td class="add content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-43"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+              </tbody>
+              <tbody class="both gr-diff section">
+                <tr
+                  aria-labelledby="left-button-41 left-content-41 right-button-44 right-content-44"
+                  class="diff-row gr-diff side-by-side"
+                  left-type="both"
+                  right-type="both"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="41"></td>
+                  <td class="gr-diff left lineNum" data-value="41">
+                    <button
+                      aria-label="41 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="41"
+                      id="left-button-41"
+                      tabindex="-1"
+                    >
+                      41
+                    </button>
+                  </td>
+                  <td class="gr-diff left no-intraline-info sign"></td>
+                  <td class="both content gr-diff left no-intraline-info">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-41"
+                    ></div>
+                    <div class="thread-group" data-side="left"></div>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="44">
+                    <button
+                      aria-label="44 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="44"
+                      id="right-button-44"
+                      tabindex="-1"
+                    >
+                      44
+                    </button>
+                  </td>
+                  <td class="gr-diff no-intraline-info right sign"></td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-44"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-42 left-content-42 right-button-45 right-content-45"
+                  class="diff-row gr-diff side-by-side"
+                  left-type="both"
+                  right-type="both"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="42"></td>
+                  <td class="gr-diff left lineNum" data-value="42">
+                    <button
+                      aria-label="42 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="42"
+                      id="left-button-42"
+                      tabindex="-1"
+                    >
+                      42
+                    </button>
+                  </td>
+                  <td class="gr-diff left no-intraline-info sign"></td>
+                  <td class="both content gr-diff left no-intraline-info">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-42"
+                    ></div>
+                    <div class="thread-group" data-side="left"></div>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="45">
+                    <button
+                      aria-label="45 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="45"
+                      id="right-button-45"
+                      tabindex="-1"
+                    >
+                      45
+                    </button>
+                  </td>
+                  <td class="gr-diff no-intraline-info right sign"></td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-45"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-43 left-content-43 right-button-46 right-content-46"
+                  class="diff-row gr-diff side-by-side"
+                  left-type="both"
+                  right-type="both"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="43"></td>
+                  <td class="gr-diff left lineNum" data-value="43">
+                    <button
+                      aria-label="43 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="43"
+                      id="left-button-43"
+                      tabindex="-1"
+                    >
+                      43
+                    </button>
+                  </td>
+                  <td class="gr-diff left no-intraline-info sign"></td>
+                  <td class="both content gr-diff left no-intraline-info">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-43"
+                    ></div>
+                    <div class="thread-group" data-side="left"></div>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="46">
+                    <button
+                      aria-label="46 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="46"
+                      id="right-button-46"
+                      tabindex="-1"
+                    >
+                      46
+                    </button>
+                  </td>
+                  <td class="gr-diff no-intraline-info right sign"></td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-46"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-44 left-content-44 right-button-47 right-content-47"
+                  class="diff-row gr-diff side-by-side"
+                  left-type="both"
+                  right-type="both"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="44"></td>
+                  <td class="gr-diff left lineNum" data-value="44">
+                    <button
+                      aria-label="44 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="44"
+                      id="left-button-44"
+                      tabindex="-1"
+                    >
+                      44
+                    </button>
+                  </td>
+                  <td class="gr-diff left no-intraline-info sign"></td>
+                  <td class="both content gr-diff left no-intraline-info">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-44"
+                    ></div>
+                    <div class="thread-group" data-side="left"></div>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="47">
+                    <button
+                      aria-label="47 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="47"
+                      id="right-button-47"
+                      tabindex="-1"
+                    >
+                      47
+                    </button>
+                  </td>
+                  <td class="gr-diff no-intraline-info right sign"></td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-47"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-45 left-content-45 right-button-48 right-content-48"
+                  class="diff-row gr-diff side-by-side"
+                  left-type="both"
+                  right-type="both"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="45"></td>
+                  <td class="gr-diff left lineNum" data-value="45">
+                    <button
+                      aria-label="45 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="45"
+                      id="left-button-45"
+                      tabindex="-1"
+                    >
+                      45
+                    </button>
+                  </td>
+                  <td class="gr-diff left no-intraline-info sign"></td>
+                  <td class="both content gr-diff left no-intraline-info">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-45"
+                    ></div>
+                    <div class="thread-group" data-side="left"></div>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="48">
+                    <button
+                      aria-label="48 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="48"
+                      id="right-button-48"
+                      tabindex="-1"
+                    >
+                      48
+                    </button>
+                  </td>
+                  <td class="gr-diff no-intraline-info right sign"></td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-48"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+              </tbody>
+            </table>
+          </div>
+        `,
+        {
+          ignoreTags: [
+            'gr-context-controls-section',
+            'gr-diff-section',
+            'gr-diff-row',
+            'gr-diff-text',
+            'gr-legacy-text',
+            'slot',
+          ],
+        }
+      );
+    });
+  });
+
   suite('selectionchange event handling', () => {
     let handleSelectionChangeStub: sinon.SinonSpy;
 
@@ -87,9 +3022,9 @@
   });
 
   test('cancel', () => {
-    const cancelStub = sinon.stub(element.diffBuilder, 'cancel');
+    const cleanupStub = sinon.stub(element.diffBuilder, 'cleanup');
     element.cancel();
-    assert.isTrue(cancelStub.calledOnce);
+    assert.isTrue(cleanupStub.calledOnce);
   });
 
   test('line limit with line_wrapping', async () => {
@@ -187,35 +3122,100 @@
       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'));
-    });
+    suite('binary diffs', () => {
+      test('render binary diff', async () => {
+        element.prefs = {
+          ...MINIMAL_PREFS,
+        };
+        element.diff = {
+          meta_a: {name: 'carrot.exe', content_type: 'binary', lines: 0},
+          meta_b: {name: 'carrot.exe', content_type: 'binary', lines: 0},
+          change_type: 'MODIFIED',
+          intraline_status: 'OK',
+          diff_header: [],
+          content: [],
+          binary: true,
+        };
+        await waitForEventOnce(element, 'render');
 
-    test('displayLine class added when displayLine is true', async () => {
-      element.displayLine = true;
-      await element.updateComplete;
-      const container = queryAndAssert(element, '.diffContainer');
-      assert.isTrue(container.classList.contains('displayLine'));
-    });
-
-    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);
+        assert.shadowDom.equal(
+          element,
+          /* HTML */ `
+            <div class="diffContainer sideBySide">
+              <gr-diff-section class="left-FILE right-FILE"> </gr-diff-section>
+              <gr-diff-row class="left-FILE right-FILE"> </gr-diff-row>
+              <table class="selected-right" id="diffTable">
+                <colgroup>
+                  <col class="blame gr-diff" />
+                  <col class="gr-diff left" width="48" />
+                  <col class="gr-diff left sign" />
+                  <col class="gr-diff left" />
+                  <col class="gr-diff right" width="48" />
+                  <col class="gr-diff right sign" />
+                  <col class="gr-diff right" />
+                </colgroup>
+                <tbody class="binary-diff gr-diff"></tbody>
+                <tbody class="both gr-diff section">
+                  <tr
+                    aria-labelledby="left-button-FILE left-content-FILE right-button-FILE right-content-FILE"
+                    class="diff-row gr-diff side-by-side"
+                    left-type="both"
+                    right-type="both"
+                    tabindex="-1"
+                  >
+                    <td class="blame gr-diff" data-line-number="FILE"></td>
+                    <td class="gr-diff left lineNum" data-value="FILE">
+                      <button
+                        aria-label="Add file comment"
+                        class="gr-diff left lineNumButton"
+                        data-value="FILE"
+                        id="left-button-FILE"
+                        tabindex="-1"
+                      >
+                        File
+                      </button>
+                    </td>
+                    <td class="gr-diff left no-intraline-info sign"></td>
+                    <td
+                      class="both content file gr-diff left no-intraline-info"
+                    >
+                      <div class="thread-group" data-side="left">
+                        <slot name="left-FILE"> </slot>
+                      </div>
+                    </td>
+                    <td class="gr-diff lineNum right" data-value="FILE">
+                      <button
+                        aria-label="Add file comment"
+                        class="gr-diff lineNumButton right"
+                        data-value="FILE"
+                        id="right-button-FILE"
+                        tabindex="-1"
+                      >
+                        File
+                      </button>
+                    </td>
+                    <td class="gr-diff no-intraline-info right sign"></td>
+                    <td
+                      class="both content file gr-diff no-intraline-info right"
+                    >
+                      <div class="thread-group" data-side="right">
+                        <slot name="right-FILE"> </slot>
+                      </div>
+                    </td>
+                  </tr>
+                </tbody>
+                <tbody class="binary-diff gr-diff">
+                  <tr class="gr-diff">
+                    <td class="gr-diff" colspan="5">
+                      <span> Difference in binary files </span>
+                    </td>
+                  </tr>
+                </tbody>
+              </table>
+            </div>
+          `
+        );
+      });
     });
 
     suite('image diffs', () => {
@@ -251,7 +3251,7 @@
         };
       });
 
-      test('renders image diffs with same file name', async () => {
+      test('render image diff', async () => {
         element.baseImage = mockFile1;
         element.revisionImage = mockFile2;
         element.diff = {
@@ -269,39 +3269,62 @@
           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.
-        assertIsDefined(element.diffTable);
-        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
+        const imageDiffSection = queryAndAssert(element, 'tbody.image-diff');
+        assert.lightDom.equal(
+          imageDiffSection,
+          /* HTML */ `
+            <tbody class="gr-diff image-diff">
+              <tr class="gr-diff">
+                <td class="blank gr-diff left lineNum"></td>
+                <td class="gr-diff left">
+                  <img
+                    class="gr-diff left"
+                    src="data:image/bmp;base64,${mockFile1.body}"
+                  />
+                </td>
+                <td class="blank gr-diff lineNum right"></td>
+                <td class="gr-diff right">
+                  <img
+                    class="gr-diff right"
+                    src="data:image/bmp;base64,${mockFile2.body}"
+                  />
+                </td>
+              </tr>
+              <tr class="gr-diff">
+                <td class="blank gr-diff left lineNum"></td>
+                <td class="gr-diff left">
+                  <label class="gr-diff">
+                    <span class="gr-diff label"> image/bmp </span>
+                  </label>
+                </td>
+                <td class="blank gr-diff lineNum right"></td>
+                <td class="gr-diff right">
+                  <label class="gr-diff">
+                    <span class="gr-diff label"> image/bmp </span>
+                  </label>
+                </td>
+              </tr>
+            </tbody>
+          `
         );
-        assert.isTrue(leftLabelContent.textContent?.includes('image/bmp'));
-
-        assert.equal(
-          rightImage.getAttribute('src'),
-          'data:image/bmp;base64,' + mockFile2.body
+        const endpoint = queryAndAssert(element, 'tbody.endpoint');
+        assert.dom.equal(
+          endpoint,
+          /* HTML */ `
+            <tbody class="gr-diff endpoint">
+              <tr class="gr-diff">
+                <gr-endpoint-decorator class="gr-diff" name="image-diff">
+                  <gr-endpoint-param class="gr-diff" name="baseImage">
+                  </gr-endpoint-param>
+                  <gr-endpoint-param class="gr-diff" name="revisionImage">
+                  </gr-endpoint-param>
+                </gr-endpoint-decorator>
+              </tr>
+            </tbody>
+          `
         );
-        assert.isTrue(rightLabelContent.textContent?.includes('image/bmp'));
       });
 
       test('renders image diffs with a different file name', async () => {
@@ -326,43 +3349,31 @@
         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.
-        assertIsDefined(element.diffTable);
-        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
+        const imageDiffSection = queryAndAssert(element, 'tbody.image-diff');
+        const leftLabel = queryAndAssert(imageDiffSection, 'td.left label');
+        const rightLabel = queryAndAssert(imageDiffSection, 'td.right label');
+        assert.dom.equal(
+          leftLabel,
+          /* HTML */ `
+            <label class="gr-diff">
+              <span class="gr-diff name"> carrot.jpg </span>
+              <br class="gr-diff" />
+              <span class="gr-diff label"> image/bmp </span>
+            </label>
+          `
         );
-        assert.isTrue(leftLabelContent.textContent?.includes('image/bmp'));
-
-        assert.isOk(rightImage);
-        assert.equal(
-          rightImage.getAttribute('src'),
-          'data:image/bmp;base64,' + mockFile2.body
+        assert.dom.equal(
+          rightLabel,
+          /* HTML */ `
+            <label class="gr-diff">
+              <span class="gr-diff name"> carrot2.jpg </span>
+              <br class="gr-diff" />
+              <span class="gr-diff label"> image/bmp </span>
+            </label>
+          `
         );
-        assert.isTrue(rightLabelContent.textContent?.includes('image/bmp'));
       });
 
       test('renders added image', async () => {
@@ -380,26 +3391,23 @@
           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);
 
-        assertIsDefined(element.diffTable);
-        const diffTable = element.diffTable;
-        const leftImage = query(diffTable, 'td.left img');
+        await waitForEventOnce(element, 'render');
+        const imageDiffSection = queryAndAssert(element, 'tbody.image-diff');
+        const leftImage = query(imageDiffSection, 'td.left img');
+        const rightImage = queryAndAssert(imageDiffSection, 'td.right img');
         assert.isNotOk(leftImage);
-        queryAndAssert(diffTable, 'td.right img');
+        assert.dom.equal(
+          rightImage,
+          /* HTML */ `
+            <img
+              class="gr-diff right"
+              src="data:image/bmp;base64,${mockFile2.body}"
+            />
+          `
+        );
       });
 
       test('renders removed image', async () => {
@@ -417,25 +3425,23 @@
           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);
 
-        assertIsDefined(element.diffTable);
-        const diffTable = element.diffTable;
-        queryAndAssert(diffTable, 'td.left img');
-        const rightImage = query(diffTable, 'td.right img');
+        await waitForEventOnce(element, 'render');
+        const imageDiffSection = queryAndAssert(element, 'tbody.image-diff');
+        const leftImage = queryAndAssert(imageDiffSection, 'td.left img');
+        const rightImage = query(imageDiffSection, 'td.right img');
         assert.isNotOk(rightImage);
+        assert.dom.equal(
+          leftImage,
+          /* HTML */ `
+            <img
+              class="gr-diff left"
+              src="data:image/bmp;base64,${mockFile1.body}"
+            />
+          `
+        );
       });
 
       test('does not render disallowed image type', async () => {
@@ -458,23 +3464,12 @@
           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);
-        assertIsDefined(element.diffTable);
-        const diffTable = element.diffTable;
-        const leftImage = query(diffTable, 'td.left img');
+
+        await waitForEventOnce(element, 'render');
+        const imageDiffSection = queryAndAssert(element, 'tbody.image-diff');
+        const leftImage = query(imageDiffSection, 'td.left img');
         assert.isNotOk(leftImage);
       });
     });
@@ -553,7 +3548,11 @@
         await element.updateComplete;
         const ROWS = 48;
         const FILE_ROW = 1;
-        assert.equal(element.getCursorStops().length, ROWS + FILE_ROW);
+        const LOST_ROW = 1;
+        assert.equal(
+          element.getCursorStops().length,
+          ROWS + FILE_ROW + LOST_ROW
+        );
       });
 
       test('returns an additional AbortStop when still loading', async () => {
@@ -562,19 +3561,12 @@
         await element.updateComplete;
         const ROWS = 48;
         const FILE_ROW = 1;
+        const LOST_ROW = 1;
         const actual = element.getCursorStops();
-        assert.equal(actual.length, ROWS + FILE_ROW + 1);
+        assert.equal(actual.length, ROWS + FILE_ROW + LOST_ROW + 1);
         assert.isTrue(actual[actual.length - 1] instanceof AbortStop);
       });
     });
-
-    test('adds .hiddenscroll', async () => {
-      _setHiddenScroll(true);
-      element.displayLine = true;
-      await element.updateComplete;
-      const container = queryAndAssert(element, '.diffContainer');
-      assert.include(container.className, 'hiddenscroll');
-    });
   });
 
   suite('logged in', async () => {
@@ -820,45 +3812,30 @@
 
     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;
+      await waitForEventOnce(element, 'render');
+
+      assert.isTrue(renderStub.called);
+      assert.isFalse(element.showWarning);
     });
 
     test('large render w/ whole file and bypass', async () => {
       element.prefs = {...MINIMAL_PREFS, context: -1};
       element.safetyBypass = 10;
-      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;
+      await waitForEventOnce(element, 'render');
+
+      assert.isTrue(renderStub.called);
+      assert.isFalse(element.showWarning);
     });
 
     test('large render w/ whole file and no bypass', async () => {
       element.prefs = {...MINIMAL_PREFS, context: -1};
-      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;
+      await waitForEventOnce(element, 'render');
+
+      assert.isFalse(renderStub.called);
+      assert.isTrue(element.showWarning);
     });
 
     test('toggles expand context using bypass', async () => {
@@ -930,8 +3907,8 @@
     const NO_NEWLINE_RIGHT = 'No newline at end of right file.';
 
     const getWarning = (element: GrDiff) => {
-      const warningElement = queryAndAssert(element, '.newlineWarning');
-      return warningElement.textContent;
+      const warningElement = query(element, '.newlineWarning');
+      return warningElement?.textContent ?? '';
     };
 
     setup(async () => {
@@ -977,17 +3954,6 @@
         assert.notInclude(getWarning(element), NO_NEWLINE_RIGHT);
       });
     });
-
-    test('computeNewlineWarningClass', () => {
-      const hidden = 'newlineWarning hidden';
-      const shown = 'newlineWarning';
-      element.loading = true;
-      assert.equal(element.computeNewlineWarningClass(false), hidden);
-      assert.equal(element.computeNewlineWarningClass(true), hidden);
-      element.loading = false;
-      assert.equal(element.computeNewlineWarningClass(false), hidden);
-      assert.equal(element.computeNewlineWarningClass(true), shown);
-    });
   });
 
   suite('key locations', () => {
@@ -995,6 +3961,7 @@
 
     setup(async () => {
       element.prefs = {...MINIMAL_PREFS};
+      element.diff = createDiff();
       renderStub = sinon.stub(element.diffBuilder, 'render');
       await element.updateComplete;
     });
@@ -1088,13 +4055,13 @@
         b: ['Non eram nescius, Brute, cum, quae summis ingeniis '],
       },
     ];
-    function assertDiffTableWithContent() {
+    function diffTableHasContent() {
       assertIsDefined(element.diffTable);
       const diffTable = element.diffTable;
-      assert.isTrue(diffTable.innerText.includes(content[0].a?.[0] ?? ''));
+      return diffTable.innerText.includes(content[0].a?.[0] ?? '');
     }
     await setupSampleDiff({content});
-    assertDiffTableWithContent();
+    await waitUntil(diffTableHasContent);
     element.diff = {...element.diff!};
     await element.updateComplete;
     // immediately cleaned up
@@ -1104,7 +4071,7 @@
     element.renderDiffTable();
     await element.updateComplete;
     // rendered again
-    assertDiffTableWithContent();
+    await waitUntil(diffTableHasContent);
   });
 
   suite('selection test', () => {
diff --git a/polygerrit-ui/app/embed/diff/gr-range-header/gr-range-header_test.ts b/polygerrit-ui/app/embed/diff/gr-range-header/gr-range-header_test.ts
new file mode 100644
index 0000000..b31197d16
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-range-header/gr-range-header_test.ts
@@ -0,0 +1,40 @@
+/**
+ * @license
+ * Copyright 2023 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-range-header';
+import {GrRangeHeader} from './gr-range-header';
+import {fixture, html, assert} from '@open-wc/testing';
+
+suite('gr-range-header test', () => {
+  let element: GrRangeHeader;
+
+  setup(async () => {
+    element = await fixture<GrRangeHeader>(
+      html`<gr-range-header></gr-range-header>`
+    );
+    await element.updateComplete;
+  });
+
+  test('renders', async () => {
+    element.filled = true;
+    element.icon = 'test-icon';
+    await element.updateComplete;
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div class="row">
+          <gr-icon
+            aria-hidden="true"
+            class="icon"
+            filled
+            icon="test-icon"
+          ></gr-icon>
+          <slot></slot>
+        </div>
+      `
+    );
+  });
+});
diff --git a/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.ts b/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.ts
index 58f3f75..38eecfa 100644
--- a/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.ts
+++ b/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.ts
@@ -144,12 +144,13 @@
       side,
       range,
       operation: (forLine, startChar, endChar) => {
-        forLine.push({
-          start: startChar,
-          end: endChar,
-          id: id(commentRange),
-          longRange,
-        });
+        if (startChar !== endChar)
+          forLine.push({
+            start: startChar,
+            end: endChar,
+            id: id(commentRange),
+            longRange,
+          });
       },
     });
   }
@@ -202,7 +203,7 @@
       // Normalize invalid ranges where the start is after the end but the
       // start still makes sense. Set the end to the end of the line.
       // @see Issue 5744
-      if (range.start >= range.end && range.start < line.text.length) {
+      if (range.start > range.end && range.start < line.text.length) {
         range.end = line.text.length;
       }
 
diff --git a/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.ts b/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.ts
index 33515b25..7feda47 100644
--- a/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.ts
@@ -69,6 +69,16 @@
   },
 };
 
+const rangeF: CommentRangeLayer = {
+  side: Side.RIGHT,
+  range: {
+    end_character: 0,
+    end_line: 24,
+    start_character: 0,
+    start_line: 23,
+  },
+};
+
 suite('gr-ranged-comment-layer', () => {
   let element: GrRangedCommentLayer;
 
@@ -79,6 +89,7 @@
       rangeC,
       rangeD,
       rangeE,
+      rangeF,
     ];
 
     element = new GrRangedCommentLayer();
@@ -219,6 +230,16 @@
       );
     });
 
+    test('do not annotate lines with end_character 0', () => {
+      line = new GrDiffLine(GrDiffLineType.BOTH);
+      line.afterNumber = 24;
+      el.setAttribute('data-side', Side.RIGHT);
+
+      element.annotate(el, lineNumberEl, line);
+
+      assert.isFalse(annotateElementStub.called);
+    });
+
     test('updateRanges remove all', () => {
       assertHasRange(rangeA, true);
       assertHasRange(rangeB, true);
diff --git a/polygerrit-ui/app/embed/diff/gr-selection-action-box/gr-selection-action-box.ts b/polygerrit-ui/app/embed/diff/gr-selection-action-box/gr-selection-action-box.ts
index cb08e55..68aa3b4 100644
--- a/polygerrit-ui/app/embed/diff/gr-selection-action-box/gr-selection-action-box.ts
+++ b/polygerrit-ui/app/embed/diff/gr-selection-action-box/gr-selection-action-box.ts
@@ -5,7 +5,7 @@
  */
 import '../../../elements/shared/gr-tooltip/gr-tooltip';
 import {GrTooltip} from '../../../elements/shared/gr-tooltip/gr-tooltip';
-import {fireEvent} from '../../../utils/event-util';
+import {fire} from '../../../utils/event-util';
 import {css, html, LitElement} from 'lit';
 import {customElement, property, query, state} from 'lit/decorators.js';
 import {sharedStyles} from '../../../styles/shared-styles';
@@ -14,16 +14,14 @@
   interface HTMLElementTagNameMap {
     'gr-selection-action-box': GrSelectionActionBox;
   }
+  interface HTMLElementEventMap {
+    /** Fired when the comment creation action was taken (click). */
+    'create-comment-requested': CustomEvent<{}>;
+  }
 }
 
 @customElement('gr-selection-action-box')
 export class GrSelectionActionBox extends LitElement {
-  /**
-   * Fired when the comment creation action was taken (click).
-   *
-   * @event create-comment-requested
-   */
-
   @query('#tooltip')
   tooltip?: GrTooltip;
 
@@ -133,6 +131,6 @@
     } // 0 = main button
     e.preventDefault();
     e.stopPropagation();
-    fireEvent(this, 'create-comment-requested');
+    fire(this, 'create-comment-requested', {});
   }
 }
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 a9f88bd..da08a1f 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
@@ -8,9 +8,11 @@
 import {DiffFileMetaInfo, DiffInfo} from '../../../types/diff';
 import {DiffLayer, DiffLayerListener} from '../../../types/types';
 import {Side} from '../../../constants/constants';
-import {getAppContext} from '../../../services/app-context';
 import {SyntaxLayerLine} from '../../../types/syntax-worker-api';
-import {CancelablePromise, makeCancelable} from '../../../scripts/util';
+import {CancelablePromise, makeCancelable} from '../../../utils/async-util';
+import {HighlightService} from '../../../services/highlight/highlight-service';
+import {Provider} from '../../../models/dependency';
+import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
 
 const LANGUAGE_MAP = new Map<string, string>([
   ['application/dart', 'dart'],
@@ -162,9 +164,10 @@
 
   private listeners: DiffLayerListener[] = [];
 
-  private readonly highlightService = getAppContext().highlightService;
-
-  private readonly reportingService = getAppContext().reportingService;
+  constructor(
+    private readonly getHighlightService: Provider<HighlightService>,
+    private readonly getReportingService: Provider<ReportingService>
+  ) {}
 
   setEnabled(enabled: boolean) {
     this.enabled = enabled;
@@ -276,7 +279,7 @@
       // eslint-disable-next-line @typescript-eslint/no-explicit-any
     } catch (err: any) {
       if (!err.isCanceled)
-        this.reportingService.error('Diff Syntax Layer', err as Error);
+        this.getReportingService().error('Diff Syntax Layer', err as Error);
       // One source of "error" can promise cancelation.
       this.leftRanges = [];
       this.rightRanges = [];
@@ -287,7 +290,7 @@
     language?: string,
     code?: string
   ): CancelablePromise<SyntaxLayerLine[]> {
-    const hlPromise = this.highlightService.highlight(language, code);
+    const hlPromise = this.getHighlightService().highlight(language, code);
     return makeCancelable(hlPromise);
   }
 
diff --git a/polygerrit-ui/app/embed/diff/gr-syntax-layer/gr-syntax-layer-worker_test.ts b/polygerrit-ui/app/embed/diff/gr-syntax-layer/gr-syntax-layer-worker_test.ts
index 5c9a6cc..c6c46f9 100644
--- a/polygerrit-ui/app/embed/diff/gr-syntax-layer/gr-syntax-layer-worker_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-syntax-layer/gr-syntax-layer-worker_test.ts
@@ -5,8 +5,14 @@
  */
 import {assert} from '@open-wc/testing';
 import {DiffInfo, GrDiffLineType, Side} from '../../../api/diff';
+import {getAppContext} from '../../../services/app-context';
+import {
+  HighlightService,
+  highlightServiceToken,
+} from '../../../services/highlight/highlight-service';
 import '../../../test/common-test-setup';
-import {mockPromise, stubHighlightService} from '../../../test/test-utils';
+import {testResolver} from '../../../test/common-test-setup';
+import {mockPromise} from '../../../test/test-utils';
 import {SyntaxLayerLine} from '../../../types/syntax-worker-api';
 import {GrDiffLine} from '../gr-diff/gr-diff-line';
 import {GrSyntaxLayerWorker} from './gr-syntax-layer-worker';
@@ -62,6 +68,7 @@
 suite('gr-syntax-layer-worker tests', () => {
   let layer: GrSyntaxLayerWorker;
   let listener: sinon.SinonStub;
+  let highlightService: HighlightService;
 
   const annotate = (side: Side, lineNumber: number, text: string) => {
     const el = document.createElement('div');
@@ -76,7 +83,11 @@
   };
 
   setup(() => {
-    layer = new GrSyntaxLayerWorker();
+    highlightService = testResolver(highlightServiceToken);
+    layer = new GrSyntaxLayerWorker(
+      () => highlightService,
+      () => getAppContext().reportingService
+    );
   });
 
   test('cancel processing', async () => {
@@ -84,7 +95,7 @@
     const mockPromise2 = mockPromise<SyntaxLayerLine[]>();
     const mockPromise3 = mockPromise<SyntaxLayerLine[]>();
     const mockPromise4 = mockPromise<SyntaxLayerLine[]>();
-    const stub = stubHighlightService('highlight');
+    const stub = sinon.stub(highlightService, 'highlight');
     stub.onCall(0).returns(mockPromise1);
     stub.onCall(1).returns(mockPromise2);
     stub.onCall(2).returns(mockPromise3);
@@ -116,7 +127,7 @@
     setup(() => {
       listener = sinon.stub();
       layer.addListener(listener);
-      stubHighlightService('highlight').callsFake((lang?: string) => {
+      sinon.stub(highlightService, 'highlight').callsFake((lang?: string) => {
         if (lang === 'lang-left') return Promise.resolve(leftRanges);
         if (lang === 'lang-right') return Promise.resolve(rightRanges);
         return Promise.resolve([]);
diff --git a/polygerrit-ui/app/embed/gr-diff-app-context-init.ts b/polygerrit-ui/app/embed/gr-diff-app-context-init.ts
index f865d6d..36ebb9f 100644
--- a/polygerrit-ui/app/embed/gr-diff-app-context-init.ts
+++ b/polygerrit-ui/app/embed/gr-diff-app-context-init.ts
@@ -63,30 +63,6 @@
     restApiService: (_ctx: Partial<AppContext>) => {
       throw new Error('restApiService is not implemented');
     },
-    jsApiService: (_ctx: Partial<AppContext>) => {
-      throw new Error('jsApiService is not implemented');
-    },
-    storageService: (_ctx: Partial<AppContext>) => {
-      throw new Error('storageService is not implemented');
-    },
-    userModel: (_ctx: Partial<AppContext>) => {
-      throw new Error('userModel is not implemented');
-    },
-    accountsModel: (_ctx: Partial<AppContext>) => {
-      throw new Error('accountsModel is not implemented');
-    },
-    routerModel: (_ctx: Partial<AppContext>) => {
-      throw new Error('routerModel is not implemented');
-    },
-    shortcutsService: (_ctx: Partial<AppContext>) => {
-      throw new Error('shortcutsService is not implemented');
-    },
-    pluginsModel: (_ctx: Partial<AppContext>) => {
-      throw new Error('pluginsModel is not implemented');
-    },
-    highlightService: (_ctx: Partial<AppContext>) => {
-      throw new Error('highlightService is not implemented');
-    },
   };
   return create<AppContext>(appRegistry);
 }
diff --git a/polygerrit-ui/app/mixins/hovercard-mixin/hovercard-mixin.ts b/polygerrit-ui/app/mixins/hovercard-mixin/hovercard-mixin.ts
index 3d3790c..4f79e4c 100644
--- a/polygerrit-ui/app/mixins/hovercard-mixin/hovercard-mixin.ts
+++ b/polygerrit-ui/app/mixins/hovercard-mixin/hovercard-mixin.ts
@@ -3,11 +3,10 @@
  * Copyright 2020 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-import {getRootElement} from '../../scripts/rootElement';
 import {Constructor} from '../../utils/common-util';
 import {LitElement, PropertyValues} from 'lit';
 import {property, query} from 'lit/decorators.js';
-import {EventType, ShowAlertEventDetail} from '../../types/events';
+import {ShowAlertEventDetail} from '../../types/events';
 import {debounce, DelayedTask} from '../../utils/async-util';
 import {hovercardStyles} from '../../styles/gr-hovercard-styles';
 import {sharedStyles} from '../../styles/shared-styles';
@@ -48,21 +47,6 @@
   focusEvent?: FocusEvent;
 }
 
-export function getHovercardContainer(
-  options: {createIfNotExists: boolean} = {createIfNotExists: false}
-): HTMLElement | null {
-  let container = getRootElement().querySelector<HTMLElement>(
-    `#${containerId}`
-  );
-  if (!container && options.createIfNotExists) {
-    // If it does not exist, create and initialize the hovercard container.
-    container = document.createElement('div');
-    container.setAttribute('id', containerId);
-    getRootElement().appendChild(container);
-  }
-  return container;
-}
-
 /**
  * How long should we wait before showing the hovercard when the user hovers
  * over the element?
@@ -177,7 +161,7 @@
         this.addTargetEventListeners();
       }
 
-      this.container = getHovercardContainer({createIfNotExists: true});
+      this.container = this.getContainer();
       this.cleanups.push(
         addShortcut(
           this,
@@ -235,6 +219,7 @@
         );
       }
       this.addEventListener('request-dependency', this.resolveDep);
+      this.addEventListener('reload', this.reload);
     }
 
     private removeTargetEventListeners() {
@@ -247,6 +232,7 @@
       }
       this.targetCleanups = [];
       this.removeEventListener('request-dependency', this.resolveDep);
+      this.removeEventListener('reload', this.reload);
     }
 
     /**
@@ -262,6 +248,10 @@
       }
     }
 
+    readonly reload = () => {
+      this.dispatchEventThroughTarget('reload');
+    };
+
     readonly mouseDebounceHide = (e: MouseEvent) => {
       this.debounceHide({mouseEvent: e});
     };
@@ -313,7 +303,7 @@
     dispatchEventThroughTarget(eventName: string): void;
 
     dispatchEventThroughTarget(
-      eventName: EventType.SHOW_ALERT,
+      eventName: 'show-alert',
       detail: ShowAlertEventDetail
     ): void;
 
@@ -334,6 +324,29 @@
         );
     }
 
+    getHost(): HTMLElement {
+      let el = this._target as Node;
+      while (el) {
+        if ((el as HTMLElement).tagName === 'DIALOG') {
+          return el as HTMLElement;
+        }
+        el = el.parentNode || (el as ShadowRoot).host;
+      }
+      return document.body;
+    }
+
+    getContainer(): HTMLElement | null {
+      const host = this.getHost();
+      let container = host.querySelector<HTMLElement>(`#${containerId}`);
+      if (!container) {
+        // If it does not exist, create and initialize the hovercard container.
+        container = document.createElement('div');
+        container.setAttribute('id', containerId);
+        host.appendChild(container);
+      }
+      return container;
+    }
+
     /**
      * Returns the target element that the hovercard is anchored to (the `id` of
      * the `for` property).
@@ -360,6 +373,10 @@
       this.forceHide();
     };
 
+    private containerClickListener = (e: MouseEvent) => {
+      e.stopPropagation();
+    };
+
     /**
      * Hovercards aren't children of <gr-app>. Dependencies must be resolved via
      * their targets, so re-route 'request-dependency' events.
@@ -424,6 +441,7 @@
         this.container.removeChild(this);
       }
       document.removeEventListener('click', this.documentClickListener);
+      this.container?.removeEventListener('click', this.containerClickListener);
       this.reportingTimer?.end({
         targetId: this._target?.id,
         tagName: this.tagName,
@@ -521,6 +539,7 @@
       if (props?.keyboardEvent) {
         this.focus();
       }
+      this.container.addEventListener('click', this.containerClickListener);
       document.addEventListener('click', this.documentClickListener);
       this.reportingTimer = this.reporting.getTimer('Show Hovercard');
     };
@@ -542,16 +561,15 @@
         if (this._isInsideViewport()) return;
       }
       this.updatePositionTo(this.position);
-      console.warn('Could not find a visible position for the hovercard.');
     }
 
     _isInsideViewport() {
       const thisRect = this.getBoundingClientRect();
-      if (thisRect.top < 0) return false;
-      if (thisRect.left < 0) return false;
-      const docuRect = document.documentElement.getBoundingClientRect();
-      if (thisRect.bottom > docuRect.height) return false;
-      if (thisRect.right > docuRect.width) return false;
+      const hostRect = this.getHost().getBoundingClientRect();
+      if (thisRect.top < hostRect.top) return false;
+      if (thisRect.left < hostRect.left) return false;
+      if (thisRect.bottom > hostRect.bottom) return false;
+      if (thisRect.right > hostRect.right) return false;
       return true;
     }
 
@@ -576,12 +594,12 @@
       // in the width and height of the bounding client rect.
       this.style.cssText = '';
 
-      const docuRect = document.documentElement.getBoundingClientRect();
+      const hostRect = this.getHost().getBoundingClientRect();
       const targetRect = this._target.getBoundingClientRect();
       const thisRect = this.getBoundingClientRect();
 
-      const targetLeft = targetRect.left - docuRect.left;
-      const targetTop = targetRect.top - docuRect.top;
+      const targetLeft = targetRect.left - hostRect.left;
+      const targetTop = targetRect.top - hostRect.top;
 
       let hovercardLeft;
       let hovercardTop;
@@ -640,6 +658,7 @@
 
   // Used for tests
   mouseHide(e: MouseEvent): void;
+  getHost(): HTMLElement;
   hide(props: MouseKeyboardOrFocusEvent): void;
   container: HTMLElement | null;
   hideTask?: DelayedTask;
diff --git a/polygerrit-ui/app/mixins/hovercard-mixin/hovercard-mixin_test.ts b/polygerrit-ui/app/mixins/hovercard-mixin/hovercard-mixin_test.ts
index 8d32c5b9..ffae9e5 100644
--- a/polygerrit-ui/app/mixins/hovercard-mixin/hovercard-mixin_test.ts
+++ b/polygerrit-ui/app/mixins/hovercard-mixin/hovercard-mixin_test.ts
@@ -73,7 +73,7 @@
     assert.typeOf(element.style.getPropertyValue('paddingTop'), 'string');
     assert.typeOf(element.style.getPropertyValue('marginTop'), 'string');
 
-    const parentRect = document.documentElement.getBoundingClientRect();
+    const parentRect = element.getHost().getBoundingClientRect();
     const targetRect = element._target!.getBoundingClientRect();
     const thisRect = element.getBoundingClientRect();
 
@@ -93,6 +93,16 @@
     );
   });
 
+  test('getHost', () => {
+    element._target = document.createElement('span');
+
+    const dialog = document.createElement('dialog');
+
+    assert.deepEqual(element.getHost(), document.body);
+    dialog.appendChild(element._target);
+    assert.deepEqual(element.getHost(), dialog);
+  });
+
   test('hide', () => {
     element.mouseHide(new MouseEvent('click'));
     const style = getComputedStyle(element);
diff --git a/polygerrit-ui/app/mixins/iron-fit-mixin/iron-fit-mixin.ts b/polygerrit-ui/app/mixins/iron-fit-mixin/iron-fit-mixin.ts
deleted file mode 100644
index 30adedf..0000000
--- a/polygerrit-ui/app/mixins/iron-fit-mixin/iron-fit-mixin.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-/**
- * @license
- * Copyright 2020 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-import {IronFitBehavior} from '@polymer/iron-fit-behavior/iron-fit-behavior';
-import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {Constructor} from '../../utils/common-util';
-
-// The mixinBehaviors clears all type information about superClass.
-// As a workaround, we define IronFitMixin with correct type.
-// Due to the following issues:
-// https://github.com/microsoft/TypeScript/issues/15870
-// https://github.com/microsoft/TypeScript/issues/9944
-// we have to import IronFitBehavior in the same file where IronFitMixin
-// is used. To ensure that this import can't be avoided, the second parameter
-// is added. Usage example:
-// class Element extends IronFitMixin(PolymerElement, IronFitBehavior as IronFitBehavior)
-// The code 'IronFitBehavior as IronFitBehavior' required, because IronFitBehavior
-// defined as an object, not as IronFitBehavior instance.
-
-export const IronFitMixin = <T extends Constructor<PolymerElement>>(
-  superClass: T,
-  _: IronFitBehavior
-): T & Constructor<IronFitBehavior> =>
-  // TODO(TS): mixinBehaviors in some lib is returning: `new () => T` instead
-  // which will fail the type check due to missing IronFitBehavior interface
-  // eslint-disable-next-line
-  mixinBehaviors([IronFitBehavior], superClass) as any;
diff --git a/polygerrit-ui/app/mixins/iron-overlay-mixin/iron-overlay-mixin.ts b/polygerrit-ui/app/mixins/iron-overlay-mixin/iron-overlay-mixin.ts
deleted file mode 100644
index 3625228..0000000
--- a/polygerrit-ui/app/mixins/iron-overlay-mixin/iron-overlay-mixin.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-/**
- * @license
- * Copyright 2020 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-import {IronOverlayBehavior} from '@polymer/iron-overlay-behavior/iron-overlay-behavior';
-import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {Constructor} from '../../utils/common-util';
-
-// The mixinBehaviors clears all type information about superClass.
-// As a workaround, we define IronOverlayMixin with correct type.
-// Due to the following issues:
-// https://github.com/microsoft/TypeScript/issues/15870
-// https://github.com/microsoft/TypeScript/issues/9944
-// we have to import IronOverlayBehavior in the same file where IronOverlayMixin
-// is used. To ensure that this import can't be avoided, the second parameter
-// is added. Usage example:
-// class Element extends IronOverlayMixin(PolymerElement, IronOverlayBehavior as IronOverlayBehavior)
-// The code 'IronOverlayBehavior as IronOverlayBehavior' required, because
-// IronOverlayBehavior defined as an object, not as IronOverlayBehavior instance.
-export const IronOverlayMixin = <T extends Constructor<PolymerElement>>(
-  superClass: T,
-  _: IronOverlayBehavior
-): T & Constructor<IronOverlayBehavior> =>
-  // TODO(TS): mixinBehaviors in some lib is returning: `new () => T`
-  // instead which will fail the type check due to missing
-  // IronOverlayBehavior interface
-  // eslint-disable-next-line
-  mixinBehaviors([IronOverlayBehavior], superClass) as any;
diff --git a/polygerrit-ui/app/models/accounts-model/accounts-model.ts b/polygerrit-ui/app/models/accounts-model/accounts-model.ts
index 3f35127..1c67857 100644
--- a/polygerrit-ui/app/models/accounts-model/accounts-model.ts
+++ b/polygerrit-ui/app/models/accounts-model/accounts-model.ts
@@ -6,52 +6,57 @@
 
 import {AccountDetailInfo, AccountInfo} from '../../api/rest-api';
 import {RestApiService} from '../../services/gr-rest-api/gr-rest-api';
-import {Finalizable} from '../../services/registry';
 import {UserId} from '../../types/common';
 import {getUserId, isDetailedAccount} from '../../utils/account-util';
+import {hasOwnProperty} from '../../utils/common-util';
 import {define} from '../dependency';
 import {Model} from '../model';
 
 export interface AccountsState {
-  accounts: {[id: UserId]: AccountDetailInfo};
+  accounts: {
+    [id: UserId]: AccountDetailInfo | AccountInfo;
+  };
 }
 
 export const accountsModelToken = define<AccountsModel>('accounts-model');
 
-export class AccountsModel extends Model<AccountsState> implements Finalizable {
+export class AccountsModel extends Model<AccountsState> {
   constructor(readonly restApiService: RestApiService) {
     super({
       accounts: {},
     });
   }
 
-  private updateStateAccount(id: UserId, account?: AccountDetailInfo) {
+  private updateStateAccount(
+    id: UserId,
+    account: AccountDetailInfo | AccountInfo
+  ) {
     if (!account) return;
     const current = {...this.getState()};
     current.accounts = {...current.accounts, [id]: account};
     this.setState(current);
   }
 
-  async getAccount(partialAccount: AccountInfo) {
+  async getAccount(
+    partialAccount: AccountInfo
+  ): Promise<AccountDetailInfo | AccountInfo> {
     const current = this.getState();
     const id = getUserId(partialAccount);
-    if (current.accounts[id]) return current.accounts[id];
+    if (hasOwnProperty(current.accounts, id)) return current.accounts[id];
     // It is possible to add emails to CC when they don't have a Gerrit
-    // account. In this case getAccountDetails will return a 404 error hence
-    // pass an empty error function to handle that.
+    // account. In this case getAccountDetails will return a 404 error then
+    // we at least use what is in partialAccount.
     const account = await this.restApiService.getAccountDetails(id, () => {
-      this.updateStateAccount(id, partialAccount as AccountDetailInfo);
+      this.updateStateAccount(id, partialAccount);
       return;
     });
     if (account) this.updateStateAccount(id, account);
-    return account;
+    return account ?? partialAccount;
   }
 
   async fillDetails(account: AccountInfo) {
     if (!isDetailedAccount(account)) {
-      if (account.email) return await this.getAccount({email: account.email});
-      else if (account._account_id)
-        return await this.getAccount({_account_id: account._account_id});
+      return await this.getAccount(account);
     }
     return account;
   }
diff --git a/polygerrit-ui/app/models/browser/browser-model.ts b/polygerrit-ui/app/models/browser/browser-model.ts
index 1592cd8..50b6325 100644
--- a/polygerrit-ui/app/models/browser/browser-model.ts
+++ b/polygerrit-ui/app/models/browser/browser-model.ts
@@ -4,7 +4,6 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import {Observable, combineLatest} from 'rxjs';
-import {Finalizable} from '../../services/registry';
 import {define} from '../dependency';
 import {DiffViewMode} from '../../api/diff';
 import {UserModel} from '../user/user-model';
@@ -26,7 +25,7 @@
 
 export const browserModelToken = define<BrowserModel>('browser-model');
 
-export class BrowserModel extends Model<BrowserState> implements Finalizable {
+export class BrowserModel extends Model<BrowserState> {
   private readonly isScreenTooSmall$ = select(
     this.state$,
     state =>
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 f706712..b13a16f 100644
--- a/polygerrit-ui/app/models/bulk-actions/bulk-actions-model.ts
+++ b/polygerrit-ui/app/models/bulk-actions/bulk-actions-model.ts
@@ -14,7 +14,6 @@
   Hashtag,
 } 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';
@@ -50,10 +49,7 @@
   allChanges: new Map(),
 };
 
-export class BulkActionsModel
-  extends Model<BulkActionsState>
-  implements Finalizable
-{
+export class BulkActionsModel extends Model<BulkActionsState> {
   constructor(private readonly restApiService: RestApiService) {
     super(initialState);
   }
diff --git a/polygerrit-ui/app/models/change/change-model.ts b/polygerrit-ui/app/models/change/change-model.ts
index e00aefe..4e9f015 100644
--- a/polygerrit-ui/app/models/change/change-model.ts
+++ b/polygerrit-ui/app/models/change/change-model.ts
@@ -5,6 +5,7 @@
  */
 import {
   BasePatchSetNum,
+  ChangeInfo,
   EditInfo,
   EDIT,
   PARENT,
@@ -12,34 +13,41 @@
   PatchSetNum,
   PreferencesInfo,
   RevisionPatchSetNum,
+  PatchSetNumber,
+  CommitId,
 } from '../../types/common';
-import {DefaultBase} from '../../constants/constants';
-import {combineLatest, from, fromEvent, Observable, forkJoin, of} from 'rxjs';
-import {
-  map,
-  filter,
-  withLatestFrom,
-  startWith,
-  switchMap,
-} from 'rxjs/operators';
-import {RouterModel} from '../../services/router/router-model';
+import {ChangeStatus, DefaultBase} from '../../constants/constants';
+import {combineLatest, from, Observable, forkJoin, of} from 'rxjs';
+import {map, filter, withLatestFrom, switchMap} from 'rxjs/operators';
 import {
   computeAllPatchSets,
   computeLatestPatchNum,
   computeLatestPatchNumWithEdit,
+  findEdit,
+  sortRevisions,
 } from '../../utils/patch-set-util';
-import {ParsedChangeInfo} from '../../types/types';
-import {fireAlert} from '../../utils/event-util';
-
-import {ChangeInfo} from '../../types/common';
+import {isDefined, ParsedChangeInfo} from '../../types/types';
+import {fireAlert, fireTitleChange} from '../../utils/event-util';
 import {RestApiService} from '../../services/gr-rest-api/gr-rest-api';
-import {Finalizable} from '../../services/registry';
 import {select} from '../../utils/observable-util';
 import {assertIsDefined} from '../../utils/common-util';
 import {Model} from '../model';
 import {UserModel} from '../user/user-model';
 import {define} from '../dependency';
 import {isOwner} from '../../utils/change-util';
+import {
+  ChangeChildView,
+  ChangeViewModel,
+  createChangeUrl,
+  createDiffUrl,
+  createEditUrl,
+} from '../views/change';
+import {NavigationService} from '../../elements/core/gr-navigation/gr-navigation';
+import {getRevertCreatedChangeIds} from '../../utils/message-util';
+import {computeTruncatedPath} from '../../utils/path-list-util';
+import {PluginLoader} from '../../elements/shared/gr-js-api-interface/gr-plugin-loader';
+import {ReportingService} from '../../services/gr-reporting/gr-reporting';
+import {Timing} from '../../constants/reporting';
 
 export enum LoadingStatus {
   NOT_LOADED = 'NOT_LOADED',
@@ -58,17 +66,44 @@
   loadingStatus: LoadingStatus;
   change?: ParsedChangeInfo;
   /**
-   * The name of the file user is viewing in the diff view mode. File path is
-   * specified in the url or derived from the commentId.
-   * Does not apply to change-view or edit-view.
-   */
-  diffPath?: string;
-  /**
    * The list of reviewed files, kept in the model because we want changes made
    * in one view to reflect on other views without re-rendering the other views.
    * Undefined means it's still loading and empty set means no files reviewed.
    */
   reviewedFiles?: string[];
+  /**
+   * Either filled from `change.mergeable`, or from a dedicated REST API call.
+   * Is initially `undefined`, such that you can identify whether this
+   * information has already been loaded once for this change or not. Will never
+   * go back to `undefined` after being set for a change.
+   */
+  mergeable?: boolean;
+}
+
+/**
+ * `change.revisions` is a dictionary mapping the revision sha to RevisionInfo,
+ * but the info object itself does not contain the sha, which is a problem when
+ * working with just the info objects.
+ *
+ * So we are iterating over the map here and are assigning the sha map key to
+ * the property `revision.commit.commit`.
+ *
+ * As usual we are treating data objects as immutable, so we are doind a lot of
+ * cloning here.
+ */
+export function updateRevisionsWithCommitShas(changeInput?: ParsedChangeInfo) {
+  if (!changeInput?.revisions) return changeInput;
+  const changeOutput = {...changeInput, revisions: {...changeInput.revisions}};
+  for (const sha of Object.keys(changeOutput.revisions)) {
+    const revision = changeOutput.revisions[sha];
+    if (revision?.commit && !revision.commit.commit) {
+      changeOutput.revisions[sha] = {
+        ...revision,
+        commit: {...revision.commit, commit: sha as CommitId},
+      };
+    }
+  }
+  return changeOutput;
 }
 
 /**
@@ -78,7 +113,7 @@
 export function updateChangeWithEdit(
   change?: ParsedChangeInfo,
   edit?: EditInfo,
-  routerPatchNum?: PatchSetNum
+  viewModelPatchNum?: PatchSetNum
 ): ParsedChangeInfo | undefined {
   if (!change || !edit) return change;
   assertIsDefined(edit.commit.commit, 'edit.commit.commit');
@@ -97,7 +132,7 @@
   // which is still done in change-view. `_patchRange.patchNum` should
   // eventually also be model managed, so we can reconcile these two code
   // snippets into one location.
-  if (routerPatchNum === undefined) {
+  if (viewModelPatchNum === undefined) {
     change.current_revision = edit.commit.commit;
   }
   return change;
@@ -105,20 +140,20 @@
 
 /**
  * Derives the base patchset number from all the data that can potentially
- * influence it. Mostly just returns `routerBasePatchNum` or PARENT, but has
+ * influence it. Mostly just returns `viewModelBasePatchNum` or PARENT, but has
  * some special logic when looking at merge commits.
  *
- * NOTE: At the moment this returns just `routerBasePatchNum ?? PARENT`, see
+ * NOTE: At the moment this returns just `viewModelBasePatchNum ?? PARENT`, see
  * TODO below.
  */
 function computeBase(
-  routerBasePatchNum: BasePatchSetNum | undefined,
+  viewModelBasePatchNum: BasePatchSetNum | undefined,
   patchNum: RevisionPatchSetNum | undefined,
   change: ParsedChangeInfo | undefined,
   preferences: PreferencesInfo
 ): BasePatchSetNum {
-  if (routerBasePatchNum && routerBasePatchNum !== PARENT) {
-    return routerBasePatchNum;
+  if (viewModelBasePatchNum && viewModelBasePatchNum !== PARENT) {
+    return viewModelBasePatchNum;
   }
   if (!change || !patchNum) return PARENT;
 
@@ -131,7 +166,7 @@
   // but we are not sure whether this was ever 100% working correctly. A
   // major challenge is being able to select PARENT explicitly even if your
   // preference for the default choice is FIRST_PARENT. <gr-file-list-header>
-  // just uses `navigation.setUrl()` and the router does not have any
+  // just uses `navigation.setUrl()` and the view model does not have any
   // way of forcing the basePatchSetNum to stick to PARENT without being
   // altered back to FIRST_PARENT here.
   // See also corresponding TODO in gr-settings-view.
@@ -149,10 +184,14 @@
 
 export const changeModelToken = define<ChangeModel>('change-model');
 
-export class ChangeModel extends Model<ChangeState> implements Finalizable {
+export class ChangeModel extends Model<ChangeState> {
   private change?: ParsedChangeInfo;
 
-  private patchNum?: PatchSetNum;
+  private patchNum?: RevisionPatchSetNum;
+
+  private basePatchNum?: BasePatchSetNum;
+
+  private latestPatchNum?: PatchSetNumber;
 
   public readonly change$ = select(
     this.state$,
@@ -164,9 +203,10 @@
     changeState => changeState.loadingStatus
   );
 
-  public readonly diffPath$ = select(
-    this.state$,
-    changeState => changeState?.diffPath
+  public readonly loading$ = select(
+    this.changeLoadingStatus$,
+    status =>
+      status === LoadingStatus.LOADING || status === LoadingStatus.RELOADING
   );
 
   public readonly reviewedFiles$ = select(
@@ -174,15 +214,27 @@
     changeState => changeState?.reviewedFiles
   );
 
+  public readonly mergeable$ = select(
+    this.state$,
+    changeState => changeState.mergeable
+  );
+
+  public readonly branch$ = select(this.change$, change => change?.branch);
+
   public readonly changeNum$ = select(this.change$, change => change?._number);
 
+  public readonly changeId$ = select(this.change$, change => change?.change_id);
+
   public readonly repo$ = select(this.change$, change => change?.project);
 
+  public readonly topic$ = select(this.change$, change => change?.topic);
+
+  public readonly status$ = select(this.change$, change => change?.status);
+
   public readonly labels$ = select(this.change$, change => change?.labels);
 
-  public readonly revisions$ = select(
-    this.change$,
-    change => change?.revisions
+  public readonly revisions$ = select(this.change$, change =>
+    sortRevisions(Object.values(change?.revisions || {}))
   );
 
   public readonly patchsets$ = select(this.change$, change =>
@@ -197,6 +249,11 @@
     computeLatestPatchNumWithEdit(patchsets)
   );
 
+  public readonly latestUploader$ = select(
+    this.change$,
+    change => change?.revisions[change.current_revision]?.uploader
+  );
+
   /**
    * Emits the current patchset number. If the route does not define the current
    * patchset num, then this selector waits for the change to be defined and
@@ -207,120 +264,327 @@
   public readonly patchNum$: Observable<RevisionPatchSetNum | undefined> =
     select(
       combineLatest([
-        this.routerModel.state$,
+        this.viewModel.state$,
         this.state$,
         this.latestPatchNumWithEdit$,
       ]).pipe(
         /**
-         * If you depend on both, router and change state, then you want to
-         * filter out inconsistent state, e.g. router changeNum already updated,
-         * change not yet reset to undefined.
+         * If you depend on both, view model and change state, then you want to
+         * filter out inconsistent state, e.g. view model changeNum already
+         * updated, change not yet reset to undefined.
          */
-        filter(([routerState, changeState, _latestPatchN]) => {
+        filter(([viewModelState, changeState, _latestPatchN]) => {
           const changeNum = changeState.change?._number;
-          const routerChangeNum = routerState.changeNum;
-          return changeNum === undefined || changeNum === routerChangeNum;
+          const viewModelChangeNum = viewModelState?.changeNum;
+          return changeNum === undefined || changeNum === viewModelChangeNum;
         })
       ),
-      ([routerState, _changeState, latestPatchN]) =>
-        routerState?.patchNum || latestPatchN
+      ([viewModelState, _changeState, latestPatchN]) =>
+        viewModelState?.patchNum || latestPatchN
     );
 
   /**
    * Emits the base patchset number. This is identical to the
-   * `routerBasePatchNum$`, but has some special logic for merges.
+   * `viewModel.basePatchNum$`, but has some special logic for merges.
    *
    * Note that this selector can emit without the change being available!
    */
   public readonly basePatchNum$: Observable<BasePatchSetNum> =
     /**
-     * If you depend on both, router and change state, then you want to filter
-     * out inconsistent state, e.g. router changeNum already updated, change not
-     * yet reset to undefined.
+     * If you depend on both, view model and change state, then you want to
+     * filter out inconsistent state, e.g. view model changeNum already
+     * updated, change not yet reset to undefined.
      */
     select(
       combineLatest([
-        this.routerModel.state$,
+        this.viewModel.state$,
         this.state$,
         this.userModel.state$,
       ]).pipe(
-        filter(([routerState, changeState, _]) => {
+        filter(([viewModelState, changeState, _]) => {
           const changeNum = changeState.change?._number;
-          const routerChangeNum = routerState.changeNum;
-          return changeNum === undefined || changeNum === routerChangeNum;
+          const viewModelChangeNum = viewModelState?.changeNum;
+          return changeNum === undefined || changeNum === viewModelChangeNum;
         }),
         withLatestFrom(
-          this.routerModel.routerBasePatchNum$,
+          this.viewModel.basePatchNum$,
           this.patchNum$,
           this.change$,
           this.userModel.preferences$
         )
       ),
-      ([_, routerBasePatchNum, patchNum, change, preferences]) =>
-        computeBase(routerBasePatchNum, patchNum, change, preferences)
+      ([_, viewModelBasePatchNum, patchNum, change, preferences]) =>
+        computeBase(viewModelBasePatchNum, patchNum, change, preferences)
     );
 
+  private selectRevision(
+    revisionNum$: Observable<RevisionPatchSetNum | undefined>
+  ) {
+    return select(
+      combineLatest([this.revisions$, revisionNum$]),
+      ([revisions, patchNum]) => {
+        if (!revisions || !patchNum) return undefined;
+        return Object.values(revisions).find(
+          revision => revision._number === patchNum
+        );
+      }
+    );
+  }
+
+  public readonly revision$ = this.selectRevision(this.patchNum$);
+
+  public readonly latestRevision$ = this.selectRevision(this.latestPatchNum$);
+
   public readonly isOwner$: Observable<boolean> = select(
     combineLatest([this.change$, this.userModel.account$]),
     ([change, account]) => isOwner(change, account)
   );
 
-  // For usage in `combineLatest` we need `startWith` such that reload$ has an
-  // initial value.
-  readonly reload$: Observable<unknown> = fromEvent(document, 'reload').pipe(
-    startWith(undefined)
+  public readonly messages$ = select(this.change$, change => change?.messages);
+
+  public readonly revertingChangeIds$ = select(this.messages$, messages =>
+    getRevertCreatedChangeIds(messages ?? [])
   );
 
   constructor(
-    readonly routerModel: RouterModel,
-    readonly restApiService: RestApiService,
-    readonly userModel: UserModel
+    private readonly navigation: NavigationService,
+    private readonly viewModel: ChangeViewModel,
+    private readonly restApiService: RestApiService,
+    private readonly userModel: UserModel,
+    private readonly pluginLoader: PluginLoader,
+    private readonly reporting: ReportingService
   ) {
     super(initialState);
     this.subscriptions = [
-      combineLatest([this.routerModel.routerChangeNum$, this.reload$])
-        .pipe(
-          map(([changeNum, _]) => changeNum),
-          switchMap(changeNum => {
-            if (changeNum !== undefined) this.updateStateLoading(changeNum);
-            const change = from(this.restApiService.getChangeDetail(changeNum));
-            const edit = from(this.restApiService.getChangeEdit(changeNum));
-            return forkJoin([change, edit]);
-          }),
-          withLatestFrom(this.routerModel.routerPatchNum$),
-          map(([[change, edit], patchNum]) =>
-            updateChangeWithEdit(change, edit, patchNum)
-          )
-        )
-        .subscribe(change => {
-          // The change service is currently a singleton, so we have to be
-          // careful to avoid situations where the application state is
-          // partially set for the old change where the user is coming from,
-          // and partially for the new change where the user is navigating to.
-          // So setting the change explicitly to undefined when the user
-          // moves away from diff and change pages (changeNum === undefined)
-          // helps with that.
-          this.updateStateChange(change ?? undefined);
-        }),
+      this.loadChange(),
+      this.loadMergeable(),
+      this.loadReviewedFiles(),
+      this.setOverviewTitle(),
+      this.setDiffTitle(),
+      this.setEditTitle(),
+      this.reportChangeReload(),
+      this.reportSendReply(),
+      this.fireShowChange(),
+      this.refuseEditForOpenChange(),
+      this.refuseEditForClosedChange(),
       this.change$.subscribe(change => (this.change = change)),
       this.patchNum$.subscribe(patchNum => (this.patchNum = patchNum)),
-      combineLatest([this.patchNum$, this.changeNum$, this.userModel.loggedIn$])
-        .pipe(
-          switchMap(([patchNum, changeNum, loggedIn]) => {
-            if (!changeNum || !patchNum || !loggedIn) {
-              this.updateStateReviewedFiles([]);
-              return of(undefined);
-            }
-            return from(this.fetchReviewedFiles(patchNum, changeNum));
-          })
-        )
-        .subscribe(),
+      this.basePatchNum$.subscribe(
+        basePatchNum => (this.basePatchNum = basePatchNum)
+      ),
+      this.latestPatchNum$.subscribe(
+        latestPatchNum => (this.latestPatchNum = latestPatchNum)
+      ),
     ];
   }
 
-  // Temporary workaround until path is derived in the model itself.
-  updatePath(diffPath?: string) {
-    this.updateState({diffPath});
+  private reportSendReply() {
+    return this.changeLoadingStatus$.subscribe(loadingStatus => {
+      // We are ending the timer on each change load, because ending a timer
+      // that was not started is a no-op. :-)
+      if (loadingStatus === LoadingStatus.LOADED) {
+        this.reporting.timeEnd(Timing.SEND_REPLY);
+      }
+    });
+  }
+
+  private reportChangeReload() {
+    return this.changeLoadingStatus$.subscribe(loadingStatus => {
+      if (
+        loadingStatus === LoadingStatus.LOADING ||
+        loadingStatus === LoadingStatus.RELOADING
+      ) {
+        this.reporting.time(Timing.CHANGE_RELOAD);
+      }
+      if (
+        loadingStatus === LoadingStatus.LOADED ||
+        loadingStatus === LoadingStatus.NOT_LOADED
+      ) {
+        this.reporting.timeEnd(Timing.CHANGE_RELOAD);
+      }
+    });
+  }
+
+  private fireShowChange() {
+    return combineLatest([
+      this.viewModel.childView$,
+      this.change$,
+      this.basePatchNum$,
+      this.patchNum$,
+      this.mergeable$,
+    ])
+      .pipe(
+        filter(
+          ([childView, change, basePatchNum, patchNum, mergeable]) =>
+            childView === ChangeChildView.OVERVIEW &&
+            !!change &&
+            !!basePatchNum &&
+            !!patchNum &&
+            mergeable !== undefined
+        )
+      )
+      .subscribe(([_, change, basePatchNum, patchNum, mergeable]) => {
+        this.pluginLoader.jsApiService.handleShowChange({
+          change,
+          basePatchNum,
+          patchNum,
+          // `?? null` is for the TypeScript compiler only. We have a
+          // `mergeable !== undefined` filter above, so this cannot happen.
+          // It would be nice to change `ShowChangeDetail` to accept `undefined`
+          // instaed of `null`, but that would be a Plugin API change ...
+          info: {mergeable: mergeable ?? null},
+        });
+      });
+  }
+
+  private refuseEditForOpenChange() {
+    return combineLatest([this.revisions$, this.patchNum$, this.status$])
+      .pipe(
+        filter(
+          ([revisions, patchNum, status]) =>
+            status === ChangeStatus.NEW &&
+            revisions.length > 0 &&
+            patchNum === EDIT
+        )
+      )
+      .subscribe(([revisions]) => {
+        const editRev = findEdit(revisions);
+        if (!editRev) {
+          const msg = 'Change edit not found. Please create a change edit.';
+          fireAlert(document, msg);
+          this.navigateToChangeResetReload();
+        }
+      });
+  }
+
+  private refuseEditForClosedChange() {
+    return combineLatest([
+      this.revisions$,
+      this.viewModel.edit$,
+      this.patchNum$,
+      this.status$,
+    ])
+      .pipe(
+        filter(
+          ([revisions, edit, patchNum, status]) =>
+            (status === ChangeStatus.ABANDONED ||
+              status === ChangeStatus.MERGED) &&
+            revisions.length > 0 &&
+            (patchNum === EDIT || edit)
+        )
+      )
+      .subscribe(([revisions]) => {
+        const editRev = findEdit(revisions);
+        if (!editRev) {
+          const msg =
+            'Change edits cannot be created if change is merged ' +
+            'or abandoned. Redirecting to non edit mode.';
+          fireAlert(document, msg);
+          this.navigateToChangeResetReload();
+        }
+      });
+  }
+
+  private setOverviewTitle() {
+    return combineLatest([this.viewModel.childView$, this.change$])
+      .pipe(
+        filter(([childView, _]) => childView === ChangeChildView.OVERVIEW),
+        map(([_, change]) => change),
+        filter(isDefined)
+      )
+      .subscribe(change => {
+        const title = `${change.subject} (${change._number})`;
+        fireTitleChange(title);
+      });
+  }
+
+  private setDiffTitle() {
+    return combineLatest([this.viewModel.childView$, this.viewModel.diffPath$])
+      .pipe(
+        filter(([childView, _]) => childView === ChangeChildView.DIFF),
+        map(([_, diffPath]) => diffPath),
+        filter(isDefined)
+      )
+      .subscribe(diffPath => {
+        const title = computeTruncatedPath(diffPath);
+        fireTitleChange(title);
+      });
+  }
+
+  private setEditTitle() {
+    return combineLatest([this.viewModel.childView$, this.viewModel.editPath$])
+      .pipe(
+        filter(([childView, _]) => childView === ChangeChildView.EDIT),
+        map(([_, editPath]) => editPath),
+        filter(isDefined)
+      )
+      .subscribe(editPath => {
+        const title = `Editing ${computeTruncatedPath(editPath)}`;
+        fireTitleChange(title);
+      });
+  }
+
+  private loadReviewedFiles() {
+    return combineLatest([
+      this.patchNum$,
+      this.changeNum$,
+      this.userModel.loggedIn$,
+    ])
+      .pipe(
+        switchMap(([patchNum, changeNum, loggedIn]) => {
+          if (!changeNum || !patchNum || !loggedIn) {
+            this.updateStateReviewedFiles([]);
+            return of(undefined);
+          }
+          return from(this.fetchReviewedFiles(patchNum, changeNum));
+        })
+      )
+      .subscribe();
+  }
+
+  private loadMergeable() {
+    return this.change$
+      .pipe(
+        switchMap(change => {
+          if (change?._number === undefined) return of(undefined);
+          if (change.mergeable !== undefined) return of(change.mergeable);
+          if (change.status === ChangeStatus.MERGED) return of(false);
+          if (change.status === ChangeStatus.ABANDONED) return of(false);
+          return from(
+            this.restApiService
+              .getMergeable(change._number)
+              .then(mergableInfo => mergableInfo?.mergeable ?? false)
+          );
+        })
+      )
+      .subscribe(mergeable => this.updateState({mergeable}));
+  }
+
+  private loadChange() {
+    return this.viewModel.changeNum$
+      .pipe(
+        switchMap(changeNum => {
+          if (changeNum !== undefined) this.updateStateLoading(changeNum);
+          const change = from(this.restApiService.getChangeDetail(changeNum));
+          const edit = from(this.restApiService.getChangeEdit(changeNum));
+          return forkJoin([change, edit]);
+        }),
+        withLatestFrom(this.viewModel.patchNum$),
+        map(([[change, edit], patchNum]) =>
+          updateChangeWithEdit(change, edit, patchNum)
+        ),
+        map(updateRevisionsWithCommitShas)
+      )
+      .subscribe(change => {
+        // The change service is currently a singleton, so we have to be
+        // careful to avoid situations where the application state is
+        // partially set for the old change where the user is coming from,
+        // and partially for the new change where the user is navigating to.
+        // So setting the change explicitly to undefined when the user
+        // moves away from diff and change pages (changeNum === undefined)
+        // helps with that.
+        this.updateStateChange(change ?? undefined);
+      });
   }
 
   updateStateReviewedFiles(reviewedFiles: string[]) {
@@ -387,6 +651,80 @@
     return this.getState().change;
   }
 
+  diffUrl(
+    diffView: {path: string; lineNum?: number},
+    patchNum = this.patchNum,
+    basePatchNum = this.basePatchNum
+  ) {
+    if (!this.change) return;
+    if (!this.patchNum) return;
+    return createDiffUrl({
+      change: this.change,
+      patchNum,
+      basePatchNum,
+      diffView,
+    });
+  }
+
+  navigateToDiff(
+    diffView: {path: string; lineNum?: number},
+    patchNum = this.patchNum,
+    basePatchNum = this.basePatchNum
+  ) {
+    const url = this.diffUrl(diffView, patchNum, basePatchNum);
+    if (!url) return;
+    this.navigation.setUrl(url);
+  }
+
+  changeUrl(openReplyDialog = false) {
+    if (!this.change) return;
+    const isLatest = this.latestPatchNum === this.patchNum;
+    return createChangeUrl({
+      change: this.change,
+      patchNum:
+        isLatest && this.basePatchNum === PARENT ? undefined : this.patchNum,
+      basePatchNum: this.basePatchNum,
+      openReplyDialog,
+    });
+  }
+
+  // Mainly used for navigating from DIFF to OVERVIEW.
+  navigateToChange(openReplyDialog = false) {
+    const url = this.changeUrl(openReplyDialog);
+    if (!url) return;
+    this.navigation.setUrl(url);
+  }
+
+  /**
+   * Wipes all URL parameters and other view state and goes to the change
+   * overview page, forcing a reload.
+   *
+   * This will also wipe the `patchNum`, so will always go to the latest
+   * patchset.
+   */
+  navigateToChangeResetReload() {
+    if (!this.change) return;
+    const url = createChangeUrl({change: this.change, forceReload: true});
+    if (!url) return;
+    this.navigation.setUrl(url);
+  }
+
+  editUrl(editView: {path: string; lineNum?: number}) {
+    if (!this.change) return;
+    return createEditUrl({
+      changeNum: this.change._number,
+      repo: this.change.project,
+      patchNum: this.patchNum,
+      editView,
+    });
+  }
+
+  navigateToEdit(editView: {path: string; lineNum?: number}) {
+    const url = this.editUrl(editView);
+    if (!url) return;
+    this.navigation.setUrl(url);
+  }
+
   /**
    * Check whether there is no newer patch than the latest patch that was
    * available when this change was loaded.
diff --git a/polygerrit-ui/app/models/change/change-model_test.ts b/polygerrit-ui/app/models/change/change-model_test.ts
index 4b51d5b..dc7d9c3 100644
--- a/polygerrit-ui/app/models/change/change-model_test.ts
+++ b/polygerrit-ui/app/models/change/change-model_test.ts
@@ -7,9 +7,12 @@
 import {ChangeStatus} from '../../constants/constants';
 import '../../test/common-test-setup';
 import {
+  TEST_NUMERIC_CHANGE_ID,
   createChange,
   createChangeMessageInfo,
+  createChangeViewState,
   createEditInfo,
+  createMergeable,
   createParsedChange,
   createRevision,
 } from '../../test/test-data-generators';
@@ -28,10 +31,34 @@
 } from '../../types/common';
 import {ParsedChangeInfo} from '../../types/types';
 import {getAppContext} from '../../services/app-context';
-import {GerritView} from '../../services/router/router-model';
-import {ChangeState, LoadingStatus, updateChangeWithEdit} from './change-model';
+import {
+  ChangeState,
+  LoadingStatus,
+  updateChangeWithEdit,
+  updateRevisionsWithCommitShas,
+} from './change-model';
 import {ChangeModel} from './change-model';
 import {assert} from '@open-wc/testing';
+import {testResolver} from '../../test/common-test-setup';
+import {userModelToken} from '../user/user-model';
+import {
+  ChangeChildView,
+  ChangeViewModel,
+  changeViewModelToken,
+} from '../views/change';
+import {navigationToken} from '../../elements/core/gr-navigation/gr-navigation';
+import {SinonStub} from 'sinon';
+import {pluginLoaderToken} from '../../elements/shared/gr-js-api-interface/gr-plugin-loader';
+import {ShowChangeDetail} from '../../elements/shared/gr-js-api-interface/gr-js-api-types';
+
+suite('updateRevisionsWithCommitShas() tests', () => {
+  test('undefined edit', async () => {
+    const change = createParsedChange();
+    const updated = updateRevisionsWithCommitShas(change);
+    assert.equal(change?.revisions?.['abc'].commit?.commit, undefined);
+    assert.equal(updated?.revisions?.['abc'].commit?.commit, 'abc' as CommitId);
+  });
+});
 
 suite('updateChangeWithEdit() tests', () => {
   test('undefined change', async () => {
@@ -65,6 +92,7 @@
 });
 
 suite('change model tests', () => {
+  let changeViewModel: ChangeViewModel;
   let changeModel: ChangeModel;
   let knownChange: ParsedChangeInfo;
   const testCompleted = new Subject<void>();
@@ -80,24 +108,20 @@
   }
 
   setup(() => {
+    changeViewModel = testResolver(changeViewModelToken);
     changeModel = new ChangeModel(
-      getAppContext().routerModel,
+      testResolver(navigationToken),
+      changeViewModel,
       getAppContext().restApiService,
-      getAppContext().userModel
+      testResolver(userModelToken),
+      testResolver(pluginLoaderToken),
+      getAppContext().reportingService
     );
     knownChange = {
       ...createChange(),
       revisions: {
-        sha1: {
-          ...createRevision(1),
-          description: 'patch 1',
-          _number: 1 as PatchSetNumber,
-        },
-        sha2: {
-          ...createRevision(2),
-          description: 'patch 2',
-          _number: 2 as PatchSetNumber,
-        },
+        sha1: {...createRevision(1), description: 'patch 1'},
+        sha2: {...createRevision(2), description: 'patch 2'},
       },
       status: ChangeStatus.NEW,
       current_revision: 'abc' as CommitId,
@@ -110,6 +134,116 @@
     changeModel.finalize();
   });
 
+  suite('mergeability', async () => {
+    let getMergeableStub: SinonStub;
+    let mergeableApiResponse = false;
+
+    setup(() => {
+      getMergeableStub = stubRestApi('getMergeable').callsFake(() =>
+        Promise.resolve(createMergeable(mergeableApiResponse))
+      );
+    });
+
+    test('mergeability initially undefined', async () => {
+      waitUntilObserved(
+        changeModel.mergeable$,
+        mergeable => mergeable === undefined
+      );
+      assert.isFalse(getMergeableStub.called);
+    });
+
+    test('mergeability true from change', async () => {
+      changeModel.updateStateChange({...knownChange, mergeable: true});
+
+      waitUntilObserved(
+        changeModel.mergeable$,
+        mergeable => mergeable === true
+      );
+      assert.isFalse(getMergeableStub.called);
+    });
+
+    test('mergeability false from change', async () => {
+      changeModel.updateStateChange({...knownChange, mergeable: false});
+
+      waitUntilObserved(
+        changeModel.mergeable$,
+        mergeable => mergeable === true
+      );
+      assert.isFalse(getMergeableStub.called);
+    });
+
+    test('mergeability false for MERGED change', async () => {
+      changeModel.updateStateChange({
+        ...knownChange,
+        status: ChangeStatus.MERGED,
+      });
+
+      waitUntilObserved(
+        changeModel.mergeable$,
+        mergeable => mergeable === false
+      );
+      assert.isFalse(getMergeableStub.called);
+    });
+
+    test('mergeability false for ABANDONED change', async () => {
+      changeModel.updateStateChange({
+        ...knownChange,
+        status: ChangeStatus.ABANDONED,
+      });
+
+      waitUntilObserved(
+        changeModel.mergeable$,
+        mergeable => mergeable === false
+      );
+      assert.isFalse(getMergeableStub.called);
+    });
+
+    test('mergeability true from API', async () => {
+      mergeableApiResponse = true;
+      changeModel.updateStateChange(knownChange);
+
+      waitUntilObserved(
+        changeModel.mergeable$,
+        mergeable => mergeable === true
+      );
+      assert.isTrue(getMergeableStub.calledOnce);
+    });
+
+    test('mergeability false from API', async () => {
+      mergeableApiResponse = false;
+      changeModel.updateStateChange(knownChange);
+
+      waitUntilObserved(
+        changeModel.mergeable$,
+        mergeable => mergeable === false
+      );
+      assert.isTrue(getMergeableStub.calledOnce);
+    });
+  });
+
+  test('fireShowChange', async () => {
+    await waitForLoadingStatus(LoadingStatus.NOT_LOADED);
+    const pluginLoader = testResolver(pluginLoaderToken);
+    const jsApiService = pluginLoader.jsApiService;
+    const showChangeStub = sinon.stub(jsApiService, 'handleShowChange');
+
+    changeViewModel.updateState({
+      childView: ChangeChildView.OVERVIEW,
+      patchNum: 1 as PatchSetNumber,
+    });
+    changeModel.updateState({
+      change: createParsedChange(),
+      mergeable: true,
+    });
+
+    assert.isTrue(showChangeStub.calledOnce);
+    const detail: ShowChangeDetail = showChangeStub.lastCall.firstArg;
+    assert.equal(detail.change?._number, createParsedChange()._number);
+    assert.equal(detail.patchNum, 1 as PatchSetNumber);
+    assert.equal(detail.basePatchNum, PARENT);
+    assert.equal(detail.info.mergeable, true);
+  });
+
   test('load a change', async () => {
     const promise = mockPromise<ParsedChangeInfo | undefined>();
     const stub = stubRestApi('getChangeDetail').callsFake(() => promise);
@@ -119,10 +253,7 @@
     assert.equal(stub.callCount, 0);
     assert.isUndefined(state?.change);
 
-    changeModel.routerModel.setState({
-      view: GerritView.CHANGE,
-      changeNum: knownChange._number,
-    });
+    testResolver(changeViewModelToken).setState(createChangeViewState());
     state = await waitForLoadingStatus(LoadingStatus.LOADING);
     assert.equal(stub.callCount, 1);
     assert.isUndefined(state?.change);
@@ -130,7 +261,7 @@
     promise.resolve(knownChange);
     state = await waitForLoadingStatus(LoadingStatus.LOADED);
     assert.equal(stub.callCount, 1);
-    assert.equal(state?.change, knownChange);
+    assert.deepEqual(state?.change, updateRevisionsWithCommitShas(knownChange));
   });
 
   test('reload a change', async () => {
@@ -138,23 +269,23 @@
     const promise = mockPromise<ParsedChangeInfo | undefined>();
     const stub = stubRestApi('getChangeDetail').callsFake(() => promise);
     let state: ChangeState;
-    changeModel.routerModel.setState({
-      view: GerritView.CHANGE,
-      changeNum: knownChange._number,
-    });
+    testResolver(changeViewModelToken).setState(createChangeViewState());
     promise.resolve(knownChange);
     state = await waitForLoadingStatus(LoadingStatus.LOADED);
+    assert.equal(stub.callCount, 1);
 
     // Reloading same change
     document.dispatchEvent(new CustomEvent('reload'));
     state = await waitForLoadingStatus(LoadingStatus.RELOADING);
-    assert.equal(stub.callCount, 2);
-    assert.equal(state?.change, knownChange);
+    assert.equal(stub.callCount, 3);
+    assert.equal(stub.getCall(1).firstArg, undefined);
+    assert.equal(stub.getCall(2).firstArg, TEST_NUMERIC_CHANGE_ID);
+    assert.deepEqual(state?.change, updateRevisionsWithCommitShas(knownChange));
 
     promise.resolve(knownChange);
     state = await waitForLoadingStatus(LoadingStatus.LOADED);
-    assert.equal(stub.callCount, 2);
-    assert.equal(state?.change, knownChange);
+    assert.equal(stub.callCount, 3);
+    assert.deepEqual(state?.change, updateRevisionsWithCommitShas(knownChange));
   });
 
   test('navigating to another change', async () => {
@@ -162,10 +293,7 @@
     let promise = mockPromise<ParsedChangeInfo | undefined>();
     const stub = stubRestApi('getChangeDetail').callsFake(() => promise);
     let state: ChangeState;
-    changeModel.routerModel.setState({
-      view: GerritView.CHANGE,
-      changeNum: knownChange._number,
-    });
+    testResolver(changeViewModelToken).setState(createChangeViewState());
     promise.resolve(knownChange);
     state = await waitForLoadingStatus(LoadingStatus.LOADED);
 
@@ -176,8 +304,8 @@
       _number: 123 as NumericChangeId,
     };
     promise = mockPromise<ParsedChangeInfo | undefined>();
-    changeModel.routerModel.setState({
-      view: GerritView.CHANGE,
+    testResolver(changeViewModelToken).setState({
+      ...createChangeViewState(),
       changeNum: otherChange._number,
     });
     state = await waitForLoadingStatus(LoadingStatus.LOADING);
@@ -187,7 +315,7 @@
     promise.resolve(otherChange);
     state = await waitForLoadingStatus(LoadingStatus.LOADED);
     assert.equal(stub.callCount, 2);
-    assert.equal(state?.change, otherChange);
+    assert.deepEqual(state?.change, updateRevisionsWithCommitShas(otherChange));
   });
 
   test('navigating to dashboard', async () => {
@@ -195,10 +323,7 @@
     let promise = mockPromise<ParsedChangeInfo | undefined>();
     const stub = stubRestApi('getChangeDetail').callsFake(() => promise);
     let state: ChangeState;
-    changeModel.routerModel.setState({
-      view: GerritView.CHANGE,
-      changeNum: knownChange._number,
-    });
+    testResolver(changeViewModelToken).setState(createChangeViewState());
     promise.resolve(knownChange);
     state = await waitForLoadingStatus(LoadingStatus.LOADED);
 
@@ -206,10 +331,7 @@
 
     promise = mockPromise<ParsedChangeInfo | undefined>();
     promise.resolve(undefined);
-    changeModel.routerModel.setState({
-      view: GerritView.CHANGE,
-      changeNum: undefined,
-    });
+    testResolver(changeViewModelToken).setState(undefined);
     state = await waitForLoadingStatus(LoadingStatus.NOT_LOADED);
     assert.equal(stub.callCount, 2);
     assert.isUndefined(state?.change);
@@ -218,13 +340,10 @@
 
     promise = mockPromise<ParsedChangeInfo | undefined>();
     promise.resolve(knownChange);
-    changeModel.routerModel.setState({
-      view: GerritView.CHANGE,
-      changeNum: knownChange._number,
-    });
+    testResolver(changeViewModelToken).setState(createChangeViewState());
     state = await waitForLoadingStatus(LoadingStatus.LOADED);
     assert.equal(stub.callCount, 3);
-    assert.equal(state?.change, knownChange);
+    assert.deepEqual(state?.change, updateRevisionsWithCommitShas(knownChange));
   });
 
   test('changeModel.fetchChangeUpdates on latest', async () => {
@@ -297,7 +416,9 @@
     assert.equal(spy.lastCall.firstArg, PARENT);
 
     // test update
-    changeModel.routerModel.updateState({basePatchNum: 1 as PatchSetNumber});
+    testResolver(changeViewModelToken).updateState({
+      basePatchNum: 1 as PatchSetNumber,
+    });
     assert.equal(spy.callCount, 2);
     assert.equal(spy.lastCall.firstArg, 1 as PatchSetNumber);
 
@@ -305,4 +426,28 @@
     changeModel.updateStateChange(createParsedChange());
     assert.equal(spy.callCount, 2);
   });
+
+  test('revision$ selector latest', async () => {
+    changeViewModel.updateState({patchNum: undefined});
+    changeModel.updateState({change: knownChange});
+    await waitUntilObserved(changeModel.revision$, x => x?._number === 2);
+  });
+
+  test('revision$ selector 1', async () => {
+    changeViewModel.updateState({patchNum: 1 as PatchSetNumber});
+    changeModel.updateState({change: knownChange});
+    await waitUntilObserved(changeModel.revision$, x => x?._number === 1);
+  });
+
+  test('latestRevision$ selector latest', async () => {
+    changeViewModel.updateState({patchNum: undefined});
+    changeModel.updateState({change: knownChange});
+    await waitUntilObserved(changeModel.latestRevision$, x => x?._number === 2);
+  });
+
+  test('latestRevision$ selector 1', async () => {
+    changeViewModel.updateState({patchNum: 1 as PatchSetNumber});
+    changeModel.updateState({change: knownChange});
+    await waitUntilObserved(changeModel.latestRevision$, x => x?._number === 2);
+  });
 });
diff --git a/polygerrit-ui/app/models/change/files-model.ts b/polygerrit-ui/app/models/change/files-model.ts
index 6922f6d..c01b718 100644
--- a/polygerrit-ui/app/models/change/files-model.ts
+++ b/polygerrit-ui/app/models/change/files-model.ts
@@ -15,7 +15,6 @@
 import {combineLatest, of, from} from 'rxjs';
 import {switchMap, map} from 'rxjs/operators';
 import {RestApiService} from '../../services/gr-rest-api/gr-rest-api';
-import {Finalizable} from '../../services/registry';
 import {select} from '../../utils/observable-util';
 import {FileInfoStatus, SpecialFilePath} from '../../constants/constants';
 import {specialFilePathCompare} from '../../utils/path-list-util';
@@ -23,7 +22,12 @@
 import {define} from '../dependency';
 import {ChangeModel} from './change-model';
 import {CommentsModel} from '../comments/comments-model';
+import {Timing} from '../../constants/reporting';
+import {ReportingService} from '../../services/gr-reporting/gr-reporting';
 
+export type FileNameToNormalizedFileInfoMap = {
+  [name: string]: NormalizedFileInfo;
+};
 export interface NormalizedFileInfo extends FileInfo {
   __path: string;
   // Compared to `FileInfo` these four props are required here.
@@ -113,10 +117,15 @@
 
 export const filesModelToken = define<FilesModel>('files-model');
 
-export class FilesModel extends Model<FilesState> implements Finalizable {
+export class FilesModel extends Model<FilesState> {
   public readonly files$ = select(this.state$, state => state.files);
 
-  public readonly filesWithUnmodified$ = select(
+  /**
+   * `files$` only includes the files that were modified. Here we also include
+   * all unmodified files that have comments with
+   * `status: FileInfoStatus.UNMODIFIED`.
+   */
+  public readonly filesIncludingUnmodified$ = select(
     combineLatest([this.files$, this.commentsModel.commentedPaths$]),
     ([files, commentedPaths]) => addUnmodified(files, commentedPaths)
   );
@@ -134,10 +143,13 @@
   constructor(
     readonly changeModel: ChangeModel,
     readonly commentsModel: CommentsModel,
-    readonly restApiService: RestApiService
+    readonly restApiService: RestApiService,
+    private readonly reporting: ReportingService
   ) {
     super(initialState);
     this.subscriptions = [
+      this.reportChangeDataStart(),
+      this.reportChangeDataEnd(),
       this.subscribeToFiles(
         (psLeft, psRight) => {
           return {basePatchNum: psLeft, patchNum: psRight};
@@ -148,7 +160,8 @@
       ),
       this.subscribeToFiles(
         (psLeft, _) => {
-          if (psLeft === PARENT || psLeft <= 0) return undefined;
+          if (psLeft === PARENT || (psLeft as PatchSetNumber) <= 0)
+            return undefined;
           return {basePatchNum: PARENT, patchNum: psLeft as PatchSetNumber};
         },
         files => {
@@ -157,7 +170,8 @@
       ),
       this.subscribeToFiles(
         (psLeft, psRight) => {
-          if (psLeft === PARENT || psLeft <= 0) return undefined;
+          if (psLeft === PARENT || (psLeft as PatchSetNumber) <= 0)
+            return undefined;
           return {basePatchNum: PARENT, patchNum: psRight as PatchSetNumber};
         },
         files => {
@@ -167,6 +181,26 @@
     ];
   }
 
+  private reportChangeDataStart() {
+    return combineLatest([this.changeModel.loading$]).subscribe(
+      ([changeLoading]) => {
+        if (changeLoading) {
+          this.reporting.time(Timing.CHANGE_DATA);
+        }
+      }
+    );
+  }
+
+  private reportChangeDataEnd() {
+    return combineLatest([this.changeModel.loading$, this.files$]).subscribe(
+      ([changeLoading, files]) => {
+        if (!changeLoading && files.length > 0) {
+          this.reporting.timeEnd(Timing.CHANGE_DATA);
+        }
+      }
+    );
+  }
+
   private subscribeToFiles(
     rangeChooser: (
       basePatchNum: BasePatchSetNum,
@@ -175,13 +209,12 @@
     filesToState: (files: NormalizedFileInfo[]) => Partial<FilesState>
   ) {
     return combineLatest([
-      this.changeModel.reload$,
       this.changeModel.changeNum$,
       this.changeModel.basePatchNum$,
       this.changeModel.patchNum$,
     ])
       .pipe(
-        switchMap(([_, changeNum, basePatchNum, patchNum]) => {
+        switchMap(([changeNum, basePatchNum, patchNum]) => {
           if (!changeNum || !patchNum) return of({});
           const range = rangeChooser(basePatchNum, patchNum);
           if (!range) return of({});
diff --git a/polygerrit-ui/app/models/change/related-changes-model.ts b/polygerrit-ui/app/models/change/related-changes-model.ts
new file mode 100644
index 0000000..6972ec8
--- /dev/null
+++ b/polygerrit-ui/app/models/change/related-changes-model.ts
@@ -0,0 +1,242 @@
+/**
+ * @license
+ * Copyright 2023 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {
+  ChangeInfo,
+  RelatedChangeAndCommitInfo,
+  SubmittedTogetherInfo,
+} from '../../types/common';
+import {RestApiService} from '../../services/gr-rest-api/gr-rest-api';
+import {select} from '../../utils/observable-util';
+import {Model} from '../model';
+import {define} from '../dependency';
+import {ChangeModel} from './change-model';
+import {combineLatest, forkJoin, from, of} from 'rxjs';
+import {map, switchMap} from 'rxjs/operators';
+import {ConfigModel} from '../config/config-model';
+import {ChangeStatus} from '../../api/rest-api';
+import {isDefined} from '../../types/types';
+
+export interface RelatedChangesState {
+  /** `undefined` means "not yet loaded". */
+  relatedChanges?: RelatedChangeAndCommitInfo[];
+  submittedTogether?: SubmittedTogetherInfo;
+  cherryPicks?: ChangeInfo[];
+  conflictingChanges?: ChangeInfo[];
+  sameTopicChanges?: ChangeInfo[];
+  revertingChanges: ChangeInfo[];
+}
+
+const initialState: RelatedChangesState = {
+  relatedChanges: undefined,
+  submittedTogether: undefined,
+  cherryPicks: undefined,
+  conflictingChanges: undefined,
+  sameTopicChanges: undefined,
+  revertingChanges: [],
+};
+
+export const relatedChangesModelToken = define<RelatedChangesModel>(
+  'related-changes-model'
+);
+
+export class RelatedChangesModel extends Model<RelatedChangesState> {
+  public readonly relatedChanges$ = select(
+    this.state$,
+    state => state.relatedChanges
+  );
+
+  public readonly submittedTogether$ = select(
+    this.state$,
+    state => state.submittedTogether
+  );
+
+  public readonly cherryPicks$ = select(
+    this.state$,
+    state => state.cherryPicks
+  );
+
+  public readonly conflictingChanges$ = select(
+    this.state$,
+    state => state.conflictingChanges
+  );
+
+  public readonly sameTopicChanges$ = select(
+    this.state$,
+    state => state.sameTopicChanges
+  );
+
+  /**
+   * Emits all changes that have reverted the current change, based on
+   * information from parsed change messages. Abandoned changes are not
+   * included.
+   */
+  public readonly revertingChanges$ = select(
+    this.state$,
+    state => state.revertingChanges
+  );
+
+  /**
+   * Emits one reverting change (if there is any) from revertingChanges$.
+   * It prefers MERGED changes. Otherwise the choice is random.
+   */
+  public readonly revertingChange$ = select(
+    this.revertingChanges$,
+    revertingChanges => {
+      if (revertingChanges.length === 0) return undefined;
+      const submittedRevert = revertingChanges.find(
+        c => c.status === ChangeStatus.MERGED
+      );
+      if (submittedRevert) return submittedRevert;
+      return revertingChanges[0];
+    }
+  );
+
+  /**
+   * Determines whether the change has a parent change. If there
+   * is a relation chain, and the change id is not the last item of the
+   * relation chain, then there is a parent.
+   */
+  public readonly hasParent$ = select(
+    combineLatest([this.changeModel.change$, this.relatedChanges$]),
+    ([change, relatedChanges]) => {
+      if (!change) return undefined;
+      if (relatedChanges === undefined) return undefined;
+      if (relatedChanges.length === 0) return false;
+      const lastChangeId = relatedChanges[relatedChanges.length - 1].change_id;
+      return lastChangeId !== change.change_id;
+    }
+  );
+
+  constructor(
+    readonly changeModel: ChangeModel,
+    readonly configModel: ConfigModel,
+    readonly restApiService: RestApiService
+  ) {
+    super(initialState);
+    this.subscriptions = [
+      this.loadRelatedChanges(),
+      this.loadSubmittedTogether(),
+      this.loadCherryPicks(),
+      this.loadConflictingChanges(),
+      this.loadSameTopicChanges(),
+      this.loadRevertingChanges(),
+    ];
+  }
+
+  private loadRelatedChanges() {
+    return combineLatest([
+      this.changeModel.changeNum$,
+      this.changeModel.latestPatchNum$,
+    ])
+      .pipe(
+        switchMap(([changeNum, latestPatchNum]) => {
+          if (!changeNum || !latestPatchNum) return of(undefined);
+          return from(
+            this.restApiService
+              .getRelatedChanges(changeNum, latestPatchNum)
+              .then(info => info?.changes ?? [])
+          );
+        })
+      )
+      .subscribe(relatedChanges => {
+        this.updateState({relatedChanges});
+      });
+  }
+
+  private loadSubmittedTogether() {
+    return this.changeModel.changeNum$
+      .pipe(
+        switchMap(changeNum => {
+          if (!changeNum) return of(undefined);
+          return from(
+            this.restApiService.getChangesSubmittedTogether(changeNum)
+          );
+        })
+      )
+      .subscribe(submittedTogether => {
+        this.updateState({submittedTogether});
+      });
+  }
+
+  private loadCherryPicks() {
+    return combineLatest([
+      this.changeModel.branch$,
+      this.changeModel.changeId$,
+      this.changeModel.repo$,
+    ])
+      .pipe(
+        switchMap(([branch, changeId, repo]) => {
+          if (!branch || !changeId || !repo) return of(undefined);
+          return from(
+            this.restApiService.getChangeCherryPicks(repo, changeId, branch)
+          );
+        })
+      )
+      .subscribe(cherryPicks => {
+        this.updateState({cherryPicks});
+      });
+  }
+
+  private loadConflictingChanges() {
+    return combineLatest([
+      this.changeModel.changeNum$,
+      this.changeModel.status$,
+      this.changeModel.mergeable$,
+    ])
+      .pipe(
+        switchMap(([changeNum, status, mergeable]) => {
+          if (!changeNum || !status || !mergeable) return of(undefined);
+          if (status !== ChangeStatus.NEW) return of(undefined);
+          return from(this.restApiService.getChangeConflicts(changeNum));
+        })
+      )
+      .subscribe(conflictingChanges => {
+        this.updateState({conflictingChanges});
+      });
+  }
+
+  private loadSameTopicChanges() {
+    return combineLatest([
+      this.changeModel.changeNum$,
+      this.changeModel.topic$,
+      this.configModel.serverConfig$,
+    ])
+      .pipe(
+        switchMap(([changeNum, topic, config]) => {
+          if (!changeNum || !topic || !config) return of(undefined);
+          if (config.change.submit_whole_topic) return of(undefined);
+          return from(
+            this.restApiService.getChangesWithSameTopic(topic, {
+              openChangesOnly: true,
+              changeToExclude: changeNum,
+            })
+          );
+        })
+      )
+      .subscribe(sameTopicChanges => {
+        this.updateState({sameTopicChanges});
+      });
+  }
+
+  private loadRevertingChanges() {
+    return this.changeModel.revertingChangeIds$
+      .pipe(
+        switchMap(changeIds => {
+          if (!changeIds?.length) return of([]);
+          return forkJoin(
+            changeIds.map(changeId =>
+              from(this.restApiService.getChange(changeId))
+            )
+          );
+        }),
+        map(changes => changes.filter(isDefined)),
+        map(changes => changes.filter(c => c.status !== ChangeStatus.ABANDONED))
+      )
+      .subscribe(revertingChanges => {
+        this.updateState({revertingChanges});
+      });
+  }
+}
diff --git a/polygerrit-ui/app/models/change/related-changes-model_test.ts b/polygerrit-ui/app/models/change/related-changes-model_test.ts
new file mode 100644
index 0000000..295f284
--- /dev/null
+++ b/polygerrit-ui/app/models/change/related-changes-model_test.ts
@@ -0,0 +1,286 @@
+/**
+ * @license
+ * Copyright 2023 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../test/common-test-setup';
+import {getAppContext} from '../../services/app-context';
+import {ChangeModel, changeModelToken} from '../change/change-model';
+import {assert} from '@open-wc/testing';
+import {testResolver} from '../../test/common-test-setup';
+import {RelatedChangesModel} from './related-changes-model';
+import {configModelToken} from '../config/config-model';
+import {SinonStub} from 'sinon';
+import {
+  ChangeInfo,
+  RelatedChangesInfo,
+  SubmittedTogetherInfo,
+} from '../../types/common';
+import {stubRestApi, waitUntilObserved} from '../../test/test-utils';
+import {
+  createParsedChange,
+  createRelatedChangesInfo,
+  createRelatedChangeAndCommitInfo,
+  createChange,
+  createChangeMessage,
+} from '../../test/test-data-generators';
+import {ChangeStatus, ReviewInputTag, TopicName} from '../../api/rest-api';
+import {MessageTag} from '../../constants/constants';
+
+suite('related-changes-model tests', () => {
+  let model: RelatedChangesModel;
+  let changeModel: ChangeModel;
+
+  setup(async () => {
+    changeModel = testResolver(changeModelToken);
+    model = new RelatedChangesModel(
+      changeModel,
+      testResolver(configModelToken),
+      getAppContext().restApiService
+    );
+    await waitUntilObserved(changeModel.change$, c => c === undefined);
+  });
+
+  teardown(() => {
+    model.finalize();
+  });
+
+  test('register and fetch', async () => {
+    assert.equal('', '');
+  });
+
+  suite('related changes and hasParent', async () => {
+    let getRelatedChangesStub: SinonStub;
+    let getRelatedChangesResponse: RelatedChangesInfo;
+    let hasParent: boolean | undefined;
+
+    setup(() => {
+      getRelatedChangesStub = stubRestApi('getRelatedChanges').callsFake(() =>
+        Promise.resolve(getRelatedChangesResponse)
+      );
+      model.hasParent$.subscribe(x => (hasParent = x));
+    });
+
+    test('relatedChanges initially undefined', async () => {
+      await waitUntilObserved(
+        model.relatedChanges$,
+        relatedChanges => relatedChanges === undefined
+      );
+      assert.isFalse(getRelatedChangesStub.called);
+      assert.isUndefined(hasParent);
+    });
+
+    test('relatedChanges loading empty', async () => {
+      changeModel.updateStateChange({...createParsedChange()});
+
+      await waitUntilObserved(
+        model.relatedChanges$,
+        relatedChanges => relatedChanges?.length === 0
+      );
+      assert.isTrue(getRelatedChangesStub.calledOnce);
+      assert.isFalse(hasParent);
+    });
+
+    test('relatedChanges loading one change', async () => {
+      getRelatedChangesResponse = {
+        ...createRelatedChangesInfo(),
+        changes: [createRelatedChangeAndCommitInfo()],
+      };
+      changeModel.updateStateChange({...createParsedChange()});
+
+      await waitUntilObserved(
+        model.relatedChanges$,
+        relatedChanges => relatedChanges?.length === 1
+      );
+      assert.isTrue(getRelatedChangesStub.calledOnce);
+      assert.isTrue(hasParent);
+    });
+  });
+
+  suite('loadSubmittedTogether', async () => {
+    let getChangesSubmittedTogetherStub: SinonStub;
+    let getChangesSubmittedTogetherResponse: SubmittedTogetherInfo;
+
+    setup(() => {
+      getChangesSubmittedTogetherStub = stubRestApi(
+        'getChangesSubmittedTogether'
+      ).callsFake(() => Promise.resolve(getChangesSubmittedTogetherResponse));
+    });
+
+    test('submittedTogether initially undefined', async () => {
+      await waitUntilObserved(
+        model.submittedTogether$,
+        submittedTogether => submittedTogether === undefined
+      );
+      assert.isFalse(getChangesSubmittedTogetherStub.called);
+    });
+
+    test('submittedTogether emits', async () => {
+      getChangesSubmittedTogetherResponse = {
+        changes: [createChange()],
+        non_visible_changes: 0,
+      };
+      changeModel.updateStateChange({...createParsedChange()});
+
+      await waitUntilObserved(
+        model.submittedTogether$,
+        submittedTogether => submittedTogether?.changes?.length === 1
+      );
+      assert.isTrue(getChangesSubmittedTogetherStub.calledOnce);
+    });
+  });
+
+  suite('loadCherryPicks', async () => {
+    let getChangeCherryPicksStub: SinonStub;
+    let getChangeCherryPicksResponse: ChangeInfo[];
+
+    setup(() => {
+      getChangeCherryPicksStub = stubRestApi('getChangeCherryPicks').callsFake(
+        () => Promise.resolve(getChangeCherryPicksResponse)
+      );
+    });
+
+    test('cherryPicks initially undefined', async () => {
+      await waitUntilObserved(
+        model.cherryPicks$,
+        cherryPicks => cherryPicks === undefined
+      );
+      assert.isFalse(getChangeCherryPicksStub.called);
+    });
+
+    test('cherryPicks emits', async () => {
+      getChangeCherryPicksResponse = [createChange()];
+      changeModel.updateStateChange({...createParsedChange()});
+
+      await waitUntilObserved(
+        model.cherryPicks$,
+        cherryPicks => cherryPicks?.length === 1
+      );
+      assert.isTrue(getChangeCherryPicksStub.calledOnce);
+    });
+  });
+
+  suite('loadConflictingChanges', async () => {
+    let getChangeConflictsStub: SinonStub;
+    let getChangeConflictsResponse: ChangeInfo[];
+
+    setup(() => {
+      getChangeConflictsStub = stubRestApi('getChangeConflicts').callsFake(() =>
+        Promise.resolve(getChangeConflictsResponse)
+      );
+    });
+
+    test('conflictingChanges initially undefined', async () => {
+      await waitUntilObserved(
+        model.conflictingChanges$,
+        conflictingChanges => conflictingChanges === undefined
+      );
+      assert.isFalse(getChangeConflictsStub.called);
+    });
+
+    test('conflictingChanges not loaded for merged changes', async () => {
+      getChangeConflictsResponse = [createChange()];
+      changeModel.updateStateChange({
+        ...createParsedChange(),
+        mergeable: true,
+        status: ChangeStatus.MERGED,
+      });
+
+      await waitUntilObserved(
+        model.conflictingChanges$,
+        conflictingChanges => conflictingChanges === undefined
+      );
+      assert.isFalse(getChangeConflictsStub.called);
+    });
+
+    test('conflictingChanges emits', async () => {
+      getChangeConflictsResponse = [createChange()];
+      changeModel.updateStateChange({...createParsedChange(), mergeable: true});
+
+      await waitUntilObserved(
+        model.conflictingChanges$,
+        conflictingChanges => conflictingChanges?.length === 1
+      );
+      assert.isTrue(getChangeConflictsStub.calledOnce);
+    });
+  });
+
+  suite('loadSameTopicChanges', async () => {
+    let getChangesWithSameTopicStub: SinonStub;
+    let getChangesWithSameTopicResponse: ChangeInfo[];
+
+    setup(() => {
+      getChangesWithSameTopicStub = stubRestApi(
+        'getChangesWithSameTopic'
+      ).callsFake(() => Promise.resolve(getChangesWithSameTopicResponse));
+    });
+
+    test('sameTopicChanges initially undefined', async () => {
+      await waitUntilObserved(
+        model.sameTopicChanges$,
+        sameTopicChanges => sameTopicChanges === undefined
+      );
+      assert.isFalse(getChangesWithSameTopicStub.called);
+    });
+
+    test('sameTopicChanges emits', async () => {
+      getChangesWithSameTopicResponse = [createChange()];
+      changeModel.updateStateChange({
+        ...createParsedChange(),
+        topic: 'test-topic' as TopicName,
+      });
+
+      await waitUntilObserved(
+        model.sameTopicChanges$,
+        sameTopicChanges => sameTopicChanges?.length === 1
+      );
+      assert.isTrue(getChangesWithSameTopicStub.calledOnce);
+    });
+  });
+
+  suite('loadRevertingChanges', async () => {
+    let getChangeStub: SinonStub;
+
+    setup(() => {
+      getChangeStub = stubRestApi('getChange').callsFake(() =>
+        Promise.resolve(createChange())
+      );
+    });
+
+    test('revertingChanges initially empty', async () => {
+      await waitUntilObserved(
+        model.revertingChanges$,
+        revertingChanges => revertingChanges.length === 0
+      );
+      assert.isFalse(getChangeStub.called);
+    });
+
+    test('revertingChanges empty when change does not contain a revert message', async () => {
+      changeModel.updateStateChange(createParsedChange());
+      await waitUntilObserved(
+        model.revertingChanges$,
+        revertingChanges => revertingChanges.length === 0
+      );
+      assert.isFalse(getChangeStub.called);
+    });
+
+    test('revertingChanges emits', async () => {
+      changeModel.updateStateChange({
+        ...createParsedChange(),
+        messages: [
+          {
+            ...createChangeMessage(),
+            message: 'Created a revert of this change as 123',
+            tag: MessageTag.TAG_REVERT as ReviewInputTag,
+          },
+        ],
+      });
+
+      await waitUntilObserved(
+        model.revertingChanges$,
+        revertingChanges => revertingChanges?.length === 1
+      );
+      assert.isTrue(getChangeStub.calledOnce);
+    });
+  });
+});
diff --git a/polygerrit-ui/app/models/checks/checks-model.ts b/polygerrit-ui/app/models/checks/checks-model.ts
index 6b35056..25785e4 100644
--- a/polygerrit-ui/app/models/checks/checks-model.ts
+++ b/polygerrit-ui/app/models/checks/checks-model.ts
@@ -12,7 +12,6 @@
 } from './checks-util';
 import {assertIsDefined} from '../../utils/common-util';
 import {select} from '../../utils/observable-util';
-import {Finalizable} from '../../services/registry';
 import {
   BehaviorSubject,
   combineLatest,
@@ -52,8 +51,7 @@
 import {getShaByPatchNum} from '../../utils/patch-set-util';
 import {ReportingService} from '../../services/gr-reporting/gr-reporting';
 import {Execution, Interaction, Timing} from '../../constants/reporting';
-import {fireAlert, fireEvent} from '../../utils/event-util';
-import {RouterModel} from '../../services/router/router-model';
+import {fireAlert, fire} from '../../utils/event-util';
 import {Model} from '../model';
 import {define} from '../dependency';
 import {
@@ -110,9 +108,30 @@
 }
 
 // This is a convenience type for working with results, because when working
-// with a bunch of results you will typically also want to know about the run
-// properties. So you can just combine them with {...run, ...result}.
-export type RunResult = CheckRun & CheckResult;
+// with a bunch of results you will typically also want to know about some run
+// properties.
+// Note that you don't want to just spread the entire run object, because you
+// definitely don't want the `results` property in the RunResult object.
+// Use the `runResult()` function below for creating `RunResult` objects.
+export type RunResult = CheckResult &
+  Pick<CheckRun, 'pluginName'> &
+  Pick<CheckRun, 'attempt'> &
+  Pick<CheckRun, 'patchset'> &
+  Pick<CheckRun, 'isLatestAttempt'> &
+  Pick<CheckRun, 'checkName'> &
+  Pick<CheckRun, 'labelName'> & {results?: never};
+
+export function runResult(run: CheckRun, result: CheckResult): RunResult {
+  return {
+    pluginName: run.pluginName,
+    attempt: run.attempt,
+    patchset: run.patchset,
+    isLatestAttempt: run.isLatestAttempt,
+    checkName: run.checkName,
+    labelName: run.labelName,
+    ...result,
+  };
+}
 
 export const checksModelToken = define<ChecksModel>('checks-model');
 
@@ -161,17 +180,15 @@
  * Can be used in `reduce()` to collect all results from all runs from all
  * providers into one array.
  */
-function collectRunResults(
+export function collectRunResults(
   allResults: RunResult[],
   providerState: ChecksProviderState
-) {
+): RunResult[] {
   return [
     ...allResults,
     ...providerState.runs.reduce((results: RunResult[], run: CheckRun) => {
       const runResults: RunResult[] =
-        run.results?.map(r => {
-          return {...run, ...r};
-        }) ?? [];
+        run.results?.map(r => runResult(run, r)) ?? [];
       return results.concat(runResults ?? []);
     }, []),
   ];
@@ -182,7 +199,7 @@
   [name: string]: string;
 }
 
-export class ChecksModel extends Model<ChecksState> implements Finalizable {
+export class ChecksModel extends Model<ChecksState> {
   private readonly providers: {[name: string]: ChecksProvider} = {};
 
   private readonly reloadSubjects: {[name: string]: Subject<void>} = {};
@@ -197,8 +214,6 @@
 
   private readonly documentVisibilityChange$ = new BehaviorSubject(undefined);
 
-  private readonly reloadListener: () => void;
-
   private readonly visibilityChangeListener: () => void;
 
   public checksSelectedPatchsetNumber$ = select(
@@ -374,11 +389,10 @@
   );
 
   constructor(
-    readonly routerModel: RouterModel,
-    readonly changeViewModel: ChangeViewModel,
-    readonly changeModel: ChangeModel,
-    readonly reporting: ReportingService,
-    readonly pluginsModel: PluginsModel
+    private readonly changeViewModel: ChangeViewModel,
+    private readonly changeModel: ChangeModel,
+    private readonly reporting: ReportingService,
+    private readonly pluginsModel: PluginsModel
   ) {
     super({
       pluginStateLatest: {},
@@ -417,8 +431,6 @@
       'visibilitychange',
       this.visibilityChangeListener
     );
-    this.reloadListener = () => this.reloadAll();
-    document.addEventListener('reload', this.reloadListener);
   }
 
   private reportStats(state: {[name: string]: ChecksProviderState}) {
@@ -462,7 +474,6 @@
   }
 
   override finalize() {
-    document.removeEventListener('reload', this.reloadListener);
     document.removeEventListener(
       'visibilitychange',
       this.visibilityChangeListener
@@ -637,8 +648,15 @@
   }
 
   updateStateSetPatchset(num?: PatchSetNumber) {
+    const newPatchset = num === this.latestPatchNum ? undefined : num;
+    const oldPatchset = this.changeViewModel.getState()?.checksPatchset;
+    // For `checksPatchset` itself we could just let updateState() do the
+    // standard old===new comparison. But we have to make sure here that
+    // the attempt reset only actually happens when a new patchset is chosen.
+    if (newPatchset === oldPatchset) return;
     this.changeViewModel.updateState({
-      checksPatchset: num === this.latestPatchNum ? undefined : num,
+      checksPatchset: newPatchset,
+      attempt: LATEST_ATTEMPT,
     });
   }
 
@@ -682,7 +700,11 @@
     );
   }
 
-  triggerAction(action: Action, run: CheckRun | undefined, context: string) {
+  triggerAction(
+    action: Action,
+    run: CheckRun | RunResult | undefined,
+    context: string
+  ) {
     if (!action?.callback) return;
     if (!this.changeNum) return;
     const patchSet = run?.patchset ?? this.latestPatchNum;
@@ -712,7 +734,7 @@
         if (result.errorMessage || result.message) {
           fireAlert(document, `${result.message ?? result.errorMessage}`);
         } else {
-          fireEvent(document, 'hide-alert');
+          fire(document, 'hide-alert', {});
         }
         if (result.shouldReload) {
           this.reloadForCheck(run?.checkName);
@@ -753,15 +775,14 @@
         patchset === ChecksPatchset.LATEST
           ? this.changeModel.latestPatchNum$
           : this.checksSelectedPatchsetNumber$,
-        this.reloadSubjects[pluginName].pipe(
-          throttleTime(1000, undefined, {trailing: true, leading: true})
-        ),
+        this.reloadSubjects[pluginName],
         pollIntervalMs === 0 ? from([0]) : timer(0, pollIntervalMs),
         this.documentVisibilityChange$,
       ])
         .pipe(
           takeWhile(_ => !!this.providers[pluginName]),
           filter(_ => document.visibilityState !== 'hidden'),
+          throttleTime(500, undefined, {leading: true, trailing: true}),
           switchMap(([change, patchNum]): Observable<FetchResponse> => {
             if (!change || !patchNum) return of(this.empty());
             if (typeof patchNum !== 'number') return of(this.empty());
diff --git a/polygerrit-ui/app/models/checks/checks-model_test.ts b/polygerrit-ui/app/models/checks/checks-model_test.ts
index 3489c5a..c8fd37a 100644
--- a/polygerrit-ui/app/models/checks/checks-model_test.ts
+++ b/polygerrit-ui/app/models/checks/checks-model_test.ts
@@ -5,7 +5,14 @@
  */
 import '../../test/common-test-setup';
 import './checks-model';
-import {ChecksModel, ChecksPatchset, ChecksProviderState} from './checks-model';
+import {
+  CheckResult,
+  ChecksModel,
+  ChecksPatchset,
+  ChecksProviderState,
+  RunResult,
+  collectRunResults,
+} from './checks-model';
 import {
   Action,
   Category,
@@ -16,7 +23,11 @@
   RunStatus,
 } from '../../api/checks';
 import {getAppContext} from '../../services/app-context';
-import {createParsedChange} from '../../test/test-data-generators';
+import {
+  createCheckResult,
+  createParsedChange,
+  createRun,
+} from '../../test/test-data-generators';
 import {waitUntil, waitUntilCalled} from '../../test/test-utils';
 import {ParsedChangeInfo} from '../../types/types';
 import {changeModelToken} from '../change/change-model';
@@ -24,6 +35,7 @@
 import {testResolver} from '../../test/common-test-setup';
 import {changeViewModelToken} from '../views/change';
 import {NumericChangeId, PatchSetNumber} from '../../api/rest-api';
+import {pluginLoaderToken} from '../../elements/shared/gr-js-api-interface/gr-plugin-loader';
 
 const PLUGIN_NAME = 'test-plugin';
 
@@ -69,11 +81,10 @@
 
   setup(() => {
     model = new ChecksModel(
-      getAppContext().routerModel,
       testResolver(changeViewModelToken),
       testResolver(changeModelToken),
       getAppContext().reportingService,
-      getAppContext().pluginsModel
+      testResolver(pluginLoaderToken).pluginsModel
     );
     model.checksLatest$.subscribe(c => (current = c[PLUGIN_NAME]));
   });
@@ -84,7 +95,7 @@
 
   test('register and fetch', async () => {
     let change: ParsedChangeInfo | undefined = undefined;
-    model.changeModel.change$.subscribe(c => (change = c));
+    testResolver(changeModelToken).change$.subscribe(c => (change = c));
     const provider = createProvider();
     const fetchSpy = sinon.spy(provider, 'fetch');
 
@@ -96,7 +107,7 @@
     await waitUntil(() => change === undefined);
 
     const testChange = createParsedChange();
-    model.changeModel.updateStateChange(testChange);
+    testResolver(changeModelToken).updateStateChange(testChange);
     await waitUntil(() => change === testChange);
     await waitUntilCalled(fetchSpy, 'fetch');
 
@@ -108,10 +119,10 @@
     assert.equal(model.changeNum, testChange._number);
   });
 
-  test('reload throttle', async () => {
+  test('fetch throttle', async () => {
     const clock = sinon.useFakeTimers();
     let change: ParsedChangeInfo | undefined = undefined;
-    model.changeModel.change$.subscribe(c => (change = c));
+    testResolver(changeModelToken).change$.subscribe(c => (change = c));
     const provider = createProvider();
     const fetchSpy = sinon.spy(provider, 'fetch');
 
@@ -123,18 +134,33 @@
     await waitUntil(() => change === undefined);
 
     const testChange = createParsedChange();
-    model.changeModel.updateStateChange(testChange);
+    testResolver(changeModelToken).updateStateChange(testChange);
     await waitUntil(() => change === testChange);
-    clock.tick(1);
+
+    model.reload('test-plugin');
+    model.reload('test-plugin');
+    model.reload('test-plugin');
+
+    // Does not emit at 'leading' of throttle interval,
+    // because fetch() is not called when change is undefined.
+    assert.equal(fetchSpy.callCount, 0);
+
+    // 600 ms is greater than the 500 ms throttle time.
+    clock.tick(600);
+    // emits at 'trailing' of throttle interval
     assert.equal(fetchSpy.callCount, 1);
 
-    // The second reload call will be processed, but only after a 1s throttle.
     model.reload('test-plugin');
-    clock.tick(100);
-    assert.equal(fetchSpy.callCount, 1);
-    // 2000 ms is greater than the 1000 ms throttle time.
-    clock.tick(2000);
+    model.reload('test-plugin');
+    model.reload('test-plugin');
+    model.reload('test-plugin');
+    // emits at 'leading' of throttle interval
     assert.equal(fetchSpy.callCount, 2);
+
+    // 600 ms is greater than the 500 ms throttle time.
+    clock.tick(600);
+    // emits at 'trailing' of throttle interval
+    assert.equal(fetchSpy.callCount, 3);
   });
 
   test('triggerAction', async () => {
@@ -268,7 +294,7 @@
   test('polls for changes', async () => {
     const clock = sinon.useFakeTimers();
     let change: ParsedChangeInfo | undefined = undefined;
-    model.changeModel.change$.subscribe(c => (change = c));
+    testResolver(changeModelToken).change$.subscribe(c => (change = c));
     const provider = createProvider();
     const fetchSpy = sinon.spy(provider, 'fetch');
 
@@ -280,10 +306,10 @@
     await waitUntil(() => change === undefined);
     clock.tick(1);
     const testChange = createParsedChange();
-    model.changeModel.updateStateChange(testChange);
+    testResolver(changeModelToken).updateStateChange(testChange);
     await waitUntil(() => change === testChange);
+    clock.tick(600); // need to wait for 500ms throttle
     await waitUntilCalled(fetchSpy, 'fetch');
-    clock.tick(1);
     const pollCount = fetchSpy.callCount;
 
     // polling should continue while we wait
@@ -295,7 +321,7 @@
   test('does not poll when config specifies 0 seconds', async () => {
     const clock = sinon.useFakeTimers();
     let change: ParsedChangeInfo | undefined = undefined;
-    model.changeModel.change$.subscribe(c => (change = c));
+    testResolver(changeModelToken).change$.subscribe(c => (change = c));
     const provider = createProvider();
     const fetchSpy = sinon.spy(provider, 'fetch');
 
@@ -307,8 +333,9 @@
     await waitUntil(() => change === undefined);
     clock.tick(1);
     const testChange = createParsedChange();
-    model.changeModel.updateStateChange(testChange);
+    testResolver(changeModelToken).updateStateChange(testChange);
     await waitUntil(() => change === testChange);
+    clock.tick(600); // need to wait for 500ms throttle
     await waitUntilCalled(fetchSpy, 'fetch');
     clock.tick(1);
     const pollCount = fetchSpy.callCount;
@@ -318,4 +345,24 @@
 
     assert.equal(fetchSpy.callCount, pollCount);
   });
+
+  test('collectRunResults does not incur quadratic size increase', async () => {
+    const results: CheckResult[] = [];
+    for (let i = 0; i < 100; i++) {
+      results.push({
+        ...createCheckResult({
+          message: 'some message',
+        }),
+      });
+    }
+    const run = createRun({results});
+    let collected: RunResult[] = [];
+    collected = collectRunResults(collected, {
+      runs: [run],
+    } as ChecksProviderState);
+    const collectedString = JSON.stringify(collected);
+    // If the `results` property would not be removed from every check run, then
+    // this combined string would be >1MB in size.
+    assert.isAtMost(collectedString.length, 50000);
+  });
 });
diff --git a/polygerrit-ui/app/models/checks/checks-util.ts b/polygerrit-ui/app/models/checks/checks-util.ts
index 7ccdf91..026e5e5 100644
--- a/polygerrit-ui/app/models/checks/checks-util.ts
+++ b/polygerrit-ui/app/models/checks/checks-util.ts
@@ -14,12 +14,17 @@
   Replacement,
   RunStatus,
 } from '../../api/checks';
-import {PatchSetNumber} from '../../api/rest-api';
-import {FixSuggestionInfo, FixReplacementInfo} from '../../types/common';
+import {PatchSetNumber, RevisionPatchSetNum} from '../../api/rest-api';
+import {CommentSide} from '../../constants/constants';
+import {
+  FixSuggestionInfo,
+  FixReplacementInfo,
+  DraftInfo,
+} from '../../types/common';
 import {OpenFixPreviewEventDetail} from '../../types/events';
-import {notUndefined} from '../../types/types';
-import {PROVIDED_FIX_ID} from '../../utils/comment-util';
-import {assert, assertNever} from '../../utils/common-util';
+import {isDefined} from '../../types/types';
+import {PROVIDED_FIX_ID, createNew} from '../../utils/comment-util';
+import {assert, assertIsDefined, assertNever} from '../../utils/common-util';
 import {fire} from '../../utils/event-util';
 import {CheckResult, CheckRun, RunResult} from './checks-model';
 
@@ -86,6 +91,25 @@
   }
 }
 
+function pleaseFixMessage(result: RunResult) {
+  return `Please fix this ${result.category} reported by ${result.checkName}: ${result.summary}
+
+${result.message}`;
+}
+
+export function createPleaseFixComment(result: RunResult): DraftInfo {
+  const pointer = result.codePointers?.[0];
+  assertIsDefined(pointer, 'codePointer');
+  return {
+    ...createNew(pleaseFixMessage(result), true),
+    path: pointer.path,
+    patch_set: result.patchset as RevisionPatchSetNum,
+    side: CommentSide.REVISION,
+    line: pointer.range.end_line ?? pointer.range.start_line,
+    range: pointer.range,
+  };
+}
+
 export function createFixAction(
   target: EventTarget,
   result?: RunResult
@@ -94,11 +118,12 @@
   if (!result?.fixes) return;
   const fixSuggestions = result.fixes
     .map(f => rectifyFix(f, result?.checkName))
-    .filter(notUndefined);
+    .filter(isDefined);
   if (fixSuggestions.length === 0) return;
   const eventDetail: OpenFixPreviewEventDetail = {
     patchNum: result.patchset as PatchSetNumber,
     fixSuggestions,
+    onCloseFixPreviewCallbacks: [],
   };
   return {
     name: 'Show Fix',
@@ -116,7 +141,7 @@
   if (!fix?.replacements) return undefined;
   const replacements = fix.replacements
     .map(rectifyReplacement)
-    .filter(notUndefined);
+    .filter(isDefined);
   if (replacements.length === 0) return undefined;
 
   return {
@@ -445,7 +470,11 @@
       );
     }
     value.isSingleAttempt = false;
-    if (run.attempt > value.latestAttempt) {
+    if (
+      value.latestAttempt !== 'all' &&
+      value.latestAttempt !== 'latest' &&
+      run.attempt > value.latestAttempt
+    ) {
       value.latestAttempt = run.attempt;
     }
     value.attempts.push(detail);
diff --git a/polygerrit-ui/app/models/checks/checks-util_test.ts b/polygerrit-ui/app/models/checks/checks-util_test.ts
index c237c59..822435d 100644
--- a/polygerrit-ui/app/models/checks/checks-util_test.ts
+++ b/polygerrit-ui/app/models/checks/checks-util_test.ts
@@ -15,8 +15,8 @@
   stringToAttemptChoice,
 } from './checks-util';
 import {Fix, Replacement} from '../../api/checks';
-import {CommentRange} from '../../api/core';
 import {PROVIDED_FIX_ID} from '../../utils/comment-util';
+import {CommentRange} from '../../api/rest-api';
 
 suite('checks-util tests', () => {
   setup(() => {});
diff --git a/polygerrit-ui/app/models/comments/comments-model.ts b/polygerrit-ui/app/models/comments/comments-model.ts
index b0ad417..eca8b7c 100644
--- a/polygerrit-ui/app/models/comments/comments-model.ts
+++ b/polygerrit-ui/app/models/comments/comments-model.ts
@@ -5,45 +5,46 @@
  */
 import {ChangeComments} from '../../elements/diff/gr-comment-api/gr-comment-api';
 import {
-  CommentBasics,
   CommentInfo,
   NumericChangeId,
   PatchSetNum,
   RevisionId,
   UrlEncodedCommentId,
-  PathToCommentsInfoMap,
   RobotCommentInfo,
   PathToRobotCommentsInfoMap,
   AccountInfo,
+  DraftInfo,
+  Comment,
+  SavingState,
+  isSaving,
+  isError,
+  isDraft,
+  isNew,
 } from '../../types/common';
 import {
   addPath,
-  DraftInfo,
-  isDraft,
+  createNew,
+  createNewPatchsetLevel,
+  id,
   isDraftThread,
-  isUnsaved,
+  isNewThread,
   reportingDetails,
-  UnsavedInfo,
 } from '../../utils/comment-util';
 import {deepEqual} from '../../utils/deep-util';
 import {select} from '../../utils/observable-util';
-import {RouterModel} from '../../services/router/router-model';
-import {Finalizable} from '../../services/registry';
 import {define} from '../dependency';
 import {combineLatest, forkJoin, from, Observable, of} from 'rxjs';
-import {fire, fireAlert, fireEvent} from '../../utils/event-util';
+import {fire, fireAlert} from '../../utils/event-util';
 import {CURRENT} from '../../utils/patch-set-util';
 import {RestApiService} from '../../services/gr-rest-api/gr-rest-api';
 import {ChangeModel} from '../change/change-model';
 import {Interaction, Timing} from '../../constants/reporting';
-import {assertIsDefined} from '../../utils/common-util';
+import {assert, assertIsDefined} from '../../utils/common-util';
 import {debounce, DelayedTask} from '../../utils/async-util';
-import {pluralize} from '../../utils/string-util';
 import {ReportingService} from '../../services/gr-reporting/gr-reporting';
 import {Model} from '../model';
 import {Deduping} from '../../api/reporting';
 import {extractMentionedUsers, getUserId} from '../../utils/account-util';
-import {EventType} from '../../types/events';
 import {SpecialFilePath} from '../../constants/constants';
 import {AccountsModel} from '../accounts-model/accounts-model';
 import {
@@ -52,24 +53,24 @@
   shareReplay,
   switchMap,
 } from 'rxjs/operators';
-import {notUndefined} from '../../types/types';
+import {isDefined} from '../../types/types';
+import {ChangeViewModel} from '../views/change';
+import {NavigationService} from '../../elements/core/gr-navigation/gr-navigation';
 
 export interface CommentState {
   /** undefined means 'still loading' */
-  comments?: PathToCommentsInfoMap;
+  comments?: {[path: string]: CommentInfo[]};
   /** undefined means 'still loading' */
   robotComments?: {[path: string]: RobotCommentInfo[]};
-  // All drafts are DraftInfo objects and have __draft = true set.
-  // Drafts have an id and are known to the backend. Unsaved drafts
-  // (see UnsavedInfo) do NOT belong in the application model.
+  // All drafts are DraftInfo objects and have `state` state set.
   /** undefined means 'still loading' */
   drafts?: {[path: string]: DraftInfo[]};
   // Ported comments only affect `CommentThread` properties, not individual
   // comments.
   /** undefined means 'still loading' */
-  portedComments?: PathToCommentsInfoMap;
+  portedComments?: {[path: string]: CommentInfo[]};
   /** undefined means 'still loading' */
-  portedDrafts?: PathToCommentsInfoMap;
+  portedDrafts?: {[path: string]: DraftInfo[]};
   /**
    * If a draft is discarded by the user, then we temporarily keep it in this
    * array in case the user decides to Undo the discard operation and bring the
@@ -96,7 +97,7 @@
   if (numPending === 0) {
     return 'All changes saved';
   }
-  return `Saving ${pluralize(numPending, 'draft')}...`;
+  return undefined;
 }
 
 // Private but used in tests.
@@ -112,6 +113,35 @@
   return nextState;
 }
 
+/** Updates a single comment in a state. */
+export function updateComment(
+  state: CommentState,
+  comment: CommentInfo
+): CommentState {
+  if (!comment.path || !state.comments) {
+    return state;
+  }
+  const newCommentsAtPath = [...state.comments[comment.path]];
+  for (let i = 0; i < newCommentsAtPath.length; ++i) {
+    if (newCommentsAtPath[i].id === comment.id) {
+      // TODO: In "delete comment" the returned comment is missing some of the
+      // fields (for example patch_set), which would throw errors when
+      // rendering. Remove merging with the old comment, once that is fixed in
+      // server code.
+      newCommentsAtPath[i] = {...newCommentsAtPath[i], ...comment};
+
+      return {
+        ...state,
+        comments: {
+          ...state.comments,
+          [comment.path]: newCommentsAtPath,
+        },
+      };
+    }
+  }
+  throw new Error('Comment to be updated does not exist');
+}
+
 // Private but used in tests.
 export function setRobotComments(
   state: CommentState,
@@ -139,7 +169,7 @@
 // Private but used in tests.
 export function setPortedComments(
   state: CommentState,
-  portedComments?: PathToCommentsInfoMap
+  portedComments?: {[path: string]: CommentInfo[]}
 ): CommentState {
   if (deepEqual(portedComments, state.portedComments)) return state;
   const nextState = {...state};
@@ -150,7 +180,7 @@
 // Private but used in tests.
 export function setPortedDrafts(
   state: CommentState,
-  portedDrafts?: PathToCommentsInfoMap
+  portedDrafts?: {[path: string]: DraftInfo[]}
 ): CommentState {
   if (deepEqual(portedDrafts, state.portedDrafts)) return state;
   const nextState = {...state};
@@ -175,7 +205,7 @@
 ): CommentState {
   const nextState = {...state};
   const drafts = [...nextState.discardedDrafts];
-  const index = drafts.findIndex(d => d.id === draftID);
+  const index = drafts.findIndex(draft => id(draft) === draftID);
   if (index === -1) {
     throw new Error('discarded draft not found');
   }
@@ -187,15 +217,14 @@
 /** Adds or updates a draft. */
 export function setDraft(state: CommentState, draft: DraftInfo): CommentState {
   const nextState = {...state};
-  if (!draft.path) throw new Error('draft path undefined');
-  if (!isDraft(draft)) throw new Error('draft is not a draft');
-  if (isUnsaved(draft)) throw new Error('unsaved drafts dont belong to model');
+  assert(!!draft.path, 'draft without path');
+  assert(isDraft(draft), 'draft is not a draft');
 
   nextState.drafts = {...nextState.drafts};
   const drafts = nextState.drafts;
   if (!drafts[draft.path]) drafts[draft.path] = [] as DraftInfo[];
   else drafts[draft.path] = [...drafts[draft.path]];
-  const index = drafts[draft.path].findIndex(d => d.id && d.id === draft.id);
+  const index = drafts[draft.path].findIndex(d => id(d) === id(draft));
   if (index !== -1) {
     drafts[draft.path][index] = draft;
   } else {
@@ -209,14 +238,12 @@
   draft: DraftInfo
 ): CommentState {
   const nextState = {...state};
-  if (!draft.path) throw new Error('draft path undefined');
-  if (!isDraft(draft)) throw new Error('draft is not a draft');
-  if (isUnsaved(draft)) throw new Error('unsaved drafts dont belong to model');
+  assert(!!draft.path, 'draft without path');
+  assert(isDraft(draft), 'draft is not a draft');
+
   nextState.drafts = {...nextState.drafts};
   const drafts = nextState.drafts;
-  const index = (drafts[draft.path] || []).findIndex(
-    d => d.id && d.id === draft.id
-  );
+  const index = (drafts[draft.path] || []).findIndex(d => id(d) === id(draft));
   if (index === -1) return state;
   const discardedDraft = drafts[draft.path][index];
   drafts[draft.path] = [...drafts[draft.path]];
@@ -225,7 +252,7 @@
 }
 
 export const commentsModelToken = define<CommentsModel>('comments-model');
-export class CommentsModel extends Model<CommentState> implements Finalizable {
+export class CommentsModel extends Model<CommentState> {
   public readonly commentsLoading$ = select(
     this.state$,
     commentState =>
@@ -254,9 +281,22 @@
     commentState => commentState.drafts
   );
 
-  public readonly draftsCount$ = select(
+  public readonly draftsLoading$ = select(
     this.drafts$,
-    drafts => Object.values(drafts ?? {}).flat().length
+    drafts => drafts === undefined
+  );
+
+  public readonly draftsArray$ = select(this.drafts$, drafts =>
+    Object.values(drafts ?? {}).flat()
+  );
+
+  public readonly draftsSaved$ = select(this.draftsArray$, drafts =>
+    drafts.filter(d => !isNew(d))
+  );
+
+  public readonly draftsCount$ = select(
+    this.draftsSaved$,
+    drafts => drafts.length
   );
 
   public readonly portedComments$ = select(
@@ -269,21 +309,26 @@
     commentState => commentState.discardedDrafts
   );
 
-  public readonly patchsetLevelDrafts$ = select(this.drafts$, drafts =>
-    Object.values(drafts ?? {})
-      .flat()
-      .filter(
-        draft =>
-          draft.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS &&
-          !draft.in_reply_to
-      )
+  public readonly savingInProgress$ = select(this.draftsArray$, drafts =>
+    drafts.some(isSaving)
+  );
+
+  public readonly savingError$ = select(this.draftsArray$, drafts =>
+    drafts.some(isError)
+  );
+
+  public readonly patchsetLevelDrafts$ = select(this.draftsArray$, drafts =>
+    drafts.filter(
+      draft =>
+        draft.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS &&
+        !draft.in_reply_to
+    )
   );
 
   public readonly mentionedUsersInDrafts$: Observable<AccountInfo[]> =
-    this.drafts$.pipe(
-      switchMap(drafts => {
+    this.draftsArray$.pipe(
+      switchMap(comments => {
         const users: AccountInfo[] = [];
-        const comments = Object.values(drafts ?? {}).flat();
         for (const comment of comments) {
           users.push(...extractMentionedUsers(comment.message));
         }
@@ -299,18 +344,16 @@
           uniqueUsers.map(user => from(this.accountsModel.fillDetails(user)));
         return forkJoin(filledUsers$);
       }),
-      map(users => users.filter(notUndefined)),
+      map(users => users.filter(isDefined)),
       distinctUntilChanged(deepEqual),
       shareReplay(1)
     );
 
   public readonly mentionedUsersInUnresolvedDrafts$: Observable<AccountInfo[]> =
-    this.drafts$.pipe(
+    this.draftsArray$.pipe(
       switchMap(drafts => {
         const users: AccountInfo[] = [];
-        const comments = Object.values(drafts ?? {})
-          .flat()
-          .filter(c => c.unresolved);
+        const comments = drafts.filter(c => c.unresolved);
         for (const comment of comments) {
           users.push(...extractMentionedUsers(comment.message));
         }
@@ -326,7 +369,7 @@
           uniqueUsers.map(user => from(this.accountsModel.fillDetails(user)));
         return forkJoin(filledUsers$);
       }),
-      map(users => users.filter(notUndefined)),
+      map(users => users.filter(isDefined)),
       distinctUntilChanged(deepEqual),
       shareReplay(1)
     );
@@ -349,8 +392,12 @@
     changeComments.getAllThreadsForChange()
   );
 
-  public readonly draftThreads$ = select(this.threads$, threads =>
-    threads.filter(isDraftThread)
+  public readonly threadsSaved$ = select(this.threads$, threads =>
+    threads.filter(t => !isNewThread(t))
+  );
+
+  public readonly draftThreadsSaved$ = select(this.threads$, threads =>
+    threads.filter(t => !isNewThread(t) && isDraftThread(t))
   );
 
   public readonly commentedPaths$ = select(
@@ -376,8 +423,6 @@
 
   private patchNum?: PatchSetNum;
 
-  private readonly reloadListener: () => void;
-
   private drafts: {[path: string]: DraftInfo[]} = {};
 
   private draftToastTask?: DelayedTask;
@@ -385,14 +430,33 @@
   private discardedDrafts: DraftInfo[] = [];
 
   constructor(
-    readonly routerModel: RouterModel,
-    readonly changeModel: ChangeModel,
-    readonly accountsModel: AccountsModel,
-    readonly restApiService: RestApiService,
-    readonly reporting: ReportingService
+    private readonly changeViewModel: ChangeViewModel,
+    private readonly changeModel: ChangeModel,
+    private readonly accountsModel: AccountsModel,
+    private readonly restApiService: RestApiService,
+    private readonly reporting: ReportingService,
+    private readonly navigation: NavigationService
   ) {
     super(initialState);
     this.subscriptions.push(
+      this.savingInProgress$.subscribe(savingInProgress => {
+        if (savingInProgress) {
+          this.navigation.blockNavigation('draft comment still saving');
+        } else {
+          this.navigation.releaseNavigation('draft comment still saving');
+        }
+      })
+    );
+    this.subscriptions.push(
+      this.savingError$.subscribe(savingError => {
+        if (savingError) {
+          this.navigation.blockNavigation('draft comment failed to save');
+        } else {
+          this.navigation.releaseNavigation('draft comment failed to save');
+        }
+      })
+    );
+    this.subscriptions.push(
       this.discardedDrafts$.subscribe(x => (this.discardedDrafts = x))
     );
     this.subscriptions.push(
@@ -402,7 +466,17 @@
       this.changeModel.patchNum$.subscribe(x => (this.patchNum = x))
     );
     this.subscriptions.push(
-      this.routerModel.routerChangeNum$.subscribe(changeNum => {
+      combineLatest([
+        this.draftsLoading$,
+        this.patchsetLevelDrafts$,
+        this.changeModel.latestPatchNum$,
+      ]).subscribe(([loading, plDraft, latestPatchNum]) => {
+        if (loading || plDraft.length > 0 || !latestPatchNum) return;
+        this.addNewDraft(createNewPatchsetLevel(latestPatchNum, '', false));
+      })
+    );
+    this.subscriptions.push(
+      this.changeViewModel.changeNum$.subscribe(changeNum => {
         this.changeNum = changeNum;
         this.setState({...initialState});
         this.reloadAllComments();
@@ -418,16 +492,6 @@
         this.reloadAllPortedComments();
       })
     );
-    this.reloadListener = () => {
-      this.reloadAllComments();
-      this.reloadAllPortedComments();
-    };
-    document.addEventListener('reload', this.reloadListener);
-  }
-
-  override finalize() {
-    document.removeEventListener('reload', this.reloadListener);
-    super.finalize();
   }
 
   // Note that this does *not* reload ported comments.
@@ -522,107 +586,150 @@
     this.modifyState(s => setPortedDrafts(s, portedDrafts));
   }
 
-  async restoreDraft(id: UrlEncodedCommentId) {
-    const found = this.discardedDrafts?.find(d => d.id === id);
+  async restoreDraft(draftId: UrlEncodedCommentId) {
+    const found = this.discardedDrafts?.find(d => id(d) === draftId);
     if (!found) throw new Error('discarded draft not found');
-    const newDraft = {
+    const newDraft: DraftInfo = {
       ...found,
-      id: undefined,
-      updated: undefined,
-      __draft: undefined,
-      __unsaved: true,
+      ...createNew(),
     };
     await this.saveDraft(newDraft);
-    this.modifyState(s => deleteDiscardedDraft(s, id));
+    this.modifyState(s => deleteDiscardedDraft(s, draftId));
+  }
+
+  /**
+   * Adds a new draft without saving it.
+   *
+   * There is no equivalent `removeNewDraft()` method, because
+   * `discardDraft()` can be used.
+   */
+  addNewDraft(draft: DraftInfo) {
+    assert(isNew(draft), 'draft must be new');
+    this.modifyState(s => setDraft(s, draft));
   }
 
   /**
    * Saves a new or updates an existing draft.
-   * The model will only be updated when a successful response comes back.
+   *
+   * `draft.message` must not be empty: Use `discardDraft()` instead.
+   *
+   * Draft must not be in `SAVING` state already.
    */
-  async saveDraft(
-    draft: DraftInfo | UnsavedInfo,
-    showToast = true
-  ): Promise<DraftInfo> {
+  async saveDraft(draft: DraftInfo, showToast = true): Promise<DraftInfo> {
     assertIsDefined(this.changeNum, 'change number');
     assertIsDefined(draft.patch_set, 'patchset number of comment draft');
-    if (!draft.message?.trim()) throw new Error('Cannot save empty draft.');
+    assert(!!draft.message?.trim(), 'cannot save empty draft');
+    assert(!isSaving(draft), 'saving already in progress');
+
+    // optimistic update
+    const draftSaving: DraftInfo = {...draft, savingState: SavingState.SAVING};
+    this.modifyState(s => setDraft(s, draftSaving));
 
     // Saving the change number as to make sure that the response is still
     // relevant when it comes back. The user maybe have navigated away.
     const changeNum = this.changeNum;
     this.report(Interaction.SAVE_COMMENT, draft);
     if (showToast) this.showStartRequest();
-    const timing = isUnsaved(draft) ? Timing.DRAFT_CREATE : Timing.DRAFT_UPDATE;
+    const timing = isNew(draft) ? Timing.DRAFT_CREATE : Timing.DRAFT_UPDATE;
     const timer = this.reporting.getTimer(timing);
-    const result = await this.restApiService.saveDiffDraft(
-      changeNum,
-      draft.patch_set,
-      draft
-    );
-    if (changeNum !== this.changeNum) throw new Error('change changed');
-    if (!result.ok) {
-      if (showToast) this.handleFailedDraftRequest();
-      throw new Error(
-        `Failed to save draft comment: ${JSON.stringify(result)}`
+
+    let savedComment;
+    try {
+      const result = await this.restApiService.saveDiffDraft(
+        changeNum,
+        draft.patch_set,
+        draft
       );
+      if (changeNum !== this.changeNum) return draft;
+      if (!result.ok) throw new Error('request failed');
+      savedComment = (await this.restApiService.getResponseObject(
+        result
+      )) as unknown as CommentInfo;
+    } catch (error) {
+      if (showToast) this.handleFailedDraftRequest();
+      const draftError: DraftInfo = {...draft, savingState: SavingState.ERROR};
+      this.modifyState(s => setDraft(s, draftError));
+      return draftError;
     }
-    const obj = await this.restApiService.getResponseObject(result);
-    const savedComment = obj as unknown as CommentInfo;
-    const updatedDraft = {
+
+    const draftSaved: DraftInfo = {
       ...draft,
       id: savedComment.id,
       updated: savedComment.updated,
-      __draft: true,
-      __unsaved: undefined,
+      savingState: SavingState.OK,
     };
-    timer.end({id: updatedDraft.id});
+    timer.end({id: draftSaved.id});
     if (showToast) this.showEndRequest();
-    this.modifyState(s => setDraft(s, updatedDraft));
-    this.report(Interaction.COMMENT_SAVED, updatedDraft);
-    return updatedDraft;
+    this.modifyState(s => setDraft(s, draftSaved));
+    this.report(Interaction.COMMENT_SAVED, draftSaved);
+    return draftSaved;
   }
 
   async discardDraft(draftId: UrlEncodedCommentId) {
     const draft = this.lookupDraft(draftId);
-    assertIsDefined(this.changeNum, 'change number');
     assertIsDefined(draft, `draft not found by id ${draftId}`);
     assertIsDefined(draft.patch_set, 'patchset number of comment draft');
+    assert(!isSaving(draft), 'saving already in progress');
 
-    if (!draft.message?.trim()) throw new Error('saved draft cant be empty');
-    // Saving the change number as to make sure that the response is still
-    // relevant when it comes back. The user maybe have navigated away.
-    const changeNum = this.changeNum;
-    this.report(Interaction.DISCARD_COMMENT, draft);
-    this.showStartRequest();
-    const timer = this.reporting.getTimer(Timing.DRAFT_DISCARD);
-    const result = await this.restApiService.deleteDiffDraft(
-      changeNum,
-      draft.patch_set,
-      {id: draft.id}
-    );
-    timer.end({id: draft.id});
-    if (changeNum !== this.changeNum) throw new Error('change changed');
-    if (!result.ok) {
-      this.handleFailedDraftRequest();
-      throw new Error(
-        `Failed to discard draft comment: ${JSON.stringify(result)}`
-      );
-    }
-    this.showEndRequest();
+    // optimistic update
     this.modifyState(s => deleteDraft(s, draft));
+
+    // For "unsaved" drafts there is nothing to discard on the server side.
+    if (draft.id) {
+      if (!draft.message?.trim()) throw new Error('empty draft');
+      // Saving the change number as to make sure that the response is still
+      // relevant when it comes back. The user maybe have navigated away.
+      assertIsDefined(this.changeNum, 'change number');
+      const changeNum = this.changeNum;
+      this.report(Interaction.DISCARD_COMMENT, draft);
+      this.showStartRequest();
+      const timer = this.reporting.getTimer(Timing.DRAFT_DISCARD);
+      const result = await this.restApiService.deleteDiffDraft(
+        changeNum,
+        draft.patch_set,
+        {id: draft.id}
+      );
+      timer.end({id: draft.id});
+      if (changeNum !== this.changeNum) throw new Error('change changed');
+      if (!result.ok) {
+        this.handleFailedDraftRequest();
+        await this.restoreDraft(draftId);
+        throw new Error(
+          `Failed to discard draft comment: ${JSON.stringify(result)}`
+        );
+      }
+      this.showEndRequest();
+    }
+
     // We don't store empty discarded drafts and don't need an UNDO then.
     if (draft.message?.trim()) {
-      fire(document, EventType.SHOW_ALERT, {
+      fire(document, 'show-alert', {
         message: 'Draft Discarded',
         action: 'Undo',
-        callback: () => this.restoreDraft(draft.id),
+        callback: () => this.restoreDraft(draftId),
       });
     }
     this.report(Interaction.COMMENT_DISCARDED, draft);
   }
 
-  private report(interaction: Interaction, comment: CommentBasics) {
+  async deleteComment(
+    changeNum: NumericChangeId,
+    comment: Comment,
+    reason: string
+  ) {
+    assertIsDefined(comment.patch_set, 'comment.patch_set');
+    assert(!isDraft(comment), 'Admin deletion is only for published comments.');
+
+    const newComment = await this.restApiService.deleteComment(
+      changeNum,
+      comment.patch_set,
+      comment.id,
+      reason
+    );
+    this.modifyState(s => updateComment(s, newComment));
+  }
+
+  private report(interaction: Interaction, comment: Comment) {
     const details = reportingDetails(comment);
     this.reporting.reportInteraction(interaction, details);
   }
@@ -644,28 +751,24 @@
 
   private updateRequestToast(requestFailed?: boolean) {
     if (this.numPendingDraftRequests === 0 && !requestFailed) {
-      fireEvent(document, 'hide-alert');
+      fire(document, 'hide-alert', {});
       return;
     }
     const message = getSavingMessage(
       this.numPendingDraftRequests,
       requestFailed
     );
+    if (!message) return;
     this.draftToastTask = debounce(
       this.draftToastTask,
-      () => {
-        // Note: the event is fired on the body rather than this element because
-        // this element may not be attached by the time this executes, in which
-        // case the event would not bubble.
-        fireAlert(document.body, message);
-      },
+      () => fireAlert(document.body, message),
       TOAST_DEBOUNCE_INTERVAL
     );
   }
 
-  private lookupDraft(id: UrlEncodedCommentId): DraftInfo | undefined {
+  private lookupDraft(commentId: UrlEncodedCommentId): DraftInfo | undefined {
     return Object.values(this.drafts)
       .flat()
-      .find(d => d.id === id);
+      .find(draft => id(draft) === commentId);
   }
 }
diff --git a/polygerrit-ui/app/models/comments/comments-model_test.ts b/polygerrit-ui/app/models/comments/comments-model_test.ts
index 32ea1bc..0d3df42 100644
--- a/polygerrit-ui/app/models/comments/comments-model_test.ts
+++ b/polygerrit-ui/app/models/comments/comments-model_test.ts
@@ -6,11 +6,14 @@
 import '../../test/common-test-setup';
 import {
   createAccountWithEmail,
+  createChangeViewState,
   createDraft,
 } from '../../test/test-data-generators';
 import {
   AccountInfo,
+  CommentInfo,
   EmailAddress,
+  NumericChangeId,
   Timestamp,
   UrlEncodedCommentId,
 } from '../../types/common';
@@ -19,15 +22,16 @@
 import {
   createComment,
   createParsedChange,
-  TEST_NUMERIC_CHANGE_ID,
 } from '../../test/test-data-generators';
 import {stubRestApi, waitUntil, waitUntilCalled} from '../../test/test-utils';
 import {getAppContext} from '../../services/app-context';
-import {GerritView} from '../../services/router/router-model';
-import {PathToCommentsInfoMap} from '../../types/common';
 import {changeModelToken} from '../change/change-model';
 import {assert} from '@open-wc/testing';
 import {testResolver} from '../../test/common-test-setup';
+import {accountsModelToken} from '../accounts-model/accounts-model';
+import {ChangeComments} from '../../elements/diff/gr-comment-api/gr-comment-api';
+import {changeViewModelToken} from '../views/change';
+import {navigationToken} from '../../elements/core/gr-navigation/gr-navigation';
 
 suite('comments model tests', () => {
   test('updateStateDeleteDraft', () => {
@@ -69,11 +73,12 @@
 
   test('loads comments', async () => {
     const model = new CommentsModel(
-      getAppContext().routerModel,
+      testResolver(changeViewModelToken),
       testResolver(changeModelToken),
-      getAppContext().accountsModel,
+      testResolver(accountsModelToken),
       getAppContext().restApiService,
-      getAppContext().reportingService
+      getAppContext().reportingService,
+      testResolver(navigationToken)
     );
     const diffCommentsSpy = stubRestApi('getDiffComments').returns(
       Promise.resolve({'foo.c': [createComment()]})
@@ -90,18 +95,15 @@
     const portedDraftsSpy = stubRestApi('getPortedDrafts').returns(
       Promise.resolve({})
     );
-    let comments: PathToCommentsInfoMap = {};
+    let comments: {[path: string]: CommentInfo[]} = {};
     subscriptions.push(model.comments$.subscribe(c => (comments = c ?? {})));
-    let portedComments: PathToCommentsInfoMap = {};
+    let portedComments: {[path: string]: CommentInfo[]} = {};
     subscriptions.push(
       model.portedComments$.subscribe(c => (portedComments = c ?? {}))
     );
 
-    model.routerModel.setState({
-      view: GerritView.CHANGE,
-      changeNum: TEST_NUMERIC_CHANGE_ID,
-    });
-    model.changeModel.updateStateChange(createParsedChange());
+    testResolver(changeViewModelToken).setState(createChangeViewState());
+    testResolver(changeModelToken).updateStateChange(createParsedChange());
 
     await waitUntilCalled(diffCommentsSpy, 'diffCommentsSpy');
     await waitUntilCalled(diffRobotCommentsSpy, 'diffRobotCommentsSpy');
@@ -130,11 +132,12 @@
     };
     stubRestApi('getAccountDetails').returns(Promise.resolve(account));
     const model = new CommentsModel(
-      getAppContext().routerModel,
+      testResolver(changeViewModelToken),
       testResolver(changeModelToken),
-      getAppContext().accountsModel,
+      testResolver(accountsModelToken),
       getAppContext().restApiService,
-      getAppContext().reportingService
+      getAppContext().reportingService,
+      testResolver(navigationToken)
     );
     let mentionedUsers: AccountInfo[] = [];
     const draft = {...createDraft(), message: 'hey @abc@def.com'};
@@ -158,11 +161,12 @@
     };
     stubRestApi('getAccountDetails').returns(Promise.resolve(account));
     const model = new CommentsModel(
-      getAppContext().routerModel,
+      testResolver(changeViewModelToken),
       testResolver(changeModelToken),
-      getAppContext().accountsModel,
+      testResolver(accountsModelToken),
       getAppContext().restApiService,
-      getAppContext().reportingService
+      getAppContext().reportingService,
+      testResolver(navigationToken)
     );
     let mentionedUsers: AccountInfo[] = [];
     const draft = {...createDraft(), message: 'hey @abc@def.com'};
@@ -186,4 +190,37 @@
     });
     await waitUntil(() => mentionedUsers.length === 0);
   });
+
+  test('delete comment change is emitted', async () => {
+    const comment = createComment();
+    stubRestApi('deleteComment').returns(
+      Promise.resolve({
+        ...comment,
+        message: 'Comment is deleted',
+      })
+    );
+    const model = new CommentsModel(
+      testResolver(changeViewModelToken),
+      testResolver(changeModelToken),
+      testResolver(accountsModelToken),
+      getAppContext().restApiService,
+      getAppContext().reportingService,
+      testResolver(navigationToken)
+    );
+
+    let changeComments: ChangeComments | undefined = undefined;
+    model.changeComments$.subscribe(x => (changeComments = x));
+    model.setState({
+      comments: {[comment.path!]: [comment]},
+      discardedDrafts: [],
+    });
+
+    model.deleteComment(123 as NumericChangeId, comment, 'Comment is deleted');
+
+    await waitUntil(
+      () =>
+        changeComments?.getAllCommentsForPath(comment.path!)[0].message ===
+        'Comment is deleted'
+    );
+  });
 });
diff --git a/polygerrit-ui/app/models/config/config-model.ts b/polygerrit-ui/app/models/config/config-model.ts
index 6e374d1..168e0f4 100644
--- a/polygerrit-ui/app/models/config/config-model.ts
+++ b/polygerrit-ui/app/models/config/config-model.ts
@@ -6,13 +6,11 @@
 import {ConfigInfo, RepoName, ServerInfo} from '../../types/common';
 import {from, of} from 'rxjs';
 import {switchMap} from 'rxjs/operators';
-import {Finalizable} from '../../services/registry';
 import {RestApiService} from '../../services/gr-rest-api/gr-rest-api';
 import {ChangeModel} from '../change/change-model';
 import {select} from '../../utils/observable-util';
 import {Model} from '../model';
 import {define} from '../dependency';
-import {getDocsBaseUrl} from '../../utils/url-util';
 
 export interface ConfigState {
   repoConfig?: ConfigInfo;
@@ -20,7 +18,7 @@
 }
 
 export const configModelToken = define<ConfigModel>('config-model');
-export class ConfigModel extends Model<ConfigState> implements Finalizable {
+export class ConfigModel extends Model<ConfigState> {
   public repoConfig$ = select(
     this.state$,
     configState => configState.repoConfig
@@ -44,7 +42,7 @@
   public docsBaseUrl$ = select(
     this.serverConfig$.pipe(
       switchMap(serverConfig =>
-        from(getDocsBaseUrl(serverConfig, this.restApiService))
+        from(this.restApiService.getDocsBaseUrl(serverConfig))
       )
     ),
     url => url
diff --git a/polygerrit-ui/app/models/dependency.ts b/polygerrit-ui/app/models/dependency.ts
index 5499db2..3b5081a 100644
--- a/polygerrit-ui/app/models/dependency.ts
+++ b/polygerrit-ui/app/models/dependency.ts
@@ -47,14 +47,15 @@
  * ---
  *
  * Ancestor components will inject the dependencies that a child component
- * requires by providing factories for those values.
+ * requires by providing providers for those values.
  *
  *
  * To provide a dependency, a component needs to specify the following prior
  * to finishing its connectedCallback:
  *
  * ```
- *   provide(this, fooToken, () => new FooImpl())
+ *   const fooImpl = new FooImpl();
+ *   provide(this, fooToken, () => fooImpl);
  * ```
  * Dependencies are injected as factories in case the construction of them
  * depends on other dependencies further up the component chain.  For instance,
@@ -63,7 +64,8 @@
  *
  * ```
  *   const barRef = resolve(this, barToken);
- *   provide(this, fooToken, () => new FooImpl(barRef()));
+ *   const fooImpl = new FooImpl(barRef());
+ *   provide(this, fooToken, () => fooImpl);
  * ```
  *
  * Lifetime guarantees
@@ -188,7 +190,7 @@
  */
 export interface DependencyRequest<T> {
   readonly dependency: DependencyToken<T>;
-  readonly callback: Callback<T>;
+  readonly callback: Callback<Provider<T>>;
 }
 
 declare global {
@@ -218,7 +220,7 @@
 {
   public constructor(
     public readonly dependency: DependencyToken<T>,
-    public readonly callback: Callback<T>
+    public readonly callback: Callback<Provider<T>>
   ) {
     super('request-dependency', {bubbles: true, composed: true});
   }
@@ -238,12 +240,20 @@
   }
 }
 
+function makeDependencyError<T>(
+  host: HTMLElement,
+  dependency: DependencyToken<T>
+): DependencyError<T> {
+  const dep = dependency.description;
+  const tag = host.tagName;
+  const msg = `Could not resolve dependency '${dep}' in '${tag}'`;
+  return new DependencyError(dependency, msg);
+}
+
 class DependencySubscriber<T>
   implements ReactiveController, ResolvedDependency<T>
 {
-  private value?: T;
-
-  private resolved = false;
+  private provider?: Provider<T>;
 
   constructor(
     private readonly host: ReactiveControllerHost & HTMLElement,
@@ -251,34 +261,26 @@
   ) {}
 
   get() {
-    this.checkResolved();
-    return this.value!;
+    if (!this.provider) {
+      throw makeDependencyError(this.host, this.dependency);
+    }
+    return this.provider();
   }
 
   hostConnected() {
-    this.value = undefined;
-    this.resolved = false;
+    this.provider = undefined;
     this.host.dispatchEvent(
-      new DependencyRequestEvent(this.dependency, (value: T) => {
-        this.resolved = true;
-        this.value = value;
+      new DependencyRequestEvent(this.dependency, (provider: Provider<T>) => {
+        this.provider = provider;
       })
     );
-    this.checkResolved();
-  }
-
-  checkResolved() {
-    if (this.resolved) return;
-    const dep = this.dependency.description;
-    const tag = this.host.tagName;
-    const msg = `Could not resolve dependency '${dep}' in '${tag}'`;
-    throw new DependencyError(this.dependency, msg);
+    if (!this.provider) {
+      throw makeDependencyError(this.host, this.dependency);
+    }
   }
 }
 
 class DependencyProvider<T> implements ReactiveController {
-  private value?: T;
-
   constructor(
     private readonly host: ReactiveControllerHost & HTMLElement,
     private readonly dependency: DependencyToken<T>,
@@ -286,20 +288,17 @@
   ) {}
 
   hostConnected() {
-    // Delay construction in case the provider has its own dependencies.
-    this.value = this.provider();
     this.host.addEventListener('request-dependency', this.fullfill);
   }
 
   hostDisconnected() {
     this.host.removeEventListener('request-dependency', this.fullfill);
-    this.value = undefined;
   }
 
   private readonly fullfill = (ev: DependencyRequestEvent<unknown>) => {
     if (ev.dependency !== this.dependency) return;
     ev.stopPropagation();
     ev.preventDefault();
-    ev.callback(this.value!);
+    ev.callback(this.provider);
   };
 }
diff --git a/polygerrit-ui/app/models/plugins/plugins-model.ts b/polygerrit-ui/app/models/plugins/plugins-model.ts
index 7826c45..83235b17 100644
--- a/polygerrit-ui/app/models/plugins/plugins-model.ts
+++ b/polygerrit-ui/app/models/plugins/plugins-model.ts
@@ -3,7 +3,6 @@
  * Copyright 2022 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-import {Finalizable} from '../../services/registry';
 import {Observable, Subject} from 'rxjs';
 import {
   CheckResult,
@@ -12,8 +11,13 @@
   ChecksProvider,
 } from '../../api/checks';
 import {Model} from '../model';
-import {define} from '../dependency';
 import {select} from '../../utils/observable-util';
+import {CoverageProvider} from '../../api/annotation';
+
+export interface CoveragePlugin {
+  pluginName: string;
+  provider: CoverageProvider;
+}
 
 export interface ChecksPlugin {
   pluginName: string;
@@ -30,14 +34,16 @@
 /** Application wide state of plugins. */
 interface PluginsState {
   /**
+   * List of plugins that have called annotationApi().setCoverageProvider().
+   */
+  coveragePlugins: CoveragePlugin[];
+  /**
    * List of plugins that have called checks().register().
    */
   checksPlugins: ChecksPlugin[];
 }
 
-export const pluginsModelToken = define<PluginsModel>('plugins-model');
-
-export class PluginsModel extends Model<PluginsState> implements Finalizable {
+export class PluginsModel extends Model<PluginsState> {
   /** Private version of the event bus below. */
   private checksAnnounceSubject$ = new Subject<ChecksPlugin>();
 
@@ -54,19 +60,38 @@
 
   public checksPlugins$ = select(this.state$, state => state.checksPlugins);
 
+  public coveragePlugins$ = select(this.state$, state => state.coveragePlugins);
+
   constructor() {
     super({
+      coveragePlugins: [],
       checksPlugins: [],
     });
   }
 
+  coverageRegister(plugin: CoveragePlugin) {
+    const nextState = {...this.getState()};
+    nextState.coveragePlugins = [...nextState.coveragePlugins];
+    const alreadyRegistered = nextState.coveragePlugins.some(
+      p => p.pluginName === plugin.pluginName
+    );
+    if (alreadyRegistered) {
+      console.warn(
+        `${plugin.pluginName} tried to register twice as a coverage provider. Ignored.`
+      );
+      return;
+    }
+    nextState.coveragePlugins.push(plugin);
+    this.setState(nextState);
+  }
+
   checksRegister(plugin: ChecksPlugin) {
     const nextState = {...this.getState()};
     nextState.checksPlugins = [...nextState.checksPlugins];
-    const alreadysRegistered = nextState.checksPlugins.some(
+    const alreadyRegistered = nextState.checksPlugins.some(
       p => p.pluginName === plugin.pluginName
     );
-    if (alreadysRegistered) {
+    if (alreadyRegistered) {
       console.warn(
         `${plugin.pluginName} tried to register twice as a checks provider. Ignored.`
       );
diff --git a/polygerrit-ui/app/models/plugins/plugins-model_test.ts b/polygerrit-ui/app/models/plugins/plugins-model_test.ts
index 639afc69..7aabae7 100644
--- a/polygerrit-ui/app/models/plugins/plugins-model_test.ts
+++ b/polygerrit-ui/app/models/plugins/plugins-model_test.ts
@@ -7,7 +7,7 @@
 import './plugins-model';
 import {ChecksApiConfig, ChecksProvider, ResponseCode} from '../../api/checks';
 import {ChecksPlugin, ChecksUpdate, PluginsModel} from './plugins-model';
-import {createRunResult} from '../../test/test-data-generators';
+import {createRun, createRunResult} from '../../test/test-data-generators';
 import {assert} from '@open-wc/testing';
 
 const PLUGIN_NAME = 'test-plugin';
@@ -75,7 +75,7 @@
     register();
     model.checksUpdate({
       pluginName: PLUGIN_NAME,
-      run: createRunResult(),
+      run: createRun(),
       result: createRunResult(),
     });
 
diff --git a/polygerrit-ui/app/models/user/user-model.ts b/polygerrit-ui/app/models/user/user-model.ts
index 2f2fe7d..97f90fa 100644
--- a/polygerrit-ui/app/models/user/user-model.ts
+++ b/polygerrit-ui/app/models/user/user-model.ts
@@ -23,10 +23,10 @@
 } from '../../constants/constants';
 import {RestApiService} from '../../services/gr-rest-api/gr-rest-api';
 import {DiffPreferencesInfo} from '../../types/diff';
-import {Finalizable} from '../../services/registry';
 import {select} from '../../utils/observable-util';
+import {define} from '../dependency';
 import {Model} from '../model';
-import {notUndefined} from '../../types/types';
+import {isDefined} from '../../types/types';
 
 export interface UserState {
   /**
@@ -56,7 +56,9 @@
   capabilities?: AccountCapabilityInfo;
 }
 
-export class UserModel extends Model<UserState> implements Finalizable {
+export const userModelToken = define<UserModel>('user-model');
+
+export class UserModel extends Model<UserState> {
   /**
    * Note that the initially emitted `undefined` value can mean "not loaded
    * the account into object yet" or "user is not logged in". Consider using
@@ -99,17 +101,17 @@
   readonly preferences$: Observable<PreferencesInfo> = select(
     this.state$,
     userState => userState.preferences
-  ).pipe(filter(notUndefined));
+  ).pipe(filter(isDefined));
 
   readonly diffPreferences$: Observable<DiffPreferencesInfo> = select(
     this.state$,
     userState => userState.diffPreferences
-  ).pipe(filter(notUndefined));
+  ).pipe(filter(isDefined));
 
   readonly editPreferences$: Observable<EditPreferencesInfo> = select(
     this.state$,
     userState => userState.editPreferences
-  ).pipe(filter(notUndefined));
+  ).pipe(filter(isDefined));
 
   readonly preferenceDiffViewMode$: Observable<DiffViewMode> = select(
     this.preferences$,
diff --git a/polygerrit-ui/app/models/views/admin.ts b/polygerrit-ui/app/models/views/admin.ts
index 2ad95a2..017470e 100644
--- a/polygerrit-ui/app/models/views/admin.ts
+++ b/polygerrit-ui/app/models/views/admin.ts
@@ -4,15 +4,263 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import {GerritView} from '../../services/router/router-model';
+import {getBaseUrl} from '../../utils/url-util';
 import {define} from '../dependency';
 import {Model} from '../model';
-import {ViewState} from './base';
+import {Route, ViewState} from './base';
+import {
+  RepoName,
+  GroupId,
+  AccountDetailInfo,
+  AccountCapabilityInfo,
+} from '../../types/common';
+import {hasOwnProperty} from '../../utils/common-util';
+import {MenuLink} from '../../api/admin';
+import {createGroupUrl, GroupDetailView} from './group';
+import {createRepoUrl, RepoDetailView} from './repo';
+
+export interface SubsectionInterface {
+  name: string;
+  view: GerritView;
+  detailType?: RepoDetailView | GroupDetailView;
+  url?: string;
+  children?: SubsectionInterface[];
+}
+
+export interface AdminNavLinksOption {
+  repoName?: RepoName;
+  groupId?: GroupId;
+  groupName?: string;
+  groupIsInternal?: boolean;
+  isAdmin?: boolean;
+  groupOwner?: boolean;
+}
+
+export interface NavLink {
+  name: string;
+  url: string;
+  view?: GerritView | AdminChildView;
+  viewableToAll?: boolean;
+  section?: string;
+  capability?: string;
+  target?: string | null;
+  subsection?: SubsectionInterface;
+  children?: SubsectionInterface[];
+}
+
+export const PLUGIN_LIST_ROUTE: Route<AdminViewState> = {
+  urlPattern: /^\/admin\/plugins(\/)?$/,
+  createState: () => {
+    const state: AdminViewState = {
+      view: GerritView.ADMIN,
+      adminView: AdminChildView.PLUGINS,
+    };
+    return state;
+  },
+};
 
 export enum AdminChildView {
   REPOS = 'gr-repo-list',
   GROUPS = 'gr-admin-group-list',
   PLUGINS = 'gr-plugin-list',
 }
+const ADMIN_LINKS: NavLink[] = [
+  {
+    name: 'Repositories',
+    url: createAdminUrl({adminView: AdminChildView.REPOS}),
+    view: 'gr-repo-list' as GerritView,
+    viewableToAll: true,
+  },
+  {
+    name: 'Groups',
+    section: 'Groups',
+    url: createAdminUrl({adminView: AdminChildView.GROUPS}),
+    view: 'gr-admin-group-list' as GerritView,
+  },
+  {
+    name: 'Plugins',
+    capability: 'viewPlugins',
+    section: 'Plugins',
+    url: createAdminUrl({adminView: AdminChildView.PLUGINS}),
+    view: 'gr-plugin-list' as GerritView,
+  },
+];
+
+export interface AdminLink {
+  url: string;
+  text: string;
+  capability: string | null;
+  view: null;
+  viewableToAll: boolean;
+  target: '_blank' | null;
+}
+
+export interface AdminLinks {
+  links: NavLink[];
+  expandedSection?: SubsectionInterface;
+}
+
+export function getAdminLinks(
+  account: AccountDetailInfo | undefined,
+  getAccountCapabilities: () => Promise<AccountCapabilityInfo>,
+  getAdminMenuLinks: () => MenuLink[],
+  options?: AdminNavLinksOption
+): Promise<AdminLinks> {
+  if (!account) {
+    return Promise.resolve(
+      filterLinks(link => !!link.viewableToAll, getAdminMenuLinks, options)
+    );
+  }
+  return getAccountCapabilities().then(capabilities =>
+    filterLinks(
+      link => !link.capability || hasOwnProperty(capabilities, link.capability),
+      getAdminMenuLinks,
+      options
+    )
+  );
+}
+
+function filterLinks(
+  filterFn: (link: NavLink) => boolean,
+  getAdminMenuLinks: () => MenuLink[],
+  options?: AdminNavLinksOption
+): AdminLinks {
+  let links: NavLink[] = ADMIN_LINKS.slice(0);
+  let expandedSection: SubsectionInterface | undefined = undefined;
+
+  const isExternalLink = (link: MenuLink) => link.url[0] !== '/';
+
+  // Append top-level links that are defined by plugins.
+  links.push(
+    ...getAdminMenuLinks().map((link: MenuLink) => {
+      return {
+        url: link.url,
+        name: link.text,
+        capability: link.capability || undefined,
+        view: undefined,
+        viewableToAll: !link.capability,
+        target: isExternalLink(link) ? '_blank' : null,
+      };
+    })
+  );
+
+  links = links.filter(filterFn);
+
+  const filteredLinks: NavLink[] = [];
+  const repoName = options && options.repoName;
+  const groupId = options && options.groupId;
+  const groupName = options && options.groupName;
+  const groupIsInternal = options && options.groupIsInternal;
+  const isAdmin = options && options.isAdmin;
+  const groupOwner = options && options.groupOwner;
+
+  // Don't bother to get sub-navigation items if only the top level links
+  // are needed. This is used by the main header dropdown.
+  if (!repoName && !groupId) {
+    return {links, expandedSection};
+  }
+
+  // Otherwise determine the full set of links and return both the full
+  // set in addition to the subsection that should be displayed if it
+  // exists.
+  for (const link of links) {
+    const linkCopy = {...link};
+    if (linkCopy.name === 'Repositories' && repoName) {
+      linkCopy.subsection = getRepoSubsections(repoName);
+      expandedSection = linkCopy.subsection;
+    } else if (linkCopy.name === 'Groups' && groupId && groupName) {
+      linkCopy.subsection = getGroupSubsections(
+        groupId,
+        groupName,
+        groupIsInternal,
+        isAdmin,
+        groupOwner
+      );
+      expandedSection = linkCopy.subsection;
+    }
+    filteredLinks.push(linkCopy);
+  }
+  return {links: filteredLinks, expandedSection};
+}
+
+function getGroupSubsections(
+  groupId: GroupId,
+  groupName: string,
+  groupIsInternal?: boolean,
+  isAdmin?: boolean,
+  groupOwner?: boolean
+) {
+  const children: SubsectionInterface[] = [];
+  const subsection: SubsectionInterface = {
+    name: groupName,
+    view: GerritView.GROUP,
+    url: createGroupUrl({groupId}),
+    children,
+  };
+  if (groupIsInternal) {
+    children.push({
+      name: 'Members',
+      detailType: GroupDetailView.MEMBERS,
+      view: GerritView.GROUP,
+      url: createGroupUrl({groupId, detail: GroupDetailView.MEMBERS}),
+    });
+  }
+  if (groupIsInternal && (isAdmin || groupOwner)) {
+    children.push({
+      name: 'Audit Log',
+      detailType: GroupDetailView.LOG,
+      view: GerritView.GROUP,
+      url: createGroupUrl({groupId, detail: GroupDetailView.LOG}),
+    });
+  }
+  return subsection;
+}
+
+function getRepoSubsections(repo: RepoName) {
+  return {
+    name: repo,
+    view: GerritView.REPO,
+    children: [
+      {
+        name: 'General',
+        view: GerritView.REPO,
+        detailType: RepoDetailView.GENERAL,
+        url: createRepoUrl({repo, detail: RepoDetailView.GENERAL}),
+      },
+      {
+        name: 'Access',
+        view: GerritView.REPO,
+        detailType: RepoDetailView.ACCESS,
+        url: createRepoUrl({repo, detail: RepoDetailView.ACCESS}),
+      },
+      {
+        name: 'Commands',
+        view: GerritView.REPO,
+        detailType: RepoDetailView.COMMANDS,
+        url: createRepoUrl({repo, detail: RepoDetailView.COMMANDS}),
+      },
+      {
+        name: 'Branches',
+        view: GerritView.REPO,
+        detailType: RepoDetailView.BRANCHES,
+        url: createRepoUrl({repo, detail: RepoDetailView.BRANCHES}),
+      },
+      {
+        name: 'Tags',
+        view: GerritView.REPO,
+        detailType: RepoDetailView.TAGS,
+        url: createRepoUrl({repo, detail: RepoDetailView.TAGS}),
+      },
+      {
+        name: 'Dashboards',
+        view: GerritView.REPO,
+        detailType: RepoDetailView.DASHBOARDS,
+        url: createRepoUrl({repo, detail: RepoDetailView.DASHBOARDS}),
+      },
+    ],
+  };
+}
+
 export interface AdminViewState extends ViewState {
   view: GerritView.ADMIN;
   adminView: AdminChildView;
@@ -21,6 +269,17 @@
   offset?: number | string;
 }
 
+export function createAdminUrl(state: Omit<AdminViewState, 'view'>) {
+  switch (state.adminView) {
+    case AdminChildView.REPOS:
+      return `${getBaseUrl()}/admin/repos`;
+    case AdminChildView.GROUPS:
+      return `${getBaseUrl()}/admin/groups`;
+    case AdminChildView.PLUGINS:
+      return `${getBaseUrl()}/admin/plugins`;
+  }
+}
+
 export const adminViewModelToken = define<AdminViewModel>('admin-view-model');
 
 export class AdminViewModel extends Model<AdminViewState | undefined> {
diff --git a/polygerrit-ui/app/utils/admin-nav-util_test.ts b/polygerrit-ui/app/models/views/admin_test.ts
similarity index 88%
rename from polygerrit-ui/app/utils/admin-nav-util_test.ts
rename to polygerrit-ui/app/models/views/admin_test.ts
index a8600c7..5d142bf 100644
--- a/polygerrit-ui/app/utils/admin-nav-util_test.ts
+++ b/polygerrit-ui/app/models/views/admin_test.ts
@@ -1,14 +1,28 @@
 /**
  * @license
- * Copyright 2018 Google LLC
+ * Copyright 2022 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
 import {assert} from '@open-wc/testing';
-import {AccountDetailInfo, GroupId, RepoName, Timestamp} from '../api/rest-api';
-import '../test/common-test-setup';
-import {AdminNavLinksOption, getAdminLinks} from './admin-nav-util';
+import '../../test/common-test-setup';
+import {assertRouteFalse, assertRouteState} from '../../test/test-utils';
+import {GerritView} from '../../services/router/router-model';
+import {
+  AdminChildView,
+  AdminViewState,
+  createAdminUrl,
+  PLUGIN_LIST_ROUTE,
+  AdminNavLinksOption,
+  getAdminLinks,
+} from './admin';
+import {
+  AccountDetailInfo,
+  GroupId,
+  RepoName,
+  Timestamp,
+} from '../../api/rest-api';
 
-suite('gr-admin-nav-behavior tests', () => {
+suite('admin links', () => {
   let capabilityStub: sinon.SinonStub;
   let menuLinkStub: sinon.SinonStub;
 
@@ -63,7 +77,7 @@
         const linkMatch = res.links.find(
           l => l.url === link.url && l.name === link.text
         );
-        assert.isTrue(!!linkMatch);
+        assert.isOk(linkMatch);
 
         // External links should open in new tab.
         if (link.url[0] !== '/') {
@@ -332,3 +346,25 @@
     });
   });
 });
+
+suite('admin view model', () => {
+  suite('routes', () => {
+    test('PLUGIN_LIST', () => {
+      assertRouteFalse(PLUGIN_LIST_ROUTE, 'admin/plugins');
+      assertRouteFalse(PLUGIN_LIST_ROUTE, '//admin/plugins');
+      assertRouteFalse(PLUGIN_LIST_ROUTE, '//admin/plugins?');
+      assertRouteFalse(PLUGIN_LIST_ROUTE, '/admin/plugins//');
+
+      const state: AdminViewState = {
+        view: GerritView.ADMIN,
+        adminView: AdminChildView.PLUGINS,
+      };
+      assertRouteState<AdminViewState>(
+        PLUGIN_LIST_ROUTE,
+        '/admin/plugins',
+        state,
+        createAdminUrl
+      );
+    });
+  });
+});
diff --git a/polygerrit-ui/app/models/views/base.ts b/polygerrit-ui/app/models/views/base.ts
index 065495d..5c388f4 100644
--- a/polygerrit-ui/app/models/views/base.ts
+++ b/polygerrit-ui/app/models/views/base.ts
@@ -3,8 +3,18 @@
  * Copyright 2022 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import {PageContext} from '../../elements/core/gr-router/gr-page';
 import {GerritView} from '../../services/router/router-model';
 
 export interface ViewState {
   view: GerritView;
 }
+
+/**
+ * Based on `urlPattern` knows whether a URL matches and if so, then
+ * `createState()` can produce a `ViewState` from the matched URL.
+ */
+export interface Route<T extends ViewState> {
+  urlPattern: RegExp;
+  createState: (ctx: PageContext) => T;
+}
diff --git a/polygerrit-ui/app/models/views/change.ts b/polygerrit-ui/app/models/views/change.ts
index 100c46b..f003e3f 100644
--- a/polygerrit-ui/app/models/views/change.ts
+++ b/polygerrit-ui/app/models/views/change.ts
@@ -10,6 +10,7 @@
   BasePatchSetNum,
   ChangeInfo,
   PatchSetNumber,
+  EDIT,
 } from '../../api/rest-api';
 import {Tab} from '../../constants/constants';
 import {GerritView} from '../../services/router/router-model';
@@ -26,18 +27,31 @@
 import {Model} from '../model';
 import {ViewState} from './base';
 
+export enum ChangeChildView {
+  OVERVIEW = 'OVERVIEW',
+  DIFF = 'DIFF',
+  EDIT = 'EDIT',
+}
+
 export interface ChangeViewState extends ViewState {
   view: GerritView.CHANGE;
+  childView: ChangeChildView;
 
   changeNum: NumericChangeId;
-  project: RepoName;
-  edit?: boolean;
+  repo: RepoName;
   patchNum?: RevisionPatchSetNum;
   basePatchNum?: BasePatchSetNum;
+  /** Refers to comment on COMMENTS tab in OVERVIEW. */
   commentId?: UrlEncodedCommentId;
+
+  // TODO: Move properties that only apply to OVERVIEW into a submessage.
+
+  edit?: boolean;
   /** This can be a string only for plugin provided tabs. */
   tab?: Tab | string;
 
+  // TODO: Move properties that only apply to CHECKS tab into a submessage.
+
   /** Checks related view state */
 
   /** selected patchset for check runs (undefined=latest) */
@@ -55,12 +69,33 @@
 
   /** for scrolling a Change Log message into view in gr-change-view */
   messageHash?: string;
-  /** for logging where the user came from */
+  /**
+   * For logging where the user came from. This is handled by the router, so
+   * this is not inspected by the model.
+   */
   usp?: string;
-  /** triggers all change related data to be reloaded */
+  /**
+   * Triggers all change related data to be reloaded. This is implemented by
+   * intercepting change view state updates and `forceReload` causing the view
+   * state to be wiped clean as `undefined` in an intermediate update.
+   */
   forceReload?: boolean;
   /** triggers opening the reply dialog */
   openReplyDialog?: boolean;
+
+  /** These properties apply to the DIFF child view only. */
+  diffView?: {
+    path?: string;
+    // TODO: Use LineNumber as a type, i.e. accept FILE and LOST.
+    lineNum?: number;
+    leftSide?: boolean;
+  };
+
+  /** These properties apply to the EDIT child view only. */
+  editView?: {
+    path?: string;
+    lineNum?: number;
+  };
 }
 
 /**
@@ -70,7 +105,7 @@
  */
 export type CreateChangeUrlObject = Omit<
   ChangeViewState,
-  'view' | 'changeNum' | 'project'
+  'view' | 'childView' | 'changeNum' | 'repo'
 > & {
   change: Pick<ChangeInfo, '_number' | 'project'>;
 };
@@ -82,28 +117,41 @@
 }
 
 export function objToState(
-  obj: CreateChangeUrlObject | Omit<ChangeViewState, 'view'>
+  obj:
+    | (CreateChangeUrlObject & {childView: ChangeChildView})
+    | Omit<ChangeViewState, 'view'>
 ): ChangeViewState {
   if (isCreateChangeUrlObject(obj)) {
     return {
       ...obj,
       view: GerritView.CHANGE,
       changeNum: obj.change._number,
-      project: obj.change.project,
+      repo: obj.change.project,
     };
   }
   return {...obj, view: GerritView.CHANGE};
 }
 
-export function createChangeUrl(
-  obj: CreateChangeUrlObject | Omit<ChangeViewState, 'view'>
-) {
-  const state: ChangeViewState = objToState(obj);
-  let range = getPatchRangeExpression(state);
-  if (range.length) {
-    range = '/' + range;
+export function createChangeViewUrl(state: ChangeViewState): string {
+  switch (state.childView) {
+    case ChangeChildView.OVERVIEW:
+      return createChangeUrl(state);
+    case ChangeChildView.DIFF:
+      return createDiffUrl(state);
+    case ChangeChildView.EDIT:
+      return createEditUrl(state);
   }
-  let suffix = `${range}`;
+}
+
+export function createChangeUrl(
+  obj: CreateChangeUrlObject | Omit<ChangeViewState, 'view' | 'childView'>
+) {
+  const state: ChangeViewState = objToState({
+    ...obj,
+    childView: ChangeChildView.OVERVIEW,
+  });
+
+  let suffix = '';
   const queries = [];
   if (state.checksPatchset && state.checksPatchset > 0) {
     queries.push(`checksPatchset=${state.checksPatchset}`);
@@ -136,7 +184,7 @@
     suffix += ',edit';
   }
   if (state.commentId) {
-    suffix = suffix + `/comments/${state.commentId}`;
+    suffix += `/comments/${state.commentId}`;
   }
   if (queries.length > 0) {
     suffix += '?' + queries.join('&');
@@ -144,19 +192,118 @@
   if (state.messageHash) {
     suffix += state.messageHash;
   }
-  if (state.project) {
-    const encodedProject = encodeURL(state.project, true);
-    return `${getBaseUrl()}/c/${encodedProject}/+/${state.changeNum}${suffix}`;
-  } else {
-    return `${getBaseUrl()}/c/${state.changeNum}${suffix}`;
+
+  return `${createChangeUrlCommon(state)}${suffix}`;
+}
+
+export function createDiffUrl(
+  obj: CreateChangeUrlObject | Omit<ChangeViewState, 'view' | 'childView'>
+) {
+  const state: ChangeViewState = objToState({
+    ...obj,
+    childView: ChangeChildView.DIFF,
+  });
+
+  const path = `/${encodeURL(state.diffView?.path ?? '')}`;
+
+  let suffix = '';
+  // TODO: Move creating of comment URLs to a separate function. We are
+  // "abusing" the `commentId` property, which should only be used for pointing
+  // to comment in the COMMENTS tab of the OVERVIEW page.
+  if (state.commentId) {
+    suffix += `comment/${state.commentId}/`;
   }
+
+  if (state.diffView?.lineNum) {
+    suffix += '#';
+    if (state.diffView?.leftSide) {
+      suffix += 'b';
+    }
+    suffix += state.diffView.lineNum;
+  }
+
+  return `${createChangeUrlCommon(state)}${path}${suffix}`;
+}
+
+export function createEditUrl(
+  obj: Omit<ChangeViewState, 'view' | 'childView'>
+): string {
+  const state: ChangeViewState = objToState({
+    ...obj,
+    childView: ChangeChildView.DIFF,
+    patchNum: obj.patchNum ?? EDIT,
+  });
+
+  const path = `/${encodeURL(state.editView?.path ?? '')}`;
+  const line = state.editView?.lineNum;
+  const suffix = line ? `#${line}` : '';
+
+  return `${createChangeUrlCommon(state)}${path},edit${suffix}`;
+}
+
+/**
+ * The shared part of creating a change URL between OVERVIEW, DIFF and EDIT
+ * child views.
+ */
+function createChangeUrlCommon(state: ChangeViewState) {
+  let range = getPatchRangeExpression(state);
+  if (range.length) range = '/' + range;
+
+  let repo = '';
+  if (state.repo) repo = `${encodeURL(state.repo)}/+/`;
+
+  return `${getBaseUrl()}/c/${repo}${state.changeNum}${range}`;
 }
 
 export const changeViewModelToken =
   define<ChangeViewModel>('change-view-model');
 
 export class ChangeViewModel extends Model<ChangeViewState | undefined> {
-  public readonly tab$ = select(this.state$, state => state?.tab);
+  public readonly changeNum$ = select(this.state$, state => state?.changeNum);
+
+  public readonly patchNum$ = select(this.state$, state => state?.patchNum);
+
+  public readonly basePatchNum$ = select(
+    this.state$,
+    state => state?.basePatchNum
+  );
+
+  public readonly openReplyDialog$ = select(
+    this.state$,
+    state => state?.openReplyDialog
+  );
+
+  public readonly commentId$ = select(this.state$, state => state?.commentId);
+
+  public readonly edit$ = select(this.state$, state => !!state?.edit);
+
+  public readonly editPath$ = select(
+    this.state$,
+    state => state?.editView?.path
+  );
+
+  public readonly diffPath$ = select(
+    this.state$,
+    state => state?.diffView?.path
+  );
+
+  public readonly diffLine$ = select(
+    this.state$,
+    state => state?.diffView?.lineNum
+  );
+
+  public readonly diffLeftSide$ = select(
+    this.state$,
+    state => state?.diffView?.leftSide ?? false
+  );
+
+  public readonly childView$ = select(this.state$, state => state?.childView);
+
+  public readonly tab$ = select(this.state$, state => {
+    if (state?.tab) return state.tab;
+    if (state?.commentId) return Tab.COMMENT_THREADS;
+    return Tab.FILES;
+  });
 
   public readonly checksPatchset$ = select(
     this.state$,
@@ -188,6 +335,39 @@
         });
       }
     });
+    document.addEventListener('reload', this.reload);
+  }
+
+  override finalize(): void {
+    document.removeEventListener('reload', this.reload);
+  }
+
+  /**
+   * Calling this is the same as firing the 'reload' event. This is also the
+   * same as adding `forceReload` parameter in the URL. See below.
+   */
+  reload = () => {
+    const state = this.getState();
+    if (state !== undefined) this.forceLoad(state);
+  };
+
+  /**
+   * This is the destination of where the `reload()` method, the `reload` event
+   * and the `forceReload` URL parameter all end up.
+   */
+  private forceLoad(state: ChangeViewState) {
+    this.setState(undefined);
+    // We have to do this in a timeout, because we need the `undefined` value to
+    // be processed by all observers first and thus have the "reset" completed.
+    setTimeout(() => this.setState({...state, forceReload: undefined}));
+  }
+
+  override setState(state: ChangeViewState | undefined): void {
+    if (state?.forceReload) {
+      this.forceLoad(state);
+    } else {
+      super.setState(state);
+    }
   }
 
   toggleSelectedCheckRun(checkName: string) {
diff --git a/polygerrit-ui/app/models/views/change_test.ts b/polygerrit-ui/app/models/views/change_test.ts
index 24ced82..837e362 100644
--- a/polygerrit-ui/app/models/views/change_test.ts
+++ b/polygerrit-ui/app/models/views/change_test.ts
@@ -6,73 +6,145 @@
 import {assert} from '@open-wc/testing';
 import {
   BasePatchSetNum,
-  NumericChangeId,
   RepoName,
   RevisionPatchSetNum,
 } from '../../api/rest-api';
-import {GerritView} from '../../services/router/router-model';
 import '../../test/common-test-setup';
-import {createChangeUrl, ChangeViewState} from './change';
-
-const STATE: ChangeViewState = {
-  view: GerritView.CHANGE,
-  changeNum: 1234 as NumericChangeId,
-  project: 'test' as RepoName,
-};
+import {
+  createChangeViewState,
+  createDiffViewState,
+  createEditViewState,
+} from '../../test/test-data-generators';
+import {
+  createChangeUrl,
+  createDiffUrl,
+  createEditUrl,
+  ChangeViewState,
+} from './change';
 
 suite('change view state tests', () => {
   test('createChangeUrl()', () => {
-    const state: ChangeViewState = {...STATE};
+    const state: ChangeViewState = createChangeViewState();
 
-    assert.equal(createChangeUrl(state), '/c/test/+/1234');
+    assert.equal(createChangeUrl(state), '/c/test-project/+/42');
 
     state.patchNum = 10 as RevisionPatchSetNum;
-    assert.equal(createChangeUrl(state), '/c/test/+/1234/10');
+    assert.equal(createChangeUrl(state), '/c/test-project/+/42/10');
 
     state.basePatchNum = 5 as BasePatchSetNum;
-    assert.equal(createChangeUrl(state), '/c/test/+/1234/5..10');
+    assert.equal(createChangeUrl(state), '/c/test-project/+/42/5..10');
 
     state.messageHash = '#123';
-    assert.equal(createChangeUrl(state), '/c/test/+/1234/5..10#123');
+    assert.equal(createChangeUrl(state), '/c/test-project/+/42/5..10#123');
   });
 
   test('createChangeUrl() baseUrl', () => {
     window.CANONICAL_PATH = '/base';
-    const state: ChangeViewState = {...STATE};
+    const state: ChangeViewState = createChangeViewState();
     assert.equal(createChangeUrl(state).substring(0, 5), '/base');
     window.CANONICAL_PATH = undefined;
   });
 
   test('createChangeUrl() checksRunsSelected', () => {
     const state: ChangeViewState = {
-      ...STATE,
+      ...createChangeViewState(),
       checksRunsSelected: new Set(['asdf']),
     };
 
     assert.equal(
       createChangeUrl(state),
-      '/c/test/+/1234?checksRunsSelected=asdf'
+      '/c/test-project/+/42?checksRunsSelected=asdf'
     );
   });
 
   test('createChangeUrl() checksResultsFilter', () => {
     const state: ChangeViewState = {
-      ...STATE,
+      ...createChangeViewState(),
       checksResultsFilter: 'asdf.*qwer',
     };
 
     assert.equal(
       createChangeUrl(state),
-      '/c/test/+/1234?checksResultsFilter=asdf.*qwer'
+      '/c/test-project/+/42?checksResultsFilter=asdf.*qwer'
     );
   });
 
   test('createChangeUrl() with repo name encoding', () => {
     const state: ChangeViewState = {
-      view: GerritView.CHANGE,
-      changeNum: 1234 as NumericChangeId,
-      project: 'x+/y+/z+/w' as RepoName,
+      ...createChangeViewState(),
+      repo: 'x+/y+/z+/w' as RepoName,
     };
-    assert.equal(createChangeUrl(state), '/c/x%252B/y%252B/z%252B/w/+/1234');
+    assert.equal(createChangeUrl(state), '/c/x%252B/y%252B/z%252B/w/+/42');
+  });
+
+  test('createDiffUrl', () => {
+    const params: ChangeViewState = {
+      ...createDiffViewState(),
+      patchNum: 12 as RevisionPatchSetNum,
+      diffView: {path: 'x+y/path.cpp'},
+    };
+    assert.equal(
+      createDiffUrl(params),
+      '/c/test-project/+/42/12/x%252By/path.cpp'
+    );
+
+    window.CANONICAL_PATH = '/base';
+    assert.equal(createDiffUrl(params).substring(0, 5), '/base');
+    window.CANONICAL_PATH = undefined;
+
+    params.repo = 'test' as RepoName;
+    assert.equal(createDiffUrl(params), '/c/test/+/42/12/x%252By/path.cpp');
+
+    params.basePatchNum = 6 as BasePatchSetNum;
+    assert.equal(createDiffUrl(params), '/c/test/+/42/6..12/x%252By/path.cpp');
+
+    params.diffView = {
+      path: 'foo bar/my+file.txt%',
+    };
+    params.patchNum = 2 as RevisionPatchSetNum;
+    delete params.basePatchNum;
+    assert.equal(
+      createDiffUrl(params),
+      '/c/test/+/42/2/foo+bar/my%252Bfile.txt%2525'
+    );
+
+    params.diffView = {
+      path: 'file.cpp',
+      lineNum: 123,
+    };
+    assert.equal(createDiffUrl(params), '/c/test/+/42/2/file.cpp#123');
+
+    params.diffView = {
+      path: 'file.cpp',
+      lineNum: 123,
+      leftSide: true,
+    };
+    assert.equal(createDiffUrl(params), '/c/test/+/42/2/file.cpp#b123');
+  });
+
+  test('diff with repo name encoding', () => {
+    const params: ChangeViewState = {
+      ...createDiffViewState(),
+      patchNum: 12 as RevisionPatchSetNum,
+      repo: 'x+/y' as RepoName,
+      diffView: {path: 'x+y/path.cpp'},
+    };
+    assert.equal(createDiffUrl(params), '/c/x%252B/y/+/42/12/x%252By/path.cpp');
+  });
+
+  test('createEditUrl', () => {
+    const params: ChangeViewState = {
+      ...createEditViewState(),
+      patchNum: 12 as RevisionPatchSetNum,
+      editView: {path: 'x+y/path.cpp' as RepoName, lineNum: 31},
+    };
+    assert.equal(
+      createEditUrl(params),
+      '/c/test-project/+/42/12/x%252By/path.cpp,edit#31'
+    );
+
+    window.CANONICAL_PATH = '/base';
+    assert.equal(createEditUrl(params).substring(0, 5), '/base');
+    window.CANONICAL_PATH = undefined;
   });
 });
diff --git a/polygerrit-ui/app/models/views/dashboard.ts b/polygerrit-ui/app/models/views/dashboard.ts
index d9ff2d2..d2e7995 100644
--- a/polygerrit-ui/app/models/views/dashboard.ts
+++ b/polygerrit-ui/app/models/views/dashboard.ts
@@ -10,7 +10,21 @@
 import {encodeURL, getBaseUrl} from '../../utils/url-util';
 import {define} from '../dependency';
 import {Model} from '../model';
-import {ViewState} from './base';
+import {Route, ViewState} from './base';
+
+export const PROJECT_DASHBOARD_ROUTE: Route<DashboardViewState> = {
+  urlPattern: /^\/p\/(.+)\/\+\/dashboard\/(.+)/,
+  createState: ctx => {
+    const project = (ctx.params[0] ?? '') as RepoName;
+    const dashboard = (ctx.params[1] ?? '') as DashboardId;
+    const state: DashboardViewState = {
+      view: GerritView.DASHBOARD,
+      project,
+      dashboard,
+    };
+    return state;
+  },
+};
 
 export interface DashboardViewState extends ViewState {
   view: GerritView.DASHBOARD;
@@ -33,7 +47,7 @@
     const query = repoName
       ? section.query.replace(REPO_TOKEN_PATTERN, repoName)
       : section.query;
-    return encodeURIComponent(section.name) + '=' + encodeURIComponent(query);
+    return encodeURL(section.name) + '=' + encodeURL(query);
   });
 }
 
@@ -43,13 +57,13 @@
     // Custom dashboard.
     const queryParams = sectionsToEncodedParams(state.sections, repoName);
     if (state.title) {
-      queryParams.push('title=' + encodeURIComponent(state.title));
+      queryParams.push('title=' + encodeURL(state.title));
     }
     const user = state.user ? state.user : '';
     return `${getBaseUrl()}/dashboard/${user}?${queryParams.join('&')}`;
   } else if (repoName) {
     // Project dashboard.
-    const encodedRepo = encodeURL(repoName, true);
+    const encodedRepo = encodeURL(repoName);
     return `${getBaseUrl()}/p/${encodedRepo}/+/dashboard/${state.dashboard}`;
   } else {
     // User dashboard.
diff --git a/polygerrit-ui/app/models/views/dashboard_test.ts b/polygerrit-ui/app/models/views/dashboard_test.ts
index 86bb5c0..9509977 100644
--- a/polygerrit-ui/app/models/views/dashboard_test.ts
+++ b/polygerrit-ui/app/models/views/dashboard_test.ts
@@ -5,11 +5,36 @@
  */
 import {assert} from '@open-wc/testing';
 import {RepoName} from '../../api/rest-api';
+import {GerritView} from '../../services/router/router-model';
 import '../../test/common-test-setup';
+import {assertRouteFalse, assertRouteState} from '../../test/test-utils';
 import {DashboardId} from '../../types/common';
-import {createDashboardUrl} from './dashboard';
+import {
+  createDashboardUrl,
+  DashboardViewState,
+  PROJECT_DASHBOARD_ROUTE,
+} from './dashboard';
 
 suite('dashboard view state tests', () => {
+  suite('routes', () => {
+    test('PROJECT_DASHBOARD_ROUTE', () => {
+      assertRouteFalse(PROJECT_DASHBOARD_ROUTE, '/p//+/dashboard/qwer');
+      assertRouteFalse(PROJECT_DASHBOARD_ROUTE, '/p/asdf/+/dashboard/');
+
+      const state: DashboardViewState = {
+        view: GerritView.DASHBOARD,
+        project: 'asdf' as RepoName,
+        dashboard: 'qwer' as DashboardId,
+      };
+      assertRouteState(
+        PROJECT_DASHBOARD_ROUTE,
+        '/p/asdf/+/dashboard/qwer',
+        state,
+        createDashboardUrl
+      );
+    });
+  });
+
   suite('createDashboardUrl()', () => {
     test('self dashboard', () => {
       assert.equal(createDashboardUrl({}), '/dashboard/self');
@@ -34,7 +59,7 @@
       };
       assert.equal(
         createDashboardUrl(state),
-        '/dashboard/?section%201=query%201&section%202=query%202'
+        '/dashboard/?section+1=query+1&section+2=query+2'
       );
     });
 
@@ -48,8 +73,8 @@
       };
       assert.equal(
         createDashboardUrl(state),
-        '/dashboard/?section%201=query%201%20repo-name&' +
-          'section%202=query%202%20repo-name'
+        '/dashboard/?section+1=query+1+repo-name&' +
+          'section+2=query+2+repo-name'
       );
     });
 
@@ -61,7 +86,7 @@
       };
       assert.equal(
         createDashboardUrl(state),
-        '/dashboard/user?name=query&title=custom%20dashboard'
+        '/dashboard/user?name=query&title=custom+dashboard'
       );
     });
 
diff --git a/polygerrit-ui/app/models/views/diff.ts b/polygerrit-ui/app/models/views/diff.ts
deleted file mode 100644
index 3cc107a..0000000
--- a/polygerrit-ui/app/models/views/diff.ts
+++ /dev/null
@@ -1,104 +0,0 @@
-/**
- * @license
- * Copyright 2022 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-import {
-  NumericChangeId,
-  RepoName,
-  RevisionPatchSetNum,
-  BasePatchSetNum,
-  ChangeInfo,
-} from '../../api/rest-api';
-import {GerritView} from '../../services/router/router-model';
-import {UrlEncodedCommentId} from '../../types/common';
-import {
-  encodeURL,
-  getBaseUrl,
-  getPatchRangeExpression,
-} from '../../utils/url-util';
-import {define} from '../dependency';
-import {Model} from '../model';
-import {ViewState} from './base';
-
-export interface DiffViewState extends ViewState {
-  view: GerritView.DIFF;
-  changeNum: NumericChangeId;
-  project?: RepoName;
-  commentId?: UrlEncodedCommentId;
-  path?: string;
-  patchNum?: RevisionPatchSetNum;
-  basePatchNum?: BasePatchSetNum;
-  lineNum?: number;
-  leftSide?: boolean;
-  commentLink?: boolean;
-}
-
-/**
- * This is a convenience type such that you can pass a `ChangeInfo` object
- * as the `change` property instead of having to set both the `changeNum` and
- * `project` properties explicitly.
- */
-export type CreateChangeUrlObject = Omit<
-  DiffViewState,
-  'view' | 'changeNum' | 'project'
-> & {
-  change: Pick<ChangeInfo, '_number' | 'project'>;
-};
-
-export function isCreateChangeUrlObject(
-  state: CreateChangeUrlObject | Omit<DiffViewState, 'view'>
-): state is CreateChangeUrlObject {
-  return !!(state as CreateChangeUrlObject).change;
-}
-
-export function objToState(
-  obj: CreateChangeUrlObject | Omit<DiffViewState, 'view'>
-): DiffViewState {
-  if (isCreateChangeUrlObject(obj)) {
-    return {
-      ...obj,
-      view: GerritView.DIFF,
-      changeNum: obj.change._number,
-      project: obj.change.project,
-    };
-  }
-  return {...obj, view: GerritView.DIFF};
-}
-
-export function createDiffUrl(
-  obj: CreateChangeUrlObject | Omit<DiffViewState, 'view'>
-) {
-  const state: DiffViewState = objToState(obj);
-  let range = getPatchRangeExpression(state);
-  if (range.length) range = '/' + range;
-
-  let suffix = `${range}/${encodeURL(state.path || '', true)}`;
-
-  if (state.lineNum) {
-    suffix += '#';
-    if (state.leftSide) {
-      suffix += 'b';
-    }
-    suffix += state.lineNum;
-  }
-
-  if (state.commentId) {
-    suffix = `/comment/${state.commentId}` + suffix;
-  }
-
-  if (state.project) {
-    const encodedProject = encodeURL(state.project, true);
-    return `${getBaseUrl()}/c/${encodedProject}/+/${state.changeNum}${suffix}`;
-  } else {
-    return `${getBaseUrl()}/c/${state.changeNum}${suffix}`;
-  }
-}
-
-export const diffViewModelToken = define<DiffViewModel>('diff-view-model');
-
-export class DiffViewModel extends Model<DiffViewState | undefined> {
-  constructor() {
-    super(undefined);
-  }
-}
diff --git a/polygerrit-ui/app/models/views/diff_test.ts b/polygerrit-ui/app/models/views/diff_test.ts
deleted file mode 100644
index b0f91bb..0000000
--- a/polygerrit-ui/app/models/views/diff_test.ts
+++ /dev/null
@@ -1,64 +0,0 @@
-/**
- * @license
- * Copyright 2022 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-import {assert} from '@open-wc/testing';
-import {
-  BasePatchSetNum,
-  NumericChangeId,
-  RepoName,
-  RevisionPatchSetNum,
-} from '../../api/rest-api';
-import {GerritView} from '../../services/router/router-model';
-import '../../test/common-test-setup';
-import {createDiffUrl, DiffViewState} from './diff';
-
-suite('diff view state tests', () => {
-  test('createDiffUrl', () => {
-    const params: DiffViewState = {
-      view: GerritView.DIFF,
-      changeNum: 42 as NumericChangeId,
-      path: 'x+y/path.cpp' as RepoName,
-      patchNum: 12 as RevisionPatchSetNum,
-      project: '' as RepoName,
-    };
-    assert.equal(createDiffUrl(params), '/c/42/12/x%252By/path.cpp');
-
-    window.CANONICAL_PATH = '/base';
-    assert.equal(createDiffUrl(params).substring(0, 5), '/base');
-    window.CANONICAL_PATH = undefined;
-
-    params.project = 'test' as RepoName;
-    assert.equal(createDiffUrl(params), '/c/test/+/42/12/x%252By/path.cpp');
-
-    params.basePatchNum = 6 as BasePatchSetNum;
-    assert.equal(createDiffUrl(params), '/c/test/+/42/6..12/x%252By/path.cpp');
-
-    params.path = 'foo bar/my+file.txt%';
-    params.patchNum = 2 as RevisionPatchSetNum;
-    delete params.basePatchNum;
-    assert.equal(
-      createDiffUrl(params),
-      '/c/test/+/42/2/foo+bar/my%252Bfile.txt%2525'
-    );
-
-    params.path = 'file.cpp';
-    params.lineNum = 123;
-    assert.equal(createDiffUrl(params), '/c/test/+/42/2/file.cpp#123');
-
-    params.leftSide = true;
-    assert.equal(createDiffUrl(params), '/c/test/+/42/2/file.cpp#b123');
-  });
-
-  test('diff with repo name encoding', () => {
-    const params: DiffViewState = {
-      view: GerritView.DIFF,
-      changeNum: 42 as NumericChangeId,
-      path: 'x+y/path.cpp',
-      patchNum: 12 as RevisionPatchSetNum,
-      project: 'x+/y' as RepoName,
-    };
-    assert.equal(createDiffUrl(params), '/c/x%252B/y/+/42/12/x%252By/path.cpp');
-  });
-});
diff --git a/polygerrit-ui/app/models/views/documentation.ts b/polygerrit-ui/app/models/views/documentation.ts
index b564d64..abb0f03 100644
--- a/polygerrit-ui/app/models/views/documentation.ts
+++ b/polygerrit-ui/app/models/views/documentation.ts
@@ -4,13 +4,18 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import {GerritView} from '../../services/router/router-model';
+import {getBaseUrl} from '../../utils/url-util';
 import {define} from '../dependency';
 import {Model} from '../model';
 import {ViewState} from './base';
 
 export interface DocumentationViewState extends ViewState {
   view: GerritView.DOCUMENTATION_SEARCH;
-  filter?: string | null;
+  filter: string;
+}
+
+export function createDocumentationUrl() {
+  return `${getBaseUrl()}/Documentation`;
 }
 
 export const documentationViewModelToken = define<DocumentationViewModel>(
diff --git a/polygerrit-ui/app/models/views/edit.ts b/polygerrit-ui/app/models/views/edit.ts
deleted file mode 100644
index c63c8ce..0000000
--- a/polygerrit-ui/app/models/views/edit.ts
+++ /dev/null
@@ -1,60 +0,0 @@
-/**
- * @license
- * Copyright 2022 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-import {
-  EDIT,
-  NumericChangeId,
-  RepoName,
-  RevisionPatchSetNum,
-} from '../../api/rest-api';
-import {GerritView} from '../../services/router/router-model';
-import {
-  encodeURL,
-  getBaseUrl,
-  getPatchRangeExpression,
-} from '../../utils/url-util';
-import {define} from '../dependency';
-import {Model} from '../model';
-import {ViewState} from './base';
-
-export interface EditViewState extends ViewState {
-  view: GerritView.EDIT;
-  changeNum: NumericChangeId;
-  project: RepoName;
-  path: string;
-  patchNum: RevisionPatchSetNum;
-  lineNum?: number;
-}
-
-export function createEditUrl(state: Omit<EditViewState, 'view'>): string {
-  if (state.patchNum === undefined) {
-    state = {...state, patchNum: EDIT};
-  }
-  let range = getPatchRangeExpression(state);
-  if (range.length) range = '/' + range;
-
-  let suffix = `${range}/${encodeURL(state.path || '', true)}`;
-  suffix += ',edit';
-
-  if (state.lineNum) {
-    suffix += '#';
-    suffix += state.lineNum;
-  }
-
-  if (state.project) {
-    const encodedProject = encodeURL(state.project, true);
-    return `${getBaseUrl()}/c/${encodedProject}/+/${state.changeNum}${suffix}`;
-  } else {
-    return `${getBaseUrl()}/c/${state.changeNum}${suffix}`;
-  }
-}
-
-export const editViewModelToken = define<EditViewModel>('edit-view-model');
-
-export class EditViewModel extends Model<EditViewState | undefined> {
-  constructor() {
-    super(undefined);
-  }
-}
diff --git a/polygerrit-ui/app/models/views/edit_test.ts b/polygerrit-ui/app/models/views/edit_test.ts
deleted file mode 100644
index 2912063..0000000
--- a/polygerrit-ui/app/models/views/edit_test.ts
+++ /dev/null
@@ -1,35 +0,0 @@
-/**
- * @license
- * Copyright 2022 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-import {assert} from '@open-wc/testing';
-import {
-  NumericChangeId,
-  RepoName,
-  RevisionPatchSetNum,
-} from '../../api/rest-api';
-import {GerritView} from '../../services/router/router-model';
-import '../../test/common-test-setup';
-import {createEditUrl, EditViewState} from './edit';
-
-suite('edit view state tests', () => {
-  test('createEditUrl', () => {
-    const params: EditViewState = {
-      view: GerritView.EDIT,
-      changeNum: 42 as NumericChangeId,
-      project: 'test-project' as RepoName,
-      path: 'x+y/path.cpp' as RepoName,
-      patchNum: 12 as RevisionPatchSetNum,
-      lineNum: 31,
-    };
-    assert.equal(
-      createEditUrl(params),
-      '/c/test-project/+/42/12/x%252By/path.cpp,edit#31'
-    );
-
-    window.CANONICAL_PATH = '/base';
-    assert.equal(createEditUrl(params).substring(0, 5), '/base');
-    window.CANONICAL_PATH = undefined;
-  });
-});
diff --git a/polygerrit-ui/app/models/views/group.ts b/polygerrit-ui/app/models/views/group.ts
index 277bcff..f4a7c78 100644
--- a/polygerrit-ui/app/models/views/group.ts
+++ b/polygerrit-ui/app/models/views/group.ts
@@ -17,12 +17,16 @@
 
 export interface GroupViewState extends ViewState {
   view: GerritView.GROUP;
+  /**
+   * This refers to the (string) `id` of `GroupInfo`, not the `groupId`, which
+   * is a number.
+   */
   groupId: GroupId;
   detail?: GroupDetailView;
 }
 
 export function createGroupUrl(state: Omit<GroupViewState, 'view'>) {
-  let url = `/admin/groups/${encodeURL(`${state.groupId}`, true)}`;
+  let url = `/admin/groups/${encodeURL(`${state.groupId}`)}`;
   if (state.detail === GroupDetailView.MEMBERS) {
     url += ',members';
   } else if (state.detail === GroupDetailView.LOG) {
diff --git a/polygerrit-ui/app/models/views/repo.ts b/polygerrit-ui/app/models/views/repo.ts
index 02fd17d..66bf5bf 100644
--- a/polygerrit-ui/app/models/views/repo.ts
+++ b/polygerrit-ui/app/models/views/repo.ts
@@ -4,7 +4,7 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import {GerritView} from '../../services/router/router-model';
-import {RepoName} from '../../types/common';
+import {BranchName, RepoName} from '../../types/common';
 import {encodeURL, getBaseUrl} from '../../utils/url-util';
 import {define} from '../dependency';
 import {Model} from '../model';
@@ -25,10 +25,18 @@
   repo?: RepoName;
   filter?: string | null;
   offset?: number | string;
+  /**
+   * This is for creating a change from the URL and then redirecting to a file
+   * editing page.
+   */
+  createEdit?: {
+    branch: BranchName;
+    path: string;
+  };
 }
 
 export function createRepoUrl(state: Omit<RepoViewState, 'view'>) {
-  let url = `/admin/repos/${encodeURL(`${state.repo}`, true)}`;
+  let url = `/admin/repos/${encodeURL(`${state.repo}`)}`;
   if (state.detail === RepoDetailView.GENERAL) {
     url += ',general';
   } else if (state.detail === RepoDetailView.ACCESS) {
diff --git a/polygerrit-ui/app/models/views/search.ts b/polygerrit-ui/app/models/views/search.ts
index 78f2d8f..2edc540 100644
--- a/polygerrit-ui/app/models/views/search.ts
+++ b/polygerrit-ui/app/models/views/search.ts
@@ -16,8 +16,9 @@
 import {NavigationService} from '../../elements/core/gr-navigation/gr-navigation';
 import {RestApiService} from '../../services/gr-rest-api/gr-rest-api';
 import {GerritView} from '../../services/router/router-model';
+import {accountKey} from '../../utils/account-util';
 import {select} from '../../utils/observable-util';
-import {addQuotesWhen} from '../../utils/string-util';
+import {escapeAndWrapSearchOperatorValue} from '../../utils/string-util';
 import {encodeURL, getBaseUrl} from '../../utils/url-util';
 import {define, Provider} from '../dependency';
 import {Model} from '../model';
@@ -64,14 +65,19 @@
 
   /**
    * The search results for the current query.
+   * `undefined` must be allowed here, because updating state with a partial
+   * state without `changes` must be possible without overwriting existing
+   * changes.
+   * TODO: We should consider moving `changes` to a another model. This is not
+   * really "view" state. View state must directly correlate to the URL.
    */
-  changes: ChangeInfo[];
+  changes?: ChangeInfo[];
 }
 
 export interface SearchUrlOptions {
   query?: string;
   offset?: number;
-  project?: RepoName;
+  repo?: RepoName;
   branch?: BranchName;
   topic?: TopicName;
   statuses?: string[];
@@ -86,46 +92,39 @@
   }
 
   if (params.query) {
-    return `${getBaseUrl()}/q/${encodeURL(params.query, true)}${offsetExpr}`;
+    return `${getBaseUrl()}/q/${encodeURL(params.query)}${offsetExpr}`;
   }
 
   const operators: string[] = [];
   if (params.owner) {
-    operators.push('owner:' + encodeURL(params.owner, false));
+    operators.push('owner:' + encodeURL(params.owner));
   }
-  if (params.project) {
-    operators.push('project:' + encodeURL(params.project, false));
+  if (params.repo) {
+    operators.push('project:' + encodeURL(params.repo));
   }
   if (params.branch) {
-    operators.push('branch:' + encodeURL(params.branch, false));
+    operators.push('branch:' + encodeURL(params.branch));
   }
   if (params.topic) {
     operators.push(
-      'topic:' +
-        addQuotesWhen(
-          encodeURL(params.topic, false),
-          /[\s:]/.test(params.topic)
-        )
+      'topic:' + escapeAndWrapSearchOperatorValue(encodeURL(params.topic))
     );
   }
   if (params.hashtag) {
     operators.push(
       'hashtag:' +
-        addQuotesWhen(
-          encodeURL(params.hashtag.toLowerCase(), false),
-          /[\s:]/.test(params.hashtag)
+        escapeAndWrapSearchOperatorValue(
+          encodeURL(params.hashtag.toLowerCase())
         )
     );
   }
   if (params.statuses) {
     if (params.statuses.length === 1) {
-      operators.push('status:' + encodeURL(params.statuses[0], false));
+      operators.push('status:' + encodeURL(params.statuses[0]));
     } else if (params.statuses.length > 1) {
       operators.push(
         '(' +
-          params.statuses
-            .map(s => `status:${encodeURL(s, false)}`)
-            .join(' OR ') +
+          params.statuses.map(s => `status:${encodeURL(s)}`).join(' OR ') +
           ')'
       );
     }
@@ -170,8 +169,11 @@
     ([query, changes]) => {
       if (changes.length === 0) return undefined;
       if (!USER_QUERY_PATTERN.test(query)) return undefined;
-      const owner = changes[0].owner;
-      return owner?._account_id ?? owner?.email;
+      const ownerKey = accountKey(changes[0].owner);
+      if (changes.some(change => accountKey(change.owner) !== ownerKey)) {
+        return undefined;
+      }
+      return ownerKey;
     }
   );
 
diff --git a/polygerrit-ui/app/models/views/search_test.ts b/polygerrit-ui/app/models/views/search_test.ts
index 9017f2e..ed6de419 100644
--- a/polygerrit-ui/app/models/views/search_test.ts
+++ b/polygerrit-ui/app/models/views/search_test.ts
@@ -4,7 +4,7 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import {assert} from '@open-wc/testing';
-import {SinonStub} from 'sinon';
+import {SinonStubbedMember} from 'sinon';
 import {
   AccountId,
   BranchName,
@@ -13,7 +13,10 @@
   RepoName,
   TopicName,
 } from '../../api/rest-api';
-import {navigationToken} from '../../elements/core/gr-navigation/gr-navigation';
+import {
+  NavigationService,
+  navigationToken,
+} from '../../elements/core/gr-navigation/gr-navigation';
 import '../../test/common-test-setup';
 import {testResolver} from '../../test/common-test-setup';
 import {createChange} from '../../test/test-data-generators';
@@ -31,7 +34,7 @@
   test('createSearchUrl', () => {
     let options: SearchUrlOptions = {
       owner: 'a%b',
-      project: 'c%d' as RepoName,
+      repo: 'c%d' as RepoName,
       branch: 'e%f' as BranchName,
       topic: 'g%h' as TopicName,
       statuses: ['op%en'],
@@ -39,7 +42,7 @@
     assert.equal(
       createSearchUrl(options),
       '/q/owner:a%2525b+project:c%2525d+branch:e%2525f+' +
-        'topic:g%2525h+status:op%2525en'
+        'topic:"g%2525h"+status:op%2525en'
     );
 
     window.CANONICAL_PATH = '/base';
@@ -50,16 +53,16 @@
     assert.equal(
       createSearchUrl(options),
       '/q/owner:a%2525b+project:c%2525d+branch:e%2525f+' +
-        'topic:g%2525h+status:op%2525en,100'
+        'topic:"g%2525h"+status:op%2525en,100'
     );
     delete options.offset;
 
     // The presence of the query param overrides other options.
     options.query = 'foo$bar';
-    assert.equal(createSearchUrl(options), '/q/foo%2524bar');
+    assert.equal(createSearchUrl(options), '/q/foo%24bar');
 
     options.offset = 100;
-    assert.equal(createSearchUrl(options), '/q/foo%2524bar,100');
+    assert.equal(createSearchUrl(options), '/q/foo%24bar,100');
 
     options = {statuses: ['a', 'b', 'c']};
     assert.equal(
@@ -68,7 +71,7 @@
     );
 
     options = {topic: 'test' as TopicName};
-    assert.equal(createSearchUrl(options), '/q/topic:test');
+    assert.equal(createSearchUrl(options), '/q/topic:"test"');
 
     options = {topic: 'test test' as TopicName};
     assert.equal(createSearchUrl(options), '/q/topic:"test+test"');
@@ -78,7 +81,7 @@
   });
 
   suite('query based navigation', () => {
-    let replaceUrlStub: SinonStub;
+    let replaceUrlStub: SinonStubbedMember<NavigationService['replaceUrl']>;
     let model: SearchViewModel;
 
     setup(() => {
@@ -151,14 +154,27 @@
     test('userId', async () => {
       assert.isUndefined(userId);
 
+      // userId set when all owners are the same
       model.updateState({
-        query: 'owner: foo@bar',
+        query: 'owner:foo',
         changes: [
           {...createChange(), owner: {email: 'foo@bar' as EmailAddress}},
+          {...createChange(), owner: {email: 'foo@bar' as EmailAddress}},
         ],
       });
       assert.equal(userId, 'foo@bar' as EmailAddress);
 
+      // userId not set when multiple owners exist
+      model.updateState({
+        query: 'owner:foo',
+        changes: [
+          {...createChange(), owner: {email: 'foo@bar' as EmailAddress}},
+          {...createChange(), owner: {email: 'foo@foo' as EmailAddress}},
+        ],
+      });
+      assert.isUndefined(userId);
+
+      // userId not set when query is not about owner
       model.updateState({
         query: 'foo bar baz',
         changes: [
@@ -166,12 +182,6 @@
         ],
       });
       assert.isUndefined(userId);
-
-      model.updateState({
-        query: 'owner: foo@bar',
-        changes: [{...createChange(), owner: {}}],
-      });
-      assert.isUndefined(userId);
     });
 
     test('repo', async () => {
diff --git a/polygerrit-ui/app/node_modules_licenses/licenses.ts b/polygerrit-ui/app/node_modules_licenses/licenses.ts
index cbb582b..126fd1f 100644
--- a/polygerrit-ui/app/node_modules_licenses/licenses.ts
+++ b/polygerrit-ui/app/node_modules_licenses/licenses.ts
@@ -329,14 +329,6 @@
     license: SharedLicenses.Polymer2018,
   },
   {
-    name: 'codemirror-minified',
-    license: {
-      name: 'codemirror-minified',
-      type: LicenseTypes.Mit,
-      packageLicenseFile: 'LICENSE',
-    },
-  },
-  {
     name: 'isarray',
     license: SharedLicenses.IsArray,
   },
@@ -369,6 +361,10 @@
     license: SharedLicenses.Polymer2018,
   },
   {
+    name: 'polygerrit-gr-page',
+    license: SharedLicenses.Page,
+  },
+  {
     name: 'web-vitals',
     license: {
       name: 'web-vitals',
diff --git a/polygerrit-ui/app/package.json b/polygerrit-ui/app/package.json
index 398ca8a..edf0206 100644
--- a/polygerrit-ui/app/package.json
+++ b/polygerrit-ui/app/package.json
@@ -12,7 +12,6 @@
     "@polymer/iron-icon": "^3.0.1",
     "@polymer/iron-iconset-svg": "^3.0.1",
     "@polymer/iron-input": "^3.0.1",
-    "@polymer/iron-overlay-behavior": "^3.0.3",
     "@polymer/iron-selector": "^3.0.1",
     "@polymer/marked-element": "^3.0.1",
     "@polymer/paper-button": "^3.0.1",
@@ -35,19 +34,17 @@
     "@types/resize-observer-browser": "^0.1.5",
     "@webcomponents/shadycss": "^1.10.2",
     "@webcomponents/webcomponentsjs": "^1.3.3",
-    "codemirror-minified": "^5.65.0",
     "highlight.js": "^11.5.0",
     "highlightjs-closure-templates": "https://github.com/highlightjs/highlightjs-closure-templates",
     "highlightjs-structured-text": "https://github.com/highlightjs/highlightjs-structured-text",
     "immer": "^9.0.5",
     "lit": "^2.2.3",
-    "page": "^1.11.6",
     "polymer-bridges": "file:../../polymer-bridges/",
     "polymer-resin": "^2.0.1",
     "resemblejs": "^4.0.0",
     "rxjs": "^6.6.7",
     "safevalues": "^0.3.1",
-    "web-vitals": "^2.1.4"
+    "web-vitals": "^3.0.0"
   },
   "license": "Apache-2.0",
   "private": true
diff --git a/polygerrit-ui/app/rollup.config.js b/polygerrit-ui/app/rollup.config.js
index be60a63..bc9d7a8 100644
--- a/polygerrit-ui/app/rollup.config.js
+++ b/polygerrit-ui/app/rollup.config.js
@@ -73,14 +73,15 @@
     customResolveOptions: {
       // By default, it tries to use page.mjs file instead of page.js
       // when importing 'page/page'.
+      // TODO: page.was removed. Is something obsolete here?
       extensions: ['.js'],
       moduleDirectory: 'external/ui_npm/node_modules',
     },
   }),
   define({
-     replacements: {
-       'process.env.NODE_ENV': JSON.stringify('production'),
-     },
+    replacements: {
+      'process.env.NODE_ENV': JSON.stringify('production'),
+    },
   }),
   importLocalFontMetaUrlResolver()],
 };
diff --git a/polygerrit-ui/app/scripts/hiddenscroll.ts b/polygerrit-ui/app/scripts/hiddenscroll.ts
deleted file mode 100644
index e95a362..0000000
--- a/polygerrit-ui/app/scripts/hiddenscroll.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-/**
- * @license
- * Copyright 2017 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-
-let hiddenscroll: boolean | undefined = undefined;
-
-window.addEventListener('WebComponentsReady', () => {
-  const elem = document.createElement('div');
-  elem.setAttribute('style', 'width:100px;height:100px;overflow:scroll');
-  document.body.appendChild(elem);
-  hiddenscroll = elem.offsetWidth === elem.clientWidth;
-  elem.remove();
-});
-
-export function _setHiddenScroll(value: boolean) {
-  hiddenscroll = value;
-}
-
-export function getHiddenScroll() {
-  return hiddenscroll;
-}
diff --git a/polygerrit-ui/app/scripts/js/bundled-polymer-bridges.js b/polygerrit-ui/app/scripts/js/bundled-polymer-bridges.js
index 573b24a..494acd9 100644
--- a/polygerrit-ui/app/scripts/js/bundled-polymer-bridges.js
+++ b/polygerrit-ui/app/scripts/js/bundled-polymer-bridges.js
@@ -58,7 +58,3 @@
 import 'polymer-bridges/polymer/lib/elements/custom-style_bridge.js';
 import 'polymer-bridges/polymer/lib/legacy/mutable-data-behavior_bridge.js';
 import 'polymer-bridges/polymer/polymer-legacy_bridge.js';
-
-// This is needed due to the Polymer.IronFocusablesHelper in gr-overlay.ts
-import 'polymer-bridges/iron-overlay-behavior/iron-focusables-helper_bridge.js';
-
diff --git a/polygerrit-ui/app/scripts/rootElement.ts b/polygerrit-ui/app/scripts/rootElement.ts
deleted file mode 100644
index 1dbb2a1..0000000
--- a/polygerrit-ui/app/scripts/rootElement.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-/**
- * @license
- * Copyright 2017 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-
-/**
- * Returns the root element of the dom: body.
- */
-export const getRootElement = () => document.body;
diff --git a/polygerrit-ui/app/scripts/util.ts b/polygerrit-ui/app/scripts/util.ts
deleted file mode 100644
index b785a71..0000000
--- a/polygerrit-ui/app/scripts/util.ts
+++ /dev/null
@@ -1,47 +0,0 @@
-/**
- * @license
- * Copyright 2015 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-
-export interface CancelablePromise<T> extends Promise<T> {
-  cancel(): void;
-}
-
-/**
- * Make the promise cancelable.
- *
- * Returns a promise with a `cancel()` method wrapped around `promise`.
- * Calling `cancel()` will reject the returned promise with
- * {isCancelled: true} synchronously. If the inner promise for a cancelled
- * promise resolves or rejects this is ignored.
- */
-export function makeCancelable<T>(promise: Promise<T>) {
-  // True if the promise is either resolved or reject (possibly cancelled)
-  let isDone = false;
-
-  let rejectPromise: (reason?: unknown) => void;
-
-  const wrappedPromise: CancelablePromise<T> = new Promise(
-    (resolve, reject) => {
-      rejectPromise = reject;
-      promise.then(
-        val => {
-          if (!isDone) resolve(val);
-          isDone = true;
-        },
-        error => {
-          if (!isDone) reject(error);
-          isDone = true;
-        }
-      );
-    }
-  ) as CancelablePromise<T>;
-
-  wrappedPromise.cancel = () => {
-    if (isDone) return;
-    rejectPromise({isCanceled: true});
-    isDone = true;
-  };
-  return wrappedPromise;
-}
diff --git a/polygerrit-ui/app/services/app-context-init.ts b/polygerrit-ui/app/services/app-context-init.ts
index e662d5f..8589ae3 100644
--- a/polygerrit-ui/app/services/app-context-init.ts
+++ b/polygerrit-ui/app/services/app-context-init.ts
@@ -8,20 +8,18 @@
 import {DependencyToken} from '../models/dependency';
 import {FlagsServiceImplementation} from './flags/flags_impl';
 import {GrReporting} from './gr-reporting/gr-reporting_impl';
-import {EventEmitter} from './gr-event-interface/gr-event-interface_impl';
 import {Auth} from './gr-auth/gr-auth_impl';
 import {GrRestApiServiceImpl} from './gr-rest-api/gr-rest-api-impl';
 import {ChangeModel, changeModelToken} from '../models/change/change-model';
 import {FilesModel, filesModelToken} from '../models/change/files-model';
 import {ChecksModel, checksModelToken} from '../models/checks/checks-model';
-import {GrJsApiInterface} from '../elements/shared/gr-js-api-interface/gr-js-api-interface-element';
-import {GrStorageService} from './storage/gr-storage_impl';
-import {UserModel} from '../models/user/user-model';
+import {GrStorageService, storageServiceToken} from './storage/gr-storage_impl';
+import {UserModel, userModelToken} from '../models/user/user-model';
 import {
   CommentsModel,
   commentsModelToken,
 } from '../models/comments/comments-model';
-import {RouterModel} from './router/router-model';
+import {RouterModel, routerModelToken} from './router/router-model';
 import {
   ShortcutsService,
   shortcutsServiceToken,
@@ -29,9 +27,14 @@
 import {assertIsDefined} from '../utils/common-util';
 import {ConfigModel, configModelToken} from '../models/config/config-model';
 import {BrowserModel, browserModelToken} from '../models/browser/browser-model';
-import {PluginsModel} from '../models/plugins/plugins-model';
-import {HighlightService} from './highlight/highlight-service';
-import {AccountsModel} from '../models/accounts-model/accounts-model';
+import {
+  HighlightService,
+  highlightServiceToken,
+} from './highlight/highlight-service';
+import {
+  AccountsModel,
+  accountsModelToken,
+} from '../models/accounts-model/accounts-model';
 import {
   DashboardViewModel,
   dashboardViewModelToken,
@@ -47,165 +50,191 @@
   agreementViewModelToken,
 } from '../models/views/agreement';
 import {ChangeViewModel, changeViewModelToken} from '../models/views/change';
-import {DiffViewModel, diffViewModelToken} from '../models/views/diff';
 import {
   DocumentationViewModel,
   documentationViewModelToken,
 } from '../models/views/documentation';
-import {EditViewModel, editViewModelToken} from '../models/views/edit';
 import {GroupViewModel, groupViewModelToken} from '../models/views/group';
 import {PluginViewModel, pluginViewModelToken} from '../models/views/plugin';
 import {RepoViewModel, repoViewModelToken} from '../models/views/repo';
 import {SearchViewModel, searchViewModelToken} from '../models/views/search';
+import {navigationToken} from '../elements/core/gr-navigation/gr-navigation';
 import {
-  NavigationService,
-  navigationToken,
-} from '../elements/core/gr-navigation/gr-navigation';
+  PluginLoader,
+  pluginLoaderToken,
+} from '../elements/shared/gr-js-api-interface/gr-plugin-loader';
+import {authServiceToken} from './gr-auth/gr-auth';
+import {
+  ServiceWorkerInstaller,
+  serviceWorkerInstallerToken,
+} from './service-worker-installer';
+import {
+  RelatedChangesModel,
+  relatedChangesModelToken,
+} from '../models/change/related-changes-model';
 
 /**
  * The AppContext lazy initializator for all services
  */
 export function createAppContext(): AppContext & Finalizable {
   const appRegistry: Registry<AppContext> = {
-    routerModel: (_ctx: Partial<AppContext>) => new RouterModel(),
     flagsService: (_ctx: Partial<AppContext>) =>
       new FlagsServiceImplementation(),
     reportingService: (ctx: Partial<AppContext>) => {
       assertIsDefined(ctx.flagsService, 'flagsService)');
       return new GrReporting(ctx.flagsService);
     },
-    eventEmitter: (_ctx: Partial<AppContext>) => new EventEmitter(),
-    authService: (ctx: Partial<AppContext>) => {
-      assertIsDefined(ctx.eventEmitter, 'eventEmitter');
-      return new Auth(ctx.eventEmitter);
-    },
+    authService: (_ctx: Partial<AppContext>) => new Auth(),
     restApiService: (ctx: Partial<AppContext>) => {
       assertIsDefined(ctx.authService, 'authService');
-      assertIsDefined(ctx.flagsService, 'flagsService');
-      return new GrRestApiServiceImpl(ctx.authService, ctx.flagsService);
-    },
-    jsApiService: (ctx: Partial<AppContext>) => {
-      const reportingService = ctx.reportingService;
-      assertIsDefined(reportingService, 'reportingService');
-      return new GrJsApiInterface(reportingService);
-    },
-    storageService: (_ctx: Partial<AppContext>) => new GrStorageService(),
-    userModel: (ctx: Partial<AppContext>) => {
-      assertIsDefined(ctx.restApiService, 'restApiService');
-      return new UserModel(ctx.restApiService);
-    },
-    accountsModel: (ctx: Partial<AppContext>) => {
-      assertIsDefined(ctx.restApiService, 'restApiService');
-      return new AccountsModel(ctx.restApiService);
-    },
-    pluginsModel: (_ctx: Partial<AppContext>) => new PluginsModel(),
-    highlightService: (ctx: Partial<AppContext>) => {
-      assertIsDefined(ctx.reportingService, 'reportingService');
-      return new HighlightService(ctx.reportingService);
+      return new GrRestApiServiceImpl(ctx.authService);
     },
   };
   return create<AppContext>(appRegistry);
 }
 
+export type Creator<T> = () => T & Finalizable;
+
+// Dependencies are provided as creator functions to ensure that they are
+// not created until they are utilized.
+// This is mainly useful in tests: E.g. don't create a
+// change-model in change-model_test.ts because it creates one in the test
+// after setting up stubs.
 export function createAppDependencies(
-  appContext: AppContext
-): Map<DependencyToken<unknown>, Finalizable> {
-  const dependencies = new Map<DependencyToken<unknown>, Finalizable>();
-  const browserModel = new BrowserModel(appContext.userModel);
-  dependencies.set(browserModelToken, browserModel);
-
-  const adminViewModel = new AdminViewModel();
-  dependencies.set(adminViewModelToken, adminViewModel);
-  const agreementViewModel = new AgreementViewModel();
-  dependencies.set(agreementViewModelToken, agreementViewModel);
-  const changeViewModel = new ChangeViewModel();
-  dependencies.set(changeViewModelToken, changeViewModel);
-  const dashboardViewModel = new DashboardViewModel();
-  dependencies.set(dashboardViewModelToken, dashboardViewModel);
-  const diffViewModel = new DiffViewModel();
-  dependencies.set(diffViewModelToken, diffViewModel);
-  const documentationViewModel = new DocumentationViewModel();
-  dependencies.set(documentationViewModelToken, documentationViewModel);
-  const editViewModel = new EditViewModel();
-  dependencies.set(editViewModelToken, editViewModel);
-  const groupViewModel = new GroupViewModel();
-  dependencies.set(groupViewModelToken, groupViewModel);
-  const pluginViewModel = new PluginViewModel();
-  dependencies.set(pluginViewModelToken, pluginViewModel);
-  const repoViewModel = new RepoViewModel();
-  dependencies.set(repoViewModelToken, repoViewModel);
-  const searchViewModel = new SearchViewModel(
-    appContext.restApiService,
-    appContext.userModel,
-    () => dependencies.get(navigationToken) as unknown as NavigationService
-  );
-  dependencies.set(searchViewModelToken, searchViewModel);
-  const settingsViewModel = new SettingsViewModel();
-  dependencies.set(settingsViewModelToken, settingsViewModel);
-
-  const router = new GrRouter(
-    appContext.reportingService,
-    appContext.routerModel,
-    appContext.restApiService,
-    adminViewModel,
-    agreementViewModel,
-    changeViewModel,
-    dashboardViewModel,
-    diffViewModel,
-    documentationViewModel,
-    editViewModel,
-    groupViewModel,
-    pluginViewModel,
-    repoViewModel,
-    searchViewModel,
-    settingsViewModel
-  );
-  dependencies.set(routerToken, router);
-  dependencies.set(navigationToken, router);
-
-  const changeModel = new ChangeModel(
-    appContext.routerModel,
-    appContext.restApiService,
-    appContext.userModel
-  );
-  dependencies.set(changeModelToken, changeModel);
-
-  const accountsModel = new AccountsModel(appContext.restApiService);
-
-  const commentsModel = new CommentsModel(
-    appContext.routerModel,
-    changeModel,
-    accountsModel,
-    appContext.restApiService,
-    appContext.reportingService
-  );
-  dependencies.set(commentsModelToken, commentsModel);
-
-  const filesModel = new FilesModel(
-    changeModel,
-    commentsModel,
-    appContext.restApiService
-  );
-  dependencies.set(filesModelToken, filesModel);
-
-  const configModel = new ConfigModel(changeModel, appContext.restApiService);
-  dependencies.set(configModelToken, configModel);
-
-  const checksModel = new ChecksModel(
-    appContext.routerModel,
-    changeViewModel,
-    changeModel,
-    appContext.reportingService,
-    appContext.pluginsModel
-  );
-
-  dependencies.set(checksModelToken, checksModel);
-
-  const shortcutsService = new ShortcutsService(
-    appContext.userModel,
-    appContext.reportingService
-  );
-  dependencies.set(shortcutsServiceToken, shortcutsService);
-
-  return dependencies;
+  appContext: AppContext,
+  resolver: <T>(token: DependencyToken<T>) => T
+): Map<DependencyToken<unknown>, Creator<unknown>> {
+  return new Map<DependencyToken<unknown>, Creator<unknown>>([
+    [authServiceToken, () => appContext.authService],
+    [routerModelToken, () => new RouterModel()],
+    [userModelToken, () => new UserModel(appContext.restApiService)],
+    [browserModelToken, () => new BrowserModel(resolver(userModelToken))],
+    [accountsModelToken, () => new AccountsModel(appContext.restApiService)],
+    [adminViewModelToken, () => new AdminViewModel()],
+    [agreementViewModelToken, () => new AgreementViewModel()],
+    [changeViewModelToken, () => new ChangeViewModel()],
+    [dashboardViewModelToken, () => new DashboardViewModel()],
+    [documentationViewModelToken, () => new DocumentationViewModel()],
+    [groupViewModelToken, () => new GroupViewModel()],
+    [pluginViewModelToken, () => new PluginViewModel()],
+    [repoViewModelToken, () => new RepoViewModel()],
+    [
+      searchViewModelToken,
+      () =>
+        new SearchViewModel(
+          appContext.restApiService,
+          resolver(userModelToken),
+          () => resolver(navigationToken)
+        ),
+    ],
+    [settingsViewModelToken, () => new SettingsViewModel()],
+    [
+      routerToken,
+      () =>
+        new GrRouter(
+          appContext.reportingService,
+          resolver(routerModelToken),
+          appContext.restApiService,
+          resolver(adminViewModelToken),
+          resolver(agreementViewModelToken),
+          resolver(changeViewModelToken),
+          resolver(dashboardViewModelToken),
+          resolver(documentationViewModelToken),
+          resolver(groupViewModelToken),
+          resolver(pluginViewModelToken),
+          resolver(repoViewModelToken),
+          resolver(searchViewModelToken),
+          resolver(settingsViewModelToken)
+        ),
+    ],
+    [navigationToken, () => resolver(routerToken)],
+    [
+      changeModelToken,
+      () =>
+        new ChangeModel(
+          resolver(navigationToken),
+          resolver(changeViewModelToken),
+          appContext.restApiService,
+          resolver(userModelToken),
+          resolver(pluginLoaderToken),
+          appContext.reportingService
+        ),
+    ],
+    [
+      commentsModelToken,
+      () =>
+        new CommentsModel(
+          resolver(changeViewModelToken),
+          resolver(changeModelToken),
+          resolver(accountsModelToken),
+          appContext.restApiService,
+          appContext.reportingService,
+          resolver(navigationToken)
+        ),
+    ],
+    [
+      filesModelToken,
+      () =>
+        new FilesModel(
+          resolver(changeModelToken),
+          resolver(commentsModelToken),
+          appContext.restApiService,
+          appContext.reportingService
+        ),
+    ],
+    [
+      configModelToken,
+      () =>
+        new ConfigModel(resolver(changeModelToken), appContext.restApiService),
+    ],
+    [
+      relatedChangesModelToken,
+      () =>
+        new RelatedChangesModel(
+          resolver(changeModelToken),
+          resolver(configModelToken),
+          appContext.restApiService
+        ),
+    ],
+    [
+      pluginLoaderToken,
+      () =>
+        new PluginLoader(
+          appContext.reportingService,
+          appContext.restApiService
+        ),
+    ],
+    [
+      checksModelToken,
+      () =>
+        new ChecksModel(
+          resolver(changeViewModelToken),
+          resolver(changeModelToken),
+          appContext.reportingService,
+          resolver(pluginLoaderToken).pluginsModel
+        ),
+    ],
+    [
+      shortcutsServiceToken,
+      () =>
+        new ShortcutsService(
+          resolver(userModelToken),
+          appContext.reportingService
+        ),
+    ],
+    [storageServiceToken, () => new GrStorageService()],
+    [
+      highlightServiceToken,
+      () => new HighlightService(appContext.reportingService),
+    ],
+    [
+      serviceWorkerInstallerToken,
+      () =>
+        new ServiceWorkerInstaller(
+          appContext.flagsService,
+          appContext.reportingService,
+          resolver(userModelToken)
+        ),
+    ],
+  ]);
 }
diff --git a/polygerrit-ui/app/services/app-context.ts b/polygerrit-ui/app/services/app-context.ts
index 5f47c43..aa2c032 100644
--- a/polygerrit-ui/app/services/app-context.ts
+++ b/polygerrit-ui/app/services/app-context.ts
@@ -5,31 +5,15 @@
  */
 import {Finalizable} from './registry';
 import {FlagsService} from './flags/flags';
-import {EventEmitterService} from './gr-event-interface/gr-event-interface';
 import {ReportingService} from './gr-reporting/gr-reporting';
 import {AuthService} from './gr-auth/gr-auth';
 import {RestApiService} from './gr-rest-api/gr-rest-api';
-import {JsApiService} from '../elements/shared/gr-js-api-interface/gr-js-api-types';
-import {StorageService} from './storage/gr-storage';
-import {UserModel} from '../models/user/user-model';
-import {RouterModel} from './router/router-model';
-import {PluginsModel} from '../models/plugins/plugins-model';
-import {HighlightService} from './highlight/highlight-service';
-import {AccountsModel} from '../models/accounts-model/accounts-model';
 
 export interface AppContext {
-  routerModel: RouterModel;
   flagsService: FlagsService;
   reportingService: ReportingService;
-  eventEmitter: EventEmitterService;
   authService: AuthService;
   restApiService: RestApiService;
-  jsApiService: JsApiService;
-  storageService: StorageService;
-  userModel: UserModel;
-  accountsModel: AccountsModel;
-  pluginsModel: PluginsModel;
-  highlightService: HighlightService;
 }
 
 /**
diff --git a/polygerrit-ui/app/services/flags/flags.ts b/polygerrit-ui/app/services/flags/flags.ts
index c3148a0..7488e79 100644
--- a/polygerrit-ui/app/services/flags/flags.ts
+++ b/polygerrit-ui/app/services/flags/flags.ts
@@ -17,10 +17,6 @@
   NEW_IMAGE_DIFF_UI = 'UiFeature__new_image_diff_ui',
   CHECKS_DEVELOPER = 'UiFeature__checks_developer',
   PUSH_NOTIFICATIONS_DEVELOPER = 'UiFeature__push_notifications_developer',
-  DIFF_RENDERING_LIT = 'UiFeature__diff_rendering_lit',
   PUSH_NOTIFICATIONS = 'UiFeature__push_notifications',
   SUGGEST_EDIT = 'UiFeature__suggest_edit',
-  CHECKS_FIXES = 'UiFeature__checks_fixes',
-  MENTION_USERS = 'UiFeature__mention_users',
-  RENDER_MARKDOWN = 'UiFeature__render_markdown',
 }
diff --git a/polygerrit-ui/app/services/gr-auth/gr-auth.ts b/polygerrit-ui/app/services/gr-auth/gr-auth.ts
index 1dc4a84..945d6f9 100644
--- a/polygerrit-ui/app/services/gr-auth/gr-auth.ts
+++ b/polygerrit-ui/app/services/gr-auth/gr-auth.ts
@@ -3,6 +3,8 @@
  * Copyright 2017 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import {define} from '../../models/dependency';
+import {AuthRequestInit} from '../../types/types';
 import {Finalizable} from '../registry';
 export enum AuthType {
   XSRF_TOKEN = 'xsrf_token',
@@ -27,12 +29,7 @@
   credentials: RequestCredentials;
 }
 
-export interface AuthRequestInit extends RequestInit {
-  // RequestInit define headers as HeadersInit, i.e.
-  // Headers | string[][] | Record<string, string>
-  // Auth class supports only Headers in options
-  headers?: Headers;
-}
+export const authServiceToken = define<AuthService>('auth-service');
 
 export interface AuthService extends Finalizable {
   baseUrl: string;
@@ -53,5 +50,5 @@
   /**
    * Perform network fetch with authentication.
    */
-  fetch(url: string, opt_options?: AuthRequestInit): Promise<Response>;
+  fetch(url: string, options?: AuthRequestInit): Promise<Response>;
 }
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 8a4e51f..2312fc9 100644
--- a/polygerrit-ui/app/services/gr-auth/gr-auth_impl.ts
+++ b/polygerrit-ui/app/services/gr-auth/gr-auth_impl.ts
@@ -3,11 +3,11 @@
  * Copyright 2017 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import {AuthRequestInit} from '../../types/types';
+import {fire} from '../../utils/event-util';
 import {getBaseUrl} from '../../utils/url-util';
-import {EventEmitterService} from '../gr-event-interface/gr-event-interface';
 import {Finalizable} from '../registry';
 import {
-  AuthRequestInit,
   AuthService,
   AuthStatus,
   AuthType,
@@ -67,11 +67,8 @@
 
   private getToken: GetTokenCallback;
 
-  public eventEmitter: EventEmitterService;
-
-  constructor(eventEmitter: EventEmitterService) {
+  constructor() {
     this.getToken = () => Promise.resolve(this.cachedTokenPromise);
-    this.eventEmitter = eventEmitter;
   }
 
   get baseUrl() {
@@ -130,7 +127,7 @@
     if (this._status === status) return;
 
     if (this._status === AuthStatus.AUTHED) {
-      this.eventEmitter.emit('auth-error', {
+      fire(document, 'auth-error', {
         message: Auth.CREDS_EXPIRED_MSG,
         action: 'Refresh credentials',
       });
@@ -165,18 +162,18 @@
   /**
    * Perform network fetch with authentication.
    */
-  fetch(url: string, opt_options?: AuthRequestInit): Promise<Response> {
-    const options: AuthRequestInitWithHeaders = {
+  fetch(url: string, options?: AuthRequestInit): Promise<Response> {
+    const optionsWithHeaders: AuthRequestInitWithHeaders = {
       headers: new Headers(),
       ...this.defaultOptions,
-      ...opt_options,
+      ...options,
     };
     if (this.type === AuthType.ACCESS_TOKEN) {
       return this._getAccessToken().then(accessToken =>
-        this._fetchWithAccessToken(url, options, accessToken)
+        this._fetchWithAccessToken(url, optionsWithHeaders, accessToken)
       );
     } else {
-      return this._fetchWithXsrfToken(url, options);
+      return this._fetchWithXsrfToken(url, optionsWithHeaders);
     }
   }
 
diff --git a/polygerrit-ui/app/services/gr-auth/gr-auth_mock.ts b/polygerrit-ui/app/services/gr-auth/gr-auth_mock.ts
index cc34681e..9cdd37e 100644
--- a/polygerrit-ui/app/services/gr-auth/gr-auth_mock.ts
+++ b/polygerrit-ui/app/services/gr-auth/gr-auth_mock.ts
@@ -3,9 +3,9 @@
  * Copyright 2020 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-import {EventEmitterService} from '../gr-event-interface/gr-event-interface';
+import {AuthRequestInit} from '../../types/types';
+import {fire} from '../../utils/event-util';
 import {
-  AuthRequestInit,
   AuthService,
   AuthStatus,
   DefaultAuthOptions,
@@ -18,11 +18,7 @@
 
   private _status = AuthStatus.UNDETERMINED;
 
-  public eventEmitter: EventEmitterService;
-
-  constructor(eventEmitter: EventEmitterService) {
-    this.eventEmitter = eventEmitter;
-  }
+  constructor() {}
 
   get isAuthed() {
     return this._status === Auth.STATUS.AUTHED;
@@ -33,7 +29,7 @@
   private _setStatus(status: AuthStatus) {
     if (this._status === status) return;
     if (this._status === AuthStatus.AUTHED) {
-      this.eventEmitter.emit('auth-error', {
+      fire(document, 'auth-error', {
         message: Auth.CREDS_EXPIRED_MSG,
         action: 'Refresh credentials',
       });
@@ -61,7 +57,7 @@
 
   setup(_getToken: GetTokenCallback, _defaultOptions: DefaultAuthOptions) {}
 
-  fetch(_url: string, _opt_options?: AuthRequestInit): Promise<Response> {
+  fetch(_url: string, _options?: AuthRequestInit): Promise<Response> {
     return Promise.resolve(new Response());
   }
 }
diff --git a/polygerrit-ui/app/services/gr-auth/gr-auth_test.ts b/polygerrit-ui/app/services/gr-auth/gr-auth_test.ts
index 4552dad..b9cef86 100644
--- a/polygerrit-ui/app/services/gr-auth/gr-auth_test.ts
+++ b/polygerrit-ui/app/services/gr-auth/gr-auth_test.ts
@@ -5,22 +5,17 @@
  */
 import '../../test/common-test-setup';
 import {Auth} from './gr-auth_impl';
-import {getAppContext} from '../app-context';
 import {stubBaseUrl} from '../../test/test-utils';
-import {EventEmitterService} from '../gr-event-interface/gr-event-interface';
 import {SinonFakeTimers} from 'sinon';
-import {AuthRequestInit, DefaultAuthOptions} from './gr-auth';
+import {DefaultAuthOptions} from './gr-auth';
 import {assert} from '@open-wc/testing';
+import {AuthRequestInit} from '../../types/types';
 
 suite('gr-auth', () => {
   let auth: Auth;
-  let eventEmitter: EventEmitterService;
 
   setup(() => {
-    // TODO(poucet): Mock the eventEmitter completely instead of getting it
-    // from appContext.
-    eventEmitter = getAppContext().eventEmitter;
-    auth = new Auth(eventEmitter);
+    auth = new Auth();
   });
 
   suite('Auth class methods', () => {
@@ -118,11 +113,13 @@
       assert.equal(auth.status, Auth.STATUS.AUTHED);
       clock.tick(1000 * 10000);
       fakeFetch.returns(Promise.resolve({status: 403}));
-      const emitStub = sinon.stub(eventEmitter, 'emit');
+      const emitStub = sinon.stub();
+      document.addEventListener('auth-error', emitStub);
       const authed2 = await auth.authCheck();
       assert.isFalse(authed2);
       assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
       assert.isTrue(emitStub.called);
+      document.removeEventListener('auth-error', emitStub);
     });
 
     test('fire event when switch from authed to error', async () => {
@@ -132,11 +129,13 @@
       assert.equal(auth.status, Auth.STATUS.AUTHED);
       clock.tick(1000 * 10000);
       fakeFetch.returns(Promise.reject(new Error('random error')));
-      const emitStub = sinon.stub(eventEmitter, 'emit');
+      const emitStub = sinon.stub();
+      document.addEventListener('auth-error', emitStub);
       const authed2 = await auth.authCheck();
       assert.isFalse(authed2);
       assert.isTrue(emitStub.called);
       assert.equal(auth.status, Auth.STATUS.ERROR);
+      document.removeEventListener('auth-error', emitStub);
     });
 
     test('no event from non-authed to other status', async () => {
@@ -146,11 +145,13 @@
       assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
       clock.tick(1000 * 10000);
       fakeFetch.returns(Promise.resolve({status: 204}));
-      const emitStub = sinon.stub(eventEmitter, 'emit');
+      const emitStub = sinon.stub();
+      document.addEventListener('auth-error', emitStub);
       const authed2 = await auth.authCheck();
       assert.isTrue(authed2);
       assert.isFalse(emitStub.called);
       assert.equal(auth.status, Auth.STATUS.AUTHED);
+      document.removeEventListener('auth-error', emitStub);
     });
 
     test('no event from non-authed to other status', async () => {
@@ -160,11 +161,13 @@
       assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
       clock.tick(1000 * 10000);
       fakeFetch.returns(Promise.reject(new Error('random error')));
-      const emitStub = sinon.stub(eventEmitter, 'emit');
+      const emitStub = sinon.stub();
+      document.addEventListener('auth-error', emitStub);
       const authed2 = await auth.authCheck();
       assert.isFalse(authed2);
       assert.isFalse(emitStub.called);
       assert.equal(auth.status, Auth.STATUS.ERROR);
+      document.removeEventListener('auth-error', emitStub);
     });
   });
 
@@ -205,9 +208,9 @@
 
     let getToken: sinon.SinonStub;
 
-    const makeToken = (opt_accessToken?: string) => {
+    const makeToken = (accessToken?: string) => {
       return {
-        access_token: opt_accessToken || 'zbaz',
+        access_token: accessToken || 'zbaz',
         expires_at: new Date(Date.now() + 10e8).getTime(),
       };
     };
diff --git a/polygerrit-ui/app/services/gr-event-interface/gr-event-interface.ts b/polygerrit-ui/app/services/gr-event-interface/gr-event-interface.ts
deleted file mode 100644
index 4153b3d..0000000
--- a/polygerrit-ui/app/services/gr-event-interface/gr-event-interface.ts
+++ /dev/null
@@ -1,59 +0,0 @@
-/**
- * @license
- * Copyright 2020 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-import {Finalizable} from '../registry';
-// eslint-disable-next-line @typescript-eslint/no-explicit-any
-export type EventCallback = (...args: any) => void;
-export type UnsubscribeMethod = () => void;
-
-export interface EventEmitterService extends Finalizable {
-  /**
-   * Register an event listener to an event.
-   */
-  addListener(eventName: string, cb: EventCallback): UnsubscribeMethod;
-
-  /**
-   * Alias for addListener.
-   */
-  on(eventName: string, cb: EventCallback): UnsubscribeMethod;
-
-  /**
-   * Attach event handler only once. Automatically removed.
-   */
-  once(eventName: string, cb: EventCallback): UnsubscribeMethod;
-
-  /**
-   * De-register an event listener to an event.
-   */
-  removeListener(eventName: string, cb: EventCallback): void;
-
-  /**
-   * Alias to removeListener
-   */
-  off(eventName: string, cb: EventCallback): void;
-
-  /**
-   * Synchronously calls each of the listeners registered for
-   * the event named eventName, in the order they were registered,
-   * passing the supplied detail to each.
-   *
-   * @return true if the event had listeners, false otherwise.
-   */
-  // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  emit(eventName: string, detail: any): boolean;
-
-  /**
-   * Alias to emit.
-   */
-  // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  dispatch(eventName: string, detail: any): boolean;
-
-  /**
-   * Remove listeners for a specific event or all.
-   *
-   * @param eventName if not provided, will remove all
-   */
-  removeAllListeners(eventName: string): void;
-}
diff --git a/polygerrit-ui/app/services/gr-event-interface/gr-event-interface_impl.ts b/polygerrit-ui/app/services/gr-event-interface/gr-event-interface_impl.ts
deleted file mode 100644
index 7228282..0000000
--- a/polygerrit-ui/app/services/gr-event-interface/gr-event-interface_impl.ts
+++ /dev/null
@@ -1,126 +0,0 @@
-/**
- * @license
- * Copyright 2019 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-import {Finalizable} from '../registry';
-import {
-  EventCallback,
-  EventEmitterService,
-  UnsubscribeMethod,
-} from './gr-event-interface';
-/**
- * An lite implementation of
- * https://nodejs.org/api/events.html#events_class_eventemitter.
- *
- * This is unrelated to the native DOM events, you should use it when you want
- * to enable EventEmitter interface on any class.
- *
- * @example
- *
- * class YourClass extends EventEmitter {
- *   // now all instance of YourClass will have this EventEmitter interface
- * }
- *
- */
-export class EventEmitter implements EventEmitterService, Finalizable {
-  private _listenersMap = new Map<string, EventCallback[]>();
-
-  finalize() {
-    this.removeAllListeners();
-  }
-
-  /**
-   * Register an event listener to an event.
-   */
-  addListener(eventName: string, cb: EventCallback): UnsubscribeMethod {
-    if (!eventName || !cb) {
-      console.warn('A valid eventname and callback is required!');
-      return () => {};
-    }
-
-    const listeners = this._listenersMap.get(eventName) || [];
-    listeners.push(cb);
-    this._listenersMap.set(eventName, listeners);
-
-    return () => {
-      this.off(eventName, cb);
-    };
-  }
-
-  /**
-   * Alias for addListener.
-   */
-  on(eventName: string, cb: EventCallback): UnsubscribeMethod {
-    return this.addListener(eventName, cb);
-  }
-
-  /**
-   * Attach event handler only once. Automatically removed.
-   */
-  once(eventName: string, cb: EventCallback): UnsubscribeMethod {
-    // eslint-disable-next-line @typescript-eslint/no-explicit-any
-    const onceWrapper = (...args: any[]) => {
-      cb(...args);
-      this.off(eventName, onceWrapper);
-    };
-    return this.on(eventName, onceWrapper);
-  }
-
-  /**
-   * De-register an event listener to an event.
-   */
-  removeListener(eventName: string, cb: EventCallback): void {
-    let listeners = this._listenersMap.get(eventName) || [];
-    listeners = listeners.filter(listener => listener !== cb);
-    this._listenersMap.set(eventName, listeners);
-  }
-
-  /**
-   * Alias to removeListener
-   */
-  off(eventName: string, cb: EventCallback): void {
-    this.removeListener(eventName, cb);
-  }
-
-  /**
-   * Synchronously calls each of the listeners registered for
-   * the event named eventName, in the order they were registered,
-   * passing the supplied detail to each.
-   *
-   * @return true if the event had listeners, false otherwise.
-   */
-  // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  emit(eventName: string, detail: any): boolean {
-    const listeners = this._listenersMap.get(eventName) || [];
-    for (const listener of listeners) {
-      try {
-        listener(detail);
-      } catch (e) {
-        console.error(e);
-      }
-    }
-    return listeners.length !== 0;
-  }
-
-  /**
-   * Alias to emit.
-   */
-  // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  dispatch(eventName: string, detail: any): boolean {
-    return this.emit(eventName, detail);
-  }
-
-  /**
-   * Remove listeners for a specific event or all.
-   *
-   * @param eventName if not provided, will remove all
-   */
-  removeAllListeners(eventName?: string): void {
-    if (eventName) {
-      this._listenersMap.set(eventName, []);
-    } else {
-      this._listenersMap = new Map<string, EventCallback[]>();
-    }
-  }
-}
diff --git a/polygerrit-ui/app/services/gr-event-interface/gr-event-interface_test.js b/polygerrit-ui/app/services/gr-event-interface/gr-event-interface_test.js
deleted file mode 100644
index a63eda3..0000000
--- a/polygerrit-ui/app/services/gr-event-interface/gr-event-interface_test.js
+++ /dev/null
@@ -1,123 +0,0 @@
-/**
- * @license
- * Copyright 2019 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-import '../../test/common-test-setup';
-import {mockPromise} from '../../test/test-utils';
-import {EventEmitter} from './gr-event-interface_impl';
-import {assert} from '@open-wc/testing';
-
-suite('gr-event-interface tests', () => {
-  let gerrit;
-  setup(() => {
-    gerrit = window.Gerrit;
-  });
-
-  suite('test on Gerrit', () => {
-    setup(() => {
-      gerrit.removeAllListeners();
-    });
-
-    test('communicate between plugin and Gerrit', async () => {
-      const eventName = 'test-plugin-event';
-      let p;
-      const promise = mockPromise();
-      gerrit.on(eventName, e => {
-        assert.equal(e.value, 'test');
-        assert.equal(e.plugin, p);
-        promise.resolve();
-      });
-      gerrit.install(plugin => {
-        p = plugin;
-        gerrit.emit(eventName, {value: 'test', plugin});
-      }, '0.1',
-      'http://test.com/plugins/testplugin/static/test.js');
-      await promise;
-    });
-
-    test('listen on events from core', async () => {
-      const eventName = 'test-plugin-event';
-      const promise = mockPromise();
-      gerrit.on(eventName, e => {
-        assert.equal(e.value, 'test');
-        promise.resolve();
-      });
-
-      gerrit.emit(eventName, {value: 'test'});
-      await promise;
-    });
-
-    test('communicate across plugins', async () => {
-      const eventName = 'test-plugin-event';
-      const promise = mockPromise();
-      gerrit.install(plugin => {
-        gerrit.on(eventName, e => {
-          assert.equal(e.plugin.getPluginName(), 'testB');
-          promise.resolve();
-        });
-      }, '0.1',
-      'http://test.com/plugins/testA/static/testA.js');
-
-      gerrit.install(plugin => {
-        gerrit.emit(eventName, {plugin});
-      }, '0.1',
-      'http://test.com/plugins/testB/static/testB.js');
-      await promise;
-    });
-  });
-
-  suite('test on interfaces', () => {
-    let testObj;
-
-    class TestClass extends EventEmitter {
-    }
-
-    setup(() => {
-      testObj = new TestClass();
-    });
-
-    test('on', () => {
-      const cbStub = sinon.stub();
-      testObj.on('test', cbStub);
-      testObj.emit('test');
-      testObj.emit('test');
-      assert.isTrue(cbStub.calledTwice);
-    });
-
-    test('once', () => {
-      const cbStub = sinon.stub();
-      testObj.once('test', cbStub);
-      testObj.emit('test');
-      testObj.emit('test');
-      assert.isTrue(cbStub.calledOnce);
-    });
-
-    test('unsubscribe', () => {
-      const cbStub = sinon.stub();
-      const unsubscribe = testObj.on('test', cbStub);
-      testObj.emit('test');
-      unsubscribe();
-      testObj.emit('test');
-      assert.isTrue(cbStub.calledOnce);
-    });
-
-    test('off', () => {
-      const cbStub = sinon.stub();
-      testObj.on('test', cbStub);
-      testObj.emit('test');
-      testObj.off('test', cbStub);
-      testObj.emit('test');
-      assert.isTrue(cbStub.calledOnce);
-    });
-
-    test('removeAllListeners', () => {
-      const cbStub = sinon.stub();
-      testObj.on('test', cbStub);
-      testObj.removeAllListeners('test');
-      testObj.emit('test');
-      assert.isTrue(cbStub.notCalled);
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts b/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts
index f552762..49259b4 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts
@@ -29,7 +29,7 @@
     eventName: string,
     eventValue?: EventValue,
     eventDetails?: EventDetails,
-    opt_noLog?: boolean
+    noLog?: boolean
   ): void;
 
   appStarted(): void;
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts b/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts
index dadf9e4..55bb64c 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts
@@ -16,7 +16,8 @@
   LifeCycle,
   Timing,
 } from '../../constants/reporting';
-import {getCLS, getFID, getLCP, Metric} from 'web-vitals';
+import {onCLS, onFID, onLCP, Metric, onINP} from 'web-vitals';
+import {getEventPath, isElementTarget} from '../../utils/dom-util';
 
 // Latency reporting constants.
 
@@ -130,8 +131,8 @@
   };
   // TODO(dmfilippov): TS-fix-any unclear what is context
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  const catchErrors = function (opt_context?: any) {
-    const context = opt_context || window;
+  const catchErrors = function (context?: any) {
+    context = context || window;
     const oldOnError = context.onerror;
     context.onerror = (
       event: Event | string,
@@ -186,21 +187,47 @@
   });
 }
 
+export function initClickReporter(reportingService: ReportingService) {
+  document.addEventListener('click', (e: MouseEvent) => {
+    const anchorEl = e
+      .composedPath()
+      .find(el => isElementTarget(el) && el.tagName.toUpperCase() === 'A') as
+      | HTMLAnchorElement
+      | undefined;
+    if (!anchorEl) return;
+    reportingService.reportInteraction(Interaction.LINK_CLICK, {
+      path: getEventPath(e),
+      link: anchorEl.href,
+      text: anchorEl.innerText,
+    });
+  });
+}
+
 export function initWebVitals(reportingService: ReportingService) {
   function reportWebVitalMetric(name: Timing, metric: Metric) {
+    let score = metric.value;
+    // CLS good score is 0.1 and poor score is 0.25. Logging system
+    // prefers integers, so we multiple by 100;
+    if (name === Timing.CLS) {
+      score *= 100;
+    }
     reportingService.reporter(
       TIMING.TYPE,
       TIMING.CATEGORY.UI_LATENCY,
       name,
-      metric.value,
-      JSON.stringify(metric),
-      false
+      score,
+      {
+        navigationType: metric.navigationType,
+        rating: metric.rating,
+        entries: metric.entries,
+      }
     );
   }
 
-  getCLS(metric => reportWebVitalMetric(Timing.CLS, metric));
-  getFID(metric => reportWebVitalMetric(Timing.FID, metric));
-  getLCP(metric => reportWebVitalMetric(Timing.LCP, metric));
+  onCLS(metric => reportWebVitalMetric(Timing.CLS, metric));
+  onFID(metric => reportWebVitalMetric(Timing.FID, metric));
+  onLCP(metric => reportWebVitalMetric(Timing.LCP, metric));
+  onINP(metric => reportWebVitalMetric(Timing.INP, metric));
 }
 
 // Calculates the time of Gerrit being in a background tab. When Gerrit reports
@@ -360,18 +387,18 @@
       // We cache until metrics plugin is loaded
       this.pending.push([eventInfo, noLog]);
       if (this._isMetricsPluginLoaded()) {
-        this.pending.forEach(([eventInfo, opt_noLog]) => {
-          this._reportEvent(eventInfo, opt_noLog);
+        this.pending.forEach(([eventInfo, noLog]) => {
+          this._reportEvent(eventInfo, noLog);
         });
         this.pending = [];
       }
     }
   }
 
-  private _reportEvent(eventInfo: EventInfo, opt_noLog?: boolean) {
+  private _reportEvent(eventInfo: EventInfo, noLog?: boolean) {
     const {type, value, name, eventDetails} = eventInfo;
     document.dispatchEvent(new CustomEvent(type, {detail: eventInfo}));
-    if (opt_noLog) {
+    if (noLog) {
       return;
     }
     if (type !== ERROR.TYPE) {
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 5c199cb..347e24c 100644
--- a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl.ts
+++ b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl.ts
@@ -20,7 +20,6 @@
 import {GrReviewerUpdatesParser} from '../../elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser';
 import {parseDate} from '../../utils/date-util';
 import {getBaseUrl} from '../../utils/url-util';
-import {getAppContext} from '../app-context';
 import {Finalizable} from '../registry';
 import {getParentIndex, isMergeParent} from '../../utils/patch-set-util';
 import {
@@ -28,7 +27,7 @@
   listChangesOptionsToHex,
 } from '../../utils/change-util';
 import {assertNever, hasOwnProperty} from '../../utils/common-util';
-import {AuthRequestInit, AuthService} from '../gr-auth/gr-auth';
+import {AuthService} from '../gr-auth/gr-auth';
 import {
   AccountCapabilityInfo,
   AccountDetailInfo,
@@ -94,13 +93,12 @@
   Password,
   PatchRange,
   PatchSetNum,
-  PathToCommentsInfoMap,
   PathToRobotCommentsInfoMap,
   PluginInfo,
   PreferencesInfo,
   PreferencesInput,
   ProjectAccessInfo,
-  ProjectAccessInfoMap,
+  RepoAccessInfoMap,
   ProjectAccessInput,
   ProjectInfo,
   ProjectInfoWithName,
@@ -120,6 +118,7 @@
   TopMenuEntryInfo,
   UrlEncodedCommentId,
   FixReplacementInfo,
+  DraftInfo,
 } from '../../types/common';
 import {
   DiffInfo,
@@ -141,14 +140,16 @@
   ReviewerState,
 } from '../../constants/constants';
 import {firePageError, fireServerError} from '../../utils/event-util';
-import {ParsedChangeInfo} from '../../types/types';
+import {AuthRequestInit, ParsedChangeInfo} from '../../types/types';
 import {ErrorCallback} from '../../api/rest';
-import {addDraftProp, DraftInfo} from '../../utils/comment-util';
-import {BaseScheduler} from '../scheduler/scheduler';
+import {addDraftProp} from '../../utils/comment-util';
+import {BaseScheduler, Scheduler} from '../scheduler/scheduler';
 import {MaxInFlightScheduler} from '../scheduler/max-in-flight-scheduler';
-import {FlagsService} from '../flags/flags';
+import {escapeAndWrapSearchOperatorValue} from '../../utils/string-util';
 
 const MAX_PROJECT_RESULTS = 25;
+export const PROBE_PATH = '/Documentation/index.html';
+export const DOCS_BASE_PATH = '/Documentation';
 
 const Requests = {
   SEND_DIFF_DRAFT: 'sendDiffDraft',
@@ -255,13 +256,13 @@
 
 type SendChangeRequest = SendRawChangeRequest | SendJSONChangeRequest;
 
-export function _testOnlyResetGrRestApiSharedObjects() {
+export function testOnlyResetGrRestApiSharedObjects(authService: AuthService) {
   siteBasedCache = new SiteBasedCache();
   fetchPromisesCache = new FetchPromisesCache();
   pendingRequest = {};
   grEtagDecorator = new GrEtagDecorator();
   projectLookup = {};
-  getAppContext().authService.clearCache();
+  authService.clearCache();
 }
 
 function createReadScheduler() {
@@ -271,6 +272,11 @@
 function createWriteScheduler() {
   return new MaxInFlightScheduler<Response>(new BaseScheduler<Response>(), 5);
 }
+
+function createSerializingScheduler() {
+  return new MaxInFlightScheduler<Response>(new BaseScheduler<Response>(), 1);
+}
+
 export class GrRestApiServiceImpl implements RestApiService, Finalizable {
   readonly _cache = siteBasedCache; // Shared across instances.
 
@@ -280,16 +286,19 @@
 
   readonly _etags = grEtagDecorator; // Shared across instances.
 
-  readonly _projectLookup = projectLookup; // Shared across instances.
+  getDocsBaseUrlCachedPromise: Promise<string | null> | undefined;
+
+  // readonly, but set in tests.
+  _projectLookup = projectLookup; // Shared across instances.
 
   // The value is set in created, before any other actions
-  private readonly _restApiHelper: GrRestApiHelper;
+  // Private, but used in tests.
+  readonly _restApiHelper: GrRestApiHelper;
 
-  constructor(
-    private readonly authService: AuthService,
-    // @ts-ignore: it's ok.
-    private readonly _flagsService: FlagsService
-  ) {
+  // Used to serialize requests for certain RPCs
+  readonly _serialScheduler: Scheduler<Response>;
+
+  constructor(private readonly authService: AuthService) {
     this._restApiHelper = new GrRestApiHelper(
       this._cache,
       this.authService,
@@ -297,6 +306,7 @@
       createReadScheduler(),
       createWriteScheduler()
     );
+    this._serialScheduler = createSerializingScheduler();
   }
 
   finalize() {}
@@ -350,13 +360,13 @@
     }) as Promise<ConfigInfo | undefined>;
   }
 
-  getRepoAccess(repo: RepoName): Promise<ProjectAccessInfoMap | undefined> {
+  getRepoAccess(repo: RepoName): Promise<RepoAccessInfoMap | undefined> {
     // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
     // supports it.
     return this._fetchSharedCacheURL({
       url: '/access/?project=' + encodeURIComponent(repo),
       anonymizedUrl: '/access/?project=*',
-    }) as Promise<ProjectAccessInfoMap | undefined>;
+    }) as Promise<RepoAccessInfoMap | undefined>;
   }
 
   getRepoDashboards(
@@ -763,7 +773,7 @@
     userId: AccountId | EmailAddress,
     errFn?: ErrorCallback
   ): Promise<AccountDetailInfo | undefined> {
-    return this._restApiHelper.fetchJSON({
+    return this._fetchSharedCacheURL({
       url: `/accounts/${encodeURIComponent(userId)}/detail`,
       anonymizedUrl: '/accounts/*/detail',
       errFn,
@@ -1097,7 +1107,8 @@
     changesPerPage?: number,
     query?: string,
     offset?: 'n,z' | number,
-    options?: string
+    options?: string,
+    errFn?: ErrorCallback
   ): Promise<ChangeInfo[] | undefined> {
     const request = this.getRequestForGetChanges(
       changesPerPage,
@@ -1107,9 +1118,13 @@
     );
 
     return Promise.resolve(
-      this._restApiHelper.fetchJSON(request, true) as Promise<
-        ChangeInfo[] | undefined
-      >
+      this._restApiHelper.fetchJSON(
+        {
+          ...request,
+          errFn,
+        },
+        true
+      ) as Promise<ChangeInfo[] | undefined>
     ).then(response => {
       if (!response) {
         return;
@@ -1132,7 +1147,6 @@
       undefined,
       listChangesOptionsToHex(
         ListChangesOption.CHANGE_ACTIONS,
-        ListChangesOption.CURRENT_ACTIONS,
         ListChangesOption.CURRENT_REVISION,
         ListChangesOption.DETAILED_LABELS,
         // TODO: remove this option and merge requirements from dashboard req
@@ -1335,13 +1349,15 @@
   queryChangeFiles(
     changeNum: NumericChangeId,
     patchNum: PatchSetNum,
-    query: string
+    query: string,
+    errFn?: ErrorCallback
   ) {
     return this._getChangeURLAndFetch({
       changeNum,
       endpoint: `/files?q=${encodeURIComponent(query)}`,
       revision: patchNum,
       anonymizedEndpoint: '/files?q=*',
+      errFn,
     }) as Promise<string[] | undefined>;
   }
 
@@ -1372,22 +1388,37 @@
     >;
   }
 
-  getChangeSuggestedReviewers(changeNum: NumericChangeId, inputVal: string) {
+  getChangeSuggestedReviewers(
+    changeNum: NumericChangeId,
+    inputVal: string,
+    errFn?: ErrorCallback
+  ) {
     return this._getChangeSuggestedGroup(
       ReviewerState.REVIEWER,
       changeNum,
-      inputVal
+      inputVal,
+      errFn
     );
   }
 
-  getChangeSuggestedCCs(changeNum: NumericChangeId, inputVal: string) {
-    return this._getChangeSuggestedGroup(ReviewerState.CC, changeNum, inputVal);
+  getChangeSuggestedCCs(
+    changeNum: NumericChangeId,
+    inputVal: string,
+    errFn?: ErrorCallback
+  ) {
+    return this._getChangeSuggestedGroup(
+      ReviewerState.CC,
+      changeNum,
+      inputVal,
+      errFn
+    );
   }
 
   _getChangeSuggestedGroup(
     reviewerState: ReviewerState,
     changeNum: NumericChangeId,
-    inputVal: string
+    inputVal: string,
+    errFn?: ErrorCallback
   ): Promise<SuggestedReviewerInfo[] | undefined> {
     // More suggestions may obscure content underneath in the reply dialog,
     // see issue 10793.
@@ -1403,6 +1434,7 @@
       endpoint: '/suggest_reviewers',
       params,
       reportEndpointAsIs: true,
+      errFn,
     }) as Promise<SuggestedReviewerInfo[] | undefined>;
   }
 
@@ -1490,7 +1522,8 @@
   async getRepos(
     filter: string | undefined,
     reposPerPage: number,
-    offset?: number
+    offset?: number,
+    errFn?: ErrorCallback
   ): Promise<ProjectInfoWithName[] | undefined> {
     const [isQuery, url] = this._getReposUrl(filter, reposPerPage, offset);
 
@@ -1504,11 +1537,13 @@
       return this._fetchSharedCacheURL({
         url,
         anonymizedUrl: '/projects/?*',
+        errFn,
       }) as Promise<ProjectInfoWithName[] | undefined>;
     } else {
       const result = await (this._fetchSharedCacheURL({
         url,
         anonymizedUrl: '/projects/?*',
+        errFn,
       }) as Promise<NameToProjectInfoMap | undefined>);
       if (result === undefined) return [];
       return Object.entries(result).map(([name, project]) => {
@@ -1634,7 +1669,8 @@
   getSuggestedGroups(
     inputVal: string,
     project?: RepoName,
-    n?: number
+    n?: number,
+    errFn?: ErrorCallback
   ): Promise<GroupNameToGroupInfoMap | undefined> {
     const params: QueryGroupsParams = {s: inputVal};
     if (n) {
@@ -1647,12 +1683,14 @@
       url: '/groups/',
       params,
       reportUrlAsIs: true,
+      errFn,
     }) as Promise<GroupNameToGroupInfoMap | undefined>;
   }
 
-  getSuggestedProjects(
+  getSuggestedRepos(
     inputVal: string,
-    n?: number
+    n?: number,
+    errFn?: ErrorCallback
   ): Promise<NameToProjectInfoMap | undefined> {
     const params = {
       m: inputVal,
@@ -1666,6 +1704,7 @@
       url: '/projects/',
       params,
       reportUrlAsIs: true,
+      errFn,
     });
   }
 
@@ -1673,13 +1712,18 @@
     inputVal: string,
     n?: number,
     canSee?: NumericChangeId,
-    filterActive?: boolean
+    filterActive?: boolean,
+    errFn?: ErrorCallback
   ): Promise<AccountInfo[] | undefined> {
     const params: QueryAccountsParams = {o: 'DETAILS', q: ''};
     const queryParams = [];
     inputVal = inputVal?.trim() ?? '';
     if (inputVal.length > 0) {
-      queryParams.push(inputVal);
+      // Wrap in quotes so that reserved keywords do not throw an error such
+      // as typing "and"
+      // Espace quotes in user input since we are wrapping input in quotes
+      // explicitly
+      queryParams.push(`${escapeAndWrapSearchOperatorValue(inputVal)}`);
     }
     if (canSee) {
       queryParams.push(`cansee:${canSee}`);
@@ -1696,6 +1740,7 @@
       url: '/accounts/',
       params,
       anonymizedUrl: '/accounts/?n=*',
+      errFn,
     }) as Promise<AccountInfo[] | undefined>;
   }
 
@@ -1778,7 +1823,8 @@
     }
     const options = listChangesOptionsToHex(
       ListChangesOption.CURRENT_REVISION,
-      ListChangesOption.CURRENT_COMMIT
+      ListChangesOption.CURRENT_COMMIT,
+      ListChangesOption.SUBMITTABLE
     );
     const params = {
       O: options,
@@ -1792,7 +1838,7 @@
   }
 
   getChangeCherryPicks(
-    project: RepoName,
+    repo: RepoName,
     changeID: ChangeId,
     branch: BranchName
   ): Promise<ChangeInfo[] | undefined> {
@@ -1801,7 +1847,7 @@
       ListChangesOption.CURRENT_COMMIT
     );
     const query = [
-      `project:${project}`,
+      `project:${repo}`,
       `change:${changeID}`,
       `-branch:${branch}`,
       '-is:abandoned',
@@ -1828,9 +1874,10 @@
       ListChangesOption.LABELS,
       ListChangesOption.CURRENT_REVISION,
       ListChangesOption.CURRENT_COMMIT,
-      ListChangesOption.DETAILED_LABELS
+      ListChangesOption.DETAILED_LABELS,
+      ListChangesOption.SUBMITTABLE
     );
-    const queryTerms = [`topic:"${topic}"`];
+    const queryTerms = [`topic:${escapeAndWrapSearchOperatorValue(topic)}`];
     if (options?.openChangesOnly) {
       queryTerms.push('status:open');
     }
@@ -1848,23 +1895,29 @@
     }) as Promise<ChangeInfo[] | undefined>;
   }
 
-  getChangesWithSimilarTopic(topic: string): Promise<ChangeInfo[] | undefined> {
-    const query = `intopic:"${topic}"`;
+  getChangesWithSimilarTopic(
+    topic: string,
+    errFn?: ErrorCallback
+  ): Promise<ChangeInfo[] | undefined> {
+    const query = `intopic:${escapeAndWrapSearchOperatorValue(topic)}`;
     return this._restApiHelper.fetchJSON({
       url: '/changes/',
       params: {q: query},
       anonymizedUrl: '/changes/intopic:*',
+      errFn,
     }) as Promise<ChangeInfo[] | undefined>;
   }
 
   getChangesWithSimilarHashtag(
-    hashtag: string
+    hashtag: string,
+    errFn?: ErrorCallback
   ): Promise<ChangeInfo[] | undefined> {
-    const query = `inhashtag:"${hashtag}"`;
+    const query = `inhashtag:${escapeAndWrapSearchOperatorValue(hashtag)}`;
     return this._restApiHelper.fetchJSON({
       url: '/changes/',
       params: {q: query},
       anonymizedUrl: '/changes/inhashtag:*',
+      errFn,
     }) as Promise<ChangeInfo[] | undefined>;
   }
 
@@ -1948,7 +2001,7 @@
   }
 
   createChange(
-    project: RepoName,
+    repo: RepoName,
     branch: BranchName,
     subject: string,
     topic?: string,
@@ -1961,7 +2014,7 @@
       method: HttpMethod.POST,
       url: '/changes/',
       body: {
-        project,
+        project: repo,
         branch,
         subject,
         topic,
@@ -2212,11 +2265,13 @@
     return this.getFromProjectLookup(changeNum).then(project => {
       const encodedRepoName = project ? encodeURIComponent(project) + '~' : '';
       const url = `/accounts/self/starred.changes/${encodedRepoName}${changeNum}`;
-      return this._restApiHelper.send({
-        method: starred ? HttpMethod.PUT : HttpMethod.DELETE,
-        url,
-        anonymizedUrl: '/accounts/self/starred.changes/*',
-      });
+      return this._serialScheduler.schedule(() =>
+        this._restApiHelper.send({
+          method: starred ? HttpMethod.PUT : HttpMethod.DELETE,
+          url,
+          anonymizedUrl: '/accounts/self/starred.changes/*',
+        })
+      );
     });
   }
 
@@ -2302,7 +2357,7 @@
 
   getDiffComments(
     changeNum: NumericChangeId
-  ): Promise<PathToCommentsInfoMap | undefined>;
+  ): Promise<{[path: string]: CommentInfo[]} | undefined>;
 
   getDiffComments(
     changeNum: NumericChangeId,
@@ -2403,7 +2458,7 @@
     changeNum: NumericChangeId,
     endpoint: '/comments' | '/drafts',
     params?: FetchParams
-  ): Promise<PathToCommentsInfoMap | undefined>;
+  ): Promise<{[path: string]: CommentInfo[]} | undefined>;
 
   _getDiffComments(
     changeNum: NumericChangeId,
@@ -2438,7 +2493,7 @@
   ): Promise<
     | GetDiffCommentsOutput
     | GetDiffRobotCommentsOutput
-    | PathToCommentsInfoMap
+    | {[path: string]: CommentInfo[]}
     | PathToRobotCommentsInfoMap
     | undefined
   > {
@@ -2460,7 +2515,7 @@
         },
         noAcceptHeader
       ) as Promise<
-        PathToCommentsInfoMap | PathToRobotCommentsInfoMap | undefined
+        {[path: string]: CommentInfo[]} | PathToRobotCommentsInfoMap | undefined
       >;
 
     if (!basePatchNum && !patchNum && !path) {
@@ -2528,7 +2583,7 @@
   getPortedComments(
     changeNum: NumericChangeId,
     revision: RevisionId
-  ): Promise<PathToCommentsInfoMap | undefined> {
+  ): Promise<{[path: string]: CommentInfo[]} | undefined> {
     // maintaining a custom error function so that errors do not surface in UI
     const errFn: ErrorCallback = (response?: Response | null) => {
       if (response)
@@ -2542,24 +2597,24 @@
     });
   }
 
-  getPortedDrafts(
+  async getPortedDrafts(
     changeNum: NumericChangeId,
     revision: RevisionId
-  ): Promise<PathToCommentsInfoMap | undefined> {
+  ): Promise<{[path: string]: DraftInfo[]} | undefined> {
     // maintaining a custom error function so that errors do not surface in UI
     const errFn: ErrorCallback = (response?: Response | null) => {
       if (response)
         console.info(`Fetching ported drafts failed, ${response.status}`);
     };
-    return this.getLoggedIn().then(loggedIn => {
-      if (!loggedIn) return {};
-      return this._getChangeURLAndFetch({
-        changeNum,
-        endpoint: '/ported_drafts/',
-        revision,
-        errFn,
-      });
-    });
+    const loggedIn = await this.getLoggedIn();
+    if (!loggedIn) return {};
+    const comments = (await this._getChangeURLAndFetch({
+      changeNum,
+      endpoint: '/ported_drafts/',
+      revision,
+      errFn,
+    })) as {[path: string]: CommentInfo[]} | undefined;
+    return addDraftProp(comments);
   }
 
   saveDiffDraft(
@@ -2657,16 +2712,16 @@
   }
 
   getCommitInfo(
-    project: RepoName,
+    repo: RepoName,
     commit: CommitId
   ): Promise<CommitInfo | undefined> {
     return this._restApiHelper.fetchJSON({
       url:
         '/projects/' +
-        encodeURIComponent(project) +
+        encodeURIComponent(repo) +
         '/commits/' +
         encodeURIComponent(commit),
-      anonymizedUrl: '/projects/*/comments/*',
+      anonymizedUrl: '/projects/*/commits/*',
     }) as Promise<CommitInfo | undefined>;
   }
 
@@ -2761,15 +2816,9 @@
 
   _changeBaseURL(
     changeNum: NumericChangeId,
-    revisionId?: RevisionId,
-    project?: RepoName
+    revisionId?: RevisionId
   ): Promise<string> {
-    // TODO(kaspern): For full slicer migration, app should warn with a call
-    // stack every time _changeBaseURL is called without a project.
-    const projectPromise = project
-      ? Promise.resolve(project)
-      : this.getFromProjectLookup(changeNum);
-    return projectPromise.then(project => {
+    return this.getFromProjectLookup(changeNum).then(project => {
       // TODO(TS): unclear why project can't be null here. Fix it
       let url = `/changes/${encodeURIComponent(
         project as RepoName
@@ -3053,37 +3102,48 @@
       });
   }
 
-  async setInProjectLookup(changeNum: NumericChangeId, project: RepoName) {
-    const lookupProject = await this._projectLookup[changeNum];
-    if (lookupProject && lookupProject !== project) {
-      console.warn(
-        'Change set with multiple project nums.' +
-          'One of them must be invalid.'
-      );
-    }
+  /**
+   * This can be called by the router, if the project can be determined from
+   * the URL. Or when handling a dashabord or a search response.
+   *
+   * Then we don't need to make a dedicated REST API call or have a fallback,
+   * if that fails.
+   */
+  setInProjectLookup(changeNum: NumericChangeId, project: RepoName) {
     this._projectLookup[changeNum] = Promise.resolve(project);
   }
 
   getFromProjectLookup(
     changeNum: NumericChangeId
   ): Promise<RepoName | undefined> {
-    const project = this._projectLookup[`${changeNum}`];
-    if (project) {
-      return project;
-    }
+    // Hopefully setInProjectLookup() has already been called. Then we don't
+    // have to make a dedicated REST API call to look up the project.
+    let projectPromise = this._projectLookup[changeNum];
+    if (projectPromise) return projectPromise;
 
-    const onError = (response?: Response | null) => firePageError(response);
+    // Ignore errors, because we have some dedicated fallback logic, see below.
+    const onError = () => {};
+    projectPromise = this.getChange(changeNum, onError).then(change => {
+      if (change?.project) return change.project;
 
-    const projectPromise = this.getChange(changeNum, onError).then(change => {
-      if (!change || !change.project) {
-        return;
+      // In the very rare case that the change index cannot provide an answer
+      // (e.g. stale index) we should check, if the router has called
+      // setInProjectLookup() in the meantime. Then we can fall back to that.
+      const currentProjectPromise = this._projectLookup[changeNum];
+      if (currentProjectPromise !== projectPromise) {
+        return currentProjectPromise;
       }
-      this.setInProjectLookup(changeNum, change.project);
-      return change.project;
+
+      // No luck. Without knowing the project we cannot proceed at all.
+      firePageError(
+        new Response(
+          `Failed to lookup the repo for change number ${changeNum}`,
+          {status: 404}
+        )
+      );
+      return undefined;
     });
-
     this._projectLookup[changeNum] = projectPromise;
-
     return projectPromise;
   }
 
@@ -3098,9 +3158,6 @@
 
   _getChangeURLAndSend(req: SendJSONChangeRequest): Promise<ParsedJSON>;
 
-  /**
-   * Alias for _changeBaseURL.then(send).
-   */
   _getChangeURLAndSend(
     req: SendChangeRequest
   ): Promise<ParsedJSON | Response | undefined> {
@@ -3128,9 +3185,6 @@
     });
   }
 
-  /**
-   * Alias for _changeBaseURL.then(_fetchJSON).
-   */
   _getChangeURLAndFetch(
     req: FetchChangeJSON,
     noAcceptHeader?: boolean
@@ -3243,13 +3297,13 @@
   }
 
   getDashboard(
-    project: RepoName,
+    repo: RepoName,
     dashboard: DashboardId,
     errFn?: ErrorCallback
   ): Promise<DashboardInfo | undefined> {
     const url =
       '/projects/' +
-      encodeURIComponent(project) +
+      encodeURIComponent(repo) +
       '/dashboards/' +
       encodeURIComponent(dashboard);
     return this._fetchSharedCacheURL({
@@ -3259,6 +3313,26 @@
     }) as Promise<DashboardInfo | undefined>;
   }
 
+  /**
+   * Get the docs base URL from either the server config or by probing.
+   *
+   * @return A promise that resolves with the docs base URL.
+   */
+  getDocsBaseUrl(config: ServerInfo | undefined): Promise<string | null> {
+    if (!this.getDocsBaseUrlCachedPromise) {
+      this.getDocsBaseUrlCachedPromise = new Promise(resolve => {
+        if (config?.gerrit?.doc_url) {
+          resolve(config.gerrit.doc_url);
+        } else {
+          this.probePath(getBaseUrl() + PROBE_PATH).then(ok => {
+            resolve(ok ? getBaseUrl() + DOCS_BASE_PATH : null);
+          });
+        }
+      });
+    }
+    return this.getDocsBaseUrlCachedPromise;
+  }
+
   getDocumentationSearches(filter: string): Promise<DocResult[] | undefined> {
     filter = filter.trim();
     const encodedFilter = encodeURIComponent(filter);
diff --git a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl_test.js b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl_test.js
deleted file mode 100644
index 3f517f4..0000000
--- a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl_test.js
+++ /dev/null
@@ -1,1578 +0,0 @@
-/**
- * @license
- * Copyright 2016 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-import '../../test/common-test-setup';
-import {
-  addListenerForTest,
-  mockPromise,
-  stubAuth,
-  waitEventLoop,
-} from '../../test/test-utils';
-import {GrReviewerUpdatesParser} from '../../elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser';
-import {
-  ListChangesOption,
-  listChangesOptionsToHex,
-} from '../../utils/change-util';
-import {getAppContext} from '../app-context';
-import {createChange} from '../../test/test-data-generators';
-import {CURRENT} from '../../utils/patch-set-util';
-import {
-  parsePrefixedJSON,
-  readResponsePayload,
-  JSON_PREFIX,
-  // eslint-disable-next-line max-len
-} from '../../elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
-import {GrRestApiServiceImpl} from './gr-rest-api-impl';
-import {CommentSide} from '../../constants/constants';
-import {EDIT, PARENT} from '../../types/common';
-import {assert} from '@open-wc/testing';
-import {getBaseUrl} from '../../utils/url-util';
-
-const EXPECTED_QUERY_OPTIONS = listChangesOptionsToHex(
-    ListChangesOption.CHANGE_ACTIONS,
-    ListChangesOption.CURRENT_ACTIONS,
-    ListChangesOption.CURRENT_REVISION,
-    ListChangesOption.DETAILED_LABELS,
-    ListChangesOption.SUBMIT_REQUIREMENTS
-);
-
-suite('gr-rest-api-service-impl tests', () => {
-  let element;
-
-  let ctr = 0;
-  let originalCanonicalPath;
-
-  setup(() => {
-    // Modify CANONICAL_PATH to effectively reset cache.
-    ctr += 1;
-    originalCanonicalPath = window.CANONICAL_PATH;
-    window.CANONICAL_PATH = `test${ctr}`;
-
-    const testJSON = ')]}\'\n{"hello": "bonjour"}';
-    sinon.stub(window, 'fetch').returns(
-        Promise.resolve({
-          ok: true,
-          text() {
-            return Promise.resolve(testJSON);
-          },
-        })
-    );
-    // fake auth
-    sinon
-        .stub(getAppContext().authService, 'authCheck')
-        .returns(Promise.resolve(true));
-    element = new GrRestApiServiceImpl(
-        getAppContext().authService,
-        getAppContext().flagsService
-    );
-    element._projectLookup = {};
-  });
-
-  teardown(() => {
-    window.CANONICAL_PATH = originalCanonicalPath;
-  });
-
-  test('parent diff comments are properly grouped', () => {
-    sinon.stub(element._restApiHelper, 'fetchJSON').callsFake(() =>
-      Promise.resolve({
-        '/COMMIT_MSG': [],
-        'sieve.go': [
-          {
-            updated: '2017-02-03 22:32:28.000000000',
-            message: 'this isn’t quite right',
-          },
-          {
-            side: CommentSide.PARENT,
-            message: 'how did this work in the first place?',
-            updated: '2017-02-03 22:33:28.000000000',
-          },
-        ],
-      })
-    );
-    return element
-        ._getDiffComments('42', '', undefined, PARENT, 1, 'sieve.go')
-        .then(obj => {
-          assert.equal(obj.baseComments.length, 1);
-          assert.deepEqual(obj.baseComments[0], {
-            side: CommentSide.PARENT,
-            message: 'how did this work in the first place?',
-            path: 'sieve.go',
-            updated: '2017-02-03 22:33:28.000000000',
-          });
-          assert.equal(obj.comments.length, 1);
-          assert.deepEqual(obj.comments[0], {
-            message: 'this isn’t quite right',
-            path: 'sieve.go',
-            updated: '2017-02-03 22:32:28.000000000',
-          });
-        });
-  });
-
-  test('_setRange', () => {
-    const comments = [
-      {
-        id: 1,
-        side: CommentSide.PARENT,
-        message: 'how did this work in the first place?',
-        updated: '2017-02-03 22:32:28.000000000',
-        range: {
-          start_line: 1,
-          start_character: 1,
-          end_line: 2,
-          end_character: 1,
-        },
-      },
-      {
-        id: 2,
-        in_reply_to: 1,
-        message: 'this isn’t quite right',
-        updated: '2017-02-03 22:33:28.000000000',
-      },
-    ];
-    const expectedResult = {
-      id: 2,
-      in_reply_to: 1,
-      message: 'this isn’t quite right',
-      updated: '2017-02-03 22:33:28.000000000',
-      range: {
-        start_line: 1,
-        start_character: 1,
-        end_line: 2,
-        end_character: 1,
-      },
-    };
-    const comment = comments[1];
-    assert.deepEqual(element._setRange(comments, comment), expectedResult);
-  });
-
-  test('_setRanges', () => {
-    const comments = [
-      {
-        id: 3,
-        in_reply_to: 2,
-        message: 'this isn’t quite right either',
-        updated: '2017-02-03 22:34:28.000000000',
-      },
-      {
-        id: 2,
-        in_reply_to: 1,
-        message: 'this isn’t quite right',
-        updated: '2017-02-03 22:33:28.000000000',
-      },
-      {
-        id: 1,
-        side: CommentSide.PARENT,
-        message: 'how did this work in the first place?',
-        updated: '2017-02-03 22:32:28.000000000',
-        range: {
-          start_line: 1,
-          start_character: 1,
-          end_line: 2,
-          end_character: 1,
-        },
-      },
-    ];
-    const expectedResult = [
-      {
-        id: 1,
-        side: CommentSide.PARENT,
-        message: 'how did this work in the first place?',
-        updated: '2017-02-03 22:32:28.000000000',
-        range: {
-          start_line: 1,
-          start_character: 1,
-          end_line: 2,
-          end_character: 1,
-        },
-      },
-      {
-        id: 2,
-        in_reply_to: 1,
-        message: 'this isn’t quite right',
-        updated: '2017-02-03 22:33:28.000000000',
-        range: {
-          start_line: 1,
-          start_character: 1,
-          end_line: 2,
-          end_character: 1,
-        },
-      },
-      {
-        id: 3,
-        in_reply_to: 2,
-        message: 'this isn’t quite right either',
-        updated: '2017-02-03 22:34:28.000000000',
-        range: {
-          start_line: 1,
-          start_character: 1,
-          end_line: 2,
-          end_character: 1,
-        },
-      },
-    ];
-    assert.deepEqual(element._setRanges(comments), expectedResult);
-  });
-
-  test('differing patch diff comments are properly grouped', () => {
-    sinon
-        .stub(element, 'getFromProjectLookup')
-        .returns(Promise.resolve('test'));
-    sinon.stub(element._restApiHelper, 'fetchJSON').callsFake(request => {
-      const url = request.url;
-      if (url === '/changes/test~42/revisions/1') {
-        return Promise.resolve({
-          '/COMMIT_MSG': [],
-          'sieve.go': [
-            {
-              message: 'this isn’t quite right',
-              updated: '2017-02-03 22:32:28.000000000',
-            },
-            {
-              side: CommentSide.PARENT,
-              message: 'how did this work in the first place?',
-              updated: '2017-02-03 22:33:28.000000000',
-            },
-          ],
-        });
-      } else if (url === '/changes/test~42/revisions/2') {
-        return Promise.resolve({
-          '/COMMIT_MSG': [],
-          'sieve.go': [
-            {
-              message: 'What on earth are you thinking, here?',
-              updated: '2017-02-03 22:32:28.000000000',
-            },
-            {
-              side: CommentSide.PARENT,
-              message: 'Yeah not sure how this worked either?',
-              updated: '2017-02-03 22:33:28.000000000',
-            },
-            {
-              message: '¯\\_(ツ)_/¯',
-              updated: '2017-02-04 22:33:28.000000000',
-            },
-          ],
-        });
-      }
-    });
-    return element
-        ._getDiffComments('42', '', undefined, 1, 2, 'sieve.go')
-        .then(obj => {
-          assert.equal(obj.baseComments.length, 1);
-          assert.deepEqual(obj.baseComments[0], {
-            message: 'this isn’t quite right',
-            path: 'sieve.go',
-            updated: '2017-02-03 22:32:28.000000000',
-          });
-          assert.equal(obj.comments.length, 2);
-          assert.deepEqual(obj.comments[0], {
-            message: 'What on earth are you thinking, here?',
-            path: 'sieve.go',
-            updated: '2017-02-03 22:32:28.000000000',
-          });
-          assert.deepEqual(obj.comments[1], {
-            message: '¯\\_(ツ)_/¯',
-            path: 'sieve.go',
-            updated: '2017-02-04 22:33:28.000000000',
-          });
-        });
-  });
-
-  test('server error', () => {
-    const getResponseObjectStub = sinon.stub(element, 'getResponseObject');
-    stubAuth('fetch').returns(Promise.resolve({ok: false}));
-    const serverErrorEventPromise = new Promise(resolve => {
-      addListenerForTest(document, 'server-error', resolve);
-    });
-
-    return Promise.all([
-      element._restApiHelper.fetchJSON({}).then(response => {
-        assert.isUndefined(response);
-        assert.isTrue(getResponseObjectStub.notCalled);
-      }),
-      serverErrorEventPromise,
-    ]);
-  });
-
-  test('legacy n,z key in change url is replaced', async () => {
-    sinon.stub(element, 'getConfig').callsFake(async () => {
-      return {};
-    });
-    const stub = sinon
-        .stub(element._restApiHelper, 'fetchJSON')
-        .returns(Promise.resolve([]));
-    await element.getChanges(1, null, 'n,z');
-    assert.equal(stub.lastCall.args[0].params.S, 0);
-  });
-
-  test('saveDiffPreferences invalidates cache line', () => {
-    const cacheKey = '/accounts/self/preferences.diff';
-    const sendStub = sinon.stub(element._restApiHelper, 'send');
-    element._cache.set(cacheKey, {tab_size: 4});
-    element.saveDiffPreferences({tab_size: 8});
-    assert.isTrue(sendStub.called);
-    assert.isFalse(element._restApiHelper._cache.has(cacheKey));
-  });
-
-  suite('getAccountSuggestions', () => {
-    let fetchStub;
-    setup(() => {
-      fetchStub = sinon
-          .stub(element._restApiHelper, 'fetch')
-          .returns(Promise.resolve(new Response()));
-    });
-
-    test('url with just email', () => {
-      element.getSuggestedAccounts('bro');
-      assert.isTrue(fetchStub.calledOnce);
-      assert.equal(
-          fetchStub.firstCall.args[0].url,
-          getBaseUrl() + '/accounts/?o=DETAILS&q=bro'
-      );
-    });
-
-    test('url with email and canSee changeId', () => {
-      element.getSuggestedAccounts('bro', undefined, 341682);
-      assert.isTrue(fetchStub.calledOnce);
-      assert.equal(
-          fetchStub.firstCall.args[0].url,
-          getBaseUrl() + '/accounts/?o=DETAILS&q=bro%20and%20cansee%3A341682'
-      );
-    });
-
-    test('url with email and canSee changeId and isActive', () => {
-      element.getSuggestedAccounts('bro', undefined, 341682, true);
-      assert.isTrue(fetchStub.calledOnce);
-      assert.equal(
-          fetchStub.firstCall.args[0].url,
-          getBaseUrl() + '/accounts/?o=DETAILS&q=bro%20and%20' +
-          'cansee%3A341682%20and%20is%3Aactive'
-      );
-    });
-  });
-
-  test('getAccount when resp is null does not add to cache', async () => {
-    const cacheKey = '/accounts/self/detail';
-    const stub = sinon
-        .stub(element._restApiHelper, 'fetchCacheURL')
-        .callsFake(() => Promise.resolve());
-
-    await element.getAccount();
-    assert.isTrue(stub.called);
-    assert.isFalse(element._restApiHelper._cache.has(cacheKey));
-
-    element._restApiHelper._cache.set(cacheKey, 'fake cache');
-    stub.lastCall.args[0].errFn();
-  });
-
-  test('getAccount does not add to cache when status is 403', async () => {
-    const cacheKey = '/accounts/self/detail';
-    const stub = sinon
-        .stub(element._restApiHelper, 'fetchCacheURL')
-        .callsFake(() => Promise.resolve());
-
-    await element.getAccount();
-    assert.isTrue(stub.called);
-    assert.isFalse(element._restApiHelper._cache.has(cacheKey));
-
-    element._cache.set(cacheKey, 'fake cache');
-    stub.lastCall.args[0].errFn({status: 403});
-  });
-
-  test('getAccount when resp is successful', async () => {
-    const cacheKey = '/accounts/self/detail';
-    const stub = sinon
-        .stub(element._restApiHelper, 'fetchCacheURL')
-        .callsFake(() => Promise.resolve());
-
-    await element.getAccount();
-
-    element._restApiHelper._cache.set(cacheKey, 'fake cache');
-    assert.isTrue(stub.called);
-    assert.equal(element._restApiHelper._cache.get(cacheKey), 'fake cache');
-    stub.lastCall.args[0].errFn({});
-  });
-
-  const preferenceSetup = function(testJSON, loggedIn) {
-    sinon
-        .stub(element, 'getLoggedIn')
-        .callsFake(() => Promise.resolve(loggedIn));
-    sinon
-        .stub(element._restApiHelper, 'fetchCacheURL')
-        .callsFake(() => Promise.resolve(testJSON));
-  };
-
-  test('getPreferences returns correctly logged in', () => {
-    const testJSON = {diff_view: 'SIDE_BY_SIDE'};
-    const loggedIn = true;
-
-    preferenceSetup(testJSON, loggedIn);
-
-    return element.getPreferences().then(obj => {
-      assert.equal(obj.diff_view, 'SIDE_BY_SIDE');
-    });
-  });
-
-  test('getPreferences returns correctly on larger screens logged in', () => {
-    const testJSON = {diff_view: 'UNIFIED_DIFF'};
-    const loggedIn = true;
-
-    preferenceSetup(testJSON, loggedIn);
-
-    return element.getPreferences().then(obj => {
-      assert.equal(obj.diff_view, 'UNIFIED_DIFF');
-    });
-  });
-
-  test('getPreferences returns correctly on larger screens no login', () => {
-    const testJSON = {diff_view: 'UNIFIED_DIFF'};
-    const loggedIn = false;
-
-    preferenceSetup(testJSON, loggedIn);
-
-    return element.getPreferences().then(obj => {
-      assert.equal(obj.diff_view, 'SIDE_BY_SIDE');
-    });
-  });
-
-  test('savPreferences normalizes download scheme', () => {
-    const sendStub = sinon
-        .stub(element._restApiHelper, 'send')
-        .returns(Promise.resolve(new Response()));
-    element.savePreferences({download_scheme: 'HTTP'});
-    assert.isTrue(sendStub.called);
-    assert.equal(sendStub.lastCall.args[0].body.download_scheme, 'http');
-  });
-
-  test('getDiffPreferences returns correct defaults', () => {
-    sinon.stub(element, 'getLoggedIn').callsFake(() => Promise.resolve(false));
-
-    return element.getDiffPreferences().then(obj => {
-      assert.equal(obj.context, 10);
-      assert.equal(obj.cursor_blink_rate, 0);
-      assert.equal(obj.font_size, 12);
-      assert.equal(obj.ignore_whitespace, 'IGNORE_NONE');
-      assert.equal(obj.line_length, 100);
-      assert.equal(obj.line_wrapping, false);
-      assert.equal(obj.show_line_endings, true);
-      assert.equal(obj.show_tabs, true);
-      assert.equal(obj.show_whitespace_errors, true);
-      assert.equal(obj.syntax_highlighting, true);
-      assert.equal(obj.tab_size, 8);
-    });
-  });
-
-  test('saveDiffPreferences set show_tabs to false', () => {
-    const sendStub = sinon.stub(element._restApiHelper, 'send');
-    element.saveDiffPreferences({show_tabs: false});
-    assert.isTrue(sendStub.called);
-    assert.equal(sendStub.lastCall.args[0].body.show_tabs, false);
-  });
-
-  test('getEditPreferences returns correct defaults', () => {
-    sinon.stub(element, 'getLoggedIn').callsFake(() => Promise.resolve(false));
-
-    return element.getEditPreferences().then(obj => {
-      assert.equal(obj.auto_close_brackets, false);
-      assert.equal(obj.cursor_blink_rate, 0);
-      assert.equal(obj.hide_line_numbers, false);
-      assert.equal(obj.hide_top_menu, false);
-      assert.equal(obj.indent_unit, 2);
-      assert.equal(obj.indent_with_tabs, false);
-      assert.equal(obj.key_map_type, 'DEFAULT');
-      assert.equal(obj.line_length, 100);
-      assert.equal(obj.line_wrapping, false);
-      assert.equal(obj.match_brackets, true);
-      assert.equal(obj.show_base, false);
-      assert.equal(obj.show_tabs, true);
-      assert.equal(obj.show_whitespace_errors, true);
-      assert.equal(obj.syntax_highlighting, true);
-      assert.equal(obj.tab_size, 8);
-      assert.equal(obj.theme, 'DEFAULT');
-    });
-  });
-
-  test('saveEditPreferences set show_tabs to false', () => {
-    const sendStub = sinon.stub(element._restApiHelper, 'send');
-    element.saveEditPreferences({show_tabs: false});
-    assert.isTrue(sendStub.called);
-    assert.equal(sendStub.lastCall.args[0].body.show_tabs, false);
-  });
-
-  test('confirmEmail', () => {
-    const sendStub = sinon.spy(element._restApiHelper, 'send');
-    element.confirmEmail('foo');
-    assert.isTrue(sendStub.calledOnce);
-    assert.equal(sendStub.lastCall.args[0].method, 'PUT');
-    assert.equal(sendStub.lastCall.args[0].url, '/config/server/email.confirm');
-    assert.deepEqual(sendStub.lastCall.args[0].body, {token: 'foo'});
-  });
-
-  test('setPreferredAccountEmail', () => {
-    const email1 = 'email1@example.com';
-    const email2 = 'email2@example.com';
-    const encodedEmail = encodeURIComponent(email2);
-    const sendStub = sinon.stub(element._restApiHelper, 'send').resolves();
-    element._cache.set('/accounts/self/emails', [
-      {email: email1, preferred: true},
-      {email: email2, preferred: false},
-    ]);
-
-    return element.setPreferredAccountEmail(email2).then(() => {
-      assert.isTrue(sendStub.calledOnce);
-      assert.equal(sendStub.lastCall.args[0].method, 'PUT');
-      assert.equal(sendStub.lastCall.args[0].url,
-          `/accounts/self/emails/${encodedEmail}/preferred`
-      );
-      assert.deepEqual(
-          element._restApiHelper._cache.get('/accounts/self/emails'), [
-            {email: email1, preferred: false},
-            {email: email2, preferred: true},
-          ]
-      );
-    });
-  });
-
-  test('setAccountStatus', () => {
-    const sendStub = sinon
-        .stub(element._restApiHelper, 'send')
-        .returns(Promise.resolve('OOO'));
-    element._cache.set('/accounts/self/detail', {});
-    return element.setAccountStatus('OOO').then(() => {
-      assert.isTrue(sendStub.calledOnce);
-      assert.equal(sendStub.lastCall.args[0].method, 'PUT');
-      assert.equal(sendStub.lastCall.args[0].url, '/accounts/self/status');
-      assert.deepEqual(sendStub.lastCall.args[0].body, {status: 'OOO'});
-      assert.deepEqual(
-          element._restApiHelper._cache.get('/accounts/self/detail'),
-          {status: 'OOO'}
-      );
-    });
-  });
-
-  suite('draft comments', () => {
-    test('_sendDiffDraftRequest pending requests tracked', () => {
-      const obj = element._pendingRequests;
-      sinon
-          .stub(element, '_getChangeURLAndSend')
-          .callsFake(() => mockPromise());
-      assert.notOk(element.hasPendingDiffDrafts());
-
-      element._sendDiffDraftRequest(null, null, null, {});
-      assert.equal(obj.sendDiffDraft.length, 1);
-      assert.isTrue(!!element.hasPendingDiffDrafts());
-
-      element._sendDiffDraftRequest(null, null, null, {});
-      assert.equal(obj.sendDiffDraft.length, 2);
-      assert.isTrue(!!element.hasPendingDiffDrafts());
-
-      for (const promise of obj.sendDiffDraft) {
-        promise.resolve();
-      }
-
-      return element.awaitPendingDiffDrafts().then(() => {
-        assert.equal(obj.sendDiffDraft.length, 0);
-        assert.isFalse(!!element.hasPendingDiffDrafts());
-      });
-    });
-
-    suite('_failForCreate200', () => {
-      test('_sendDiffDraftRequest checks for 200 on create', () => {
-        const sendPromise = Promise.resolve();
-        sinon.stub(element, '_getChangeURLAndSend').returns(sendPromise);
-        const failStub = sinon
-            .stub(element, '_failForCreate200')
-            .returns(Promise.resolve());
-        return element._sendDiffDraftRequest('PUT', 123, 4, {}).then(() => {
-          assert.isTrue(failStub.calledOnce);
-          assert.isTrue(failStub.calledWithExactly(sendPromise));
-        });
-      });
-
-      test('_sendDiffDraftRequest no checks for 200 on non create', () => {
-        sinon.stub(element, '_getChangeURLAndSend').returns(Promise.resolve());
-        const failStub = sinon
-            .stub(element, '_failForCreate200')
-            .returns(Promise.resolve());
-        return element
-            ._sendDiffDraftRequest('PUT', 123, 4, {id: '123'})
-            .then(() => {
-              assert.isFalse(failStub.called);
-            });
-      });
-
-      test('_failForCreate200 fails on 200', () => {
-        const result = {
-          ok: true,
-          status: 200,
-          headers: {
-            entries: () => [
-              ['Set-CoOkiE', 'secret'],
-              ['Innocuous', 'hello'],
-            ],
-          },
-        };
-        return element
-            ._failForCreate200(Promise.resolve(result))
-            .then(() => {
-              assert.fail('Error expected.');
-            })
-            .catch(e => {
-              assert.isOk(e);
-              assert.include(e.message, 'Saving draft resulted in HTTP 200');
-              assert.include(e.message, 'hello');
-              assert.notInclude(e.message, 'secret');
-            });
-      });
-
-      test('_failForCreate200 does not fail on 201', () => {
-        const result = {
-          ok: true,
-          status: 201,
-          headers: {entries: () => []},
-        };
-        return element._failForCreate200(Promise.resolve(result));
-      });
-    });
-  });
-
-  test('saveChangeEdit', () => {
-    element._projectLookup = {1: Promise.resolve('test')};
-    const change_num = '1';
-    const file_name = 'index.php';
-    const file_contents = '<?php';
-    sinon
-        .stub(element._restApiHelper, 'send')
-        .returns(Promise.resolve([change_num, file_name, file_contents]));
-    sinon
-        .stub(element, 'getResponseObject')
-        .returns(Promise.resolve([change_num, file_name, file_contents]));
-    element._cache.set('/changes/' + change_num + '/edit/' + file_name, {});
-    return element
-        .saveChangeEdit(change_num, file_name, file_contents)
-        .then(() => {
-          assert.isTrue(element._restApiHelper.send.calledOnce);
-          assert.equal(
-              element._restApiHelper.send.lastCall.args[0].method,
-              'PUT'
-          );
-          assert.equal(
-              element._restApiHelper.send.lastCall.args[0].url,
-              '/changes/test~1/edit/' + file_name
-          );
-          assert.equal(
-              element._restApiHelper.send.lastCall.args[0].body,
-              file_contents
-          );
-        });
-  });
-
-  test('putChangeCommitMessage', () => {
-    element._projectLookup = {1: Promise.resolve('test')};
-    const change_num = '1';
-    const message = 'this is a commit message';
-    sinon
-        .stub(element._restApiHelper, 'send')
-        .returns(Promise.resolve([change_num, message]));
-    sinon
-        .stub(element, 'getResponseObject')
-        .returns(Promise.resolve([change_num, message]));
-    element._cache.set('/changes/' + change_num + '/message', {});
-    return element.putChangeCommitMessage(change_num, message).then(() => {
-      assert.isTrue(element._restApiHelper.send.calledOnce);
-      assert.equal(element._restApiHelper.send.lastCall.args[0].method, 'PUT');
-      assert.equal(
-          element._restApiHelper.send.lastCall.args[0].url,
-          '/changes/test~1/message'
-      );
-      assert.deepEqual(element._restApiHelper.send.lastCall.args[0].body, {
-        message,
-      });
-    });
-  });
-
-  test('deleteChangeCommitMessage', () => {
-    element._projectLookup = {1: Promise.resolve('test')};
-    const change_num = '1';
-    const messageId = 'abc';
-    sinon
-        .stub(element._restApiHelper, 'send')
-        .returns(Promise.resolve([change_num, messageId]));
-    sinon
-        .stub(element, 'getResponseObject')
-        .returns(Promise.resolve([change_num, messageId]));
-    return element.deleteChangeCommitMessage(change_num, messageId).then(() => {
-      assert.isTrue(element._restApiHelper.send.calledOnce);
-      assert.equal(
-          element._restApiHelper.send.lastCall.args[0].method,
-          'DELETE'
-      );
-      assert.equal(
-          element._restApiHelper.send.lastCall.args[0].url,
-          '/changes/test~1/messages/abc'
-      );
-    });
-  });
-
-  test('startWorkInProgress', () => {
-    const sendStub = sinon
-        .stub(element, '_getChangeURLAndSend')
-        .returns(Promise.resolve('ok'));
-    element.startWorkInProgress('42');
-    assert.isTrue(sendStub.calledOnce);
-    assert.equal(sendStub.lastCall.args[0].changeNum, '42');
-    assert.equal(sendStub.lastCall.args[0].method, 'POST');
-    assert.isNotOk(sendStub.lastCall.args[0].patchNum);
-    assert.equal(sendStub.lastCall.args[0].endpoint, '/wip');
-    assert.deepEqual(sendStub.lastCall.args[0].body, {});
-
-    element.startWorkInProgress('42', 'revising...');
-    assert.isTrue(sendStub.calledTwice);
-    assert.equal(sendStub.lastCall.args[0].changeNum, '42');
-    assert.equal(sendStub.lastCall.args[0].method, 'POST');
-    assert.isNotOk(sendStub.lastCall.args[0].patchNum);
-    assert.equal(sendStub.lastCall.args[0].endpoint, '/wip');
-    assert.deepEqual(sendStub.lastCall.args[0].body, {
-      message: 'revising...',
-    });
-  });
-
-  test('deleteComment', () => {
-    const sendStub = sinon
-        .stub(element, '_getChangeURLAndSend')
-        .returns(Promise.resolve('some response'));
-    return element
-        .deleteComment('foo', 'bar', '01234', 'removal reason')
-        .then(response => {
-          assert.equal(response, 'some response');
-          assert.isTrue(sendStub.calledOnce);
-          assert.equal(sendStub.lastCall.args[0].changeNum, 'foo');
-          assert.equal(sendStub.lastCall.args[0].method, 'POST');
-          assert.equal(sendStub.lastCall.args[0].patchNum, 'bar');
-          assert.equal(
-              sendStub.lastCall.args[0].endpoint,
-              '/comments/01234/delete'
-          );
-          assert.deepEqual(sendStub.lastCall.args[0].body, {
-            reason: 'removal reason',
-          });
-        });
-  });
-
-  test('createRepo encodes name', () => {
-    const sendStub = sinon
-        .stub(element._restApiHelper, 'send')
-        .returns(Promise.resolve());
-    return element.createRepo({name: 'x/y'}).then(() => {
-      assert.isTrue(sendStub.calledOnce);
-      assert.equal(sendStub.lastCall.args[0].url, '/projects/x%2Fy');
-    });
-  });
-
-  test('queryChangeFiles', () => {
-    const fetchStub = sinon
-        .stub(element, '_getChangeURLAndFetch')
-        .returns(Promise.resolve());
-    return element.queryChangeFiles('42', EDIT, 'test/path.js').then(() => {
-      assert.equal(fetchStub.lastCall.args[0].changeNum, '42');
-      assert.equal(
-          fetchStub.lastCall.args[0].endpoint,
-          '/files?q=test%2Fpath.js'
-      );
-      assert.equal(fetchStub.lastCall.args[0].revision, EDIT);
-    });
-  });
-
-  test('normal use', () => {
-    const defaultQuery = '';
-
-    assert.equal(
-        element._getReposUrl('test', 25).toString(),
-        [false, '/projects/?n=26&S=0&d=&m=test'].toString()
-    );
-
-    assert.equal(
-        element._getReposUrl(null, 25).toString(),
-        [false, `/projects/?n=26&S=0&d=&m=${defaultQuery}`].toString()
-    );
-
-    assert.equal(
-        element._getReposUrl('test', 25, 25).toString(),
-        [false, '/projects/?n=26&S=25&d=&m=test'].toString()
-    );
-
-    assert.equal(
-        element._getReposUrl('inname:test', 25, 25).toString(),
-        [true, '/projects/?n=26&S=25&query=inname%3Atest'].toString()
-    );
-  });
-
-  test('invalidateReposCache', () => {
-    const url = '/projects/?n=26&S=0&query=test';
-
-    element._cache.set(url, {});
-
-    element.invalidateReposCache();
-
-    assert.isUndefined(element._sharedFetchPromises[url]);
-
-    assert.isFalse(element._cache.has(url));
-  });
-
-  test('invalidateAccountsCache', () => {
-    const url = '/accounts/self/detail';
-
-    element._cache.set(url, {});
-
-    element.invalidateAccountsCache();
-
-    assert.isUndefined(element._sharedFetchPromises[url]);
-
-    assert.isFalse(element._cache.has(url));
-  });
-
-  suite('getRepos', () => {
-    const defaultQuery = '';
-    let fetchCacheURLStub;
-    setup(() => {
-      fetchCacheURLStub = sinon
-          .stub(element._restApiHelper, 'fetchCacheURL')
-          .returns(Promise.resolve([]));
-    });
-
-    test('normal use', () => {
-      element.getRepos('test', 25);
-      assert.equal(
-          fetchCacheURLStub.lastCall.args[0].url,
-          '/projects/?n=26&S=0&d=&m=test'
-      );
-
-      element.getRepos(null, 25);
-      assert.equal(
-          fetchCacheURLStub.lastCall.args[0].url,
-          `/projects/?n=26&S=0&d=&m=${defaultQuery}`
-      );
-
-      element.getRepos('test', 25, 25);
-      assert.equal(
-          fetchCacheURLStub.lastCall.args[0].url,
-          '/projects/?n=26&S=25&d=&m=test'
-      );
-    });
-
-    test('with blank', () => {
-      element.getRepos('test/test', 25);
-      assert.equal(
-          fetchCacheURLStub.lastCall.args[0].url,
-          '/projects/?n=26&S=0&d=&m=test%2Ftest'
-      );
-    });
-
-    test('with hyphen', () => {
-      element.getRepos('foo-bar', 25);
-      assert.equal(
-          fetchCacheURLStub.lastCall.args[0].url,
-          '/projects/?n=26&S=0&d=&m=foo-bar'
-      );
-    });
-
-    test('with leading hyphen', () => {
-      element.getRepos('-bar', 25);
-      assert.equal(
-          fetchCacheURLStub.lastCall.args[0].url,
-          '/projects/?n=26&S=0&d=&m=-bar'
-      );
-    });
-
-    test('with trailing hyphen', () => {
-      element.getRepos('foo-bar-', 25);
-      assert.equal(
-          fetchCacheURLStub.lastCall.args[0].url,
-          '/projects/?n=26&S=0&d=&m=foo-bar-'
-      );
-    });
-
-    test('with underscore', () => {
-      element.getRepos('foo_bar', 25);
-      assert.equal(
-          fetchCacheURLStub.lastCall.args[0].url,
-          '/projects/?n=26&S=0&d=&m=foo_bar'
-      );
-    });
-
-    test('with underscore', () => {
-      element.getRepos('foo_bar', 25);
-      assert.equal(
-          fetchCacheURLStub.lastCall.args[0].url,
-          '/projects/?n=26&S=0&d=&m=foo_bar'
-      );
-    });
-
-    test('hyphen only', () => {
-      element.getRepos('-', 25);
-      assert.equal(
-          fetchCacheURLStub.lastCall.args[0].url,
-          `/projects/?n=26&S=0&d=&m=-`
-      );
-    });
-
-    test('using query', () => {
-      element.getRepos('description:project', 25);
-      assert.equal(
-          fetchCacheURLStub.lastCall.args[0].url,
-          `/projects/?n=26&S=0&query=description%3Aproject`
-      );
-    });
-  });
-
-  test('_getGroupsUrl normal use', () => {
-    assert.equal(element._getGroupsUrl('test', 25), '/groups/?n=26&S=0&m=test');
-
-    assert.equal(element._getGroupsUrl(null, 25), '/groups/?n=26&S=0');
-
-    assert.equal(
-        element._getGroupsUrl('test', 25, 25),
-        '/groups/?n=26&S=25&m=test'
-    );
-  });
-
-  test('invalidateGroupsCache', () => {
-    const url = '/groups/?n=26&S=0&m=test';
-
-    element._cache.set(url, {});
-
-    element.invalidateGroupsCache();
-
-    assert.isUndefined(element._sharedFetchPromises[url]);
-
-    assert.isFalse(element._cache.has(url));
-  });
-
-  suite('getGroups', () => {
-    let fetchCacheURLStub;
-    setup(() => {
-      fetchCacheURLStub = sinon.stub(element._restApiHelper, 'fetchCacheURL');
-    });
-
-    test('normal use', () => {
-      element.getGroups('test', 25);
-      assert.equal(
-          fetchCacheURLStub.lastCall.args[0].url,
-          '/groups/?n=26&S=0&m=test'
-      );
-
-      element.getGroups(null, 25);
-      assert.equal(fetchCacheURLStub.lastCall.args[0].url, '/groups/?n=26&S=0');
-
-      element.getGroups('test', 25, 25);
-      assert.equal(
-          fetchCacheURLStub.lastCall.args[0].url,
-          '/groups/?n=26&S=25&m=test'
-      );
-    });
-
-    test('regex', () => {
-      element.getGroups('^test.*', 25);
-      assert.equal(
-          fetchCacheURLStub.lastCall.args[0].url,
-          '/groups/?n=26&S=0&r=%5Etest.*'
-      );
-
-      element.getGroups('^test.*', 25, 25);
-      assert.equal(
-          fetchCacheURLStub.lastCall.args[0].url,
-          '/groups/?n=26&S=25&r=%5Etest.*'
-      );
-    });
-  });
-
-  test('gerrit auth is used', () => {
-    stubAuth('fetch').returns(Promise.resolve());
-    element._restApiHelper.fetchJSON({url: 'foo'});
-    assert(getAppContext().authService.fetch.called);
-  });
-
-  test('getSuggestedAccounts does not return _fetchJSON', () => {
-    const _fetchJSONSpy = sinon.spy(element._restApiHelper, 'fetchJSON');
-    return element.getSuggestedAccounts().then(accts => {
-      assert.isFalse(_fetchJSONSpy.called);
-      assert.equal(accts.length, 0);
-    });
-  });
-
-  test('_fetchJSON gets called by getSuggestedAccounts', () => {
-    const _fetchJSONStub = sinon
-        .stub(element._restApiHelper, 'fetchJSON')
-        .callsFake(() => Promise.resolve());
-    return element.getSuggestedAccounts('own').then(() => {
-      assert.deepEqual(_fetchJSONStub.lastCall.args[0].params, {
-        q: 'own',
-        o: 'DETAILS',
-      });
-    });
-  });
-
-  suite('getChangeDetail', () => {
-    suite('change detail options', () => {
-      setup(() => {
-        sinon
-            .stub(element, '_getChangeDetail')
-            .callsFake(async (changeNum, options) => {
-              return {changeNum, options};
-            });
-      });
-
-      test('signed pushes disabled', async () => {
-        sinon.stub(element, 'getConfig').callsFake(async () => {
-          return {};
-        });
-        const {changeNum, options} = await element.getChangeDetail(123);
-        assert.strictEqual(123, changeNum);
-        assert.isNotOk(
-            parseInt(options, 16) & (1 << ListChangesOption.PUSH_CERTIFICATES)
-        );
-      });
-
-      test('signed pushes enabled', async () => {
-        sinon.stub(element, 'getConfig').callsFake(async () => {
-          return {receive: {enable_signed_push: true}};
-        });
-        const {changeNum, options} = await element.getChangeDetail(123);
-        assert.strictEqual(123, changeNum);
-        assert.ok(
-            parseInt(options, 16) & (1 << ListChangesOption.PUSH_CERTIFICATES)
-        );
-      });
-    });
-
-    test('GrReviewerUpdatesParser.parse is used', () => {
-      sinon
-          .stub(GrReviewerUpdatesParser, 'parse')
-          .returns(Promise.resolve('foo'));
-      return element.getChangeDetail(42).then(result => {
-        assert.isTrue(GrReviewerUpdatesParser.parse.calledOnce);
-        assert.equal(result, 'foo');
-      });
-    });
-
-    test('_getChangeDetail passes params to ETags decorator', () => {
-      const changeNum = 4321;
-      element._projectLookup[changeNum] = Promise.resolve('test');
-      const expectedUrl =
-        window.CANONICAL_PATH + '/changes/test~4321/detail?O=516714';
-      sinon.stub(element._etags, 'getOptions');
-      sinon.stub(element._etags, 'collect');
-      return element._getChangeDetail(changeNum, '516714').then(() => {
-        assert.isTrue(element._etags.getOptions.calledWithExactly(expectedUrl));
-        assert.equal(element._etags.collect.lastCall.args[0], expectedUrl);
-      });
-    });
-
-    test('_getChangeDetail calls errFn on 500', () => {
-      const errFn = sinon.stub();
-      sinon.stub(element, 'getChangeActionURL').returns(Promise.resolve(''));
-      sinon
-          .stub(element._restApiHelper, 'fetchRawJSON')
-          .returns(Promise.resolve({ok: false, status: 500}));
-      return element._getChangeDetail(123, '516714', errFn).then(() => {
-        assert.isTrue(errFn.called);
-      });
-    });
-
-    test('_getChangeDetail populates _projectLookup', async () => {
-      sinon.stub(element, 'getChangeActionURL').returns(Promise.resolve(''));
-      sinon.stub(element._restApiHelper, 'fetchRawJSON').returns(
-          Promise.resolve({
-            ok: true,
-            status: 200,
-            text: () => Promise.resolve(`)]}'{"_number":1,"project":"test"}`),
-          })
-      );
-      await element._getChangeDetail(1, '516714');
-      assert.equal(Object.keys(element._projectLookup).length, 1);
-      const project = await element._projectLookup[1];
-      assert.equal(project, 'test');
-    });
-
-    suite('_getChangeDetail ETag cache', () => {
-      let requestUrl;
-      let mockResponseSerial;
-      let collectSpy;
-
-      setup(() => {
-        requestUrl = '/foo/bar';
-        const mockResponse = {foo: 'bar', baz: 42};
-        mockResponseSerial = JSON_PREFIX + JSON.stringify(mockResponse);
-        sinon.stub(element._restApiHelper, 'urlWithParams').returns(requestUrl);
-        sinon
-            .stub(element, 'getChangeActionURL')
-            .returns(Promise.resolve(requestUrl));
-        collectSpy = sinon.spy(element._etags, 'collect');
-      });
-
-      test('contributes to cache', () => {
-        const getPayloadSpy = sinon.spy(element._etags, 'getCachedPayload');
-        sinon.stub(element._restApiHelper, 'fetchRawJSON').returns(
-            Promise.resolve({
-              text: () => Promise.resolve(mockResponseSerial),
-              status: 200,
-              ok: true,
-            })
-        );
-
-        return element._getChangeDetail(123, '516714').then(detail => {
-          assert.isFalse(getPayloadSpy.called);
-          assert.isTrue(collectSpy.calledOnce);
-          const cachedResponse = element._etags.getCachedPayload(requestUrl);
-          assert.equal(cachedResponse, mockResponseSerial);
-        });
-      });
-
-      test('uses cache on HTTP 304', () => {
-        const getPayloadStub = sinon.stub(element._etags, 'getCachedPayload');
-        getPayloadStub.returns(mockResponseSerial);
-        sinon.stub(element._restApiHelper, 'fetchRawJSON').returns(
-            Promise.resolve({
-              text: () => Promise.resolve(''),
-              status: 304,
-              ok: true,
-            })
-        );
-
-        return element._getChangeDetail(123, '').then(detail => {
-          assert.isFalse(collectSpy.called);
-          assert.isTrue(getPayloadStub.calledOnce);
-        });
-      });
-    });
-  });
-
-  test('setInProjectLookup', async () => {
-    await element.setInProjectLookup('test', 'project');
-    const project = await element.getFromProjectLookup('test');
-    assert.deepEqual(project, 'project');
-  });
-
-  suite('getFromProjectLookup', () => {
-    test('getChange succeeds, no project', async () => {
-      sinon.stub(element, 'getChange').returns(Promise.resolve(null));
-      const val = await element.getFromProjectLookup();
-      assert.strictEqual(val, undefined);
-    });
-
-    test('getChange succeeds with project', () => {
-      sinon
-          .stub(element, 'getChange')
-          .returns(Promise.resolve({project: 'project'}));
-      const projectLookup = element.getFromProjectLookup('test');
-      return projectLookup.then(val => {
-        assert.equal(val, 'project');
-        assert.deepEqual(element._projectLookup, {test: projectLookup});
-      });
-    });
-  });
-
-  suite('getChanges populates _projectLookup', () => {
-    test('multiple queries', async () => {
-      sinon.stub(element._restApiHelper, 'fetchJSON').returns(
-          Promise.resolve([
-            [
-              {_number: 1, project: 'test'},
-              {_number: 2, project: 'test'},
-            ],
-            [{_number: 3, project: 'test/test'}],
-          ])
-      );
-      // When opt_query instanceof Array, _fetchJSON returns
-      // Array<Array<Object>>.
-      await element.getChangesForMultipleQueries(null, []);
-      assert.equal(Object.keys(element._projectLookup).length, 3);
-      const project1 = await element.getFromProjectLookup(1);
-      assert.equal(project1, 'test');
-      const project2 = await element.getFromProjectLookup(2);
-      assert.equal(project2, 'test');
-      const project3 = await element.getFromProjectLookup(3);
-      assert.equal(project3, 'test/test');
-    });
-
-    test('no query', async () => {
-      sinon.stub(element._restApiHelper, 'fetchJSON').returns(
-          Promise.resolve([
-            {_number: 1, project: 'test'},
-            {_number: 2, project: 'test'},
-            {_number: 3, project: 'test/test'},
-          ])
-      );
-
-      // When opt_query !instanceof Array, _fetchJSON returns
-      // Array<Object>.
-      await element.getChanges();
-      assert.equal(Object.keys(element._projectLookup).length, 3);
-      const project1 = await element.getFromProjectLookup(1);
-      assert.equal(project1, 'test');
-      const project2 = await element.getFromProjectLookup(2);
-      assert.equal(project2, 'test');
-      const project3 = await element.getFromProjectLookup(3);
-      assert.equal(project3, 'test/test');
-    });
-  });
-
-  test('getDetailedChangesWithActions', async () => {
-    const c1 = createChange();
-    c1._number = 1;
-    const c2 = createChange();
-    c2._number = 2;
-    const getChangesStub = sinon
-        .stub(element, 'getChanges')
-        .callsFake((changesPerPage, query, offset, options) => {
-          assert.isUndefined(changesPerPage);
-          assert.strictEqual(query, 'change:1 OR change:2');
-          assert.isUndefined(offset);
-          assert.strictEqual(options, EXPECTED_QUERY_OPTIONS);
-          return Promise.resolve([]);
-        });
-    await element.getDetailedChangesWithActions([c1._number, c2._number]);
-    assert.isTrue(getChangesStub.calledOnce);
-  });
-
-  test('_getChangeURLAndFetch', () => {
-    element._projectLookup = {1: Promise.resolve('test')};
-    const fetchStub = sinon
-        .stub(element._restApiHelper, 'fetchJSON')
-        .returns(Promise.resolve());
-    const req = {changeNum: 1, endpoint: '/test', revision: 1};
-    return element._getChangeURLAndFetch(req).then(() => {
-      assert.equal(
-          fetchStub.lastCall.args[0].url,
-          '/changes/test~1/revisions/1/test'
-      );
-    });
-  });
-
-  test('_getChangeURLAndSend', () => {
-    element._projectLookup = {1: Promise.resolve('test')};
-    const sendStub = sinon
-        .stub(element._restApiHelper, 'send')
-        .returns(Promise.resolve());
-
-    const req = {
-      changeNum: 1,
-      method: 'POST',
-      patchNum: 1,
-      endpoint: '/test',
-    };
-    return element._getChangeURLAndSend(req).then(() => {
-      assert.isTrue(sendStub.calledOnce);
-      assert.equal(sendStub.lastCall.args[0].method, 'POST');
-      assert.equal(
-          sendStub.lastCall.args[0].url,
-          '/changes/test~1/revisions/1/test'
-      );
-    });
-  });
-
-  suite('reading responses', () => {
-    test('_readResponsePayload', async () => {
-      const mockObject = {foo: 'bar', baz: 'foo'};
-      const serial = JSON_PREFIX + JSON.stringify(mockObject);
-      const mockResponse = {text: () => Promise.resolve(serial)};
-      const payload = await readResponsePayload(mockResponse);
-      assert.deepEqual(payload.parsed, mockObject);
-      assert.equal(payload.raw, serial);
-    });
-
-    test('_parsePrefixedJSON', () => {
-      const obj = {x: 3, y: {z: 4}, w: 23};
-      const serial = JSON_PREFIX + JSON.stringify(obj);
-      const result = parsePrefixedJSON(serial);
-      assert.deepEqual(result, obj);
-    });
-  });
-
-  test('setChangeTopic', () => {
-    const sendSpy = sinon.spy(element, '_getChangeURLAndSend');
-    return element.setChangeTopic(123, 'foo-bar').then(() => {
-      assert.isTrue(sendSpy.calledOnce);
-      assert.deepEqual(sendSpy.lastCall.args[0].body, {topic: 'foo-bar'});
-    });
-  });
-
-  test('setChangeHashtag', () => {
-    const sendSpy = sinon.spy(element, '_getChangeURLAndSend');
-    return element.setChangeHashtag(123, 'foo-bar').then(() => {
-      assert.isTrue(sendSpy.calledOnce);
-      assert.equal(sendSpy.lastCall.args[0].body, 'foo-bar');
-    });
-  });
-
-  test('generateAccountHttpPassword', () => {
-    const sendSpy = sinon.spy(element._restApiHelper, 'send');
-    return element.generateAccountHttpPassword().then(() => {
-      assert.isTrue(sendSpy.calledOnce);
-      assert.deepEqual(sendSpy.lastCall.args[0].body, {generate: true});
-    });
-  });
-
-  suite('getChangeFiles', () => {
-    test('patch only', () => {
-      const fetchStub = sinon
-          .stub(element, '_getChangeURLAndFetch')
-          .returns(Promise.resolve());
-      const range = {basePatchNum: PARENT, patchNum: 2};
-      return element.getChangeFiles(123, range).then(() => {
-        assert.isTrue(fetchStub.calledOnce);
-        assert.equal(fetchStub.lastCall.args[0].revision, 2);
-        assert.isNotOk(fetchStub.lastCall.args[0].params);
-      });
-    });
-
-    test('simple range', () => {
-      const fetchStub = sinon
-          .stub(element, '_getChangeURLAndFetch')
-          .returns(Promise.resolve());
-      const range = {basePatchNum: 4, patchNum: 5};
-      return element.getChangeFiles(123, range).then(() => {
-        assert.isTrue(fetchStub.calledOnce);
-        assert.equal(fetchStub.lastCall.args[0].revision, 5);
-        assert.isOk(fetchStub.lastCall.args[0].params);
-        assert.equal(fetchStub.lastCall.args[0].params.base, 4);
-        assert.isNotOk(fetchStub.lastCall.args[0].params.parent);
-      });
-    });
-
-    test('parent index', () => {
-      const fetchStub = sinon
-          .stub(element, '_getChangeURLAndFetch')
-          .returns(Promise.resolve());
-      const range = {basePatchNum: -3, patchNum: 5};
-      return element.getChangeFiles(123, range).then(() => {
-        assert.isTrue(fetchStub.calledOnce);
-        assert.equal(fetchStub.lastCall.args[0].revision, 5);
-        assert.isOk(fetchStub.lastCall.args[0].params);
-        assert.isNotOk(fetchStub.lastCall.args[0].params.base);
-        assert.equal(fetchStub.lastCall.args[0].params.parent, 3);
-      });
-    });
-  });
-
-  suite('getDiff', () => {
-    test('patchOnly', () => {
-      const fetchStub = sinon
-          .stub(element, '_getChangeURLAndFetch')
-          .returns(Promise.resolve());
-      return element.getDiff(123, PARENT, 2, 'foo/bar.baz').then(() => {
-        assert.isTrue(fetchStub.calledOnce);
-        assert.equal(fetchStub.lastCall.args[0].revision, 2);
-        assert.isOk(fetchStub.lastCall.args[0].params);
-        assert.isNotOk(fetchStub.lastCall.args[0].params.parent);
-        assert.isNotOk(fetchStub.lastCall.args[0].params.base);
-      });
-    });
-
-    test('simple range', () => {
-      const fetchStub = sinon
-          .stub(element, '_getChangeURLAndFetch')
-          .returns(Promise.resolve());
-      return element.getDiff(123, 4, 5, 'foo/bar.baz').then(() => {
-        assert.isTrue(fetchStub.calledOnce);
-        assert.equal(fetchStub.lastCall.args[0].revision, 5);
-        assert.isOk(fetchStub.lastCall.args[0].params);
-        assert.isNotOk(fetchStub.lastCall.args[0].params.parent);
-        assert.equal(fetchStub.lastCall.args[0].params.base, 4);
-      });
-    });
-
-    test('parent index', () => {
-      const fetchStub = sinon
-          .stub(element, '_getChangeURLAndFetch')
-          .returns(Promise.resolve());
-      return element.getDiff(123, -3, 5, 'foo/bar.baz').then(() => {
-        assert.isTrue(fetchStub.calledOnce);
-        assert.equal(fetchStub.lastCall.args[0].revision, 5);
-        assert.isOk(fetchStub.lastCall.args[0].params);
-        assert.isNotOk(fetchStub.lastCall.args[0].params.base);
-        assert.equal(fetchStub.lastCall.args[0].params.parent, 3);
-      });
-    });
-  });
-
-  test('getDashboard', () => {
-    const fetchCacheURLStub = sinon.stub(
-        element._restApiHelper,
-        'fetchCacheURL'
-    );
-    element.getDashboard('gerrit/project', 'default:main');
-    assert.isTrue(fetchCacheURLStub.calledOnce);
-    assert.equal(
-        fetchCacheURLStub.lastCall.args[0].url,
-        '/projects/gerrit%2Fproject/dashboards/default%3Amain'
-    );
-  });
-
-  test('getFileContent', () => {
-    sinon.stub(element, '_getChangeURLAndSend').returns(
-        Promise.resolve({
-          ok: 'true',
-          headers: {
-            get(header) {
-              if (header === 'X-FYI-Content-Type') {
-                return 'text/java';
-              }
-            },
-          },
-        })
-    );
-
-    sinon
-        .stub(element, 'getResponseObject')
-        .returns(Promise.resolve('new content'));
-
-    const edit = element.getFileContent('1', 'tst/path', 'EDIT').then(res => {
-      assert.deepEqual(res, {
-        content: 'new content',
-        type: 'text/java',
-        ok: true,
-      });
-    });
-
-    const normal = element.getFileContent('1', 'tst/path', '3').then(res => {
-      assert.deepEqual(res, {
-        content: 'new content',
-        type: 'text/java',
-        ok: true,
-      });
-    });
-
-    return Promise.all([edit, normal]);
-  });
-
-  test('getFileContent suppresses 404s', () => {
-    const res = {status: 404};
-    const spy = sinon.spy();
-    addListenerForTest(document, 'server-error', spy);
-    sinon
-        .stub(getAppContext().authService, 'fetch')
-        .returns(Promise.resolve(res));
-    sinon.stub(element, '_changeBaseURL').returns(Promise.resolve(''));
-    return element
-        .getFileContent('1', 'tst/path', '1')
-        .then(() => waitEventLoop())
-        .then(() => {
-          assert.isFalse(spy.called);
-
-          res.status = 500;
-          return element.getFileContent('1', 'tst/path', '1');
-        })
-        .then(() => {
-          assert.isTrue(spy.called);
-          assert.notEqual(spy.lastCall.args[0].detail.response.status, 404);
-        });
-  });
-
-  test('getChangeFilesOrEditFiles is edit-sensitive', () => {
-    const fn = element.getChangeOrEditFiles.bind(element);
-    const getChangeFilesStub = sinon
-        .stub(element, 'getChangeFiles')
-        .returns(Promise.resolve({}));
-    const getChangeEditFilesStub = sinon
-        .stub(element, 'getChangeEditFiles')
-        .returns(Promise.resolve({}));
-
-    return fn('1', {patchNum: EDIT}).then(() => {
-      assert.isTrue(getChangeEditFilesStub.calledOnce);
-      assert.isFalse(getChangeFilesStub.called);
-      return fn('1', {patchNum: '1'}).then(() => {
-        assert.isTrue(getChangeEditFilesStub.calledOnce);
-        assert.isTrue(getChangeFilesStub.calledOnce);
-      });
-    });
-  });
-
-  test('_fetch forwards request and logs', () => {
-    const logStub = sinon.stub(element._restApiHelper, '_logCall');
-    const response = {status: 404, text: sinon.stub()};
-    const url = 'my url';
-    const fetchOptions = {method: 'DELETE'};
-    sinon.stub(element.authService, 'fetch').returns(Promise.resolve(response));
-    const startTime = 123;
-    sinon.stub(Date, 'now').returns(startTime);
-    const req = {url, fetchOptions};
-    return element._restApiHelper.fetch(req).then(() => {
-      assert.isTrue(logStub.calledOnce);
-      assert.isTrue(logStub.calledWith(req, startTime, response.status));
-      assert.isFalse(response.text.called);
-    });
-  });
-
-  test('_logCall only reports requests with anonymized URLss', async () => {
-    sinon.stub(Date, 'now').returns(200);
-    const handler = sinon.stub();
-    addListenerForTest(document, 'gr-rpc-log', handler);
-
-    element._restApiHelper._logCall({url: 'url'}, 100, 200);
-    assert.isFalse(handler.called);
-
-    element._restApiHelper._logCall(
-        {url: 'url', anonymizedUrl: 'not url'},
-        100,
-        200
-    );
-    await waitEventLoop();
-    assert.isTrue(handler.calledOnce);
-  });
-
-  test('ported comment errors do not trigger error dialog', () => {
-    const change = createChange();
-    const handler = sinon.stub();
-    addListenerForTest(document, 'server-error', handler);
-    sinon.stub(element._restApiHelper, 'fetchJSON').returns(
-        Promise.resolve({
-          ok: false,
-        })
-    );
-
-    element.getPortedComments(change._number, CURRENT);
-
-    assert.isFalse(handler.called);
-  });
-
-  test('ported drafts are not requested user is not logged in', () => {
-    const change = createChange();
-    sinon.stub(element, 'getLoggedIn').returns(Promise.resolve(false));
-    const getChangeURLAndFetchStub = sinon.stub(
-        element,
-        '_getChangeURLAndFetch'
-    );
-
-    element.getPortedDrafts(change._number, CURRENT);
-
-    assert.isFalse(getChangeURLAndFetchStub.called);
-  });
-
-  test('saveChangeStarred', async () => {
-    sinon
-        .stub(element, 'getFromProjectLookup')
-        .returns(Promise.resolve('test'));
-    const sendStub = sinon
-        .stub(element._restApiHelper, 'send')
-        .returns(Promise.resolve());
-
-    await element.saveChangeStarred(123, true);
-    assert.isTrue(sendStub.calledOnce);
-    assert.deepEqual(sendStub.lastCall.args[0], {
-      method: 'PUT',
-      url: '/accounts/self/starred.changes/test~123',
-      anonymizedUrl: '/accounts/self/starred.changes/*',
-    });
-
-    await element.saveChangeStarred(456, false);
-    assert.isTrue(sendStub.calledTwice);
-    assert.deepEqual(sendStub.lastCall.args[0], {
-      method: 'DELETE',
-      url: '/accounts/self/starred.changes/test~456',
-      anonymizedUrl: '/accounts/self/starred.changes/*',
-    });
-  });
-});
diff --git a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl_test.ts b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl_test.ts
new file mode 100644
index 0000000..7abcb0d
--- /dev/null
+++ b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl_test.ts
@@ -0,0 +1,1677 @@
+/**
+ * @license
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../test/common-test-setup';
+import {
+  addListenerForTest,
+  assertFails,
+  MockPromise,
+  mockPromise,
+  waitEventLoop,
+} from '../../test/test-utils';
+import {GrReviewerUpdatesParser} from '../../elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser';
+import {
+  ListChangesOption,
+  listChangesOptionsToHex,
+} from '../../utils/change-util';
+import {
+  createAccountDetailWithId,
+  createChange,
+  createComment,
+  createGerritInfo,
+  createParsedChange,
+  createServerInfo,
+} from '../../test/test-data-generators';
+import {CURRENT} from '../../utils/patch-set-util';
+import {
+  parsePrefixedJSON,
+  readResponsePayload,
+  JSON_PREFIX,
+} from '../../elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
+import {GrRestApiServiceImpl} from './gr-rest-api-impl';
+import {
+  CommentSide,
+  createDefaultEditPrefs,
+  HttpMethod,
+} from '../../constants/constants';
+import {
+  BasePatchSetNum,
+  ChangeInfo,
+  ChangeMessageId,
+  CommentInfo,
+  DashboardId,
+  DiffPreferenceInput,
+  EDIT,
+  EditPreferencesInfo,
+  Hashtag,
+  HashtagsInput,
+  NumericChangeId,
+  PARENT,
+  ParsedJSON,
+  PatchSetNum,
+  PreferencesInfo,
+  RepoName,
+  RevisionId,
+  RevisionPatchSetNum,
+  RobotCommentInfo,
+  ServerInfo,
+  Timestamp,
+  UrlEncodedCommentId,
+} from '../../types/common';
+import {assert} from '@open-wc/testing';
+import {AuthService} from '../gr-auth/gr-auth';
+import {GrAuthMock} from '../gr-auth/gr-auth_mock';
+import {getBaseUrl} from '../../utils/url-util';
+
+const EXPECTED_QUERY_OPTIONS = listChangesOptionsToHex(
+  ListChangesOption.CHANGE_ACTIONS,
+  // Current actions can be costly to calculate (e.g submit action)
+  // They are not used in bulk actions.
+  // ListChangesOption.CURRENT_ACTIONS,
+  ListChangesOption.CURRENT_REVISION,
+  ListChangesOption.DETAILED_LABELS,
+  ListChangesOption.SUBMIT_REQUIREMENTS
+);
+
+suite('gr-rest-api-service-impl tests', () => {
+  let element: GrRestApiServiceImpl;
+  let authService: AuthService;
+
+  let ctr = 0;
+  let originalCanonicalPath: string | undefined;
+
+  setup(() => {
+    // Modify CANONICAL_PATH to effectively reset cache.
+    ctr += 1;
+    originalCanonicalPath = window.CANONICAL_PATH;
+    window.CANONICAL_PATH = `test${ctr}`;
+
+    const testJSON = ')]}\'\n{"hello": "bonjour"}';
+    sinon.stub(window, 'fetch').resolves(new Response(testJSON));
+    // fake auth
+    authService = new GrAuthMock();
+    sinon.stub(authService, 'authCheck').resolves(true);
+    element = new GrRestApiServiceImpl(authService);
+
+    element._projectLookup = {};
+  });
+
+  teardown(() => {
+    window.CANONICAL_PATH = originalCanonicalPath;
+  });
+
+  test('parent diff comments are properly grouped', async () => {
+    sinon.stub(element._restApiHelper, 'fetchJSON').resolves({
+      '/COMMIT_MSG': [],
+      'sieve.go': [
+        {
+          updated: '2017-02-03 22:32:28.000000000',
+          message: 'this isn’t quite right',
+        },
+        {
+          side: CommentSide.PARENT,
+          message: 'how did this work in the first place?',
+          updated: '2017-02-03 22:33:28.000000000',
+        },
+      ],
+    } as unknown as ParsedJSON);
+    const obj = await element._getDiffComments(
+      42 as NumericChangeId,
+      '/comments',
+      undefined,
+      PARENT,
+      1 as PatchSetNum,
+      'sieve.go'
+    );
+    assert.equal(obj.baseComments.length, 1);
+    assert.deepEqual(obj.baseComments[0], {
+      side: CommentSide.PARENT,
+      message: 'how did this work in the first place?',
+      path: 'sieve.go',
+      updated: '2017-02-03 22:33:28.000000000' as Timestamp,
+    } as RobotCommentInfo);
+    assert.equal(obj.comments.length, 1);
+    assert.deepEqual(obj.comments[0], {
+      message: 'this isn’t quite right',
+      path: 'sieve.go',
+      updated: '2017-02-03 22:32:28.000000000' as Timestamp,
+    } as RobotCommentInfo);
+  });
+
+  test('_setRange', () => {
+    const comments: CommentInfo[] = [
+      {
+        id: '1' as UrlEncodedCommentId,
+        side: CommentSide.PARENT,
+        message: 'how did this work in the first place?',
+        updated: '2017-02-03 22:32:28.000000000' as Timestamp,
+        range: {
+          start_line: 1,
+          start_character: 1,
+          end_line: 2,
+          end_character: 1,
+        },
+      },
+      {
+        id: '2' as UrlEncodedCommentId,
+        in_reply_to: '1' as UrlEncodedCommentId,
+        message: 'this isn’t quite right',
+        updated: '2017-02-03 22:33:28.000000000' as Timestamp,
+      },
+    ];
+    const expectedResult: CommentInfo = {
+      id: '2' as UrlEncodedCommentId,
+      in_reply_to: '1' as UrlEncodedCommentId,
+      message: 'this isn’t quite right',
+      updated: '2017-02-03 22:33:28.000000000' as Timestamp,
+      range: {
+        start_line: 1,
+        start_character: 1,
+        end_line: 2,
+        end_character: 1,
+      },
+    };
+    const comment = comments[1];
+    assert.deepEqual(element._setRange(comments, comment), expectedResult);
+  });
+
+  test('_setRanges', () => {
+    const comments: CommentInfo[] = [
+      {
+        id: '3' as UrlEncodedCommentId,
+        in_reply_to: '2' as UrlEncodedCommentId,
+        message: 'this isn’t quite right either',
+        updated: '2017-02-03 22:34:28.000000000' as Timestamp,
+      },
+      {
+        id: '2' as UrlEncodedCommentId,
+        in_reply_to: '1' as UrlEncodedCommentId,
+        message: 'this isn’t quite right',
+        updated: '2017-02-03 22:33:28.000000000' as Timestamp,
+      },
+      {
+        id: '1' as UrlEncodedCommentId,
+        side: CommentSide.PARENT,
+        message: 'how did this work in the first place?',
+        updated: '2017-02-03 22:32:28.000000000' as Timestamp,
+        range: {
+          start_line: 1,
+          start_character: 1,
+          end_line: 2,
+          end_character: 1,
+        },
+      },
+    ];
+    const expectedResult: CommentInfo[] = [
+      {
+        id: '1' as UrlEncodedCommentId,
+        side: CommentSide.PARENT,
+        message: 'how did this work in the first place?',
+        updated: '2017-02-03 22:32:28.000000000' as Timestamp,
+        range: {
+          start_line: 1,
+          start_character: 1,
+          end_line: 2,
+          end_character: 1,
+        },
+      },
+      {
+        id: '2' as UrlEncodedCommentId,
+        in_reply_to: '1' as UrlEncodedCommentId,
+        message: 'this isn’t quite right',
+        updated: '2017-02-03 22:33:28.000000000' as Timestamp,
+        range: {
+          start_line: 1,
+          start_character: 1,
+          end_line: 2,
+          end_character: 1,
+        },
+      },
+      {
+        id: '3' as UrlEncodedCommentId,
+        in_reply_to: '2' as UrlEncodedCommentId,
+        message: 'this isn’t quite right either',
+        updated: '2017-02-03 22:34:28.000000000' as Timestamp,
+        range: {
+          start_line: 1,
+          start_character: 1,
+          end_line: 2,
+          end_character: 1,
+        },
+      },
+    ];
+    assert.deepEqual(element._setRanges(comments), expectedResult);
+  });
+
+  test('differing patch diff comments are properly grouped', async () => {
+    sinon.stub(element, 'getFromProjectLookup').resolves('test' as RepoName);
+    sinon.stub(element._restApiHelper, 'fetchJSON').callsFake(async request => {
+      const url = request.url;
+      if (url === '/changes/test~42/revisions/1/comments') {
+        return {
+          '/COMMIT_MSG': [],
+          'sieve.go': [
+            {
+              message: 'this isn’t quite right',
+              updated: '2017-02-03 22:32:28.000000000',
+            },
+            {
+              side: CommentSide.PARENT,
+              message: 'how did this work in the first place?',
+              updated: '2017-02-03 22:33:28.000000000',
+            },
+          ],
+        } as unknown as ParsedJSON;
+      } else if (url === '/changes/test~42/revisions/2/comments') {
+        return {
+          '/COMMIT_MSG': [],
+          'sieve.go': [
+            {
+              message: 'What on earth are you thinking, here?',
+              updated: '2017-02-03 22:32:28.000000000',
+            },
+            {
+              side: CommentSide.PARENT,
+              message: 'Yeah not sure how this worked either?',
+              updated: '2017-02-03 22:33:28.000000000',
+            },
+            {
+              message: '¯\\_(ツ)_/¯',
+              updated: '2017-02-04 22:33:28.000000000',
+            },
+          ],
+        } as unknown as ParsedJSON;
+      }
+      return undefined;
+    });
+    const obj = await element._getDiffComments(
+      42 as NumericChangeId,
+      '/comments',
+      undefined,
+      1 as BasePatchSetNum,
+      2 as PatchSetNum,
+      'sieve.go'
+    );
+    assert.equal(obj.baseComments.length, 1);
+    assert.deepEqual(obj.baseComments[0], {
+      message: 'this isn’t quite right',
+      path: 'sieve.go',
+      updated: '2017-02-03 22:32:28.000000000' as Timestamp,
+    } as RobotCommentInfo);
+    assert.equal(obj.comments.length, 2);
+    assert.deepEqual(obj.comments[0], {
+      message: 'What on earth are you thinking, here?',
+      path: 'sieve.go',
+      updated: '2017-02-03 22:32:28.000000000' as Timestamp,
+    } as RobotCommentInfo);
+    assert.deepEqual(obj.comments[1], {
+      message: '¯\\_(ツ)_/¯',
+      path: 'sieve.go',
+      updated: '2017-02-04 22:33:28.000000000' as Timestamp,
+    } as RobotCommentInfo);
+  });
+
+  test('server error', async () => {
+    const getResponseObjectStub = sinon.stub(element, 'getResponseObject');
+    sinon
+      .stub(authService, 'fetch')
+      .resolves(new Response(undefined, {status: 502}));
+    const serverErrorEventPromise = new Promise(resolve => {
+      addListenerForTest(document, 'server-error', resolve);
+    });
+    const response = await element._restApiHelper.fetchJSON({url: ''});
+    assert.isUndefined(response);
+    assert.isTrue(getResponseObjectStub.notCalled);
+    await serverErrorEventPromise;
+  });
+
+  test('legacy n,z key in change url is replaced', async () => {
+    const stub = sinon
+      .stub(element._restApiHelper, 'fetchJSON')
+      .resolves([] as unknown as ParsedJSON);
+    await element.getChanges(1, undefined, 'n,z');
+    assert.equal(stub.lastCall.args[0].params!.S, 0);
+  });
+
+  test('saveDiffPreferences invalidates cache line', () => {
+    const cacheKey = '/accounts/self/preferences.diff';
+    const sendStub = sinon.stub(element._restApiHelper, 'send');
+    element._cache.set(cacheKey, {tab_size: 4} as unknown as ParsedJSON);
+    element.saveDiffPreferences({
+      tab_size: 8,
+      ignore_whitespace: 'IGNORE_NONE',
+    });
+    assert.isTrue(sendStub.called);
+    assert.isFalse(element._cache.has(cacheKey));
+  });
+
+  suite('getAccountSuggestions', () => {
+    let fetchStub: sinon.SinonStub;
+    setup(() => {
+      fetchStub = sinon
+        .stub(element._restApiHelper, 'fetch')
+        .resolves(new Response());
+    });
+
+    test('url with just email', () => {
+      element.getSuggestedAccounts('bro');
+      assert.isTrue(fetchStub.calledOnce);
+      assert.equal(
+        fetchStub.firstCall.args[0].url,
+        `${getBaseUrl()}/accounts/?o=DETAILS&q=%22bro%22`
+      );
+    });
+
+    test('url with email and canSee changeId', () => {
+      element.getSuggestedAccounts('bro', undefined, 341682 as NumericChangeId);
+      assert.isTrue(fetchStub.calledOnce);
+      assert.equal(
+        fetchStub.firstCall.args[0].url,
+        `${getBaseUrl()}/accounts/?o=DETAILS&q=%22bro%22%20and%20cansee%3A341682`
+      );
+    });
+
+    test('url with email and canSee changeId and isActive', () => {
+      element.getSuggestedAccounts(
+        'bro',
+        undefined,
+        341682 as NumericChangeId,
+        true
+      );
+      assert.isTrue(fetchStub.calledOnce);
+      assert.equal(
+        fetchStub.firstCall.args[0].url,
+        `${getBaseUrl()}/accounts/?o=DETAILS&q=%22bro%22%20and%20cansee%3A341682%20and%20is%3Aactive`
+      );
+    });
+  });
+
+  test('getAccount when resp is undefined clears cache', async () => {
+    const cacheKey = '/accounts/self/detail';
+    const account = createAccountDetailWithId();
+    element._cache.set(cacheKey, account);
+    const stub = sinon
+      .stub(element._restApiHelper, 'fetchCacheURL')
+      .callsFake(async req => {
+        req.errFn!(undefined);
+        return undefined;
+      });
+    assert.isTrue(element._cache.has(cacheKey));
+
+    await element.getAccount();
+    assert.isTrue(stub.called);
+    assert.isFalse(element._cache.has(cacheKey));
+  });
+
+  test('getAccount when status is 403 clears cache', async () => {
+    const cacheKey = '/accounts/self/detail';
+    const account = createAccountDetailWithId();
+    element._cache.set(cacheKey, account);
+    const stub = sinon
+      .stub(element._restApiHelper, 'fetchCacheURL')
+      .callsFake(async req => {
+        req.errFn!(new Response(undefined, {status: 403}));
+        return undefined;
+      });
+    assert.isTrue(element._cache.has(cacheKey));
+
+    await element.getAccount();
+    assert.isTrue(stub.called);
+    assert.isFalse(element._cache.has(cacheKey));
+  });
+
+  test('getAccount when resp is successful updates cache', async () => {
+    const cacheKey = '/accounts/self/detail';
+    const account = createAccountDetailWithId();
+    const stub = sinon
+      .stub(element._restApiHelper, 'fetchCacheURL')
+      .callsFake(async () => {
+        element._cache.set(cacheKey, account);
+        return undefined;
+      });
+    assert.isFalse(element._cache.has(cacheKey));
+
+    await element.getAccount();
+    assert.isTrue(stub.called);
+    assert.equal(element._cache.get(cacheKey), account);
+  });
+
+  const preferenceSetup = function (testJSON: unknown, loggedIn: boolean) {
+    sinon
+      .stub(element, 'getLoggedIn')
+      .callsFake(() => Promise.resolve(loggedIn));
+    sinon
+      .stub(element._restApiHelper, 'fetchCacheURL')
+      .callsFake(() => Promise.resolve(testJSON as ParsedJSON));
+  };
+
+  test('getPreferences returns correctly logged in', async () => {
+    const testJSON = {diff_view: 'SIDE_BY_SIDE'};
+    const loggedIn = true;
+
+    preferenceSetup(testJSON, loggedIn);
+
+    const obj = await element.getPreferences();
+    assert.equal(obj!.diff_view, 'SIDE_BY_SIDE');
+  });
+
+  test('getPreferences returns correctly on larger screens logged in', async () => {
+    const testJSON = {diff_view: 'UNIFIED_DIFF'};
+    const loggedIn = true;
+
+    preferenceSetup(testJSON, loggedIn);
+
+    const obj = await element.getPreferences();
+    assert.equal(obj!.diff_view, 'UNIFIED_DIFF');
+  });
+
+  test('getPreferences returns correctly on larger screens no login', async () => {
+    const testJSON = {diff_view: 'UNIFIED_DIFF'};
+    const loggedIn = false;
+
+    preferenceSetup(testJSON, loggedIn);
+
+    const obj = await element.getPreferences();
+    assert.equal(obj!.diff_view, 'SIDE_BY_SIDE');
+  });
+
+  test('savPreferences normalizes download scheme', () => {
+    const sendStub = sinon
+      .stub(element._restApiHelper, 'send')
+      .resolves(new Response());
+    element.savePreferences({download_scheme: 'HTTP'});
+    assert.isTrue(sendStub.called);
+    assert.equal(
+      (sendStub.lastCall.args[0].body as Partial<PreferencesInfo>)
+        .download_scheme,
+      'http'
+    );
+  });
+
+  test('getDiffPreferences returns correct defaults', async () => {
+    sinon.stub(element, 'getLoggedIn').callsFake(() => Promise.resolve(false));
+
+    const obj = (await element.getDiffPreferences())!;
+    assert.equal(obj.context, 10);
+    assert.equal(obj.cursor_blink_rate, 0);
+    assert.equal(obj.font_size, 12);
+    assert.equal(obj.ignore_whitespace, 'IGNORE_NONE');
+    assert.equal(obj.line_length, 100);
+    assert.equal(obj.line_wrapping, false);
+    assert.equal(obj.show_line_endings, true);
+    assert.equal(obj.show_tabs, true);
+    assert.equal(obj.show_whitespace_errors, true);
+    assert.equal(obj.syntax_highlighting, true);
+    assert.equal(obj.tab_size, 8);
+  });
+
+  test('saveDiffPreferences set show_tabs to false', () => {
+    const sendStub = sinon.stub(element._restApiHelper, 'send');
+    element.saveDiffPreferences({
+      show_tabs: false,
+      ignore_whitespace: 'IGNORE_NONE',
+    });
+    assert.isTrue(sendStub.called);
+    assert.equal(
+      (sendStub.lastCall.args[0].body as Partial<DiffPreferenceInput>)
+        .show_tabs,
+      false
+    );
+  });
+
+  test('getEditPreferences returns correct defaults', async () => {
+    sinon.stub(element, 'getLoggedIn').callsFake(() => Promise.resolve(false));
+
+    const obj = (await element.getEditPreferences())!;
+    assert.equal(obj.auto_close_brackets, false);
+    assert.equal(obj.cursor_blink_rate, 0);
+    assert.equal(obj.hide_line_numbers, false);
+    assert.equal(obj.hide_top_menu, false);
+    assert.equal(obj.indent_unit, 2);
+    assert.equal(obj.indent_with_tabs, false);
+    assert.equal(obj.key_map_type, 'DEFAULT');
+    assert.equal(obj.line_length, 100);
+    assert.equal(obj.line_wrapping, false);
+    assert.equal(obj.match_brackets, true);
+    assert.equal(obj.show_base, false);
+    assert.equal(obj.show_tabs, true);
+    assert.equal(obj.show_whitespace_errors, true);
+    assert.equal(obj.syntax_highlighting, true);
+    assert.equal(obj.tab_size, 8);
+    assert.equal(obj.theme, 'DEFAULT');
+  });
+
+  test('saveEditPreferences set show_tabs to false', () => {
+    const sendStub = sinon.stub(element._restApiHelper, 'send');
+    element.saveEditPreferences({
+      ...createDefaultEditPrefs(),
+      show_tabs: false,
+    });
+    assert.isTrue(sendStub.called);
+    assert.equal(
+      (sendStub.lastCall.args[0].body as EditPreferencesInfo).show_tabs,
+      false
+    );
+  });
+
+  test('confirmEmail', () => {
+    const sendStub = sinon.spy(element._restApiHelper, 'send');
+    element.confirmEmail('foo');
+    assert.isTrue(sendStub.calledOnce);
+    assert.equal(sendStub.lastCall.args[0].method, HttpMethod.PUT);
+    assert.equal(sendStub.lastCall.args[0].url, '/config/server/email.confirm');
+    assert.deepEqual(sendStub.lastCall.args[0].body, {token: 'foo'});
+  });
+
+  test('setPreferredAccountEmail', async () => {
+    const email1 = 'email1@example.com';
+    const email2 = 'email2@example.com';
+    const encodedEmail = encodeURIComponent(email2);
+    const sendStub = sinon.stub(element._restApiHelper, 'send').resolves();
+    element._cache.set('/accounts/self/emails', [
+      {email: email1, preferred: true},
+      {email: email2, preferred: false},
+    ]);
+
+    await element.setPreferredAccountEmail(email2);
+    assert.isTrue(sendStub.calledOnce);
+    assert.equal(sendStub.lastCall.args[0].method, HttpMethod.PUT);
+    assert.equal(
+      sendStub.lastCall.args[0].url,
+      `/accounts/self/emails/${encodedEmail}/preferred`
+    );
+    assert.deepEqual(element._cache.get('/accounts/self/emails'), [
+      {email: email1, preferred: false},
+      {email: email2, preferred: true},
+    ]);
+  });
+
+  test('setAccountStatus', async () => {
+    const sendStub = sinon
+      .stub(element._restApiHelper, 'send')
+      .resolves('OOO' as unknown as ParsedJSON);
+    element._cache.set('/accounts/self/detail', createAccountDetailWithId());
+    await element.setAccountStatus('OOO');
+    assert.isTrue(sendStub.calledOnce);
+    assert.equal(sendStub.lastCall.args[0].method, HttpMethod.PUT);
+    assert.equal(sendStub.lastCall.args[0].url, '/accounts/self/status');
+    assert.deepEqual(sendStub.lastCall.args[0].body, {status: 'OOO'});
+    assert.deepEqual(
+      element._cache.get('/accounts/self/detail')!.status,
+      'OOO'
+    );
+  });
+
+  suite('draft comments', () => {
+    test('_sendDiffDraftRequest pending requests tracked', async () => {
+      const obj = element._pendingRequests;
+      sinon
+        .stub(element, '_getChangeURLAndSend')
+        .callsFake(() => mockPromise());
+      assert.notOk(element.hasPendingDiffDrafts());
+
+      element._sendDiffDraftRequest(
+        HttpMethod.PUT,
+        123 as NumericChangeId,
+        1 as PatchSetNum,
+        {}
+      );
+      assert.equal(obj.sendDiffDraft.length, 1);
+      assert.isTrue(!!element.hasPendingDiffDrafts());
+
+      element._sendDiffDraftRequest(
+        HttpMethod.PUT,
+        123 as NumericChangeId,
+        1 as PatchSetNum,
+        {}
+      );
+      assert.equal(obj.sendDiffDraft.length, 2);
+      assert.isTrue(!!element.hasPendingDiffDrafts());
+
+      for (const promise of obj.sendDiffDraft) {
+        (promise as MockPromise<void>).resolve();
+      }
+
+      await element.awaitPendingDiffDrafts();
+      assert.equal(obj.sendDiffDraft.length, 0);
+      assert.isFalse(!!element.hasPendingDiffDrafts());
+    });
+
+    suite('_failForCreate200', () => {
+      test('_sendDiffDraftRequest checks for 200 on create', async () => {
+        const sendPromise = Promise.resolve({} as unknown as ParsedJSON);
+        sinon.stub(element, '_getChangeURLAndSend').returns(sendPromise);
+        const failStub = sinon.stub(element, '_failForCreate200').resolves();
+        await element._sendDiffDraftRequest(
+          HttpMethod.PUT,
+          123 as NumericChangeId,
+          4 as PatchSetNum,
+          {}
+        );
+        assert.isTrue(failStub.calledOnce);
+        assert.isTrue(failStub.calledWithExactly(sendPromise));
+      });
+
+      test('_sendDiffDraftRequest no checks for 200 on non create', async () => {
+        sinon.stub(element, '_getChangeURLAndSend').resolves();
+        const failStub = sinon.stub(element, '_failForCreate200').resolves();
+        await element._sendDiffDraftRequest(
+          HttpMethod.PUT,
+          123 as NumericChangeId,
+          4 as PatchSetNum,
+          {
+            id: '123' as UrlEncodedCommentId,
+          }
+        );
+        assert.isFalse(failStub.called);
+      });
+
+      test('_failForCreate200 fails on 200', async () => {
+        const result = new Response(undefined, {
+          status: 200,
+          headers: {
+            'Set-CoOkiE': 'secret',
+            Innocuous: 'hello',
+          },
+        });
+        const error = await assertFails<Error>(
+          element._failForCreate200(Promise.resolve(result))
+        );
+        assert.isOk(error);
+        assert.include(error.message, 'Saving draft resulted in HTTP 200');
+        assert.include(error.message, 'hello');
+        assert.notInclude(error.message, 'secret');
+      });
+
+      test('_failForCreate200 does not fail on 201', () => {
+        const result = new Response(undefined, {status: 201});
+        return element._failForCreate200(Promise.resolve(result));
+      });
+    });
+  });
+
+  test('saveChangeEdit', async () => {
+    element._projectLookup = {1: Promise.resolve('test' as RepoName)};
+    const change_num = 1 as NumericChangeId;
+    const file_name = 'index.php';
+    const file_contents = '<?php';
+    const sendStub = sinon
+      .stub(element._restApiHelper, 'send')
+      .resolves([
+        change_num,
+        file_name,
+        file_contents,
+      ] as unknown as ParsedJSON);
+    sinon
+      .stub(element, 'getResponseObject')
+      .resolves([
+        change_num,
+        file_name,
+        file_contents,
+      ] as unknown as ParsedJSON);
+    element._cache.set(
+      `/changes/${change_num}/edit/${file_name}`,
+      {} as unknown as ParsedJSON
+    );
+    await element.saveChangeEdit(change_num, file_name, file_contents);
+    assert.isTrue(sendStub.calledOnce);
+    assert.equal(sendStub.lastCall.args[0].method, HttpMethod.PUT);
+    assert.equal(
+      sendStub.lastCall.args[0].url,
+      '/changes/test~1/edit/' + file_name
+    );
+    assert.equal(sendStub.lastCall.args[0].body, file_contents);
+  });
+
+  test('putChangeCommitMessage', async () => {
+    element._projectLookup = {1: Promise.resolve('test' as RepoName)};
+    const change_num = 1 as NumericChangeId;
+    const message = 'this is a commit message';
+    const sendStub = sinon
+      .stub(element._restApiHelper, 'send')
+      .resolves([change_num, message] as unknown as ParsedJSON);
+    sinon
+      .stub(element, 'getResponseObject')
+      .resolves([change_num, message] as unknown as ParsedJSON);
+    element._cache.set(
+      `/changes/${change_num}/message`,
+      {} as unknown as ParsedJSON
+    );
+    await element.putChangeCommitMessage(change_num, message);
+    assert.isTrue(sendStub.calledOnce);
+    assert.equal(sendStub.lastCall.args[0].method, HttpMethod.PUT);
+    assert.equal(sendStub.lastCall.args[0].url, '/changes/test~1/message');
+    assert.deepEqual(sendStub.lastCall.args[0].body, {
+      message,
+    });
+  });
+
+  test('deleteChangeCommitMessage', async () => {
+    element._projectLookup = {1: Promise.resolve('test' as RepoName)};
+    const change_num = 1 as NumericChangeId;
+    const messageId = 'abc' as ChangeMessageId;
+    const sendStub = sinon
+      .stub(element._restApiHelper, 'send')
+      .resolves([change_num, messageId] as unknown as ParsedJSON);
+    sinon
+      .stub(element, 'getResponseObject')
+      .resolves([change_num, messageId] as unknown as ParsedJSON);
+    await element.deleteChangeCommitMessage(change_num, messageId);
+    assert.isTrue(sendStub.calledOnce);
+    assert.equal(sendStub.lastCall.args[0].method, HttpMethod.DELETE);
+    assert.equal(sendStub.lastCall.args[0].url, '/changes/test~1/messages/abc');
+  });
+
+  test('startWorkInProgress', () => {
+    const sendStub = sinon
+      .stub(element, '_getChangeURLAndSend')
+      .resolves('ok' as unknown as ParsedJSON);
+    element.startWorkInProgress(42 as NumericChangeId);
+    assert.isTrue(sendStub.calledOnce);
+    assert.equal(sendStub.lastCall.args[0].changeNum, 42 as NumericChangeId);
+    assert.equal(sendStub.lastCall.args[0].method, HttpMethod.POST);
+    assert.isNotOk(sendStub.lastCall.args[0].patchNum);
+    assert.equal(sendStub.lastCall.args[0].endpoint, '/wip');
+    assert.deepEqual(sendStub.lastCall.args[0].body, {});
+
+    element.startWorkInProgress(42 as NumericChangeId, 'revising...');
+    assert.isTrue(sendStub.calledTwice);
+    assert.equal(sendStub.lastCall.args[0].changeNum, 42 as NumericChangeId);
+    assert.equal(sendStub.lastCall.args[0].method, HttpMethod.POST);
+    assert.isNotOk(sendStub.lastCall.args[0].patchNum);
+    assert.equal(sendStub.lastCall.args[0].endpoint, '/wip');
+    assert.deepEqual(sendStub.lastCall.args[0].body, {
+      message: 'revising...',
+    });
+  });
+
+  test('deleteComment', async () => {
+    const comment = createComment();
+    const sendStub = sinon
+      .stub(element, '_getChangeURLAndSend')
+      .resolves(comment as unknown as ParsedJSON);
+    const response = await element.deleteComment(
+      123 as NumericChangeId,
+      1 as PatchSetNum,
+      '01234' as UrlEncodedCommentId,
+      'removal reason'
+    );
+    assert.equal(response, comment);
+    assert.isTrue(sendStub.calledOnce);
+    assert.equal(sendStub.lastCall.args[0].changeNum, 123 as NumericChangeId);
+    assert.equal(sendStub.lastCall.args[0].method, HttpMethod.POST);
+    assert.equal(sendStub.lastCall.args[0].patchNum, 1 as PatchSetNum);
+    assert.equal(sendStub.lastCall.args[0].endpoint, '/comments/01234/delete');
+    assert.deepEqual(sendStub.lastCall.args[0].body, {
+      reason: 'removal reason',
+    });
+  });
+
+  test('createRepo encodes name', async () => {
+    const sendStub = sinon.stub(element._restApiHelper, 'send').resolves();
+    await element.createRepo({name: 'x/y' as RepoName});
+    assert.isTrue(sendStub.calledOnce);
+    assert.equal(sendStub.lastCall.args[0].url, '/projects/x%2Fy');
+  });
+
+  test('queryChangeFiles', async () => {
+    const fetchStub = sinon.stub(element, '_getChangeURLAndFetch').resolves();
+    await element.queryChangeFiles(42 as NumericChangeId, EDIT, 'test/path.js');
+    assert.equal(fetchStub.lastCall.args[0].changeNum, 42 as NumericChangeId);
+    assert.equal(
+      fetchStub.lastCall.args[0].endpoint,
+      '/files?q=test%2Fpath.js'
+    );
+    assert.equal(fetchStub.lastCall.args[0].revision, EDIT);
+  });
+
+  test('normal use', () => {
+    const defaultQuery = '';
+
+    assert.equal(
+      element._getReposUrl('test', 25).toString(),
+      [false, '/projects/?n=26&S=0&d=&m=test'].toString()
+    );
+
+    assert.equal(
+      element._getReposUrl(undefined, 25).toString(),
+      [false, `/projects/?n=26&S=0&d=&m=${defaultQuery}`].toString()
+    );
+
+    assert.equal(
+      element._getReposUrl('test', 25, 25).toString(),
+      [false, '/projects/?n=26&S=25&d=&m=test'].toString()
+    );
+
+    assert.equal(
+      element._getReposUrl('inname:test', 25, 25).toString(),
+      [true, '/projects/?n=26&S=25&query=inname%3Atest'].toString()
+    );
+  });
+
+  test('invalidateReposCache', () => {
+    const url = '/projects/?n=26&S=0&query=test';
+
+    element._cache.set(url, {} as unknown as ParsedJSON);
+
+    element.invalidateReposCache();
+
+    assert.isUndefined(element._sharedFetchPromises.get(url));
+
+    assert.isFalse(element._cache.has(url));
+  });
+
+  test('invalidateAccountsCache', () => {
+    const url = '/accounts/self/detail';
+
+    element._cache.set(url, {} as unknown as ParsedJSON);
+
+    element.invalidateAccountsCache();
+
+    assert.isUndefined(element._sharedFetchPromises.get(url));
+
+    assert.isFalse(element._cache.has(url));
+  });
+
+  suite('getRepos', () => {
+    const defaultQuery = '';
+    let fetchCacheURLStub: sinon.SinonStub;
+    setup(() => {
+      fetchCacheURLStub = sinon
+        .stub(element._restApiHelper, 'fetchCacheURL')
+        .resolves([] as unknown as ParsedJSON);
+    });
+
+    test('normal use', () => {
+      element.getRepos('test', 25);
+      assert.equal(
+        fetchCacheURLStub.lastCall.args[0].url,
+        '/projects/?n=26&S=0&d=&m=test'
+      );
+
+      element.getRepos(undefined, 25);
+      assert.equal(
+        fetchCacheURLStub.lastCall.args[0].url,
+        `/projects/?n=26&S=0&d=&m=${defaultQuery}`
+      );
+
+      element.getRepos('test', 25, 25);
+      assert.equal(
+        fetchCacheURLStub.lastCall.args[0].url,
+        '/projects/?n=26&S=25&d=&m=test'
+      );
+    });
+
+    test('with blank', () => {
+      element.getRepos('test/test', 25);
+      assert.equal(
+        fetchCacheURLStub.lastCall.args[0].url,
+        '/projects/?n=26&S=0&d=&m=test%2Ftest'
+      );
+    });
+
+    test('with hyphen', () => {
+      element.getRepos('foo-bar', 25);
+      assert.equal(
+        fetchCacheURLStub.lastCall.args[0].url,
+        '/projects/?n=26&S=0&d=&m=foo-bar'
+      );
+    });
+
+    test('with leading hyphen', () => {
+      element.getRepos('-bar', 25);
+      assert.equal(
+        fetchCacheURLStub.lastCall.args[0].url,
+        '/projects/?n=26&S=0&d=&m=-bar'
+      );
+    });
+
+    test('with trailing hyphen', () => {
+      element.getRepos('foo-bar-', 25);
+      assert.equal(
+        fetchCacheURLStub.lastCall.args[0].url,
+        '/projects/?n=26&S=0&d=&m=foo-bar-'
+      );
+    });
+
+    test('with underscore', () => {
+      element.getRepos('foo_bar', 25);
+      assert.equal(
+        fetchCacheURLStub.lastCall.args[0].url,
+        '/projects/?n=26&S=0&d=&m=foo_bar'
+      );
+    });
+
+    test('with underscore', () => {
+      element.getRepos('foo_bar', 25);
+      assert.equal(
+        fetchCacheURLStub.lastCall.args[0].url,
+        '/projects/?n=26&S=0&d=&m=foo_bar'
+      );
+    });
+
+    test('hyphen only', () => {
+      element.getRepos('-', 25);
+      assert.equal(
+        fetchCacheURLStub.lastCall.args[0].url,
+        '/projects/?n=26&S=0&d=&m=-'
+      );
+    });
+
+    test('using query', () => {
+      element.getRepos('description:project', 25);
+      assert.equal(
+        fetchCacheURLStub.lastCall.args[0].url,
+        '/projects/?n=26&S=0&query=description%3Aproject'
+      );
+    });
+  });
+
+  test('_getGroupsUrl normal use', () => {
+    assert.equal(element._getGroupsUrl('test', 25), '/groups/?n=26&S=0&m=test');
+
+    assert.equal(element._getGroupsUrl('', 25), '/groups/?n=26&S=0');
+
+    assert.equal(
+      element._getGroupsUrl('test', 25, 25),
+      '/groups/?n=26&S=25&m=test'
+    );
+  });
+
+  test('invalidateGroupsCache', () => {
+    const url = '/groups/?n=26&S=0&m=test';
+
+    element._cache.set(url, {} as unknown as ParsedJSON);
+
+    element.invalidateGroupsCache();
+
+    assert.isUndefined(element._sharedFetchPromises.get(url));
+
+    assert.isFalse(element._cache.has(url));
+  });
+
+  suite('getGroups', () => {
+    let fetchCacheURLStub: sinon.SinonStub;
+    setup(() => {
+      fetchCacheURLStub = sinon.stub(element._restApiHelper, 'fetchCacheURL');
+    });
+
+    test('normal use', () => {
+      element.getGroups('test', 25);
+      assert.equal(
+        fetchCacheURLStub.lastCall.args[0].url,
+        '/groups/?n=26&S=0&m=test'
+      );
+
+      element.getGroups('', 25);
+      assert.equal(fetchCacheURLStub.lastCall.args[0].url, '/groups/?n=26&S=0');
+
+      element.getGroups('test', 25, 25);
+      assert.equal(
+        fetchCacheURLStub.lastCall.args[0].url,
+        '/groups/?n=26&S=25&m=test'
+      );
+    });
+
+    test('regex', () => {
+      element.getGroups('^test.*', 25);
+      assert.equal(
+        fetchCacheURLStub.lastCall.args[0].url,
+        '/groups/?n=26&S=0&r=%5Etest.*'
+      );
+
+      element.getGroups('^test.*', 25, 25);
+      assert.equal(
+        fetchCacheURLStub.lastCall.args[0].url,
+        '/groups/?n=26&S=25&r=%5Etest.*'
+      );
+    });
+  });
+
+  test('gerrit auth is used', () => {
+    const fetchStub = sinon.stub(authService, 'fetch').resolves();
+    element._restApiHelper.fetchJSON({url: 'foo'});
+    assert(fetchStub.called);
+  });
+
+  test('getSuggestedAccounts does not return fetchJSON', async () => {
+    const fetchJSONSpy = sinon.spy(element._restApiHelper, 'fetchJSON');
+    const accts = await element.getSuggestedAccounts('');
+    assert.isFalse(fetchJSONSpy.called);
+    assert.equal(accts!.length, 0);
+  });
+
+  test('fetchJSON gets called by getSuggestedAccounts', async () => {
+    const fetchJSONStub = sinon
+      .stub(element._restApiHelper, 'fetchJSON')
+      .resolves();
+    await element.getSuggestedAccounts('own');
+    assert.deepEqual(fetchJSONStub.lastCall.args[0].params, {
+      q: '"own"',
+      o: 'DETAILS',
+    });
+  });
+
+  suite('getChangeDetail', () => {
+    suite('change detail options', () => {
+      let changeDetailStub: sinon.SinonStub;
+      setup(() => {
+        changeDetailStub = sinon
+          .stub(element, '_getChangeDetail')
+          .resolves({...createChange(), _number: 123 as NumericChangeId});
+      });
+
+      test('signed pushes disabled', async () => {
+        sinon.stub(element, 'getConfig').resolves({
+          ...createServerInfo(),
+          receive: {enable_signed_push: undefined},
+        });
+        const change = await element.getChangeDetail(123 as NumericChangeId);
+        assert.strictEqual(123, change!._number);
+        const options = changeDetailStub.firstCall.args[1];
+        assert.isNotOk(
+          parseInt(options, 16) & (1 << ListChangesOption.PUSH_CERTIFICATES)
+        );
+      });
+
+      test('signed pushes enabled', async () => {
+        sinon.stub(element, 'getConfig').resolves({
+          ...createServerInfo(),
+          receive: {enable_signed_push: 'true'},
+        });
+        const change = await element.getChangeDetail(123 as NumericChangeId);
+        assert.strictEqual(123, change!._number);
+        const options = changeDetailStub.firstCall.args[1];
+        assert.ok(
+          parseInt(options, 16) & (1 << ListChangesOption.PUSH_CERTIFICATES)
+        );
+      });
+    });
+
+    test('GrReviewerUpdatesParser.parse is used', async () => {
+      const changeInfo = createParsedChange();
+      const parseStub = sinon
+        .stub(GrReviewerUpdatesParser, 'parse')
+        .resolves(changeInfo);
+      const result = await element.getChangeDetail(42 as NumericChangeId);
+      assert.isTrue(parseStub.calledOnce);
+      assert.equal(result, changeInfo);
+    });
+
+    test('_getChangeDetail passes params to ETags decorator', async () => {
+      const changeNum = 4321 as NumericChangeId;
+      element._projectLookup[changeNum] = Promise.resolve('test' as RepoName);
+      const expectedUrl = `${window.CANONICAL_PATH}/changes/test~4321/detail?O=516714`;
+      const optionsStub = sinon.stub(element._etags, 'getOptions');
+      const collectStub = sinon.stub(element._etags, 'collect');
+      await element._getChangeDetail(changeNum, '516714');
+      assert.isTrue(optionsStub.calledWithExactly(expectedUrl));
+      assert.equal(collectStub.lastCall.args[0], expectedUrl);
+    });
+
+    test('_getChangeDetail calls errFn on 500', async () => {
+      const errFn = sinon.stub();
+      sinon.stub(element, 'getChangeActionURL').resolves('');
+      sinon
+        .stub(element._restApiHelper, 'fetchRawJSON')
+        .resolves(new Response(undefined, {status: 500}));
+      await element._getChangeDetail(123 as NumericChangeId, '516714', errFn);
+      assert.isTrue(errFn.called);
+    });
+
+    test('_getChangeDetail populates _projectLookup', async () => {
+      sinon.stub(element, 'getChangeActionURL').resolves('');
+      sinon.stub(element._restApiHelper, 'fetchRawJSON').resolves(
+        new Response(')]}\'{"_number":1,"project":"test"}', {
+          status: 200,
+        })
+      );
+      await element._getChangeDetail(1 as NumericChangeId, '516714');
+      assert.equal(Object.keys(element._projectLookup).length, 1);
+      const project = await element._projectLookup[1];
+      assert.equal(project, 'test' as RepoName);
+    });
+
+    suite('_getChangeDetail ETag cache', () => {
+      let requestUrl: string;
+      let mockResponseSerial: string;
+      let collectSpy: sinon.SinonSpy;
+
+      setup(() => {
+        requestUrl = '/foo/bar';
+        const mockResponse = {foo: 'bar', baz: 42};
+        mockResponseSerial = JSON_PREFIX + JSON.stringify(mockResponse);
+        sinon.stub(element._restApiHelper, 'urlWithParams').returns(requestUrl);
+        sinon.stub(element, 'getChangeActionURL').resolves(requestUrl);
+        collectSpy = sinon.spy(element._etags, 'collect');
+      });
+
+      test('contributes to cache', async () => {
+        const getPayloadSpy = sinon.spy(element._etags, 'getCachedPayload');
+        sinon.stub(element._restApiHelper, 'fetchRawJSON').resolves(
+          new Response(mockResponseSerial, {
+            status: 200,
+          })
+        );
+
+        await element._getChangeDetail(123 as NumericChangeId, '516714');
+        assert.isFalse(getPayloadSpy.called);
+        assert.isTrue(collectSpy.calledOnce);
+        const cachedResponse = element._etags.getCachedPayload(requestUrl);
+        assert.equal(cachedResponse, mockResponseSerial);
+      });
+
+      test('uses cache on HTTP 304', async () => {
+        const getPayloadStub = sinon.stub(element._etags, 'getCachedPayload');
+        getPayloadStub.returns(mockResponseSerial);
+        sinon.stub(element._restApiHelper, 'fetchRawJSON').resolves(
+          new Response(undefined, {
+            status: 304,
+          })
+        );
+
+        await element._getChangeDetail(123 as NumericChangeId, '');
+        assert.isFalse(collectSpy.called);
+        assert.isTrue(getPayloadStub.calledOnce);
+      });
+    });
+  });
+
+  test('setInProjectLookup', async () => {
+    element.setInProjectLookup(555 as NumericChangeId, 'project' as RepoName);
+    const project = await element.getFromProjectLookup(555 as NumericChangeId);
+    assert.deepEqual(project, 'project' as RepoName);
+  });
+
+  suite('getFromProjectLookup', () => {
+    const changeNum = 555 as NumericChangeId;
+    const repo = 'test-repo' as RepoName;
+
+    test('getChange fails to yield a project', async () => {
+      const promise = mockPromise<null>();
+      sinon.stub(element, 'getChange').returns(promise);
+
+      const projectLookup = element.getFromProjectLookup(changeNum);
+      promise.resolve(null);
+
+      assert.isUndefined(await projectLookup);
+    });
+
+    test('getChange succeeds with project', async () => {
+      const promise = mockPromise<null | ChangeInfo>();
+      sinon.stub(element, 'getChange').returns(promise);
+
+      const projectLookup = element.getFromProjectLookup(changeNum);
+      promise.resolve({...createChange(), project: repo});
+
+      assert.equal(await projectLookup, repo);
+      assert.deepEqual(element._projectLookup, {'555': projectLookup});
+    });
+
+    test('getChange fails, but a setInProjectLookup() call is used as fallback', async () => {
+      const promise = mockPromise<null>();
+      sinon.stub(element, 'getChange').returns(promise);
+
+      const projectLookup = element.getFromProjectLookup(changeNum);
+      element.setInProjectLookup(changeNum, repo);
+      promise.resolve(null);
+
+      assert.equal(await projectLookup, repo);
+    });
+  });
+
+  suite('getChanges populates _projectLookup', () => {
+    test('multiple queries', async () => {
+      sinon.stub(element._restApiHelper, 'fetchJSON').resolves([
+        [
+          {_number: 1, project: 'test'},
+          {_number: 2, project: 'test'},
+        ],
+        [{_number: 3, project: 'test/test'}],
+      ] as unknown as ParsedJSON);
+      // When query instanceof Array, fetchJSON returns
+      // Array<Array<Object>>.
+      await element.getChangesForMultipleQueries(undefined, []);
+      assert.equal(Object.keys(element._projectLookup).length, 3);
+      const project1 = await element.getFromProjectLookup(1 as NumericChangeId);
+      assert.equal(project1, 'test' as RepoName);
+      const project2 = await element.getFromProjectLookup(2 as NumericChangeId);
+      assert.equal(project2, 'test' as RepoName);
+      const project3 = await element.getFromProjectLookup(3 as NumericChangeId);
+      assert.equal(project3, 'test/test' as RepoName);
+    });
+
+    test('no query', async () => {
+      sinon.stub(element._restApiHelper, 'fetchJSON').resolves([
+        {_number: 1, project: 'test'},
+        {_number: 2, project: 'test'},
+        {_number: 3, project: 'test/test'},
+      ] as unknown as ParsedJSON);
+
+      // When query !instanceof Array, fetchJSON returns Array<Object>.
+      await element.getChanges();
+      assert.equal(Object.keys(element._projectLookup).length, 3);
+      const project1 = await element.getFromProjectLookup(1 as NumericChangeId);
+      assert.equal(project1, 'test' as RepoName);
+      const project2 = await element.getFromProjectLookup(2 as NumericChangeId);
+      assert.equal(project2, 'test' as RepoName);
+      const project3 = await element.getFromProjectLookup(3 as NumericChangeId);
+      assert.equal(project3, 'test/test' as RepoName);
+    });
+  });
+
+  test('getDetailedChangesWithActions', async () => {
+    const c1 = createChange();
+    c1._number = 1 as NumericChangeId;
+    const c2 = createChange();
+    c2._number = 2 as NumericChangeId;
+    const getChangesStub = sinon
+      .stub(element, 'getChanges')
+      .callsFake((changesPerPage, query, offset, options) => {
+        assert.isUndefined(changesPerPage);
+        assert.strictEqual(query, 'change:1 OR change:2');
+        assert.isUndefined(offset);
+        assert.strictEqual(options, EXPECTED_QUERY_OPTIONS);
+        return Promise.resolve([]);
+      });
+    await element.getDetailedChangesWithActions([c1._number, c2._number]);
+    assert.isTrue(getChangesStub.calledOnce);
+  });
+
+  test('_getChangeURLAndFetch', async () => {
+    element._projectLookup = {1: Promise.resolve('test' as RepoName)};
+    const fetchStub = sinon
+      .stub(element._restApiHelper, 'fetchJSON')
+      .resolves();
+    const req = {
+      changeNum: 1 as NumericChangeId,
+      endpoint: '/test',
+      revision: 1 as RevisionId,
+    };
+    await element._getChangeURLAndFetch(req);
+    assert.equal(
+      fetchStub.lastCall.args[0].url,
+      '/changes/test~1/revisions/1/test'
+    );
+  });
+
+  test('_getChangeURLAndSend', async () => {
+    element._projectLookup = {1: Promise.resolve('test' as RepoName)};
+    const sendStub = sinon.stub(element._restApiHelper, 'send').resolves();
+
+    const req = {
+      changeNum: 1 as NumericChangeId,
+      method: HttpMethod.POST,
+      patchNum: 1 as PatchSetNum,
+      endpoint: '/test',
+    };
+    await element._getChangeURLAndSend(req);
+    assert.isTrue(sendStub.calledOnce);
+    assert.equal(sendStub.lastCall.args[0].method, HttpMethod.POST);
+    assert.equal(
+      sendStub.lastCall.args[0].url,
+      '/changes/test~1/revisions/1/test'
+    );
+  });
+
+  suite('reading responses', () => {
+    test('_readResponsePayload', async () => {
+      const mockObject = {foo: 'bar', baz: 'foo'} as unknown as ParsedJSON;
+      const serial = JSON_PREFIX + JSON.stringify(mockObject);
+      const response = new Response(serial);
+      const payload = await readResponsePayload(response);
+      assert.deepEqual(payload.parsed, mockObject);
+      assert.equal(payload.raw, serial);
+    });
+
+    test('_parsePrefixedJSON', () => {
+      const obj = {x: 3, y: {z: 4}, w: 23} as unknown as ParsedJSON;
+      const serial = JSON_PREFIX + JSON.stringify(obj);
+      const result = parsePrefixedJSON(serial);
+      assert.deepEqual(result, obj);
+    });
+  });
+
+  test('setChangeTopic', async () => {
+    const sendSpy = sinon.spy(element, '_getChangeURLAndSend');
+    await element.setChangeTopic(123 as NumericChangeId, 'foo-bar');
+    assert.isTrue(sendSpy.calledOnce);
+    assert.deepEqual(sendSpy.lastCall.args[0].body, {topic: 'foo-bar'});
+  });
+
+  test('setChangeHashtag', async () => {
+    const sendSpy = sinon.spy(element, '_getChangeURLAndSend');
+    await element.setChangeHashtag(123 as NumericChangeId, {
+      add: ['foo-bar' as Hashtag],
+    });
+    assert.isTrue(sendSpy.calledOnce);
+    assert.sameDeepMembers(
+      (sendSpy.lastCall.args[0].body! as HashtagsInput).add!,
+      ['foo-bar']
+    );
+  });
+
+  test('generateAccountHttpPassword', async () => {
+    const sendSpy = sinon.spy(element._restApiHelper, 'send');
+    await element.generateAccountHttpPassword();
+    assert.isTrue(sendSpy.calledOnce);
+    assert.deepEqual(sendSpy.lastCall.args[0].body, {generate: true});
+  });
+
+  suite('getChangeFiles', () => {
+    test('patch only', async () => {
+      const fetchStub = sinon.stub(element, '_getChangeURLAndFetch').resolves();
+      const range = {basePatchNum: PARENT, patchNum: 2 as RevisionPatchSetNum};
+      await element.getChangeFiles(123 as NumericChangeId, range);
+      assert.isTrue(fetchStub.calledOnce);
+      assert.equal(
+        fetchStub.lastCall.args[0].revision,
+        2 as RevisionPatchSetNum
+      );
+      assert.isNotOk(fetchStub.lastCall.args[0].params);
+    });
+
+    test('simple range', async () => {
+      const fetchStub = sinon.stub(element, '_getChangeURLAndFetch').resolves();
+      const range = {
+        basePatchNum: 4 as BasePatchSetNum,
+        patchNum: 5 as RevisionPatchSetNum,
+      };
+      await element.getChangeFiles(123 as NumericChangeId, range);
+      assert.isTrue(fetchStub.calledOnce);
+      assert.equal(fetchStub.lastCall.args[0].revision, 5 as RevisionId);
+      assert.isOk(fetchStub.lastCall.args[0].params);
+      assert.equal(fetchStub.lastCall.args[0].params!.base, 4);
+      assert.isNotOk(fetchStub.lastCall.args[0].params!.parent);
+    });
+
+    test('parent index', async () => {
+      const fetchStub = sinon.stub(element, '_getChangeURLAndFetch').resolves();
+      const range = {
+        basePatchNum: -3 as BasePatchSetNum,
+        patchNum: 5 as RevisionPatchSetNum,
+      };
+      await element.getChangeFiles(123 as NumericChangeId, range);
+      assert.isTrue(fetchStub.calledOnce);
+      assert.equal(fetchStub.lastCall.args[0].revision, 5 as RevisionId);
+      assert.isOk(fetchStub.lastCall.args[0].params);
+      assert.isNotOk(fetchStub.lastCall.args[0].params!.base);
+      assert.equal(fetchStub.lastCall.args[0].params!.parent, 3);
+    });
+  });
+
+  suite('getDiff', () => {
+    test('patchOnly', async () => {
+      const fetchStub = sinon.stub(element, '_getChangeURLAndFetch').resolves();
+      await element.getDiff(
+        123 as NumericChangeId,
+        PARENT,
+        2 as PatchSetNum,
+        'foo/bar.baz'
+      );
+      assert.isTrue(fetchStub.calledOnce);
+      assert.equal(fetchStub.lastCall.args[0].revision, 2 as RevisionId);
+      assert.isOk(fetchStub.lastCall.args[0].params);
+      assert.isNotOk(fetchStub.lastCall.args[0].params!.parent);
+      assert.isNotOk(fetchStub.lastCall.args[0].params!.base);
+    });
+
+    test('simple range', async () => {
+      const fetchStub = sinon.stub(element, '_getChangeURLAndFetch').resolves();
+      await element.getDiff(
+        123 as NumericChangeId,
+        4 as PatchSetNum,
+        5 as PatchSetNum,
+        'foo/bar.baz'
+      );
+      assert.isTrue(fetchStub.calledOnce);
+      assert.equal(fetchStub.lastCall.args[0].revision, 5 as RevisionId);
+      assert.isOk(fetchStub.lastCall.args[0].params);
+      assert.isNotOk(fetchStub.lastCall.args[0].params!.parent);
+      assert.equal(fetchStub.lastCall.args[0].params!.base, 4);
+    });
+
+    test('parent index', async () => {
+      const fetchStub = sinon.stub(element, '_getChangeURLAndFetch').resolves();
+      await element.getDiff(
+        123 as NumericChangeId,
+        -3 as PatchSetNum,
+        5 as PatchSetNum,
+        'foo/bar.baz'
+      );
+      assert.isTrue(fetchStub.calledOnce);
+      assert.equal(fetchStub.lastCall.args[0].revision, 5 as RevisionId);
+      assert.isOk(fetchStub.lastCall.args[0].params);
+      assert.isNotOk(fetchStub.lastCall.args[0].params!.base);
+      assert.equal(fetchStub.lastCall.args[0].params!.parent, 3);
+    });
+  });
+
+  test('getDashboard', () => {
+    const fetchCacheURLStub = sinon.stub(
+      element._restApiHelper,
+      'fetchCacheURL'
+    );
+    element.getDashboard(
+      'gerrit/project' as RepoName,
+      'default:main' as DashboardId
+    );
+    assert.isTrue(fetchCacheURLStub.calledOnce);
+    assert.equal(
+      fetchCacheURLStub.lastCall.args[0].url,
+      '/projects/gerrit%2Fproject/dashboards/default%3Amain'
+    );
+  });
+
+  test('getFileContent', async () => {
+    sinon.stub(element, '_getChangeURLAndSend').resolves(
+      new Response(undefined, {
+        status: 200,
+        headers: {
+          'X-FYI-Content-Type': 'text/java',
+        },
+      }) as unknown as ParsedJSON
+    );
+
+    sinon
+      .stub(element, 'getResponseObject')
+      .resolves('new content' as unknown as ParsedJSON);
+
+    const edit = await element.getFileContent(
+      1 as NumericChangeId,
+      'tst/path',
+      'EDIT' as PatchSetNum
+    );
+
+    assert.deepEqual(edit, {
+      content: 'new content',
+      type: 'text/java',
+      ok: true,
+    });
+
+    const normal = await element.getFileContent(
+      1 as NumericChangeId,
+      'tst/path',
+      '3' as PatchSetNum
+    );
+    assert.deepEqual(normal, {
+      content: 'new content',
+      type: 'text/java',
+      ok: true,
+    });
+  });
+
+  test('getFileContent suppresses 404s', async () => {
+    const res404 = new Response(undefined, {status: 404});
+    const res500 = new Response(undefined, {status: 500});
+    const spy = sinon.spy();
+    addListenerForTest(document, 'server-error', spy);
+    const authStub = sinon.stub(authService, 'fetch').resolves(res404);
+    sinon.stub(element, '_changeBaseURL').resolves('');
+    await element.getFileContent(
+      1 as NumericChangeId,
+      'tst/path',
+      1 as PatchSetNum
+    );
+    await waitEventLoop();
+    assert.isFalse(spy.called);
+    authStub.reset();
+    authStub.resolves(res500);
+    await element.getFileContent(
+      1 as NumericChangeId,
+      'tst/path',
+      1 as PatchSetNum
+    );
+    assert.isTrue(spy.called);
+    assert.notEqual(spy.lastCall.args[0].detail.response.status, 404);
+  });
+
+  test('getChangeFilesOrEditFiles is edit-sensitive', async () => {
+    const getChangeFilesStub = sinon
+      .stub(element, 'getChangeFiles')
+      .resolves({});
+    const getChangeEditFilesStub = sinon
+      .stub(element, 'getChangeEditFiles')
+      .resolves({files: {}});
+
+    await element.getChangeOrEditFiles(1 as NumericChangeId, {
+      basePatchNum: PARENT,
+      patchNum: EDIT,
+    });
+    assert.isTrue(getChangeEditFilesStub.calledOnce);
+    assert.isFalse(getChangeFilesStub.called);
+    await element.getChangeOrEditFiles(1 as NumericChangeId, {
+      basePatchNum: PARENT,
+      patchNum: 1 as RevisionPatchSetNum,
+    });
+    assert.isTrue(getChangeEditFilesStub.calledOnce);
+    assert.isTrue(getChangeFilesStub.calledOnce);
+  });
+
+  test('_fetch forwards request and logs', async () => {
+    const logStub = sinon.stub(element._restApiHelper, '_logCall');
+    const response = new Response(undefined, {status: 404});
+    const url = 'my url';
+    const fetchOptions = {method: 'DELETE'};
+    sinon.stub(authService, 'fetch').resolves(response);
+    const startTime = 123;
+    sinon.stub(Date, 'now').returns(startTime);
+    const req = {url, fetchOptions};
+    await element._restApiHelper.fetch(req);
+    assert.isTrue(logStub.calledOnce);
+    assert.isTrue(logStub.calledWith(req, startTime, response.status));
+  });
+
+  test('_logCall only reports requests with anonymized URLss', async () => {
+    sinon.stub(Date, 'now').returns(200);
+    const handler = sinon.stub();
+    addListenerForTest(document, 'gr-rpc-log', handler);
+
+    element._restApiHelper._logCall({url: 'url'}, 100, 200);
+    assert.isFalse(handler.called);
+
+    element._restApiHelper._logCall(
+      {url: 'url', anonymizedUrl: 'not url'},
+      100,
+      200
+    );
+    await waitEventLoop();
+    assert.isTrue(handler.calledOnce);
+  });
+
+  test('ported comment errors do not trigger error dialog', () => {
+    const change = createChange();
+    const handler = sinon.stub();
+    addListenerForTest(document, 'server-error', handler);
+    sinon.stub(element._restApiHelper, 'fetchJSON').resolves({
+      ok: false,
+    } as unknown as ParsedJSON);
+
+    element.getPortedComments(change._number, CURRENT);
+
+    assert.isFalse(handler.called);
+  });
+
+  test('ported drafts are not requested user is not logged in', () => {
+    const change = createChange();
+    sinon.stub(element, 'getLoggedIn').resolves(false);
+    const getChangeURLAndFetchStub = sinon.stub(
+      element,
+      '_getChangeURLAndFetch'
+    );
+
+    element.getPortedDrafts(change._number, CURRENT);
+
+    assert.isFalse(getChangeURLAndFetchStub.called);
+  });
+
+  test('saveChangeStarred', async () => {
+    sinon.stub(element, 'getFromProjectLookup').resolves('test' as RepoName);
+    const sendStub = sinon.stub(element._restApiHelper, 'send').resolves();
+
+    await element.saveChangeStarred(123 as NumericChangeId, true);
+    assert.isTrue(sendStub.calledOnce);
+    assert.deepEqual(sendStub.lastCall.args[0], {
+      method: HttpMethod.PUT,
+      url: '/accounts/self/starred.changes/test~123',
+      anonymizedUrl: '/accounts/self/starred.changes/*',
+    });
+
+    await element.saveChangeStarred(456 as NumericChangeId, false);
+    assert.isTrue(sendStub.calledTwice);
+    assert.deepEqual(sendStub.lastCall.args[0], {
+      method: HttpMethod.DELETE,
+      url: '/accounts/self/starred.changes/test~456',
+      anonymizedUrl: '/accounts/self/starred.changes/*',
+    });
+  });
+
+  suite('getDocsBaseUrl tests', () => {
+    test('null config', async () => {
+      const probePathMock = sinon.stub(element, 'probePath').resolves(true);
+      const docsBaseUrl = await element.getDocsBaseUrl(undefined);
+      assert.equal(
+        probePathMock.lastCall.args[0],
+        `${getBaseUrl()}/Documentation/index.html`
+      );
+      assert.equal(docsBaseUrl, `${getBaseUrl()}/Documentation`);
+    });
+
+    test('no doc config', async () => {
+      const probePathMock = sinon.stub(element, 'probePath').resolves(true);
+      const config: ServerInfo = {
+        ...createServerInfo(),
+        gerrit: createGerritInfo(),
+      };
+      const docsBaseUrl = await element.getDocsBaseUrl(config);
+      assert.equal(
+        probePathMock.lastCall.args[0],
+        `${getBaseUrl()}/Documentation/index.html`
+      );
+      assert.equal(docsBaseUrl, `${getBaseUrl()}/Documentation`);
+    });
+
+    test('has doc config', async () => {
+      const probePathMock = sinon.stub(element, 'probePath').resolves(true);
+      const config: ServerInfo = {
+        ...createServerInfo(),
+        gerrit: {...createGerritInfo(), doc_url: 'foobar'},
+      };
+      const docsBaseUrl = await element.getDocsBaseUrl(config);
+      assert.isFalse(probePathMock.called);
+      assert.equal(docsBaseUrl, 'foobar');
+    });
+
+    test('no probe', async () => {
+      const probePathMock = sinon.stub(element, 'probePath').resolves(false);
+      const docsBaseUrl = await element.getDocsBaseUrl(undefined);
+      assert.equal(
+        probePathMock.lastCall.args[0],
+        `${getBaseUrl()}/Documentation/index.html`
+      );
+      assert.isNotOk(docsBaseUrl);
+    });
+  });
+});
diff --git a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts
index b794233..d8bf276 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
@@ -64,13 +64,12 @@
   Password,
   PatchRange,
   PatchSetNum,
-  PathToCommentsInfoMap,
   PathToRobotCommentsInfoMap,
   PluginInfo,
   PreferencesInfo,
   PreferencesInput,
   ProjectAccessInfo,
-  ProjectAccessInfoMap,
+  RepoAccessInfoMap,
   ProjectAccessInput,
   ProjectInfo,
   ProjectInfoWithName,
@@ -91,6 +90,7 @@
   TopMenuEntryInfo,
   UrlEncodedCommentId,
   UserId,
+  DraftInfo,
 } from '../../types/common';
 import {
   DiffInfo,
@@ -99,7 +99,6 @@
 } from '../../types/diff';
 import {ParsedChangeInfo} from '../../types/types';
 import {ErrorCallback} from '../../api/rest';
-import {DraftInfo} from '../../utils/comment-util';
 
 export type CancelConditionCallback = () => boolean;
 
@@ -127,7 +126,8 @@
   getRepos(
     filter: string | undefined,
     reposPerPage: number,
-    offset?: number
+    offset?: number,
+    errFn?: ErrorCallback
   ): Promise<ProjectInfoWithName[] | undefined>;
 
   send(
@@ -152,11 +152,13 @@
 
   getChangeSuggestedReviewers(
     changeNum: NumericChangeId,
-    input: string
+    input: string,
+    errFn?: ErrorCallback
   ): Promise<SuggestedReviewerInfo[] | undefined>;
   getChangeSuggestedCCs(
     changeNum: NumericChangeId,
-    input: string
+    input: string,
+    errFn?: ErrorCallback
   ): Promise<SuggestedReviewerInfo[] | undefined>;
   /**
    * Request list of accounts via https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#query-account
@@ -166,12 +168,14 @@
     input: string,
     n?: number,
     canSee?: NumericChangeId,
-    filterActive?: boolean
+    filterActive?: boolean,
+    errFn?: ErrorCallback
   ): Promise<AccountInfo[] | undefined>;
   getSuggestedGroups(
     input: string,
     project?: RepoName,
-    n?: number
+    n?: number,
+    errFn?: ErrorCallback
   ): Promise<GroupNameToGroupInfoMap | undefined>;
   /**
    * Execute a change action or revision action on a change.
@@ -194,8 +198,8 @@
 
   getChangeDetail(
     changeNum?: number | string,
-    opt_errFn?: ErrorCallback,
-    opt_cancelCondition?: Function
+    errFn?: ErrorCallback,
+    cancelCondition?: Function
   ): Promise<ParsedChangeInfo | undefined>;
 
   /**
@@ -267,7 +271,8 @@
   queryChangeFiles(
     changeNum: NumericChangeId,
     patchNum: PatchSetNum,
-    query: string
+    query: string,
+    errFn?: ErrorCallback
   ): Promise<string[] | undefined>;
 
   getRepoAccessRights(
@@ -287,7 +292,7 @@
     errFn?: ErrorCallback
   ): Promise<DashboardInfo[] | undefined>;
 
-  getRepoAccess(repo: RepoName): Promise<ProjectAccessInfoMap | undefined>;
+  getRepoAccess(repo: RepoName): Promise<RepoAccessInfoMap | undefined>;
 
   getProjectConfig(
     repo: RepoName,
@@ -369,8 +374,10 @@
     endpoint: string
   ): Promise<string>;
 
+  getDocsBaseUrl(config?: ServerInfo): Promise<string | null>;
+
   createChange(
-    project: RepoName,
+    repo: RepoName,
     branch: BranchName,
     subject: string,
     topic?: string,
@@ -403,16 +410,16 @@
   getPortedComments(
     changeNum: NumericChangeId,
     revision: RevisionId
-  ): Promise<PathToCommentsInfoMap | undefined>;
+  ): Promise<{[path: string]: CommentInfo[]} | undefined>;
 
   getPortedDrafts(
     changeNum: NumericChangeId,
     revision: RevisionId
-  ): Promise<PathToCommentsInfoMap | undefined>;
+  ): Promise<{[path: string]: DraftInfo[]} | undefined>;
 
   getDiffComments(
     changeNum: NumericChangeId
-  ): Promise<PathToCommentsInfoMap | undefined>;
+  ): Promise<{[path: string]: CommentInfo[]} | undefined>;
   getDiffComments(
     changeNum: NumericChangeId,
     basePatchNum: PatchSetNum,
@@ -425,7 +432,7 @@
     patchNum?: PatchSetNum,
     path?: string
   ):
-    | Promise<PathToCommentsInfoMap | undefined>
+    | Promise<{[path: string]: CommentInfo[]} | undefined>
     | Promise<GetDiffCommentsOutput>;
 
   getDiffRobotComments(
@@ -472,7 +479,8 @@
     changesPerPage?: number,
     query?: string,
     offset?: 'n,z' | number,
-    options?: string
+    options?: string,
+    errFn?: ErrorCallback
   ): Promise<ChangeInfo[] | undefined>;
   getChangesForMultipleQueries(
     changesPerPage?: number,
@@ -513,9 +521,10 @@
 
   deleteWatchedProjects(projects: ProjectWatchInfo[]): Promise<Response>;
 
-  getSuggestedProjects(
+  getSuggestedRepos(
     inputVal: string,
-    n?: number
+    n?: number,
+    errFn?: ErrorCallback
   ): Promise<NameToProjectInfoMap | undefined>;
 
   invalidateGroupsCache(): void;
@@ -640,7 +649,7 @@
   ): Promise<ChangeInfo[] | undefined>;
 
   getChangeCherryPicks(
-    project: RepoName,
+    repo: RepoName,
     changeID: ChangeId,
     branch: BranchName
   ): Promise<ChangeInfo[] | undefined>;
@@ -652,9 +661,13 @@
       changeToExclude?: NumericChangeId;
     }
   ): Promise<ChangeInfo[] | undefined>;
-  getChangesWithSimilarTopic(topic: string): Promise<ChangeInfo[] | undefined>;
+  getChangesWithSimilarTopic(
+    topic: string,
+    errFn?: ErrorCallback
+  ): Promise<ChangeInfo[] | undefined>;
   getChangesWithSimilarHashtag(
-    hashtag: string
+    hashtag: string,
+    errFn?: ErrorCallback
   ): Promise<ChangeInfo[] | undefined>;
 
   /**
@@ -764,7 +777,7 @@
    * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#get-dashboard
    */
   getDashboard(
-    project: RepoName,
+    repo: RepoName,
     dashboard: DashboardId,
     errFn?: ErrorCallback
   ): Promise<DashboardInfo | undefined>;
@@ -802,7 +815,7 @@
 
   getTopMenus(): Promise<TopMenuEntryInfo[] | undefined>;
 
-  setInProjectLookup(changeNum: NumericChangeId, project: RepoName): void;
+  setInProjectLookup(changeNum: NumericChangeId, repo: RepoName): void;
   getMergeable(changeNum: NumericChangeId): Promise<MergeableInfo | undefined>;
 
   putChangeCommitMessage(
diff --git a/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.ts b/polygerrit-ui/app/services/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.ts
similarity index 87%
rename from polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.ts
rename to polygerrit-ui/app/services/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.ts
index 0bb02d8..842dace 100644
--- a/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.ts
+++ b/polygerrit-ui/app/services/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.ts
@@ -7,7 +7,7 @@
   getAccountDisplayName,
   getGroupDisplayName,
 } from '../../utils/display-name-util';
-import {RestApiService} from '../../services/gr-rest-api/gr-rest-api';
+import {RestApiService} from '../gr-rest-api/gr-rest-api';
 import {
   AccountInfo,
   isReviewerAccountSuggestion,
@@ -20,7 +20,7 @@
 import {assertNever} from '../../utils/common-util';
 import {AutocompleteSuggestion} from '../../elements/shared/gr-autocomplete/gr-autocomplete';
 import {allSettled, isFulfilled} from '../../utils/async-util';
-import {notUndefined, ParsedChangeInfo} from '../../types/types';
+import {isDefined, ParsedChangeInfo} from '../../types/types';
 import {accountKey} from '../../utils/account-util';
 import {
   AccountId,
@@ -29,6 +29,7 @@
   GroupId,
   ReviewerState,
 } from '../../api/rest-api';
+import {throwingErrorCallback} from '../../elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
 
 export interface ReviewerSuggestionsProvider {
   getSuggestions(input: string): Promise<Suggestion[]>;
@@ -52,6 +53,11 @@
     this.changes = changes;
   }
 
+  /**
+   * Requests related suggestions.
+   *
+   * If the request fails the returned promise is rejected.
+   */
   async getSuggestions(input: string): Promise<Suggestion[]> {
     if (!this.loggedIn) return [];
 
@@ -63,7 +69,7 @@
     const suggestionsByChangeIndex = resultsByChangeIndex
       .filter(isFulfilled)
       .map(result => result.value)
-      .filter(notUndefined);
+      .filter(isDefined);
     if (suggestionsByChangeIndex.length !== resultsByChangeIndex.length) {
       // one of the requests failed, so don't allow any suggestions.
       return [];
@@ -121,8 +127,16 @@
     input: string
   ): Promise<SuggestedReviewerInfo[] | undefined> {
     return this.type === ReviewerState.REVIEWER
-      ? this.restApi.getChangeSuggestedReviewers(changeNumber, input)
-      : this.restApi.getChangeSuggestedCCs(changeNumber, input);
+      ? this.restApi.getChangeSuggestedReviewers(
+          changeNumber,
+          input,
+          throwingErrorCallback
+        )
+      : this.restApi.getChangeSuggestedCCs(
+          changeNumber,
+          input,
+          throwingErrorCallback
+        );
   }
 }
 
diff --git a/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider_test.ts b/polygerrit-ui/app/services/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider_test.ts
similarity index 98%
rename from polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider_test.ts
rename to polygerrit-ui/app/services/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider_test.ts
index 15f3d24..e96a2ad 100644
--- a/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider_test.ts
+++ b/polygerrit-ui/app/services/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider_test.ts
@@ -5,7 +5,7 @@
  */
 import '../../test/common-test-setup';
 import {GrReviewerSuggestionsProvider} from './gr-reviewer-suggestions-provider';
-import {getAppContext} from '../../services/app-context';
+import {getAppContext} from '../app-context';
 import {stubRestApi} from '../../test/test-utils';
 import {
   AccountDetailInfo,
diff --git a/polygerrit-ui/app/services/highlight/highlight-service.ts b/polygerrit-ui/app/services/highlight/highlight-service.ts
index bfaa263..d10d875 100644
--- a/polygerrit-ui/app/services/highlight/highlight-service.ts
+++ b/polygerrit-ui/app/services/highlight/highlight-service.ts
@@ -3,6 +3,7 @@
  * Copyright 2022 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import {define} from '../../models/dependency';
 import {
   SyntaxWorkerRequest,
   SyntaxWorkerInit,
@@ -39,6 +40,8 @@
  */
 const CODE_MAX_LENGTH = 25 * CODE_MAX_LINES;
 
+export const highlightServiceToken =
+  define<HighlightService>('highlight-service');
 /**
  * Service for syntax highlighting. Maintains some HighlightJS workers doing
  * their job in the background.
diff --git a/polygerrit-ui/app/services/router/router-model.ts b/polygerrit-ui/app/services/router/router-model.ts
index f8bc778..5e2cc10 100644
--- a/polygerrit-ui/app/services/router/router-model.ts
+++ b/polygerrit-ui/app/services/router/router-model.ts
@@ -4,23 +4,16 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import {Observable} from 'rxjs';
-import {Finalizable} from '../registry';
-import {
-  NumericChangeId,
-  RevisionPatchSetNum,
-  BasePatchSetNum,
-} from '../../types/common';
 import {Model} from '../../models/model';
 import {select} from '../../utils/observable-util';
+import {define} from '../../models/dependency';
 
 export enum GerritView {
   ADMIN = 'admin',
   AGREEMENTS = 'agreements',
   CHANGE = 'change',
   DASHBOARD = 'dashboard',
-  DIFF = 'diff',
   DOCUMENTATION_SEARCH = 'documentation-search',
-  EDIT = 'edit',
   GROUP = 'group',
   PLUGIN_SCREEN = 'plugin-screen',
   REPO = 'repo',
@@ -28,31 +21,23 @@
   SETTINGS = 'settings',
 }
 
+// TODO: Consider renaming this to AppElementState or something similar.
+// Or maybe RootViewState. This class does *not* model the state of the router.
 export interface RouterState {
   // Note that this router model view must be updated before view model state.
   view?: GerritView;
-  changeNum?: NumericChangeId;
-  patchNum?: RevisionPatchSetNum;
-  basePatchNum?: BasePatchSetNum;
 }
 
-export class RouterModel extends Model<RouterState> implements Finalizable {
+export const routerModelToken = define<RouterModel>('router-model');
+
+// TODO: Consider renaming this to AppElementViewModel or something similar.
+// Or maybe RootViewModel. This class is *not* a view model of the router.
+export class RouterModel extends Model<RouterState> {
   readonly routerView$: Observable<GerritView | undefined> = select(
     this.state$,
     state => state.view
   );
 
-  readonly routerChangeNum$: Observable<NumericChangeId | undefined> = select(
-    this.state$,
-    state => state.changeNum
-  );
-
-  readonly routerPatchNum$: Observable<RevisionPatchSetNum | undefined> =
-    select(this.state$, state => state.patchNum);
-
-  readonly routerBasePatchNum$: Observable<BasePatchSetNum | undefined> =
-    select(this.state$, state => state.basePatchNum);
-
   constructor() {
     super({});
   }
diff --git a/polygerrit-ui/app/services/service-worker-installer.ts b/polygerrit-ui/app/services/service-worker-installer.ts
index 53cd325..b83713c 100644
--- a/polygerrit-ui/app/services/service-worker-installer.ts
+++ b/polygerrit-ui/app/services/service-worker-installer.ts
@@ -12,17 +12,51 @@
 import {UserModel} from '../models/user/user-model';
 import {AccountDetailInfo} from '../api/rest-api';
 import {until} from '../utils/async-util';
+import {LifeCycle} from '../constants/reporting';
+import {ReportingService} from './gr-reporting/gr-reporting';
+import {define} from '../models/dependency';
+import {Model} from '../models/model';
+import {Observable} from 'rxjs';
+import {select} from '../utils/observable-util';
 
 /** Type of incoming messages for ServiceWorker. */
 export enum ServiceWorkerMessageType {
   TRIGGER_NOTIFICATIONS = 'TRIGGER_NOTIFICATIONS',
   USER_PREFERENCE_CHANGE = 'USER_PREFERENCE_CHANGE',
+  REPORTING = 'REPORTING',
 }
 
 export const TRIGGER_NOTIFICATION_UPDATES_MS = 5 * 60 * 1000;
 
-export class ServiceWorkerInstaller {
-  initialized = false;
+export const serviceWorkerInstallerToken = define<ServiceWorkerInstaller>(
+  'service-worker-installer'
+);
+
+/**
+ * Service worker state:
+ * initialized - True when service worker registered and event listeners added.
+ *             - False otherwise
+ * shouldShowPrompt - True when user didn't make decision about notifications
+ *                  - False otherwise
+ */
+export interface ServiceWorkerInstallerState {
+  initialized: boolean;
+  shouldShowPrompt: boolean;
+}
+
+export class ServiceWorkerInstaller extends Model<ServiceWorkerInstallerState> {
+  readonly initialized$: Observable<Boolean | undefined> = select(
+    this.state$,
+    state => state.initialized
+  );
+
+  readonly shouldShowPrompt$: Observable<Boolean | undefined> = select(
+    this.initialized$,
+    _ => this.shouldShowPrompt()
+  );
+
+  // Internal state, it's exposed in initialized$
+  private initialized = false;
 
   account?: AccountDetailInfo;
 
@@ -30,8 +64,10 @@
 
   constructor(
     private readonly flagsService: FlagsService,
+    private readonly reportingService: ReportingService,
     private readonly userModel: UserModel
   ) {
+    super({initialized: false, shouldShowPrompt: false});
     if (!this.flagsService.isEnabled(KnownExperimentId.PUSH_NOTIFICATIONS)) {
       return;
     }
@@ -43,10 +79,12 @@
       ) {
         this.allowBrowserNotificationsPreference =
           prefs.allow_browser_notifications;
+        // flag can disable notifications similar to user setting
         navigator.serviceWorker.controller?.postMessage({
           type: ServiceWorkerMessageType.USER_PREFERENCE_CHANGE,
           allowBrowserNotificationsPreference:
-            this.allowBrowserNotificationsPreference,
+            this.allowBrowserNotificationsPreference &&
+            this.flagsService.isEnabled(KnownExperimentId.PUSH_NOTIFICATIONS),
         });
       }
     });
@@ -73,9 +111,40 @@
       return;
     }
     await registerServiceWorker('/service-worker.js');
-    const permission = await Notification.requestPermission();
+    const permission = Notification.permission;
+    this.reportingService.reportLifeCycle(LifeCycle.NOTIFICATION_PERMISSION, {
+      permission,
+    });
     if (this.isPermitted(permission)) this.startTriggerTimer();
     this.initialized = true;
+    this.updateState({initialized: true});
+    // Assumption: service worker will send event only to 1 client.
+    navigator.serviceWorker.onmessage = event => {
+      if (event.data?.type === ServiceWorkerMessageType.REPORTING) {
+        this.reportingService.reportLifeCycle(LifeCycle.SERVICE_WORKER_UPDATE, {
+          eventName: event.data.eventName as string | undefined,
+        });
+      }
+    };
+  }
+
+  // private, used in test
+  shouldShowPrompt(): boolean {
+    if (!this.initialized) return false;
+    if (!this.flagsService.isEnabled(KnownExperimentId.PUSH_NOTIFICATIONS)) {
+      return false;
+    }
+    if (!this.areNotificationsEnabled()) return false;
+    return Notification.permission === 'default';
+  }
+
+  public async requestPermission() {
+    const permission = await Notification.requestPermission();
+    this.reportingService.reportLifeCycle(LifeCycle.NOTIFICATION_PERMISSION, {
+      requested: true,
+      permission,
+    });
+    if (this.isPermitted(permission)) this.startTriggerTimer();
   }
 
   areNotificationsEnabled() {
diff --git a/polygerrit-ui/app/services/service-worker-installer_test.ts b/polygerrit-ui/app/services/service-worker-installer_test.ts
index e8fd233..a036289 100644
--- a/polygerrit-ui/app/services/service-worker-installer_test.ts
+++ b/polygerrit-ui/app/services/service-worker-installer_test.ts
@@ -9,14 +9,17 @@
 import {assert} from '@open-wc/testing';
 import {createDefaultPreferences} from '../constants/constants';
 import {waitUntilObserved} from '../test/test-utils';
+import {testResolver} from '../test/common-test-setup';
+import {userModelToken} from '../models/user/user-model';
 
 suite('service worker installer tests', () => {
   test('init', async () => {
     const registerStub = sinon.stub(window.navigator.serviceWorker, 'register');
     const flagsService = getAppContext().flagsService;
-    const userModel = getAppContext().userModel;
+    const reportingService = getAppContext().reportingService;
+    const userModel = testResolver(userModelToken);
     sinon.stub(flagsService, 'isEnabled').returns(true);
-    new ServiceWorkerInstaller(flagsService, userModel);
+    new ServiceWorkerInstaller(flagsService, reportingService, userModel);
     const prefs = {
       ...createDefaultPreferences(),
       allow_browser_notifications: true,
diff --git a/polygerrit-ui/app/services/shortcuts/shortcuts-config.ts b/polygerrit-ui/app/services/shortcuts/shortcuts-config.ts
index da61c41..9ca2213 100644
--- a/polygerrit-ui/app/services/shortcuts/shortcuts-config.ts
+++ b/polygerrit-ui/app/services/shortcuts/shortcuts-config.ts
@@ -35,6 +35,8 @@
   GO_TO_MERGED_CHANGES = 'GO_TO_MERGED_CHANGES',
   GO_TO_ABANDONED_CHANGES = 'GO_TO_ABANDONED_CHANGES',
   GO_TO_WATCHED_CHANGES = 'GO_TO_WATCHED_CHANGES',
+  GO_TO_REPOS = 'GO_TO_REPOS',
+  GO_TO_GROUPS = 'GO_TO_GROUPS',
 
   CURSOR_NEXT_CHANGE = 'CURSOR_NEXT_CHANGE',
   CURSOR_PREV_CHANGE = 'CURSOR_PREV_CHANGE',
@@ -167,6 +169,16 @@
     {key: 'w', combo: ComboKey.G}
   );
   describe(
+    Shortcut.GO_TO_REPOS,
+    ShortcutSection.EVERYWHERE,
+    'Go to Repositories',
+    {key: 'r', combo: ComboKey.G}
+  );
+  describe(Shortcut.GO_TO_GROUPS, ShortcutSection.EVERYWHERE, 'Go to Groups', {
+    key: 'g',
+    combo: ComboKey.G,
+  });
+  describe(
     Shortcut.TOGGLE_CHECKBOX,
     ShortcutSection.ACTIONS,
     'Toggle checkbox',
diff --git a/polygerrit-ui/app/services/shortcuts/shortcuts-service.ts b/polygerrit-ui/app/services/shortcuts/shortcuts-service.ts
index dac9b92..756c209 100644
--- a/polygerrit-ui/app/services/shortcuts/shortcuts-service.ts
+++ b/polygerrit-ui/app/services/shortcuts/shortcuts-service.ts
@@ -25,6 +25,7 @@
 import {Finalizable} from '../registry';
 import {UserModel} from '../../models/user/user-model';
 import {define} from '../../models/dependency';
+import {isCharacterLetter, isUpperCase} from '../../utils/string-util';
 
 export {Shortcut, ShortcutSection};
 
@@ -365,7 +366,10 @@
   if (binding.combo === ComboKey.V) {
     description.push('v');
   }
-  if (binding.modifiers?.includes(Modifier.SHIFT_KEY)) {
+  if (
+    binding.modifiers?.includes(Modifier.SHIFT_KEY) ||
+    (isCharacterLetter(binding.key) && isUpperCase(binding.key))
+  ) {
     description.push('Shift');
   }
   if (binding.modifiers?.includes(Modifier.ALT_KEY)) {
@@ -377,6 +381,12 @@
   if (binding.modifiers?.includes(Modifier.META_KEY)) {
     description.push('Meta/Cmd');
   }
-  description.push(describeKey(binding.key));
+
+  let key = describeKey(binding.key);
+  if (isCharacterLetter(key)) {
+    key = key.toLowerCase();
+  }
+  description.push(key);
+
   return description;
 }
diff --git a/polygerrit-ui/app/services/shortcuts/shortcuts-service_test.ts b/polygerrit-ui/app/services/shortcuts/shortcuts-service_test.ts
index 5b38a8a..164000a 100644
--- a/polygerrit-ui/app/services/shortcuts/shortcuts-service_test.ts
+++ b/polygerrit-ui/app/services/shortcuts/shortcuts-service_test.ts
@@ -15,6 +15,8 @@
 import {getAppContext} from '../app-context';
 import {pressKey} from '../../test/test-utils';
 import {assert} from '@open-wc/testing';
+import {testResolver} from '../../test/common-test-setup';
+import {userModelToken} from '../../models/user/user-model';
 
 const KEY_A: Binding = {key: 'a'};
 
@@ -23,14 +25,14 @@
 
   setup(() => {
     service = new ShortcutsService(
-      getAppContext().userModel,
+      testResolver(userModelToken),
       getAppContext().reportingService
     );
   });
 
   test('getShortcut', () => {
     assert.equal(service.getShortcut(Shortcut.NEXT_FILE), ']');
-    assert.equal(service.getShortcut(Shortcut.TOGGLE_LEFT_PANE), 'A');
+    assert.equal(service.getShortcut(Shortcut.TOGGLE_LEFT_PANE), 'Shift+a');
   });
 
   suite('addShortcut()', () => {
diff --git a/polygerrit-ui/app/services/storage/gr-storage.ts b/polygerrit-ui/app/services/storage/gr-storage.ts
index b3b76d4..d7eb09a 100644
--- a/polygerrit-ui/app/services/storage/gr-storage.ts
+++ b/polygerrit-ui/app/services/storage/gr-storage.ts
@@ -3,29 +3,15 @@
  * Copyright 2021 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-import {CommentRange, NumericChangeId, PatchSetNum} from '../../types/common';
+import {NumericChangeId} from '../../types/common';
 import {Finalizable} from '../registry';
 
-export interface StorageLocation {
-  changeNum: number;
-  patchNum: PatchSetNum | '@change';
-  path?: string;
-  line?: number;
-  range?: CommentRange;
-}
-
 export interface StorageObject {
   message?: string;
   updated: number;
 }
 
 export interface StorageService extends Finalizable {
-  getDraftComment(location: StorageLocation): StorageObject | null;
-
-  setDraftComment(location: StorageLocation, message: string): void;
-
-  eraseDraftComment(location: StorageLocation): void;
-
   getEditableContentItem(key: string): StorageObject | null;
 
   setEditableContentItem(key: string, message: string): void;
diff --git a/polygerrit-ui/app/services/storage/gr-storage_impl.ts b/polygerrit-ui/app/services/storage/gr-storage_impl.ts
index 0caffbc..7a47e0e 100644
--- a/polygerrit-ui/app/services/storage/gr-storage_impl.ts
+++ b/polygerrit-ui/app/services/storage/gr-storage_impl.ts
@@ -3,9 +3,10 @@
  * Copyright 2016 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-import {StorageLocation, StorageObject, StorageService} from './gr-storage';
+import {StorageObject, StorageService} from './gr-storage';
 import {Finalizable} from '../registry';
 import {NumericChangeId} from '../../types/common';
+import {define} from '../../models/dependency';
 
 export const DURATION_DAY = 24 * 60 * 60 * 1000;
 
@@ -16,30 +17,19 @@
 CLEANUP_PREFIXES_MAX_AGE_MAP.set('draft', DURATION_DAY);
 CLEANUP_PREFIXES_MAX_AGE_MAP.set('editablecontent', DURATION_DAY);
 
+export const storageServiceToken = define<StorageService>('storage-service');
+
 export class GrStorageService implements StorageService, Finalizable {
   private lastCleanup = 0;
 
-  private readonly storage = window.localStorage;
+  // visible for testing
+  storage = window.localStorage;
 
-  private exceededQuota = false;
+  // visible for testing
+  exceededQuota = false;
 
   finalize() {}
 
-  getDraftComment(location: StorageLocation): StorageObject | null {
-    this.cleanupItems();
-    return this.getObject(this.getDraftKey(location));
-  }
-
-  setDraftComment(location: StorageLocation, message: string) {
-    const key = this.getDraftKey(location);
-    this.setObject(key, {message, updated: Date.now()});
-  }
-
-  eraseDraftComment(location: StorageLocation) {
-    const key = this.getDraftKey(location);
-    this.storage.removeItem(key);
-  }
-
   getEditableContentItem(key: string): StorageObject | null {
     this.cleanupItems();
     return this.getObject(this.getEditableContentKey(key));
@@ -76,29 +66,13 @@
     }
   }
 
-  private getDraftKey(location: StorageLocation): string {
-    const range = location.range
-      ? `${location.range.start_line}-${location.range.start_character}` +
-        `-${location.range.end_character}-${location.range.end_line}`
-      : null;
-    let key = [
-      'draft',
-      location.changeNum,
-      location.patchNum,
-      location.path,
-      location.line || '',
-    ].join(':');
-    if (range) {
-      key = key + ':' + range;
-    }
-    return key;
-  }
-
-  private getEditableContentKey(key: string): string {
+  // visible for testing
+  getEditableContentKey(key: string): string {
     return `editablecontent:${key}`;
   }
 
-  private cleanupItems() {
+  // visible for testing
+  cleanupItems() {
     // Throttle cleanup to the throttle interval.
     if (
       this.lastCleanup &&
diff --git a/polygerrit-ui/app/services/storage/gr-storage_mock.ts b/polygerrit-ui/app/services/storage/gr-storage_mock.ts
index 65e6a89..822fef2 100644
--- a/polygerrit-ui/app/services/storage/gr-storage_mock.ts
+++ b/polygerrit-ui/app/services/storage/gr-storage_mock.ts
@@ -4,28 +4,10 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import {NumericChangeId} from '../../types/common';
-import {StorageLocation, StorageObject, StorageService} from './gr-storage';
+import {StorageObject, StorageService} from './gr-storage';
 
 const storage = new Map<string, StorageObject>();
 
-const getDraftKey = (location: StorageLocation): string => {
-  const range = location.range
-    ? `${location.range.start_line}-${location.range.start_character}` +
-      `-${location.range.end_character}-${location.range.end_line}`
-    : null;
-  let key = [
-    'draft',
-    location.changeNum,
-    location.patchNum,
-    location.path,
-    location.line || '',
-  ].join(':');
-  if (range) {
-    key = key + ':' + range;
-  }
-  return key;
-};
-
 const getEditableContentKey = (key: string): string => `editablecontent:${key}`;
 
 export function cleanUpStorage() {
@@ -34,19 +16,6 @@
 
 export const grStorageMock: StorageService = {
   finalize(): void {},
-  getDraftComment(location: StorageLocation): StorageObject | null {
-    return storage.get(getDraftKey(location)) ?? null;
-  },
-
-  setDraftComment(location: StorageLocation, message: string) {
-    const key = getDraftKey(location);
-    storage.set(key, {message, updated: Date.now()});
-  },
-
-  eraseDraftComment(location: StorageLocation) {
-    const key = getDraftKey(location);
-    storage.delete(key);
-  },
 
   getEditableContentItem(key: string): StorageObject | null {
     return storage.get(getEditableContentKey(key)) ?? null;
diff --git a/polygerrit-ui/app/services/storage/gr-storage_test.ts b/polygerrit-ui/app/services/storage/gr-storage_test.ts
index 92d611e..72878f8 100644
--- a/polygerrit-ui/app/services/storage/gr-storage_test.ts
+++ b/polygerrit-ui/app/services/storage/gr-storage_test.ts
@@ -4,17 +4,14 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import {assert} from '@open-wc/testing';
+import {NumericChangeId} from '../../api/rest-api';
 import '../../test/common-test-setup';
-import {PatchSetNum} from '../../types/common';
-import {StorageLocation} from './gr-storage';
 import {GrStorageService} from './gr-storage_impl';
 
 suite('gr-storage tests', () => {
-  // We have to type as any because we access private methods
-  // for testing
-  let grStorage: any;
+  let grStorage: GrStorageService;
 
-  function mockStorage(opt_quotaExceeded: boolean) {
+  function mockStorage(quotaExceeded: boolean): Storage {
     return {
       getItem(key: string) {
         return (this as any)[key];
@@ -23,12 +20,12 @@
         delete (this as any)[key];
       },
       setItem(key: string, value: string) {
-        if (opt_quotaExceeded) {
+        if (quotaExceeded) {
           throw new DOMException('error', 'QuotaExceededError');
         }
         (this as any)[key] = value;
       },
-    };
+    } as Storage;
   }
 
   setup(() => {
@@ -36,115 +33,12 @@
     grStorage.storage = mockStorage(false);
   });
 
-  test('storing, retrieving and erasing drafts', () => {
-    const changeNum = 1234;
-    const patchNum = 5 as PatchSetNum;
-    const path = 'my_source_file.js';
-    const line = 123;
-    const location = {
-      changeNum,
-      patchNum,
-      path,
-      line,
-    };
-
-    // The key is in the expected format.
-    const key = grStorage.getDraftKey(location);
-    assert.equal(key, ['draft', changeNum, patchNum, path, line].join(':'));
-
-    // There should be no draft initially.
-    const draft = grStorage.getDraftComment(location);
-    assert.isNotOk(draft);
-
-    // Setting the draft stores it under the expected key.
-    grStorage.setDraftComment(location, 'my comment');
-    assert.isOk(grStorage.storage.getItem(key));
-    assert.equal(
-      JSON.parse(grStorage.storage.getItem(key)).message,
-      'my comment'
-    );
-    assert.isOk(JSON.parse(grStorage.storage.getItem(key)).updated);
-
-    // Erasing the draft removes the key.
-    grStorage.eraseDraftComment(location);
-    assert.isNotOk(grStorage.storage.getItem(key));
-  });
-
-  test('automatically removes old drafts', () => {
-    const changeNum = 1234;
-    const patchNum = 5;
-    const path = 'my_source_file.js';
-    const line = 123;
-    const location = {
-      changeNum,
-      patchNum,
-      path,
-      line,
-    };
-
-    const key = grStorage.getDraftKey(location);
-
-    // Make sure that the call to cleanup doesn't get throttled.
-    grStorage.lastCleanup = 0;
-
-    const cleanupSpy = sinon.spy(grStorage, 'cleanupItems');
-
-    // Create a message with a timestamp that is a second behind the max age.
-    grStorage.storage.setItem(
-      key,
-      JSON.stringify({
-        message: 'old message',
-        updated: Date.now() - 24 * 60 * 60 * 1000 - 1000,
-      })
-    );
-
-    // Getting the draft should cause it to be removed.
-    const draft = grStorage.getDraftComment(location);
-
-    assert.isTrue(cleanupSpy.called);
-    assert.isNotOk(draft);
-    assert.isNotOk(grStorage.storage.getItem(key));
-  });
-
-  test('getDraftKey', () => {
-    const changeNum = 1234;
-    const patchNum = 5 as PatchSetNum;
-    const path = 'my_source_file.js';
-    const line = 123;
-    const location: StorageLocation = {
-      changeNum,
-      patchNum,
-      path,
-      line,
-    };
-    let expectedResult = 'draft:1234:5:my_source_file.js:123';
-    assert.equal(grStorage.getDraftKey(location), expectedResult);
-    location.range = {
-      start_character: 1,
-      start_line: 1,
-      end_character: 1,
-      end_line: 2,
-    };
-    expectedResult = 'draft:1234:5:my_source_file.js:123:1-1-1-2';
-    assert.equal(grStorage.getDraftKey(location), expectedResult);
-  });
-
   test('exceeded quota disables storage', () => {
     grStorage.storage = mockStorage(true);
     assert.isFalse(grStorage.exceededQuota);
 
-    const changeNum = 1234;
-    const patchNum = 5;
-    const path = 'my_source_file.js';
-    const line = 123;
-    const location = {
-      changeNum,
-      patchNum,
-      path,
-      line,
-    };
-    const key = grStorage.getDraftKey(location);
-    grStorage.setDraftComment(location, 'my comment');
+    const key = grStorage.getEditableContentKey('test-key');
+    grStorage.setEditableContentItem(key, 'test message');
     assert.isTrue(grStorage.exceededQuota);
     assert.isNotOk(grStorage.storage.getItem(key));
   });
@@ -159,16 +53,16 @@
     grStorage.setEditableContentItem(key, 'my content');
 
     // Setting the draft stores it under the expected key.
-    let item = grStorage.storage.getItem(computedKey);
+    const item = grStorage.storage.getItem(computedKey);
     assert.isOk(item);
-    assert.equal(JSON.parse(item).message, 'my content');
-    assert.isOk(JSON.parse(item).updated);
+    assert.equal(JSON.parse(item!).message, 'my content');
+    assert.isOk(JSON.parse(item!).updated);
 
     // getEditableContentItem performs as expected.
-    item = grStorage.getEditableContentItem(key);
-    assert.isOk(item);
-    assert.equal(item.message, 'my content');
-    assert.isOk(item.updated);
+    const obj = grStorage.getEditableContentItem(key);
+    assert.isOk(obj);
+    assert.equal(obj!.message, 'my content');
+    assert.isOk(obj!.updated);
     assert.isTrue(cleanupStub.called);
 
     // eraseEditableContentItem performs as expected.
@@ -188,8 +82,8 @@
       'editablecontent:c50_psedit_index.php'
     );
     assert.isOk(item);
-    assert.equal(JSON.parse(item).message, 'my content test 1');
-    assert.isOk(JSON.parse(item).updated);
+    assert.equal(JSON.parse(item!).message, 'my content test 1');
+    assert.isOk(JSON.parse(item!).updated);
 
     // We have to add getItem, removeItem and setItem to the array.
     // Typically these functions don't get outputed in .storage,
@@ -204,7 +98,7 @@
       'editablecontent:c50_ps3_index.php',
     ]);
 
-    grStorage.eraseEditableContentItemsForChangeEdit(50);
+    grStorage.eraseEditableContentItemsForChangeEdit(50 as NumericChangeId);
 
     // We have to add getItem, removeItem and setItem to the array.
     // Typically these functions don't get outputed in .storage,
diff --git a/polygerrit-ui/app/styles/dashboard-header-styles.ts b/polygerrit-ui/app/styles/dashboard-header-styles.ts
index cd45f8b..d9edb99 100644
--- a/polygerrit-ui/app/styles/dashboard-header-styles.ts
+++ b/polygerrit-ui/app/styles/dashboard-header-styles.ts
@@ -30,8 +30,7 @@
   .info > div > span {
     display: inline-block;
     font-weight: var(--font-weight-bold);
-    text-align: right;
-    width: 4em;
+    width: 3.5em;
   }
 `;
 
diff --git a/polygerrit-ui/app/styles/gr-change-list-styles.ts b/polygerrit-ui/app/styles/gr-change-list-styles.ts
index 4af276e54..0c7c151 100644
--- a/polygerrit-ui/app/styles/gr-change-list-styles.ts
+++ b/polygerrit-ui/app/styles/gr-change-list-styles.ts
@@ -14,11 +14,11 @@
     background-color: var(--selection-background-color);
   }
   gr-change-list-item[highlight] {
-    background-color: var(--assignee-highlight-color);
+    background-color: var(--line-item-highlight-color);
   }
   gr-change-list-item[highlight][selected],
   gr-change-list-item[highlight]:focus {
-    background-color: var(--assignee-highlight-selection-color);
+    background-color: var(--line-item-highlight-selection-color);
   }
   .groupTitle td,
   .cell {
diff --git a/polygerrit-ui/app/styles/gr-form-styles.ts b/polygerrit-ui/app/styles/gr-form-styles.ts
index cc89c3c..120b0bd 100644
--- a/polygerrit-ui/app/styles/gr-form-styles.ts
+++ b/polygerrit-ui/app/styles/gr-form-styles.ts
@@ -9,6 +9,7 @@
   .gr-form-styles input {
     background-color: var(--view-background-color);
     color: var(--primary-text-color);
+    font: inherit;
   }
   .gr-form-styles select {
     background-color: var(--select-background-color);
diff --git a/polygerrit-ui/app/styles/gr-menu-page-styles.ts b/polygerrit-ui/app/styles/gr-menu-page-styles.ts
index 7a44e79..17b7461 100644
--- a/polygerrit-ui/app/styles/gr-menu-page-styles.ts
+++ b/polygerrit-ui/app/styles/gr-menu-page-styles.ts
@@ -32,7 +32,7 @@
     color: var(--deemphasized-text-color);
     padding: var(--spacing-l);
   }
-  @media only screen and (max-width: 67em) {
+  @media only screen and (max-width: 70em) {
     .main {
       margin: var(--spacing-xxl) 0 var(--spacing-xxl) 15em;
     }
diff --git a/polygerrit-ui/app/styles/gr-modal-styles.ts b/polygerrit-ui/app/styles/gr-modal-styles.ts
new file mode 100644
index 0000000..b1bcf51
--- /dev/null
+++ b/polygerrit-ui/app/styles/gr-modal-styles.ts
@@ -0,0 +1,30 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {css} from 'lit';
+
+export const modalStyles = css`
+  dialog {
+    padding: 0;
+    border: 1px solid var(--border-color);
+    border-radius: var(--border-radius);
+    background: var(--dialog-background-color);
+    box-shadow: var(--elevation-level-5);
+    /*
+     * These styles are taken from main.css
+     * Dialog exists in the top-layer outside the body hence the styles
+     * in main.css were not being applied.
+     */
+    font-family: var(--font-family, ''), 'Roboto', Arial, sans-serif;
+    font-size: var(--font-size-normal, 1rem);
+    line-height: var(--line-height-normal, 1.4);
+    color: var(--primary-text-color, black);
+  }
+
+  dialog::backdrop {
+    background-color: black;
+    opacity: var(--modal-opacity, 0.6);
+  }
+`;
diff --git a/polygerrit-ui/app/styles/gr-page-nav-styles.ts b/polygerrit-ui/app/styles/gr-page-nav-styles.ts
index b6e8f60..963b2a2 100644
--- a/polygerrit-ui/app/styles/gr-page-nav-styles.ts
+++ b/polygerrit-ui/app/styles/gr-page-nav-styles.ts
@@ -16,13 +16,16 @@
     border-top: 1px solid transparent;
     display: block;
     padding: 0 var(--spacing-xl);
-  }
-  .navStyles li a {
-    display: block;
     overflow: hidden;
     text-overflow: ellipsis;
     white-space: nowrap;
   }
+  .navStyles li a {
+    display: block;
+    /* overflow and text-overflow are not inherited, must repeat them */
+    overflow: hidden;
+    text-overflow: ellipsis;
+  }
   .navStyles .subsectionItem {
     padding-left: var(--spacing-xxl);
   }
diff --git a/polygerrit-ui/app/styles/shared-styles.ts b/polygerrit-ui/app/styles/shared-styles.ts
index 839f612..5a7ca48 100644
--- a/polygerrit-ui/app/styles/shared-styles.ts
+++ b/polygerrit-ui/app/styles/shared-styles.ts
@@ -124,13 +124,8 @@
     /* iron-autogrow-textarea has a "-webkit-appearance: textarea" :host
         css rule, which prevents overriding the border color. Clear that. */
     -webkit-appearance: none;
-    --iron-autogrow-textarea: {
-      box-sizing: border-box;
-      padding: var(--spacing-s);
-    };
     --iron-autogrow-textarea_-_box-sizing: border-box;
     --iron-autogrow-textarea_-_padding: var(--spacing-s);
-    --iron-autogrow-textarea_-_white-space: pre-wrap;
   }
   a {
     color: var(--link-color);
diff --git a/polygerrit-ui/app/styles/themes/app-theme.ts b/polygerrit-ui/app/styles/themes/app-theme.ts
index e148da9..0503e4c 100644
--- a/polygerrit-ui/app/styles/themes/app-theme.ts
+++ b/polygerrit-ui/app/styles/themes/app-theme.ts
@@ -113,6 +113,8 @@
     --white-10: #ffffff1a;
     --white-12: #ffffff1f;
 
+    --modal-opacity: 0.32;
+
     --error-foreground: var(--red-700);
     --error-background: var(--red-50);
     --error-background-hover: linear-gradient(
@@ -224,7 +226,7 @@
     --tooltip-button-text-color: var(--gerrit-blue-dark);
     --negative-red-text-color: var(--red-600);
     --positive-green-text-color: var(--green-700);
-    --indirect-ancestor-text-color: var(--green-700);
+    --indirect-relation-text-color: var(--green-700);
 
     /* background colors */
     /* primary background colors */
@@ -246,10 +248,13 @@
     --table-subheader-background-color: var(--background-color-tertiary);
     --view-background-color: var(--background-color-primary);
     /* unique background colors */
+    /* TODO: Remove assignee colors once references are migrated */
     --assignee-highlight-color: #fcfad6;
-    /* TODO: Find a nicer way to combine the --assignee-highlight-color and the
-       --selection-background-color than to just invent another unique color. */
     --assignee-highlight-selection-color: #f6f4d0;
+    --line-item-highlight-color: #fcfad6;
+    /* TODO: Find a nicer way to combine the --line-item-highlight-color and the
+       --selection-background-color than to just invent another unique color. */
+    --line-item-highlight-selection-color: #f6f4d0;
     --chip-selected-background-color: var(--blue-50);
     --edit-mode-background-color: #ebf5fb;
     --emphasis-color: #fff9c4;
@@ -273,6 +278,11 @@
     --robot-comment-background-color: var(--blue-50);
     --unresolved-comment-background-color: #fef7e0;
 
+
+    /* Suggest edits */
+    --user-suggestion-header-background: var(--gray-700);
+    --user-suggestion-header-color: white;
+
     /* vote background colors */
     --vote-color-approved: var(--green-300);
     --vote-color-disliked: var(--red-50);
@@ -397,6 +407,8 @@
 
     --diff-moved-in-background: var(--cyan-50);
     --diff-moved-in-label-color: var(--cyan-900);
+    --diff-moved-in-changed-background: var(--cyan-50);
+    --diff-moved-in-changed-label-color: var(--cyan-900);
     --diff-moved-out-background: var(--purple-50);
     --diff-moved-out-label-color: var(--purple-900);
 
@@ -411,8 +423,8 @@
     --diff-trailing-whitespace-indicator: #ff9ad2;
     --focused-line-outline-color: var(--blue-700);
     --coverage-covered-line-num-color: var(--deemphasized-text-color);
-    --coverage-covered: #e0f2f1;
-    --coverage-not-covered: #ffd1a4;
+    --coverage-covered: var(--cyan-100);
+    --coverage-not-covered: var(--orange-100);
     --ranged-comment-hint-text-color: var(--orange-900);
     --token-highlighting-color: #fffd54;
 
@@ -474,13 +486,9 @@
 
     /* misc */
     --border-radius: 4px;
-    --reply-overlay-z-index: 1000;
     --line-length-indicator-color: #681da8;
 
-    /* paper and iron component overrides */
-    --iron-overlay-backdrop-background-color: black;
-    --iron-overlay-backdrop-opacity: 0.32;
-
+    /* paper component overrides */
     --paper-tooltip-delay-in: 200ms;
     --paper-tooltip-delay-out: 0;
     --paper-tooltip-duration-in: 0;
@@ -517,9 +525,6 @@
     --paper-tooltip: {
       font-size: var(--font-size-small);
     };
-    --iron-overlay-backdrop: {
-      transition: none;
-    };
   }
 `;
 
diff --git a/polygerrit-ui/app/styles/themes/dark-theme.ts b/polygerrit-ui/app/styles/themes/dark-theme.ts
index 2330041..dc3d4e9 100644
--- a/polygerrit-ui/app/styles/themes/dark-theme.ts
+++ b/polygerrit-ui/app/styles/themes/dark-theme.ts
@@ -113,7 +113,7 @@
     --tooltip-button-text-color: var(--gerrit-blue-light);
     --negative-red-text-color: var(--red-200);
     --positive-green-text-color: var(--green-200);
-    --indirect-ancestor-text-color: var(--green-200);
+    --indirect-relation-text-color: var(--green-200);
 
     /* background colors */
     /* primary background colors */
@@ -123,8 +123,8 @@
     /* directly derived from primary background colors */
     /*   empty, because inheriting from app-theme is just fine
       /* unique background colors */
-    --assignee-highlight-color: #3a361c;
-    --assignee-highlight-selection-color: #423e24;
+    --line-item-highlight-color: #3a361c;
+    --line-item-highlight-selection-color: #423e24;
     --chip-selected-background-color: #3c4455;
     --edit-mode-background-color: #5c0a36;
     --emphasis-color: #383f4a;
@@ -138,6 +138,10 @@
     --robot-comment-background-color: #1e3a5f;
     --unresolved-comment-background-color: #614a19;
 
+    /* Suggest edits */
+    --user-suggestion-header-background: var(--gray-700);
+    --user-suggestion-header-color: white;
+
     /* vote background colors */
     --vote-color-approved: var(--green-300);
     --vote-color-disliked: var(--red-tonal);
@@ -226,6 +230,8 @@
 
     --diff-moved-in-background: #1d4042;
     --diff-moved-in-label-color: var(--cyan-50);
+    --diff-moved-in-changed-background: #1d4042;
+    --diff-moved-in-changed-label-color: var(--cyan-50);
     --diff-moved-out-background: #230e34;
     --diff-moved-out-label-color: var(--purple-50);
 
@@ -239,9 +245,9 @@
     --diff-tab-indicator-color: var(--deemphasized-text-color);
     --diff-trailing-whitespace-indicator: #ff9ad2;
     --focused-line-outline-color: var(--blue-200);
-    --coverage-covered: #37674a;
+    --coverage-covered: var(--cyan-tonal);
     --coverage-covered-line-num-color: var(--gray-200);
-    --coverage-not-covered: #6b3600;
+    --coverage-not-covered: var(--orange-tonal);
     --ranged-comment-hint-text-color: var(--blue-50);
     --token-highlighting-color: var(--yellow-tonal);
 
@@ -279,9 +285,6 @@
     /* misc */
     --line-length-indicator-color: #d7aefb;
 
-    /* paper and iron component overrides */
-    --iron-overlay-backdrop-background-color: white;
-
     /* rules applied to html */
     background-color: var(--view-background-color);
   }
@@ -292,7 +295,13 @@
   const styleEl = document.createElement('style');
   styleEl.setAttribute('id', 'dark-theme');
   safeStyleEl.setTextContent(styleEl, darkThemeCss);
-  document.head.appendChild(styleEl);
+
+  // We would like to insert the dark theme styles after the light theme such
+  // that the dark theme values override the defaults in the light theme. But
+  // OTOH we want to insert before any plugin provided styles, because we do NOT
+  // want to override those.
+  const pluginStyleEl = document.head.querySelector('style#plugin-style');
+  document.head.insertBefore(styleEl, pluginStyleEl);
 }
 
 export function removeTheme() {
diff --git a/polygerrit-ui/app/test/common-test-setup.ts b/polygerrit-ui/app/test/common-test-setup.ts
index 306747b..365bb16 100644
--- a/polygerrit-ui/app/test/common-test-setup.ts
+++ b/polygerrit-ui/app/test/common-test-setup.ts
@@ -6,24 +6,23 @@
 // TODO(dmfilippov): remove bundled-polymer.js imports when the following issue
 // https://github.com/Polymer/polymer-resin/issues/9 is resolved.
 import '../scripts/bundled-polymer';
-import {AppContext, injectAppContext} from '../services/app-context';
+import {getAppContext} from '../services/app-context';
 import {Finalizable} from '../services/registry';
 import {
   createTestAppContext,
   createTestDependencies,
-  Creator,
 } from './test-app-context-init';
-import {_testOnly_resetPluginLoader} from '../elements/shared/gr-js-api-interface/gr-plugin-loader';
-import {_testOnlyResetGrRestApiSharedObjects} from '../services/gr-rest-api/gr-rest-api-impl';
+import {testOnlyResetGrRestApiSharedObjects} from '../services/gr-rest-api/gr-rest-api-impl';
 import {
   cleanupTestUtils,
   getCleanupsCount,
-  addIronOverlayBackdropStyleEl,
-  removeIronOverlayBackdropStyleEl,
   removeThemeStyles,
 } from './test-utils';
 import {safeTypesBridge} from '../utils/safe-types-util';
-import {initGlobalVariables} from '../elements/gr-app-global-var-init';
+import {
+  initGerrit,
+  initGlobalVariables,
+} from '../elements/gr-app-global-var-init';
 import {assert, fixtureCleanup} from '@open-wc/testing';
 import {
   _testOnly_defaultResinReportHandler,
@@ -39,6 +38,8 @@
 } from '../models/dependency';
 import * as sinon from 'sinon';
 import '../styles/themes/app-theme.ts';
+import {Creator} from '../services/app-context-init';
+import {pluginLoaderToken} from '../elements/shared/gr-js-api-interface/gr-plugin-loader';
 
 declare global {
   interface Window {
@@ -59,7 +60,6 @@
 });
 
 let testSetupTimestampMs = 0;
-let appContext: AppContext & Finalizable;
 
 const injectedDependencies: Map<
   DependencyToken<unknown>,
@@ -91,44 +91,44 @@
 }
 
 function resolveDependency(evt: DependencyRequestEvent<unknown>) {
-  evt.callback(testResolver(evt.dependency));
+  evt.callback(() => testResolver(evt.dependency));
 }
 
 setup(() => {
   testSetupTimestampMs = new Date().getTime();
-  addIronOverlayBackdropStyleEl();
 
   // If the following asserts fails - then window.stub is
   // overwritten by some other code.
   assert.equal(getCleanupsCount(), 0);
-  appContext = createTestAppContext();
-  injectAppContext(appContext);
-  finalizers.push(appContext);
-  const dependencies = createTestDependencies(appContext, testResolver);
+  initGlobalVariables(createTestAppContext(), false);
+
+  finalizers.push(getAppContext());
+  const dependencies = createTestDependencies(getAppContext(), testResolver);
   for (const [token, provider] of dependencies) {
     injectDependency(token, provider);
   }
   document.addEventListener('request-dependency', resolveDependency);
+  initGerrit(testResolver(pluginLoaderToken));
+
   // The following calls is necessary to avoid influence of previously executed
   // tests.
-  initGlobalVariables(appContext);
-
   const selection = document.getSelection();
   if (selection) {
     selection.removeAllRanges();
   }
-  const pl = _testOnly_resetPluginLoader();
   // For testing, always init with empty plugin list
   // Since when serve in gr-app, we always retrieve the list
   // from project config and init loading after that, all
   // `awaitPluginsLoaded` will rely on that to kick off,
   // in testing, we want to kick start this earlier.
-  // You still can manually call _testOnly_resetPluginLoader
-  // to reset this behavior if you need to test something specific.
-  pl.loadPlugins([]);
-  _testOnlyResetGrRestApiSharedObjects();
+  testResolver(pluginLoaderToken).loadPlugins([]);
+  testOnlyResetGrRestApiSharedObjects(getAppContext().authService);
 });
 
+export function removeRequestDependencyListener() {
+  document.removeEventListener('request-dependency', resolveDependency);
+}
+
 // Very simple function to catch unexpected elements in documents body.
 // It can't catch everything, but in most cases it is enough.
 function checkChildAllowed(element: Element) {
@@ -136,23 +136,6 @@
   if (allowedTags.includes(element.tagName)) {
     return;
   }
-  if (element.tagName === 'TEST-FIXTURE') {
-    if (
-      element.children.length === 0 ||
-      (element.children.length === 1 &&
-        element.children[0].tagName === 'TEMPLATE')
-    ) {
-      return;
-    }
-    assert.fail(
-      `Test fixture
-        ${element.outerHTML}` +
-        "isn't resotred after the test is finished. Please ensure that " +
-        'restore() method is called for this test-fixture. Usually the call' +
-        'happens automatically.'
-    );
-    return;
-  }
   if (
     element.tagName === 'DIV' &&
     element.id === 'gr-hovercard-container' &&
@@ -185,11 +168,10 @@
   fixtureCleanup();
   cleanupTestUtils();
   checkGlobalSpace();
-  removeIronOverlayBackdropStyleEl();
   removeThemeStyles();
   cancelAllTasks();
   cleanUpStorage();
-  document.removeEventListener('request-dependency', resolveDependency);
+  removeRequestDependencyListener();
   injectedDependencies.clear();
   // Reset state
   for (const f of finalizers) {
diff --git a/polygerrit-ui/app/test/functional/README.md b/polygerrit-ui/app/test/functional/README.md
deleted file mode 100644
index 82c6133..0000000
--- a/polygerrit-ui/app/test/functional/README.md
+++ /dev/null
@@ -1,54 +0,0 @@
-# Functional test suite
-
-## Installing Docker (OSX)
-
-Simplest way to install all of those is to use Homebrew:
-
-```
-brew cask install docker
-```
-
-This will install a Docker in Applications. To run if from the command-line:
-
-```
-open /Applications/Docker.app
-```
-
-It'll require privileged access and will require user password to be entered.
-
-To validate Docker is installed correctly, run hello-world image:
-
-```
-docker run hello-world
-```
-
-## Building a Docker image
-
-Should be done once only for development purposes, run from the Gerrit checkout
-path:
-
-```
-docker build -t gerrit/polygerrit-functional:v1 \
-  polygerrit-ui/app/test/functional/infra
-```
-
-## Running a smoke test
-
-Running a smoke test from Gerrit checkout path:
-
-```
-./polygerrit-ui/app/test/functional/run_functional.sh
-```
-
-The successful output should be something similar to this:
-
-```
-Starting local server..
-Starting Webdriver..
-Started
-.
-
-
-1 spec, 0 failures
-Finished in 2.565 seconds
-```
diff --git a/polygerrit-ui/app/test/functional/infra/Dockerfile b/polygerrit-ui/app/test/functional/infra/Dockerfile
deleted file mode 100644
index e642176..0000000
--- a/polygerrit-ui/app/test/functional/infra/Dockerfile
+++ /dev/null
@@ -1,38 +0,0 @@
-FROM selenium/standalone-chrome-debug
-
-USER root
-
-# nvm environment variables
-ENV NVM_DIR /usr/local/nvm
-ENV NODE_VERSION 9.4.0
-
-# install nvm
-# https://github.com/creationix/nvm#install-script
-RUN wget -qO- https://raw.githubusercontent.com/creationix/nvm/v0.33.8/install.sh | bash
-
-# install node and npm
-RUN [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" \
-    && nvm install $NODE_VERSION \
-    && nvm alias default $NODE_VERSION \
-    && nvm use default
-
-ENV NODE_PATH $NVM_DIR/v$NODE_VERSION/lib/node_modules
-ENV PATH $NVM_DIR/versions/node/v$NODE_VERSION/bin:$PATH
-
-RUN npm install -g jasmine
-RUN npm install -g http-server
-
-USER seluser
-
-RUN mkdir -p /tmp/app
-WORKDIR /tmp/app
-
-RUN npm init -y
-RUN npm install --save selenium-webdriver
-
-EXPOSE 8080
-
-COPY test-infra.js /tmp/app/node_modules
-COPY run.sh /tmp/app/
-
-ENTRYPOINT [ "/tmp/app/run.sh" ]
diff --git a/polygerrit-ui/app/test/functional/infra/run.sh b/polygerrit-ui/app/test/functional/infra/run.sh
deleted file mode 100755
index 4beb3dd..0000000
--- a/polygerrit-ui/app/test/functional/infra/run.sh
+++ /dev/null
@@ -1,14 +0,0 @@
-#!/bin/sh
-echo Starting local server..
-cp /app/polygerrit_ui.zip .
-unzip -q polygerrit_ui.zip
-nohup http-server polygerrit_ui > /tmp/http-server.log 2>&1 &
-
-echo Starting Webdriver..
-nohup /opt/bin/entry_point.sh > /tmp/webdriver.log 2>&1 &
-
-# Wait for servers to start
-sleep 5
-
-cp $@ .
-jasmine $(basename $@)
diff --git a/polygerrit-ui/app/test/functional/infra/test-infra.js b/polygerrit-ui/app/test/functional/infra/test-infra.js
deleted file mode 100644
index 2619694..0000000
--- a/polygerrit-ui/app/test/functional/infra/test-infra.js
+++ /dev/null
@@ -1,24 +0,0 @@
-'use strict';
-
-const {Builder} = require('selenium-webdriver');
-
-let driver;
-
-function setup() {
-  return new Builder()
-      .forBrowser('chrome')
-      .usingServer('http://localhost:4444/wd/hub')
-      .build()
-      .then(d => {
-        driver = d;
-        return driver.get('http://localhost:8080');
-      })
-      .then(() => driver);
-}
-
-function cleanup() {
-  return driver.quit();
-}
-
-exports.setup = setup;
-exports.cleanup = cleanup;
diff --git a/polygerrit-ui/app/test/functional/run_functional.sh b/polygerrit-ui/app/test/functional/run_functional.sh
deleted file mode 100755
index 7ce57b8..0000000
--- a/polygerrit-ui/app/test/functional/run_functional.sh
+++ /dev/null
@@ -1,10 +0,0 @@
-#!/usr/bin/env bash
-
-bazel build //polygerrit-ui/app:polygerrit_ui
-
-docker run --rm \
-  -p 5900:5900 \
-  -v `pwd`/polygerrit-ui/app/test/functional:/tests \
-  -v `pwd`/bazel-genfiles/polygerrit-ui/app:/app \
-  -it gerrit/polygerrit-functional:v1 \
-  /tests/test.js
diff --git a/polygerrit-ui/app/test/functional/test.js b/polygerrit-ui/app/test/functional/test.js
deleted file mode 100644
index ae572af..0000000
--- a/polygerrit-ui/app/test/functional/test.js
+++ /dev/null
@@ -1,21 +0,0 @@
-/**
- * @fileoverview Minimal viable frontend functional test.
- */
-'use strict';
-
-const {until} = require('selenium-webdriver');
-const {setup, cleanup} = require('test-infra');
-
-jasmine.DEFAULT_TIMEOUT_INTERVAL = 20000;
-
-describe('example ', () => {
-  let driver;
-
-  beforeAll(() => setup().then(d => driver = d));
-
-  afterAll(() => cleanup());
-
-  it('should update title', () => driver.wait(
-      until.titleIs('status:open · Gerrit Code Review'), 5000
-  ));
-});
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 492bd8d..bfa881e 100644
--- a/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
+++ b/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
@@ -30,10 +30,9 @@
   ConfigInfo,
   EditInfo,
   DashboardInfo,
-  ProjectAccessInfoMap,
+  RepoAccessInfoMap,
   IncludedInInfo,
   CommentInfo,
-  PathToCommentsInfoMap,
   PluginInfo,
   DocResult,
   ContributorAgreementInfo,
@@ -59,6 +58,7 @@
   UrlEncodedRepoName,
   NumericChangeId,
   PreferencesInput,
+  DraftInfo,
 } from '../../types/common';
 import {DiffInfo, DiffPreferencesInfo} from '../../types/diff';
 import {readResponsePayload} from '../../elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
@@ -67,6 +67,7 @@
   createChange,
   createCommit,
   createConfig,
+  createMergeable,
   createPreferences,
   createServerInfo,
   createSubmittedTogetherInfo,
@@ -76,6 +77,11 @@
   createDefaultEditPrefs,
 } from '../../constants/constants';
 import {ParsedChangeInfo} from '../../types/types';
+import {getBaseUrl} from '../../utils/url-util';
+import {
+  DOCS_BASE_PATH,
+  PROBE_PATH,
+} from '../../services/gr-rest-api/gr-rest-api-impl';
 
 export const grRestApiMock: RestApiService = {
   addAccountEmail(): Promise<Response> {
@@ -305,6 +311,16 @@
     // eslint-disable-next-line @typescript-eslint/no-explicit-any
     return Promise.resolve({}) as any;
   },
+  getDocsBaseUrl(config?: ServerInfo): Promise<string | null> {
+    if (config?.gerrit?.doc_url) {
+      return Promise.resolve(config.gerrit.doc_url);
+    } else {
+      return this.probePath(getBaseUrl() + PROBE_PATH).then(ok =>
+        Promise.resolve(ok ? getBaseUrl() + DOCS_BASE_PATH : null)
+      );
+    }
+    return Promise.resolve('');
+  },
   getDocumentationSearches(): Promise<DocResult[] | undefined> {
     return Promise.resolve([]);
   },
@@ -350,18 +366,19 @@
     return Promise.resolve(true);
   },
   getMergeable(): Promise<MergeableInfo | undefined> {
-    throw new Error('getMergeable() not implemented by RestApiMock.');
+    return Promise.resolve(createMergeable());
   },
   getPlugins(): Promise<{[p: string]: PluginInfo} | undefined> {
     return Promise.resolve({});
   },
-  getPortedComments(): Promise<PathToCommentsInfoMap | undefined> {
+  getPortedComments(): Promise<{[path: string]: CommentInfo[]} | undefined> {
     return Promise.resolve({});
   },
-  getPortedDrafts(): Promise<PathToCommentsInfoMap | undefined> {
+  getPortedDrafts(): Promise<{[path: string]: DraftInfo[]} | undefined> {
     return Promise.resolve({});
   },
   getPreferences(): Promise<PreferencesInfo | undefined> {
+    // TODO: Use createDefaultPreferences() instead.
     return Promise.resolve(createPreferences());
   },
   getProjectConfig(): Promise<ConfigInfo | undefined> {
@@ -376,7 +393,7 @@
       name: repo,
     });
   },
-  getRepoAccess(): Promise<ProjectAccessInfoMap | undefined> {
+  getRepoAccess(): Promise<RepoAccessInfoMap | undefined> {
     return Promise.resolve({});
   },
   getRepoAccessRights(): Promise<ProjectAccessInfo | undefined> {
@@ -412,7 +429,7 @@
   getSuggestedGroups(): Promise<GroupNameToGroupInfoMap | undefined> {
     return Promise.resolve({});
   },
-  getSuggestedProjects(): Promise<NameToProjectInfoMap | undefined> {
+  getSuggestedRepos(): Promise<NameToProjectInfoMap | undefined> {
     return Promise.resolve({});
   },
   getTopMenus(): Promise<TopMenuEntryInfo[] | undefined> {
diff --git a/polygerrit-ui/app/test/test-app-context-init.ts b/polygerrit-ui/app/test/test-app-context-init.ts
index 0693570..026e3b5 100644
--- a/polygerrit-ui/app/test/test-app-context-init.ts
+++ b/polygerrit-ui/app/test/test-app-context-init.ts
@@ -6,221 +6,48 @@
 
 // Init app context before any other imports
 import {create, Registry, Finalizable} from '../services/registry';
-import {DependencyToken} from '../models/dependency';
-import {assertIsDefined} from '../utils/common-util';
 import {AppContext} from '../services/app-context';
 import {grReportingMock} from '../services/gr-reporting/gr-reporting_mock';
 import {grRestApiMock} from './mocks/gr-rest-api_mock';
 import {grStorageMock} from '../services/storage/gr-storage_mock';
 import {GrAuthMock} from '../services/gr-auth/gr-auth_mock';
 import {FlagsServiceImplementation} from '../services/flags/flags_impl';
-import {EventEmitter} from '../services/gr-event-interface/gr-event-interface_impl';
-import {ChangeModel, changeModelToken} from '../models/change/change-model';
-import {FilesModel, filesModelToken} from '../models/change/files-model';
-import {ChecksModel, checksModelToken} from '../models/checks/checks-model';
-import {GrJsApiInterface} from '../elements/shared/gr-js-api-interface/gr-js-api-interface-element';
-import {UserModel} from '../models/user/user-model';
-import {
-  CommentsModel,
-  commentsModelToken,
-} from '../models/comments/comments-model';
-import {RouterModel} from '../services/router/router-model';
-import {
-  ShortcutsService,
-  shortcutsServiceToken,
-} from '../services/shortcuts/shortcuts-service';
-import {ConfigModel, configModelToken} from '../models/config/config-model';
-import {BrowserModel, browserModelToken} from '../models/browser/browser-model';
-import {PluginsModel} from '../models/plugins/plugins-model';
 import {MockHighlightService} from '../services/highlight/highlight-service-mock';
-import {
-  AccountsModel,
-  accountsModelToken,
-} from '../models/accounts-model/accounts-model';
-import {
-  DashboardViewModel,
-  dashboardViewModelToken,
-} from '../models/views/dashboard';
-import {
-  SettingsViewModel,
-  settingsViewModelToken,
-} from '../models/views/settings';
-import {GrRouter, routerToken} from '../elements/core/gr-router/gr-router';
-import {AdminViewModel, adminViewModelToken} from '../models/views/admin';
-import {
-  AgreementViewModel,
-  agreementViewModelToken,
-} from '../models/views/agreement';
-import {ChangeViewModel, changeViewModelToken} from '../models/views/change';
-import {DiffViewModel, diffViewModelToken} from '../models/views/diff';
-import {
-  DocumentationViewModel,
-  documentationViewModelToken,
-} from '../models/views/documentation';
-import {EditViewModel, editViewModelToken} from '../models/views/edit';
-import {GroupViewModel, groupViewModelToken} from '../models/views/group';
-import {PluginViewModel, pluginViewModelToken} from '../models/views/plugin';
-import {RepoViewModel, repoViewModelToken} from '../models/views/repo';
-import {SearchViewModel, searchViewModelToken} from '../models/views/search';
+import {createAppDependencies, Creator} from '../services/app-context-init';
 import {navigationToken} from '../elements/core/gr-navigation/gr-navigation';
+import {DependencyToken} from '../models/dependency';
+import {storageServiceToken} from '../services/storage/gr-storage_impl';
+import {highlightServiceToken} from '../services/highlight/highlight-service';
 
 export function createTestAppContext(): AppContext & Finalizable {
   const appRegistry: Registry<AppContext> = {
-    routerModel: (_ctx: Partial<AppContext>) => new RouterModel(),
     flagsService: (_ctx: Partial<AppContext>) =>
       new FlagsServiceImplementation(),
     reportingService: (_ctx: Partial<AppContext>) => grReportingMock,
-    eventEmitter: (_ctx: Partial<AppContext>) => new EventEmitter(),
-    authService: (ctx: Partial<AppContext>) => {
-      assertIsDefined(ctx.eventEmitter, 'eventEmitter');
-      return new GrAuthMock(ctx.eventEmitter);
-    },
+    authService: (_ctx: Partial<AppContext>) => new GrAuthMock(),
     restApiService: (_ctx: Partial<AppContext>) => grRestApiMock,
-    jsApiService: (ctx: Partial<AppContext>) => {
-      assertIsDefined(ctx.reportingService, 'reportingService');
-      return new GrJsApiInterface(ctx.reportingService);
-    },
-    storageService: (_ctx: Partial<AppContext>) => grStorageMock,
-    userModel: (ctx: Partial<AppContext>) => {
-      assertIsDefined(ctx.restApiService, 'restApiService');
-      return new UserModel(ctx.restApiService);
-    },
-    accountsModel: (ctx: Partial<AppContext>) => {
-      assertIsDefined(ctx.restApiService, 'restApiService');
-      return new AccountsModel(ctx.restApiService);
-    },
-    shortcutsService: (ctx: Partial<AppContext>) => {
-      assertIsDefined(ctx.userModel, 'userModel');
-      assertIsDefined(ctx.flagsService, 'flagsService');
-      assertIsDefined(ctx.reportingService, 'reportingService');
-      return new ShortcutsService(ctx.userModel, ctx.reportingService);
-    },
-    pluginsModel: (_ctx: Partial<AppContext>) => new PluginsModel(),
-    highlightService: (ctx: Partial<AppContext>) => {
-      assertIsDefined(ctx.reportingService, 'reportingService');
-      return new MockHighlightService(ctx.reportingService);
-    },
   };
   return create<AppContext>(appRegistry);
 }
 
-export type Creator<T> = () => T & Finalizable;
-
-// Test dependencies are provides as creator functions to ensure that they are
-// not created if a test doesn't depend on them. E.g. don't create a
-// change-model in change-model_test.ts because it creates one in the test
-// after setting up stubs.
 export function createTestDependencies(
   appContext: AppContext,
   resolver: <T>(token: DependencyToken<T>) => T
 ): Map<DependencyToken<unknown>, Creator<unknown>> {
-  const dependencies = new Map<DependencyToken<unknown>, Creator<unknown>>();
-  const browserModel = () => new BrowserModel(appContext.userModel);
-  dependencies.set(browserModelToken, browserModel);
-
-  const adminViewModelCreator = () => new AdminViewModel();
-  dependencies.set(adminViewModelToken, adminViewModelCreator);
-  const agreementViewModelCreator = () => new AgreementViewModel();
-  dependencies.set(agreementViewModelToken, agreementViewModelCreator);
-  const changeViewModelCreator = () => new ChangeViewModel();
-  dependencies.set(changeViewModelToken, changeViewModelCreator);
-  const dashboardViewModelCreator = () => new DashboardViewModel();
-  dependencies.set(dashboardViewModelToken, dashboardViewModelCreator);
-  const diffViewModelCreator = () => new DiffViewModel();
-  dependencies.set(diffViewModelToken, diffViewModelCreator);
-  const documentationViewModelCreator = () => new DocumentationViewModel();
-  dependencies.set(documentationViewModelToken, documentationViewModelCreator);
-  const editViewModelCreator = () => new EditViewModel();
-  dependencies.set(editViewModelToken, editViewModelCreator);
-  const groupViewModelCreator = () => new GroupViewModel();
-  dependencies.set(groupViewModelToken, groupViewModelCreator);
-  const pluginViewModelCreator = () => new PluginViewModel();
-  dependencies.set(pluginViewModelToken, pluginViewModelCreator);
-  const repoViewModelCreator = () => new RepoViewModel();
-  dependencies.set(repoViewModelToken, repoViewModelCreator);
-  const searchViewModelCreator = () =>
-    new SearchViewModel(appContext.restApiService, appContext.userModel, () =>
-      resolver(navigationToken)
-    );
-  dependencies.set(searchViewModelToken, searchViewModelCreator);
-  const settingsViewModelCreator = () => new SettingsViewModel();
-  dependencies.set(settingsViewModelToken, settingsViewModelCreator);
-
-  const routerCreator = () =>
-    new GrRouter(
-      appContext.reportingService,
-      appContext.routerModel,
-      appContext.restApiService,
-      resolver(adminViewModelToken),
-      resolver(agreementViewModelToken),
-      resolver(changeViewModelToken),
-      resolver(dashboardViewModelToken),
-      resolver(diffViewModelToken),
-      resolver(documentationViewModelToken),
-      resolver(editViewModelToken),
-      resolver(groupViewModelToken),
-      resolver(pluginViewModelToken),
-      resolver(repoViewModelToken),
-      resolver(searchViewModelToken),
-      resolver(settingsViewModelToken)
-    );
-  dependencies.set(routerToken, routerCreator);
+  const dependencies = createAppDependencies(appContext, resolver);
+  dependencies.set(storageServiceToken, () => grStorageMock);
   dependencies.set(navigationToken, () => {
     return {
       setUrl: () => {},
       replaceUrl: () => {},
       finalize: () => {},
+      blockNavigation: () => {},
+      releaseNavigation: () => {},
     };
   });
-
-  const changeModelCreator = () =>
-    new ChangeModel(
-      appContext.routerModel,
-      appContext.restApiService,
-      appContext.userModel
-    );
-  dependencies.set(changeModelToken, changeModelCreator);
-
-  const accountsModelCreator = () =>
-    new AccountsModel(appContext.restApiService);
-  dependencies.set(accountsModelToken, accountsModelCreator);
-
-  const commentsModelCreator = () =>
-    new CommentsModel(
-      appContext.routerModel,
-      resolver(changeModelToken),
-      resolver(accountsModelToken),
-      appContext.restApiService,
-      appContext.reportingService
-    );
-  dependencies.set(commentsModelToken, commentsModelCreator);
-
-  const filesModelCreator = () =>
-    new FilesModel(
-      resolver(changeModelToken),
-      resolver(commentsModelToken),
-      appContext.restApiService
-    );
-  dependencies.set(filesModelToken, filesModelCreator);
-
-  const configModelCreator = () =>
-    new ConfigModel(resolver(changeModelToken), appContext.restApiService);
-  dependencies.set(configModelToken, configModelCreator);
-
-  const checksModelCreator = () =>
-    new ChecksModel(
-      appContext.routerModel,
-      resolver(changeViewModelToken),
-      resolver(changeModelToken),
-      appContext.reportingService,
-      appContext.pluginsModel
-    );
-
-  dependencies.set(checksModelToken, checksModelCreator);
-
-  const shortcutServiceCreator = () =>
-    new ShortcutsService(appContext.userModel, appContext.reportingService);
-  dependencies.set(shortcutsServiceToken, shortcutServiceCreator);
-
+  dependencies.set(
+    highlightServiceToken,
+    () => new MockHighlightService(appContext.reportingService)
+  );
   return dependencies;
 }
diff --git a/polygerrit-ui/app/test/test-data-generators.ts b/polygerrit-ui/app/test/test-data-generators.ts
index ab3064f..d2cba9a 100644
--- a/polygerrit-ui/app/test/test-data-generators.ts
+++ b/polygerrit-ui/app/test/test-data-generators.ts
@@ -66,57 +66,53 @@
   SubmitTypeInfo,
   SuggestInfo,
   Timestamp,
-  TimezoneOffset,
   UrlEncodedCommentId,
   UserConfigInfo,
+  CommentThread,
+  DraftInfo,
+  ChangeMessage,
+  SavingState,
 } from '../types/common';
 import {
   AccountsVisibility,
   AccountTag,
-  AppTheme,
   AuthType,
   ChangeStatus,
   CommentSide,
-  DateFormat,
-  DefaultBase,
+  createDefaultPreferences,
   DefaultDisplayNameConfig,
-  DiffViewMode,
   EmailStrategy,
   InheritedBooleanInfoConfiguredValue,
   MergeabilityComputationBehavior,
   RequirementStatus,
   RevisionKind,
   SubmitType,
-  TimeFormat,
 } from '../constants/constants';
 import {formatDate} from '../utils/date-util';
 import {GetDiffCommentsOutput} from '../services/gr-rest-api/gr-rest-api';
 import {CommitInfoWithRequiredCommit} from '../elements/change/gr-change-metadata/gr-change-metadata';
 import {WebLinkInfo} from '../types/diff';
-import {
-  ChangeMessage,
-  CommentThread,
-  createCommentThreads,
-  DraftInfo,
-  UnsavedInfo,
-} from '../utils/comment-util';
+import {createCommentThreads, createNew} from '../utils/comment-util';
 import {GerritView} from '../services/router/router-model';
 import {ChangeComments} from '../elements/diff/gr-comment-api/gr-comment-api';
 import {EditRevisionInfo, ParsedChangeInfo} from '../types/types';
 import {
   DetailedLabelInfo,
-  FileInfo,
   QuickLabelInfo,
   SubmitRequirementExpressionInfo,
   SubmitRequirementResultInfo,
   SubmitRequirementStatus,
 } from '../api/rest-api';
-import {CheckResult, RunResult} from '../models/checks/checks-model';
+import {CheckResult, CheckRun, RunResult} from '../models/checks/checks-model';
 import {Category, RunStatus} from '../api/checks';
 import {DiffInfo} from '../api/diff';
 import {SearchViewState} from '../models/views/search';
-import {ChangeViewState} from '../models/views/change';
-import {EditViewState} from '../models/views/edit';
+import {ChangeChildView, ChangeViewState} from '../models/views/change';
+import {NormalizedFileInfo} from '../models/change/files-model';
+import {GroupViewState} from '../models/views/group';
+import {RepoDetailView, RepoViewState} from '../models/views/repo';
+import {AdminChildView, AdminViewState} from '../models/views/admin';
+import {DashboardViewState} from '../models/views/dashboard';
 
 const TEST_DEFAULT_EXPRESSION = 'label:Verified=MAX -label:Verified=MIN';
 export const TEST_PROJECT_NAME: RepoName = 'test-project' as RepoName;
@@ -136,9 +132,13 @@
     nanosecondSuffix) as Timestamp;
 }
 
-export function createCommentLink(match = 'test'): CommentLinkInfo {
+export function createCommentLink(
+  match = 'test',
+  link = 'http://test.com'
+): CommentLinkInfo {
   return {
     match,
+    link,
   };
 }
 
@@ -243,7 +243,6 @@
     name,
     email: `${name}@` as EmailAddress,
     date: dateToTimestamp(new Date(2019, 11, 6, 14, 5, 8)),
-    tz: 0 as TimezoneOffset,
   };
 }
 
@@ -388,6 +387,7 @@
     messages.push({
       ...createChangeMessageInfo((i + messageIdStart).toString(16)),
       date: dateToTimestamp(messageDate),
+      author: createAccountDetailWithId(i),
     });
     messageDate = new Date(messageDate);
     messageDate.setDate(messageDate.getDate() + 1);
@@ -395,14 +395,19 @@
   return messages;
 }
 
-export function createFileInfo(): FileInfo {
+export function createFileInfo(
+  path = 'test-path/test-file.txt'
+): NormalizedFileInfo {
   return {
     size: 314,
     size_delta: 7,
+    lines_deleted: 0,
+    lines_inserted: 0,
+    __path: path,
   };
 }
 
-export function createChange(): ChangeInfo {
+export function createChange(partial: Partial<ChangeInfo> = {}): ChangeInfo {
   return {
     id: TEST_CHANGE_INFO_ID,
     project: TEST_PROJECT_NAME,
@@ -418,6 +423,7 @@
     owner: createAccountWithId(),
     // This is documented as optional, but actually always set.
     reviewers: createReviewers(),
+    ...partial,
   };
 }
 
@@ -559,8 +565,7 @@
     content: [
       {
         ab: [
-          'Lorem ipsum dolor sit amet, suspendisse inceptos vehicula, ' +
-            'nulla phasellus.',
+          'Lorem ipsum dolor sit amet, suspendisse inceptos vehicula.',
           'Mattis lectus.',
           'Sodales duis.',
           'Orci a faucibus.',
@@ -631,7 +636,7 @@
           'Etiam dui, blandit wisi.',
           'Mi nec.',
           'Vitae eget vestibulum.',
-          'Ullamcorper nunc ante, nec imperdiet felis, consectetur in.',
+          'Ullamcorper nunc ante, nec imperdiet felis, consectetur.',
           'Ac eget.',
           'Vel fringilla, interdum pellentesque placerat, proin ante.',
         ],
@@ -667,25 +672,19 @@
   };
 }
 
-export function createMergeable(): MergeableInfo {
+export function createMergeable(mergeable = false): MergeableInfo {
   return {
     submit_type: SubmitType.MERGE_IF_NECESSARY,
-    mergeable: false,
+    mergeable,
   };
 }
 
-// TODO: Maybe reconcile with createDefaultPreferences() in constants.ts.
+// TODO: Do not change the values of createDefaultPreferences() here.
 export function createPreferences(): PreferencesInfo {
   return {
+    ...createDefaultPreferences(),
     changes_per_page: 10,
-    theme: AppTheme.AUTO,
-    date_format: DateFormat.ISO,
-    time_format: TimeFormat.HHMM_24,
-    diff_view: DiffViewMode.SIDE_BY_SIDE,
-    my: [],
-    change_table: [],
     email_strategy: EmailStrategy.ENABLED,
-    default_base_for_merges: DefaultBase.AUTO_MERGE,
     allow_browser_notifications: true,
   };
 }
@@ -697,8 +696,9 @@
 export function createChangeViewState(): ChangeViewState {
   return {
     view: GerritView.CHANGE,
+    childView: ChangeChildView.OVERVIEW,
     changeNum: TEST_NUMERIC_CHANGE_ID,
-    project: TEST_PROJECT_NAME,
+    repo: TEST_PROJECT_NAME,
   };
 }
 
@@ -712,13 +712,89 @@
   };
 }
 
-export function createEditViewState(): EditViewState {
+export function createEditViewState(): ChangeViewState {
   return {
-    view: GerritView.EDIT,
+    view: GerritView.CHANGE,
+    childView: ChangeChildView.EDIT,
     changeNum: TEST_NUMERIC_CHANGE_ID,
     patchNum: EDIT,
-    path: 'foo/bar.baz',
-    project: TEST_PROJECT_NAME,
+    repo: TEST_PROJECT_NAME,
+    editView: {path: 'foo/bar.baz'},
+  };
+}
+
+export function createDiffViewState(): ChangeViewState {
+  return {
+    view: GerritView.CHANGE,
+    childView: ChangeChildView.DIFF,
+    changeNum: TEST_NUMERIC_CHANGE_ID,
+    repo: TEST_PROJECT_NAME,
+  };
+}
+
+export function createSearchViewState(): SearchViewState {
+  return {
+    view: GerritView.SEARCH,
+    query: '',
+    offset: '0',
+    loading: false,
+  };
+}
+
+export function createDashboardViewState(): DashboardViewState {
+  return {
+    view: GerritView.DASHBOARD,
+    user: 'self',
+  };
+}
+
+export function createAdminReposViewState(): AdminViewState {
+  return {
+    view: GerritView.ADMIN,
+    adminView: AdminChildView.REPOS,
+    offset: '0',
+    filter: '',
+    openCreateModal: false,
+  };
+}
+
+export function createAdminPluginsViewState(): AdminViewState {
+  return {
+    view: GerritView.ADMIN,
+    adminView: AdminChildView.PLUGINS,
+    offset: '0',
+    filter: '',
+  };
+}
+
+export function createGroupViewState(): GroupViewState {
+  return {
+    view: GerritView.GROUP,
+    groupId: 'test-group-id' as GroupId,
+  };
+}
+
+export function createRepoViewState(): RepoViewState {
+  return {
+    view: GerritView.REPO,
+  };
+}
+
+export function createRepoBranchesViewState(): RepoViewState {
+  return {
+    view: GerritView.REPO,
+    detail: RepoDetailView.BRANCHES,
+    offset: '0',
+    filter: '',
+  };
+}
+
+export function createRepoTagsViewState(): RepoViewState {
+  return {
+    view: GerritView.REPO,
+    detail: RepoDetailView.TAGS,
+    offset: '0',
+    filter: '',
   };
 }
 
@@ -766,18 +842,16 @@
 export function createDraft(extra: Partial<CommentInfo> = {}): DraftInfo {
   return {
     ...createComment(),
-    __draft: true,
+    savingState: SavingState.OK,
     ...extra,
   };
 }
 
-export function createUnsaved(extra: Partial<CommentInfo> = {}): UnsavedInfo {
+export function createNewDraft(extra: Partial<CommentInfo> = {}): DraftInfo {
   return {
     ...createComment(),
-    __unsaved: true,
-    id: undefined,
-    updated: undefined,
     ...extra,
+    ...createNew(),
   };
 }
 
@@ -789,7 +863,24 @@
     robot_id: 'robot-id-123' as RobotId,
     robot_run_id: 'robot-run-id-456' as RobotRunId,
     properties: {},
-    fix_suggestions: [],
+    fix_suggestions: [
+      {
+        fix_id: 'robot-run-id-456-fix' as FixId,
+        description: 'Robot suggestion',
+        replacements: [
+          {
+            path: 'abc.txt'!,
+            range: {
+              start_line: 0,
+              start_character: 0,
+              end_line: 1,
+              end_character: 10,
+            },
+            replacement: 'replacement',
+          },
+        ],
+      },
+    ],
     ...extra,
   };
 }
@@ -900,6 +991,9 @@
 export function createThread(
   ...comments: Partial<CommentInfo | DraftInfo>[]
 ): CommentThread {
+  if (comments.length === 0) {
+    comments = [createComment()];
+  }
   return {
     comments: comments.map(c => createComment(c)),
     rootId: 'test-root-id-comment-thread' as UrlEncodedCommentId,
@@ -1013,27 +1107,39 @@
   };
 }
 
-export function createRunResult(): RunResult {
+export function createRun(partial: Partial<CheckRun> = {}): CheckRun {
   return {
     attemptDetails: [],
-    category: Category.INFO,
     checkName: 'test-name',
-    internalResultId: 'test-internal-result-id',
     internalRunId: 'test-internal-run-id',
     isLatestAttempt: true,
     isSingleAttempt: true,
     pluginName: 'test-plugin-name',
     status: RunStatus.COMPLETED,
+    ...partial,
+  };
+}
+
+export function createRunResult(): RunResult {
+  return {
+    category: Category.INFO,
+    checkName: 'test-name',
+    internalResultId: 'test-internal-result-id',
+    isLatestAttempt: true,
+    pluginName: 'test-plugin-name',
     summary: 'This is the test summary.',
     message: 'This is the test message.',
   };
 }
 
-export function createCheckResult(): CheckResult {
+export function createCheckResult(
+  partial: Partial<CheckResult> = {}
+): CheckResult {
   return {
     category: Category.ERROR,
     summary: 'error',
     internalResultId: 'test-internal-result-id',
+    ...partial,
   };
 }
 
diff --git a/polygerrit-ui/app/test/test-utils.ts b/polygerrit-ui/app/test/test-utils.ts
index d6ad434..21150eb 100644
--- a/polygerrit-ui/app/test/test-utils.ts
+++ b/polygerrit-ui/app/test/test-utils.ts
@@ -4,42 +4,23 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import '../types/globals';
-import {_testOnly_resetPluginLoader} from '../elements/shared/gr-js-api-interface/gr-plugin-loader';
-import {_testOnly_resetEndpoints} from '../elements/shared/gr-js-api-interface/gr-plugin-endpoints';
 import {getAppContext} from '../services/app-context';
 import {RestApiService} from '../services/gr-rest-api/gr-rest-api';
 import {SinonSpy, SinonStub} from 'sinon';
-import {StorageService} from '../services/storage/gr-storage';
-import {AuthService} from '../services/gr-auth/gr-auth';
 import {ReportingService} from '../services/gr-reporting/gr-reporting';
-import {UserModel} from '../models/user/user-model';
 import {queryAndAssert, query} from '../utils/common-util';
 import {FlagsService} from '../services/flags/flags';
-import {Key, Modifier} from '../utils/dom-util';
+import {Key, Modifier, whenVisible} from '../utils/dom-util';
 import {Observable} from 'rxjs';
 import {filter, take, timeout} from 'rxjs/operators';
-import {HighlightService} from '../services/highlight/highlight-service';
 import {assert} from '@open-wc/testing';
+import {Route, ViewState} from '../models/views/base';
+import {PageContext} from '../elements/core/gr-router/gr-page';
+import {waitUntil} from '../utils/async-util';
 export {query, queryAll, queryAndAssert} from '../utils/common-util';
-
-export interface MockPromise<T> extends Promise<T> {
-  resolve: (value?: T) => void;
-  reject: (reason?: any) => void;
-}
-
-export function mockPromise<T = unknown>(): MockPromise<T> {
-  let res: (value?: T) => void;
-  let rej: (reason?: any) => void;
-  const promise: MockPromise<T> = new Promise<T | undefined>(
-    (resolve, reject) => {
-      res = resolve;
-      rej = reject;
-    }
-  ) as MockPromise<T>;
-  promise.resolve = res!;
-  promise.reject = rej!;
-  return promise;
-}
+export {waitUntil} from '../utils/async-util';
+export {mockPromise} from '../utils/async-util';
+export type {MockPromise} from '../utils/async-util';
 
 export function isHidden(el: Element | undefined | null) {
   if (!el) return true;
@@ -51,14 +32,6 @@
   return getComputedStyle(el).getPropertyValue('display') !== 'none';
 }
 
-// Provide reset plugins function to clear installed plugins between tests.
-// No gr-app found (running tests)
-export const resetPlugins = () => {
-  _testOnly_resetEndpoints();
-  const pl = _testOnly_resetPluginLoader();
-  pl.loadPlugins([]);
-};
-
 export type CleanupCallback = () => void;
 
 const cleanups: CleanupCallback[] = [];
@@ -109,28 +82,6 @@
   return sinon.spy(getAppContext().restApiService, method);
 }
 
-export function stubUsers<K extends keyof UserModel>(method: K) {
-  return sinon.stub(getAppContext().userModel, method);
-}
-
-export function stubHighlightService<K extends keyof HighlightService>(
-  method: K
-) {
-  return sinon.stub(getAppContext().highlightService, method);
-}
-
-export function stubStorage<K extends keyof StorageService>(method: K) {
-  return sinon.stub(getAppContext().storageService, method);
-}
-
-export function spyStorage<K extends keyof StorageService>(method: K) {
-  return sinon.spy(getAppContext().storageService, method);
-}
-
-export function stubAuth<K extends keyof AuthService>(method: K) {
-  return sinon.stub(getAppContext().authService, method);
-}
-
 export function stubReporting<K extends keyof ReportingService>(method: K) {
   return sinon.stub(getAppContext().reportingService, method);
 }
@@ -158,24 +109,6 @@
   ReturnType<F>
 >;
 
-/**
- * Forcing an opacity of 0 onto the ironOverlayBackdrop is required, because
- * otherwise the backdrop stays around in the DOM for too long waiting for
- * an animation to finish.
- */
-export function addIronOverlayBackdropStyleEl() {
-  const el = document.createElement('style');
-  el.setAttribute('id', 'backdrop-style');
-  document.head.appendChild(el);
-  el.sheet!.insertRule('body { --iron-overlay-backdrop-opacity: 0; }');
-}
-
-export function removeIronOverlayBackdropStyleEl() {
-  const el = document.getElementById('backdrop-style');
-  if (!el?.parentNode) throw new Error('Backdrop style element not found.');
-  el.parentNode?.removeChild(el);
-}
-
 export function removeThemeStyles() {
   // Do not remove the light theme, because it is only added once statically,
   // not once per gr-app instantiation.
@@ -183,6 +116,30 @@
   document.head.querySelector('#dark-theme')?.remove();
 }
 
+function getActiveElement() {
+  return document.activeElement;
+}
+
+export function isFocusInsideElement(element: Element) {
+  // In Polymer 2 focused element either <paper-input> or nested
+  // native input <input> element depending on the current focus
+  // in browser window.
+  // For example, the focus is changed if the developer console
+  // get a focus.
+  let activeElement = getActiveElement();
+  while (activeElement) {
+    if (activeElement === element) {
+      return true;
+    }
+    if (activeElement.parentElement) {
+      activeElement = activeElement.parentElement;
+    } else {
+      activeElement = (activeElement.getRootNode() as ShadowRoot).host;
+    }
+  }
+  return false;
+}
+
 export async function waitQueryAndAssert<E extends Element = Element>(
   el: Element | null | undefined,
   selector: string
@@ -194,29 +151,9 @@
   return queryAndAssert<E>(el, selector);
 }
 
-export async function waitUntil(
-  predicate: (() => boolean) | (() => Promise<boolean>),
-  message = 'The waitUntil() predicate is still false after 1000 ms.',
-  timeout_ms = 1000
-): Promise<void> {
-  const start = Date.now();
-  let sleep = 0;
-  if (await predicate()) return Promise.resolve();
-  const error = new Error(message);
-  return new Promise((resolve, reject) => {
-    const waiter = async () => {
-      if (await predicate()) {
-        resolve();
-        return;
-      }
-      if (Date.now() - start >= timeout_ms) {
-        reject(error);
-        return;
-      }
-      setTimeout(waiter, sleep);
-      sleep = sleep === 0 ? 1 : sleep * 4;
-    };
-    waiter();
+export async function waitUntilVisible(element: Element): Promise<void> {
+  return new Promise(resolve => {
+    whenVisible(element, () => resolve());
   });
 }
 
@@ -324,12 +261,12 @@
   element.dispatchEvent(new MouseEvent('mousedown', eventOptions));
 }
 
-export function assertFails(promise: Promise<unknown>, error?: unknown) {
+export function assertFails<T = unknown>(promise: Promise<unknown>, error?: T) {
   return promise
     .then((_v: unknown) => {
       assert.fail('Promise resolved but should have failed');
     })
-    .catch((e: unknown) => {
+    .catch((e: T) => {
       if (error) {
         assert.equal(e, error);
       }
@@ -352,3 +289,26 @@
   };
   return new Proxy(obj, handler) as unknown as T;
 }
+
+export function assertRouteState<T extends ViewState>(
+  route: Route<T>,
+  path: string,
+  state: T,
+  createUrl: (state: T) => string
+) {
+  const {urlPattern, createState} = route;
+  const ctx = new PageContext(path);
+  const matches = ctx.match(urlPattern);
+  assert.isTrue(matches);
+  assert.deepEqual(createState(ctx), state);
+  assert.equal(path, createUrl(state));
+}
+
+export function assertRouteFalse<T extends ViewState>(
+  route: Route<T>,
+  path: string
+) {
+  const ctx = new PageContext(path);
+  const matches = ctx.match(route.urlPattern);
+  assert.isFalse(matches);
+}
diff --git a/polygerrit-ui/app/tsconfig.json b/polygerrit-ui/app/tsconfig.json
index 0ddf130..98aaf0f 100644
--- a/polygerrit-ui/app/tsconfig.json
+++ b/polygerrit-ui/app/tsconfig.json
@@ -47,7 +47,7 @@
     "lib": [
       "dom",
       "dom.iterable",
-      "es2020",
+      "es2021",
       "webworker"
     ],
 
diff --git a/polygerrit-ui/app/types/common.ts b/polygerrit-ui/app/types/common.ts
index 008a8de..b148780 100644
--- a/polygerrit-ui/app/types/common.ts
+++ b/polygerrit-ui/app/types/common.ts
@@ -3,10 +3,9 @@
  * Copyright 2020 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-import {CommentRange} from '../api/core';
 import {
   ChangeStatus,
-  ProjectState,
+  RepoState,
   SubmitType,
   InheritedBooleanInfoConfiguredValue,
   PermissionAction,
@@ -23,7 +22,6 @@
   EmailFormat,
   MergeStrategy,
 } from '../constants/constants';
-import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
 import {
   AccountId,
   AccountDetailInfo,
@@ -107,7 +105,6 @@
   SubmitTypeInfo,
   SuggestInfo,
   Timestamp,
-  TimezoneOffset,
   TopicName,
   UrlEncodedRepoName,
   UserConfigInfo,
@@ -116,8 +113,10 @@
   isDetailedLabelInfo,
   isQuickLabelInfo,
   Base64FileContent,
+  CommentRange,
 } from '../api/rest-api';
 import {DiffInfo, IgnoreWhitespaceType} from './diff';
+import {LineNumber} from '../api/diff';
 
 export type {
   AccountId,
@@ -200,7 +199,6 @@
   SubmitTypeInfo,
   SuggestInfo,
   Timestamp,
-  TimezoneOffset,
   TopicName,
   UrlEncodedRepoName,
   UserConfigInfo,
@@ -217,11 +215,6 @@
 
 export type PropertyType<T, K extends keyof T> = ReturnType<() => T[K]>;
 
-export type ElementPropertyDeepChange<
-  T,
-  K extends keyof T
-> = PolymerDeepPropertyChange<PropertyType<T, K>, PropertyType<T, K>>;
-
 /**
  * Type alias for parsed json object to make code cleaner
  */
@@ -364,6 +357,16 @@
   members?: string[];
 }
 
+export interface DropdownLink {
+  url?: string;
+  name?: string;
+  external?: boolean;
+  target?: string | null;
+  download?: boolean;
+  id?: string;
+  tooltip?: string;
+}
+
 /**
  * New options for a group.
  * https://gerrit-review.googlesource.com/Documentation/rest-api-groups.html
@@ -679,6 +682,160 @@
   id?: string;
 }
 
+export enum ChangeStates {
+  ABANDONED = 'Abandoned',
+  ACTIVE = 'Active',
+  MERGE_CONFLICT = 'Merge Conflict',
+  GIT_CONFLICT = 'Git Conflict',
+  MERGED = 'Merged',
+  PRIVATE = 'Private',
+  READY_TO_SUBMIT = 'Ready to submit',
+  REVERT_CREATED = 'Revert Created',
+  REVERT_SUBMITTED = 'Revert Submitted',
+  WIP = 'WIP',
+}
+
+export enum SavingState {
+  /**
+   * Currently not saving. Not yet saved or last saving attempt successful.
+   */
+  // Possible prior states: SAVING
+  // Possible subsequent states: SAVING
+  OK = 'OK',
+  /**
+   * Currently saving to the backend.
+   */
+  // Possible prior states: OK, ERROR
+  // Possible subsequent states: OK, ERROR
+  SAVING = 'SAVING',
+  /**
+   * Latest saving attempt failed with an error.
+   */
+  // Possible prior states: SAVING
+  // Possible subsequent states: SAVING
+  ERROR = 'ERROR',
+}
+
+export type DraftInfo = Omit<CommentInfo, 'id' | 'updated'> & {
+  // Must be set for all drafts.
+  // Drafts received from the backend will be modified immediately with
+  // `state: OK` before allowing them to get into the model.
+  savingState: SavingState;
+  // Must be set for new drafts created in this session.
+  // Use the id() utility function for uniquely identifying drafts.
+  client_id?: UrlEncodedCommentId;
+  // Must be set for drafts known to the backend.
+  // Use the id() utility function for uniquely identifying drafts.
+  id?: UrlEncodedCommentId;
+  // Set, iff `id` is set. Reflects the time when the draft was last saved to
+  // the backend.
+  updated?: Timestamp;
+};
+
+export interface NewDraftInfo extends DraftInfo {
+  client_id: UrlEncodedCommentId;
+  id: undefined;
+  updated: undefined;
+}
+
+/**
+ * This is what human, robot and draft comments can agree upon.
+ *
+ * Note that `id` and `updated` must be considered optional, because we might
+ * be dealing with unsaved draft comments.
+ */
+export type Comment = DraftInfo | CommentInfo | RobotCommentInfo;
+
+// TODO: Replace the CommentMap type with just an array of paths.
+export type CommentMap = {[path: string]: boolean};
+
+export function isRobot<T extends Comment>(
+  x: T | RobotCommentInfo | undefined
+): x is RobotCommentInfo {
+  return !!x && !!(x as RobotCommentInfo).robot_id;
+}
+
+export function isDraft<T extends Comment>(
+  x: T | DraftInfo | undefined
+): x is DraftInfo {
+  return !!x && (x as DraftInfo).savingState !== undefined;
+}
+
+export function isSaving<T extends Comment>(
+  x: T | DraftInfo | undefined
+): boolean {
+  return !!x && (x as DraftInfo).savingState === SavingState.SAVING;
+}
+
+export function isError<T extends Comment>(
+  x: T | DraftInfo | undefined
+): boolean {
+  return !!x && (x as DraftInfo).savingState === SavingState.ERROR;
+}
+
+/**
+ * A new draft comment is a comment that was created by the user in this session
+ * and has not yet been saved to the backend. Such a comment must have a
+ * `client_id`, but it must not have an `id`.
+ */
+export function isNew<T extends Comment>(
+  x: T | DraftInfo | undefined
+): boolean {
+  return !!x && !!(x as DraftInfo).client_id && !(x as DraftInfo).id;
+}
+
+export interface CommentThread {
+  /**
+   * This can only contain at most one draft. And if so, then it is the last
+   * comment in this list. This must not contain unsaved drafts.
+   */
+  comments: Array<Comment>;
+  /**
+   * Identical to the id of the first comment. If this is undefined, then the
+   * thread only contains an unsaved draft.
+   */
+  rootId?: UrlEncodedCommentId;
+  /**
+   * Note that all location information is typically identical to that of the
+   * first comment, but not for ported comments!
+   */
+  path: string;
+  commentSide: CommentSide;
+  /* mergeParentNum is the merge parent number only valid for merge commits
+     when commentSide is PARENT.
+     mergeParentNum is undefined for auto merge commits
+     Same as `parent` in CommentInfo.
+  */
+  mergeParentNum?: number;
+  patchNum?: RevisionPatchSetNum;
+  /* Different from CommentInfo, which just keeps the line undefined for
+     FILE comments. */
+  line?: LineNumber;
+  range?: CommentRange;
+  /**
+   * Was the thread ported over from its original location to a newer patchset?
+   * If yes, then the location information above contains the ported location,
+   * but the comments still have the original location set.
+   */
+  ported?: boolean;
+  /**
+   * Only relevant when ported:true. Means that no ported range could be
+   * computed. `line` and `range` can be undefined then.
+   */
+  rangeInfoLost?: boolean;
+}
+
+export type CommentIdToCommentThreadMap = {
+  [urlEncodedCommentId: string]: CommentThread;
+};
+
+export interface ChangeMessage extends ChangeMessageInfo {
+  // TODO(TS): maybe should be an enum instead
+  type: string;
+  expanded: boolean;
+  commentThreads: CommentThread[];
+}
+
 /**
  * The CommentInfo entity contains information about an inline comment.
  * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#comment-info
@@ -704,8 +861,6 @@
   source_content_type?: string;
 }
 
-export type PathToCommentsInfoMap = {[path: string]: CommentInfo[]};
-
 /**
  * The ContextLine entity contains the line number and line text of a single line of the source file content..
  * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#context-line
@@ -750,8 +905,6 @@
   type: string;
   _name?: string;
   _expectedType?: string;
-  _width?: number;
-  _height?: number;
 }
 
 /**
@@ -769,13 +922,13 @@
   can_add?: boolean;
   can_add_tags?: boolean;
   config_visible?: boolean;
-  groups: ProjectAccessGroups;
+  groups: RepoAccessGroups;
   config_web_links: WebLinkInfo[];
 }
 
-export type ProjectAccessInfoMap = {[projectName: string]: ProjectAccessInfo};
+export type RepoAccessInfoMap = {[projectName: string]: ProjectAccessInfo};
 export type LocalAccessSectionInfo = {[ref: string]: AccessSectionInfo};
-export type ProjectAccessGroups = {[uuid: string]: GroupInfo};
+export type RepoAccessGroups = {[uuid: string]: GroupInfo};
 
 /**
  * The AccessSectionInfo describes the access rights that are assigned on a ref.
@@ -858,7 +1011,7 @@
   reject_empty_commit?: InheritedBooleanInfoConfiguredValue;
   max_object_size_limit?: MaxObjectSizeLimitInfo;
   submit_type?: SubmitType;
-  state?: ProjectState;
+  state?: RepoState;
   plugin_config_values?: PluginNameToPluginParametersMap;
   commentlinks?: ConfigInfoCommentLinks;
 }
@@ -907,13 +1060,13 @@
  * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#project-access-input
  */
 export interface ProjectAccessInput {
-  remove?: RefToProjectAccessInfoMap;
-  add?: RefToProjectAccessInfoMap;
+  remove?: RefToRepoAccessInfoMap;
+  add?: RefToRepoAccessInfoMap;
   message?: string;
   parent?: string;
 }
 
-export type RefToProjectAccessInfoMap = {[refName: string]: ProjectAccessInfo};
+export type RefToRepoAccessInfoMap = {[refName: string]: ProjectAccessInfo};
 
 /**
  * Represent a file in a base64 encoding
@@ -1205,18 +1358,6 @@
 export type RobotCommentInput = RobotCommentInfo;
 
 /**
- * This is what human, robot and draft comments can agree upon.
- *
- * Human, robot and saved draft comments all have a required id, but unsaved
- * drafts do not. That is why the id is omitted from CommentInfo, such that it
- * can be optional in Draft, but required in CommentInfo and RobotCommentInfo.
- */
-export interface CommentBasics extends Omit<CommentInfo, 'id' | 'updated'> {
-  id?: UrlEncodedCommentId;
-  updated?: Timestamp;
-}
-
-/**
  * The RobotCommentInfo entity contains information about a robot inline comment
  * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#robot-comment-info
  */
@@ -1513,3 +1654,8 @@
   conflicts?: string[];
   mergeable_into?: string[];
 }
+
+export interface ChangeActionDialog extends HTMLElement {
+  resetFocus?(): void;
+  init?(): void;
+}
diff --git a/polygerrit-ui/app/types/diff.ts b/polygerrit-ui/app/types/diff.ts
index c03a167..2a8c7e5 100644
--- a/polygerrit-ui/app/types/diff.ts
+++ b/polygerrit-ui/app/types/diff.ts
@@ -44,6 +44,8 @@
   /**
    * Links to the file diff in external sites as a list of DiffWebLinkInfo
    * entries.
+   *
+   * NOTE: Unused as of Feb 2023.
    */
   web_links?: DiffWebLinkInfo[];
 
@@ -58,18 +60,15 @@
  * The DiffWebLinkInfo entity describes a link on a diff screen to an external
  * site.
  * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#diff-web-link-info
+ *
+ * NOTE: Unused as of Feb 2023.
  */
 export declare interface DiffWebLinkInfo {
-  /** The link name. */
   name: string;
-  /** The link URL. */
   url: string;
-  /** URL to the icon of the link. */
   image_url: string;
-  // TODO: Are these really of type string? Not able to trigger them, but the
-  // docs sound more like boolean.
-  show_on_side_by_side_diff_view: string;
-  show_on_unified_diff_view: string;
+  show_on_side_by_side_diff_view: boolean;
+  show_on_unified_diff_view: boolean;
 }
 
 export interface DiffFileMetaInfo extends DiffFileMetaInfoApi {
diff --git a/polygerrit-ui/app/types/events.ts b/polygerrit-ui/app/types/events.ts
index 08c5ef4..922d779 100644
--- a/polygerrit-ui/app/types/events.ts
+++ b/polygerrit-ui/app/types/events.ts
@@ -3,80 +3,62 @@
  * Copyright 2020 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-import {FixSuggestionInfo, PatchSetNum} from './common';
-import {ChangeMessage} from '../utils/comment-util';
+import {
+  AccountInfo,
+  ChangeMessage,
+  DropdownLink,
+  FixSuggestionInfo,
+  PatchSetNum,
+} from './common';
 import {FetchRequest} from './types';
 import {LineNumberEventDetail, MovedLinkClickedEventDetail} from '../api/diff';
 import {Category, RunStatus} from '../api/checks';
 
-export enum EventType {
-  BIND_VALUE_CHANGED = 'bind-value-changed',
-  CHANGE = 'change',
-  CHANGED = 'changed',
-  CHANGE_MESSAGE_DELETED = 'change-message-deleted',
-  COMMIT = 'commit',
-  DIALOG_CHANGE = 'dialog-change',
-  DROP = 'drop',
-  EDITABLE_CONTENT_SAVE = 'editable-content-save',
-  GR_RPC_LOG = 'gr-rpc-log',
-  IRON_ANNOUNCE = 'iron-announce',
-  KEYDOWN = 'keydown',
-  KEYPRESS = 'keypress',
-  LOCATION_CHANGE = 'location-change',
-  MOVED_LINK_CLICKED = 'moved-link-clicked',
-  NETWORK_ERROR = 'network-error',
-  OPEN_FIX_PREVIEW = 'open-fix-preview',
-  CLOSE_FIX_PREVIEW = 'close-fix-preview',
-  PAGE_ERROR = 'page-error',
-  RECREATE_CHANGE_VIEW = 'recreate-change-view',
-  RECREATE_DIFF_VIEW = 'recreate-diff-view',
-  RELOAD = 'reload',
-  REPLY = 'reply',
-  SERVER_ERROR = 'server-error',
-  SHORTCUT_TRIGGERERD = 'shortcut-triggered',
-  SHOW_ALERT = 'show-alert',
-  SHOW_ERROR = 'show-error',
-  SHOW_TAB = 'show-tab',
-  SHOW_SECONDARY_TAB = 'show-secondary-tab',
-  TAP_ITEM = 'tap-item',
-  TITLE_CHANGE = 'title-change',
-}
-
+// TODO: Local events that are only fired by one component should also be
+// declared and documented in that component. Don't collect ALL the events here.
+// 'show-alert' for example is fine to keep, because it is fired all over the
+// place. But 'line-cursor-moved-in' is only fired by <gr-diff-cursor>, so let's
+// move it there.
 declare global {
   interface HTMLElementEventMap {
-    /* prettier-ignore */
+    'add-reviewer': AddReviewerEvent;
     'bind-value-changed': BindValueChangeEvent;
-    /* prettier-ignore */
+    /** Fired when a 'cancel' button in a dialog was pressed. */
+    // prettier-ignore
+    'cancel': CustomEvent<{}>;
+    // prettier-ignore
     'change': ChangeEvent;
-    /* prettier-ignore */
+    // prettier-ignore
     'changed': ChangedEvent;
-    'change-message-deleted': ChangeMessageDeletedEvent;
-    /* prettier-ignore */
-    'commit': CommitEvent;
+    // prettier-ignore
+    'close': CustomEvent<{}>;
+    // prettier-ignore
+    'commit': AutocompleteCommitEvent;
+    /** Fired when a 'confirm' button in a dialog was pressed. */
+    // prettier-ignore
+    'confirm': CustomEvent<{}>;
     'dialog-change': DialogChangeEvent;
-    /* prettier-ignore */
+    // prettier-ignore
     'drop': DropEvent;
-    'editable-content-save': EditableContentSaveEvent;
+    'hide-alert': CustomEvent<{}>;
     'location-change': LocationChangeEvent;
     'iron-announce': IronAnnounceEvent;
+    'iron-resize': CustomEvent<{}>;
     'line-mouse-enter': LineNumberEvent;
     'line-mouse-leave': LineNumberEvent;
     'line-cursor-moved-in': LineNumberEvent;
     'line-cursor-moved-out': LineNumberEvent;
     'moved-link-clicked': MovedLinkClickedEvent;
     'open-fix-preview': OpenFixPreviewEvent;
-    'close-fix-preview': CloseFixPreviewEvent;
     'reply-to-comment': ReplyToCommentEvent;
-    /* prettier-ignore */
-    'reload': ReloadEvent;
-    /* prettier-ignore */
-    'reply': ReplyEvent;
+    // prettier-ignore
+    'reload': CustomEvent<{}>;
+    'remove-reviewer': RemoveReviewerEvent;
     'show-alert': ShowAlertEvent;
     'show-error': ShowErrorEvent;
     'show-tab': SwitchTabEvent;
     'show-secondary-tab': SwitchTabEvent;
     'tap-item': TapItemEvent;
-    'title-change': TitleChangeEvent;
   }
 }
 
@@ -85,15 +67,38 @@
     'gr-rpc-log': RpcLogEvent;
     'network-error': NetworkErrorEvent;
     'page-error': PageErrorEvent;
-    /* prettier-ignore */
-    'reload': ReloadEvent;
+    // prettier-ignore
+    'reload': CustomEvent<{}>;
     'server-error': ServerErrorEvent;
     'show-alert': ShowAlertEvent;
     'show-error': ShowErrorEvent;
-    'location-change': LocationChangeEvent;
+    'auth-error': AuthErrorEvent;
+    'title-change': TitleChangeEvent;
   }
 }
 
+export interface AutocompleteCommitEventDetail {
+  value: string;
+}
+
+export type AutocompleteCommitEvent =
+  CustomEvent<AutocompleteCommitEventDetail>;
+
+export interface AddAccountEventDetail {
+  value: string;
+}
+export type AddAccountEvent = CustomEvent<AddAccountEventDetail>;
+
+export interface AddReviewerEventDetail {
+  reviewer: AccountInfo;
+}
+export type AddReviewerEvent = CustomEvent<AddReviewerEventDetail>;
+
+export interface RemoveReviewerEventDetail {
+  reviewer: AccountInfo;
+}
+export type RemoveReviewerEvent = CustomEvent<RemoveReviewerEventDetail>;
+
 export interface BindValueChangeEventDetail {
   value: string | undefined;
 }
@@ -101,7 +106,8 @@
 
 export type ChangeEvent = InputEvent;
 
-export type ChangedEvent = CustomEvent<string>;
+// TODO: This event seems to be unused (no listener). Remove?
+export type ChangedEvent = CustomEvent<string | undefined>;
 
 export interface ChangeMessageDeletedEventDetail {
   message: ChangeMessage;
@@ -109,8 +115,6 @@
 export type ChangeMessageDeletedEvent =
   CustomEvent<ChangeMessageDeletedEventDetail>;
 
-export type CommitEvent = CustomEvent;
-
 // TODO(milutin) - remove once new gr-dialog will do it out of the box
 // This informs gr-app-element to remove footer, header from a11y tree
 export interface DialogChangeEventDetail {
@@ -127,6 +131,13 @@
 export type EditableContentSaveEvent =
   CustomEvent<EditableContentSaveEventDetail>;
 
+export interface FileActionTapEventDetail {
+  path: string;
+  action: string;
+}
+
+export type FileActionTapEvent = CustomEvent<FileActionTapEventDetail>;
+
 export interface RpcLogEventDetail {
   status: number | null;
   method: string;
@@ -158,18 +169,16 @@
 export interface OpenFixPreviewEventDetail {
   patchNum: PatchSetNum;
   fixSuggestions: FixSuggestionInfo[];
+  onCloseFixPreviewCallbacks: ((fixapplied: boolean) => void)[];
 }
 export type OpenFixPreviewEvent = CustomEvent<OpenFixPreviewEventDetail>;
 
-export interface CloseFixPreviewEventDetail {
-  fixApplied: boolean;
-}
-export type CloseFixPreviewEvent = CustomEvent<CloseFixPreviewEventDetail>;
 export interface ReplyToCommentEventDetail {
   content: string;
   userWantsToEdit: boolean;
   unresolved: boolean;
 }
+
 export type ReplyToCommentEvent = CustomEvent<ReplyToCommentEventDetail>;
 
 export interface PageErrorEventDetail {
@@ -177,15 +186,10 @@
 }
 export type PageErrorEvent = CustomEvent<PageErrorEventDetail>;
 
-export interface ReloadEventDetail {
-  clearPatchset: boolean;
+export interface RemoveAccountEventDetail {
+  account: AccountInfo;
 }
-export type ReloadEvent = CustomEvent<ReloadEventDetail>;
-
-export interface ReplyEventDetail {
-  message: ChangeMessage;
-}
-export type ReplyEvent = CustomEvent<ReplyEventDetail>;
+export type RemoveAccountEvent = CustomEvent<RemoveAccountEventDetail>;
 
 export interface ServerErrorEventDetail {
   request?: FetchRequest;
@@ -207,6 +211,20 @@
 }
 export type ShowErrorEvent = CustomEvent<ShowErrorEventDetail>;
 
+export interface ShowReplyDialogEventDetail {
+  value: {
+    reviewersOnly: boolean;
+    ccsOnly: boolean;
+  };
+}
+export type ShowReplyDialogEvent = CustomEvent<ShowReplyDialogEventDetail>;
+
+export interface AuthErrorEventDetail {
+  message: string;
+  action: string;
+}
+export type AuthErrorEvent = CustomEvent<AuthErrorEventDetail>;
+
 // Type for the custom event to switch tab.
 export interface SwitchTabEventDetail {
   // name of the tab to set as active, from custom event
@@ -232,7 +250,7 @@
 }
 export type SwitchTabEvent = CustomEvent<SwitchTabEventDetail>;
 
-export type TapItemEvent = CustomEvent;
+export type TapItemEvent = CustomEvent<DropdownLink>;
 
 export interface TitleChangeEventDetail {
   title: string;
diff --git a/polygerrit-ui/app/types/types.ts b/polygerrit-ui/app/types/types.ts
index 04052e2..6517836 100644
--- a/polygerrit-ui/app/types/types.ts
+++ b/polygerrit-ui/app/types/types.ts
@@ -5,12 +5,10 @@
  */
 import {DiffLayer as DiffLayerApi} from '../api/diff';
 import {MessageTag, Side} from '../constants/constants';
-import {IronA11yAnnouncer} from '@polymer/iron-a11y-announcer/iron-a11y-announcer';
 import {
   AccountInfo,
   BasePatchSetNum,
   ChangeViewChangeInfo,
-  CommitId,
   CommitInfo,
   EditPatchSet,
   PatchSetNum,
@@ -18,21 +16,11 @@
   RevisionInfo,
   Timestamp,
 } from './common';
-import {AuthRequestInit} from '../services/gr-auth/gr-auth';
 
-export function notUndefined<T>(x: T): x is NonNullable<T> {
+export function isDefined<T>(x: T): x is NonNullable<T> {
   return x !== undefined && x !== null;
 }
 
-export interface FixIronA11yAnnouncer extends IronA11yAnnouncer {
-  requestAvailability(): void;
-}
-
-export interface CommitRange {
-  baseCommit: CommitId;
-  commit: CommitId;
-}
-
 export type {CoverageRange} from '../api/diff';
 export {CoverageType} from '../api/diff';
 
@@ -42,6 +30,13 @@
   GENERIC = 'GENERIC',
 }
 
+export interface AuthRequestInit extends RequestInit {
+  // RequestInit define headers as HeadersInit, i.e.
+  // Headers | string[][] | Record<string, string>
+  // Auth class supports only Headers in options
+  headers?: Headers;
+}
+
 /*
 export interface OwnerRoot {
   host?: HTMLElement;
diff --git a/polygerrit-ui/app/utils/account-util.ts b/polygerrit-ui/app/utils/account-util.ts
index 7681b10..6160cdc 100644
--- a/polygerrit-ui/app/utils/account-util.ts
+++ b/polygerrit-ui/app/utils/account-util.ts
@@ -13,7 +13,6 @@
   isAccount,
   isDetailedLabelInfo,
   isGroup,
-  NumericChangeId,
   ReviewerInput,
   ServerInfo,
   UserId,
@@ -22,13 +21,11 @@
 } from '../types/common';
 import {AccountTag, ReviewerState} from '../constants/constants';
 import {assertNever, hasOwnProperty} from './common-util';
-import {getAccountDisplayName, getDisplayName} from './display-name-util';
+import {getDisplayName} from './display-name-util';
 import {getApprovalInfo} from './label-util';
-import {RestApiService} from '../services/gr-rest-api/gr-rest-api';
 import {ParsedChangeInfo} from '../types/types';
 
 export const ACCOUNT_TEMPLATE_REGEX = '<GERRIT_ACCOUNT_(\\d+)>';
-const SUGGESTIONS_LIMIT = 15;
 // https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address
 export const MENTIONS_REGEX =
   /(?:^|\s)@([a-zA-Z0-9.!#$%&'*+=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*)(?=\s+|$)/g;
@@ -123,6 +120,17 @@
   );
 }
 
+export function uniqueAccountId(
+  account: AccountInfo,
+  index: number,
+  accountArray: AccountInfo[]
+) {
+  return (
+    index ===
+    accountArray.findIndex(other => account._account_id === other._account_id)
+  );
+}
+
 export function isDetailedAccount(account?: AccountInfo) {
   // In case ChangeInfo is requested without DetailedAccount option, the
   // reviewer entry is returned as just {_account_id: 123}
@@ -216,28 +224,6 @@
   return maxScores.join(', ');
 }
 
-export function getAccountSuggestions(
-  input: string,
-  restApiService: RestApiService,
-  config?: ServerInfo,
-  canSee?: NumericChangeId,
-  filterActive = false
-) {
-  return restApiService
-    .getSuggestedAccounts(input, SUGGESTIONS_LIMIT, canSee, filterActive)
-    .then(accounts => {
-      if (!accounts) return [];
-      const accountSuggestions = [];
-      for (const account of accounts) {
-        accountSuggestions.push({
-          name: getAccountDisplayName(config, account),
-          value: account._account_id?.toString(),
-        });
-      }
-      return accountSuggestions;
-    });
-}
-
 /**
  * Extracts mentioned users from a given text.
  * A user can be mentioned by triggering the mentions dropdown in a comment
diff --git a/polygerrit-ui/app/utils/admin-nav-util.ts b/polygerrit-ui/app/utils/admin-nav-util.ts
deleted file mode 100644
index c8fc9fb..0000000
--- a/polygerrit-ui/app/utils/admin-nav-util.ts
+++ /dev/null
@@ -1,249 +0,0 @@
-/**
- * @license
- * Copyright 2018 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-import {
-  RepoName,
-  GroupId,
-  AccountDetailInfo,
-  AccountCapabilityInfo,
-} from '../types/common';
-import {hasOwnProperty} from './common-util';
-import {GerritView} from '../services/router/router-model';
-import {MenuLink} from '../api/admin';
-import {AdminChildView} from '../models/views/admin';
-import {createGroupUrl, GroupDetailView} from '../models/views/group';
-import {createRepoUrl, RepoDetailView} from '../models/views/repo';
-
-const ADMIN_LINKS: NavLink[] = [
-  {
-    name: 'Repositories',
-    noBaseUrl: true,
-    url: '/admin/repos',
-    view: 'gr-repo-list' as GerritView,
-    viewableToAll: true,
-  },
-  {
-    name: 'Groups',
-    section: 'Groups',
-    noBaseUrl: true,
-    url: '/admin/groups',
-    view: 'gr-admin-group-list' as GerritView,
-  },
-  {
-    name: 'Plugins',
-    capability: 'viewPlugins',
-    section: 'Plugins',
-    noBaseUrl: true,
-    url: '/admin/plugins',
-    view: 'gr-plugin-list' as GerritView,
-  },
-];
-
-export interface AdminLink {
-  url: string;
-  text: string;
-  capability: string | null;
-  noBaseUrl: boolean;
-  view: null;
-  viewableToAll: boolean;
-  target: '_blank' | null;
-}
-
-export interface AdminLinks {
-  links: NavLink[];
-  expandedSection?: SubsectionInterface;
-}
-
-export function getAdminLinks(
-  account: AccountDetailInfo | undefined,
-  getAccountCapabilities: () => Promise<AccountCapabilityInfo>,
-  getAdminMenuLinks: () => MenuLink[],
-  options?: AdminNavLinksOption
-): Promise<AdminLinks> {
-  if (!account) {
-    return Promise.resolve(
-      _filterLinks(link => !!link.viewableToAll, getAdminMenuLinks, options)
-    );
-  }
-  return getAccountCapabilities().then(capabilities =>
-    _filterLinks(
-      link => !link.capability || hasOwnProperty(capabilities, link.capability),
-      getAdminMenuLinks,
-      options
-    )
-  );
-}
-
-function _filterLinks(
-  filterFn: (link: NavLink) => boolean,
-  getAdminMenuLinks: () => MenuLink[],
-  options?: AdminNavLinksOption
-): AdminLinks {
-  let links: NavLink[] = ADMIN_LINKS.slice(0);
-  let expandedSection: SubsectionInterface | undefined = undefined;
-
-  const isExternalLink = (link: MenuLink) => link.url[0] !== '/';
-
-  // Append top-level links that are defined by plugins.
-  links.push(
-    ...getAdminMenuLinks().map((link: MenuLink) => {
-      return {
-        url: link.url,
-        name: link.text,
-        capability: link.capability || undefined,
-        noBaseUrl: !isExternalLink(link),
-        view: undefined,
-        viewableToAll: !link.capability,
-        target: isExternalLink(link) ? '_blank' : null,
-      };
-    })
-  );
-
-  links = links.filter(filterFn);
-
-  const filteredLinks: NavLink[] = [];
-  const repoName = options && options.repoName;
-  const groupId = options && options.groupId;
-  const groupName = options && options.groupName;
-  const groupIsInternal = options && options.groupIsInternal;
-  const isAdmin = options && options.isAdmin;
-  const groupOwner = options && options.groupOwner;
-
-  // Don't bother to get sub-navigation items if only the top level links
-  // are needed. This is used by the main header dropdown.
-  if (!repoName && !groupId) {
-    return {links, expandedSection};
-  }
-
-  // Otherwise determine the full set of links and return both the full
-  // set in addition to the subsection that should be displayed if it
-  // exists.
-  for (const link of links) {
-    const linkCopy = {...link};
-    if (linkCopy.name === 'Repositories' && repoName) {
-      linkCopy.subsection = getRepoSubsections(repoName);
-      expandedSection = linkCopy.subsection;
-    } else if (linkCopy.name === 'Groups' && groupId && groupName) {
-      linkCopy.subsection = getGroupSubsections(
-        groupId,
-        groupName,
-        groupIsInternal,
-        isAdmin,
-        groupOwner
-      );
-      expandedSection = linkCopy.subsection;
-    }
-    filteredLinks.push(linkCopy);
-  }
-  return {links: filteredLinks, expandedSection};
-}
-
-export function getGroupSubsections(
-  groupId: GroupId,
-  groupName: string,
-  groupIsInternal?: boolean,
-  isAdmin?: boolean,
-  groupOwner?: boolean
-) {
-  const children: SubsectionInterface[] = [];
-  const subsection: SubsectionInterface = {
-    name: groupName,
-    view: GerritView.GROUP,
-    url: createGroupUrl({groupId}),
-    children,
-  };
-  if (groupIsInternal) {
-    children.push({
-      name: 'Members',
-      detailType: GroupDetailView.MEMBERS,
-      view: GerritView.GROUP,
-      url: createGroupUrl({groupId, detail: GroupDetailView.MEMBERS}),
-    });
-  }
-  if (groupIsInternal && (isAdmin || groupOwner)) {
-    children.push({
-      name: 'Audit Log',
-      detailType: GroupDetailView.LOG,
-      view: GerritView.GROUP,
-      url: createGroupUrl({groupId, detail: GroupDetailView.LOG}),
-    });
-  }
-  return subsection;
-}
-
-export function getRepoSubsections(repo: RepoName) {
-  return {
-    name: repo,
-    view: GerritView.REPO,
-    children: [
-      {
-        name: 'General',
-        view: GerritView.REPO,
-        detailType: RepoDetailView.GENERAL,
-        url: createRepoUrl({repo, detail: RepoDetailView.GENERAL}),
-      },
-      {
-        name: 'Access',
-        view: GerritView.REPO,
-        detailType: RepoDetailView.ACCESS,
-        url: createRepoUrl({repo, detail: RepoDetailView.ACCESS}),
-      },
-      {
-        name: 'Commands',
-        view: GerritView.REPO,
-        detailType: RepoDetailView.COMMANDS,
-        url: createRepoUrl({repo, detail: RepoDetailView.COMMANDS}),
-      },
-      {
-        name: 'Branches',
-        view: GerritView.REPO,
-        detailType: RepoDetailView.BRANCHES,
-        url: createRepoUrl({repo, detail: RepoDetailView.BRANCHES}),
-      },
-      {
-        name: 'Tags',
-        view: GerritView.REPO,
-        detailType: RepoDetailView.TAGS,
-        url: createRepoUrl({repo, detail: RepoDetailView.TAGS}),
-      },
-      {
-        name: 'Dashboards',
-        view: GerritView.REPO,
-        detailType: RepoDetailView.DASHBOARDS,
-        url: createRepoUrl({repo, detail: RepoDetailView.DASHBOARDS}),
-      },
-    ],
-  };
-}
-
-export interface SubsectionInterface {
-  name: string;
-  view: GerritView;
-  detailType?: RepoDetailView | GroupDetailView;
-  url?: string;
-  children?: SubsectionInterface[];
-}
-
-export interface AdminNavLinksOption {
-  repoName?: RepoName;
-  groupId?: GroupId;
-  groupName?: string;
-  groupIsInternal?: boolean;
-  isAdmin?: boolean;
-  groupOwner?: boolean;
-}
-
-export interface NavLink {
-  name: string;
-  noBaseUrl: boolean;
-  url: string;
-  view?: GerritView | AdminChildView;
-  viewableToAll?: boolean;
-  section?: string;
-  capability?: string;
-  target?: string | null;
-  subsection?: SubsectionInterface;
-  children?: SubsectionInterface[];
-}
diff --git a/polygerrit-ui/app/utils/async-util.ts b/polygerrit-ui/app/utils/async-util.ts
index 4281f43..1af66fa 100644
--- a/polygerrit-ui/app/utils/async-util.ts
+++ b/polygerrit-ui/app/utils/async-util.ts
@@ -37,6 +37,11 @@
 
 export const _testOnly_allTasks = new Map<number, DelayedTask>();
 
+export enum ResolvedDelayedTaskStatus {
+  CALLBACK_EXECUTED = 'CALLBACK_EXECUTED',
+  TASK_CANCELLED = 'TASK_CANCELLED',
+}
+
 /**
  * This is just a very simple and small wrapper around setTimeout(). Instead of
  * the usual:
@@ -52,34 +57,69 @@
  * It is just nicer to have an object for this instead of a number as a handle.
  */
 export class DelayedTask {
-  private timer?: number;
+  private timerId?: number;
 
-  constructor(private callback: () => void, waitMs = 0) {
-    this.timer = window.setTimeout(() => {
-      if (this.timer) _testOnly_allTasks.delete(this.timer);
-      this.timer = undefined;
-      if (this.callback) this.callback();
-    }, waitMs);
-    _testOnly_allTasks.set(this.timer, this);
+  /**
+   * Promise that is resolved after the callback is run or the task is
+   * cancelled.
+   *
+   * If callback returns a Promise this resolves after the promise is settled.
+   */
+  public readonly promise: Promise<ResolvedDelayedTaskStatus>;
+
+  private resolvePromise?: (
+    value: ResolvedDelayedTaskStatus | PromiseLike<ResolvedDelayedTaskStatus>
+  ) => void;
+
+  private callCallbackAndResolveOnCompletion() {
+    let callbackResult;
+    if (this.callback) callbackResult = this.callback();
+    if (callbackResult instanceof Promise) {
+      callbackResult.finally(() => {
+        this.resolvePromise!(ResolvedDelayedTaskStatus.CALLBACK_EXECUTED);
+      });
+    } else {
+      this.resolvePromise!(ResolvedDelayedTaskStatus.CALLBACK_EXECUTED);
+    }
+  }
+
+  constructor(
+    private readonly callback: () => void | Promise<void>,
+    waitMs = 0
+  ) {
+    this.promise = new Promise(resolve => {
+      this.resolvePromise = resolve;
+      this.timerId = window.setTimeout(() => {
+        if (this.timerId) _testOnly_allTasks.delete(this.timerId);
+        this.timerId = undefined;
+        this.callCallbackAndResolveOnCompletion();
+      }, waitMs);
+      _testOnly_allTasks.set(this.timerId, this);
+    });
+  }
+
+  private cancelTimer() {
+    window.clearTimeout(this.timerId);
+    if (this.timerId) _testOnly_allTasks.delete(this.timerId);
+    this.timerId = undefined;
   }
 
   cancel() {
     if (this.isActive()) {
-      window.clearTimeout(this.timer);
-      if (this.timer) _testOnly_allTasks.delete(this.timer);
-      this.timer = undefined;
+      this.cancelTimer();
+      this.resolvePromise?.(ResolvedDelayedTaskStatus.TASK_CANCELLED);
     }
   }
 
   flush() {
     if (this.isActive()) {
-      this.cancel();
-      if (this.callback) this.callback();
+      this.cancelTimer();
+      this.callCallbackAndResolveOnCompletion();
     }
   }
 
   isActive() {
-    return this.timer !== undefined;
+    return this.timerId !== undefined;
   }
 }
 
@@ -245,3 +285,115 @@
     )
   );
 }
+
+/**
+ * Noop function that can be used to suppress the tsetse must-use-promises rule.
+ *
+ * Example Usage:
+ *   async function x() {
+ *     await doA();
+ *     noAwait(doB());
+ *   }
+ */
+export function noAwait(_: {then: Function} | null | undefined) {}
+
+export interface CancelablePromise<T> extends Promise<T> {
+  cancel(): void;
+}
+
+/**
+ * Make the promise cancelable.
+ *
+ * Returns a promise with a `cancel()` method wrapped around `promise`.
+ * Calling `cancel()` will reject the returned promise with
+ * {isCancelled: true} synchronously. If the inner promise for a cancelled
+ * promise resolves or rejects this is ignored.
+ */
+export function makeCancelable<T>(promise: Promise<T>) {
+  // True if the promise is either resolved or reject (possibly cancelled)
+  let isDone = false;
+
+  let rejectPromise: (reason?: unknown) => void;
+
+  const wrappedPromise: CancelablePromise<T> = new Promise(
+    (resolve, reject) => {
+      rejectPromise = reject;
+      promise.then(
+        val => {
+          if (!isDone) resolve(val);
+          isDone = true;
+        },
+        error => {
+          if (!isDone) reject(error);
+          isDone = true;
+        }
+      );
+    }
+  ) as CancelablePromise<T>;
+
+  wrappedPromise.cancel = () => {
+    if (isDone) return;
+    rejectPromise({isCanceled: true});
+    isDone = true;
+  };
+  return wrappedPromise;
+}
+
+export async function waitUntil(
+  predicate: (() => boolean) | (() => Promise<boolean>),
+  message = 'The waitUntil() predicate is still false after 1000 ms.',
+  timeout_ms = 1000
+): Promise<void> {
+  if (await predicate()) return Promise.resolve();
+  const start = Date.now();
+  let sleep = 10;
+  const error = new Error(message);
+  return new Promise((resolve, reject) => {
+    const waiter = async () => {
+      if (await predicate()) {
+        resolve();
+        return;
+      }
+      if (Date.now() - start >= timeout_ms) {
+        reject(error);
+        return;
+      }
+      setTimeout(waiter, sleep);
+      sleep *= 2;
+    };
+    waiter();
+  });
+}
+
+export interface MockPromise<T> extends Promise<T> {
+  resolve: (value?: T) => void;
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  reject: (reason?: any) => void;
+}
+
+export function mockPromise<T = unknown>(): MockPromise<T> {
+  let res: (value?: T) => void;
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  let rej: (reason?: any) => void;
+  const promise: MockPromise<T> = new Promise<T | undefined>(
+    (resolve, reject) => {
+      res = resolve;
+      rej = reject;
+    }
+  ) as MockPromise<T>;
+  promise.resolve = res!;
+  promise.reject = rej!;
+  return promise;
+}
+
+// MockPromise is the established name in tests, and we don't want to rename
+// that in 50 files. But "Mock" is a bit misleading and definitely not a great
+// fit for non-test code. So let's also export under a different name.
+export type InteractivePromise<T> = MockPromise<T>;
+export const interactivePromise = mockPromise;
+
+export function timeoutPromise(timeoutMs: number): Promise<void> {
+  return new Promise<void>(resolve => {
+    setTimeout(resolve, timeoutMs);
+  });
+}
diff --git a/polygerrit-ui/app/utils/async-util_test.ts b/polygerrit-ui/app/utils/async-util_test.ts
index 9f029b8..afc16d3 100644
--- a/polygerrit-ui/app/utils/async-util_test.ts
+++ b/polygerrit-ui/app/utils/async-util_test.ts
@@ -6,10 +6,46 @@
 import {assert} from '@open-wc/testing';
 import {SinonFakeTimers} from 'sinon';
 import '../test/common-test-setup';
-import {waitEventLoop} from '../test/test-utils';
-import {asyncForeach, debounceP} from './async-util';
+import {mockPromise, waitEventLoop, waitUntil} from '../test/test-utils';
+import {
+  asyncForeach,
+  debounceP,
+  DelayedTask,
+  interactivePromise,
+  timeoutPromise,
+} from './async-util';
 
 suite('async-util tests', () => {
+  suite('interactivePromise', () => {
+    test('simple test', async () => {
+      let resolved = false;
+      const promise = interactivePromise();
+      promise.then(() => (resolved = true));
+      assert.isFalse(resolved);
+      promise.resolve();
+      await promise;
+      assert.isTrue(resolved);
+    });
+  });
+
+  suite('timeoutPromise', () => {
+    let clock: SinonFakeTimers;
+    setup(() => {
+      clock = sinon.useFakeTimers();
+    });
+    test('simple test', async () => {
+      let resolved = false;
+      const promise = timeoutPromise(1000);
+      promise.then(() => (resolved = true));
+      assert.isFalse(resolved);
+      clock.tick(999);
+      assert.isFalse(resolved);
+      clock.tick(1);
+      await promise;
+      assert.isTrue(resolved);
+    });
+  });
+
   suite('asyncForeach', () => {
     test('loops over each item', async () => {
       const fn = sinon.stub().resolves();
@@ -205,4 +241,16 @@
       await waitEventLoop();
     });
   });
+
+  test('DelayedTask promise resolved when callback is done', async () => {
+    const callbackPromise = mockPromise<void>();
+    const task = new DelayedTask(() => callbackPromise);
+    let completed = false;
+    task.promise.then(() => (completed = true));
+    await waitUntil(() => !task.isActive());
+
+    assert.isFalse(completed);
+    callbackPromise.resolve();
+    await waitUntil(() => completed);
+  });
 });
diff --git a/polygerrit-ui/app/utils/attention-set-util.ts b/polygerrit-ui/app/utils/attention-set-util.ts
index 77834bd..9dcde62 100644
--- a/polygerrit-ui/app/utils/attention-set-util.ts
+++ b/polygerrit-ui/app/utils/attention-set-util.ts
@@ -3,7 +3,13 @@
  * Copyright 2020 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-import {AccountInfo, ChangeInfo, ServerInfo} from '../types/common';
+import {
+  AccountInfo,
+  ChangeInfo,
+  CommentThread,
+  DetailedLabelInfo,
+  ServerInfo,
+} from '../types/common';
 import {ParsedChangeInfo} from '../types/types';
 import {
   getAccountTemplate,
@@ -11,8 +17,9 @@
   isServiceUser,
   replaceTemplates,
 } from './account-util';
-import {CommentThread, isMentionedThread, isUnresolved} from './comment-util';
+import {isMentionedThread, isUnresolved} from './comment-util';
 import {hasOwnProperty} from './common-util';
+import {getCodeReviewLabel} from './label-util';
 
 export function canHaveAttention(account?: AccountInfo): boolean {
   return !!account?._account_id && !isServiceUser(account);
@@ -101,9 +108,10 @@
 /**
  *  Sort order:
  * 1. The user themselves
- * 2. Human users in the attention set.
- * 3. Other human users.
- * 4. Service users.
+ * 2. Users in the attention set first.
+ * 3. Human users first.
+ * 4. Users that have voted first in this order of vote values:
+ *    -2, -1, +2, +1, 0 or no vote.
  */
 export function sortReviewers(
   r1: AccountInfo,
@@ -117,7 +125,22 @@
   }
   const a1 = hasAttention(r1, change) ? 1 : 0;
   const a2 = hasAttention(r2, change) ? 1 : 0;
-  const s1 = isServiceUser(r1) ? -2 : 0;
-  const s2 = isServiceUser(r2) ? -2 : 0;
-  return a2 - a1 + s2 - s1;
+  if (a2 - a1 !== 0) return a2 - a1;
+
+  const s1 = isServiceUser(r1) ? -1 : 0;
+  const s2 = isServiceUser(r2) ? -1 : 0;
+  if (s2 - s1 !== 0) return s2 - s1;
+
+  const crLabel = getCodeReviewLabel(change?.labels ?? {}) as DetailedLabelInfo;
+  let v1 =
+    crLabel?.all?.find(vote => vote._account_id === r1._account_id)?.value ?? 0;
+  let v2 =
+    crLabel?.all?.find(vote => vote._account_id === r2._account_id)?.value ?? 0;
+  // We want negative votes getting a higher score than positive votes, so
+  // we choose 10 as a random number that is higher than all positive votes that
+  // are in use, and then add the absolute value of the vote to that.
+  // So -2 becomes 12.
+  if (v1 < 0) v1 = 10 - v1;
+  if (v2 < 0) v2 = 10 - v2;
+  return v2 - v1;
 }
diff --git a/polygerrit-ui/app/utils/attention-set-util_test.ts b/polygerrit-ui/app/utils/attention-set-util_test.ts
index 8092a6e..5bd1924 100644
--- a/polygerrit-ui/app/utils/attention-set-util_test.ts
+++ b/polygerrit-ui/app/utils/attention-set-util_test.ts
@@ -6,9 +6,11 @@
 import '../test/common-test-setup';
 import {
   createAccountDetailWithIdNameAndEmail,
+  createAccountWithId,
   createChange,
   createComment,
   createCommentThread,
+  createParsedChange,
   createServerInfo,
 } from '../test/test-data-generators';
 import {
@@ -22,9 +24,10 @@
   getMentionedReason,
   getReason,
   hasAttention,
+  sortReviewers,
 } from './attention-set-util';
 import {DefaultDisplayNameConfig} from '../api/rest-api';
-import {AccountsVisibility} from '../constants/constants';
+import {AccountsVisibility, AccountTag} from '../constants/constants';
 import {assert} from '@open-wc/testing';
 
 const KERMIT: AccountInfo = {
@@ -101,6 +104,45 @@
     assert.equal(getReason(config, OTHER_ACCOUNT, change), 'Added by kermit');
   });
 
+  test('sortReviewers', () => {
+    const a1 = createAccountWithId(1);
+    a1.tags = [AccountTag.SERVICE_USER];
+    const a2 = createAccountWithId(2);
+    a2.tags = [AccountTag.SERVICE_USER];
+    const a3 = createAccountWithId(3);
+    const a4 = createAccountWithId(4);
+    const a5 = createAccountWithId(5);
+    const a6 = createAccountWithId(6);
+    const a7 = createAccountWithId(7);
+
+    const reviewers = [a1, a2, a3, a4, a5, a6, a7];
+    const change = {
+      ...createParsedChange(),
+      attention_set: {'6': {account: a6}},
+      labels: {
+        'Code-Review': {
+          all: [
+            {...a2, value: 1},
+            {...a4, value: 1},
+            {...a5, value: -1},
+          ],
+        },
+      },
+    };
+    assert.sameOrderedMembers(
+      reviewers.sort((r1, r2) => sortReviewers(r1, r2, change, a7)),
+      [
+        a7, // self
+        a6, // is in the attention set
+        a5, // human user, has voted -1
+        a4, // human user, has voted +1
+        a3, // human user, has not voted
+        a2, // service user, has voted
+        a1, // service user, has not voted
+      ]
+    );
+  });
+
   test('getMentionReason', () => {
     let comment = {
       ...createComment(),
diff --git a/polygerrit-ui/app/utils/change-util.ts b/polygerrit-ui/app/utils/change-util.ts
index 1a3d361..fcc1361 100644
--- a/polygerrit-ui/app/utils/change-util.ts
+++ b/polygerrit-ui/app/utils/change-util.ts
@@ -11,15 +11,17 @@
   ChangeInfo,
   AccountInfo,
   RelatedChangeAndCommitInfo,
+  ChangeStates,
 } from '../types/common';
 import {ParsedChangeInfo} from '../types/types';
-import {ChangeStates} from '../elements/shared/gr-change-status/gr-change-status';
 import {getUserId, isServiceUser} from './account-util';
 
 // This can be wrong! See WARNING above
 interface ChangeStatusesOptions {
   mergeable: boolean; // This can be wrong! See WARNING above
   submitEnabled: boolean; // This can be wrong! See WARNING above
+  /** Is there a reverting change and if so, what status has it? */
+  revertingChangeStatus?: ChangeStatus;
 }
 
 export const ChangeDiffType = {
@@ -111,11 +113,11 @@
 }
 
 export function changeBaseURL(
-  project: string,
+  repo: string,
   changeNum: NumericChangeId,
   patchNum: PatchSetNum
 ): string {
-  let v = `${getBaseUrl()}/changes/${encodeURIComponent(project)}~${changeNum}`;
+  let v = `${getBaseUrl()}/changes/${encodeURIComponent(repo)}~${changeNum}`;
   if (patchNum) {
     v += `/revisions/${patchNum}`;
   }
@@ -156,19 +158,22 @@
 
 export function changeStatuses(
   change: ChangeInfo,
-  opt_options?: ChangeStatusesOptions
+  options?: ChangeStatusesOptions
 ): ChangeStates[] {
   const states = [];
   if (change.status === ChangeStatus.MERGED) {
+    if (options?.revertingChangeStatus === ChangeStatus.MERGED) {
+      return [ChangeStates.MERGED, ChangeStates.REVERT_SUBMITTED];
+    }
+    if (options?.revertingChangeStatus !== undefined) {
+      return [ChangeStates.MERGED, ChangeStates.REVERT_CREATED];
+    }
     return [ChangeStates.MERGED];
   }
   if (change.status === ChangeStatus.ABANDONED) {
     return [ChangeStates.ABANDONED];
   }
-  if (
-    change.mergeable === false ||
-    (opt_options && opt_options.mergeable === false)
-  ) {
+  if (change.mergeable === false || (options && options.mergeable === false)) {
     // 'mergeable' prop may not always exist (@see Issue 6819)
     states.push(ChangeStates.MERGE_CONFLICT);
   } else if (change.contains_git_conflicts) {
@@ -183,12 +188,12 @@
 
   // If there are any pre-defined statuses, only return those. Otherwise,
   // will determine the derived status.
-  if (states.length || !opt_options) {
+  if (states.length || !options) {
     return states;
   }
 
   // If no missing requirements, either active or ready to submit.
-  if (change.submittable && opt_options.submitEnabled) {
+  if (change.submittable && options.submitEnabled) {
     states.push(ChangeStates.READY_TO_SUBMIT);
   } else {
     // Otherwise it is active.
diff --git a/polygerrit-ui/app/utils/change-util_test.ts b/polygerrit-ui/app/utils/change-util_test.ts
index 70e6fd6..f768145 100644
--- a/polygerrit-ui/app/utils/change-util_test.ts
+++ b/polygerrit-ui/app/utils/change-util_test.ts
@@ -5,7 +5,6 @@
  */
 import {assert} from '@open-wc/testing';
 import {ChangeStatus} from '../constants/constants';
-import {ChangeStates} from '../elements/shared/gr-change-status/gr-change-status';
 import '../test/common-test-setup';
 import {
   createAccountWithId,
@@ -15,6 +14,7 @@
 } from '../test/test-data-generators';
 import {
   AccountId,
+  ChangeStates,
   CommitId,
   NumericChangeId,
   PatchSetNum,
@@ -129,6 +129,32 @@
     assert.deepEqual(changeStatuses(change), [ChangeStates.MERGED]);
   });
 
+  test('Merged and Reverted status', () => {
+    const change = {
+      ...createChange(),
+      revisions: createRevisions(1),
+      current_revision: 'rev1' as CommitId,
+      status: ChangeStatus.MERGED,
+    };
+    assert.deepEqual(changeStatuses(change), [ChangeStates.MERGED]);
+    assert.deepEqual(
+      changeStatuses(change, {
+        revertingChangeStatus: ChangeStatus.NEW,
+        mergeable: true,
+        submitEnabled: true,
+      }),
+      [ChangeStates.MERGED, ChangeStates.REVERT_CREATED]
+    );
+    assert.deepEqual(
+      changeStatuses(change, {
+        revertingChangeStatus: ChangeStatus.MERGED,
+        mergeable: true,
+        submitEnabled: true,
+      }),
+      [ChangeStates.MERGED, ChangeStates.REVERT_SUBMITTED]
+    );
+  });
+
   test('Abandoned status', () => {
     const change = {
       ...createChange(),
diff --git a/polygerrit-ui/app/utils/comment-util.ts b/polygerrit-ui/app/utils/comment-util.ts
index f531698..ee1a44c 100644
--- a/polygerrit-ui/app/utils/comment-util.ts
+++ b/polygerrit-ui/app/utils/comment-util.ts
@@ -4,13 +4,9 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import {
-  CommentBasics,
   CommentInfo,
   PatchSetNum,
-  RobotCommentInfo,
-  Timestamp,
   UrlEncodedCommentId,
-  CommentRange,
   PatchRange,
   PARENT,
   ContextLine,
@@ -18,79 +14,28 @@
   RevisionPatchSetNum,
   AccountInfo,
   AccountDetailInfo,
-  ChangeMessageInfo,
   VotingRangeInfo,
   FixSuggestionInfo,
   FixId,
+  PatchSetNumber,
+  CommentThread,
+  DraftInfo,
+  ChangeMessage,
+  isRobot,
+  isDraft,
+  Comment,
+  CommentIdToCommentThreadMap,
+  SavingState,
+  NewDraftInfo,
+  isNew,
 } from '../types/common';
 import {CommentSide, SpecialFilePath} from '../constants/constants';
 import {parseDate} from './date-util';
-import {CommentIdToCommentThreadMap} from '../elements/diff/gr-comment-api/gr-comment-api';
 import {isMergeParent, getParentIndex} from './patch-set-util';
 import {DiffInfo} from '../types/diff';
-import {LineNumber} from '../api/diff';
 import {FormattedReviewerUpdateInfo} from '../types/types';
 import {extractMentionedUsers} from './account-util';
-
-export interface DraftCommentProps {
-  // This must be true for all drafts. Drafts received from the backend will be
-  // modified immediately with __draft:true before allowing them to get into
-  // the application state.
-  __draft: boolean;
-}
-
-export interface UnsavedCommentProps {
-  // This must be true for all unsaved comment drafts. An unsaved draft is
-  // always just local to a comment component like <gr-comment> or
-  // <gr-comment-thread>. Unsaved drafts will never appear in the application
-  // state.
-  __unsaved: boolean;
-}
-
-export type DraftInfo = CommentInfo & DraftCommentProps;
-
-export type UnsavedInfo = CommentBasics & UnsavedCommentProps;
-
-export type Comment = UnsavedInfo | DraftInfo | CommentInfo | RobotCommentInfo;
-
-// TODO: Replace the CommentMap type with just an array of paths.
-export type CommentMap = {[path: string]: boolean};
-
-export function isRobot<T extends CommentBasics>(
-  x: T | DraftInfo | RobotCommentInfo | undefined
-): x is RobotCommentInfo {
-  return !!x && !!(x as RobotCommentInfo).robot_id;
-}
-
-export function isDraft<T extends CommentBasics>(
-  x: T | DraftInfo | undefined
-): x is DraftInfo {
-  return !!x && !!(x as DraftInfo).__draft;
-}
-
-export function isUnsaved<T extends CommentBasics>(
-  x: T | UnsavedInfo | undefined
-): x is UnsavedInfo {
-  return !!x && !!(x as UnsavedInfo).__unsaved;
-}
-
-export function isDraftOrUnsaved<T extends CommentBasics>(
-  x: T | DraftInfo | UnsavedInfo | undefined
-): x is UnsavedInfo | DraftInfo {
-  return isDraft(x) || isUnsaved(x);
-}
-
-interface SortableComment {
-  updated: Timestamp;
-  id: UrlEncodedCommentId;
-}
-
-export interface ChangeMessage extends ChangeMessageInfo {
-  // TODO(TS): maybe should be an enum instead
-  type: string;
-  expanded: boolean;
-  commentThreads: CommentThread[];
-}
+import {assertIsDefined, uuid} from './common-util';
 
 export function isFormattedReviewerUpdate(
   message: ChangeMessage
@@ -105,43 +50,89 @@
 export const PATCH_SET_PREFIX_PATTERN =
   /^(?:Uploaded\s*)?[Pp]atch [Ss]et \d+:\s*(.*)/;
 
-export function sortComments<T extends SortableComment>(comments: T[]): T[] {
+/**
+ * We need a way to uniquely identify drafts. That is easy for all drafts that
+ * were already known to the backend at the time of change page load: They will
+ * have an `id` that we can use.
+ *
+ * For newly created drafts we start by setting a `client_id`, so that we can
+ * identify the draft even, if no `id` is available yet.
+ *
+ * If a comment with a `client_id` gets saved, then id gets an `id`, but we have
+ * to keep using the `client_id`, because that is what the UI is already using,
+ * e.g. in `repeat()` directives.
+ */
+export function id(comment: Comment): UrlEncodedCommentId {
+  if (isDraft(comment)) {
+    if (isNew(comment)) {
+      assertIsDefined(comment.client_id);
+      return comment.client_id;
+    }
+    if (comment.client_id) {
+      return comment.client_id;
+    }
+  }
+  assertIsDefined(comment.id);
+  return comment.id;
+}
+
+export function sortComments<T extends Comment>(comments: T[]): T[] {
   return comments.slice(0).sort((c1, c2) => {
+    const n1 = isNew(c1);
+    const n2 = isNew(c2);
+    if (n1 !== n2) return n1 ? 1 : -1;
+
     const d1 = isDraft(c1);
     const d2 = isDraft(c2);
     if (d1 !== d2) return d1 ? 1 : -1;
 
-    const date1 = parseDate(c1.updated);
-    const date2 = parseDate(c2.updated);
-    const dateDiff = date1.valueOf() - date2.valueOf();
-    if (dateDiff !== 0) return dateDiff;
+    if (c1.updated && c2.updated) {
+      const date1 = parseDate(c1.updated);
+      const date2 = parseDate(c2.updated);
+      const dateDiff = date1.valueOf() - date2.valueOf();
+      if (dateDiff !== 0) return dateDiff;
+    }
 
-    const id1 = c1.id;
-    const id2 = c2.id;
+    const id1 = id(c1);
+    const id2 = id(c2);
     return id1.localeCompare(id2);
   });
 }
 
-export function createUnsavedComment(thread: CommentThread): UnsavedInfo {
+export function createNew(
+  message?: string,
+  unresolved?: boolean
+): NewDraftInfo {
+  const newDraft: NewDraftInfo = {
+    savingState: SavingState.OK,
+    client_id: uuid() as UrlEncodedCommentId,
+    id: undefined,
+    updated: undefined,
+  };
+  if (message !== undefined) newDraft.message = message;
+  if (unresolved !== undefined) newDraft.unresolved = unresolved;
+  return newDraft;
+}
+
+export function createNewPatchsetLevel(
+  patchNum?: PatchSetNumber,
+  message?: string,
+  unresolved?: boolean
+): DraftInfo {
   return {
-    path: thread.path,
-    patch_set: thread.patchNum,
-    side: thread.commentSide ?? CommentSide.REVISION,
-    line: typeof thread.line === 'number' ? thread.line : undefined,
-    range: thread.range,
-    parent: thread.mergeParentNum,
-    message: '',
-    unresolved: true,
-    __unsaved: true,
+    ...createNew(message, unresolved),
+    patch_set: patchNum,
+    path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
   };
 }
 
-export function createUnsavedReply(
+export function createNewReply(
   replyingTo: CommentInfo,
   message: string,
   unresolved: boolean
-): UnsavedInfo {
+): DraftInfo {
   return {
+    ...createNew(message, unresolved),
     path: replyingTo.path,
     patch_set: replyingTo.patch_set,
     side: replyingTo.side,
@@ -149,13 +140,10 @@
     range: replyingTo.range,
     parent: replyingTo.parent,
     in_reply_to: replyingTo.id,
-    message,
-    unresolved,
-    __unsaved: true,
   };
 }
 
-export function createCommentThreads(comments: CommentInfo[]) {
+export function createCommentThreads(comments: Comment[]) {
   const sortedComments = sortComments(comments);
   const threads: CommentThread[] = [];
   const idThreadMap: CommentIdToCommentThreadMap = {};
@@ -165,7 +153,7 @@
       const thread = idThreadMap[comment.in_reply_to];
       if (thread) {
         thread.comments.push(comment);
-        if (comment.id) idThreadMap[comment.id] = thread;
+        if (id(comment)) idThreadMap[id(comment)] = thread;
         continue;
       }
     }
@@ -182,58 +170,17 @@
       path: comment.path,
       line: comment.line,
       range: comment.range,
-      rootId: comment.id,
+      rootId: id(comment),
     };
     if (!comment.line && !comment.range) {
       newThread.line = 'FILE';
     }
     threads.push(newThread);
-    if (comment.id) idThreadMap[comment.id] = newThread;
+    if (id(comment)) idThreadMap[id(comment)] = newThread;
   }
   return threads;
 }
 
-export interface CommentThread {
-  /**
-   * This can only contain at most one draft. And if so, then it is the last
-   * comment in this list. This must not contain unsaved drafts.
-   */
-  comments: Array<CommentInfo | DraftInfo | RobotCommentInfo>;
-  /**
-   * Identical to the id of the first comment. If this is undefined, then the
-   * thread only contains an unsaved draft.
-   */
-  rootId?: UrlEncodedCommentId;
-  /**
-   * Note that all location information is typically identical to that of the
-   * first comment, but not for ported comments!
-   */
-  path: string;
-  commentSide: CommentSide;
-  /* mergeParentNum is the merge parent number only valid for merge commits
-     when commentSide is PARENT.
-     mergeParentNum is undefined for auto merge commits
-     Same as `parent` in CommentInfo.
-  */
-  mergeParentNum?: number;
-  patchNum?: RevisionPatchSetNum;
-  /* Different from CommentInfo, which just keeps the line undefined for
-     FILE comments. */
-  line?: LineNumber;
-  range?: CommentRange;
-  /**
-   * Was the thread ported over from its original location to a newer patchset?
-   * If yes, then the location information above contains the ported location,
-   * but the comments still have the original location set.
-   */
-  ported?: boolean;
-  /**
-   * Only relevant when ported:true. Means that no ported range could be
-   * computed. `line` and `range` can be undefined then.
-   */
-  rangeInfoLost?: boolean;
-}
-
 export function equalLocation(t1?: CommentThread, t2?: CommentThread) {
   if (t1 === t2) return true;
   if (t1 === undefined || t2 === undefined) return false;
@@ -249,22 +196,24 @@
   );
 }
 
-export function getLastComment(thread: CommentThread): CommentInfo | undefined {
+export function getLastComment(
+  thread: CommentThread
+): CommentInfo | DraftInfo | undefined {
   const len = thread.comments.length;
   return thread.comments[len - 1];
 }
 
 export function getLastPublishedComment(
   thread: CommentThread
-): CommentInfo | undefined {
-  const publishedComments = thread.comments.filter(c => !isDraftOrUnsaved(c));
+): CommentInfo | DraftInfo | undefined {
+  const publishedComments = thread.comments.filter(c => !isDraft(c));
   const len = publishedComments.length;
   return publishedComments[len - 1];
 }
 
 export function getFirstComment(
   thread: CommentThread
-): CommentInfo | undefined {
+): CommentInfo | DraftInfo | undefined {
   return thread.comments[0];
 }
 
@@ -289,6 +238,14 @@
   return isDraft(getLastComment(thread));
 }
 
+/**
+ * Returns true, if the thread consists only of one comment that has not yet
+ * been saved to the backend.
+ */
+export function isNewThread(thread: CommentThread): boolean {
+  return isNew(getFirstComment(thread));
+}
+
 export function isMentionedThread(
   thread: CommentThread,
   account?: AccountInfo
@@ -371,10 +328,7 @@
 /**
  * Whether the given comment should be included in the given patch range.
  */
-export function isInPatchRange(
-  comment: CommentBasics,
-  range: PatchRange
-): boolean {
+export function isInPatchRange(comment: Comment, range: PatchRange): boolean {
   return (
     isInBaseOfPatchRange(comment, range) ||
     isInRevisionOfPatchRange(comment, range)
@@ -481,8 +435,8 @@
 }
 
 /**
- * Add __draft:true to all drafts returned from server so that they can be told
- * apart from published comments easily.
+ * Add `savingState: SavingState.OK` to all drafts returned from server so that
+ * they can be told apart from published comments easily.
  */
 export function addDraftProp(
   draftsByPath: {[path: string]: CommentInfo[]} = {}
@@ -490,13 +444,13 @@
   const updated: {[path: string]: DraftInfo[]} = {};
   for (const filePath of Object.keys(draftsByPath)) {
     updated[filePath] = (draftsByPath[filePath] ?? []).map(draft => {
-      return {...draft, __draft: true};
+      return {...draft, savingState: SavingState.OK};
     });
   }
   return updated;
 }
 
-export function reportingDetails(comment: CommentBasics) {
+export function reportingDetails(comment: Comment) {
   return {
     id: comment?.id,
     message_length: comment?.message?.trim().length,
@@ -504,11 +458,12 @@
     unresolved: comment?.unresolved,
     path_length: comment?.path?.length,
     line: comment?.range?.start_line ?? comment?.line,
-    unsaved: isUnsaved(comment),
+    unsaved: isNew(comment),
   };
 }
 
-export const USER_SUGGESTION_START_PATTERN = '```suggestion\n';
+export const USER_SUGGESTION_INFO_STRING = 'suggestion';
+export const USER_SUGGESTION_START_PATTERN = `\`\`\`${USER_SUGGESTION_INFO_STRING}\n`;
 
 // This can either mean a user or a checks provided fix.
 // "Provided" means that the fix is sent along with the request
@@ -521,13 +476,17 @@
   return comment.message?.includes(USER_SUGGESTION_START_PATTERN) ?? false;
 }
 
+export function getUserSuggestionFromString(content: string) {
+  const start =
+    content.indexOf(USER_SUGGESTION_START_PATTERN) +
+    USER_SUGGESTION_START_PATTERN.length;
+  const end = content.indexOf('\n```', start);
+  return content.substring(start, end);
+}
+
 export function getUserSuggestion(comment: Comment) {
   if (!comment.message) return;
-  const start =
-    comment.message.indexOf(USER_SUGGESTION_START_PATTERN) +
-    USER_SUGGESTION_START_PATTERN.length;
-  const end = comment.message.indexOf('\n```', start);
-  return comment.message.substring(start, end);
+  return getUserSuggestionFromString(comment.message);
 }
 
 export function getContentInCommentRange(
@@ -583,3 +542,17 @@
       .includes(account.email)
   );
 }
+
+export function findComment(
+  comments: {
+    [path: string]: (CommentInfo | DraftInfo)[];
+  },
+  commentId: UrlEncodedCommentId
+) {
+  if (!commentId) return undefined;
+  let comment;
+  for (const path of Object.keys(comments)) {
+    comment = comment || comments[path].find(c => c.id === commentId);
+  }
+  return comment;
+}
diff --git a/polygerrit-ui/app/utils/comment-util_test.ts b/polygerrit-ui/app/utils/comment-util_test.ts
index 0a8aa82..7bf0c1e 100644
--- a/polygerrit-ui/app/utils/comment-util_test.ts
+++ b/polygerrit-ui/app/utils/comment-util_test.ts
@@ -16,6 +16,8 @@
   createUserFixSuggestion,
   PROVIDED_FIX_ID,
   getMentionedThreads,
+  isNewThread,
+  createNew,
 } from './comment-util';
 import {
   createAccountWithEmail,
@@ -24,6 +26,9 @@
 } from '../test/test-data-generators';
 import {CommentSide} from '../constants/constants';
 import {
+  Comment,
+  DraftInfo,
+  SavingState,
   PARENT,
   RevisionPatchSetNum,
   Timestamp,
@@ -69,6 +74,17 @@
     );
   });
 
+  test('isNewThread', () => {
+    let thread = createCommentThread([createComment()]);
+    assert.isFalse(isNewThread(thread));
+
+    thread = createCommentThread([createComment(), createNew()]);
+    assert.isFalse(isNewThread(thread));
+
+    thread = createCommentThread([createNew()]);
+    assert.isTrue(isNewThread(thread));
+  });
+
   suite('getPatchRangeForCommentUrl', () => {
     test('comment created with side=PARENT does not navigate to latest ps', () => {
       const comment = {
@@ -127,13 +143,13 @@
   });
 
   test('comments sorting', () => {
-    const comments = [
+    const comments: Comment[] = [
       {
         id: 'new_draft' as UrlEncodedCommentId,
         message: 'i do not like either of you',
-        __draft: true,
+        savingState: SavingState.OK,
         updated: '2015-12-20 15:01:20.396000000' as Timestamp,
-      },
+      } as DraftInfo,
       {
         id: 'sallys_confession' as UrlEncodedCommentId,
         message: 'i like you, jack',
@@ -145,7 +161,7 @@
         message: 'i like you, too',
         updated: '2015-12-24 15:01:20.396000000' as Timestamp,
         line: 1,
-        in_reply_to: 'sallys_confession',
+        in_reply_to: 'sallys_confession' as UrlEncodedCommentId,
       },
     ];
     const sortedComments = sortComments(comments);
@@ -156,7 +172,7 @@
 
   suite('createCommentThreads', () => {
     test('creates threads from individual comments', () => {
-      const comments = [
+      const comments: Comment[] = [
         {
           id: 'sallys_confession' as UrlEncodedCommentId,
           message: 'i like you, jack',
@@ -177,7 +193,7 @@
         {
           id: 'new_draft' as UrlEncodedCommentId,
           message: 'i do not like either of you' as UrlEncodedCommentId,
-          __draft: true,
+          savingState: SavingState.OK,
           updated: '2015-12-20 15:01:20.396000000' as Timestamp,
           patch_set: 1 as RevisionPatchSetNum,
           path: 'some/path',
diff --git a/polygerrit-ui/app/utils/common-util.ts b/polygerrit-ui/app/utils/common-util.ts
index 183d167..c89bdff 100644
--- a/polygerrit-ui/app/utils/common-util.ts
+++ b/polygerrit-ui/app/utils/common-util.ts
@@ -175,3 +175,10 @@
   await navigator.clipboard.writeText(text);
   fireAlert(document, `${copyTargetName ?? text} was copied to clipboard`);
 }
+
+/**
+ * Produces strings such as `y364b4tm28n`.
+ */
+export function uuid() {
+  return Math.random().toString(36).substring(2);
+}
diff --git a/polygerrit-ui/app/utils/date-util.ts b/polygerrit-ui/app/utils/date-util.ts
index 72e6cb7..d95d24a 100644
--- a/polygerrit-ui/app/utils/date-util.ts
+++ b/polygerrit-ui/app/utils/date-util.ts
@@ -83,21 +83,17 @@
   return diff < 180 * Duration.DAY;
 }
 
-// TODO(dmfilippov): TS-Fix review this type. All fields here must be optional,
-// but this require some changes in the code. During JS->TS migration
-// we want to avoid code changes where possible, so for simplicity we
-// define it with almost all fields mandatory
+// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/formatToParts
 interface DateTimeFormatParts {
-  year: string;
-  month: string;
-  day: string;
-  hour: string;
-  minute: string;
-  second: string;
-  dayPeriod: string;
-  dayperiod?: string;
-  // Object can have other properties, but our code doesn't use it
-  [key: string]: string | undefined;
+  year?: string;
+  month?: string;
+  day?: string;
+  hour?: string;
+  minute?: string;
+  second?: string;
+  // AM or PM
+  dayPeriod?: string;
+  weekday?: string;
 }
 
 export function formatDate(date: Date, format: string) {
@@ -117,6 +113,14 @@
     }
   }
 
+  if (format.includes('ddd')) {
+    if (format.includes('dddd')) {
+      options.weekday = 'long';
+    } else {
+      options.weekday = 'short';
+    }
+  }
+
   if (format.includes('DD')) {
     options.day = '2-digit';
   }
@@ -146,15 +150,38 @@
     locale = 'en-GB';
   }
 
-  const dtf = new Intl.DateTimeFormat(locale, options);
-  const parts = dtf
-    .formatToParts(date)
-    .filter(o => o.type !== 'literal')
-    .reduce((acc, o: Intl.DateTimeFormatPart) => {
-      acc[o.type] = o.value;
-      return acc;
-    }, {} as DateTimeFormatParts);
-  if (format.includes('YY')) {
+  // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/formatToParts
+  const dtfParts = new Intl.DateTimeFormat(locale, options).formatToParts(date);
+  const parts: DateTimeFormatParts = {};
+  for (const entry of dtfParts) {
+    switch (entry.type) {
+      case 'year':
+        parts.year = entry.value;
+        break;
+      case 'month':
+        parts.month = entry.value;
+        break;
+      case 'day':
+        parts.day = entry.value;
+        break;
+      case 'hour':
+        parts.hour = entry.value;
+        break;
+      case 'minute':
+        parts.minute = entry.value;
+        break;
+      case 'second':
+        parts.second = entry.value;
+        break;
+      case 'dayPeriod':
+        parts.dayPeriod = entry.value;
+        break;
+      case 'weekday':
+        parts.weekday = entry.value;
+        break;
+    }
+  }
+  if (parts.year && format.includes('YY')) {
     if (format.includes('YYYY')) {
       format = format.replace('YYYY', parts.year);
     } else {
@@ -162,41 +189,50 @@
     }
   }
 
-  if (format.includes('DD')) {
+  if (parts.day && format.includes('DD')) {
     format = format.replace('DD', parts.day);
   }
 
-  if (format.includes('HH')) {
+  if (parts.hour && format.includes('HH')) {
     format = format.replace('HH', parts.hour);
   }
 
-  if (format.includes('h')) {
+  if (parts.hour && format.includes('h')) {
     format = format.replace('h', parts.hour);
   }
 
-  if (format.includes('mm')) {
+  if (parts.minute && format.includes('mm')) {
     format = format.replace('mm', parts.minute);
   }
 
-  if (format.includes('ss')) {
+  if (parts.second && format.includes('ss')) {
     format = format.replace('ss', parts.second);
   }
 
-  if (format.includes('A')) {
-    if (parts.dayperiod) {
-      // Workaround for chrome 70 and below
-      format = format.replace('A', parts.dayperiod.toUpperCase());
-    } else {
-      format = format.replace('A', parts.dayPeriod.toUpperCase());
-    }
+  if (parts.dayPeriod && format.includes('A')) {
+    format = format.replace('A', parts.dayPeriod.toUpperCase());
   }
-  if (format.includes('MM')) {
+
+  // Month and weekday must be last, because they will yield characters that
+  // could be interpreted as format strings, e.g. `h` in `Thursday` would
+  // otherwise be replaced by "hours".
+
+  if (parts.month && format.includes('MM')) {
     if (format.includes('MMM')) {
       format = format.replace('MMM', parts.month);
     } else {
       format = format.replace('MM', parts.month);
     }
   }
+
+  if (parts.weekday && format.includes('ddd')) {
+    if (format.includes('dddd')) {
+      format = format.replace('dddd', parts.weekday);
+    } else {
+      format = format.replace('ddd', parts.weekday);
+    }
+  }
+
   return format;
 }
 
diff --git a/polygerrit-ui/app/utils/date-util_test.ts b/polygerrit-ui/app/utils/date-util_test.ts
index 8e802b7..8d16655 100644
--- a/polygerrit-ui/app/utils/date-util_test.ts
+++ b/polygerrit-ui/app/utils/date-util_test.ts
@@ -194,6 +194,18 @@
         )
       );
     });
+
+    test('weekday', () => {
+      assert.equal(
+        '2013-07-03 Wed',
+        formatDate(new Date('Jul 03 2013 12:14:00'), 'YYYY-MM-DD ddd')
+      );
+      assert.equal(
+        '2013-07-03 Wednesday',
+        formatDate(new Date('Jul 03 2013 00:15:00'), 'YYYY-MM-DD dddd')
+      );
+    });
+
     test('h:mm:ss A shows correctly midnight and midday', () => {
       const timeFormat = 'h:mm A';
       assert.equal(
diff --git a/polygerrit-ui/app/utils/display-name-util.ts b/polygerrit-ui/app/utils/display-name-util.ts
index 7c39e1a..850509f 100644
--- a/polygerrit-ui/app/utils/display-name-util.ts
+++ b/polygerrit-ui/app/utils/display-name-util.ts
@@ -56,21 +56,21 @@
   account: AccountInfo
 ) {
   const reviewerName = getDisplayName(config, account);
-  const reviewerEmail = _accountEmail(account.email);
+  const reviewerEmail = accountEmail(account.email);
   const reviewerStatus = account.status ? '(' + account.status + ')' : '';
   return [reviewerName, reviewerEmail, reviewerStatus]
     .filter(p => p.length > 0)
     .join(' ');
 }
 
-function _accountEmail(email?: string) {
+function accountEmail(email?: string) {
   if (typeof email !== 'undefined') {
     return '<' + email + '>';
   }
   return '';
 }
 
-export const _testOnly_accountEmail = _accountEmail;
+export const _testOnly_accountEmail = accountEmail;
 
 export function getGroupDisplayName(group: GroupInfo) {
   return `${group.name || ''} (group)`;
diff --git a/polygerrit-ui/app/utils/dom-util.ts b/polygerrit-ui/app/utils/dom-util.ts
index 0b53f61..056238a 100644
--- a/polygerrit-ui/app/utils/dom-util.ts
+++ b/polygerrit-ui/app/utils/dom-util.ts
@@ -182,25 +182,37 @@
 }
 
 /**
- * Are any ancestors of the element (or the element itself) members of the
- * given class.
+ * Are any ancestors of the element (or the element itself) tagged with the
+ * given css class?
  *
+ * We are walking up the DOM using `element.parentElement`, but are not crossing
+ * Shadow DOM boundaries, if there are any.
  */
 export function descendedFromClass(
-  element: Element,
+  element: Element | undefined,
   className: string,
   stopElement?: Element
 ) {
-  let isDescendant = element.classList.contains(className);
-  while (
-    !isDescendant &&
-    element.parentElement &&
-    (!stopElement || element.parentElement !== stopElement)
-  ) {
-    isDescendant = element.classList.contains(className);
-    element = element.parentElement;
+  return parentWithClass(element, className, stopElement) !== undefined;
+}
+
+/**
+ * Returns an ancestor of the element (or the element itself) tagged with the
+ * given css class - or undefined.
+ *
+ * We are walking up the DOM using `element.parentElement`, but are not crossing
+ * Shadow DOM boundaries, if there are any.
+ */
+export function parentWithClass(
+  element: Element | undefined,
+  className: string,
+  stopElement?: Element
+) {
+  while (element && (!stopElement || element !== stopElement)) {
+    if (element.classList.contains(className)) return element;
+    element = element.parentElement ?? undefined;
   }
-  return isDescendant;
+  return undefined;
 }
 
 /**
@@ -453,7 +465,7 @@
   const path: EventTarget[] = e.composedPath() ?? [];
   for (const el of path) {
     if (!isElementTarget(el)) continue;
-    if (el.tagName === 'GR-OVERLAY') return true;
+    if (el.tagName === 'DIALOG') return true;
   }
   return false;
 }
diff --git a/polygerrit-ui/app/utils/dom-util_test.ts b/polygerrit-ui/app/utils/dom-util_test.ts
index 4b52548..fe185be 100644
--- a/polygerrit-ui/app/utils/dom-util_test.ts
+++ b/polygerrit-ui/app/utils/dom-util_test.ts
@@ -59,11 +59,13 @@
 }
 
 async function createFixture() {
-  return await fixture<HTMLElement>(html` <div id="test" class="a b c">
-    <a class="testBtn" style="color:red;"></a>
-    <dom-util-test-element></dom-util-test-element>
-    <span class="ss"></span>
-  </div>`);
+  return await fixture<HTMLElement>(html`
+    <div id="test" class="a b c d">
+      <a class="testBtn" style="color:red;"></a>
+      <dom-util-test-element></dom-util-test-element>
+      <span class="ss"></span>
+    </div>
+  `);
 }
 
 suite('dom-util tests', () => {
@@ -127,7 +129,7 @@
         path = getEventPath(e as MouseEvent);
       });
       aLink.click();
-      assert.equal(path, 'html>body>div>div#test.a.b.c>a.testBtn');
+      assert.equal(path, 'html>body>div>div#test.a.b.c.d>a.testBtn');
     });
   });
 
@@ -150,14 +152,44 @@
   });
 
   suite('descendedFromClass', () => {
-    test('basic tests', async () => {
+    test('descends from itself', async () => {
       const element = await createFixture();
       const testEl = queryAndAssert(element, 'dom-util-test-element');
-      // .c is a child of .a and not vice versa.
-      assert.isTrue(descendedFromClass(queryAndAssert(testEl, '.c'), 'a'));
-      assert.isFalse(descendedFromClass(queryAndAssert(testEl, '.a'), 'c'));
+      assert.isTrue(descendedFromClass(queryAndAssert(testEl, '.c'), 'c'));
+      assert.isTrue(descendedFromClass(queryAndAssert(testEl, '.b'), 'b'));
+      assert.isTrue(descendedFromClass(queryAndAssert(testEl, '.a'), 'a'));
+    });
 
-      // Stops at stop element.
+    test('.c in .b in .a', async () => {
+      const element = await createFixture();
+      const testEl = queryAndAssert(element, 'dom-util-test-element');
+      const a = queryAndAssert(testEl, '.a');
+      const b = queryAndAssert(testEl, '.b');
+      const c = queryAndAssert(testEl, '.c');
+      assert.isTrue(descendedFromClass(a, 'a'));
+      assert.isTrue(descendedFromClass(b, 'a'));
+      assert.isTrue(descendedFromClass(c, 'a'));
+      assert.isFalse(descendedFromClass(a, 'b'));
+      assert.isTrue(descendedFromClass(b, 'b'));
+      assert.isTrue(descendedFromClass(c, 'b'));
+      assert.isFalse(descendedFromClass(a, 'c'));
+      assert.isFalse(descendedFromClass(b, 'c'));
+      assert.isTrue(descendedFromClass(c, 'c'));
+    });
+
+    test('stops at shadow root', async () => {
+      const element = await createFixture();
+      const testEl = queryAndAssert(element, 'dom-util-test-element');
+      const a = queryAndAssert(testEl, '.a');
+      // div.d is a parent of testEl, but `descendedFromClass` does not cross
+      // the shadow root boundary of <dom-util-test-element>. So div.a inside
+      // the shadow root is not considered to descend from div.d outside of it.
+      assert.isFalse(descendedFromClass(a, 'd'));
+    });
+
+    test('stops at stop element', async () => {
+      const element = await createFixture();
+      const testEl = queryAndAssert(element, 'dom-util-test-element');
       assert.isFalse(
         descendedFromClass(
           queryAndAssert(testEl, '.c'),
@@ -297,15 +329,6 @@
       });
     });
 
-    test('suppress shortcut event from children of <gr-overlay>', async () => {
-      const overlay = document.createElement('gr-overlay');
-      const div = document.createElement('div');
-      overlay.appendChild(div);
-      await keyEventOn(div, e => {
-        assert.isTrue(shouldSuppress(e));
-      });
-    });
-
     test('suppress "enter" shortcut event from <gr-button>', async () => {
       await keyEventOn(
         document.createElement('gr-button'),
diff --git a/polygerrit-ui/app/utils/event-util.ts b/polygerrit-ui/app/utils/event-util.ts
index 714955b..af545e7 100644
--- a/polygerrit-ui/app/utils/event-util.ts
+++ b/polygerrit-ui/app/utils/event-util.ts
@@ -6,47 +6,34 @@
 import {FetchRequest} from '../types/types';
 import {
   DialogChangeEventDetail,
-  EventType,
   SwitchTabEventDetail,
   TabState,
 } from '../types/events';
 
-export function fireEvent(target: EventTarget, type: string) {
-  target.dispatchEvent(
-    new CustomEvent(type, {
-      composed: true,
-      bubbles: true,
-    })
-  );
-}
-
 export type HTMLElementEventDetailType<K extends keyof HTMLElementEventMap> =
-  HTMLElementEventMap[K] extends CustomEvent<infer DT>
-    ? unknown extends DT
-      ? never
-      : DT
-    : never;
+  HTMLElementEventMap[K] extends CustomEvent<infer DT> ? DT : never;
 
 type DocumentEventDetailType<K extends keyof DocumentEventMap> =
-  DocumentEventMap[K] extends CustomEvent<infer DT>
-    ? unknown extends DT
-      ? never
-      : DT
-    : never;
+  DocumentEventMap[K] extends CustomEvent<infer DT> ? DT : never;
 
 export function fire<K extends keyof DocumentEventMap>(
-  target: Document,
+  target: Document | undefined,
   type: K,
   detail: DocumentEventDetailType<K>
 ): void;
 
 export function fire<K extends keyof HTMLElementEventMap>(
-  target: EventTarget,
+  target: EventTarget | undefined,
   type: K,
   detail: HTMLElementEventDetailType<K>
 ): void;
 
-export function fire<T>(target: EventTarget, type: string, detail: T) {
+export function fire<T>(
+  target: EventTarget | undefined,
+  type: string,
+  detail: T
+) {
+  if (!target) return;
   target.dispatchEvent(
     new CustomEvent<T>(type, {
       detail,
@@ -56,28 +43,60 @@
   );
 }
 
+export function fireNoBubble<K extends keyof HTMLElementEventMap, T>(
+  target: EventTarget,
+  type: K,
+  detail: T
+) {
+  target.dispatchEvent(
+    new CustomEvent<T>(type, {
+      detail,
+      composed: true,
+      bubbles: false,
+    })
+  );
+}
+
+export function fireNoBubbleNoCompose<K extends keyof HTMLElementEventMap, T>(
+  target: EventTarget,
+  type: K,
+  detail: T
+) {
+  target.dispatchEvent(
+    new CustomEvent<T>(type, {
+      detail,
+      composed: false,
+      bubbles: false,
+    })
+  );
+}
+
 export function fireAlert(target: EventTarget, message: string) {
-  fire(target, EventType.SHOW_ALERT, {message, showDismiss: true});
+  fire(target, 'show-alert', {message, showDismiss: true});
+}
+
+export function fireError(target: EventTarget, message: string) {
+  fire(target, 'show-error', {message});
 }
 
 export function firePageError(response?: Response | null) {
   if (response === null) response = undefined;
-  fire(document, EventType.PAGE_ERROR, {response});
+  fire(document, 'page-error', {response});
 }
 
 export function fireServerError(response: Response, request?: FetchRequest) {
-  fire(document, EventType.SERVER_ERROR, {
+  fire(document, 'server-error', {
     response,
     request,
   });
 }
 
 export function fireNetworkError(error: Error) {
-  fire(document, EventType.NETWORK_ERROR, {error});
+  fire(document, 'network-error', {error});
 }
 
-export function fireTitleChange(target: EventTarget, title: string) {
-  fire(target, EventType.TITLE_CHANGE, {title});
+export function fireTitleChange(title: string) {
+  fire(document, 'title-change', {title});
 }
 
 // TODO(milutin) - remove once new gr-dialog will do it out of the box
@@ -86,11 +105,11 @@
   target: EventTarget,
   detail: DialogChangeEventDetail
 ) {
-  fire(target, EventType.DIALOG_CHANGE, detail);
+  fire(target, 'dialog-change', detail);
 }
 
 export function fireIronAnnounce(target: EventTarget, text: string) {
-  fire(target, EventType.IRON_ANNOUNCE, {text});
+  fire(target, 'iron-announce', {text});
 }
 
 export function fireShowTab(
@@ -100,15 +119,11 @@
   tabState?: TabState
 ) {
   const detail: SwitchTabEventDetail = {tab, scrollIntoView, tabState};
-  fire(target, EventType.SHOW_TAB, detail);
+  fire(target, 'show-tab', detail);
 }
 
-export function fireCloseFixPreview(target: EventTarget, fixApplied: boolean) {
-  fire(target, EventType.CLOSE_FIX_PREVIEW, {fixApplied});
-}
-
-export function fireReload(target: EventTarget, clearPatchset?: boolean) {
-  fire(target, EventType.RELOAD, {clearPatchset: !!clearPatchset});
+export function fireReload(target: EventTarget) {
+  fire(target, 'reload', {});
 }
 
 export function waitForEventOnce<K extends keyof HTMLElementEventMap>(
diff --git a/polygerrit-ui/app/utils/file-util.ts b/polygerrit-ui/app/utils/file-util.ts
new file mode 100644
index 0000000..246ac20
--- /dev/null
+++ b/polygerrit-ui/app/utils/file-util.ts
@@ -0,0 +1,45 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+/** See also Patch.java for the backend equivalent. */
+export enum FileMode {
+  /** Mode indicating an entry is a symbolic link. */
+  SYMLINK = 0o120000,
+
+  /** Mode indicating an entry is a non-executable file. */
+  REGULAR_FILE = 0o100644,
+
+  /** Mode indicating an entry is an executable file. */
+  EXECUTABLE_FILE = 0o100755,
+
+  /** Mode indicating an entry is a submodule commit in another repository. */
+  GITLINK = 0o160000,
+}
+
+export function fileModeToString(mode?: number, includeNumber = true): string {
+  const str = fileModeStr(mode);
+  const num = mode?.toString(8);
+  return `${str}${includeNumber && str ? ` (${num})` : ''}`;
+}
+
+function fileModeStr(mode?: number): string {
+  if (mode === FileMode.SYMLINK) return 'symlink';
+  if (mode === FileMode.REGULAR_FILE) return 'regular';
+  if (mode === FileMode.EXECUTABLE_FILE) return 'executable';
+  if (mode === FileMode.GITLINK) return 'gitlink';
+  return '';
+}
+
+export function expandFileMode(input?: string) {
+  if (!input) return input;
+  for (const modeNum of Object.values(FileMode) as FileMode[]) {
+    const modeStr = modeNum?.toString(8);
+    if (input.includes(modeStr)) {
+      return input.replace(modeStr, `${fileModeToString(modeNum)}`);
+    }
+  }
+  return input;
+}
diff --git a/polygerrit-ui/app/utils/file-util_test.ts b/polygerrit-ui/app/utils/file-util_test.ts
new file mode 100644
index 0000000..aeab026
--- /dev/null
+++ b/polygerrit-ui/app/utils/file-util_test.ts
@@ -0,0 +1,38 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {assert} from '@open-wc/testing';
+import '../test/common-test-setup';
+import {expandFileMode, FileMode, fileModeToString} from './file-util';
+
+suite('file-util tests', () => {
+  test('fileModeToString', () => {
+    const check = (
+      mode: number | undefined,
+      str: string,
+      includeNumber = true
+    ) => assert.equal(fileModeToString(mode, includeNumber), str);
+
+    check(undefined, '');
+    check(0, '');
+    check(1, '');
+    check(FileMode.REGULAR_FILE, 'regular', false);
+    check(FileMode.EXECUTABLE_FILE, 'executable', false);
+    check(FileMode.SYMLINK, 'symlink', false);
+    check(FileMode.GITLINK, 'gitlink', false);
+    check(FileMode.REGULAR_FILE, 'regular (100644)');
+    check(FileMode.EXECUTABLE_FILE, 'executable (100755)');
+    check(FileMode.SYMLINK, 'symlink (120000)');
+    check(FileMode.GITLINK, 'gitlink (160000)');
+  });
+
+  test('expandFileMode', () => {
+    assert.deepEqual(['asdf'].map(expandFileMode), ['asdf']);
+    assert.deepEqual(
+      ['old mode 100644', 'new mode 100755'].map(expandFileMode),
+      ['old mode regular (100644)', 'new mode executable (100755)']
+    );
+  });
+});
diff --git a/polygerrit-ui/app/utils/label-util.ts b/polygerrit-ui/app/utils/label-util.ts
index aaa35a4..8929e9c 100644
--- a/polygerrit-ui/app/utils/label-util.ts
+++ b/polygerrit-ui/app/utils/label-util.ts
@@ -153,7 +153,14 @@
   return false;
 }
 
-export function canVote(label: DetailedLabelInfo, account: AccountInfo) {
+// This method is checking labels.all from change detail,
+// that shows only permitted voting for reviewers or CC.
+// It doesn't have permitted votes for owner. You
+// can see permitted labels for logged in user in change.permitted_labels
+export function canReviewerVote(
+  label: DetailedLabelInfo,
+  account: AccountInfo
+) {
   const approvalInfo = getApprovalInfo(label, account);
   if (!approvalInfo) return false;
   if (approvalInfo.permitted_voting_range) {
diff --git a/polygerrit-ui/app/utils/link-util.ts b/polygerrit-ui/app/utils/link-util.ts
index afc6745..48e9c07 100644
--- a/polygerrit-ui/app/utils/link-util.ts
+++ b/polygerrit-ui/app/utils/link-util.ts
@@ -31,14 +31,8 @@
 ): RewriteResult[] {
   const enabledRewrites = Object.values(repoCommentLinks).filter(
     commentLinkInfo =>
-      commentLinkInfo.enabled !== false &&
-      (commentLinkInfo.link !== undefined || commentLinkInfo.html !== undefined)
+      commentLinkInfo.enabled !== false && commentLinkInfo.link !== undefined
   );
-  // Always linkify URLs starting with https?://
-  enabledRewrites.push({
-    match: '(https?://\\S+[\\w/])',
-    link: '$1',
-  });
   return enabledRewrites.flatMap(rewrite => {
     const regexp = new RegExp(rewrite.match, 'g');
     const partialResults: RewriteResult[] = [];
@@ -118,25 +112,19 @@
   matchedText: string,
   rewrite: CommentLinkInfo
 ): string {
-  if (rewrite.link !== undefined) {
-    const replacementHref = rewrite.link.startsWith('/')
-      ? `${getBaseUrl()}${rewrite.link}`
-      : rewrite.link;
-    const regexp = new RegExp(rewrite.match, 'g');
-    return matchedText.replace(
-      regexp,
-      createLinkTemplate(
-        replacementHref,
-        rewrite.text ?? '$&',
-        rewrite.prefix,
-        rewrite.suffix
-      )
-    );
-  } else if (rewrite.html !== undefined) {
-    return matchedText.replace(new RegExp(rewrite.match, 'g'), rewrite.html);
-  } else {
-    throw new Error('commentLinkInfo is not a link or html rewrite');
-  }
+  const replacementHref = rewrite.link.startsWith('/')
+    ? `${getBaseUrl()}${rewrite.link}`
+    : rewrite.link;
+  const regexp = new RegExp(rewrite.match, 'g');
+  return matchedText.replace(
+    regexp,
+    createLinkTemplate(
+      replacementHref,
+      rewrite.text ?? '$&',
+      rewrite.prefix,
+      rewrite.suffix
+    )
+  );
 }
 
 function createLinkTemplate(
diff --git a/polygerrit-ui/app/utils/link-util_test.ts b/polygerrit-ui/app/utils/link-util_test.ts
index 1f4e894..e4e719b 100644
--- a/polygerrit-ui/app/utils/link-util_test.ts
+++ b/polygerrit-ui/app/utils/link-util_test.ts
@@ -12,17 +12,6 @@
   }
 
   suite('link rewrites', () => {
-    test('default linking', () => {
-      assert.equal(
-        linkifyUrlsAndApplyRewrite('http://www.google.com', {}),
-        link('http://www.google.com', 'http://www.google.com')
-      );
-      assert.equal(
-        linkifyUrlsAndApplyRewrite('https://www.google.com', {}),
-        link('https://www.google.com', 'https://www.google.com')
-      );
-    });
-
     test('without text', () => {
       assert.equal(
         linkifyUrlsAndApplyRewrite('foo', {
@@ -76,56 +65,6 @@
     });
   });
 
-  suite('html rewrites', () => {
-    test('basic case', () => {
-      assert.equal(
-        linkifyUrlsAndApplyRewrite('foo', {
-          foo: {
-            match: '(foo)',
-            html: '<div>$1</div>',
-          },
-        }),
-        '<div>foo</div>'
-      );
-    });
-
-    test('only inserts', () => {
-      assert.equal(
-        linkifyUrlsAndApplyRewrite('foo', {
-          foo: {
-            match: 'foo',
-            html: 'foo bar',
-          },
-        }),
-        'foo bar'
-      );
-    });
-
-    test('only deletes', () => {
-      assert.equal(
-        linkifyUrlsAndApplyRewrite('foo bar baz', {
-          bar: {
-            match: 'bar',
-            html: '',
-          },
-        }),
-        'foo  baz'
-      );
-    });
-
-    test('multiple matches', () => {
-      assert.equal(
-        linkifyUrlsAndApplyRewrite('foo foo', {
-          foo: {
-            match: '(foo)',
-            html: '<div>$1</div>',
-          },
-        }),
-        '<div>foo</div> <div>foo</div>'
-      );
-    });
-  });
-
   test('for overlapping rewrites prefer the latest ending', () => {
     assert.equal(
       linkifyUrlsAndApplyRewrite('foobarbaz', {
@@ -135,14 +74,14 @@
         },
         foobarbaz: {
           match: 'foobarbaz',
-          html: '<div>foobarbaz.gov</div>',
+          link: 'foobarbaz.gov',
         },
         foobar: {
           match: 'foobar',
           link: 'foobar.gov',
         },
       }),
-      '<div>foobarbaz.gov</div>'
+      link('foobarbaz', 'foobarbaz.gov')
     );
   });
 
@@ -155,14 +94,14 @@
         },
         foobarbaz: {
           match: 'foobarbaz',
-          html: '<div>FooBarBaz.gov</div>',
+          link: 'FooBarBaz.gov',
         },
         foobar: {
           match: 'barbaz',
           link: 'BarBaz.gov',
         },
       }),
-      '<div>FooBarBaz.gov</div>'
+      link('foobarbaz', 'FooBarBaz.gov')
     );
   });
 
@@ -171,18 +110,18 @@
       linkifyUrlsAndApplyRewrite('foobarbaz', {
         foo: {
           match: 'foo',
-          html: 'FOO',
+          link: 'FOO',
         },
         oobarba: {
           match: 'oobarba',
-          html: 'OOBARBA',
+          link: 'OOBARBA',
         },
         baz: {
           match: 'baz',
-          html: 'BAZ',
+          link: 'BAZ',
         },
       }),
-      'FOObarBAZ'
+      `${link('foo', 'FOO')}bar${link('baz', 'BAZ')}`
     );
   });
 
@@ -191,18 +130,27 @@
       linkifyUrlsAndApplyRewrite('bugs: 123 234 345', {
         bug1: {
           match: '(bugs:) (\\d+)',
-          html: '$1 <div>bug/$2</div>',
+          prefix: '$1 ',
+          link: 'bug/$2',
+          text: 'bug/$2',
         },
         bug2: {
           match: '(bugs:) (\\d+) (\\d+)',
-          html: '$1 $2 <div>bug/$3</div>',
+          prefix: '$1 $2 ',
+          link: 'bug/$3',
+          text: 'bug/$3',
         },
         bug3: {
           match: '(bugs:) (\\d+) (\\d+) (\\d+)',
-          html: '$1 $2 $3 <div>bug/$4</div>',
+          prefix: '$1 $2 $3 ',
+          link: 'bug/$4',
+          text: 'bug/$4',
         },
       }),
-      'bugs: <div>bug/123</div> <div>bug/234</div> <div>bug/345</div>'
+      `bugs: ${link('bug/123', 'bug/123')} ${link('bug/234', 'bug/234')} ${link(
+        'bug/345',
+        'bug/345'
+      )}`
     );
   });
 });
diff --git a/polygerrit-ui/app/utils/lit-util.ts b/polygerrit-ui/app/utils/lit-util.ts
deleted file mode 100644
index 7ffab89..0000000
--- a/polygerrit-ui/app/utils/lit-util.ts
+++ /dev/null
@@ -1,69 +0,0 @@
-/**
- * @license
- * Copyright 2022 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-import {html} from 'lit';
-
-/**
- * This is a patched version of html`` to work around this Chrome bug:
- * https://bugs.chromium.org/p/v8/issues/detail?id=13190.
- *
- * The problem is that Chrome should guarantee that the TemplateStringsArray
- * is always the same instance, if the strings themselves are equal, but that
- * guarantee seems to be broken. So we are maintaining a map from
- * "concatenated strings" to TemplateStringsArray. If "concatenated strings"
- * are equal, then return the already known instance of TemplateStringsArray,
- * so html`` can use its strict equality check on it.
- */
-export class HtmlPatched {
-  constructor(private readonly reporter?: (key: string) => void) {}
-
-  /**
-   * If `strings` are in this set, then we are sure that they are also in the
-   * map, and that we will not run into the issue of "same key, but different
-   * strings array". So this set allows us to optimize performance a bit, and
-   * call the native html`` function early.
-   */
-  private readonly lookupSet = new Set<TemplateStringsArray>();
-
-  private readonly lookupMap = new Map<string, TemplateStringsArray>();
-
-  /**
-   * Proxies lit's html`` tagges template literal. See
-   * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Template_literals
-   * https://lit.dev/docs/libraries/standalone-templates/
-   *
-   * Example: If you call html`a${1}b${2}c`, then
-   * ['a', 'b', 'c'] are the "strings", and 1, 2 are the "values".
-   */
-  html(strings: TemplateStringsArray, ...values: unknown[]) {
-    if (this.lookupSet.has(strings)) {
-      return this.nativeHtml(strings, ...values);
-    }
-
-    const key = strings.join('\0');
-    const oldStrings = this.lookupMap.get(key);
-
-    if (oldStrings === undefined) {
-      this.lookupSet.add(strings);
-      this.lookupMap.set(key, strings);
-      return this.nativeHtml(strings, ...values);
-    }
-
-    if (oldStrings === strings) {
-      return this.nativeHtml(strings, ...values);
-    }
-
-    // Without using HtmlPatcher html`` would be called with `strings`,
-    // which will be considered different, although actually being equal.
-    console.warn(`HtmlPatcher was required for '${key.substring(0, 100)}'.`);
-    this.reporter?.(key);
-    return this.nativeHtml(oldStrings, ...values);
-  }
-
-  // Allows spying on calls in tests.
-  nativeHtml(strings: TemplateStringsArray, ...values: unknown[]) {
-    return html(strings, ...values);
-  }
-}
diff --git a/polygerrit-ui/app/utils/lit-util_test.ts b/polygerrit-ui/app/utils/lit-util_test.ts
deleted file mode 100644
index 17197f0..0000000
--- a/polygerrit-ui/app/utils/lit-util_test.ts
+++ /dev/null
@@ -1,118 +0,0 @@
-/**
- * @license
- * Copyright 2020 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-import {assert} from '@open-wc/testing';
-import '../test/common-test-setup';
-import {HtmlPatched} from './lit-util';
-
-function tsa(strings: string[]): TemplateStringsArray {
-  return strings as unknown as TemplateStringsArray;
-}
-
-suite('lit-util HtmlPatched tests', () => {
-  let patched: HtmlPatched;
-  let nativeHtmlSpy: sinon.SinonSpy;
-  let reporterSpy: sinon.SinonSpy;
-
-  setup(async () => {
-    reporterSpy = sinon.spy();
-    patched = new HtmlPatched(reporterSpy);
-    nativeHtmlSpy = sinon.spy(patched, 'nativeHtml');
-  });
-
-  test('simple call', () => {
-    const instance1 = tsa(['1']);
-    patched.html(instance1, 'a value');
-    assert.equal(nativeHtmlSpy.callCount, 1);
-    assert.equal(reporterSpy.callCount, 0);
-    assert.strictEqual(nativeHtmlSpy.getCalls()[0].args[0], instance1);
-    assert.strictEqual(nativeHtmlSpy.getCalls()[0].args[1], 'a value');
-  });
-
-  test('two calls, same instance', () => {
-    const instance1 = tsa(['1']);
-    patched.html(instance1, 'a value');
-    patched.html(instance1, 'a value');
-    assert.equal(nativeHtmlSpy.callCount, 2);
-    assert.equal(reporterSpy.callCount, 0);
-    assert.strictEqual(nativeHtmlSpy.getCalls()[0].firstArg, instance1);
-    assert.strictEqual(nativeHtmlSpy.getCalls()[1].firstArg, instance1);
-  });
-
-  test('two calls, different strings', () => {
-    const instance1 = tsa(['1']);
-    const instance2 = tsa(['2']);
-    patched.html(instance1, 'a value');
-    patched.html(instance2, 'a value');
-    assert.equal(nativeHtmlSpy.callCount, 2);
-    assert.equal(reporterSpy.callCount, 0);
-    assert.strictEqual(nativeHtmlSpy.getCalls()[0].firstArg, instance1);
-    assert.strictEqual(nativeHtmlSpy.getCalls()[1].firstArg, instance2);
-  });
-
-  test('two calls, same strings, different instances', () => {
-    const instance1 = tsa(['1']);
-    const instance2 = tsa(['1']);
-    patched.html(instance1, 'a value');
-    patched.html(instance2, 'a value');
-    assert.equal(nativeHtmlSpy.callCount, 2);
-    assert.equal(reporterSpy.callCount, 1);
-    assert.strictEqual(nativeHtmlSpy.getCalls()[0].firstArg, instance1);
-    assert.strictEqual(nativeHtmlSpy.getCalls()[1].firstArg, instance1);
-  });
-
-  test('many calls', () => {
-    const instance1a = tsa(['1']);
-    const instance1b = tsa(['1']);
-    const instance1c = tsa(['1']);
-    const instance2a = tsa(['asdf', 'qwer']);
-    const instance2b = tsa(['asdf', 'qwer']);
-    const instance2c = tsa(['asdf', 'qwer']);
-    const instance3a = tsa(['asd', 'fqwer']);
-    const instance3b = tsa(['asd', 'fqwer']);
-    const instance3c = tsa(['asd', 'fqwer']);
-
-    patched.html(instance1a, 'a value');
-    patched.html(instance1a, 'a value');
-    patched.html(instance1b, 'a value');
-    patched.html(instance1b, 'a value');
-    patched.html(instance1c, 'a value');
-    patched.html(instance1c, 'a value');
-    patched.html(instance2a, 'a value');
-    patched.html(instance2a, 'a value');
-    patched.html(instance2b, 'a value');
-    patched.html(instance2b, 'a value');
-    patched.html(instance2c, 'a value');
-    patched.html(instance2c, 'a value');
-    patched.html(instance3a, 'a value');
-    patched.html(instance3a, 'a value');
-    patched.html(instance3b, 'a value');
-    patched.html(instance3b, 'a value');
-    patched.html(instance3c, 'a value');
-    patched.html(instance3c, 'a value');
-
-    assert.equal(nativeHtmlSpy.callCount, 18);
-    assert.equal(reporterSpy.callCount, 12);
-
-    assert.strictEqual(nativeHtmlSpy.getCalls()[0].firstArg, instance1a);
-    assert.strictEqual(nativeHtmlSpy.getCalls()[1].firstArg, instance1a);
-    assert.strictEqual(nativeHtmlSpy.getCalls()[2].firstArg, instance1a);
-    assert.strictEqual(nativeHtmlSpy.getCalls()[3].firstArg, instance1a);
-    assert.strictEqual(nativeHtmlSpy.getCalls()[4].firstArg, instance1a);
-    assert.strictEqual(nativeHtmlSpy.getCalls()[5].firstArg, instance1a);
-    assert.strictEqual(nativeHtmlSpy.getCalls()[6].firstArg, instance2a);
-    assert.strictEqual(nativeHtmlSpy.getCalls()[7].firstArg, instance2a);
-    assert.strictEqual(nativeHtmlSpy.getCalls()[8].firstArg, instance2a);
-    assert.strictEqual(nativeHtmlSpy.getCalls()[9].firstArg, instance2a);
-    assert.strictEqual(nativeHtmlSpy.getCalls()[10].firstArg, instance2a);
-    assert.strictEqual(nativeHtmlSpy.getCalls()[11].firstArg, instance2a);
-    assert.strictEqual(nativeHtmlSpy.getCalls()[12].firstArg, instance3a);
-    assert.strictEqual(nativeHtmlSpy.getCalls()[13].firstArg, instance3a);
-    assert.strictEqual(nativeHtmlSpy.getCalls()[14].firstArg, instance3a);
-    assert.strictEqual(nativeHtmlSpy.getCalls()[15].firstArg, instance3a);
-    assert.strictEqual(nativeHtmlSpy.getCalls()[16].firstArg, instance3a);
-    assert.strictEqual(nativeHtmlSpy.getCalls()[17].firstArg, instance3a);
-  });
-});
diff --git a/polygerrit-ui/app/utils/message-util_test.ts b/polygerrit-ui/app/utils/message-util_test.ts
new file mode 100644
index 0000000..22a5e4d
--- /dev/null
+++ b/polygerrit-ui/app/utils/message-util_test.ts
@@ -0,0 +1,37 @@
+/**
+ * @license
+ * Copyright 2023 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {getRevertCreatedChangeIds} from './message-util';
+import {assert} from '@open-wc/testing';
+import {MessageTag} from '../constants/constants';
+import {ChangeId, ReviewInputTag} from '../api/rest-api';
+import {createChangeMessage} from '../test/test-data-generators';
+
+suite('message-util tests', () => {
+  test('getRevertCreatedChangeIds', () => {
+    const messages = [
+      {
+        ...createChangeMessage(),
+        message: 'Created a revert of this change as 123',
+        tag: MessageTag.TAG_REVERT as ReviewInputTag,
+      },
+      {
+        ...createChangeMessage(),
+        message: 'Created a revert of this change as xyz',
+        tag: MessageTag.TAG_REVERT as ReviewInputTag,
+      },
+      {
+        ...createChangeMessage(),
+        message: 'Created a revert of this change as abc',
+        tag: undefined,
+      },
+    ];
+
+    assert.deepEqual(getRevertCreatedChangeIds(messages), [
+      '123' as ChangeId,
+      'xyz' as ChangeId,
+    ]);
+  });
+});
diff --git a/polygerrit-ui/app/utils/page-wrapper-utils.ts b/polygerrit-ui/app/utils/page-wrapper-utils.ts
deleted file mode 100644
index 78e78ed..0000000
--- a/polygerrit-ui/app/utils/page-wrapper-utils.ts
+++ /dev/null
@@ -1,42 +0,0 @@
-/**
- * @license
- * Copyright 2020 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-// @ts-ignore: Bazel is not yet configured to download the types
-import pagejs from 'page';
-
-// Reexport page.js. To make it work rollup patches page.js and replace "this"
-// to "window". Otherwise, it can't assign global property. We can't import
-// page.mjs because typescript doesn't support mjs extensions
-export interface Page {
-  (pattern: string | RegExp, ...pageCallback: PageCallback[]): void;
-  (pageCallback: PageCallback): void;
-  show(url: string): void;
-  redirect(url: string): void;
-  replace(path: string, state: null, init: boolean, dispatch: boolean): void;
-  base(url: string): void;
-  start(): void;
-  exit(pattern: string | RegExp, ...pageCallback: PageCallback[]): void;
-}
-
-// See https://visionmedia.github.io/page.js/ for details
-export interface PageContext {
-  canonicalPath: string;
-  path: string;
-  querystring: string;
-  pathname: string;
-  hash: string;
-  params: {[paramIndex: string]: string};
-}
-
-export type PageNextCallback = () => void;
-
-export type PageCallback = (
-  context: PageContext,
-  next: PageNextCallback
-) => void;
-
-// TODO: Convert page usages to the real types and remove this file of wrapper
-// types. Also remove workarounds in rollup config.
-export const page = pagejs as unknown as Page;
diff --git a/polygerrit-ui/app/utils/patch-set-util.ts b/polygerrit-ui/app/utils/patch-set-util.ts
index 6c46921..7f3b6eb 100644
--- a/polygerrit-ui/app/utils/patch-set-util.ts
+++ b/polygerrit-ui/app/utils/patch-set-util.ts
@@ -35,11 +35,6 @@
   wip?: boolean;
 }
 
-interface PatchRange {
-  patchNum?: RevisionPatchSetNum;
-  basePatchNum?: BasePatchSetNum;
-}
-
 /**
  * Whether the given patch is a numbered parent of a merge (i.e. a negative
  * number).
@@ -64,7 +59,7 @@
 export function convertToPatchSetNum(
   patchset: string | undefined
 ): PatchSetNum | undefined {
-  if (patchset === undefined) return patchset;
+  if (!patchset) return undefined;
   if (!isPatchSetNum(patchset)) {
     console.error('string is not of type PatchSetNum');
   }
@@ -206,7 +201,7 @@
       };
     });
   }
-  return _computeWipForPatchSets(change, patchNums);
+  return computeWipForPatchSets(change, patchNums);
 }
 
 /**
@@ -218,7 +213,7 @@
  * @return The given list of patch set objects, with the
  *     wip property set on each of them
  */
-function _computeWipForPatchSets(
+function computeWipForPatchSets(
   change: ChangeInfo | ParsedChangeInfo,
   patchNums: PatchSet[]
 ) {
@@ -249,7 +244,7 @@
   return patchNums;
 }
 
-export const _testOnly_computeWipForPatchSets = _computeWipForPatchSets;
+export const _testOnly_computeWipForPatchSets = computeWipForPatchSets;
 
 export function computeLatestPatchNum(
   allPatchSets?: PatchSet[]
@@ -294,10 +289,6 @@
   return allPatchSets[0].num === EDIT;
 }
 
-export function hasEditPatchsetLoaded(patchRange: PatchRange) {
-  return patchRange.patchNum === EDIT;
-}
-
 /**
  * @param revisions A sorted array of revisions.
  *
diff --git a/polygerrit-ui/app/utils/path-list-util.ts b/polygerrit-ui/app/utils/path-list-util.ts
index 1116123..b007d47 100644
--- a/polygerrit-ui/app/utils/path-list-util.ts
+++ b/polygerrit-ui/app/utils/path-list-util.ts
@@ -4,7 +4,7 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import {SpecialFilePath, FileInfoStatus} from '../constants/constants';
-import {FileInfo} from '../types/common';
+import {FileInfo, FileNameToFileInfoMap} from '../types/common';
 import {hasOwnProperty} from './common-util';
 
 export function specialFilePathCompare(a: string, b: string) {
@@ -55,7 +55,7 @@
 // In case there are files with comments on them but they are unchanged, then
 // we explicitly displays the file to render the comments with Unchanged status
 export function addUnmodifiedFiles(
-  files: {[filename: string]: FileInfo},
+  files: FileNameToFileInfoMap,
   commentedPaths: {[fileName: string]: boolean}
 ) {
   if (!commentedPaths) return;
diff --git a/polygerrit-ui/app/utils/path-list-util_test.ts b/polygerrit-ui/app/utils/path-list-util_test.ts
index 50f5c0e..cdd8182 100644
--- a/polygerrit-ui/app/utils/path-list-util_test.ts
+++ b/polygerrit-ui/app/utils/path-list-util_test.ts
@@ -12,9 +12,9 @@
   specialFilePathCompare,
   truncatePath,
 } from './path-list-util';
-import {FileInfo} from '../api/rest-api';
 import {hasOwnProperty} from './common-util';
 import {assert} from '@open-wc/testing';
+import {FileNameToFileInfoMap} from '../types/common';
 
 suite('path-list-utl tests', () => {
   test('special sort', () => {
@@ -117,7 +117,7 @@
       'file1.txt': true,
     };
 
-    const files: {[filename: string]: FileInfo} = {
+    const files: FileNameToFileInfoMap = {
       'file2.txt': {
         status: FileInfoStatus.REWRITTEN,
         size_delta: 10,
@@ -144,7 +144,7 @@
     assert.equal(shortenedPath, expectedPath);
   });
 
-  test('truncatePath with opt_threshold', () => {
+  test('truncatePath with threshold', () => {
     let path = 'level1/level2/level3/level4/file.js';
     let shortenedPath = truncatePath(path, 2);
     // The expected path is truncated with an ellipsis.
diff --git a/polygerrit-ui/app/utils/string-util.ts b/polygerrit-ui/app/utils/string-util.ts
index b6f1ad1..81dcde1 100644
--- a/polygerrit-ui/app/utils/string-util.ts
+++ b/polygerrit-ui/app/utils/string-util.ts
@@ -14,14 +14,18 @@
   return `${count} ${noun}` + (count > 1 ? 's' : '');
 }
 
-export function addQuotesWhen(string: string, cond: boolean): string {
-  return cond ? `"${string}"` : string;
-}
-
 export function charsOnly(s: string): string {
   return s.replace(/[^a-zA-Z]+/g, '');
 }
 
+export function isCharacterLetter(ch: string): boolean {
+  return ch.length === 1 && ch.toLowerCase() !== ch.toUpperCase();
+}
+
+export function isUpperCase(ch: string): boolean {
+  return ch === ch.toUpperCase();
+}
+
 export function ordinal(n?: number): string {
   if (n === undefined) return '';
   if (n % 10 === 1 && n % 100 !== 11) return `${n}st`;
@@ -30,6 +34,15 @@
   return `${n}th`;
 }
 
+/** Escape operator value to avoid affecting overall query.
+ *
+ * Escapes quotes (") and backslashes (\). Wraps in quotes so the value can
+ * contain spaces and colons.
+ */
+export function escapeAndWrapSearchOperatorValue(value: string): string {
+  return `"${value.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`;
+}
+
 /**
  * This converts any inputed value into string.
  *
diff --git a/polygerrit-ui/app/utils/string-util_test.ts b/polygerrit-ui/app/utils/string-util_test.ts
index c6c65b1..d6c4187 100644
--- a/polygerrit-ui/app/utils/string-util_test.ts
+++ b/polygerrit-ui/app/utils/string-util_test.ts
@@ -10,6 +10,7 @@
   ordinal,
   listForSentence,
   diffFilePaths,
+  escapeAndWrapSearchOperatorValue,
 } from './string-util';
 
 suite('string-util tests', () => {
@@ -84,4 +85,11 @@
       fileName: 'COMMIT_MSG',
     });
   });
+
+  test('escapeAndWrapSearchOperatorValue', () => {
+    assert.equal(
+      escapeAndWrapSearchOperatorValue('"value of \\: \\"something"'),
+      '"\\"value of \\\\: \\\\\\"something\\""'
+    );
+  });
 });
diff --git a/polygerrit-ui/app/utils/submit-requirement-util.ts b/polygerrit-ui/app/utils/submit-requirement-util.ts
index 6672712..ff99b7f 100644
--- a/polygerrit-ui/app/utils/submit-requirement-util.ts
+++ b/polygerrit-ui/app/utils/submit-requirement-util.ts
@@ -3,10 +3,7 @@
  * Copyright 2022 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-
 import {SubmitRequirementExpressionInfo} from '../api/rest-api';
-import {Execution} from '../constants/reporting';
-import {getAppContext} from '../services/app-context';
 
 export enum SubmitRequirementExpressionAtomStatus {
   UNKNOWN = 'UNKNOWN',
@@ -55,13 +52,8 @@
   const result: SubmitRequirementExpressionPart[] = [];
   let currentIndex = 0;
   for (const {start, end, isPassing} of matchedAtoms) {
-    if (start < currentIndex) {
-      getAppContext().reportingService.reportExecution(
-        Execution.REACHABLE_CODE,
-        'Overlapping atom matches in submit requirement expression.'
-      );
-      continue;
-    }
+    // We don't handle overlapping matches, but this can happen.
+    if (start < currentIndex) continue;
     if (start > currentIndex) {
       result.push({
         value: expression.slice(currentIndex, start),
diff --git a/polygerrit-ui/app/utils/url-util.ts b/polygerrit-ui/app/utils/url-util.ts
index 739d04b..2f9bc0c 100644
--- a/polygerrit-ui/app/utils/url-util.ts
+++ b/polygerrit-ui/app/utils/url-util.ts
@@ -8,45 +8,15 @@
   BasePatchSetNum,
   PARENT,
   RevisionPatchSetNum,
-  ServerInfo,
 } from '../types/common';
-import {RestApiService} from '../services/gr-rest-api/gr-rest-api';
 import {AuthType} from '../api/rest-api';
 
-const PROBE_PATH = '/Documentation/index.html';
-const DOCS_BASE_PATH = '/Documentation';
-
 export function getBaseUrl(): string {
   // window is not defined in service worker, therefore no CANONICAL_PATH
   if (typeof window === 'undefined') return '';
   return self.CANONICAL_PATH || '';
 }
 
-export interface PatchRangeParams {
-  patchNum?: RevisionPatchSetNum;
-  basePatchNum?: BasePatchSetNum;
-}
-
-export function rootUrl() {
-  return `${getBaseUrl()}/`;
-}
-
-/**
- * Given an object of parameters, potentially including a `patchNum` or a
- * `basePatchNum` or both, return a string representation of that range. If
- * no range is indicated in the params, the empty string is returned.
- */
-export function getPatchRangeExpression(params: PatchRangeParams) {
-  let range = '';
-  if (params.patchNum) {
-    range = `${params.patchNum}`;
-  }
-  if (params.basePatchNum && params.basePatchNum !== PARENT) {
-    range = `${params.basePatchNum}..${range}`;
-  }
-  return range;
-}
-
 /**
  * Return the url to use for login. If the server configuration
  * contains the `loginUrl` in the `auth` section then that custom url
@@ -78,6 +48,31 @@
   }
 }
 
+export interface PatchRangeParams {
+  patchNum?: RevisionPatchSetNum;
+  basePatchNum?: BasePatchSetNum;
+}
+
+export function rootUrl() {
+  return `${getBaseUrl()}/`;
+}
+
+/**
+ * Given an object of parameters, potentially including a `patchNum` or a
+ * `basePatchNum` or both, return a string representation of that range. If
+ * no range is indicated in the params, the empty string is returned.
+ */
+export function getPatchRangeExpression(params: PatchRangeParams) {
+  let range = '';
+  if (params.patchNum) {
+    range = `${params.patchNum}`;
+  }
+  if (params.basePatchNum && params.basePatchNum !== PARENT) {
+    range = `${params.basePatchNum}..${range}`;
+  }
+  return range;
+}
+
 function sanitizeRelativeUrl(relativeUrl: string): string {
   return relativeUrl.startsWith('/') ? relativeUrl : `/${relativeUrl}`;
 }
@@ -88,48 +83,60 @@
   throw new Error(`Cannot prepend origin to relative path '${path}'.`);
 }
 
-let getDocsBaseUrlCachedPromise: Promise<string | null> | undefined;
-
 /**
- * Get the docs base URL from either the server config or by probing.
- *
- * @return A promise that resolves with the docs base URL.
+ * Encodes *parts* of a URL. See inline comments below for the details.
+ * Note specifically that ? & = # are encoded. So this is very close to
+ * encodeURIComponent() with some tweaks.
  */
-export function getDocsBaseUrl(
-  config: ServerInfo | undefined,
-  restApi: RestApiService
-): Promise<string | null> {
-  if (!getDocsBaseUrlCachedPromise) {
-    getDocsBaseUrlCachedPromise = new Promise(resolve => {
-      if (config?.gerrit?.doc_url) {
-        resolve(config.gerrit.doc_url);
-      } else {
-        restApi.probePath(getBaseUrl() + PROBE_PATH).then(ok => {
-          resolve(ok ? getBaseUrl() + DOCS_BASE_PATH : null);
-        });
-      }
-    });
-  }
-  return getDocsBaseUrlCachedPromise;
-}
+export function encodeURL(url: string): string {
+  // gr-page decodes the entire URL, and then decodes once more the
+  // individual regex matching groups. It uses `decodeURIComponent()`, which
+  // will choke on singular `%` chars without two trailing digits. We prefer
+  // to not double encode *everything* (just for readaiblity and simplicity),
+  // but `%` *must* be double encoded.
+  let output = url.replaceAll('%', '%25');
+  // `+` also requires double encoding, because `%2B` would be decoded to `+`
+  // and then replaced by ` `.
+  output = output.replaceAll('+', '%2B');
 
-export function _testOnly_clearDocsBaseUrlCache() {
-  getDocsBaseUrlCachedPromise = undefined;
-}
+  // This escapes ALL characters EXCEPT:
+  // A–Z a–z 0–9 - _ . ! ~ * ' ( )
+  // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURIComponent
+  output = encodeURIComponent(output);
 
-/**
- * Pretty-encodes a URL. Double-encodes the string, and then replaces
- *   benevolent characters for legibility.
- */
-export function encodeURL(url: string, replaceSlashes?: boolean): string {
-  // @see Issue 4255 regarding double-encoding.
-  let output = encodeURIComponent(encodeURIComponent(url));
-  // @see Issue 4577 regarding more readable URLs.
-  output = output.replace(/%253A/g, ':');
-  output = output.replace(/%2520/g, '+');
-  if (replaceSlashes) {
-    output = output.replace(/%252F/g, '/');
-  }
+  // If we would use `encodeURI()` instead of `encodeURIComponent()`, then we
+  // would also NOT encode:
+  // ; / ? : @ & = + $ , #
+  //
+  // That would be more readable, but for example ? and & have special meaning
+  // in the URL, so they must be encoded. Let's discuss all these chars and
+  // decide whether we have to encode them or not.
+  //
+  // ? & = # have to be encoded. Otherwise we might mess up the URL.
+  //
+  // : @ do not have to be encoded, because we are only dealing with path,
+  // query and fragment of the URL, not with scheme, user, host, port.
+  // For search queries it is much nicer to not encode those chars, think of
+  // searching for `owner:spearce@spearce.org`.
+  //
+  // / does not have to be encoded, because we don't care about individual path
+  // components. File path and repo names are so much nicer to read without /
+  // being encoded!
+  //
+  // + must be encoded, because we want to use it instead of %20 for spaces, see
+  // below.
+  //
+  // ; $ , probably don't have to be encoded, but we don't bother about them
+  // much, so we don't reverse the encoding here, but we don't think it would
+  // cause any harm, if we did.
+  output = output.replace(/%3A/g, ':');
+  output = output.replace(/%40/g, '@');
+  output = output.replace(/%2F/g, '/');
+
+  // gr-page replaces `+` by ` ` in addition to calling `decodeURIComponent()`.
+  // So we can use `+` to increase readability.
+  output = output.replace(/%20/g, '+');
+
   return output;
 }
 
@@ -137,6 +144,10 @@
  * Single decode for URL components. Will decode plus signs ('+') to spaces.
  * Note: because this function decodes once, it is not the inverse of
  * encodeURL.
+ *
+ * This function must only be used for decoding data returned by the REST API.
+ * Don't use it for decoding browser URLs. The only place for decoding browser
+ * URLs must gr-page.ts.
  */
 export function singleDecodeURL(url: string): string {
   const withoutPlus = url.replace(/\+/g, '%20');
diff --git a/polygerrit-ui/app/utils/url-util_test.ts b/polygerrit-ui/app/utils/url-util_test.ts
index 8262686..e2ca617 100644
--- a/polygerrit-ui/app/utils/url-util_test.ts
+++ b/polygerrit-ui/app/utils/url-util_test.ts
@@ -3,37 +3,23 @@
  * Copyright 2020 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-import {
-  AuthType,
-  BasePatchSetNum,
-  RevisionPatchSetNum,
-  ServerInfo,
-} from '../api/rest-api';
+import {AuthType, BasePatchSetNum, RevisionPatchSetNum} from '../api/rest-api';
 import '../test/common-test-setup';
 import {
-  createAuth,
-  createGerritInfo,
-  createServerInfo,
-} from '../test/test-data-generators';
-import {
-  getBaseUrl,
-  getDocsBaseUrl,
-  _testOnly_clearDocsBaseUrlCache,
   encodeURL,
+  getBaseUrl,
+  getPatchRangeExpression,
+  loginUrl,
+  PatchRangeParams,
   singleDecodeURL,
   toPath,
   toPathname,
   toSearchParams,
-  getPatchRangeExpression,
-  PatchRangeParams,
-  loginUrl,
 } from './url-util';
-import {getAppContext, AppContext} from '../services/app-context';
-import {stubRestApi} from '../test/test-utils';
 import {assert} from '@open-wc/testing';
+import {createAuth} from '../test/test-data-generators';
 
 suite('url-util tests', () => {
-  let appContext: AppContext;
   suite('getBaseUrl tests', () => {
     let originalCanonicalPath: string | undefined;
 
@@ -53,7 +39,6 @@
 
   suite('loginUrl tests', () => {
     const authConfig = createAuth();
-    const customLoginUrl = '/custom';
 
     test('default url if auth.loginUrl is not defined', () => {
       const current = encodeURIComponent(
@@ -71,8 +56,9 @@
             window.location.search +
             window.location.hash
         );
-
+      const customLoginUrl = '/custom';
       authConfig.login_url = customLoginUrl;
+
       authConfig.auth_type = AuthType.LDAP;
       assert.deepEqual(loginUrl(authConfig), defaultUrl);
       authConfig.auth_type = AuthType.OPENID_SSO;
@@ -82,7 +68,9 @@
     });
 
     test('use auth.loginUrl when defined', () => {
+      const customLoginUrl = '/custom';
       authConfig.login_url = customLoginUrl;
+
       authConfig.auth_type = AuthType.HTTP;
       assert.deepEqual(loginUrl(authConfig), customLoginUrl);
       authConfig.auth_type = AuthType.HTTP_LDAP;
@@ -96,85 +84,27 @@
     });
   });
 
-  suite('getDocsBaseUrl tests', () => {
-    setup(() => {
-      _testOnly_clearDocsBaseUrlCache();
-      appContext = getAppContext();
-    });
-
-    test('null config', async () => {
-      const probePathMock = stubRestApi('probePath').resolves(true);
-      const docsBaseUrl = await getDocsBaseUrl(
-        undefined,
-        appContext.restApiService
-      );
-      assert.isTrue(probePathMock.calledWith('/Documentation/index.html'));
-      assert.equal(docsBaseUrl, '/Documentation');
-    });
-
-    test('no doc config', async () => {
-      const probePathMock = stubRestApi('probePath').resolves(true);
-      const config: ServerInfo = {
-        ...createServerInfo(),
-        gerrit: createGerritInfo(),
-      };
-      const docsBaseUrl = await getDocsBaseUrl(
-        config,
-        appContext.restApiService
-      );
-      assert.isTrue(probePathMock.calledWith('/Documentation/index.html'));
-      assert.equal(docsBaseUrl, '/Documentation');
-    });
-
-    test('has doc config', async () => {
-      const probePathMock = stubRestApi('probePath').resolves(true);
-      const config: ServerInfo = {
-        ...createServerInfo(),
-        gerrit: {...createGerritInfo(), doc_url: 'foobar'},
-      };
-      const docsBaseUrl = await getDocsBaseUrl(
-        config,
-        appContext.restApiService
-      );
-      assert.isFalse(probePathMock.called);
-      assert.equal(docsBaseUrl, 'foobar');
-    });
-
-    test('no probe', async () => {
-      const probePathMock = stubRestApi('probePath').resolves(false);
-      const docsBaseUrl = await getDocsBaseUrl(
-        undefined,
-        appContext.restApiService
-      );
-      assert.isTrue(probePathMock.calledWith('/Documentation/index.html'));
-      assert.isNotOk(docsBaseUrl);
-    });
-  });
-
   suite('url encoding and decoding tests', () => {
     suite('encodeURL', () => {
-      test('double encodes', () => {
-        assert.equal(encodeURL('abc?123'), 'abc%253F123');
-        assert.equal(encodeURL('def/ghi'), 'def%252Fghi');
-        assert.equal(encodeURL('jkl'), 'jkl');
-        assert.equal(encodeURL(''), '');
+      test('does not encode alphanumeric chars', () => {
+        assert.equal(encodeURL("AZaz09-_.!~*'()"), "AZaz09-_.!~*'()");
       });
 
-      test('does not convert colons', () => {
-        assert.equal(encodeURL('mno:pqr'), 'mno:pqr');
+      test('double encodes %', () => {
+        assert.equal(encodeURL('abc%def'), 'abc%2525def');
       });
 
-      test('converts spaces to +', () => {
+      test('double encodes +', () => {
+        assert.equal(encodeURL('abc+def'), 'abc%252Bdef');
+      });
+
+      test('does not encode colon and slash', () => {
+        assert.equal(encodeURL(':/'), ':/');
+      });
+
+      test('encodes spaces as +', () => {
         assert.equal(encodeURL('words with spaces'), 'words+with+spaces');
       });
-
-      test('does not convert slashes when configured', () => {
-        assert.equal(encodeURL('stu/vwx', true), 'stu/vwx');
-      });
-
-      test('does not convert slashes when configured', () => {
-        assert.equal(encodeURL('stu/vwx', true), 'stu/vwx');
-      });
     });
 
     suite('singleDecodeUrl', () => {
diff --git a/polygerrit-ui/app/utils/weblink-util.ts b/polygerrit-ui/app/utils/weblink-util.ts
index 1e9315c..17ad44b 100644
--- a/polygerrit-ui/app/utils/weblink-util.ts
+++ b/polygerrit-ui/app/utils/weblink-util.ts
@@ -3,51 +3,26 @@
  * Copyright 2022 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-import {CommitId, ServerInfo} from '../api/rest-api';
-
-export interface WebLink {
-  name?: string;
-  label: string;
-  url: string;
-}
-
-export interface GeneratedWebLink {
-  name?: string;
-  label?: string;
-  url?: string;
-}
-
-export function getPatchSetWeblink(
-  commit?: CommitId,
-  weblinks?: GeneratedWebLink[],
-  config?: ServerInfo
-): GeneratedWebLink | undefined {
-  if (!commit) return undefined;
-  const name = commit.slice(0, 7);
-  const weblink = getBrowseCommitWeblink(weblinks, config);
-  if (!weblink?.url) return {name};
-  return {name, url: weblink.url};
-}
+import {ServerInfo, WebLinkInfo} from '../api/rest-api';
 
 // visible for testing
-export function getCodeBrowserWeblink(weblinks: GeneratedWebLink[]) {
+export function getCodeBrowserWeblink(weblinks: WebLinkInfo[]) {
   // is an ordered allowed list of web link types that provide direct
   // links to the commit in the url property.
-  const codeBrowserLinks = ['gitiles', 'browse', 'gitweb'];
+  const codeBrowserLinks = ['gitiles', 'browse', 'gitweb', 'code search'];
   for (let i = 0; i < codeBrowserLinks.length; i++) {
     const weblink = weblinks.find(
-      weblink => weblink.name === codeBrowserLinks[i]
+      weblink => weblink.name?.toLowerCase() === codeBrowserLinks[i]
     );
     if (weblink) return weblink;
   }
   return undefined;
 }
 
-// visible for testing
 export function getBrowseCommitWeblink(
-  weblinks?: GeneratedWebLink[],
+  weblinks?: WebLinkInfo[],
   config?: ServerInfo
-): GeneratedWebLink | undefined {
+): WebLinkInfo | undefined {
   if (!weblinks) return undefined;
 
   // Use primary weblink if configured and exists.
@@ -61,9 +36,9 @@
 }
 
 export function getChangeWeblinks(
-  weblinks?: GeneratedWebLink[],
+  weblinks?: WebLinkInfo[],
   config?: ServerInfo
-): GeneratedWebLink[] {
+): WebLinkInfo[] {
   if (!weblinks?.length) return [];
   const commitWeblink = getBrowseCommitWeblink(weblinks, config);
   return weblinks.filter(
diff --git a/polygerrit-ui/app/utils/weblink-util_test.ts b/polygerrit-ui/app/utils/weblink-util_test.ts
index be97cfd..63842e2 100644
--- a/polygerrit-ui/app/utils/weblink-util_test.ts
+++ b/polygerrit-ui/app/utils/weblink-util_test.ts
@@ -16,17 +16,20 @@
   test('getCodeBrowserWeblink', () => {
     assert.deepEqual(
       getCodeBrowserWeblink([
-        {name: 'gitweb'},
-        {name: 'gitiles'},
-        {name: 'browse'},
-        {name: 'test'},
+        {name: 'gitweb', url: 'http://www.test.com'},
+        {name: 'gitiles', url: 'http://www.test.com'},
+        {name: 'browse', url: 'http://www.test.com'},
+        {name: 'test', url: 'http://www.test.com'},
       ]),
-      {name: 'gitiles'}
+      {name: 'gitiles', url: 'http://www.test.com'}
     );
 
     assert.deepEqual(
-      getCodeBrowserWeblink([{name: 'gitweb'}, {name: 'test'}]),
-      {name: 'gitweb'}
+      getCodeBrowserWeblink([
+        {name: 'gitweb', url: 'http://www.test.com'},
+        {name: 'test', url: 'http://www.test.com'},
+      ]),
+      {name: 'gitweb', url: 'http://www.test.com'}
     );
   });
 
diff --git a/polygerrit-ui/app/workers/service-worker-class.ts b/polygerrit-ui/app/workers/service-worker-class.ts
index 218744d..03b6b902 100644
--- a/polygerrit-ui/app/workers/service-worker-class.ts
+++ b/polygerrit-ui/app/workers/service-worker-class.ts
@@ -18,6 +18,7 @@
 } from './service-worker-indexdb';
 import {createDashboardUrl} from '../models/views/dashboard';
 import {createChangeUrl} from '../models/views/change';
+import {noAwait} from '../utils/async-util';
 
 export class ServiceWorker {
   constructor(
@@ -130,16 +131,22 @@
     // User can have different service workers for different origins/hosts.
     // TODO(milutin): Check if this works properly with getBaseUrl()
     const data = {url: `${self.location.origin}${changeUrl}`};
-
-    // TODO(milutin): Add gerrit host icon
-    this.ctx.registration.showNotification(change.subject, {body, data});
+    const icon = `${self.location.origin}/favicon.ico`;
+    this.ctx.registration.showNotification(change.subject, {
+      body,
+      data,
+      icon,
+    });
+    this.sendReport('notify about 1 change');
   }
 
   private showNotificationForDashboard(numOfChangesToNotifyAbout: number) {
-    const title = `You are in the attention set for ${numOfChangesToNotifyAbout} changes.`;
+    const title = `You are in the attention set for ${numOfChangesToNotifyAbout} new changes.`;
     const dashboardUrl = createDashboardUrl({});
     const data = {url: `${self.location.origin}${dashboardUrl}`};
-    this.ctx.registration.showNotification(title, {data});
+    const icon = `${self.location.origin}/favicon.ico`;
+    this.ctx.registration.showNotification(title, {data, icon});
+    this.sendReport(`notify about ${numOfChangesToNotifyAbout} changes`);
   }
 
   // private but used in test
@@ -154,6 +161,7 @@
     const prevLatestUpdateTimestampMs = this.latestUpdateTimestampMs;
     this.latestUpdateTimestampMs = Date.now();
     await this.saveState();
+    noAwait(this.sendReport('polling'));
     const changes = await this.getLatestAttentionSetChanges();
     const latestAttentionChanges = filterAttentionChangesAfter(
       changes,
@@ -173,4 +181,19 @@
     const changes = payload.parsed as unknown as ParsedChangeInfo[] | undefined;
     return changes ?? [];
   }
+
+  /**
+   * Send report event to 1 client (last focused one). The client will use
+   * gr-reporting service to send event to metric event collectors.
+   */
+  async sendReport(eventName: string) {
+    const clientsArr = await this.ctx.clients.matchAll({type: 'window'});
+    const lastFocusedClient = clientsArr?.[0];
+    if (!lastFocusedClient) return;
+
+    lastFocusedClient.postMessage({
+      type: ServiceWorkerMessageType.REPORTING,
+      eventName,
+    });
+  }
 }
diff --git a/polygerrit-ui/app/workers/service-worker-class_test.ts b/polygerrit-ui/app/workers/service-worker-class_test.ts
index 4cbd7bb..33a19d9 100644
--- a/polygerrit-ui/app/workers/service-worker-class_test.ts
+++ b/polygerrit-ui/app/workers/service-worker-class_test.ts
@@ -105,7 +105,7 @@
     assert.isTrue(showNotificationMock.calledOnce);
     assert.isTrue(
       showNotificationMock.calledWithMatch(
-        'You are in the attention set for 2 changes.'
+        'You are in the attention set for 2 new changes.'
       )
     );
   });
diff --git a/polygerrit-ui/app/yarn.lock b/polygerrit-ui/app/yarn.lock
index c8375ae..e056a35 100644
--- a/polygerrit-ui/app/yarn.lock
+++ b/polygerrit-ui/app/yarn.lock
@@ -161,7 +161,7 @@
   dependencies:
     "@polymer/polymer" "^3.0.0"
 
-"@polymer/iron-overlay-behavior@^3.0.0-pre.27", "@polymer/iron-overlay-behavior@^3.0.3":
+"@polymer/iron-overlay-behavior@^3.0.0-pre.27":
   version "3.0.3"
   resolved "https://registry.yarnpkg.com/@polymer/iron-overlay-behavior/-/iron-overlay-behavior-3.0.3.tgz#29c198e19e05bb2bcf7d86d3c11848cb93301d00"
   integrity sha512-Q/Fp0+uOQQ145ebZ7T8Cxl4m1tUKYjyymkjcL2rXUm+aDQGb1wA1M1LYxUF5YBqd+9lipE0PTIiYwA2ZL/sznA==
@@ -520,11 +520,6 @@
   resolved "https://registry.yarnpkg.com/code-point-at/-/code-point-at-1.1.0.tgz#0d070b4d043a5bea33a2f1a40e2edb3d9a4ccf77"
   integrity sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=
 
-codemirror-minified@^5.65.0:
-  version "5.65.0"
-  resolved "https://registry.yarnpkg.com/codemirror-minified/-/codemirror-minified-5.65.0.tgz#283f21655d6fc3477e64532c86a657bbc2063c19"
-  integrity sha512-AxpxR5XolsvgAjwE1BspomW6fhj541BxMyj0HT5TmeketKJ/kPSEiTZes/cQgHvHOmGB4clbR67Mz/ORrjYkMQ==
-
 concat-map@0.0.1:
   version "0.0.1"
   resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b"
@@ -672,11 +667,6 @@
   resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz#a3b30a5c4f199183167aaab93beefae3ddfb654f"
   integrity sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=
 
-isarray@0.0.1:
-  version "0.0.1"
-  resolved "https://registry.yarnpkg.com/isarray/-/isarray-0.0.1.tgz#8a18acfca9a8f4177e09abfc6038939b05d1eedf"
-  integrity sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=
-
 isarray@~1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11"
@@ -808,25 +798,11 @@
   dependencies:
     wrappy "1"
 
-page@^1.11.6:
-  version "1.11.6"
-  resolved "https://registry.yarnpkg.com/page/-/page-1.11.6.tgz#5ef4efc7073749b8085ccdaa0dcd7c9e0de12fe3"
-  integrity sha512-P6e2JfzkBrPeFCIPplLP7vDDiU84RUUZMrWdsH4ZBGJ8OosnwFkcUkBHp1DTIjuipLliw9yQn/ZJsXZvarsO+g==
-  dependencies:
-    path-to-regexp "~1.2.1"
-
 path-is-absolute@^1.0.0:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f"
   integrity sha1-F0uSaHNVNP+8es5r9TpanhtcX18=
 
-path-to-regexp@~1.2.1:
-  version "1.2.1"
-  resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-1.2.1.tgz#b33705c140234d873c8721c7b9fd8b541ed3aff9"
-  integrity sha1-szcFwUAjTYc8hyHHuf2LVB7Tr/k=
-  dependencies:
-    isarray "0.0.1"
-
 "polymer-bridges@file:../../polymer-bridges":
   version "1.0.0"
 
@@ -988,10 +964,10 @@
   resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
   integrity sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=
 
-web-vitals@^2.1.4:
-  version "2.1.4"
-  resolved "https://registry.yarnpkg.com/web-vitals/-/web-vitals-2.1.4.tgz#76563175a475a5e835264d373704f9dde718290c"
-  integrity sha512-sVWcwhU5mX6crfI5Vd2dC4qchyTqxV8URinzt25XqVh+bHEPGH4C3NPrNionCP7Obx59wrYEbNlw4Z8sjALzZg==
+web-vitals@^3.0.0:
+  version "3.1.1"
+  resolved "https://registry.yarnpkg.com/web-vitals/-/web-vitals-3.1.1.tgz#bb124a03df7a135617f495c5bb7dbc30ecf2cce3"
+  integrity sha512-qvllU+ZeQChqzBhZ1oyXmWsjJ8a2jHYpH8AMaVuf29yscOPZfTQTjQFRX6+eADTdsDE8IanOZ0cetweHMs8/2A==
 
 webidl-conversions@^3.0.0:
   version "3.0.1"
diff --git a/polygerrit-ui/package.json b/polygerrit-ui/package.json
index 1287d0c..6fa4d0f 100644
--- a/polygerrit-ui/package.json
+++ b/polygerrit-ui/package.json
@@ -11,6 +11,7 @@
     "@open-wc/testing": "^3.1.6",
     "@web/dev-server-esbuild": "^0.3.2",
     "@web/test-runner": "^0.14.0",
+    "@web/test-runner-playwright": "^0.9.0",
     "@web/test-runner-visual-regression": "^0.6.6",
     "accessibility-developer-tools": "^2.12.0",
     "karma": "^6.3.20",
@@ -25,6 +26,7 @@
     "test": "web-test-runner",
     "test:screenshot": "web-test-runner --run-screenshots",
     "test:screenshot-update": "web-test-runner --update-screenshots --files",
+    "test:browsers": "web-test-runner --playwright --browsers webkit firefox chromium",
     "test:coverage": "web-test-runner --coverage",
     "test:watch": "web-test-runner --watch",
     "test:single": "web-test-runner --watch --files",
diff --git a/polygerrit-ui/yarn.lock b/polygerrit-ui/yarn.lock
index ca6943d..35409a8 100644
--- a/polygerrit-ui/yarn.lock
+++ b/polygerrit-ui/yarn.lock
@@ -965,7 +965,7 @@
     "@jridgewell/sourcemap-codec" "^1.4.10"
     "@jridgewell/trace-mapping" "^0.3.9"
 
-"@jridgewell/resolve-uri@^3.0.3":
+"@jridgewell/resolve-uri@3.1.0", "@jridgewell/resolve-uri@^3.0.3":
   version "3.1.0"
   resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz#2203b118c157721addfe69d47b70465463066d78"
   integrity sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w==
@@ -975,11 +975,19 @@
   resolved "https://registry.yarnpkg.com/@jridgewell/set-array/-/set-array-1.1.2.tgz#7c6cf998d6d20b914c0a55a91ae928ff25965e72"
   integrity sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==
 
-"@jridgewell/sourcemap-codec@^1.4.10":
+"@jridgewell/sourcemap-codec@1.4.14", "@jridgewell/sourcemap-codec@^1.4.10":
   version "1.4.14"
   resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz#add4c98d341472a289190b424efbdb096991bb24"
   integrity sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==
 
+"@jridgewell/trace-mapping@^0.3.12":
+  version "0.3.17"
+  resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.17.tgz#793041277af9073b0951a7fe0f0d8c4c98c36985"
+  integrity sha512-MCNzAp77qzKca9+W/+I0+sEpaUnZoeasnghNeVc41VZCEKaCH73Vq3BZZ/SzWIgrqE4H4ceI+p+b6C0mHf9T4g==
+  dependencies:
+    "@jridgewell/resolve-uri" "3.1.0"
+    "@jridgewell/sourcemap-codec" "1.4.14"
+
 "@jridgewell/trace-mapping@^0.3.9":
   version "0.3.15"
   resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.15.tgz#aba35c48a38d3fd84b37e66c9c0423f9744f9774"
@@ -1883,6 +1891,16 @@
     picomatch "^2.2.2"
     v8-to-istanbul "^8.0.0"
 
+"@web/test-runner-coverage-v8@^0.5.0":
+  version "0.5.0"
+  resolved "https://registry.yarnpkg.com/@web/test-runner-coverage-v8/-/test-runner-coverage-v8-0.5.0.tgz#d1b033fd4baddaf5636a41cd017e321a338727a6"
+  integrity sha512-4eZs5K4JG7zqWEhVSO8utlscjbVScV7K6JVwoWWcObFTGAaBMbDVzwGRimyNSzvmfTdIO/Arze4CeUUfCl4iLQ==
+  dependencies:
+    "@web/test-runner-core" "^0.10.20"
+    istanbul-lib-coverage "^3.0.0"
+    picomatch "^2.2.2"
+    v8-to-istanbul "^9.0.1"
+
 "@web/test-runner-mocha@^0.7.5":
   version "0.7.5"
   resolved "https://registry.yarnpkg.com/@web/test-runner-mocha/-/test-runner-mocha-0.7.5.tgz#696f8cb7f5118a72bd7ac5778367ae3bd3fb92cd"
@@ -1891,6 +1909,15 @@
     "@types/mocha" "^8.2.0"
     "@web/test-runner-core" "^0.10.20"
 
+"@web/test-runner-playwright@^0.9.0":
+  version "0.9.0"
+  resolved "https://registry.yarnpkg.com/@web/test-runner-playwright/-/test-runner-playwright-0.9.0.tgz#c13b71ecfe763ae5d15dff586a35a9840c238b1f"
+  integrity sha512-RhWkz1CY3KThHoX89yZ/gz9wDSPujxd2wMWNxqhov4y/XDI+0TS44TWKBfWXnuvlQFZPi8JFT7KibCo3pb/Mcg==
+  dependencies:
+    "@web/test-runner-core" "^0.10.20"
+    "@web/test-runner-coverage-v8" "^0.5.0"
+    playwright "^1.22.2"
+
 "@web/test-runner-visual-regression@^0.6.6":
   version "0.6.6"
   resolved "https://registry.yarnpkg.com/@web/test-runner-visual-regression/-/test-runner-visual-regression-0.6.6.tgz#4a4dc734f360cba66a005e07b4a1c0a9ef956444"
@@ -4626,6 +4653,18 @@
   dependencies:
     find-up "^4.0.0"
 
+playwright-core@1.27.1:
+  version "1.27.1"
+  resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.27.1.tgz#840ef662e55a3ed759d8b5d3d00a5f885a7184f4"
+  integrity sha512-9EmeXDncC2Pmp/z+teoVYlvmPWUC6ejSSYZUln7YaP89Z6lpAaiaAnqroUt/BoLo8tn7WYShcfaCh+xofZa44Q==
+
+playwright@^1.22.2:
+  version "1.27.1"
+  resolved "https://registry.yarnpkg.com/playwright/-/playwright-1.27.1.tgz#4eecac5899566c589d4220ca8acc16abe8a67450"
+  integrity sha512-xXYZ7m36yTtC+oFgqH0eTgullGztKSRMb4yuwLPl8IYSmgBM88QiB+3IWb1mRIC9/NNwcgbG0RwtFlg+EAFQHQ==
+  dependencies:
+    playwright-core "1.27.1"
+
 pngjs@^6.0.0:
   version "6.0.0"
   resolved "https://registry.yarnpkg.com/pngjs/-/pngjs-6.0.0.tgz#ca9e5d2aa48db0228a52c419c3308e87720da821"
@@ -5555,6 +5594,15 @@
     convert-source-map "^1.6.0"
     source-map "^0.7.3"
 
+v8-to-istanbul@^9.0.1:
+  version "9.0.1"
+  resolved "https://registry.yarnpkg.com/v8-to-istanbul/-/v8-to-istanbul-9.0.1.tgz#b6f994b0b5d4ef255e17a0d17dc444a9f5132fa4"
+  integrity sha512-74Y4LqY74kLE6IFyIjPtkSTWzUZmj8tdHT9Ii/26dvQ6K9Dl2NbEfj0XgU2sHCtKgt5VupqhlO/5aWuqS+IY1w==
+  dependencies:
+    "@jridgewell/trace-mapping" "^0.3.12"
+    "@types/istanbul-lib-coverage" "^2.0.1"
+    convert-source-map "^1.6.0"
+
 valid-url@^1.0.9:
   version "1.0.9"
   resolved "https://registry.yarnpkg.com/valid-url/-/valid-url-1.0.9.tgz#1c14479b40f1397a75782f115e4086447433a200"
diff --git a/proto/cache.proto b/proto/cache.proto
index 83c2ce2..7063ee5 100644
--- a/proto/cache.proto
+++ b/proto/cache.proto
@@ -198,14 +198,7 @@
   string server_id = 20;
   bool has_server_id = 21;
 
-  message AssigneeStatusUpdateProto {
-    // Epoch millis.
-    int64 timestamp_millis = 1;
-    int32 updated_by = 2;
-    int32 current_assignee = 3;
-    bool has_current_assignee = 4;
-  }
-  repeated AssigneeStatusUpdateProto assignee_update = 22;
+  reserved 22;  // assignee_update;
 
   // An update to the attention set of the change. See class AttentionSetUpdate
   // for context.
@@ -535,9 +528,10 @@
 // Serialized form of com.google.gerrit.entities.StoredCommentLinkInfo.
 // Next ID: 10
 message StoredCommentLinkInfoProto {
+  reserved 4;  // html
+
   string name = 1;
   string match = 2;
-  string html = 4;
   bool enabled = 5;
   bool override_only = 6;
   string link = 3;
@@ -696,7 +690,7 @@
 
 // Serialized form of
 // com.google.gerrit.server.patch.filediff.FileDiffOutput
-// Next ID: 13
+// Next ID: 15
 message FileDiffOutputProto {
   // Next ID: 5
   message Edit {
@@ -728,4 +722,6 @@
   bytes new_commit = 10;
   ComparisonType comparison_type = 11;
   bool negative = 12;
+  string old_mode = 13; // ENUM as string
+  string new_mode = 14; // ENUM as string
 }
diff --git a/proto/entities.proto b/proto/entities.proto
index 191cca7..f89e0f0 100644
--- a/proto/entities.proto
+++ b/proto/entities.proto
@@ -45,7 +45,6 @@
   optional string topic = 14;
   optional string original_subject = 17;
   optional string submission_id = 18;
-  optional Account_Id assignee = 19;
   optional bool is_private = 20;
   optional bool work_in_progress = 21;
   optional bool review_started = 22;
@@ -59,6 +58,7 @@
   reserved 11;   // nbrPatchSets
   reserved 15;   // lastSha1MergeTested
   reserved 16;   // mergeable
+  reserved 19;   // assignee
   reserved 101;  // note_db_state
 }
 
@@ -98,6 +98,7 @@
   optional string groups = 6;
   optional string push_certificate = 8;
   optional string description = 9;
+  optional Account_Id real_uploader_account_id = 10;
 
   // Deleted fields, should not be reused:
   reserved 5;  // draft
diff --git a/resources/com/google/gerrit/server/commit-msg_test.sh b/resources/com/google/gerrit/server/commit-msg_test.sh
index 542632a1..a14109d 100755
--- a/resources/com/google/gerrit/server/commit-msg_test.sh
+++ b/resources/com/google/gerrit/server/commit-msg_test.sh
@@ -54,6 +54,31 @@
   fi
 }
 
+function test_empty_with_cutoff {
+  rm -f input
+  cat << EOF > input
+# Please enter the commit message for your changes.
+# ------------------------ >8 ------------------------
+# Do not modify or remove the line above.
+# Everything below it will be ignored.
+diff --git a/file.txt b/file.txt
+index 625fd613d9..03aeba3b21 100755
+--- a/file.txt
++++ b/file.txt
+@@ -38,6 +38,7 @@
+ context
+ line
+ 
++hello, world
+ 
+ context
+ line
+EOF
+  if ${hook} input ; then
+    fail "must fail on empty message"
+  fi
+}
+
 function test_keep_cutoff_line {
   if ! prereq_modern_git ; then
     echo "old version of Git detected; skipping scissors test."
diff --git a/resources/com/google/gerrit/server/config/CapabilityConstants.properties b/resources/com/google/gerrit/server/config/CapabilityConstants.properties
index 1a355eb..b6aca6f 100644
--- a/resources/com/google/gerrit/server/config/CapabilityConstants.properties
+++ b/resources/com/google/gerrit/server/config/CapabilityConstants.properties
@@ -21,3 +21,4 @@
 viewConnections = View Connections
 viewPlugins = View Plugins
 viewQueue = View Queue
+viewSecondaryEmails = View Secondary Emails
diff --git a/resources/com/google/gerrit/server/mail/Comment.soy b/resources/com/google/gerrit/server/mail/Comment.soy
index 98ab4b2..4b621b5 100644
--- a/resources/com/google/gerrit/server/mail/Comment.soy
+++ b/resources/com/google/gerrit/server/mail/Comment.soy
@@ -77,13 +77,8 @@
       {for $line, $index in $comment.lines}
         {if $index == 0}
           {if $comment.startLine != 0}
-            {$comment.link}
+            {$comment.link}{sp}:{\n}
           {/if}
-
-          // Insert a space before the newline so that Gmail does not mistakenly
-          // link the following line with the file link. See issue 9201.
-          {sp}{\n}
-
           {$comment.linePrefix}
         {else}
           {$comment.linePrefixEmpty}
diff --git a/resources/com/google/gerrit/server/mail/SetAssignee.soy b/resources/com/google/gerrit/server/mail/SetAssignee.soy
deleted file mode 100644
index 83aa580..0000000
--- a/resources/com/google/gerrit/server/mail/SetAssignee.soy
+++ /dev/null
@@ -1,71 +0,0 @@
-/**
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-{namespace com.google.gerrit.server.mail.template.SetAssignee}
-
-/**
- * The .SetAssignee template will determine the contents of the email related
- * to a user being assigned to a change.
- */
-{template SetAssignee kind="text"}
-  {@param change: ?}
-  {@param email: ?}
-  {@param fromName: ?}
-  {@param patchSet: ?}
-  {@param projectName: ?}
-  Hello{sp}
-  {$email.assigneeName},
-
-  {\n}
-  {\n}
-
-  {$fromName} has assigned a change to you.
-
-  {sp}Please visit
-
-  {\n}
-  {\n}
-
-  {sp}{sp}{sp}{sp}{$email.changeUrl}
-
-  {\n}
-  {\n}
-
-  to view the change.
-
-  {\n}
-  {\n}
-
-  Change subject: {$change.subject}{\n}
-  ......................................................................{\n}
-
-  {\n}
-
-  {$email.changeDetail}{\n}
-
-  {if $email.sshHost}
-    {\n}
-    {sp}{sp}git pull ssh:{print '//'}{$email.sshHost}/{$projectName}
-        {sp}{$patchSet.refName}
-    {\n}
-  {/if}
-
-  {if $email.includeDiff}
-    {\n}
-    {$email.unifiedDiff}
-    {\n}
-  {/if}
-{/template}
diff --git a/resources/com/google/gerrit/server/mail/SetAssigneeHtml.soy b/resources/com/google/gerrit/server/mail/SetAssigneeHtml.soy
deleted file mode 100644
index 5435cab..0000000
--- a/resources/com/google/gerrit/server/mail/SetAssigneeHtml.soy
+++ /dev/null
@@ -1,56 +0,0 @@
-/**
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-{namespace com.google.gerrit.server.mail.template.SetAssigneeHtml}
-
-import * as mailTemplate from 'com/google/gerrit/server/mail/Private.soy';
-
-{template SetAssigneeHtml}
-  {@param diffLines: ?}
-  {@param email: ?}
-  {@param fromName: ?}
-  {@param patchSet: ?}
-  {@param projectName: ?}
-  <p>
-    {$fromName} has <strong>assigned</strong> a change to{sp}
-    {$email.assigneeName}.{sp}
-  </p>
-
-  {if $email.changeUrl}
-    <p>
-      {call mailTemplate.ViewChangeButton data="all" /}
-    </p>
-  {/if}
-
-  {call mailTemplate.Pre}
-    {param content: $email.changeDetail /}
-  {/call}
-
-  {if $email.sshHost}
-    {call mailTemplate.Pre}
-      {param content kind="html"}
-        git pull ssh:{print '//'}{$email.sshHost}/{$projectName}
-        {sp}{$patchSet.refName}
-      {/param}
-    {/call}
-  {/if}
-
-  {if $email.includeDiff}
-    {call mailTemplate.UnifiedDiff}
-      {param diffLines: $diffLines /}
-    {/call}
-  {/if}
-{/template}
diff --git a/resources/com/google/gerrit/server/tools/root/hooks/commit-msg b/resources/com/google/gerrit/server/tools/root/hooks/commit-msg
index d9fd1f1..0154d43 100755
--- a/resources/com/google/gerrit/server/tools/root/hooks/commit-msg
+++ b/resources/com/google/gerrit/server/tools/root/hooks/commit-msg
@@ -50,7 +50,7 @@
 
 trap 'rm -f "$dest" "$dest-2"' EXIT
 
-if ! git stripspace --strip-comments < "$1" > "${dest}" ; then
+if ! cat "$1" | sed -e '/>8/q' | git stripspace --strip-comments > "${dest}" ; then
    echo "cannot strip comments from $1"
    exit 1
 fi
diff --git a/tools/BUILD b/tools/BUILD
index d649cd7..70d4315 100644
--- a/tools/BUILD
+++ b/tools/BUILD
@@ -46,11 +46,13 @@
         "-XepDisableWarningsInGeneratedCode",
         # The XepDisableWarningsInGeneratedCode disables only warnings, but
         # not errors. We should manually exclude all files generated by
-        # AutoValue; such files always start $AutoValue_.....
+        # AutoValue; such files always start AutoValue_..., $AutoValue_...,
+        # $$AutoValue_... or AutoValueGson_...
         # XepExcludedPaths is a regexp. If you need more paths - use | as
         # separator.
-        "-XepExcludedPaths:.*/\\\\$$AutoValue_.*\\.java",
+        "-XepExcludedPaths:.*/\\\\$$?\\\\$$?AutoValue(Gson)?_.*\\.java",
         "-Xep:AlmostJavadoc:ERROR",
+        "-Xep:AlreadyChecked:ERROR",
         "-Xep:AlwaysThrows:ERROR",
         "-Xep:AmbiguousMethodReference:ERROR",
         "-Xep:AnnotateFormatMethod:ERROR",
@@ -68,7 +70,7 @@
         "-Xep:AutoValueConstructorOrderChecker:ERROR",
         "-Xep:AutoValueFinalMethods:ERROR",
         "-Xep:AutoValueImmutableFields:ERROR",
-        # "-Xep:AutoValueSubclassLeaked:WARN",
+        "-Xep:AutoValueSubclassLeaked:ERROR",
         "-Xep:BadAnnotationImplementation:ERROR",
         "-Xep:BadComparable:ERROR",
         "-Xep:BadImport:ERROR",
@@ -125,7 +127,7 @@
         "-Xep:DoNotCallSuggester:ERROR",
         "-Xep:DoNotClaimAnnotations:ERROR",
         "-Xep:DoNotMock:ERROR",
-        "-Xep:DoNotMockAutoValue:WARN",
+        "-Xep:DoNotMockAutoValue:ERROR",
         "-Xep:DoubleBraceInitialization:ERROR",
         "-Xep:DoubleCheckedLocking:ERROR",
         "-Xep:DuplicateMapKeys:ERROR",
@@ -146,7 +148,7 @@
         "-Xep:EqualsUsingHashCode:ERROR",
         "-Xep:EqualsWrongThing:ERROR",
         "-Xep:ErroneousThreadPoolConstructorChecker:ERROR",
-        "-Xep:EscapedEntity:WARN",
+        "-Xep:EscapedEntity:ERROR",
         "-Xep:ExpectedExceptionChecker:ERROR",
         "-Xep:ExtendingJUnitAssert:ERROR",
         "-Xep:ExtendsAutoValue:ERROR",
@@ -243,7 +245,7 @@
         "-Xep:JavaLocalTimeGetNano:ERROR",
         "-Xep:JavaPeriodGetDays:ERROR",
         "-Xep:JavaTimeDefaultTimeZone:ERROR",
-        "-Xep:JavaUtilDate:WARN",
+        "-Xep:JavaUtilDate:ERROR",
         "-Xep:JdkObsolete:ERROR",
         "-Xep:JodaConstructors:ERROR",
         "-Xep:JodaDateTimeConstants:ERROR",
@@ -353,6 +355,7 @@
         "-Xep:RestrictedApiChecker:ERROR",
         "-Xep:RethrowReflectiveOperationExceptionAsLinkageError:ERROR",
         "-Xep:ReturnFromVoid:ERROR",
+        "-Xep:ReturnMissingNullable:ERROR",
         "-Xep:ReturnValueIgnored:ERROR",
         "-Xep:RxReturnValueIgnored:ERROR",
         "-Xep:SameNameButDifferent:ERROR",
@@ -428,6 +431,7 @@
         "-Xep:WrongOneof:ERROR",
         "-Xep:XorPower:ERROR",
         "-Xep:ZoneIdOfZ:ERROR",
+        "-Xlint:unchecked",
     ],
     packages = ["error_prone_packages"],
 )
diff --git a/tools/bzl/asciidoc.bzl b/tools/bzl/asciidoc.bzl
index 703d5b7..28de4ec 100644
--- a/tools/bzl/asciidoc.bzl
+++ b/tools/bzl/asciidoc.bzl
@@ -122,7 +122,7 @@
     ),
     "_exe": attr.label(
         default = Label("//java/com/google/gerrit/asciidoctor:asciidoc"),
-        cfg = "host",
+        cfg = "exec",
         allow_files = True,
         executable = True,
     ),
diff --git a/tools/deps.bzl b/tools/deps.bzl
index 9a17ca8..133d06d 100644
--- a/tools/deps.bzl
+++ b/tools/deps.bzl
@@ -1,9 +1,8 @@
 load("//tools/bzl:maven_jar.bzl", "GERRIT", "maven_jar")
-load("@bazel_tools//tools/build_defs/repo:java.bzl", "java_import_external")
 
 CAFFEINE_VERS = "2.9.2"
 ANTLR_VERS = "3.5.2"
-COMMONMARK_VERS = "0.10.0"
+COMMONMARK_VERSION = "0.21.0"
 FLEXMARK_VERS = "0.50.50"
 GREENMAIL_VERS = "1.5.5"
 MAIL_VERS = "1.6.0"
@@ -15,7 +14,7 @@
 AUTO_VALUE_GSON_VERSION = "1.3.1"
 PROLOG_VERS = "1.4.4"
 PROLOG_REPO = GERRIT
-GITILES_VERS = "1.0.0"
+GITILES_VERS = "1.1.0"
 GITILES_REPO = GERRIT
 
 # When updating Bouncy Castle, also update it in bazlets.
@@ -115,8 +114,8 @@
 
     maven_jar(
         name = "commons-codec",
-        artifact = "commons-codec:commons-codec:1.10",
-        sha1 = "4b95f4897fa13f2cd904aee711aeafc0c5295cd8",
+        artifact = "commons-codec:commons-codec:1.15",
+        sha1 = "49d94806b6e3dc933dacbd8acb0fdbab8ebd1e5d",
     )
 
     # When upgrading commons-compress, also upgrade tukaani-xz
@@ -173,26 +172,26 @@
     # commonmark must match the version used in Gitiles
     maven_jar(
         name = "commonmark",
-        artifact = "com.atlassian.commonmark:commonmark:" + COMMONMARK_VERS,
-        sha1 = "119cb7bedc3570d9ecb64ec69ab7686b5c20559b",
+        artifact = "org.commonmark:commonmark:" + COMMONMARK_VERSION,
+        sha1 = "c98f0473b17c87fe4fa2fc62a7c6523a2fe018f0",
     )
 
     maven_jar(
         name = "cm-autolink",
-        artifact = "com.atlassian.commonmark:commonmark-ext-autolink:" + COMMONMARK_VERS,
-        sha1 = "a6056a5efbd68f57d420bc51bbc54b28a5d3c56b",
+        artifact = "org.commonmark:commonmark-ext-autolink:" + COMMONMARK_VERSION,
+        sha1 = "55c0312cf443fa3d5af0daeeeca00d6deee3cf90",
     )
 
     maven_jar(
         name = "gfm-strikethrough",
-        artifact = "com.atlassian.commonmark:commonmark-ext-gfm-strikethrough:" + COMMONMARK_VERS,
-        sha1 = "40837da951b421b545edddac57012e15fcc9e63c",
+        artifact = "org.commonmark:commonmark-ext-gfm-strikethrough:" + COMMONMARK_VERSION,
+        sha1 = "953f4b71e133a98fcca93f3c3f4e58b895b76d1f",
     )
 
     maven_jar(
         name = "gfm-tables",
-        artifact = "com.atlassian.commonmark:commonmark-ext-gfm-tables:" + COMMONMARK_VERS,
-        sha1 = "c075db2a3301100cf70c7dced8ecf86b494458a2",
+        artifact = "org.commonmark:commonmark-ext-gfm-tables:" + COMMONMARK_VERSION,
+        sha1 = "fb7d65fa89a4cfcd2f51535d2549b570cf1dbd1a",
     )
 
     maven_jar(
@@ -348,8 +347,8 @@
     # Transitive dependency of flexmark and gitiles
     maven_jar(
         name = "autolink",
-        artifact = "org.nibor.autolink:autolink:0.7.0",
-        sha1 = "649f9f13422cf50c926febe6035662ae25dc89b2",
+        artifact = "org.nibor.autolink:autolink:0.10.0",
+        sha1 = "6579ea7079be461e5ffa99f33222a632711cc671",
     )
 
     maven_jar(
@@ -378,8 +377,8 @@
 
     maven_jar(
         name = "jsoup",
-        artifact = "org.jsoup:jsoup:1.9.2",
-        sha1 = "5e3bda828a80c7a21dfbe2308d1755759c2fd7b4",
+        artifact = "org.jsoup:jsoup:1.14.3",
+        sha1 = "c43a81e18e6d0eb71951aa031d55d5c293c531a6",
     )
 
     maven_jar(
@@ -528,14 +527,14 @@
         artifact = "com.google.gitiles:blame-cache:" + GITILES_VERS,
         attach_source = False,
         repository = GITILES_REPO,
-        sha1 = "f46833f8aa6f33ce3e443c8a414c295559eaf43e",
+        sha1 = "31c1a6e5d92b57bb2f9db24e1032145961c09a8d",
     )
 
     maven_jar(
         name = "gitiles-servlet",
         artifact = "com.google.gitiles:gitiles-servlet:" + GITILES_VERS,
         repository = GITILES_REPO,
-        sha1 = "90e107da00c2cd32490dd9ae8e3fb1ee095ea675",
+        sha1 = "c6550362c5c22d8e07edd4e2151ee12594082e76",
     )
 
     # prettify must match the version used in Gitiles
diff --git a/tools/maven/gerrit-acceptance-framework_pom.xml b/tools/maven/gerrit-acceptance-framework_pom.xml
index 0cd3031..8d46209 100644
--- a/tools/maven/gerrit-acceptance-framework_pom.xml
+++ b/tools/maven/gerrit-acceptance-framework_pom.xml
@@ -2,7 +2,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-acceptance-framework</artifactId>
-  <version>3.7.7-SNAPSHOT</version>
+  <version>3.8.4-SNAPSHOT</version>
   <packaging>jar</packaging>
   <name>Gerrit Code Review - Acceptance Test Framework</name>
   <description>Framework for Gerrit's acceptance tests</description>
diff --git a/tools/maven/gerrit-extension-api_pom.xml b/tools/maven/gerrit-extension-api_pom.xml
index 1482523..bc9d782 100644
--- a/tools/maven/gerrit-extension-api_pom.xml
+++ b/tools/maven/gerrit-extension-api_pom.xml
@@ -2,7 +2,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-extension-api</artifactId>
-  <version>3.7.7-SNAPSHOT</version>
+  <version>3.8.4-SNAPSHOT</version>
   <packaging>jar</packaging>
   <name>Gerrit Code Review - Extension API</name>
   <description>API for Gerrit Extensions</description>
diff --git a/tools/maven/gerrit-plugin-api_pom.xml b/tools/maven/gerrit-plugin-api_pom.xml
index 8d70a58..c83a153 100644
--- a/tools/maven/gerrit-plugin-api_pom.xml
+++ b/tools/maven/gerrit-plugin-api_pom.xml
@@ -2,7 +2,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-plugin-api</artifactId>
-  <version>3.7.7-SNAPSHOT</version>
+  <version>3.8.4-SNAPSHOT</version>
   <packaging>jar</packaging>
   <name>Gerrit Code Review - Plugin API</name>
   <description>API for Gerrit Plugins</description>
diff --git a/tools/maven/gerrit-war_pom.xml b/tools/maven/gerrit-war_pom.xml
index 2f353d4..9e573cf 100644
--- a/tools/maven/gerrit-war_pom.xml
+++ b/tools/maven/gerrit-war_pom.xml
@@ -2,7 +2,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-war</artifactId>
-  <version>3.7.7-SNAPSHOT</version>
+  <version>3.8.4-SNAPSHOT</version>
   <packaging>war</packaging>
   <name>Gerrit Code Review - WAR</name>
   <description>Gerrit WAR</description>
diff --git a/tools/migration/html_to_link_commentlink.md b/tools/migration/html_to_link_commentlink.md
new file mode 100644
index 0000000..45570ac
--- /dev/null
+++ b/tools/migration/html_to_link_commentlink.md
@@ -0,0 +1,47 @@
+# Overview
+
+**Raw html substitution will no longer be an option for comment links.**
+
+The raw-html option for commentlink sections is deprecated and removed.
+Example:
+
+```
+[commentlink "issue b/"]
+  match = (^|\\s)b/(\\d+)
+  html = $1<a href=\"http://b/issue?id=$2&query=$2\" target=\"_blank\">b/$2</a>
+```
+
+Before it allowed to find and replace text matches in commit messages and
+comments with arbitrary html. When misconfigured this has in the past enabled
+injecting undesired html code and XSS attacks by writing a comment.
+
+Even though the sanitization of the resulting html has improved. This feature is
+more powerful than needed. In almost all cases across host configurations html
+is only used to either configure text of the link, or limit the link to wrap
+only a portion of the matched text.
+
+To fill the gap in functionality from deprecating the option additional optional
+parameters (prefix, suffix and text) have been added. They allow to generate
+links that look like:
+```
+  PREFIX<a href="LINK">TEXT</a>SUFFIX
+```
+With substitution being strictly plaintext and all html escaped.
+
+The comment link section in project configs (in refs/meta/config) never
+supported the raw-html option and don't need to be updated.
+
+# Config migration command
+
+```
+CONFIG_FILE=<path to gerrit.config file>
+perl -0pe 's/([ \t]*)html\s*=\s*\"(.*)<a.* href=(?:\\\"(\S+)\\\"|(\S+)(?=\s|>))(?: .*)?>(.*)<\/a>(.*)(?<!\\)\"/$1link = \"$3$4\"\n$1prefix = \"$2\"\n$1text = \"$5\"\n$1suffix = \"$6\"/g' $CONFIG_FILE |
+perl -0pe 's/([ \t]*)html\s*=\s*(\S.*)?<a.* href=(?:\\\"(\S+)\\\"|(\S+)(?=\s|>))(?: .*)?>(.*)<\/a>(.*\S)?/$1link = \"$3$4\"\n$1prefix = \"$2\"\n$1text = \"$5\"\n$1suffix = \"$6\"/g' |
+perl -ne 'print if !/\s*(prefix|suffix|text)\s*=\s*\"\"/'
+```
+
+The command does 3 simple string replace passes:
+
+1. Replace `html=<value>` with quote-escaped value.
+2. Replace `html=<value>` with value without quotes.
+3. Remove empty `prefix`, `suffix`, `text` fields.
diff --git a/tools/node_tools/node_modules_licenses/licenses-map.ts b/tools/node_tools/node_modules_licenses/licenses-map.ts
index 7dfb23e..642a749 100644
--- a/tools/node_tools/node_modules_licenses/licenses-map.ts
+++ b/tools/node_tools/node_modules_licenses/licenses-map.ts
@@ -97,6 +97,9 @@
    */
   public generateMap(nodeModulesFiles: ReadonlyArray<string>): LicensesMap {
     const installedPackages = this.getInstalledPackages(nodeModulesFiles);
+    // Static packages that are not inside `node_modules` directories.
+    // gr-page.ts was derived from page.js, so we reproduce the original LICENSE.
+    installedPackages.push({name: 'polygerrit-gr-page', version: 'current', rootPath: 'polygerrit-ui/app/elements/core/gr-router/', files: ['gr-page.ts']});
     const licensedFilesGroupedByLicense = this.getLicensedFilesGroupedByLicense(installedPackages);
 
     const result: LicensesMap = {};
diff --git a/tools/nongoogle.bzl b/tools/nongoogle.bzl
index 3ad74a8..7f26ef3 100644
--- a/tools/nongoogle.bzl
+++ b/tools/nongoogle.bzl
@@ -135,8 +135,8 @@
 
     maven_jar(
         name = "error-prone-annotations",
-        artifact = "com.google.errorprone:error_prone_annotations:2.10.0",
-        sha1 = "9bc20b94d3ac42489cf6ce1e42509c86f6f861a1",
+        artifact = "com.google.errorprone:error_prone_annotations:2.15.0",
+        sha1 = "38c8485a652f808c8c149150da4e5c2b0bd17f9a",
     )
 
     FLOGGER_VERS = "0.7.4"
diff --git a/version.bzl b/version.bzl
index 14e5b72..ac8c13c 100644
--- a/version.bzl
+++ b/version.bzl
@@ -2,4 +2,4 @@
 # Used by :api_install and :api_deploy targets
 # when talking to the destination repository.
 #
-GERRIT_VERSION = "3.7.7-SNAPSHOT"
+GERRIT_VERSION = "3.8.4-SNAPSHOT"